63 KiB
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.vueinto 5 focused files (container + preview + action bar + composable + canvas compositor) + add 7 icons + mock for dev. - Config: Update
manifest.jsonfor 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 intoasset.proto - ❌
backend/pkg/proto/share/— DELETED, generated code inpkg/proto/asset/ - ❌
backend/services/shareService/— DELETED, share is part ofassetServicepackage - ❌
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 toasset_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
AssetServiceinterface is defined -
How a typical service method signature looks
-
How redis client is wired in
-
How Dubbo metadata
user_idis extracted from context -
Step 2: Write the share service (in same
servicepackage, 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。
实施步骤:
- 读
backend/services/assetService/service/asset_service.go找到AssetServiceinterface 定义和AssetServiceImplstruct 定义。 - 在 interface 上加 2 个方法:
GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error)
- 在
AssetServiceImplstruct 上加字段shareService *ShareService。 - 在
*AssetServiceImpl上加 2 个方法实现,内部调s.shareService.GetAssetQrcode(...)和TrackShare(...)(薄包装,把req字段映射成 service 函数的 positional args)。 - 修改
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
ToGRPCCodeswitch (before thedefaultcase)
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_idis extracted from gin context (likely viac.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:
getUserIDFromGinis duplicated across controllers — consider extracting tomiddlewarein 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_typeGetAssetQrcode: rejects invalid asset (AssetExists=false)GetAssetQrcode: rejects invalid userGetAssetQrcode: generates valid URL containingfrom=,s=, asset_idTrackShare: rejects invalid result / share_targetTrackShare: 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 withapplinks+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_eventswith 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 |