# 后端全面审查报告(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 拼接无此问题;`: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` 不失效已签发 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 重算校正等级与已发奖励。