docs:修改文档

This commit is contained in:
zerosaturation 2026-06-16 02:45:19 +08:00
parent 9ab54c7640
commit d9473fda7a
2 changed files with 440 additions and 153 deletions

View File

@ -129,7 +129,7 @@ TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
### 2.3 复用现有资产
- **OSS 上传**:举报证据图、反馈截图复用 `assetService` 的 OSS 签名接口`GET /api/v1/assets/oss/signature?type=asset`
- **OSS 上传**:举报证据图、反馈截图复用 `assetService` 的 OSS 签名接口**新增 `type` 白名单 `report` / `feedback`**,与现有 `avatar` / `asset` 并列。客户端按场景传 `?type=report`(举报证据)或 `?type=feedback`反馈截图OSS 端按 `type` 锁定目录前缀 `report/` / `feedback/`(与 `asset/<uid>/<sid>/` 命名空间完全隔离,不可写进 `asset/`
- **认证**:客户端 JWT 用 topfans userService后台 JWT 用 `TopFans-activity` 已有的 `verify_token`
- **通知中心**:处理结果通过 topfans `notificationService` 模板下发(新增 6 个模板)
- **数据迁移**:遵循 topfans 现有 `backend/migrations/00x_*.sql` 命名规范
@ -265,8 +265,8 @@ CREATE TABLE reports (
CONSTRAINT fk_reports_category FOREIGN KEY (category_code)
REFERENCES report_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT chk_reports_status
CHECK (status IN ('pending','reviewing','auto_hidden','resolved','dismissed','withdrawn','archived')),
-- v2.4 加 'withdrawn'(客户端 DELETE 撤回)和 'archived'6.5 cron 90 天 auto-archive
CHECK (status IN ('pending','reviewing','auto_hidden','resolved','dismissed','withdrawn')),
-- v2.4 加 'withdrawn'(客户端 DELETE 撤回)
CONSTRAINT chk_reports_target_type
CHECK (target_type IN ('asset','user_profile')),
CONSTRAINT chk_reports_resolved_action
@ -633,7 +633,7 @@ CREATE INDEX idx_admin_audit_logs_resource
└─────→ resolved/dismissed
```
> **图示说明**:本 ASCII 图简化展示主要迁移;完整 9 行迁移矩阵见下表。图中"释放认领"回退到 pending"快速驳回"是 `pending → dismissed` 快速通道;"解除隐藏"是 `auto_hidden → reviewing → dismissed (恢复)`
> **图示说明**:本 ASCII 图简化展示主要迁移;完整 14 行迁移矩阵见下表。图中"释放认领"回退到 pending"快速驳回"是 `pending → dismissed` 快速通道;"解除隐藏"是 `auto_hidden → reviewing → dismissed (恢复)`
| 状态 | 含义 | 进入 | 退出 |
|------|------|------|------|
@ -660,7 +660,6 @@ CREATE INDEX idx_admin_audit_logs_resource
| resolved | (终态) | - | - |
| resolved | resolved (restore/unban) | 后台 | **解除 ban/takedown 误判**:把已 `resolved` (resolved_action='ban' or 'takedown') 的工单对应的 `users.is_active=true, deleted_at=NULL` 恢复 | **v2.5 CTE 条件写 mts**(避免无 op 时误导审计):<br>```sql<br>WITH user_unbanned AS (<br> UPDATE users SET is_active=true, deleted_at=NULL<br> WHERE id=$id AND is_active=false<br> RETURNING id<br>), report_updated AS (<br> UPDATE reports SET resolved_action='restore',<br> resolution_note='unban: ' \|\| $note,<br> resolved_by=$admin_id, resolved_at=$now<br> WHERE id=$id AND status='resolved' AND resolved_action IN ('ban','takedown')<br> RETURNING id<br>)<br>-- **关键:仅当 user_unbanned=1实际解了 user才写 mts restore**<br>INSERT INTO moderation_target_status (target_type, target_id, last_action_type, source, operator_admin_id, reason, created_at, updated_at)<br>SELECT 'user_profile', $id, 'restore', 'admin', $admin_id, 'unban: ' \|\| $note, $now, $now<br>WHERE EXISTS (SELECT 1 FROM user_unbanned);<br>-- 写流水(无论 user 是否真解,都记此次 admin 动作)<br>INSERT INTO moderation_actions (report_id, admin_id, action_type, target_type, target_id, note, success, created_at)<br>VALUES ($id, $admin_id, 'restore', 'user_profile', $id,<br> 'unban: ' \|\| $note \|\| ' (user_unbanned=' \|\| (SELECT count(*) FROM user_unbanned) \|\| ')', TRUE, $now);<br>```<br>行为分支:<br>- user_unbanned=1实际解 user+ report_updated=1 → 完整 unbanmts+流水双写<br>- user_unbanned=0user 已 active+ report_updated=1 → **只写流水,不写 mts**(避免 "明明不是我解的" 误导审计)<br>- report_updated=0status 不在 ['ban','takedown'])→ 50014 状态机非法迁移 |
| pending | withdrawn | 客户端 | 撤回误报(`DELETE /api/v1/moderation/reports/{id}`,仅 reporter_id=本人)| UPDATE reports SET status='withdrawn', updated_at=$now WHERE id=$id AND status='pending' AND reporter_id=$current_user; 写 moderation_actionsaction_type='withdraw', admin_id=$current_user, report_id=$id|
| pending | archived | 系统 cron | 6.5 cron 90 天未处理自动 archive | UPDATE reports SET status='archived', updated_at=$now, resolution_note='auto-archived: pending > 90 days' WHERE status='pending' AND created_at < now-90d; 6.5 cron SQL 完整版见阶段 6.5 | - |
| dismissed | (终态) | - | - | - |
> **pending → dismissed 快速通道**:用于"同一用户对同一对象重复举报"或"明显不在分类范围内的误报"——管理员可在不认领的情况下直接驳回;状态机仍记录在迁移矩阵中,与 6.2 流程保持一致。
@ -772,7 +771,11 @@ WHERE id = ? AND status IN ('pending', 'auto_hidden')
> - `DELETE /api/v1/moderation/reports/{id}`——软删除:`UPDATE reports SET status='withdrawn', updated_at=$now WHERE id=$id AND status='pending' AND reporter_id=$current_user`**v2.4 不增 `withdrawn_at` 列**(与 `updated_at` 冗余;`status='withdrawn'` + `updated_at` 已携带完整时间信息);写 `moderation_actions.action_type='withdraw'`v2.4 新增此 action_type
> - 用途:账号被盗 / 误点错分类 / 撤销误报
> **证据上传**:客户端先调用 `GET /api/v1/assets/oss/signature?type=asset` 直传 OSS再把返回的 `key` 提交给本接口。
> **证据上传**:客户端按工单类型调用:
> - 举报证据:`GET /api/v1/assets/oss/signature?type=report`OSS 锁定 `report/` 前缀)
> - 反馈截图:`GET /api/v1/assets/oss/signature?type=feedback`OSS 锁定 `feedback/` 前缀)
>
> 直传 OSS 后把返回的 `key` 提交给本接口。**禁止**再用 `?type=asset` 上报证据(会落到 `asset/<uid>/<sid>/` 命名空间,污染藏品数据)。
**提交反馈请求体**`POST /api/v1/moderation/feedbacks`
```json
@ -987,7 +990,7 @@ gateway → moderationService.SubmitReport()
│ - 注:此流程不影响 `reports` 状态机(仅清 mts 警告字段)
│ - **v2.2 CHECK 放宽**`chk_moderation_actions_xor` 允许 `report_id``feedback_id` 同时为 NULL用于 warn_cleared 这类无 ticket 关联的审计行)——改为 `(report_id IS NOT NULL) <> (feedback_id IS NOT NULL) OR (report_id IS NULL AND feedback_id IS NULL)`(新增 OR 分支)
├─ dismiss驳回种迁移路径,见 4.1 状态机矩阵):
├─ dismiss驳回种迁移路径,见 4.1 状态机矩阵):
│ **路径 Apending → dismissed快速通道无需 review**
│ - 适用场景:明显误报 / 分类错误 / 重复举报
│ - 权限:任意审核员(无需 claimed_by`reports.claimed_by IS NULL`
@ -1154,7 +1157,7 @@ triggered, count := redis.Eval(ctx, luaScript, ...)
| 50012 | 提交过于频繁(限流)| 429 |
| | **响应体**`{ code, message, scope: "user"|"ip"|"device", limit: 30|200 }`v2.5 新增)—— 前端可显示 "今日已提交 X 次user 限 30/ip 限 200/device 限 200",精准定位哪个维度超限 | | |
| 50013 | 反馈每日提交超限 | 429 |
| 50014 | 状态机非法迁移(如 `pending → archived` 跳过 reviewing| 409 |
| 50014 | 状态机非法迁移(如 `pending → resolved` 跳过 reviewing| 409 |
| | **响应体**`{ code, message, current_status: "pending"|"reviewing"|"auto_hidden"|"resolved"|"dismissed", suggested_action: "re_claim"|"wait_for_admin"|"contact_support" }`v2.5 新增)—— race 后 admin A 拿到的不是 50008"已被他人认领")而是 50014"工单已被释放或结案,请 re-claim 后再操作"),前端按 `current_status` 智能提示 | | |
| 50015 | 管理员未登录 / Token 失效 | 401 |
| **50016**v2.3| 举报对象类型不支持(`target_type` 不在白名单 `{'asset','user_profile'}`| 400 |
@ -1200,8 +1203,9 @@ triggered, count := redis.Eval(ctx, luaScript, ...)
| 防重复举报 | DB 局部唯一索引 `uk_reports_reporter_target_pending`(结案后允许再次举报)|
| 防 Redis 计数重复 | Lua 脚本用 `user_marker` SETNX 保证独立用户才 INCR |
| 防自动隐藏并发 | Redis 短锁 `mod:report:lock:*` 5s + DB 幂等 SQL仅 pending → auto_hidden|
| 证据图校验 | OSS 签名接口限定目录 `report/``feedback/` 前缀 |
| **证据图大小/类型**v2.4 修订)| **moderationService 不能直接校验**(证据图通过 `GET /api/v1/assets/oss/signature?type=asset` 拿 presigned URL 后**客户端直传 OSS**moderationService 只收到 `key` 字符串,不见字节)。校验在 **OSS bucket policy 层**实施:单图 ≤ 5MB / MIME 限 `image/png` `image/jpeg` / OSS 返回 4xx 时客户端应捕获并**不**提交 report服务端 9.1 仍 50018 作为**防御纵深**(客户端篡改 key 提交,提交时校验 `evidence_keys` 字符串格式与 `report/` 前缀,但**不**二次校验大小/MIME——OSS 已校验)。监控指标 `moderation.oss.evidence_upload_failed.count`OSS 4xx 响应)|
| 证据图校验 | OSS 签名接口**扩展 `type` 白名单**`report` / `feedback` 两个新值(除既有 `avatar` / `asset`);调用时分别传 `?type=report`(举报证据)或 `?type=feedback`反馈截图OSS 端按 `type` 锁定目录前缀 `report/` / `feedback/`,与 `asset/<uid>/<sid>/` 命名空间完全隔离。**实现侧要求**`asset_controller.go` 第 871/1279/1544 三处白名单硬编码 `avatar` / `asset` 必须扩为四个值;`config.go` `GetUploadDir()` switch 补 `report``OSSReportEvidenceDir`(默认 `report/`)、`feedback` → `OSSFeedbackEvidenceDir`(默认 `feedback/`)两个 case**不能依赖默认 fallback 落到 AssetDir**——否则攻击者可绕过白名单把恶意 key 写到 `asset/...`)。**阿里云控制台防呆**OSS 是对象存储,`report/` / `feedback/` 目录由**首次上传隐式创建****无须**预先在阿里云 OSS 控制台"创建文件夹";唯一需在控制台手动配置的是 **Bucket CORS 跨域策略**和(如果启用)**Referer 防盗链白名单**——把 `report/` `feedback/` 加入即可 |
| 证据图调用副作用 | **`GetOSSUploadSignature` 第 915 行无条件调 `InitMintOrder`**(针对 `avatar` / `asset` 创建 PENDING 铸造单)。新增 `type=report` / `type=feedback` 路径**必须** if 分支跳过 `InitMintOrder`——否则举报/反馈上传会在 `asset_mint_orders` 产生幽灵 PENDING 单,污染铸造池且无 `target_asset_id` 可关联 |
| **证据图大小/类型**v2.4 修订)| **moderationService 不能直接校验**(证据图通过 `GET /api/v1/assets/oss/signature?type=report``?type=feedback` 拿 presigned URL 后**客户端直传 OSS**moderationService 只收到 `key` 字符串,不见字节)。校验在 **OSS bucket policy 层**实施:单图 ≤ 5MB / MIME 限 `image/png` `image/jpeg` / OSS 返回 4xx 时客户端应捕获并**不**提交 report服务端 9.1 仍 50018 作为**防御纵深**(客户端篡改 key 提交,提交时校验 `evidence_keys` 字符串格式与 `report/` / `feedback/` 前缀,但**不**二次校验大小/MIME——OSS 已校验)。监控指标 `moderation.oss.evidence_upload_failed.count`OSS 4xx 响应)|
| 描述长度 | 服务端校验 ≤ 500 字DB 字段 VARCHAR(500)|
| 证据图数量 | 服务端校验 ≤ 5 张 |
| 匿名保护 | `is_anonymous=true` 时:`reporter_id` 仍记录但 API 响应中不返回给被举报方(**仅对被举报方匿名****后台所有审核员可见** `reporter_id`1.3 范围"仅一种角色"——不存在超级管理员;如需审核员侧也匿名,需解除 YAGNI 限制并新增 super_admin 角色)|
@ -1211,7 +1215,6 @@ triggered, count := redis.Eval(ctx, luaScript, ...)
| **star_id stale 容忍**v2.3| `reports.star_id` 是 denormalized 副本,跨表无 FKstar binding 转移后 reports.star_id 不回溯更新 |
| 全局限流(举报) | 每用户每天 ≤ 30 次举报Redis 计数 `mod:rl:report:user:{user_id}:{yyyymmdd}`TTL 36h超限返回 50012 |
| 全局限流(反馈) | 每用户每天 ≤ 5 条反馈Redis 计数 `mod:rl:feedback:user:{user_id}:{yyyymmdd}`TTL 36h超限返回 50013 |
| **Claim 超时自动释放**v2.3| 15 分钟未操作的 `reviewing` 工单由 cron 任务自动回退到 `pending``UPDATE reports SET status='pending', claimed_by=NULL, claimed_at=NULL WHERE status='reviewing' AND claimed_at < now() - INTERVAL '15 minutes'`监控指标 `moderation.reports.claim_timeout.count` |
### 9.2 数据隔离
@ -1224,7 +1227,6 @@ triggered, count := redis.Eval(ctx, luaScript, ...)
- `reports` / `feedbacks` 永久保留(合规需要)
- `moderation_actions` 永久保留(审计)
- `moderation_target_status` UPSERT 保留最新状态,历史可查 `moderation_actions`
- **PII 匿名化**v2.3 新增):`feedbacks.contact` 字段(邮箱/手机/微信)在 `status IN ('closed','archived')`**90 天** 自动匿名化为 `NULL`,由每日 cron 执行:`UPDATE feedbacks SET contact=NULL WHERE status IN ('closed','archived') AND updated_at < now() - INTERVAL '90 days' AND contact IS NOT NULL`监控 `moderation.feedback.pii_anonymized.count` GDPR / 中国个人信息保护法》"最小必要 + 限期保存"原则一致
---
@ -1399,78 +1401,10 @@ v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail)
- 6.4 用户警告展示(仅前端/详情页需要,**非热路径**
- `LEFT JOIN moderation_target_status ON target_type='user_profile' AND target_id=users.id WHERE is_warned = TRUE`
- 用于在用户主页/控制台展示最近一次警告原因与时间;不阻塞登录
- 6.5 性能优化(如警告查询变热):
- 性能优化(可选 addendum如警告查询变热):
- Redis 缓存 `mod:warn:cache:{user_id}` TTL 60s
- 命中即跳过 JOIN
### 阶段 6.5定时任务v2.3 新增)
每日 cron 任务(建议挂载在 K8s CronJob 或 systemd timer
> **步骤顺序约束**v2.5 文档化,避免 PII 时钟被业务操作重置):
> 1. **先 PII 匿名化**(用 `closed_at`/`replied_at`/`archived_at` 而非 `updated_at`,与具体业务操作解耦)
> 2. **后 auto-archive**status='pending' 状态 90 天未处理)
> 3. **最后 claim 超时回收**reviewing 状态 15 分钟未操作)
>
> 若一个反馈既 > 90 天 pending 又被 stale-claim 锁定 91 天链路是claim 释放 → status='pending' → auto-archive → status='archived'。PII 匿名化时钟从 `archived_at` 起(不是 `created_at`——admin 调查时间会延长 PII 保留窗口(这是 GDPR/中国《个人信息保护法》"最小必要"原则下可接受的 trade-off
```sql
-- 1. PII 匿名化feedbacks.contact 90 天后清空v2.5 扩到所有终态 replied/closed/archived
UPDATE feedbacks
SET contact = NULL
WHERE contact IS NOT NULL
AND (
-- 'replied' 没有 closed_at/archived_at用 replied_atPII 时钟从回复时刻起 90 天)
(status = 'replied' AND replied_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
OR (status = 'closed' AND closed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
OR (status = 'archived' AND archived_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
);
-- v2.5 修复 v2.4 GDPR 漏洞:之前只覆盖 closed/archived漏了 'replied'admin 回复后用户的 contact 永不匿名化)
-- 2. 过期 pending 工单自动 archivereports—— v2.5 补 archived_at + resolution_note
UPDATE reports
SET status = 'archived',
updated_at = EXTRACT(EPOCH FROM NOW()) * 1000,
resolution_note = 'auto-archived: pending > 90 days'
WHERE status = 'pending'
AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
-- 2b. 过期 pending 工单自动 archivefeedbacks—— v2.5 补 archived_by/archived_at满足 chk_feedbacks_archived_fields+ 审计行
UPDATE feedbacks
SET status = 'archived',
archived_by = 0, -- 0 = system auto sentinel (与 admin_id CHECK 配合)
archived_at = EXTRACT(EPOCH FROM NOW()) * 1000,
updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
WHERE status = 'pending'
AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
-- 2c. 审计行v2.5 新增)—— 为刚 archived 的 feedbacks 写 moderation_actions 流水
INSERT INTO moderation_actions
(report_id, feedback_id, admin_id, action_type, target_type, target_id,
note, success, created_at)
SELECT NULL, id, 0, 'archive', 'user_profile', id,
'auto-archived by cron: pending > 90 days', TRUE, archived_at
FROM feedbacks
WHERE status = 'archived' AND archived_by = 0
AND archived_at = EXTRACT(EPOCH FROM NOW()) * 1000;
-- 3. Claim 超时自动释放(每 5 分钟跑)
UPDATE reports
SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
WHERE status = 'reviewing'
AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
-- 同理 feedbacks
UPDATE feedbacks
SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
WHERE status = 'reviewing'
AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
```
> **监控指标**
> - `moderation.feedback.pii_anonymized.count` — 每日匿名化条数
> - `moderation.reports.auto_archived.count` — 每日 archive 条数
> - `moderation.reports.claim_timeout.count` — claim 超时回收条数
### 阶段 7通知与测试
- 7.1 通知模板注册到 `notificationService`6 个模板:含自动隐藏)
- 7.2 Go 单元测试
@ -1526,9 +1460,6 @@ type ModerationConfig struct {
RateLimitUserPerDay int // 30举报/ 5反馈
RateLimitIPPerDay int // 200v2.3IP 限流防多账号 spam
RateLimitDevicePerDay int // 200v2.3,设备指纹限流)
ClaimTimeout time.Duration // 15 分钟v2.3cron 任务自动释放)
PIIAnonymizeAfter time.Duration // 90 天v2.3feedbacks.contact 自动匿名化)
AutoArchiveAfter time.Duration // 90 天v2.3pending 工单自动 archive
RedisKeyPrefix string // "mod:report"(生产)| "test:mod:report"测试v2.3
PathDCounterPolicy string // "reset" | "preserve" | "expire_1h"v2.3,路径 D 的 Redis counter 行为)
}
@ -1575,9 +1506,6 @@ type ModerationConfig struct {
- `moderation.actions.auto_hide.count` (counter, tag: target_type) — 系统自动 takedown 次数(与人工 takedown 区分)
- `moderation.actions.warn_cleared.count` (counter) — warn_cleared 流程触发次数
- `moderation.redis.counter_reset.count` (counter, tag: path=C|D) — Redis counter 重置监控
- `moderation.feedback.pii_anonymized.count` (counter) — 每日 PII 匿名化条数
- `moderation.reports.auto_archived.count` (counter) — 每日 auto-archive 条数
- `moderation.reports.claim_timeout.count` (counter) — claim 超时自动释放条数
- `moderation.feedback.terminal_state.claim_attempt.count` (counter, tag: status=replied|closed|archived) — 监控试图认领终态工单次数(验证 50009 区分是否生效)
- `moderation.api.latency.evidence_upload.p95` (histogram) — 证据上传 P95 延迟
- `moderation.rate_limit.hit.count` (counter, tag: type=report|feedback, scope=user|ip|device) — 限流触发监控

View File

@ -11,6 +11,11 @@
**目标平台**app-plusiOS / Android。H5 暂不支持,**为后续接入 `mp-weixin` 预留接口**(前端调用代码不需变更)。
**新增目标 - 分享归因追踪**(用户 2026-06-16 提出):
- 每次分享动作需记录**当前分享人**的身份标识 `sharer_user_id`**仅限已登录用户**,未登录用户不能分享,见 § 3.4
- 每次分享动作需记录**分享来源端**`system_type`android / ios / mp-weixin / h5 等,见 § 3 枚举表)
- 这两个字段用于后续追溯分享链路、二级传播、渠道归因(例:用户 A 在 iOS 上分享给用户 BB 扫码注册/打开后可识别出"来自 A"
**关键决策**(用户确认):
| 决策点 | 结论 |
@ -21,7 +26,11 @@
| 技术路线 | `uni.share` API统一跨端预留 mp-weixin 接入时验证) |
| 分享内容 | **带二维码的合成图** |
| 图片合成 | **前端 Canvas 离屏合成**(方案 A |
| 二维码来源 | **后端接口**(消除 `ShareReportButtons.vue:75` 的占位图路径 `/static/icon/qrcode-placeholder.png`,该文件实际不存在,见 § 13 |
| 二维码来源 | **后端接口**(消除 `ShareReportButtons.vue:75` 的占位图路径 `/static/icon/qrcode-placeholder.png`,该文件实际不存在,见 § 12 |
| 分享人身份字段 | **仅** `sharer_user_id`(强制登录;不提供匿名/share_code 兜底) |
| 分享端识别 | 客户端读取 `uni.getSystemInfo()``android` / `ios` / `h5` / `mp-weixin` 等 uni-app 标准值) |
| 归因数据落点 | 新增后端接口 + 同步埋点(详见 § 3.5、§ 8 |
| 未登录用户处理 | **禁止分享**:弹窗上"保存图片"和 5 个分享按钮全部禁用,顶部显示"登录后才能分享"提示并提供登录入口(详见 § 3.4 |
## 2. 架构与组件拆分
@ -52,22 +61,246 @@
## 3. 二维码后端接口契约
`ShareReportButtons.vue:75` 当前用占位图,本设计要求后端出真实二维码。
`ShareReportButtons.vue:75` 当前用占位图,本设计要求后端出真实二维码。**二维码内容必须编码分享人信息和分享端**,用于二级传播追溯(接收端扫码后能从 URL 参数识别"来自谁"和"从哪个平台")。
| 项 | 值 |
|----|----|
| Method & Path | `GET /api/v1/share/asset-qrcode/:assetId` |
| Query 参数 | 无 |
| Query 参数 | `sharer_user_id`**必填**,登录用户 ID与 § 3.4 登录门槛联动,未登录前端不调此接口) |
| | `system_type`**必填**,系统/平台类型,决定分享来源端的运行环境,值见下表) |
| | `share_target`(可选,候选值 `weixin_friend` / `weixin_moment` / `qq` / `qq_zone` / `sinaweibo` / `save_image`;前端弹窗打开时已知要分享的目标,但本期**不传**,默认所有二维码按 `weixin_friend` 编码,后续按需扩展) |
| 鉴权 | 需要登录态 |
#### `system_type` 取值(分享来源端的系统类型)
> 该字段标识**用户发起分享时所在的客户端运行环境**,用于二级归因中区分渠道。值取自 `uni.getSystemInfo()`**按需扩展**,后端字段类型 `varchar(32)` 不强制 enum。
| 取值 | 含义 | uni-app 对应 | 优先级 |
|------|------|--------------|--------|
| **`android`** | Android 原生 Appapp-plus 打包) | `platform === 'android'` | 高 |
| **`ios`** | iOS 原生 Appapp-plus 打包) | `platform === 'ios'` | 高 |
| **`mp-weixin`** | 微信小程序 | `platform === 'mp-weixin'` | 高 |
| **`mp-alipay`** | 支付宝小程序 | `platform === 'mp-alipay'` | 中 |
| **`mp-baidu`** | 百度小程序 | `platform === 'mp-baidu'` | 中 |
| **`mp-toutiao`** | 字节跳动小程序(抖音/头条) | `platform === 'mp-toutiao'` | 中 |
| **`mp-lark`** | 飞书小程序 | `platform === 'mp-lark'` | 低 |
| **`mp-qq`** | QQ 小程序 | `platform === 'mp-qq'` | 中 |
| **`mp-kuaishou`** | 快手小程序 | `platform === 'mp-kuaishou'` | 低 |
| **`mp-xhs`** | 小红书小程序 | `platform === 'mp-xhs'` | 低 |
| **`h5`** | 移动端 H5 页面含普通浏览器、WebView 内嵌页) | `platform === 'h5'` | 中 |
| **`app-plus`** | uni-app app-plus 通用兜底Android/iOS 未能识别时) | `platform === 'app-plus'` | 兜底 |
| **`other`** | 上述都未覆盖的新平台/调试场景 | 兜底 | 兜底 |
> **关键说明**
> - 本期产品**实际覆盖**只有 `android` / `ios` / `mp-weixin`(其中 mp-weixin 待后续小程序上线后启用,见 § 4.4)。其他值仅在 schema 层预留
> - `android``ios` 由当前 app-plus 客户端上报;`mp-weixin` 待 § 4.4 接入小程序时启用
> - `system_type``share_target` 是**正交**维度:前者是"分享来源端"(客户端类型),后者是"分享目标渠道"(微信好友/QQ/微博等)。例如「在 Android 上分享到微信好友」= `system_type=android, share_target=weixin_friend」
| 响应 | `200 { qrcode_url: string, expires_at: number }`Unix 秒,签发后 7 天过期) |
| 失败 | `404`asset 不存在)、`5xx`(降级) |
| 二维码内容 | `https://h5.topfans.com/asset/{assetId}`h5 落地页,后续小程序上线后替换为短链) |
| 缓存 | 同一 assetId 会话内只请求一次(前端 `ref` 缓存) |
| 失败 | `400`(缺 `sharer_user_id` / `system_type`)、`404`asset 不存在)、`5xx`(降级) |
| 二维码内容 | `https://h5.topfans.com/asset/{assetId}?from={sharer_user_id}&s={system_type}` |
| | - `from`:分享人用户 ID短参数名 `from`,节省扫码密度) |
| | - `s`:分享来源端的系统类型(短参数名 `s`;值为 `android` / `ios` / `mp-weixin` 等,见 § 3 枚举表) |
| | h5 落地页解析这两个参数后:
| | 1. 展示资产详情
| | 2. 调埋点 `qr_scan_landing`(带 `from` / `s` / `asset_id`
| | 3. 若用户未登录 + 跳登录页,登录成功回跳后**预填**邀请码 `from` 到注册请求中(**本期不做**,仅预留 URL 参数) |
| 缓存 | 同一 `(assetId, sharer_user_id, system_type)` 三元组会话内只请求一次(前端 `ref` 缓存)。三元组任一变更 → 重新请求 |
| 续签策略 | `expires_at - now < 24h` 时下次开弹窗**预热**请求(不阻塞 UI。过期不阻断分享弹窗打开仍能用缓存的 URL |
| 失败兜底 | 弹窗 UI 用占位图toast 提示"二维码生成失败,分享图可能无水印" |
**与 image-compositor 协作**`composeShareImage` 接收一个**已下载到本地的** `qrcodeLocalPath`(经过 `uni.downloadFile`),不再关心来源是后端还是占位图。
### 3.3 二维码请求示例
**前端调用**`useShare` 内部):
```js
async function fetchQrcode(assetId) {
// 1. 取登录用户信息(同步 OKstorage 读取不阻塞 UI
const userInfo = JSON.parse(uni.getStorageSync('user') || '{}');
// 2. 取系统类型(异步,避免阻塞 UI 线程)
const { platform } = await uni.getSystemInfo();
// 3. 调后端二维码接口
const res = await uni.request({
url: `/api/v1/share/asset-qrcode/${assetId}`,
method: 'GET',
data: {
sharer_user_id: userInfo.id,
system_type: platform
}
});
return res.data; // { qrcode_url, expires_at }
}
```
> **为什么用 `uni.getSystemInfo()` 而不是 `uni.getSystemInfoSync()`**
> - `getSystemInfoSync` 在低端机上可能阻塞 UI 主线程数十毫秒(特别是冷启动首次调用时)
> - `getSystemInfo` 是异步 Promise API不阻塞 UI现代 uni-app 最佳实践推荐异步版本
> - 当前业务场景(弹窗打开时调用)用户对延迟敏感,应优先用异步版本
**后端生成逻辑**(伪代码):
```go
func (h *Handler) GetAssetQrcode(c *gin.Context) {
assetID := c.Param("assetId")
sharerUserID := c.Query("sharer_user_id") // 必填,未填返 400
systemType := c.Query("system_type") // 必填,未填返 400
// 1. 校验资产存在
// 2. 拼接目标 URLhttps://h5.topfans.com/asset/{assetId}?from={sharerUserID}&s={systemType}
// 3. 用 qrcode 库生成 PNG转 CDN URL
// 4. 写缓存 share:qrcode:{assetId}:{sharerUserID}:{systemType} → qrcode_urlTTL 7d
// 5. 返回 { qrcode_url, expires_at }
}
```
**后端缓存键设计**
- Redis key 格式:`share:qrcode:{asset_id}:{sharer_user_id}:{system_type}`
- TTL7 天(与 `expires_at` 一致)
- 命中后直接返回缓存的 `qrcode_url`,不重新生成
- 不同 sharer 分享同一个 asset → 生成不同二维码(因为 `from` 参数不同)
> **后端可选优化YAGNI本期不做**:用短链服务把 `https://h5.topfans.com/asset/12345?from=678&s=android` 缩短为 `https://t.fans/a8K2` 之类的短链,进一步降低二维码密度。本期直接用完整 URL落地页跳转用 query 参数解析。
### 3.4 未登录用户禁止分享(登录门槛)
**结论**:用户必须登录后才能触发任何分享动作(保存图片 + 5 个分享按钮)。这是产品侧对 2026-06-16 反馈的最终决策:**只登录用户才能分享**,不做匿名 / 临时分享码兜底。
**为什么不做匿名分享**
- 归因数据质量:匿名用户无法追溯到具体账号,渠道归因统计价值低
- 业务诉求:分享行为本身是用户主动传播行为,登录门槛可筛掉机器人/脚本刷量
- 隐私更简单:无需设计 `share_code` 生成 / 存储 / 清理机制
**前端处理**
| 触发点 | 检查项 | 未登录时行为 |
|--------|--------|------------|
| 用户点击"分享"按钮(`ShareReportButtons.vue:handleShare` | `uni.getStorageSync('user')` 是否存在且能解析出有效 `user_id` | 不打开弹窗,弹 toast「请先登录」并跳转到登录页沿用项目现有登录跳转逻辑 |
| 直接渲染 `<ShareModal>`(其他调用方未来可能直接传 `visible=true` | `useShare` 初始化时检查登录态 | 不渲染 6 个按钮,弹窗内显示"登录后才能分享"提示 + 「去登录」按钮 |
**未登录态弹窗 UI**
```
┌────────────────────────────────────┐
│ ✕ 分享藏品 │
├────────────────────────────────────┤
│ │
│ [图片预览区域 - 仍然渲染] │
│ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 🔒 登录后才能分享 │ │
│ │ │ │
│ │ [ 去登录 ] [ 再看看 ] │ │
│ └──────────────────────────────┘ │
│ │
└────────────────────────────────────┘
```
- 保留图片预览(让用户看到要分享什么)
- 6 个分享按钮**完全不渲染**(不是 disabled而是 v-if 移除,避免 DOM 探嗅)
- 「去登录」点击后 emit('close') + 跳转登录页
- 「再看看」点击后 emit('close')
**后端校验**:即便前端做了拦截,`POST /share/track` 也必须做服务端二次校验:
```go
// share track handler
// 注TrackShareRequest 中 SharerUserId 定义为 int64值类型非指针
func (h *Handler) TrackShare(ctx context.Context, req *TrackShareRequest) (*TrackShareResponse, error) {
// 二次拦截:从 JWT token / context 取当前用户 ID 与请求中传入的 SharerUserId 比对
currentUserID := auth.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, codex.NewError(codex.ErrUnauthorized, "请先登录后再分享")
}
if req.SharerUserId != currentUserID {
// 防止越权:前端篡改 user_id 上报他人
return nil, codex.NewError(codex.ErrForbidden, "分享人 ID 与当前登录用户不一致")
}
// ... 后续落库逻辑
}
```
- 缺 `sharer_user_id` 直接返回 `ErrUnauthorized`(业务码 +1xxxxx
- 不依赖前端拦截,前端漏洞不污染数据库
- 详见 § 3.5 接口契约(`sharer_user_id` 改为必填)
### 3.5 分享归因追踪接口(新增)
每次分享动作(无论成功 / 失败 / 取消)需调用后端接口记录**分享人**和**分享端**,用于渠道归因、二级传播追溯。**调用前必须已登录**(见 § 3.4)。
| 项 | 值 |
|----|----|
| Method & Path | `POST /api/v1/share/track` |
| Content-Type | `application/json` |
| 鉴权 | **强制**(缺 `sharer_user_id` 直接 401 |
| 请求体 | 见下方 Request Body |
| 响应 | `200 { topfans.common.BaseResponse base = 1; int64 share_event_id = 2; }``share_event_id` 为后端落库的事件 ID |
| 失败 | `401`(未登录)、`4xx`(参数错误,**前端不重试**/ `5xx`(前端**静默失败**,不阻断分享流程,不弹 toast |
#### Request Body
```jsonc
{
"asset_id": 12345, // 必填,被分享的资产 ID
"sharer_user_id": 678, // 必填,已登录用户 ID前端从 storage 取)
"system_type": "android" | "ios" | "mp-weixin" | "mp-alipay" | ..., // 必填uni.getSystemInfo()
"share_target": "weixin_friend" | "weixin_moment" | "qq" | "qq_zone" | "sinaweibo" | "save_image", // 必填,本次分享的目标渠道
"result": "success" | "cancel" | "fail_app_missing" | "fail_network" | "fail_canvas" | "fail_other", // 必填,分享结果
"client_ts": 1718500000000, // 必填,客户端时间戳(毫秒)
"extra": { // 可选,扩展字段
"app_version": "1.0.0",
"os_version": "Android 13",
"device_id": "..." // 用于风控的去标识化设备指纹
}
}
```
#### 字段补充说明
- **`sharer_user_id`****必填**,从 `uni.getStorageSync('user').id` 取。后端校验该用户存在且状态正常(未注销/封禁),否则返回 401
- **`system_type` 取值**:直接传 `uni.getSystemInfo()` 的值。uni-app 标准枚举包括 `android` / `ios` / `h5` / `mp-weixin` / `mp-alipay` / `mp-baidu` / `mp-toutiao` / `mp-lark` / `mp-qq` / `mp-kuaishou` / `mp-xhs` / `app-plus`。后端字段类型用 `varchar(32)`,不强制 enum保留扩展性。**完整取值表见 § 3** |
- **写库 vs 异步队列**:本期**直接同步写库**`share_events` 表)。如果未来 QPS 上升导致 DB 压力,可改为异步 MQ本期不做
- **隐私**:已登录用户场景下不存在 PII 问题(`user_id` 本身就是业务标识GDPR 合规要求下后端仍可保留 90 天滚动清理机制(**本期不做**,由运营/法务后续提需求)
#### 数据库表设计
```sql
-- 分享事件追踪表(落库 share_events
CREATE TABLE IF NOT EXISTS share_events (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL, -- 被分享的资产
sharer_user_id BIGINT NOT NULL, -- 分享人用户 ID强制非空见 § 3.4
system_type VARCHAR(32) NOT NULL, -- android / ios / mp-weixin / h5 ...(见 § 3 枚举表)
share_target VARCHAR(32) NOT NULL, -- weixin_friend / qq / sinaweibo / save_image
result VARCHAR(32) NOT NULL, -- success / cancel / fail_*
client_ts BIGINT NOT NULL, -- 客户端时间戳(毫秒)
server_ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
extra JSONB DEFAULT '{}'::jsonb, -- 扩展信息app_version / os_version / device_id
CONSTRAINT fk_share_events_sharer FOREIGN KEY (sharer_user_id) REFERENCES users(id) ON DELETE RESTRICT
);
CREATE INDEX idx_share_events_asset_id ON share_events (asset_id);
CREATE INDEX idx_share_events_sharer ON share_events (sharer_user_id);
CREATE INDEX idx_share_events_system_type ON share_events (system_type);
CREATE INDEX idx_share_events_server_ts ON share_events (server_ts DESC);
-- 序列起始值预留(按 CLAUDE.md 数据库规范)
CREATE SEQUENCE share_events_id_seq START WITH 10000;
```
> **设计变化**(对比初版):移除 `share_code` 列与相关约束/索引;`sharer_user_id` 改为 `NOT NULL`;新增 `FOREIGN KEY` 约束确保分享事件始终关联有效用户。
#### 客户端调用流程
- **触发时机**`uni.share` 回调success / fail`uni.saveImageToPhotosAlbum` 回调后
- **前置校验**`useShare` 内部 `pick()` 时先确认 `currentUserId` 非空(前端 § 3.4 二次拦截,避免误上报)
- **失败兜底**:接口调用失败时**不弹 toast**、**不重试**、**不阻断**当前分享流程。最多在 console.warn 输出一行
- **批量上报****YAGNI本期不做**理论上可以攒批上报10s 一次),本期先按"每次分享 = 一次 HTTP 请求"实现。后续如果出现 QPS 瓶颈再优化
- **节流保护**:单用户 1s 内最多上报 5 次(前端 `useShare` 内部维护计数器 + setTimeout 节流),防止异常情况下被刷
## 4. `manifest.json` 配置变更
### 4.1 启用 Share 模块与 URL Scheme
@ -281,8 +514,7 @@
// qrcodeLocalPath: string 本地二维码临时文件路径(缺失时回退到 /static/share/mock_qrcode.png
// avatarLocalPath: string 本地头像临时文件路径(缺失时回退到 /static/square/gerenzhongxinkangpinkuang.png
// nickname: string 用户昵称(缺首字母时回退到 'T'
// brandLine: string? 默认 "TOPFANS让热爱被发现"
// brandDesc: string? 默认 "AI做周边应援全免费"
// slogan: { brandLine: string, brandDesc: string }? 默认由 useShare 从 BRAND_SLOGANS 随机抽取一条(见 § 5.3.2
//
// 返回 Promiseresolve 后得到 { tempFilePath, width, height }
// tempFilePath: 本地临时文件路径,可直接传给 uni.share / uni.saveImageToPhotosAlbum
@ -300,7 +532,8 @@ export function composeShareImage(opts) { /* ... */ }
| L1 | 750×1000 封面 | 顶部 0~1000 | ❌ | `coverLocalPath` `aspectFill` 缩放 |
| L2 | 头像圆 (240×240 px) + "ID: {nickname}" 文字 | y=720~960 | ✅(在 L1 上叠加) | `avatarLocalPath` + `nickname` |
| L3 | 二维码 (240×240 px) + 边框背景 | y=960~1200 | ✅(在 L1 上叠加) | `qrcodeLocalPath` |
| L4 | "TOPFANS让热爱被发现" + "AI做周边应援全免费" | y=1200~1334 | ✅(在 L0 上叠加) | 常量文案 |
| L4 | `{slogan.brandLine}` + `{slogan.brandDesc}` | y=1200~1334 | ✅(在 L0 上叠加) | `slogan` 入参(由 useShare 从 `BRAND_SLOGANS` 随机抽取,见 § 5.3.2 |
> **覆盖关系关键点**L2 / L3 都在 L1封面之上叠加绘制海报布局L4 在 L0 之上叠加。L1 顶部 0~1000 区域不被 L2 遮挡的部分y=0~720即为封面顶部净空。L3 底部 1200 处开始让位给 L4。
>
@ -314,8 +547,13 @@ export function composeShareImage(opts) { /* ... */ }
- **首次 pick**`useShare` 内部 `uni.downloadFile` 把 3 个 URL 转 local path然后**用 local path** 算 `composeKey`(确保 nickname 变化 / 头像变化 / 远端图被替换都能触发重合成)
- **后续 pick**(弹窗内切换平台)复用缓存的 local path`uni.downloadFile` 结果本身可缓存),不再重复下载
- propsURL变化时 `watch` 触发 **缓存失效 + 清除 lastLocalPaths**,下次 pick 重新 download
- **品牌文案初始化**(新增):
- `useShare` 顶层初始化时调一次 `pickRandomSlogan()`(见 § 5.3.2),结果缓存为闭包内常量 `currentSlogan`
- 整个弹窗会话内 `currentSlogan` 不变(避免同一弹窗切换平台时文案跳变)
- 弹窗关闭后下次再开 → 重新随机抽一条(用户每次打开看到不同文案的概率高,增加新鲜感)
- `composeShareImage(opts)` 调用时把 `slogan: currentSlogan` 传入,**L4 层渲染时使用**
- **两级缓存****仅进程内有效**,不做持久化):
- **L1 内存缓存**`useShare` 闭包内 `Map`key = `composeKey`(用 local path 算value = `tempFilePath`。弹窗打开期间切换平台直接命中。
- **L1 内存缓存**`useShare` 闭包内 `Map`key = `composeKey`(用 local path + slogan ,见 § 5.4value = `tempFilePath`。弹窗打开期间切换平台直接命中。
- **L2 模块级缓存**`image-compositor.js` 顶层 `Map`):跨 `useShare` 实例(同一进程内 `ShareModal` 多次 mount / 多个组件同时使用)共享。**最多保留 20 条**LRU 淘汰。
- **不持久化原因**`uni.saveImageToPhotosAlbum` / `uni.share` 使用的本地临时文件路径在 app 进程结束后失效,写入 `uni.storage` 在下次启动时拿到也是死路径。`uni.storage` 只适合存**元数据**(如 composeKey → 远端 URL不适合存**文件路径**。
- **查询顺序**L1 → L2 → composeShareImage
@ -326,6 +564,43 @@ export function composeShareImage(opts) { /* ... */ }
- **app 回到前台兜底**`App.onShow` 触发时若 `state` 仍为 `sharing`(说明回调一直没来),启动 8s 兜底定时器,到时强制重置(避免用户回到 app 后 state 永远卡在 sharing
- **多组件复用**`ShareReportButtons.vue`(当前调用方)未来可继续用同一 composable新增"长按图片直接分享"场景时再开一个 composable 实例即可。
### 5.3.2 品牌文案随机选取
L4 文案从候选池中**每个会话随机抽取一条**(不是每次合成重抽),避免同一弹窗打开期间切换平台看到不同文案的诡异体验。
**数据源**(新增文件):
```js
// frontend/utils/brand-slogans.js (新增)
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: '你的应援值得被收藏' }
// ... 共 8~12 条,运营/设计后续可调整
];
export function pickRandomSlogan() {
return BRAND_SLOGANS[Math.floor(Math.random() * BRAND_SLOGANS.length)];
}
```
**useShare 集成**(在 § 5.3.1 初始化约定中增加):
- `useShare``<script setup>` 顶层(首次挂载时)调用 `pickRandomSlogan()` 一次,缓存为闭包内变量 `currentSlogan`
- 整个弹窗会话期间 `currentSlogan` 不变(**不论切换多少次平台都复用同一条**
- 弹窗关闭后下次再开 → 重新随机抽一条(用户感知到"每次打开文案可能不一样",增加新鲜感)
- `composeShareImage` 调用时把 `currentSlogan` 作为 `slogan` 字段传入
**缓存键影响**`composeKey` 计算时必须把 `slogan` 也算进去(不同文案 → 不同合成图)。具体更新见 § 5.4。
**手动换一句****YAGNI本期不做**):弹窗底部未来可加"换一句文案"按钮,调用 `pickRandomSlogan()` 重抽并触发 `composeShareImage` 重新合成。本期不实现,文案每会话稳定即可。
### 5.4 跨分享复用合成图
`useShare` 内部维护**两级进程内缓存**(实现细节见 § 5.3.1
@ -342,7 +617,7 @@ if (l1Cache.has(composeKey) || l2ModuleCache.has(composeKey)) {
> **不再使用 `uni.storage` 持久化**(理由见 § 5.3.1:临时文件路径进程结束后失效)。
`composeKey``coverLocalPath + qrcodeLocalPath + avatarLocalPath + nickname` 做 hash`useShare` 内部 download 后的 local path详见 § 5.3.1**任一变更则重合成**(含用户换头像场景)。
`composeKey``coverLocalPath + qrcodeLocalPath + avatarLocalPath + nickname + slogan.brandLine + slogan.brandDesc` 做 hash`useShare` 内部 download 后的 local path + 当前会话的随机 slogan,详见 § 5.3.1 / § 5.3.2**任一变更则重合成**(含用户换头像场景、含 slogan 重新随机场景)。
### 5.5 错误码映射
@ -360,11 +635,66 @@ if (l1Cache.has(composeKey) || l2ModuleCache.has(composeKey)) {
function isUserCancel(errMsg = '') {
// iOS: "user cancel" / "cancel"(英文)
// Android: "用户取消" / "取消"(中文,部分 SDK 报英文 "cancel"
// 注h5 端 navigator.share 报 "AbortError",但本 spec 不做 h5§ 14),故不匹配
// 注h5 端 navigator.share 报 "AbortError",但本 spec 不做 h5§ 13),故不匹配
return /cancel|取消/i.test(errMsg);
}
```
### 5.6 归因追踪调用流程
分享动作结束success / fail / cancel 任一回调触发)后,**调用追踪接口**记录分享人 + 分享端:
```
用户 ShareActionBar useShare 后端追踪接口
│ │ │ │
│ 选完平台 │ │ │
│ uni.share │ │ │
│ 回调触发 │ │ │
│ │ │ │
│ │ │ 准备 payload │
│ │ │ - sharer_user_id (从 storage 取,登录态校验)
│ │ │ - system_type (uni.getSystemInfo())
│ │ │ - share_target (本次选的平台)
│ │ │ - result (success / cancel / fail_*)
│ │ │ - asset_id
│ │ │ - client_ts (Date.now())
│ │ │ │
│ │ │ POST /share/track
│ │ │────────────────►│
│ │ │ │ 校验登录态 + 写库 share_events
│ │ │◄────────────────│
│ │ │ { share_event_id } | 401 Unauthorized
│ │ │ (失败静默) │
```
**关键约束**
- 追踪接口调用**不影响**弹窗关闭、toast 显示等用户感知行为。`uni.share` 回调触发后**先**走弹窗关闭 / toast 流程,**再** fire-and-forget 调追踪接口await 不阻塞 UI
- 取消分享(`cancel`**也**要上报(用于统计「分享被取消率」),仅 `result: 'cancel'`
- 失败同样上报(用于渠道失败率归因),按 § 5.5 错误码映射填 `result`
- **保存图片** 走同一接口,`share_target: 'save_image'`
- **`sharer_user_id` 取值**:从 `uni.getStorageSync('user').id` 取(项目现有登录逻辑已写 storage。`useShare` 内部在 `pick()` 入口再做一次非空校验(防 § 3.4 拦截被绕过)
**登录态校验示例代码**(位于 `useShare` 内部):
```js
function pick(action) {
// 前端二次拦截:未登录不进入分享流程(兜底 § 3.4
const userStr = uni.getStorageSync('user');
if (!userStr) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 800);
return;
}
const userInfo = JSON.parse(userStr);
if (!userInfo?.id) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
// ... 后续 composing/sharing 流程
}
```
## 6. 图标资源
当前 `ShareModal.vue` 用了错误的占位图(`wenhao.png`、`shangxiahuadong.png`),新增以下资源到 `frontend/static/share/`
@ -402,34 +732,63 @@ function isUserCancel(errMsg = '') {
## 8. 监控埋点
接入项目现有的 `uniStatistics`manifest 已 `enable: true`)。**保存图片走独立事件** `save_image_*`,不与分享事件混报(语义不同):
接入项目现有的 `uniStatistics`manifest 已 `enable: true`)。**保存图片走独立事件** `save_image_*`,不与分享事件混报(语义不同)。**所有事件均带上分享人(`user_id`)和分享来源端系统类型(`system_type`)字段**(已登录用户才有分享事件,见 § 3.4
```js
// useShare composable 初始化时一次性取到 systemType后续所有事件复用避免在事件回调里再 await
const { platform: systemType } = await uni.getSystemInfo();
// 5 个分享平台共用
uni.report('share_action_click', {
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
user_id: currentUserId
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo', // 仅 5 个 share targetsave_image 走独立事件
user_id: currentUserId, // 已登录用户 ID必填
system_type: systemType // 预解析值:'android' / 'ios' / 'mp-weixin' / 'h5' 等
});
uni.report('share_result', {
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
result: 'success' | 'cancel' | 'fail_app_missing' | 'fail_network' | 'fail_canvas' | 'fail_other',
duration_ms: elapsed
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
user_id: currentUserId,
system_type: systemType,
result: 'success' | 'cancel' | 'fail_app_missing' | 'fail_network' | 'fail_canvas' | 'fail_other',
duration_ms: elapsed
});
// 保存图片独立事件
uni.report('save_image_click', { asset_id: assetId, user_id: currentUserId });
// 保存图片独立事件target 字段在 save_image_* 中省略,事件名本身已区分)
uni.report('save_image_click', {
asset_id: assetId,
user_id: currentUserId,
system_type: systemType
});
// duration_ms: 从调用 uni.saveImageToPhotosAlbum 到回调止
uni.report('save_image_result', {
asset_id: assetId,
result: 'success' | 'fail_permission' | 'fail_already_saved' | 'fail_other',
duration_ms: elapsed
asset_id: assetId,
user_id: currentUserId,
system_type: systemType,
result: 'success' | 'fail_permission' | 'fail_already_saved' | 'fail_other',
duration_ms: elapsed
});
// 追踪接口调用结果(可选埋点:用于监控 share_events 落库失败率)
uni.report('share_track_result', {
share_event_id: shareEventId, // 后端返回的事件 ID
http_status: 200 | 401 | 4xx | 5xx,
duration_ms: elapsed
});
```
通过这两条事件可计算:分享 CTR、各平台失败率、canvas 成功率iOS 黑底问题占比)。
> **`target` 字段约定**`share_action_click` / `share_result``target` 字段**仅包含 5 个 share target**`weixin_friend` / `weixin_moment` / `qq` / `qq_zone` / `sinaweibo`)。保存图片行为通过独立事件 `save_image_*` 区分,不在 `target` 枚举里。
>
> **`system_type` 取值时机**:在 `useShare` 初始化时一次性 `await uni.getSystemInfo()` 拿到 `systemType`,所有事件复用同一个值。不要在事件回调里再 await避免每次都触发异步调用且字段值为 Promise 对象)。
通过这些事件可计算:
- 分享 CTR`share_action_click` / 弹窗打开次数)
- 各平台失败率(`share_result.result != 'success'` 占比)
- canvas 成功率iOS 黑底问题占比)
- **分享渠道分布**(按 `system_type` 分组iOS 用户占比、Android 用户占比、微信小程序用户占比)
- **人均分享次数**(按 `user_id` 分组聚合)
- **追踪接口健康度**`share_track_result` 失败率)
## 9. iOS 平台特定坑
@ -440,22 +799,9 @@ uni.report('save_image_result', {
| canvas 离屏黑底 | 透明 PNG 合成 | 先 fillRect 白底再 draw |
| iOS 剪贴板读取需用户授权 | 「复制链接」降级 | `uni.setClipboardData` 写入不需要用户授权uni-app 已封装),但 iOS 14+ **读取** 剪贴板会弹「允许粘贴」系统弹窗。**写场景不受影响**,本设计只是写剪贴板,无需额外处理 |
## 10. 灰度发布
## 10. 测试
简化为两级(弹窗升级不需要 5%/20% 这么细的灰度):
| 阶段 | 范围 | 验收指标 | 时长 |
|------|------|---------|------|
| **内测** | 仅 `versionCode >= 110` 内测包 | 5 平台各分享 5 次无崩溃 + 手动 checklist 全过 | 1~2 天 |
| **全量** | 100% 灰度发布 | `share_result` 失败率fail_* 占比)较旧版本不上升 > 0.5pp | 持续观察 7 天 |
**回滚开关**`useShare` 内部读取 `uni.getStorageSync('feature_share_redesign')`(默认 `true`),开关为 `false` 时回退到原 2 按钮逻辑,无需发版即可关停。
> **回退实现方式**(避免双倍代码负担):`ShareModal.vue` 顶层根据开关值 `computed` 决定渲染新版 6 按钮 `<ShareActionBar>` 还是旧版 2 按钮内联模板。旧版 2 按钮逻辑**仅保留内联模板 + 极简函数**< 30 不抽组件开关一关新代码 100% dead codeVite tree-shaking + 死代码删除)。
## 11. 测试
### 11.0 前置:接入测试框架
### 10.0 前置:接入测试框架
**项目当前 `frontend/package.json` 只有 `vuex` 一个依赖,无任何测试框架**。**已确认项目构建工具是 Vite**`frontend/vite.config.js` 存在,未发现 webpack/vue-cli 痕迹。Vitest 与 Vite 原生兼容共享配置、resolve.alias、transform 流水线),无需额外胶水代码。
@ -466,9 +812,9 @@ uni.report('save_image_result', {
3. `frontend/package.json` 增加脚本:`"test:unit": "vitest run"`、`"test:watch": "vitest"`
4. 在 `frontend/.gitignore` 里加 `coverage/` 排除覆盖率产物
预计工作量 0.5 人天。**没这一步 § 11.1 全部不成立**。
预计工作量 0.5 人天。**没这一步 § 10.1 全部不成立**。
### 11.1 测试分层
### 10.1 测试分层
| 层 | 工具 | 覆盖 |
|----|------|------|
@ -476,7 +822,7 @@ uni.report('save_image_result', {
| 单元 | Vitest | `useShare.js` 的状态机分支mock `uni.share`/`uni.downloadFile` |
| 组件 | @vue/test-utils | `ShareActionBar.vue` 渲染 6 个按钮、点击抛 `onPick` |
| E2E | uni-app 自动化 (`automator`) | iOS 真机 / Android 真机 5 平台分享(后续迭代,本期不做) |
| 手动 | 验收 checklist | 见 § 11.3 |
| 手动 | 验收 checklist | 见 § 10.3 |
**Vitest 注入 `uni` 全局 stub**(解决 happy-dom/jsdom 不带 uni 的问题):
@ -500,7 +846,7 @@ if (!globalThis.uni) {
> 实施时如发现 `globalThis.uni` 被 Vite 编译时静态分析剔除uni-app 编译时会把 `uni.xxx` 替换成平台代码),需要改用 `vi.stubGlobal('uni', { ... })` 或在 `vitest.config.js``define` 里注入。
### 11.2 关键单元用例
### 10.2 关键单元用例
`image-compositor`
@ -534,10 +880,23 @@ if (!globalThis.uni) {
- 选 weixin_friend → 探测 App 未装 → 抛 L1 错误,不调 `uni.share`
- 选 weixin_friend → 探测已装 → 调 `composeShareImage` → 调 `uni.share`
- `uni.share` 回调 errMsg 含 `cancel` → 静默,不弹 toast
- 同一 `coverLocalPath+qrcodeLocalPath+avatarLocalPath+nickname` 二次调用 → 跳过合成,复用 `lastComposedPath`
- 同一 `coverLocalPath+qrcodeLocalPath+avatarLocalPath+nickname+slogan` 二次调用 → 跳过合成,复用 `lastComposedPath`
- 任意入参变化 → 重新合成(含 `avatarLocalPath` 变化,即用户换头像场景)
- `slogan` 字段变化slogan 重抽) → 重新合成composeKey 失效)
### 11.3 手动验收 Checklist
`brand-slogans`(独立测试组):
- `pickRandomSlogan()` 返回值必须是 `BRAND_SLOGANS` 数组中的某个元素(不能返回未定义值)
- `pickRandomSlogan()` 连续调用 1000 次 → 至少覆盖 6 条不同 slogan验证随机性候选池 ≥ 8 条时的概率统计)
- `BRAND_SLOGANS` 数组所有元素都包含 `brandLine``brandDesc` 两个字段schema 校验)
`composeShareImage` 文案分支:
- `opts.slogan` 提供 → L4 层渲染 `slogan.brandLine` + `slogan.brandDesc`
- `opts.slogan` 缺失 → 回退默认文案 `'TOPFANS让热爱被发现'` + `'AI做周边应援全免费'`(不抛错)
- `opts.slogan.brandLine` 为空字符串 → 跳过 brandLine 行只渲染 brandDesc容错
### 10.3 手动验收 Checklist
**功能**
@ -569,28 +928,28 @@ if (!globalThis.uni) {
- 弹窗从打开到 6 个按钮可点击 ≤ 500ms中端机如 iPhone X / 小米 8 实测基线;低端机不保证)
- 同一分享图二次调用合成耗时 ≤ 100msL1 内存缓存命中路径L2 模块级缓存命中再 +50ms差值主要来自 Map 查找)
## 12. Definition of Done
## 11. Definition of Done
- 上述手动 checklist 全部 ✅
- 单元测试 100% 通过,`image-compositor` 行覆盖 100%(与 § 11.1 一致)
- 单元测试 100% 通过,`image-compositor` 行覆盖 100%(与 § 10.1 一致)
- iPhone XiOS 16+)和小米 12Android 13真机各 5 次分享无崩溃
- 全量发布后 24 小时 L1+L2 错误率较内测不上升 > 0.3pp
- 上线后 24 小时 L1+L2 错误率不上升 > 0.3pp(基线对照:发布前 7 天均值)
- `manifest.json` 占位 `<TBD>` 全部替换为真实凭证
## 13. 待用户/后端提供的信息
## 12. 待用户/后端提供的信息
- [ ] 微信开放平台移动应用 AppID§ 13.2.1
- [ ] QQ 互联移动应用 AppID§ 13.2.2
- [ ] 微博开放平台 AppID§ 13.2.3
- [ ] 微信开放平台移动应用 AppID§ 12.2.1
- [ ] QQ 互联移动应用 AppID§ 12.2.2
- [ ] 微博开放平台 AppID§ 12.2.3
- [ ] iOS Bundle ID
- [ ] Android 应用签名 SHA1微信/QQ/ MD5微博— **见 § 13.2.4 说明**
- [ ] Android 应用签名 SHA1微信/QQ/ MD5微博— **见 § 12.2.4 说明**
- [ ] 后端 `GET /api/v1/share/asset-qrcode/:assetId` 接口就绪
- [ ] 后端 `POST /api/v1/share/track` 接口就绪(§ 3.5+ `share_events` 表 migration 落地
- [ ] 后端 `/share/track` 实现登录态校验401 路径),与 § 3.4 一致
> **当前线上坏路径**`frontend/pages/components/ShareReportButtons.vue:75` 引用 `/static/icon/qrcode-placeholder.png`,但该文件**在仓库中不存在**(已验证 `frontend/static/icon/` 目录里无此文件)。当前线上代码该路径已坏。本 spec 落地时同步处理:要么创建该占位图,要么改路径到 `/static/share/mock_qrcode.png`(推荐后者,与 § 6 资源目录一致)。
### 13.1 前端 mock 策略(后端接口未就绪时)
### 13.1 前端 mock 策略(后端接口未就绪时)
### 12.1 前端 mock 策略(后端接口未就绪时)
为不阻塞前端开发,前端在 `frontend/utils/share-mock.js` 实现 mock
@ -601,11 +960,11 @@ if (!globalThis.uni) {
预计后端接口到位后删除 `share-mock.js` 即可,不影响生产代码。
### 13.2 各平台 AppID 申请路径
### 12.2 各平台 AppID 申请路径
> ⚠️ **三平台 AppID 申请均需 1~7 个工作日审核**,必须在打包前至少 1 周启动申请。**可并行申请三个平台**以节省时间。
#### 13.2.1 微信开放平台 AppID
#### 12.2.1 微信开放平台 AppID
| 项 | 值 |
|----|----|
@ -626,7 +985,7 @@ if (!globalThis.uni) {
**最终填到 manifest § 4.2**`weixin.appid = "wx..."`
#### 13.2.2 QQ 互联 AppID
#### 12.2.2 QQ 互联 AppID
| 项 | 值 |
|----|----|
@ -647,7 +1006,7 @@ if (!globalThis.uni) {
**最终填到 manifest § 4.2**`qq.appid = "10..."`QQ 互联后台叫 "APP ID",数字格式)
#### 13.2.3 微博开放平台 AppID
#### 12.2.3 微博开放平台 AppID
| 项 | 值 |
|----|----|
@ -670,7 +1029,7 @@ if (!globalThis.uni) {
**最终填到 manifest § 4.2**`sinaweibo.appid = "..."`(微博后台叫 "App Key"**不是 AppID**,按 uni-app 规范统一写 `appid`
#### 13.2.4 签名算法三平台对比
#### 12.2.4 签名算法三平台对比
| 平台 | Android 签名算法 | 取法 |
|------|----------------|------|
@ -680,7 +1039,7 @@ if (!globalThis.uni) {
> ⚠️ **同一个 keystore 同时算出 SHA1 + MD5 即可**,不需要为不同平台准备多个 keystore。
#### 13.2.5 三平台申请前 checklist
#### 12.2.5 三平台申请前 checklist
| 准备项 | 微信 | QQ | 微博 | 备注 |
|--------|------|------|------|------|
@ -691,7 +1050,7 @@ if (!globalThis.uni) {
| 应用图标 (108×108) | ✅ | ✅ | ✅ | 设计师出 |
| 应用截图 | ✅ 5 张 | ✅ 3~5 张 | ✅ 3~5 张 | 提前准备 |
#### 13.2.6 ❌ 常见错路
#### 12.2.6 ❌ 常见错路
| 需求 | 错路 | 正确方向 |
|------|------|---------|
@ -700,7 +1059,7 @@ if (!globalThis.uni) {
| 微博 AppID | 微博广告平台 | **微博开放平台** open.weibo.com |
| 复用现有 AppID | 现有 H5 / 小程序的 AppID 直接用 | **必须**重新申请移动应用 AppID |
## 14. 不做的事YAGNI
## 13. 不做的事YAGNI
- 不做海报编辑/二次裁剪
- 不做"先选平台 → 再选好友"的二级分享面板(直接走 `uni.share` 系统面板)