# 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="" 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":}}` - [ ] **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 ``` - [ ] **Step 2: DO NOT git commit.** --- ### Task 18: Create SharePreviewCard component **Files:** - Create: `frontend/pages/components/SharePreviewCard.vue` - [ ] **Step 1: Write the component** ```vue ``` - [ ] **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 ``` - [ ] **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": "", "UniversalLinks": "https://h5.topfans.com/uni-universallinks/" }, "qq": { "appid": "" }, "sinaweibo": { "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 | 1–7, 9–13 | 5 | 4 | ~1100 | | 2. Frontend Utils | 14–16 | 3 | 0 | ~250 | | 3. Frontend UI | 17–21 | 4 | 1 | ~600 | | 4. Config | 22–24 | 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 |