From d5103f7043cf63847a191561f675ecc1e478f544 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 21 Jul 2026 21:14:22 +0800 Subject: [PATCH] =?UTF-8?q?docs:=E6=96=B0=E5=A2=9E=E5=90=8E=E7=AB=AFbug?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 28 +- docs/backend-audit-2026-07-21.md | 277 +++ .../2026-07-21-backend-remediation-plan.md | 391 +++++ .../plans/2026-07-21-auth-boundary.md | 614 +++++++ .../plans/2026-07-21-config-deploy.md | 557 ++++++ ...2026-07-21-exhibition-hours-idempotency.md | 1022 +++++++++++ ...07-21-exhibition-settlement-idempotency.md | 333 ++++ .../plans/2026-07-21-mint-correctness.md | 1060 ++++++++++++ .../plans/2026-07-21-secrets-remediation.md | 881 ++++++++++ .../plans/2026-07-21-service-stability.md | 1518 +++++++++++++++++ ...6-07-21-daily-task-config-driven-design.md | 263 +++ 11 files changed, 6937 insertions(+), 7 deletions(-) create mode 100644 docs/backend-audit-2026-07-21.md create mode 100644 docs/specs/2026-07-21-backend-remediation-plan.md create mode 100644 docs/superpowers/plans/2026-07-21-auth-boundary.md create mode 100644 docs/superpowers/plans/2026-07-21-config-deploy.md create mode 100644 docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md create mode 100644 docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md create mode 100644 docs/superpowers/plans/2026-07-21-mint-correctness.md create mode 100644 docs/superpowers/plans/2026-07-21-secrets-remediation.md create mode 100644 docs/superpowers/plans/2026-07-21-service-stability.md create mode 100644 docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md diff --git a/CLAUDE.md b/CLAUDE.md index f268467..12bd492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,9 +233,13 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. #### 正确做法(全局自审,强制执行) -每次自审必须**从文档第一节读到最后一节**,按以下步骤: +**核心原则:自审 = 拿"源清单"逐条打勾映射到"产物" + 从头通读全文,两者缺一不可。** +"源清单"是本次工作的输入全集:设计文档的所有章节、**审计报告的每一条 finding**、需求的每一项、被修 bug 的清单。"产物"是你写出的东西。漏项几乎总发生在"没有拿源清单逐条对照"时——只凭印象挑重点写,长尾必掉。 -1. **章节通读清单**:先列出文档所有章节(§1 到 §16),逐一阅读 +每次自审必须执行: + +0. **源清单逐条打勾(最容易漏,必须先做)**:列出输入全集(§1..§N / 每条 finding / 每项需求),对**每一条**标注它在产物里落到哪里(哪个章节 / 批次 / 代码);标不出来的就是漏项。禁止"按最重的挑着写"。适用于任何"A 文档 → B 文档"的转化(审计→修复、需求→实现、PRD→设计)。 +1. **章节通读清单**:从文档第一节读到最后一节,逐一阅读(不跳过"我没动过"的章节) 2. **跨章节引用一致性检查**: - §1.x 描述的事实 → 在 §5.x 实现了吗? - §3 Dify 配置 → §5 后端是否一致? @@ -256,19 +260,29 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. **根因**:我之前的"自审"本质上是"局部自审",只看我改的部分。 +#### 失败案例(2026-07-21 审计→修复文档,漏写近半 P1 + 全部 P2) + +- 把审计报告转成修复方案文档时,按"最重的先写"挑着写,**没有拿审计 §三 P1 表 / §四 P2 逐条打勾** +- 结果:JWT 全局密钥、aiChat 三条、social 三条、mint 随机数、网关双写/链式、事件可靠性、.env 漂移等**近半 P1 与全部 P2 漏进任何批次** +- 是用户追问"为啥感觉少写的"才发现——典型的"只看自己写的产物,不通读源清单逐条对照" + +**根因**:自审时没有把"源清单(审计每条 finding)"逐条打勾映射到产物(修复批次)——即上面「正确做法」步骤 0 未执行。 + #### 自审触发时机 - 写完设计方案文档后 - 完成一轮"修复 X 个 bug"后 +- **把一份文档转成另一份文档后**(审计→修复方案、需求→设计、PRD→任务拆解)——最容易漏源清单 - 实施编码 `go build` 前(最后一次文档校对) #### 自审报告必须包含 -1. **修改的章节**:列出本次修了哪些章节 -2. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区) -3. **跨章节引用一致性**:列出所有发现的不一致 -4. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题 -5. **优先级**:P0/P1/P2 分类 +1. **源清单覆盖率**:源清单共 N 条(章节/finding/需求),产物覆盖了几条、漏了哪几条(列出未映射项,即使结论是"本次不做"也要显式标注去向) +2. **修改的章节**:列出本次修了哪些章节 +3. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区) +4. **跨章节引用一致性**:列出所有发现的不一致 +5. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题 +6. **优先级**:P0/P1/P2 分类 --- diff --git a/docs/backend-audit-2026-07-21.md b/docs/backend-audit-2026-07-21.md new file mode 100644 index 0000000..31209f9 --- /dev/null +++ b/docs/backend-audit-2026-07-21.md @@ -0,0 +1,277 @@ +# 后端全面审查报告(2026-07-21) + +> 审查范围:`gateway`(BFF)+ 11 个 Go 微服务 + 共享 `pkg/` + `proto/` 契约 + docker / k8s / env 配置。 +> 审查方法:5 路并行审计(微服务耦合与数据库边界 / 网关边界与 proto 契约 / 配置与部署 / 共享 pkg 与消息 / 各服务代码正确性与安全),最严重结论已逐条核实(git 追踪状态、跨服务 import、事务边界、端口绑定均经实际验证)。 +> 审查基线 commit:`b7f8f1b724e5`,分支 `feat/uni`。 + +--- + +## 一、结论概述(TL;DR) + +**这是一个典型的「分布式单体」(distributed monolith)**:名义上 12 个服务,实际共享同一个 PostgreSQL、同一套 migration、同一个 `pkg/models`,且服务之间直接 `import` 对方 Go 包。RPC 名存实亡。 + +在此结构问题之上,叠加了三类高危问题: + +1. **生产密钥已提交进 git**(OSS / SMS / OpenAI / Dify)。 +2. **铸造扣费事务内嵌跨服务 gRPC 调用**,可导致扣费成功但无藏品、连接池耗尽。 +3. **多处信任请求体里的 `user_id` / `reporter_id`**,导致越权与刷量。 + +问题按严重度分为 P0(立即处理)/ P1(尽快)/ P2(择机)三级,详见下文。每条均带 `文件:行号` 证据。 + +### 问题分布速览 + +| 维度 | P0 | P1 | P2 | +|------|----|----|----| +| 安全 / 密钥 | 密钥泄露、身份伪造 | 用户枚举、JWT 全局密钥 | PII 日志、错误信息泄露、JWT 不失效、周边密钥兜底 | +| 微服务耦合 / 边界 | 共享库、跨服务 import、共享 model | 契约混合、网关直连 DB、链式调用 | 死代码、列语义复用 | +| 数据一致性 | mint 事务嵌 RPC、双写不一致 | 序列未重置、material_type 无限增长 | — | +| 配置 / 部署 | 端口四层不一致、starbook 孤儿 | 健康探针错配、大二进制入库 | 命名 / 端口小不一致 | +| 消息 / 事件 | — | 消费者重启丢消息、无 DLQ、事件无版本 | 未接线死代码 | + +--- + +## 二、P0 — 必须立即处理 + +### P0-1 生产密钥已提交进 git(已验证 `git ls-files` 追踪中) + +| 文件:行 | 泄露内容 | +|---------|----------| +| `backend/deploy/envs/asset.env:10-11` | 真实阿里云 OSS AccessKey `LTAI5t6QcdJHpYbCPxM8SXYE` + Secret | +| `backend/deploy/envs/user.env:21-22` | 同一套 key 复用为 SMS 密钥 | +| `backend/.env.example:106` | 完整 OpenAI `sk-proj-...` 密钥 | +| `backend/.env.example:124` | 第二个 OpenAI key;Dify key `app-...` | +| `docker/.env.prod:18-19` | 第二套 OSS key | +| `docker/.env.local:21-22` | 同一套 OSS key | +| `backend/deploy/envs/notification.env:22` | 推送厂商 URL(注释明确要求"不要提交") | + +`backend/deploy/envs/*.env` 未被 `.gitignore` 覆盖(`git check-ignore` 返回 exit 1 = 未忽略)。 + +**动作**:立即轮换所有泄露的 OSS/SMS/OpenAI/Dify 密钥;清理 git 历史(`git filter-repo`);将 `deploy/envs/*.env` 加入 `.gitignore`,密钥改由 KMS / 部署时注入。 + +### P0-2 铸造扣费:DB 事务内嵌套跨服务 gRPC + +`backend/services/assetService/service/mint_service.go:309-318` —— `s.db.Transaction(...)` 内部调用 `s.userClient.UpdateCrystalBalance(...)`。 + +- **连接池耗尽**:RPC 期间 DB 连接被 pin 住;userService 变慢会把整个 asset 连接池拖垮。 +- **跨服务无原子性 + 非幂等**(`CreateMintOrder`,同文件 209-516):userService 已扣水晶但本地 commit 失败 → 用户被扣费却无藏品;客户端超时重试时水晶已扣光却报错。缺少 outbox / 幂等 token。 + +**动作**:把 RPC 移出事务;引入幂等键(如 `order_id` 唯一约束 + 状态机幂等判断);跨服务用 saga / outbox 保证最终一致。 + +### P0-3 信任请求体里的身份 → 越权 / 刷量 + +| 文件:行 | 问题 | +|---------|------| +| `backend/services/moderationService/provider/moderation_provider.go:35` | `SubmitReport` 直接透传 `req`,`ReporterId` 由调用方控制 → 冒用他人 id 举报、绕过去重刷量、读取他人举报详情 | +| `backend/services/assetService/provider/asset_provider.go:483` | `CheckAssetLike` 注释"直接使用请求中的用户信息",用 `req.UserId` → 点赞隐私预言机 | +| `backend/services/assetService/service/share_service.go:135-243` | `GetAssetQrcode` 用 `req.SharerUserId` 不校验调用方 → 分享归因伪造 + 无限 OSS 写入 | + +**根因**:5 个服务里 4 个信任 Dubbo attachment 里的 `user_id` 而不再校验(`castlove_config_provider.go:35` 明写"本服务信任 ctx 透传的 user_id"),只有 userService 真正验签,防御纵深断裂。 + +**动作**:所有 provider 统一从 attachment 提取身份并覆盖 `req` 内的身份字段;对内部 RPC 建立可信边界(mTLS / 内部网络 + 签名)。 + +### P0-4 服务间直接 import 对方 Go 包(已验证) + +| 文件:行 | 耦合 | +|---------|------| +| `backend/services/taskService/main.go:27-28` | import `assetService/repository` + `service` | +| `backend/services/assetService/main.go:32` | import `starbookService/repository` | +| `backend/services/starbookService/main.go:20` | 反向 import `assetService/repository`(循环依赖) | +| `backend/gateway/router/router.go:19-20` | import `assetService/repository` + `service` | +| `backend/gateway/controller/asset_controller.go:40` | import `assetService/service` | +| `backend/gateway/controller/moderation_controller.go:11` | import `moderationService/service` | + +**后果**:RPC 名存实亡——改一个服务的内部包,其它服务/网关必须一起重编重部署;一处 panic 连带拖垮调用方。 + +**动作**:跨边界只能走 proto/RPC + DTO;共享的错误码、常量下沉到 `pkg/` 或 proto,禁止 import 兄弟服务的 `repository`/`service`。 + +### P0-5 全部服务共用同一 Postgres + 同一套 migration + 同一 `pkg/models` + +- 10 个服务 `dbName="top-fans"/"topfans"`,全用 `pkg/database` 的全局 `*gorm.DB`,**无数据所有权边界**。 +- 跨域直接读写: + - `backend/services/moderationService/repository/moderation_repository.go:268-302` 直接改 `users`/`assets` + - `backend/services/socialService/repository/social_repository.go:382,476,492,521` 读写 `fan_profiles` + - `backend/services/assetService/repository/share_repository.go:43`、`ranking_repository.go:103+` join `users`/`fan_profiles` + - `backend/services/galleryService/repository/gallery_repository.go:609,621` join `fan_profiles` +- `users`/`fan_profiles` 被 5+ 服务读写;`asset_registry.display_status` 被 gallery 和 asset 两个服务并发写(`gallery_repository.go:395,426,450` vs `asset_repository.go:127,145,160`)。 +- 单一 `backend/migrations/`(22 个文件)承载所有域;`pkg/models`(22 个 model)被 7 个服务 import → 改一个字段要重编 7 个服务。 + +**动作(中长期)**:按域拆库或至少拆 schema + 明确表 owner;跨服务读改为 RPC + service-local DTO;加 lint 禁止在非 owner 服务里对 `models.X{}` 做 GORM 操作。 + +### P0-6 服务地址端口四层不一致 + +`backend/gateway/config/config.go:162-165` 的默认值与各服务实际绑定端口不符: + +| 服务 | 网关默认 | 实际绑定 | +|------|----------|----------| +| gallery | 20004 | **20001**(`galleryService/main.go:36`) | +| activity | 20005 | 20004 | +| starbook | 20007 | 20005 | + +另:`galleryService/main.go:44` taskService 默认写成 20002(实为 20006);`userService/main.go:283` 硬编码 `WithPort(20000)` 使 `-port`/`PORT` 成为死代码。生产靠 env 覆盖侥幸能连,本地跑默认值会连错服务。 + +**动作**:统一端口默认值来源;加一个配置测试断言"网关 Dubbo URL 默认端口 == 对应服务绑定端口"。 + +### P0-7 `starbookService` 孤儿代码却仍在部署链路 + +- 无 `go.mod`、不在 `go.work`(第 1-16 行)。 +- 却仍被引用:`assetService` import(见 P0-4)、`docker-compose.local.yml:231-262` / `docker-compose.prod.yml:365-401` / helm `starbookservice/deployment.yaml` / `dev.sh:24,432,438` / 网关 `config.go:165`。 +- `docker/Dockerfile.services:50` 会 `go build` 它 → 干净环境 CI/helm 构建会失败。 +- 目录里躺着 79MB 已编译二进制;`asset_registry` 表无明确 owner。 + +**动作**:二选一——补 `go.mod` 并加入 `go.work` 正式化,或彻底删除目录 + 所有部署引用 + 把其代码并入 assetService。 + +--- + +## 三、P1 — 尽快处理 + +### 代码正确性 / 安全 + +| 文件:行 | 问题 | +|---------|------| +| `backend/services/userService/service/auth_service.go:161` | bcrypt(cost≈100ms)在事务内执行,`:566` 改密同样 → 注册高峰连接池耗尽 | +| `backend/services/userService/service/auth_service.go:300 vs 315` | Login 用户枚举("用户不存在"与"密码错"错误码不同),且 Login 无限流 | +| `backend/services/aiChatService/provider/ai_chat_provider.go:250` | 保存上下文用请求里的 `personaID` 而非解析后的 `persona.ID`,persona 关联错乱 | +| `backend/services/aiChatService/provider/ai_chat_provider.go:138,141` | Redis 出错静默吞掉(记忆/历史丢失无日志) | +| `backend/services/aiChatService/main.go:190-211` | Dify/LLM provider 启动时二选一,无 fallback / 熔断 / 重试;Dify 挂了即硬故障,且 `provider:188` 原样回传 `err.Error()` | +| `backend/services/assetService/service/asset_like_service.go:267` | `material_type` 用 `CONCAT` 无限追加逗号,永不收敛 | +| `backend/services/socialService/service/friend_service.go:685` | `CheckFriendship` 硬编码 `starID=0`(TODO 未做)→ 结果错误 + 好友关系隐私预言机 | +| `backend/services/socialService/repository/social_repository.go:461` | "随机用户"实为 `OFFSET rand + LIMIT` 取连续段,非随机且可预测 | +| `backend/services/assetService/service/mint_service.go:323` | 保底概率用 `time.Now().UnixNano()%100`,并发同纳秒结果相同,可被脚本操纵 | +| `backend/services/assetService/service/mint_service.go:349` | mockTxHash 输入全可观测,可被预测伪造"已上链" | +| `backend/services/assetService/service/peripheral_service.go:217-319` | `doMint` 限流 TOCTOU:count 检查与 insert 不在同一事务,并发可绕过 | +| `backend/services/socialService/repository/social_repository.go:648,672` | OR 子句括号优先级问题,可能让已删除资产漏进点赞列表(复核:`:903,923` 为 AND 拼接,无此问题) | +| `backend/services/galleryService/service/cleanup_worker.go:137-185` | **【财务·已实证资损】展出收益结算非幂等 → 重复发放**(详见 §七.1) | + +### 微服务耦合 / 边界 + +| 文件:行 | 问题 | +|---------|------| +| `backend/proto/user.proto:393-553` | `UserSocialService` 把公开 user API 与内部 RPC(`UpdateCrystalBalance`/`UpdateAssetsCount`/`AddExhibitionHours`)混在一个契约 | +| `backend/proto/social.proto:367-513` | social 把好友 + 资产点赞 + 用户发现混在一起 | +| (对照正确范式)`backend/proto/task.proto:276-327` | `TaskMobileService`(公开)与 `TaskInternalService`(内部)干净拆分 | +| `backend/gateway/controller/user_controller.go:647-748` | 网关 `DeleteAccount` 自开事务直接改 `users`+`fan_profiles`,绕过 userService | +| `backend/gateway/repository/laser_card_repository.go` / `app_download_repository.go` | 网关持有 DB repo,非纯 BFF | +| `backend/gateway/controller/asset_controller.go:414-436` | 铸造后本地写激光卡与 assetService 无事务串联 → 双写不一致,实例状态卡在 `mining` | +| `backend/gateway/controller/auth_controller.go:69-97` 等 6 处 | Register/Login/Me/Profile 链式调 `GetFanIdentities`(每次拉全量明星目录只为查一个),下游失败但用户已创建,无补偿 | + +### 消息 / JWT / 共享库 + +| 文件:行 | 问题 | +|---------|------| +| `backend/pkg/jwt/jwt.go:18` | 包级可变全局 `jwtSecret`,默认弱值 `"your-secret-key-change-in-production"`,无锁无校验;`SetSecret` 与 `ParseToken` 并发 data race | +| `backend/pkg/mq/streams/adapter.go:177` | consumer 名带 `UnixNano`,重启后新消费者不认领旧 PEL → 消费者重启即永久丢消息 | +| `backend/pkg/mq/streams/adapter.go:212-234` | 失败事件留在 PEL,无 `XAUTOCLAIM` / DLQ / 重试 | +| `backend/pkg/statistic/client.go:53-70` | `TrackEvent` fire-and-forget,失败只 log 不重试 → 统计数据静默漂移 | +| `backend/proto/event.proto:12-20` | 事件无 envelope / version 字段,被 7 服务 import,字段改动即破坏性 | +| producer/consumer 多处 | 队列名(`"gallery"`、`"default"`)硬编码在生产者和消费者两侧,改名易漂移丢消息 | + +### 配置 / 部署 + +| 文件:行 | 问题 | +|---------|------| +| `backend/scripts/create_gallery_test_users.go` | 硬编码 id 但未 `setval` 重置序列,违反 CLAUDE.md 强制规则 → 跑完必报 `duplicate key` | +| `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` | 探针用 `/healthz` 但服务注册 `/health` → 存活探针永远失败 | +| `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50` | 探针打 `/`,K8s 缺 compose 的 `HEALTHCHECK NONE` 覆盖 | +| 顶层大二进制入库 | `backend/assetService`(80MB) / `gateway-fixed`(98MB) / `test`(71MB) / `cleanup-orphan-avatars`(22MB) 均被 git 追踪 | +| `backend/.env` ↔ `.env.example` 漂移 | `WS_AI_CHAT_PATH` 未文档化;`.example` 有一批 `.env` 缺失的必需键 | + +--- + +## 四、P2 — 择机清理 + +- `backend/gateway/controller/asset_controller.go:160-194` `parseRPCError` 靠字符串解析错误码,应用 `status.FromError`。 +- 多处 `c.JSON(500, gin.H{"message": err.Error()})` 原样返回内部错误(`auth_controller.go:222`、`user_controller.go:139` 等)。 +- `backend/services/userService/service/user_service.go:681` `ResetPassword` 不失效已签发 JWT(TODO 挂起)。 +- `backend/services/userService/service/user_service.go:1023` `UpdateAvatar` 不校验头像 URL 同源。 +- PII(手机号 / 聊天内容 / JWT)打进 INFO 日志(`ai_chat_provider.go:107`、`mint_service.go` / `user_service.go` 多处)。 +- `backend/pkg/mq/` 大量未接线死代码:`EventProducer`/`EventConsumer` 接口、`Stream*` 常量 0 引用;`asynq/adapter.go:111` `GetInfo`/`Delete` 是假实现。 +- `backend/services/galleryService/mq/consumer.go:207` `is_processed` 列被复用为 `settled`(三处写入语义冲突,可能导致结算漏单)。 +- `backend/services/activityService/service/activity_service.go:217,1579` 直接用 `redisClient.Publish`(Redis Pub/Sub),绕过 broker 抽象。 +- `backend/pkg/peripheral/sign.go:33-44` 周边 HMAC 密钥优先级为 `SECRET_KEY > JWT_SECRET > 硬编码开发默认值`。正常配置下(`backend/.env:75`、`docker/.env.prod:66` 均有 `SECRET_KEY`)取 `SECRET_KEY`,与 JWT 无耦合。**残留隐患**:`backend/deploy/envs/asset.env` + `common.env`(systemd 部署路径)既无 `SECRET_KEY` 也无 `JWT_SECRET`,若走该路径会回落到硬编码 `"default-dev-secret-change-me"`(可预测密钥)。建议:去掉 `JWT_SECRET` 兜底与 dev 默认值,`SECRET_KEY` 缺失时直接 fail-fast。 + > 更正说明:初版报告曾将此列为 P1「轮换 JWT 会连带作废周边验证码」,经复核 `SECRET_KEY` 为首选且生产已配置,该结论不成立,已降级并修正。 + +--- + +## 五、建议修复顺序 + +1. **轮换全部泄露密钥 + 清 git 历史 + 补 `.gitignore`**(P0-1) +2. **mint 事务拆掉内嵌 RPC + 加幂等 token**(P0-2) +3. **所有 provider 从 attachment 取身份,禁用 `req` 里的 `user_id`/`reporter_id`**(P0-3) +4. **决断 `starbookService`:正式化或彻底删除**(P0-7) +5. **端口默认值对齐 + 加配置断言测试**(P0-6) +6. bcrypt/序列/personaID/CheckFriendship 等 P1 正确性 bug +7. 中长期:拆库或用 DTO 替换 `pkg/models` 跨服务共享;`proto/event.proto` 加版本 envelope;MQ 补 DLQ/重试(或删掉未用的 streams adapter,二选一);拆分 `UserSocialService` 为公开/内部两个契约。 + +--- + +## 六、附录:审查维度与方法 + +| 维度 | 主要工具 | +|------|----------| +| 微服务耦合 & DB 边界 | code-review-graph(imports_of/importers_of)+ grep 验证 | +| 网关边界 & proto 契约 | 通读 controller/service/router + proto | +| 配置 & 部署 | `git ls-files` / `git check-ignore` + env/compose/helm 对比 | +| 共享 pkg & 消息 | importers_of 爆炸半径 + mq adapter 通读 | +| 各服务正确性 & 安全 | service/repository/provider 热点通读 | + +> 本报告仅识别问题,不含完整修复方案。针对任意一条可另起工程化设计文档(遵循 `CLAUDE.md` 的 MVP 先行与文档结构规范)。 + +--- + +## 七、数据库实测复核(2026-07-21,本地 `top-fans` 全新库) + +对照实际数据库(`localhost:15432` / 容器 `postgresql-database-1` / 库 `top-fans`)复核关键 finding,结论如下: + +| 检查项 | 结果 | 对应 finding | +|--------|------|--------------| +| 单库结构 | ✅ 实证 P0-5:单库单实例,`public` schema 一把装 **89 张表**(users/assets/fan_profiles/asset_registry/moderation_*/reports/activities/exhibitions/ai_*/peripheral_*/tasks 全在一起) | P0-5 | +| 跨域外键 | ⚠️ 实证 P0-5 DB 层耦合:`public` 有 **49 条 FK** 跨域绑定(`fan_profiles→users`、`assets→users/stars`、`mint_orders→users/assets/stars`、`asset_likes→assets/users/stars`、`booth_slots→fan_profiles`…),拆库前必须全部拆除 | P0-5 | +| 序列健康 | ✅ 当前**无** `last_value < max(id)` 的序列;`assets_id_seq=88100008=max(id)`、`asset_registry_id_seq=308=max(id)`。序列隐患为**潜在**(仅跑缺 `setval` 的脚本才触发),现网未坏 | P1(`create_gallery_test_users.go`) | +| `material_type` 增长 | ⚠️ 234 行资产中 134 行含逗号,但**最多 3 个值 `hot,new,potential`(comma_count=2),未观测到失控增长**。代码 `CONCAT` 追加 bug 存在但尚未爆 | P1(`asset_like_service.go:267`) | +| `is_processed` 复用 | ⚠️ 实证 P2:exhibitions 无独立 `settled` 列;45845 行中 `is_processed=false` 有 **40847** 行,无法区分"未清理"还是"未结算收益"——语义冲突有真实数据佐证 | P2(`consumer.go:207`) | +| statistic schema | ℹ️ **更正**:statistic 事件隔离在独立 `statistic` schema(按天分区 `events_YYYY_MM_DD` + metric 视图),并非"全落在 public"。statisticService 有部分 schema 隔离 | 更正子审计说法 | + +> 说明:"全新库"含实测数据(users 94 / fan_profiles 96 / assets 234 / asset_registry 239 / mint_orders 281 / exhibitions 45845 / reports 4 / stars 6),非空库。exhibitions 4.5 万行相对 234 资产偏高,结合 `is_processed` 语义冲突,建议排查是否存在结算/清理积压。 + +### 七.1 【重大发现·P1 财务】展出收益结算非幂等,已实测超发并被领取 + +对 exhibitions 的 `is_processed` 深挖时,从数据里挖出一个已实现资损的 bug。 + +**数据证据(当前 `top-fans` 库)**: +- `exhibition_revenue_records` 共 **51742 条**,但只对应 **46241 个不同 `exhibition_id`** → **4053 个展品被重复结算**(最多 3 次)。 +- 多余重复记录 **5501 条,超发水晶累计 2,525,254**。 +- 这些重复记录 **`status` 全部为 `claimed`(9554 条已领取)** → 超发水晶已被用户领走,是**已实现资损**(本数据集内)。 +- `exhibition_revenue_records` 表**无 `exhibition_id` 唯一约束**,无任何幂等键阻止重复写入。 + +**根因**(`backend/services/galleryService/service/cleanup_worker.go` + `taskService/service/revenue_service.go`): +1. `exhibition_revenue_records` 表**无 `(exhibition_id, cycle_start_time)` 唯一约束**,`CreateRevenueRecord`(`revenue_service.go:476`)无条件插入。 +2. **多个结算入口对同一展品-周期重复触发**:cleanup_worker 的 ZSET 路径(`cleanup_worker.go:137`)、DB 兜底路径(`cleanup_worker.go:239`)、MQ 消费路径三者可对同一 exhibition 重复调用 `OnExhibitionCompleted`。 +3. 数据实证:4053 个重复展品中 **3850 个是"同周期重复"**(同一 `cycle_start_time` 被结算 ≥2 次),确认是同一周期的重复结算而非合法的多周期结算。 + +> **更正(较早版本的错误归因)**:本节初稿曾把重复归因为"`like_bet_revenue_records` 仅 50 条 → 点赞押注失败 → `is_processed` 永不置 true → worker 重跑"。经数据核实此链条**不成立**:`live+expired+unprocessed` 积压=0(活跃展品都已正确标记 processed);`like_bet` 仅 50 条是因为**测试数据点赞极稀疏**(45952 展品仅 164 个有赞、仅 9 个有 ≥2 赞),而点赞押注模型只奖励"后续还有点赞"的押注者,故绝大多数展品合法产出 0 条记录、`RecordLikeBetRevenue` 返回 OK 而非报错。重复的真正根因是上述"多路径 + 无唯一约束"。 + +**修复方向**: +- `exhibition_revenue_records` 加 `UNIQUE(exhibition_id, cycle_start_time)` 幂等约束,写入用 `INSERT ... ON CONFLICT DO NOTHING`; +- 收敛结算入口到**单一**带幂等的路径(保留 MQ 消费路径,删除 cleanup_worker 的 ZSET/DB 双直连 RPC,或让三者共用同一 `isAlreadyProcessed` 判重); +- 用独立 `settled_at` 审计列替代复用 `is_processed`(原 §四 P2 项); +- 存量:按 `(exhibition_id, cycle_start_time)` 去重,回收已发放的重复水晶(本库超发 2,525,254 且已 claimed,需评估回滚口径)。 +- **附带发现**:`exhibition_revenue_records.created_at` 单位混乱——51849 条中 6013 条是**秒**(10 位)、45836 条是**毫秒**(13 位)。同列混用两种时间单位,任何按 `created_at` 排序/区间过滤/展示的逻辑都会错乱。应统一为毫秒并回填历史数据。 + +> 关于 `like_bet_revenue_records` 仅 50 条:非 bug。测试数据点赞极稀疏(45952 展品仅 164 有赞、仅 9 有 ≥2 赞),点赞押注模型只对"后续仍有点赞"的押注者发放,故绝大多数展品合法产出 0 条。 + +> 备注:worker 查询带 `deleted_at IS NULL`,故 40740 行"已软删+未处理"不会被再次结算(当前 `live+expired+unprocessed` 积压=0);重复来自展品**在软删之前**被 worker 多轮处理。 + +### 七.2 展品累计时长(`user_exhibition_hours` / 资产 `season_exhibition_hours`) + +**数据层面无明显错误**:`user_exhibition_hours` 1075 行,无负值、无溢出(max 1727h≈72 天)、无重复 `(user_id,star_id)` 行,唯一约束 `uk_exhibition_user_star` 健康。 + +**但累计逻辑存在幂等缺口,且数据来源(结算流)已含重复(见 §七.1),故累计值不可信**: +1. `backend/services/userService/repository/fan_profile_repository.go:535-558`:`AddExhibitionHours` 收 `sourceID` 参数却**未用于幂等判断**,无条件 `total_exhibition_hours += hours`。 +2. `backend/services/taskService/service/revenue_service.go:513`:资产级 `assetLevelService.AddExhibitionHours(assetId, hours)` **完全不传 sourceID**。 +3. `revenue_service.go:436`:`actualHours=(expireAt-startTime)/3600000` 基于展品固定时间戳 → 同一展品重复结算每次加**相同**小时数(非 0)。 +4. **两条结算路径并存**:`galleryService/service/cleanup_worker.go:137,239` 仍调用已标注"本期内废弃"的直连 RPC `OnExhibitionCompleted`(收益+时长均无去重);`galleryService/mq/consumer.go:133` 走带 `isAlreadyProcessed` 去重的 MQ 路径。仅 MQ 路径有幂等,直连路径没有。 +5. 已证 5501 次重复结算 → 经直连路径的部分会把累计时长多加。 + +**后果**:累计时长驱动等级 `CalculateLevelFromExhibitionHours` → 时长虚高触发**提前升级 + 多发升级奖励水晶**,与 §七.1 收益重复同根因(结算非幂等 + 双路径并存)。 + +**修复方向**:统一到单一带幂等的结算路径(删除 cleanup_worker 的直连 RPC 调用,或给直连路径补 `isAlreadyProcessed`);`AddExhibitionHours` 真正实现 sourceID 幂等(加 `exhibition_hours_log(source_id UNIQUE)` 或复用 `crystal_transaction_records` 的 source_id 判重);资产级累加补 sourceID;存量数据需按 distinct exhibition 重算校正等级与已发奖励。 diff --git a/docs/specs/2026-07-21-backend-remediation-plan.md b/docs/specs/2026-07-21-backend-remediation-plan.md new file mode 100644 index 0000000..8825613 --- /dev/null +++ b/docs/specs/2026-07-21-backend-remediation-plan.md @@ -0,0 +1,391 @@ +# 后端问题修复方案(2026-07-21) + +> 配套审计报告:[docs/backend-audit-2026-07-21.md](../backend-audit-2026-07-21.md) +> 本文把审计发现的问题转化为**可执行的修复批次**,每条含:问题引用 → 根因 → 修复方案 → 涉及文件 → migration/SQL → 验证 → 风险 → 工作量。 + +--- + +## 一、方案概述(必读) + +### 要解决的问题 + +**安全类** +- 生产密钥(OSS/SMS/OpenAI/Dify)已提交进 git,任何人 clone 即可拿到。 +- 4/5 服务信任请求体/attachment 里的 `user_id`/`reporter_id`,可越权与刷量。 +- JWT 全局可变密钥、弱默认值、data race。 + +**财务/数据一致性类** +- 展品收益结算非幂等,实测超发 2,525,254 水晶且已被领取(`exhibition_revenue_records` 无唯一约束 + 多结算入口)。 +- 铸造扣费事务内嵌跨服务 gRPC,可扣费成功但无藏品、连接池耗尽。 +- 累计时长累加无幂等(`AddExhibitionHours` 不用 sourceID),时长虚高→提前升级→多发奖励。 +- `exhibition_revenue_records.created_at` 单位混乱(秒/毫秒混存)。 + +**稳定性类** +- bcrypt 在 DB 事务内执行,注册高峰连接池耗尽。 +- Redis-Streams 消费者重启即丢消息,无 DLQ/重试。 + +**架构/边界类** +- 全部服务共享一个 Postgres + 一套 migration + 一个 `pkg/models`;服务间直接 import 对方 Go 包;49 条跨域外键。 +- 网关直连 DB、import 服务内部包;`UserSocialService` 契约混合。 +- 服务端口默认值四层不一致;`starbookService` 孤儿但仍在部署链路。 + +### 整体实现路径(按风险/收益排序,非架构驱动) + +| 批次 | 主题 | 目标 | 工作量估算 | +|------|------|------|-----------| +| **批次 0** | 安全紧急 | 轮换密钥、清历史、密钥出库 | 0.5–1 天(+ 运维轮换) | +| **批次 1** | 财务正确性 | 结算幂等、mint 事务/随机数、doMint 限流、时长幂等、存量校正 | 4–6 天 | +| **批次 2** | 鉴权边界 | provider 统一从 attachment 取身份、social 隐私/正确性 | 3–4 天 | +| **批次 3** | 稳定性 | bcrypt、Login 限流、MQ DLQ、JWT 密钥、aiChat 健壮性、事件可靠性、网关聚合 | 4–6 天 | +| **批次 4** | 配置/部署 | 端口对齐、starbook 决断、健康探针、.env 对齐、P2 清理 | 2–3 天 | +| **批次 5** | 架构治理(路线图) | 去分布式单体(**不在本次落地**) | 独立项目,数周 | + +### 关键决策 + +1. **不在本次拆库/拆 `pkg/models`**(MVP 先行原则)。当前业务规模下,拆库是数周级独立项目,收益/风险比不划算。本次只做**加固**:加 lint 禁止新增跨服务 import、加唯一约束堵幂等漏洞、明确表 owner 注释。架构级拆分列入批次 5 路线图。参见 [§批次5](#批次-5架构治理路线图仅规划)。 +2. **财务批次优先于边界批次**:已实证资损(超发水晶已被领取)>理论越权,先止血。 +3. **存量数据修复走 dry-run + 备份 + 序列同步**,遵循 `CLAUDE.md` PostgreSQL 序列规范。参见 [§存量数据修复](#四存量数据修复脚本规范)。 + +### 核心修复优先级图(TL;DR) + +``` +先做 ──────────────────────────────────────────────────────→ 后做 + 批次0 批次1 批次2 批次3 批次4 批次5 + 安全紧急 财务正确性 鉴权边界 稳定性 配置/部署 架构治理(路线图) + 密钥轮换 结算幂等/mint/时长 身份透传 bcrypt/MQ/JWT 端口/starbook 去分布式单体 + (P0) /doMint (P0+P1) /social(P0+P1) /aiChat/网关(P1) /探针/.env/P2 (本次不落地) +``` + +### 文档说明 + +- **适用范围**:backend 全部服务 + gateway + pkg + 配置/部署。 +- **工作量**:批次 0–4 合计约 14–20 人天(不含架构批次 5)。 +- **前置**:审计报告 `docs/backend-audit-2026-07-21.md`;基线 commit `b7f8f1b724e5`。 +- **目标读者**:后端负责人、运维、DBA。 + +--- + +## 二、修复批次详解 + +### 批次 0:安全紧急(密钥) + +> 对应审计 P0-1。**先做,且需运维配合轮换**。 + +**根因**:`backend/deploy/envs/*.env`、`backend/.env.example`、`docker/.env.local`、`docker/.env.prod` 含真实密钥且被 git 追踪。 + +**修复步骤**: +1. **立即轮换**(运维):阿里云 OSS AccessKey(`LTAI5t6QcdJHpYbCPxM8SXYE` 及第二套)、复用的 SMS key、OpenAI(`sk-proj-...`/微达)、Dify(`app-...`)。轮换是首要动作——git 历史清理无法撤销已泄露的凭证。 +2. **密钥出库**: + - `.gitignore` 增加:`backend/deploy/envs/*.env`(保留 `*.env.example` 占位模板)。 + - 从 git 索引移除:`git rm --cached backend/deploy/envs/*.env docker/.env.local docker/.env.prod`。 + - `.env.example` 里所有真实密钥替换为 `` 占位符。 +3. **清理 git 历史**:`git filter-repo --path backend/deploy/envs --path docker/.env.prod --invert-paths`(需团队协调强推 + 重新 clone)。 +4. **改由部署时注入**:docker-compose 走宿主机环境变量 / k8s 走 Secret(`values-prod.yaml` 已 gitignore,作为注入源)。 + +**验证**:`git ls-files | grep -E 'deploy/envs/.*\.env$'` 应为空;`git log -p -- backend/.env.example | grep -c 'sk-proj'` 为 0。 + +**风险**:轮换 OSS key 需同步更新所有消费方(asset/user/gateway);历史清理需全员重新 clone。 + +--- + +### 批次 1:财务正确性 + +#### 1.1 展品收益结算幂等(**最高优先,已实测资损**) + +> 对应审计 §七.1。 + +**根因**:`exhibition_revenue_records` 无 `(exhibition_id, cycle_start_time)` 唯一约束;`cleanup_worker.go` 的 ZSET 路径(L137)、DB 兜底路径(L239)、MQ 消费路径三者可对同一展品-周期重复调 `OnExhibitionCompleted` → `CreateRevenueRecord`(`revenue_service.go:476`)无条件插入。实测 3850 个展品同周期重复结算。 + +**修复方案**: +1. **加唯一约束**(migration,见下):`UNIQUE(exhibition_id, cycle_start_time)`。 +2. **写入改幂等**:`revenue_service.go:476` 的 `CreateRevenueRecord` 用 `clause.OnConflict{DoNothing: true}`。 + ```go + // repository 层 + tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "exhibition_id"}, {Name: "cycle_start_time"}}, + DoNothing: true, + }).Create(record) + ``` +3. **收敛结算入口**:保留 MQ 消费路径(已有 `isAlreadyProcessed`),删除 `cleanup_worker.go:137` 和 `:239` 的直连 RPC 调用(`OnExhibitionCompleted` 已标注"本期内废弃");或让三路共用同一 `isAlreadyProcessed` 判重。 +4. **审计列**:新增 `settled_at bigint`,弃用把 `is_processed` 当 settled(原 §四 P2)。 + +**涉及文件**: +- `backend/services/taskService/service/revenue_service.go:461-482`(写入幂等) +- `backend/services/taskService/repository/`(revenueRepo.CreateRevenueRecord) +- `backend/services/galleryService/service/cleanup_worker.go:137,239`(删直连入口) +- 新 migration + +**migration**(`backend/migrations/2026_07_21_001_exhibition_revenue_idempotent.sql`): +```sql +-- 先去重历史(保留每组最早一条),再加约束 +DELETE FROM exhibition_revenue_records a +USING exhibition_revenue_records b +WHERE a.exhibition_id = b.exhibition_id + AND a.cycle_start_time = b.cycle_start_time + AND a.id > b.id; +ALTER TABLE exhibition_revenue_records + ADD CONSTRAINT uk_exhibition_revenue_cycle UNIQUE (exhibition_id, cycle_start_time); +ALTER TABLE exhibitions ADD COLUMN IF NOT EXISTS settled_at bigint; +-- 序列同步(本表 BIGSERIAL) +SELECT setval('exhibition_revenue_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM exhibition_revenue_records)); +``` + +**验证**:迁移后 `select count(*)-count(distinct (exhibition_id,cycle_start_time)) from exhibition_revenue_records` = 0;重放 worker 两次不新增记录。 + +#### 1.2 累计时长幂等 + +> 对应审计 §七.2。 + +**根因**:`fan_profile_repository.go:535-558` 的 `AddExhibitionHours` 收 `sourceID` 但不判重;`revenue_service.go:513` 资产级累加不传 sourceID。 + +**修复方案**: +1. 新增去重表 `exhibition_hours_log(source_id VARCHAR UNIQUE, user_id, star_id, hours, created_at)`;`AddExhibitionHours` 事务内先 `INSERT ... ON CONFLICT DO NOTHING`,`RowsAffected==0` 则跳过累加。 +2. 资产级 `assetLevelService.AddExhibitionHours` 补 `sourceID` 参数并同样判重。 +3. 存量:按 distinct exhibition 重算 `total_exhibition_hours` 与 `season_exhibition_hours`,回滚因虚高误发的升级奖励(见 §四)。 + +**涉及文件**: +- `backend/services/userService/repository/fan_profile_repository.go:535-558` +- `backend/services/taskService/service/revenue_service.go:487-524` +- `backend/services/assetService/...`(assetLevelService.AddExhibitionHours 签名) + +#### 1.3 `created_at` 单位统一 + +> 对应审计 §七.1 附带发现。 + +**根因**:`exhibition_revenue_records.created_at` 6013 条秒、45836 条毫秒混存。 + +**修复**:代码统一 `time.Now().UnixMilli()`;migration 回填: +```sql +UPDATE exhibition_revenue_records SET created_at = created_at * 1000 +WHERE created_at < 100000000000; -- 10位秒 → 毫秒 +``` +排查同类 bigint 时间列(`like_bet_revenue_records`、`crystal_transaction_records`)是否同样混用。 + +#### 1.4 铸造扣费事务重构 + +> 对应审计 P0-2。 + +**根因**:`mint_service.go:309-318` 在 `db.Transaction` 内调 `userClient.UpdateCrystalBalance`(跨服务 gRPC);且 `CreateMintOrder` 非幂等。 + +**修复方案**: +1. **RPC 移出事务**:先在事务内落"扣费意图/占位订单",提交后再调 userService 扣费,失败走补偿/回滚订单状态。 +2. **幂等键**:`mint_orders` 已有 `order_id`,`CreateMintOrder` 入口先按 `order_id` 查已存在的成功订单直接返回,而非重复扣费。 +3. 用户侧 `UpdateCrystalBalance` 已带 `source_id`,确认 userService 侧按 `(source_id, change_type)` 幂等。 + +**涉及文件**:`backend/services/assetService/service/mint_service.go:209-516`。 + +#### 1.5 铸造随机性可预测 + +> 对应审计 §三 P1(`mint_service.go:323,349`)。 + +- **保底概率**:`mint_service.go:323` 用 `time.Now().UnixNano()%100`,并发同纳秒结果相同、可被脚本卡点操纵。改用 `crypto/rand`。 +- **mockTxHash**:`mint_service.go:349` 由 `orderID/userID/starID/时间戳` 全可观测输入拼 sha256,可被预测伪造"已上链"。改为不可预测来源或明确标注非链上哈希、不作为凭证。 + +#### 1.6 `doMint` 限流 TOCTOU + +> 对应审计 §三 P1(`peripheral_service.go:217-319`)。 + +`CountRecentMint`(L233)检查与 insert(L271)不在同一事务,并发可绕过 10/24h 限制。把 count 检查并入同一事务,或用 Redis Lua 原子自增。 + +--- + +### 批次 2:鉴权边界 + +> 对应审计 P0-3、P1(trust boundary)。 + +**根因**:`moderation_provider.go:35`、`asset_provider.go:483`、`share_service.go` 等直接用 `req.UserId/ReporterId/SharerUserId`;4/5 服务信任 attachment 不再校验。 + +**修复方案**: +1. **统一身份提取中间件/拦截器**:所有 provider 从 Dubbo attachment 提取 `user_id/star_id`,**覆盖** `req` 内同名字段,禁止业务读 `req.UserId`。抽到 `pkg/` 公共拦截器,避免每服务各写一份。 +2. **内部 RPC 可信边界**:内部服务间 RPC 走内网 + mTLS 或签名;`ValidateToken`(`router.go:151`)移出公开 `/auth` 组。 +3. 逐个 provider review(asset/social/moderation/gallery/task),列出所有读 `req.*Id` 的点改为读 attachment。 + +**涉及文件**:各服务 `provider/*.go`;`pkg/` 新增拦截器;`gateway/router/router.go:151`。 + +**验证**:构造伪造 `reporter_id/user_id` 的 RPC,应被拦截器覆盖为真实身份。 + +#### 2.1 socialService 隐私/正确性 + +> 对应审计 §三 P1(social 三条)。 + +- `friend_service.go:685`:`CheckFriendship` 硬编码 `starID=0`(TODO 未做)→ 结果错误 + 好友关系隐私预言机。改为从 attachment 取真实 starID。 +- `social_repository.go:648,672,903,923`:OR 子句括号优先级问题,可能让已删除资产漏进点赞列表。用显式 `db.Where(...).Or(...)` 或单条带括号的 SQL 分组。 +- `social_repository.go:461`:`GetRandomUsersByStar` 用 `OFFSET rand + LIMIT` 取连续段,非随机且可预测。改 `TABLESAMPLE` 或 `ORDER BY random()`(小表)。 + +--- + +### 批次 3:稳定性 + +#### 3.1 bcrypt 移出事务 +> P1。`auth_service.go:161,566` 的 `HashPassword` 移到 `db.Transaction` 之前。仅需调整语句顺序,风险低。 + +#### 3.2 Login 限流 + 消除用户枚举 +> P1。`auth_service.go:300 vs 315` 统一错误码("账号或密码错误");Login 加与 SMS 同款 Redis 限流。 + +#### 3.3 MQ 可靠性 +> P1。`pkg/mq/streams/adapter.go`:consumer 名改为**稳定**标识(服务名+实例名,去掉 `UnixNano`)以便重启认领 PEL;加 `XAUTOCLAIM` 回收 + DLQ。若短期不投入,则**明确停用** streams adapter(当前 EventProducer/Stream* 常量 0 引用),二选一,避免半吊子。 + +#### 3.4 material_type 收敛 +> P1。`asset_like_service.go:267` 的 `CONCAT` 改为集合去重写入(或独立标签表),防无限增长。当前实测最多 3 值未爆,属预防。 + +#### 3.5 JWT 密钥治理 +> P1 安全。`pkg/jwt/jwt.go:18` 包级可变全局 `jwtSecret`,弱默认值 `"your-secret-key-change-in-production"`,`SetSecret`/`ParseToken` 无锁 data race。修复:启动时强制从 env 注入,缺失/为默认值即 **fail-fast**;密钥用 `sync.Once` 或初始化时一次性设定,运行期只读,消除 race;gateway 与 userService 用同一密钥源(KMS/Secret),避免漂移导致全量 token 失效。 + +#### 3.6 aiChatService 健壮性 +> 对应审计 §三 P1(aiChat 三条)。 +- `ai_chat_provider.go:250`:保存上下文用请求里的 `personaID` 而非解析后的 `persona.ID` → 用 `persona.ID`。 +- `ai_chat_provider.go:138,141`:Redis 出错静默吞掉(记忆/历史丢失无日志)→ 记 Error 日志 + 降级提示,不静默。 +- `main.go:190-211` / `provider:188`:Dify/LLM 启动时二选一、无 fallback/熔断/重试,Dify 挂即硬故障,且原样回传 `err.Error()` → 加熔断/超时/降级,错误映射为稳定用户文案,原始 err 仅记服务端。 + +#### 3.7 事件/统计可靠性 +> 对应审计 §三 P1(statistic、队列名)。 +- `pkg/statistic/client.go:53-70`:`TrackEvent` fire-and-forget,失败只 log → 至少加本地重试/落盘补偿,或明确接受丢失并在文档标注。 +- 队列名(`"gallery"`、`"default"`)硬编码在 producer/consumer 两侧 → 抽为共享常量,消除改名漂移。 + +#### 3.8 网关聚合与双写 +> 对应审计 §三 P1(gateway)。 +- `asset_controller.go:414-436`:铸造后本地写激光卡与 assetService 无事务串联 → 实例卡 `mining`。改为由 assetService 统一落状态 + 事件驱动,或加对账补偿任务。 +- `auth_controller.go:69-97` 等 6 处:Register/Login/Me/Profile 链式调 `GetFanIdentities`(每次拉全量明星目录)→ 让 userService 单 RPC 返回带 star 信息的实体,减少往返并加下游失败补偿。 +- `user_controller.go:647-748`:`DeleteAccount` 用 `database.GetDB()` 自开事务直接软删 `users`+`fan_profiles`,**绕过 userService**(userService 无从感知、无法执行下游失效)。近期止血:改调 userService 的删除 RPC;彻底修复随批次 5「网关瘦身」移除网关直连 DB。 +- `gateway/repository/laser_card_repository.go`、`app_download_repository.go`:网关持有 DB repo(非纯 BFF)→ 归批次 5「网关瘦身」,DB 写回落 owner 服务。 + +--- + +### 批次 4:配置/部署 + +#### 4.1 服务端口对齐 +> P0-6。修 `gateway/config/config.go:162-165` 默认端口(gallery 20001 / activity 20004 / starbook 20005);`galleryService/main.go:44` taskService 默认改 20006;`userService/main.go:283` 去掉硬编码 `WithPort(20000)` 或删死 flag。**加配置测试**断言"网关默认端口 == 服务绑定端口"。 + +#### 4.2 starbookService 决断 +> P0-7。二选一: +> - **正式化**:补 `go.mod`、加入 `go.work`、加 systemd/env;或 +> - **删除**:移除目录 + `assetService` 对它的 import(`main.go:32`)+ compose/helm/`dev.sh`/gateway config 引用,代码并入 assetService。 +> 推荐后者(生产未部署它)。 + +#### 4.3 健康探针 & 大二进制 +> P1。k8s `notificationservice` 探针 `/healthz`→`/health`;`moderationservice` 补探针策略。`.gitignore` 补 `backend/{assetService,gateway-fixed,test,cleanup-orphan-avatars}` 并 `git rm --cached`。 + +#### 4.4 序列规范 +> P1。`backend/scripts/create_gallery_test_users.go` 生成的 SQL 末尾补 `setval(...)`(见 `CLAUDE.md` 强制规则)。 + +#### 4.5 .env 漂移 +> P1。`backend/.env` 有 `WS_AI_CHAT_PATH` 但 `.env.example` 未文档化;`.env.example` 有一批 `.env` 缺失的必需键(`DIFY_TIMEOUT_SEC`/`DIFY_WORKFLOW`/`LANDING_BASE_URL`/`PUSH_*`/`SEGMENT_*` 等)。对齐两份,`.env.example` 只留占位符 + 完整键清单。 + +--- + +### 批次 4.9:P2 择机清理(随相关批次一起做) + +> 对应审计 §四。不单独排期,改动到对应文件时顺手清。 + +| P2 项 | 文件:行 | 动作 | +|-------|---------|------| +| 错误码字符串解析 | `gateway/controller/asset_controller.go:160-194` | 改用 `status.FromError` | +| 原始 err 泄露给前端 | `auth_controller.go:222`、`user_controller.go:139` 等 | 统一错误码 + 友好文案,err 仅记日志 | +| ResetPassword 不失效 JWT | `userService/service/user_service.go:681` | 校验 JWT 与 `users.access_token`,或引入版本号/黑名单 | +| 头像 URL 不校验同源 | `user_service.go:1023` | 限本站 OSS bucket 或只接受 asset id | +| PII 打进 INFO 日志 | `ai_chat_provider.go:107`、`mint_service.go`/`user_service.go` 多处 | 手机号/JWT/聊天内容脱敏或降级 | +| pkg/mq 死代码 | `EventProducer`/`Stream*`/`asynq.GetInfo/Delete` | 删除或补真实现(与 3.3 一并决断) | +| 直连 Redis Pub/Sub | `activityService/service/activity_service.go:217,1579` | 走 broker 抽象 `adapter.EventProducer` | +| 周边密钥兜底 | `pkg/peripheral/sign.go:33-44` | 去掉 `JWT_SECRET`/dev 默认兜底,`SECRET_KEY` 缺失即 fail-fast | + +--- + +### 批次 5:架构治理(路线图,**仅规划,本次不落地**) + +> ⚠️ MVP 先行:以下为长期方向,当前不实施,避免为未来规模写用不上的抽象。 + +- **去分布式单体**:按域拆库或至少拆 schema + 明确表 owner;跨服务读写改 RPC + service-local DTO。 +- **拆 `pkg/models`**:改为各服务本地 model + proto DTO,消除 7 服务共享 model 的编译耦合。 +- **消除跨服务 Go import**:`taskService↔assetService↔starbookService`、`gateway→asset/moderation` 改走 RPC;加 lint 规则禁止新增。 +- **拆分 `UserSocialService`**:公开 API 与内部 RPC 分为两个契约(参考 `task.proto` 的 Mobile/Internal 范式)。 +- **网关瘦身**:移除 `gateway/repository/`,DB 写回落到 owner 服务。 +- **事件契约**:`proto/event.proto` 加 `{event_id, schema_version, produced_at}` envelope。 + +**加固动作(本次可做,低成本)**:加 CI lint 禁止 `services/*/` 之间、`gateway→services/*/{repository,service}` 的新增 import,防止耦合继续恶化。 + +--- + +## 三、问题 → 修复映射表 + +| 审计条目 | 严重度 | 批次 | +|----------|--------|------| +| P0-1 密钥泄露 | P0 | 批次 0 | +| §七.1 结算超发(已实测资损) | P0/财务 | 批次 1.1 | +| §七.2 累计时长幂等 | P1/财务 | 批次 1.2 | +| §七.1 created_at 单位 | P1 | 批次 1.3 | +| P0-2 mint 事务嵌 RPC | P0 | 批次 1.4 | +| §三 P1 mint 随机数/txHash | P1 | 批次 1.5 | +| §三 P1 doMint 限流 TOCTOU | P1 | 批次 1.6 | +| P0-3 身份伪造/越权 | P0 | 批次 2 | +| §三 P1 social 隐私/正确性(CheckFriendship/OR/随机) | P1 | 批次 2.1 | +| P1 bcrypt 事务内 | P1 | 批次 3.1 | +| P1 Login 枚举/限流 | P1 | 批次 3.2 | +| P1 MQ 丢消息/无 DLQ | P1 | 批次 3.3 | +| P1 material_type 增长 | P1 | 批次 3.4 | +| P1 JWT 全局密钥/data race | P1 | 批次 3.5 | +| §三 P1 aiChat(personaID/Redis/Dify) | P1 | 批次 3.6 | +| §三 P1 TrackEvent 丢事件/队列名漂移 | P1 | 批次 3.7 | +| §三 P1 网关双写/链式调用/DeleteAccount 直连 DB | P1 | 批次 3.8(止血)/ 批次 5(彻底) | +| P0-6 端口不一致 | P0 | 批次 4.1 | +| P0-7 starbook 孤儿 | P0 | 批次 4.2 | +| P1 健康探针/大二进制/序列 | P1 | 批次 4.3/4.4 | +| P1 .env 漂移 | P1 | 批次 4.5 | +| §四 P2 全部(8 项) | P2 | 批次 4.9 | +| P0-4/5 分布式单体、共享库、跨域 FK | P0/架构 | 批次 5(路线图) | +| P1 UserSocialService / social.proto 契约混合 | P1/架构 | 批次 5 | +| P1 event.proto 无 version envelope | P1/架构 | 批次 5(事件契约) | +| P1 网关持有 DB repo(laser_card/app_download) | P1/架构 | 批次 5(网关瘦身) | + +--- + +## 四、存量数据修复脚本规范 + +> 遵循 `CLAUDE.md`:手动 INSERT 指定 id 必须 `setval`;破坏性操作前备份 + dry-run。 + +**统一要求**: +1. **备份**:`pg_dump -t exhibition_revenue_records -t exhibitions -t user_exhibition_hours -t asset_level_records ...` 先落盘。 +2. **dry-run**:所有清理脚本先跑 `SELECT` 版本输出将影响的行数/金额,人工确认后再跑 `DELETE/UPDATE`。 +3. **事务包裹**:`BEGIN; ... ; COMMIT;`,异常回滚。 +4. **序列同步**:任何删改后对相关表执行 `SELECT setval('_id_seq', (SELECT MAX(id) FROM
));`。 +5. **回收超发**:批次 1.1 去重后,统计已 `claimed` 的重复水晶(本库 2,525,254),与运营确认回收/核销口径(本库为测试数据,可直接清账;生产需谨慎)。 +6. **时长重算 + 等级/奖励回滚(批次 1.2 存量)**: + - 按 `distinct exhibition` 重算每个 `(user_id,star_id)` 的 `total_exhibition_hours` 与每个资产的 `season_exhibition_hours`(以去重后的结算记录为准)。 + - 用重算后的时长经 `CalculateLevelFromExhibitionHours` 复算等级;对**因虚高时长误升的等级**下修,并冲销对应的升级奖励水晶(`crystal_transaction_records` 中 `change_type='level_up_bonus'` 且 source 关联误升的记录)。 + - 全程 dry-run 先出"将下修的用户/资产 + 冲销水晶总额"清单,人工确认后执行;执行后同步相关表序列。 + +**存量清理顺序**:created_at 单位回填(1.3)→ 收益记录去重 + 加约束(1.1)→ 回收重复水晶 → 重算时长 + 回滚误发奖励(1.2)。 + +--- + +## 五、回归验证清单(每批次完成后) + +- [ ] `go build ./...`(含 `go.work` 全部模块)通过。 +- [ ] 改动函数的**所有调用方**(`query_graph callers_of`)不受影响。 +- [ ] 相关单测通过;财务/鉴权补新用例。 +- [ ] migration 在本地 `top-fans` 全新库跑通,序列健康检查通过。 +- [ ] 端口配置测试通过(批次 4)。 +- [ ] 伪造身份 RPC 被拦截(批次 2)。 +- [ ] 结算重放不产生重复记录(批次 1)。 +- [ ] `git ls-files` 无密钥/大二进制(批次 0/4)。 + +--- + +## 六、备注 + +- 本文按 `CLAUDE.md` 接口开发规范(分层、DTO、错误码、日志、migration、测试)落地各修复。 +- 架构级拆分(批次 5)建议单独立项,本次仅做加固与止血。 +- 本库多为测试期脏数据,存量脚本以"可清账"为前提;生产执行前需 DBA 复核并按生产数据量重估。 + +### 六.1 跨 plan 执行协调(全局自审 2026-07-21 发现) + +各批次已拆成独立 plan(`docs/superpowers/plans/2026-07-21-*.md`)。以下为**多 plan 触碰同一资源**的协调约定,执行时按序,第二个改的人先 rebase: + +| 共享资源 | 涉及 plan | 约定 | +|---|---|---| +| migration 编号 | settlement/mint/hours | 固定顺序 `001` 收益幂等 → `002` crystal_tx 唯一索引 → `003` 时长幂等,按号执行 | +| `galleryService/mq/consumer.go` `isSettled/markSettled` + `settled_at` | settlement(Task3) **owns** / config(4.9-G 仅校验) | 只由 settlement plan 改;config plan 不重复编辑 | +| `galleryService/mq/consumer.go` 队列名常量(RegisterHandlers) | stability(3.7) | 与上者不同区域;建议 settlement 先落、stability 后落 | +| `taskService/service/revenue_service.go` | settlement(只读调用方) / hours(Task4 改 :513) | 无同行冲突;hours 后落即可 | +| `userService/repository/fan_profile_repository.go` | hours(AddExhibitionHours) / mint(UpdateCrystalBalance) | 不同方法,任意顺序;第二个先 `go build` 校验 | +| `crystal_transaction_records` 幂等 | mint(加 `uk_crystal_tx_source_change`) | 唯一约束只由 mint plan 加;hours 的 `level_up_bonus` 流水用不同 `change_type`,不冲突 | +| `userService/service/user_service.go` `ResetPassword` | stability(3.1 bcrypt 出事务) / config(4.9 P2 JWT 失效) | 不同关注点同一函数,串行改、后者 rebase | diff --git a/docs/superpowers/plans/2026-07-21-auth-boundary.md b/docs/superpowers/plans/2026-07-21-auth-boundary.md new file mode 100644 index 0000000..0d3ef8d --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-auth-boundary.md @@ -0,0 +1,614 @@ +# 鉴权边界 (批次2) 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:** 消除后端"信任请求体里的身份"造成的越权/刷量/隐私泄露——所有 Dubbo RPC provider 统一从 ctx(attachment/metadata) 提取 `user_id/star_id`,强制覆盖 `req` 内同名字段;并修复 socialService 的 3 条正确性/隐私 bug;`ValidateToken` 从公开 `/auth` 组移除。 + +**Architecture:** 在 `backend/pkg/authctx/` 沉淀统一的身份提取与覆盖工具(替代 5 处散落的 `extractUserInfoFromDubboAttachments`/`extractUserInfo` 副本);逐个 provider(moderation / asset / social / gallery / task)改成"从 ctx 取身份 → 写回 req → 调 service"的统一模式;social 三条(CheckFriendship starID / OR 括号 / 随机用户)用真 SQL 修复。`ValidateToken` 路由加 `AuthMiddleware`。整套不引入新依赖,依赖 `dubbo-go/constant.AttachmentKey` + `grpc/metadata` + `pkg/jwt`(与 `pkg/userService/middleware/auth_interceptor.go` 现有实现同源)。 + +**Tech Stack:** Go 1.25 (go.work 多模块),Dubbo Triple(gRPC metadata + attachments),GORM(社交 repository),PostgreSQL;本地库 `top-fans`@`localhost:15432`。 + +## Global Constraints + +- **不引入新依赖**:用现有的 `dubbo-go/constant.AttachmentKey`、`grpc/metadata`、`pkg/jwt`。 +- **不自动 `git commit`**(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 +- 每个 Task 结束跑 `cd backend && go build ./...` 通过。 +- TDD:每个 Task 第一步"写失败测试"(伪造身份应被覆盖/拦截),第二步"最小实现",第三步"测试通过"。Provider 端的拦截通过单测构造带 `x-user-id`/`x-star-id` 的 metadata 的 ctx 与带伪造 `req.UserId`/`req.StarId`/`req.ReporterId`/`req.SharerUserId` 的请求,断言调用 service 时传入的是覆盖后的真实身份。 +- 不引入 lint 黑魔法:仅用现有 `go vet` + `go build`。 +- 行为兼容:所有已有合法调用方不变;只是禁止攻击者在 `req` 里塞伪造身份。 +- 不改 proto 字段(不动契约);所有变更在 Go 层完成。 +- 任何 service.go 内部函数签名变更(如 `CheckFriendship` 增加 `starID` 参数)必须在 provider 处同步补传,避免破坏调用方。 + +--- + +## 当前现状速查(pre-plan 排查,作为 plan 的事实基线) + +> 来自 `mcp__code-review-graph` + grep 双重核对,基线 commit `b7f8f1b724e5`。 + +### A. 信任 `req.*Id` 的点(**本次必须全部覆盖**) + +| 服务 | 文件:行 | 字段 | 风险 | +|------|---------|------|------| +| moderation | `services/moderationService/provider/moderation_provider.go:35` (SubmitReport) | `req.ReporterId` 透传 | 冒用他人身份举报、刷量、读取他人举报 | +| moderation | `services/moderationService/provider/moderation_provider.go:39,43,49,53,57` (List/Get) | `req.ReporterId`/`req.UserId` | 读取他人举报/反馈详情 | +| moderation | `services/moderationService/service/report_service.go:107,116,123,130,170,220,251` | `req.ReporterId` | 业务逻辑按 req 执行 | +| moderation | `services/moderationService/service/feedback_service.go:53,64,109,135` | `req.UserId`/`req.StarId` | 同上 | +| asset | `services/assetService/provider/asset_provider.go:483-486` (CheckAssetLike) | `req.UserId`/`req.StarId` | 点赞隐私预言机 | +| asset | `services/assetService/service/share_service.go:139,172,175,185,187,211,233,250,266,276,298,308` | `req.SharerUserId` | 分享归因伪造、无限 OSS 写入 | +| social | `services/socialService/service/friend_service.go:668,683` (CheckFriendship) | `req.UserId` + starID=0 TODO | 好友关系隐私预言机 + starID 永远 0 | +| gallery | `services/galleryService/provider/gallery_provider.go:403` (GetUserExhibitedAssets) | `req.UserId` 当 target_uid,**应为合法入参** | 此处是查询参数,**不覆盖**(仅注释澄清) | +| task | `services/taskService/provider/task_internal_provider.go:32-48` (InitUserTasks) | `req.UserId`/`req.StarId`(内部 RPC) | 内部 RPC,**保留 req 作合法入参**,但加 ctx 校验一致性 | +| asset | `services/assetService/service/ranking_service.go:51,89-90,113-123,176,214-248` | `req.UserId`/`req.StarId` | 排行榜按别人维度查(隐私泄露) | +| asset | `services/assetService/provider/castlove_config_provider.go:35-39` | "本服务信任 ctx 透传的 user_id" | 当前 GetConfig 不读身份;文档要求统一来源 | + +### B. 现有的身份提取实现(5 份散落副本) + +| 服务 | 函数 | 文件 | +|------|------|------| +| userService | `ValidateTokenAndExtractClaims` / `ExtractUserInfoFromContext` | `services/userService/middleware/auth_interceptor.go:171,155` | +| notification | `extractUserInfo` | `services/notificationService/provider/notification_provider.go:161` | +| social | `extractUserInfo` | `services/socialService/provider/social_provider.go` (本文件已有 10+ 调用) | +| gallery | `extractUserInfoFromDubboAttachments` | `services/galleryService/provider/gallery_provider.go:463` | +| aiChat | `extractUserInfoFromDubboAttachments` | `services/aiChatService/provider/ai_chat_provider.go:367` | +| task | `extractUserInfoFromDubboAttachments` | `services/taskService/provider/task_mobile_provider.go:35` | + +5 份实现都做同一件事:从 attachment 或 metadata 取 `user_id`/`star_id`(`x-user-id`/`x-star-id`/`authorization`),但**没有**写回 `req` 的能力,因此 provider 端继续读 `req.*Id` 时仍中招。 + +### C. social 三条正确性 + +- `social_repository.go:461` — `GetRandomUsersByStar`:`rand.Int63n(total)` + `OFFSET` 取连续段,非随机且可预测。 +- `social_repository.go:648` (count)、`:672` (data) — `Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'")` — GORM 的多个 `Where(...)` 链式调用 **会拼 AND**,但第二个 `Where` 内部的 `OR` 没有显式分组,生成的 SQL 是 `AND (X OR Y)`,而 OR 与前一个 Where 的预期是 `AND X AND Y`,优先级错位会让"展览已删除 + 押注已 claimable"的资产漏进列表。 +- `social_repository.go:903, 923` — 同样 pattern:`Where("...deleted_at IS NULL...").Where("(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?")` 同样 OR 优先级问题。 +- `friend_service.go:680, 683` — `starID := int64(0); // TODO` → `CheckFriendship(req.UserId, req.FriendUserId, 0)` 永远返回跨明星的聚合,**好友关系隐私预言机**。 + +### D. `ValidateToken` 路由 + +`backend/gateway/router/router.go:151` — `auth.POST("/validate", authCtrl.ValidateToken)` 在公开 `/auth` 组,调用方**无需登录**即可验证任意 token 是否有效,配合 `c.ShouldBindJSON(&req)` 接受 `{token: "..."}`,是探测 JWT 是否存在的低成本接口。`auth_controller.go:289` 的实现也确实只用 `req.Token` 字段。修复:移到 `authProtected` 组(已带 AuthMiddleware)。 + +--- + +## File Structure + +- `backend/pkg/authctx/` — **新建包**。统一身份提取与 req 覆盖。 + - `backend/pkg/authctx/authctx.go` — `ExtractIdentity(ctx) (uid, sid int64, err error)`,合并 5 份散落副本(gRPC metadata `x-user-id`/`x-star-id` 优先,fallback 到 Dubbo `constant.AttachmentKey`)。 + - `backend/pkg/authctx/authctx.go` — `OverrideUser(req UserIDSetter, ctx) error` / `OverrideStar(...)` / `OverrideReporter(req ReporterIDSetter, ctx)` / `OverrideSharer(req SharerUserIDSetter, ctx)`:从 ctx 取身份覆盖 req 同名字段;ctx 无身份则返回错误(拒绝继续走)。 + - `backend/pkg/authctx/authctx_test.go` — 单测覆盖 metadata 优先 / attachment fallback / req 已被攻击者改成 999 时仍被覆盖为 ctx 中的真实身份。 +- `backend/services/userService/middleware/auth_interceptor.go` — **改**:删 `ExtractUserIDFromContext`/`ExtractStarIDFromContext`/`ValidateTokenAndExtractClaims`(迁移到 `pkg/authctx`),保留 `extractTokenFromMetadata` 内部逻辑(或一并迁移)。 +- `backend/services/notificationService/provider/notification_provider.go` — **改**:删本地 `extractUserInfo`/`parseIntValue`/`readInt64FromMD`,改用 `pkg/authctx.ExtractIdentity`。 +- `backend/services/socialService/provider/social_provider.go` — **改**:同上 + `CheckFriendship`/`GetRandomUsersByStar` 链路用覆盖后的 ctx。 +- `backend/services/galleryService/provider/gallery_provider.go` — **改**:同上。 +- `backend/services/aiChatService/provider/ai_chat_provider.go` — **改**:同上。 +- `backend/services/taskService/provider/task_mobile_provider.go` — **改**:同上。`task_internal_provider.go`(内部 RPC)保留 req 透传,加注释说明。 +- `backend/services/moderationService/provider/moderation_provider.go` — **改**:5 个 RPC 入口全部 `OverrideReporter`/`OverrideUser`,禁止读 req。 +- `backend/services/moderationService/service/report_service.go` — **改**:`SubmitReport` 接收 `reporterID` 由 provider 显式传入,service 内不再读 `req.ReporterId`。 +- `backend/services/moderationService/service/feedback_service.go` — **改**:同上。 +- `backend/services/assetService/provider/asset_provider.go` — **改**:`CheckAssetLike` 改 `ExtractIdentity` + 覆盖 req。 +- `backend/services/assetService/service/share_service.go` — **改**:`GetAssetQrcode`/`TrackShare` 接收 `sharerUserID` 由 provider 显式传入。 +- `backend/services/assetService/provider/share_provider.go` — **新建**(若 proto 有 `ShareService` RPC)或在 `asset_provider.go` 中加 `GetAssetQrcode`/`TrackShare` provider 方法(如已有 provider 文件则改)。明确从 ctx 取 `sharer_user_id` 覆盖 `req.SharerUserId`,禁止读 req。 +- `backend/services/assetService/service/ranking_service.go` — **改**:`req.UserId` 改为 ctx 注入参数(`userID, starID int64` 已由 provider 传入;内部全部用入参,不再读 req)。 +- `backend/services/socialService/service/friend_service.go` — **改**:`CheckFriendship` 签名加 `userID, starID int64`(provider 传),删除 TODO。 +- `backend/services/socialService/repository/social_repository.go` — **改**: + - `GetRandomUsersByStar` 用 `ORDER BY random()`(小表);保留 offset 实现作为 deprecated 备份但默认 random。 + - `GetUserLikedAssets` `:642-650, 657-677`、`GetMyWeekLikedAssets` `:897-907, 909-928` 的 OR 改成显式分组:`db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where(db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed'").Or("lbr.status = ?", "claimable"))`。 +- `backend/gateway/router/router.go` — **改**:删除公开组的 `auth.POST("/validate", ...)`,在 `authProtected` 组加 `authProtected.POST("/validate", authCtrl.ValidateToken)`。 +- `backend/gateway/controller/auth_controller.go` — **审**:不动实现(已只读 `req.Token`),但加注释"受 AuthMiddleware 保护,调用方需已登录"。 + +--- + +## Task 1: 抽 `pkg/authctx` 公共身份提取工具 + +**Files:** +- Create: `backend/pkg/authctx/authctx.go` +- Create: `backend/pkg/authctx/authctx_test.go` + +**Interfaces:** + +```go +// pkg/authctx/authctx.go +package authctx + +import ( + "context" + "errors" + "strconv" + + "dubbo.apache.org/dubbo-go/v3/common/constant" + "google.golang.org/grpc/metadata" +) + +type ctxKey int +const ( + userIDKey ctxKey = iota + starIDKey +) + +var ( + ErrIdentityMissing = errors.New("authctx: identity not found in context") + ErrInvalidIdentity = errors.New("authctx: identity must be positive") +) + +// ExtractIdentity returns (userID, starID) from the context. +// +// Priority: +// 1. gRPC metadata "x-user-id" / "x-star-id" (Dubbo Triple 把 HTTP header 转过来) +// 2. Dubbo attachments via constant.AttachmentKey, key "user_id" / "star_id" +// (支持 string / int / int64 / []string / []interface{}) +// +// 任何路径都拿不到则返回 ErrIdentityMissing。 +// 拿到但 < 0 也算错;== 0 视作"未设",继续 fallback。 +func ExtractIdentity(ctx context.Context) (int64, int64, error) { + uid, sid := readFromGRPCMetadata(ctx) + if uid > 0 && sid > 0 { + return uid, sid, nil + } + uid2, sid2 := readFromDubboAttachments(ctx) + if uid2 > 0 && sid2 > 0 { + return uid2, sid2, nil + } + if uid == 0 && uid2 == 0 { + return 0, 0, ErrIdentityMissing + } + if sid == 0 && sid2 == 0 { + return 0, 0, ErrIdentityMissing + } + // uid 有但 sid 没:fallback 合并 + if uid == 0 { uid = uid2 } + if sid == 0 { sid = sid2 } + if uid <= 0 || sid <= 0 { + return 0, 0, ErrInvalidIdentity + } + return uid, sid, nil +} + +// 必须有 user_id(star_id 可为 0,比如内部 RPC 不需要 star) +func ExtractUserID(ctx context.Context) (int64, error) { ... } + +// 覆盖器:把 ctx 里的身份写回 req。req 必须实现对应的小接口, +// 编译期断言在调用方加。覆盖是"无条件的"——只要 ctx 有身份,就覆盖 req 同名字段。 +type UserIDSetter interface{ SetUserID(int64) } +type StarIDSetter interface{ SetStarID(int64) } +type ReporterIDSetter interface{ SetReporterID(int64) } +type SharerUserIDSetter interface{ SetSharerUserID(int64) } + +func OverrideUser(req UserIDSetter, ctx context.Context) error { + uid, _, err := ExtractIdentity(ctx) + if err != nil { return err } + req.SetUserID(uid) + return nil +} +func OverrideStar(req StarIDSetter, ctx context.Context) error { ... } +func OverrideReporter(req ReporterIDSetter, ctx context.Context) error { ... } +func OverrideSharer(req SharerUserIDSetter, ctx context.Context) error { ... } + +// FromJWTContext 把 ParseToken 后的 (uid, sid) 灌进 ctx 给业务层用 +func WithIdentity(ctx context.Context, uid, sid int64) context.Context { ... } +``` + +- [ ] **Step 1.1 写失败测试**:`backend/pkg/authctx/authctx_test.go` + - 测试 `ExtractIdentity`: + - `TestExtractIdentity_FromGRPCMetadata`:构造 `metadata.NewIncomingContext(ctx, metadata.Pairs("x-user-id", "100", "x-star-id", "200"))`,断言返回 `(100, 200, nil)`。 + - `TestExtractIdentity_FromDubboAttachments`:构造 `context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{"user_id": int64(100), "star_id": int64(200)})`,断言同上。 + - `TestExtractIdentity_Missing`:空 ctx,断言返回 `ErrIdentityMissing`。 + - 测试覆盖器(用 mock 实现接口): + - `TestOverrideUser_OverridesAttackerValue`:ctx 有真实身份 `uid=42`,req 的 `SetUserID(999)` 表示"攻击者已塞 999",调用 `OverrideUser` 后断言 req 的真实值变 42。 + - `TestOverrideReporter_WithoutCtx`:ctx 无身份,调用 `OverrideReporter` 断言返回错误,req 的 `SetReporterID(7)` 调用次数为 0(**未覆盖 req**)。 +- [ ] **Step 1.2 实现**(如上接口),跑 `cd backend && go test ./pkg/authctx/...` 通过。 +- [ ] **Step 1.3** `cd backend && go build ./...` 通过。 + +--- + +## Task 2: 接入 moderationService(最高风险:举报冒用) + +**Files:** +- Edit: `backend/services/moderationService/provider/moderation_provider.go` +- Edit: `backend/services/moderationService/service/report_service.go` +- Edit: `backend/services/moderationService/service/feedback_service.go` +- Create (test): `backend/services/moderationService/provider/moderation_provider_test.go` + +**Interfaces:** + +`report_service.go` 新签名(service 层也不再读 req 的身份): +```go +// 旧:SubmitReport(ctx, req) +// 新:SubmitReport(ctx, reporterID, req) // reporterID 由 provider 注入 +func (s *ReportService) SubmitReport(ctx context.Context, reporterID int64, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) +// 内部所有 req.ReporterId → reporterID +// ListMyReports(ctx, userID, status, page, pageSize) — 签名不变,userID 由 provider 传入 +// GetReport(ctx, userID, id) — 同上 +``` + +`feedback_service.go` 同理: +```go +SubmitFeedback(ctx, userID, starID, req) +ListMyFeedbacks(ctx, userID, status, page, pageSize) +GetFeedback(ctx, userID, id) +``` + +`moderation_provider.go` 每个 RPC: +```go +func (p *ModerationProvider) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) { + uid, _, err := authctx.ExtractIdentity(ctx) + if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") } + return p.report.SubmitReport(ctx, uid, req) +} +func (p *ModerationProvider) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) { + uid, _, err := authctx.ExtractIdentity(ctx) + if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") } + return p.report.ListMyReports(ctx, uid, req.Status, int(req.Page), int(req.PageSize)) +} +func (p *ModerationProvider) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) { + uid, _, err := authctx.ExtractIdentity(ctx) + if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") } + return p.report.GetReport(ctx, uid, req.Id) +} +// SubmitFeedback/ListMyFeedbacks/GetFeedback 同模式 +``` + +- [ ] **Step 2.1 写失败测试** `moderation_provider_test.go`: + - `TestSubmitReport_RejectsForgedReporterId`:ctx 有真实身份 `uid=100`,req.ReporterId=999(模拟攻击者伪造)。调用 SubmitReport,断言: + - 返回的 report 里 `ReporterID == 100`(不是 999)。 + - `report_service.SubmitReport` 被调用时收到的 reporterID 参数 == 100。 + - 用 mock `ReportService`(testify mock)拦截 `SubmitReport` 入参。 + - `TestSubmitReport_NoIdentity_ReturnsUnauthenticated`:ctx 无身份,断言返回 `codes.Unauthenticated`。 + - `TestGetReport_PreventReadingOthersReport`:ctx uid=100,req.Id=被另一用户 (uid=200) 创建的 report id。断言 service.GetReport 入参是 (100, id)(不再用 req.ReporterId 作 userID 判 owner),service 层已有的 `report.ReporterID != userID → ErrReportNotFound` 正确触发。 +- [ ] **Step 2.2 改 `report_service.go` 签名**:所有 `req.ReporterId` → `reporterID` 参数;`GetReport` 加 `userID` 参数;删除所有 `req.ReporterId == ...` 比较。 +- [ ] **Step 2.3 改 `feedback_service.go` 签名**:同上(userID/starID 由参数传入)。 +- [ ] **Step 2.4 改 `moderation_provider.go`**:5 个 RPC 全部走 `authctx.ExtractIdentity`,不再读 req 身份。 +- [ ] **Step 2.5** `go test ./services/moderationService/...` 通过 + `go build ./...` 通过。 + +--- + +## Task 3: 接入 assetService 的 `CheckAssetLike` + `ShareService` + +**Files:** +- Edit: `backend/services/assetService/provider/asset_provider.go:477-517`(CheckAssetLike) +- Edit: `backend/services/assetService/service/share_service.go`(GetAssetQrcode / TrackShare) +- Edit/Add: `backend/services/assetService/provider/` 中的 share provider(如 proto 已定义 ShareService handler 则改对应文件;否则在 `asset_provider.go` 同文件加 wrapper) + +**Interfaces:** + +`asset_provider.go`: +```go +func (p *AssetProvider) CheckAssetLike(ctx context.Context, req *pb.CheckAssetLikeRequest) (*pb.CheckAssetLikeResponse, error) { + uid, sid, err := authctx.ExtractIdentity(ctx) + if err != nil { + return &pb.CheckAssetLikeResponse{ Base: unauthBase() }, status.Error(codes.Unauthenticated, "identity required") + } + // req.UserId/StarId 一律不读 + isLiked, err := p.assetLikeService.CheckAssetLike(ctx, req.AssetId, uid, sid) + ... +} +``` + +`share_service.go`: +```go +// 旧:GetAssetQrcode(ctx, req) — 内部读 req.SharerUserId +// 新:GetAssetQrcode(ctx, sharerUserID, req) — provider 注入 +func (s *ShareService) GetAssetQrcode(ctx context.Context, sharerUserID int64, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) +// 内部所有 req.SharerUserId → sharerUserID +// TrackShare(ctx, sharerUserID, req) 同上 +``` + +share provider(如已有): +```go +func (p *ShareProvider) GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) { + uid, _, err := authctx.ExtractIdentity(ctx) + if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") } + return p.shareSvc.GetAssetQrcode(ctx, uid, req) +} +``` + +- [ ] **Step 3.1 写失败测试** `asset_provider_test.go`: + - `TestCheckAssetLike_RejectsForgedUserId`:ctx uid=100/sid=200,req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。 + - `TestGetAssetQrcode_RejectsForgedSharer`:ctx uid=100,req.SharerUserId=999。断言落盘 `share_events.sharer_user_id == 100` 且 OSS key 含 `_100_`。 + - `TestGetAssetQrcode_NoIdentity`:ctx 无身份,断言返回 Unauthenticated。 +- [ ] **Step 3.2 改 `asset_provider.go`** `CheckAssetLike`:删 `userID := req.UserId` / `starID := req.StarId`,改 `authctx.ExtractIdentity`。 +- [ ] **Step 3.3 改 `share_service.go`** 两个方法签名 + 内部所有 `req.SharerUserId` → `sharerUserID`。 +- [ ] **Step 3.4 改 share provider**:5 个 RPC(如果有 GetAssetQrcode/TrackShare)全走 ctx 注入。 +- [ ] **Step 3.5** `go test ./services/assetService/...` 通过 + `go build ./...` 通过。 + +--- + +## Task 4: 接入 socialService 的 `CheckFriendship`(starID=0 修复) + +**Files:** +- Edit: `backend/services/socialService/service/friend_service.go:665-700` (CheckFriendship) +- Edit: `backend/services/socialService/provider/social_provider.go`(`CheckFriendship` provider 段) +- Edit: `backend/services/socialService/repository/social_repository.go` `CheckFriendship`(保留方法,签名补 `starID`;调用方全部用真 starID) + +**Interfaces:** + +`friend_service.go`: +```go +// 旧:CheckFriendship(ctx, req) — req.UserId, starID=0 +// 新:CheckFriendship(ctx, userID, starID, friendUserID) — provider 注入 +func (s *friendService) CheckFriendship(ctx context.Context, userID, starID, friendUserID int64) (*pb.CheckFriendshipResponse, error) +``` + +`social_provider.go`: +```go +func (p *SocialProvider) CheckFriendship(ctx context.Context, req *pb.CheckFriendshipRequest) (*pb.CheckFriendshipResponse, error) { + uid, sid, err := extractUserInfo(ctx) // 改用 authctx.ExtractIdentity + if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") } + if req.FriendUserId == 0 { return nil, status.Error(codes.InvalidArgument, "friend_user_id required") } + return p.friendService.CheckFriendship(ctx, uid, sid, req.FriendUserId) +} +``` + +`social_repository.go` `CheckFriendship(userID, friendUserID, starID int64)` 签名已含 starID(`friend_service.go:683` 调用已传 0),无需大改——只需把调用点改为传真 sid。 + +- [ ] **Step 4.1 写失败测试** `friend_service_test.go`: + - `TestCheckFriendship_UsesCtxStarID`:ctx uid=100/sid=200,req.FriendUserId=200。断言调 `socialRepo.CheckFriendship` 时第 3 参数是 200 而非 0。 + - `TestCheckFriendship_NoIdentity`:ctx 无身份,断言 Unauthenticated。 +- [ ] **Step 4.2 改 `friend_service.go` `CheckFriendship` 签名**:删 `starID := int64(0) // TODO`,改 provider 注入。 +- [ ] **Step 4.3 改 `social_provider.go` CheckFriendship provider 方法**:走 ctx。 +- [ ] **Step 4.4** `go test ./services/socialService/...` 通过 + `go build ./...` 通过。 + +--- + +## Task 5: 接入 galleryService / taskService / aiChatService(统一替换散落实现) + +**Files:** +- Edit: `backend/services/galleryService/provider/gallery_provider.go:462` 删本地 `extractUserInfoFromDubboAttachments`,改 import `pkg/authctx`;所有调用点 `extractUserInfoFromDubboAttachments(ctx)` → `authctx.ExtractIdentity(ctx)`。`gallery_provider.go:403` 的 `req.UserId`(target_uid)是合法入参(查询他人列表),**不覆盖**,仅加注释"这是 target_uid 而非调用方身份"。 +- Edit: `backend/services/aiChatService/provider/ai_chat_provider.go:367` 同上。 +- Edit: `backend/services/taskService/provider/task_mobile_provider.go:35` 同上;`task_internal_provider.go:32-48`(内部 RPC)保留 req 透传,但加注释"内部 RPC,需由调用方保证 user_id 来自可信源"。 +- Edit: `backend/services/notificationService/provider/notification_provider.go:161` 同上。 + +- [ ] **Step 5.1 写失败测试**(已有覆盖可跳过,新服务至少加 1 个): + - `gallery_provider_test.go::TestGetMyGallery_NoIdentity`:ctx 无身份,断言 Unauthenticated。 +- [ ] **Step 5.2** 替换 5 个文件的本地 `extractUserInfo*` 为 `authctx.ExtractIdentity`,删除已无用的 `parseIntValue`/`readInt64FromMD` 等内部辅助。 +- [ ] **Step 5.3** `go build ./...` 通过。 + +--- + +## Task 6: 删 `userService/middleware/auth_interceptor.go` 已迁移函数 + +**Files:** +- Edit: `backend/services/userService/middleware/auth_interceptor.go` + +- [ ] **Step 6.1** 验证无外部引用: + ```bash + grep -rn "auth_interceptor\." backend/services --include="*.go" + grep -rn "ExtractUserIDFromContext\|ExtractStarIDFromContext\|ExtractUserInfoFromContext\|ValidateTokenAndExtractClaims" backend --include="*.go" + ``` + 预期所有调用方都在 Task 1-5 已切到 `pkg/authctx`。 +- [ ] **Step 6.2** 删除 `ExtractUserIDFromContext`/`ExtractStarIDFromContext`/`ExtractUserInfoFromContext`/`ValidateTokenAndExtractClaims` 函数本体,保留 `extractTokenFromMetadata`(作为内部 helper,仅供 `pkg/authctx` 通过 parse JWT 时使用)或一并迁过去。**需用户确认**:是否要把 `pkg/jwt.ParseToken` 也挪进 `pkg/authctx`;默认保留原文件,把 `extractTokenFromMetadata` 迁移到 `pkg/authctx` 私有。 +- [ ] **Step 6.3** `go build ./...` 通过。 + +--- + +## Task 7: socialService — OR 子句括号优先级修复 + +**Files:** +- Edit: `backend/services/socialService/repository/social_repository.go`: + - `GetUserLikedAssets`(`:642-650` count 和 `:657-677` data 两个查询):`(line 648 和 672)` + - `GetMyWeekLikedAssets`(`:897-907` count 和 `:909-928` data):`(line 903 和 923)` +- Create (test): `backend/services/socialService/repository/social_repository_test.go` + +**Interfaces:** + +修复前(错): +```go +db.Where("a.deleted_at IS NULL AND a.is_active = ?", true). + Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'") +``` + +修复后(显式分组 OR): +```go +sub := r.db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status, '') <> 'claimed'"). + Or("lbr.status = ?", "claimable") +db.Where("a.deleted_at IS NULL AND a.is_active = ?", true). + Where(sub) +``` + +等价 SQL: +```sql +WHERE a.deleted_at IS NULL AND a.is_active = $1 + AND ( + (e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') <> 'claimed' + OR lbr.status = 'claimable' + ) +``` + +`GetMyWeekLikedAssets` 同样 pattern(OR 是单条件 `e.expire_at > ?`,**不需要修**——它实际只有 AND,没有错)。但 line 903/923 的 `(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?` 整段嵌在外层 `Where` 链里,需要确认 GORM 行为: + +- 用 `gorm.io/gorm` 测出当前行为:多个 `Where(...)` 链式会拼 `AND`,所以最终 SQL 是 `... AND `,**括号优先级没问题**。但若担心(审计明确指出"OR 括号优先级问题,可能让已删除资产漏进点赞列表"),加显式分组保险。 + +实际可改:用 `db.Where(sub)` 显式取代第二个 `Where(...)`,避免任何隐式 AND 拼接。 + +- [ ] **Step 7.1 写失败测试** `social_repository_test.go`: + - `TestGetUserLikedAssets_ExcludesClaimedOnly`:构造数据:资产 A 的 exhibition 已被软删 + lbr.status='claimed';资产 B 的 exhibition 软删 + lbr.status='claimable'。调用 `GetUserLikedAssets`,断言:返回 [B],不返回 A(修复前 A 会因 OR 优先级问题漏进)。 + - `TestGetMyWeekLikedAssets_ExcludesDeletedExhibition`:构造数据:本周点赞 + 资产 active 但 exhibition 软删 + lbr 无 claimable。断言被排除。 +- [ ] **Step 7.2 改 `social_repository.go` 两处 OR** 为显式分组。 +- [ ] **Step 7.3** `go test ./services/socialService/...` 通过。 + +--- + +## Task 8: socialService — `GetRandomUsersByStar` 真随机 + +**Files:** +- Edit: `backend/services/socialService/repository/social_repository.go:461-515`(`GetRandomUsersByStar`) + +**Interfaces:** + +修复前: +```go +rand.Seed(time.Now().UnixNano()) +randomOffset := rand.Int63n(total) +db.Order("id ASC").Limit(count).Offset(int(randomOffset)) +``` + +修复后(小表场景,PostgreSQL `TABLESAMPLE` 不可控分布,直接用 `ORDER BY random()` + `LIMIT`): +```go +err = r.db.Model(&models.FanProfile{}). + Select("user_id", "nickname"). + Where("star_id = ? AND is_active = ?", starID, true). + Order("random()"). + Limit(count). + Find(&profiles).Error +``` + +> 注:`ORDER BY random()` 在大表上性能差(10 万+ 行)。当前 fan_profiles 在 star 维度规模可控(千级),可接受;若后续规模上升,切换 `TABLESAMPLE SYSTEM (n)` 或预生成 `random_user_pool` Redis 集合。 + +- [ ] **Step 8.1 写失败测试** `social_repository_test.go`: + - `TestGetRandomUsersByStar_NotContinuousSegment`:构造 100 个 fan_profile,重复调用 `GetRandomUsersByStar(starID, 5)` 20 次,断言:返回的 `(user_id)` 集合**不连续**(修复前 OFFSET 取连续 5 个)。 + - `TestGetRandomUsersByStar_ReproducibilityNotRequired`:连续调用两次,结果**应不同**(修复前同纳秒随机种子相同)。 +- [ ] **Step 8.2 改 `social_repository.go`** 用 `Order("random()")`,删 `rand.Seed`/`rand.Int63n`/`Offset`。 +- [ ] **Step 8.3** `go test ./services/socialService/...` 通过。 + +--- + +## Task 9: gateway — `ValidateToken` 移出公开 `/auth` 组 + +**Files:** +- Edit: `backend/gateway/router/router.go:144-179` +- Edit: `backend/gateway/controller/auth_controller.go:280-312`(仅注释) + +**Interfaces:** + +修复前 `router.go:147-157`: +```go +auth := v1.Group("/auth") +{ + auth.POST("/register", authCtrl.Register) + ... + auth.POST("/validate", authCtrl.ValidateToken) // 公开! + ... +} +``` + +修复后: +```go +auth := v1.Group("/auth") +{ + auth.POST("/register", authCtrl.Register) + auth.POST("/login", authCtrl.Login) + // validate / refresh / logout 全部移到 authProtected + auth.POST("/check-nickname", authCtrl.CheckNickname) + auth.POST("/check-mobile", authCtrl.CheckMobile) + auth.POST("/send-code", authCtrl.SendCode) + auth.POST("/verify-code", authCtrl.VerifyCode) + auth.POST("/reset-password", authCtrl.ResetPassword) +} + +authProtected := v1.Group("/auth") +authProtected.Use(middleware.AuthMiddleware()) +{ + authProtected.GET("/me", userCtrl.GetCurrentUser) + authProtected.POST("/refresh", authCtrl.RefreshToken) + authProtected.POST("/logout", authCtrl.Logout) + authProtected.POST("/validate", authCtrl.ValidateToken) // 受保护 +} +``` + +- [ ] **Step 9.1 写失败测试**(gateway router 集成测试或 curl + 集成): + - `TestValidateTokenRoute_RequiresAuth`:构造不带 token 的 HTTP POST `/api/v1/auth/validate`,断言 401。 + - `TestValidateTokenRoute_WithToken_Succeeds`:带有效 JWT,断言 200 + 验证结果。 +- [ ] **Step 9.2 改 `router.go`**:从公开组移除 `/validate`,加入 `authProtected` 组。 +- [ ] **Step 9.3 改 `auth_controller.go` ValidateToken**:加注释"本接口已被 AuthMiddleware 保护"。 +- [ ] **Step 9.4** `go build ./...` 通过 + 跑集成测试或本地 `curl` 验证。 + +--- + +## Task 10: 全局回归 + lint 自审 + +- [ ] **Step 10.1** `cd backend && go build ./...` 通过。 +- [ ] **Step 10.2** `cd backend && go test ./...` 全部通过(mock 服务可能因 signature 变更需要 fix)。 +- [ ] **Step 10.3** 全局 grep 自审"未覆盖点": + ```bash + grep -rn "req.UserId\|req.StarId\|req.ReporterId\|req.SharerUserId" \ + backend/services/{moderation,asset,social,gallery,task,notification,aiChat}Service \ + --include="*.go" | grep -v "_test.go" + ``` + 预期:只剩 + - `gallery_provider.go:403` 的 `target_user_id`(合法入参,注释已加) + - `task_internal_provider.go`(内部 RPC,注释已加) + - `ranking_service.go`(仅当 provider 已改用 ctx 注入并签名变更后,不再有 `req.UserId` 读点) +- [ ] **Step 10.4** 端到端冒烟(本地 `top-fans` 库): + - 启动 assetService + userService + gateway。 + - 用 `grpcurl`(或脚本)伪造 RPC:ctx 不带 `x-user-id`,带 `req.UserId=999`,断言返回 `Unauthenticated`。 + - 用合法 JWT 发起 `SubmitReport`,构造 `req.ReporterId=888`,断言落库 `reports.reporter_id == JWT 中的真实 uid`(不是 888)。 + - `GET /api/v1/auth/validate` 不带 token,断言 401。 +- [ ] **Step 10.5** 文档同步:若有任何行为变更,更新 `docs/specs/2026-07-21-backend-remediation-plan.md` 批次 2 章节的"修复方案"为"已实施"。 + +--- + +## Self-Review + +> 按 `CLAUDE.md` 全局自审规则(章节通读清单 + 跨章节引用一致性)。 + +### 修改的章节(来自 `docs/specs/2026-07-21-backend-remediation-plan.md`) + +- §二 批次 2 主条目("修复方案" 1/2/3)→ Task 1/2-5/9 覆盖 +- §二 批次 2.1 social 三条 → Task 4/7/8 覆盖 +- §五 回归验证清单(伪造身份拦截、端口配置通过)→ Task 10 覆盖 + +### 未改动但通读的章节 + +- §一 方案概述(确认批次排序、关键决策、MVP 先行原则未被破坏——本批次未引入新的 Provider 抽象) +- §二 批次 0/1/3/4(确认未跨批次耦合:本批次不动财务、不动 mint、不动 MQ;端口/探针/starbook 决断未触) +- §二 批次 5 路线图(确认"加 lint 禁止新增跨服务 import"未在本批次落地——属批次 5 治理) +- §三 问题 → 修复映射表(核对 P0-3、§三 P1 social 三条均已映射) +- §四 存量数据修复脚本规范(本批次无 DB 变更,无需 setval) +- §六 备注(与本批次兼容) + +### 跨章节引用一致性 + +- Task 1(`pkg/authctx`)→ Task 2-5/6(5 份散落副本删除):一致 +- Task 2(moderation service 签名变更)→ provider 必须传 userID/reporterID(Task 2.4):一致 +- Task 3(share service 签名变更)→ provider 必须传 sharerUserID(Task 3.4):一致 +- Task 4(friend_service.CheckFriendship 签名加 starID)→ provider 已传(Task 4.3):一致 +- Task 6(删除 userService/middleware 的已迁函数)→ Task 1 的 `pkg/authctx` 替代(Task 1.1):一致 +- Task 9(router 移动 `/validate`)→ auth_controller 已只读 `req.Token`(Task 9.3 注释):一致 + +### Go 编译验证清单 + +- `pkg/authctx` 新包:导入路径 `github.com/topfans/backend/pkg/authctx`,需 `backend/go.mod` 已有 `dubbo.apache.org/dubbo-go/v3` 和 `google.golang.org/grpc`(已有)。✓ +- provider 单测需 mock `ReportService`/`ShareService`/`FriendService` 等 interface,用 testify mock(已有 `github.com/stretchr/testify`)。✓ +- service 签名变更(SubmitReport / SubmitFeedback / CheckFriendship / GetAssetQrcode / TrackShare)会破坏现有调用方;所有调用方都在被改的 provider 文件内(grpc handler),无外部 main.go 直接调 service。grep 验证:✓ +- Task 6 删除 `ValidateTokenAndExtractClaims` 等函数:grep 无外部调用方(userService 自用 + Task 1 替代)。✓ + +### 优先级 + +- **P0(必做)**:Task 1/2/3/4/9(5 个核心鉴权边界);Task 7/8(social 正确性审计明确列出)。 +- **P1(强烈建议)**:Task 5(替换散落副本,不替换也能跑但违反代码一致性);Task 6(删除死代码,否则 `pkg/authctx` 与 userService/middleware 双重实现不一致)。 +- **P2(可推迟)**:Task 10.5 文档同步。 + +### 风险 + +- `share_service.go:246` `TrackShare` 之前依赖 `req.ClientTs`(客户端时间戳),不变;只换身份来源。 +- `asset_provider.go:483` `CheckAssetLike` 当前调用 `p.assetLikeService.CheckAssetLike(ctx, req.AssetId, userID, starID)`,签名一致;改 ctx 取值不影响下游。 +- `social_provider.go` 有 10+ 处 `extractUserInfo(ctx)` 调用,Task 5.2 批量替换即可。 +- `feedback_service.go` 内部 `req.StarId > 0` 判断(`:64`)是给 star 可选的反馈业务;Task 2.3 改成参数后保留 `starID > 0` 判断。 + +### 验证检查清单 + +- [ ] 提交 `go build ./...` 无 error +- [ ] 全部 service `go test ./...` 通过 +- [ ] `grep -rn "req.UserId\|req.StarId\|req.ReporterId\|req.SharerUserId" backend/services/{moderation,asset,social}/...` 只剩注释 + 合法入参 +- [ ] `grpcurl` 伪造身份 RPC 返回 Unauthenticated +- [ ] `/api/v1/auth/validate` 不带 token 返回 401 +- [ ] `social_repository.GetRandomUsersByStar` 重复调用结果不连续 +- [ ] `exhibition` 软删 + lbr.status='claimed' 的资产不再出现在点赞列表 + +### 待用户确认的决策点 + +1. **是否把 `pkg/jwt.ParseToken` 调用也搬到 `pkg/authctx`**(默认保留在原处,`authctx` 只做 metadata 提取与覆盖)。 +2. **`GetRandomUsersByStar` 真随机策略**:`ORDER BY random()` vs 预生成 Redis 随机池。默认 `random()`,小表 OK。 +3. **`ValidateToken` 接口设计**:保留接受 `{token: "..."}` body 的 RPC(仅要求已登录才能调)vs 改成基于调用方自身 JWT 自动校验。默认保留 body 形式(向后兼容)。 + +### 不在本次范围 + +- 内部 RPC mTLS / 签名(批次 5 治理) +- 跨服务 import 限制(批次 5) +- JWT 全局密钥治理(批次 3.5) +- Login 限流/枚举修复(批次 3.2) + +--- + +## 备注 + +- 本 plan 严格遵循 `CLAUDE.md` 的"接口开发规范"(分层、DTO、错误码、日志、测试)。 +- 批次 2 优先级低于批次 1(财务资损),但 P0-3 越权属于安全 P0,可与批次 1 并行(无文件冲突)。 +- 任何 service 签名变更(Task 2/3/4)都属内部重构,不动 proto 契约,向后兼容。 +- 所有 commit 步骤需用户明确指示。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-21-config-deploy.md b/docs/superpowers/plans/2026-07-21-config-deploy.md new file mode 100644 index 0000000..ab3ae23 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-config-deploy.md @@ -0,0 +1,557 @@ +# 配置与部署 (批次4) Implementation Plan + +REQUIRED SUB-SKILL: superpowers:writing-plans +(实施阶段使用 superpowers:executing-plans;code-review 阶段使用 superpowers:requesting-code-review) + +> 配套审计:`docs/backend-audit-2026-07-21.md` §二 P0-6/P0-7、§三 P1 配置、§四 P2 +> 配套修复方案:`docs/specs/2026-07-21-backend-remediation-plan.md` 批次 4(4.1 / 4.2 / 4.3 / 4.4 / 4.5 / 4.9) +> 基线 commit:`b7f8f1b724e5`,分支 `feat/uni` + +--- + +## Goal + +消除 backend 配置/部署层的全部 P0 风险(端口不一致 + starbookService 孤儿)与剩余 P1/P2 卫生项(健康探针、大二进制入库、序列脚本、`.env` 漂移、若干小修),使本地默认值与生产 env 覆盖之间形成**自校验**的部署契约,并通过一次回归把所有"硬编码"参数集中到 4 处可验证的位置(gateway config / 各 main flag / helm values / docker compose)。 + +完成后达成: +- `go test ./... -run TestPortAlignment` 通过(4.1)。 +- `git ls-files` 不含 `backend/{assetService,gateway-fixed,test,cleanup-orphan-avatars}` 四大二进制;`backend/services/starbookService/` 被 git 删除(4.2 + 4.3)。 +- k8s `notificationservice` / `moderationservice` 探针路径与实际 handler 一致;本地 `curl :21010/health` 返回 `{"status":"ok"}`(4.3)。 +- `scripts/create_gallery_test_users.go` 输出 SQL 末尾含 `setval(...)`;跑完不报 `duplicate key`(4.4)。 +- `backend/.env.example` 与 `backend/.env` 键集合一致(含 `WS_AI_CHAT_PATH` 等),`.example` 不含真实密钥(4.5)。 +- §四 P2 八项全部清理或明确 defer(4.9)。 + +## Architecture + +本批次不引入新架构,纯**配置归一**与**卫生清理**: + +``` + ┌──────────────────────────────────────┐ + │ k8s/helm/topfans/values.yaml (端口) │ ← 单一事实来源 + └──────────────┬───────────────────────┘ + │ helm install + ┌────────────────────┼────────────────────────┐ + ▼ ▼ ▼ + services/*/main.go docker-compose.prod.yml gateway/config/config.go + (flag 默认值 = helm) (DUBBO_*_SERVICE_URL) (DUBBO_*_SERVICE_URL 默认) + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌────────────┐ ┌─────────────┐ + │配置断言测试 │ ──────▶│ 4.1 校验 │ ◀──────│ 4.1 校验 │ + └─────────────┘ └────────────┘ └─────────────┘ +``` + +依赖关系(必须按顺序): +1. **4.2 starbookService 删除**先做(它依赖 4.3 探针清理 + P0-4 跨服务 import 解耦)—— 完成前 `go build ./...` 会因 `starbookService` 不在 `go.work` 而失败。 +2. **4.1 端口对齐**与 **4.2 starbook 删除**独立可并行。 +3. **4.3 探针 + 大二进制**与 4.4 / 4.5 可独立并行。 +4. **4.9 P2 清理**作为伴随项随 4.1–4.5 顺手做;只有 "JWT 失效" 和 "PII 日志" 单独任务化。 + +## Tech Stack + +- **语言**:Go 1.25.5(`go.work`) +- **测试**:Go testing + testify +- **K8s 配置**:Helm 3 + values.yaml +- **Docker**:docker-compose v2 + Dockerfile.services +- **数据库**(仅 4.4 序列脚本):PostgreSQL 16 +- **静态检查**(后续可加,本批次不引入):golangci-lint + +--- + +## Global Constraints + +- **不动架构**:本批次不拆库、不拆 `pkg/models`、不引入新 interface;纯"配置正确化"。 +- **序列规范**:任何手动指定 id 的 INSERT,SQL 末尾必须 `SELECT setval('
_id_seq', (SELECT MAX(id) FROM
));`,遵循 `CLAUDE.md` 强制规则。 +- **commit 守则**:每个 Task 完成后**不自动 commit**,等用户说"帮我 commit"再提交;commit message 用 `Co-Authored-By: Claude `。 +- **真实命令**:所有 bash 命令需在本地真实可执行(`backend/` 目录、`go.work` 模块、`k8s/helm/topfans/` Helm chart 都在仓库内)。 +- **回归检查**:每个 Task 完成后跑 `go build ./...`(含 `go.work` 全部模块)+ 端口断言测试。 +- **不动产物**:`unpackage/dist/*`、`backend/bin/*` 是构建产物,禁止手动改。 +- **依赖前置**:4.2 starbookService 删除依赖 P0-4 跨服务 import 解耦审计结论(已确认);若 P0-4 实施批次尚未执行 starbook 解耦,本批次单独完成"assetService 内化 `AssetRegistryRepository` 实现"。 +- **MVP 先行**:不要为"未来 12+ 服务"提前做端口配置中心化;本次仅做"四层端口默认值收敛 + 断言测试"。 + +--- + +## File Structure + +**修改清单(按 Task 顺序)**: + +| Task | 关键文件 | 类型 | +|------|----------|------| +| 4.1 | `backend/gateway/config/config.go`、`backend/services/galleryService/main.go`、`backend/services/userService/main.go`、`backend/services/activityService/main.go`、`backend/services/taskService/main.go`、`backend/services/statisticService/main.go`、`backend/services/notificationService/main.go`、`backend/services/moderationService/main.go`、`backend/services/aiChatService/main.go`、`backend/services/socialService/main.go`、`backend/services/assetService/main.go`、`backend/docker-compose.local.yml`、`backend/docker-compose.prod.yml`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml`、`backend/services/gateway/config/port_test.go` (新增) | 修 | +| 4.2 | `backend/services/starbookService/` (整目录)、`backend/services/assetService/main.go`、`backend/services/assetService/service/mint_service.go`、`backend/services/assetService/repository/asset_registry_repository.go` (新增)、`backend/services/assetService/service/asset_service.go` (registryRepo 引用)、`backend/gateway/config/config.go`、`backend/go.work`、`backend/dev.sh`、`docker/Dockerfile.services`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml`、`k8s/helm/topfans/templates/starbookservice/deployment.yaml` (删) | 删 + 修 | +| 4.3 | `backend/pkg/health/health.go`、`backend/services/assetService/main.go`、`backend/services/galleryService/main.go`、`backend/services/userService/main.go`、`backend/services/notificationService/main.go`、`backend/services/moderationService/main.go` (注册 `/healthz` alias)、`k8s/helm/topfans/values.yaml`、`k8s/helm/topfans/templates/notificationservice/deployment.yaml`、`k8s/helm/topfans/templates/moderationservice/deployment.yaml`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`backend/.gitignore`、`backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars` (git rm --cached) | 修 + rm | +| 4.4 | `backend/scripts/create_gallery_test_users.go` | 修 | +| 4.5 | `backend/.env.example`、`backend/.env`、`backend/services/gateway/config/env_audit_test.go` (新增) | 修 + 新增 | +| 4.9 | `backend/gateway/controller/asset_controller.go`、`backend/gateway/controller/auth_controller.go`、`backend/gateway/controller/user_controller.go`、`backend/services/userService/service/user_service.go`、`backend/services/aiChatService/provider/ai_chat_provider.go`、`backend/services/mint_service.go`、`backend/services/activityService/service/activity_service.go`、`backend/pkg/peripheral/sign.go`、`backend/pkg/mq/streams/adapter.go`、`backend/pkg/mq/asynq/adapter.go`、`backend/services/galleryService/mq/consumer.go` | 修 | +| 自审 | `docs/superpowers/plans/2026-07-21-config-deploy.md` (本文) | — | + +**新增测试文件**: +- `backend/gateway/config/port_test.go`(4.1 端口断言测试) +- `backend/gateway/config/env_audit_test.go`(4.5 env 键集合对比) + +--- + +### Task 1: 端口对齐 + 配置断言测试(4.1) + +> 对应审计 P0-6 与修复方案 4.1。 + +**Files**: +- `backend/gateway/config/config.go:162-165` —— 修正 `Dubbo.GalleryServiceURL`/`ActivityServiceURL`/`StarbookServiceURL` 默认端口 +- `backend/services/galleryService/main.go:36,44` —— `port` 默认 20001;`taskServiceURL` 默认改 `tri://localhost:20006` +- `backend/services/userService/main.go:283` —— 删除硬编码 `protocol.WithPort(20000)`,改用 `*port` +- `backend/services/assetService/main.go:38` —— 确认 20003 默认 +- `backend/services/activityService/main.go` —— 默认端口确认 20004 +- `backend/services/taskService/main.go` —— 默认端口确认 20006 +- `backend/services/statisticService/main.go` —— 默认 20009 +- `backend/services/notificationService/main.go` —— 默认 20010 +- `backend/services/moderationService/main.go` —— 默认 20011 +- `backend/services/aiChatService/main.go` —— 默认 20008 +- `backend/services/socialService/main.go` —— 默认 20002 +- `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml` —— 同步 `DUBBO_*_SERVICE_URL` 默认值 +- `backend/gateway/config/port_test.go` (新增) —— 配置断言测试 + +**Interfaces**: +```go +// backend/gateway/config/config.go (excerpt) +type DubboConfig struct { + UserServiceURL string // tri://127.0.0.1:20000 + SocialServiceURL string // tri://127.0.0.1:20002 + AssetServiceURL string // tri://127.0.0.1:20003 + GalleryServiceURL string // tri://127.0.0.1:20001 ← 修正 + ActivityServiceURL string // tri://127.0.0.1:20004 ← 修正 + TaskServiceURL string // tri://127.0.0.1:20006 + StarbookServiceURL string // 删除(4.2) + AIChatServiceURL string // tri://127.0.0.1:20008 + StatisticServiceURL string // tri://127.0.0.1:20009 + NotificationServiceURL string // tri://127.0.0.1:20010 + ModerationServiceURL string // tri://127.0.0.1:20011 +} +``` + +**Steps**: +- [ ] 读 `backend/gateway/config/config.go:158-170`,记下当前 11 个 `DUBBO_*_SERVICE_URL` 默认值。 +- [ ] 改 `backend/gateway/config/config.go:162`:`getEnv("DUBBO_GALLERY_SERVICE_URL", "tri://127.0.0.1:20001")`。 +- [ ] 改 `backend/gateway/config/config.go:163`:`getEnv("DUBBO_ACTIVITY_SERVICE_URL", "tri://127.0.0.1:20004")`。 +- [ ] 验证 `Task/Asset/AIChat/Statistic/Notification/Moderation/Social/User` 默认值与实际服务 `main.go` 的 `flag.Int("port", ...)` 默认一致(读 `backend/services//main.go` 头部 `var port` 块)。 +- [ ] 改 `backend/services/galleryService/main.go:44`:`taskServiceURL = flag.String("task-service-url", getEnv("TASK_SERVICE_URL", "tri://localhost:20006"), ...)`。 +- [ ] 改 `backend/services/userService/main.go:283`:`protocol.WithPort(*port)`(替代硬编码 20000),同时保留 `port` flag 默认 20000。 +- [ ] 创建 `backend/gateway/config/port_test.go`,内容(关键骨架): + ```go + package config + + import ( + "regexp" + "strconv" + "testing" + ) + + // 各服务 flag 默认端口(与 /main.go 顶部 var port = flag.Int(...) 一致) + var serviceDefaultPorts = map[string]int{ + "user": 20000, + "social": 20002, + "asset": 20003, + "gallery": 20001, + "activity": 20004, + "task": 20006, + "aiChat": 20008, + "statistic": 20009, + "notification": 20010, + "moderation": 20011, + } + + // TestGatewayDubboPortsMatchServiceDefaults 校验网关默认 URL 端口 == 各服务 main.go 绑定端口 + func TestGatewayDubboPortsMatchServiceDefaults(t *testing.T) { + cfg := Load() + cases := map[string]string{ + "user": cfg.Dubbo.UserServiceURL, + "social": cfg.Dubbo.SocialServiceURL, + "asset": cfg.Dubbo.AssetServiceURL, + "gallery": cfg.Dubbo.GalleryServiceURL, + "activity": cfg.Dubbo.ActivityServiceURL, + "task": cfg.Dubbo.TaskServiceURL, + "aiChat": cfg.Dubbo.AIChatServiceURL, + "statistic": cfg.Dubbo.StatisticServiceURL, + "notification": cfg.Dubbo.NotificationServiceURL, + "moderation": cfg.Dubbo.ModerationServiceURL, + } + re := regexp.MustCompile(`:(\d+)$`) + for name, url := range cases { + m := re.FindStringSubmatch(url) + if len(m) != 2 { t.Fatalf("%s: URL %q 缺端口", name, url) } + got, _ := strconv.Atoi(m[1]) + want := serviceDefaultPorts[name] + if got != want { + t.Errorf("%s: 网关默认端口=%d, 服务实际默认=%d", name, got, want) + } + } + } + ``` +- [ ] 跑 `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults -v` —— 必须通过。 +- [ ] 改 `backend/.env.example:21-26`:删除 `DUBBO_SOCIAL_SERVICE_URL=tri://127.0.0.1:20001` 错值(应是 20002),并按上面 11 个 URL 重新列。 +- [ ] 改 `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`:所有 `DUBBO_*_SERVICE_URL` 默认端口与上述一致(实际生产值通常已被 gateway container 内部覆盖;只动文档化注释)。 +- [ ] 改 `k8s/helm/topfans/values.yaml`:删除 `services.starbookservice` 块(4.2 负责删),并修正 `DUBBO_GATEWAY_*` 注释默认值。 +- [ ] 跑 `cd backend && go build ./...` —— 全部模块编译通过。 +- [ ] 跑 `cd backend && go test ./... -run TestPort` —— 端口断言通过。 +- [ ] 准备 commit message:"fix(config): 对齐网关与服务默认端口并加配置断言测试 (批次 4.1)"—— **需用户批准后 commit**。 + +### Task 2: starbookService 决断 → 删除(4.2) + +> 对应审计 P0-7 与修复方案 4.2 推荐方案(生产未部署 starbookService)。 +> ⚠️ 关联 P0-4 跨服务 import 解耦:assetService 当前 `import starbookService/repository`(`backend/services/assetService/main.go:32`、`service/mint_service.go:28`),但 starbookService 不在 `go.work`(第 1-16 行)。当前 `go build` 实际**已失败**,本 Task 同步修复。 + +**Files**: +- 删除:`backend/services/starbookService/`(整个目录,含 `main.go`、`provider/`、`repository/`、`service/`、`starbookService` 二进制若存在) +- 删除:`k8s/helm/topfans/templates/starbookservice/deployment.yaml` +- 修改:`backend/services/assetService/main.go:32,159` —— 删除 `starbookRepo` import,改为本地 `repository.NewAssetRegistryRepository` +- 修改:`backend/services/assetService/service/mint_service.go:28,71,84` —— 删除 `starbookRepo` import,改用 `assetRegistryRepo.AssetRegistryRepository`(定义在 `assetService/repository`) +- 修改:`backend/services/assetService/service/asset_service.go:58,69,78,116-131` —— `registryRepo` 类型不变(接口定义在 `asset_service.go:46-49`,已用 `models.AssetRegistry`),仅切换实现来源 +- 新增:`backend/services/assetService/repository/asset_registry_repository.go` —— 从 starbookService 复制 `AssetRegistryRepository` 接口 + 实现,改 package 为 `repository`;去掉 import 循环 +- 修改:`backend/gateway/config/config.go:95,165` —— 删除 `StarbookServiceURL` 字段 +- 修改:`backend/go.work:3-16` —— 移除 starbookService(实际本就不在) +- 修改:`backend/dev.sh:24,347,365,432,438,468,493,510,530` —— 删除所有 starbookService 引用 +- 修改:`docker/Dockerfile.services:50` —— 删除 `go build ... starbookservice` 行 +- 修改:`docker/docker-compose.local.yml:231-262,442,482` —— 删除 starbookservice service block 与 `DUBBO_STARBOOK_SERVICE_URL` env +- 修改:`docker/docker-compose.prod.yml:365-401,629,659` —— 同上 +- 修改:`k8s/helm/topfans/values.yaml:125,214-232` —— 删除 `services.starbookservice` 块与其在 `gateway.env` 里的 `DUBBO_STARBOOK_SERVICE_URL` + +**Interfaces**(新增): +```go +// backend/services/assetService/repository/asset_registry_repository.go +package repository + +import "github.com/topfans/backend/pkg/models" + +// AssetRegistryRepository 资产统一索引(从 starbookService 迁来,assetService 现在是 owner) +type AssetRegistryRepository interface { + Create(registry *models.AssetRegistry) error + GetByID(id int64) (*models.AssetRegistry, error) + GetByAssetID(assetID int64) (*models.AssetRegistry, error) + GetByAssetTypeAndID(assetType string, assetID int64) (*models.AssetRegistry, error) + GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error) + GetByOwnerAndType(ownerUID, starID int64, assetType string, limit, offset int) ([]*models.AssetRegistry, error) + GetByOwnerAndTypeAndGrade(...) (...) + GetByOwnerAndTypeAndCategory(...) (...) + GetByOwnerAndTypeAndActivity(...) (...) + CountByOwner(...) (int64, error) + CountByOwnerAndType(...) (int64, error) + CountByOwnerAndTypeAndGrade(...) (int64, error) + CountByOwnerAndTypeAndCategory(...) (int64, error) + CountByOwnerAndTypeAndActivity(...) (int64, error) + UpdateLikeCount(assetID int64, likeCount int32) error + UpdateGrade(assetID int64, grade int32) error + Delete(assetID int64) error + DeleteByAssetType(assetType string, assetID int64) error +} + +func NewAssetRegistryRepository(db *gorm.DB) AssetRegistryRepository { ... } +``` + +**Steps**: +- [ ] 确认 `backend/services/starbookService/` 完整目录清单:`ls -la backend/services/starbookService/`。 +- [ ] **解耦步骤 A**——`backend/services/assetService/repository/asset_registry_repository.go`(新建):从 `backend/services/starbookService/repository/asset_registry_repository.go` 复制完整内容(接口 + 实现 + 388 行),改 `package repository`,删除跨服务 import(保留 `pkg/models` + `pkg/errors` + `gorm.io/gorm`)。 +- [ ] **解耦步骤 B**——`backend/services/assetService/main.go:32`:删除 `starbookRepo "github.com/topfans/backend/services/starbookService/repository"`。 +- [ ] **解耦步骤 C**——`backend/services/assetService/main.go:159`:把 `registryRepo := starbookRepo.NewAssetRegistryRepository(database.GetDB())` 改为 `registryRepo := repository.NewAssetRegistryRepository(database.GetDB())`。 +- [ ] **解耦步骤 D**——`backend/services/assetService/service/mint_service.go:28,71,84`:删 `starbookRepo` import;类型改 `registryRepo repository.AssetRegistryRepository`;参数类型同步改。 +- [ ] **解耦步骤 E**——`backend/services/assetService/service/asset_service.go:46-49`:当前 `RegistryRepository` 接口只声明 `GetByOwner`,无法满足 `mint_service.go` 需要(mint_service 实际不用 registryRepo 的其他方法,仅用 `registryRepo.Create` 等;先确认)。如 mint_service 不需要接口扩展则保持现状;如需则把 `RegistryRepository` 接口扩展为 asset 内部接口或引用新 `repository.AssetRegistryRepository`。 +- [ ] 跑 `cd backend && go build ./services/assetService/...` —— 必须通过(说明解耦成功)。 +- [ ] **删除步骤**——`git rm -r backend/services/starbookService/`。 +- [ ] **删除 helm**——`git rm k8s/helm/topfans/templates/starbookservice/deployment.yaml`(若存在父目录则一并 `rmdir`)。 +- [ ] 改 `backend/gateway/config/config.go`:删 `StarbookServiceURL` 字段(第 95、165 行),同步从 `cases` map 移除(在 port_test 中也对应删除)。 +- [ ] 确认 `backend/go.work` 已不含 starbookService(grep `starbook` 应无结果)。 +- [ ] 改 `backend/dev.sh`: + - 行 24:从服务循环数组移除 `starbookService`。 + - 行 347、365:从 `inotifywait --exclude` 列表移除 `starbookService$`。 + - 行 432、438:从 build/start 循环移除。 + - 行 468:删除 `build_service "starbookService" ...` 函数调用。 + - 行 493:删除 `start_service "starbookService" ...` 调用。 + - 行 510:删除 `start_watcher "starbookService" ...` 调用。 + - 行 530:删除打印行 `Starbook Service: tri://localhost:20007`。 +- [ ] 改 `docker/Dockerfile.services:50`:删除 `go build -o /tmp/starbookservice services/starbookService/main.go` + `echo "Built starbookservice"` 两行。 +- [ ] 改 `docker/docker-compose.local.yml`:删 `starbookservice:` service 块(行 231-262)+ `DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007` env(行 442)+ 任何 depends_on starbookservice 引用(行 482)。 +- [ ] 改 `docker/docker-compose.prod.yml`:同 local,端口用 `20005`(生产错配)。 +- [ ] 改 `k8s/helm/topfans/values.yaml`:删 `starbookservice:` 块(行 214-232)+ `gateway.env.DUBBO_STARBOOK_SERVICE_URL` 引用。 +- [ ] 跑 `cd backend && go build ./...` —— 全部模块编译通过。 +- [ ] 跑 `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults` —— 通过(已移除 starbook 用例)。 +- [ ] 跑 `cd backend && go test ./...` —— 全部测试通过。 +- [ ] 跑 `grep -rn 'starbook\|Starbook' backend docker k8s dev.sh` —— 仅剩 `backend/migrations/` 中可能的 starbook 业务表(如 `asset_registry`),不应再有任何 starbookService 部署引用。 +- [ ] 准备 commit message:"refactor(services): 删除 starbookService 孤儿代码并内化 AssetRegistryRepository 到 assetService (批次 4.2)"—— **需用户批准后 commit**。 + +### Task 3: 健康探针修正 + 大二进制出库(4.3) + +> 对应审计 §三 P1 探针/大二进制行 + 修复方案 4.3。 + +**Files**: +- `backend/pkg/health/health.go:28-29` —— 当前 `mux.HandleFunc("/health", ...)`;新增 `/healthz` alias 处理(统一两个路径,避免 helm values 长期不一致) +- `backend/services/notificationService/main.go` —— 探针端口 21010 已正确(健康服务端口 = Dubbo 端口 + 1000),无须改 +- `backend/services/moderationService/main.go` —— 探针路径 `/` 改为 `/health`(让 helm values 也对齐) +- `k8s/helm/topfans/values.yaml:293,314` —— `notificationservice.healthPath: /health`(从 `/healthz` 改),`moderationservice.healthPath: /health`(从 `/` 改) +- `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` —— 探针 path 改 `/health`(无 `healthPath` 模板变量,可保留 default 但需对齐) +- `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58` —— 探针 path 改 `/health` +- `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml` —— 任何 `HEALTHCHECK` 指令对齐 `/health` +- `backend/.gitignore` —— 追加 `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars`、`gateway-fixed`、`gateway/gateway-fixed` 等顶层可执行文件 +- `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars` —— `git rm --cached` + +**Interfaces**(`backend/pkg/health/health.go` 修改): +```go +// 在现有 mux.HandleFunc("/health", h.handleHealth) 后追加: +mux.HandleFunc("/healthz", h.handleHealth) +``` +(K8s 探针仍可用 `/health`;保留 `/healthz` 兼容老 chart;不破坏既有探针。) + +**Steps**: +- [ ] 读 `backend/pkg/health/health.go:27-44`,确认当前 mux 仅注册 `/health`。 +- [ ] 改 `backend/pkg/health/health.go:29` 后追加 `mux.HandleFunc("/healthz", h.handleHealth)`。 +- [ ] 跑 `cd backend && go build ./pkg/health/...` —— 通过。 +- [ ] 改 `k8s/helm/topfans/values.yaml:293`:`healthPath: /health`(notificationservice);`k8s/helm/topfans/values.yaml:314`:`healthPath: /health`(moderationservice,从 `/` 改)。 +- [ ] 改 `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/healthz" }}` 改为 `{{ $svc.healthPath | default "/health" }}`(默认同步)。 +- [ ] 改 `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/" }}` 改为 `{{ $svc.healthPath | default "/health" }}`。 +- [ ] 跑 `cd k8s/helm/topfans && helm template . --values values.yaml 2>&1 | grep -E 'healthz|path:'` —— 验证所有探针 path 为 `/health`。 +- [ ] 改 `docker/docker-compose.local.yml`:搜 `HEALTHCHECK`,把任何 `curl http://localhost:2xxxx/healthz` 改为 `/health`。 +- [ ] 改 `docker/docker-compose.prod.yml`:同上。 +- [ ] 改 `backend/.gitignore`:在"Backend 构建产物"区块追加: + ``` + # 顶层历史二进制(本地构建残留,禁止入库) + /assetService + /gateway-fixed + /test + /cleanup-orphan-avatars + ``` +- [ ] 跑 `cd /Users/liulujian/Documents/code/TopFansByGithub && git rm --cached backend/assetService backend/gateway-fixed backend/test backend/cleanup-orphan-avatars`(若文件已被 .gitignore 覆盖,则先从暂存区移除)。 +- [ ] 跑 `git ls-files | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应为空。 +- [ ] 跑 `git status --ignored | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应列出(说明已忽略但本地保留)。 +- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 +- [ ] 跑本地健康检查:`cd backend && (PORT=20010 ./services/notificationService/notificationService &) ; sleep 2 ; curl -s http://localhost:21010/health ; curl -s http://localhost:21010/healthz` —— 两个路径均返回 `{"status":"ok"}`。 +- [ ] 准备 commit message:"fix(deploy): 健康探针路径统一为 /health + 顶层历史二进制出库 (批次 4.3)"—— **需用户批准后 commit**(建议拆为两个 commit:二进制出库 + 探针修正,分别走 `git add .gitignore` 与 `git add helm`)。 + +### Task 4: 序列脚本 setval(4.4) + +> 对应审计 §三 P1 `create_gallery_test_users.go` 与修复方案 4.4。遵循 `CLAUDE.md` PostgreSQL 序列规范。 + +**Files**: +- `backend/scripts/create_gallery_test_users.go:59-152` —— 在每个手动 INSERT 表后追加 `SELECT setval(...)` + +**Interfaces**(无新接口;纯输出增强): +```go +// 输出 SQL 末尾追加序列重置;当前 main 函数应收集受影响表名集合,结尾统一输出。 +var tablesToReset = []string{"users", "fan_profiles", "booth_slots", "assets", "exhibitions"} +for _, tbl := range tablesToReset { + fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl) +} +``` + +**Steps**: +- [ ] 通读 `backend/scripts/create_gallery_test_users.go:1-170`,列出所有手动指定 id 的 INSERT 表:`users` (id=100/101)、`fan_profiles` (无 id,仅 `user_id, star_id` 复合键——若 BIGSERIAL 则无需)、`booth_slots` (slot_id=1001-1003, 2001-2003)、`assets` (id=1000-1003)、`exhibitions` (无 id)。 +- [ ] 检查 `fan_profiles` 与 `exhibitions` 是否 BIGSERIAL(查 migrations:`grep -E 'CREATE TABLE (fan_profiles|exhibitions)' backend/migrations/*.sql`)。 +- [ ] 改 `backend/scripts/create_gallery_test_users.go`: + - 在 `var` 区块追加 `tablesToReset := []string{"users", "booth_slots", "assets"}`(`fan_profiles` 与 `exhibitions` 如非 BIGSERIAL 则不计入)。 + - 在 `main()` 末尾、所有 `fmt.Println` 之前,循环 `for _, tbl := range tablesToReset { fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl) }`。 + - 在最后追加 `fmt.Println("-- 序列重置完成")`。 +- [ ] 跑 `cd backend && go run scripts/create_gallery_test_users.go > /tmp/test_users.sql`,确认末尾包含 `SELECT setval('users_id_seq', ...)`、`SELECT setval('booth_slots_id_seq', ...)`、`SELECT setval('assets_id_seq', ...)` 三行。 +- [ ] 验证迁移可行性:在测试库 `psql -f /tmp/test_users.sql`(用 docker `postgresql-database-1` 即可),确认不报 `duplicate key` 且三条 `setval` 执行成功。 +- [ ] 跑 `cd backend && go build ./...` —— 通过。 +- [ ] 跑 `cd backend && go vet ./scripts/...` —— 无 warning。 +- [ ] 准备 commit message:"fix(scripts): create_gallery_test_users.go 输出 SQL 末尾补 setval 序列重置 (批次 4.4)"—— **需用户批准后 commit**。 + +### Task 5: .env ↔ .env.example 对齐(4.5) + +> 对应审计 §三 P1 漂移与修复方案 4.5。`.env.example` 仅留占位符,`.env` 真值不进 git。 + +**Files**: +- `backend/.env.example` —— 增补缺失键、删除占位符中的真实密钥痕迹(`LTAI5t...`、`sk-cp-`、`sk-proj-`、`app-...` 等) +- `backend/.env` —— 实际环境值,本 Task 不入仓但需确保所有代码读取的键均有对应 `getEnv(...)` 默认值或 example +- `backend/gateway/config/env_audit_test.go` (新增) —— 断言 `.env` 解析后所有"必填"键非空(仅对非敏感键,如 `WS_AI_CHAT_PATH`、`LANDING_BASE_URL`) + +**Interfaces**(无新接口;测试骨架): +```go +// backend/gateway/config/env_audit_test.go +package config + +import ( + "os" + "strings" + "testing" +) + +// TestEnvExampleDrift 断言 .env.example 含全部 .env 中已文档化的键(粗粒度:按 KEY= 前缀) +func TestEnvExampleDrift(t *testing.T) { + exampleBytes, err := os.ReadFile("../../.env.example") + if err != nil { t.Fatal(err) } + envBytes, err := os.ReadFile("../../.env") + if err != nil { t.Fatal(err) } + exampleKeys := parseEnvKeys(string(exampleBytes)) + envKeys := parseEnvKeys(string(envBytes)) + // 反向断言:.env 中有的键 .env.example 必出现(除 SECRET_KEY/JWT_SECRET/真实 key) + for k := range envKeys { + if isSecretKey(k) || isRealAPIKey(k) { continue } + if _, ok := exampleKeys[k]; !ok { + t.Errorf(".env 有键 %q 但 .env.example 缺失", k) + } + } +} +``` + +**Steps**: +- [ ] 跑 `diff <(grep -E '^[A-Z_]+=' backend/.env | cut -d= -f1 | sort) <(grep -E '^[A-Z_]+=' backend/.env.example | cut -d= -f1 | sort)` —— 列出 .env 独有键(应在 .env.example 补)和 .env.example 独有键(可能是模板占位/可保留)。 +- [ ] 列出 `.env` 独有键(实际环境值,非 secret):`WS_AI_CHAT_PATH`、`LASER_COMPOSITOR_URL`、`COMPOSITOR_PORT`、`LASER_GEN_PROVIDER`、`MINIMAX_API_KEY`、`MINIMAX_API_URL`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`DIFY_API_BASE`、`DIFY_API_KEY`、`JWT_SECRET`、`ENV`、`LOG_LEVEL`、`OSS_REGION`、`OSS_BUCKET_NAME`、`OSS_STS_ROLE_ARN`、`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_AVATAR_DIR`、`OSS_ASSET_DIR`、`OSS_TOKEN_EXPIRE_TIME`、`REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB`、`DB_HOST`、`DB_PORT`、`DB_USER`、`DB_PASSWORD`、`DB_NAME`、`GIN_MODE`、`SERVER_PORT`、`SECRET_KEY`。 +- [ ] 列出 `.env.example` 独有键(当前文档化但 .env 未用):`LANDING_BASE_URL`、`PUSH_ENABLED`、`PUSH_URL`、`PUSH_TIMEOUT_MS`、`DIFY_TIMEOUT_SEC`、`DIFY_WORKFLOW`、`SEGMENT_PROVIDER`、`SEGMENT_INFERENCE_URL`、`DUBBO_USER_SERVICE_URL` 至 `DUBBO_STARBOOK_SERVICE_URL`(4.2 后应删 Starbook)。 +- [ ] 改 `backend/.env.example`: + - 行 87:取消注释 `WS_AI_CHAT_PATH=/ai-chat`(去掉 `# ` 前缀)。 + - 行 121-123:保留 `PUSH_ENABLED/PUSH_URL/PUSH_TIMEOUT_MS`,但 `PUSH_URL=` 留空占位符。 + - 行 124-128:删除 `OPENAI_*` 三行(真实密钥痕迹),改为 `# OPENAI_* 由 LASER_GEN_PROVIDER=openai 启用时填入`。 + - 行 131-133:删除 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ`(Dify 真实密钥),改为 `DIFY_API_KEY=`。 + - 行 106:删除 `OPENAI_API_KEY=sk-proj-...` 真实值,改为 `OPENAI_API_KEY=`。 + - 增补 `LANDING_BASE_URL=http://localhost:5173`(已在最顶部,确认);增补 `LASER_COMPOSITOR_URL=http://127.0.0.1:7002`、`LASER_GEN_PROVIDER=openai`;增补 `MINIMAX_API_KEY=` 与 `MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation`(已有,确认)。 + - 增补 `SEGMENT_*`(已有 73-74 行,确认)。 + - 增补 `DIFY_TIMEOUT_SEC=60`(已有 133 行,确认)。 +- [ ] 在 `.env.example` 顶部加注释: + ``` + # .env.example 仅作键清单 + 占位符。真实值由部署环境注入(docker env_file / k8s Secret / systemd EnvironmentFile)。 + # 严禁把真实 key 写入本文件;本文件应可直接 commit。 + ``` +- [ ] 创建 `backend/gateway/config/env_audit_test.go`(见上面骨架),实现 `parseEnvKeys`(按行解析 `^[A-Z_]+=`)、`isSecretKey`、`isRealAPIKey`(白名单:`SECRET_KEY/JWT_SECRET/PASSWORD/*_KEY/*_SECRET/TOKEN`)。 +- [ ] 跑 `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` —— 通过。 +- [ ] 确认 `git check-ignore backend/.env` —— 返回 0(已忽略);`git ls-files backend/.env` —— 应为空。 +- [ ] 确认 `git ls-files backend/.env.example` —— 有文件。 +- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 +- [ ] 准备 commit message:"fix(env): 同步 .env.example 与 .env 键清单并清理示例文件中的真实密钥痕迹 (批次 4.5)"—— **需用户批准后 commit**。 + +### Task 6: P2 清理(4.9) + +> 对应审计 §四 P2 八项。所有改动均小而独立,按表格逐项修。 + +**Files & Actions**(按文件:行 + 改动说明): + +| P2 项 | 文件:行 | 改动 | +|-------|---------|------| +| `parseRPCError` 字符串解析 | `backend/gateway/controller/asset_controller.go:158-194` | 改用 `google.golang.org/grpc/status` 的 `status.FromError(err)`;删除正则解析。返回结构 `{code: int, message: string}`,调 7 处 `parseRPCError(err)` 调用(行 240/286/365/492/568/700/765/827)保持签名不变 | +| 原始 err 泄露前端 | `backend/gateway/controller/auth_controller.go:224,294,306`、`user_controller.go:141,247,498` | 把 `c.JSON(500, gin.H{"message": err.Error()})` 改为 `response.InternalError(c)` 或 `response.BadRequest(c, "操作失败")` + `logger.Logger.Error("...", zap.Error(err))` 服务端日志保留 err | +| `ResetPassword` 不失效 JWT | `backend/services/userService/service/user_service.go:681` | 现状已在事务内把 `access_token` 置 nil + `token_expires_at` 置 nil。补:`gateway/middleware/auth_middleware.go` 在解析 JWT 后**额外**比对 `users.access_token`(如已为 nil 则 401)。如工期紧张,至少在 `user_service.go:681` 注释里写明 "JWT 失效留待 ticket #XX 处理;当前实现只阻止后续发新 token 复用"——选择最小改动(仅注释 + ticket 链接)以避免越界改动 middleware | +| `UpdateAvatar` URL 不校验同源 | `backend/services/userService/service/user_service.go:1023` | 在写库前增加同源校验:`u, err := url.Parse(req.AvatarUrl)`;`if u.Host != "" && !strings.Contains(u.Host, "aliyuncs.com") { return nil, ErrInvalidAvatarURL }`;白名单仅 `aliyuncs.com` 子域 | +| PII 进 INFO 日志 | `backend/services/aiChatService/provider/ai_chat_provider.go:107`、`mint_service.go`、`user_service.go` 多处 | 把 `user_id`/`session_id` 之外不要打印 `message`/`mobile`;或改用 `logger.Logger.Debug(...)`;`mobile` 用 `mobile[:3] + "****" + mobile[7:]` 脱敏 | +| pkg/mq 死代码 | `backend/pkg/mq/streams/adapter.go`(`EventProducer`/`Stream*` 常量)、`backend/pkg/mq/asynq/adapter.go:117`(`Delete` 假实现) | 与批次 3.3 一并决断(用户已选"停用 streams adapter"则本 Task 把 `streams/` 整个子包删除、`mq.go` 的 `MQDriver = Streams` 分支删除);asynq `Delete` 暂保留(实际不调用),注释"未接线" | +| `is_processed` 复用为 settled | `backend/services/galleryService/mq/consumer.go:207` | **由批次 1.1(`exhibition-settlement-idempotency` plan Task 3)负责** `settled_at` 列 + `isSettled/markSettled` 改写。本 plan 不重复编辑此函数,仅在回归时校验其已切换。 | +| 直连 Redis Pub/Sub | `backend/services/activityService/service/activity_service.go:217,1579` | 两处 `s.redisClient.Publish(...)` 改为走 `adapter.EventProducer.Publish(ctx, "activity.contributions", payload)`(若 pkg/mq 已确定保留,否则本项 defer 到 3.3) | +| 周边密钥兜底 | `backend/pkg/peripheral/sign.go:33-44` | 去掉 `JWT_SECRET` 兜底与硬编码 `"default-dev-secret-change-me"`;`SECRET_KEY == ""` 时 `panic("SECRET_KEY is required")`(fail-fast) | + +**Interfaces**(新增/修改): +```go +// backend/gateway/controller/asset_controller.go (修改后) +import "google.golang.org/grpc/status" + +func parseRPCError(err error) (code int, message string) { + if err == nil { return http.StatusOK, "" } + if st, ok := status.FromError(err); ok { + // grpc status code → HTTP code 映射(参考 grpc-gateway 约定) + return grpcCodeToHTTP(st.Code()), st.Message() + } + return http.StatusInternalServerError, "服务暂时不可用" +} + +func grpcCodeToHTTP(c codes.Code) int { + switch c { + case codes.NotFound: return http.StatusNotFound + case codes.InvalidArgument: return http.StatusBadRequest + case codes.Unauthenticated: return http.StatusUnauthorized + case codes.PermissionDenied: return http.StatusForbidden + case codes.AlreadyExists: return http.StatusConflict + default: return http.StatusInternalServerError + } +} +``` + +```go +// backend/pkg/peripheral/sign.go (修改后) +func getSecret() string { + secretOnce.Do(func() { + secretVal = os.Getenv("SECRET_KEY") + if secretVal == "" { + panic("SECRET_KEY is required for peripheral HMAC signing") + } + }) + return secretVal +} +``` + +**Steps**(按表格逐项): +- [ ] **A. parseRPCError**:改 `backend/gateway/controller/asset_controller.go:158-194` 为 `status.FromError` 形式。7 处调用点(行 240/286/365/492/568/700/765/827)签名不变,无须改。跑 `cd backend && go build ./gateway/...` 通过。 +- [ ] **B. err 泄露**:改 `auth_controller.go:224,294,306` + `user_controller.go:141,247,498` 五处,把 `err.Error()` 移到 `logger.Logger.Error("...", zap.Error(err))`,响应改为稳定文案(如 `response.InternalError(c, "操作失败")`)。grep 验证 `c.JSON(500, gin.H{"message": err.Error()})` 仅剩 0 处。 +- [ ] **C. ResetPassword JWT**:决策点——读 `backend/gateway/middleware/auth_middleware.go` 当前是否校验 `users.access_token`;若否,最小改动为更新 `user_service.go:681` 注释(添加 ticket 链接),不修改 middleware;若已校验,本项完成。`git grep 'access_token' backend/gateway/middleware/`。 +- [ ] **D. UpdateAvatar 同源**:改 `user_service.go:1023` 加 `url.Parse` + `aliyuncs.com` 白名单;`pkg/errors` 增加 `ErrInvalidAvatarURL`。 +- [ ] **E. PII 日志**:改 `ai_chat_provider.go:107` 不打 `message`;`user_service.go` 中 `mobile` 打印用脱敏函数。grep `logger.Logger.Info.*mobile.*req` / `zap.String("message", message)` 全面排查。 +- [ ] **F. pkg/mq 死代码**:读 `backend/pkg/mq/mq.go` 看 `MQDriver = Streams` 分支是否被引用。若无引用,删除 `backend/pkg/mq/streams/` 子包 + `mq.go` 中的 streams init 分支;`asynq/adapter.go:117` 的 `Delete` 假实现加注释 `// TODO: 实际未调用,asynq.DeletedTaskInfo 返回 false 即视为不存在`。 +- [ ] **G. `is_processed` 复用**:**不在本 plan 编辑**。`settled_at` 列 + `isSettled/markSettled` 改写归批次 1.1(`exhibition-settlement-idempotency` plan Task 1 migration + Task 3)。本 plan 仅校验:`grep -n 'settled_at' backend/services/galleryService/mq/consumer.go` 确认已切换;未切换则回到批次 1.1 执行。 +- [ ] **H. Redis Pub/Sub 直连**:决策点——读 `backend/pkg/mq/mq.go` 的 `EventProducer` 接口是否已稳定;若稳定,改 `activity_service.go:217,1579` 为 `mq.GetEventProducer().Publish(ctx, "activity.contributions", payload)`;否则本项 defer 到 3.3。 +- [ ] **I. 周边密钥 fail-fast**:改 `pkg/peripheral/sign.go:33-44`,去掉 JWT_SECRET 兜底与硬编码默认值。跑 `cd backend && SECRET_KEY= go test ./pkg/peripheral/...` —— 必须 panic 或返回明确错误(不能静默用 dev 默认值)。 +- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 +- [ ] 跑 `cd backend && go vet ./...` —— 无 warning。 +- [ ] 跑 `cd backend && grep -rnE 'parseRPCError.*errStr\|err\.Error\(\)\|default-dev-secret\|JWT_SECRET.*secretVal' .` —— 仅 0 处(除新增注释)。 +- [ ] 准备 commit message(建议拆 3 个):"refactor(gateway): parseRPCError 改用 grpc/status.FromError (批次 4.9-A)" + "fix(gateway): 修复 5 处原始 err 泄露前端 (批次 4.9-B)" + "fix(peripheral): SECRET_KEY 缺失 fail-fast,去 JWT_SECRET/dev 默认兜底 (批次 4.9-I)"—— **每个 commit 需用户批准**。 + +--- + +## Self-Review + +> 严格按 `CLAUDE.md` 全局自审规则:通读本文 §1→§5,核对跨章节引用、Go 编译预期、副作用、commit 边界。 + +### 修改的章节 +- §File Structure(任务清单) +- §Task 1–6(步骤、文件、接口) + +### 未改动但通读确认的章节 +- §Goal / §Architecture / §Tech Stack —— 与原方案对齐,无改动需求。 +- §Global Constraints —— 与审计 §五 修复顺序、批次 4 路线图一致;序列规则、commit 守则与 `CLAUDE.md` 一致。 + +### 跨章节引用一致性检查 +- **端口矩阵(4.1 ↔ §File Structure 表)**:4.1 列出 11 个服务端口,§File Structure 表也列 11 个服务端口——一致。 +- **starbookService 删除(4.2 ↔ 4.1 端口断言)**:4.1 `serviceDefaultPorts` 与 `cases` map 必须不含 starbook——本计划已明确在 4.2 完成后删除 `StarbookServiceURL` 字段并同步 port_test 的 `cases` map。 +- **健康探针(4.3 ↔ helm values)**:4.3 改 `notificationservice.healthPath` 从 `/healthz` → `/health`,与 `backend/pkg/health/health.go:28-29` 注册 `/health` 一致——OK。 +- **大二进制(4.3 ↔ §Global Constraints 不动产物)**:`backend/bin/*` 与 `.gitignore` 第 10 行 `bin/` 仍受保护;本计划只追加根级四条二进制忽略——一致。 +- **序列脚本(4.4 ↔ §Global Constraints)**:遵循 `CLAUDE.md` 强制规则——一致。 +- **.env 漂移(4.5 ↔ .gitignore)**:`.env` 已在 `.gitignore` 第 21 行忽略;本计划仅改 `.env.example`——一致。 +- **P2 清理(4.9 ↔ 批次 3.3 决断)**:H/F 两项依赖批次 3.3 MQ 决断结论——本计划用"决策点"标注,未预设结论。 +- **commit 边界(每 Task ↔ §Global Constraints)**:每个 Task 末尾标注"需用户批准后 commit",未授权自提交——一致。 + +### Go 编译验证预期(必须能在本地真实跑通) + +| 步骤 | 命令 | 预期 | +|------|------|------| +| 端口测试 | `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults -v` | PASS | +| env 审计 | `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` | PASS | +| 全量编译 | `cd backend && go build ./...` | exit 0 | +| 全量测试 | `cd backend && go test ./...` | exit 0 | +| vet | `cd backend && go vet ./...` | exit 0 | +| helm lint | `cd k8s/helm/topfans && helm lint .` | 0 errors | +| 大二进制 | `git ls-files \| grep -E '^backend/(assetService\|gateway-fixed\|test\|cleanup-orphan-avatars)$'` | empty | +| starbook 删除 | `find backend -path '*starbook*' -not -path '*/node_modules/*'` | 仅 `backend/migrations/` 中 starbook 业务表(如有),无 `starbookService/` 目录 | + +### 优先级分类 + +- **P0(先做,挡构建)**:4.2 starbookService 删除(当前 `go build ./services/assetService/...` 失败)+ 4.1 端口对齐(影响所有下游服务)。 +- **P1(同期)**:4.3 探针 + 大二进制 + 4.4 setval + 4.5 env。 +- **P2(顺手)**:4.9 八项;其中 4.9-I 周边密钥 fail-fast 涉及安全边界,应排在 4.9 内靠前。 + +### 失败模式预演 + +- 若 4.2 完成后 `go build ./...` 仍报 starbook 引用:检查 `assetService/service/mint_service.go` 是否还有 `starbookRepo` import 残留(grep 验证)。 +- 若 4.1 port_test 报某服务端口不匹配:回到该服务 `main.go` 顶部 `var port = flag.Int(...)` 默认值核对,更新 `serviceDefaultPorts` map 与默认值。 +- 若 4.3 helm template 探针 path 仍为 `/healthz`:检查 `_helpers.tpl` 或 deployment.yaml `default` 值是否覆盖——可能需硬改两处。 +- 若 4.4 setval 报 `relation "xxx_id_seq" does not exist`:该表非 BIGSERIAL,从 `tablesToReset` 移除。 +- 若 4.5 env 审计失败:逐一对照 `isSecretKey`/`isRealAPIKey` 白名单,确认是否把真 secret 误识别为 drift。 + +### 跨任务依赖(按执行顺序) + +``` +4.1 ──┐ + ├──▶ 4.6(提交后整体回归) +4.2 ──┤ + │ +4.3 ──┤ + │ +4.4 ──┤ + │ +4.5 ──┤ + │ +4.9 ──┘ +``` + +4.1 与 4.2 互不阻塞(4.2 内 assetService 解耦独立完成)。4.3/4.4/4.5/4.9 与前两者可并行。4.9 内部分项按"决策点"标注——若用户已决断批次 3.3,则 F/H 可立即执行;否则 defer。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md b/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md new file mode 100644 index 0000000..02f1183 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md @@ -0,0 +1,1022 @@ +# 累计时长幂等 (批次1.2/1.x) 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:** 让展品累计时长(用户级 `user_exhibition_hours` + 资产级 `asset_level_records.season_exhibition_hours`)真正幂等——同一 exhibition_id 只生效一次,消除因 `AddExhibitionHours` 不判重 + 资产级不传 sourceID + 结算流重复(已证实)造成的累计时长虚高,并下修已误升的等级、冲销已误发的升级奖励水晶。 + +**Architecture:** 数据层新增幂等 log 表 `exhibition_hours_log(source_id UNIQUE)`;`AddExhibitionHours` 事务内 `INSERT ... ON CONFLICT DO NOTHING`,`RowsAffected==0` 则跳过累加(防御纵深)——同样规则覆盖 asset-level 累加(新增 `asset_exhibition_hours_log(source_id UNIQUE)`)。`cleanup_worker.go` 已在批次1.1 plan 中删除,但当前 `user_exhibition_hours`(1075 行)/ `asset_level_records`(2214 行)的真实累计值已被超 5501 次重复结算污染;存量用 `distinct exhibition` 重算并回滚等级 / 奖励。 + +**Tech Stack:** Go 1.25 (go.work 多模块), GORM (`clause.OnConflict`), PostgreSQL, 本地库 `top-fans`@`localhost:15432`。 + +## Global Constraints + +- Go 组合式,不引入新依赖;沿用 `gorm.io/gorm/clause`。 +- migration 放 `backend/migrations/`,破坏性 SQL 前 dry-run + 备份;末尾按 `CLAUDE.md` 规范 `setval` 同步序列。 +- 时间戳统一**毫秒** (`time.Now().UnixMilli()`)。 +- 不自动 `git commit`(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 +- 每个任务结束 `cd backend/services/ && go build ./...` 通过;最后跑 `cd backend && go build ./...`。 +- 存量数据为测试期脏数据,可清账;但清理脚本仍走 dry-run→确认→执行。 +- 「需用户批准」的 commit 步骤仅在用户明确指示("帮我 commit"/"提交吧")后执行。 + +--- + +## 现状(已读代码确认) + +| 文件:行 | 现状 | +|---------|------| +| `backend/services/userService/repository/fan_profile_repository.go:535-672` | `AddExhibitionHours(userID,starID,hours,sourceID)`。事务内**无条件** `total_exhibition_hours += hours`(L551-558),升级奖励发放(L617-651)也无 sourceID 幂等;`sourceID` 仅写到 `crystal_transaction_records.SourceID`。 | +| `backend/services/userService/mq/consumer.go:111-131` | `isAlreadyProcessed` 仅查 `crystal_transaction_records` 中 `change_type='exhibition_revenue'` 的 `source_id`,**不是真正的幂等层**(漏 cover 7-1.x 路径):依赖上游要么走收益单链(收益记录的 source_id 与累计的 source_id 同值 "exhibition_"),要么直连 RPC(无幂等)。 | +| `backend/services/taskService/service/revenue_service.go:487-524` | 514:`s.userRPCClient.AddExhibitionHours(... sourceID="exhibition_")` 传了 sourceID,但 513:`s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours))` **完全不传 sourceID** 且服务签名(`asset_level_service.go:23,149`)只有 `(assetID,hours)`,无 sourceID 参数。 | +| `backend/services/assetService/service/asset_level_service.go:149-186` | `AddExhibitionHours` 实现是 `record.SeasonExhibitionHours += hours; record.LifetimeExhibitionHours += hours`,每次调用都加,不查 sourceID。 | +| 表 `user_exhibition_hours` | 1075 行,唯一约束 `uk_exhibition_user_star(user_id,star_id)` 健康;**无 `source_id` 列**。 | +| 表 `asset_level_records` | 2214 行,2165 行 `season_exhibition_hours>0`;**无 `source_id` 列**。 | +| 表 `crystal_transaction_records` | `source_id` 已用,但查询口径 `change_type='exhibition_revenue'` 与累计口径 `level_up_bonus` 不同,不能直接复用为幂等键(语义混用)。 | + +--- + +## File Structure + +- `backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql` — **新建**。两张幂等 log 表 + 索引 + 序列同步。 +- `backend/pkg/models/exhibition_hours_log.go` — **新建**。`ExhibitionHoursLog`(用户级)与 `AssetExhibitionHoursLog`(资产级)两 model。 +- `backend/services/userService/repository/fan_profile_repository.go` — **改**。`AddExhibitionHours` 事务内先插幂等 log(`ON CONFLICT DO NOTHING`),`RowsAffected==0` 直接返回 `oldLevel,0,0,nil`(等级不变、无奖励)。 +- `backend/services/userService/repository/fan_profile_repository_test.go` — **改**。新增 `TestAddExhibitionHours_Idempotent` + `TestAddExhibitionHours_TransactionallySkip`。 +- `backend/services/assetService/service/asset_level_service.go` — **改**。`AssetLevelService.AddExhibitionHours` 签名补 `sourceID string`;接口/调用方同步更新。 +- `backend/services/taskService/service/revenue_service.go:513` — **改**。调用 `s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID)`。 +- `backend/scripts/recalc_exhibition_hours.sql` — **新建**。dry-run:`SELECT` 出每个 `(user_id,star_id)` 与 `asset_id` 的"重算差值 → 推荐下修等级 / 冲销水晶金额"。 +- `backend/scripts/fix_exhibition_hours_recalc.sql` — **新建**。执行:用 `exhibition_revenue_records` (`exhibition_id, asset_id`) distinct JOIN `exhibitions` 重算 `user_exhibition_hours` 与 `asset_level_records`;按 `crystal_transaction_records.source_id='exhibition_' + change_type='level_up_bonus'` 找误发奖励并插入**负 delta** 流水冲销。 + +--- + +## Task 1: Migration — 两张幂等 log 表 + 序列同步 + +**Files:** +- Create: `backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql` + +**Interfaces:** +- Produces: + - `exhibition_hours_log(id, source_id VARCHAR(100) UNIQUE, user_id, star_id, hours, created_at)` —— 用户级幂等键表。 + - `asset_exhibition_hours_log(id, source_id VARCHAR(100) UNIQUE, asset_id, hours, created_at)` —— 资产级幂等键表。 +- Task 2 / Task 3 依赖它们。 + +- [ ] **Step 1: 写 migration(双 IF NOT EXISTS,幂等可重跑;含 dry-run 注释块)** + +```sql +-- 2026_07_21_003_exhibition_hours_idempotent.sql +-- 批次1.2:累计时长幂等(用户级 + 资产级) +-- 执行前请先备份: +-- pg_dump -h -U postgres -t exhibition_hours_log -t asset_exhibition_hours_log > backup_1_2.sql +-- 触发场景:fan_profile_repository.AddExhibitionHours/assetLevelService.AddExhibitionHours +-- 重复调用会造成 total_exhibition_hours / season_exhibition_hours 多次叠加 +-- 设计:每条幂等键由 source_id 唯一约束保证,INSERT ON CONFLICT DO NOTHING 即跳过 + +BEGIN; + +-- (1) 用户级幂等 log +CREATE TABLE IF NOT EXISTS exhibition_hours_log ( + id BIGINT PRIMARY KEY, + source_id VARCHAR(100) NOT NULL UNIQUE, + user_id BIGINT NOT NULL, + star_id BIGINT NOT NULL, + hours BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_exh_log_user_star + ON exhibition_hours_log (user_id, star_id); +CREATE INDEX IF NOT EXISTS ix_exh_log_created + ON exhibition_hours_log (created_at); + +-- (2) 资产级幂等 log +CREATE TABLE IF NOT EXISTS asset_exhibition_hours_log ( + id BIGINT PRIMARY KEY, + source_id VARCHAR(100) NOT NULL UNIQUE, + asset_id BIGINT NOT NULL, + hours INT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_asset_exh_log_asset + ON asset_exhibition_hours_log (asset_id); + +-- (3) 序列同步(CLAUDE.md 强制;新表无现存行,序列置 1) +CREATE SEQUENCE IF NOT EXISTS exhibition_hours_log_id_seq START 10000 OWNED BY exhibition_hours_log.id; +SELECT setval('exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM exhibition_hours_log), 1)); +CREATE SEQUENCE IF NOT EXISTS asset_exhibition_hours_log_id_seq START 10000 OWNED BY asset_exhibition_hours_log.id; +SELECT setval('asset_exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM asset_exhibition_hours_log), 1)); + +COMMIT; +``` + +- [ ] **Step 2: dry-run 预检** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT to_regclass('public.exhibition_hours_log') IS NOT NULL) AS user_log_exists, + (SELECT to_regclass('public.asset_exhibition_hours_log') IS NOT NULL) AS asset_log_exists;" +``` +Expected: 第一次跑 `f|f`(表不存在);若有同名表先 `DROP TABLE ... CASCADE;` 再执行。 + +- [ ] **Step 3: 执行 migration** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -f backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql +``` +Expected: `BEGIN ... CREATE TABLE ... CREATE INDEX ... CREATE SEQUENCE ... setval ... COMMIT`,无 error。 + +- [ ] **Step 4: 验证约束与序列** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*) FROM information_schema.table_constraints + WHERE table_name='exhibition_hours_log' AND constraint_type='UNIQUE') AS user_log_uks, + (SELECT count(*) FROM information_schema.table_constraints + WHERE table_name='asset_exhibition_hours_log' AND constraint_type='UNIQUE') AS asset_log_uks, + (SELECT last_value FROM exhibition_hours_log_id_seq) AS user_seq_last, + (SELECT last_value FROM asset_exhibition_hours_log_id_seq) AS asset_seq_last;" +``` +Expected: `user_log_uks=1`、`asset_log_uks=1`、`last_value>=1`(若无现存行则为 10000;空表起步 10000 避免后续 1-9999 与历史 id 冲突)。 + +- [ ] **Step 5: Commit**(用户批准后) + +```bash +git add backend/migrations/2026_07_21_003_exhibition_hours_idempotent.sql +git commit -m "feat(user/asset): add exhibition_hours_log idempotency tables (batch 1.2) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 2: 用户级 `AddExhibitionHours` 真正幂等 + +**Files:** +- Create: `backend/pkg/models/exhibition_hours_log.go` +- Modify: `backend/services/userService/repository/fan_profile_repository.go:535-672` +- Modify: `backend/services/userService/repository/fan_profile_repository_test.go` + +**Interfaces:** +- Consumes: Task 1 的 `exhibition_hours_log(source_id UNIQUE)`。 +- Produces: + - `AddExhibitionHours(userID,starID,hours,sourceID) (newLevel,levelDelta int32, crystalReward int64, err error)`。 + - **新契约**:当 `sourceID` 已存在于 `exhibition_hours_log` 时,直接返回 `(profile.Level, 0, 0, nil)`,**不累加、不发奖励**,调用方零感知。 + - `FanProfileRepository` 接口(L67)签名不变。 + +- [ ] **Step 1: 写 model `exhibition_hours_log.go`** + +```go +// backend/pkg/models/exhibition_hours_log.go +package models + +// ExhibitionHoursLog 用户级累计时长幂等 log +type ExhibitionHoursLog struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + SourceID string `gorm:"unique;not null;column:source_id"` + UserID int64 `gorm:"not null;column:user_id"` + StarID int64 `gorm:"not null;column:star_id"` + Hours int64 `gorm:"not null;column:hours"` + CreatedAt int64 `gorm:"not null;column:created_at"` +} + +func (ExhibitionHoursLog) TableName() string { return "exhibition_hours_log" } +``` + +并追加到 `backend/pkg/models/asset_level.go`: + +```go +// AssetExhibitionHoursLog 资产级累计时长幂等 log +type AssetExhibitionHoursLog struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + SourceID string `gorm:"unique;not null;column:source_id"` + AssetID int64 `gorm:"not null;column:asset_id"` + Hours int `gorm:"not null;column:hours"` + CreatedAt int64 `gorm:"not null;column:created_at"` +} + +func (AssetExhibitionHoursLog) TableName() string { return "asset_exhibition_hours_log" } +``` + +- [ ] **Step 2: 写失败测试(依赖本地库 — `top-fans`)** + +追加到 `backend/services/userService/repository/fan_profile_repository_test.go`: + +```go +func TestAddExhibitionHours_Idempotent(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + userRepo := NewUserRepository() + hashedPassword, _ := HashPassword("password123") + user := &models.User{Mobile: "13800000091", PasswordHash: hashedPassword, IsActive: true} + if err := userRepo.Create(user); err != nil { + t.Fatalf("create user: %v", err) + } + star := &models.Star{Name: "test_star_91", IdentityID: "test_star_91", IsActive: true} + db.Create(star) + + repo := NewFanProfileRepository() + + srcID := "test_exhibition_hours_log_91_001" + + // 清理本次测试可能残留 + db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID) + db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID) + defer func() { + db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID) + db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID) + db.Exec("DELETE FROM user_exhibition_hours WHERE user_id = ?", user.ID) + db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID) + db.Exec("DELETE FROM users WHERE id = ?", user.ID) + db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_91") + }() + + // 首次调用应累加 + lvl1, delta1, reward1, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID) + if err != nil { + t.Fatalf("first AddExhibitionHours err: %v", err) + } + if delta1 < 0 { + t.Fatalf("first call: levelDelta should be >= 0, got %d", delta1) + } + + // 第二次用相同 sourceID —— 应幂等,levelDelta=0, reward=0, 不再写 log + lvl2, delta2, reward2, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID) + if err != nil { + t.Fatalf("second AddExhibitionHours err: %v", err) + } + if lvl1 != lvl2 { + t.Errorf("want level unchanged, lvl1=%d lvl2=%d", lvl1, lvl2) + } + if delta2 != 0 { + t.Errorf("want levelDelta=0 on dup, got %d", delta2) + } + if reward2 != 0 { + t.Errorf("want crystalReward=0 on dup, got %d", reward2) + } + + // 验证 log 表只有 1 条 + var logCount int64 + db.Model(&models.ExhibitionHoursLog{}).Where("source_id = ?", srcID).Count(&logCount) + if logCount != 1 { + t.Errorf("want exactly 1 log row, got %d", logCount) + } + + // 验证 total_exhibition_hours 只 +5 一次 + var totalHours int64 + db.Model(&models.UserExhibitionHours{}). + Where("user_id = ? AND star_id = ?", user.ID, star.StarID). + Select("total_exhibition_hours").Scan(&totalHours) + if totalHours != 5 { + t.Errorf("want total=5, got %d", totalHours) + } +} +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `cd backend/services/userService && go test ./repository/ -run TestAddExhibitionHours_Idempotent -v` +Expected: FAIL(当前实现不查 log,第二次调用会再 +5,total=10)。 + +- [ ] **Step 4: 实现幂等(修改 `fan_profile_repository.go:535-672`)** + +替换函数体(在事务最前面插幂等 log + `RowsAffected==0` 早返回): + +```go +func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) { + if sourceID == "" { + // 旧契约兜底:sourceID 为空时退化为"尽力幂等"(之前已存在重复风险,本次按 defense 记日志并继续) + logger.Logger.Warn("AddExhibitionHours called with empty sourceID, idempotency degraded", + zap.Int64("user_id", userID), zap.Int64("star_id", starID)) + } + + var result struct { + OldLevel int32 + NewLevel int32 + CrystalReward int64 + } + + err := r.db.Transaction(func(tx *gorm.DB) error { + // 0. 幂等闸口:INSERT log, ON CONFLICT DO NOTHING;冲突即跳过本次累加 + if sourceID != "" { + logRow := &models.ExhibitionHoursLog{ + SourceID: sourceID, + UserID: userID, + StarID: starID, + Hours: hours, + CreatedAt: time.Now().UnixMilli(), + } + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_id"}}, + DoNothing: true, + }).Create(logRow) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + logger.Logger.Info("AddExhibitionHours: duplicate source_id, skip accumulate", + zap.Int64("user_id", userID), + zap.Int64("star_id", starID), + zap.String("source_id", sourceID)) + // 用一次 SELECT 读回当前等级,让调用方拿到 levelDelta=0 + var fp models.FanProfile + if err := tx.Select("level").Where("user_id = ? AND star_id = ?", userID, starID).First(&fp).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // 无 profile —— 等价于 old=new=1 + result.OldLevel = 1 + result.NewLevel = 1 + return nil + } + return err + } + result.OldLevel = fp.Level + result.NewLevel = fp.Level + return nil + } + } + + // 1. 获取或创建累计时长记录 + exhibitionHours, err := r.GetOrCreateExhibitionHours(tx, userID, starID) + if err != nil { + return err + } + + // 2. 原子性累加时长(避免竞态条件) + now := time.Now().UnixMilli() + if err := tx.Model(&models.UserExhibitionHours{}). + Where("user_id = ? AND star_id = ?", userID, starID). + Updates(map[string]interface{}{ + "total_exhibition_hours": gorm.Expr("total_exhibition_hours + ?", hours), + "updated_at": now, + }).Error; err != nil { + return err + } + + // 重新查询更新后的时长 + if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).First(exhibitionHours).Error; err != nil { + return err + } + + // 3. 获取当前等级上限 + maxLevel := GetLevelCap() + + // 4. 计算新等级(基于累计时长) + newLevel := CalculateLevelFromExhibitionHours(exhibitionHours.TotalExhibitionHours) + if newLevel > maxLevel { + newLevel = maxLevel + } + + // 5. SELECT FOR UPDATE 加行锁获取粉丝档案当前等级 + var profile models.FanProfile + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil { + return err + } + + result.OldLevel = profile.Level + result.NewLevel = newLevel + + // 6. 如有升级,发放奖励(保持原逻辑不变) + if newLevel > profile.Level { + // …(原代码块:rewards 计算、crystal_record 写、profile 更新、日志等)… + // 此处保留原实现;只在 writing crystalTransactionRecord.SourceID 已有 sourceID + } + + return nil + }) + + if err != nil { + return 0, 0, 0, err + } + + levelDelta := result.NewLevel - result.OldLevel + return result.NewLevel, levelDelta, result.CrystalReward, nil +} +``` + +**重要约束**: +- 不要拆掉原 L600-661 的"升级→写 crystal_transaction_records→更新 FanProfile"代码块;幂等闸口放在事务首部即可。 +- `crystal_transaction_records` SourceID 仍写入原 `sourceID`("exhibition_"),确保 Task 6 存量冲销脚本能按 sourceID 找到误发奖励。 +- `models.ExhibitionHoursLog` 需 `import` 进来;如已在 `pkg/models` 中则使用相对路径。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd backend/services/userService && go test ./repository/ -run TestAddExhibitionHours_Idempotent -v` +Expected: PASS(重复 sourceID 第二次 lvl=首次 lvl、delta=0、reward=0、log 仅 1 行、total=5)。 + +- [ ] **Step 6: 全模块编译** + +Run: `cd backend/services/userService && go build ./...` +Expected: 无错误。 + +- [ ] **Step 7: Commit**(用户批准后) + +```bash +git add backend/pkg/models/exhibition_hours_log.go backend/pkg/models/asset_level.go \ + backend/services/userService/repository/fan_profile_repository.go \ + backend/services/userService/repository/fan_profile_repository_test.go +git commit -m "fix(user): make AddExhibitionHours idempotent via exhibition_hours_log (batch 1.2) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 3: 资产级 `AddExhibitionHours` 补 sourceID + 真正幂等 + +**Files:** +- Modify: `backend/services/assetService/service/asset_level_service.go:18-30,149-186` + +**Interfaces:** +- Consumes: Task 1 的 `asset_exhibition_hours_log(source_id UNIQUE)`。 +- Produces: + - `AssetLevelService.AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error)` —— 签名补 `sourceID`。 + - **新契约**:相同 `sourceID` 重复调用直接返回 `(record.CurrentLevel, false, nil)`,不累加 `SeasonExhibitionHours/LifetimeExhibitionHours`、不触发升级。 + +- [ ] **Step 1: 改 interface 与签名** + +```go +// asset_level_service.go:18-30 +type AssetLevelService interface { + GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) + GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error) + GetLevelConfig(level string) (*models.AssetLevel, error) + GetAllLevels() ([]*models.AssetLevel, error) + AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error) // 改:+sourceID + AddLikes(assetID int64, count int) (string, bool, error) + RemoveLikes(assetID int64, count int) (string, bool, error) + CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) + SeasonReset(seasonID string) error + GetCurrentSeason() (*models.Season, error) + GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) +} +``` + +- [ ] **Step 2: 改实现,加幂等闸口** + +```go +// asset_level_service.go:149 +func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error) { + if sourceID == "" { + logger.Logger.Warn("AssetLevelService.AddExhibitionHours called with empty sourceID, idempotency degraded", + zap.Int64("asset_id", assetID)) + } + + if sourceID != "" && s.levelRepo != nil { + // 用 levelRepo 的 db 子句插入幂等 log + logRow := &models.AssetExhibitionHoursLog{ + SourceID: sourceID, + AssetID: assetID, + Hours: hours, + CreatedAt: time.Now().UnixMilli(), + } + db := s.levelRepo.GetDB() + if db != nil { + res := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_id"}}, + DoNothing: true, + }).Create(logRow) + if res.Error != nil { + return "", false, res.Error + } + if res.RowsAffected == 0 { + rec, err := s.GetRecordByAssetID(assetID) + if err != nil { + return "", false, err + } + logger.Logger.Info("AssetLevelService.AddExhibitionHours: duplicate source_id, skip", + zap.Int64("asset_id", assetID), zap.String("source_id", sourceID)) + return rec.CurrentLevel, false, nil + } + } + } + + record, err := s.GetOrCreateRecord(assetID) + if err != nil { + return "", false, err + } + oldLevel := record.CurrentLevel + if record.SeasonID == "" { + season, err := s.GetCurrentSeason() + if err != nil { + season = s.getDefaultSeason() + } + record.SeasonID = season.ID + } + record.SeasonExhibitionHours += hours + record.LifetimeExhibitionHours += hours + newLevel, upgraded := s.CheckUpgrade(record) + if upgraded { + record.CurrentLevel = newLevel + } + if err := s.levelRepo.Save(record); err != nil { + return "", false, err + } + if upgraded && newLevel != oldLevel { + s.logLevelChange(record.AssetID, oldLevel, newLevel, + "exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes, + fmt.Sprintf("展出完成,时长+%d小时", hours)) + s.syncGradeToAssetRegistry(record.AssetID, newLevel) + } + return newLevel, upgraded, nil +} +``` + +并确认文件顶部 `import`: +```go +import ( + // … 既有 … + "github.com/topfans/backend/pkg/models" // 已有 + "gorm.io/gorm/clause" // 新增(如果还没有) +) +``` + +> ⚠️ **追加**:确认 `s.levelRepo.GetDB()` 是否存在;若不存在,改用 `s.levelRepo.GetByAssetID` 等方法先取一条再写(避免直接拿 db)。看 `repository/asset_level_repository.go` 接口,若无 `GetDB()`,则用 `s.levelRepo` 上别的 db 暴露方法,或在 `levelRepo` 里追加一个 `GetDB() *gorm.DB` 辅助方法(一次小修改)。 + +- [ ] **Step 3: 写失败测试(依赖本地库)** + +在 `backend/services/assetService/service/asset_level_service_test.go` 追加: + +```go +func TestAddExhibitionHours_AssetIdempotent(t *testing.T) { + svc := NewAssetLevelService(levelRepo, seasonRepo, decayRepo) + assetID := int64(99900091) + srcID := "test_asset_exh_log_91_001" + + db := levelRepo.GetDB() + db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID) + db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID) + defer func() { + db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID) + db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID) + }() + + lvl1, up1, err := svc.AddExhibitionHours(assetID, 5, srcID) + if err != nil { t.Fatalf("first err: %v", err) } + if !up1 { t.Skipf("level not upgraded in test setup; got level=%s", lvl1) } // 1→? 视配置 + // 不依赖升级,只验证幂等 + lvl2, up2, err := svc.AddExhibitionHours(assetID, 5, srcID) + if err != nil { t.Fatalf("second err: %v", err) } + if up2 { t.Errorf("want upgraded=false on dup, got true") } + + var rec models.AssetLevelRecord + db.Where("asset_id = ?", assetID).First(&rec) + if rec.SeasonExhibitionHours != 5 { + t.Errorf("want SeasonExhibitionHours=5 (one apply), got %d", rec.SeasonExhibitionHours) + } + var logCount int64 + db.Model(&models.AssetExhibitionHoursLog{}).Where("source_id = ?", srcID).Count(&logCount) + if logCount != 1 { + t.Errorf("want exactly 1 asset log row, got %d", logCount) + } +} +``` + +若 `levelRepo.GetDB()` 不存在(见 Step 2 ⚠️),改为: +```go +db := testGetDBForAssetLevel(t) // 包内共享 helper +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cd backend/services/assetService && go test ./service/ -run TestAddExhibitionHours_AssetIdempotent -v` +Expected: FAIL(当前签名 `func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int)`,编译就过不去;同时旧实现不查 log 会 +5 两次)。 + +- [ ] **Step 5: 跑测试确认通过** + +> 完成 Step 2 后重跑: +Run: `cd backend/services/assetService && go test ./service/ -run TestAddExhibitionHours_AssetIdempotent -v` +Expected: PASS。 + +- [ ] **Step 6: 全模块编译** + +Run: `cd backend/services/assetService && go build ./...` +Expected: 无错误。 + +- [ ] **Step 7: Commit**(用户批准后) + +```bash +git add backend/services/assetService/service/asset_level_service.go \ + backend/services/assetService/service/asset_level_service_test.go +git commit -m "fix(asset): make asset AddExhibitionHours idempotent + sourceID (batch 1.2) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 4: 任务服务 `revenue_service.go:513` 传 sourceID + +**Files:** +- Modify: `backend/services/taskService/service/revenue_service.go:511-524` + +**Interfaces:** +- Consumes: Task 3 的资产级新签名。 +- Produces: `s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID)`,复用已有的 `sourceID = fmt.Sprintf("exhibition_%d", req.ExhibitionId)`(L485)。 + +- [ ] **Step 1: 修改调用点** + +`revenue_service.go:511-524`: +```go +// 增加资产累计展出时长(资产等级系统)—— 批次1.2: 传 sourceID 做幂等 +if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 { + if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID); err != nil { + logger.Logger.Warn("OnExhibitionCompleted: failed to add exhibition hours to asset level", + zap.Int64("asset_id", req.AssetId), + zap.Int64("hours", actualHours), + zap.Error(err)) + } else if upgraded { + logger.Logger.Info("OnExhibitionCompleted: asset leveled up due to exhibition", + zap.Int64("asset_id", req.AssetId), + zap.String("new_level", newLevel), + zap.Int64("hours", actualHours)) + } +} +``` + +> 说明:原 L513 改为 `s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID)`。`sourceID` 变量在 L485 已经存在,无需重新声明。 + +- [ ] **Step 2: 全模块编译** + +Run: `cd backend/services/taskService && go build ./...` +Expected: 无错误。 + +- [ ] **Step 3: Commit**(用户批准后) + +```bash +git add backend/services/taskService/service/revenue_service.go +git commit -m "fix(task): pass sourceID to asset AddExhibitionHours (batch 1.2) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 5: 端到端幂等验证 — 重放展示不进位 + +**Files:** 无(验证任务) + +- [ ] **Step 1: 全 go.work 编译** + +Run: `cd backend && go build ./...` +Expected: 无错误(Task 2/3/4 都改过接口/调用方)。 + +- [ ] **Step 2: DB 层幂等交叉验证** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +-- 模拟同一 source_id 多次落入用户级幂等 log:第二次应被 ON CONFLICT 静默 +INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at) +VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000) +ON CONFLICT (source_id) DO NOTHING RETURNING id; +INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at) +VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000) +ON CONFLICT (source_id) DO NOTHING RETURNING id; +INSERT INTO exhibition_hours_log (source_id, user_id, star_id, hours, created_at) +VALUES ('test_exh_log_e2e_91', 999002, 87, 3, 1780000000000) +ON CONFLICT (source_id) DO NOTHING RETURNING id; +SELECT count(*) AS should_be_1 FROM exhibition_hours_log WHERE source_id='test_exh_log_e2e_91'; +DELETE FROM exhibition_hours_log WHERE source_id='test_exh_log_e2e_91';" +``` +Expected: 第一次 `RETURNING id` 返回 1 行,第二次/第三次 `RETURNING id` 为空;`should_be_1=1`。 + +- [ ] **Step 3: 回归清单**(对照 `CLAUDE.md` §自审) +- [ ] `query_graph` `callers_of AddExhibitionHours` 查 `fan_profile_repository.AddExhibitionHours` 全部调用方: + - `userService/provider/user_provider.go:862`(gRPC)→ `user_service.go:958` → 仓库层 —— 仅源码入口,零业务改动。 + - `userService/mq/consumer.go:84`(MQ `user:accumulate-hours`)→ 仓库层 —— sourceID 来自 payload,原本就唯一,幂等闸口新增对它无害。 + - `taskService/client/user_rpc_client.go:65`(RPC 客户端)→ gRPC provider —— 唯一实际传参的调用方;`revenue_service.go:487` 仍写 `sourceID="exhibition_"`。 +- [ ] `query_graph` `callers_of AssetLevelService.AddExhibitionHours` 查 `taskService/service/revenue_service.go:513` —— Task 4 改后编译通过即可,无别处调用。 +- [ ] MQ `isAlreadyProcessed` 仍查询 `change_type='exhibition_revenue'` 的 `crystal_transaction_records`:与新增的 `exhibition_hours_log` 不冲突(前者减少进入 handler 的次数,后者减少重复累加),形成两层防御纵深。 + +--- + +## Task 6: 存量数据校正 — dry-run 脚本 + +**Files:** +- Create: `backend/scripts/recalc_exhibition_hours.sql`(dry-run,仅 SELECT) + +**Interfaces:** +- Produces: 给运营/DBA 看的报告——"重算差值 / 将下修等级 / 将冲销水晶"。 +- Task 7 执行脚本依赖本任务的输出经人工确认后再执行。 + +- [ ] **Step 1: 写 dry-run SQL** + +```sql +-- recalc_exhibition_hours.sql +-- 批次1.2 存量校正(DRY-RUN): 输出"按 distinct exhibition 重算"前后的差异 +-- 1) 用户级 total_exhibition_hours:按 slot_owner_uid + occupier_star_id sum distinct exhibition hours +-- 2) 资产级 season_exhibition_hours:按 asset_id sum distinct (exhibition_id, expire_at - start_time)/3600000 +-- 3) 已发"误升奖励"水晶:crystal_transaction_records.change_type='level_up_bonus' 且 source_id IN (受影响 exhibitions) +-- ⚠️ 本文件仅 SELECT,禁止任何 DDL/DML。 + +\echo '==== 用户级:当前 vs 重算 (差值 > 0 即虚高) ====' +WITH per_user_star AS ( + SELECT e.slot_owner_uid AS user_id, + e.occupier_star_id AS star_id, + SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours + FROM exhibitions e + WHERE e.deleted_at IS NULL + GROUP BY e.slot_owner_uid, e.occupier_star_id +), +current_ueh AS ( + SELECT user_id, star_id, total_exhibition_hours AS current_hours + FROM user_exhibition_hours +) +SELECT COALESCE(c.user_id, r.user_id) AS user_id, + COALESCE(c.star_id, r.star_id) AS star_id, + COALESCE(c.current_hours, 0) AS current_hours, + COALESCE(r.recalc_hours, 0) AS recalc_hours, + (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours +FROM current_ueh c +FULL OUTER JOIN per_user_star r USING (user_id, star_id) +WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0) +ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC +LIMIT 50; + +\echo '==== 资产级:当前 vs 重算 (差值 > 0 即虚高) ====' +WITH per_asset AS ( + SELECT e.asset_id, + SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours + FROM exhibitions e + WHERE e.deleted_at IS NULL AND e.asset_id > 0 + GROUP BY e.asset_id +) +SELECT COALESCE(c.asset_id, r.asset_id) AS asset_id, + COALESCE(c.current_hours, 0) AS current_hours, + COALESCE(r.recalc_hours, 0) AS recalc_hours, + (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours +FROM ( + SELECT asset_id, season_exhibition_hours AS current_hours + FROM asset_level_records WHERE season_exhibition_hours > 0 +) c +FULL OUTER JOIN per_asset r USING (asset_id) +WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0) +ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC +LIMIT 50; + +\echo '==== 已发升级奖励水晶总额(change_type=level_up_bonus) ====' +SELECT count(*) AS level_up_bonus_records, + COALESCE(sum(delta), 0) AS total_crystal +FROM crystal_transaction_records +WHERE change_type = 'level_up_bonus'; + +\echo '==== 待冲销水晶估算:误升用户数 × 平均奖励(dry-run,不写) ====' +WITH affected_users AS ( + SELECT COALESCE(c.user_id, r.user_id) AS user_id, + COALESCE(c.star_id, r.star_id) AS star_id, + (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours + FROM (SELECT user_id, star_id, total_exhibition_hours AS current_hours FROM user_exhibition_hours) c + FULL OUTER JOIN ( + SELECT e.slot_owner_uid AS user_id, e.occupier_star_id AS star_id, + SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours + FROM exhibitions e WHERE e.deleted_at IS NULL GROUP BY 1,2 + ) r USING (user_id, star_id) + WHERE COALESCE(c.current_hours, 0) > COALESCE(r.recalc_hours, 0) +) +SELECT count(DISTINCT (user_id, star_id)) AS users_will_downgrade, + count(DISTINCT user_id) AS user_count +FROM affected_users; +``` + +- [ ] **Step 2: 跑 dry-run** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \ + -f backend/scripts/recalc_exhibition_hours.sql 2>&1 | head -100 +``` +Expected: 输出 (a) 用户级差值清单(按 diff_hours 降序,limit 50)、(b) 资产级差值清单、(c) 升级奖励水晶总额、(d) 待下修用户数。**无 DML/DDL**,数据库无变化。 + +- [ ] **Step 3: 人工确认(不在脚本内)** + +> 这是 GATE:等待用户在确认输出数字合理后明确指示"执行 / 跳过 / 仅清理测试库"。脚本仅输出,不自动跑 Task 7。 + +--- + +## Task 7: 存量数据校正 — 执行脚本(按 dry-run 输出) + +**Files:** +- Create: `backend/scripts/fix_exhibition_hours_recalc.sql` + +> ⚠️ 执行门槛:必须先跑 Task 6 dry-run 并经**用户**明确指示后才执行本任务。本库为测试数据可清账;生产执行需 DBA 复核。 + +- [ ] **Step 1: 备份** + +Run: +```bash +PGPASSWORD=123456 pg_dump -h localhost -p 15432 -U postgres -d top-fans \ + -t exhibition_hours_log -t asset_exhibition_hours_log -t user_exhibition_hours \ + -t asset_level_records -t fan_profiles -t crystal_transaction_records \ + -t exhibitions > backup_exh_hours_recalc_$(date +%Y%m%d_%H%M%S).sql +ls -lh backup_exh_hours_recalc_*.sql +``` +Expected: 备份文件 > 0 字节。 + +- [ ] **Step 2: 写执行 SQL(含事务 + 序列同步)** + +```sql +-- fix_exhibition_hours_recalc.sql +-- 批次1.2 存量校正(执行)。破坏性 SQL,先备份。 +-- 依赖:Task 6 dry-run 输出经人工确认。 +-- 步骤:(1) 重算 user_exhibition_hours (2) 重算 asset_level_records (3) 按新累计时长复算 fan_profiles.level +-- (4) 找误发等级奖励,插负 delta 流水冲销 (5) 序列同步 + +BEGIN; + +-- (1) 用户级:用 CTE 算出"distinct exhibitions per (user,star)"的小时数,再 UPDATE +WITH per_user_star AS ( + SELECT e.slot_owner_uid AS user_id, + e.occupier_star_id AS star_id, + SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours, + MAX(e.updated_at) AS max_updated_at + FROM exhibitions e + WHERE e.deleted_at IS NULL + GROUP BY e.slot_owner_uid, e.occupier_star_id +) +UPDATE user_exhibition_hours ueh +SET total_exhibition_hours = COALESCE(p.recalc_hours, 0), + updated_at = COALESCE(p.max_updated_at, ueh.updated_at) +FROM per_user_star p +WHERE ueh.user_id = p.user_id AND ueh.star_id = p.star_id; + +-- (2) 资产级:按 asset_id sum distinct exhibition hours;asset_level_records.season 是当前赛季口径, +-- 这里"重算 = 单赛季累计"——若一资产在本赛季跨多个 exhibitions,distinct 合并; +-- 历史赛季(LifetimeExhibitionHours)保留原值,不再回溯。 +WITH per_asset AS ( + SELECT e.asset_id, + SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours, + MAX(e.updated_at) AS max_updated_at + FROM exhibitions e + WHERE e.deleted_at IS NULL AND e.asset_id > 0 + GROUP BY e.asset_id +) +UPDATE asset_level_records alr +SET season_exhibition_hours = LEAST(alr.season_exhibition_hours, COALESCE(p.recalc_hours, 0)), + updated_at = COALESCE(p.max_updated_at, alr.updated_at) +FROM per_asset p +WHERE alr.asset_id = p.asset_id; + +-- (3) 复算 fan_profiles.level(用项目已有 CalculateLevelFromExhibitionHours 的"阈值表"语义—— +-- 简单实现:取 max(level) WHERE max_exhibition_hours <= total_exhibition_hours) +-- 注:此步只下修,**不上修**(避免反向多发奖励)。 +UPDATE fan_profiles fp +SET level = COALESCE(( + SELECT MAX(lt.level) FROM level_thresholds lt + WHERE lt.max_exhibition_hours <= COALESCE(( + SELECT ueh.total_exhibition_hours FROM user_exhibition_hours ueh + WHERE ueh.user_id = fp.user_id AND ueh.star_id = fp.star_id + ), 0) +), 1) +WHERE fp.user_id IS NOT NULL AND fp.star_id IS NOT NULL; + +-- (4) 找误发奖励:现存 level_up_bonus 中 source_id IN(已被多个 exhibition 重复结算)对应的 exhibition +-- 已与 cleanup_worker 删除相联;本步按"是否 double-recorded"甄别。 +-- 简化策略:对每个 fan_profiles.level 下降者,删除其 source_id='exhibition_' 但当前展览已 +-- uniq 的 level_up_bonus,写一条负 delta 的冲销流水。 +INSERT INTO crystal_transaction_records + (user_id, star_id, change_type, delta, balance_before, balance_after, source_id, description, created_at) +SELECT fp.user_id, fp.star_id, 'level_down_reverse', -ctr.delta, + fp.crystal_balance - ctr.delta, fp.crystal_balance, + 'reverse_' || ctr.source_id, + '时长幂等改造: 撤销误发升级奖励', EXTRACT(EPOCH FROM now())*1000 +FROM crystal_transaction_records ctr +JOIN fan_profiles fp + ON fp.user_id = ctr.user_id AND fp.star_id = ctr.star_id +WHERE ctr.change_type = 'level_up_bonus' + AND ctr.source_id LIKE 'exhibition_%' + AND EXISTS ( + SELECT 1 FROM exhibition_revenue_records err + WHERE ('exhibition_' || err.exhibition_id::text) = ctr.source_id + GROUP BY err.exhibition_id, err.cycle_start_time + HAVING count(*) > 1 + ); + +-- 同步被冲销用户的 fan_profiles.crystal_balance +UPDATE fan_profiles fp +SET crystal_balance = GREATEST(0, fp.crystal_balance + COALESCE(( + SELECT SUM(delta) FROM crystal_transaction_records ctr + WHERE ctr.user_id = fp.user_id AND ctr.star_id = fp.star_id + AND ctr.change_type = 'level_down_reverse' +), 0)); + +-- (5) 序列同步(CLAUDE.md 强制) +SELECT setval('crystal_transaction_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM crystal_transaction_records)); +SELECT setval('user_exhibition_hours_id_seq', (SELECT COALESCE(MAX(id),1) FROM user_exhibition_hours)); +SELECT setval('asset_level_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM asset_level_records)); + +COMMIT; +``` + +> **设计权衡**:上述 INSERT 选取"该 source_id 对应的 exhibition 在 `exhibition_revenue_records` 同一 (exhibition_id, cycle_start_time) 出现 ≥2 次"的 source_id 集,来冲销对应升级奖励。这是基于已知 5501 次重复的合理子集;若需更精确可加 (`current_level < new_level_at_time`),但需要历史 level_changes 表(本项目未建)——MVP 先行,按此子集先行止血,剩余留给运营人工核对。 + +- [ ] **Step 3: 跑执行** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \ + -f backend/scripts/fix_exhibition_hours_recalc.sql +``` +Expected: 一组 UPDATE / INSERT 影响行数;末尾 COMMIT 无 error。 + +- [ ] **Step 4: 验证** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*) FROM crystal_transaction_records WHERE change_type='level_down_reverse') AS reverse_records, + (SELECT COALESCE(sum(delta),0) FROM crystal_transaction_records WHERE change_type='level_down_reverse') AS reverse_total, + (SELECT count(*) FROM user_exhibition_hours WHERE total_exhibition_hours < 0) AS neg_total, + (SELECT count(*) FROM asset_level_records WHERE season_exhibition_hours < 0) AS neg_asset; +" +``` +Expected: `reverse_records > 0`、`reverse_total < 0`、负值行=0。 + +- [ ] **Step 5: 序列健康复检** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT schemaname, sequencename, last_value, + (SELECT MAX(id) FROM crystal_transaction_records) AS tbl_max, + last_value >= (SELECT MAX(id) FROM crystal_transaction_records) AS healthy +FROM pg_sequences +WHERE sequencename IN ('crystal_transaction_records_id_seq', + 'user_exhibition_hours_id_seq', + 'asset_level_records_id_seq');" +``` +Expected: 所有行 `healthy=t`。 + +- [ ] **Step 6: Commit**(用户批准后) + +```bash +git add backend/scripts/recalc_exhibition_hours.sql backend/scripts/fix_exhibition_hours_recalc.sql +git commit -m "fix(user/asset): recalc + rollback over-credited level bonuses (batch 1.x) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 8: 全局回归 — 编译/测试/调用方/相邻模块 + +**Files:** 无(验证任务) + +- [ ] **Step 1: 全 go.work 编译** + +Run: `cd backend && go build ./...` +Expected: 无错误。 + +- [ ] **Step 2: 改动函数的直接调用方 (callers) 仍能工作** + +- [ ] `query_graph callers_of fanProfileRepository.AddExhibitionHours` —— 三个调用方:`userService/provider/user_provider.go:862`(gRPC 入口,转发)+ `userService/service/user_service.go:991`(无变化)+ `userService/mq/consumer.go:84`(已有 sourceID 来自 payload,幂等闸口无害)。编译通过即代表 OK。 +- [ ] `query_graph callers_of AssetLevelService.AddExhibitionHours` —— 仅 `taskService/service/revenue_service.go:513`,Task 4 已改。 +- [ ] `query_graph tests_for AddExhibitionHours` —— Task 2 / Task 3 新增 2 个测试。 + +- [ ] **Step 3: 同包相邻文件扫一遍** + +Run: +```bash +cd backend && grep -rn "exhibition_hours\|asset_exhibition_hours\|ExhibitionHoursLog" \ + --include="*.go" services/ pkg/ +``` +Expected: 仅 Task 2/3 引入的位置,无第三条路径。 + +- [ ] **Step 4: 跨章节引用一致性** +- [ ] `docs/specs/2026-07-21-backend-remediation-plan.md §批次1.2`:本 plan 全部对齐三条 (去重表 + 资产级 sourceID + 存量重算)。 +- [ ] `docs/backend-audit-2026-07-21.md §七.2` 五点根因:Task 2 解 (1)(3),Task 3 解 (2),Task 6/7 解 (4)(5)。 + +- [ ] **Step 5: 删除/未改动章节**(CLAUDE.md 全局自审) + +未改动章节:`docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md`(批次 1.1 已前置完成 —— `cleanup_worker.go` 已删、settled_at 已加、ON CONFLICT 已加)。本 plan 与其无重叠,仅 Task 5 与批次 1.1 中"端到端"步骤互不依赖,可独立 review。 + +--- + +## Self-Review + +- **Spec 覆盖**:批次 1.2 三条 (AddExhibitionHours 幂等 + 资产级 sourceID + 存量重算) → Task 2 / Task 3 / Task 4 / Task 6 / Task 7。`docs/specs/2026-07-21-backend-remediation-plan.md §四` 备份/dry-run/事务/序列同步规范 → 全部 Task 都遵循(备份+dry-run 写在 Task 6,事务包裹+setval 写在 Task 7)。 +- **CLAUDE.md 全球约束**: + - 序列同步(强制):Task 1 末尾 + Task 7 末尾,setval 全打。 + - 不自动 commit:所有 commit 步骤标记 "用户批准后"。 + - 全局自审:Task 8 已列出未改动章节(批次 1.1 plan)。 + - 全局自审失败案例提醒:本 plan 改动了 `pkg/models/asset_level.go`(追加 model),需检查是否影响 7 个 service 编译 → Task 8 Step 3 已 grep。 + - 文档维护传染性:改了 fan_profile_repository.go / asset_level_service.go → 同步修 revenue_service.go:513 调用方(Task 4)→ 同步重算脚本(Task 6/7)→ 同步回归(Task 8)。 +- **MVP 原则**:未引入 provider 抽象、ConversationStore、Redis lock 等"未来 100 明星"复杂基础设施;幂等闸口选"DB 层 + 本地 service local"路径,符合 MVP。 +- **类型一致性**: + - `AssetLevelService.AddExhibitionHours(assetID, hours, sourceID)` —— `string` 类型与现有 `String()` 参数兼容;调用方 `int(actualHours)` 仍可隐式转。 + - `ExhibitionHoursLog.Hours int64`(与 `user_exhibition_hours.total_exhibition_hours` 一致);`AssetExhibitionHoursLog.Hours int`(与 `asset_level_records.season_exhibition_hours int` 一致)。 + - 冲销流的 `delta` 类型为 `int64`(与 crystal_transaction_records 一致)。 +- **回归盲点**:Task 7 的 level 重算使用 `level_thresholds.max_exhibition_hours` —— 已确认 `models.LevelThreshold` 字段拼写为 `max_exhibition_hours`(pkg/models/level.go:12),与脚本一致。 +- **测试依赖**:本仓库测试通过 `setupTestDB` 连真实 `top-fans` 库(见 user_repository_test.go:13-30)。`asset_level_service_test.go` 现有 `TestCalculateBuff` 是纯函数无 DB 依赖,Task 3 测试补 DB 路径,需提供 `testGetDBForAssetLevel` 辅助或扩 `levelRepo.GetDB()`(Step 2 ⚠️)。 +- **Placeholder 扫描**:无 TBD。 +- **优先分类**: + - P0: Task 1(migration)、Task 2(用户级幂等闸口)。 + - P1: Task 3(资产级幂等闸口)、Task 4(调用方传 sourceID)。 + - P2: Task 6/7(存量校正;可有可无;无则后续有 P1 资损仍在累计未来展览)。 + +--- + +**路径**: `/Users/liulujian/Documents/code/TopFansByGithub/docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md` +**任务数**: 8(含前置 0、migration 1、Go 改造 3、脚本 2、回归 2) diff --git a/docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md b/docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md new file mode 100644 index 0000000..46965ea --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-exhibition-settlement-idempotency.md @@ -0,0 +1,333 @@ +# 展品收益结算幂等 (批次 1.1 + 1.3) 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:** 让展品收益结算幂等——同一展品同一周期只产生一条 `exhibition_revenue_records`,消除已实测的重复发放(超发 2,525,254 水晶),并统一 `created_at` 时间单位。 + +**Architecture:** 数据层加 `UNIQUE(exhibition_id, cycle_start_time)` + 写入 `ON CONFLICT DO NOTHING`,使所有结算路径在 DB 层天然幂等(防御纵深)。活跃结算路径是 MQ (`HandleExhibitionSettled` → `revenue:exhibition` 子任务 → `RevenueService` → `revenue_repo.CreateRevenueRecord`);已废弃的 `cleanup_worker.go`(死代码)已删除。MQ 的 `isSettled/markSettled` 从复用 `is_processed` 切到独立 `settled_at` 列。 + +**Tech Stack:** Go 1.25 (go.work 多模块), GORM, PostgreSQL, asynq(MQ), 本地库 `top-fans`@`localhost:15432`。 + +## Global Constraints + +- Go 组合式,不引入新依赖;沿用 `gorm.io/gorm/clause`。 +- migration 放 `backend/migrations/`,破坏性 SQL 前 dry-run + 备份;末尾按 `CLAUDE.md` 规范 `setval` 同步序列。 +- 时间戳统一**毫秒** (`time.Now().UnixMilli()`)。 +- 不自动 `git commit`(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 +- 每个任务结束 `go build ./...`(在对应模块目录)通过。 +- 存量数据为测试期脏数据,可清账;但清理脚本仍走 dry-run→确认→执行。 + +--- + +## 前置(已完成) + +- ✅ 已删除死代码 `backend/services/galleryService/service/cleanup_worker.go`(`NewCleanupWorker` 全仓无调用方,唯一引用是一条注释)。 +- ✅ 已修 `backend/services/taskService/repository/like_bet_repo.go` 中提及已删 `CleanupWorker` 的过时注释。 +- ⚠️ **遗留缺口**:`cleanupInvalidDisplayStatus`(display_status 定时兜底清理)随死 worker 删除;该兜底此前已随死 worker 停摆(非本次回归)。是否需重新装配到 MQ 路径,见 Task 5(记录为独立后续项,不在本 plan 落地)。 + +--- + +## File Structure + +- `backend/migrations/2026_07_21_001_exhibition_revenue_idempotent.sql` — **新建**。created_at 单位回填 + 历史去重 + 唯一约束 + `exhibitions.settled_at` 列 + 序列同步。 +- `backend/services/taskService/repository/revenue_repo.go` — **改**。`CreateRevenueRecord` 加 `ON CONFLICT DO NOTHING`;冲突时回查既有记录返回(保证调用方 `createdRecord.ID` 不为 0);`CreatedAt` 改 `UnixMilli()`。 +- `backend/services/galleryService/mq/consumer.go` — **改**。`isSettled/markSettled` 从 `is_processed` 切到 `settled_at`。 +- `backend/scripts/fix_exhibition_revenue_dedup.sql` — **新建**。存量核对/回收超发水晶的 dry-run + 执行脚本(本 plan 只做收益记录去重侧;时长重算属批次 1.2)。 + +--- + +## Task 1: Migration — created_at 单位 + 历史去重 + 唯一约束 + settled_at + +**Files:** +- Create: `backend/migrations/2026_07_21_001_exhibition_revenue_idempotent.sql` + +**Interfaces:** +- Produces: 约束 `uk_exhibition_revenue_cycle UNIQUE(exhibition_id, cycle_start_time)`;列 `exhibitions.settled_at bigint`。Task 2/3 依赖它们。 + +- [ ] **Step 1: 写 migration(含 dry-run 注释块)** + +```sql +-- 2026_07_21_001_exhibition_revenue_idempotent.sql +-- 批次1.1+1.3:展品收益结算幂等 + created_at 单位统一 +-- 执行前请先备份: +-- pg_dump -h -U postgres -t exhibition_revenue_records -t exhibitions > backup_1_1.sql + +BEGIN; + +-- (1.3) created_at 秒→毫秒回填(10 位秒 → 13 位毫秒) +UPDATE exhibition_revenue_records +SET created_at = created_at * 1000 +WHERE created_at > 0 AND created_at < 100000000000; + +-- (1.1) 历史去重:同一 (exhibition_id, cycle_start_time) 只保留 id 最小的一条 +DELETE FROM exhibition_revenue_records a +USING exhibition_revenue_records b +WHERE a.exhibition_id = b.exhibition_id + AND a.cycle_start_time = b.cycle_start_time + AND a.id > b.id; + +-- (1.1) 唯一约束(幂等基石) +ALTER TABLE exhibition_revenue_records + ADD CONSTRAINT uk_exhibition_revenue_cycle UNIQUE (exhibition_id, cycle_start_time); + +-- (settled_at) 独立审计列,替代复用 is_processed +ALTER TABLE exhibitions ADD COLUMN IF NOT EXISTS settled_at bigint; +-- 回填:已被当作 settled 的历史行(is_processed=true)迁移到 settled_at +UPDATE exhibitions SET settled_at = COALESCE(updated_at, EXTRACT(EPOCH FROM now())*1000) +WHERE is_processed = true AND settled_at IS NULL; + +-- 序列同步(CLAUDE.md 强制) +SELECT setval('exhibition_revenue_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM exhibition_revenue_records)); + +COMMIT; +``` + +- [ ] **Step 2: dry-run 预检(先看将影响多少行,不提交)** + +Run(对本地 `top-fans` 库): +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*) FROM exhibition_revenue_records WHERE created_at>0 AND created_at<100000000000) AS created_at_to_fix, + (SELECT count(*) FROM exhibition_revenue_records) - (SELECT count(DISTINCT (exhibition_id,cycle_start_time)) FROM exhibition_revenue_records) AS dup_rows_to_delete;" +``` +Expected: 打印 `created_at_to_fix|dup_rows_to_delete`(预期约 `6013|5501`)。人工确认数字合理后再执行 Step 3。 + +- [ ] **Step 3: 执行 migration** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -f backend/migrations/2026_07_21_001_exhibition_revenue_idempotent.sql +``` +Expected: `BEGIN ... UPDATE ... DELETE ... ALTER TABLE ... COMMIT`,无 error。 + +- [ ] **Step 4: 验证约束与单位** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*)-count(DISTINCT (exhibition_id,cycle_start_time)) FROM exhibition_revenue_records) AS remaining_dups, + (SELECT count(*) FROM exhibition_revenue_records WHERE created_at>0 AND created_at<100000000000) AS remaining_seconds, + (SELECT count(*) FROM information_schema.constraint_column_usage WHERE constraint_name='uk_exhibition_revenue_cycle') AS constraint_cols, + (SELECT count(*) FROM information_schema.columns WHERE table_name='exhibitions' AND column_name='settled_at') AS has_settled_at;" +``` +Expected: `remaining_dups=0`、`remaining_seconds=0`、`constraint_cols=2`、`has_settled_at=1`。 + +- [ ] **Step 5: Commit**(用户批准后) + +```bash +git add backend/migrations/2026_07_21_001_exhibition_revenue_idempotent.sql +git commit -m "feat(gallery): add exhibition revenue idempotency migration (batch 1.1/1.3) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 2: `CreateRevenueRecord` 幂等 + created_at 毫秒 + +**Files:** +- Modify: `backend/services/taskService/repository/revenue_repo.go:30-37` +- Test: `backend/services/taskService/repository/revenue_repo_test.go`(新建或追加) + +**Interfaces:** +- Consumes: Task 1 的唯一约束 `uk_exhibition_revenue_cycle`。 +- Produces: `CreateRevenueRecord(record) (*model.ExhibitionRevenueRecord, error)` —— 冲突时**不报错**,返回既有记录(`ID != 0`),保证调用方 `revenue_service.go:394/528` 的 `createdRecord.ID` 可用。 + +- [ ] **Step 1: 写失败测试(依赖本地库或 sqlite;此处用本地 top-fans)** + +在 `revenue_repo_test.go`: +```go +func TestCreateRevenueRecord_Idempotent(t *testing.T) { + db := testDB(t) // 连接 top-fans 测试库;无则 t.Skip + repo := NewRevenueRepository(db) + rec := &model.ExhibitionRevenueRecord{ + UserID: 999001, StarID: 87, ExhibitionID: 999900001, AssetID: 1, + SlotID: 1, SlotOwnerUID: 1, SlotType: "exhibition", + CrystalAmount: 10, CycleStartTime: 1780000000000, CycleEndTime: 1780003600000, + Status: "claimable", + } + r1, err := repo.CreateRevenueRecord(rec) + if err != nil { t.Fatal(err) } + rec2 := *rec // 同 (exhibition_id, cycle_start_time) + r2, err := repo.CreateRevenueRecord(&rec2) + if err != nil { t.Fatalf("second insert must not error: %v", err) } + if r1.ID != r2.ID { t.Fatalf("want same id (idempotent), got %d vs %d", r1.ID, r2.ID) } + // cleanup + db.Exec("DELETE FROM exhibition_revenue_records WHERE exhibition_id=?", rec.ExhibitionID) +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd backend/services/taskService && go test ./repository/ -run TestCreateRevenueRecord_Idempotent -v` +Expected: FAIL(当前无 ON CONFLICT,第二次插入报 `duplicate key` 或 id 不同)。 + +- [ ] **Step 3: 实现幂等写入** + +替换 `revenue_repo.go` 的 `CreateRevenueRecord`: +```go +func (r *revenueRepository) CreateRevenueRecord(record *model.ExhibitionRevenueRecord) (*model.ExhibitionRevenueRecord, error) { + record.CreatedAt = time.Now().UnixMilli() // 统一毫秒(原为 Unix() 秒,见批次1.3) + res := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "exhibition_id"}, {Name: "cycle_start_time"}}, + DoNothing: true, + }).Create(record) + if res.Error != nil { + logger.Logger.Error("Failed to CreateRevenueRecord", zap.Int64("user_id", record.UserID), zap.Error(res.Error)) + return nil, res.Error + } + // 冲突被忽略(RowsAffected==0 且 ID 未回填)时,回查既有记录,保证调用方拿到有效 ID + if res.RowsAffected == 0 || record.ID == 0 { + var existing model.ExhibitionRevenueRecord + if err := r.db.Where("exhibition_id = ? AND cycle_start_time = ?", record.ExhibitionID, record.CycleStartTime). + First(&existing).Error; err != nil { + return nil, err + } + logger.Logger.Warn("CreateRevenueRecord: duplicate settle ignored, returning existing", + zap.Int64("exhibition_id", record.ExhibitionID), zap.Int64("existing_id", existing.ID)) + return &existing, nil + } + return record, nil +} +``` +并确保文件已 `import "gorm.io/gorm/clause"`。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd backend/services/taskService && go test ./repository/ -run TestCreateRevenueRecord_Idempotent -v` +Expected: PASS(两次返回同一 ID)。 + +- [ ] **Step 5: 全模块编译** + +Run: `cd backend/services/taskService && go build ./...` +Expected: 无错误。 + +- [ ] **Step 6: Commit**(用户批准后) + +```bash +git add backend/services/taskService/repository/revenue_repo.go backend/services/taskService/repository/revenue_repo_test.go +git commit -m "fix(task): make CreateRevenueRecord idempotent via ON CONFLICT (batch 1.1) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 3: MQ `isSettled/markSettled` 切到 `settled_at` + +**Files:** +- Modify: `backend/services/galleryService/mq/consumer.go:204-221` + +**Interfaces:** +- Consumes: Task 1 的 `exhibitions.settled_at` 列。 +- Produces: 结算幂等判定不再复用 `is_processed`,消除审计报告 §四 P2「一列两义」。 + +- [ ] **Step 1: 改 isSettled** + +```go +func isSettled(ctx context.Context, exhibitionID int64) bool { + var settledAt *int64 + err := database.GetDB().Table("public.exhibitions"). + Select("settled_at"). + Where("id = ?", exhibitionID). + Scan(&settledAt).Error + if err != nil { + return false // 查询失败视为未结算,允许 handler 继续(写入侧有唯一约束兜底) + } + return settledAt != nil && *settledAt > 0 +} +``` + +- [ ] **Step 2: 改 markSettled** + +```go +func markSettled(ctx context.Context, exhibitionID int64) error { + return database.GetDB().Table("public.exhibitions"). + Where("id = ?", exhibitionID). + Update("settled_at", time.Now().UnixMilli()).Error +} +``` + +- [ ] **Step 3: 删掉/更新 L202-203 的过时注释**("先简化用 is_processed / 待 migration 加 settled 列"——migration 已在 Task 1 加)。 + +- [ ] **Step 4: 编译** + +Run: `cd backend/services/galleryService && go build ./...` +Expected: 无错误(确认 `time` 已 import,consumer.go 顶部已有)。 + +- [ ] **Step 5: Commit**(用户批准后) + +```bash +git add backend/services/galleryService/mq/consumer.go +git commit -m "refactor(gallery): use settled_at instead of is_processed for settle idempotency (batch 1.1) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 4: 端到端回归 — 结算重放不产生重复 + +**Files:** 无(验证任务) + +- [ ] **Step 1: 全 go.work 编译** + +Run: `cd backend && go build ./...` +Expected: 无错误。 + +- [ ] **Step 2: 重放幂等验证(SQL 级)** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +-- 模拟同一展品同周期二次插入应被约束挡下 +INSERT INTO exhibition_revenue_records (user_id,star_id,exhibition_id,asset_id,slot_id,slot_owner_uid,slot_type,crystal_amount,cycle_start_time,cycle_end_time,status,created_at) +VALUES (999001,87,999900002,1,1,1,'exhibition',10,1780000000000,1780003600000,'claimable',1780000000000) +ON CONFLICT (exhibition_id,cycle_start_time) DO NOTHING; +INSERT INTO exhibition_revenue_records (user_id,star_id,exhibition_id,asset_id,slot_id,slot_owner_uid,slot_type,crystal_amount,cycle_start_time,cycle_end_time,status,created_at) +VALUES (999001,87,999900002,1,1,1,'exhibition',10,1780000000000,1780003600000,'claimable',1780000000000) +ON CONFLICT (exhibition_id,cycle_start_time) DO NOTHING; +SELECT count(*) AS should_be_1 FROM exhibition_revenue_records WHERE exhibition_id=999900002; +DELETE FROM exhibition_revenue_records WHERE exhibition_id=999900002;" +``` +Expected: `should_be_1 = 1`。 + +- [ ] **Step 3: 回归清单**(对照 CLAUDE.md) + - [ ] `query_graph callers_of CreateRevenueRecord` 两个调用方(revenue_service.go:340/476)仍能拿到有效 `createdRecord.ID`。 + - [ ] `like_bet` 侧未受影响(其 `BatchCreate` 已幂等)。 + - [ ] MQ `HandleExhibitionSettled` 逻辑不依赖被删的 worker。 + +--- + +## Task 5: 遗留项登记(不在本 plan 落地) + +- [ ] **display_status 兜底清理**:随死 worker 删除的 `cleanupInvalidDisplayStatus` 需评估是否重新装配到 MQ 或独立小 worker。登记为独立任务,交由用户决定,不在本 plan 实现(避免夹带扩张)。 + +--- + +## 存量数据回收(超发水晶,批次 1.x 的收益侧) + +> Task 1 的 migration 已物理去重收益记录。已 `claimed` 的重复水晶回收口径需与运营确认;本库为测试数据可直接清账。 + +`backend/scripts/fix_exhibition_revenue_dedup.sql`(dry-run 版,先只 SELECT): +```sql +-- 统计去重前已被领取的重复超发(供核对) +SELECT count(*) AS extra_claimed_records, + COALESCE(sum(crystal_amount),0) AS extra_claimed_crystal +FROM ( + SELECT id, crystal_amount, + row_number() OVER (PARTITION BY exhibition_id, cycle_start_time ORDER BY id) AS rn + FROM exhibition_revenue_records WHERE status='claimed' +) t WHERE rn > 1; +``` +执行回收(清账)需人工确认后另行编写,遵循备份+事务+序列同步。 + +--- + +## Self-Review + +- **Spec 覆盖**:批次1.1(唯一约束+ON CONFLICT+收敛入口+settled_at)→ Task 1/2/3;批次1.3(created_at 单位)→ Task 1 Step1 + Task 2 Step3;死 worker 删除 → 前置已完成。累计时长幂等(1.2)、mint(1.4-1.6)不在本 plan(各自独立)。 +- **Placeholder 扫描**:无 TBD;migration/Go 代码均给出完整内容。 +- **类型一致性**:`CreateRevenueRecord` 签名与现有一致;冲突分支回查返回 `*model.ExhibitionRevenueRecord`,调用方 `.ID` 可用;`settled_at` 列名与 migration 一致。 diff --git a/docs/superpowers/plans/2026-07-21-mint-correctness.md b/docs/superpowers/plans/2026-07-21-mint-correctness.md new file mode 100644 index 0000000..f8f36d0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-mint-correctness.md @@ -0,0 +1,1060 @@ +# 铸造正确性 (批次 1.4 + 1.5 + 1.6) 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:** 修复铸造链路三类正确性问题:(1) 扣费跨服务 gRPC 嵌在 DB 事务内 (P0-2);(2) `CreateMintOrder` 非幂等、userService `UpdateCrystalBalance` 未按 `(source_id, change_type)` 去重;(3) 保底概率用 `time.Now().UnixNano()%100` 可预测、`mockTxHash` 全可观测输入可伪造;(4) `peripheral_service.doMint` 限流 count/insert TOCTOU 可绕过。 + +**Architecture:** +- **幂等基石**(DB 层):`crystal_transaction_records` 加 `UNIQUE(source_id, change_type) WHERE source_id<>''`;`userService.UpdateCrystalBalance` 先查重,命中即返回当前余额、不二次扣费。 +- **事务重构**(批次 1.4):`CreateMintOrder` 拆为 `txn_local`(写 PROCESSING 占位订单)→ **事务外 RPC** `UpdateCrystalBalance`(依赖上面幂等性防重)→ `txn_local`(建 asset/registry/订单 SUCCESS);失败走补偿标记 `FAILED+error_message`,**不自动退水晶**(避免引入新的回滚漏洞;后续由对账任务处理)。 +- **入口幂等**(批次 1.4):`CreateMintOrder` 入口按 `order_id` 查已存在 `SUCCESS` 订单直接返回;同 `PreCreateMintOrder` 现有的「先查后插」双层防护风格。 +- **随机性**(批次 1.5):保底概率改 `crypto/rand.Intn(100)`;`mockTxHash` 改 `crypto/rand` 32B hex + 前缀 `MOCK-NOT-ON-CHAIN:`,**不写入 `assets.tx_hash`**(仅作日志/调试使用),并把字段从响应里显式标注 `mock_tx_hash`,让前端不会误以为已上链。 +- **限流原子化**(批次 1.6):`peripheral_service.doMint` 限频改 Redis Lua `INCR + EXPIRE` 原子自增(key=`periph:mint:{owner_uid}:{yyyymmdd}`);DB 路径保留 `asset_registry` 唯一约束作兜底(已存在)。 + +**Tech Stack:** Go 1.25 (go.work 多模块)、GORM、PostgreSQL(`crystal_transaction_records`/`mint_orders`/`asset_registry`)、Dubbo RPC、`crypto/rand`、Redis(`go-redis`,项目已在 gateway 使用)。 + +## Global Constraints + +- Go 组合式;不改 RPC 协议(不引入新 proto 字段)。 +- migration 放 `backend/migrations/`,破坏性 SQL 前 dry-run + 备份;末尾按 `CLAUDE.md` 规范 `setval` 同步序列(涉及 `crystal_transaction_records`)。 +- 时间戳统一**毫秒**(`time.Now().UnixMilli()`)。 +- 不自动 `git commit`(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 +- 每个任务结束 `go build ./...`(在 `backend/` 目录)通过。 +- 不引入新第三方依赖(`crypto/rand` 标准库、`go-redis` 已在 gateway 使用,复用既有 Redis client)。 +- 涉及 PII 不进 INFO 日志(沿用 `mint_service.go` 现状,user_id/star_id 已 OK;不打印 order_id/asset_id 完整值到生产日志,DEBUG 才打印)。 + +--- + +## File Structure + +- `backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql` — **新建**。`crystal_transaction_records` 加部分唯一索引 `(source_id, change_type) WHERE source_id<>''`;序列同步。 +- `backend/services/userService/repository/fan_profile_repository.go` — **改**。`UpdateCrystalBalance` 入口先按 `(source_id, change_type)` 查重;命中直接返当前余额,不二次扣。 +- `backend/services/userService/repository/fan_profile_repository_test.go` — **改**(或新增 `_idempotency_test.go`)。补"同 source_id 第二次调用不改余额"用例。 +- `backend/services/assetService/service/mint_service.go` — **改**。`CreateMintOrder` 拆事务;入口幂等查重;保底概率换 `crypto/rand`;`mockTxHash` 移除出 `assets.tx_hash`。 +- `backend/services/assetService/service/mint_service_test.go` — **新建**。TDD 用例:幂等命中 / RPC 失败标 FAILED / 保底概率分布不恒定。 +- `backend/services/assetService/service/peripheral_service.go` — **改**。`doMint` 限流改 Redis Lua 原子自增;保留 DB 唯一约束兜底。 +- `backend/services/assetService/service/peripheral_service_test.go` — **改**。补"并发双击 11 次仍有 1 次被 50012 拒"用例(不依赖真 Redis,用 miniredis)。 +- `backend/services/assetService/repository/peripheral_repo.go` — **不改**(DB 唯一约束已存在,`ErrDuplicateRegistry` sentinel 已存在)。 +- `backend/pkg/redisx/redis_client.go` 或 `backend/services/assetService/.../redis.go` — **新建**。封装 `mintRateLimiter` Lua 脚本与 key 生成。 + +--- + +## Task 1: 幂等基石 — `crystal_transaction_records` 部分唯一约束 + 用户侧查重 + +**Files:** +- Create: `backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql` +- Modify: `backend/services/userService/repository/fan_profile_repository.go:397-466`(`UpdateCrystalBalance`) +- Modify: `backend/services/userService/repository/fan_profile_repository_test.go`(追加 `_idempotency_test.go` 同包) +- Test: `backend/services/userService/repository/crystal_idempotency_test.go`(新增,便于隔离) + +**Interfaces:** +- Produces: `crystal_transaction_records` 上 `(source_id, change_type)` 部分唯一索引(`source_id <> ''`);`UpdateCrystalBalance(userID, starID, delta, changeType, sourceID, description)` 在 `sourceID != ""` 时**重复调用返回首次结果**(相同 newBalance,不二次写入流水)。 + +- [ ] **Step 1: 写 migration(含 dry-run 注释块)** + +```sql +-- 2026_07_21_002_crystal_tx_source_id_unique.sql +-- 批次1.4 幂等基石: crystal_transaction_records 同 (source_id, change_type) 只允许一条 +-- 执行前请先备份: +-- pg_dump -h -U postgres -t crystal_transaction_records > backup_1_4.sql + +BEGIN; + +-- (a) 历史去重: 同一 (source_id, change_type) 保留 id 最小的一条(其他标 NULL,不入新流水) +UPDATE crystal_transaction_records a +SET source_id = '' +FROM crystal_transaction_records b +WHERE a.source_id = b.source_id + AND a.change_type = b.change_type + AND a.source_id <> '' + AND a.id > b.id; + +-- (b) 部分唯一索引(source_id 非空时强制唯一) +CREATE UNIQUE INDEX IF NOT EXISTS uk_crystal_tx_source_change + ON crystal_transaction_records (source_id, change_type) + WHERE source_id <> ''; + +-- (c) 序列同步(本表 BIGSERIAL,手动删改后必须 setval) +SELECT setval( + pg_get_serial_sequence('crystal_transaction_records', 'id'), + (SELECT COALESCE(MAX(id), 1) FROM crystal_transaction_records) +); + +COMMIT; +``` + +- [ ] **Step 2: dry-run 预检** + +Run(本地 `top-fans`): +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*) FROM crystal_transaction_records + WHERE source_id <> '' AND id NOT IN ( + SELECT MIN(id) FROM crystal_transaction_records + WHERE source_id <> '' GROUP BY source_id, change_type + )) AS dup_to_blank;" +``` +Expected: 一个数字(如 `0` 或几位数);人工确认合理后再执行 Step 3。 + +- [ ] **Step 3: 执行 migration** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans \ + -f backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql +``` +Expected: `BEGIN ... UPDATE ... CREATE INDEX ... COMMIT`,无 error。 + +- [ ] **Step 4: 验证约束** + +Run: +```bash +PGPASSWORD=123456 psql -h localhost -p 15432 -U postgres -d top-fans -tA -c " +SELECT + (SELECT count(*) - count(*) FROM pg_indexes WHERE indexname='uk_crystal_tx_source_change') AS idx_exists, + (SELECT count(*) FROM crystal_transaction_records + WHERE source_id <> '' GROUP BY source_id, change_type HAVING count(*) > 1) AS dup_groups;" +``` +Expected: `idx_exists=1`、`dup_groups=`(空)。 + +- [ ] **Step 5: 写失败测试 — 同 source_id 第二次调用不改余额** + +Create `backend/services/userService/repository/crystal_idempotency_test.go`: + +```go +package repository + +import ( + "testing" + + "github.com/topfans/backend/pkg/models" +) + +// TestUpdateCrystalBalance_IdempotentBySourceID 验证同 (source_id, change_type) 第二次调用 +// 不二次扣费、不二次写流水,newBalance 与首次一致。 +func TestUpdateCrystalBalance_IdempotentBySourceID(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + // 准备 user + star + fan_profile + userRepo := NewUserRepository() + user := &models.User{Mobile: "19900088001", PasswordHash: "x", IsActive: true} + if err := userRepo.Create(user); err != nil { t.Fatal(err) } + star := &models.Star{Name: "s", IdentityID: "idemp_star_1", IsActive: true} + db.Create(star) + profile := &models.FanProfile{ + UserID: user.ID, StarID: star.StarID, Nickname: "n", Level: 1, IsActive: true, + CrystalBalance: 1000, + } + db.Create(profile) + + repo := NewFanProfileRepository() + const sourceID = "mint-order-test-001" + const changeType = "mint_cost" + + // 第一次调用 + bal1, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint") + if err != nil { t.Fatalf("first call: %v", err) } + if bal1 != 900 { t.Errorf("first newBalance want 900, got %d", bal1) } + + // 第二次同 sourceID+changeType — 必须幂等,余额仍 900 + bal2, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint retry") + if err != nil { t.Fatalf("second call: %v", err) } + if bal2 != 900 { t.Errorf("second newBalance want 900 (idempotent), got %d", bal2) } + + // 校验: 只写了一条流水 + var n int64 + db.Model(&models.CrystalTransactionRecord{}). + Where("user_id=? AND star_id=? AND source_id=? AND change_type=?", user.ID, star.StarID, sourceID, changeType). + Count(&n) + if n != 1 { t.Errorf("tx rows want 1, got %d", n) } +} +``` + +Run: `cd backend && go test ./services/userService/repository/ -run TestUpdateCrystalBalance_IdempotentBySourceID -v` +Expected: FAIL(当前 `UpdateCrystalBalance` 没有 source_id 查重,第二次会再扣 100 → 余额 800,n=2)。 + +- [ ] **Step 6: 实现 `UpdateCrystalBalance` 入口查重** + +Modify `backend/services/userService/repository/fan_profile_repository.go` 在 `UpdateCrystalBalance` 函数体顶部(约 L403 `if userID <= 0 { ... }` 后),插入: + +```go + // ★ 批次1.4 幂等: 同 (source_id, change_type) 已落账则直接返回当前余额。 + // 防止上游 RPC 重试或事务回滚后重放导致的重复扣费。 + if sourceID != "" { + var existing models.CrystalTransactionRecord + err := r.db.Where("source_id = ? AND change_type = ?", sourceID, changeType). + Order("id DESC").First(&existing).Error + if err == nil { + // 已落账 — 取该 user 当前余额返,不二次扣。 + var profile models.FanProfile + if err := r.db.Where("user_id=? AND star_id=?", userID, starID). + First(&profile).Error; err != nil { + return 0, err + } + logger.Logger.Info("UpdateCrystalBalance idempotent hit", + zap.String("source_id", sourceID), + zap.String("change_type", changeType), + zap.Int64("user_id", userID), + zap.Int64("balance", profile.CrystalBalance), + ) + return profile.CrystalBalance, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + } +``` + +(保持函数其余逻辑不变;确保 import `models` 已在文件头)。 + +- [ ] **Step 7: 跑测试通过** + +Run: `cd backend && go test ./services/userService/repository/ -run TestUpdateCrystalBalance_IdempotentBySourceID -v` +Expected: PASS。 + +- [ ] **Step 8: 跑全量回归** + +Run: `cd backend && go build ./...` +Expected: 无 error。 + +- [ ] **Step 9: Commit(用户批准后)** + +```bash +git add backend/migrations/2026_07_21_002_crystal_tx_source_id_unique.sql \ + backend/services/userService/repository/fan_profile_repository.go \ + backend/services/userService/repository/crystal_idempotency_test.go +git commit -m "fix(userService): crystal_transaction_records (source_id, change_type) idempotent" +``` + +--- + +## Task 2: 入口幂等 — `CreateMintOrder` 命中已 SUCCESS 订单直接返回 + +**Files:** +- Modify: `backend/services/assetService/service/mint_service.go:209-258`(`CreateMintOrder` 函数体) +- Test: `backend/services/assetService/service/mint_service_idempotency_test.go`(新增) + +**Interfaces:** +- 行为:`CreateMintOrder(req, userID, starID)` 收到已存在的 `req.OrderId` 且 `status=SUCCESS` → 直接重放响应(asset/order/cost_crystal/balance_after),**不重复扣费**、**不重复创建 asset**。 + +- [ ] **Step 1: 写失败测试 — 重复创建 SUCCESS 订单不二次扣费** + +Create `backend/services/assetService/service/mint_service_idempotency_test.go`: + +```go +package service + +import ( + "context" + "testing" + + "github.com/topfans/backend/pkg/models" + "github.com/topfans/backend/services/assetService/repository" +) + +// TestCreateMintOrder_IdempotentOnSuccessOrder 验证: 同 order_id 第二次调用, +// 状态已是 SUCCESS → 直接返回原 asset,不再调 UpdateCrystalBalance。 +func TestCreateMintOrder_IdempotentOnSuccessOrder(t *testing.T) { + db := setupServiceTestDB(t) + defer cleanupServiceTestDB(t, db) + + // 准备 user + star + fan_profile(余额 1000) + star := createServiceTestStar(t, db, "mint_idem_star") + user := createServiceTestUser(t, db, "19900077001") + db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at) + VALUES (?, ?, 'n', 1, true, 1000, 1, 1)`, user.ID, star.StarID) + + mintRepo := repository.NewMintOrderRepository(db) + const orderID = "idem-order-uuid-001" + // 预置一个 SUCCESS 订单 + originalAsset := &models.Asset{ + OwnerUID: user.ID, StarID: star.StarID, Name: "existing", + CoverURL: "http://x/a.jpg", Status: models.AssetStatusActive, IsActive: true, + } + db.Create(originalAsset) + db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at) + VALUES (?, ?, ?, 'regular', 1, 1, 1)`, user.ID, originalAsset.ID, star.StarID) + db.Create(&models.MintOrder{ + OrderID: orderID, UserID: user.ID, StarID: star.StarID, + Status: models.MintOrderStatusSuccess, CostCrystal: 100, + }) + db.Create(&models.MintOrder{OrderID: "fake-real-asset-link"}) // 占位避免 lint + + // mock userClient: 重复调用时 UpdateCrystalBalance 不应被触发 + uc := &mockUserClient{balance: 900} + svc := NewMintService( + repository.NewAssetRepository(db), + mintRepo, + uc, + db, nil, + nil, nil, nil, + nil, + ) + + resp, err := svc.CreateMintOrder(&pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if resp.Order.OrderId != orderID { t.Errorf("want orderID=%s, got %s", orderID, resp.Order.OrderId) } + if uc.updateCrystalCalls != 0 { + t.Errorf("UpdateCrystalBalance must not be called on idempotent hit, got %d calls", uc.updateCrystalCalls) + } + // 余额没被二次扣 + if uc.balance != 900 { t.Errorf("balance want 900, got %d", uc.balance) } +} +``` + +需要补一个本地 mock `mockUserClient` 满足 `client.UserServiceClient` 接口(只实现 `UpdateCrystalBalance`/`GetFanProfile`/`UpdateAssetsCount`),并写文件顶部的辅助 `pbCreateMintReq`: + +```go +// 在同一 _test.go 文件顶部(import 之后) +type mockUserClient struct { + balance int64 + updateCrystalCalls int +} + +func (m *mockUserClient) UpdateCrystalBalance(_ context.Context, _, _ int64, delta int64, _, _, _ string) (int64, error) { + m.updateCrystalCalls++ + m.balance += delta + return m.balance, nil +} +func (m *mockUserClient) UpdateAssetsCount(_ context.Context, _, _ int64, delta int32) (int32, error) { + return 0, nil +} +func (m *mockUserClient) GetFanProfile(_ context.Context, _, _ int64) (*pbUser.FanProfile, error) { + return &pbUser.FanProfile{CrystalBalance: m.balance}, nil +} + +func pbCreateMintReq(orderID string, _, _ int64) *pb.CreateMintOrderRequest { + return &pb.CreateMintOrderRequest{ + OrderId: orderID, + MaterialUrl: "http://x/m.jpg", + Name: "n", + Description: "d", + Info: "i", + MaterialType: "new", + } +} +``` + +(实际写时按 `client.UserServiceClient` 完整接口补齐所有方法 — 现有接口是 3 个方法;导入 `pbUser "github.com/topfans/backend/pkg/proto/user"` 与 `pb "github.com/topfans/backend/pkg/proto/asset"`)。 + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_IdempotentOnSuccessOrder -v` +Expected: FAIL(当前 `CreateMintOrder` 入口不查 SUCCESS 状态,会走到事务里 → 触发第二次扣费或状态错乱)。 + +- [ ] **Step 2: 在 `CreateMintOrder` 入口加幂等短路** + +Modify `backend/services/assetService/service/mint_service.go` 在 L226 `if req.OrderId == "" { ... }` 之后、L231 `// 2. 获取当前累计铸爱次数` 之前,插入: + +```go + // ★ 批次1.4 幂等: 已 SUCCESS 的同 order_id 直接返回,不再走扣费/建档流程。 + existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID) + if err == nil && existing != nil && existing.Status == models.MintOrderStatusSuccess { + logger.Logger.Info("CreateMintOrder idempotent hit", + zap.String("order_id", existing.OrderID), + zap.Int64("user_id", userID), + ) + // 重放响应: 用既有 asset / 既有成本,不再二次扣。 + var assetProto *pb.Asset + if existing.AssetID != nil { + if a, err := s.assetRepo.GetByID(*existing.AssetID); err == nil && a != nil { + assetProto = ModelToProtoAssetDetail(a, "", "", false, 0, 0, 0, 0, getInt32Value(a.Grade)) + } + } + return &pb.CreateMintOrderResponse{ + Base: &pbCommon.BaseResponse{ + Code: uint32(codes.OK), Message: "idempotent", Timestamp: time.Now().UnixMilli(), + }, + Order: ModelToProtoMintOrder(existing), + Asset: assetProto, + CostCrystal: existing.CostCrystal, + BalanceAfter: 0, // 余额不再重算(避免对 userService 二次调用) + }, nil + } +``` + +注意:`s.assetRepo.GetByID` 是现有方法(已在 `GetMintOrder` 用过 `s.assetRepo.GetByID(*order.AssetID)`),无需新接口。 + +- [ ] **Step 3: 跑测试通过** + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_IdempotentOnSuccessOrder -v` +Expected: PASS。 + +- [ ] **Step 4: 跑全量回归** + +Run: `cd backend && go build ./...` +Expected: 无 error。 + +- [ ] **Step 5: Commit(用户批准后)** + +```bash +git add backend/services/assetService/service/mint_service.go \ + backend/services/assetService/service/mint_service_idempotency_test.go +git commit -m "fix(assetService): CreateMintOrder idempotent short-circuit on SUCCESS order" +``` + +--- + +## Task 3: 事务重构 — `CreateMintOrder` 把 RPC 移出事务(commit/失败标记补偿) + +**Files:** +- Modify: `backend/services/assetService/service/mint_service.go:240-516`(`CreateMintOrder` 函数体 — 仅保留 RPC 移出事务的拆解;幂等短路已在 Task 2 加好) + +**Interfaces:** +- 行为:拆 `CreateMintOrder` 为三段: + 1. **`txn_local#1`**(PENDING → PROCESSING + 校验、cost 计算、mint_count 自增、序列同步;**不调 RPC、不写 asset**) + 2. **事务外 RPC**:`s.userClient.UpdateCrystalBalance(ctx, userID, starID, -cost, "mint_cost", req.OrderId, ...)`;依赖 Task 1 的幂等防护 + 3. **`txn_local#2`**:写 asset + asset_registry + mint_order 推进 SUCCESS + minted_at + - 任一步失败 → `txn_local#3`(单独事务)把 mint_order 标 FAILED + error_message,**不回滚水晶**(已在事务外,且 mint_cost 流水是审计记录) + - 状态机:`PENDING → PROCESSING → SUCCESS|FAILED`;FAILED 订单不再二次扣(用户可重试新建 PreCreateMintOrder) + +- [ ] **Step 1: 写失败测试 — RPC 失败时 mint_order 落 FAILED 且水晶已扣** + +Append to `backend/services/assetService/service/mint_service_idempotency_test.go`: + +```go +// TestCreateMintOrder_RPCFailure_MarksOrderFailed 验证: +// RPC UpdateCrystalBalance 返回错误 → mint_order 落 FAILED 状态 + 错误信息; +// 不会泄漏 PROCESSING 状态的"僵尸"订单。 +func TestCreateMintOrder_RPCFailure_MarksOrderFailed(t *testing.T) { + db := setupServiceTestDB(t) + defer cleanupServiceTestDB(t, db) + + star := createServiceTestStar(t, db, "mint_rpc_fail_star") + user := createServiceTestUser(t, db, "19900077002") + db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at) + VALUES (?, ?, 'n', 1, true, 100, 1, 1)`, user.ID, star.StarID) + + mintRepo := repository.NewMintOrderRepository(db) + uc := &mockUserClient{balance: 100, failOnUpdate: true} + svc := NewMintService( + repository.NewAssetRepository(db), mintRepo, uc, + db, nil, nil, nil, nil, nil, + ) + // mint cost config: 用最小配置(没有本地配置 repo 时跳过价格校验的精确值,只验证状态机) + // — 桩实现仅校验 cost > 0 且 UpdateCrystalBalance 入参含负数。 + orderID := "rpc-fail-uuid-001" + db.Create(&models.MintOrder{ + OrderID: orderID, UserID: user.ID, StarID: star.StarID, + Status: models.MintOrderStatusPending, + }) + + // 注: 此用例只在 mock 配置足够注入成本时验证状态机;若 GetMintCost 走真实配置, + // 需先用 mint_cost_configs fixture 写入一行(参考 test helpers)。 + _, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID) + if err == nil { t.Fatal("expected error from RPC failure, got nil") } + + final, _ := mintRepo.GetByOrderID(orderID) + if final.Status != models.MintOrderStatusFailed { + t.Errorf("want status=FAILED, got %s", final.Status) + } + if final.ErrorMessage == nil || *final.ErrorMessage == "" { + t.Error("want error_message populated") + } +} +``` + +需在 `mockUserClient` 上加 `failOnUpdate bool` 字段,并在 `UpdateCrystalBalance` 实现里返回 `fmt.Errorf("simulated RPC failure")` 当 `failOnUpdate==true`。 + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_RPCFailure_MarksOrderFailed -v` +Expected: FAIL(当前 RPC 在事务内,失败会事务回滚 → 订单仍是 PENDING,状态机未推进)。 + +- [ ] **Step 2: 在 `CreateMintOrder` 替换事务结构** + +Modify `backend/services/assetService/service/mint_service.go`,把 L240-516 的 `db.Transaction(...)` 拆为三段。**关键改动**(保留原注释/日志): + +```go +// === 阶段 1: txn_local#1 — 订单 PROCESSING + 校验 + 费用/序列 === +var costCrystal int64 +err = s.db.Transaction(func(tx *gorm.DB) error { + // ... 保留原 L249-292 的"3.0 取出阶段一订单 + 覆盖字段 + 必填校验"逻辑 ... + // ... 保留原 L298-306 的"3.1 获取铸造消耗配置 + capturedCostCrystal" ... + costCrystal = localMintCost.CostCrystal + // mint_count 自增(不调 RPC,纯 DB) + if err := s.UpdateMintCountAndBoost(ctx, tx, userID, starID, 0); err != nil { + return err + } + // 序列同步(沿用 syncAssetsIDSequence) + if err := syncAssetsIDSequence(tx); err != nil { return err } + // 推进订单到 PROCESSING + if err := tx.Model(&models.MintOrder{}). + Where("order_id = ?", req.OrderId). + Updates(map[string]interface{}{ + "status": models.MintOrderStatusProcessing, + "cost_crystal": costCrystal, + "updated_at": time.Now().UnixMilli(), + }).Error; err != nil { + return err + } + mintOrder = existing + return nil +}) +if err != nil { + // 阶段1失败: 订单回 PENDING 或 FAILED(本例原状态本就是 PENDING,无需二次操作) + return nil, fmt.Errorf("phase1 prepare: %w", err) +} + +// === 阶段 2: 事务外 RPC — 扣水晶(依赖 userService source_id 幂等,Task 1) === +var newBalance int64 +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +newBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal, + "mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId)) +if err != nil { + // 阶段2失败: txn_local#3 把订单标 FAILED + s.markMintOrderFailed(req.OrderId, err) + return nil, fmt.Errorf("水晶扣费失败: %w", err) +} + +// === 阶段 3: txn_local#2 — 建 asset/registry, 订单 SUCCESS === +var asset *models.Asset +err = s.db.Transaction(func(tx *gorm.DB) error { + // ... 保留原 L345-415 的"3.5 创建 asset + registry" ... + // ... 保留原 L417-446 的"3.5 推进到 SUCCESS" ... + return nil +}) +if err != nil { + // 阶段3失败: 订单标 FAILED(水晶已扣 — 走对账任务回退,不在本路径处理) + s.markMintOrderFailed(req.OrderId, err) + return nil, fmt.Errorf("phase2 finalize: %w", err) +} + +// ... 阶段4/5/6 沿用原 L455-513(资产等级初始化、owner 信息、statistic 事件) ... +``` + +并在文件里加私有 helper: + +```go +// markMintOrderFailed 在独立事务中标 FAILED,失败仅日志不阻塞主流程 +func (s *mintService) markMintOrderFailed(orderID string, cause error) { + now := time.Now().UnixMilli() + err := s.db.Model(&models.MintOrder{}). + Where("order_id = ?", orderID). + Updates(map[string]interface{}{ + "status": models.MintOrderStatusFailed, + "error_message": cause.Error(), + "updated_at": now, + }).Error + if err != nil { + logger.Logger.Error("markMintOrderFailed failed", + zap.String("order_id", orderID), + zap.Error(err), + ) + } +} +``` + +⚠️ **删掉**原 L309-318 的事务内 RPC 调用;删掉原 L322-336 的事务内保底概率计算(迁移到 Task 4 的 `crypto/rand` 实现,事务内仍是合法位置 — 见 Task 4 Step 1);删掉原 L349 的 `mockTxHash`(Task 5 替换)。`boostBps` 计算仍可在 txn_local#1 末尾做(**不调 RPC**,纯本地随机 + DB 写)。 + +- [ ] **Step 3: 跑测试通过** + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_RPCFailure_MarksOrderFailed -v` +Expected: PASS。 + +- [ ] **Step 4: 跑全量回归** + +Run: `cd backend && go build ./...` +Expected: 无 error。 + +- [ ] **Step 5: Commit(用户批准后)** + +```bash +git add backend/services/assetService/service/mint_service.go \ + backend/services/assetService/service/mint_service_idempotency_test.go +git commit -m "refactor(assetService): split CreateMintOrder txn, RPC moved out (P0-2)" +``` + +--- + +## Task 4: 保底概率改 `crypto/rand` + +**Files:** +- Modify: `backend/services/assetService/service/mint_service.go`(Task 3 已把保底计算放在 `txn_local#1` 末尾 — 仅替换随机源) + +**Interfaces:** +- 行为:`localMintCost.Probability > 0` 时用 `crypto/rand.Int(rand.Reader, big.NewInt(100))` 取 [0,100);小于 `Probability` 触发保底。**不再使用** `time.Now().UnixNano()%100`。 + +- [ ] **Step 1: 写失败测试 — 1000 次采样不应恒等于同一分布** + +Append to `backend/services/assetService/service/mint_service_idempotency_test.go`: + +```go +import ( + "crypto/rand" + "math/big" + "testing" +) + +// TestMintGuaranteeProbability_NonPredictable 验证随机源已从 time.Now() 切到 crypto/rand: +// 1000 次连续采样,100% 命中(Probability=100)与 0% 命中(Probability=0)必须分别全命中/全不命中; +// 且单次返回落在 [0,100) 区间。 +func TestMintGuaranteeProbability_NonPredictable(t *testing.T) { + // 抽 1000 个 [0,100) 整数,验证区间 + 100% 概率 vs 0% 概率两个极端。 + for i := 0; i < 1000; i++ { + v, err := rand.Int(rand.Reader, big.NewInt(100)) + if err != nil { t.Fatal(err) } + if v.Cmp(big.NewInt(100)) >= 0 || v.Sign() < 0 { + t.Fatalf("out of range: %v", v) + } + } + // 边界 + if alwaysTriggers(100, 1000) != 1000 { t.Error("P=100 should always trigger") } + if alwaysTriggers(0, 1000) != 0 { t.Error("P=0 should never trigger") } +} + +func alwaysTriggers(probability, n int) int { + hits := 0 + for i := 0; i < n; i++ { + v, _ := rand.Int(rand.Reader, big.NewInt(100)) + if v.Int64() < int64(probability) { hits++ } + } + return hits +} +``` + +Run: `cd backend && go test ./services/assetService/service/ -run TestMintGuaranteeProbability_NonPredictable -v` +Expected: PASS(这个 helper 测试本身不依赖 mint_service 内部代码,只验证 `crypto/rand` 行为符合规格)。**真正失败测试见 Step 3**。 + +- [ ] **Step 2: 在 mint_service 加 helper `rollGuarantee`** + +Modify `backend/services/assetService/service/mint_service.go` 文件内(任意 helper 区),加: + +```go +import ( + "crypto/rand" + "math/big" +) + +// rollGuarantee 按 probability(0-100) 概率返回是否触发保底. +// ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100,消除并发同纳秒相同结果 +// 与脚本卡点操纵风险。 +func rollGuarantee(probability int) bool { + if probability <= 0 { + return false + } + if probability >= 100 { + return true + } + v, err := rand.Int(rand.Reader, big.NewInt(100)) + if err != nil { + // 熵源失败: 降级为不触发(避免伪随机劣化;线上极少出现) + logger.Logger.Warn("crypto/rand.Int failed, falling back to no-guarantee", zap.Error(err)) + return false + } + return v.Int64() < int64(probability) +} +``` + +- [ ] **Step 3: 在 `txn_local#1` 末尾替换原 L322-336 概率计算** + +原 L322-336 (在事务外 / Task 3 拆完之后仍是同一个位置) 替换为: + +```go + // 3.3 保底概率(★ 批次1.5: crypto/rand,非可预测) + var boostBps int32 = 0 + if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 { + if rollGuarantee(localMintCost.Probability) { + boostBps = int32(localMintCost.RewardValue) + logger.Logger.Info("Mint guarantee triggered", + zap.Int64("user_id", userID), + zap.Int64("star_id", starID), + zap.Int32("mint_count", currentMintCount+1), + zap.Int32("boost_bps", boostBps), + zap.Int64("probability", localMintCost.Probability), + ) + } + } +``` + +- [ ] **Step 4: 跑全量回归** + +Run: `cd backend && go build ./... && go test ./services/assetService/service/ -run "TestMint|TestCreateMintOrder" -v` +Expected: 全部 PASS(包含 Task 2/3 的用例)。 + +- [ ] **Step 5: Commit(用户批准后)** + +```bash +git add backend/services/assetService/service/mint_service.go \ + backend/services/assetService/service/mint_service_idempotency_test.go +git commit -m "fix(assetService): rollGuarantee uses crypto/rand, not UnixNano" +``` + +--- + +## Task 5: `mockTxHash` 去可预测性 + 标注非链上凭证 + +**Files:** +- Modify: `backend/services/assetService/service/mint_service.go:349-364`(mint 事务内的 mockTxHash + asset.TxHash 写入) + +**Interfaces:** +- 行为:删除 `assets.tx_hash` 与 `assets.block_number` 写入("非链上凭证"明确不写入 DB 字段,避免被前端当作真的链上 hash 展示);改为在响应 proto 字段(若已有)写 `mock_tx_hash = "MOCK-NOT-ON-CHAIN:"`;若 proto 字段不存在,**仅在 DEBUG 日志输出**,**不写 asset 表**。 + +- [ ] **Step 1: 写测试 — TxHash 字段不应写入** + +Append to `backend/services/assetService/service/mint_service_idempotency_test.go`: + +```go +// TestCreateMintOrder_NoOnChainTxHashWritten 验证 mint 完成后, +// 新建 asset 的 tx_hash / block_number 列为 NULL(明确非链上凭证)。 +func TestCreateMintOrder_NoOnChainTxHashWritten(t *testing.T) { + db := setupServiceTestDB(t) + defer cleanupServiceTestDB(t, db) + + star := createServiceTestStar(t, db, "mock_tx_star") + user := createServiceTestUser(t, db, "19900077003") + db.Exec(`INSERT INTO fan_profiles (user_id, star_id, nickname, level, is_active, crystal_balance, created_at, updated_at) + VALUES (?, ?, 'n', 1, true, 1000, 1, 1)`, user.ID, star.StarID) + + mintRepo := repository.NewMintOrderRepository(db) + uc := &mockUserClient{balance: 900} + svc := NewMintService( + repository.NewAssetRepository(db), mintRepo, uc, + db, nil, nil, nil, nil, nil, + ) + // mint_cost_configs fixture: 由 repository 桩提供,这里跳过(测试只关心 tx_hash 不写) + + orderID := "no-tx-hash-uuid-001" + db.Create(&models.MintOrder{ + OrderID: orderID, UserID: user.ID, StarID: star.StarID, + Status: models.MintOrderStatusPending, + }) + + resp, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID) + if err != nil { t.Fatalf("unexpected: %v", err) } + if resp.Asset == nil { t.Fatal("no asset returned") } + if resp.Asset.TxHash != "" { + t.Errorf("asset.tx_hash must be empty (non-chain), got %q", resp.Asset.TxHash) + } + // DB 验证 + var asset models.Asset + db.First(&asset, resp.Asset.Id) + if asset.TxHash != nil || asset.BlockNumber != nil { + t.Errorf("DB tx_hash/block_number must be NULL, got %v/%v", asset.TxHash, asset.BlockNumber) + } +} +``` + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_NoOnChainTxHashWritten -v` +Expected: FAIL(当前 L349 的 sha256(...) 会写入 `assets.tx_hash`)。 + +- [ ] **Step 2: 删除原 L349-364 的 `mockTxHash` 与 `BlockNumber` 写入** + +Modify `backend/services/assetService/service/mint_service.go` 在 `txn_local#2` 创建 asset 的位置(约 L345-366),删除以下整段: + +```go +mockTxHash := fmt.Sprintf("0x%x", sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%d-%d", mintOrder.OrderID, userID, starID, time.Now().UnixNano())))) +mockBlockNumber := int64(time.Now().Unix()) // 使用当前时间戳作为模拟区块号 +... +TxHash: &mockTxHash, +BlockNumber: &mockBlockNumber, +``` + +并把 `"crypto/sha256"` import 移除(已无引用)。`mintedAt` 字段保留(`mintedAt := time.Now().UnixMilli()` 与 `MintedAt: &mintedAt` 仍写 — 表示"业务生效时间",非链上时间)。 + +- [ ] **Step 3: 跑测试通过** + +Run: `cd backend && go test ./services/assetService/service/ -run TestCreateMintOrder_NoOnChainTxHashWritten -v` +Expected: PASS。 + +- [ ] **Step 4: 跑全量回归** + +Run: `cd backend && go build ./...` +Expected: 无 error。 + +- [ ] **Step 5: Commit(用户批准后)** + +```bash +git add backend/services/assetService/service/mint_service.go \ + backend/services/assetService/service/mint_service_idempotency_test.go +git commit -m "fix(assetService): drop mockTxHash from assets.tx_hash (non-chain credential)" +``` + +--- + +## Task 6: `doMint` 限流改 Redis Lua 原子自增 + +**Files:** +- Modify: `backend/services/assetService/service/peripheral_service.go:217-239`(`doMint` 的限频段) +- Modify: `backend/services/assetService/service/peripheral_service_test.go`(加并发测试) +- Create: `backend/services/assetService/service/mint_rate_limiter.go`(封装 Lua 脚本 + key 生成) + +**Interfaces:** +- 行为:限频入口从 `s.repo.CountRecentMint` 改为 `s.rateLimiter.IncrAndCheck(ownerUID, "peripheral", 10, 24h)`;底层走 Redis `EVAL` Lua:`INCR + EXPIRE`(首次设置 24h);原子返回 `(count, allowed)`。`count >= 10` → 返 `BizCodeRateLimited`;否则继续。**DB 唯一约束仍作最终兜底**(已有 `ErrDuplicateRegistry` 处理)。 + +- [ ] **Step 1: 写失败测试 — 并发 11 次请求只放过 10 次** + +Append to `backend/services/assetService/service/peripheral_service_test.go`: + +```go +import ( + "sync" + "sync/atomic" +) + +// TestPeripheralService_MintFromPeripheral_RateLimitAtomic 验证 Redis Lua 原子限频: +// 并发 11 次 mint(同一 owner_uid),恰有 10 次成功/已添加,1 次返 BizCodeRateLimited。 +// 不依赖真 Redis: 用 miniredis(github.com/alicebob/miniredis/v2)内嵌。 +func TestPeripheralService_MintFromPeripheral_RateLimitAtomic(t *testing.T) { + db := setupServiceTestDB(t) + defer cleanupServiceTestDB(t, db) + + // 启 miniredis + s, _ := miniredis.Run() + defer s.Close() + rdb := redis.NewClient(&redis.Options{Addr: s.Addr()}) + limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour) + + repo := repository.NewPeripheralRepository(db) + svc := NewPeripheralServiceWithLimiter(repo, limiter) // 新增构造函数,见 Step 3 + + _, _ = setupAssetWithPeripheral(t, db) + user := createServiceTestUser(t, db, "19900077010") + ownerUID := user.ID + // 准备 10 个不同 asset_id(绕开 UNIQUE) + 10 个 peripheral_info + for i := 0; i < 10; i++ { + asset := createServiceTestAsset(t, db, ownerUID, int64(87), fmt.Sprintf("p-%d", i)) + db.Exec(`INSERT INTO peripheral_info (asset_id, star_id, user_id, code, image, brand, company, hash, verifier, first_verified_at, created_at, updated_at) + VALUES (?, 87, ?, ?, ?, 'B', 'C', '0xh', 'V', 1, 1, 1)`, asset.ID, ownerUID, + fmt.Sprintf("PERI-%d", i), fmt.Sprintf("http://x/%d.jpg", i)) + db.Exec(`INSERT INTO peripheral_verify_code (code_hash, peripheral_info_id, created_at) + SELECT 'h-' || ?, id, 1 FROM peripheral_info WHERE asset_id=?`, i, asset.ID) + } + + // 并发 11 次: 最后一个必然 50012 + var wg sync.WaitGroup + var ok, rateLimited, other int32 + for i := 0; i < 11; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + code := fmt.Sprintf("PERI-%d", i%10) + _, err := svc.MintFromPeripheral(context.Background(), ownerUID, code) + if err == nil { atomic.AddInt32(&ok, 1); return } + var bizErr *BizError + if errors.As(err, &bizErr) { + switch bizErr.Code { + case BizCodeRateLimited: atomic.AddInt32(&rateLimited, 1) + case BizCodeAlreadyAdded: atomic.AddInt32(&ok, 1) // 也算"放过" + default: atomic.AddInt32(&other, 1) + } + return + } + atomic.AddInt32(&other, 1) + }(i) + } + wg.Wait() + if ok != 10 || rateLimited != 1 || other != 0 { + t.Errorf("want ok=10 rateLimited=1 other=0, got ok=%d rateLimited=%d other=%d", ok, rateLimited, other) + } +} +``` + +需要新增的 import: `github.com/alicebob/miniredis/v2`、`github.com/redis/go-redis/v9`、`sync`、`sync/atomic`、`fmt`。 + +需要新增的构造函数 `NewPeripheralServiceWithLimiter(repo, limiter)`(Step 3 一并实现)。 + +Run: `cd backend && go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral_RateLimitAtomic -v` +Expected: FAIL(当前 `CountRecentMint`+`InsertPeripheralRegistry` 不在同一原子单元,并发 11 次可能 11 次都过 count 检查,再由 UNIQUE 兜底返 `AlreadyAdded` — 用例断言 `ok=10 rateLimited=1`,现状下 rateLimited=0,FAIL)。 + +- [ ] **Step 2: 实现 Redis Lua 限频器** + +Create `backend/services/assetService/service/mint_rate_limiter.go`: + +```go +package service + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// mintRateLimitScript Redis Lua 脚本: +// - INCR key; 若返回值为 1(刚创建),EXPIRE 24h. +// - 返回 [count, allowed] (allowed = count <= limit). +// 脚本保证 INCR + EXPIRE 原子,避免"先 incr 后挂"导致 key 永驻。 +var mintRateLimitScript = redis.NewScript(` +local n = redis.call("INCR", KEYS[1]) +if n == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) +end +return {n, n <= tonumber(ARGV[2]) and 1 or 0} +`) + +// MintRateLimiter 铸造限频器(Redis Lua 原子自增). +type MintRateLimiter struct { + rdb *redis.Client + limit int64 // 单窗口允许次数 + window time.Duration // 窗口大小(用作 EXPIRE) +} + +// NewMintRateLimiter 创建限频器. +func NewMintRateLimiter(rdb *redis.Client, limit int64, window time.Duration) *MintRateLimiter { + return &MintRateLimiter{rdb: rdb, limit: limit, window: window} +} + +// IncrAndCheck 原子自增并返回 (count, allowed). +// - count: 当前窗口内累计次数 +// - allowed: count <= limit 时为 true +func (l *MintRateLimiter) IncrAndCheck(ctx context.Context, ownerUID int64, assetType string) (int64, bool, error) { + key := l.key(ownerUID, assetType) + windowSec := int64(l.window.Seconds()) + res, err := mintRateLimitScript.Run(ctx, l.rdb, []string{key}, windowSec, l.limit).Result() + if err != nil { + return 0, false, fmt.Errorf("redis EVAL mintRateLimit: %w", err) + } + arr, ok := res.([]interface{}) + if !ok || len(arr) != 2 { + return 0, false, fmt.Errorf("redis EVAL returned unexpected shape: %v", res) + } + count, _ := arr[0].(int64) + allowed, _ := arr[1].(int64) + return count, allowed == 1, nil +} + +func (l *MintRateLimiter) key(ownerUID int64, assetType string) string { + // 按本地自然日切窗口(避免 24h 滑动导致跨日重置) + return fmt.Sprintf("periph:mint:%s:%d:%s", assetType, ownerUID, time.Now().UTC().Format("20060102")) +} +``` + +注:`go.mod` 已在 gateway 使用 `github.com/redis/go-redis/v9`;`miniredis` 是测试用(需 `go get github.com/alicebob/miniredis/v2`,**项目是否首次引入**需确认 — 若未引入,本 plan 加 `_test.go` 时一并 `go get`)。若不愿引入新依赖,备选:**走 DB 事务内 count + insert 原子化**(见 Step 4 备选方案)。 + +- [ ] **Step 3: 修改 `doMint` 用限频器 + 新构造函数** + +Modify `backend/services/assetService/service/peripheral_service.go`: + +```go +// PeripheralService 周边验真 + 加入藏品的业务层 +type PeripheralService struct { + repo *repository.PeripheralRepository + rateLimiter *MintRateLimiter +} + +// NewPeripheralService 原构造函数(无 Redis 限频 — 兼容旧测试) +func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService { + return &PeripheralService{repo: repo} +} + +// NewPeripheralServiceWithLimiter 含 Redis 限频的构造函数(Step 6+ 生产用) +func NewPeripheralServiceWithLimiter(repo *repository.PeripheralRepository, limiter *MintRateLimiter) *PeripheralService { + return &PeripheralService{repo: repo, rateLimiter: limiter} +} +``` + +Modify `doMint`(L217-239),把 L233-239 的 `CountRecentMint` 段替换为: + +```go + // 2. 限频: Redis Lua 原子自增(★ 批次1.6 修复原 CountRecentMint + InsertPeripheralRegistry TOCTOU) + if s.rateLimiter != nil { + count, allowed, err := s.rateLimiter.IncrAndCheck(ctx, ownerUID, "peripheral") + if err != nil { + // Redis 故障: 降级为 DB count(保守 — 放过少量请求,避免 Redis 挂了全站熔断) + logger.Logger.Warn("MintRateLimiter failed, falling back to DB count", + zap.Int64("owner_uid", ownerUID), zap.Error(err)) + dbCount, derr := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour) + if derr != nil { + return nil, fmt.Errorf("DB_COUNT_FALLBACK_FAILED: %w", derr) + } + if dbCount >= 10 { + return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} + } + } else if !allowed { + logger.Logger.Info("Mint rate limited", + zap.Int64("owner_uid", ownerUID), zap.Int64("count", count)) + return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} + } + } else { + // 兼容路径: 无 Redis 限频器时走 DB count(原行为) + count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour) + if err != nil { + return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err) + } + if count >= 10 { + return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} + } + } +``` + +- [ ] **Step 4: 跑测试通过** + +Run: `cd backend && go test ./services/assetService/service/ -run TestPeripheralService_MintFromPeripheral -v` +Expected: PASS(包含新并发用例与原有 RateLimited / AlreadyAdded 用例)。 + +- [ ] **Step 5: 跑全量回归** + +Run: `cd backend && go build ./...` +Expected: 无 error。 + +- [ ] **Step 6: Commit(用户批准后)** + +```bash +git add backend/services/assetService/service/peripheral_service.go \ + backend/services/assetService/service/mint_rate_limiter.go \ + backend/services/assetService/service/peripheral_service_test.go \ + backend/go.mod backend/go.sum +git commit -m "fix(assetService): doMint rate limit via Redis Lua atomic INCR (P1)" +``` + +--- + +## Self-Review + +### 1. Spec coverage(批次 1.4 / 1.5 / 1.6 全部覆盖) + +| 修复项 | 引用 | 任务 | +|--------|------|------| +| **1.4** RPC 移出事务 | `mint_service.go:309-318` | Task 3 | +| **1.4** `mint_orders.order_id` 幂等(重复请求直接返已 SUCCESS) | `mint_service.go:CreateMintOrder` | Task 2 | +| **1.4** userService 侧 `(source_id, change_type)` 幂等 | `fan_profile_repository.go:UpdateCrystalBalance` | Task 1 | +| **1.5** 保底概率改 `crypto/rand` | `mint_service.go:323` | Task 4 | +| **1.5** `mockTxHash` 去可预测性 / 不作链上凭证 | `mint_service.go:349` | Task 5 | +| **1.6** `doMint` count/insert 原子化 | `peripheral_service.go:233,271` | Task 6 | + +✅ 全部覆盖,无遗漏。 + +### 2. Placeholder scan + +- ✅ 无 `TODO/FIXME/类似待办`(除原代码已有注释,保留)。 +- ✅ 无 "implement later" / "fill in details"。 +- ✅ 每步都有完整代码或命令(SQL、Go 测试、Redis Lua、helper)。 +- ✅ Task 3 给的 RPC-out-of-txn 骨架含三段切分 + 失败标记 helper;Task 6 给 Redis Lua 完整脚本。 +- ✅ 没有引用未定义类型/方法:`mockUserClient` 在测试文件内自实现;`NewPeripheralServiceWithLimiter` 在 Step 3 定义;`syncAssetsIDSequence` 是 mint_service.go 现有 helper;`UpdateMintCountAndBoost(ctx, tx, ...)` 是现有方法(不传 RPC)。 + +### 3. Type / signature 一致性 + +| 名称 | 出现处 | 一致性 | +|------|--------|--------| +| `mint_service.go:UpdateMintCountAndBoost(ctx, tx, userID, starID, boostBps)` | Task 3 Step 2 沿用 | ✅ 已存在方法(mint_service.go:926) | +| `syncAssetsIDSequence(tx)` | Task 3 Step 2 沿用 | ✅ 已存在(mint_service.go:982) | +| `s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal, changeType, sourceID, description)` | Task 3 Step 2 | ✅ 已存在 client.UserServiceClient 接口 | +| `s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID)` | Task 2 Step 2 | ✅ 已存在(mint_order_repository.go:101) | +| `s.assetRepo.GetByID(*existing.AssetID)` | Task 2 Step 2 | ✅ 已存在(GetMintOrder 用过) | +| `models.MintOrderStatusProcessing/Success/Failed` | Task 3 | ✅ 已存在常量 | +| `client.UserServiceClient.UpdateCrystalBalance` 参数顺序 | mockUserClient vs Step 2 | ✅ 一致(ctx, userID, starID, delta, changeType, sourceID, description) | +| `BizCodeRateLimited = 50012` | Task 6 | ✅ 已存在 | +| `repository.ErrDuplicateRegistry` | 兜底 | ✅ 已存在(peripheral_repo.go:22) | + +### 4. 全局回归提醒 + +按 `CLAUDE.md` 自审规则 — 修复后必须做整体回归: + +- ✅ Task 1: 用户侧查重影响所有调 `UpdateCrystalBalance` 的服务(asset/activity/task/gallery),但**仅在 `sourceID != ""` 时查重**,旧调用方(`""` source_id 的)不受影响。 +- ✅ Task 2: `CreateMintOrder` 入口幂等短路只命中 `SUCCESS` 状态 — PENDING/FAILED/PROCESSING 订单继续走原路径。 +- ✅ Task 3: 状态机新增 PROCESSING 中间态;事务拆分后 **PROCESSING 状态会持续到阶段3完成**(秒级)。若进程崩溃在阶段2/3之间,需对账任务扫 `status=PROCESSING AND updated_at < now - 5min` 的孤儿订单标 FAILED(**本 plan 不实现对账**,记入批次后续项)。 +- ✅ Task 4: 随机源替换不影响业务字段;`crypto/rand.Int` 熵失败时降级为不触发保底(保守,不误发收益)。 +- ✅ Task 5: 删除 `assets.tx_hash`/`block_number` 写入是**不兼容变更** — 但本字段本来就标注"模拟,后续引入区块链功能",DB 现有数据允许为 NULL(proto 也是 `optional`)。前端若展示此字段需同步下线(**通知前端组,本 plan 不在 backend 范围**)。 +- ✅ Task 6: Redis Lua 限频器与 DB count 双轨;Redis 故障降级为 DB count(旧行为,避免 Redis 挂时全站熔断)。 + +### 5. P0/P1 优先级 + +- P0: Task 1(幂等基石)、Task 2(入口幂等)、Task 3(事务拆 RPC) — **财务正确性,先做** +- P1: Task 4(随机性)、Task 5(mockTxHash)、Task 6(TOCTOU) — **安全/稳定性,并行** + +### 6. 后续项(不在本 plan,列出供后续 plan) + +- 对账任务:扫 `mint_orders.status='PROCESSING' AND updated_at < now() - interval '5 minutes'` 的孤儿订单标 FAILED 并对账 RPC 流水(如已扣水晶但无 asset,需触发 userService 退款 — 当前 RPC 是单向扣,需 userService 加 `ReverseCrystalBalance` 或对账脚本直接 UPDATE `fan_profiles.crystal_balance` + 写一条 `change_type='mint_refund'` 流水)。 +- 批次 1.4 配套:admin/ops 后台给 `mint_orders` 加筛选 `status=FAILED` 的 UI + 一键重试(用户新建 `PreCreateMintOrder` 即可)。 +- 批次 1.6 配套:Redis 部署到 assetService 进程;如不部署,沿用 Task 6 的 DB count 降级路径。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-21-secrets-remediation.md b/docs/superpowers/plans/2026-07-21-secrets-remediation.md new file mode 100644 index 0000000..c3b41a2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-secrets-remediation.md @@ -0,0 +1,881 @@ +# 密钥出库与轮换 (批次0) 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:** 把已泄露到 git 的生产密钥(OSS/SMS/OpenAI/Dify/PUSH_URL/MiniMax)从仓库「出库」:删掉跟踪、补 `.gitignore`、`.env.example` 仅留占位符;同时明确告知云端密钥轮换与 git 历史清理为**运维动作**,AI 不可执行(也无法执行——AI 没有云控制台/团队强推权限)。 + +**Architecture:** +- **代码侧(AI 可做)**:`backend/deploy/envs/*.env` + `docker/.env*` + 根 `.gitignore` 之外的 `.env` 加进 `.gitignore`;`git rm --cached` 删跟踪;`backend/.env.example` 真实密钥替换为 `` 占位符;创建 `docker/.gitignore`(当前不存在)。 +- **运维侧(AI 不可做)**: + 1. **云端轮换**:阿里云 RAM 子账号 AccessKey、OpenAI/微达 中转站 API Key、Dify App API Key、uniPush URL/TOKEN、SMS AccessKey——在云控制台禁用旧 key、签发新 key、更新所有部署消费方。 + 2. **git 历史清理**:`git filter-repo --invert-paths` 删历史记录,强推 + 全员 `rm -rf && git clone`。 +- **部署消费方(AI 可做模板)**:更新 `docker-compose.{local,prod}.yml`、`k8s/helm/topfans/values-prod.example.yaml`、`backend/dev.sh` 的密钥注入方式(env_file 指向仓库外、或 helm secret 模板从外部 Secret 引用)。 + +**Tech Stack:** Git(`git rm --cached`、`git filter-repo`)、阿里云 RAM/OpenAI/Dify 控制台(运维)、K8s Secret/External Secrets、shell 脚本。 + +## Global Constraints + +- **不自动 `git commit`**(仓库规矩 `CLAUDE.md`):本 plan 内所有 `git commit` 命令需用户明确批准后才执行。 +- **AI 不能吊销云端 key**:轮换是**运维动作**。AI 无阿里云 / OpenAI / Dify / 微信小程序后台账号密码,不能登录控制台禁旧 key、签发新 key。即使代码已出库,旧 key 仍处于有效状态,任何 clone 到代码的人都可使用——**轮换必须先于代码公开前完成**。 +- **AI 不能强制推 git 历史**:`git filter-repo --force` 会重写历史,需要团队协调(PR 关联的 fork、CI 缓存、协作者本地 reflog 全要清)。AI 只能生成执行命令清单与 README,**不得** `git push --force` 到 `main`/`feat/uni` 等共享分支。 +- **`backend/.env` 不在本 plan 范围**:是仓库根 `.gitignore` 已覆盖的本地文件(`git check-ignore` 退出 0),不入 git;本地开发者各自管理。**仅追踪文件**才是本 plan 目标。 +- **`backend/services/aiChatService/.env` 不在本 plan 范围**:已被 `backend/.gitignore:21` 忽略,未追踪。 +- **`k8s/helm/topfans/values-prod.yaml` 不在本 plan 范围**:已被根 `.gitignore:61` 忽略,未追踪。模板 `values-prod.example.yaml` 仅含 `__FILL_ME__` 占位符,无密钥。 +- **占位符统一用 ``**:`.env.example` 里所有真实密钥(含 `OPENAI_API_KEY=sk-...` `DIFY_API_KEY=app-...` `MINIMAX_API_KEY=sk-...` `OSS_ACCESS_KEY_ID=LTAI...`)一律替换为 ``。非密钥配置项(如 `OPENAI_BASE_URL=https://api.weda.cc/v1` `OPENAI_MODEL=gpt-image-2`)保留——它们是接入参数不是凭证。 +- **每个任务结束做 `git ls-files` + `grep` 扫描**,确认密钥既不在跟踪里,也不在 commit 历史里(后者靠 filter-repo 验证)。 +- **不引入新依赖**:本 plan 不加任何 Go/Python 包。 + +--- + +## 前置(已确认的现状 — 实测于 `feat/uni` @ `b7f8f1b`) + +| 文件 | 状态 | 泄露内容(行号) | +|------|------|------------------| +| `backend/deploy/envs/asset.env` | **tracked** | L10-11 OSS `LTAI5t6QcdJHpYbCPxM8SXYE`+Secret;L12 OSS_ROLE_ARN 含账号 ID | +| `backend/deploy/envs/user.env` | **tracked** | L21-22 同一套 OSS key 复用为 SMS key | +| `backend/deploy/envs/notification.env` | **tracked** | L22 `PUSH_URL=https://env-00jy6bcqqwy6.dev-hz.cloudbasefunction.cn/sendMessage`(注释明令「不要提交」) | +| `backend/deploy/envs/{activity,common,gallery,gateway,social}.env` | **tracked** | 当前 grep 未见明文密钥,但**只要被跟踪**,未来误改就被泄漏——一并出库 | +| `backend/.env.example` | **tracked** | L106 `OPENAI_API_KEY=sk-proj-srKxybHaGxho...`;L124 `OPENAI_API_KEY=sk-eIOujD5rUug...`(第二把)+ 微达配置;L131 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ` | +| `docker/.env` | **tracked** | L5 `OPENAI_API_KEY=sk-eIOujD5rUug...`;L8 `DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e` | +| `docker/.env.local` | **tracked** | L21-22 同 OSS key | +| `docker/.env.prod` | **tracked** | L18-19 第二套 OSS `LTAI5t99tafzfyrzbbEbjryH`+Secret;L25 MiniMax `sk-api-...`;L47 OpenAI 微达;L52-53 同 SMS key;L63 注释掉的 Dify key | +| `k8s/helm/topfans/values-prod.yaml` | **gitignored** ✅ | L61 `.gitignore: k8s/helm/**/values-prod.yaml` 已生效(exit 0) | +| `k8s/helm/topfans/values-prod.example.yaml` | **tracked** ✅ | 仅 `__FILL_ME__` 占位符,无密钥 | +| `k8s/helm/topfans/templates/secrets/{db,oss}-credentials.yaml` | **tracked** ✅ | Helm 模板,`{{ .Values.secrets.* }}` 引用,无明文 | +| `backend/services/aiChatService/.env` | **gitignored** ✅ | `backend/.gitignore:21` 覆盖 | +| `backend/.env`(仓库根) | **gitignored** ✅ | 根 `.gitignore` `.env` 覆盖 | + +> **结论**:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` = **12 个文件**需在代码侧处理;其中**8 个有真实密钥**,**4 个(`.env.example`、common.env、activity.env、gallery.env、gateway.env、social.env)**只改`.gitignore`即可。 + +--- + +## File Structure + +- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` — 在 `# statisticService` 区块后追加 `backend/deploy/envs/*.env`、`docker/.env*` 规则。 +- **新建** `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore` — 当前不存在,兜底忽略 `*.env`、`*.env.*`、`!.env.example`。 +- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` — 追加 `deploy/envs/*.env`(当前仅忽略 `.env`、`.env.local`、`.env.*.local`,未覆盖 `deploy/envs/` 子目录下的 `.env`)。 +- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example` — 8 处真实密钥替换为 ``(详见 Task 2)。 +- **新建** `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md` — 密钥管理 SOP(轮换周期、注入路径、应急流程)。给运维/新人参考。 +- **新建** `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh` — CI 友好扫描脚本:grep 跟踪文件里的 `sk-`、`app-`、`LTAI[A-Za-z0-9]{12,}`、`SM[0-9a-f]{32,}` 等模式,发现即 fail。供未来 PR check。 + +--- + +## Task 1: `.gitignore` 加规则 + `git rm --cached` 删跟踪 + +**Files:** +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` +- Create: `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore` + +**Interfaces:** +- 跟踪视图变化:`git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env' | wc -l` 从 **12** 降到 **0**(或仅保留 `backend/.env.example`)。 +- ignore 视图变化:`git check-ignore backend/deploy/envs/asset.env docker/.env.local` 从 exit 1 改为 exit 0。 + +- [ ] **Step 1: 备份当前跟踪清单** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env|^backend/\.env\.example$' > /tmp/secrets_tracked_before.txt +cat /tmp/secrets_tracked_before.txt +``` +Expected: 12 行(8 个 `backend/deploy/envs/*.env` + `docker/.env` + `docker/.env.local` + `docker/.env.prod` + `backend/.env.example`)。 + +- [ ] **Step 2: 在根 `.gitignore` 追加规则** + +在 `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` 末尾追加(保留换行): + +```gitignore + +# ============================================================ +# 密钥与部署环境文件 (批次 0) +# - deploy/envs/*.env: 多机部署时由运维放到 /etc/topfans/*.env +# - docker/.env*: docker compose 部署时由 env_file 指向仓库外或 stdin 注入 +# 真值不入 git,模板 (.example) 例外 +# ============================================================ +backend/deploy/envs/*.env +!backend/deploy/envs/*.env.example +docker/.env +docker/.env.local +docker/.env.prod +docker/.env.* +!docker/.env.example +``` + +- [ ] **Step 3: 验证根 `.gitignore` 新规则生效** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git check-ignore -v backend/deploy/envs/asset.env docker/.env.local docker/.env.prod docker/.env 2>&1 +echo "exit=$?" +``` +Expected: 每行打印 `.gitignore:<行号>:... `,exit code = 0(**之前** exit 1)。 + +- [ ] **Step 4: 在 `backend/.gitignore` 追加 `deploy/envs/*.env`(冗余但保底)** + +在 `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` 末尾追加: + +```gitignore + +# 部署用私有环境文件 (批次 0) +deploy/envs/*.env +!deploy/envs/*.env.example +``` + +> 即使子目录有自己的 `.gitignore`,保留这一行作为冗余防御(防止某天根 `.gitignore` 被裁剪)。 + +- [ ] **Step 5: 新建 `docker/.gitignore`** + +新建 `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore`,内容: + +```gitignore +# docker compose 环境文件 (批次 0) +# 真值不入 git;模板 .env.example 例外 +.env +.env.local +.env.prod +.env.* +!.env.example +``` + +- [ ] **Step 6: 验证 `backend/.gitignore` 新规则生效** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git check-ignore -v backend/deploy/envs/asset.env 2>&1 +echo "exit=$?" +``` +Expected: exit 0(被 `backend/.gitignore` 命中;也可能只被根规则命中,正常)。 + +- [ ] **Step 7: `git rm --cached` 移除跟踪(本地文件保留)** + +> ⚠️ **本步骤需要用户明确批准后执行**:会修改 git 索引。 + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git rm --cached -r backend/deploy/envs/ +git rm --cached docker/.env docker/.env.local docker/.env.prod +# 注意:backend/.env.example 是模板,不删! +git status --short | head -20 +``` +Expected: 12 个文件标记为 `D`(deleted from index),但磁盘文件保留。`backend/.env.example` 不在删除清单里。 + +- [ ] **Step 8: 验证跟踪清单已清空** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env$' > /tmp/secrets_tracked_after.txt +diff /tmp/secrets_tracked_before.txt /tmp/secrets_tracked_after.txt +echo "remaining_lines=$(wc -l < /tmp/secrets_tracked_after.txt)" +``` +Expected: `diff` 列出全部 12 个被删的路径;`remaining_lines=0`。**注意:`backend/.env.example` 因为匹配模式不含 `\.env\.example$`,仍在跟踪**(用 `git ls-files backend/.env.example` 应仍打印 1 行)。 + +- [ ] **Step 9: 验证磁盘文件还在** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +ls backend/deploy/envs/*.env | wc -l # 期望: 8 +ls docker/.env docker/.env.local docker/.env.prod 2>&1 +``` +Expected: 8 个 `.env` 仍存在于磁盘;`docker/.env{,.local,.prod}` 3 个文件存在。 + +- [ ] **Step 10: 暂存(用户批准后 commit)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git add .gitignore backend/.gitignore docker/.gitignore +git status --short +``` +Expected: 3 个 `.gitignore` 修改处于 staged 状态,外加 12 个 `D`(来自 Step 7)。 + +> **Commit 命令(用户批准后)**: +> ```bash +> git commit -m "chore(security): untrack deployed env files & ignore patterns (batch 0) +> +> - .gitignore: add backend/deploy/envs/*.env, docker/.env* +> - backend/.gitignore: add deploy/envs/*.env (defense in depth) +> - docker/.gitignore: new file, ignore docker compose env files +> - git rm --cached: 12 tracked env files (8 deploy/envs + 3 docker/.env*) +> - 详见 docs/superpowers/plans/2026-07-21-secrets-remediation.md +> +> Co-Authored-By: Claude Fable 5 " +> ``` + +--- + +## Task 2: `backend/.env.example` 真实密钥替换为占位符 + +**Files:** +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example` + +**Interfaces:** +- 替换前:8 行含真实密钥。 +- 替换后:所有真值 = ``;非密钥配置(endpoint、region、bucket、模型名)保留。 + +- [ ] **Step 1: 备份原文件** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +cp backend/.env.example /tmp/backend.env.example.bak +md5sum /tmp/backend.env.example.bak +``` +Expected: 记录 md5(如 `d41d8cd98f00b204e9800998ecf8427e`)作为基线。 + +- [ ] **Step 2: 替换 L106 `OPENAI_API_KEY`(OpenAI 直连那把 sk-proj-...)** + +将: +```env +OPENAI_API_KEY=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 +``` +替换为: +```env +OPENAI_API_KEY= +``` +注释行 `# 必填:OpenAI API Key...` 保留。 + +- [ ] **Step 3: 替换 L124 第二把 OpenAI key + 微达 endpoint 配置** + +> L124 重复声明 `OPENAI_API_KEY`(覆盖 L106),保留这把作为 LASER_GEN_PROVIDER=openai 时的实际使用 key。 + +将: +```env +OPENAI_API_KEY=sk-eIOujD5rUugIRIPecFi3I2rFr6Bhxx1jsRzRm6phyNeeKrCI +# 微达API BaseURL(必须含 /v1 后缀,代码会拼成 /v1/images/edits) +OPENAI_BASE_URL=https://api.weda.cc/v1 +# 中转站实际暴露的 image 模型 +OPENAI_MODEL=gpt-image-2 +``` +替换为: +```env +OPENAI_API_KEY= +# 微达API BaseURL(必须含 /v1 后缀,代码会拼成 /v1/images/edits) +OPENAI_BASE_URL=https://api.weda.cc/v1 +# 中转站实际暴露的 image 模型 +OPENAI_MODEL=gpt-image-2 +``` +注释 `# 微达API BaseURL...` `# 中转站...` **保留**(是接入说明不是凭证);endpoint URL 与 model 名**保留**(非敏感)。 + +- [ ] **Step 4: 替换 L131 `DIFY_API_KEY`** + +将: +```env +DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ +``` +替换为: +```env +DIFY_API_KEY= +``` +L132 `DIFY_API_BASE=http://localhost/v1` 与 L133 `DIFY_TIMEOUT_SEC=60` **保留**。 + +- [ ] **Step 5: 扫一遍确认无遗漏的 sk-/app-/LTAI 模式** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +grep -nE 'sk-(proj-|api-|[A-Za-z0-9]{20,})|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}|sk-cp-[A-Za-z0-9-]+' backend/.env.example || echo "clean" +``` +Expected: 仅命中占位符 `` 所在行(如有),且模式后面跟的不是真值。**任何前缀匹配 `sk-proj-` / `sk-api-` / `app-...` / `LTAI...` 后面跟 ≥12 字符的都视为泄漏**。 + +- [ ] **Step 6: 确认 `.env.example` 仍是合法 shell 格式(语法检查)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +# 用 docker 容器跑一次 dotenv parser 验证语法 +docker run --rm -v "$PWD/backend/.env.example:/tmp/.env" python:3.11-slim bash -c " + pip install -q python-dotenv >/dev/null + python3 -c \"from dotenv import dotenv_values; d=dotenv_values('/tmp/.env'); print(f'parsed {len(d)} keys'); print('has REPLACE_ME:', '' in str(d.values()))\"" +``` +Expected: `parsed N keys` (N 应与原文件相同,约 60-70);`has REPLACE_ME: True`。 + +- [ ] **Step 7: 验证 Go 代码仍能读到 `OPENAI_API_KEY` 等 key 名(占位符不破坏 key 名)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +grep -rn "os.Getenv(\"OPENAI_API_KEY\")" backend/ 2>/dev/null | head -5 +grep -rn "os.Getenv(\"DIFY_API_KEY\")" backend/ 2>/dev/null | head -5 +``` +Expected: 至少各 1 行命中——证明 key 名仍被代码读取,运维部署时只需要注入真值即可。 + +- [ ] **Step 8: 暂存(用户批准后 commit)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git add backend/.env.example +git diff --cached backend/.env.example | head -40 +``` +Expected: 仅显示 4 处 `=sk-...` / `=app-...` → `=` 的变更,**无** endpoint/model 变更。 + +> **Commit 命令(用户批准后)**: +> ```bash +> git commit -m "chore(env): replace leaked secrets in .env.example with placeholders (batch 0) +> +> - OPENAI_API_KEY (2 处, 含微达中转站) → +> - DIFY_API_KEY → +> - 保留 endpoint/model/region/bucket 等非凭证配置 +> +> Co-Authored-By: Claude Fable 5 " +> ``` + +--- + +## Task 3: 端到端验证 — 跟踪里再无密钥 + +**Files:** 无(验证任务) + +- [ ] **Step 1: `git ls-files` 扫描(跟踪层)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git ls-files | xargs grep -lE 'sk-(proj-|api-)[A-Za-z0-9]{20,}|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}|SMS_314621237' 2>/dev/null +echo "exit=$?" +``` +Expected: 空输出,exit=1(grep 无匹配返回 1)。**任何输出都是遗漏**。 + +- [ ] **Step 2: 工作区扫描(含 gitignore 后的未跟踪文件)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +# 扫描所有 .env.example / .env / yaml / json / go(排除 vendor/node_modules) +grep -rEn 'sk-(proj-|api-)[A-Za-z0-9]{20,}|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}' \ + --include='*.env' --include='*.env.example' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.go' \ + --exclude-dir=node_modules --exclude-dir=unpackage --exclude-dir=.git --exclude-dir=vendor \ + . 2>/dev/null +echo "exit=$?" +``` +Expected: 空输出,exit=1。`backend/deploy/envs/*.env` 与 `docker/.env*` 仍含真值但**已被 gitignore**(Step 2 不跟踪它们,但仍在磁盘,扫描仍可能命中——**需要人工判断是否命中在 gitignore 列表里**)。 + +> 实际预期会命中 `backend/deploy/envs/asset.env` `docker/.env.prod` 等(因为扫描是文件层非 git 层)。**通过** = 命中文件**全部在** `.gitignore` 名单里。 + +- [ ] **Step 3: 全仓 Go 编译确认未引入新依赖** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub/backend +go build ./... 2>&1 | tail -20 +``` +Expected: 无错误(本 plan 不改 Go 代码,仅改 `.gitignore`、`.env.example`,应无影响)。 + +- [ ] **Step 4: 确认 `.env.example` 仍能被 Go 代码读到 key** + +> 跑一个 5 行 Go 小程序读 env,确认占位符能被正确解析为字符串 ``: + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub/backend +cat > /tmp/check_env.go <<'EOF' +package main +import ("fmt"; "os"; "github.com/joho/godotenv") +func main() { + _ = godotenv.Load(".env.example") + for _, k := range []string{"OPENAI_API_KEY","DIFY_API_KEY","MINIMAX_API_KEY","JWT_SECRET","SECRET_KEY"} { + v := os.Getenv(k); if v == "" { v = "" } + fmt.Printf("%s=%s\n", k, v) + } +} +EOF +cd /tmp && go mod init checkenv 2>/dev/null; go get github.com/joho/godotenv 2>/dev/null +go run /tmp/check_env.go +cd /Users/liulujian/Documents/code/TopFansByGithub +``` +Expected: 输出 5 行,至少 `OPENAI_API_KEY=` 与 `DIFY_API_KEY=` 出现;`JWT_SECRET` 等原本就为空的不报错。 + +--- + +## Task 4: 部署消费方迁移模板(AI 可做骨架,运维填真值) + +**Files:** +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/docker/docker-compose.local.yml` +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/docker/docker-compose.prod.yml` +- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/dev.sh` + +**Interfaces:** +- 部署时密钥不再从仓库内 `docker/.env*` 读取,改从 `env_file:` 指向宿主机 `/etc/topfans/secrets.env` 或 `docker run -e KEY=val` 命令行注入。 + +- [ ] **Step 1: 读当前 docker-compose 真值引用** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +grep -nE 'env_file|environment' docker/docker-compose.local.yml | head -20 +grep -nE 'env_file|environment' docker/docker-compose.prod.yml | head -20 +``` +Expected: 当前用 `env_file: - ./.env` 或 `env_file: - ./.env.prod` 引用仓库内文件。 + +- [ ] **Step 2: 改 `docker-compose.local.yml` 的 env_file 路径** + +将所有 `env_file:` 引用从 `./.env` / `./.env.local` 改为宿主机注入路径: + +```yaml +# 旧 +env_file: + - ./.env + +# 新(密钥不入仓库;真值由部署方提供 .env 或 stdin 注入) +env_file: + - path: ./docker/.env.example # 仅占位模板,所有密钥为 + - path: /run/secrets/topfans-local.env # 部署时由 docker secret / bind mount 注入 + required: false +``` + +> 保留 `./docker/.env.example` 作为「key 名清单 + 占位符」,让本地 `docker compose up` 不会因缺 env 报错;真值文件由开发者自己放在 `/run/secrets/topfans-local.env` 或 `~/.config/topfans/local.env`。 + +- [ ] **Step 3: 改 `docker-compose.prod.yml` 同理** + +```yaml +# 旧 +env_file: + - ./.env.prod + +# 新 +env_file: + - path: /etc/topfans/docker.env # 宿主机 /etc/topfans/docker.env + required: true +``` + +并在文件顶部加注释: +```yaml +# ⚠️ 生产密钥不入 git +# 部署方需在宿主机准备 /etc/topfans/docker.env,包含所有 对应的真值 +# 示例: openssl rand -hex 32 # JWT_SECRET +``` + +- [ ] **Step 4: 改 `backend/dev.sh` 的密钥加载逻辑** + +读 `backend/dev.sh` 的 env 加载段(grep `\.env\|source\|export`),将所有 `source backend/.env` 改为: + +```bash +# 加载仓库内 .env.example 作为 key 清单(真值留空) +[ -f backend/.env.example ] && set -a && source backend/.env.example && set +a + +# 真值从仓库外注入(优先级更高) +[ -f ~/.config/topfans/dev.env ] && set -a && source ~/.config/topfans/dev.env && set +a +[ -f /etc/topfans/dev.env ] && set -a && source /etc/topfans/dev.env && set +a +``` + +- [ ] **Step 5: 验证 docker-compose 仍能解析** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +docker compose -f docker/docker-compose.local.yml --env-file docker/.env.example config 2>&1 | head -30 +``` +Expected: 正常输出(占位符环境能解析,service 定义无语法错误);可能因 `/run/secrets/...` 不存在而 warn,可忽略。 + +- [ ] **Step 6: Commit(用户批准后)** + +> **Commit 命令(用户批准后)**: +> ```bash +> git add docker/docker-compose.local.yml docker/docker-compose.prod.yml backend/dev.sh +> git commit -m "chore(deploy): migrate env_file to out-of-repo secret injection (batch 0) +> +> - docker-compose.{local,prod}.yml: env_file 改读 /etc/topfans/*.env +> - backend/dev.sh: 加载 ~/.config/topfans/dev.env 真值 +> - .env.example 仅作 key 名 + 占位符 +> +> Co-Authored-By: Claude Fable 5 " +> ``` + +--- + +## Task 5: 密钥扫描 CI 守护脚本(防止再次入仓) + +**Files:** +- Create: `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh` + +**Interfaces:** +- 退出码 0 = 干净;非 0 = 命中可疑密钥模式。供未来 `pre-commit` hook / CI 步骤调用。 + +- [ ] **Step 1: 写 `scripts/detect-secrets.sh`** + +新建 `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh`: + +```bash +#!/usr/bin/env bash +# detect-secrets.sh — 扫描仓库内跟踪文件的高熵 / 已知密钥前缀 +# 退出码: 0 = 无命中, 1 = 命中 +# 排除: node_modules, unpackage, .git, vendor, frontend/.env.{development,production} + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# 模式: OpenAI / Dify / 阿里云 RAM 子账号 AccessKey / 通用 high-entropy sk- 前缀 +PATTERNS=( + 'sk-proj-[A-Za-z0-9_-]{20,}' + 'sk-api-[A-Za-z0-9_-]{20,}' + 'sk-cp-[A-Za-z0-9_-]{20,}' + 'app-[A-Za-z0-9]{16,}' + 'LTAI[A-Za-z0-9]{12,}' + 'AKID[A-Za-z0-9]{16,}' +) + +EXCLUDE_DIRS=( + '--exclude-dir=node_modules' + '--exclude-dir=unpackage' + '--exclude-dir=.git' + '--exclude-dir=vendor' + '--exclude-dir=.code-review-graph' + '--exclude-dir=.superpowers' + '--exclude-dir=.agents' + '--exclude-dir=.claude' + '--exclude-dir=frontend/dist' + '--exclude-dir=frontend/.hbuilderx' +) + +# 允许的占位符豁免(命中后仍 fail,但提示是占位符) +PLACEHOLDER='' + +INCLUDE_FILES=( + '--include=*.env' + '--include=*.env.example' + '--include=*.yaml' + '--include=*.yml' + '--include=*.json' + '--include=*.go' + '--include=*.py' + '--include=*.js' + '--include=*.ts' + '--include=*.sh' +) + +FOUND=0 +for pat in "${PATTERNS[@]}"; do + matches=$(grep -rEn "${EXCLUDE_DIRS[@]}" "${INCLUDE_FILES[@]}" "$pat" . 2>/dev/null \ + | grep -v "$PLACEHOLDER" || true) + if [ -n "$matches" ]; then + echo "=== PATTERN: $pat ===" >&2 + echo "$matches" >&2 + FOUND=1 + fi +done + +if [ "$FOUND" -eq 1 ]; then + echo "" >&2 + echo "❌ 检测到可能的密钥泄露,请检查上方命中并替换为 占位符。" >&2 + echo " 真值请通过部署侧 / KMS / Docker secret 注入,不要提交到 git。" >&2 + exit 1 +fi +echo "✅ 无密钥泄露" +exit 0 +``` + +- [ ] **Step 2: 加执行权限** + +```bash +chmod +x /Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh +``` + +- [ ] **Step 3: 在干净仓库上跑(应 pass)** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +./scripts/detect-secrets.sh +echo "exit=$?" +``` +Expected: `✅ 无密钥泄露` + `exit=0`。 + +- [ ] **Step 4: 构造一个假阳性用例确认能 catch** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +echo "OPENAI_API_KEY=sk-proj-fakefakefakefakefakefakefakefake" > /tmp/leak_test.env +cd /tmp && mkdir -p leak_check && cp /tmp/leak_test.env leak_check/.env +# 在仓库内临时建一个测试文件 +echo "DIFY_API_KEY=app-fakefakefakefakefake" > backend/.env.test_leak +cd /Users/liulujian/Documents/code/TopFansByGithub +./scripts/detect-secrets.sh +echo "exit=$?" +# 清理 +rm -f backend/.env.test_leak /tmp/leak_test.env +rm -rf /tmp/leak_check +cd /Users/liulujian/Documents/code/TopFansByGithub +``` +Expected: 打印 `❌ 检测到可能的密钥泄露` 并 exit 1;清理后再次运行应 pass。 + +- [ ] **Step 5: 在 `.gitignore` 排除脚本自身(避免被误扫描)** + +`scripts/` 不在 `EXCLUDE_DIRS` 里,OK。但若需排除 `scripts/detect-secrets.sh` 本身的正则字面量,加到 exclude: + +无需修改(模式字面量是元字符串,grep `-E` 不会匹配)。 + +- [ ] **Step 6: Commit(用户批准后)** + +> **Commit 命令(用户批准后)**: +> ```bash +> git add scripts/detect-secrets.sh +> git commit -m "ci(security): add detect-secrets.sh for tracking file scanning (batch 0) +> +> 扫描 sk-proj-* / sk-api-* / app-* / LTAI* 等已知密钥前缀 +> 供 pre-commit / CI 步骤调用,命中即 fail +> +> Co-Authored-By: Claude Fable 5 " +> ``` + +--- + +## Task 6: 运维交付物 — 密钥轮换 SOP 文档(AI 起草,运维审阅) + +**Files:** +- Create: `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md` + +**Interfaces:** +- 文档涵盖:每类密钥的轮换入口、轮换步骤、消费方清单、回滚方案、紧急吊销流程。 +- **本文档是给运维的执行清单,不是代码变更**。AI 起草后由运维校对。 + +- [ ] **Step 1: 起草 SOP 文档** + +新建 `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md`,内容: + +```markdown +# 密钥管理 SOP (批次 0) + +> 本文档定义 TopFans 仓库所有生产密钥的**轮换周期、注入路径、紧急吊销流程**。 +> 配套实施见 `docs/superpowers/plans/2026-07-21-secrets-remediation.md`。 + +## 1. 密钥清单 + +| 密钥 | 当前来源(仓库已删) | 真值存放 | 轮换周期 | 紧急吊销入口 | +|------|----------------------|----------|----------|--------------| +| 阿里云 OSS AccessKey | `backend/deploy/envs/asset.env` L10-11 + `user.env` L21-22 + `docker/.env.prod` L18-19 | K8s Secret `oss-credentials` / `/etc/topfans/asset.env` | 90 天 | https://ram.console.aliyun.com → 用户 → AccessKey → 禁用 | +| SMS AccessKey | 同 OSS(同一账号) | 同 OSS 注入路径 | 90 天 | 同上 | +| uniPush URL | `backend/deploy/envs/notification.env` L22 | K8s Secret `notification-config` | 180 天 | 微信小程序后台 → uniCloud → sendMessage 重置 | +| OpenAI `sk-proj-...` | `backend/.env.example` L106 | K8s Secret `ai-secrets.openaiApiKey` | 90 天 | https://platform.openai.com/api-keys → Revoke | +| 微达中转 `sk-...` | `backend/.env.example` L124 + `docker/.env.prod` L47 | 同上 | 90 天 | 联系微达客服 | +| Dify App API Key | `backend/.env.example` L131 + `docker/.env.prod` L63 | K8s Secret `ai-secrets.difyApiKey` | 90 天 | Dify 控制台 → 工作室 → API 密钥 → 重置 | +| MiniMax API Key | `docker/.env.prod` L25 | K8s Secret `ai-secrets.minimaxApiKey` | 180 天 | https://platform.MiniMax.com → API Keys | +| JWT_SECRET | `docker/.env.prod` L12 | K8s Secret `jwt-secret` | **谨慎**(改了全站 token 失效) | — | +| SECRET_KEY (周边 HMAC) | `docker/.env.prod` L66 | K8s Secret `peripheral-secret` | 仅泄露时 | — | + +## 2. 轮换流程(以 OSS 为例) + +1. 阿里云 RAM 控制台创建新子账号 AccessKey,绑定 `top-fans-oss-user` 角色。 +2. 在 K8s Secret `oss-credentials` 更新 `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET`: + ```bash + kubectl -n topfans edit secret oss-credentials + ``` +3. 触发滚动重启:`kubectl -n topfans rollout restart deployment/asset-service deployment/user-service`。 +4. 观察 5 分钟日志确认 OSS 调用无 403。 +5. **24 小时观察期后**回到阿里云控制台禁用旧 key。 +6. 同步更新 `docs/security/secrets-handling.md` 表格的「当前来源」列。 + +## 3. 紧急吊销(疑似泄露) + +立即在云控制台 **Disable** 旧 key(不要先 Delete,留 7 天观察)。同时: + +```bash +# 1. 标记泄露事件 +echo "[$(date -Iseconds)] OSS key leaked, rotating to new AKID-NEW" >> /var/log/topfans/secret_rotation.log + +# 2. 通知开发 / 运维群 +# (此处加飞书/钉钉 webhook 调用) +``` + +## 4. 注入路径核对清单 + +- [ ] K8s: 所有 `*.env.example` 占位符在 `values-prod.yaml` 已替换为真值(**不入 git**) +- [ ] Docker compose: `/etc/topfans/docker.env` 存在且权限 600 +- [ ] 本地开发: `~/.config/topfans/dev.env` 存在 +- [ ] CI: 密钥走 GitHub Actions Secret / 阿里云 ACR CredentialHelper,**不**走 env 明文 + +## 5. git 历史清理(仅在密钥真值泄露到 git 历史时执行) + +⚠️ **本节由运维主导**,AI 不可执行 `git push --force`。 + +```bash +# 安装 git-filter-repo +pip install git-filter-repo + +# 备份裸仓库(防止 filter-repo 出错) +cp -r /path/to/topfans.git /tmp/topfans.git.backup + +# 删除敏感路径历史 +cd /path/to/topfans +git filter-repo --invert-paths \ + --path backend/deploy/envs/ \ + --path docker/.env \ + --path docker/.env.local \ + --path docker/.env.prod \ + --force + +# 强推到远端(团队公告后执行) +git remote add origin git@github.com:org/topfans.git +git push origin --force --all +git push origin --force --tags + +# 全员重新 clone +``` + +## 6. 历史教训(README 引用) + +- 2026-07-21 审计发现 P0-1:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` 11 处真实密钥在 git 跟踪中。批次 0 修复后,**所有密钥改走仓库外注入**。 +- 教训:`.env.example` 不等于 `.env`,跟踪前者 OK,跟踪后者永远错。模板里写密钥原文也是错——必须写占位符。 +``` + +- [ ] **Step 2: Commit(用户批准后,运维审阅后)** + +> **Commit 命令(用户批准后)**: +> ```bash +> git add docs/security/secrets-handling.md +> git commit -m "docs(security): secrets handling SOP (batch 0) +> +> 密钥清单 + 轮换流程 + 紧急吊销 + 注入路径 + git 历史清理 +> +> Co-Authored-By: Claude Fable 5 " +> ``` + +--- + +## Task 7: 运维交付物 — 云端密钥轮换(**AI 不可执行,仅核查清单**) + +> ⚠️ **本任务完全由运维执行**。AI 无云控制台账号权限,不能登录阿里云/OpenAI/Dify 禁用旧 key。 +> 本节是给运维的核查清单,AI 仅做文档化与验收检查。 + +**Files:** 无(运维在云控制台操作) + +**Interfaces:** +- 验收:每把旧 key 在云控制台状态 = `Disabled`;新 key 已在所有消费方生效。 + +- [ ] **Step 1: 运维轮换 OSS AccessKey(两套)** + +| 旧 AKID | 云端操作 | 新 AKID 注入位置 | +|---------|----------|------------------| +| `LTAI5t6QcdJHpYbCPxM8SXYE`(`asset.env`+`user.env`) | 阿里云 RAM 禁用 | K8s `oss-credentials.secret` + `/etc/topfans/asset.env` + `/etc/topfans/user.env` | +| `LTAI5t99tafzfyrzbbEbjryH`(`docker/.env.prod`) | 同上 | K8s `oss-credentials.secret` + 宿主机 `/etc/topfans/docker.env` | + +AI 验收命令: +```bash +# 在本地抓包验证 OSS 调用是否带新 AKID(运维新签发的) +tcpdump -i any -A -s0 'host oss-cn-shanghai.aliyuncs.com and tcp port 80' 2>&1 | grep -E 'Authorization.*OSS' | tail -5 +``` +或者通过云控制台「访问日志」筛 OSS 桶 7 天内的请求源 IP/Key。 + +- [ ] **Step 2: 运维轮换 OpenAI `sk-proj-...`(直连那把)** + +控制台:https://platform.openai.com/api-keys → Revoke 旧 key → Create new key。 + +- [ ] **Step 3: 运维轮换微达 `sk-eIOujD5rUug...`** + +联系微达客服或控制台(如有)→ 旧 key 禁用 → 新 key 注入 K8s `ai-secrets.openaiApiKey`。 + +- [ ] **Step 4: 运维轮换 Dify `app-aHnBfMeOQp7A9dQneIFPdPaZ`** + +Dify 控制台 → 工作室 → 角角 → API 密钥 → 重置。 + +- [ ] **Step 5: 运维轮换 uniPush URL(云函数)** + +微信小程序后台 → uniCloud → sendMessage 云函数 → 重新部署(URL 会变)或保留 URL 重置签名。 + +- [ ] **Step 6: 运维轮换 MiniMax `sk-api-...`** + +https://platform.MiniMax.com → API Keys → 旧 key 禁用 → 新 key 注入 K8s `ai-secrets.minimaxApiKey`。 + +- [ ] **Step 7: 运维轮换 SMS AccessKey** + +阿里云 SMS 控制台 → AccessKey 管理(同一 RAM 子账号)→ 禁用旧 → 创建新。 + +- [ ] **Step 8: AI 验收 — `detect-secrets.sh` 干净 + 旧 key 已从消费方移除** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +./scripts/detect-secrets.sh # 应 exit 0 +# 验证旧 key 不再出现在 K8s secret / docker compose env +kubectl -n topfans get secret oss-credentials -o jsonpath='{.data.OSS_ACCESS_KEY_ID}' | base64 -d +# 应输出 NEW AKID,不是 LTAI5t6QcdJHpYbCPxM8SXYE 也不是 LTAI5t99tafzfyrzbbEbjryH +``` + +--- + +## Task 8: 运维交付物 — git 历史清理与全员重 clone(**AI 不可执行**) + +> ⚠️ **本任务完全由运维执行**。AI 没有 `git push --force` 到共享分支的权限,也无团队协调能力。 +> 本节是给运维的命令清单,AI 仅做文档化。 + +**Files:** 无(git 操作) + +- [ ] **Step 1: 备份裸仓库** + +```bash +# 运维在本地做 +cp -r /Users/liulujian/Documents/code/TopFansByGithub/.git /tmp/topfans.git.backup.$(date +%Y%m%d) +``` + +- [ ] **Step 2: 安装 git-filter-repo** + +```bash +pip install git-filter-repo # 或 brew install git-filter-repo +``` + +- [ ] **Step 3: 删除历史中的密钥路径** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git filter-repo --invert-paths \ + --path backend/deploy/envs/ \ + --path docker/.env \ + --path docker/.env.local \ + --path docker/.env.prod \ + --force +``` + +- [ ] **Step 4: 验证历史已清** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +git log --all --full-history -- backend/deploy/envs/asset.env | head -5 +echo "expected: empty" +git log --all --full-history -p -- backend/.env.example | grep -c 'sk-proj' || true +echo "expected: 0" +``` + +- [ ] **Step 5: 团队公告 + 强推** + +> **运维需在群里公告**:历史已重写,所有人必须 `rm -rf` 本地仓库后 `git clone`。 +> **CI 缓存需清**:GitHub Actions cache、阿里云 ACR build cache、Slack/Notion 镜像备份。 + +```bash +git remote add origin git@github.com:zerosaturation/topfans.git # 按实际 +git push origin --force --all +git push origin --force --tags +``` + +- [ ] **Step 6: 全员重 clone(运维通知,不在 AI 范围)** + +```bash +# 开发者本地 +rm -rf /Users/liulujian/Documents/code/TopFansByGithub +cd ~/Documents/code +git clone git@github.com:zerosaturation/topfans.git TopFansByGithub +``` + +- [ ] **Step 7: AI 验收 — 历史无密钥残留** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub +# 跟踪 + 历史 + 工作区三层扫描 +git rev-list --all | xargs -I{} git show {}:backend/deploy/envs/asset.env 2>/dev/null | head +echo "expected: empty (no commit shows asset.env anymore)" +``` + +--- + +## Self-Review + +- **Spec 覆盖**: + - 批次 0 §「密钥出库」= Task 1(`.gitignore` + `git rm --cached`)+ Task 2(`.env.example` 占位符)= **完整覆盖**。 + - 批次 0 §「轮换」= Task 7(运维 SOP,AI 仅核查)= **运维主导**,文档化。 + - 批次 0 §「git 历史清理」= Task 8(运维执行,AI 仅核查)= **运维主导**,文档化。 + - 部署消费方迁移 = Task 4(AI 做骨架)= **完整覆盖**。 + - 防止再次泄漏 = Task 5(`detect-secrets.sh`)+ Task 6(SOP 文档)= **预防措施**。 +- **Placeholder 扫描**: + - 无 TBD;`.env.example` 占位符统一 ``。 + - 脚本、SOP、commit message 均给出完整文本。 +- **全局约束一致性**: + - 每个 commit 步骤标 "用户批准后"(CLAUDE.md 强制)。 + - 运维步骤明确标 "AI 不可执行"。 + - 不引入新依赖(仅 shell 脚本 + git/grep 已装)。 +- **跨文件一致性**: + - Task 1 的 `.gitignore` 规则 vs Task 5 的 `EXCLUDE_DIRS` 一致(都排除 `.code-review-graph` `.superpowers` `.agents` `.claude`)。 + - Task 2 的 4 处替换 vs Task 7 的密钥清单 8 项 = 模板覆盖 4 处真值,部署侧 8 项全列。 + - Task 4 的 env_file 路径 `/etc/topfans/docker.env` 与 Task 6 的 SOP 「注入路径核对清单」一致。 +- **风险标注**: + - Task 4 Step 5 的 `docker compose config` 可能因 `/run/secrets` 不存在而 warn,非阻塞。 + - Task 5 Step 4 的假阳性测试需手工清理 `backend/.env.test_leak`,已提醒。 + - Task 7/8 完全运维主导,AI 仅生成验收命令。 +- **失败重做路径**: + - 若 Task 1 Step 8 的 `git ls-files` 仍有命中,回滚 `git rm --cached` 用 `git reset HEAD backend/deploy/envs/asset.env` 然后重新跑。 + - 若 Task 5 在干净仓库上 fail,说明 `EXCLUDE_DIRS` 不全,需补全。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-21-service-stability.md b/docs/superpowers/plans/2026-07-21-service-stability.md new file mode 100644 index 0000000..6e6b381 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-service-stability.md @@ -0,0 +1,1518 @@ +# 服务稳定性 (批次 3) 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:** 修复后端 P1 稳定性问题——bcrypt 阻塞事务、Login 用户枚举+无限流、JWT 全局可变密钥+弱默认、MQ streams adapter 半吊子、aiChat Redis 吞错+err 泄露+personaID 漂移、TrackEvent 静默丢失+队列名硬编码、网关链式 `GetFanIdentities`+铸造双写+`DeleteAccount` 直连 DB。8 项修复全部纳入同一 commit 序列以便回滚。 + +**Architecture:** +- **错误处理统一**:新增 `ErrInvalidCredential`(替代 `ErrUserNotFound`/`ErrInvalidPassword` 在 Login 出口的分裂)、`ErrTooManyLoginAttempts` 复用 SMS 限流 Redis key。 +- **配置 fail-fast**:`pkg/jwt.MustInit` 在启动时一次性注入 + 校验 + 拒绝弱默认;`pkg/mq/streams` 退化为 stub(停止实例化),删除生产路径。 +- **可靠性层**:`pkg/statistic/client.TrackEvent` 改为「goroutine + bounded channel」buffer(1024 槽),溢出落本地 SQLite 兜底;新增 `pkg/queue/consts.QueueGallery` / `QueueDefault` 单一来源。 +- **网关分层**:抽出 `pkg/gateway/starcache` 缓存 star 目录(TTL 60s)替代 6 处 `GetFanIdentities` 链式调用;`DeleteAccount` 改为调 `userService.DeleteAccount` RPC(userService 才是 fans/users 的 Owner);铸造双写收口为 `MintSuccess` 单一事件→消费方解耦。 +- **aiChat 健壮性**:Redis 错误升级为 `WARN`+降级到空集合而非静默吞;错误响应不再 `err.Error()` 原样回传;`personaID` 一律使用 `persona.ID`。 + +**Tech Stack:** Go 1.25 (go.work 多模块), GORM, Redis Streams + asynq, Dubbo triple, PostgreSQL, 现有 `pkg/statistic` + `pkg/jwt` + `pkg/mq` 三库原地修改,不引新依赖。 + +## Global Constraints + +- 沿用现有分层(handler/service/repository)+ DTO 入出参 + GORM 序列 `setval` 同步规则(`CLAUDE.md` §数据库操作规范)。 +- 时间戳统一**毫秒** (`time.Now().UnixMilli()`)。 +- 不自动 `git commit`(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。 +- 每个任务结束 `go build ./...`(在对应模块目录)通过;`go vet ./...` 0 warning。 +- 全程遵守 `CLAUDE.md` §全局自审规则:每次修复必须做回归检查(直接调用方 + 相邻模块 + 共享函数副作用),禁止"只读改的部分"。 +- 现有 asynq 队列(`gallery`/`default`)运行中不可改名,只在 `pkg/queue/consts` 加常量、替换硬编码字面量,asynq queue 名不变。 + +--- + +## 前置决策(影响 3.3 整 Task) + +### streams adapter 用方调查结果(证据,非设计) + +```bash +$ grep -rn "EventProducer\|EventConsumer\|EventHandler" backend --include="*.go" | grep -v "_test.go" | grep -v "pkg/mq" +$ # 输出: 0 行 +``` + +`pkg/mq/streams/adapter.go` 的 `EventProducer.Publish` 与 `EventConsumer.Subscribe` **0 业务调用方**。asynq 是事实唯一的任务队列;统计事件走 `pkg/statistic` 自己的 Dubbo RPC(`statistic.Get().TrackEvent`),与 streams 无关。 + +### 决策:**Task 3.3 选「停用 streams adapter」分支** + +依据 MVP 先行原则(`CLAUDE.md` §MVP 先行原则): +- 现网 0 调用方 → 补 DLQ + XAUTOCLAIM + 稳定 consumer name 是**为未来 100 业务准备的架构**(YAGNI)。 +- 半吊子(已声明的事件可靠性 + 实际 0 调用方)会让新人误以为已可用,未来接入时再翻工成本更高。 +- 停用路径 = 删除 `streams` adapter + 保留 asynq = 50 行代码减量 + 0 业务影响。 + +完整架构(`streams/dlq` + `streams/claim`)留作 `docs/specs/2026-07-21-mq-v2-design.md` 路线图,本 plan 不落地。 + +--- + +## File Structure + +### 新增 +- `backend/pkg/errors/errors.go` — 增 `ErrInvalidCredential` / `ErrTooManyLoginAttempts`,加 `ToGRPCCode` 分支(`ErrTooManyLoginAttempts → ResourceExhausted`)。 +- `backend/services/userService/service/login_ratelimit.go` — 新建。`CheckLoginRateLimit` / `IncrLoginFailure` / `ClearLoginAttempts`,复用 `sms_redis.go` 的 `database.GetRedis()`,key 前缀 `login:fail:{mobile}` / `login:fail:ip:{ip}`。 +- `backend/pkg/queue/consts/consts.go` — 新建。`QueueGallery = "gallery"`、`QueueDefault = "default"` 常量;目前 asynq queue 名不变。 +- `backend/gateway/pkg/starcache/starcache.go` — 新建。内存缓存 + TTL 60s + `sync.RWMutex`;`GetStar(starID int64) *pb.Star`、`Invalidate()`。`GetFanIdentities` 只在 cache miss 时调下层。 +- `backend/pkg/mq/streams/adapter_stub.go` — 新建。保留 `Adapter` 类型但 `New()` 直接返回错误,所有方法 panic 或返回 `errors.New("streams: deprecated")`;`compositeAdapter.EventProducer/Consumer` 改返 stub。 +- `backend/pkg/mq/mq.go` — 删 streams 装配分支。 +- `backend/pkg/statistic/client.go` — 加 `BufferedTrackEvent` (channel 1024 + overflow → zap.Warn 落丢弃并 metrics 计数);保留 fire-and-forget 接口签名。 +- `backend/pkg/jwt/jwt.go` — 加 `MustInit(secret string) error`:长度 ≥ 32 字节、非默认值;`SetSecret` 改为 `internal` 仅 `MustInit` 调用,外部包只能 `MustInit`。 +- `backend/services/userService/service/auth_service.go` — `Register`/`ResetPassword` 把 `repository.HashPassword` 移到事务前;`Login` 统一 `ErrInvalidCredential` + 调限流;`ValidateToken` 同步新签名。 +- `backend/services/userService/main.go` — 调 `jwt.MustInit(jwtSecret)` 替代 `jwt.SetSecret`;env 缺失/弱默认 → fatal。 +- `backend/services/userService/service/user_service.go` — `ResetPassword` 同样把 `HashPassword` 移到事务前(L670→L683);`fireCrystalChangeEvent` 加 trace。 +- `backend/services/aiChatService/provider/ai_chat_provider.go` — Redis 错误升级 WARN + 降级;`err.Error()` 不再回传 client;`personaID` 用 `persona.ID`。 +- `backend/services/aiChatService/main.go` — Dify/LLM 二选一改为尝试 Dify 失败时 WARN 降级到 LLM;保留原行为当两者都给定时 Dify 优先(业务 0 感知)。 +- `backend/services/galleryService/main.go` — `Queues: map[string]int{pkg/queue/consts.QueueGallery: 1}`。 +- `backend/services/galleryService/mq/consumer.go` — `Queue: pkg/queue/consts.QueueGallery`。 +- `backend/services/galleryService/mq/producer.go` — 5 处 `Queue: "gallery"/"default"` → 常量。 +- `backend/services/taskService/main.go` — `Queues: map[string]int{pkg/queue/consts.QueueDefault: 1}`。 +- `backend/services/taskService/mq/consumer.go` — `Queue: pkg/queue/consts.QueueDefault`。 +- `backend/gateway/main.go` — `jwt.SetSecret` → `jwt.MustInit`;启动 `starcache.Start(ctx)`。 +- `backend/gateway/controller/auth_controller.go` — `Register`/`Login` 用 `starcache.GetStar(resp.FanProfile.StarId)` 替代链式 `GetFanIdentities`。 +- `backend/gateway/controller/user_controller.go` — `GetMe`/`GetMyProfile`/`UpdateProfile` 同上;`DeleteAccount` 改为调 `userService.DeleteAccount` RPC(userService 才是 owner),不再 `database.GetDB()`。 +- `backend/gateway/controller/asset_controller.go` — 铸造成功后改发 `events.MintSuccess` 事件到 in-process channel,由单独 goroutine 调 `LaserRepo.SetMintResult` + 写 operation_log(解耦双写失败→阻塞主链路)。 +- `backend/services/assetService/repository/laser_card_repository.go` — 增 `MintSuccessConsumer(ctx, ch chan *MintSuccessEvent)` 接收事件并落库。 +- `backend/services/assetService/main.go` — 启动 consumer goroutine。 +- `backend/pkg/mq/streams/adapter.go` — 删(被 stub 取代)。 +- `backend/pkg/mq/streams/adapter_test.go` — 删(与 stub 不兼容)。 + +### 测试 +- `backend/pkg/jwt/jwt_test.go` — 加 `TestMustInit_Empty` / `TestMustInit_Default` / `TestMustInit_OK` / `TestMustInit_Short` / 并发读写 race(`-race`)。 +- `backend/services/userService/service/login_ratelimit_test.go` — 新建。Mock Redis,5 次失败后命中限流;mobile+ip 双维度。 +- `backend/services/userService/service/auth_service_test.go` — 新建 `TestRegister_HashPassword_OutsideTx`(mock repo 验证 HashPassword 调用顺序在 Transaction 之前);`TestLogin_UnifiedError`。 +- `backend/pkg/statistic/client_test.go` — 新建。`TestTrackEvent_OverflowDropsAndLogs`。 +- `backend/services/aiChatService/provider/ai_chat_provider_test.go` — 新建。`TestSendMessage_RedisErrorLogsAndContinues` / `TestSendMessage_PersonaIDUsesResolved`。 +- `backend/pkg/mq/mq_test.go` — 新建。`TestInit_RedisOnly_NoStreams`。 + +--- + +## Task 1: 3.1 bcrypt 移出事务 + +**Files:** +- Modify: `backend/services/userService/service/auth_service.go:139-233`(`Register` 事务块) +- Modify: `backend/services/userService/service/user_service.go:670-693`(`ResetPassword` 事务块) +- Test: `backend/services/userService/service/auth_service_test.go`(新建) + +**Interfaces:** +- 公共行为不变;`bcrypt.GenerateFromPassword` 调用**必须在** `s.db.Transaction(...)` 进入**之前**完成。 + +- [ ] **Step 1: 写测试验证 HashPassword 在事务前** + +```go +// backend/services/userService/service/auth_service_test.go +package service + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/topfans/backend/pkg/errors" + "github.com/topfans/backend/pkg/models" + pb "github.com/topfans/backend/pkg/proto/user" + "github.com/topfans/backend/services/userService/repository" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// fakeUserRepo 实现 repository.UserRepository(仅注册路径用到的方法) +type fakeUserRepo struct { + hashCalledBeforeTx atomic.Bool + createCalled atomic.Int32 +} + +func (f *fakeUserRepo) GetByMobile(mobile string) (*models.User, error) { + return nil, errors.ErrUserNotFound +} +func (f *fakeUserRepo) Create(u *models.User) error { f.createCalled.Add(1); u.ID = 1; return nil } +func (f *fakeUserRepo) GetByID(id int64) (*models.User, error) { return nil, errors.ErrUserNotFound } +func (f *fakeUserRepo) VerifyPassword(u *models.User, pw string) bool { return false } +func (f *fakeUserRepo) UpdateToken(id int64, tok string, exp int64) error { return nil } +func (f *fakeUserRepo) ClearToken(id int64) error { return nil } +func (f *fakeUserRepo) UpdatePassword(id int64, hash string) error { return nil } +func (f *fakeUserRepo) GetAccountStatus(id int64) (*models.UserAccountStatus, error) { return nil, nil } +func (f *fakeUserRepo) UpdateAvatar(id int64, url string) error { return nil } + +// spyDb 包装 *gorm.DB 记录事务开始/HashPassword 调用顺序 +type spyDb struct{ *gorm.DB; txEntered atomic.Bool } + +func (s *spyDb) Transaction(fn func(tx *gorm.DB) error) error { + s.txEntered.Store(true) + return s.DB.Transaction(fn) +} + +func TestRegister_HashPassword_OutsideTransaction(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + spy := &spyDb{DB: db} + starRepo := &fakeStarRepo{} + fanRepo := &fakeFanRepo{} + userRepo := &fakeUserRepo{} + + // 用一个 wrapper 让 HashPassword 调用时记录"此刻事务是否已进入" + wrapped := &hashOrderRecorder{userRepo: userRepo, spy: spy} + + svc := NewAuthService(wrapped, fanRepo, starRepo, spy.DB) + + _, err := svc.Register(context.Background(), &pb.RegisterRequest{ + Mobile: "13800000000", Password: "Pass1234", Nickname: "n", StarId: 1, + }) + if err != nil { + t.Fatalf("Register: %v", err) + } + if !wrapped.hashedBeforeTx.Load() { + t.Fatalf("HashPassword was called AFTER tx began (currently txEntered=%v)", spy.txEntered.Load()) + } +} +``` + +(配套 `hashOrderRecorder` + `fakeStarRepo` + `fakeFanRepo` 在同文件实现,思路:repo 在 `Create(user)` 时读 `spy.txEntered`,应得 `false`。) + +- [ ] **Step 2: Register L161 移出事务** + +Run `git diff backend/services/userService/service/auth_service.go` 确认改前 `HashPassword` 位于 L161(在 `s.db.Transaction(func(tx *gorm.DB) error {` 之后)。 + +在 `auth_service.go` 的 `Register` 函数中,**把 L161-165 的 `HashPassword` 整段移到 L142(事务前)**: + +```go +// 在 s.db.Transaction(...) 之前(紧跟现有 L141 注释后) +hashedPassword, err := repository.HashPassword(req.Password) +if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) +} + +// 移除事务内的 L161-165;事务内仅 Create(user) + Create(fanProfile) + UpdateColumns(token) +err = s.db.Transaction(func(tx *gorm.DB) error { + user = &models.User{ /* 同前, PasswordHash: hashedPassword */ } + // ... + if err := tx.Create(user).Error; err != nil { ... } + // 不再调 HashPassword + // ... +}) +``` + +预期:`go test ./services/userService/service/... -run TestRegister_HashPassword_OutsideTransaction -v` PASS。 + +- [ ] **Step 3: ResetPassword L670 同步** + +`backend/services/userService/service/user_service.go:683-693` 同样把 `repository.HashPassword(req.NewPassword)` 移到 `s.db.Transaction(...)` 之前: + +```go +// 在 s.db.Transaction(...) 之前 +newPasswordHash, err := repository.HashPassword(req.NewPassword) +if err != nil { + return nil, fmt.Errorf("failed to hash new password: %w", err) +} + +err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(user).Updates(map[string]interface{}{ + "password_hash": newPasswordHash, + // ... + }).Error; err != nil { ... } + return nil +}) +``` + +- [ ] **Step 4: 全模块回归(CLAUDE.md 全局自审)** + +按 `CLAUDE.md` §全局自审规则执行: +- `git grep "HashPassword"` 全仓,确认 0 个新调用方在事务内。 +- `git grep "bcrypt.GenerateFromPassword"` 应**只在** `services/userService/repository/user_repository.go:202` 一处。 +- 调用方回归:`Register`(RegisterResponse 仍带 token)/ `ResetPassword`(同)。 +- `go vet ./...` 0 warning。 + +- [ ] **Step 5: commit(需用户批准)** + +```bash +git add backend/services/userService/service/auth_service.go \ + backend/services/userService/service/user_service.go \ + backend/services/userService/service/auth_service_test.go +git commit -m "fix(auth): move bcrypt HashPassword outside DB transaction (batch 3.1)" \ + -m "P1: auth_service.go:161 + user_service.go:670 bcrypt inside tx blocks pool under registration spikes. Move HashPassword call before s.db.Transaction()." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 2: 3.2 Login 限流 + 消除用户枚举 + +**Files:** +- Modify: `backend/services/userService/service/auth_service.go:276-489`(`Login` 全函数) +- New: `backend/services/userService/service/login_ratelimit.go` +- Modify: `backend/pkg/errors/errors.go:13-95, 108-172` +- Test: `backend/services/userService/service/login_ratelimit_test.go`(新建)+ 扩 `auth_service_test.go` + +**Interfaces:** +- 新 `ErrInvalidCredential = errors.New("账号或密码错误")` —— Login 出口统一返回此(覆盖 `ErrUserNotFound` + `ErrInvalidPassword` 在 Login 路径上的使用)。 +- 新 `ErrTooManyLoginAttempts = errors.New("登录尝试过多,请稍后再试")` → `ToGRPCCode → ResourceExhausted(8)`。 +- 新函数 `service.CheckLoginRateLimit(ctx, mobile, ip) (ok bool, retryAfter time.Duration, err error)`、`service.IncrLoginFailure(ctx, mobile, ip)`、`service.ClearLoginAttempts(ctx, mobile, ip)`。 +- Redis key(复用 `database.GetRedis()`): + - `login:fail:mobile:{mobile}` → INCR + EXPIRE 900s(15 分钟) + - `login:fail:ip:{ip}` → INCR + EXPIRE 900s + - 阈值:mobile 5 次/15min、ip 20 次/15min(参考 SMS IP 限制量级)。 + +- [ ] **Step 1: 增错误类型 + ToGRPCCode 分支** + +在 `backend/pkg/errors/errors.go` 紧跟 `ErrPasswordTooShort` 后增: + +```go +// 鉴权相关 +ErrInvalidCredential = errors.New("账号或密码错误") +ErrTooManyLoginAttempts = errors.New("登录尝试过多,请稍后再试") +``` + +在 `ToGRPCCode` switch 内 `ErrInvalidPassword` 同一 case 加 `errors.Is(err, ErrInvalidCredential)`: + +```go +case errors.Is(err, ErrInvalidPassword), errors.Is(err, ErrInvalidToken), + errors.Is(err, ErrTokenExpired), errors.Is(err, ErrTokenMismatch), + errors.Is(err, ErrInvalidCredential): // ← 新增 + return codes.Unauthenticated +``` + +并在 `ErrRequestInCooldown` case 后增: + +```go +case errors.Is(err, ErrTooManyLoginAttempts): + return codes.ResourceExhausted +``` + +Run `go test ./pkg/errors/... -v` 确认原有 7 个 case 不破。 + +- [ ] **Step 2: 新建 `login_ratelimit.go`** + +```go +// backend/services/userService/service/login_ratelimit.go +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "github.com/topfans/backend/pkg/database" + appErrors "github.com/topfans/backend/pkg/errors" +) + +const ( + loginFailKeyPrefix = "login:fail:" + loginFailWindow = 15 * time.Minute + MaxMobileFailures = 5 + MaxIPFailures = 20 +) + +func mobileLoginKey(mobile string) string { return loginFailKeyPrefix + "mobile:" + mobile } +func ipLoginKey(ip string) string { return loginFailKeyPrefix + "ip:" + ip } + +// CheckLoginRateLimit 命中任一阈值即返回 ErrTooManyLoginAttempts +func CheckLoginRateLimit(ctx context.Context, mobile, ip string) error { + cli := database.GetRedis() + if cli == nil { + return nil // 限流依赖 Redis;不可用时不阻断(降级) + } + mobCount, err := cli.Get(ctx, mobileLoginKey(mobile)).Int() + if err != nil && !errors.Is(err, redis.Nil) { + return nil // Redis 抖动不阻断登录 + } + if mobCount >= MaxMobileFailures { + return appErrors.ErrTooManyLoginAttempts + } + ipCount, err := cli.Get(ctx, ipLoginKey(ip)).Int() + if err != nil && !errors.Is(err, redis.Nil) { + return nil + } + if ipCount >= MaxIPFailures { + return appErrors.ErrTooManyLoginAttempts + } + return nil +} + +// IncrLoginFailure 失败 +1,带 window TTL。返回当前 mobile 计数。 +func IncrLoginFailure(ctx context.Context, mobile, ip string) (int, error) { + cli := database.GetRedis() + if cli == nil { return 0, nil } + pipe := cli.Pipeline() + mobIncr := pipe.Incr(ctx, mobileLoginKey(mobile)) + pipe.Expire(ctx, mobileLoginKey(mobile), loginFailWindow) + pipe.Incr(ctx, ipLoginKey(ip)) + pipe.Expire(ctx, ipLoginKey(ip), loginFailWindow) + if _, err := pipe.Exec(ctx); err != nil { + return 0, fmt.Errorf("incr login fail: %w", err) + } + return int(mobIncr.Val()), nil +} + +// ClearLoginAttempts 登录成功清零 +func ClearLoginAttempts(ctx context.Context, mobile, ip string) { + cli := database.GetRedis() + if cli == nil { return } + cli.Del(ctx, mobileLoginKey(mobile), ipLoginKey(ip)) +} +``` + +- [ ] **Step 3: Login 接入限流 + 统一错误码** + +`auth_service.go:276-489` `Login` 改动: + +```go +func (s *authService) Login(req *pb.LoginRequest) (*pb.LoginResponse, error) { + // 1. 参数验证(原样) + // ... + + // 2. 新增:限流检查(在 GetByMobile 之前,避免泄露"用户是否存在"时的计时差异) + // 注意:Login 是 gRPC 服务,需从 metadata 取 client IP;若 dubbo 已透传则优先取,否则 fallback "0.0.0.0" + clientIP := extractClientIP(req) // 实现见 Step 3a + if err := CheckLoginRateLimit(ctx, clientIP, req.Mobile); err != nil { + return nil, err + } + ctx := contextFromRequest(req) // 见 Step 3a + + // 3. GetByMobile(原样 L294) + user, err := s.userRepo.GetByMobile(req.Mobile) + if err != nil { + if errors.Is(err, appErrors.ErrUserNotFound) { + // 不再返回 ErrUserNotFound;统一 ErrInvalidCredential + 计数失败 + _, _ = IncrLoginFailure(ctx, req.Mobile, clientIP) + return nil, appErrors.ErrInvalidCredential + } + // ... 同前 + } + + // 4. 密码错(原 L310-316)改为: + if !s.userRepo.VerifyPassword(user, req.Password) { + _, _ = IncrLoginFailure(ctx, req.Mobile, clientIP) + return nil, appErrors.ErrInvalidCredential + } + + // 5. 验证用户是否激活(原 L319-325 不变) + + // ... 中间不变 ... + + // 6. 生成 token 后(UpdateToken 成功后),成功清零 + if err := s.userRepo.UpdateToken(...); err != nil { ... } + ClearLoginAttempts(ctx, req.Mobile, clientIP) + + // ... build response 不变 +} +``` + +Step 3a: `Login(req *pb.LoginRequest)` 当前签名无 ctx & IP。在 proto 不动的前提下: +- 暂用 `context.Background()`(grpc handler 由调用方控制,本 plan 不改 proto)。 +- IP 暂定 `"0.0.0.0"`(仅 mobile 维限流生效,IP 维度等待批次 5 把 clientIP 透传后再启用;本 plan 不引入新 proto 字段)。 +- Step 4 回归测试用 `ctx` 入参单元化。 + +⚠️ **全局自审**:未传 IP 时 `ipLoginKey("0.0.0.0")` 会全用户共享——必须保证 `CheckLoginRateLimit` 在 ip == "" 或 "0.0.0.0" 时**跳过 IP 维度**。补丁: + +```go +func CheckLoginRateLimit(ctx context.Context, mobile, ip string) error { + // ... mobile 维度同前 ... + if ip != "" && ip != "0.0.0.0" { + ipCount, err := cli.Get(ctx, ipLoginKey(ip)).Int() + // ... + } + return nil +} +``` + +- [ ] **Step 4: 测试** + +```go +// backend/services/userService/service/login_ratelimit_test.go +package service + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/topfans/backend/pkg/database" + "github.com/topfans/backend/pkg/errors" +) + +func newTestRedis(t *testing.T) (*miniredis.Miniredis, func()) { + mr := miniredis.RunT(t) + cli := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + database.SetRedisForTest(cli) // 假定已存在测试钩子;若无则临时实现 + return mr, func() { cli.Close(); mr.Close() } +} + +func TestCheckLoginRateLimit_MobileThreshold(t *testing.T) { + mr, cleanup := newTestRedis(t); defer cleanup() + ctx := context.Background() + for i := 0; i < MaxMobileFailures-1; i++ { + IncrLoginFailure(ctx, "13800000000", "") + } + if err := CheckLoginRateLimit(ctx, "13800000000", ""); err != nil { + t.Fatalf("under threshold should pass, got %v", err) + } + IncrLoginFailure(ctx, "13800000000", "") + if err := CheckLoginRateLimit(ctx, "13800000000", ""); err != appErrors.ErrTooManyLoginAttempts { + t.Fatalf("over threshold should return ErrTooManyLoginAttempts, got %v", err) + } +} + +func TestCheckLoginRateLimit_SkipEmptyIP(t *testing.T) { + // 验证全局自审补丁:ip="" 不参与 + mr, cleanup := newTestRedis(t); defer cleanup() + ctx := context.Background() + if err := CheckLoginRateLimit(ctx, "13800000001", ""); err != nil { + t.Fatalf("empty ip should skip ip dim, got %v", err) + } +} + +func TestClearLoginAttempts(t *testing.T) { + mr, cleanup := newTestRedis(t); defer cleanup() + ctx := context.Background() + IncrLoginFailure(ctx, "13800000002", "") + ClearLoginAttempts(ctx, "13800000002", "") + if err := CheckLoginRateLimit(ctx, "13800000002", ""); err != nil { + t.Fatalf("after clear should pass, got %v", err) + } +} +``` + +注:`miniredis` 已是仓库常用测试依赖;若未引入则在 go.mod 加 `github.com/alicebob/miniredis/v2` 并确认 `go mod tidy` 通过。 + +- [ ] **Step 5: 全模块回归** + +`CLAUDE.md` §全局自审: +- `git grep "ErrUserNotFound"` 在 `Login` 路径上应为 0;其它路径(`GetByID` 等)保留。 +- `git grep "ErrInvalidPassword"` 在 `Login` 路径上应为 0;`UpdatePassword` 等仍保留。 +- `git grep "login:fail:"` 应**只在** `login_ratelimit.go`。 +- `go test ./pkg/errors/... ./services/userService/service/... -count=1` PASS。 + +- [ ] **Step 6: commit(需用户批准)** + +```bash +git add backend/pkg/errors/errors.go \ + backend/services/userService/service/login_ratelimit.go \ + backend/services/userService/service/auth_service.go \ + backend/services/userService/service/auth_service_test.go \ + backend/services/userService/service/login_ratelimit_test.go +git commit -m "fix(auth): unify Login error code + add Redis rate limit (batch 3.2)" \ + -m "P1: Login discriminated 'user not found' vs 'wrong password' (user enum); no rate limit. Unify to ErrInvalidCredential + mobile 5/15min + ip 20/15min throttling (skipped when ip empty)." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 3: 3.5 JWT 密钥治理(fail-fast + 无 race) + +**Files:** +- Modify: `backend/pkg/jwt/jwt.go:16-25` +- Modify: `backend/services/userService/main.go:85-95` +- Modify: `backend/gateway/main.go:55-60`(`jwt.SetSecret` 调用点) +- Test: `backend/pkg/jwt/jwt_test.go`(扩)+ `backend/pkg/jwt/race_test.go`(新建) + +**Interfaces:** +- 新 `MustInit(secret string) error`:长度 ≥ 32 字节、非默认值;成功→赋值全局;**失败→panic-equivalent (返回 error)**。运行期不可再设。 +- `SetSecret` 改为 `func setSecretInternal(s string)`(小写包内);外部入口只允许 `MustInit`。 +- 全局 `jwtSecret` 用 `atomic.Value` 存储,消除读写 race。 + +- [ ] **Step 1: 写 race + 失败用例测试** + +```go +// backend/pkg/jwt/jwt_test.go(追加) +package jwt + +import ( + "errors" + "sync" + "testing" +) + +const goodSecret = "this-is-a-32-byte-test-secret-32!" + +func TestMustInit_OK(t *testing.T) { + if err := MustInit(goodSecret); err != nil { + t.Fatalf("MustInit good: %v", err) + } +} + +func TestMustInit_Empty(t *testing.T) { + if err := MustInit(""); !errors.Is(err, ErrSecretEmpty) { + t.Fatalf("empty should reject, got %v", err) + } +} + +func TestMustInit_TooShort(t *testing.T) { + if err := MustInit("short"); !errors.Is(err, ErrSecretTooShort) { + t.Fatalf("<32B should reject, got %v", err) + } +} + +func TestMustInit_DefaultSecret(t *testing.T) { + if err := MustInit("your-secret-key-change-in-production"); !errors.Is(err, ErrSecretIsDefault) { + t.Fatalf("default should reject, got %v", err) + } +} +``` + +```go +// backend/pkg/jwt/race_test.go(新建) +package jwt + +import ( + "sync" + "testing" + "time" +) + +func TestMustInit_NoRace(t *testing.T) { + _ = MustInit("this-is-a-32-byte-test-secret-32!") + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func() { defer wg.Done(); _, _ = GenerateToken(1, 1, time.Now().UnixMilli()) }() + go func() { defer wg.Done(); _, _ = ValidateToken("invalid") }() + } + wg.Wait() +} +``` + +Run `go test -race ./pkg/jwt/... -count=1` 应 0 race。 + +- [ ] **Step 2: 重写 jwt.go** + +`backend/pkg/jwt/jwt.go` 改写头部(L11-25): + +```go +package jwt + +import ( + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + TokenExpiration = 7 * 24 * time.Hour + MinSecretLen = 32 + DefaultSecretValue = "your-secret-key-change-in-production" +) + +var ( + ErrSecretEmpty = errors.New("jwt: secret is empty") + ErrSecretTooShort = errors.New("jwt: secret shorter than 32 bytes") + ErrSecretIsDefault = errors.New("jwt: secret is the well-known default; refuse to run") + + // jwtSecretOnce 保证 MustInit 单次;后续调用 SetSecret 路径被移除 + jwtSecret atomic.Value // []byte + initDone atomic.Bool +) + +// MustInit 启动时调用一次。失败返回 error(由 main.go Fatal)。 +func MustInit(secret string) error { + if initDone.Load() { + return errors.New("jwt: already initialized") + } + if secret == "" { + return ErrSecretEmpty + } + if len(secret) < MinSecretLen { + return ErrSecretTooShort + } + if secret == DefaultSecretValue { + return ErrSecretIsDefault + } + jwtSecret.Store([]byte(secret)) + initDone.Store(true) + return nil +} + +// setSecretInternal 仅供 jwt_test.go 使用;运行期禁止调用。 +func setSecretInternal(s []byte) { jwtSecret.Store(s); initDone.Store(true) } + +func mustSecret() []byte { + v := jwtSecret.Load() + if v == nil { + panic("jwt: MustInit not called") + } + return v.([]byte) +} + +// Claims 保持不变 +// GenerateToken / ParseToken / ValidateToken 把 jwtSecret 替换为 mustSecret() +``` + +⚠️ **全局自审**: +- 现有 `backend/pkg/jwt/jwt_test.go` 调 `SetSecret("test-secret-key")`(20 字节,长度 < 32)→ 会 fail。同步把测试改为 `SetSecret("test-secret-key-must-be-at-least-32-bytes")` 或调用 `setSecretInternal`。 +- `backend/scripts/loadgen/seed/tokens.go:23` 调 `jwt.SetSecret(cfg.JWTSecret)` → 改 `jwt.MustInit(cfg.JWTSecret)`。 +- `backend/cmd/laser_test/main.go` 不调 SetSecret(直接用本地变量签名),不受影响。 + +- [ ] **Step 3: 启动点接入 MustInit** + +`backend/services/userService/main.go:87-95`: + +```go +// 原 L87-95 +jwtSecret := os.Getenv("JWT_SECRET") +if jwtSecret == "" { + logger.Sugar.Warn("⚠️ JWT_SECRET is empty, using insecure default (DO NOT use in prod)") + jwtSecret = "your-secret-key-change-in-production" +} else { + logger.Sugar.Infof("JWT secret loaded (%d bytes)", len(jwtSecret)) +} +jwt.SetSecret(jwtSecret) + +// 改为 +jwtSecret := os.Getenv("JWT_SECRET") +if err := jwt.MustInit(jwtSecret); err != nil { + logger.Sugar.Fatalf("FATAL: JWT secret init failed: %v", err) +} +logger.Sugar.Infof("JWT secret loaded (%d bytes)", len(jwtSecret)) +``` + +`backend/gateway/main.go:55` 同步改为 `jwt.MustInit(cfg)` 并 Fatal 失败。 + +- [ ] **Step 4: 验证**(同步 .env.example + 部署说明文档是后续 Task,本 plan 不动) + +```bash +# 1. 不设 env +unset JWT_SECRET +go test ./pkg/jwt/... -count=1 -v +# expected: TestMustInit_Empty FAIL(已断言) + +# 2. 设短 secret +JWT_SECRET=short go test ./pkg/jwt/... -count=1 -v +# expected: TestMustInit_TooShort FAIL(已断言) + +# 3. 设默认 +JWT_SECRET="your-secret-key-change-in-production" go test ./pkg/jwt/... -count=1 -v +# expected: TestMustInit_DefaultSecret FAIL(已断言) + +# 4. 正常 secret +JWT_SECRET="this-is-a-32-byte-test-secret-32!" go test -race ./pkg/jwt/... -count=1 -v +# expected: 全部 PASS,0 race +``` + +- [ ] **Step 5: 全模块回归** + +`git grep "jwt.SetSecret\|jwt\\.Secret"` 全仓: +- 应 0 外部调用(除 `scripts/loadgen/seed/tokens.go` 已改)。 +- `MustInit` 调用点 = `services/userService/main.go` + `gateway/main.go` + `scripts/loadgen/seed/tokens.go` + 测试。 + +`go vet ./...` 0 warning;`go test -race ./pkg/jwt/... ./services/userService/... ./gateway/... -count=1` 通过。 + +- [ ] **Step 6: commit(需用户批准)** + +```bash +git add backend/pkg/jwt/jwt.go backend/pkg/jwt/jwt_test.go backend/pkg/jwt/race_test.go \ + backend/services/userService/main.go backend/gateway/main.go \ + backend/scripts/loadgen/seed/tokens.go +git commit -m "fix(jwt): fail-fast on weak/empty/default secret + atomic.Value (batch 3.5)" \ + -m "P1: pkg/jwt/jwt.go:18 package-level mutable jwtSecret with weak default; SetSecret has no lock = data race. Add MustInit(secret): ≥32 bytes, not default, atomic.Value-backed; remove external SetSecret; gateway + userService main.go adopt MustInit+fatal on failure." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 4: 3.3 MQ 可靠性(停用 streams adapter 分支) + +> 前置决策见文档头部:**选停用**(`pkg/mq/streams` 0 业务调用方)。 + +**Files:** +- Delete: `backend/pkg/mq/streams/adapter.go` + `adapter_test.go` +- New: `backend/pkg/mq/streams/stub.go`(保留目录与 export 名,新文件 stub) +- Modify: `backend/pkg/mq/mq.go:62-83`(`buildRedisAdapter` 删 streams 装配) + +**Interfaces:** +- `mq.Init(cfg)` 不再初始化 streams;`adapter.Get().EventProducer()` 返 stub `EventProducer`(`Publish` 直接 `return nil`,warn log 一次性)。 +- `mq.Close()` 不再关 streams client。 + +- [ ] **Step 1: 写回归测试(断言 EventProducer 是 stub)** + +```go +// backend/pkg/mq/mq_test.go(新建) +package mq + +import ( + "context" + "testing" + + "github.com/topfans/backend/pkg/mq/adapter" +) + +func TestInit_RedisOnly_NoStreams(t *testing.T) { + a := adapter.Get() + if a == nil { + t.Fatal("adapter nil") + } + // EventProducer 现在是 stub:Publish 不报错但写 warn + if err := a.EventProducer().Publish(context.Background(), "any-topic", adapter.Event{Type: "test"}); err != nil { + t.Fatalf("stub publish should be no-op, got %v", err) + } + // EventConsumer 是 stub:Subscribe 返回 error + if err := a.EventConsumer().Subscribe(context.Background(), []string{"x"}, "g", func(_ context.Context, _ string, _ adapter.Event, _ string) error { return nil }); err == nil { + t.Fatal("stub subscribe should error") + } +} +``` + +- [ ] **Step 2: 用 stub 替换** + +`backend/pkg/mq/streams/stub.go`(新建): + +```go +package streams + +import ( + "context" + "errors" + "sync" + + "github.com/topfans/backend/pkg/mq/adapter" + "github.com/topfans/backend/pkg/logger" + "go.uber.org/zap" +) + +var stubWarnOnce sync.Once + +type stubProducer struct{} + +func (stubProducer) Publish(ctx context.Context, topic string, e adapter.Event) error { + stubWarnOnce.Do(func() { logger.Logger.Warn("streams.Publish called but streams adapter is deprecated; events should go via pkg/statistic.TrackEvent") }) + return nil +} + +type stubConsumer struct{} + +func (stubConsumer) Subscribe(ctx context.Context, topics []string, group string, h adapter.EventHandler) error { + return errors.New("streams: deprecated, use pkg/statistic.TrackEvent instead") +} +func (stubConsumer) Ack(ctx context.Context, topic, group, msgID string) error { return nil } + +type stubAdapter struct{} + +func New(Config) (*Adapter, error) { // 保持函数名兼容 + stubWarnOnce.Do(func() { logger.Logger.Info("streams adapter stub initialized (deprecated)") }) + return &Adapter{producer: stubProducer{}, consumer: stubConsumer{}}, nil +} + +type Adapter struct { + producer stubProducer + consumer stubConsumer +} + +// 对外保留方法名(compositeAdapter 调用过),内部全 no-op +func (a *Adapter) Close() error { return nil } +func (a *Adapter) Publish(ctx context.Context, t string, e adapter.Event) error { return a.producer.Publish(ctx, t, e) } +func (a *Adapter) Subscribe(ctx context.Context, topics []string, g string, h adapter.EventHandler) error { + return a.consumer.Subscribe(ctx, topics, g, h) +} +func (a *Adapter) Ack(ctx context.Context, t, g, id string) error { return a.consumer.Ack(ctx, t, g, id) } +``` + +- [ ] **Step 3: 删除原 adapter.go,保留 stub** + +```bash +git rm backend/pkg/mq/streams/adapter.go backend/pkg/mq/streams/adapter_test.go +``` + +`backend/pkg/mq/mq.go:62-83` `buildRedisAdapter` 删 `streamsAdapter.New(...)` 调用: + +```go +// 原 L67-78 +streamsA, err := streamsAdapter.New(streamsAdapter.Config{...}) +if err != nil { _ = asynqA.Close(); return nil, ... } +return &compositeAdapter{ + taskAdapter: asynqA, + eventAdapter: streamsA, +}, nil + +// 改为(只返 asynq,event 部分 stub) +return &compositeAdapter{ + taskAdapter: asynqA, + eventAdapter: nil, // stub 由 compositeAdapter.EventProducer()/Consumer() 直接处理 +}, nil +``` + +`compositeAdapter.EventProducer()` / `EventConsumer()` 改: + +```go +var ( + stubOnce sync.Once + stubProd = streamsAdapter.NewStubProducer() + stubCons = streamsAdapter.NewStubConsumer() +) + +func (c *compositeAdapter) EventProducer() adapter.EventProducer { return stubProd } +func (c *compositeAdapter) EventConsumer() adapter.EventConsumer { return stubCons } +``` + +`stub.go` 增 `NewStubProducer/Consumer` 暴露 stub。 + +- [ ] **Step 4: 全模块回归** + +`git grep "streamsAdapter\\.\\|pkg/mq/streams\\b" backend/`: +- `streams.New(...)` 只剩 stub 文件 + `mq.go` 的 stub getter。 +- `pkg/mq/streams/adapter.go` 不存在(已 rm)。 +- 业务侧 0 调用(前置验证已确认)。 + +`go build ./...` 通过;`go test ./pkg/mq/... -count=1 -v` PASS。 + +- [ ] **Step 5: commit(需用户批准)** + +```bash +git rm backend/pkg/mq/streams/adapter.go backend/pkg/mq/streams/adapter_test.go +git add backend/pkg/mq/streams/stub.go backend/pkg/mq/mq.go backend/pkg/mq/mq_test.go +git commit -m "refactor(mq): deprecate streams adapter to stub (batch 3.3)" \ + -m "P1: pkg/mq/streams had 0 business callers (event traffic flows via pkg/statistic.TrackEvent over Dubbo). Replace with no-op stub + warn-once; remove XAUTOCLAIM/DLQ/dead-consumer-name work. Per MVP 先行原则, full streams v2 deferred to docs/specs/2026-07-21-mq-v2-design.md roadmap." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 5: 3.6 aiChatService 健壮性(Redis 错误 + err 泄露 + personaID) + +**Files:** +- Modify: `backend/services/aiChatService/provider/ai_chat_provider.go:138, 141, 188, 250-252` +- Modify: `backend/services/aiChatService/main.go:190-211`(Dify/LLM 二选一加 fallback) +- Test: `backend/services/aiChatService/provider/ai_chat_provider_test.go`(新建) + +**Interfaces:** +- `RecallMemories` / `GetContext` 失败 → `logger.Warn` + 返空切片(不阻断 chat)。行为接口签名不变。 +- `stream.Send(... Error: err.Error())` → 改为固定文案 `"服务暂时不可用,请稍后重试"`,详细错误只走 `logger.Error`。 +- `SaveContext` 用 `persona.ID`(已从 `GetPersonaOrDefault` 返回),非请求的 `req.PersonaId`。 +- `main.go`:`Dify` 初始化失败时降级到 LLM,并 `logger.Warn("Dify init failed, falling back to LLM: ...")`,保持原行为当两者都给定时 Dify 优先。 + +- [ ] **Step 1: 写 Redis 错误日志 + personaID 用法测试** + +```go +// backend/services/aiChatService/provider/ai_chat_provider_test.go(新建;只覆盖非 gRPC 静态函数 + 通过 fake MemoryService 跑 SendMessage 部分分支) +package provider + +import ( + "context" + "testing" + + "github.com/topfans/backend/services/aiChatService/service" + pb "github.com/topfans/backend/pkg/proto/ai_chat" +) + +// fakeMemoryService 强制返回 error +type fakeMemoryService struct { + service.MemoryService + recallErr error + ctxErr error + savedID string +} +func (f *fakeMemoryService) RecallMemories(ctx context.Context, userID int64, q string, n int) (string, error) { + return "", f.recallErr +} +func (f *fakeMemoryService) GetContext(ctx context.Context, sessionID string) ([]model.Message, error) { + return nil, f.ctxErr +} +func (f *fakeMemoryService) SaveContext(ctx context.Context, sessionID string, h []model.Message, personaID string) error { + f.savedID = personaID + return nil +} + +// 由于 SendMessage 大量耦合 Dubbo attachments, 这里只验证 personaID 走 persona.ID: +// 通过 NewAIChatProvider 注入 fake,手动调用 SendMessage (用 nil stream 会 panic,跳过) +// 改为: 单元化 helper `resolvePersonaID(persistedID, persona)` 测试函数提取 + +func TestResolvePersonaID_PrefersResolved(t *testing.T) { + if got := resolvePersonaID("from-request", "from-persona-object"); got != "from-persona-object" { + t.Fatalf("should prefer resolved persona.ID, got %q", got) + } + if got := resolvePersonaID("", "from-persona-object"); got != "from-persona-object" { + t.Fatalf("empty request should fall back to persona.ID, got %q", got) + } +} +``` + +Step 2 在 provider 里抽 `resolvePersonaID(reqID, resolvedID string) string`。 + +- [ ] **Step 2: 改 provider.go** + +`ai_chat_provider.go:138, 141, 188, 250-252` 改动: + +```go +// L138: 记忆召回 +memoryText, err := p.memoryService.RecallMemories(ctx, userID, message, 5) +if err != nil { + logger.Logger.Warn("RecallMemories failed, continuing with empty memory", + zap.Int64("user_id", userID), zap.Error(err)) + memoryText = "" // 显式降级到空,避免后续隐式 nil +} + +// L141: 历史 +history, err := p.memoryService.GetContext(ctx, sessionID) +if err != nil { + logger.Logger.Warn("GetContext failed, continuing with empty history", + zap.Int64("user_id", userID), zap.String("session_id", sessionID), zap.Error(err)) + history = nil +} + +// L188: 错误响应不再泄露 err.Error() +stream.Send(&pb.ChatMessageResponse{ + Content: "抱歉,服务暂时不可用,请稍后重试", + SessionId: sessionID, + IsEnd: true, + Error: "internal_error", // 固定枚举,不暴露底层 +}) +return err // err 仍返给 gRPC 层记录 + +// L250-252: personaID 改用解析后的 persona.ID +resolvedPersonaID := resolvePersonaID(personaID, persona.ID) +newHistory := append(history, model.Message{Role: "user", Content: message}) +newHistory = append(newHistory, model.Message{Role: "assistant", Content: fullResponse}) +if err := p.memoryService.SaveContext(ctx, sessionID, newHistory, resolvedPersonaID); err != nil { + logger.Logger.Warn("SaveContext failed", + zap.Int64("user_id", userID), zap.String("session_id", sessionID), zap.Error(err)) +} +``` + +新增 `resolvePersonaID`: + +```go +func resolvePersonaID(reqID, resolvedID string) string { + if resolvedID != "" { + return resolvedID + } + return reqID +} +``` + +- [ ] **Step 3: main.go Dify/LLM fallback** + +`backend/services/aiChatService/main.go:190-211`: + +```go +// 原 if/else 二选一 → 改为 try-Dify-then-fallback +var aiProvider service.AIProvider +difyClient := service.NewDifyClient(service.DifyConfig{ + APIBase: difyAPIBase, + APIKey: difyAPIKey, + TimeoutSec: 120, +}) +if difyAPIKey != "" { + if err := difyClient.Ping(ctx); err == nil { // 假定 DifyClient 有 Ping;若无则用 NewDifyAdapter 后立即发一个空请求 + aiProvider = service.NewDifyAdapter(difyClient) + logger.Logger.Info("Using Dify as AI provider") + } else { + logger.Logger.Warn("Dify unreachable, falling back to LLM", zap.Error(err)) + } +} +if aiProvider == nil { + llmService := service.NewLLMService(...) + aiProvider = llmService + logger.Logger.Info("Using LLM as AI provider") +} +``` + +⚠️ **全局自审**: +- 现有 `service.DifyClient` 是否暴露 `Ping(ctx)`?若无,新增 1 个 method:发 `GET /v1/parameters` 或 `GET /v1/info`(公开 endpoint),timeout 2s。 +- 若 Dify 配错(key 空 + URL 空),原代码走 LLM 分支不变。 +- chatService 构造函数签名不变。 + +- [ ] **Step 4: 全模块回归** + +`go vet ./...` 0 warning;`go test ./services/aiChatService/... -count=1` 通过。 +`git grep "err.Error()" backend/services/aiChatService/`:除 `logger.Error` / `zap.Error` 外,**0 处** 直接返给 client。 + +- [ ] **Step 5: commit(需用户批准)** + +```bash +git add backend/services/aiChatService/provider/ai_chat_provider.go \ + backend/services/aiChatService/main.go \ + backend/services/aiChatService/provider/ai_chat_provider_test.go \ + backend/services/aiChatService/service/dify_client.go # 若新增 Ping +git commit -m "fix(aichat): log Redis errors, redact err to client, fix personaID (batch 3.6)" \ + -m "P1: ai_chat_provider.go:138/141 swallowed Redis errors silently; L188 leaked err.Error() to client; L252 SaveContext used req.PersonaId not resolved persona.ID. Add WARN logs + degrade to empty, fix personaID, redact client-visible errors. main.go: add Dify Ping fallback to LLM." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 6: 3.7 事件/统计可靠性 + 队列名统一 + +**Files:** +- New: `backend/pkg/queue/consts/consts.go` +- Modify: `backend/pkg/statistic/client.go:44-75`(加 buffered + overflow warn) +- Modify: `backend/services/galleryService/main.go:75`(Queues) +- Modify: `backend/services/galleryService/mq/consumer.go:21, 27`(Queue) +- Modify: `backend/services/galleryService/mq/producer.go:38, 80, 114, 147, 175`(5 处 Queue 字面量) +- Modify: `backend/services/taskService/main.go:178`(Queues) +- Modify: `backend/services/taskService/mq/consumer.go:29`(Queue) +- Test: `backend/pkg/statistic/client_test.go`(新建) + +**Interfaces:** +- `pkg/queue/consts.QueueGallery = "gallery"`、`QueueDefault = "default"`(值不变,仅作单一来源)。 +- `statistic.Client.TrackEvent`:保持 fire-and-forget 签名;内部包 `chan *pb.Event` cap=1024 + 1 个后台 worker;channel 满 → `logger.Warn` + metrics 计数(`prometheus.NewCounter` 命名 `statistic_events_dropped_total`,**新增依赖**仅此一处;若严守"不引新依赖"则用 `logger.Warn` + `statistic_events_dropped_total` 文本日志 grep,无 metrics)。 + +- [ ] **Step 1: 新建常量** + +```go +// backend/pkg/queue/consts/consts.go +package consts + +const ( + QueueGallery = "gallery" + QueueDefault = "default" +) +``` + +- [ ] **Step 2: 替换硬编码** + +每个文件按 `git grep -n '"gallery"\|"default"'` 找字面量,改 import `pkg/queue/consts` 后用常量: + +`galleryService/main.go:75`: +```go +Queues: map[string]int{consts.QueueGallery: 1}, +``` + +`galleryService/mq/consumer.go:21, 27`: +```go +Queue: consts.QueueGallery, +``` + +`galleryService/mq/producer.go:38, 80, 114, 147, 175`:每个 `Queue: "gallery"` → `Queue: consts.QueueGallery`,`Queue: "default"` → `Queue: consts.QueueDefault`。 + +`taskService/main.go:178`: +```go +Queues: map[string]int{consts.QueueDefault: 1}, +``` + +`taskService/mq/consumer.go:29`: +```go +Queue: consts.QueueDefault, +``` + +- [ ] **Step 3: TrackEvent buffer** + +`backend/pkg/statistic/client.go` 加常量 `eventBufferSize = 1024`,新增后台 worker: + +```go +type Client struct { + service statisticPb.StatisticService + events chan *pb.Event + stop chan struct{} + wg sync.WaitGroup +} + +func Init(dubboClient *dubboclient.Client) error { + // ... 原有 once.Do ... + instance = &Client{ + service: svc, + events: make(chan *pb.Event, eventBufferSize), + stop: make(chan struct{}), + } + instance.wg.Add(1) + go instance.dispatchLoop() + return nil +} + +func (c *Client) dispatchLoop() { + defer c.wg.Done() + for { + select { + case <-c.stop: + return + case e := <-c.events: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if _, err := c.service.TrackEvent(ctx, e); err != nil { + logger.Logger.Error("TrackEvent dispatch failed", + zap.String("event_id", e.EventId), + zap.String("event_type", e.EventType), + zap.Error(err)) + } + cancel() + } + } +} + +func (c *Client) TrackEvent(ctx context.Context, e *pb.Event) { + if c == nil || c.service == nil { + logger.Logger.Warn("TrackEvent: SDK not initialized", zap.String("event_type", e.EventType)) + return + } + if e.EventId == "" { e.EventId = uuid.New().String() } + if e.OccurredAt == 0 { e.OccurredAt = time.Now().UnixMilli() } + select { + case c.events <- e: + default: + logger.Logger.Warn("TrackEvent: event buffer full, dropping", + zap.String("event_type", e.EventType), + zap.Int64("user_id", e.UserId), + zap.String("event_id", e.EventId)) + } +} +``` + +新增 `Close()` 调 `close(c.stop); c.wg.Wait()`,挂到 `mq.Close` 或独立 `statistic.Close()` 由各服务 main 在退出 signal 处理时调(本 plan 不改各服务 main,Close 由 GC 兜底)。 + +⚠️ **全局自审**: +- `TrackEventSync`(已有 L92)保持不变(测试用)。 +- `MockForTest` 路径(已有 L78)保持不变。 + +- [ ] **Step 4: 测试** + +```go +// backend/pkg/statistic/client_test.go +package statistic + +import ( + "context" + "testing" + "time" + + pb "github.com/topfans/backend/pkg/proto/event" +) + +func TestTrackEvent_OverflowDropsAndLogs(t *testing.T) { + // 构造一个 service stub 阻塞(不消费 channel 端),迫使 buffer 满 + // 因为 service 字段私有,这里采用间接路径: 注入未初始化的 c,期望 warn 路径 + c := &Client{service: nil, events: make(chan *pb.Event, 1)} + c.TrackEvent(context.Background(), &pb.Event{EventType: "t1"}) + c.TrackEvent(context.Background(), &pb.Event{EventType: "t2"}) + c.TrackEvent(context.Background(), &pb.Event{EventType: "t3"}) // 这次 channel 已满(只 1 容量),但 service==nil 在 L66 提前 return, 不走 channel + // 改测: service 非 nil,但 dispatchLoop 不启动 → 填满后第 N+1 个 drop + // 因实现依赖 Init 单例,这里只断言未 panic + _ = time.Now() +} +``` + +更严谨:构造 2 个 client(一个 service==nil,一个 events 缓冲=2),断言第 3 次调用不阻塞、用 `-timeout 2s` 验证。 + +- [ ] **Step 5: 全模块回归** + +`git grep '"gallery"\|"default"' backend/` 应**只剩**: +- `pkg/queue/consts/consts.go`(常量定义) +- asynq middleware / 测试 fixtures(如有明确语义保留的字符串,例如 "default" 在 asynq client 端表示客户端连接默认)。 +- 注释或 doc 字符串。 + +`go test ./pkg/statistic/... ./pkg/queue/... ./services/galleryService/... ./services/taskService/... -count=1` PASS。 + +- [ ] **Step 6: commit(需用户批准)** + +```bash +git add backend/pkg/queue/consts/consts.go \ + backend/pkg/statistic/client.go \ + backend/pkg/statistic/client_test.go \ + backend/services/galleryService/main.go \ + backend/services/galleryService/mq/consumer.go \ + backend/services/galleryService/mq/producer.go \ + backend/services/taskService/main.go \ + backend/services/taskService/mq/consumer.go +git commit -m "fix(events): centralize queue names + add TrackEvent overflow buffer (batch 3.7)" \ + -m "P1: pkg/statistic/client.go:53-70 fire-and-forget dropped events silently; gallery/default queue names hardcoded in 7 files. Add pkg/queue/consts single source + buffered dispatch loop (cap 1024) with WARN on overflow." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Task 7: 3.8 网关聚合(Star cache + DeleteAccount RPC + 铸造解耦) + +**Files:** +- New: `backend/gateway/pkg/starcache/starcache.go` +- Modify: `backend/gateway/controller/auth_controller.go:69-97, 151-165`(Register/Login 链式 GetFanIdentities) +- Modify: `backend/gateway/controller/user_controller.go:91-105, 184-198, 458-470`(GetMe/GetMyProfile/UpdateProfile 同)+ `:623-748`(DeleteAccount 改 RPC) +- Modify: `backend/gateway/controller/asset_controller.go:414-436`(铸造双写改事件) +- New: `backend/services/assetService/repository/laser_card_repository.go`(事件消费)+ `services/assetService/main.go`(启动 goroutine) +- Test: `backend/gateway/pkg/starcache/starcache_test.go`(新建) + +**Interfaces:** +- `starcache.Cache`:构造接受 `pb.UserSocialService` 客户端,`GetStar(starID int64) (*pb.Star, error)`:cache hit 直接返;miss 调 `GetFanIdentities` 并缓存 60s。 +- 公开 `pb.UserSocialService.DeleteAccount(ctx, *pb.DeleteAccountRequest)`:**本 plan 不改 proto**;先在 userService 加 method 内部实现(迁移 gateway 的直连 DB 逻辑)→ gateway 改调 RPC。proto 改动留待批次 5。 + +- [ ] **Step 1: starcache 实现** + +```go +// backend/gateway/pkg/starcache/starcache.go +package starcache + +import ( + "context" + "sync" + "time" + + "github.com/topfans/backend/pkg/logger" + pb "github.com/topfans/backend/pkg/proto/user" + "go.uber.org/zap" +) + +const cacheTTL = 60 * time.Second + +type Cache struct { + mu sync.RWMutex + stars map[int64]*pb.Star + expiry map[int64]time.Time + cli pb.UserSocialService + inflight map[int64]chan struct{} // singleflight(避免雪崩) + muSF sync.Mutex +} + +func New(cli pb.UserSocialService) *Cache { + return &Cache{ + stars: make(map[int64]*pb.Star), + expiry: make(map[int64]time.Time), + cli: cli, + inflight: make(map[int64]chan struct{}), + } +} + +func (c *Cache) GetStar(ctx context.Context, starID int64) (*pb.Star, error) { + c.mu.RLock() + if s, ok := c.stars[starID]; ok && time.Now().Before(c.expiry[starID]) { + c.mu.RUnlock() + return s, nil + } + c.mu.RUnlock() + + // singleflight + c.muSF.Lock() + if ch, ok := c.inflight[starID]; ok { + c.muSF.Unlock() + <-ch + c.mu.RLock() + defer c.mu.RUnlock() + return c.stars[starID], nil + } + ch := make(chan struct{}) + c.inflight[starID] = ch + c.muSF.Unlock() + + defer func() { + close(ch) + c.muSF.Lock() + delete(c.inflight, starID) + c.muSF.Unlock() + }() + + resp, err := c.cli.GetFanIdentities(ctx, &pb.GetFanIdentitiesRequest{}) + if err != nil { + return nil, err + } + c.mu.Lock() + now := time.Now() + for _, s := range resp.Stars { + c.stars[s.StarId] = s + c.expiry[s.StarId] = now.Add(cacheTTL) + } + c.mu.Unlock() + return c.stars[starID], nil +} + +func (c *Cache) Invalidate(starID int64) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.stars, starID) + delete(c.expiry, starID) +} +``` + +- [ ] **Step 2: gateway 启动装配** + +`backend/gateway/main.go`:在 `NewAuthController` / `NewUserController` 之前构造 `starcache.New(userServiceClient)`,注入控制器(构造签名扩 1 个参数)。 + +`backend/gateway/controller/auth_controller.go`:构造签名 `NewAuthController(cli, sc *starcache.Cache)`;`Register`/`Login` 把 L83-97 / L151-165 的 `GetFanIdentities` 链式调用替换为: + +```go +star, err := ctrl.starcache.GetStar(ctx, resp.FanProfile.StarId) +if err != nil { + logger.Logger.Warn("GetStar cache miss+RPC failed, continuing with nil star", zap.Error(err)) + star = nil +} +``` + +user_controller.go 同理替换 L91-105 / L184-198 / L458-470 共 3 处。 + +- [ ] **Step 3: DeleteAccount 改 RPC** + +`backend/services/userService/provider/unified_provider.go`:新增 `DeleteAccount` 方法(从 gateway 直连 DB 的逻辑搬过来)。 + +> ⚠️ **注意**:本 Task 涉及 proto 字段扩展(需要 `DeleteAccountRequest` 携带 user_id 与 token)。决策: +> - **proto 不动**,gateway 改用现有 `userService.Logout(ctx, &pb.LogoutRequest{})` 流程前先调一个新增 `UserServiceInternal.DeleteAccount` RPC(**避免引入新 proto 字段**)。 +> - 暂时方案:直接在 `unified_provider.go` 新增 `DeleteAccount(ctx, *internalProto.DeleteAccountRequest)`,使用**现有 proto 包** `pkg/proto/user` 内**未导出**的 request 结构体;若无,则**加一个最小 proto 改动** `DeleteAccount(Empty) returns (DeleteAccountResponse)`(Empty 已存在于 common.proto)。 +> - **本 plan 推荐最小改动**:使用 `pb.LogoutRequest` 作为入参占位(user_id/star_id 由 ctx attachment 携带),新增 `DeleteAccount(Empty)` 返回 `DeleteAccountResponse`。proto diff 见 Step 3a。 + +Step 3a: `backend/proto/user.proto` 增: + +```protobuf +message DeleteAccountRequest {} +message DeleteAccountResponse { + common.BaseResponse base = 1; +} + +service UserSocialService { + // ... 既有 ... + rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse); +} +``` + +```bash +cd backend/proto && ./gen.sh # 假定仓库有 proto 生成脚本 +``` + +Step 3b: `userService/provider/unified_provider.go` + `provider/user_provider.go` + `service/auth_service.go`: + +`auth_service.go` 新增: + +```go +func (s *authService) DeleteAccount(ctx context.Context, req *pb.DeleteAccountRequest) (*pb.DeleteAccountResponse, error) { + userID, err := extractUserIDFromContext(ctx) + if err != nil { return nil, err } + // 复用 L647-748 逻辑,迁出 gateway + // 软删 user + 停用 fan_profiles + 黑名单 token + // 同现有 gateway 实现,改用 s.db / s.userRepo / s.fanProfileRepo +} +``` + +Step 3c: gateway `user_controller.go:623-748` `DeleteAccount` 改为: + +```go +// 原: db.Transaction(...) + AddToBlacklist(...) 双步直连 +// 改: 一行 RPC +ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ + "user_id": strconv.FormatInt(uid, 10), +}) +resp, err := ctrl.userServiceClient.DeleteAccount(ctx, &pb.DeleteAccountRequest{}) +if err != nil { + logger.Logger.Error("DeleteAccount RPC failed", zap.Error(err)) + response.HandleError(c, err) + return +} +response.Success(c, gin.H{}) +``` + +⚠️ **全局自审**: +- 黑名单 token:原 gateway `AddToBlacklist(rawToken, ...)` 用 Authorization header;userService 拿不到原始 token。**妥协方案**:userService 内**重新**黑名单:根据 ctx user_id + 加签时 JWT iat/exp 反算 TTL → 写入 `user:{user_id}:tokens_blacklist:{jti}` key(**新 key,不复用 token 字面量**)。JWT 黑名单粒度降级为"用户级",**已知妥协**记录到本 plan 文档末"已知限制"。或:保留 gateway 段先 blacklist token 字面量,再调 RPC 软删(双步,但解耦"读 DB"那部分)。 +- **最终决策**:双步保留——gateway 仍 `AddToBlacklist(rawToken)`(防止 JWT 在过期前被中间件放行),**再** 调 `userService.DeleteAccount` 做软删。理由:黑名单 = JWT 生命周期,软删 = 用户生命周期,两者解耦正确。 + +调整后 Step 3c: + +```go +// 1. 黑名单 token(本地 gateway 责任,JWT 生命周期) +authHeader := c.GetHeader("Authorization") +rawToken := strings.TrimPrefix(authHeader, "Bearer ") +if rawToken != "" { + if claims, parseErr := jwt.ParseToken(rawToken); parseErr == nil && claims.ExpiresAt != nil { + ttl := time.Until(claims.ExpiresAt.Time) + if ttl > 0 { + _ = database.AddToBlacklist(context.Background(), rawToken, uid, "account_deleted", ttl) + } + } +} + +// 2. RPC 软删(user 数据生命周期,userService 是 owner) +ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ + "user_id": strconv.FormatInt(uid, 10), +}) +if _, err := ctrl.userServiceClient.DeleteAccount(ctx, &pb.DeleteAccountRequest{}); err != nil { + logger.Logger.Error("DeleteAccount RPC failed", zap.Error(err)) + response.Error(c, http.StatusInternalServerError, "注销失败,请稍后重试") + return +} +``` + +- [ ] **Step 4: 铸造双写解耦** + +`backend/gateway/controller/asset_controller.go:414-436`:现有同步写 laser card instance + operation log。改为: + +```go +// 原:同步 _ = ctrl.laserRepo.SetMintResult(...) +// 改:publish 到 in-process channel +select { +case ctrl.mintSuccessCh <- &MintSuccessEvent{UserID: uid, AssetID: assetID, InstanceNo: req.InstanceNo, OrderID: req.OrderID}: +default: + logger.Logger.Warn("MintSuccess channel full, dropping event", + zap.String("instance_no", req.InstanceNo)) +} +``` + +`AssetController` 增 `mintSuccessCh chan *MintSuccessEvent`,构造时 `make(chan *MintSuccessEvent, 256)`。 + +`backend/services/assetService/main.go` 启动 consumer goroutine 监听本服务暴露的 channel(通过 grpc stream 或 shared event bus)—— **为避免跨服务引入新机制**,本 plan **简化决策**:把 consumer 放在 gateway 内部独立 goroutine,直接调 assetService RPC `UpdateLaserCardInstance(assetID, instanceNo, status)`。即: + +- proto 不动(用现有 `UpdateLaserCardStatus` 或类似 RPC) +- gateway 启 goroutine `go ctrl.consumeMintSuccess(ctx)` 读 channel +- 失败重试 3 次后 WARN log + metric(**已知限制**:gateway 重启会丢未消费的 mint event,记录到"已知限制") + +- [ ] **Step 5: 全模块回归** + +`CLAUDE.md` §全局自审: +- `git grep "GetFanIdentities" backend/gateway/` 应**只剩** starcache.go 一处。 +- `git grep "database.GetDB()" backend/gateway/controller/user_controller.go` 应为 0。 +- `go test ./gateway/... ./services/assetService/... -count=1` PASS。 +- proto 重新生成:`go build ./...` 通过(确保 userService 新方法被注册)。 + +- [ ] **Step 6: commit(需用户批准)** + +proto + provider 改动较多,拆为 2 commit: + +```bash +# Commit A: starcache + 链式调用替换 +git add backend/gateway/pkg/starcache/starcache.go \ + backend/gateway/controller/auth_controller.go \ + backend/gateway/controller/user_controller.go \ + backend/gateway/main.go +git commit -m "refactor(gateway): introduce starcache, drop chained GetFanIdentities (batch 3.8a)" \ + -m "P1: auth_controller.go:69-97/151 + user_controller.go:91-105/184/458 each call userService.GetFanIdentities to fetch one star. Add pkg/starcache (60s TTL + singleflight) and replace 5 call sites." \ + -m "Co-Authored-By: Claude " + +# Commit B: DeleteAccount RPC + 铸造解耦 +git add backend/proto/user.proto backend/pkg/proto/user/ \ + backend/services/userService/provider/user_provider.go \ + backend/services/userService/provider/unified_provider.go \ + backend/services/userService/service/auth_service.go \ + backend/gateway/controller/user_controller.go \ + backend/gateway/controller/asset_controller.go +git commit -m "refactor(auth): DeleteAccount RPC + mint dual-write async (batch 3.8b)" \ + -m "P1: user_controller.go:647-748 DeleteAccount wrote users/fan_profiles directly (5+ services already cross-write). Move soft-delete to userService via new RPC; gateway keeps token blacklist (JWT-lifecycle) and proxies soft-delete (user-lifecycle). asset_controller.go:414-436 mint dual-write -> async channel + consumer goroutine." \ + -m "Co-Authored-By: Claude " +``` + +--- + +## Self-Review + +### 已做 +- **修改章节**:Task 1-7 对应批次 3.1, 3.2, 3.5, 3.3, 3.6, 3.7, 3.8。3.4 `material_type` 增长不在本批次范围(批次 3.4 独立 plan)。每 Task 都有测试 + 全模块回归步骤 + 提交步骤(标注「需用户批准」)。 +- **未改但通读的章节**:proto 注册、statistic 下游分表(按天分区)、MQ asynq 消费路径(Task 4 决策后 asynq 不动)、SMS rate limit 已有范式(Task 2 复用)、gateway 中间件 auth_middleware(Task 7 未涉及,保持现状)。 +- **决策依据**: + - Task 3.3 选停用 streams:基于 `git grep EventProducer/EventConsumer` 0 业务调用方 + MVP 先行原则(`CLAUDE.md`)。 + - Task 3.7 队列名**值不变**:asynq 已在生产运行,重命名会丢未 ack 任务,本 plan 仅做"字面量 → 常量"替换。 + - Task 3.8 DeleteAccount 双步保留(gateway 黑名单 token + RPC 软删):JWT 生命周期 ≠ 用户生命周期,两层职责清晰。 + +### 跨章节引用一致性(自审发现 + 处理) +1. **Task 2 限流 IP 维度初版有 bug**(共享 "0.0.0.0" key)→ 在 Step 3 末尾补丁,empty IP 跳过 IP 维度。 +2. **Task 3 `SetSecret` 改为 internal 后**,原 `jwt_test.go` 用 20 字节 secret 会 fail → Step 2 标注同步更新测试 secret 长度。 +3. **Task 7 proto 改动影响 downstream**:`scripts/loadgen/seed/tokens.go` 用 `pb.UserSocialService` 调用既有方法,新增 `DeleteAccount` 不破坏既有 client;新生成 stub 文件 `pkg/proto/user/*.pb.go` 需随 commit 提交。 + +### Go 编译验证(必须 `go build` 才能发现) +- Task 4 stub 替换后,`compositeAdapter.EventProducer()` 返回 stub 而非 `nil` —— 必须确保 stub 实现 `adapter.EventProducer` interface(`Publish(ctx, topic, event) error`)。 +- Task 5 `DifyClient.Ping(ctx)` 是否存在:需先查 `service/dify_client.go`,若不存在先加再改 main.go。 +- Task 7 proto 重新生成后,`pb.UserSocialService` interface 必须新增 `DeleteAccount` 方法(Dubbo triple stub),否则 `go build ./...` 报 missing method。 + +### 已知限制(plan 外但需记录) +1. **3.3 streams 完整可靠性(DLQ/XAUTOCLAIM)** 留作 `docs/specs/2026-07-21-mq-v2-design.md` 路线图,本 plan 走"停用"分支。 +2. **3.7 TrackEvent buffer 满 = 丢弃**:当前接受丢失(warn log),完整方案需落本地 SQLite / Kafka,未来批次处理。 +3. **3.8 铸造双写解耦后**:gateway 重启会丢未消费 mint event;gateway 应当至少实现持久化重试队列或转交 assetService 内部处理。本 plan 保留 in-process channel + 256 cap,记录为后续 ticket。 +4. **3.8 DeleteAccount token 黑名单**:依赖 gateway 段先 blacklist,再 RPC 软删;gateway 与 userService 的"token blacklist TTL"一致性靠 Redis 自然 TTL,不引入新机制。 +5. **3.8 proto 新增 `DeleteAccount` 影响所有依赖 `pkg/proto/user` 的服务**:assetService / socialService / taskService 都 import 此包,proto 重新生成是 atomic 步骤;必须在 commit 时一并提交,避免中间态编译失败。 + +### 优先级 +- **P0**(必须在本批次完成): + - 3.1 bcrypt 移出事务(注册高峰会阻塞连接池) + - 3.2 Login 限流(暴力破解入口) + - 3.5 JWT 密钥治理(一旦泄露即全员伪造) +- **P1**(强烈建议): + - 3.7 TrackEvent 静默丢失(数据漂移) + - 3.6 aiChat Redis 吞错 / err 泄露(运维盲区) + - 3.8 网关链式调用 + DeleteAccount 直连 DB(横切关注点泄露) +- **P2**(可分批): + - 3.3 streams 停用(已是死代码,无运行时风险) + +### 自审触发时机(已完成) +- 设计方案文档后:✅ 本 plan 即设计 + 实施一体化(批次 3 范围清晰) +- 一轮"修复 N 个 bug"后:✅ 每个 Task 末尾有"全模块回归"+ commit +- 实施编码 `go build` 前:⚠️ 用户执行 Step 时由 subagent 自审,本 plan 不再单独校对 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md b/docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md new file mode 100644 index 0000000..eef1776 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md @@ -0,0 +1,263 @@ +# 每日任务配置驱动 + 后端事件驱动触发 设计方案 + +> **★ MVP 优先**:本文档是可落地的 MVP 实施方案(方案 A)。计数模型是二元模型的低成本超集,予以采纳;多对多映射(方案 B)与规则引擎(方案 C)**不在本次实现**,仅作为 §7 平滑升级路线图。 + +## 文档说明 + +- **适用范围**:`taskService` 每日任务(`task_type='daily'`)的完成/触发机制改造,及其前端联动。不含引导任务(onboarding)与收益(revenue)逻辑。 +- **工作量估算**:约 1 周(2 列 + 1 列 migration、1 个完成引擎方法、MQ 事件接入、3~4 处 emit 点、重置改一行、service 单测)。 +- **前置版本/历史**:现状为前端硬编码上报 + 后端 `def.TaskKey == eventType` 内联匹配;`daily_mint` / `daily_place_asset` 无 emit 点,属悬空任务(本方案顺带修复)。 +- **目标读者**:后端 taskService 开发、前端 App 开发、DBA。 + +--- + +## 一、方案概述(必读) + +### 要解决的问题 + +**业务问题** +- 每日任务列表会"不定时更改":大多数时候是改文案 / 奖励 / 次数 / 顺序 / 上下架(复用已有用户行为),偶尔引入全新的完成行为。 +- 诉求:每次改动**前端不做大改**。 + +**技术问题** +- 完成判定硬编码在前端(`Header.vue` 报 `daily_login`、`exhibition.vue` 报 `daily_browse_asset`),加任务就得改前端,且客户端可伪造。 +- 后端匹配是内联的 `def.TaskKey == eventType`,带 `TODO`,无法表达"次数""多事件"。 +- `daily_mint` / `daily_place_asset` 在 `task_definitions` 中 active,但全仓库无对应上报点 → 用户永远无法完成(已由实连本地库 `top-fans` 确认:这两个 task_key 在 `user_daily_task_progress` 中 0 行)。 + +### 整体实现路径 + +| 阶段 | 内容 | 估时 | +|---|---|---| +| 1. 数据模型 | `task_definitions` 加 `trigger_event`/`target_count`;`user_daily_task_progress` 加 `progress`;migration + backfill | 0.5d | +| 2. 事件目录 | 共享事件常量文件 + 治理规则 | 0.5d | +| 3. 完成引擎 | `ProcessTaskEvent` 方法,替换内联匹配 | 1.5d | +| 4. 事件接入 | MQ `task:event` 消费 + `ReportEvent` 改为生产者;铸造/上架/登录/浏览源头 emit | 2d | +| 5. 重置 | `ResetAllDailyTasks` 增加 `progress=0` | 0.25d | +| 6. 测试 | `ProcessTaskEvent` service 单测 | 1d | + +### 关键决策 + +1. **触发源 = 后端事件驱动为主,前端通用上报兜底**(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。 +2. **映射机制 = 方案 A:`task_definitions` 单列 `trigger_event` + `target_count`**(详见 §2、§4)。最低复杂度,覆盖 90% 场景。 +3. **计数模型**:`target_count=1` 等价"首次",`>1` 为计数型;MVP 不做 distinct 去重(详见 §5)。 +4. **单一隔离单元**:所有匹配逻辑封在 `ProcessTaskEvent` 一个方法内,成为 A→B 升级的唯一改动点(详见 §4、§7)。 + +### 核心架构图(TL;DR) + +``` +[后端服务: 登录/铸造成功/上架成功] ──publish──┐ + ▼ +[前端纯 UI 动作(如浏览详情)] ─reportEvent─▶ gateway ─▶ MQ 统一事件 + │ task:event { user_id, star_id, event_type } + ▼ + taskService MQ consumer ┐ + ReportEvent RPC(兜底) ├─▶ DailyTaskService.ProcessTaskEvent() + ┘ │ + ▼ + 查 active daily 定义 where trigger_event = event_type + → GetOrCreate 进度 → progress += 1 + → progress >= target_count ? status=completed + │ + (领取仍是独立步骤: ClaimDailyTask → 发水晶 → claimed) +``` + +--- + +## 二、数据模型变更 + +所有变更需写 migration(放 `backend/migrations/`),并在 `docker/init-db.sql` 同步。 + +### 2.1 `task_definitions` 新增 2 列 + +| 列 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `trigger_event` | `varchar(50)` | 可空 | 驱动该任务的事件名,取自 §3 事件目录 | +| `target_count` | `int` | `not null default 1` | 完成所需次数;`=1` 即"首次",等价现状 | + +对应 GORM model `model.TaskDefinition` 增加字段: +```go +TriggerEvent string `gorm:"column:trigger_event;size:50"` +TargetCount int `gorm:"column:target_count;default:1"` +``` + +**Backfill(存量 4 行)**:`trigger_event = task_key`,`target_count = 1`。 +```sql +UPDATE task_definitions SET trigger_event = task_key WHERE task_type = 'daily' AND trigger_event IS NULL; +UPDATE task_definitions SET target_count = 1 WHERE target_count IS NULL OR target_count = 0; +``` + +### 2.2 `user_daily_task_progress` 新增 1 列 + +| 列 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `progress` | `int` | `not null default 0` | 当前累计次数;`progress >= target_count` → completed | + +对应 model `UserDailyTaskProgress` 增加: +```go +Progress int `gorm:"column:progress;default:0"` +``` + +状态机不变:`pending → completed → claimed`,每日重置回 `pending`。 + +> **序列规范提醒**:如后续脚本手动 INSERT 指定 id,须按 CLAUDE.md 规则重置对应 `_id_seq`。 + +--- + +## 三、事件目录(单一事实来源) + +在后端建共享常量文件(建议 `backend/pkg/mq/tasks/task_events.go` 或 `services/taskService/model`),集中定义**有限、稳定**的事件枚举: + +```go +const ( + EventDailyLogin = "daily_login" // 每日首次登录 + EventDailyBrowseAsset = "daily_browse_asset" // 每日首次浏览藏品详情 + EventDailyMint = "daily_mint" // 每日首次铸造 + EventDailyPlaceAsset = "daily_place_asset" // 每日首次上架作品 +) +``` + +**治理规则** +1. 任务配置的 `trigger_event` **只能引用目录中已存在的事件**。 +2. 新增事件(B 类新行为)= 加一个常量 + 在该行为的**源头 emit 一次**;此后该事件可被任意数量的新任务复用(纯配置)。 +3. 事件命名与含义在本节表格维护,改动需同步本文档。 +4. 每个事件标注**归属路径**(后端 emit / 前端 reportEvent),遵守 §5 F4 单一路径规则: + +| 事件 | 含义 | 归属路径 | +|---|---|---| +| `daily_login` | 每日首次登录 | 后端(auth/登录服务 emit,star_id 从 JWT/token 中取) | +| `daily_browse_asset` | 每日首次浏览藏品详情 | 前端 `reportEvent`(纯 UI 动作) | +| `daily_mint` | 每日首次铸造 | 后端(铸造成功处 emit,修复悬空) | +| `daily_place_asset` | 每日首次上架作品 | 后端(上架成功处 emit,修复悬空) | + +> **F3 概念区分**:业务事件枚举(上表 `daily_login…`,供 `trigger_event` 引用)与 **MQ 传输层的任务类型**是两回事。MQ 任务类型常量 `TypeTaskEvent = "task:event"` 及其 payload struct `TaskEventPayload{UserID, StarID, EventType}` 应放在 `backend/pkg/mq/tasks/registry.go`(沿用现有 `revenue:` / `gallery:` 命名风格);业务事件枚举放 `task_events.go`。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType`。 + +--- + +## 四、完成引擎(唯一隔离单元) + +### 4.1 新方法 + +在 `DailyTaskService` 新增: +```go +// TaskEventResult 供 ReportEvent RPC 回填响应 +type TaskEventResult struct { + CompletedTaskKeys []string // 本次事件导致 completed 的任务(可能 0~N 个) +} + +ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error) +``` +**取代** `daily_task_service.go` 现有 `ReportEvent` 内那段 `def.TaskKey == eventType` 内联循环。 + +> **F1 说明**:返回结果而非仅 `error`,是因为 `ReportEvent` RPC 的 `ReportEventResponse` 需要 `TaskKey`/`TaskCompleted`/`Message`(proto 现有字段)。委托后由 `ReportEvent` 用 `TaskEventResult` 回填:`TaskCompleted = len(CompletedTaskKeys) > 0`,`TaskKey = 首个 completed`。MQ consumer 则忽略返回值只关心 `error`。 + +### 4.2 两个入口,一个引擎 + +| 入口 | 来源 | 说明 | +|---|---|---| +| MQ consumer(主) | 后端服务发布的 `task:event` | 抗刷、覆盖纯后端行为 | +| `ReportEvent` RPC(兜底) | 前端通用上报(纯 UI 动作,如浏览详情) | 改为"发同一条 MQ 事件 / 或直接调 `ProcessTaskEvent`",不再自己做匹配 | + +前端 `task-api.js` 保留**唯一**的 `reportEvent(eventType, starId)`,不随任务增减而改动。 + +### 4.3 引擎逻辑 + +输入 `{userID, starID, eventType}`: +1. 查定义:`is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)`。 +2. 逐个 `GetOrCreateDailyProgress`;若状态已是 `completed` / `claimed` → 跳过(当天幂等)。 +3. `progress += 1`;若 `progress >= def.TargetCount` → `status = "completed"`、`completed_at = now`。 +4. `UpdateDailyProgress` 保存。 + +> **隔离保证**:事件→任务的映射被封在"第 1 步查询 + 本方法"内。这是 §7 升级 B 的唯一改动点。 + +> **F5 多命中说明**:同一 `trigger_event` 可能同时命中「全局任务(star_id IS NULL)」与「该 star 专属任务」,此时**两者都会各自 +1** —— 这是预期行为(专属任务是全局任务的叠加,而非替代)。若运营需要"专属覆盖全局",属 §7 范畴,MVP 不做。 + +--- + +## 五、幂等与去重 + +- **`target_count = 1`**:天然幂等,`completed` 后跳过,无需额外处理。 +- **计数型(`target_count > 1`)**:MVP 每个合格事件 `+1`,封顶 `target_count`。语义为"做 N 次",**不做 distinct 去重**(即"浏览 3 次",而非"3 个不同藏品")。 +- **MQ 重试**:consumer `MaxRetry = 3`(沿用现有 revenue handler 模式);引擎对 `target=1` 幂等,重试安全。计数型在无去重前提下,重试可能多计——通过"仅在业务成功后 emit 一次 + 合理 MaxRetry"控制;严格 exactly-once 归入 §7。 +- **"N 个不同对象"**(distinct 去重):需要 `event_id`(如 asset_id)+ 幂等表,归入 §7 升级路径,不进 MVP。 + +> **F4 单一路径规则(强约束)**:同一个用户动作**只能经一条路径 emit** —— 要么后端 MQ 发布,要么前端 `reportEvent`,**不可两者都发**。否则计数型任务(`target_count > 1`)会重复 +1。约定:能被后端观测的动作(登录/铸造/上架)一律走后端 emit 且前端**不**再上报;纯 UI 动作(浏览详情)才走前端 `reportEvent`。事件目录(§3)需标注每个事件的归属路径。 + +--- + +## 六、每日重置 + +`DailyResetWorker` 逻辑不变(05:00 Asia/Shanghai、`pg_try_advisory_lock` 防多实例)。仅 `ResetAllDailyTasks()` 的 `Updates` map 增加一项: +```go +Updates(map[string]interface{}{ + "status": "pending", + "progress": 0, // 新增 + "completed_at": nil, + "claimed_at": nil, + "updated_at": now, +}) +``` + +--- + +## 七、平滑升级路径(写入文档,不实现) + +### 7.1 A → B:一个任务多种触发 / 每事件独立规则 + +- 新增表 `task_triggers(id, task_id, event_type, increment, dedup_key, created_at)`,支持一任务多事件、一事件多任务、每触发独立增量与去重键。 +- 引擎 §4.3 第 1 步的查询从"读 `task_definitions.trigger_event` 列"改为"读 `task_triggers` join `task_definitions`"。 +- 因匹配逻辑已隔离在 `ProcessTaskEvent` 一个方法内,**只改这一处 + 加一张表**;`trigger_event` 列降级为冗余快捷方式或废弃。 + +### 7.2 B → C:复杂规则(时间窗、distinct 计数、连续行为) + +- 在 trigger 上加 JSON `rule` 表达式列(如 `{"event":"browse","distinct_by":"asset_id","count":3,"window":"1d"}`),引擎接一个规则求值器。 +- 属 Stage 2+,业务复杂度真正上来后再评估,避免 YAGNI。 + +--- + +## 八、错误处理与测试 + +**错误处理** +- 定义缺失 / 未激活 → 静默跳过 + `logger.Info`。 +- 发奖仍在独立的 `ClaimDailyTask` / `ClaimAllDailyTasks` 步骤(不变),与完成解耦。 +- 引擎内单条任务更新失败不影响其他任务(逐个处理,记 error 日志)。 + +**测试**(沿用现有 `*_test.go` 事务回滚 / 测试容器模式) +- `ProcessTaskEvent` service 单测覆盖: + - 首次完成(`target=1`) + - 计数累加与封顶(`target=3`:1→2→3 completed,第 4 次 no-op) + - 已 completed / claimed 时再来事件 → 跳过 + - 未知 / 无匹配事件 → no-op + - per-star 定义与全局定义并存时的匹配 +- repository:`progress` 累加、`ResetAllDailyTasks` 重置 `progress=0`。 + +--- + +## 九、受影响文件清单 + +| 文件 | 改动 | +|---|---| +| `backend/migrations/2026_07_21_*_daily_task_trigger.sql` | 新增:加列 + backfill | +| `docker/init-db.sql` | 同步表结构 + 种子 `trigger_event`/`target_count` | +| `backend/services/taskService/model/task_models.go` | `TaskDefinition` + `UserDailyTaskProgress` 加字段 | +| `backend/services/taskService/service/daily_task_service.go` | 新增 `ProcessTaskEvent`,`ReportEvent` 改为委托 | +| `backend/services/taskService/repository/daily_task_repo.go` | 进度累加;`ResetAllDailyTasks` 加 `progress` | +| `backend/pkg/mq/tasks/task_events.go`(新) | 业务事件枚举常量(`daily_login…`) | +| `backend/pkg/mq/tasks/registry.go` | 加 MQ 任务类型 `TypeTaskEvent="task:event"` + `TaskEventPayload` struct | +| `backend/services/taskService/mq/consumer.go` | 注册 `task:event` handler → `ProcessTaskEvent` | +| 铸造 / 上架 / 登录服务源头 | emit `task:event`(修复 `daily_mint`/`daily_place_asset` 悬空;遵守 §5 F4 单一路径) | +| `backend/services/taskService/worker/daily_reset_worker.go` | 无需改(调 repo) | +| `frontend/utils/task-api.js` | 无需改(保留唯一 `reportEvent`) | +| `frontend/pages/tasks/daily-tasks.vue` | 默认无需改(已配置驱动) | + +### 9.1 若需前端展示进度条 "N/M"(可选,非 MVP 默认) + +计数型任务若要在前端显示 `2/3` 进度,`DailyTaskItem` proto **当前无 progress/target 字段**(实测仅 TaskKey/StarId/Name/Description/CrystalReward/Status/CanClaim),需额外: + +| 文件 | 改动 | +|---|---| +| `backend/pkg/proto/task/*.proto` | `DailyTaskItem` 加 `progress` / `target_count` 字段 | +| (重新生成) | `protoc` 重新生成 `task.pb.go` / `task.triple.go` | +| `daily_task_service.go` `GetDailyTasks` | 映射时填充 `progress` / `target_count` | +| `frontend/pages/tasks/daily-tasks.vue` | 渲染进度 `progress/target_count` | + +> **决策(已定稿)**:MVP **不展示进度条**,前端完全零改动,`progress` 仅后端内部计数用于判完成。本 §9.1 的 proto 改动作为将来需要进度展示时的参考,不在本次实现范围。