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

63 KiB
Raw Blame History

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

-- 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:

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)

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 })
  // 生成分享二维码 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)
// ================== 分享功能(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
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
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

cd backend/services/assetService
go get github.com/skip2/go-qrcode@latest
go mod tidy
  • Step 2: Write the generator
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:

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 个方法:
GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error)
  1. AssetServiceImpl struct 上加字段 shareService *ShareService
  2. *AssetServiceImpl 上加 2 个方法实现,内部调 s.shareService.GetAssetQrcode(...)TrackShare(...) (薄包装,把 req 字段映射成 service 函数的 positional args)。
  3. 修改 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)

    // 分享服务相关错误
    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)
    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:

// 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)

        // 分享相关路由(需要认证)— 用 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
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
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
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

// 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
// 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

// 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

<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

<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)

// 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)

<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:

shareQrcodeUrl.value = '/static/icon/qrcode-placeholder.png';

To:

shareQrcodeUrl.value = '/static/share/mock_qrcode.png';
  • Step 2: Add login gate in handleShare
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:

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
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)

"modules": {
    "Share": {},
    "VideoPlayer": {},
    "Camera": {},
    "Speech": {},
    "Push": {}
}
  • Step 2: Add Android schemes (under app-plus.distribute.android)
"schemes": "topfans"
  • Step 3: Add iOS URL types (under app-plus.distribute.ios)
"urltypes": [
    { "urlschemes": ["topfans"] }
]
  • Step 4: Add sdkConfigs.share placeholder block (top-level or under app-plus)
"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