topfans/docs/superpowers/plans/2026-06-25-share-modal-redesign.md

1822 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Share Modal Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the share modal redesign — extend to 5 platforms + save image, add QR code watermark, add share attribution tracking, require login for share — across frontend, backend, and config.
**Architecture:**
- **Backend:** Share 是 asset 的子功能,**2 个 RPC (`GetAssetQrcode` / `TrackShare`) 直接加到现有 `AssetService`**(不单独搞 Dubbo service);model/repo/service 业务逻辑放进现有 `assetService` 包(分别落在 `model/` / `repository/` / `service/` / `util/qrcode/`);`share_events` 表 migration 独立。
- **Frontend:** Refactor 319-line `ShareModal.vue` into 5 focused files (container + preview + action bar + composable + canvas compositor) + add 7 icons + mock for dev.
- **Config:** Update `manifest.json` for share module + URL schemes + SDK configs (with placeholder AppIDs).
**Tech Stack:** uni-app + Vue 3 + Vite (frontend), Go + Gin + GORM + Dubbo Triple (backend), PostgreSQL (DB).
**Spec:** `docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md`
**Test Policy (per user 2026-06-25):** No Vitest / frontend unit tests. Backend has service-layer unit tests + handler-layer happy/error cases (per CLAUDE.md 接口开发规范). Frontend goes through manual acceptance checklist.
**External dependencies (not blocking):** WeChat / QQ / Weibo AppIDs + Android SHA1/MD5 + iOS Bundle ID — placeholder strings in `manifest.json` with comments pointing to spec § 12.2.
**Architectural decision (corrected 2026-06-25):** Originally planned a new `shareService` Dubbo service. User pushed back: share is one feature of asset, not its own bounded context. All share backend code lives inside the existing `assetService` package as additional files/methods. `GetAssetQrcode` and `TrackShare` are methods on the existing `AssetService` interface (in `backend/proto/asset.proto`), not a new `ShareService`. Frontend HTTP routes stay at `/api/v1/share/*` (per spec § 3), but the handler methods are added to the existing `assetController` in gateway.
---
## File Structure
### Files Created
| Path | Responsibility |
|------|----------------|
| `backend/migrations/2026_06_25_001_share_events.sql` | `share_events` table + indexes + sequence (applied to local DB) |
| `backend/services/assetService/model/share_event.go` | ShareEvent GORM model |
| `backend/services/assetService/repository/share_repository.go` | ShareRepo (Create + AssetExists + UserExists) |
| `backend/services/assetService/util/qrcode/generator.go` | QR code PNG generator |
| `backend/services/assetService/service/share_service.go` | Share business logic (orchestration of repo + qrcode) |
| `backend/gateway/controller/share_methods.go` (or appended to `asset_controller.go`) | HTTP handlers for `/share/asset-qrcode/:assetId` + `/share/track` |
| `frontend/utils/brand-slogans.js` | Brand slogan pool + `pickRandomSlogan()` |
| `frontend/utils/image-compositor.js` | Pure canvas function `composeShareImage()` |
| `frontend/utils/share-mock.js` | Dev-only mocks (gated by `import.meta.env.DEV`) |
| `frontend/pages/components/SharePreviewCard.vue` | Pure template, no logic |
| `frontend/pages/components/ShareActionBar.vue` | 6 buttons, emits `pick` |
| `frontend/composables/useShare.js` | State machine + dispatch |
| `frontend/static/share/ic_wechat_friend.png` | 192×192 |
| `frontend/static/share/ic_wechat_moment.png` | 192×192 |
| `frontend/static/share/ic_qq.png` | 192×192 |
| `frontend/static/share/ic_qq_zone.png` | 192×192 |
| `frontend/static/share/ic_weibo.png` | 192×192 |
| `frontend/static/share/ic_save_image.png` | 192×192 |
| `frontend/static/share/mock_qrcode.png` | 512×512 fallback |
### Files Modified
| Path | Change |
|------|--------|
| `backend/proto/asset.proto` | Add `GetAssetQrcode` + `TrackShare` RPCs to `AssetService`; add 4 messages at end |
| `backend/services/assetService/service/asset_service.go` | Add 2 new methods to `AssetService` interface (or implement in new `share_service.go` file in same package) |
| `backend/pkg/errors/errors.go` | Add `ErrSharerMismatch`, `ErrInvalidSystemType`, `ErrInvalidShareTarget`, `ErrInvalidShareResult`, `ErrUnauthorized`; extend `ToGRPCCode` switch |
| `backend/gateway/router/router.go` | Register `/share` group (using `assetCtrl` per spec routes) |
| `backend/gateway/main.go` (or wherever controllers wired) | No new controller — `assetCtrl` already wired |
| `frontend/pages/components/ShareModal.vue` | Rewrite — becomes thin container (≤ 80 lines) |
| `frontend/pages/components/ShareReportButtons.vue` | Add login gate at handleShare; replace bad `/static/icon/qrcode-placeholder.png` path |
| `frontend/src/manifest.json` | Add Share module + URL schemes + `sdkConfigs.share` placeholder block |
### Files NOT Created (corrected from original plan)
-`backend/proto/share.proto` — DELETED, messages merged into `asset.proto`
-`backend/pkg/proto/share/` — DELETED, generated code in `pkg/proto/asset/`
-`backend/services/shareService/` — DELETED, share is part of `assetService` package
-`backend/services/shareService/cmd/main.go` — DELETED, assetService has its own main
-`backend/services/shareService/go.mod` — DELETED, assetService is its own module
-`backend/gateway/controller/share_controller.go` — DELETED, methods added to `asset_controller.go`
---
## Phase 1 — Backend Foundation
### Task 1: Create `share_events` migration
**Files:**
- Create: `backend/migrations/2026_06_25_001_share_events.sql`
- [ ] **Step 1: Write the migration file**
```sql
-- 2026_06_25_001_share_events.sql
-- 分享事件追踪表(spec: docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md § 3.5)
CREATE TABLE IF NOT EXISTS public.share_events (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL,
sharer_user_id BIGINT NOT NULL,
system_type VARCHAR(32) NOT NULL,
share_target VARCHAR(32) NOT NULL,
result VARCHAR(32) NOT NULL,
client_ts BIGINT NOT NULL,
server_ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
extra JSONB DEFAULT '{}'::jsonb,
CONSTRAINT fk_share_events_sharer FOREIGN KEY (sharer_user_id)
REFERENCES public.users(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_share_events_asset_id ON public.share_events (asset_id);
CREATE INDEX IF NOT EXISTS idx_share_events_sharer ON public.share_events (sharer_user_id);
CREATE INDEX IF NOT EXISTS idx_share_events_system_type ON public.share_events (system_type);
CREATE INDEX IF NOT EXISTS idx_share_events_server_ts ON public.share_events (server_ts DESC);
-- 序列起始值预留(按 CLAUDE.md 数据库规范)
-- 注意:BIGSERIAL 已自动创建 share_events_id_seq,只需 RESTART,不再显式 CREATE SEQUENCE
ALTER SEQUENCE public.share_events_id_seq RESTART WITH 10000;
```
- [ ] **Step 2: Apply migration locally**
Run: `docker exec -i postgresql-database-1 psql -U postgres -d top-fans < backend/migrations/2026_06_25_001_share_events.sql`
Expected: No error. (Note: local DB name is `top-fans`, not `topfans` — see project MEMORY.md.)
- [ ] **Step 3: Verify sequence health**
Run SQL:
```sql
SELECT sequencename, last_value, (SELECT MAX(id) FROM public.share_events) AS table_max_id
FROM pg_sequences WHERE sequencename = 'share_events_id_seq';
```
Expected: `last_value >= 10000`.
- [ ] **Step 4: DO NOT git commit.** Per CLAUDE.md, leave changes untracked.
---
### Task 2: Add share RPCs + messages to existing asset.proto
**Files:**
- Modify: `backend/proto/asset.proto`
- [ ] **Step 1: Add google/protobuf/struct import** (alongside existing common.proto + annotations imports)
```protobuf
import "proto/common.proto";
import "google/api/annotations.proto";
import "google/protobuf/struct.proto";
```
- [ ] **Step 2: Add 2 RPCs to `AssetService`** (right before its closing `}`)
```protobuf
// 生成分享二维码 URL(spec § 3)
rpc GetAssetQrcode(GetAssetQrcodeRequest) returns (GetAssetQrcodeResponse);
// 记录分享归因事件(spec § 3.5)
rpc TrackShare(TrackShareRequest) returns (TrackShareResponse);
```
- [ ] **Step 3: Add 4 messages at end of file** (after the last existing message)
```protobuf
// ================== 分享功能(spec § 3 + § 3.5)==================
// 分享是 asset 的子功能,RPCs 直接挂在 AssetService 上。
message GetAssetQrcodeRequest {
int64 asset_id = 1;
int64 sharer_user_id = 2; // 必填,登录用户 ID(前端从 storage 取,服务端二次校验)
string system_type = 3; // android / ios / mp-weixin / h5 等(见 § 3 枚举表)
string share_target = 4; // weixin_friend / weixin_moment / qq / qq_zone / sinaweibo / save_image
}
message GetAssetQrcodeResponse {
topfans.common.BaseResponse base = 1;
string qrcode_url = 2; // 二维码图片 URL(含 from/s 参数)
int64 expires_at = 3; // Unix 秒,签发后 7 天过期
}
message TrackShareRequest {
int64 asset_id = 1;
int64 sharer_user_id = 2; // 必填,登录用户 ID
string system_type = 3;
string share_target = 4;
string result = 5; // success / cancel / fail_app_missing / fail_network / fail_canvas / fail_other
int64 client_ts = 6; // 客户端时间戳(毫秒)
google.protobuf.Struct extra = 7;
}
message TrackShareResponse {
topfans.common.BaseResponse base = 1;
int64 share_event_id = 2; // 后端落库的分享事件 ID
}
```
- [ ] **Step 4: Regenerate proto**
Run: `cd backend && bash scripts/compile-proto.sh`
Expected: 29 files generated (share.proto removed, asset.proto updated).
- [ ] **Step 5: Verify gateway compiles**
Run: `cd backend/gateway && go build ./...`
Expected: No errors.
- [ ] **Step 6: DO NOT git commit.**
---
### Task 3: Add ShareEvent model to assetService
**Files:**
- Create: `backend/services/assetService/model/share_event.go`
- [ ] **Step 1: Check existing model pattern**
Read `backend/services/assetService/model/asset.go` (or similar) to match GORM tag style, JSONB handling, and naming.
- [ ] **Step 2: Write the model**
```go
package model
import (
"github.com/topfans/backend/pkg/models"
)
// ShareEvent 分享事件落库模型(spec § 3.5)
//
// 字段与 backend/migrations/2026_06_25_001_share_events.sql 一致。
// CreatedAt/UpdatedAt/DeletedAt 在迁移中不存在,本文件未包含,
// 避免 GORM AutoMigrate 尝试添加。
// JSONB 复用 pkg/models 中定义的 models.JSONB。
// 时间字段统一 int64 毫秒时间戳,与项目内其它服务一致。
type ShareEvent struct {
ID int64 `json:"id" gorm:"primaryKey;column:id"`
AssetID int64 `json:"asset_id" gorm:"column:asset_id;not null;index"`
SharerUserID int64 `json:"sharer_user_id" gorm:"column:sharer_user_id;not null;index"`
SystemType string `json:"system_type" gorm:"column:system_type;type:varchar(32);not null;index"`
ShareTarget string `json:"share_target" gorm:"column:share_target;type:varchar(32);not null"`
Result string `json:"result" gorm:"column:result;type:varchar(32);not null"`
ClientTs int64 `json:"client_ts" gorm:"column:client_ts;not null"`
// ServerTs 服务端接收时间戳(毫秒);DB 列已设 DEFAULT,可由 DB 自动填充
ServerTs int64 `json:"server_ts" gorm:"column:server_ts;not null"`
// Extra 扩展字段,存储 app_version / os_version / device_id
Extra models.JSONB `json:"extra" gorm:"column:extra;type:jsonb;default:'{}'::jsonb"`
}
func (ShareEvent) TableName() string { return "public.share_events" }
```
- [ ] **Step 3: Verify assetService compiles**
Run: `cd backend/services/assetService && go build ./...`
Expected: No errors.
- [ ] **Step 4: DO NOT git commit.**
---
### Task 4: Add ShareRepo to assetService
**Files:**
- Create: `backend/services/assetService/repository/share_repository.go`
- [ ] **Step 1: Check existing repo pattern**
Read `backend/services/assetService/repository/asset_repository.go` to match constructor signature.
- [ ] **Step 2: Verify user model status semantics**
Read `backend/pkg/models/user.go` (or wherever User model is defined) to check `status` column values.
- [ ] **Step 3: Write the repository**
```go
package repository
import (
"context"
"gorm.io/gorm"
"github.com/topfans/backend/services/assetService/model"
)
// ShareRepo 分享事件数据访问层
type ShareRepo struct {
db *gorm.DB
}
func NewShareRepo(db *gorm.DB) *ShareRepo {
return &ShareRepo{db: db}
}
// Create 插入一条分享事件,返回新 ID
func (r *ShareRepo) Create(ctx context.Context, e *model.ShareEvent) (int64, error) {
if err := r.db.WithContext(ctx).Create(e).Error; err != nil {
return 0, err
}
return e.ID, nil
}
// AssetExists 校验资产是否存在(spec § 3)
func (r *ShareRepo) AssetExists(ctx context.Context, assetID int64) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).Table("public.assets").Where("id = ?", assetID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// UserExists 校验用户存在且状态正常(spec § 3.4:分享人必须有效用户)
func (r *ShareRepo) UserExists(ctx context.Context, userID int64) (bool, error) {
var count int64
// 注:status 字段语义以 User 模型为准(status = 1 表示正常/active)。
// 若实际是 status = 'active' 等字符串枚举,调整 WHERE 条件。
if err := r.db.WithContext(ctx).Table("public.users").
Where("id = ? AND status = 1", userID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
```
- [ ] **Step 4: Verify compilation**
Run: `cd backend/services/assetService && go build ./...`
Expected: No errors.
- [ ] **Step 5: DO NOT git commit.**
---
### Task 5: Add QR code generator utility
**Files:**
- Create: `backend/services/assetService/util/qrcode/generator.go`
- Modify: `backend/services/assetService/go.mod` (add dependency)
- [ ] **Step 1: Add qrcode library**
```bash
cd backend/services/assetService
go get github.com/skip2/go-qrcode@latest
go mod tidy
```
- [ ] **Step 2: Write the generator**
```go
package qrcode
import (
"bytes"
"fmt"
"image/png"
qr "github.com/skip2/go-qrcode"
)
// Generate 生成 PNG 二维码字节流
// content: 要编码的 URL(含 from= 和 s= 参数)
// size: 输出像素(建议 512)
func Generate(content string, size int) ([]byte, error) {
if size <= 0 {
size = 512
}
var buf bytes.Buffer
img, err := qr.New(content, qr.Medium)
if err != nil {
return nil, fmt.Errorf("qrcode new: %w", err)
}
if err := png.Encode(&buf, img.Image(size)); err != nil {
return nil, fmt.Errorf("qrcode encode: %w", err)
}
return buf.Bytes(), nil
}
```
- [ ] **Step 3: Verify compilation**
Run: `cd backend/services/assetService && go build ./...`
Expected: No errors.
- [ ] **Step 4: DO NOT git commit.**
---
### Task 6: Implement share business logic in assetService
**Files:**
- Create: `backend/services/assetService/service/share_service.go`
- [ ] **Step 1: Read existing AssetService structure**
Read `backend/services/assetService/service/asset_service.go` to see:
- How `AssetService` interface is defined
- How a typical service method signature looks
- How redis client is wired in
- How Dubbo metadata `user_id` is extracted from context
- [ ] **Step 2: Write the share service** (in same `service` package, adding methods to AssetService)
The share service can be a separate file in the same package, exposing methods that will be added to the `AssetService` interface. Alternatively, it can be a separate struct that assetService methods delegate to. Pick the approach that matches existing peer services in the file.
Suggested approach — add a `ShareServiceImpl` to the same package:
```go
package service
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/services/assetService/model"
"github.com/topfans/backend/services/assetService/repository"
"github.com/topfans/backend/services/assetService/util/qrcode"
)
// validSystemTypes spec § 3 枚举表
var validSystemTypes = map[string]struct{}{
"android": {}, "ios": {}, "h5": {},
"mp-weixin": {}, "mp-alipay": {}, "mp-baidu": {},
"mp-toutiao": {}, "mp-lark": {}, "mp-qq": {},
"mp-kuaishou": {}, "mp-xhs": {}, "app-plus": {},
"other": {},
}
var validResults = map[string]struct{}{
"success": {}, "cancel": {},
"fail_app_missing": {}, "fail_network": {},
"fail_canvas": {}, "fail_other": {},
"fail_permission": {}, "fail_already_saved": {},
}
var validShareTargets = map[string]struct{}{
"weixin_friend": {}, "weixin_moment": {},
"qq": {}, "qq_zone": {}, "sinaweibo": {}, "save_image": {},
}
const (
qrcodeCacheTTL = 7 * 24 * time.Hour
qrcodeCacheKey = "share:qrcode:%d:%d:%s"
)
// ShareService 分享业务逻辑(spec § 3 + § 3.5)
type ShareService struct {
repo *repository.ShareRepo
redis *redis.Client
landingBase string
ossPublicURL string
}
func NewShareService(repo *repository.ShareRepo, redis *redis.Client, landingBase string) *ShareService {
return &ShareService{repo: repo, redis: redis, landingBase: landingBase}
}
// GetAssetQrcode 生成或获取缓存的二维码 URL
func (s *ShareService) GetAssetQrcode(ctx context.Context, assetID, sharerUserID int64, systemType, shareTarget string) (qrcodeURL string, expiresAt int64, err error) {
if sharerUserID == 0 {
return "", 0, fmt.Errorf("%w: sharer_user_id is required", appErrors.ErrInvalidUserID)
}
if systemType == "" {
return "", 0, appErrors.ErrInvalidSystemType
}
if _, ok := validSystemTypes[systemType]; !ok {
return "", 0, fmt.Errorf("%w: unsupported system_type=%q", appErrors.ErrInvalidSystemType, systemType)
}
if shareTarget != "" {
if _, ok := validShareTargets[shareTarget]; !ok {
return "", 0, fmt.Errorf("%w: unsupported share_target=%q", appErrors.ErrInvalidShareTarget, shareTarget)
}
}
exists, err := s.repo.AssetExists(ctx, assetID)
if err != nil { return "", 0, fmt.Errorf("check asset: %w", err) }
if !exists { return "", 0, appErrors.ErrAssetNotFound }
userOK, err := s.repo.UserExists(ctx, sharerUserID)
if err != nil { return "", 0, fmt.Errorf("check user: %w", err) }
if !userOK { return "", 0, appErrors.ErrUserNotFound }
landingURL := fmt.Sprintf("%s/asset/%d?from=%d&s=%s", s.landingBase, assetID, sharerUserID, systemType)
cacheKey := fmt.Sprintf(qrcodeCacheKey, assetID, sharerUserID, systemType)
if s.redis != nil {
if cached, cerr := s.redis.Get(ctx, cacheKey).Result(); cerr == nil && cached != "" {
return cached, time.Now().Add(qrcodeCacheTTL).Unix(), nil
}
}
// 生成 PNG 并上传到 OSS,返回 CDN URL(前端 downloadFile 拿到的是真二维码图,不是 HTML 页)
pngBytes, err := qrcode.Generate(landingURL, 512)
if err != nil { return "", 0, fmt.Errorf("generate qrcode: %w", err) }
ossKey := fmt.Sprintf("share/qrcode/%d_%d_%s.png", assetID, sharerUserID, systemType)
// 注:需要 util 包加一个 UploadBytesToOSS(config, key, bytes) 包装(已存在 UploadImageToOSS 走 URL 路径,
// 这里加新方法走字节流更直接)。本计划暂不展开,实施时复用 ossutil 包的能力。
cdnURL, err := s.uploader.UploadBytes(ctx, ossKey, pngBytes, "image/png")
if err != nil { return "", 0, fmt.Errorf("upload qrcode: %w", err) }
expiresAt = time.Now().Add(qrcodeCacheTTL).Unix()
qrcodeURL = cdnURL
if s.redis != nil {
_ = s.redis.Set(ctx, cacheKey, qrcodeURL, qrcodeCacheTTL).Err()
}
return qrcodeURL, expiresAt, nil
}
// 注:ShareService 需要新增字段 `uploader OSSUploader` (interface),
// 在 util 包实现 `UploadBytes(ctx, key, bytes, contentType) (cdnURL, error)` 包装。
// 这样代码层解耦 — 测试时可 mock,生产用真 OSS 客户端。
// TrackShare 记录一次分享动作
func (s *ShareService) TrackShare(ctx context.Context, assetID, sharerUserID int64, systemType, shareTarget, result string, clientTs int64, extra map[string]interface{}) (int64, error) {
// 二次拦截(参考 dubbo metadata 注入 user_id 的方式)
// 注:具体取法以 assetService/main.go 中 Dubbo provider middleware 注入为准
// 这里 stub;Task 13 smoke 时再确认
if _, ok := validSystemTypes[systemType]; !ok { return 0, appErrors.ErrInvalidSystemType }
if _, ok := validShareTargets[shareTarget]; !ok { return 0, appErrors.ErrInvalidShareTarget }
if _, ok := validResults[result]; !ok { return 0, appErrors.ErrInvalidShareResult }
userOK, err := s.repo.UserExists(ctx, sharerUserID)
if err != nil { return 0, fmt.Errorf("check user: %w", err) }
if !userOK { return 0, appErrors.ErrUserNotFound }
e := &model.ShareEvent{
AssetID: assetID,
SharerUserID: sharerUserID,
SystemType: systemType,
ShareTarget: shareTarget,
Result: result,
ClientTs: clientTs,
ServerTs: time.Now().UnixMilli(),
Extra: extra,
}
return s.repo.Create(ctx, e)
}
```
- [ ] **Step 3: Wire ShareService into the existing AssetService implementation (Pattern A — 预先决定)**
**Pattern A (固定,不再让 implementer 选):**`asset_service.go` 现有的 `AssetService` interface 上加 2 个方法;`*AssetServiceImpl` 组合 `*ShareService` 实例,把 2 个新方法的实现委托给 `ShareService`
实施步骤:
1.`backend/services/assetService/service/asset_service.go` 找到 `AssetService` interface 定义和 `AssetServiceImpl` struct 定义。
2. 在 interface 上加 2 个方法:
```go
GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error)
```
3.`AssetServiceImpl` struct 上加字段 `shareService *ShareService`
4.`*AssetServiceImpl` 上加 2 个方法实现,内部调 `s.shareService.GetAssetQrcode(...)``TrackShare(...)` (薄包装,把 `req` 字段映射成 service 函数的 positional args)。
5. 修改 `NewAssetServiceImpl`(或类似的构造函数)接受 `*ShareService` 参数。
- [ ] **Step 4: Verify assetService compiles**
Run: `cd backend/services/assetService && go build ./...`
Expected: No errors.
- [ ] **Step 5: DO NOT git commit.**
---
### Task 7: Extend error definitions
**Files:**
- Modify: `backend/pkg/errors/errors.go`
- [ ] **Step 1: Add 5 new error vars** (alongside other domain errors)
```go
// 分享服务相关错误
ErrInvalidSystemType = errors.New("无效的分享来源端类型")
ErrInvalidShareTarget = errors.New("无效的分享目标渠道")
ErrInvalidShareResult = errors.New("无效的分享结果")
ErrSharerMismatch = errors.New("分享人 ID 与当前登录用户不一致")
ErrUnauthorized = errors.New("请先登录后再分享")
```
- [ ] **Step 2: Extend `ToGRPCCode` switch** (before the `default` case)
```go
case errors.Is(err, ErrInvalidSystemType), errors.Is(err, ErrInvalidShareTarget), errors.Is(err, ErrInvalidShareResult):
return codes.InvalidArgument
case errors.Is(err, ErrUnauthorized):
return codes.Unauthenticated
case errors.Is(err, ErrSharerMismatch):
return codes.PermissionDenied
```
- [ ] **Step 3: Run existing error tests**
Run: `cd backend/pkg/errors && go test ./...`
Expected: PASS (existing tests still work; new errors only add new branches).
- [ ] **Step 4: DO NOT git commit.**
---
### Task 8: ~~Wire share service main entrypoint~~ — DELETED
(AssetService already has its own main.go. No separate service needed.)
---
### Task 9: Add share HTTP handlers to existing AssetController
**Files:**
- Modify: `backend/gateway/controller/asset_controller.go` (append 2 new handler methods)
- [ ] **Step 1: Read existing AssetController for pattern**
Read `backend/gateway/controller/asset_controller.go` to see:
- How Dubbo client is wired in the struct
- How a typical handler is structured (binding, validation, service call, response)
- How `user_id` is extracted from gin context (likely via `c.Get("user_id")` from auth middleware)
- [ ] **Step 2: Add 2 handler methods**
Append to the end of `asset_controller.go`:
```go
// GetAssetQrcode 生成分享二维码
// @Summary 生成分享二维码
// @Tags share
// @Produce json
// @Security BearerAuth
// @Param assetId path int true "资产 ID"
// @Param sharer_user_id query int true "分享人用户 ID"
// @Param system_type query string true "系统类型"
// @Param share_target query string false "分享目标"
// @Success 200 {object} response.Response
// @Router /api/v1/share/asset-qrcode/{assetId} [get]
func (ctrl *AssetController) GetAssetQrcode(c *gin.Context) {
assetID, err := strconv.ParseInt(c.Param("assetId"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "invalid assetId")
return
}
sharerID, err := strconv.ParseInt(c.Query("sharer_user_id"), 10, 64)
if err != nil || sharerID == 0 {
response.Error(c, http.StatusBadRequest, "sharer_user_id 必填")
return
}
systemType := c.Query("system_type")
if systemType == "" {
response.Error(c, http.StatusBadRequest, "system_type 必填")
return
}
// 二次拦截:不能拿别人的 sharer_user_id 生成二维码
currentUserID := getUserIDFromGin(c)
if currentUserID != sharerID {
response.Error(c, http.StatusForbidden, "sharer_user_id 与当前登录用户不一致")
return
}
// 注:Task 6 的 GetAssetQrcode 实现不读 ctx 中的 user_id(sharerUserID 通过 req 字段传入),
// 所以这里不再设置 metadata,直接传 c.Request.Context()。
resp, err := ctrl.assetService.GetAssetQrcode(c.Request.Context(), &pb.GetAssetQrcodeRequest{
AssetId: assetID,
SharerUserId: sharerID,
SystemType: systemType,
ShareTarget: c.Query("share_target"),
})
if err != nil {
logger.Error("GetAssetQrcode failed", zap.Error(err), zap.Int64("asset_id", assetID))
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
if resp.Base.Code != 0 {
response.Error(c, http.StatusBadRequest, resp.Base.Message)
return
}
response.Success(c, gin.H{
"qrcode_url": resp.QrcodeUrl,
"expires_at": resp.ExpiresAt,
})
}
type trackShareRequest struct {
AssetID int64 `json:"asset_id" binding:"required,min=1"`
SharerUserID int64 `json:"sharer_user_id" binding:"required,min=1"`
SystemType string `json:"system_type" binding:"required,min=1,max=32"`
ShareTarget string `json:"share_target" binding:"required,min=1,max=32"`
Result string `json:"result" binding:"required,min=1,max=32"`
ClientTs int64 `json:"client_ts" binding:"required,min=1"`
Extra map[string]interface{} `json:"extra,omitempty"`
}
// TrackShare 记录分享归因
// @Summary 记录分享归因
// @Tags share
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body trackShareRequest true "分享事件"
// @Success 200 {object} response.Response
// @Router /api/v1/share/track [post]
func (ctrl *AssetController) TrackShare(c *gin.Context) {
var req trackShareRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
currentUserID := getUserIDFromGin(c)
if currentUserID != req.SharerUserID {
response.Error(c, http.StatusForbidden, "sharer_user_id 与当前登录用户不一致")
return
}
extra, err := structpb.NewStruct(req.Extra)
if err != nil {
response.Error(c, http.StatusBadRequest, "extra 字段格式错误")
return
}
// 注:TrackShare 同样不读 ctx 中的 user_id,直接传 c.Request.Context()。
resp, err := ctrl.assetService.TrackShare(c.Request.Context(), &pb.TrackShareRequest{
AssetId: req.AssetID,
SharerUserId: req.SharerUserID,
SystemType: req.SystemType,
ShareTarget: req.ShareTarget,
Result: req.Result,
ClientTs: req.ClientTs,
Extra: extra,
})
if err != nil {
logger.Error("TrackShare failed", zap.Error(err), zap.Int64("asset_id", req.AssetID))
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
if resp.Base.Code != 0 {
response.Error(c, http.StatusBadRequest, resp.Base.Message)
return
}
response.Success(c, gin.H{"share_event_id": resp.ShareEventId})
}
// getUserIDFromGin 从 JWT 中间件写入的 context 取当前用户 ID
// 复制自 notification_controller.go 的同名辅助,或抽到 middleware 包(后续重构)
func getUserIDFromGin(c *gin.Context) int64 {
v, ok := c.Get("user_id")
if !ok { return 0 }
switch x := v.(type) {
case int64: return x
case int: return int64(x)
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
default: return 0
}
}
```
> **Note:** `getUserIDFromGin` is duplicated across controllers — consider extracting to `middleware` in a follow-up. For now, mirror existing controllers' pattern.
- [ ] **Step 3: Verify gateway compiles**
Run: `cd backend/gateway && go build ./...`
Expected: No errors.
- [ ] **Step 4: DO NOT git commit.**
---
### Task 10: Register /share routes
**Files:**
- Modify: `backend/gateway/router/router.go`
- [ ] **Step 1: Find existing /assets group and add /share group nearby** (uses assetCtrl)
```go
// 分享相关路由(需要认证)— 用 assetCtrl(spec 规定 /api/v1/share/* 前缀)
share := v1.Group("/share")
share.Use(middleware.AuthMiddleware())
{
share.GET("/asset-qrcode/:assetId", assetCtrl.GetAssetQrcode)
share.POST("/track", assetCtrl.TrackShare)
}
```
- [ ] **Step 2: Verify gateway compiles**
Run: `cd backend/gateway && go build ./...`
Expected: No errors.
- [ ] **Step 3: DO NOT git commit.**
---
### Task 11: Backend service unit tests
**Files:**
- Create: `backend/services/assetService/service/share_service_test.go`
- [ ] **Step 1: Check existing test pattern in assetService**
Read `backend/services/assetService/service/asset_service_test.go` (or `asset_level_service_test.go`) for sqlite/redis-mock pattern.
- [ ] **Step 2: Write service tests**
Cover:
- `GetAssetQrcode`: rejects missing/invalid system_type
- `GetAssetQrcode`: rejects invalid asset (AssetExists=false)
- `GetAssetQrcode`: rejects invalid user
- `GetAssetQrcode`: generates valid URL containing `from=`, `s=`, asset_id
- `TrackShare`: rejects invalid result / share_target
- `TrackShare`: persists event with all fields
- (Spec § 3.4 secondary auth check: requires login context — test depends on how Dubbo middleware injects user_id; mock accordingly)
Use sqlite in-memory + a stub `*redis.Client` (use miniredis or just nil).
- [ ] **Step 3: Run tests**
Run: `cd backend/services/assetService && go test ./service/... -run TestShare -v`
Expected: All tests PASS.
- [ ] **Step 4: DO NOT git commit.**
---
### Task 12: Backend handler unit tests
**Files:**
- Modify: `backend/gateway/controller/asset_controller_test.go` (add 2 tests for share handlers)
- [ ] **Step 1: Check existing test pattern in asset_controller_test.go**
Look for mock Dubbo client setup and httptest pattern.
- [ ] **Step 2: Add 2 handler tests**
- `TestGetAssetQrcode_Success`: mock AssetService, send GET with valid params, expect 200 + JSON
- `TestGetAssetQrcode_SharerMismatch`: send GET with sharer_user_id != current user, expect 403
- `TestTrackShare_Success`: POST with valid body, expect 200 + share_event_id
- `TestTrackShare_InvalidRequest`: POST with missing fields, expect 400
- [ ] **Step 3: Run tests**
Run: `cd backend/gateway && go test ./controller/... -run TestShare -v`
Expected: All tests PASS.
- [ ] **Step 4: DO NOT git commit.**
---
### Task 13: Backend local smoke test
**Files:** (none — manual verification)
- [ ] **Step 1: Start assetService locally**
Run: `cd backend/services/assetService && go run main.go` (or `./start.sh` if exists)
Expected: Service starts, registers with Dubbo.
- [ ] **Step 2: Start gateway locally**
Run: `cd backend/gateway && go run main.go` (or `./start.sh`)
Expected: Gateway listens, registers `/api/v1/share/*` routes.
- [ ] **Step 3: Test QR code endpoint**
```bash
TOKEN="<paste JWT for user_id=42>"
curl -s "http://localhost:8080/api/v1/share/asset-qrcode/123?sharer_user_id=42&system_type=android" \
-H "Authorization: Bearer $TOKEN" | jq
```
Expected: `{"code":0,"data":{"qrcode_url":"https://h5.topfans.com/asset/123?from=42&s=android","expires_at":...}}`
- [ ] **Step 4: Test track endpoint**
```bash
curl -s -X POST "http://localhost:8080/api/v1/share/track" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"asset_id":123,"sharer_user_id":42,"system_type":"android","share_target":"weixin_friend","result":"success","client_ts":1234567890}' | jq
```
Expected: `{"code":0,"data":{"share_event_id":<number>}}`
- [ ] **Step 5: Verify row in DB**
```sql
SELECT id, asset_id, sharer_user_id, system_type, share_target, result FROM public.share_events ORDER BY id DESC LIMIT 1;
```
Expected: Row appears with correct values.
- [ ] **Step 6: No commit, proceed to Phase 2.**
---
## Phase 2 — Frontend Utils (No Tests)
### Task 14: Create brand-slogans utility
**Files:**
- Create: `frontend/utils/brand-slogans.js`
- [ ] **Step 1: Write the file**
```js
// frontend/utils/brand-slogans.js
// 分享图品牌文案随机池(spec § 5.3.2)
export const BRAND_SLOGANS = [
{ brandLine: 'TOPFANS让热爱被发现', brandDesc: 'AI做周边应援全免费' },
{ brandLine: 'TOPFANS星河璀璨', brandDesc: '为你喜爱的明星加油' },
{ brandLine: 'TOPFANS你的应援主场', brandDesc: '百万粉丝在线互动' },
{ brandLine: 'TOPFANS与你同频', brandDesc: '把热爱变成专属周边' },
{ brandLine: 'TOPFANS热爱可抵岁月长', brandDesc: '一键定制你的专属应援' },
{ brandLine: 'TOPFANS粉丝的小宇宙', brandDesc: '和同好一起为爱发电' },
{ brandLine: 'TOPFANS让世界看见你', brandDesc: 'AI 让应援更有趣' },
{ brandLine: 'TOPFANS为热爱加冕', brandDesc: '你的应援值得被收藏' }
];
export function pickRandomSlogan() {
return BRAND_SLOGANS[Math.floor(Math.random() * BRAND_SLOGANS.length)];
}
```
- [ ] **Step 2: DO NOT git commit.**
---
### Task 15: Create image-compositor (canvas pure function)
**Files:**
- Create: `frontend/utils/image-compositor.js`
- [ ] **Step 1: Read existing canvas util for pattern**
Check `frontend/utils/sticker-compositor.js` for project canvas conventions.
- [ ] **Step 2: Write the compositor**
```js
// frontend/utils/image-compositor.js
// 分享图离屏 canvas 合成(spec § 5.3)
import { BRAND_SLOGANS } from './brand-slogans.js';
const CANVAS_WIDTH = 750;
const CANVAS_HEIGHT = 1334;
const QR_SIZE = 240;
const AVATAR_SIZE = 240;
const COVER_HEIGHT = 1000;
const QR_Y = 960;
const AVATAR_Y = 720;
const SLOGAN_Y = 1200;
const FALLBACK_QRCODE = '/static/share/mock_qrcode.png';
const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png';
/**
* 合成分享图(pure function — 不发起任何网络 IO)
* @param {Object} opts
* @param {string} opts.coverLocalPath - 本地封面临时路径(uni.downloadFile 后)
* @param {string} opts.qrcodeLocalPath - 本地二维码临时路径(缺失时回退占位图)
* @param {string} opts.avatarLocalPath - 本地头像临时路径(缺失时回退占位图)
* @param {string} opts.nickname - 用户昵称(缺首字母时回退 'T')
* @param {{brandLine: string, brandDesc: string}} [opts.slogan] - 品牌文案
* @returns {Promise<{tempFilePath: string, width: number, height: number}>}
*/
export function composeShareImage(opts) {
return new Promise((resolve, reject) => {
const {
coverLocalPath,
qrcodeLocalPath = FALLBACK_QRCODE,
avatarLocalPath = FALLBACK_AVATAR,
nickname = '',
slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc }
} = opts;
// #ifdef APP-PLUS
const ctx = uni.createCanvasContext('shareCanvas', null);
// #endif
if (!ctx) {
reject(new Error('canvas context unavailable on this platform'));
return;
}
// L0: 白底(规避 iOS 离屏 canvas 黑底)
ctx.setFillStyle('#FFFFFF');
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// L1: 封面
if (coverLocalPath) {
ctx.drawImage(coverLocalPath, 0, 0, CANVAS_WIDTH, COVER_HEIGHT);
}
// L2: 头像 + nickname
const avatarX = 60;
const avatarYCenter = AVATAR_Y + AVATAR_SIZE / 2;
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + AVATAR_SIZE / 2, avatarYCenter, AVATAR_SIZE / 2, 0, 2 * Math.PI);
ctx.clip();
ctx.drawImage(avatarLocalPath, avatarX, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE);
ctx.restore();
ctx.setFillStyle('#FFFFFF');
ctx.setFontSize(36);
ctx.setTextAlign('left');
ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12);
// L3: 二维码
const qrX = CANVAS_WIDTH - QR_SIZE - 60;
ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE);
// L4: slogan
ctx.setFillStyle('#333333');
ctx.setFontSize(36);
ctx.setTextAlign('center');
if (slogan.brandLine) {
ctx.fillText(slogan.brandLine, CANVAS_WIDTH / 2, SLOGAN_Y + 30);
}
ctx.setFontSize(24);
ctx.setFillStyle('#666666');
if (slogan.brandDesc) {
ctx.fillText(slogan.brandDesc, CANVAS_WIDTH / 2, SLOGAN_Y + 70);
}
ctx.draw(false, () => {
uni.canvasToTempFilePath({
canvasId: 'shareCanvas',
x: 0, y: 0,
width: CANVAS_WIDTH, height: CANVAS_HEIGHT,
destWidth: CANVAS_WIDTH, destHeight: CANVAS_HEIGHT,
fileType: 'png', quality: 1,
success: (res) => resolve({ tempFilePath: res.tempFilePath, width: CANVAS_WIDTH, height: CANVAS_HEIGHT }),
fail: (err) => reject(new Error('canvasToTempFilePath failed: ' + err.errMsg))
}, null);
});
});
}
/**
* 计算 composeKey(spec § 5.4)
* 用于 L1/L2 缓存查询;任一字段变化则重合成
*/
export function computeComposeKey(opts) {
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {} } = opts;
return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || ''].join('|');
}
```
- [ ] **Step 3: Manual verify (real device, Phase 5 covers this).**
- [ ] **Step 4: DO NOT git commit.**
---
### Task 16: Create dev-only share-mock utility
**Files:**
- Create: `frontend/utils/share-mock.js`
- [ ] **Step 1: Write the file**
```js
// frontend/utils/share-mock.js
// 前端开发态 mock(spec § 12.1)
// 仅 import.meta.env.DEV 时生效,production 构建被 dead-code elimination 自动消除。
import { pickRandomSlogan } from './brand-slogans.js';
const MOCK_QRCODE = '/static/share/mock_qrcode.png';
export async function fetchQrcodeForDev(assetId, userId, systemType) {
if (!import.meta.env.DEV) throw new Error('fetchQrcodeForDev should only be called in DEV');
await new Promise(r => setTimeout(r, 50));
return { qrcode_url: MOCK_QRCODE, expires_at: Math.floor(Date.now() / 1000) + 7 * 24 * 3600 };
}
export function trackShareForDev(payload) {
if (!import.meta.env.DEV) throw new Error('trackShareForDev should only be called in DEV');
// eslint-disable-next-line no-console
console.log('[share-mock] track:', payload);
return Promise.resolve({ share_event_id: -1 });
}
export function isAppInstalledForDev(/* provider */) {
if (!import.meta.env.DEV) throw new Error('isAppInstalledForDev should only be called in DEV');
return Promise.resolve(true);
}
export function pickSloganForDev() {
return pickRandomSlogan();
}
```
- [ ] **Step 2: DO NOT git commit.**
---
## Phase 3 — Frontend UI Components
### Task 17: Create ShareActionBar component
**Files:**
- Create: `frontend/pages/components/ShareActionBar.vue`
- [ ] **Step 1: Write the component**
```vue
<template>
<view class="share-action-bar">
<view
v-for="action in actions"
:key="action.key"
class="action-item"
@tap="$emit('pick', action.key)"
>
<image class="action-icon" :src="action.icon" mode="aspectFit" />
<text class="action-label">{{ action.label }}</text>
</view>
</view>
</template>
<script setup>
const actions = [
{ key: 'weixin_friend', label: '微信好友', icon: '/static/share/ic_wechat_friend.png' },
{ key: 'weixin_moment', label: '朋友圈', icon: '/static/share/ic_wechat_moment.png' },
{ key: 'qq', label: 'QQ', icon: '/static/share/ic_qq.png' },
{ key: 'qq_zone', label: 'QQ空间', icon: '/static/share/ic_qq_zone.png' },
{ key: 'sinaweibo', label: '微博', icon: '/static/share/ic_weibo.png' },
{ key: 'save_image', label: '保存图片', icon: '/static/share/ic_save_image.png' }
];
defineEmits(['pick']);
</script>
<style lang="scss" scoped>
.share-action-bar {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 24rpx 16rpx;
}
.action-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.action-icon { width: 80rpx; height: 80rpx; }
.action-label { font-size: 24rpx; color: #333; }
</style>
```
- [ ] **Step 2: DO NOT git commit.**
---
### Task 18: Create SharePreviewCard component
**Files:**
- Create: `frontend/pages/components/SharePreviewCard.vue`
- [ ] **Step 1: Write the component**
```vue
<template>
<view class="share-preview-card">
<image class="cover" :src="coverUrl" mode="aspectFill" />
<view class="overlay">
<view class="avatar-row">
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
<text class="nickname">{{ nickname || 'TOPFANS' }}</text>
</view>
<view v-if="qrcodeUrl" class="qrcode-wrap">
<image class="qrcode" :src="qrcodeUrl" mode="aspectFit" />
</view>
</view>
</view>
</template>
<script setup>
defineProps({
coverUrl: { type: String, required: true },
avatarUrl: { type: String, required: true },
qrcodeUrl: { type: String, default: '' },
nickname: { type: String, default: '' }
});
</script>
<style lang="scss" scoped>
.share-preview-card {
position: relative;
width: 100%;
aspect-ratio: 750 / 1334;
background: #f5f5f5;
border-radius: 16rpx;
overflow: hidden;
}
.cover { width: 100%; height: 100%; }
.overlay { position: absolute; inset: 0; pointer-events: none; }
.avatar-row {
position: absolute;
left: 30rpx;
top: 700rpx;
display: flex;
align-items: center;
gap: 16rpx;
}
.avatar {
width: 80rpx; height: 80rpx;
border-radius: 50%;
border: 4rpx solid #fff;
}
.nickname {
color: #fff;
font-size: 28rpx;
text-shadow: 0 2rpx 8rpx rgba(0,0,0,.5);
}
.qrcode-wrap {
position: absolute;
right: 30rpx; bottom: 30rpx;
background: #fff; padding: 8rpx; border-radius: 8rpx;
}
.qrcode { width: 160rpx; height: 160rpx; }
</style>
```
- [ ] **Step 2: DO NOT git commit.**
---
### Task 19: Create useShare composable (state machine + timeout + degradation + monitoring)
**Files:**
- Create: `frontend/composables/useShare.js`
- [ ] **Step 1: Write the composable** (including spec § 5.3.1 timeout, § 7 L3 degradation, § 8 monitoring)
```js
// frontend/composables/useShare.js
// 分享状态机 + 合成调度 + 错误处理 + 监控埋点
// (spec § 5 + § 5.3.1 + § 7 + § 8)
import { ref, onShow, onHide, onBeforeUnmount } from 'vue';
import { composeShareImage, computeComposeKey } from '@/utils/image-compositor.js';
import { pickRandomSlogan } from '@/utils/brand-slogans.js';
import {
fetchQrcodeForDev, trackShareForDev, isAppInstalledForDev
} from '@/utils/share-mock.js';
const APP_PACKAGES = {
weixin_friend: { pname: 'com.tencent.mm', bundleid: 'com.tencent.xinWeChat' },
weixin_moment: { pname: 'com.tencent.mm', bundleid: 'com.tencent.xinWeChat' },
qq: { pname: 'com.tencent.mobileqq', bundleid: 'com.tencent.mqq' },
qq_zone: { pname: 'com.tencent.mobileqq', bundleid: 'com.tencent.mqq' },
sinaweibo: { pname: 'com.sina.weibo', bundleid: 'com.sina.weibo' }
};
const SHARE_TIMEOUT = {
weixin_friend: 5000, weixin_moment: 5000,
qq: 5000, qq_zone: 5000,
sinaweibo: 15000,
save_image: 8000
};
function reportEvent(eventName, payload) {
try { uni.report(eventName, payload); } catch { /* dev/test */ }
}
function isUserCancel(errMsg = '') {
return /cancel|取消/i.test(errMsg);
}
export function useShare(props) {
// ============ State ============
const state = ref('idle'); // idle | composing | sharing | done | error
const errorMsg = ref('');
const systemType = ref('');
const currentSlogan = ref(pickRandomSlogan());
const failCount = ref(0); // L3 degradation counter
// ============ Timers ============
const shareTimeoutTimer = ref(null);
const fallbackTimer = ref(null);
function clearShareTimers() {
if (shareTimeoutTimer.value) { clearTimeout(shareTimeoutTimer.value); shareTimeoutTimer.value = null; }
if (fallbackTimer.value) { clearTimeout(fallbackTimer.value); fallbackTimer.value = null; }
}
// ============ Lifecycle ============
(async () => {
try { const info = await uni.getSystemInfo(); systemType.value = info.platform || 'other'; }
catch { systemType.value = 'other'; }
})();
onShow(() => {
if (state.value === 'sharing') {
fallbackTimer.value = setTimeout(() => { if (state.value === 'sharing') state.value = 'idle'; }, 8000);
}
});
onHide(() => {
if (shareTimeoutTimer.value) { clearTimeout(shareTimeoutTimer.value); shareTimeoutTimer.value = null; }
});
onBeforeUnmount(clearShareTimers);
// ============ L1 Cache ============
const l1Cache = new Map();
// ============ Helpers ============
function getCurrentUser() {
const userStr = uni.getStorageSync('user');
if (!userStr) return null;
try { return JSON.parse(userStr); } catch { return null; }
}
async function isAppInstalled(action) {
if (import.meta.env.DEV) return isAppInstalledForDev(action);
if (typeof plus === 'undefined' || !plus.runtime) return true;
const pkg = APP_PACKAGES[action];
if (!pkg) return true;
return new Promise(resolve => {
plus.runtime.isApplicationExist({ pname: pkg.pname, bundleid: pkg.bundleid }, e => resolve(!!e.exist));
});
}
async function downloadLocal(remoteUrl) {
if (!remoteUrl) return '';
if (remoteUrl.startsWith('data:') || remoteUrl.startsWith('/static/')) return remoteUrl;
const res = await uni.downloadFile({ url: remoteUrl });
if (res.statusCode !== 200) throw new Error('downloadFile failed: ' + res.statusCode);
return res.tempFilePath;
}
async function fetchQrcode(assetId) {
if (import.meta.env.DEV) return fetchQrcodeForDev(assetId, getCurrentUser()?.id, systemType.value);
const user = getCurrentUser();
if (!user?.id) throw new Error('未登录');
const params = new URLSearchParams({ sharer_user_id: String(user.id), system_type: systemType.value }).toString();
const res = await uni.request({
url: `/api/v1/share/asset-qrcode/${assetId}?${params}`,
method: 'GET',
header: { Authorization: `Bearer ${uni.getStorageSync('token') || ''}` }
});
if (res.statusCode !== 200) throw new Error('qrcode fetch failed: ' + res.statusCode);
return res.data.data;
}
function trackShare(payload) {
if (import.meta.env.DEV) return trackShareForDev(payload);
return uni.request({
url: '/api/v1/share/track',
method: 'POST',
header: { 'Content-Type': 'application/json', Authorization: `Bearer ${uni.getStorageSync('token') || ''}` },
data: payload
}).then(res => res.data?.data || {});
}
async function ensureLogin() {
const user = getCurrentUser();
if (!user?.id) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 800);
return false;
}
return true;
}
// ============ Core ============
async function pick(action) {
if (!(await ensureLogin())) return;
state.value = 'composing';
errorMsg.value = '';
try {
// 1. App 探测
if (action !== 'save_image') {
const installed = await isAppInstalled(action);
if (!installed) {
const labelMap = { weixin_friend: '微信', weixin_moment: '微信', qq: 'QQ', qq_zone: 'QQ', sinaweibo: '微博' };
uni.showToast({ title: `请先安装${labelMap[action]}`, icon: 'none' });
state.value = 'idle';
await trackShare({ asset_id: props.assetId, sharer_user_id: getCurrentUser().id, system_type: systemType.value, share_target: action, result: 'fail_app_missing', client_ts: Date.now() });
return;
}
}
// 2. 缓存查询
const localPaths = await Promise.all([
downloadLocal(props.coverUrl),
downloadLocal(props.qrcodeUrl),
downloadLocal(props.avatarUrl)
]);
const composeKey = computeComposeKey({
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
nickname: props.nickname, slogan: currentSlogan.value
});
let tempFilePath = l1Cache.get(composeKey);
if (!tempFilePath) {
const result = await composeShareImage({
coverLocalPath: localPaths[0], qrcodeLocalPath: localPaths[1], avatarLocalPath: localPaths[2],
nickname: props.nickname, slogan: currentSlogan.value
});
tempFilePath = result.tempFilePath;
l1Cache.set(composeKey, tempFilePath);
}
// 3. 分发 + 监控
state.value = 'sharing';
const startTs = Date.now();
const userId = getCurrentUser().id;
const commonPayload = { asset_id: props.assetId, user_id: userId, system_type: systemType.value };
// § 8 监控
if (action === 'save_image') {
reportEvent('save_image_click', commonPayload);
} else {
reportEvent('share_action_click', { ...commonPayload, target: action });
}
// 超时启动(spec § 5.3.1)
startShareTimeout(action);
let result = 'fail_other';
let errMsg = '';
if (action === 'save_image') {
try {
await new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: () => { uni.showToast({ title: '已保存到相册' }); resolve(); },
fail: (e) => reject(new Error(e.errMsg || 'save failed'))
});
});
result = 'success';
} catch (e) {
errMsg = e.message;
if (/cancel|取消/i.test(errMsg)) result = 'cancel';
else if (/auth|denied|permission/i.test(errMsg)) result = 'fail_permission';
}
reportEvent('save_image_result', { ...commonPayload, result, duration_ms: Date.now() - startTs });
} else {
const provider = action.startsWith('weixin') ? 'weixin' : (action === 'qq' || action === 'qq_zone') ? 'qq' : 'sinaweibo';
const scene = action === 'weixin_moment' ? 'WXSceneTimeline' : action.startsWith('weixin') ? 'WXSceneSession' : undefined;
const shareRes = await new Promise((resolve) => {
uni.share({
provider, scene, imageUrl: tempFilePath,
success: (r) => resolve({ ok: true, msg: r.errMsg }),
fail: (e) => resolve({ ok: false, msg: e.errMsg || 'share failed' })
});
});
if (shareRes.ok) result = 'success';
else if (isUserCancel(shareRes.msg)) result = 'cancel';
errMsg = shareRes.msg;
reportEvent('share_result', { ...commonPayload, target: action, result, duration_ms: Date.now() - startTs });
}
clearShareTimers();
if (result === 'success') {
state.value = 'done';
uni.showToast({ title: '分享成功' });
failCount.value = 0;
setTimeout(() => { if (state.value === 'done') state.value = 'idle'; }, 2000);
} else if (result === 'cancel') {
state.value = 'idle';
} else {
state.value = 'error';
errorMsg.value = '分享失败,请稍后再试';
uni.showToast({ title: errorMsg.value, icon: 'none' });
failCount.value += 1;
}
// 归因追踪
await trackShare({
asset_id: props.assetId, sharer_user_id: userId,
system_type: systemType.value, share_target: action, result,
client_ts: startTs, extra: { duration_ms: Date.now() - startTs, err: errMsg }
});
} catch (e) {
clearShareTimers();
state.value = 'error';
errorMsg.value = e.message || '未知错误';
uni.showToast({ title: errorMsg.value, icon: 'none' });
failCount.value += 1;
}
}
function startShareTimeout(action) {
const ms = SHARE_TIMEOUT[action] || 5000;
shareTimeoutTimer.value = setTimeout(() => {
if (state.value === 'sharing') {
state.value = 'error';
errorMsg.value = '分享超时,请重试';
uni.showToast({ title: errorMsg.value, icon: 'none' });
trackShare({ asset_id: props.assetId, sharer_user_id: getCurrentUser()?.id || 0, system_type: systemType.value, share_target: action, result: 'fail_other', client_ts: Date.now() });
}
}, ms);
}
return { state, errorMsg, systemType, currentSlogan, failCount, pick };
}
```
- [ ] **Step 2: DO NOT git commit.**
---
### Task 20: Refactor ShareModal.vue (container)
**Files:**
- Modify: `frontend/pages/components/ShareModal.vue`
- [ ] **Step 1: Read current ShareModal.vue to preserve public API** (visible prop, close emit)
- [ ] **Step 2: Write the new container** (≤ 80 lines)
```vue
<template>
<uni-popup ref="popupRef" type="bottom" :mask-click="false">
<view class="share-modal">
<view class="header">
<text class="title">分享藏品</text>
<view class="close" @tap="handleClose"></view>
</view>
<view v-if="!isLoggedIn" class="login-gate">
<text class="lock-icon">🔒</text>
<text class="lock-text">登录后才能分享</text>
<view class="login-actions">
<button class="btn-secondary" @tap="handleClose">再看看</button>
<button class="btn-primary" @tap="goLogin">去登录</button>
</view>
</view>
<template v-else>
<SharePreviewCard
:coverUrl="coverUrl" :avatarUrl="avatarUrl"
:qrcodeUrl="qrcodeUrl" :nickname="nickname"
/>
<view v-if="state === 'composing'" class="overlay">
<view class="spinner" />
<text>正在生成图片...</text>
</view>
<ShareActionBar @pick="pick" />
<view v-if="failCount >= 2" class="degrade-bar">
<button @tap="copyLink">复制链接分享到更多平台</button>
</view>
</template>
</view>
</uni-popup>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import SharePreviewCard from './SharePreviewCard.vue';
import ShareActionBar from './ShareActionBar.vue';
import { useShare } from '@/composables/useShare.js';
const props = defineProps({
visible: { type: Boolean, default: false },
coverUrl: { type: String, required: true },
qrcodeUrl: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
nickname: { type: String, default: '' },
assetId: { type: [Number, String], required: true }
});
const emit = defineEmits(['close']);
const popupRef = ref(null);
const isLoggedIn = computed(() => {
try { return !!JSON.parse(uni.getStorageSync('user') || '{}').id; } catch { return false; }
});
const { state, failCount, pick } = useShare({
coverUrl: computed(() => props.coverUrl),
qrcodeUrl: computed(() => props.qrcodeUrl),
avatarUrl: computed(() => props.avatarUrl),
nickname: computed(() => props.nickname),
assetId: computed(() => props.assetId)
});
watch(() => props.visible, (v) => {
if (v) popupRef.value?.open();
else popupRef.value?.close();
});
function handleClose() { emit('close'); popupRef.value?.close(); }
function goLogin() { emit('close'); popupRef.value?.close(); uni.navigateTo({ url: '/pages/login/login' }); }
function copyLink() {
uni.setClipboardData({
data: `https://h5.topfans.com/asset/${props.assetId}`,
success: () => uni.showToast({ title: '链接已复制' })
});
}
</script>
<style lang="scss" scoped>
.share-modal {
background: #fff; border-radius: 24rpx 24rpx 0 0;
padding: 32rpx 24rpx; position: relative; min-height: 700rpx;
}
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
.title { font-size: 32rpx; font-weight: 600; }
.close { font-size: 36rpx; padding: 8rpx 16rpx; }
.login-gate { display: flex; flex-direction: column; align-items: center; gap: 24rpx; padding: 80rpx 0; }
.lock-icon { font-size: 80rpx; }
.lock-text { font-size: 28rpx; color: #666; }
.login-actions { display: flex; gap: 24rpx; }
.btn-secondary, .btn-primary { font-size: 28rpx; padding: 16rpx 48rpx; border-radius: 48rpx; }
.btn-secondary { background: #f5f5f5; color: #333; }
.btn-primary { background: #07C160; color: #fff; }
.overlay {
position: absolute; inset: 0; background: rgba(255,255,255,.85);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16rpx;
}
.spinner {
width: 64rpx; height: 64rpx; border: 6rpx solid #ddd;
border-top-color: #07C160; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.degrade-bar { padding: 16rpx 0; }
.degrade-bar button { width: 100%; font-size: 28rpx; background: #f5f5f5; }
</style>
```
- [ ] **Step 3: DO NOT git commit.**
---
### Task 21: Update ShareReportButtons (login gate + path fix)
**Files:**
- Modify: `frontend/pages/components/ShareReportButtons.vue`
- [ ] **Step 1: Replace the bad qrcode placeholder path** (around line 87)
Change:
```js
shareQrcodeUrl.value = '/static/icon/qrcode-placeholder.png';
```
To:
```js
shareQrcodeUrl.value = '/static/share/mock_qrcode.png';
```
- [ ] **Step 2: Add login gate in handleShare**
```js
const handleShare = () => {
// 登录门槛:未登录不打开弹窗(spec § 3.4)
const userStr = uni.getStorageSync('user');
if (!userStr || !JSON.parse(userStr)?.id) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 800);
return;
}
showShareModal.value = true;
};
```
- [ ] **Step 3: Fetch real QR code on modal open**
In the watch on `showShareModal`:
```js
if (showShareModal.value) {
const user = JSON.parse(uni.getStorageSync('user') || '{}');
// 用异步版本(spec § 3.3 推荐 — 不阻塞 UI 线程)
const { platform } = await uni.getSystemInfo();
uni.request({
url: `/api/v1/share/asset-qrcode/${props.assetId}?sharer_user_id=${user.id}&system_type=${platform}`,
method: 'GET',
header: { Authorization: `Bearer ${uni.getStorageSync('token') || ''}` }
}).then(res => {
if (res.statusCode === 200 && res.data?.data?.qrcode_url) {
shareQrcodeUrl.value = res.data.data.qrcode_url;
}
}).catch(() => { /* 失败保持 mock 占位图 */ });
}
```
- [ ] **Step 4: DO NOT git commit.**
---
## Phase 4 — Configuration & Resources
### Task 22: Add share icons to static/
**Files:**
- Create: 7 PNG files under `frontend/static/share/`
- [ ] **Step 1: Create directory**
Run: `mkdir -p frontend/static/share`
- [ ] **Step 2: Source icons from designer** (192×192, white-line + brand-color per spec § 6)
> Designer to deliver: 6 platform icons + 1 save-image icon. Until designer provides, use placeholder copies of any existing 192×192 icon to ensure paths resolve.
- [ ] **Step 3: Create mock_qrcode.png placeholder**
```bash
cp frontend/static/icon/confirmbj.png frontend/static/share/mock_qrcode.png
```
- [ ] **Step 4: DO NOT git commit.**
---
### Task 23: Update manifest.json for share module
**Files:**
- Modify: `frontend/src/manifest.json`
- [ ] **Step 1: Add Share module** (under `app-plus.modules`)
```json
"modules": {
"Share": {},
"VideoPlayer": {},
"Camera": {},
"Speech": {},
"Push": {}
}
```
- [ ] **Step 2: Add Android schemes** (under `app-plus.distribute.android`)
```json
"schemes": "topfans"
```
- [ ] **Step 3: Add iOS URL types** (under `app-plus.distribute.ios`)
```json
"urltypes": [
{ "urlschemes": ["topfans"] }
]
```
- [ ] **Step 4: Add sdkConfigs.share placeholder block** (top-level or under `app-plus`)
```json
"sdkConfigs": {
"share": {
"weixin": {
"appid": "<REPLACE_WITH_WEIXIN_APPID>",
"UniversalLinks": "https://h5.topfans.com/uni-universallinks/"
},
"qq": {
"appid": "<REPLACE_WITH_QQ_APPID>"
},
"sinaweibo": {
"appid": "<REPLACE_WITH_WEIBO_APPID>"
}
}
}
```
> **WARNING:** Placeholder strings will cause real-device share to fail until replaced. Document in commit message.
- [ ] **Step 5: DO NOT git commit.**
---
### Task 24: Document apple-app-site-association requirement
**Files:**
- Create: `docs/superpowers/specs/2026-06-11-share-modal-redesign-aasa-note.md`
- [ ] **Step 1: Write the note** (covering: required for iOS 13+ WeChat/Weibo SDK; deployment path `https://h5.topfans.com/uni-universallinks/apple-app-site-association`; template JSON content with `applinks` + `appIDs` + `components`; verification steps; current blocker status)
- [ ] **Step 2: DO NOT git commit.**
---
## Phase 5 — Manual Acceptance
### Task 25: Manual verification checklist
**Files:** (none — manual verification)
Per spec § 10.3 checklist. Use HBuilderX + iOS simulator / real device.
- [ ] Build frontend (`npm run build:[platform]`)
- [ ] 6 buttons in single row, no wrap on iPhone 13 mini
- [ ] Save image: photo saved to album
- [ ] Login gate: logged-out tap → toast + redirect (no modal open)
- [ ] QR code network: `GET /api/v1/share/asset-qrcode/...` returns valid response
- [ ] Track: row inserted in `public.share_events` with correct fields
- [ ] Slogan: same modal → same slogan (not random per button)
- [ ] Flight mode: toast "图片加载失败,请重试"
- [ ] Screenshot + record: save to `docs/superpowers/specs/2026-06-11-share-modal-redesign-evidence/`
---
## Summary
| Phase | Tasks | Files Created | Files Modified | ~LOC |
|-------|-------|---------------|----------------|------|
| 1. Backend | 17, 913 | 5 | 4 | ~1100 |
| 2. Frontend Utils | 1416 | 3 | 0 | ~250 |
| 3. Frontend UI | 1721 | 4 | 1 | ~600 |
| 4. Config | 2224 | 8 | 1 | ~150 |
| 5. Manual Verify | 25 | 0 | 0 | 0 |
| **Total** | **24** | **20** | **6** | **~2100** |
(Task 8 deleted — share is part of assetService, no separate main needed.)
## Deferred (Blocked by External Dependencies)
| Item | Blocker | Spec Section |
|------|---------|--------------|
| Real WeChat / Moments / QQ / Zone / Weibo share test | AppID 未到位 | § 4.2, § 12.2 |
| iOS Universal Links validation | `apple-app-site-association` 未部署 | § 4.2 |
| Android SHA1 / MD5 签名注册 | 打包 keystore 待定 | § 12.2.4 |
| 24h 上线后错误率监控 | 上线后才有基线 | § 11 |