docs:新增后端bug修改文档

This commit is contained in:
zerosaturation 2026-07-21 21:14:22 +08:00
parent b7f8f1b724
commit d5103f7043
11 changed files with 6937 additions and 7 deletions

View File

@ -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 分类
---

View File

@ -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 keyDify 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-516userService 已扣水晶但本地 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` | bcryptcost≈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` 限流 TOCTOUcount 检查与 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` 不失效已签发 JWTTODO 挂起)。
- `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` 加版本 envelopeMQ 补 DLQ/重试(或删掉未用的 streams adapter二选一拆分 `UserSocialService` 为公开/内部两个契约。
---
## 六、附录:审查维度与方法
| 维度 | 主要工具 |
|------|----------|
| 微服务耦合 & DB 边界 | code-review-graphimports_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` 复用 | ⚠️ 实证 P2exhibitions 无独立 `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 重算校正等级与已发奖励。

View File

@ -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.51 天(+ 运维轮换) |
| **批次 1** | 财务正确性 | 结算幂等、mint 事务/随机数、doMint 限流、时长幂等、存量校正 | 46 天 |
| **批次 2** | 鉴权边界 | provider 统一从 attachment 取身份、social 隐私/正确性 | 34 天 |
| **批次 3** | 稳定性 | bcrypt、Login 限流、MQ DLQ、JWT 密钥、aiChat 健壮性、事件可靠性、网关聚合 | 46 天 |
| **批次 4** | 配置/部署 | 端口对齐、starbook 决断、健康探针、.env 对齐、P2 清理 | 23 天 |
| **批次 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 + 配置/部署。
- **工作量**:批次 04 合计约 1420 人天(不含架构批次 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` 里所有真实密钥替换为 `<REPLACE_ME>` 占位符。
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检查与 insertL271不在同一事务并发可绕过 10/24h 限制。把 count 检查并入同一事务,或用 Redis Lua 原子自增。
---
### 批次 2鉴权边界
> 对应审计 P0-3、P1trust 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 reviewasset/social/moderation/gallery/task列出所有读 `req.*Id` 的点改为读 attachment。
**涉及文件**:各服务 `provider/*.go``pkg/` 新增拦截器;`gateway/router/router.go:151`。
**验证**:构造伪造 `reporter_id/user_id` 的 RPC应被拦截器覆盖为真实身份。
#### 2.1 socialService 隐私/正确性
> 对应审计 §三 P1social 三条)。
- `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` 或初始化时一次性设定,运行期只读,消除 racegateway 与 userService 用同一密钥源KMS/Secret避免漂移导致全量 token 失效。
#### 3.6 aiChatService 健壮性
> 对应审计 §三 P1aiChat 三条)。
- `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 事件/统计可靠性
> 对应审计 §三 P1statistic、队列名
- `pkg/statistic/client.go:53-70``TrackEvent` fire-and-forget失败只 log → 至少加本地重试/落盘补偿,或明确接受丢失并在文档标注。
- 队列名(`"gallery"`、`"default"`)硬编码在 producer/consumer 两侧 → 抽为共享常量,消除改名漂移。
#### 3.8 网关聚合与双写
> 对应审计 §三 P1gateway
- `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.9P2 择机清理(随相关批次一起做)
> 对应审计 §四。不单独排期,改动到对应文件时顺手清。
| 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 aiChatpersonaID/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 repolaser_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('<table>_id_seq', (SELECT MAX(id) FROM <table>));`
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 |

View File

@ -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` 副本);逐个 providermoderation / 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 TriplegRPC metadata + attachmentsGORM社交 repositoryPostgreSQL本地库 `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_idstar_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=100req.Id=被另一用户 (uid=200) 创建的 report id。断言 service.GetReport 入参是 (100, id)(不再用 req.ReporterId 作 userID 判 ownerservice 层已有的 `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=200req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。
- `TestGetAssetQrcode_RejectsForgedSharer`ctx uid=100req.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=200req.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` 同样 patternOR 是单条件 `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 <last_where>`**括号优先级没问题**。但若担心(审计明确指出"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`(或脚本)伪造 RPCctx 不带 `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/65 份散落副本删除):一致
- Task 2moderation service 签名变更)→ provider 必须传 userID/reporterIDTask 2.4):一致
- Task 3share service 签名变更)→ provider 必须传 sharerUserIDTask 3.4):一致
- Task 4friend_service.CheckFriendship 签名加 starID→ provider 已传Task 4.3):一致
- Task 6删除 userService/middleware 的已迁函数)→ Task 1 的 `pkg/authctx` 替代Task 1.1):一致
- Task 9router 移动 `/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/95 个核心鉴权边界Task 7/8social 正确性审计明确列出)。
- **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 步骤需用户明确指示。

View File

