topfans/docs/backend-audit-2026-07-21.md
zerosaturation d026102a91 docs: backend audit report 2026-07-21 (findings + corrections)
后端全面审查报告:微服务耦合/边界、配置、各服务正确性与安全问题分级(P0/P1/P2),
含数据库实测复核(§七 结算超发/累计时长)与多处复核更正(peripheral 密钥、social OR)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 01:11:13 +08:00

278 lines
24 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 后端全面审查报告2026-07-21
> 审查范围:`gateway`BFF+ 11 个 Go 微服务 + 共享 `pkg/` + `proto/` 契约 + docker / k8s / env 配置。
> 审查方法5 路并行审计(微服务耦合与数据库边界 / 网关边界与 proto 契约 / 配置与部署 / 共享 pkg 与消息 / 各服务代码正确性与安全最严重结论已逐条核实git 追踪状态、跨服务 import、事务边界、端口绑定均经实际验证
> 审查基线 commit`b7f8f1b724e5`,分支 `feat/uni`。
---
## 一、结论概述TL;DR
**这是一个典型的「分布式单体」distributed monolith**:名义上 12 个服务,实际共享同一个 PostgreSQL、同一套 migration、同一个 `pkg/models`,且服务之间直接 `import` 对方 Go 包。RPC 名存实亡。
在此结构问题之上,叠加了三类高危问题:
1. **生产密钥已提交进 git**OSS / SMS / OpenAI / Dify
2. **铸造扣费事务内嵌跨服务 gRPC 调用**,可导致扣费成功但无藏品、连接池耗尽。
3. **多处信任请求体里的 `user_id` / `reporter_id`**,导致越权与刷量。
问题按严重度分为 P0立即处理/ P1尽快/ P2择机三级详见下文。每条均带 `文件:行号` 证据。
### 问题分布速览
| 维度 | P0 | P1 | P2 |
|------|----|----|----|
| 安全 / 密钥 | 密钥泄露、身份伪造 | 用户枚举、JWT 全局密钥 | PII 日志、错误信息泄露、JWT 不失效、周边密钥兜底 |
| 微服务耦合 / 边界 | 共享库、跨服务 import、共享 model | 契约混合、网关直连 DB、链式调用 | 死代码、列语义复用 |
| 数据一致性 | mint 事务嵌 RPC、双写不一致 | 序列未重置、material_type 无限增长 | — |
| 配置 / 部署 | 端口四层不一致、starbook 孤儿 | 健康探针错配、大二进制入库 | 命名 / 端口小不一致 |
| 消息 / 事件 | — | 消费者重启丢消息、无 DLQ、事件无版本 | 未接线死代码 |
---
## 二、P0 — 必须立即处理
### P0-1 生产密钥已提交进 git已验证 `git ls-files` 追踪中)
| 文件:行 | 泄露内容 |
|---------|----------|
| `backend/deploy/envs/asset.env:10-11` | 真实阿里云 OSS AccessKey `LTAI5t6QcdJHpYbCPxM8SXYE` + Secret |
| `backend/deploy/envs/user.env:21-22` | 同一套 key 复用为 SMS 密钥 |
| `backend/.env.example:106` | 完整 OpenAI `sk-proj-...` 密钥 |
| `backend/.env.example:124` | 第二个 OpenAI keyDify key `app-...` |
| `docker/.env.prod:18-19` | 第二套 OSS key |
| `docker/.env.local:21-22` | 同一套 OSS key |
| `backend/deploy/envs/notification.env:22` | 推送厂商 URL注释明确要求"不要提交" |
`backend/deploy/envs/*.env` 未被 `.gitignore` 覆盖(`git check-ignore` 返回 exit 1 = 未忽略)。
**动作**:立即轮换所有泄露的 OSS/SMS/OpenAI/Dify 密钥;清理 git 历史(`git filter-repo`);将 `deploy/envs/*.env` 加入 `.gitignore`,密钥改由 KMS / 部署时注入。
### P0-2 铸造扣费DB 事务内嵌套跨服务 gRPC
`backend/services/assetService/service/mint_service.go:309-318` —— `s.db.Transaction(...)` 内部调用 `s.userClient.UpdateCrystalBalance(...)`
- **连接池耗尽**RPC 期间 DB 连接被 pin 住userService 变慢会把整个 asset 连接池拖垮。
- **跨服务无原子性 + 非幂等**`CreateMintOrder`,同文件 209-516userService 已扣水晶但本地 commit 失败 → 用户被扣费却无藏品;客户端超时重试时水晶已扣光却报错。缺少 outbox / 幂等 token。
**动作**:把 RPC 移出事务;引入幂等键(如 `order_id` 唯一约束 + 状态机幂等判断);跨服务用 saga / outbox 保证最终一致。
### P0-3 信任请求体里的身份 → 越权 / 刷量
| 文件:行 | 问题 |
|---------|------|
| `backend/services/moderationService/provider/moderation_provider.go:35` | `SubmitReport` 直接透传 `req``ReporterId` 由调用方控制 → 冒用他人 id 举报、绕过去重刷量、读取他人举报详情 |
| `backend/services/assetService/provider/asset_provider.go:483` | `CheckAssetLike` 注释"直接使用请求中的用户信息",用 `req.UserId` → 点赞隐私预言机 |
| `backend/services/assetService/service/share_service.go:135-243` | `GetAssetQrcode``req.SharerUserId` 不校验调用方 → 分享归因伪造 + 无限 OSS 写入 |
**根因**5 个服务里 4 个信任 Dubbo attachment 里的 `user_id` 而不再校验(`castlove_config_provider.go:35` 明写"本服务信任 ctx 透传的 user_id"),只有 userService 真正验签,防御纵深断裂。
**动作**:所有 provider 统一从 attachment 提取身份并覆盖 `req` 内的身份字段;对内部 RPC 建立可信边界mTLS / 内部网络 + 签名)。
### P0-4 服务间直接 import 对方 Go 包(已验证)
| 文件:行 | 耦合 |
|---------|------|
| `backend/services/taskService/main.go:27-28` | import `assetService/repository` + `service` |
| `backend/services/assetService/main.go:32` | import `starbookService/repository` |
| `backend/services/starbookService/main.go:20` | 反向 import `assetService/repository`(循环依赖) |
| `backend/gateway/router/router.go:19-20` | import `assetService/repository` + `service` |
| `backend/gateway/controller/asset_controller.go:40` | import `assetService/service` |
| `backend/gateway/controller/moderation_controller.go:11` | import `moderationService/service` |
**后果**RPC 名存实亡——改一个服务的内部包,其它服务/网关必须一起重编重部署;一处 panic 连带拖垮调用方。
**动作**:跨边界只能走 proto/RPC + DTO共享的错误码、常量下沉到 `pkg/` 或 proto禁止 import 兄弟服务的 `repository`/`service`。
### P0-5 全部服务共用同一 Postgres + 同一套 migration + 同一 `pkg/models`
- 10 个服务 `dbName="top-fans"/"topfans"`,全用 `pkg/database` 的全局 `*gorm.DB`**无数据所有权边界**。
- 跨域直接读写:
- `backend/services/moderationService/repository/moderation_repository.go:268-302` 直接改 `users`/`assets`
- `backend/services/socialService/repository/social_repository.go:382,476,492,521` 读写 `fan_profiles`
- `backend/services/assetService/repository/share_repository.go:43`、`ranking_repository.go:103+` join `users`/`fan_profiles`
- `backend/services/galleryService/repository/gallery_repository.go:609,621` join `fan_profiles`
- `users`/`fan_profiles` 被 5+ 服务读写;`asset_registry.display_status` 被 gallery 和 asset 两个服务并发写(`gallery_repository.go:395,426,450` vs `asset_repository.go:127,145,160`)。
- 单一 `backend/migrations/`22 个文件)承载所有域;`pkg/models`22 个 model被 7 个服务 import → 改一个字段要重编 7 个服务。
**动作(中长期)**:按域拆库或至少拆 schema + 明确表 owner跨服务读改为 RPC + service-local DTO加 lint 禁止在非 owner 服务里对 `models.X{}` 做 GORM 操作。
### P0-6 服务地址端口四层不一致
`backend/gateway/config/config.go:162-165` 的默认值与各服务实际绑定端口不符:
| 服务 | 网关默认 | 实际绑定 |
|------|----------|----------|
| gallery | 20004 | **20001**`galleryService/main.go:36` |
| activity | 20005 | 20004 |
| starbook | 20007 | 20005 |
另:`galleryService/main.go:44` taskService 默认写成 20002实为 20006`userService/main.go:283` 硬编码 `WithPort(20000)` 使 `-port`/`PORT` 成为死代码。生产靠 env 覆盖侥幸能连,本地跑默认值会连错服务。
**动作**:统一端口默认值来源;加一个配置测试断言"网关 Dubbo URL 默认端口 == 对应服务绑定端口"。
### P0-7 `starbookService` 孤儿代码却仍在部署链路
-`go.mod`、不在 `go.work`(第 1-16 行)。
- 却仍被引用:`assetService` import见 P0-4、`docker-compose.local.yml:231-262` / `docker-compose.prod.yml:365-401` / helm `starbookservice/deployment.yaml` / `dev.sh:24,432,438` / 网关 `config.go:165`
- `docker/Dockerfile.services:50``go build` 它 → 干净环境 CI/helm 构建会失败。
- 目录里躺着 79MB 已编译二进制;`asset_registry` 表无明确 owner。
**动作**:二选一——补 `go.mod` 并加入 `go.work` 正式化,或彻底删除目录 + 所有部署引用 + 把其代码并入 assetService。
---
## 三、P1 — 尽快处理
### 代码正确性 / 安全
| 文件:行 | 问题 |
|---------|------|
| `backend/services/userService/service/auth_service.go:161` | bcryptcost≈100ms在事务内执行`:566` 改密同样 → 注册高峰连接池耗尽 |
| `backend/services/userService/service/auth_service.go:300 vs 315` | Login 用户枚举("用户不存在"与"密码错"错误码不同),且 Login 无限流 |
| `backend/services/aiChatService/provider/ai_chat_provider.go:250` | 保存上下文用请求里的 `personaID` 而非解析后的 `persona.ID`persona 关联错乱 |
| `backend/services/aiChatService/provider/ai_chat_provider.go:138,141` | Redis 出错静默吞掉(记忆/历史丢失无日志) |
| `backend/services/aiChatService/main.go:190-211` | Dify/LLM provider 启动时二选一,无 fallback / 熔断 / 重试Dify 挂了即硬故障,且 `provider:188` 原样回传 `err.Error()` |
| `backend/services/assetService/service/asset_like_service.go:267` | `material_type``CONCAT` 无限追加逗号,永不收敛 |
| `backend/services/socialService/service/friend_service.go:685` | `CheckFriendship` 硬编码 `starID=0`TODO 未做)→ 结果错误 + 好友关系隐私预言机 |
| `backend/services/socialService/repository/social_repository.go:461` | "随机用户"实为 `OFFSET rand + LIMIT` 取连续段,非随机且可预测 |
| `backend/services/assetService/service/mint_service.go:323` | 保底概率用 `time.Now().UnixNano()%100`,并发同纳秒结果相同,可被脚本操纵 |
| `backend/services/assetService/service/mint_service.go:349` | mockTxHash 输入全可观测,可被预测伪造"已上链" |
| `backend/services/assetService/service/peripheral_service.go:217-319` | `doMint` 限流 TOCTOUcount 检查与 insert 不在同一事务,并发可绕过 |
| `backend/services/socialService/repository/social_repository.go:648,672` | ~~OR 子句括号优先级问题~~ **复核后降级/更正**`:903,923` 是 AND 拼接无此问题;`:648,672` 经 GORM v1.31.1 实测——GORM 自动给裸 OR 加外层括号(`clause/where.go buildExprs`),当前版本**不触发**已删除资产漏出,属假阳性。已做防御性显式分组以消除对 GORM 内部行为的隐式依赖 |
| `backend/services/galleryService/service/cleanup_worker.go:137-185` | **【财务·已实证资损】展出收益结算非幂等 → 重复发放**(详见 §七.1 |
### 微服务耦合 / 边界
| 文件:行 | 问题 |
|---------|------|
| `backend/proto/user.proto:393-553` | `UserSocialService` 把公开 user API 与内部 RPC`UpdateCrystalBalance`/`UpdateAssetsCount`/`AddExhibitionHours`)混在一个契约 |
| `backend/proto/social.proto:367-513` | social 把好友 + 资产点赞 + 用户发现混在一起 |
| (对照正确范式)`backend/proto/task.proto:276-327` | `TaskMobileService`(公开)与 `TaskInternalService`(内部)干净拆分 |
| `backend/gateway/controller/user_controller.go:647-748` | 网关 `DeleteAccount` 自开事务直接改 `users`+`fan_profiles`,绕过 userService |
| `backend/gateway/repository/laser_card_repository.go` / `app_download_repository.go` | 网关持有 DB repo非纯 BFF |
| `backend/gateway/controller/asset_controller.go:414-436` | 铸造后本地写激光卡与 assetService 无事务串联 → 双写不一致,实例状态卡在 `mining` |
| `backend/gateway/controller/auth_controller.go:69-97` 等 6 处 | Register/Login/Me/Profile 链式调 `GetFanIdentities`(每次拉全量明星目录只为查一个),下游失败但用户已创建,无补偿 |
### 消息 / JWT / 共享库
| 文件:行 | 问题 |
|---------|------|
| `backend/pkg/jwt/jwt.go:18` | 包级可变全局 `jwtSecret`,默认弱值 `"your-secret-key-change-in-production"`,无锁无校验;`SetSecret` 与 `ParseToken` 并发 data race |
| `backend/pkg/mq/streams/adapter.go:177` | consumer 名带 `UnixNano`,重启后新消费者不认领旧 PEL → 消费者重启即永久丢消息 |
| `backend/pkg/mq/streams/adapter.go:212-234` | 失败事件留在 PEL`XAUTOCLAIM` / DLQ / 重试 |
| `backend/pkg/statistic/client.go:53-70` | `TrackEvent` fire-and-forget失败只 log 不重试 → 统计数据静默漂移 |
| `backend/proto/event.proto:12-20` | 事件无 envelope / version 字段,被 7 服务 import字段改动即破坏性 |
| producer/consumer 多处 | 队列名(`"gallery"`、`"default"`)硬编码在生产者和消费者两侧,改名易漂移丢消息 |
### 配置 / 部署
| 文件:行 | 问题 |
|---------|------|
| `backend/scripts/create_gallery_test_users.go` | 硬编码 id 但未 `setval` 重置序列,违反 CLAUDE.md 强制规则 → 跑完必报 `duplicate key` |
| `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` | 探针用 `/healthz` 但服务注册 `/health` → 存活探针永远失败 |
| `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50` | 探针打 `/`K8s 缺 compose 的 `HEALTHCHECK NONE` 覆盖 |
| 顶层大二进制入库 | `backend/assetService`(80MB) / `gateway-fixed`(98MB) / `test`(71MB) / `cleanup-orphan-avatars`(22MB) 均被 git 追踪 |
| `backend/.env``.env.example` 漂移 | `WS_AI_CHAT_PATH` 未文档化;`.example` 有一批 `.env` 缺失的必需键 |
---
## 四、P2 — 择机清理
- `backend/gateway/controller/asset_controller.go:160-194` `parseRPCError` 靠字符串解析错误码,应用 `status.FromError`
- 多处 `c.JSON(500, gin.H{"message": err.Error()})` 原样返回内部错误(`auth_controller.go:222`、`user_controller.go:139` 等)。
- `backend/services/userService/service/user_service.go:681` `ResetPassword` 不失效已签发 JWTTODO 挂起)。
- `backend/services/userService/service/user_service.go:1023` `UpdateAvatar` 不校验头像 URL 同源。
- PII手机号 / 聊天内容 / JWT打进 INFO 日志(`ai_chat_provider.go:107`、`mint_service.go` / `user_service.go` 多处)。
- `backend/pkg/mq/` 大量未接线死代码:`EventProducer`/`EventConsumer` 接口、`Stream*` 常量 0 引用;`asynq/adapter.go:111` `GetInfo`/`Delete` 是假实现。
- `backend/services/galleryService/mq/consumer.go:207` `is_processed` 列被复用为 `settled`(三处写入语义冲突,可能导致结算漏单)。
- `backend/services/activityService/service/activity_service.go:217,1579` 直接用 `redisClient.Publish`Redis Pub/Sub绕过 broker 抽象。
- `backend/pkg/peripheral/sign.go:33-44` 周边 HMAC 密钥优先级为 `SECRET_KEY > JWT_SECRET > 硬编码开发默认值`。正常配置下(`backend/.env:75`、`docker/.env.prod:66` 均有 `SECRET_KEY`)取 `SECRET_KEY`,与 JWT 无耦合。**残留隐患**`backend/deploy/envs/asset.env` + `common.env`systemd 部署路径)既无 `SECRET_KEY` 也无 `JWT_SECRET`,若走该路径会回落到硬编码 `"default-dev-secret-change-me"`(可预测密钥)。建议:去掉 `JWT_SECRET` 兜底与 dev 默认值,`SECRET_KEY` 缺失时直接 fail-fast。
> 更正说明:初版报告曾将此列为 P1「轮换 JWT 会连带作废周边验证码」,经复核 `SECRET_KEY` 为首选且生产已配置,该结论不成立,已降级并修正。
---
## 五、建议修复顺序
1. **轮换全部泄露密钥 + 清 git 历史 + 补 `.gitignore`**P0-1
2. **mint 事务拆掉内嵌 RPC + 加幂等 token**P0-2
3. **所有 provider 从 attachment 取身份,禁用 `req` 里的 `user_id`/`reporter_id`**P0-3
4. **决断 `starbookService`:正式化或彻底删除**P0-7
5. **端口默认值对齐 + 加配置断言测试**P0-6
6. bcrypt/序列/personaID/CheckFriendship 等 P1 正确性 bug
7. 中长期:拆库或用 DTO 替换 `pkg/models` 跨服务共享;`proto/event.proto` 加版本 envelopeMQ 补 DLQ/重试(或删掉未用的 streams adapter二选一拆分 `UserSocialService` 为公开/内部两个契约。
---
## 六、附录:审查维度与方法
| 维度 | 主要工具 |
|------|----------|
| 微服务耦合 & DB 边界 | code-review-graphimports_of/importers_of+ grep 验证 |
| 网关边界 & proto 契约 | 通读 controller/service/router + proto |
| 配置 & 部署 | `git ls-files` / `git check-ignore` + env/compose/helm 对比 |
| 共享 pkg & 消息 | importers_of 爆炸半径 + mq adapter 通读 |
| 各服务正确性 & 安全 | service/repository/provider 热点通读 |
> 本报告仅识别问题,不含完整修复方案。针对任意一条可另起工程化设计文档(遵循 `CLAUDE.md` 的 MVP 先行与文档结构规范)。
---
## 七、数据库实测复核2026-07-21本地 `top-fans` 全新库)
对照实际数据库(`localhost:15432` / 容器 `postgresql-database-1` / 库 `top-fans`)复核关键 finding结论如下
| 检查项 | 结果 | 对应 finding |
|--------|------|--------------|
| 单库结构 | ✅ 实证 P0-5单库单实例`public` schema 一把装 **89 张表**users/assets/fan_profiles/asset_registry/moderation_*/reports/activities/exhibitions/ai_*/peripheral_*/tasks 全在一起) | P0-5 |
| 跨域外键 | ⚠️ 实证 P0-5 DB 层耦合:`public` 有 **49 条 FK** 跨域绑定(`fan_profiles→users`、`assets→users/stars`、`mint_orders→users/assets/stars`、`asset_likes→assets/users/stars`、`booth_slots→fan_profiles`…),拆库前必须全部拆除 | P0-5 |
| 序列健康 | ✅ 当前**无** `last_value < max(id)` 的序列;`assets_id_seq=88100008=max(id)`、`asset_registry_id_seq=308=max(id)`。序列隐患为**潜在**(仅跑缺 `setval` 的脚本才触发),现网未坏 | P1`create_gallery_test_users.go` |
| `material_type` 增长 | ⚠️ 234 行资产中 134 行含逗号,但**最多 3 个值 `hot,new,potential`comma_count=2未观测到失控增长**。代码 `CONCAT` 追加 bug 存在但尚未爆 | P1`asset_like_service.go:267` |
| `is_processed` 复用 | ⚠️ 实证 P2exhibitions 无独立 `settled`45845 行中 `is_processed=false`**40847** 行,无法区分"未清理"还是"未结算收益"——语义冲突有真实数据佐证 | P2`consumer.go:207` |
| statistic schema | **更正**statistic 事件隔离在独立 `statistic` schema按天分区 `events_YYYY_MM_DD` + metric 视图),并非"全落在 public"。statisticService 有部分 schema 隔离 | 更正子审计说法 |
> 说明:"全新库"含实测数据users 94 / fan_profiles 96 / assets 234 / asset_registry 239 / mint_orders 281 / exhibitions 45845 / reports 4 / stars 6非空库。exhibitions 4.5 万行相对 234 资产偏高,结合 `is_processed` 语义冲突,建议排查是否存在结算/清理积压。
### 七.1 【重大发现·P1 财务】展出收益结算非幂等,已实测超发并被领取
对 exhibitions 的 `is_processed` 深挖时,从数据里挖出一个已实现资损的 bug。
**数据证据(当前 `top-fans` 库)**
- `exhibition_revenue_records`**51742 条**,但只对应 **46241 个不同 `exhibition_id`****4053 个展品被重复结算**(最多 3 次)。
- 多余重复记录 **5501 条,超发水晶累计 2,525,254**
- 这些重复记录 **`status` 全部为 `claimed`9554 条已领取)** → 超发水晶已被用户领走,是**已实现资损**(本数据集内)。
- `exhibition_revenue_records` 表**无 `exhibition_id` 唯一约束**,无任何幂等键阻止重复写入。
**根因**`backend/services/galleryService/service/cleanup_worker.go` + `taskService/service/revenue_service.go`
1. `exhibition_revenue_records` 表**无 `(exhibition_id, cycle_start_time)` 唯一约束**`CreateRevenueRecord``revenue_service.go:476`)无条件插入。
2. **多个结算入口对同一展品-周期重复触发**cleanup_worker 的 ZSET 路径(`cleanup_worker.go:137`、DB 兜底路径(`cleanup_worker.go:239`、MQ 消费路径三者可对同一 exhibition 重复调用 `OnExhibitionCompleted`
3. 数据实证4053 个重复展品中 **3850 个是"同周期重复"**(同一 `cycle_start_time` 被结算 ≥2 次),确认是同一周期的重复结算而非合法的多周期结算。
> **更正(较早版本的错误归因)**:本节初稿曾把重复归因为"`like_bet_revenue_records` 仅 50 条 → 点赞押注失败 → `is_processed` 永不置 true → worker 重跑"。经数据核实此链条**不成立**`live+expired+unprocessed` 积压=0活跃展品都已正确标记 processed`like_bet` 仅 50 条是因为**测试数据点赞极稀疏**45952 展品仅 164 个有赞、仅 9 个有 ≥2 赞),而点赞押注模型只奖励"后续还有点赞"的押注者,故绝大多数展品合法产出 0 条记录、`RecordLikeBetRevenue` 返回 OK 而非报错。重复的真正根因是上述"多路径 + 无唯一约束"。
**修复方向**
- `exhibition_revenue_records``UNIQUE(exhibition_id, cycle_start_time)` 幂等约束,写入用 `INSERT ... ON CONFLICT DO NOTHING`
- 收敛结算入口到**单一**带幂等的路径(保留 MQ 消费路径,删除 cleanup_worker 的 ZSET/DB 双直连 RPC或让三者共用同一 `isAlreadyProcessed` 判重);
- 用独立 `settled_at` 审计列替代复用 `is_processed`(原 §四 P2 项);
- 存量:按 `(exhibition_id, cycle_start_time)` 去重,回收已发放的重复水晶(本库超发 2,525,254 且已 claimed需评估回滚口径
- **附带发现**`exhibition_revenue_records.created_at` 单位混乱——51849 条中 6013 条是**秒**10 位、45836 条是**毫秒**13 位)。同列混用两种时间单位,任何按 `created_at` 排序/区间过滤/展示的逻辑都会错乱。应统一为毫秒并回填历史数据。
> 关于 `like_bet_revenue_records` 仅 50 条:非 bug。测试数据点赞极稀疏45952 展品仅 164 有赞、仅 9 有 ≥2 赞),点赞押注模型只对"后续仍有点赞"的押注者发放,故绝大多数展品合法产出 0 条。
> 备注worker 查询带 `deleted_at IS NULL`,故 40740 行"已软删+未处理"不会被再次结算(当前 `live+expired+unprocessed` 积压=0重复来自展品**在软删之前**被 worker 多轮处理。
### 七.2 展品累计时长(`user_exhibition_hours` / 资产 `season_exhibition_hours`
**数据层面无明显错误**`user_exhibition_hours` 1075 行无负值、无溢出max 1727h≈72 天)、无重复 `(user_id,star_id)` 行,唯一约束 `uk_exhibition_user_star` 健康。
**但累计逻辑存在幂等缺口,且数据来源(结算流)已含重复(见 §七.1),故累计值不可信**
1. `backend/services/userService/repository/fan_profile_repository.go:535-558``AddExhibitionHours` 收 `sourceID` 参数却**未用于幂等判断**,无条件 `total_exhibition_hours += hours`
2. `backend/services/taskService/service/revenue_service.go:513`:资产级 `assetLevelService.AddExhibitionHours(assetId, hours)` **完全不传 sourceID**
3. `revenue_service.go:436``actualHours=(expireAt-startTime)/3600000` 基于展品固定时间戳 → 同一展品重复结算每次加**相同**小时数(非 0
4. **两条结算路径并存**`galleryService/service/cleanup_worker.go:137,239` 仍调用已标注"本期内废弃"的直连 RPC `OnExhibitionCompleted`(收益+时长均无去重);`galleryService/mq/consumer.go:133` 走带 `isAlreadyProcessed` 去重的 MQ 路径。仅 MQ 路径有幂等,直连路径没有。
5. 已证 5501 次重复结算 → 经直连路径的部分会把累计时长多加。
**后果**:累计时长驱动等级 `CalculateLevelFromExhibitionHours` → 时长虚高触发**提前升级 + 多发升级奖励水晶**,与 §七.1 收益重复同根因(结算非幂等 + 双路径并存)。
**修复方向**:统一到单一带幂等的结算路径(删除 cleanup_worker 的直连 RPC 调用,或给直连路径补 `isAlreadyProcessed``AddExhibitionHours` 真正实现 sourceID 幂等(加 `exhibition_hours_log(source_id UNIQUE)` 或复用 `crystal_transaction_records` 的 source_id 判重);资产级累加补 sourceID存量数据需按 distinct exhibition 重算校正等级与已发奖励。