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