docs:新增后端bug修改文档
This commit is contained in:
parent
b7f8f1b724
commit
d5103f7043
28
CLAUDE.md
28
CLAUDE.md
@ -233,9 +233,13 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
#### 正确做法(全局自审,强制执行)
|
||||
|
||||
每次自审必须**从文档第一节读到最后一节**,按以下步骤:
|
||||
**核心原则:自审 = 拿"源清单"逐条打勾映射到"产物" + 从头通读全文,两者缺一不可。**
|
||||
"源清单"是本次工作的输入全集:设计文档的所有章节、**审计报告的每一条 finding**、需求的每一项、被修 bug 的清单。"产物"是你写出的东西。漏项几乎总发生在"没有拿源清单逐条对照"时——只凭印象挑重点写,长尾必掉。
|
||||
|
||||
1. **章节通读清单**:先列出文档所有章节(§1 到 §16),逐一阅读
|
||||
每次自审必须执行:
|
||||
|
||||
0. **源清单逐条打勾(最容易漏,必须先做)**:列出输入全集(§1..§N / 每条 finding / 每项需求),对**每一条**标注它在产物里落到哪里(哪个章节 / 批次 / 代码);标不出来的就是漏项。禁止"按最重的挑着写"。适用于任何"A 文档 → B 文档"的转化(审计→修复、需求→实现、PRD→设计)。
|
||||
1. **章节通读清单**:从文档第一节读到最后一节,逐一阅读(不跳过"我没动过"的章节)
|
||||
2. **跨章节引用一致性检查**:
|
||||
- §1.x 描述的事实 → 在 §5.x 实现了吗?
|
||||
- §3 Dify 配置 → §5 后端是否一致?
|
||||
@ -256,19 +260,29 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
**根因**:我之前的"自审"本质上是"局部自审",只看我改的部分。
|
||||
|
||||
#### 失败案例(2026-07-21 审计→修复文档,漏写近半 P1 + 全部 P2)
|
||||
|
||||
- 把审计报告转成修复方案文档时,按"最重的先写"挑着写,**没有拿审计 §三 P1 表 / §四 P2 逐条打勾**
|
||||
- 结果:JWT 全局密钥、aiChat 三条、social 三条、mint 随机数、网关双写/链式、事件可靠性、.env 漂移等**近半 P1 与全部 P2 漏进任何批次**
|
||||
- 是用户追问"为啥感觉少写的"才发现——典型的"只看自己写的产物,不通读源清单逐条对照"
|
||||
|
||||
**根因**:自审时没有把"源清单(审计每条 finding)"逐条打勾映射到产物(修复批次)——即上面「正确做法」步骤 0 未执行。
|
||||
|
||||
#### 自审触发时机
|
||||
|
||||
- 写完设计方案文档后
|
||||
- 完成一轮"修复 X 个 bug"后
|
||||
- **把一份文档转成另一份文档后**(审计→修复方案、需求→设计、PRD→任务拆解)——最容易漏源清单
|
||||
- 实施编码 `go build` 前(最后一次文档校对)
|
||||
|
||||
#### 自审报告必须包含
|
||||
|
||||
1. **修改的章节**:列出本次修了哪些章节
|
||||
2. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区)
|
||||
3. **跨章节引用一致性**:列出所有发现的不一致
|
||||
4. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题
|
||||
5. **优先级**:P0/P1/P2 分类
|
||||
1. **源清单覆盖率**:源清单共 N 条(章节/finding/需求),产物覆盖了几条、漏了哪几条(列出未映射项,即使结论是"本次不做"也要显式标注去向)
|
||||
2. **修改的章节**:列出本次修了哪些章节
|
||||
3. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区)
|
||||
4. **跨章节引用一致性**:列出所有发现的不一致
|
||||
5. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题
|
||||
6. **优先级**:P0/P1/P2 分类
|
||||
|
||||
---
|
||||
|
||||
|
||||
277
docs/backend-audit-2026-07-21.md
Normal file
277
docs/backend-audit-2026-07-21.md
Normal 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 key;Dify key `app-...` |
|
||||
| `docker/.env.prod:18-19` | 第二套 OSS key |
|
||||
| `docker/.env.local:21-22` | 同一套 OSS key |
|
||||
| `backend/deploy/envs/notification.env:22` | 推送厂商 URL(注释明确要求"不要提交") |
|
||||
|
||||
`backend/deploy/envs/*.env` 未被 `.gitignore` 覆盖(`git check-ignore` 返回 exit 1 = 未忽略)。
|
||||
|
||||
**动作**:立即轮换所有泄露的 OSS/SMS/OpenAI/Dify 密钥;清理 git 历史(`git filter-repo`);将 `deploy/envs/*.env` 加入 `.gitignore`,密钥改由 KMS / 部署时注入。
|
||||
|
||||
### P0-2 铸造扣费:DB 事务内嵌套跨服务 gRPC
|
||||
|
||||
`backend/services/assetService/service/mint_service.go:309-318` —— `s.db.Transaction(...)` 内部调用 `s.userClient.UpdateCrystalBalance(...)`。
|
||||
|
||||
- **连接池耗尽**:RPC 期间 DB 连接被 pin 住;userService 变慢会把整个 asset 连接池拖垮。
|
||||
- **跨服务无原子性 + 非幂等**(`CreateMintOrder`,同文件 209-516):userService 已扣水晶但本地 commit 失败 → 用户被扣费却无藏品;客户端超时重试时水晶已扣光却报错。缺少 outbox / 幂等 token。
|
||||
|
||||
**动作**:把 RPC 移出事务;引入幂等键(如 `order_id` 唯一约束 + 状态机幂等判断);跨服务用 saga / outbox 保证最终一致。
|
||||
|
||||
### P0-3 信任请求体里的身份 → 越权 / 刷量
|
||||
|
||||
| 文件:行 | 问题 |
|
||||
|---------|------|
|
||||
| `backend/services/moderationService/provider/moderation_provider.go:35` | `SubmitReport` 直接透传 `req`,`ReporterId` 由调用方控制 → 冒用他人 id 举报、绕过去重刷量、读取他人举报详情 |
|
||||
| `backend/services/assetService/provider/asset_provider.go:483` | `CheckAssetLike` 注释"直接使用请求中的用户信息",用 `req.UserId` → 点赞隐私预言机 |
|
||||
| `backend/services/assetService/service/share_service.go:135-243` | `GetAssetQrcode` 用 `req.SharerUserId` 不校验调用方 → 分享归因伪造 + 无限 OSS 写入 |
|
||||
|
||||
**根因**:5 个服务里 4 个信任 Dubbo attachment 里的 `user_id` 而不再校验(`castlove_config_provider.go:35` 明写"本服务信任 ctx 透传的 user_id"),只有 userService 真正验签,防御纵深断裂。
|
||||
|
||||
**动作**:所有 provider 统一从 attachment 提取身份并覆盖 `req` 内的身份字段;对内部 RPC 建立可信边界(mTLS / 内部网络 + 签名)。
|
||||
|
||||
### P0-4 服务间直接 import 对方 Go 包(已验证)
|
||||
|
||||
| 文件:行 | 耦合 |
|
||||
|---------|------|
|
||||
| `backend/services/taskService/main.go:27-28` | import `assetService/repository` + `service` |
|
||||
| `backend/services/assetService/main.go:32` | import `starbookService/repository` |
|
||||
| `backend/services/starbookService/main.go:20` | 反向 import `assetService/repository`(循环依赖) |
|
||||
| `backend/gateway/router/router.go:19-20` | import `assetService/repository` + `service` |
|
||||
| `backend/gateway/controller/asset_controller.go:40` | import `assetService/service` |
|
||||
| `backend/gateway/controller/moderation_controller.go:11` | import `moderationService/service` |
|
||||
|
||||
**后果**:RPC 名存实亡——改一个服务的内部包,其它服务/网关必须一起重编重部署;一处 panic 连带拖垮调用方。
|
||||
|
||||
**动作**:跨边界只能走 proto/RPC + DTO;共享的错误码、常量下沉到 `pkg/` 或 proto,禁止 import 兄弟服务的 `repository`/`service`。
|
||||
|
||||
### P0-5 全部服务共用同一 Postgres + 同一套 migration + 同一 `pkg/models`
|
||||
|
||||
- 10 个服务 `dbName="top-fans"/"topfans"`,全用 `pkg/database` 的全局 `*gorm.DB`,**无数据所有权边界**。
|
||||
- 跨域直接读写:
|
||||
- `backend/services/moderationService/repository/moderation_repository.go:268-302` 直接改 `users`/`assets`
|
||||
- `backend/services/socialService/repository/social_repository.go:382,476,492,521` 读写 `fan_profiles`
|
||||
- `backend/services/assetService/repository/share_repository.go:43`、`ranking_repository.go:103+` join `users`/`fan_profiles`
|
||||
- `backend/services/galleryService/repository/gallery_repository.go:609,621` join `fan_profiles`
|
||||
- `users`/`fan_profiles` 被 5+ 服务读写;`asset_registry.display_status` 被 gallery 和 asset 两个服务并发写(`gallery_repository.go:395,426,450` vs `asset_repository.go:127,145,160`)。
|
||||
- 单一 `backend/migrations/`(22 个文件)承载所有域;`pkg/models`(22 个 model)被 7 个服务 import → 改一个字段要重编 7 个服务。
|
||||
|
||||
**动作(中长期)**:按域拆库或至少拆 schema + 明确表 owner;跨服务读改为 RPC + service-local DTO;加 lint 禁止在非 owner 服务里对 `models.X{}` 做 GORM 操作。
|
||||
|
||||
### P0-6 服务地址端口四层不一致
|
||||
|
||||
`backend/gateway/config/config.go:162-165` 的默认值与各服务实际绑定端口不符:
|
||||
|
||||
| 服务 | 网关默认 | 实际绑定 |
|
||||
|------|----------|----------|
|
||||
| gallery | 20004 | **20001**(`galleryService/main.go:36`) |
|
||||
| activity | 20005 | 20004 |
|
||||
| starbook | 20007 | 20005 |
|
||||
|
||||
另:`galleryService/main.go:44` taskService 默认写成 20002(实为 20006);`userService/main.go:283` 硬编码 `WithPort(20000)` 使 `-port`/`PORT` 成为死代码。生产靠 env 覆盖侥幸能连,本地跑默认值会连错服务。
|
||||
|
||||
**动作**:统一端口默认值来源;加一个配置测试断言"网关 Dubbo URL 默认端口 == 对应服务绑定端口"。
|
||||
|
||||
### P0-7 `starbookService` 孤儿代码却仍在部署链路
|
||||
|
||||
- 无 `go.mod`、不在 `go.work`(第 1-16 行)。
|
||||
- 却仍被引用:`assetService` import(见 P0-4)、`docker-compose.local.yml:231-262` / `docker-compose.prod.yml:365-401` / helm `starbookservice/deployment.yaml` / `dev.sh:24,432,438` / 网关 `config.go:165`。
|
||||
- `docker/Dockerfile.services:50` 会 `go build` 它 → 干净环境 CI/helm 构建会失败。
|
||||
- 目录里躺着 79MB 已编译二进制;`asset_registry` 表无明确 owner。
|
||||
|
||||
**动作**:二选一——补 `go.mod` 并加入 `go.work` 正式化,或彻底删除目录 + 所有部署引用 + 把其代码并入 assetService。
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 — 尽快处理
|
||||
|
||||
### 代码正确性 / 安全
|
||||
|
||||
| 文件:行 | 问题 |
|
||||
|---------|------|
|
||||
| `backend/services/userService/service/auth_service.go:161` | bcrypt(cost≈100ms)在事务内执行,`:566` 改密同样 → 注册高峰连接池耗尽 |
|
||||
| `backend/services/userService/service/auth_service.go:300 vs 315` | Login 用户枚举("用户不存在"与"密码错"错误码不同),且 Login 无限流 |
|
||||
| `backend/services/aiChatService/provider/ai_chat_provider.go:250` | 保存上下文用请求里的 `personaID` 而非解析后的 `persona.ID`,persona 关联错乱 |
|
||||
| `backend/services/aiChatService/provider/ai_chat_provider.go:138,141` | Redis 出错静默吞掉(记忆/历史丢失无日志) |
|
||||
| `backend/services/aiChatService/main.go:190-211` | Dify/LLM provider 启动时二选一,无 fallback / 熔断 / 重试;Dify 挂了即硬故障,且 `provider:188` 原样回传 `err.Error()` |
|
||||
| `backend/services/assetService/service/asset_like_service.go:267` | `material_type` 用 `CONCAT` 无限追加逗号,永不收敛 |
|
||||
| `backend/services/socialService/service/friend_service.go:685` | `CheckFriendship` 硬编码 `starID=0`(TODO 未做)→ 结果错误 + 好友关系隐私预言机 |
|
||||
| `backend/services/socialService/repository/social_repository.go:461` | "随机用户"实为 `OFFSET rand + LIMIT` 取连续段,非随机且可预测 |
|
||||
| `backend/services/assetService/service/mint_service.go:323` | 保底概率用 `time.Now().UnixNano()%100`,并发同纳秒结果相同,可被脚本操纵 |
|
||||
| `backend/services/assetService/service/mint_service.go:349` | mockTxHash 输入全可观测,可被预测伪造"已上链" |
|
||||
| `backend/services/assetService/service/peripheral_service.go:217-319` | `doMint` 限流 TOCTOU:count 检查与 insert 不在同一事务,并发可绕过 |
|
||||
| `backend/services/socialService/repository/social_repository.go:648,672` | OR 子句括号优先级问题,可能让已删除资产漏进点赞列表(复核:`:903,923` 为 AND 拼接,无此问题) |
|
||||
| `backend/services/galleryService/service/cleanup_worker.go:137-185` | **【财务·已实证资损】展出收益结算非幂等 → 重复发放**(详见 §七.1) |
|
||||
|
||||
### 微服务耦合 / 边界
|
||||
|
||||
| 文件:行 | 问题 |
|
||||
|---------|------|
|
||||
| `backend/proto/user.proto:393-553` | `UserSocialService` 把公开 user API 与内部 RPC(`UpdateCrystalBalance`/`UpdateAssetsCount`/`AddExhibitionHours`)混在一个契约 |
|
||||
| `backend/proto/social.proto:367-513` | social 把好友 + 资产点赞 + 用户发现混在一起 |
|
||||
| (对照正确范式)`backend/proto/task.proto:276-327` | `TaskMobileService`(公开)与 `TaskInternalService`(内部)干净拆分 |
|
||||
| `backend/gateway/controller/user_controller.go:647-748` | 网关 `DeleteAccount` 自开事务直接改 `users`+`fan_profiles`,绕过 userService |
|
||||
| `backend/gateway/repository/laser_card_repository.go` / `app_download_repository.go` | 网关持有 DB repo,非纯 BFF |
|
||||
| `backend/gateway/controller/asset_controller.go:414-436` | 铸造后本地写激光卡与 assetService 无事务串联 → 双写不一致,实例状态卡在 `mining` |
|
||||
| `backend/gateway/controller/auth_controller.go:69-97` 等 6 处 | Register/Login/Me/Profile 链式调 `GetFanIdentities`(每次拉全量明星目录只为查一个),下游失败但用户已创建,无补偿 |
|
||||
|
||||
### 消息 / JWT / 共享库
|
||||
|
||||
| 文件:行 | 问题 |
|
||||
|---------|------|
|
||||
| `backend/pkg/jwt/jwt.go:18` | 包级可变全局 `jwtSecret`,默认弱值 `"your-secret-key-change-in-production"`,无锁无校验;`SetSecret` 与 `ParseToken` 并发 data race |
|
||||
| `backend/pkg/mq/streams/adapter.go:177` | consumer 名带 `UnixNano`,重启后新消费者不认领旧 PEL → 消费者重启即永久丢消息 |
|
||||
| `backend/pkg/mq/streams/adapter.go:212-234` | 失败事件留在 PEL,无 `XAUTOCLAIM` / DLQ / 重试 |
|
||||
| `backend/pkg/statistic/client.go:53-70` | `TrackEvent` fire-and-forget,失败只 log 不重试 → 统计数据静默漂移 |
|
||||
| `backend/proto/event.proto:12-20` | 事件无 envelope / version 字段,被 7 服务 import,字段改动即破坏性 |
|
||||
| producer/consumer 多处 | 队列名(`"gallery"`、`"default"`)硬编码在生产者和消费者两侧,改名易漂移丢消息 |
|
||||
|
||||
### 配置 / 部署
|
||||
|
||||
| 文件:行 | 问题 |
|
||||
|---------|------|
|
||||
| `backend/scripts/create_gallery_test_users.go` | 硬编码 id 但未 `setval` 重置序列,违反 CLAUDE.md 强制规则 → 跑完必报 `duplicate key` |
|
||||
| `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` | 探针用 `/healthz` 但服务注册 `/health` → 存活探针永远失败 |
|
||||
| `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50` | 探针打 `/`,K8s 缺 compose 的 `HEALTHCHECK NONE` 覆盖 |
|
||||
| 顶层大二进制入库 | `backend/assetService`(80MB) / `gateway-fixed`(98MB) / `test`(71MB) / `cleanup-orphan-avatars`(22MB) 均被 git 追踪 |
|
||||
| `backend/.env` ↔ `.env.example` 漂移 | `WS_AI_CHAT_PATH` 未文档化;`.example` 有一批 `.env` 缺失的必需键 |
|
||||
|
||||
---
|
||||
|
||||
## 四、P2 — 择机清理
|
||||
|
||||
- `backend/gateway/controller/asset_controller.go:160-194` `parseRPCError` 靠字符串解析错误码,应用 `status.FromError`。
|
||||
- 多处 `c.JSON(500, gin.H{"message": err.Error()})` 原样返回内部错误(`auth_controller.go:222`、`user_controller.go:139` 等)。
|
||||
- `backend/services/userService/service/user_service.go:681` `ResetPassword` 不失效已签发 JWT(TODO 挂起)。
|
||||
- `backend/services/userService/service/user_service.go:1023` `UpdateAvatar` 不校验头像 URL 同源。
|
||||
- PII(手机号 / 聊天内容 / JWT)打进 INFO 日志(`ai_chat_provider.go:107`、`mint_service.go` / `user_service.go` 多处)。
|
||||
- `backend/pkg/mq/` 大量未接线死代码:`EventProducer`/`EventConsumer` 接口、`Stream*` 常量 0 引用;`asynq/adapter.go:111` `GetInfo`/`Delete` 是假实现。
|
||||
- `backend/services/galleryService/mq/consumer.go:207` `is_processed` 列被复用为 `settled`(三处写入语义冲突,可能导致结算漏单)。
|
||||
- `backend/services/activityService/service/activity_service.go:217,1579` 直接用 `redisClient.Publish`(Redis Pub/Sub),绕过 broker 抽象。
|
||||
- `backend/pkg/peripheral/sign.go:33-44` 周边 HMAC 密钥优先级为 `SECRET_KEY > JWT_SECRET > 硬编码开发默认值`。正常配置下(`backend/.env:75`、`docker/.env.prod:66` 均有 `SECRET_KEY`)取 `SECRET_KEY`,与 JWT 无耦合。**残留隐患**:`backend/deploy/envs/asset.env` + `common.env`(systemd 部署路径)既无 `SECRET_KEY` 也无 `JWT_SECRET`,若走该路径会回落到硬编码 `"default-dev-secret-change-me"`(可预测密钥)。建议:去掉 `JWT_SECRET` 兜底与 dev 默认值,`SECRET_KEY` 缺失时直接 fail-fast。
|
||||
> 更正说明:初版报告曾将此列为 P1「轮换 JWT 会连带作废周边验证码」,经复核 `SECRET_KEY` 为首选且生产已配置,该结论不成立,已降级并修正。
|
||||
|
||||
---
|
||||
|
||||
## 五、建议修复顺序
|
||||
|
||||
1. **轮换全部泄露密钥 + 清 git 历史 + 补 `.gitignore`**(P0-1)
|
||||
2. **mint 事务拆掉内嵌 RPC + 加幂等 token**(P0-2)
|
||||
3. **所有 provider 从 attachment 取身份,禁用 `req` 里的 `user_id`/`reporter_id`**(P0-3)
|
||||
4. **决断 `starbookService`:正式化或彻底删除**(P0-7)
|
||||
5. **端口默认值对齐 + 加配置断言测试**(P0-6)
|
||||
6. bcrypt/序列/personaID/CheckFriendship 等 P1 正确性 bug
|
||||
7. 中长期:拆库或用 DTO 替换 `pkg/models` 跨服务共享;`proto/event.proto` 加版本 envelope;MQ 补 DLQ/重试(或删掉未用的 streams adapter,二选一);拆分 `UserSocialService` 为公开/内部两个契约。
|
||||
|
||||
---
|
||||
|
||||
## 六、附录:审查维度与方法
|
||||
|
||||
| 维度 | 主要工具 |
|
||||
|------|----------|
|
||||
| 微服务耦合 & DB 边界 | code-review-graph(imports_of/importers_of)+ grep 验证 |
|
||||
| 网关边界 & proto 契约 | 通读 controller/service/router + proto |
|
||||
| 配置 & 部署 | `git ls-files` / `git check-ignore` + env/compose/helm 对比 |
|
||||
| 共享 pkg & 消息 | importers_of 爆炸半径 + mq adapter 通读 |
|
||||
| 各服务正确性 & 安全 | service/repository/provider 热点通读 |
|
||||
|
||||
> 本报告仅识别问题,不含完整修复方案。针对任意一条可另起工程化设计文档(遵循 `CLAUDE.md` 的 MVP 先行与文档结构规范)。
|
||||
|
||||
---
|
||||
|
||||
## 七、数据库实测复核(2026-07-21,本地 `top-fans` 全新库)
|
||||
|
||||
对照实际数据库(`localhost:15432` / 容器 `postgresql-database-1` / 库 `top-fans`)复核关键 finding,结论如下:
|
||||
|
||||
| 检查项 | 结果 | 对应 finding |
|
||||
|--------|------|--------------|
|
||||
| 单库结构 | ✅ 实证 P0-5:单库单实例,`public` schema 一把装 **89 张表**(users/assets/fan_profiles/asset_registry/moderation_*/reports/activities/exhibitions/ai_*/peripheral_*/tasks 全在一起) | P0-5 |
|
||||
| 跨域外键 | ⚠️ 实证 P0-5 DB 层耦合:`public` 有 **49 条 FK** 跨域绑定(`fan_profiles→users`、`assets→users/stars`、`mint_orders→users/assets/stars`、`asset_likes→assets/users/stars`、`booth_slots→fan_profiles`…),拆库前必须全部拆除 | P0-5 |
|
||||
| 序列健康 | ✅ 当前**无** `last_value < max(id)` 的序列;`assets_id_seq=88100008=max(id)`、`asset_registry_id_seq=308=max(id)`。序列隐患为**潜在**(仅跑缺 `setval` 的脚本才触发),现网未坏 | P1(`create_gallery_test_users.go`) |
|
||||
| `material_type` 增长 | ⚠️ 234 行资产中 134 行含逗号,但**最多 3 个值 `hot,new,potential`(comma_count=2),未观测到失控增长**。代码 `CONCAT` 追加 bug 存在但尚未爆 | P1(`asset_like_service.go:267`) |
|
||||
| `is_processed` 复用 | ⚠️ 实证 P2:exhibitions 无独立 `settled` 列;45845 行中 `is_processed=false` 有 **40847** 行,无法区分"未清理"还是"未结算收益"——语义冲突有真实数据佐证 | P2(`consumer.go:207`) |
|
||||
| statistic schema | ℹ️ **更正**:statistic 事件隔离在独立 `statistic` schema(按天分区 `events_YYYY_MM_DD` + metric 视图),并非"全落在 public"。statisticService 有部分 schema 隔离 | 更正子审计说法 |
|
||||
|
||||
> 说明:"全新库"含实测数据(users 94 / fan_profiles 96 / assets 234 / asset_registry 239 / mint_orders 281 / exhibitions 45845 / reports 4 / stars 6),非空库。exhibitions 4.5 万行相对 234 资产偏高,结合 `is_processed` 语义冲突,建议排查是否存在结算/清理积压。
|
||||
|
||||
### 七.1 【重大发现·P1 财务】展出收益结算非幂等,已实测超发并被领取
|
||||
|
||||
对 exhibitions 的 `is_processed` 深挖时,从数据里挖出一个已实现资损的 bug。
|
||||
|
||||
**数据证据(当前 `top-fans` 库)**:
|
||||
- `exhibition_revenue_records` 共 **51742 条**,但只对应 **46241 个不同 `exhibition_id`** → **4053 个展品被重复结算**(最多 3 次)。
|
||||
- 多余重复记录 **5501 条,超发水晶累计 2,525,254**。
|
||||
- 这些重复记录 **`status` 全部为 `claimed`(9554 条已领取)** → 超发水晶已被用户领走,是**已实现资损**(本数据集内)。
|
||||
- `exhibition_revenue_records` 表**无 `exhibition_id` 唯一约束**,无任何幂等键阻止重复写入。
|
||||
|
||||
**根因**(`backend/services/galleryService/service/cleanup_worker.go` + `taskService/service/revenue_service.go`):
|
||||
1. `exhibition_revenue_records` 表**无 `(exhibition_id, cycle_start_time)` 唯一约束**,`CreateRevenueRecord`(`revenue_service.go:476`)无条件插入。
|
||||
2. **多个结算入口对同一展品-周期重复触发**:cleanup_worker 的 ZSET 路径(`cleanup_worker.go:137`)、DB 兜底路径(`cleanup_worker.go:239`)、MQ 消费路径三者可对同一 exhibition 重复调用 `OnExhibitionCompleted`。
|
||||
3. 数据实证:4053 个重复展品中 **3850 个是"同周期重复"**(同一 `cycle_start_time` 被结算 ≥2 次),确认是同一周期的重复结算而非合法的多周期结算。
|
||||
|
||||
> **更正(较早版本的错误归因)**:本节初稿曾把重复归因为"`like_bet_revenue_records` 仅 50 条 → 点赞押注失败 → `is_processed` 永不置 true → worker 重跑"。经数据核实此链条**不成立**:`live+expired+unprocessed` 积压=0(活跃展品都已正确标记 processed);`like_bet` 仅 50 条是因为**测试数据点赞极稀疏**(45952 展品仅 164 个有赞、仅 9 个有 ≥2 赞),而点赞押注模型只奖励"后续还有点赞"的押注者,故绝大多数展品合法产出 0 条记录、`RecordLikeBetRevenue` 返回 OK 而非报错。重复的真正根因是上述"多路径 + 无唯一约束"。
|
||||
|
||||
**修复方向**:
|
||||
- `exhibition_revenue_records` 加 `UNIQUE(exhibition_id, cycle_start_time)` 幂等约束,写入用 `INSERT ... ON CONFLICT DO NOTHING`;
|
||||
- 收敛结算入口到**单一**带幂等的路径(保留 MQ 消费路径,删除 cleanup_worker 的 ZSET/DB 双直连 RPC,或让三者共用同一 `isAlreadyProcessed` 判重);
|
||||
- 用独立 `settled_at` 审计列替代复用 `is_processed`(原 §四 P2 项);
|
||||
- 存量:按 `(exhibition_id, cycle_start_time)` 去重,回收已发放的重复水晶(本库超发 2,525,254 且已 claimed,需评估回滚口径)。
|
||||
- **附带发现**:`exhibition_revenue_records.created_at` 单位混乱——51849 条中 6013 条是**秒**(10 位)、45836 条是**毫秒**(13 位)。同列混用两种时间单位,任何按 `created_at` 排序/区间过滤/展示的逻辑都会错乱。应统一为毫秒并回填历史数据。
|
||||
|
||||
> 关于 `like_bet_revenue_records` 仅 50 条:非 bug。测试数据点赞极稀疏(45952 展品仅 164 有赞、仅 9 有 ≥2 赞),点赞押注模型只对"后续仍有点赞"的押注者发放,故绝大多数展品合法产出 0 条。
|
||||
|
||||
> 备注:worker 查询带 `deleted_at IS NULL`,故 40740 行"已软删+未处理"不会被再次结算(当前 `live+expired+unprocessed` 积压=0);重复来自展品**在软删之前**被 worker 多轮处理。
|
||||
|
||||
### 七.2 展品累计时长(`user_exhibition_hours` / 资产 `season_exhibition_hours`)
|
||||
|
||||
**数据层面无明显错误**:`user_exhibition_hours` 1075 行,无负值、无溢出(max 1727h≈72 天)、无重复 `(user_id,star_id)` 行,唯一约束 `uk_exhibition_user_star` 健康。
|
||||
|
||||
**但累计逻辑存在幂等缺口,且数据来源(结算流)已含重复(见 §七.1),故累计值不可信**:
|
||||
1. `backend/services/userService/repository/fan_profile_repository.go:535-558`:`AddExhibitionHours` 收 `sourceID` 参数却**未用于幂等判断**,无条件 `total_exhibition_hours += hours`。
|
||||
2. `backend/services/taskService/service/revenue_service.go:513`:资产级 `assetLevelService.AddExhibitionHours(assetId, hours)` **完全不传 sourceID**。
|
||||
3. `revenue_service.go:436`:`actualHours=(expireAt-startTime)/3600000` 基于展品固定时间戳 → 同一展品重复结算每次加**相同**小时数(非 0)。
|
||||
4. **两条结算路径并存**:`galleryService/service/cleanup_worker.go:137,239` 仍调用已标注"本期内废弃"的直连 RPC `OnExhibitionCompleted`(收益+时长均无去重);`galleryService/mq/consumer.go:133` 走带 `isAlreadyProcessed` 去重的 MQ 路径。仅 MQ 路径有幂等,直连路径没有。
|
||||
5. 已证 5501 次重复结算 → 经直连路径的部分会把累计时长多加。
|
||||
|
||||
**后果**:累计时长驱动等级 `CalculateLevelFromExhibitionHours` → 时长虚高触发**提前升级 + 多发升级奖励水晶**,与 §七.1 收益重复同根因(结算非幂等 + 双路径并存)。
|
||||
|
||||
**修复方向**:统一到单一带幂等的结算路径(删除 cleanup_worker 的直连 RPC 调用,或给直连路径补 `isAlreadyProcessed`);`AddExhibitionHours` 真正实现 sourceID 幂等(加 `exhibition_hours_log(source_id UNIQUE)` 或复用 `crystal_transaction_records` 的 source_id 判重);资产级累加补 sourceID;存量数据需按 distinct exhibition 重算校正等级与已发奖励。
|
||||
391
docs/specs/2026-07-21-backend-remediation-plan.md
Normal file
391
docs/specs/2026-07-21-backend-remediation-plan.md
Normal 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.5–1 天(+ 运维轮换) |
|
||||
| **批次 1** | 财务正确性 | 结算幂等、mint 事务/随机数、doMint 限流、时长幂等、存量校正 | 4–6 天 |
|
||||
| **批次 2** | 鉴权边界 | provider 统一从 attachment 取身份、social 隐私/正确性 | 3–4 天 |
|
||||
| **批次 3** | 稳定性 | bcrypt、Login 限流、MQ DLQ、JWT 密钥、aiChat 健壮性、事件可靠性、网关聚合 | 4–6 天 |
|
||||
| **批次 4** | 配置/部署 | 端口对齐、starbook 决断、健康探针、.env 对齐、P2 清理 | 2–3 天 |
|
||||
| **批次 5** | 架构治理(路线图) | 去分布式单体(**不在本次落地**) | 独立项目,数周 |
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **不在本次拆库/拆 `pkg/models`**(MVP 先行原则)。当前业务规模下,拆库是数周级独立项目,收益/风险比不划算。本次只做**加固**:加 lint 禁止新增跨服务 import、加唯一约束堵幂等漏洞、明确表 owner 注释。架构级拆分列入批次 5 路线图。参见 [§批次5](#批次-5架构治理路线图仅规划)。
|
||||
2. **财务批次优先于边界批次**:已实证资损(超发水晶已被领取)>理论越权,先止血。
|
||||
3. **存量数据修复走 dry-run + 备份 + 序列同步**,遵循 `CLAUDE.md` PostgreSQL 序列规范。参见 [§存量数据修复](#四存量数据修复脚本规范)。
|
||||
|
||||
### 核心修复优先级图(TL;DR)
|
||||
|
||||
```
|
||||
先做 ──────────────────────────────────────────────────────→ 后做
|
||||
批次0 批次1 批次2 批次3 批次4 批次5
|
||||
安全紧急 财务正确性 鉴权边界 稳定性 配置/部署 架构治理(路线图)
|
||||
密钥轮换 结算幂等/mint/时长 身份透传 bcrypt/MQ/JWT 端口/starbook 去分布式单体
|
||||
(P0) /doMint (P0+P1) /social(P0+P1) /aiChat/网关(P1) /探针/.env/P2 (本次不落地)
|
||||
```
|
||||
|
||||
### 文档说明
|
||||
|
||||
- **适用范围**:backend 全部服务 + gateway + pkg + 配置/部署。
|
||||
- **工作量**:批次 0–4 合计约 14–20 人天(不含架构批次 5)。
|
||||
- **前置**:审计报告 `docs/backend-audit-2026-07-21.md`;基线 commit `b7f8f1b724e5`。
|
||||
- **目标读者**:后端负责人、运维、DBA。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复批次详解
|
||||
|
||||
### 批次 0:安全紧急(密钥)
|
||||
|
||||
> 对应审计 P0-1。**先做,且需运维配合轮换**。
|
||||
|
||||
**根因**:`backend/deploy/envs/*.env`、`backend/.env.example`、`docker/.env.local`、`docker/.env.prod` 含真实密钥且被 git 追踪。
|
||||
|
||||
**修复步骤**:
|
||||
1. **立即轮换**(运维):阿里云 OSS AccessKey(`LTAI5t6QcdJHpYbCPxM8SXYE` 及第二套)、复用的 SMS key、OpenAI(`sk-proj-...`/微达)、Dify(`app-...`)。轮换是首要动作——git 历史清理无法撤销已泄露的凭证。
|
||||
2. **密钥出库**:
|
||||
- `.gitignore` 增加:`backend/deploy/envs/*.env`(保留 `*.env.example` 占位模板)。
|
||||
- 从 git 索引移除:`git rm --cached backend/deploy/envs/*.env docker/.env.local docker/.env.prod`。
|
||||
- `.env.example` 里所有真实密钥替换为 `<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)检查与 insert(L271)不在同一事务,并发可绕过 10/24h 限制。把 count 检查并入同一事务,或用 Redis Lua 原子自增。
|
||||
|
||||
---
|
||||
|
||||
### 批次 2:鉴权边界
|
||||
|
||||
> 对应审计 P0-3、P1(trust boundary)。
|
||||
|
||||
**根因**:`moderation_provider.go:35`、`asset_provider.go:483`、`share_service.go` 等直接用 `req.UserId/ReporterId/SharerUserId`;4/5 服务信任 attachment 不再校验。
|
||||
|
||||
**修复方案**:
|
||||
1. **统一身份提取中间件/拦截器**:所有 provider 从 Dubbo attachment 提取 `user_id/star_id`,**覆盖** `req` 内同名字段,禁止业务读 `req.UserId`。抽到 `pkg/` 公共拦截器,避免每服务各写一份。
|
||||
2. **内部 RPC 可信边界**:内部服务间 RPC 走内网 + mTLS 或签名;`ValidateToken`(`router.go:151`)移出公开 `/auth` 组。
|
||||
3. 逐个 provider review(asset/social/moderation/gallery/task),列出所有读 `req.*Id` 的点改为读 attachment。
|
||||
|
||||
**涉及文件**:各服务 `provider/*.go`;`pkg/` 新增拦截器;`gateway/router/router.go:151`。
|
||||
|
||||
**验证**:构造伪造 `reporter_id/user_id` 的 RPC,应被拦截器覆盖为真实身份。
|
||||
|
||||
#### 2.1 socialService 隐私/正确性
|
||||
|
||||
> 对应审计 §三 P1(social 三条)。
|
||||
|
||||
- `friend_service.go:685`:`CheckFriendship` 硬编码 `starID=0`(TODO 未做)→ 结果错误 + 好友关系隐私预言机。改为从 attachment 取真实 starID。
|
||||
- `social_repository.go:648,672,903,923`:OR 子句括号优先级问题,可能让已删除资产漏进点赞列表。用显式 `db.Where(...).Or(...)` 或单条带括号的 SQL 分组。
|
||||
- `social_repository.go:461`:`GetRandomUsersByStar` 用 `OFFSET rand + LIMIT` 取连续段,非随机且可预测。改 `TABLESAMPLE` 或 `ORDER BY random()`(小表)。
|
||||
|
||||
---
|
||||
|
||||
### 批次 3:稳定性
|
||||
|
||||
#### 3.1 bcrypt 移出事务
|
||||
> P1。`auth_service.go:161,566` 的 `HashPassword` 移到 `db.Transaction` 之前。仅需调整语句顺序,风险低。
|
||||
|
||||
#### 3.2 Login 限流 + 消除用户枚举
|
||||
> P1。`auth_service.go:300 vs 315` 统一错误码("账号或密码错误");Login 加与 SMS 同款 Redis 限流。
|
||||
|
||||
#### 3.3 MQ 可靠性
|
||||
> P1。`pkg/mq/streams/adapter.go`:consumer 名改为**稳定**标识(服务名+实例名,去掉 `UnixNano`)以便重启认领 PEL;加 `XAUTOCLAIM` 回收 + DLQ。若短期不投入,则**明确停用** streams adapter(当前 EventProducer/Stream* 常量 0 引用),二选一,避免半吊子。
|
||||
|
||||
#### 3.4 material_type 收敛
|
||||
> P1。`asset_like_service.go:267` 的 `CONCAT` 改为集合去重写入(或独立标签表),防无限增长。当前实测最多 3 值未爆,属预防。
|
||||
|
||||
#### 3.5 JWT 密钥治理
|
||||
> P1 安全。`pkg/jwt/jwt.go:18` 包级可变全局 `jwtSecret`,弱默认值 `"your-secret-key-change-in-production"`,`SetSecret`/`ParseToken` 无锁 data race。修复:启动时强制从 env 注入,缺失/为默认值即 **fail-fast**;密钥用 `sync.Once` 或初始化时一次性设定,运行期只读,消除 race;gateway 与 userService 用同一密钥源(KMS/Secret),避免漂移导致全量 token 失效。
|
||||
|
||||
#### 3.6 aiChatService 健壮性
|
||||
> 对应审计 §三 P1(aiChat 三条)。
|
||||
- `ai_chat_provider.go:250`:保存上下文用请求里的 `personaID` 而非解析后的 `persona.ID` → 用 `persona.ID`。
|
||||
- `ai_chat_provider.go:138,141`:Redis 出错静默吞掉(记忆/历史丢失无日志)→ 记 Error 日志 + 降级提示,不静默。
|
||||
- `main.go:190-211` / `provider:188`:Dify/LLM 启动时二选一、无 fallback/熔断/重试,Dify 挂即硬故障,且原样回传 `err.Error()` → 加熔断/超时/降级,错误映射为稳定用户文案,原始 err 仅记服务端。
|
||||
|
||||
#### 3.7 事件/统计可靠性
|
||||
> 对应审计 §三 P1(statistic、队列名)。
|
||||
- `pkg/statistic/client.go:53-70`:`TrackEvent` fire-and-forget,失败只 log → 至少加本地重试/落盘补偿,或明确接受丢失并在文档标注。
|
||||
- 队列名(`"gallery"`、`"default"`)硬编码在 producer/consumer 两侧 → 抽为共享常量,消除改名漂移。
|
||||
|
||||
#### 3.8 网关聚合与双写
|
||||
> 对应审计 §三 P1(gateway)。
|
||||
- `asset_controller.go:414-436`:铸造后本地写激光卡与 assetService 无事务串联 → 实例卡 `mining`。改为由 assetService 统一落状态 + 事件驱动,或加对账补偿任务。
|
||||
- `auth_controller.go:69-97` 等 6 处:Register/Login/Me/Profile 链式调 `GetFanIdentities`(每次拉全量明星目录)→ 让 userService 单 RPC 返回带 star 信息的实体,减少往返并加下游失败补偿。
|
||||
- `user_controller.go:647-748`:`DeleteAccount` 用 `database.GetDB()` 自开事务直接软删 `users`+`fan_profiles`,**绕过 userService**(userService 无从感知、无法执行下游失效)。近期止血:改调 userService 的删除 RPC;彻底修复随批次 5「网关瘦身」移除网关直连 DB。
|
||||
- `gateway/repository/laser_card_repository.go`、`app_download_repository.go`:网关持有 DB repo(非纯 BFF)→ 归批次 5「网关瘦身」,DB 写回落 owner 服务。
|
||||
|
||||
---
|
||||
|
||||
### 批次 4:配置/部署
|
||||
|
||||
#### 4.1 服务端口对齐
|
||||
> P0-6。修 `gateway/config/config.go:162-165` 默认端口(gallery 20001 / activity 20004 / starbook 20005);`galleryService/main.go:44` taskService 默认改 20006;`userService/main.go:283` 去掉硬编码 `WithPort(20000)` 或删死 flag。**加配置测试**断言"网关默认端口 == 服务绑定端口"。
|
||||
|
||||
#### 4.2 starbookService 决断
|
||||
> P0-7。二选一:
|
||||
> - **正式化**:补 `go.mod`、加入 `go.work`、加 systemd/env;或
|
||||
> - **删除**:移除目录 + `assetService` 对它的 import(`main.go:32`)+ compose/helm/`dev.sh`/gateway config 引用,代码并入 assetService。
|
||||
> 推荐后者(生产未部署它)。
|
||||
|
||||
#### 4.3 健康探针 & 大二进制
|
||||
> P1。k8s `notificationservice` 探针 `/healthz`→`/health`;`moderationservice` 补探针策略。`.gitignore` 补 `backend/{assetService,gateway-fixed,test,cleanup-orphan-avatars}` 并 `git rm --cached`。
|
||||
|
||||
#### 4.4 序列规范
|
||||
> P1。`backend/scripts/create_gallery_test_users.go` 生成的 SQL 末尾补 `setval(...)`(见 `CLAUDE.md` 强制规则)。
|
||||
|
||||
#### 4.5 .env 漂移
|
||||
> P1。`backend/.env` 有 `WS_AI_CHAT_PATH` 但 `.env.example` 未文档化;`.env.example` 有一批 `.env` 缺失的必需键(`DIFY_TIMEOUT_SEC`/`DIFY_WORKFLOW`/`LANDING_BASE_URL`/`PUSH_*`/`SEGMENT_*` 等)。对齐两份,`.env.example` 只留占位符 + 完整键清单。
|
||||
|
||||
---
|
||||
|
||||
### 批次 4.9:P2 择机清理(随相关批次一起做)
|
||||
|
||||
> 对应审计 §四。不单独排期,改动到对应文件时顺手清。
|
||||
|
||||
| P2 项 | 文件:行 | 动作 |
|
||||
|-------|---------|------|
|
||||
| 错误码字符串解析 | `gateway/controller/asset_controller.go:160-194` | 改用 `status.FromError` |
|
||||
| 原始 err 泄露给前端 | `auth_controller.go:222`、`user_controller.go:139` 等 | 统一错误码 + 友好文案,err 仅记日志 |
|
||||
| ResetPassword 不失效 JWT | `userService/service/user_service.go:681` | 校验 JWT 与 `users.access_token`,或引入版本号/黑名单 |
|
||||
| 头像 URL 不校验同源 | `user_service.go:1023` | 限本站 OSS bucket 或只接受 asset id |
|
||||
| PII 打进 INFO 日志 | `ai_chat_provider.go:107`、`mint_service.go`/`user_service.go` 多处 | 手机号/JWT/聊天内容脱敏或降级 |
|
||||
| pkg/mq 死代码 | `EventProducer`/`Stream*`/`asynq.GetInfo/Delete` | 删除或补真实现(与 3.3 一并决断) |
|
||||
| 直连 Redis Pub/Sub | `activityService/service/activity_service.go:217,1579` | 走 broker 抽象 `adapter.EventProducer` |
|
||||
| 周边密钥兜底 | `pkg/peripheral/sign.go:33-44` | 去掉 `JWT_SECRET`/dev 默认兜底,`SECRET_KEY` 缺失即 fail-fast |
|
||||
|
||||
---
|
||||
|
||||
### 批次 5:架构治理(路线图,**仅规划,本次不落地**)
|
||||
|
||||
> ⚠️ MVP 先行:以下为长期方向,当前不实施,避免为未来规模写用不上的抽象。
|
||||
|
||||
- **去分布式单体**:按域拆库或至少拆 schema + 明确表 owner;跨服务读写改 RPC + service-local DTO。
|
||||
- **拆 `pkg/models`**:改为各服务本地 model + proto DTO,消除 7 服务共享 model 的编译耦合。
|
||||
- **消除跨服务 Go import**:`taskService↔assetService↔starbookService`、`gateway→asset/moderation` 改走 RPC;加 lint 规则禁止新增。
|
||||
- **拆分 `UserSocialService`**:公开 API 与内部 RPC 分为两个契约(参考 `task.proto` 的 Mobile/Internal 范式)。
|
||||
- **网关瘦身**:移除 `gateway/repository/`,DB 写回落到 owner 服务。
|
||||
- **事件契约**:`proto/event.proto` 加 `{event_id, schema_version, produced_at}` envelope。
|
||||
|
||||
**加固动作(本次可做,低成本)**:加 CI lint 禁止 `services/*/` 之间、`gateway→services/*/{repository,service}` 的新增 import,防止耦合继续恶化。
|
||||
|
||||
---
|
||||
|
||||
## 三、问题 → 修复映射表
|
||||
|
||||
| 审计条目 | 严重度 | 批次 |
|
||||
|----------|--------|------|
|
||||
| P0-1 密钥泄露 | P0 | 批次 0 |
|
||||
| §七.1 结算超发(已实测资损) | P0/财务 | 批次 1.1 |
|
||||
| §七.2 累计时长幂等 | P1/财务 | 批次 1.2 |
|
||||
| §七.1 created_at 单位 | P1 | 批次 1.3 |
|
||||
| P0-2 mint 事务嵌 RPC | P0 | 批次 1.4 |
|
||||
| §三 P1 mint 随机数/txHash | P1 | 批次 1.5 |
|
||||
| §三 P1 doMint 限流 TOCTOU | P1 | 批次 1.6 |
|
||||
| P0-3 身份伪造/越权 | P0 | 批次 2 |
|
||||
| §三 P1 social 隐私/正确性(CheckFriendship/OR/随机) | P1 | 批次 2.1 |
|
||||
| P1 bcrypt 事务内 | P1 | 批次 3.1 |
|
||||
| P1 Login 枚举/限流 | P1 | 批次 3.2 |
|
||||
| P1 MQ 丢消息/无 DLQ | P1 | 批次 3.3 |
|
||||
| P1 material_type 增长 | P1 | 批次 3.4 |
|
||||
| P1 JWT 全局密钥/data race | P1 | 批次 3.5 |
|
||||
| §三 P1 aiChat(personaID/Redis/Dify) | P1 | 批次 3.6 |
|
||||
| §三 P1 TrackEvent 丢事件/队列名漂移 | P1 | 批次 3.7 |
|
||||
| §三 P1 网关双写/链式调用/DeleteAccount 直连 DB | P1 | 批次 3.8(止血)/ 批次 5(彻底) |
|
||||
| P0-6 端口不一致 | P0 | 批次 4.1 |
|
||||
| P0-7 starbook 孤儿 | P0 | 批次 4.2 |
|
||||
| P1 健康探针/大二进制/序列 | P1 | 批次 4.3/4.4 |
|
||||
| P1 .env 漂移 | P1 | 批次 4.5 |
|
||||
| §四 P2 全部(8 项) | P2 | 批次 4.9 |
|
||||
| P0-4/5 分布式单体、共享库、跨域 FK | P0/架构 | 批次 5(路线图) |
|
||||
| P1 UserSocialService / social.proto 契约混合 | P1/架构 | 批次 5 |
|
||||
| P1 event.proto 无 version envelope | P1/架构 | 批次 5(事件契约) |
|
||||
| P1 网关持有 DB repo(laser_card/app_download) | P1/架构 | 批次 5(网关瘦身) |
|
||||
|
||||
---
|
||||
|
||||
## 四、存量数据修复脚本规范
|
||||
|
||||
> 遵循 `CLAUDE.md`:手动 INSERT 指定 id 必须 `setval`;破坏性操作前备份 + dry-run。
|
||||
|
||||
**统一要求**:
|
||||
1. **备份**:`pg_dump -t exhibition_revenue_records -t exhibitions -t user_exhibition_hours -t asset_level_records ...` 先落盘。
|
||||
2. **dry-run**:所有清理脚本先跑 `SELECT` 版本输出将影响的行数/金额,人工确认后再跑 `DELETE/UPDATE`。
|
||||
3. **事务包裹**:`BEGIN; ... ; COMMIT;`,异常回滚。
|
||||
4. **序列同步**:任何删改后对相关表执行 `SELECT setval('<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 |
|
||||
614
docs/superpowers/plans/2026-07-21-auth-boundary.md
Normal file
614
docs/superpowers/plans/2026-07-21-auth-boundary.md
Normal 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` 副本);逐个 provider(moderation / asset / social / gallery / task)改成"从 ctx 取身份 → 写回 req → 调 service"的统一模式;social 三条(CheckFriendship starID / OR 括号 / 随机用户)用真 SQL 修复。`ValidateToken` 路由加 `AuthMiddleware`。整套不引入新依赖,依赖 `dubbo-go/constant.AttachmentKey` + `grpc/metadata` + `pkg/jwt`(与 `pkg/userService/middleware/auth_interceptor.go` 现有实现同源)。
|
||||
|
||||
**Tech Stack:** Go 1.25 (go.work 多模块),Dubbo Triple(gRPC metadata + attachments),GORM(社交 repository),PostgreSQL;本地库 `top-fans`@`localhost:15432`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **不引入新依赖**:用现有的 `dubbo-go/constant.AttachmentKey`、`grpc/metadata`、`pkg/jwt`。
|
||||
- **不自动 `git commit`**(仓库规矩:需用户明确指示)。步骤里的 commit 命令仅在用户批准后执行。
|
||||
- 每个 Task 结束跑 `cd backend && go build ./...` 通过。
|
||||
- TDD:每个 Task 第一步"写失败测试"(伪造身份应被覆盖/拦截),第二步"最小实现",第三步"测试通过"。Provider 端的拦截通过单测构造带 `x-user-id`/`x-star-id` 的 metadata 的 ctx 与带伪造 `req.UserId`/`req.StarId`/`req.ReporterId`/`req.SharerUserId` 的请求,断言调用 service 时传入的是覆盖后的真实身份。
|
||||
- 不引入 lint 黑魔法:仅用现有 `go vet` + `go build`。
|
||||
- 行为兼容:所有已有合法调用方不变;只是禁止攻击者在 `req` 里塞伪造身份。
|
||||
- 不改 proto 字段(不动契约);所有变更在 Go 层完成。
|
||||
- 任何 service.go 内部函数签名变更(如 `CheckFriendship` 增加 `starID` 参数)必须在 provider 处同步补传,避免破坏调用方。
|
||||
|
||||
---
|
||||
|
||||
## 当前现状速查(pre-plan 排查,作为 plan 的事实基线)
|
||||
|
||||
> 来自 `mcp__code-review-graph` + grep 双重核对,基线 commit `b7f8f1b724e5`。
|
||||
|
||||
### A. 信任 `req.*Id` 的点(**本次必须全部覆盖**)
|
||||
|
||||
| 服务 | 文件:行 | 字段 | 风险 |
|
||||
|------|---------|------|------|
|
||||
| moderation | `services/moderationService/provider/moderation_provider.go:35` (SubmitReport) | `req.ReporterId` 透传 | 冒用他人身份举报、刷量、读取他人举报 |
|
||||
| moderation | `services/moderationService/provider/moderation_provider.go:39,43,49,53,57` (List/Get) | `req.ReporterId`/`req.UserId` | 读取他人举报/反馈详情 |
|
||||
| moderation | `services/moderationService/service/report_service.go:107,116,123,130,170,220,251` | `req.ReporterId` | 业务逻辑按 req 执行 |
|
||||
| moderation | `services/moderationService/service/feedback_service.go:53,64,109,135` | `req.UserId`/`req.StarId` | 同上 |
|
||||
| asset | `services/assetService/provider/asset_provider.go:483-486` (CheckAssetLike) | `req.UserId`/`req.StarId` | 点赞隐私预言机 |
|
||||
| asset | `services/assetService/service/share_service.go:139,172,175,185,187,211,233,250,266,276,298,308` | `req.SharerUserId` | 分享归因伪造、无限 OSS 写入 |
|
||||
| social | `services/socialService/service/friend_service.go:668,683` (CheckFriendship) | `req.UserId` + starID=0 TODO | 好友关系隐私预言机 + starID 永远 0 |
|
||||
| gallery | `services/galleryService/provider/gallery_provider.go:403` (GetUserExhibitedAssets) | `req.UserId` 当 target_uid,**应为合法入参** | 此处是查询参数,**不覆盖**(仅注释澄清) |
|
||||
| task | `services/taskService/provider/task_internal_provider.go:32-48` (InitUserTasks) | `req.UserId`/`req.StarId`(内部 RPC) | 内部 RPC,**保留 req 作合法入参**,但加 ctx 校验一致性 |
|
||||
| asset | `services/assetService/service/ranking_service.go:51,89-90,113-123,176,214-248` | `req.UserId`/`req.StarId` | 排行榜按别人维度查(隐私泄露) |
|
||||
| asset | `services/assetService/provider/castlove_config_provider.go:35-39` | "本服务信任 ctx 透传的 user_id" | 当前 GetConfig 不读身份;文档要求统一来源 |
|
||||
|
||||
### B. 现有的身份提取实现(5 份散落副本)
|
||||
|
||||
| 服务 | 函数 | 文件 |
|
||||
|------|------|------|
|
||||
| userService | `ValidateTokenAndExtractClaims` / `ExtractUserInfoFromContext` | `services/userService/middleware/auth_interceptor.go:171,155` |
|
||||
| notification | `extractUserInfo` | `services/notificationService/provider/notification_provider.go:161` |
|
||||
| social | `extractUserInfo` | `services/socialService/provider/social_provider.go` (本文件已有 10+ 调用) |
|
||||
| gallery | `extractUserInfoFromDubboAttachments` | `services/galleryService/provider/gallery_provider.go:463` |
|
||||
| aiChat | `extractUserInfoFromDubboAttachments` | `services/aiChatService/provider/ai_chat_provider.go:367` |
|
||||
| task | `extractUserInfoFromDubboAttachments` | `services/taskService/provider/task_mobile_provider.go:35` |
|
||||
|
||||
5 份实现都做同一件事:从 attachment 或 metadata 取 `user_id`/`star_id`(`x-user-id`/`x-star-id`/`authorization`),但**没有**写回 `req` 的能力,因此 provider 端继续读 `req.*Id` 时仍中招。
|
||||
|
||||
### C. social 三条正确性
|
||||
|
||||
- `social_repository.go:461` — `GetRandomUsersByStar`:`rand.Int63n(total)` + `OFFSET` 取连续段,非随机且可预测。
|
||||
- `social_repository.go:648` (count)、`:672` (data) — `Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'")` — GORM 的多个 `Where(...)` 链式调用 **会拼 AND**,但第二个 `Where` 内部的 `OR` 没有显式分组,生成的 SQL 是 `AND (X OR Y)`,而 OR 与前一个 Where 的预期是 `AND X AND Y`,优先级错位会让"展览已删除 + 押注已 claimable"的资产漏进列表。
|
||||
- `social_repository.go:903, 923` — 同样 pattern:`Where("...deleted_at IS NULL...").Where("(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?")` 同样 OR 优先级问题。
|
||||
- `friend_service.go:680, 683` — `starID := int64(0); // TODO` → `CheckFriendship(req.UserId, req.FriendUserId, 0)` 永远返回跨明星的聚合,**好友关系隐私预言机**。
|
||||
|
||||
### D. `ValidateToken` 路由
|
||||
|
||||
`backend/gateway/router/router.go:151` — `auth.POST("/validate", authCtrl.ValidateToken)` 在公开 `/auth` 组,调用方**无需登录**即可验证任意 token 是否有效,配合 `c.ShouldBindJSON(&req)` 接受 `{token: "..."}`,是探测 JWT 是否存在的低成本接口。`auth_controller.go:289` 的实现也确实只用 `req.Token` 字段。修复:移到 `authProtected` 组(已带 AuthMiddleware)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `backend/pkg/authctx/` — **新建包**。统一身份提取与 req 覆盖。
|
||||
- `backend/pkg/authctx/authctx.go` — `ExtractIdentity(ctx) (uid, sid int64, err error)`,合并 5 份散落副本(gRPC metadata `x-user-id`/`x-star-id` 优先,fallback 到 Dubbo `constant.AttachmentKey`)。
|
||||
- `backend/pkg/authctx/authctx.go` — `OverrideUser(req UserIDSetter, ctx) error` / `OverrideStar(...)` / `OverrideReporter(req ReporterIDSetter, ctx)` / `OverrideSharer(req SharerUserIDSetter, ctx)`:从 ctx 取身份覆盖 req 同名字段;ctx 无身份则返回错误(拒绝继续走)。
|
||||
- `backend/pkg/authctx/authctx_test.go` — 单测覆盖 metadata 优先 / attachment fallback / req 已被攻击者改成 999 时仍被覆盖为 ctx 中的真实身份。
|
||||
- `backend/services/userService/middleware/auth_interceptor.go` — **改**:删 `ExtractUserIDFromContext`/`ExtractStarIDFromContext`/`ValidateTokenAndExtractClaims`(迁移到 `pkg/authctx`),保留 `extractTokenFromMetadata` 内部逻辑(或一并迁移)。
|
||||
- `backend/services/notificationService/provider/notification_provider.go` — **改**:删本地 `extractUserInfo`/`parseIntValue`/`readInt64FromMD`,改用 `pkg/authctx.ExtractIdentity`。
|
||||
- `backend/services/socialService/provider/social_provider.go` — **改**:同上 + `CheckFriendship`/`GetRandomUsersByStar` 链路用覆盖后的 ctx。
|
||||
- `backend/services/galleryService/provider/gallery_provider.go` — **改**:同上。
|
||||
- `backend/services/aiChatService/provider/ai_chat_provider.go` — **改**:同上。
|
||||
- `backend/services/taskService/provider/task_mobile_provider.go` — **改**:同上。`task_internal_provider.go`(内部 RPC)保留 req 透传,加注释说明。
|
||||
- `backend/services/moderationService/provider/moderation_provider.go` — **改**:5 个 RPC 入口全部 `OverrideReporter`/`OverrideUser`,禁止读 req。
|
||||
- `backend/services/moderationService/service/report_service.go` — **改**:`SubmitReport` 接收 `reporterID` 由 provider 显式传入,service 内不再读 `req.ReporterId`。
|
||||
- `backend/services/moderationService/service/feedback_service.go` — **改**:同上。
|
||||
- `backend/services/assetService/provider/asset_provider.go` — **改**:`CheckAssetLike` 改 `ExtractIdentity` + 覆盖 req。
|
||||
- `backend/services/assetService/service/share_service.go` — **改**:`GetAssetQrcode`/`TrackShare` 接收 `sharerUserID` 由 provider 显式传入。
|
||||
- `backend/services/assetService/provider/share_provider.go` — **新建**(若 proto 有 `ShareService` RPC)或在 `asset_provider.go` 中加 `GetAssetQrcode`/`TrackShare` provider 方法(如已有 provider 文件则改)。明确从 ctx 取 `sharer_user_id` 覆盖 `req.SharerUserId`,禁止读 req。
|
||||
- `backend/services/assetService/service/ranking_service.go` — **改**:`req.UserId` 改为 ctx 注入参数(`userID, starID int64` 已由 provider 传入;内部全部用入参,不再读 req)。
|
||||
- `backend/services/socialService/service/friend_service.go` — **改**:`CheckFriendship` 签名加 `userID, starID int64`(provider 传),删除 TODO。
|
||||
- `backend/services/socialService/repository/social_repository.go` — **改**:
|
||||
- `GetRandomUsersByStar` 用 `ORDER BY random()`(小表);保留 offset 实现作为 deprecated 备份但默认 random。
|
||||
- `GetUserLikedAssets` `:642-650, 657-677`、`GetMyWeekLikedAssets` `:897-907, 909-928` 的 OR 改成显式分组:`db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).Where(db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed'").Or("lbr.status = ?", "claimable"))`。
|
||||
- `backend/gateway/router/router.go` — **改**:删除公开组的 `auth.POST("/validate", ...)`,在 `authProtected` 组加 `authProtected.POST("/validate", authCtrl.ValidateToken)`。
|
||||
- `backend/gateway/controller/auth_controller.go` — **审**:不动实现(已只读 `req.Token`),但加注释"受 AuthMiddleware 保护,调用方需已登录"。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 抽 `pkg/authctx` 公共身份提取工具
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/pkg/authctx/authctx.go`
|
||||
- Create: `backend/pkg/authctx/authctx_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
```go
|
||||
// pkg/authctx/authctx.go
|
||||
package authctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
const (
|
||||
userIDKey ctxKey = iota
|
||||
starIDKey
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIdentityMissing = errors.New("authctx: identity not found in context")
|
||||
ErrInvalidIdentity = errors.New("authctx: identity must be positive")
|
||||
)
|
||||
|
||||
// ExtractIdentity returns (userID, starID) from the context.
|
||||
//
|
||||
// Priority:
|
||||
// 1. gRPC metadata "x-user-id" / "x-star-id" (Dubbo Triple 把 HTTP header 转过来)
|
||||
// 2. Dubbo attachments via constant.AttachmentKey, key "user_id" / "star_id"
|
||||
// (支持 string / int / int64 / []string / []interface{})
|
||||
//
|
||||
// 任何路径都拿不到则返回 ErrIdentityMissing。
|
||||
// 拿到但 < 0 也算错;== 0 视作"未设",继续 fallback。
|
||||
func ExtractIdentity(ctx context.Context) (int64, int64, error) {
|
||||
uid, sid := readFromGRPCMetadata(ctx)
|
||||
if uid > 0 && sid > 0 {
|
||||
return uid, sid, nil
|
||||
}
|
||||
uid2, sid2 := readFromDubboAttachments(ctx)
|
||||
if uid2 > 0 && sid2 > 0 {
|
||||
return uid2, sid2, nil
|
||||
}
|
||||
if uid == 0 && uid2 == 0 {
|
||||
return 0, 0, ErrIdentityMissing
|
||||
}
|
||||
if sid == 0 && sid2 == 0 {
|
||||
return 0, 0, ErrIdentityMissing
|
||||
}
|
||||
// uid 有但 sid 没:fallback 合并
|
||||
if uid == 0 { uid = uid2 }
|
||||
if sid == 0 { sid = sid2 }
|
||||
if uid <= 0 || sid <= 0 {
|
||||
return 0, 0, ErrInvalidIdentity
|
||||
}
|
||||
return uid, sid, nil
|
||||
}
|
||||
|
||||
// 必须有 user_id(star_id 可为 0,比如内部 RPC 不需要 star)
|
||||
func ExtractUserID(ctx context.Context) (int64, error) { ... }
|
||||
|
||||
// 覆盖器:把 ctx 里的身份写回 req。req 必须实现对应的小接口,
|
||||
// 编译期断言在调用方加。覆盖是"无条件的"——只要 ctx 有身份,就覆盖 req 同名字段。
|
||||
type UserIDSetter interface{ SetUserID(int64) }
|
||||
type StarIDSetter interface{ SetStarID(int64) }
|
||||
type ReporterIDSetter interface{ SetReporterID(int64) }
|
||||
type SharerUserIDSetter interface{ SetSharerUserID(int64) }
|
||||
|
||||
func OverrideUser(req UserIDSetter, ctx context.Context) error {
|
||||
uid, _, err := ExtractIdentity(ctx)
|
||||
if err != nil { return err }
|
||||
req.SetUserID(uid)
|
||||
return nil
|
||||
}
|
||||
func OverrideStar(req StarIDSetter, ctx context.Context) error { ... }
|
||||
func OverrideReporter(req ReporterIDSetter, ctx context.Context) error { ... }
|
||||
func OverrideSharer(req SharerUserIDSetter, ctx context.Context) error { ... }
|
||||
|
||||
// FromJWTContext 把 ParseToken 后的 (uid, sid) 灌进 ctx 给业务层用
|
||||
func WithIdentity(ctx context.Context, uid, sid int64) context.Context { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 1.1 写失败测试**:`backend/pkg/authctx/authctx_test.go`
|
||||
- 测试 `ExtractIdentity`:
|
||||
- `TestExtractIdentity_FromGRPCMetadata`:构造 `metadata.NewIncomingContext(ctx, metadata.Pairs("x-user-id", "100", "x-star-id", "200"))`,断言返回 `(100, 200, nil)`。
|
||||
- `TestExtractIdentity_FromDubboAttachments`:构造 `context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{"user_id": int64(100), "star_id": int64(200)})`,断言同上。
|
||||
- `TestExtractIdentity_Missing`:空 ctx,断言返回 `ErrIdentityMissing`。
|
||||
- 测试覆盖器(用 mock 实现接口):
|
||||
- `TestOverrideUser_OverridesAttackerValue`:ctx 有真实身份 `uid=42`,req 的 `SetUserID(999)` 表示"攻击者已塞 999",调用 `OverrideUser` 后断言 req 的真实值变 42。
|
||||
- `TestOverrideReporter_WithoutCtx`:ctx 无身份,调用 `OverrideReporter` 断言返回错误,req 的 `SetReporterID(7)` 调用次数为 0(**未覆盖 req**)。
|
||||
- [ ] **Step 1.2 实现**(如上接口),跑 `cd backend && go test ./pkg/authctx/...` 通过。
|
||||
- [ ] **Step 1.3** `cd backend && go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 接入 moderationService(最高风险:举报冒用)
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/moderationService/provider/moderation_provider.go`
|
||||
- Edit: `backend/services/moderationService/service/report_service.go`
|
||||
- Edit: `backend/services/moderationService/service/feedback_service.go`
|
||||
- Create (test): `backend/services/moderationService/provider/moderation_provider_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
`report_service.go` 新签名(service 层也不再读 req 的身份):
|
||||
```go
|
||||
// 旧:SubmitReport(ctx, req)
|
||||
// 新:SubmitReport(ctx, reporterID, req) // reporterID 由 provider 注入
|
||||
func (s *ReportService) SubmitReport(ctx context.Context, reporterID int64, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error)
|
||||
// 内部所有 req.ReporterId → reporterID
|
||||
// ListMyReports(ctx, userID, status, page, pageSize) — 签名不变,userID 由 provider 传入
|
||||
// GetReport(ctx, userID, id) — 同上
|
||||
```
|
||||
|
||||
`feedback_service.go` 同理:
|
||||
```go
|
||||
SubmitFeedback(ctx, userID, starID, req)
|
||||
ListMyFeedbacks(ctx, userID, status, page, pageSize)
|
||||
GetFeedback(ctx, userID, id)
|
||||
```
|
||||
|
||||
`moderation_provider.go` 每个 RPC:
|
||||
```go
|
||||
func (p *ModerationProvider) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
|
||||
uid, _, err := authctx.ExtractIdentity(ctx)
|
||||
if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
|
||||
return p.report.SubmitReport(ctx, uid, req)
|
||||
}
|
||||
func (p *ModerationProvider) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) {
|
||||
uid, _, err := authctx.ExtractIdentity(ctx)
|
||||
if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
|
||||
return p.report.ListMyReports(ctx, uid, req.Status, int(req.Page), int(req.PageSize))
|
||||
}
|
||||
func (p *ModerationProvider) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) {
|
||||
uid, _, err := authctx.ExtractIdentity(ctx)
|
||||
if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
|
||||
return p.report.GetReport(ctx, uid, req.Id)
|
||||
}
|
||||
// SubmitFeedback/ListMyFeedbacks/GetFeedback 同模式
|
||||
```
|
||||
|
||||
- [ ] **Step 2.1 写失败测试** `moderation_provider_test.go`:
|
||||
- `TestSubmitReport_RejectsForgedReporterId`:ctx 有真实身份 `uid=100`,req.ReporterId=999(模拟攻击者伪造)。调用 SubmitReport,断言:
|
||||
- 返回的 report 里 `ReporterID == 100`(不是 999)。
|
||||
- `report_service.SubmitReport` 被调用时收到的 reporterID 参数 == 100。
|
||||
- 用 mock `ReportService`(testify mock)拦截 `SubmitReport` 入参。
|
||||
- `TestSubmitReport_NoIdentity_ReturnsUnauthenticated`:ctx 无身份,断言返回 `codes.Unauthenticated`。
|
||||
- `TestGetReport_PreventReadingOthersReport`:ctx uid=100,req.Id=被另一用户 (uid=200) 创建的 report id。断言 service.GetReport 入参是 (100, id)(不再用 req.ReporterId 作 userID 判 owner),service 层已有的 `report.ReporterID != userID → ErrReportNotFound` 正确触发。
|
||||
- [ ] **Step 2.2 改 `report_service.go` 签名**:所有 `req.ReporterId` → `reporterID` 参数;`GetReport` 加 `userID` 参数;删除所有 `req.ReporterId == ...` 比较。
|
||||
- [ ] **Step 2.3 改 `feedback_service.go` 签名**:同上(userID/starID 由参数传入)。
|
||||
- [ ] **Step 2.4 改 `moderation_provider.go`**:5 个 RPC 全部走 `authctx.ExtractIdentity`,不再读 req 身份。
|
||||
- [ ] **Step 2.5** `go test ./services/moderationService/...` 通过 + `go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 接入 assetService 的 `CheckAssetLike` + `ShareService`
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/assetService/provider/asset_provider.go:477-517`(CheckAssetLike)
|
||||
- Edit: `backend/services/assetService/service/share_service.go`(GetAssetQrcode / TrackShare)
|
||||
- Edit/Add: `backend/services/assetService/provider/` 中的 share provider(如 proto 已定义 ShareService handler 则改对应文件;否则在 `asset_provider.go` 同文件加 wrapper)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
`asset_provider.go`:
|
||||
```go
|
||||
func (p *AssetProvider) CheckAssetLike(ctx context.Context, req *pb.CheckAssetLikeRequest) (*pb.CheckAssetLikeResponse, error) {
|
||||
uid, sid, err := authctx.ExtractIdentity(ctx)
|
||||
if err != nil {
|
||||
return &pb.CheckAssetLikeResponse{ Base: unauthBase() }, status.Error(codes.Unauthenticated, "identity required")
|
||||
}
|
||||
// req.UserId/StarId 一律不读
|
||||
isLiked, err := p.assetLikeService.CheckAssetLike(ctx, req.AssetId, uid, sid)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`share_service.go`:
|
||||
```go
|
||||
// 旧:GetAssetQrcode(ctx, req) — 内部读 req.SharerUserId
|
||||
// 新:GetAssetQrcode(ctx, sharerUserID, req) — provider 注入
|
||||
func (s *ShareService) GetAssetQrcode(ctx context.Context, sharerUserID int64, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
|
||||
// 内部所有 req.SharerUserId → sharerUserID
|
||||
// TrackShare(ctx, sharerUserID, req) 同上
|
||||
```
|
||||
|
||||
share provider(如已有):
|
||||
```go
|
||||
func (p *ShareProvider) GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) {
|
||||
uid, _, err := authctx.ExtractIdentity(ctx)
|
||||
if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
|
||||
return p.shareSvc.GetAssetQrcode(ctx, uid, req)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.1 写失败测试** `asset_provider_test.go`:
|
||||
- `TestCheckAssetLike_RejectsForgedUserId`:ctx uid=100/sid=200,req.UserId=999/req.StarId=888。断言调 service 时传入 (100, 200)。
|
||||
- `TestGetAssetQrcode_RejectsForgedSharer`:ctx uid=100,req.SharerUserId=999。断言落盘 `share_events.sharer_user_id == 100` 且 OSS key 含 `_100_`。
|
||||
- `TestGetAssetQrcode_NoIdentity`:ctx 无身份,断言返回 Unauthenticated。
|
||||
- [ ] **Step 3.2 改 `asset_provider.go`** `CheckAssetLike`:删 `userID := req.UserId` / `starID := req.StarId`,改 `authctx.ExtractIdentity`。
|
||||
- [ ] **Step 3.3 改 `share_service.go`** 两个方法签名 + 内部所有 `req.SharerUserId` → `sharerUserID`。
|
||||
- [ ] **Step 3.4 改 share provider**:5 个 RPC(如果有 GetAssetQrcode/TrackShare)全走 ctx 注入。
|
||||
- [ ] **Step 3.5** `go test ./services/assetService/...` 通过 + `go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 接入 socialService 的 `CheckFriendship`(starID=0 修复)
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/socialService/service/friend_service.go:665-700` (CheckFriendship)
|
||||
- Edit: `backend/services/socialService/provider/social_provider.go`(`CheckFriendship` provider 段)
|
||||
- Edit: `backend/services/socialService/repository/social_repository.go` `CheckFriendship`(保留方法,签名补 `starID`;调用方全部用真 starID)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
`friend_service.go`:
|
||||
```go
|
||||
// 旧:CheckFriendship(ctx, req) — req.UserId, starID=0
|
||||
// 新:CheckFriendship(ctx, userID, starID, friendUserID) — provider 注入
|
||||
func (s *friendService) CheckFriendship(ctx context.Context, userID, starID, friendUserID int64) (*pb.CheckFriendshipResponse, error)
|
||||
```
|
||||
|
||||
`social_provider.go`:
|
||||
```go
|
||||
func (p *SocialProvider) CheckFriendship(ctx context.Context, req *pb.CheckFriendshipRequest) (*pb.CheckFriendshipResponse, error) {
|
||||
uid, sid, err := extractUserInfo(ctx) // 改用 authctx.ExtractIdentity
|
||||
if err != nil { return nil, status.Error(codes.Unauthenticated, "identity required") }
|
||||
if req.FriendUserId == 0 { return nil, status.Error(codes.InvalidArgument, "friend_user_id required") }
|
||||
return p.friendService.CheckFriendship(ctx, uid, sid, req.FriendUserId)
|
||||
}
|
||||
```
|
||||
|
||||
`social_repository.go` `CheckFriendship(userID, friendUserID, starID int64)` 签名已含 starID(`friend_service.go:683` 调用已传 0),无需大改——只需把调用点改为传真 sid。
|
||||
|
||||
- [ ] **Step 4.1 写失败测试** `friend_service_test.go`:
|
||||
- `TestCheckFriendship_UsesCtxStarID`:ctx uid=100/sid=200,req.FriendUserId=200。断言调 `socialRepo.CheckFriendship` 时第 3 参数是 200 而非 0。
|
||||
- `TestCheckFriendship_NoIdentity`:ctx 无身份,断言 Unauthenticated。
|
||||
- [ ] **Step 4.2 改 `friend_service.go` `CheckFriendship` 签名**:删 `starID := int64(0) // TODO`,改 provider 注入。
|
||||
- [ ] **Step 4.3 改 `social_provider.go` CheckFriendship provider 方法**:走 ctx。
|
||||
- [ ] **Step 4.4** `go test ./services/socialService/...` 通过 + `go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 接入 galleryService / taskService / aiChatService(统一替换散落实现)
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/galleryService/provider/gallery_provider.go:462` 删本地 `extractUserInfoFromDubboAttachments`,改 import `pkg/authctx`;所有调用点 `extractUserInfoFromDubboAttachments(ctx)` → `authctx.ExtractIdentity(ctx)`。`gallery_provider.go:403` 的 `req.UserId`(target_uid)是合法入参(查询他人列表),**不覆盖**,仅加注释"这是 target_uid 而非调用方身份"。
|
||||
- Edit: `backend/services/aiChatService/provider/ai_chat_provider.go:367` 同上。
|
||||
- Edit: `backend/services/taskService/provider/task_mobile_provider.go:35` 同上;`task_internal_provider.go:32-48`(内部 RPC)保留 req 透传,但加注释"内部 RPC,需由调用方保证 user_id 来自可信源"。
|
||||
- Edit: `backend/services/notificationService/provider/notification_provider.go:161` 同上。
|
||||
|
||||
- [ ] **Step 5.1 写失败测试**(已有覆盖可跳过,新服务至少加 1 个):
|
||||
- `gallery_provider_test.go::TestGetMyGallery_NoIdentity`:ctx 无身份,断言 Unauthenticated。
|
||||
- [ ] **Step 5.2** 替换 5 个文件的本地 `extractUserInfo*` 为 `authctx.ExtractIdentity`,删除已无用的 `parseIntValue`/`readInt64FromMD` 等内部辅助。
|
||||
- [ ] **Step 5.3** `go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 删 `userService/middleware/auth_interceptor.go` 已迁移函数
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/userService/middleware/auth_interceptor.go`
|
||||
|
||||
- [ ] **Step 6.1** 验证无外部引用:
|
||||
```bash
|
||||
grep -rn "auth_interceptor\." backend/services --include="*.go"
|
||||
grep -rn "ExtractUserIDFromContext\|ExtractStarIDFromContext\|ExtractUserInfoFromContext\|ValidateTokenAndExtractClaims" backend --include="*.go"
|
||||
```
|
||||
预期所有调用方都在 Task 1-5 已切到 `pkg/authctx`。
|
||||
- [ ] **Step 6.2** 删除 `ExtractUserIDFromContext`/`ExtractStarIDFromContext`/`ExtractUserInfoFromContext`/`ValidateTokenAndExtractClaims` 函数本体,保留 `extractTokenFromMetadata`(作为内部 helper,仅供 `pkg/authctx` 通过 parse JWT 时使用)或一并迁过去。**需用户确认**:是否要把 `pkg/jwt.ParseToken` 也挪进 `pkg/authctx`;默认保留原文件,把 `extractTokenFromMetadata` 迁移到 `pkg/authctx` 私有。
|
||||
- [ ] **Step 6.3** `go build ./...` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Task 7: socialService — OR 子句括号优先级修复
|
||||
|
||||
**Files:**
|
||||
- Edit: `backend/services/socialService/repository/social_repository.go`:
|
||||
- `GetUserLikedAssets`(`:642-650` count 和 `:657-677` data 两个查询):`(line 648 和 672)`
|
||||
- `GetMyWeekLikedAssets`(`:897-907` count 和 `:909-928` data):`(line 903 和 923)`
|
||||
- Create (test): `backend/services/socialService/repository/social_repository_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
修复前(错):
|
||||
```go
|
||||
db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).
|
||||
Where("((e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') != 'claimed') OR lbr.status = 'claimable'")
|
||||
```
|
||||
|
||||
修复后(显式分组 OR):
|
||||
```go
|
||||
sub := r.db.Where("(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status, '') <> 'claimed'").
|
||||
Or("lbr.status = ?", "claimable")
|
||||
db.Where("a.deleted_at IS NULL AND a.is_active = ?", true).
|
||||
Where(sub)
|
||||
```
|
||||
|
||||
等价 SQL:
|
||||
```sql
|
||||
WHERE a.deleted_at IS NULL AND a.is_active = $1
|
||||
AND (
|
||||
(e.id IS NULL OR e.deleted_at IS NULL) AND COALESCE(lbr.status,'') <> 'claimed'
|
||||
OR lbr.status = 'claimable'
|
||||
)
|
||||
```
|
||||
|
||||
`GetMyWeekLikedAssets` 同样 pattern(OR 是单条件 `e.expire_at > ?`,**不需要修**——它实际只有 AND,没有错)。但 line 903/923 的 `(e.id IS NULL OR e.deleted_at IS NULL) AND e.expire_at > ?` 整段嵌在外层 `Where` 链里,需要确认 GORM 行为:
|
||||
|
||||
- 用 `gorm.io/gorm` 测出当前行为:多个 `Where(...)` 链式会拼 `AND`,所以最终 SQL 是 `... AND <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`(或脚本)伪造 RPC:ctx 不带 `x-user-id`,带 `req.UserId=999`,断言返回 `Unauthenticated`。
|
||||
- 用合法 JWT 发起 `SubmitReport`,构造 `req.ReporterId=888`,断言落库 `reports.reporter_id == JWT 中的真实 uid`(不是 888)。
|
||||
- `GET /api/v1/auth/validate` 不带 token,断言 401。
|
||||
- [ ] **Step 10.5** 文档同步:若有任何行为变更,更新 `docs/specs/2026-07-21-backend-remediation-plan.md` 批次 2 章节的"修复方案"为"已实施"。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
> 按 `CLAUDE.md` 全局自审规则(章节通读清单 + 跨章节引用一致性)。
|
||||
|
||||
### 修改的章节(来自 `docs/specs/2026-07-21-backend-remediation-plan.md`)
|
||||
|
||||
- §二 批次 2 主条目("修复方案" 1/2/3)→ Task 1/2-5/9 覆盖
|
||||
- §二 批次 2.1 social 三条 → Task 4/7/8 覆盖
|
||||
- §五 回归验证清单(伪造身份拦截、端口配置通过)→ Task 10 覆盖
|
||||
|
||||
### 未改动但通读的章节
|
||||
|
||||
- §一 方案概述(确认批次排序、关键决策、MVP 先行原则未被破坏——本批次未引入新的 Provider 抽象)
|
||||
- §二 批次 0/1/3/4(确认未跨批次耦合:本批次不动财务、不动 mint、不动 MQ;端口/探针/starbook 决断未触)
|
||||
- §二 批次 5 路线图(确认"加 lint 禁止新增跨服务 import"未在本批次落地——属批次 5 治理)
|
||||
- §三 问题 → 修复映射表(核对 P0-3、§三 P1 social 三条均已映射)
|
||||
- §四 存量数据修复脚本规范(本批次无 DB 变更,无需 setval)
|
||||
- §六 备注(与本批次兼容)
|
||||
|
||||
### 跨章节引用一致性
|
||||
|
||||
- Task 1(`pkg/authctx`)→ Task 2-5/6(5 份散落副本删除):一致
|
||||
- Task 2(moderation service 签名变更)→ provider 必须传 userID/reporterID(Task 2.4):一致
|
||||
- Task 3(share service 签名变更)→ provider 必须传 sharerUserID(Task 3.4):一致
|
||||
- Task 4(friend_service.CheckFriendship 签名加 starID)→ provider 已传(Task 4.3):一致
|
||||
- Task 6(删除 userService/middleware 的已迁函数)→ Task 1 的 `pkg/authctx` 替代(Task 1.1):一致
|
||||
- Task 9(router 移动 `/validate`)→ auth_controller 已只读 `req.Token`(Task 9.3 注释):一致
|
||||
|
||||
### Go 编译验证清单
|
||||
|
||||
- `pkg/authctx` 新包:导入路径 `github.com/topfans/backend/pkg/authctx`,需 `backend/go.mod` 已有 `dubbo.apache.org/dubbo-go/v3` 和 `google.golang.org/grpc`(已有)。✓
|
||||
- provider 单测需 mock `ReportService`/`ShareService`/`FriendService` 等 interface,用 testify mock(已有 `github.com/stretchr/testify`)。✓
|
||||
- service 签名变更(SubmitReport / SubmitFeedback / CheckFriendship / GetAssetQrcode / TrackShare)会破坏现有调用方;所有调用方都在被改的 provider 文件内(grpc handler),无外部 main.go 直接调 service。grep 验证:✓
|
||||
- Task 6 删除 `ValidateTokenAndExtractClaims` 等函数:grep 无外部调用方(userService 自用 + Task 1 替代)。✓
|
||||
|
||||
### 优先级
|
||||
|
||||
- **P0(必做)**:Task 1/2/3/4/9(5 个核心鉴权边界);Task 7/8(social 正确性审计明确列出)。
|
||||
- **P1(强烈建议)**:Task 5(替换散落副本,不替换也能跑但违反代码一致性);Task 6(删除死代码,否则 `pkg/authctx` 与 userService/middleware 双重实现不一致)。
|
||||
- **P2(可推迟)**:Task 10.5 文档同步。
|
||||
|
||||
### 风险
|
||||
|
||||
- `share_service.go:246` `TrackShare` 之前依赖 `req.ClientTs`(客户端时间戳),不变;只换身份来源。
|
||||
- `asset_provider.go:483` `CheckAssetLike` 当前调用 `p.assetLikeService.CheckAssetLike(ctx, req.AssetId, userID, starID)`,签名一致;改 ctx 取值不影响下游。
|
||||
- `social_provider.go` 有 10+ 处 `extractUserInfo(ctx)` 调用,Task 5.2 批量替换即可。
|
||||
- `feedback_service.go` 内部 `req.StarId > 0` 判断(`:64`)是给 star 可选的反馈业务;Task 2.3 改成参数后保留 `starID > 0` 判断。
|
||||
|
||||
### 验证检查清单
|
||||
|
||||
- [ ] 提交 `go build ./...` 无 error
|
||||
- [ ] 全部 service `go test ./...` 通过
|
||||
- [ ] `grep -rn "req.UserId\|req.StarId\|req.ReporterId\|req.SharerUserId" backend/services/{moderation,asset,social}/...` 只剩注释 + 合法入参
|
||||
- [ ] `grpcurl` 伪造身份 RPC 返回 Unauthenticated
|
||||
- [ ] `/api/v1/auth/validate` 不带 token 返回 401
|
||||
- [ ] `social_repository.GetRandomUsersByStar` 重复调用结果不连续
|
||||
- [ ] `exhibition` 软删 + lbr.status='claimed' 的资产不再出现在点赞列表
|
||||
|
||||
### 待用户确认的决策点
|
||||
|
||||
1. **是否把 `pkg/jwt.ParseToken` 调用也搬到 `pkg/authctx`**(默认保留在原处,`authctx` 只做 metadata 提取与覆盖)。
|
||||
2. **`GetRandomUsersByStar` 真随机策略**:`ORDER BY random()` vs 预生成 Redis 随机池。默认 `random()`,小表 OK。
|
||||
3. **`ValidateToken` 接口设计**:保留接受 `{token: "..."}` body 的 RPC(仅要求已登录才能调)vs 改成基于调用方自身 JWT 自动校验。默认保留 body 形式(向后兼容)。
|
||||
|
||||
### 不在本次范围
|
||||
|
||||
- 内部 RPC mTLS / 签名(批次 5 治理)
|
||||
- 跨服务 import 限制(批次 5)
|
||||
- JWT 全局密钥治理(批次 3.5)
|
||||
- Login 限流/枚举修复(批次 3.2)
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 本 plan 严格遵循 `CLAUDE.md` 的"接口开发规范"(分层、DTO、错误码、日志、测试)。
|
||||
- 批次 2 优先级低于批次 1(财务资损),但 P0-3 越权属于安全 P0,可与批次 1 并行(无文件冲突)。
|
||||
- 任何 service 签名变更(Task 2/3/4)都属内部重构,不动 proto 契约,向后兼容。
|
||||
- 所有 commit 步骤需用户明确指示。
|
||||
557
docs/superpowers/plans/2026-07-21-config-deploy.md
Normal file
557
docs/superpowers/plans/2026-07-21-config-deploy.md
Normal file
@ -0,0 +1,557 @@
|
||||
# 配置与部署 (批次4) Implementation Plan
|
||||
|
||||
REQUIRED SUB-SKILL: superpowers:writing-plans
|
||||
(实施阶段使用 superpowers:executing-plans;code-review 阶段使用 superpowers:requesting-code-review)
|
||||
|
||||
> 配套审计:`docs/backend-audit-2026-07-21.md` §二 P0-6/P0-7、§三 P1 配置、§四 P2
|
||||
> 配套修复方案:`docs/specs/2026-07-21-backend-remediation-plan.md` 批次 4(4.1 / 4.2 / 4.3 / 4.4 / 4.5 / 4.9)
|
||||
> 基线 commit:`b7f8f1b724e5`,分支 `feat/uni`
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
消除 backend 配置/部署层的全部 P0 风险(端口不一致 + starbookService 孤儿)与剩余 P1/P2 卫生项(健康探针、大二进制入库、序列脚本、`.env` 漂移、若干小修),使本地默认值与生产 env 覆盖之间形成**自校验**的部署契约,并通过一次回归把所有"硬编码"参数集中到 4 处可验证的位置(gateway config / 各 main flag / helm values / docker compose)。
|
||||
|
||||
完成后达成:
|
||||
- `go test ./... -run TestPortAlignment` 通过(4.1)。
|
||||
- `git ls-files` 不含 `backend/{assetService,gateway-fixed,test,cleanup-orphan-avatars}` 四大二进制;`backend/services/starbookService/` 被 git 删除(4.2 + 4.3)。
|
||||
- k8s `notificationservice` / `moderationservice` 探针路径与实际 handler 一致;本地 `curl :21010/health` 返回 `{"status":"ok"}`(4.3)。
|
||||
- `scripts/create_gallery_test_users.go` 输出 SQL 末尾含 `setval(...)`;跑完不报 `duplicate key`(4.4)。
|
||||
- `backend/.env.example` 与 `backend/.env` 键集合一致(含 `WS_AI_CHAT_PATH` 等),`.example` 不含真实密钥(4.5)。
|
||||
- §四 P2 八项全部清理或明确 defer(4.9)。
|
||||
|
||||
## Architecture
|
||||
|
||||
本批次不引入新架构,纯**配置归一**与**卫生清理**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ k8s/helm/topfans/values.yaml (端口) │ ← 单一事实来源
|
||||
└──────────────┬───────────────────────┘
|
||||
│ helm install
|
||||
┌────────────────────┼────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
services/*/main.go docker-compose.prod.yml gateway/config/config.go
|
||||
(flag 默认值 = helm) (DUBBO_*_SERVICE_URL) (DUBBO_*_SERVICE_URL 默认)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌────────────┐ ┌─────────────┐
|
||||
│配置断言测试 │ ──────▶│ 4.1 校验 │ ◀──────│ 4.1 校验 │
|
||||
└─────────────┘ └────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
依赖关系(必须按顺序):
|
||||
1. **4.2 starbookService 删除**先做(它依赖 4.3 探针清理 + P0-4 跨服务 import 解耦)—— 完成前 `go build ./...` 会因 `starbookService` 不在 `go.work` 而失败。
|
||||
2. **4.1 端口对齐**与 **4.2 starbook 删除**独立可并行。
|
||||
3. **4.3 探针 + 大二进制**与 4.4 / 4.5 可独立并行。
|
||||
4. **4.9 P2 清理**作为伴随项随 4.1–4.5 顺手做;只有 "JWT 失效" 和 "PII 日志" 单独任务化。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **语言**:Go 1.25.5(`go.work`)
|
||||
- **测试**:Go testing + testify
|
||||
- **K8s 配置**:Helm 3 + values.yaml
|
||||
- **Docker**:docker-compose v2 + Dockerfile.services
|
||||
- **数据库**(仅 4.4 序列脚本):PostgreSQL 16
|
||||
- **静态检查**(后续可加,本批次不引入):golangci-lint
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **不动架构**:本批次不拆库、不拆 `pkg/models`、不引入新 interface;纯"配置正确化"。
|
||||
- **序列规范**:任何手动指定 id 的 INSERT,SQL 末尾必须 `SELECT setval('<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` 已不含 starbookService(grep `starbook` 应无结果)。
|
||||
- [ ] 改 `backend/dev.sh`:
|
||||
- 行 24:从服务循环数组移除 `starbookService`。
|
||||
- 行 347、365:从 `inotifywait --exclude` 列表移除 `starbookService$`。
|
||||
- 行 432、438:从 build/start 循环移除。
|
||||
- 行 468:删除 `build_service "starbookService" ...` 函数调用。
|
||||
- 行 493:删除 `start_service "starbookService" ...` 调用。
|
||||
- 行 510:删除 `start_watcher "starbookService" ...` 调用。
|
||||
- 行 530:删除打印行 `Starbook Service: tri://localhost:20007`。
|
||||
- [ ] 改 `docker/Dockerfile.services:50`:删除 `go build -o /tmp/starbookservice services/starbookService/main.go` + `echo "Built starbookservice"` 两行。
|
||||
- [ ] 改 `docker/docker-compose.local.yml`:删 `starbookservice:` service 块(行 231-262)+ `DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007` env(行 442)+ 任何 depends_on starbookservice 引用(行 482)。
|
||||
- [ ] 改 `docker/docker-compose.prod.yml`:同 local,端口用 `20005`(生产错配)。
|
||||
- [ ] 改 `k8s/helm/topfans/values.yaml`:删 `starbookservice:` 块(行 214-232)+ `gateway.env.DUBBO_STARBOOK_SERVICE_URL` 引用。
|
||||
- [ ] 跑 `cd backend && go build ./...` —— 全部模块编译通过。
|
||||
- [ ] 跑 `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults` —— 通过(已移除 starbook 用例)。
|
||||
- [ ] 跑 `cd backend && go test ./...` —— 全部测试通过。
|
||||
- [ ] 跑 `grep -rn 'starbook\|Starbook' backend docker k8s dev.sh` —— 仅剩 `backend/migrations/` 中可能的 starbook 业务表(如 `asset_registry`),不应再有任何 starbookService 部署引用。
|
||||
- [ ] 准备 commit message:"refactor(services): 删除 starbookService 孤儿代码并内化 AssetRegistryRepository 到 assetService (批次 4.2)"—— **需用户批准后 commit**。
|
||||
|
||||
### Task 3: 健康探针修正 + 大二进制出库(4.3)
|
||||
|
||||
> 对应审计 §三 P1 探针/大二进制行 + 修复方案 4.3。
|
||||
|
||||
**Files**:
|
||||
- `backend/pkg/health/health.go:28-29` —— 当前 `mux.HandleFunc("/health", ...)`;新增 `/healthz` alias 处理(统一两个路径,避免 helm values 长期不一致)
|
||||
- `backend/services/notificationService/main.go` —— 探针端口 21010 已正确(健康服务端口 = Dubbo 端口 + 1000),无须改
|
||||
- `backend/services/moderationService/main.go` —— 探针路径 `/` 改为 `/health`(让 helm values 也对齐)
|
||||
- `k8s/helm/topfans/values.yaml:293,314` —— `notificationservice.healthPath: /health`(从 `/healthz` 改),`moderationservice.healthPath: /health`(从 `/` 改)
|
||||
- `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` —— 探针 path 改 `/health`(无 `healthPath` 模板变量,可保留 default 但需对齐)
|
||||
- `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58` —— 探针 path 改 `/health`
|
||||
- `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml` —— 任何 `HEALTHCHECK` 指令对齐 `/health`
|
||||
- `backend/.gitignore` —— 追加 `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars`、`gateway-fixed`、`gateway/gateway-fixed` 等顶层可执行文件
|
||||
- `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars` —— `git rm --cached`
|
||||
|
||||
**Interfaces**(`backend/pkg/health/health.go` 修改):
|
||||
```go
|
||||
// 在现有 mux.HandleFunc("/health", h.handleHealth) 后追加:
|
||||
mux.HandleFunc("/healthz", h.handleHealth)
|
||||
```
|
||||
(K8s 探针仍可用 `/health`;保留 `/healthz` 兼容老 chart;不破坏既有探针。)
|
||||
|
||||
**Steps**:
|
||||
- [ ] 读 `backend/pkg/health/health.go:27-44`,确认当前 mux 仅注册 `/health`。
|
||||
- [ ] 改 `backend/pkg/health/health.go:29` 后追加 `mux.HandleFunc("/healthz", h.handleHealth)`。
|
||||
- [ ] 跑 `cd backend && go build ./pkg/health/...` —— 通过。
|
||||
- [ ] 改 `k8s/helm/topfans/values.yaml:293`:`healthPath: /health`(notificationservice);`k8s/helm/topfans/values.yaml:314`:`healthPath: /health`(moderationservice,从 `/` 改)。
|
||||
- [ ] 改 `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/healthz" }}` 改为 `{{ $svc.healthPath | default "/health" }}`(默认同步)。
|
||||
- [ ] 改 `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/" }}` 改为 `{{ $svc.healthPath | default "/health" }}`。
|
||||
- [ ] 跑 `cd k8s/helm/topfans && helm template . --values values.yaml 2>&1 | grep -E 'healthz|path:'` —— 验证所有探针 path 为 `/health`。
|
||||
- [ ] 改 `docker/docker-compose.local.yml`:搜 `HEALTHCHECK`,把任何 `curl http://localhost:2xxxx/healthz` 改为 `/health`。
|
||||
- [ ] 改 `docker/docker-compose.prod.yml`:同上。
|
||||
- [ ] 改 `backend/.gitignore`:在"Backend 构建产物"区块追加:
|
||||
```
|
||||
# 顶层历史二进制(本地构建残留,禁止入库)
|
||||
/assetService
|
||||
/gateway-fixed
|
||||
/test
|
||||
/cleanup-orphan-avatars
|
||||
```
|
||||
- [ ] 跑 `cd /Users/liulujian/Documents/code/TopFansByGithub && git rm --cached backend/assetService backend/gateway-fixed backend/test backend/cleanup-orphan-avatars`(若文件已被 .gitignore 覆盖,则先从暂存区移除)。
|
||||
- [ ] 跑 `git ls-files | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应为空。
|
||||
- [ ] 跑 `git status --ignored | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应列出(说明已忽略但本地保留)。
|
||||
- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。
|
||||
- [ ] 跑本地健康检查:`cd backend && (PORT=20010 ./services/notificationService/notificationService &) ; sleep 2 ; curl -s http://localhost:21010/health ; curl -s http://localhost:21010/healthz` —— 两个路径均返回 `{"status":"ok"}`。
|
||||
- [ ] 准备 commit message:"fix(deploy): 健康探针路径统一为 /health + 顶层历史二进制出库 (批次 4.3)"—— **需用户批准后 commit**(建议拆为两个 commit:二进制出库 + 探针修正,分别走 `git add .gitignore` 与 `git add helm`)。
|
||||
|
||||
### Task 4: 序列脚本 setval(4.4)
|
||||
|
||||
> 对应审计 §三 P1 `create_gallery_test_users.go` 与修复方案 4.4。遵循 `CLAUDE.md` PostgreSQL 序列规范。
|
||||
|
||||
**Files**:
|
||||
- `backend/scripts/create_gallery_test_users.go:59-152` —— 在每个手动 INSERT 表后追加 `SELECT setval(...)`
|
||||
|
||||
**Interfaces**(无新接口;纯输出增强):
|
||||
```go
|
||||
// 输出 SQL 末尾追加序列重置;当前 main 函数应收集受影响表名集合,结尾统一输出。
|
||||
var tablesToReset = []string{"users", "fan_profiles", "booth_slots", "assets", "exhibitions"}
|
||||
for _, tbl := range tablesToReset {
|
||||
fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl)
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- [ ] 通读 `backend/scripts/create_gallery_test_users.go:1-170`,列出所有手动指定 id 的 INSERT 表:`users` (id=100/101)、`fan_profiles` (无 id,仅 `user_id, star_id` 复合键——若 BIGSERIAL 则无需)、`booth_slots` (slot_id=1001-1003, 2001-2003)、`assets` (id=1000-1003)、`exhibitions` (无 id)。
|
||||
- [ ] 检查 `fan_profiles` 与 `exhibitions` 是否 BIGSERIAL(查 migrations:`grep -E 'CREATE TABLE (fan_profiles|exhibitions)' backend/migrations/*.sql`)。
|
||||
- [ ] 改 `backend/scripts/create_gallery_test_users.go`:
|
||||
- 在 `var` 区块追加 `tablesToReset := []string{"users", "booth_slots", "assets"}`(`fan_profiles` 与 `exhibitions` 如非 BIGSERIAL 则不计入)。
|
||||
- 在 `main()` 末尾、所有 `fmt.Println` 之前,循环 `for _, tbl := range tablesToReset { fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl) }`。
|
||||
- 在最后追加 `fmt.Println("-- 序列重置完成")`。
|
||||
- [ ] 跑 `cd backend && go run scripts/create_gallery_test_users.go > /tmp/test_users.sql`,确认末尾包含 `SELECT setval('users_id_seq', ...)`、`SELECT setval('booth_slots_id_seq', ...)`、`SELECT setval('assets_id_seq', ...)` 三行。
|
||||
- [ ] 验证迁移可行性:在测试库 `psql -f /tmp/test_users.sql`(用 docker `postgresql-database-1` 即可),确认不报 `duplicate key` 且三条 `setval` 执行成功。
|
||||
- [ ] 跑 `cd backend && go build ./...` —— 通过。
|
||||
- [ ] 跑 `cd backend && go vet ./scripts/...` —— 无 warning。
|
||||
- [ ] 准备 commit message:"fix(scripts): create_gallery_test_users.go 输出 SQL 末尾补 setval 序列重置 (批次 4.4)"—— **需用户批准后 commit**。
|
||||
|
||||
### Task 5: .env ↔ .env.example 对齐(4.5)
|
||||
|
||||
> 对应审计 §三 P1 漂移与修复方案 4.5。`.env.example` 仅留占位符,`.env` 真值不进 git。
|
||||
|
||||
**Files**:
|
||||
- `backend/.env.example` —— 增补缺失键、删除占位符中的真实密钥痕迹(`LTAI5t...`、`sk-cp-`、`sk-proj-`、`app-...` 等)
|
||||
- `backend/.env` —— 实际环境值,本 Task 不入仓但需确保所有代码读取的键均有对应 `getEnv(...)` 默认值或 example
|
||||
- `backend/gateway/config/env_audit_test.go` (新增) —— 断言 `.env` 解析后所有"必填"键非空(仅对非敏感键,如 `WS_AI_CHAT_PATH`、`LANDING_BASE_URL`)
|
||||
|
||||
**Interfaces**(无新接口;测试骨架):
|
||||
```go
|
||||
// backend/gateway/config/env_audit_test.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnvExampleDrift 断言 .env.example 含全部 .env 中已文档化的键(粗粒度:按 KEY= 前缀)
|
||||
func TestEnvExampleDrift(t *testing.T) {
|
||||
exampleBytes, err := os.ReadFile("../../.env.example")
|
||||
if err != nil { t.Fatal(err) }
|
||||
envBytes, err := os.ReadFile("../../.env")
|
||||
if err != nil { t.Fatal(err) }
|
||||
exampleKeys := parseEnvKeys(string(exampleBytes))
|
||||
envKeys := parseEnvKeys(string(envBytes))
|
||||
// 反向断言:.env 中有的键 .env.example 必出现(除 SECRET_KEY/JWT_SECRET/真实 key)
|
||||
for k := range envKeys {
|
||||
if isSecretKey(k) || isRealAPIKey(k) { continue }
|
||||
if _, ok := exampleKeys[k]; !ok {
|
||||
t.Errorf(".env 有键 %q 但 .env.example 缺失", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**:
|
||||
- [ ] 跑 `diff <(grep -E '^[A-Z_]+=' backend/.env | cut -d= -f1 | sort) <(grep -E '^[A-Z_]+=' backend/.env.example | cut -d= -f1 | sort)` —— 列出 .env 独有键(应在 .env.example 补)和 .env.example 独有键(可能是模板占位/可保留)。
|
||||
- [ ] 列出 `.env` 独有键(实际环境值,非 secret):`WS_AI_CHAT_PATH`、`LASER_COMPOSITOR_URL`、`COMPOSITOR_PORT`、`LASER_GEN_PROVIDER`、`MINIMAX_API_KEY`、`MINIMAX_API_URL`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`DIFY_API_BASE`、`DIFY_API_KEY`、`JWT_SECRET`、`ENV`、`LOG_LEVEL`、`OSS_REGION`、`OSS_BUCKET_NAME`、`OSS_STS_ROLE_ARN`、`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_AVATAR_DIR`、`OSS_ASSET_DIR`、`OSS_TOKEN_EXPIRE_TIME`、`REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB`、`DB_HOST`、`DB_PORT`、`DB_USER`、`DB_PASSWORD`、`DB_NAME`、`GIN_MODE`、`SERVER_PORT`、`SECRET_KEY`。
|
||||
- [ ] 列出 `.env.example` 独有键(当前文档化但 .env 未用):`LANDING_BASE_URL`、`PUSH_ENABLED`、`PUSH_URL`、`PUSH_TIMEOUT_MS`、`DIFY_TIMEOUT_SEC`、`DIFY_WORKFLOW`、`SEGMENT_PROVIDER`、`SEGMENT_INFERENCE_URL`、`DUBBO_USER_SERVICE_URL` 至 `DUBBO_STARBOOK_SERVICE_URL`(4.2 后应删 Starbook)。
|
||||
- [ ] 改 `backend/.env.example`:
|
||||
- 行 87:取消注释 `WS_AI_CHAT_PATH=/ai-chat`(去掉 `# ` 前缀)。
|
||||
- 行 121-123:保留 `PUSH_ENABLED/PUSH_URL/PUSH_TIMEOUT_MS`,但 `PUSH_URL=` 留空占位符。
|
||||
- 行 124-128:删除 `OPENAI_*` 三行(真实密钥痕迹),改为 `# OPENAI_* 由 LASER_GEN_PROVIDER=openai 启用时填入`。
|
||||
- 行 131-133:删除 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ`(Dify 真实密钥),改为 `DIFY_API_KEY=`。
|
||||
- 行 106:删除 `OPENAI_API_KEY=sk-proj-...` 真实值,改为 `OPENAI_API_KEY=`。
|
||||
- 增补 `LANDING_BASE_URL=http://localhost:5173`(已在最顶部,确认);增补 `LASER_COMPOSITOR_URL=http://127.0.0.1:7002`、`LASER_GEN_PROVIDER=openai`;增补 `MINIMAX_API_KEY=` 与 `MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation`(已有,确认)。
|
||||
- 增补 `SEGMENT_*`(已有 73-74 行,确认)。
|
||||
- 增补 `DIFY_TIMEOUT_SEC=60`(已有 133 行,确认)。
|
||||
- [ ] 在 `.env.example` 顶部加注释:
|
||||
```
|
||||
# .env.example 仅作键清单 + 占位符。真实值由部署环境注入(docker env_file / k8s Secret / systemd EnvironmentFile)。
|
||||
# 严禁把真实 key 写入本文件;本文件应可直接 commit。
|
||||
```
|
||||
- [ ] 创建 `backend/gateway/config/env_audit_test.go`(见上面骨架),实现 `parseEnvKeys`(按行解析 `^[A-Z_]+=`)、`isSecretKey`、`isRealAPIKey`(白名单:`SECRET_KEY/JWT_SECRET/PASSWORD/*_KEY/*_SECRET/TOKEN`)。
|
||||
- [ ] 跑 `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` —— 通过。
|
||||
- [ ] 确认 `git check-ignore backend/.env` —— 返回 0(已忽略);`git ls-files backend/.env` —— 应为空。
|
||||
- [ ] 确认 `git ls-files backend/.env.example` —— 有文件。
|
||||
- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。
|
||||
- [ ] 准备 commit message:"fix(env): 同步 .env.example 与 .env 键清单并清理示例文件中的真实密钥痕迹 (批次 4.5)"—— **需用户批准后 commit**。
|
||||
|
||||
### Task 6: P2 清理(4.9)
|
||||
|
||||
> 对应审计 §四 P2 八项。所有改动均小而独立,按表格逐项修。
|
||||
|
||||
**Files & Actions**(按文件:行 + 改动说明):
|
||||
|
||||
| P2 项 | 文件:行 | 改动 |
|
||||
|-------|---------|------|
|
||||
| `parseRPCError` 字符串解析 | `backend/gateway/controller/asset_controller.go:158-194` | 改用 `google.golang.org/grpc/status` 的 `status.FromError(err)`;删除正则解析。返回结构 `{code: int, message: string}`,调 7 处 `parseRPCError(err)` 调用(行 240/286/365/492/568/700/765/827)保持签名不变 |
|
||||
| 原始 err 泄露前端 | `backend/gateway/controller/auth_controller.go:224,294,306`、`user_controller.go:141,247,498` | 把 `c.JSON(500, gin.H{"message": err.Error()})` 改为 `response.InternalError(c)` 或 `response.BadRequest(c, "操作失败")` + `logger.Logger.Error("...", zap.Error(err))` 服务端日志保留 err |
|
||||
| `ResetPassword` 不失效 JWT | `backend/services/userService/service/user_service.go:681` | 现状已在事务内把 `access_token` 置 nil + `token_expires_at` 置 nil。补:`gateway/middleware/auth_middleware.go` 在解析 JWT 后**额外**比对 `users.access_token`(如已为 nil 则 401)。如工期紧张,至少在 `user_service.go:681` 注释里写明 "JWT 失效留待 ticket #XX 处理;当前实现只阻止后续发新 token 复用"——选择最小改动(仅注释 + ticket 链接)以避免越界改动 middleware |
|
||||
| `UpdateAvatar` URL 不校验同源 | `backend/services/userService/service/user_service.go:1023` | 在写库前增加同源校验:`u, err := url.Parse(req.AvatarUrl)`;`if u.Host != "" && !strings.Contains(u.Host, "aliyuncs.com") { return nil, ErrInvalidAvatarURL }`;白名单仅 `aliyuncs.com` 子域 |
|
||||
| PII 进 INFO 日志 | `backend/services/aiChatService/provider/ai_chat_provider.go:107`、`mint_service.go`、`user_service.go` 多处 | 把 `user_id`/`session_id` 之外不要打印 `message`/`mobile`;或改用 `logger.Logger.Debug(...)`;`mobile` 用 `mobile[:3] + "****" + mobile[7:]` 脱敏 |
|
||||
| pkg/mq 死代码 | `backend/pkg/mq/streams/adapter.go`(`EventProducer`/`Stream*` 常量)、`backend/pkg/mq/asynq/adapter.go:117`(`Delete` 假实现) | 与批次 3.3 一并决断(用户已选"停用 streams adapter"则本 Task 把 `streams/` 整个子包删除、`mq.go` 的 `MQDriver = Streams` 分支删除);asynq `Delete` 暂保留(实际不调用),注释"未接线" |
|
||||
| `is_processed` 复用为 settled | `backend/services/galleryService/mq/consumer.go:207` | **由批次 1.1(`exhibition-settlement-idempotency` plan Task 3)负责** `settled_at` 列 + `isSettled/markSettled` 改写。本 plan 不重复编辑此函数,仅在回归时校验其已切换。 |
|
||||
| 直连 Redis Pub/Sub | `backend/services/activityService/service/activity_service.go:217,1579` | 两处 `s.redisClient.Publish(...)` 改为走 `adapter.EventProducer.Publish(ctx, "activity.contributions", payload)`(若 pkg/mq 已确定保留,否则本项 defer 到 3.3) |
|
||||
| 周边密钥兜底 | `backend/pkg/peripheral/sign.go:33-44` | 去掉 `JWT_SECRET` 兜底与硬编码 `"default-dev-secret-change-me"`;`SECRET_KEY == ""` 时 `panic("SECRET_KEY is required")`(fail-fast) |
|
||||
|
||||
**Interfaces**(新增/修改):
|
||||
```go
|
||||
// backend/gateway/controller/asset_controller.go (修改后)
|
||||
import "google.golang.org/grpc/status"
|
||||
|
||||
func parseRPCError(err error) (code int, message string) {
|
||||
if err == nil { return http.StatusOK, "" }
|
||||
if st, ok := status.FromError(err); ok {
|
||||
// grpc status code → HTTP code 映射(参考 grpc-gateway 约定)
|
||||
return grpcCodeToHTTP(st.Code()), st.Message()
|
||||
}
|
||||
return http.StatusInternalServerError, "服务暂时不可用"
|
||||
}
|
||||
|
||||
func grpcCodeToHTTP(c codes.Code) int {
|
||||
switch c {
|
||||
case codes.NotFound: return http.StatusNotFound
|
||||
case codes.InvalidArgument: return http.StatusBadRequest
|
||||
case codes.Unauthenticated: return http.StatusUnauthorized
|
||||
case codes.PermissionDenied: return http.StatusForbidden
|
||||
case codes.AlreadyExists: return http.StatusConflict
|
||||
default: return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// backend/pkg/peripheral/sign.go (修改后)
|
||||
func getSecret() string {
|
||||
secretOnce.Do(func() {
|
||||
secretVal = os.Getenv("SECRET_KEY")
|
||||
if secretVal == "" {
|
||||
panic("SECRET_KEY is required for peripheral HMAC signing")
|
||||
}
|
||||
})
|
||||
return secretVal
|
||||
}
|
||||
```
|
||||
|
||||
**Steps**(按表格逐项):
|
||||
- [ ] **A. parseRPCError**:改 `backend/gateway/controller/asset_controller.go:158-194` 为 `status.FromError` 形式。7 处调用点(行 240/286/365/492/568/700/765/827)签名不变,无须改。跑 `cd backend && go build ./gateway/...` 通过。
|
||||
- [ ] **B. err 泄露**:改 `auth_controller.go:224,294,306` + `user_controller.go:141,247,498` 五处,把 `err.Error()` 移到 `logger.Logger.Error("...", zap.Error(err))`,响应改为稳定文案(如 `response.InternalError(c, "操作失败")`)。grep 验证 `c.JSON(500, gin.H{"message": err.Error()})` 仅剩 0 处。
|
||||
- [ ] **C. ResetPassword JWT**:决策点——读 `backend/gateway/middleware/auth_middleware.go` 当前是否校验 `users.access_token`;若否,最小改动为更新 `user_service.go:681` 注释(添加 ticket 链接),不修改 middleware;若已校验,本项完成。`git grep 'access_token' backend/gateway/middleware/`。
|
||||
- [ ] **D. UpdateAvatar 同源**:改 `user_service.go:1023` 加 `url.Parse` + `aliyuncs.com` 白名单;`pkg/errors` 增加 `ErrInvalidAvatarURL`。
|
||||
- [ ] **E. PII 日志**:改 `ai_chat_provider.go:107` 不打 `message`;`user_service.go` 中 `mobile` 打印用脱敏函数。grep `logger.Logger.Info.*mobile.*req` / `zap.String("message", message)` 全面排查。
|
||||
- [ ] **F. pkg/mq 死代码**:读 `backend/pkg/mq/mq.go` 看 `MQDriver = Streams` 分支是否被引用。若无引用,删除 `backend/pkg/mq/streams/` 子包 + `mq.go` 中的 streams init 分支;`asynq/adapter.go:117` 的 `Delete` 假实现加注释 `// TODO: 实际未调用,asynq.DeletedTaskInfo 返回 false 即视为不存在`。
|
||||
- [ ] **G. `is_processed` 复用**:**不在本 plan 编辑**。`settled_at` 列 + `isSettled/markSettled` 改写归批次 1.1(`exhibition-settlement-idempotency` plan Task 1 migration + Task 3)。本 plan 仅校验:`grep -n 'settled_at' backend/services/galleryService/mq/consumer.go` 确认已切换;未切换则回到批次 1.1 执行。
|
||||
- [ ] **H. Redis Pub/Sub 直连**:决策点——读 `backend/pkg/mq/mq.go` 的 `EventProducer` 接口是否已稳定;若稳定,改 `activity_service.go:217,1579` 为 `mq.GetEventProducer().Publish(ctx, "activity.contributions", payload)`;否则本项 defer 到 3.3。
|
||||
- [ ] **I. 周边密钥 fail-fast**:改 `pkg/peripheral/sign.go:33-44`,去掉 JWT_SECRET 兜底与硬编码默认值。跑 `cd backend && SECRET_KEY= go test ./pkg/peripheral/...` —— 必须 panic 或返回明确错误(不能静默用 dev 默认值)。
|
||||
- [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。
|
||||
- [ ] 跑 `cd backend && go vet ./...` —— 无 warning。
|
||||
- [ ] 跑 `cd backend && grep -rnE 'parseRPCError.*errStr\|err\.Error\(\)\|default-dev-secret\|JWT_SECRET.*secretVal' .` —— 仅 0 处(除新增注释)。
|
||||
- [ ] 准备 commit message(建议拆 3 个):"refactor(gateway): parseRPCError 改用 grpc/status.FromError (批次 4.9-A)" + "fix(gateway): 修复 5 处原始 err 泄露前端 (批次 4.9-B)" + "fix(peripheral): SECRET_KEY 缺失 fail-fast,去 JWT_SECRET/dev 默认兜底 (批次 4.9-I)"—— **每个 commit 需用户批准**。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
> 严格按 `CLAUDE.md` 全局自审规则:通读本文 §1→§5,核对跨章节引用、Go 编译预期、副作用、commit 边界。
|
||||
|
||||
### 修改的章节
|
||||
- §File Structure(任务清单)
|
||||
- §Task 1–6(步骤、文件、接口)
|
||||
|
||||
### 未改动但通读确认的章节
|
||||
- §Goal / §Architecture / §Tech Stack —— 与原方案对齐,无改动需求。
|
||||
- §Global Constraints —— 与审计 §五 修复顺序、批次 4 路线图一致;序列规则、commit 守则与 `CLAUDE.md` 一致。
|
||||
|
||||
### 跨章节引用一致性检查
|
||||
- **端口矩阵(4.1 ↔ §File Structure 表)**:4.1 列出 11 个服务端口,§File Structure 表也列 11 个服务端口——一致。
|
||||
- **starbookService 删除(4.2 ↔ 4.1 端口断言)**:4.1 `serviceDefaultPorts` 与 `cases` map 必须不含 starbook——本计划已明确在 4.2 完成后删除 `StarbookServiceURL` 字段并同步 port_test 的 `cases` map。
|
||||
- **健康探针(4.3 ↔ helm values)**:4.3 改 `notificationservice.healthPath` 从 `/healthz` → `/health`,与 `backend/pkg/health/health.go:28-29` 注册 `/health` 一致——OK。
|
||||
- **大二进制(4.3 ↔ §Global Constraints 不动产物)**:`backend/bin/*` 与 `.gitignore` 第 10 行 `bin/` 仍受保护;本计划只追加根级四条二进制忽略——一致。
|
||||
- **序列脚本(4.4 ↔ §Global Constraints)**:遵循 `CLAUDE.md` 强制规则——一致。
|
||||
- **.env 漂移(4.5 ↔ .gitignore)**:`.env` 已在 `.gitignore` 第 21 行忽略;本计划仅改 `.env.example`——一致。
|
||||
- **P2 清理(4.9 ↔ 批次 3.3 决断)**:H/F 两项依赖批次 3.3 MQ 决断结论——本计划用"决策点"标注,未预设结论。
|
||||
- **commit 边界(每 Task ↔ §Global Constraints)**:每个 Task 末尾标注"需用户批准后 commit",未授权自提交——一致。
|
||||
|
||||
### Go 编译验证预期(必须能在本地真实跑通)
|
||||
|
||||
| 步骤 | 命令 | 预期 |
|
||||
|------|------|------|
|
||||
| 端口测试 | `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults -v` | PASS |
|
||||
| env 审计 | `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` | PASS |
|
||||
| 全量编译 | `cd backend && go build ./...` | exit 0 |
|
||||
| 全量测试 | `cd backend && go test ./...` | exit 0 |
|
||||
| vet | `cd backend && go vet ./...` | exit 0 |
|
||||
| helm lint | `cd k8s/helm/topfans && helm lint .` | 0 errors |
|
||||
| 大二进制 | `git ls-files \| grep -E '^backend/(assetService\|gateway-fixed\|test\|cleanup-orphan-avatars)$'` | empty |
|
||||
| starbook 删除 | `find backend -path '*starbook*' -not -path '*/node_modules/*'` | 仅 `backend/migrations/` 中 starbook 业务表(如有),无 `starbookService/` 目录 |
|
||||
|
||||
### 优先级分类
|
||||
|
||||
- **P0(先做,挡构建)**:4.2 starbookService 删除(当前 `go build ./services/assetService/...` 失败)+ 4.1 端口对齐(影响所有下游服务)。
|
||||
- **P1(同期)**:4.3 探针 + 大二进制 + 4.4 setval + 4.5 env。
|
||||
- **P2(顺手)**:4.9 八项;其中 4.9-I 周边密钥 fail-fast 涉及安全边界,应排在 4.9 内靠前。
|
||||
|
||||
### 失败模式预演
|
||||
|
||||
- 若 4.2 完成后 `go build ./...` 仍报 starbook 引用:检查 `assetService/service/mint_service.go` 是否还有 `starbookRepo` import 残留(grep 验证)。
|
||||
- 若 4.1 port_test 报某服务端口不匹配:回到该服务 `main.go` 顶部 `var port = flag.Int(...)` 默认值核对,更新 `serviceDefaultPorts` map 与默认值。
|
||||
- 若 4.3 helm template 探针 path 仍为 `/healthz`:检查 `_helpers.tpl` 或 deployment.yaml `default` 值是否覆盖——可能需硬改两处。
|
||||
- 若 4.4 setval 报 `relation "xxx_id_seq" does not exist`:该表非 BIGSERIAL,从 `tablesToReset` 移除。
|
||||
- 若 4.5 env 审计失败:逐一对照 `isSecretKey`/`isRealAPIKey` 白名单,确认是否把真 secret 误识别为 drift。
|
||||
|
||||
### 跨任务依赖(按执行顺序)
|
||||
|
||||
```
|
||||
4.1 ──┐
|
||||
├──▶ 4.6(提交后整体回归)
|
||||
4.2 ──┤
|
||||
│
|
||||
4.3 ──┤
|
||||
│
|
||||
4.4 ──┤
|
||||
│
|
||||
4.5 ──┤
|
||||
│
|
||||
4.9 ──┘
|
||||
```
|
||||
|
||||
4.1 与 4.2 互不阻塞(4.2 内 assetService 解耦独立完成)。4.3/4.4/4.5/4.9 与前两者可并行。4.9 内部分项按"决策点"标注——若用户已决断批次 3.3,则 F/H 可立即执行;否则 defer。
|
||||
1022
docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md
Normal file
1022
docs/superpowers/plans/2026-07-21-exhibition-hours-idempotency.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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` 已 import,consumer.go 顶部已有)。
|
||||
|
||||
- [ ] **Step 5: Commit**(用户批准后)
|
||||
|
||||
```bash
|
||||
git add backend/services/galleryService/mq/consumer.go
|
||||
git commit -m "refactor(gallery): use settled_at instead of is_processed for settle idempotency (batch 1.1)
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <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.3(created_at 单位)→ Task 1 Step1 + Task 2 Step3;死 worker 删除 → 前置已完成。累计时长幂等(1.2)、mint(1.4-1.6)不在本 plan(各自独立)。
|
||||
- **Placeholder 扫描**:无 TBD;migration/Go 代码均给出完整内容。
|
||||
- **类型一致性**:`CreateRevenueRecord` 签名与现有一致;冲突分支回查返回 `*model.ExhibitionRevenueRecord`,调用方 `.ID` 可用;`settled_at` 列名与 migration 一致。
|
||||
1060
docs/superpowers/plans/2026-07-21-mint-correctness.md
Normal file
1060
docs/superpowers/plans/2026-07-21-mint-correctness.md
Normal file
File diff suppressed because it is too large
Load Diff
881
docs/superpowers/plans/2026-07-21-secrets-remediation.md
Normal file
881
docs/superpowers/plans/2026-07-21-secrets-remediation.md
Normal 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`+Secret;L12 OSS_ROLE_ARN 含账号 ID |
|
||||
| `backend/deploy/envs/user.env` | **tracked** | L21-22 同一套 OSS key 复用为 SMS key |
|
||||
| `backend/deploy/envs/notification.env` | **tracked** | L22 `PUSH_URL=https://env-00jy6bcqqwy6.dev-hz.cloudbasefunction.cn/sendMessage`(注释明令「不要提交」) |
|
||||
| `backend/deploy/envs/{activity,common,gallery,gateway,social}.env` | **tracked** | 当前 grep 未见明文密钥,但**只要被跟踪**,未来误改就被泄漏——一并出库 |
|
||||
| `backend/.env.example` | **tracked** | L106 `OPENAI_API_KEY=sk-proj-srKxybHaGxho...`;L124 `OPENAI_API_KEY=sk-eIOujD5rUug...`(第二把)+ 微达配置;L131 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ` |
|
||||
| `docker/.env` | **tracked** | L5 `OPENAI_API_KEY=sk-eIOujD5rUug...`;L8 `DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e` |
|
||||
| `docker/.env.local` | **tracked** | L21-22 同 OSS key |
|
||||
| `docker/.env.prod` | **tracked** | L18-19 第二套 OSS `LTAI5t99tafzfyrzbbEbjryH`+Secret;L25 MiniMax `sk-api-...`;L47 OpenAI 微达;L52-53 同 SMS key;L63 注释掉的 Dify key |
|
||||
| `k8s/helm/topfans/values-prod.yaml` | **gitignored** ✅ | L61 `.gitignore: k8s/helm/**/values-prod.yaml` 已生效(exit 0) |
|
||||
| `k8s/helm/topfans/values-prod.example.yaml` | **tracked** ✅ | 仅 `__FILL_ME__` 占位符,无密钥 |
|
||||
| `k8s/helm/topfans/templates/secrets/{db,oss}-credentials.yaml` | **tracked** ✅ | Helm 模板,`{{ .Values.secrets.* }}` 引用,无明文 |
|
||||
| `backend/services/aiChatService/.env` | **gitignored** ✅ | `backend/.gitignore:21` 覆盖 |
|
||||
| `backend/.env`(仓库根) | **gitignored** ✅ | 根 `.gitignore` `.env` 覆盖 |
|
||||
|
||||
> **结论**:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` = **12 个文件**需在代码侧处理;其中**8 个有真实密钥**,**4 个(`.env.example`、common.env、activity.env、gallery.env、gateway.env、social.env)**只改`.gitignore`即可。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` — 在 `# statisticService` 区块后追加 `backend/deploy/envs/*.env`、`docker/.env*` 规则。
|
||||
- **新建** `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore` — 当前不存在,兜底忽略 `*.env`、`*.env.*`、`!.env.example`。
|
||||
- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` — 追加 `deploy/envs/*.env`(当前仅忽略 `.env`、`.env.local`、`.env.*.local`,未覆盖 `deploy/envs/` 子目录下的 `.env`)。
|
||||
- **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example` — 8 处真实密钥替换为 `<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=1(grep 无匹配返回 1)。**任何输出都是遗漏**。
|
||||
|
||||
- [ ] **Step 2: 工作区扫描(含 gitignore 后的未跟踪文件)**
|
||||
|
||||
```bash
|
||||
cd /Users/liulujian/Documents/code/TopFansByGithub
|
||||
# 扫描所有 .env.example / .env / yaml / json / go(排除 vendor/node_modules)
|
||||
grep -rEn 'sk-(proj-|api-)[A-Za-z0-9]{20,}|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}' \
|
||||
--include='*.env' --include='*.env.example' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.go' \
|
||||
--exclude-dir=node_modules --exclude-dir=unpackage --exclude-dir=.git --exclude-dir=vendor \
|
||||
. 2>/dev/null
|
||||
echo "exit=$?"
|
||||
```
|
||||
Expected: 空输出,exit=1。`backend/deploy/envs/*.env` 与 `docker/.env*` 仍含真值但**已被 gitignore**(Step 2 不跟踪它们,但仍在磁盘,扫描仍可能命中——**需要人工判断是否命中在 gitignore 列表里**)。
|
||||
|
||||
> 实际预期会命中 `backend/deploy/envs/asset.env` `docker/.env.prod` 等(因为扫描是文件层非 git 层)。**通过** = 命中文件**全部在** `.gitignore` 名单里。
|
||||
|
||||
- [ ] **Step 3: 全仓 Go 编译确认未引入新依赖**
|
||||
|
||||
```bash
|
||||
cd /Users/liulujian/Documents/code/TopFansByGithub/backend
|
||||
go build ./... 2>&1 | tail -20
|
||||
```
|
||||
Expected: 无错误(本 plan 不改 Go 代码,仅改 `.gitignore`、`.env.example`,应无影响)。
|
||||
|
||||
- [ ] **Step 4: 确认 `.env.example` 仍能被 Go 代码读到 key**
|
||||
|
||||
> 跑一个 5 行 Go 小程序读 env,确认占位符能被正确解析为字符串 `<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-1:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` 11 处真实密钥在 git 跟踪中。批次 0 修复后,**所有密钥改走仓库外注入**。
|
||||
- 教训:`.env.example` 不等于 `.env`,跟踪前者 OK,跟踪后者永远错。模板里写密钥原文也是错——必须写占位符。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit(用户批准后,运维审阅后)**
|
||||
|
||||
> **Commit 命令(用户批准后)**:
|
||||
> ```bash
|
||||
> git add docs/security/secrets-handling.md
|
||||
> git commit -m "docs(security): secrets handling SOP (batch 0)
|
||||
>
|
||||
> 密钥清单 + 轮换流程 + 紧急吊销 + 注入路径 + git 历史清理
|
||||
>
|
||||
> Co-Authored-By: Claude Fable 5 <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(运维 SOP,AI 仅核查)= **运维主导**,文档化。
|
||||
- 批次 0 §「git 历史清理」= Task 8(运维执行,AI 仅核查)= **运维主导**,文档化。
|
||||
- 部署消费方迁移 = Task 4(AI 做骨架)= **完整覆盖**。
|
||||
- 防止再次泄漏 = Task 5(`detect-secrets.sh`)+ Task 6(SOP 文档)= **预防措施**。
|
||||
- **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` 不全,需补全。
|
||||
1518
docs/superpowers/plans/2026-07-21-service-stability.md
Normal file
1518
docs/superpowers/plans/2026-07-21-service-stability.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,263 @@
|
||||
# 每日任务配置驱动 + 后端事件驱动触发 设计方案
|
||||
|
||||
> **★ MVP 优先**:本文档是可落地的 MVP 实施方案(方案 A)。计数模型是二元模型的低成本超集,予以采纳;多对多映射(方案 B)与规则引擎(方案 C)**不在本次实现**,仅作为 §7 平滑升级路线图。
|
||||
|
||||
## 文档说明
|
||||
|
||||
- **适用范围**:`taskService` 每日任务(`task_type='daily'`)的完成/触发机制改造,及其前端联动。不含引导任务(onboarding)与收益(revenue)逻辑。
|
||||
- **工作量估算**:约 1 周(2 列 + 1 列 migration、1 个完成引擎方法、MQ 事件接入、3~4 处 emit 点、重置改一行、service 单测)。
|
||||
- **前置版本/历史**:现状为前端硬编码上报 + 后端 `def.TaskKey == eventType` 内联匹配;`daily_mint` / `daily_place_asset` 无 emit 点,属悬空任务(本方案顺带修复)。
|
||||
- **目标读者**:后端 taskService 开发、前端 App 开发、DBA。
|
||||
|
||||
---
|
||||
|
||||
## 一、方案概述(必读)
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
**业务问题**
|
||||
- 每日任务列表会"不定时更改":大多数时候是改文案 / 奖励 / 次数 / 顺序 / 上下架(复用已有用户行为),偶尔引入全新的完成行为。
|
||||
- 诉求:每次改动**前端不做大改**。
|
||||
|
||||
**技术问题**
|
||||
- 完成判定硬编码在前端(`Header.vue` 报 `daily_login`、`exhibition.vue` 报 `daily_browse_asset`),加任务就得改前端,且客户端可伪造。
|
||||
- 后端匹配是内联的 `def.TaskKey == eventType`,带 `TODO`,无法表达"次数""多事件"。
|
||||
- `daily_mint` / `daily_place_asset` 在 `task_definitions` 中 active,但全仓库无对应上报点 → 用户永远无法完成(已由实连本地库 `top-fans` 确认:这两个 task_key 在 `user_daily_task_progress` 中 0 行)。
|
||||
|
||||
### 整体实现路径
|
||||
|
||||
| 阶段 | 内容 | 估时 |
|
||||
|---|---|---|
|
||||
| 1. 数据模型 | `task_definitions` 加 `trigger_event`/`target_count`;`user_daily_task_progress` 加 `progress`;migration + backfill | 0.5d |
|
||||
| 2. 事件目录 | 共享事件常量文件 + 治理规则 | 0.5d |
|
||||
| 3. 完成引擎 | `ProcessTaskEvent` 方法,替换内联匹配 | 1.5d |
|
||||
| 4. 事件接入 | MQ `task:event` 消费 + `ReportEvent` 改为生产者;铸造/上架/登录/浏览源头 emit | 2d |
|
||||
| 5. 重置 | `ResetAllDailyTasks` 增加 `progress=0` | 0.25d |
|
||||
| 6. 测试 | `ProcessTaskEvent` service 单测 | 1d |
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **触发源 = 后端事件驱动为主,前端通用上报兜底**(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。
|
||||
2. **映射机制 = 方案 A:`task_definitions` 单列 `trigger_event` + `target_count`**(详见 §2、§4)。最低复杂度,覆盖 90% 场景。
|
||||
3. **计数模型**:`target_count=1` 等价"首次",`>1` 为计数型;MVP 不做 distinct 去重(详见 §5)。
|
||||
4. **单一隔离单元**:所有匹配逻辑封在 `ProcessTaskEvent` 一个方法内,成为 A→B 升级的唯一改动点(详见 §4、§7)。
|
||||
|
||||
### 核心架构图(TL;DR)
|
||||
|
||||
```
|
||||
[后端服务: 登录/铸造成功/上架成功] ──publish──┐
|
||||
▼
|
||||
[前端纯 UI 动作(如浏览详情)] ─reportEvent─▶ gateway ─▶ MQ 统一事件
|
||||
│ task:event { user_id, star_id, event_type }
|
||||
▼
|
||||
taskService MQ consumer ┐
|
||||
ReportEvent RPC(兜底) ├─▶ DailyTaskService.ProcessTaskEvent()
|
||||
┘ │
|
||||
▼
|
||||
查 active daily 定义 where trigger_event = event_type
|
||||
→ GetOrCreate 进度 → progress += 1
|
||||
→ progress >= target_count ? status=completed
|
||||
│
|
||||
(领取仍是独立步骤: ClaimDailyTask → 发水晶 → claimed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型变更
|
||||
|
||||
所有变更需写 migration(放 `backend/migrations/`),并在 `docker/init-db.sql` 同步。
|
||||
|
||||
### 2.1 `task_definitions` 新增 2 列
|
||||
|
||||
| 列 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `trigger_event` | `varchar(50)` | 可空 | 驱动该任务的事件名,取自 §3 事件目录 |
|
||||
| `target_count` | `int` | `not null default 1` | 完成所需次数;`=1` 即"首次",等价现状 |
|
||||
|
||||
对应 GORM model `model.TaskDefinition` 增加字段:
|
||||
```go
|
||||
TriggerEvent string `gorm:"column:trigger_event;size:50"`
|
||||
TargetCount int `gorm:"column:target_count;default:1"`
|
||||
```
|
||||
|
||||
**Backfill(存量 4 行)**:`trigger_event = task_key`,`target_count = 1`。
|
||||
```sql
|
||||
UPDATE task_definitions SET trigger_event = task_key WHERE task_type = 'daily' AND trigger_event IS NULL;
|
||||
UPDATE task_definitions SET target_count = 1 WHERE target_count IS NULL OR target_count = 0;
|
||||
```
|
||||
|
||||
### 2.2 `user_daily_task_progress` 新增 1 列
|
||||
|
||||
| 列 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `progress` | `int` | `not null default 0` | 当前累计次数;`progress >= target_count` → completed |
|
||||
|
||||
对应 model `UserDailyTaskProgress` 增加:
|
||||
```go
|
||||
Progress int `gorm:"column:progress;default:0"`
|
||||
```
|
||||
|
||||
状态机不变:`pending → completed → claimed`,每日重置回 `pending`。
|
||||
|
||||
> **序列规范提醒**:如后续脚本手动 INSERT 指定 id,须按 CLAUDE.md 规则重置对应 `_id_seq`。
|
||||
|
||||
---
|
||||
|
||||
## 三、事件目录(单一事实来源)
|
||||
|
||||
在后端建共享常量文件(建议 `backend/pkg/mq/tasks/task_events.go` 或 `services/taskService/model`),集中定义**有限、稳定**的事件枚举:
|
||||
|
||||
```go
|
||||
const (
|
||||
EventDailyLogin = "daily_login" // 每日首次登录
|
||||
EventDailyBrowseAsset = "daily_browse_asset" // 每日首次浏览藏品详情
|
||||
EventDailyMint = "daily_mint" // 每日首次铸造
|
||||
EventDailyPlaceAsset = "daily_place_asset" // 每日首次上架作品
|
||||
)
|
||||
```
|
||||
|
||||
**治理规则**
|
||||
1. 任务配置的 `trigger_event` **只能引用目录中已存在的事件**。
|
||||
2. 新增事件(B 类新行为)= 加一个常量 + 在该行为的**源头 emit 一次**;此后该事件可被任意数量的新任务复用(纯配置)。
|
||||
3. 事件命名与含义在本节表格维护,改动需同步本文档。
|
||||
4. 每个事件标注**归属路径**(后端 emit / 前端 reportEvent),遵守 §5 F4 单一路径规则:
|
||||
|
||||
| 事件 | 含义 | 归属路径 |
|
||||
|---|---|---|
|
||||
| `daily_login` | 每日首次登录 | 后端(auth/登录服务 emit,star_id 从 JWT/token 中取) |
|
||||
| `daily_browse_asset` | 每日首次浏览藏品详情 | 前端 `reportEvent`(纯 UI 动作) |
|
||||
| `daily_mint` | 每日首次铸造 | 后端(铸造成功处 emit,修复悬空) |
|
||||
| `daily_place_asset` | 每日首次上架作品 | 后端(上架成功处 emit,修复悬空) |
|
||||
|
||||
> **F3 概念区分**:业务事件枚举(上表 `daily_login…`,供 `trigger_event` 引用)与 **MQ 传输层的任务类型**是两回事。MQ 任务类型常量 `TypeTaskEvent = "task:event"` 及其 payload struct `TaskEventPayload{UserID, StarID, EventType}` 应放在 `backend/pkg/mq/tasks/registry.go`(沿用现有 `revenue:` / `gallery:` 命名风格);业务事件枚举放 `task_events.go`。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType`。
|
||||
|
||||
---
|
||||
|
||||
## 四、完成引擎(唯一隔离单元)
|
||||
|
||||
### 4.1 新方法
|
||||
|
||||
在 `DailyTaskService` 新增:
|
||||
```go
|
||||
// TaskEventResult 供 ReportEvent RPC 回填响应
|
||||
type TaskEventResult struct {
|
||||
CompletedTaskKeys []string // 本次事件导致 completed 的任务(可能 0~N 个)
|
||||
}
|
||||
|
||||
ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error)
|
||||
```
|
||||
**取代** `daily_task_service.go` 现有 `ReportEvent` 内那段 `def.TaskKey == eventType` 内联循环。
|
||||
|
||||
> **F1 说明**:返回结果而非仅 `error`,是因为 `ReportEvent` RPC 的 `ReportEventResponse` 需要 `TaskKey`/`TaskCompleted`/`Message`(proto 现有字段)。委托后由 `ReportEvent` 用 `TaskEventResult` 回填:`TaskCompleted = len(CompletedTaskKeys) > 0`,`TaskKey = 首个 completed`。MQ consumer 则忽略返回值只关心 `error`。
|
||||
|
||||
### 4.2 两个入口,一个引擎
|
||||
|
||||
| 入口 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| MQ consumer(主) | 后端服务发布的 `task:event` | 抗刷、覆盖纯后端行为 |
|
||||
| `ReportEvent` RPC(兜底) | 前端通用上报(纯 UI 动作,如浏览详情) | 改为"发同一条 MQ 事件 / 或直接调 `ProcessTaskEvent`",不再自己做匹配 |
|
||||
|
||||
前端 `task-api.js` 保留**唯一**的 `reportEvent(eventType, starId)`,不随任务增减而改动。
|
||||
|
||||
### 4.3 引擎逻辑
|
||||
|
||||
输入 `{userID, starID, eventType}`:
|
||||
1. 查定义:`is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)`。
|
||||
2. 逐个 `GetOrCreateDailyProgress`;若状态已是 `completed` / `claimed` → 跳过(当天幂等)。
|
||||
3. `progress += 1`;若 `progress >= def.TargetCount` → `status = "completed"`、`completed_at = now`。
|
||||
4. `UpdateDailyProgress` 保存。
|
||||
|
||||
> **隔离保证**:事件→任务的映射被封在"第 1 步查询 + 本方法"内。这是 §7 升级 B 的唯一改动点。
|
||||
|
||||
> **F5 多命中说明**:同一 `trigger_event` 可能同时命中「全局任务(star_id IS NULL)」与「该 star 专属任务」,此时**两者都会各自 +1** —— 这是预期行为(专属任务是全局任务的叠加,而非替代)。若运营需要"专属覆盖全局",属 §7 范畴,MVP 不做。
|
||||
|
||||
---
|
||||
|
||||
## 五、幂等与去重
|
||||
|
||||
- **`target_count = 1`**:天然幂等,`completed` 后跳过,无需额外处理。
|
||||
- **计数型(`target_count > 1`)**:MVP 每个合格事件 `+1`,封顶 `target_count`。语义为"做 N 次",**不做 distinct 去重**(即"浏览 3 次",而非"3 个不同藏品")。
|
||||
- **MQ 重试**:consumer `MaxRetry = 3`(沿用现有 revenue handler 模式);引擎对 `target=1` 幂等,重试安全。计数型在无去重前提下,重试可能多计——通过"仅在业务成功后 emit 一次 + 合理 MaxRetry"控制;严格 exactly-once 归入 §7。
|
||||
- **"N 个不同对象"**(distinct 去重):需要 `event_id`(如 asset_id)+ 幂等表,归入 §7 升级路径,不进 MVP。
|
||||
|
||||
> **F4 单一路径规则(强约束)**:同一个用户动作**只能经一条路径 emit** —— 要么后端 MQ 发布,要么前端 `reportEvent`,**不可两者都发**。否则计数型任务(`target_count > 1`)会重复 +1。约定:能被后端观测的动作(登录/铸造/上架)一律走后端 emit 且前端**不**再上报;纯 UI 动作(浏览详情)才走前端 `reportEvent`。事件目录(§3)需标注每个事件的归属路径。
|
||||
|
||||
---
|
||||
|
||||
## 六、每日重置
|
||||
|
||||
`DailyResetWorker` 逻辑不变(05:00 Asia/Shanghai、`pg_try_advisory_lock` 防多实例)。仅 `ResetAllDailyTasks()` 的 `Updates` map 增加一项:
|
||||
```go
|
||||
Updates(map[string]interface{}{
|
||||
"status": "pending",
|
||||
"progress": 0, // 新增
|
||||
"completed_at": nil,
|
||||
"claimed_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、平滑升级路径(写入文档,不实现)
|
||||
|
||||
### 7.1 A → B:一个任务多种触发 / 每事件独立规则
|
||||
|
||||
- 新增表 `task_triggers(id, task_id, event_type, increment, dedup_key, created_at)`,支持一任务多事件、一事件多任务、每触发独立增量与去重键。
|
||||
- 引擎 §4.3 第 1 步的查询从"读 `task_definitions.trigger_event` 列"改为"读 `task_triggers` join `task_definitions`"。
|
||||
- 因匹配逻辑已隔离在 `ProcessTaskEvent` 一个方法内,**只改这一处 + 加一张表**;`trigger_event` 列降级为冗余快捷方式或废弃。
|
||||
|
||||
### 7.2 B → C:复杂规则(时间窗、distinct 计数、连续行为)
|
||||
|
||||
- 在 trigger 上加 JSON `rule` 表达式列(如 `{"event":"browse","distinct_by":"asset_id","count":3,"window":"1d"}`),引擎接一个规则求值器。
|
||||
- 属 Stage 2+,业务复杂度真正上来后再评估,避免 YAGNI。
|
||||
|
||||
---
|
||||
|
||||
## 八、错误处理与测试
|
||||
|
||||
**错误处理**
|
||||
- 定义缺失 / 未激活 → 静默跳过 + `logger.Info`。
|
||||
- 发奖仍在独立的 `ClaimDailyTask` / `ClaimAllDailyTasks` 步骤(不变),与完成解耦。
|
||||
- 引擎内单条任务更新失败不影响其他任务(逐个处理,记 error 日志)。
|
||||
|
||||
**测试**(沿用现有 `*_test.go` 事务回滚 / 测试容器模式)
|
||||
- `ProcessTaskEvent` service 单测覆盖:
|
||||
- 首次完成(`target=1`)
|
||||
- 计数累加与封顶(`target=3`:1→2→3 completed,第 4 次 no-op)
|
||||
- 已 completed / claimed 时再来事件 → 跳过
|
||||
- 未知 / 无匹配事件 → no-op
|
||||
- per-star 定义与全局定义并存时的匹配
|
||||
- repository:`progress` 累加、`ResetAllDailyTasks` 重置 `progress=0`。
|
||||
|
||||
---
|
||||
|
||||
## 九、受影响文件清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `backend/migrations/2026_07_21_*_daily_task_trigger.sql` | 新增:加列 + backfill |
|
||||
| `docker/init-db.sql` | 同步表结构 + 种子 `trigger_event`/`target_count` |
|
||||
| `backend/services/taskService/model/task_models.go` | `TaskDefinition` + `UserDailyTaskProgress` 加字段 |
|
||||
| `backend/services/taskService/service/daily_task_service.go` | 新增 `ProcessTaskEvent`,`ReportEvent` 改为委托 |
|
||||
| `backend/services/taskService/repository/daily_task_repo.go` | 进度累加;`ResetAllDailyTasks` 加 `progress` |
|
||||
| `backend/pkg/mq/tasks/task_events.go`(新) | 业务事件枚举常量(`daily_login…`) |
|
||||
| `backend/pkg/mq/tasks/registry.go` | 加 MQ 任务类型 `TypeTaskEvent="task:event"` + `TaskEventPayload` struct |
|
||||
| `backend/services/taskService/mq/consumer.go` | 注册 `task:event` handler → `ProcessTaskEvent` |
|
||||
| 铸造 / 上架 / 登录服务源头 | emit `task:event`(修复 `daily_mint`/`daily_place_asset` 悬空;遵守 §5 F4 单一路径) |
|
||||
| `backend/services/taskService/worker/daily_reset_worker.go` | 无需改(调 repo) |
|
||||
| `frontend/utils/task-api.js` | 无需改(保留唯一 `reportEvent`) |
|
||||
| `frontend/pages/tasks/daily-tasks.vue` | 默认无需改(已配置驱动) |
|
||||
|
||||
### 9.1 若需前端展示进度条 "N/M"(可选,非 MVP 默认)
|
||||
|
||||
计数型任务若要在前端显示 `2/3` 进度,`DailyTaskItem` proto **当前无 progress/target 字段**(实测仅 TaskKey/StarId/Name/Description/CrystalReward/Status/CanClaim),需额外:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `backend/pkg/proto/task/*.proto` | `DailyTaskItem` 加 `progress` / `target_count` 字段 |
|
||||
| (重新生成) | `protoc` 重新生成 `task.pb.go` / `task.triple.go` |
|
||||
| `daily_task_service.go` `GetDailyTasks` | 映射时填充 `progress` / `target_count` |
|
||||
| `frontend/pages/tasks/daily-tasks.vue` | 渲染进度 `progress/target_count` |
|
||||
|
||||
> **决策(已定稿)**:MVP **不展示进度条**,前端完全零改动,`progress` 仅后端内部计数用于判完成。本 §9.1 的 proto 改动作为将来需要进度展示时的参考,不在本次实现范围。
|
||||
Loading…
Reference in New Issue
Block a user