@ -0,0 +1,557 @@
# 配置与部署 (批次4) Implementation Plan
REQUIRED SUB-SKILL: superpowers:writing-plans
(实施阶段使用 superpowers:executing-planscode-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` 批次 44.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 八项全部清理或明确 defer4.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.14.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 的 INSERTSQL 末尾必须 `SELECT setval('<table>_id_seq', (SELECT MAX(id) FROM <table>));`,遵循 `CLAUDE.md` 强制规则。
- **commit 守则**:每个 Task 完成后**不自动 commit**,等用户说"帮我 commit"再提交commit message 用 `Co-Authored-By: Claude <noreply@anthropic.com>`
- **真实命令**:所有 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/<svc>/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 默认端口(与 <service>/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` 已不含 starbookServicegrep `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: 序列脚本 setval4.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 16步骤、文件、接口
### 未改动但通读确认的章节
- §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。

File diff suppressed because it is too large Load Diff

View File

@ -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 <host> -U postgres -t exhibition_revenue_records -t exhibitions <db> > 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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` 已 importconsumer.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 <noreply@anthropic.com>"
```
---
## 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.3created_at 单位)→ Task 1 Step1 + Task 2 Step3死 worker 删除 → 前置已完成。累计时长幂等1.2、mint1.4-1.6)不在本 plan各自独立
- **Placeholder 扫描**:无 TBDmigration/Go 代码均给出完整内容。
- **类型一致性**`CreateRevenueRecord` 签名与现有一致;冲突分支回查返回 `*model.ExhibitionRevenueRecord`,调用方 `.ID` 可用;`settled_at` 列名与 migration 一致。

File diff suppressed because it is too large Load Diff

View File

@ -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` 真实密钥替换为 `<REPLACE_ME>` 占位符;创建 `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__` 占位符,无密钥。
- **占位符统一用 `<REPLACE_ME>`**`.env.example` 里所有真实密钥(含 `OPENAI_API_KEY=sk-...` `DIFY_API_KEY=app-...` `MINIMAX_API_KEY=sk-...` `OSS_ACCESS_KEY_ID=LTAI...`)一律替换为 `<REPLACE_ME>`。非密钥配置项(如 `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`+SecretL12 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`+SecretL25 MiniMax `sk-api-...`L47 OpenAI 微达L52-53 同 SMS keyL63 注释掉的 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 处真实密钥替换为 `<REPLACE_ME>`(详见 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:<行号>:... <path>`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 <noreply@anthropic.com>"
> ```
---
## Task 2: `backend/.env.example` 真实密钥替换为占位符
**Files:**
- Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example`
**Interfaces:**
- 替换前8 行含真实密钥。
- 替换后:所有真值 = `<REPLACE_ME>`非密钥配置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=<REPLACE_ME>
```
注释行 `# 必填: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=<REPLACE_ME>
# 微达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=<REPLACE_ME>
```
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: 仅命中占位符 `<REPLACE_ME>` 所在行(如有),且模式后面跟的不是真值。**任何前缀匹配 `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:', '<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-...``=<REPLACE_ME>` 的变更,**无** endpoint/model 变更。
> **Commit 命令(用户批准后)**
> ```bash
> git commit -m "chore(env): replace leaked secrets in .env.example with placeholders (batch 0)
>
> - OPENAI_API_KEY (2 处, 含微达中转站) → <REPLACE_ME>
> - DIFY_API_KEY → <REPLACE_ME>
> - 保留 endpoint/model/region/bucket 等非凭证配置
>
> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
> ```
---
## 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=1grep 无匹配返回 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确认占位符能被正确解析为字符串 `<REPLACE_ME>`
```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 = "<empty>" }
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=<REPLACE_ME>``DIFY_API_KEY=<REPLACE_ME>` 出现;`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 # 仅占位模板,所有密钥为 <REPLACE_ME>
- 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包含所有 <REPLACE_ME> 对应的真值
# 示例: 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 <noreply@anthropic.com>"
> ```
---
## 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='<REPLACE_ME>'
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 "❌ 检测到可能的密钥泄露,请检查上方命中并替换为 <REPLACE_ME> 占位符。" >&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 <noreply@anthropic.com>"
> ```
---
## 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-18 个 `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 <noreply@anthropic.com>"
> ```
---
## 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运维 SOPAI 仅核查)= **运维主导**,文档化。
- 批次 0 §「git 历史清理」= Task 8运维执行AI 仅核查)= **运维主导**,文档化。
- 部署消费方迁移 = Task 4AI 做骨架)= **完整覆盖**
- 防止再次泄漏 = Task 5`detect-secrets.sh`+ Task 6SOP 文档)= **预防措施**
- **Placeholder 扫描**
- 无 TBD`.env.example` 占位符统一 `<REPLACE_ME>`
- 脚本、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` 不全,需补全。

File diff suppressed because it is too large Load Diff

View File

@ -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/登录服务 emitstar_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 改动作为将来需要进度展示时的参考,不在本次实现范围。