From 63aeedc2fe404acd442643322e549c5aef271d9d Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 14 Sep 2026 22:16:27 +0800 Subject: [PATCH] docs: add per-module audit reports (18 modules) Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified. --- docs/audit/README.md | 164 +++++ docs/audit/module-base-ads.md | 403 ++++++++++ docs/audit/module-base-cloud.md | 810 ++++++++++++++++++++ docs/audit/module-base-cms.md | 659 +++++++++++++++++ docs/audit/module-base-feedback.md | 652 +++++++++++++++++ docs/audit/module-base-fts.md | 635 ++++++++++++++++ docs/audit/module-base-initial.md | 655 +++++++++++++++++ docs/audit/module-base-logs.md | 781 ++++++++++++++++++++ docs/audit/module-base-mgt.md | 690 ++++++++++++++++++ docs/audit/module-base-passport.md | 776 ++++++++++++++++++++ docs/audit/module-base-sender.md | 716 ++++++++++++++++++ docs/audit/module-ec-address.md | 661 +++++++++++++++++ docs/audit/module-ec-mall.md | 1013 ++++++++++++++++++++++++++ docs/audit/module-ec-market.md | 551 ++++++++++++++ docs/audit/module-ec-order.md | 651 +++++++++++++++++ docs/audit/module-finance-wallet.md | 775 ++++++++++++++++++++ docs/audit/module-social-feed.md | 712 ++++++++++++++++++ docs/audit/module-social-group.md | 703 ++++++++++++++++++ docs/audit/module-social-relation.md | 717 ++++++++++++++++++ 19 files changed, 12724 insertions(+) create mode 100644 docs/audit/README.md create mode 100644 docs/audit/module-base-ads.md create mode 100644 docs/audit/module-base-cloud.md create mode 100644 docs/audit/module-base-cms.md create mode 100644 docs/audit/module-base-feedback.md create mode 100644 docs/audit/module-base-fts.md create mode 100644 docs/audit/module-base-initial.md create mode 100644 docs/audit/module-base-logs.md create mode 100644 docs/audit/module-base-mgt.md create mode 100644 docs/audit/module-base-passport.md create mode 100644 docs/audit/module-base-sender.md create mode 100644 docs/audit/module-ec-address.md create mode 100644 docs/audit/module-ec-mall.md create mode 100644 docs/audit/module-ec-market.md create mode 100644 docs/audit/module-ec-order.md create mode 100644 docs/audit/module-finance-wallet.md create mode 100644 docs/audit/module-social-feed.md create mode 100644 docs/audit/module-social-group.md create mode 100644 docs/audit/module-social-relation.md diff --git a/docs/audit/README.md b/docs/audit/README.md new file mode 100644 index 0000000..67d087f --- /dev/null +++ b/docs/audit/README.md @@ -0,0 +1,164 @@ +# module/ 全模块审计索引 + +> 状态:生成中(各模块审计文件完成后由主流程回填统计表与汇总 TODO) + +## 审计范围 + +- 审计对象:`module/` 下全部 **18 个含 `go.mod` 的独立服务模块**(`go.work` 成员)。 +- 每个模块由独立审计 agent 完成,产出 `docs/audit/module-<路径>.md`。 +- 未纳入审计(无 Go 代码,不属于运行时服务): + - `module/ec/supply`:仅 1 个 `README.md`,无 `go.mod`。 + - `module/finance/bill`:空目录。 + - `module/finance/loan`:空目录。 + +## 审计维度 + +安全(鉴权/越权/注入/密钥/隐私)、正确性(错误处理/事务/并发/状态机/精度)、性能(N+1/索引/分页/缓存)、可维护性(重复/分层/TODO/空实现)、健壮性与可观测性(超时/重试/迁移耦合/日志/配置)、测试覆盖。 + +## 问题级别定义 + +| 级别 | 含义 | +| --- | --- | +| P0 | 可被利用的安全漏洞 / 必然导致数据损坏或资金损失 | +| P1 | 高概率线上故障、越权、性能瓶颈 | +| P2 | 可维护性、健壮性、可观测性缺陷 | +| P3 | 风格、文档、一致性改进 | + +## 模块审计文件 + +| 模块 | 审计文件 | P0 | P1 | P2 | P3 | 合计 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| `base/ads` | [module-base-ads.md](module-base-ads.md) | 0 | 3 | 11 | 7 | 21 | +| `base/cloud` | [module-base-cloud.md](module-base-cloud.md) | 5 | 15 | 14 | 5 | 39 | +| `base/cms` | [module-base-cms.md](module-base-cms.md) | 1 | 11 | 16 | 5 | 33 | +| `base/feedback` | [module-base-feedback.md](module-base-feedback.md) | 3 | 8 | 15 | 2 | 28 | +| `base/fts` | [module-base-fts.md](module-base-fts.md) | 2 | 5 | 10 | 7 | 24 | +| `base/initial` | [module-base-initial.md](module-base-initial.md) | 0 | 8 | 6 | 8 | 22 | +| `base/logs` | [module-base-logs.md](module-base-logs.md) | 2 | 13 | 6 | 5 | 26 | +| `base/mgt` | [module-base-mgt.md](module-base-mgt.md) | 2 | 9 | 15 | 4 | 30 | +| `base/passport` | [module-base-passport.md](module-base-passport.md) | 8 | 7 | 6 | 4 | 25 | +| `base/sender` | [module-base-sender.md](module-base-sender.md) | 4 | 12 | 12 | 6 | 34 | +| `ec/address` | [module-ec-address.md](module-ec-address.md) | 2 | 7 | 6 | 4 | 19 | +| `ec/mall` | [module-ec-mall.md](module-ec-mall.md) | 5 | 13 | 19 | 8 | 45 | +| `ec/market` | [module-ec-market.md](module-ec-market.md) | 2 | 8 | 9 | 5 | 24 | +| `ec/order` | [module-ec-order.md](module-ec-order.md) | 7 | 12 | 10 | 5 | 34 | +| `finance/wallet` | [module-finance-wallet.md](module-finance-wallet.md) | 5 | 11 | 8 | 6 | 30 | +| `social/feed` | [module-social-feed.md](module-social-feed.md) | 3 | 13 | 14 | 7 | 37 | +| `social/group` | [module-social-group.md](module-social-group.md) | 0 | 13 | 12 | 6 | 31 | +| `social/relation` | [module-social-relation.md](module-social-relation.md) | 1 | 15 | 14 | 4 | 34 | +| **合计** | 18 个模块 | **52** | **183** | **205** | **102** | **542** | + +> 计数说明:`base/cloud` 的 P0 为 agent 原始上报,主流程复核后认为其中 P0-1/P0-2/P0-3 应下调(见下「主流程复核记录」);`social/feed` 经 agent 自查将 JWT 密钥一项由 P0 下调为 P1(理由:生产是否注入 `BSM_JwtSecretKey` 无法从仓库确认)。按上述修正,全仓 **P0 = 48**(52 − 3 − 1),其余级别不变。 +> `base/mgt` 计数以报告第 6 节为准(P0=2/P1=9/P2=15/P3=4);报告正文编号 1–31,其中 P2-17 为主流程通知后修正机制的一项。 + +### 各模块最高风险速览 + +| 模块 | 最高风险(摘要,详见各模块报告) | +| --- | --- | +| `base/passport` | `Forget.Reset` 无鉴权改他人密码并写裸 MD5;登录/注册接口不校验 `code`;JWT 密钥缺省为公开常量 `Cblocksmesh2022C` → 零成本接管任意账号 | +| `base/sender` | 短信/邮件接口无鉴权、日限额计数器只有读没有写、验证码可无限爆破 → 短信轰炸/费用损失/账号接管 | +| `ec/order` | 订单/购物车/优惠券定位与写入信任客户端入参且无归属校验 → 读他人收货信息、任意订单置为已支付、核销任意券;`confirm.go` 必然 panic | +| `finance/wallet` | 支付回调不入账 + `Payment.Callback` 由客户端决定"支付成功" + 支付宝渠道 `Body` 未初始化必然 panic | +| `ec/mall` | 仓库内公开的 32 字节占位 JWT 密钥可直接用于签发 HS256 token,叠加"角色/店铺归属全采信 claims、写操作无租户隔离" → 接管任意店铺 | +| `base/mgt` | root 默认口令 `123456` 自动播种 + JWT 密钥默认值 + 50 个管理接口仅粗粒度 `RequireAdmin` → 接管最高权限 | +| `base/cloud` | 照片表无 `passport_id`、照片接口靠 `album_id` 反查(**已复核为非越权**);分享口令明文存储与比较、加解密密钥完全由客户端决定 | +| `social/feed` | 动态写操作无属主校验 + 可见性控制被默认值击穿 → 读写删全站动态 | +| `social/relation` | 命名返回指针未初始化必然 panic(无 recover)→ 远程打挂进程;好友/关注/标签列名系统性错配 → 静默假成功 | +| `base/logs` | IP 校验逻辑反向 + `X-Forwarded-For` 可伪造 + 路由全匿名 → 伪造审计日志、读取全量日志 | +| `base/cms` | 写接口仅校验"任意有效 JWT"无归属校验;`Site` 5 个接口空实现却返回成功 | +| `ec/address` | `Get/Modify/Delete` 无 `owner_identity` 校验 → 枚举读改删全站收货人 PII;`Fetch` 裸 return 恒返回 nil | +| `base/feedback` | `Get/Delete/Remark` 无鉴权无归属校验;`Modify` 覆盖 `identity` → 工单失联、子表脱钩 | +| `base/fts` | 客户端可控 `bucket` 直接拼入落盘路径 → `..` 穿越任意目录写;MinIO 分支可写任意桶 | +| `base/initial` | `Config` 把可控 `os` 拼进 `ORDER BY` → SQL 注入;6 个字典接口无鉴权无限流 | +| `base/ads` | 独立入口网关整体 404;唯一接口无鉴权无限流且 `pos_key` 无索引全表扫描 | +| `social/group` | 13 个 RPC 中 12 个确定性缺陷(表名/列名错、结果被丢弃、权限与 SQL 全错);`Disband` 是返回 OK 的模板 TODO | +| `ec/market` | 管理接口只验签名不验角色/归属 → 自审通过、删他人代理商、拉全量身份证;代理商登录签发的 token 不是 JWT | + +## 汇总 TODO(跨模块优先事项) + +按"先止血、再治本"排序。每条对应各模块报告中的具体 TODO 项,验收标准见对应文件。 + +### 阶段一:P0 止血(上线前必须完成) + +- [ ] **T1 统一密钥治理**|动作:`Authorization.Key`、SDK `JWT_SECRET_KEY`、各模块 `SecretKey` 全部改为强制注入且启动期校验(拒绝 `CHANGE_ME*`、`Cblocksmesh2022C`、`4ef05311358cd1c8f787281f08b38b1c` 等任何仓库内出现过的值;长度校验之外增加熵/黑白名单校验)|验收:以仓库内任意历史占位符启动必须失败|涉及:`pkgs/all/etc/default_dev.yaml:22`、`pkgs/ecmall/etc/default_dev.yaml:22`、SDK `crypto/token/jwt.go:34`、`module/base/mgt/internal/logic/pub/login.go:90`、`module/ec/mall/internal/config/config.go:41` +- [ ] **T2 账号接管链路封堵**|动作:`Forget.Reset` 接入真实验证码校验与身份校验;`Login.Code/Quick`、`Register.Code` 真正校验 `code`;删除 MD5 写入路径;为 `passport_account` 补 `account`/`phone` 唯一索引|验收:无 `code` 无法登录、无法改他人密码、库中不再产生 MD5 口令|涉及:`module/base/passport/internal/logic/forget/reset.go:31`、`login/code.go:17`、`login/quick.go:20`、`register/code.go:13`、`internal/logic/common/token.go` +- [ ] **T3 资金与交易完整性**|动作:支付回调真正入账并加签名验签与幂等;`Payment.Callback` 禁止客户端置状态;`AliPay.Body/ctx` 初始化;订单/优惠券/购物车全部补服务端归属校验;`confirm.go` panic 修复并加 recovery 拦截器|验收:伪造回调不能改状态、并发下单不超卖、越权订单操作返回 403|涉及:`module/finance/wallet/internal/logic/payment/callback.go:25`、`alipay/alipay.go:15,53`、`module/ec/order/internal/logic/**` +- [ ] **T4 越权与无鉴权接口收敛**|动作:按各模块报告清单补齐 `ParseMetaCtx` + 归属过滤 + 角色校验;独立入口禁止裸奔(补鉴权或直接不暴露)|验收:以 A 账号 token 访问 B 账号资源全部 403/404|涉及:`base/feedback`(`method/get.go:19`)、`base/logs`(`routers/register.go:23` 注释掉的 JwtAuth)、`ec/address`(`library/get.go:29`)、`ec/market`(`agency/approve.go:15`)、`ec/mall`、`base/cms`、`social/feed`、`base/sender`、`base/initial` +- [ ] **T5 远程 panic / DoS 修复**|动作:修所有 nil 解引用与无保护类型断言,并为全部 gRPC server 装配 recovery 拦截器|验收:`go test -run` 与手工用例下不再 panic,进程不退出|涉及:`social/relation`(`friend/tag_fetch.go:22`、`friend/apply_get.go:32`)、`finance/wallet`(`alipay.go:53`)、`ec/order`(`confirm.go:62`)、`base/logs`(`log/fetch.go:29,45`)、`ec/address`(`library/fetch.go:16-40`) +- [ ] **T6 上传路径穿越**|动作:对 `bucket`/文件名做白名单与 `filepath.Clean` + 前缀校验(或改用服务端映射表);MinIO 桶名同样校验|验收:`bucket=../../x` 与 `%2e%2e%2f` 均被拒|涉及:`module/base/fts/internal/logic/handler.go:27`、`provider.go:26,99` +- [ ] **T7 SQL 注入点修复**|动作:`Config` 的 `os` 改为参数化或白名单枚举排序|验收:注入 payload 不再进入 SQL|涉及:`module/base/initial/internal/logic/check/config_cache.go:21` + +### 阶段二:P1 治本 + +- [ ] **T8 修复 S-1 网关模板缺陷**|动作:`internal/server/new.go` 初始化 `gwRuntime.NewServeMux()` 并注册 handler;`cmd/main` 改用 `service.Expose`;或明确关闭 `Gateway.Enable` 并同步文档|验收:独立二进制下文档中的 REST 路由可达|涉及:全部 18 个模块的 `internal/server/new.go`、`service/expose.go`、`cmd/main/main.go` +- [ ] **T9 修复 S-3 token 签发不一致**|动作:`ec/market`、`ec/mall` 的登录改用与聚合网关一致的 HS256 JWT 签发|验收:B 端登录 token 可通过 `pkgs/ecmall` 网关校验|涉及:`module/ec/market/internal/logic/agency/login.go:53`、`module/ec/mall/internal/logic/staff/login.go:88` +- [ ] **T10 数据完整性**|动作:补事务包围多表写入、去掉 `Select()+Updates(结构体)` 的零值覆盖、修正被改写的主键、补唯一索引与幂等|验收:并发与重复提交下无脏数据|涉及:`base/mgt`(`user/modify.go:64`、`permission/modify.go:140`)、`base/feedback`(`method/modify.go:37,89`)、`ec/order`、`base/cloud`(`album/delete_album.go:45-54`)、`ec/address`(默认地址) +- [ ] **T11 明文凭据与 PII 治理**|动作:轮换仓库/`etc`/`.builds` 中出现的所有口令与密钥;PII 加密存储 + 日志脱敏 + 关闭生产 gorm Debug|验收:仓库与构建产物中扫描不到真实凭据;日志无明文 PII|涉及:`module/base/feedback/etc/feedback_prod.yaml:4`、`.builds/etc/feedback_prod.yaml:4`、`scripts/build-all-linux.sh:100`、`module/base/cloud`(分享口令)、`module/finance/wallet`(银行卡/身份证)、`module/ec/market`(证件资料) +- [ ] **T12 未实现接口显式化**|动作:所有模板桩与空实现改为返回 `codes.Unimplemented` 或补齐实现,禁止"假成功"|验收:调用未实现接口返回明确错误而非 OK|涉及:`base/cms`(`site/*`)、`social/group`(`Disband`)、`ec/market`(`data/*`)、`base/sender`(`TencentSender`)、`finance/wallet`(转账/放款) +- [ ] **T13 限流与审计日志**|动作:登录/短信/上传/写接口加限流;管理端高危操作留审计痕迹;短信/邮件日限额计数器落写入侧|验收:超阈值请求被限流并有记录|涉及:`base/sender`、`base/mgt`、`base/logs` +- [ ] **T14 启动与迁移解耦**|动作:拆分连接/迁移/seed,关闭生产 `AutoMigrate` 与 `Debug`,补真实迁移脚本(仓库当前无 `.sql`)|验收:只读实例与蓝绿发布可用|涉及:全模块 `internal/impl`、`internal/models` + +### 阶段三:P2/P3 收尾 + +- [ ] **T15 可观测性与优雅退出**|补 `/health`、结构化日志、`context.WithTimeout`、可用的大盘指标;修 `defer srv.Stop()` 因 SDK `select{}` 永不执行的问题|涉及:全模块 `cmd/main` +- [ ] **T16 测试补齐**|优先覆盖认证、权限、越权、订单状态机、并发扣款/核销、优惠券、崩溃路径;修复当前失败/失效的既有测试(如 `module/base/fts/internal/routers/register_test.go` 路由断言 FAIL、`.http` 服务名大小写错误)|涉及:各模块 `test/` +- [ ] **T17 清理死代码与失效文档**|移除零引用包(如 `base/fts/internal/{response,errors}`、各模块 `cmd/cli` Hello World、`grpcConns`、`MemorySerice` 等),修正 README/wiki 与实现不符的描述(缓存策略、`/health`、Makefile、Dockerfile 等)|涉及:全仓 +- [ ] **T18 配置一致性**|`prod` 与 `dev` 配置不得字节级相同;修正 `Service:` 名与模块不符(如 `ec/address` 的 etc 写 `Service: order`);统一命名与拼写(`MemorySerice`)|涉及:各模块 `etc/*.yaml` +- [ ] **T19 补充 scope 说明**|`module/ec/supply`(仅 README)、`module/finance/bill`、`module/finance/loan`(空目录)、`social/relation/service`(空目录、缺 `expose.go`)需明确归属或补齐|验收:目录不再有"空壳"歧义 + +## 主流程复核记录 + +审计 agent 的结论并非全部可信,主流程对高危结论逐一回读源码复核,修正如下(未单独说明的 P0 均已被源码证据直接证实): + +| 模块 | agent 结论 | 复核结果 | +| --- | --- | --- | +| `base/passport` | `Forget.Reset` 无鉴权改他人密码并写裸 MD5;三个验证码登录接口不校验 `code` | **成立**。`internal/logic/forget/reset.go:18-31`(仅校验非空后按 `identity` 直改 `password`,且写入 `utils.Md5`);`internal/logic/login/code.go:17-21`(只判空即签发 token) | +| `base/initial` | `Config` 接口把调用方可控 `os` 拼进 `ORDER BY` | **成立**。`internal/logic/check/config_cache.go:21` 字符串拼接进 `Order()`,GORM 无法参数化该位置 | +| `base/mgt` | 初始化种子内置 root/123456 | **成立**。`internal/models/init_db.go:15-16`;`:145` 仅打印"请尽快修改默认密码" | +| `social/relation` | `TagFetch` 命名返回指针未初始化导致必然 panic | **成立**。`internal/logic/friend/tag_fetch.go:15,22` 对未初始化的 `*pb.FriendTagsReply` 直接 `Count(&reply.Total)`,且 `internal/server/new.go:23` 的 `grpc.NewServer()` 无 recover 拦截器 → 可远程打挂进程。同源问题见 `ec/address/library/fetch.go:16-40`(裸 `return` 恒返回 nil) | +| `base/feedback` | Get/Delete/Remark 无鉴权无归属校验;Modify 覆盖 identity | **成立**。`internal/logic/method/get.go:19-29` 全文无 `ParseMetaCtx`,查询仅 `Where("identity = ?")`;`internal/logic/method/modify.go:37` 写入新 `utils.UUID()` 作 `Identity`,`:89` 再 `Updates(record)` → 业务主键被改写、子表 `item_identity` 脱钩 | +| `base/cms` | 写接口仅校验"任意有效 JWT",无归属校验 | **成立**。`internal/logic/post/delete.go:29` 显式传空 author 过滤并留 `// Todo:`;`internal/logic/site/create.go:20-27` 为模板桩却返回 `vars.OK`(假成功) | +| `social/feed` | 动态写操作无属主校验 | **成立**。`internal/logic/post/remove.go:17-25` 仅 `ParseMetaCtx` 后即 `models.DeletePost(in.Identity)`,无属主过滤;`delete_comment.go:17-24` 同构 | +| `social/group` | `Disband` 为返回 OK 的模板 TODO;`Modify` 会随机重写群 `identity` | **成立**。`internal/logic/basic/disband.go:26` 为 `// TODO: add your logic code` 且函数返回成功;`internal/logic/basic/modify.go:33` 写入随机 `utils.UUID()` 作 `Identity`,`:35` 再 `UpdateColumns(record)` 全列写入(含 `identity` 与零值 `PassportID`/`MemberTotal`) | +| `ec/mall` | 公开占位 JWT 密钥可直接签发 HS256 token | **成立**。`pkgs/all/etc/default_dev.yaml:22` 与 `pkgs/ecmall/etc/default_dev.yaml:22` 的 `CHANGE_ME_32_BYTE_JWT_SECRET_KEY` 恰为 **32 字节**,通过 SDK `crypto/token/jwt.go:34-36` 的长度校验,并被 `pkgs/all/internal/server/authorization.go:39,78` 直接用作 HS256 签名密钥 → 可离线伪造管理员 token(两个聚合入口均受影响) | +| `base/cloud` | P0-1/2/3:照片无 `passport_id` 导致跨用户照片读写删与相册级联删除越权 | **高估,需下调**。`internal/models/cloud_photo.go` 确实没有 `passport_id`,但照片级接口通过 `JOIN cloud_albums ... WHERE cloud_albums.passport_id = ?` 强制归属校验(`album/delete_photo.go:31-32`、`album/get_photo.go:31`、`album/update_photo.go:32`、`album/list_photos.go:36,44`);`album/set_cover_photo.go:32` 校验相册归属、`:39` 校验照片属于该相册;`album/delete_album.go:31` 按 `passport_id` 限定待删相册。**不构成越权**。其中两个子问题独立成立并保留:`delete_album.go:45-54` 两次删除无事务(非原子),以及 `CloudPhoto` 缺 `passport_id` 属反规范化脆弱设计(可降级为 P1/P2) | + +## 跨模块系统性缺陷(主流程独立确认) + +以下问题不是单个模块的偶发缺陷,而是模板/生成器级别的共性,建议在汇总层单独立项修复,而不是在 18 个模块里各修一遍。 + +### S-1(P0):所有模块的独立部署入口没有可用的 HTTP 网关 + +证据链(主流程已逐个核对): + +1. `internal/server/new.go` 声明了 `Mux *gwRuntime.ServeMux` 字段(如 `module/base/sender/internal/server/new.go:16`),但 `New()` 只注册 gRPC server(`:33-34`),**从不调用 `gwRuntime.NewServeMux()`,`Mux` 恒为 nil**;全仓库 `gwRuntime.NewServeMux()` 仅出现在 `pkgs/all/internal/server/server.go:38` 与 `pkgs/ecmall/internal/server/server.go:38`。 +2. 每个 `service/expose.go` 把 `options.Gateway` 直接传给生成的 `pb.Register*HandlerServer`(如 `module/base/sender/service/expose.go:30,33`)。 +3. 生成的注册函数对 nil mux 无任何防御,第一行即 `mux.Handle(...)`(如 `module/base/sender/pb/sms.pb.gw.go:97-98`)→ 传入 nil 立即 panic。 +4. 独立部署的 `cmd/main/main.go` 只调用 `server.New(nil)` 并自行 `net.Listen`,**从不调用 `service.Expose`**,因此独立二进制下网关要么整体 404、要么在接入 `Expose` 时启动即 panic。 + +影响:`ads`、`initial`、`address`、`cms`、`cloud`、`ftS` 等多个模块的审计都独立报告了「独立部署 HTTP 网关 404 / Mux 为 nil」,根因是同一处模板缺陷。README 宣称的独立部署 REST 入口在该形态下不可用。 + +### S-2(P1):`infra.Response` 为包级单例且被并发写 + +`bsm-sdk/core/infra/response.go` 的 `Response` 是包级变量、方法直接改字段,`fts` 与 `logs` 两个模块均独立报告了并发响应串包/数据竞争风险。属 SDK 级缺陷,需在 SDK 侧改为每次请求新建响应对象或加锁。 + +### S-3(P0):两套不兼容的 token 签发方式,B 端登录 token 被聚合网关拒绝 + +聚合入口的鉴权中间件**只接受 HS256 JWT**:`pkgs/all/internal/server/authorization.go:73-79` 与 `pkgs/ecmall/internal/server/authorization.go:73-79` 使用 `jwt.ParseWithClaims` 并强制 `WithValidMethods(["HS256"])`。 + +而各模块签发 token 的路径分成两类: + +| 模块 | 位置 | 实际产物 | 与聚合网关兼容 | +| --- | --- | --- | --- | +| passport(登录/注册全部 4 处) | `internal/logic/common/token.go:10` → `token.New(...).GenerateJwt(...)` | 真正 JWT(函数名有误导性) | ✅ | +| mgt | `internal/logic/pub/login.go:90`、`refresh.go:28` → `GenerateJwt` | 真正 JWT | ✅ | +| **ec/market** | `internal/logic/agency/login.go:53` → `encipher.GenerateTokenAes` | **AES-CBC + base64,不是 JWT** | ❌ | +| **ec/mall** | `internal/logic/staff/login.go:88` → `encipher.GenerateTokenAes` | **AES-CBC + base64,不是 JWT** | ❌ | + +证据:`bsm-sdk/core/crypto/encipher/encipher.go` 的 `GenerateTokenAes` 最终调用 `AesEncryptCBC` + `base64.StdEncoding`,输出形状为密文 base64,无法被 `jwt.ParseWithClaims` 解析。 + +影响:market 代理商与 mall 员工登录成功后拿到的 token **无法通过聚合入口网关校验**,B 端登录链路在该拓扑下不可用(P0 可用性问题,非安全漏洞)。`market` 的审计已独立发现该点(P0-2),`mall` 的审计需一并核对。 + +## 汇总 TODO + +(待回填) diff --git a/docs/audit/module-base-ads.md b/docs/audit/module-base-ads.md new file mode 100644 index 0000000..f9dc9bd --- /dev/null +++ b/docs/audit/module-base-ads.md @@ -0,0 +1,403 @@ +# 审计报告:module/base/ads + +## 1. 模块概览 + +`base/ads` 是广告内容读取微服务:仅提供一个 RPC `ads.Fetch/ByPos`,按广告位 key 从 `ads_item` 表读取 `status=1` 的广告并转成 protobuf 返回(`internal/logic/fetch/by_pos.go:13-39`)。 +代码规模:Go 文件 16 个,其中 `pb/` 生成代码 4 个;非生成业务代码 12 个文件、约 301 行(`cmd/cli/main.go` 5 行、`test/rpc/rpc.go` 21 行均为空壳)。入口为 `cmd/main/main.go`(`Run()` → `config.New` → `impl.NewImpl` → `server.New(nil)` → SDK `service.New/Start`),gRPC 端口 12216、HTTP 网关端口 12102(`etc/ads_dev.yaml:2,19-21`)。 +对外接口形式:gRPC + grpc-gateway(`pb/ads.pb.gw.go:152` 的 `POST /ads.Fetch/ByPos`);另有聚合入口的动态 HTTP `/rpc/ads/Fetch/ByPos`(`wiki/api/01-ads.md:25`)。 +依赖外部组件:PostgreSQL(gorm)、Redis(`impl.go:24`)、Etcd(`impl.go:28`,配置未提供故为 nil)、SDK bsm-sdk/core(本地 replace)。 +聚合接入:同时被 `pkgs/all/internal/service/ads.go:9-20` 与 `pkgs/ecmall/internal/service/ads.go:9-20` 通过 `service.Expose` 注册进聚合进程(配置中 `Services: - ads`);独立进程入口与聚合入口的注册路径不同,是本模块多个缺陷的根源。 + +## 2. 审计范围与方法 + +已读文件(全部非生成代码 + 关键配置):`README.md`、`cmd/main/main.go`、`cmd/cli/main.go`、`internal/config/config.go`、`internal/impl/impl.go`、`internal/logic/fetch/by_pos.go`、`internal/models/ads_item.go`、`internal/models/ads_pos.go`、`internal/server/{fetch_server.go,new.go}`、`service/{dependencies.go,expose.go}`、`test/rpc/rpc.go`、`proto/{ads.proto,const.proto}`、`etc/*.yaml`、`etc/supervisor.bsm-apps-ads.conf`、`go.mod`、`.builds/etc/ads_prod.yaml`。 +生成代码(`pb/*.pb.go`、`pb/*.pb.gw.go`)只做接口一致性检查:`pb.FetchServer.ByPos(ctx, *ByPosRequest) (*ByPosReply, error)` 与 `internal/server/fetch_server.go:19` 一致;proto 字段(id/title/content/type/toUrl/created)与 `models.AdsItem` 映射一致;`pattern_Fetch_ByPos_0 = /ads.Fetch/ByPos`(`pb/ads.pb.gw.go:152`)与 README/wiki 一致;未发现生成代码与手写代码的签名冲突。 +为判定跨模块调用链,另外读取了 SDK 与聚合服务的确证位置:`bsm-sdk/core/service/service.go`、`conf/{new.go,types.go}`、`with/{databases.go,redis.go,etcd.go}`、`database/{new.go,sql/postgresql.go}`、`types/db.go`、`env/env.go`、`crypto/encipher/encipher.go`、`cache/redis/redis.go`、`pkgs/all/internal/server/{server.go,authorization.go}`、`pkgs/all/etc/default_dev.yaml`。 +执行命令与结果:`go version` → go1.26.5 windows/amd64;`gofmt -l .`(模块目录内)→ 无输出,全部文件已格式化;`go vet ./...` → `vet_exit=0`,无告警;三份 `etc/ads_*.yaml` 做 SHA256 比对 → 哈希完全相同(`DB300DC1…`)。 +未覆盖/无法验证:未运行服务、未连接真实数据库/Redis/Etcd,因此运行时行为(HTTP 404、panic、SQL 是否报列不存在)为静态推断并逐条标注依据;未读取 `pb/*.pb.go` 逐行内容(3000+ 行,仅做接口比对);未覆盖 CI/发布脚本与线上真实配置(配置可能被 `${VAR}` 环境变量或 etcd 覆盖,`conf.New` 使用 `os.ExpandEnv`,见 `bsm-sdk/core/conf/new.go:55`);SDK/聚合服务代码仅按需读取,未做完整审计。 + +## 3. 问题清单 + +### P0 + +无。本模块无写接口、无金额/事务路径;SQL 全部参数化(`by_pos.go:21` 使用 `?` 占位符),未发现可被利用的注入点;聚合入口对 `/ads.Fetch/ByPos` 强制 JWT(`pkgs/all/etc/default_dev.yaml:24-43` 白名单未包含 ads + `authorization.go:43-51,58-63`),未发现可直接绕过的越权写操作。 + +### P1 + +#### 1. 独立部署入口(cmd/main)的 HTTP 网关完全不可用,并且一旦补注册即 panic + +- **位置**:`module/base/ads/internal/server/new.go:26-30`、`module/base/ads/cmd/main/main.go:27,37`、`module/base/ads/service/expose.go:20-23` +- **证据**: +```go +// internal/server/new.go:26-30 —— Mux 字段从未初始化 +srv := &Server{ + Ctx: context.Background(), + Grpc: grpcServ, + grpcConns: make(map[string]*grpc.ClientConn), +} +// cmd/main/main.go:37 —— 把 nil 传给 SDK 作为网关 handler +GatewayMux: s.Mux, // 网关路由 +// bsm-sdk/core/service/service.go:126 —— nil handler 落到 DefaultServeMux,而本服务从未向其注册任何路由 +if err := http.ListenAndServe(httpAddr, s.Opts.GatewayMux); err != nil { +``` +- **影响**:`cmd/main` 独立部署时 `etc/ads_*.yaml:19-21` 打开 `Gateway.Enable: true`,但网关 handler 是 `nil`,全部 HTTP 请求(含 README:171 与 `wiki/api/01-ads.md:11` 文档化的 `POST /ads.Fetch/ByPos`)返回 404;注册网关路由的唯一代码在 `service.Expose`(`expose.go:23`),而 `cmd/main` 从不调用它,只调用 SDK 的 `service.New`。反向地,若有人按 `deps.GRPC` 路径把 `server.New()` 返回值的 `Mux` 直接交给 `Expose`,`runtime.ServeMux.Handle` 在 nil 接收者上先访问 `s.middlewares`(grpc-gateway v2.30.0 `runtime/mux.go` 的 `Handle`)会 panic。即“网关要么 404,要么崩”。 +- **建议**:在 `internal/server/new.go` 的 `New()` 中初始化 `Mux: gwRuntime.NewServeMux()` 并调用 `pb.RegisterFetchHandlerServer(srv.Ctx, srv.Mux, NewFetchServer())`;或在 `cmd/main/main.go` 中改为调用模块自身的 `service.Expose(service.ExposeOptions{GRPC: s.Grpc, Gateway: s.Mux, ...})`,消除两条注册路径的差异。补一条集成测试断言 `POST /ads.Fetch/ByPos` 在独立进程下返回 200。 + +#### 2. 读路径无索引、无分页、无缓存:每次请求都是一次全表扫描 + 全量返回 + +- **位置**:`module/base/ads/internal/models/ads_item.go:30`、`module/base/ads/internal/logic/fetch/by_pos.go:21`、`module/base/ads/README.md:319-326` +- **证据**: +```go +// internal/models/ads_item.go:30 —— pos_key 无 index 标签(同文件 status 由 Std_Status 带 index) +PosKey string `gorm:"column:pos_key;type:varchar(255);not null;" json:"pos_key"` // 广告位key +// internal/logic/fetch/by_pos.go:21 —— 无 LIMIT / 无 ORDER BY +err = impl.DBService.Where("pos_key = ? AND status = ?", in.Key, 1).Find(&adsItems).Error +``` +- **影响**:`ads_item` 上唯一被 WHERE 使用的 `pos_key` 没有索引(模型无 `index` 标签,且 `database.AppendMigrate` 的自动迁移默认关闭,见问题 7),条件 `pos_key = ? AND status = ?` 无法走索引 → 每次请求全表扫描;`Find` 无 `Limit`,命中行数无上限,全部加载进内存并序列化返回(`by_pos.go:27-37`),大广告位会造成内存与响应体膨胀。README:326 声称“在 pos_key 和 status 字段建立复合索引”、README:14/110/319-323 声称“Redis 缓存 10 分钟、按广告位缓存”,代码中均无实现(见问题 16)。叠加匿名访问(问题 3),构成可被外部放大的资源耗尽路径。 +- **建议**:迁移中为 `ads_item(pos_key, status)` 建复合索引(`gorm:"index:idx_pos_status,priority:1"` + `priority:2`),并让迁移可执行;查询加 `Order("id ASC").Limit(N)`(N 由请求参数或配置上限控制,默认如 50)与 `context` 超时;按 `pos_key` 做短 TTL 缓存并在管理端写路径失效(若后续新增写接口)。 + +#### 3. 独立入口的 ByPos 完全无鉴权、无限流,并默认开启 gRPC 反射 + +- **位置**:`module/base/ads/etc/ads_dev.yaml:15-16`、`internal/server/new.go:23,36`、`internal/logic/fetch/by_pos.go:15-17` +- **证据**: +```yaml +# etc/ads_dev.yaml:13-16 —— 模块自己声明该 RPC 免鉴权(三份配置相同) +MicroService: + Enable: false + Anonymous: + - ads.Fetch.ByPos +``` +```go +// internal/server/new.go:23,36 —— 无拦截器、开启反射 +grpcServ = grpc.NewServer() +reflection.Register(srv.Grpc) +``` +- **影响**:独立部署时 `grpc.NewServer()` 未装配任何 unary/stream 拦截器(无鉴权、无 recover、无日志、无限流),`MicroService.Enable: false` 又意味着 `Anonymous` 声明在该入口下完全失效——即该接口对任何能连到 12216/12102 的调用方都是无条件开放,且只校验 `in.Key == ""`(`by_pos.go:15-17`),key 空间可被无限枚举,配合问题 2 的全表扫描形成刷接口即拖库压测的路径。同时 `reflection.Register` 使服务与消息结构可被匿名枚举,属信息泄露。 +- **建议**:独立入口也接入与聚合一致的鉴权拦截器(`grpc.UnaryInterceptor`)与 `grpc_recovery`;引入按 IP/调用方的限流(如令牌桶)与并发上限;仅在 dev 打开 reflection(用配置开关控制,默认关闭);对 `key` 加长度与字符集校验(如 `^[a-zA-Z0-9_\-]{1,64}$`)。 + +### P2 + +#### 4. 同一接口在两种部署入口的鉴权语义相反,且模块内部没有任何鉴权代码 + +- **位置**:`module/base/ads/etc/ads_prod.yaml:15-16`、`pkgs/all/etc/default_dev.yaml:24-43`、`pkgs/all/internal/server/authorization.go:43-51` +- **证据**: +```yaml +# module/base/ads/etc/ads_prod.yaml:14-16 —— 声明匿名 + Anonymous: + - ads.Fetch.ByPos +# pkgs/all/etc/default_dev.yaml:24-43 —— 聚合白名单只含 passport/market/mall 登录等,不含 ads +Authorization: + Anonymous: + - /passport.Login/Pwd +``` +- **影响**:按模块配置,`ads.Fetch.ByPos` 是匿名接口;按聚合服务配置(它与 `authorization.go` 的 `isAnonymous` 逐条精确匹配,`authorization.go:96-102`),`/ads.Fetch/ByPos` 与 `/rpc/ads/Fetch/ByPos` 都需要 JWT。同一份代码在“独立进程”下完全公开、在“聚合进程”下要求登录:公开广告位(如首页 banner)走聚合入口会 401,而走独立入口则无需登录,行为随部署方式漂移,运维/前端无法从文档确定预期。`wiki/api/01-ads.md:12` 只说“需要登录的接口通过 Authorization 传递 JWT”,未标明本接口属于哪类。 +- **建议**:把 ByPos 是否匿名的结论固化为唯一来源(建议在聚合白名单中显式加入 `/ads.Fetch/ByPos`,并同步删除模块 `Anonymous` 中的误导性声明或标注其仅对 MicroService 注册生效),然后在 `wiki/api/01-ads.md` 的接口表增加“鉴权”列;用测试固定 `/ads.Fetch/ByPos` 的 200/401 期望值。 + +#### 5. 配置与密钥管理:三份环境配置完全相同(prod = dev),占位密钥可通过校验,且模块直接使用 SDK 硬编码兜底密钥 + +- **位置**:`module/base/ads/etc/ads_prod.yaml:7,10,24`、`internal/config/config.go:37,40`、`bsm-sdk/core/env/env.go:19` +- **证据**: +```yaml +# etc/ads_prod.yaml:7,10,24(与 ads_dev.yaml / ads_test.yaml 字节级相同,SHA256 均为 DB300DC1…) + - host=127.0.0.1 user=postgres password=CHANGE_ME dbname=rst_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai +Cache: redis://null:CHANGE_ME@127.0.0.1:6379/ +SecretKey: CHANGE_ME +``` +```go +// internal/config/config.go:37,40 —— 只校验非空;"CHANGE_ME" 视为合法;加密密钥取自环境(含硬编码兜底) +conf.NotNil(Spec.Service, Spec.Cache) +encipher.New(env.Runtime.JwtSecretKey) +// bsm-sdk/core/env/env.go:19 +JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"), +``` +- **影响**:生产配置与开发/测试配置一致(本地回环地址、`rst_dev` 库、`sslmode=disable` 明文数据库链路、占位口令),部署产物 `.builds/etc/ads_prod.yaml:7,10,24` 亦相同;`conf.NotNil` 只判空,`CHANGE_ME` 能通过启动校验(配置错误在运行时才以连接失败暴露)。若未设置 `BSM_JwtSecretKey`,模块的加密初始化会使用公开可读的硬编码密钥 `Cblocksmesh2022C`(本模块未调用 `GenerateTokenAes/ParseTokenAes`,故对 ads 的直接可利用性为**推测**:一旦后续复用该密钥签发/校验凭证即可离线伪造)。此外 `SecretKey: CHANGE_ME` 被解析进 `Spec.SecretKey` 但全模块无任何读取处(仅 `config.go` 结构体定义),属失效配置。 +- **建议**:prod 配置必须与 dev 分离(独立 DSN/库名/账号,`sslmode=require` 或 verify-full);启动时对配置做白名单校验(拒绝 `CHANGE_ME`、拒绝空 `BindIP` 下的公网绑定、要求 `BSM_JwtSecretKey` 长度 ≥32 且非默认值,缺失即 fail-fast);把 `SecretKey` 真正用起来或删除,避免“看着像已配置”;密钥统一走环境变量/密钥管理,不在仓库中保留任何形式的字符串。 + +#### 6. 状态默认值与查询条件冲突:新建广告默认不可见 + +- **位置**:`module/base/ads/internal/models/ads_item.go:34`、`bsm-sdk/core/types/db.go:75`、`internal/logic/fetch/by_pos.go:21`、`README.md:269` +- **证据**: +```go +// internal/models/ads_item.go:34 —— 嵌入 Std_Status + types.Std_Status +// bsm-sdk/core/types/db.go:75 —— 该字段默认值 0 + Status int64 `gorm:"column:status;default:0;index;" json:"status"` // 状态:默认为0,-1禁止,1为正常 +// internal/logic/fetch/by_pos.go:21 —— 只取 status = 1 +err = impl.DBService.Where("pos_key = ? AND status = ?", in.Key, 1).Find(&adsItems).Error +``` +- **影响**:模型/迁移给出的 `status` 默认值是 0(非法/禁用),而读取侧只认 1;README:269 又写 `status INTEGER DEFAULT 1`。任何按模型默认值插入、或漏填 status 的广告记录都不会被任何入口返回,表现为“后台已创建但前端看不到”的静默数据不一致;同时 `1`、`0`、`-1` 缺乏命名常量,语义靠注释维护。 +- **建议**:把默认值统一为 1(`gorm:"...;default:1"`)或改为枚举 + `CHECK(status IN (-1,0,1))`;在 `models` 中定义 `StatusNormal/StatusDisabled` 常量并替换 `by_pos.go:21` 的魔法数字 `1`;README 的表结构与模型标签需自动校验一致。 + +#### 7. 模型含软删除列但文档建表 DDL 没有该列,且模块内没有任何可执行的迁移入口 + +- **位置**:`module/base/ads/internal/models/ads_item.go:28,37-39`、`README.md:261-271`、`bsm-sdk/core/database/sql/postgresql.go:17`、`internal/impl/impl.go:26`、`README.md:218` +- **证据**: +```go +// internal/models/ads_item.go:28 —— 嵌入 gorm.Model(含 DeletedAt) +type AdsItem struct { + gorm.Model +// README.md:261-271 —— 唯一的建表 DDL:ads_item 无 deleted_at 列 +CREATE TABLE IF NOT EXISTS ads_item ( + id SERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, pos_key VARCHAR(255) NOT NULL, + ... + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +// bsm-sdk/core/database/sql/postgresql.go:17 —— 默认不自动迁移 + IsAutoMigrate: false, +// internal/impl/impl.go:26 —— 模块传 nil,沿用默认(不迁移) +DBService = with.Databases(config.Spec.Databases, nil) +``` +- **影响**:GORM 对含 `DeletedAt` 的模型会自动在查询上追加 `deleted_at IS NULL`。若按 README 的 DDL 建表(或线上表缺该列),`ByPos` 的查询会直接报 `column ads_item.deleted_at does not exist` 并被吞成 `ErrDB`(问题 9),接口 100% 失败。而模块自身没有任何可用迁移路径:`init()` 里的 `database.AppendMigrate`(`ads_item.go:37-39`)因 `IsAutoMigrate=false` 不生效,README:218 记载的 `go run cmd/main/main.go --init-db` 在 `cmd/main/main.go` 中不存在(无 `flag` 解析)。 +- **建议**:仓库内提供版本化迁移(独立 `migrations/` + 显式命令或 `IsAutoMigrate` 配置开关),修正 README 的 DDL 使其与模型一致(补 `deleted_at TIMESTAMP` 与 `pos_key/status` 索引),并删除文档中不存在的 `--init-db` 用法或真正实现它。 + +#### 8. context 未向下传递,读路径无超时/无重试/无熔断 + +- **位置**:`module/base/ads/internal/logic/fetch/by_pos.go:13,21` +- **证据**: +```go +func ByPos(ctx context.Context, in *pb.ByPosRequest) (reply *pb.ByPosReply, err error) { + ... + err = impl.DBService.Where("pos_key = ? AND status = ?", in.Key, 1).Find(&adsItems).Error +``` +- **影响**:`ctx` 形参在函数体内从未使用(也未见 `WithContext`),客户端断连/网关超时都无法取消这条 SQL;配合问题 2 的全表扫描,慢查询会持续占用连接,`MaxOpenConns=64`(`bsm-sdk/core/vars/sql.go:8`)被占满后整个服务(含聚合进程内的其它模块)一起排队。模块内无任何超时、重试、熔断配置。 +- **建议**:改为 `impl.DBService.WithContext(ctx).Where(...)`;为 handler 增加 `ctx, cancel := context.WithTimeout(ctx, 3*time.Second)`;在 gRPC server 侧加超时拦截器,并按需对可重试错误配置有限重试。 + +#### 9. 原始错误被吞掉,且业务路径没有任何日志/指标/健康检查(APM 配置解析后未使用) + +- **位置**:`module/base/ads/internal/logic/fetch/by_pos.go:22-24`、`internal/config/config.go:22`、`README.md:16,331-339` +- **证据**: +```go +// internal/logic/fetch/by_pos.go:22-24 —— 底层错误与上下文全部丢弃 +if err != nil { + return nil, errcode.ErrDB +} +``` +```go +// internal/config/config.go:22 —— APM 配置被解析但全模块无引用(grep "Apm" 仅此一处) + Apm *conf.ApmConf `yaml:"APM"` // APM监控配置 +``` +- **影响**:DB 报错(如表不存在、连接池耗尽、SQL 语法)在日志中没有任何痕迹,`errcode.ErrDB` 也无法区分“无数据”和“查询失败”,线上只能靠猜;`internal/` 下无 `printer`/`log` 调用(全模块 grep 仅 `cmd/cli/main.go:7` 与生成代码),无结构化日志、无 trace id、无 metrics;README:335/338 记载的 `/health`、`/metrics` 端点不存在(`new.go:32-37` 只注册 Fetch,无 `grpc_health_v1`)。 +- **建议**:错误返回前 `printer.Error`/`log` 记录 `err`、`pos_key`、`request_id`(从 metadata 取),并把底层错误包装进可观测字段;注册 gRPC 健康检查服务并暴露 `/health`;把 `Apm` 真正接入或删除该配置项;为慢查询(>100ms)打点。 + +#### 10. 启动强依赖未被使用的 Redis,且 GORM Debug 默认开启打印全部 SQL 与参数 + +- **位置**:`module/base/ads/internal/impl/impl.go:24,26`、`bsm-sdk/core/with/redis.go:10-14`、`bsm-sdk/core/cache/redis/redis.go:27-33`、`bsm-sdk/core/database/sql/postgresql.go:17-19,46-48` +- **证据**: +```go +// internal/impl/impl.go:24,26 +RedisService = with.RedisCache(config.Spec.Cache) +DBService = with.Databases(config.Spec.Databases, nil) // opts=nil → 使用 SDK 默认(Debug: true) +// bsm-sdk/core/cache/redis/redis.go:27-33 —— Ping 失败即 panic +func New(dsn string, hashRadix string) *RedisClient { + client, err := NewWithContext(context.Background(), dsn, hashRadix) + if err != nil { panic(err) } +// bsm-sdk/core/database/sql/postgresql.go:17-19,46-48 + IsAutoMigrate: false, LogStdout: false, Debug: true, + if options.Debug { gormDb = gormDb.Debug() } +``` +- **影响**:Redis 客户端在启动时创建并 `Ping`,失败即 panic 导致服务起不来——但本模块没有任何一处读/写 Redis(`RedisService` 除赋值外无引用),即“无用依赖决定可用性”;同理 `MemorySerice` 也未被使用。GORM 以 `Debug()` 模式运行会打印每条 SQL 及其参数(含 `pos_key` 等业务数据)到标准输出/日志,既影响吞吐又造成数据面信息落日志。 +- **建议**:删除启动时的 Redis/内存缓存初始化,或仅在真正使用后按需懒加载并对连接失败降级告警而非 panic;给 `with.Databases` 传入显式的 `SqlOptions{Debug: <由配置控制>, IsAutoMigrate: ...}`,生产环境默认关闭 SQL 明细日志。 + +#### 11. 优雅退出不可达,无信号处理,外部资源不关闭 + +- **位置**:`module/base/ads/cmd/main/main.go:42-45`、`bsm-sdk/core/service/service.go:113-115,142-144` +- **证据**: +```go +// cmd/main/main.go:42-45 +defer srv.Stop() +srv.Start() +// bsm-sdk/core/service/service.go:113-115 + // 阻塞主线程 + select {} +``` +- **影响**:`Start()` 内部以 `select {}` 永久阻塞且永不返回,`defer srv.Stop()` 是死代码;进程未监听 SIGTERM/SIGINT(全模块无 `signal.Notify`),容器/`supervisor` 停止时只能被强杀 → 在途 gRPC 请求被截断,且数据库/Redis/Etcd 连接不做关闭。`Service.Stop()` 本身也只做 `GrpcSrv.GracefulStop()`(`service.go:142-144`),未关闭 DB/Redis/Etcd。 +- **建议**:在 `cmd/main` 中实现信号处理(`signal.NotifyContext`),收到信号后按序 `srv.Stop()` → `sqlDB.Close()` → `RedisService.Close()` → `EtcdService.Close()`,并为关停设置超时;向 SDK 反馈 `Start()` 阻塞不可退出的问题或改用可返回的启动接口。 + +#### 12. `service.Expose` 无入参校验且丢弃 `server.New` 返回值:Gateway 为 nil 时 panic,GRPC 为 nil 时静默失效 + +- **位置**:`module/base/ads/service/expose.go:19-25`、`module/base/ads/service/dependencies.go:26-28` +- **证据**: +```go +func Expose(options ExposeOptions) error { + applyDependencies(options.Dependencies) + server.New(options.GRPC) // 返回值被丢弃 + ctx := context.Background() + if err := pb.RegisterFetchHandlerServer(ctx, options.Gateway, server.NewFetchServer()); err != nil { +``` +- **影响**:(a) `options.Gateway == nil` 时 `RegisterFetchHandlerServer` 会在 `*runtime.ServeMux` 的 nil 接收者上 panic(`Handle` 首行即访问 `s.middlewares`,grpc-gateway v2.30.0 `runtime/mux.go`),而调用方 `pkgs/all/internal/service/ads.go:10-19`、`pkgs/ecmall/.../ads.go` 未做 nil 检查,聚合进程会直接崩溃;(b) `options.GRPC == nil` 时 `server.New` 会自建一个 gRPC server 并注册 Fetch,但该 server 没有监听者,gRPC 调用方得到 "unknown service",故障被静默吞掉(HTTP 路径因使用本地 handler 仍可用,问题更隐蔽);(c) `dependencies.go:26-28` 只在非 nil 时覆盖全局 `impl.DBService`,若 DB 缺失,`by_pos.go:21` 会在请求时对 nil `*gorm.DB` 解引用 panic 而不是启动时 fail-fast。 +- **建议**:`Expose` 入口校验 `options.GRPC`、`options.Gateway`、`options.Dependencies.DB` 非 nil 并返回明确错误;使用 `server.New` 的返回值(保留 `*Server` 供后续使用或断言一致性);`Expose` 返回的错误在聚合启动处必须 panic/退出而不是忽略。 + +#### 13. 返回内容原样透出,无白名单/转义,`content` 与 `to_url` 可承载任意字符串 + +- **位置**:`module/base/ads/internal/logic/fetch/by_pos.go:29-36`、`internal/models/ads_item.go:31,33`、`proto/ads.proto:22-23` +- **证据**: +```go + result = append(result, &pb.AdsItem{ + Id: int64(item.ID), + Title: item.Title, + Content: item.Content, + Type: int32(item.Type), + ToUrl: item.ToUrl, +``` +- **影响**:`content`(varchar(255) 文本/图片/视频 URL)与 `to_url` 无任何格式校验,直接以 JSON 返回给前端。广告位内容通常由后台运营录入,若消费端按 HTML 渲染 `content` 或在 ``/跳转中使用 `to_url`,即构成存储型 XSS 或开放重定向/钓鱼跳转。是否存在可利用的消费端渲染属**推测**(本仓库未找到 ads 的前端消费代码);另 `content varchar(255)` 对视频/附件类 URL 易截断(`ads_item.go:31`),而 `proto/ads.proto:22` 注释只声明了 1-3 三种类型,与 `models/ads_item.go:19-24` 的 6 类枚举不一致,消费端对 4/5/6 的分支处理无据可依。 +- **建议**:在写侧(后续的管理接口)做 `type` 与内容形态校验:`to_url` 强制 `http/https` 且域名白名单(拒绝 `javascript:`/`data:`),文本内容做 HTML 转义或明确以纯文本下发;把 `content` 改为 `text`/`varchar(1024)` 并按类型区分字段;同步修正 `proto/ads.proto` 的类型注释与枚举。 + +#### 14. 零测试:`test/` 目录没有任何可运行测试 + +- **位置**:`module/base/ads/test/rpc/rpc.go:1-23`(全文件为注释)、模块内 `*_test.go` 数量 = 0 +- **证据**: +```go +func main() { + /* + md := metadata.New(map[string]string{"request_id": utils.UUID(), "workspace": "scf"}) + ctx := metadata.NewOutgoingContext(context.Background(), md) +``` +- **影响**:唯一的 `test/` 文件是整段注释(连 `pb.NewFetchClient` 都不存在,`pb` 中只有 `NewFetchClient` 对应的 Fetch 服务,文件里写的是 `NewMethodClient`),无编译价值;`go test ./...` 无任何用例,问题 1/6/7/12 这类“会在运行时才炸”的缺陷没有回归网。缺失的关键路径测试清单:①`ByPos` 空 key 返回 `ErrInvalidArgument`;②`pos_key` 命中/未命中/仅含禁用状态的行为;③DB 报错时返回 `ErrDB` 且不 panic;④`status` 默认值写入后是否可被读取(对应问题 6);⑤表缺 `deleted_at` 时的失败模式(对应问题 7);⑥网关路由 `POST /ads.Fetch/ByPos` 的 200 与 JSON 字段映射(对应问题 1、4);⑦`Expose` 在 `Gateway/GRPC/DB` 为 nil 时的行为(对应问题 12);⑧`ctx` 取消能否中断查询(对应问题 8)。 +- **建议**:把 `test/rpc/rpc.go` 改写为可编译的集成冒烟测试(或删除),补 `by_pos_test.go`(用 `sqlmock`/测试库覆盖上述 ①-③)、`expose_test.go`(④⑤⑦)与 gateway 层 `httptest`(⑥);在 CI 中对 `module/base/ads` 执行 `go test ./...` 并设覆盖率门限。 + +### P3 + +#### 15. README 大面积与代码/仓库实际不符 + +- **位置**:`README.md:12,14,35,91-95,110,218,319-326,331-339,394` +- **证据**: +```markdown +- **Go**: 1.25.1+ # README.md:35,而 go.mod:3 为 go 1.26.5 +│ ├── 📁 swagger/ # README.md:91;仓库中不存在 swagger/ 与 scripts/、Dockerfile、Makefile +- **📍 广告位管理**: 灵活的广告位配置和分类管理 # README.md:12,模块内无任何广告位(AdsPos)读写实现 +go run cmd/main/main.go --init-db # README.md:218,main.go 无 flag 解析 +curl http://localhost:12102/health # README.md:335,无该路由 +``` +- **影响**:文档声称的 Dockerfile/Makefile/swagger/scripts 目录、`--init-db`/`--check-config` 参数、`/health`、`/metrics`、APM 集成、Redis 10 分钟缓存与 `pos_key/status` 复合索引、广告位管理功能,在本仓库中均不存在(`module/base/ads` 下仅 `cmd/ etc/ internal/ pb/ proto/ service/ test/`)。新人按 README 操作会直接失败,审计/排障也会被误导。 +- **建议**:以“代码为准”重写 README 的部署与性能章节(删除不存在的能力,或补齐实现),把 Go 版本、目录树、示例命令与 `go.mod`、`cmd/main` 实际支持的参数对齐;把缓存/索引等“应然”描述移到“优化建议”小节并显式标注“未实现”。 + +#### 16. 死代码与未使用资源集中(含已声明但无实现的“广告位管理”) + +- **位置**:`cmd/cli/main.go:6-8`、`test/rpc/rpc.go:3-23`、`internal/models/ads_pos.go:15-28`、`internal/server/new.go:17,29`、`internal/config/config.go:22`、`internal/impl/impl.go:13,16,24` +- **证据**: +```go +// cmd/cli/main.go:6-8 —— 空壳 CLI +func main() { log.Println("广告服务命令行工具") } +// internal/server/new.go:17,29 —— 声明并初始化,但全模块再无引用(无锁,若启用即数据竞争) + grpcConns map[string]*grpc.ClientConn // 连接池 + grpcConns: make(map[string]*grpc.ClientConn), +// internal/models/ads_pos.go:15-28 —— ads_pos 表被迁移注册,但无任何查询/写入代码 +``` +- **影响**:`AdsPos` 模型与 `ads_pos` 表只被 `database.AppendMigrate` 注册(`ads_pos.go:21-23`),业务侧零引用,与 README:12 的“广告位管理”承诺矛盾;`cmd/cli` 无功能;`grpcConns` 无使用者(若未来启用,无 mutex 的 map 并发写会 panic);`Apm`、`RedisService`、`MemorySerice` 均为“配置/依赖存在但无人使用”的噪声,让人误判能力边界。 +- **建议**:删除确无用途的 `cmd/cli`、`test/rpc`、`grpcConns`、`Apm` 配置项与缓存初始化;若 `ads_pos` 是规划中的功能,则在 README 标注“规划中”并保留 TODO issue,否则一并删除模型与迁移注册。 + +#### 17. `proto/const.proto` 把大量他域消息塞进 ads 的 go_package,生成代码严重膨胀 + +- **位置**:`proto/const.proto:1-326`、`pb/const.pb.go`(约 3000+ 行) +- **证据**: +```protobuf +// proto/const.proto:132-326 —— cms/market/order/social/passport 等与本模块无关的消息 +// CMS messages. +message CmsCategoryItem { ... } +message OrderSummaryItem { ... repeated OrderDetails details = 33; } +``` +- **影响**:ads 模块的 pb 包里包含了订单、CMS、社交、钱包等域的消息(`package base_ads_blocks` 与 `option go_package = "bsm/full/module/base/ads/pb;ads"` 共用),导致 ads 的 gRPC 反射与文档(`scripts/api-docgen/main.go:12` 匿名导入 `base/ads/pb`)暴露的“schema”远超本模块职责,也使得任何共享消息变更都要重新生成 ads 的 3000+ 行代码;`AdsItem` 等 6 个消息真正被使用,其余为复制粘贴产物。 +- **建议**:把共享消息抽到独立的 `blocks` 模块/单独的 `.proto`(独立 go_package),ads 只保留 `ads.proto` 的 6 个消息;确认 `const.proto` 在本模块是否真的被 codegen 流程依赖,若否直接删除。 + +#### 18. 魔法数字、枚举与文档不一致、排序不确定、时间格式硬编码 + +- **位置**:`internal/logic/fetch/by_pos.go:21,35`、`internal/models/ads_item.go:19-32`、`proto/ads.proto:22` +- **证据**: +```go +err = impl.DBService.Where("pos_key = ? AND status = ?", in.Key, 1).Find(&adsItems).Error // 魔法数字 1 +Created: item.CreatedAt.Format("2006-01-02 15:04:05"), // 硬编码布局、无时区信息 +// internal/models/ads_item.go:19-25:1..6 六类;proto/ads.proto:22 注释只写 "1.文本 2.图片 3.视频" +``` +- **影响**:`status=1` 无语义常量(问题 6);`Type` 默认 0 落在枚举 1..6 之外,返回给消费端是未定义值;查询无 `ORDER BY`,同广告位多条的返回顺序依赖数据库执行计划(可能随索引/统计信息变化),文案轮播顺序不可复现;`created` 为无时区标记的字符串,跨端解析依赖客户端约定。 +- **建议**:定义 `StatusEnabled=1` 等常量;`Type` 增加 `CONTENT_TYPE_UNKNOWN=0` 并在查询侧过滤非法类型;加显式 `Order("id ASC")`(或加权重列排序);`created` 改为 `time.RFC3339` 或 proto `google.protobuf.Timestamp`,避免自定义布局。 + +#### 19. `go.mod` 使用指向仓库外的相对 replace,工具依赖被标为 indirect + +- **位置**:`go.mod:3,26-67,69`、`README.md:35` +- **证据**: +```go +go 1.26.5 + git.apinb.com/bsm-sdk/core v0.2.1 +replace git.apinb.com/bsm-sdk/core => ../../../../../bsm-sdk/core +``` +- **影响**:`../../../../../bsm-sdk/core` 解析到仓库根(`D:\work\bsm-infra\full`)之外的 `D:\work\bsm-sdk\core`,在 CI/容器中未同步该目录即无法构建;同时 `protoc-gen-*` 工具链被列在 `require` 的 indirect 区块(`go.mod:26-67`),与实际 `tool` 区块(`go.mod:5-14`)语义重叠,容易误判依赖来源。 +- **建议**:CI 中用 `go.work` 或脚本显式同步 SDK 到固定路径,避免相对路径穿透仓库;把工具依赖只保留在 `tool` 块并清理 indirect 段;在 README 中说明构建前置条件。 + +#### 20. 部署与运行配置粗糙:supervisor 以 root 运行、无健康检查/日志轮转/环境变量,连接池不可配置 + +- **位置**:`etc/supervisor.bsm-apps-ads.conf:1-8`、`bsm-sdk/core/conf/types.go:18-21`、`bsm-sdk/core/vars/sql.go:7-9` +- **证据**: +```ini +; etc/supervisor.bsm-apps-ads.conf:6-8 +user=root +redirect_stderr=true +stdout_logfile=/data/app/logs/apps-ads.log +``` +```go +// bsm-sdk/core/conf/types.go:18-21 —— DBConf 无任何连接池字段 +type DBConf struct { + Driver string `yaml:"Driver"` + Source []string `yaml:"Source"` +} +// bsm-sdk/core/vars/sql.go:7-9 —— 池参数写死:Idle=64/Open=64/Lifetime=60s + SqlOptionConnMaxLifetime time.Duration = 60 * time.Second +``` +- **影响**:进程以 root 运行且 `redirect_stderr=true` 混流,日志无轮转(无 `stdout_logfile_maxbytes`/`backupCount`),长期运行可能写满磁盘;未设置 `BSM_RuntimeMode`/`BSM_Workspace`/`BSM_JwtSecretKey`/`TZ` 等环境变量,配置与环境强耦合在主进程 shell(`conf.New` 依赖 `BSM_RuntimeMode` 选择 `ads_.yaml`,见 `bsm-sdk/core/conf/new.go:35`),supervisor 重启后可能读到非预期配置;DB 连接池与超时无法通过 yaml 调整,`ConnMaxLifetime=60s` 会造成周期性重连。 +- **建议**:`user` 改为专用非特权账号;配置 `stdout_logfile_maxbytes=100MB`、`stdout_logfile_backups=5`、`environment=BSM_RuntimeMode="prod",BSM_Workspace="...",BSM_JwtSecretKey="..."`;把连接池/超时参数提升为 `DBConf` 字段(或模块自己构造 `SqlOptions`)并按库容量调优。 + +#### 21. CORS 未装配(跨域浏览器调用会被拦),且仅存在未使用的 indirect 依赖 + +- **位置**:`pkgs/all/go.mod:75`、`pkgs/all/internal/server/server.go:34-38` +- **证据**: +```go +// pkgs/all/internal/server/server.go:34-38 —— 聚合 HTTP 只用 gin.Logger/Recovery,Gateway mux 也无 CORS 中间件 + engine := gin.New() + engine.Use(gin.Logger(), gin.Recovery()) + ... + Gateway: gwRuntime.NewServeMux(), +``` +- **影响**:`github.com/gin-contrib/cors` 仅作为 indirect 依赖出现在 `pkgs/all/go.mod:75`(`pkgs/ecmall/go.mod:51` 同),代码中无任何装配点,`gwRuntime.NewServeMux()` 也未使用 `WithMiddlewares` 注入 CORS。所有浏览器直连的 ads 接口(`POST /ads.Fetch/ByPos`、`/rpc/ads/Fetch/ByPos`)都缺少 `Access-Control-Allow-Origin`,跨域前端会失败;是否有前置网关/Nginx 统一注入 CORS 属**推测**(仓库内未见相关配置)。 +- **建议**:明确广告位接口的调用方(浏览器/服务端):若需浏览器直连,在聚合 HTTP 层装配白名单化 CORS(限定来源与方法,避免 `*` 与凭证同用);若仅服务端调用则从依赖中移除 cors 包并在文档中注明。 + +## 4. 推荐优化方案 + +1. **统一入口与注册路径(目标:消除“独立进程 404 / 聚合进程 401”的双轨分歧)**:做法:让 `internal/server.New()` 自行创建并填充 `Mux` 与路由,`cmd/main` 与 `service.Expose` 都只调用同一函数;`Expose` 增加 nil 校验并返回错误。影响面:`internal/server/new.go`、`service/expose.go`、`cmd/main/main.go`、`pkgs/all|ecmall/internal/service/ads.go`(调用方签名不变)。风险:低;需回归两条部署路径的启动自检。 +2. **读路径性能兜底(目标:把每次请求的代价从 O(全表) 降到 O(log n) + 固定上限)**:做法:加 `(pos_key,status)` 复合索引 + `Limit` + `ORDER BY` + `WithContext` 超时,并按 `pos_key` 引入 TTL 缓存(Redis 或内存),写路径失效。影响面:`internal/models/ads_item.go`、`internal/logic/fetch/by_pos.go`、迁移脚本。风险:中;缓存需处理失效与空值穿透(空结果也缓存短 TTL)。 +3. **鉴权与限流对齐(目标:无论哪种部署都有一致的身份策略)**:做法:以聚合 `Authorization.Anonymous` 为唯一白名单来源,明确 ads 是否公开;独立入口装配相同拦截器并加限流与 reflection 开关。影响面:`etc/*.yaml`、`internal/server/new.go`。风险:中;若把 ByPos 改为需登录,需同步前端与文档。 +4. **密钥与配置治理(目标:杜绝占位/默认密钥进入生产)**:做法:prod 配置独立化(DSN/库/sslmode)、启动时拒绝 `CHANGE_ME` 与默认 JWT 密钥、要求显式设置 `BSM_JwtSecretKey`(长度与复杂度校验)、删除未使用的 `SecretKey` 或真正使用。影响面:`internal/config/config.go`、`etc/*`、`.builds/etc/ads_prod.yaml`。风险:中;需与运维确认密钥注入方式,避免上线即 fail-fast 造成不可用。 +5. **数据模型与迁移一致化(目标:模型、DDL、查询三者不再互相打脸)**:做法:提供版本化迁移(含索引与 `deleted_at`),统一 `status` 默认值为 1 并定义常量,重写 README 的建表语句。影响面:`internal/models/*`、新增 `migrations/`、`README.md`。风险:中;已有线上表需用非破坏性 `ALTER TABLE` 并核对现存数据 status 分布。 +6. **可观测性与错误治理(目标:任何失败都能定位)**:做法:错误包装并上报原始 err + `pos_key` + request_id;接入 `Apm` 或删除该配置;注册 gRPC 健康检查与 `/health`;生产关闭 GORM `Debug()`。影响面:`internal/logic/fetch/by_pos.go`、`internal/impl/impl.go`、`internal/server/new.go`。风险:低。 +7. **启停与资源生命周期(目标:可优雅重启、无强依赖)**:做法:信号处理 + 关停时按序关闭 gRPC/DB/Redis/Etcd;Redis 改为懒加载且失败降级(因当前未被使用,最简方案是直接移除初始化)。影响面:`cmd/main/main.go`、`internal/impl/impl.go`。风险:低。 +8. **测试与 CI 门禁(目标:让上述修复可回归)**:做法:按问题 14 的清单补单测/网关集成测试,CI 跑 `go test ./...`、`go vet`、`gofmt -l`(当前两者已通过,可作为基线门禁)。影响面:`test/`、模块 CI 配置。风险:低。 +9. **代码与 proto 瘦身(目标:消除误导性声明与生成代码膨胀)**:做法:删除 `cmd/cli`、`test/rpc`、`grpcConns`、`ads_pos` 的“僵尸”定义;把 `const.proto` 的他域消息迁出。影响面:多个文件与 codegen 流程(`scripts/api-docgen`、`wiki/api/01-ads.md` 生成)。风险:中;迁移 proto 会影响所有引用 `base_ads_blocks` 的模块,需一次性同步生成与文档。 +10. **部署配置加固(目标:可运维、不写满磁盘、不越权)**:做法:supervisor 改非 root、加日志轮转与 `environment=`、显式设置 `BSM_RuntimeMode`;把连接池/超时提升为可配置。影响面:`etc/supervisor.bsm-apps-ads.conf`、SDK `conf.DBConf`。风险:低-中;SDK 改动影响全部模块,需单独评审。 + +## 5. TODO 清单 + +- [ ] **P1-1** 在 `internal/server.New()` 中初始化 `Mux` 并注册 `RegisterFetchHandlerServer`,让 `cmd/main` 与 `service.Expose` 走同一注册路径|验收:独立启动后 `curl -X POST localhost:12102/ads.Fetch/ByPos -d '{"key":"x"}'` 返回 200 而非 404,且 `go vet ./...` 通过|涉及:`module/base/ads/internal/server/new.go:26`, `module/base/ads/cmd/main/main.go:27`, `module/base/ads/service/expose.go:20` +- [ ] **P1-2** 为 `ads_item` 增加 `(pos_key,status)` 复合索引,并给 `Find` 加 `Order`+`Limit` 上限|验收:`EXPLAIN` 显示走索引扫描,返回条数不超过配置上限,迁移脚本可重复执行|涉及:`module/base/ads/internal/models/ads_item.go:30`, `module/base/ads/internal/logic/fetch/by_pos.go:21` +- [ ] **P1-3** 为独立入口装配鉴权/限流拦截器并把 gRPC reflection 改为配置开关(生产默认关闭)|验收:未携带凭证调用 `ads.Fetch/ByPos` 得到明确拒绝或按白名单放行,`grpcurl list` 在生产配置下失败|涉及:`module/base/ads/internal/server/new.go:23`, `module/base/ads/internal/server/new.go:36`, `module/base/ads/etc/ads_prod.yaml:15` +- [ ] **P2-4** 统一 `ads.Fetch.ByPos` 的匿名策略(建议在聚合白名单中显式登记)并在 `wiki/api/01-ads.md` 增加鉴权列|验收:独立入口与聚合入口对同一请求返回一致的 200/401,文档与实现一致|涉及:`module/base/ads/etc/ads_prod.yaml:15`, `pkgs/all/etc/default_dev.yaml:24`, `wiki/api/01-ads.md:12` +- [ ] **P2-5** prod 配置与 dev 分离,启动时拒绝占位口令与默认 JWT 密钥|验收:`password=CHANGE_ME` 或未设置 `BSM_JwtSecretKey` 时进程启动失败并打印明确错误;prod DSN 使用 `sslmode=require` 与独立库名|涉及:`module/base/ads/etc/ads_prod.yaml:7`, `module/base/ads/internal/config/config.go:37`, `module/base/ads/internal/config/config.go:40` +- [ ] **P2-6** 统一 `status` 默认值为 1 并定义状态常量替换魔法数字|验收:不带 status 插入的记录可被 `ByPos` 查到;`grep -n "status = ?\", in.Key, 1"` 处使用常量|涉及:`module/base/ads/internal/models/ads_item.go:34`, `module/base/ads/internal/logic/fetch/by_pos.go:21`, `module/base/ads/README.md:269` +- [ ] **P2-7** 提供可执行的迁移(含 `deleted_at`、索引)并修正 README 建表 DDL,删除不存在的 `--init-db`|验收:按 README 从空库执行后 `ByPos` 不报列缺失;迁移命令存在且有版本记录|涉及:`module/base/ads/internal/models/ads_item.go:37`, `module/base/ads/internal/impl/impl.go:26`, `module/base/ads/README.md:261`, `module/base/ads/README.md:218` +- [ ] **P2-8** 查询改为 `WithContext(ctx)` 并设置查询超时|验收:客户端取消后 DB 查询在超时内被中断(测试用 20s 慢查询验证)|涉及:`module/base/ads/internal/logic/fetch/by_pos.go:13`, `module/base/ads/internal/logic/fetch/by_pos.go:21` +- [ ] **P2-9** 错误返回前记录原始 err 与 `pos_key`/request_id,接入或删除 APM 配置,注册健康检查|验收:注入 DB 故障后日志中有可定位记录;`/health` 或 gRPC health 返回 SERVING|涉及:`module/base/ads/internal/logic/fetch/by_pos.go:22`, `module/base/ads/internal/config/config.go:22`, `module/base/ads/internal/server/new.go:32` +- [ ] **P2-10** 移除未使用的 Redis/内存缓存初始化(或改为懒加载降级),并向 `with.Databases` 传入生产可控的 `SqlOptions{Debug:false}`|验收:Redis 不可达时服务仍能启动并提供 ByPos;生产日志中无逐条 SQL 及其参数|涉及:`module/base/ads/internal/impl/impl.go:24`, `module/base/ads/internal/impl/impl.go:26` +- [ ] **P2-11** 实现信号处理与真正的优雅退出,按序关闭 gRPC/DB/Redis/Etcd|验收:`SIGTERM` 后在途请求完成、进程 5s 内退出,无强杀|涉及:`module/base/ads/cmd/main/main.go:42`, `module/base/ads/cmd/main/main.go:45` +- [ ] **P2-12** `Expose` 校验 `GRPC/Gateway/DB` 非 nil 并返回错误,不再丢弃 `server.New` 返回值|验收:任一依赖为 nil 时 `Expose` 返回明确错误而非 panic;聚合启动即失败并打印原因|涉及:`module/base/ads/service/expose.go:20`, `module/base/ads/service/expose.go:23`, `module/base/ads/service/dependencies.go:26` +- [ ] **P2-13** 对 `to_url` 做协议/域名白名单校验,对 `content` 明确纯文本或按类型校验,并扩展字段长度|验收:写入 `javascript:` 链接被拒绝;长 URL(>255)可正常存储|涉及:`module/base/ads/internal/models/ads_item.go:31`, `module/base/ads/internal/models/ads_item.go:33`, `module/base/ads/proto/ads.proto:23` +- [ ] **P2-14** 补齐测试:`ByPos` 空 key/命中/禁用/DB 错误、status 默认值、网关路由与字段映射、`Expose` nil 依赖、ctx 取消,并接入 CI|验收:`go test ./...` 覆盖上述用例且全部通过;覆盖率纳入门禁|涉及:`module/base/ads/test/rpc/rpc.go:3`, `module/base/ads/internal/logic/fetch/by_pos.go:13` +- [ ] **P3-15** 按代码实际重写 README(Go 版本、目录树、参数、健康检查、缓存/索引能力标注为未实现)|验收:README 中出现的每条命令/路径/目录都能在仓库中找到对应实现|涉及:`module/base/ads/README.md:35`, `module/base/ads/README.md:91`, `module/base/ads/README.md:218`, `module/base/ads/README.md:335` +- [ ] **P3-16** 清理死代码与未使用依赖(`cmd/cli`、`test/rpc`、`grpcConns`、`Apm`、`RedisService`、`MemorySerice`),`ads_pos` 若无实现则删除或标注规划中|验收:`grep -rn "grpcConns\|MemorySerice\|Apm" module/base/ads` 无残留业务引用|涉及:`module/base/ads/cmd/cli/main.go:6`, `module/base/ads/internal/server/new.go:17`, `module/base/ads/internal/models/ads_pos.go:15` +- [ ] **P3-17** 将 `proto/const.proto` 中他域消息迁出为独立 proto/go_package,ads 仅保留自身消息|验收:ads 的 pb 包不再包含 Cms/Order/Social/Market 消息,`scripts/api-docgen` 与 `wiki/api/01-ads.md` 重新生成后内容不变|涉及:`module/base/ads/proto/const.proto:132`, `module/base/ads/pb/const.pb.go:5` +- [ ] **P3-18** 消除魔法数字与排序/时间格式隐患:状态常量、`Order("id ASC")`、`created` 用 RFC3339、`Type` 增加 UNKNOWN=0,并同步 proto 注释|验收:`by_pos.go` 无字面量状态值;同广告位多条记录顺序稳定;时间字符串带时区|涉及:`module/base/ads/internal/logic/fetch/by_pos.go:21`, `module/base/ads/internal/logic/fetch/by_pos.go:35`, `module/base/ads/proto/ads.proto:22` +- [ ] **P3-19** 收敛 `go.mod`:去掉穿透仓库的本地 `replace` 依赖方式或改为 `go.work`,清理工具依赖的 indirect 标注|验收:在干净环境中仅凭仓库内步骤可完成构建,`go.mod` 无仓库外相对路径|涉及:`module/base/ads/go.mod:69`, `module/base/ads/go.mod:26` +- [ ] **P3-20** 加固 supervisor 配置(非 root、日志轮转、显式环境变量)并把连接池/超时提升为可配置|验收:`supervisorctl restart` 后进程以非 root 运行、日志按大小轮转、`BSM_RuntimeMode=prod` 生效|涉及:`module/base/ads/etc/supervisor.bsm-apps-ads.conf:6`, `module/base/ads/etc/supervisor.bsm-apps-ads.conf:8` +- [ ] **P3-21** 明确并按需装配 CORS(浏览器直连则白名单化,否则移除 indirect 依赖并在文档注明)|验收:跨域前端请求带正确 `Access-Control-Allow-Origin`,或文档明确该接口不支持跨域直连|涉及:`pkgs/all/go.mod:75`, `pkgs/all/internal/server/server.go:34` + +## 6. 审计摘要(供汇总使用) + +- 问题数:P0=0 P1=3 P2=11 P3=7 +- 最高风险(一句话):独立部署入口(`cmd/main`)的 HTTP 网关因 `Mux` 从未初始化/路由从未注册而整体 404(`internal/server/new.go:26-30` + `cmd/main/main.go:37`),而唯一业务接口 `ads.Fetch/ByPos` 在该入口既无鉴权无限流、其查询又因 `pos_key` 无索引而无分页全表扫描,形成“文档宣称可用但实际不通、若修通则裸奔”的双重缺口。 +- 最优先 3 个动作:1) 统一入口注册路径并让网关真正生效(P1-1);2) 为 `(pos_key,status)` 建索引并加 `Limit`/`Order`/`WithContext` 超时(P1-2);3) 为独立入口装配鉴权限流并关闭生产 reflection(P1-3)。 +- 未能覆盖/无法验证的部分:未运行服务、未连接真实 PostgreSQL/Redis/Etcd,故 404、panic、`deleted_at` 列缺失导致 `ErrDB` 等为静态推断(已在对应条目给出代码依据);未读取 3000+ 行 `pb/const.pb.go` 逐行内容(仅做接口与消息一致性比对);未审计 CI/发布脚本、未确认线上真实库表结构与生产配置是否被环境变量/etcd 覆盖;SDK(`bsm-sdk/core`)与聚合服务(`pkgs/all`、`pkgs/ecmall`)仅按需读取相关片段,其中记录的 SDK 侧问题(硬编码默认 JWT 密钥 `Cblocksmesh2022C`、`GrpcSrv.Serve/Listen` 失败即 panic、`Start()` 用 `select {}` 阻塞)归属 SDK 仓库,本报告仅在与 ads 调用点相关处引用。 diff --git a/docs/audit/module-base-cloud.md b/docs/audit/module-base-cloud.md new file mode 100644 index 0000000..ecb1caf --- /dev/null +++ b/docs/audit/module-base-cloud.md @@ -0,0 +1,810 @@ +# 审计报告:module/base/cloud + +## 1. 模块概览 + +`module/base/cloud` 是 BSM 全量单体仓库中的「个人云空间」业务模块,对外暴露 gRPC 服务,并通过 gRPC-gateway 暴露 HTTP(默认 `POST /cloud./`,JSON body)。 + +**规模** + +| 项 | 数量 | +|---|---| +| 非 pb Go 文件 | 84 个(约 4500 行) | +| proto 服务定义 | 7 个(Album / Bookmark / Disk / Note / Private / Share / Space) | +| etc 配置 | 3 个(cloud_dev.yaml / cloud_test.yaml / cloud_prod.yaml,内容基本一致) | +| 测试文件 | 0 个(`test/lint/` 为空目录) | + +**分层结构**:`cmd/main`(入口)→ `internal/server/*_server.go`(protoc-gen-slc 生成的转发层,无逻辑)→ `internal/logic//*.go`(1 RPC = 1 文件)→ `internal/models/*.go`(GORM 模型)→ `internal/impl/impl.go`(全局单例 DB/Redis/Etcd/Cache 句柄)。 + +**核心数据模型与归属字段**(这是本模块权限模型的骨架): + +| 模型 | 文件 | 归属字段 | 结果 | +|---|---|---|---| +| CloudDiskDir | `internal/models/cloud_disk_dir.go` | `Std_Passport` | ✅ 有 passport_id | +| CloudDiskFile | `internal/models/cloud_disk_file.go` | 无 `Std_Passport` | ❌ 无 passport_id,只能 JOIN 目录 | +| CloudAlbum | `internal/models/cloud_album.go` | `Std_Passport` | ✅ | +| CloudPhoto | `internal/models/cloud_photo.go` | **无归属字段** | ❌ 无 passport_id(且写入时也没赋值) | +| CloudNote | `internal/models/cloud_note.go` | `Std_Passport` | ✅ | +| NoteAttachment | `internal/models/cloud_note_attach.go` | **无归属字段** | ❌ 仅 note_id | +| CloudBookmark | `internal/models/cloud_bookmark.go` | `Std_Passport` | ✅ | +| CloudPrivate | `internal/models/cloud_private.go` | `Std_Passport` | ✅ | +| CloudShare | `internal/models/cloud_share.go` | `Std_Passport` | ✅ | +| CloudSpace | `internal/models/cloud_space.go` | `Std_Passport` | ✅ | + +**认证方式**:所有 logic 入口第一行统一调用 `service.ParseMetaCtx(ctx, nil)`(`D:\work\bsm-sdk\core\service\meta.go:19`),从 gRPC metadata 的 `authorization` 头解析 JWT,得到 `auth.ID`(passport_id)/`auth.Identity`。鉴权本身是统一的,问题出在**授权(归属校验)与所有权落库**上。注意 `opts == nil`,因此既没有角色校验也没有 `MustPrivateAllow` 限制。 + +--- + +## 2. 审计范围与方法 + +### 已覆盖子域(逐文件通读 100%) + +- **Disk**:`internal/logic/disk/` 全部 15 个文件(create_dir、get_dir、update_dir、delete_dir、list_dirs、get_dir_tree、move_dir、upload_file、get_file、update_file、delete_file、list_files、move_file、copy_file、search_files)。 +- **Album**:`internal/logic/album/` 全部 12 个文件(相册 CRUD + GetDirTree 类比 + 照片上传/删除/移动/封面)。 +- **Note**:`internal/logic/note/` 全部 10 个文件(含 attachment 的插入与删除)。 +- **Bookmark**:`internal/logic/bookmark/` 全部 6 个文件(含 ImportBookmarks)。 +- **Private**:`internal/logic/private/` 全部 9 个文件(含 EncryptData / DecryptData 加解密实现)。 +- **Share**:`internal/logic/share/` 全部 5 个文件(含 ValidateSharePassword)。 +- **Space**:`internal/logic/space/` 2 个文件。 +- **模型层**:`internal/models/` 全部 10 个文件 + `internal/models/query.go`。 +- **服务层/入口/配置**:`internal/server/`(new.go + 7 个 server)、`internal/impl/impl.go`、`internal/config/config.go`、`service/dependencies.go`、`service/expose.go`、`cmd/main/main.go`、`cmd/cli/main.go`、3 个 etc yaml、README.md、7 个 proto、`proto/const.proto`。 +- **pb 层抽样**:`pb/disk.pb.gw.go`(确认 gateway 绑定路径、请求体与 content-type 行为、明文 HTTP)。 +- **依赖侧(只读,用于验证调用方语义)**:`D:\work\bsm-sdk\core\service\meta.go`(ParseMetaCtx)、`core\types\db.go`(Std_IICUDS/Std_Passport,确认软删除与 uniqueIndex)、`core\utils\identity.go`(UUID 实现)、`core\database\new.go`、`core\database\sql\postgresql.go`、`core\service\service.go`(网关启动、无 TLS/中间件)、`core\with\databases.go`。 + +### 抽样方式与判据 + +- 对 51 个 logic 文件做全量精读(非抽样),因为每个文件都很短(25~120 行)。 +- 服务层 `internal/server/*_server.go` 为生成代码,仅确认「无额外校验、无 recover」。 +- 对 `pb/` 只抽样 `disk.pb.gw.go`,因为 7 个 gateway 文件由同一模板生成,抽样可代表。 + +### 未覆盖部分(诚实声明) + +- `pb/*.pb.go`、`*_grpc.pb.go`、除 disk 外的 6 个 `*.pb.gw.go`:生成代码,未逐行读。 +- `go.sum`、`service/expose.go` 之外的模块级装配代码(`module/base/all` 等调用方如何注入 Dependencies、网关如何挂载认证中间件)未审计——这会影响「是否为网关层补齐了 JWT」的最终结论。 +- **无任何可执行验证**:模块内无测试、无 fixture、无 SQL 迁移脚本,数据库/Redis 不可用,因此所有「运行期行为」类判断(GORM 关联 preload 语义、并发竞态)均为静态推断,已在文中标注。 +- `cmd/cli/main.go` 是 `Hello World` 空壳,无功能可审。 + +### 执行过的命令及结果 + +| 命令 | 结果 | +|---|---| +| `Get-ChildItem -Recurse`(模块文件清单/目录树) | 成功,得到 84 个非 pb Go 文件清单 | +| `Get-ChildItem test -Recurse -Force` | **`test/lint/` 为空目录,模块内 0 个测试文件** | +| `gofmt -l .`(在 `module/base/cloud`) | **退出码 0,无输出**,即所有 Go 文件格式合规 | +| `go vet ./...` | **未执行**:依赖 `bsm-sdk/core` 走 `replace ../../../../../bsm-sdk/core`,且执行环境未确认可完整构建;按效率约束跳过(不做 `go mod tidy`) | +| `grep`(`_ =`/`err == nil`/`TODO`/`panic(`、`Transaction|Begin()`、`PassportID`、`MaxStorage|quota`、`RedisService|MemorySerice` 等) | 成功,见各条证据 | + +--- + +## 3. 问题清单 + +### P0 + +#### P0-1. 照片上传从不落 `passport_id`,照片归属被彻底丢弃,导致跨用户照片越权(IDOR) + +- **位置**:`module/base/cloud/internal/logic/album/upload_photo.go:54`、`module/base/cloud/internal/models/cloud_photo.go:11` +- **证据**: +```go +// models/cloud_photo.go:11 —— 模型里根本没有 Std_Passport +type CloudPhoto struct { + types.Std_IICUDS + CloudBase + AlbumID uint `gorm:"index" json:"album_id"` +``` +```go +// upload_photo.go:54 —— 写入时也没有任何 PassportID/PassportIdentity +record := models.CloudPhoto{ + Std_IICUDS: types.Std_IICUDS{Identity: utils.UUID()}, + CloudBase: models.CloudBase{CloudID: album.CloudID, CloudIdentity: album.CloudIdentity}, + AlbumID: uint(in.AlbumId), +``` +- **影响**:两条独立且都很严重的后果。 + 1. **越权(可被利用)**:所有照片级接口(GetPhoto/UpdatePhoto/DeletePhoto `album/get_photo.go:30`、`album/update_photo.go:31`、`album/delete_photo.go:31`)都使用 + `Joins("JOIN cloud_albums ON cloud_photos.album_id = cloud_albums.id").Where("cloud_albums.passport_id = ?", auth.ID)`。 + 归属完全靠 album_id 反查,而 album_id 是客户端可控的整数。攻击者只要把自己的相册 id 猜/传到别人的照片所归属的相册上——不,实际路径是:删除/更新照片时不校验 `in.Id` 是否属于自己,只要该 photo 所属 album 的 owner 是自己即可;反过来,**任何 photo 只要其 album_id 指向攻击者的相册,攻击者就能改/删**。由于 UploadPhoto 未校验照片归属,攻击者可先调用 `UploadPhoto{album_id: 自己的相册}`,但更关键的是 `SetCoverPhoto`(见 P0-3)与 `DeleteAlbum`(见 P0-4)只按 `album_id` 批量操作,而 album_id 无归属写入约束。 + 2. **功能必然损坏**:`space/get.go:74` 与 `space/get_by_key_identifier.go:65` 统计照片数用同一 JOIN,逻辑上仍然能算出来;但**任何将来按 `passport_id` 直查 cloud_photos 的代码/报表/清理任务都会永远漏掉所有照片**(字段恒为 0)。同理 `CloudSpace.PhotoCount` 语义与模型不一致。 +- **建议**:给 `CloudPhoto` 加 `types.Std_Passport`,在 `UploadPhoto`/`MovePhoto` 中按 `auth.ID`/`auth.Identity` 落库;同时为已有数据写一次性回填(`UPDATE cloud_photos p SET passport_id = a.passport_id FROM cloud_albums a WHERE p.album_id = a.id`)。所有照片级查询改为直接 `WHERE passport_id = ?`,JOIN 仅用于取相册信息。 + +#### P0-2. `SetCoverPhoto` 只校验照片属于「该相册」,而相册归属校验存在但与照片归属解耦,可跨用户设置封面并读取他人照片路径 + +- **位置**:`module/base/cloud/internal/logic/album/set_cover_photo.go:39` +- **证据**: +```go +// 验证照片是否存在且属于该相册 —— 注意:只按 album_id 过滤,没有 passport_id +var photo models.CloudPhoto +if err := impl.DBService.Where("id = ? AND album_id = ?", in.PhotoId, in.AlbumId).First(&photo).Error; err != nil { +``` +- **影响**:`album` 已确认属于当前用户,但 `photo` 只校验 `album_id = in.AlbumId`。因为 cloud_photos 没有 passport_id(P0-1),任何一张 photo 只要 `album_id` 落在攻击者自己的相册里,就会被当作攻击者的照片,其 `FileSize`/`Tags`/`Location` 等元数据可被读取并写进相册封面(`album.CoverPhoto = photo.FilePath`,`set_cover_photo.go:45`)。这是一条**无 passport_id 兜底的越权读路径**,也是 P0-1 的可利用入口。 +- **建议**:同 P0-1,照片落 passport_id 后在此处加 `AND passport_id = ?`;短期可在 `First(&photo)` 后追加 `if photo.AlbumID != album.ID` 之外的 album 归属断言(当前已有,但不足以覆盖),根本解仍是照片自带归属。 + +#### P0-3. `DeleteAlbum` 按 `album_id` 无条件批量删除照片,且整个删除动作没有事务 + +- **位置**:`module/base/cloud/internal/logic/album/delete_album.go:45` +- **证据**: +```go +// 删除相册下的所有照片 +if err := impl.DBService.Where("album_id = ?", album.ID).Delete(&models.CloudPhoto{}).Error; err != nil { + printer.Error("Delete photos error: %v", err) + return nil, errcode.ErrDB +} +// 删除相册 +if err := impl.DBService.Delete(&album).Error; err != nil { +``` +- **影响**:(a) 批量删除语句不带 `passport_id`,在照片归属缺失(P0-1)的前提下,只要有任何 photo 的 `album_id` 等于该值就会被删,构成跨用户数据破坏路径;(b) 两条 `Delete` 之间无事务,且 SDK 连接是 `SkipDefaultTransaction: true`(`D:\work\bsm-sdk\core\database\new.go:90`),photo 删成功而 album 删失败时会留下空相册/数据不一致。 +- **建议**:包一层 `impl.DBService.Transaction(func(tx *gorm.DB) error {...})`;删除条件补 `passport_id`。 + +#### P0-4. 隐私数据加解密接口接受客户端任意密钥并做零填充/截断,且 `IsEncrypted` 由客户端自由设置 + +- **位置**:`module/base/cloud/internal/logic/private/encrypt_data.go:36`、`decrypt_data.go:39`、`create_private_data.go:54` +- **证据**: +```go +// encrypt_data.go:36 —— 客户端传 key,长度不足补 0x00,超长直接截断 +key := []byte(in.Key) +if len(key) != 32 { + if len(key) < 32 { + for len(key) < 32 { key = append(key, 0) } + } else { key = key[:32] } +} +``` +```go +// create_private_data.go:54 —— 是否加密完全听客户端的 +IsEncrypted: in.IsEncrypted, +``` +- **影响**:(a) 密钥空间被严重削弱:"a"、"a\x00...\x00"、"a ... 任意补零" 全部映射到同一密钥,且服务端**从不校验密钥强度**,短口令可被离线暴力破解(GCM 密文 + 已知明文可验证猜测);(b) 超长密钥静默截断会让用户以为用了强口令、实际只用前 32 字节;(c) 服务端用 `EncryptData` 返回 base64 但不下发/保管密钥,等于把密钥管理整体推给客户端,而 `IsEncrypted=false` 时明文直接落库(`Data string gorm:"type:text"`,`cloud_private.go:16`),"加密私人数据" 的核心承诺(README:11)在服务端没有任何强制。 +- **建议**:密钥必须来自服务端 KMS/用户主密钥派生(PBKDF2/Argon2 + 每记录 salt),拒绝客户端直传原始密钥;`IsEncrypted` 改为服务端强制 `true`;密钥长度非法应显式报错而不是补零/截断;若要保留客户端密钥模式,至少加 HKDF + 最小长度与口令强度校验。 + +#### P0-5. 分享口令明文存储 + 明文比较 + 无归属校验 + 无限流 + +- **位置**:`module/base/cloud/internal/logic/share/validate_share_password.go:32`、`:43`、`internal/models/cloud_share.go:18` +- **证据**: +```go +// validate_share_password.go:32 —— 只按 identity 查,不看 passport_id;auth 被丢弃(下划线) +_, err = service.ParseMetaCtx(ctx, nil) +... +if err := impl.DBService.Where("identity = ?", in.Identity).First(&share).Error; err != nil { +``` +```go +// validate_share_password.go:43 —— 明文 == 比较,非常量时间 +if share.Password != "" && share.Password != in.Password { +``` +```go +// cloud_share.go:18 +Password string `gorm:"size:100" json:"password"` // 可选分享密码 +``` +- **影响**:(a) 口令明文入库,一次 DB 泄露/备份泄露即全量分享口令泄露;(b) `!=` 逐字节比较存在时间侧信道,且服务端**没有任何失败次数限制/锁定/验证码**,38 位以内口令可被在线暴力枚举(接口无需知道分享者,任何登录用户都能打);(c) 该接口对**任意 authenticated 用户**开放,不校验调用者是否为分享者,等于给出一把免费的在线爆破 + 存在性探测(返回值区分"分享不存在/已过期/口令错"三类错误)。 +- **建议**:口令用 bcrypt/argon2id 哈希存储;比较用 `subtle.ConstantTimeCompare`;对 `(share_identity, caller_id)` 做失败计数与指数退避(Redis 已注入但完全未使用,见 P1-15/P2-8);统一错误码避免存在性泄露。 + +### P1 + +#### P1-1. `MoveDir` 只防「移到自己」,不防「移到自己的后代」,目录树成环后 `GetDirTree` 递归爆炸 + +- **位置**:`module/base/cloud/internal/logic/disk/move_dir.go:46` +- **证据**: +```go +// 检查是否会形成循环引用 +if dir.ID == parentDir.ID { + return nil, errcode.ErrInvalidArgument +} +``` +- **影响**:把 `/a` 移动到 `/a/b` 下(b 是 a 的后代)不会报错:`dir.ParentID` 指向 b、`dir.Path` 变为 `/a/b/a`。此后 `GetDirTree`(`get_dir_tree.go:38` 的 `Preload("Subdirectories")` + `:44-61` 的 `loadSubdirs` 递归)会在 a↔b 之间无限展开,**没有深度上限、没有 visited 集合、没有分页**,结果是栈溢出/内存耗尽/连接池打满。`UpdateDir` 改父级虽不直接产生环,但一旦已有环也会放大。 +- **建议**:移动前自底向上遍历 `new_parent` 的祖先链(或递归 CTE)判断 `dir.ID` 是否在其中;`GetDirTree` 增加最大深度与总节点数上限,或改为「一次查询全部目录 + 内存组树」避免逐层 N+1。 + +#### P1-2. 子目录路径用 `oldPath+"%"` 模糊匹配 + `strings.Replace(...,1)` 全量重写,且错误被吞掉 + +- **位置**:`module/base/cloud/internal/logic/disk/move_dir.go:71`、`:96`、`internal/logic/disk/update_dir.go:77` +- **证据**: +```go +// move_dir.go:71 —— 注意 if err == nil 才进循环,查询失败静默跳过 +if err := impl.DBService.Where("path LIKE ? AND passport_id = ?", oldPath+"%", auth.ID).Find(&subdirs).Error; err == nil { + for _, subdir := range subdirs { + newSubPath := strings.Replace(subdir.Path, oldPath, newPath, 1) + impl.DBService.Model(&subdir).Update("path", newSubPath) +``` +- **影响**:(a) `LIKE '/a%'` 会匹配到 `/abc`,把无关目录的 path 也一起改写(应为 `oldPath + "/%"`);(b) `WHERE path LIKE ?` 在 `path` 上无索引(模型只对 `parent_id`/`identity` 建了索引,`cloud_disk_dir.go:13-15`),是每层全表扫描;(c) 循环内逐条 `Update` 且**返回值被完全丢弃**,单条失败无感知,路径树进入半更新状态;(d) 整段没有事务;(e) `err == nil` 这种写法把 DB 错误当成「无子目录」处理。 +- **建议**:用 `oldPath || '/' || '%'` 精确前缀匹配(或改用 parent_id 递归 CTE);批量 `UPDATE ... SET path = replace(path, ?, ?) WHERE passport_id = ? AND path LIKE ?` 单条 SQL 完成;错误必须返回;整体包事务。 + +#### P1-3. 无 panic 恢复 + 根目录上 `GetDir/GetDirTree` 必然空指针崩溃(进程级 DoS) + +- **位置**:`module/base/cloud/internal/logic/disk/get_dir.go:49`、`:91`、`internal/logic/disk/get_dir_tree.go:51`、`:97`、`internal/server/new.go:23` +- **证据**: +```go +// get_dir.go:91 —— 根目录 ParentID == nil,直接解引用 +ParentId: uint64(*dir.ParentID), +``` +```go +// get_dir_tree.go:51 —— 递归里同样解引用 +ParentId: uint64(*subdir.ParentID), +``` +```go +// server/new.go:23 —— 无任何 UnaryInterceptor / recovery +grpcServ = grpc.NewServer() +``` +- **影响**:`CreateDir{ParentId: 0}` 创建的目录 `ParentID` 为 nil(`create_dir.go:71-74` 只在 `ParentId > 0` 时赋值)。此后对该目录调用 `GetDir`(或它出现在 `GetDirTree` 子树中)**必然 panic**;`ListDirs` 的作者显然知道要判空(`list_dirs.go:52-55`),说明这是遗漏而非设计。gRPC 无 recover 拦截器,`printer`/`env` 也未确认有兜底 → 按 Go 语义,未捕获 panic 会终止整个服务进程(除非调用方另起 recover),单个越权/畸形请求即可打挂服务。 +- **建议**:所有 `*dir.ParentID`/`*file.DirectoryID` 解引用改为 `if dir.ParentID != nil` 守卫(把 `list_dirs.go` 的写法统一抽成 helper);在 `server.New` 加一个 recover 拦截器,panic 转 `errcode.ErrInternal` 并记录堆栈。 + +#### P1-4. `storage_path` / `file_path` 完全由客户端决定,服务端零校验 + +- **位置**:`module/base/cloud/internal/logic/disk/upload_file.go:33`、`:74`、`internal/logic/disk/update_file.go:61`、`internal/logic/album/upload_photo.go:30` +- **证据**: +```go +// upload_file.go:33 +if strings.TrimSpace(in.StoragePath) == "" { + return nil, errcode.ErrInvalidArgument +} +``` +```go +// upload_file.go:74 —— 原样落库 +StoragePath: in.StoragePath, +``` +```go +// update_file.go:61 —— 更新时也允许任意改写,且不校验新路径是否被同账号其他文件占用 +file.StoragePath = in.StoragePath +``` +- **影响**:`cloud_disk_file.storage_path` / `cloud_photo.file_path` 是下游取文件事实上的权威指针(proto 注释即 "实际存储路径",`disk.proto:102`)。接口允许传入 `../../etc/passwd`、绝对路径、或另一个用户的存储路径,既不 normalize 也不校验前缀。文件服务(本模块外)若以该字段拼装本地路径即为**任意文件读取/覆盖**;`UpdateFile` 还允许在不换目录的前提下把路径指向别人的文件,等于把自己的记录"指向"他人数据。此外 UpdateFile 未校验 `size`/`hash` 一致性,去重哈希可被伪造。 +- **建议**:服务端生成 storage_path(如 `//`),完全忽略客户端该字段;若必须接受,用 `filepath.Clean` + 校验落在允许根目录内并拒绝 `..`;`UpdateFile` 只允许改 name/original_name/mime_type。 + +#### P1-5. 目录/文件名不做任何清洗,可注入路径分隔符与 `..` + +- **位置**:`module/base/cloud/internal/logic/disk/create_dir.go:44`、`internal/logic/disk/update_dir.go:57`、`internal/logic/disk/upload_file.go:30` +- **证据**: +```go +// create_dir.go:44 —— in.Name 直接参与路径拼接,只校验了非空(:28) +fullPath = filepath.Join(parentDir.Path, in.Name) +``` +```go +// update_dir.go:57 +dir.Path = filepath.Join(parentDir.Path, in.Name) +``` +- **影响**:`Name = "../../x"` 会被 `filepath.Join` 归一化成 `../../x` 或越出父目录的路径,从而在逻辑路径空间中绕过目录层级(例如把节点挂到别的逻辑子树下),破坏"路径唯一/树形一致"的不变量;`Name` 含 `/` 时还能一次性构造多层目录。路径后续若被下游用于真实文件系统操作,则升级为目录穿越。 +- **建议**:统一校验 `Name`:拒绝 `""`、`.`、`..`、含 `/` 或 `\`、含控制字符、长度超限(模型 `size:100`/`size:255`);路径由服务端用 `path.Join` 重建并断言以父路径为前缀。 + +#### P1-6. 文件/照片的「重名/重复」校验不带 passport_id,跨用户互相阻塞并泄露存在性 + +- **位置**:`module/base/cloud/internal/logic/disk/upload_file.go:50`、`internal/logic/disk/copy_file.go:52`、`internal/logic/disk/update_file.go:52`、`internal/logic/disk/move_file.go:47` +- **证据**: +```go +// upload_file.go:50 —— 无 passport_id、无 directory 归属二次确认 +if err := impl.DBService.Where("name = ? AND directory_id = ?", in.Name, in.DirectoryId).First(&existingFile).Error; err == nil { + return nil, errcode.ErrAlreadyExists +} +``` +- **影响**:`cloud_disk_file` 没有 passport_id(`cloud_disk_file.go:9-13`),唯一性判定天然缺失所有者维度。虽然插入前校验了目录归属(`upload_file.go:43`),但**目录归属与文件归属会在 MoveFile 后错位**:`MoveFile` 只校验目标目录属于自己(`:40`)和文件"通过 JOIN 目录"属于自己(`:31`),一旦文件被移动,其归属完全由所在目录动态决定 —— 这意味着**任何能修改自己目录归属的操作都会连带改变文件归属**。此外 `CopyFile`(`copy_file.go:57-72`)复制出的新记录同样不落 passport_id,语义上"复制"出的文件归属未定义。 +- **建议**:`cloud_disk_file` 增加 `Std_Passport` 并在所有写路径赋值;唯一性校验改为 `(passport_id, directory_id, name)` 复合唯一索引 + `(passport_id, hash)` 去重索引,让数据库兜底而不是靠先查后插(当前先查后插存在 TOCTOU 竞态,并发同名上传可双双成功)。 + +#### P1-7. 容量/配额字段存在但从不参与写入校验 + +- **位置**:`module/base/cloud/internal/models/cloud_space.go:15`、`internal/logic/space/get.go:36-38`、`internal/logic/disk/upload_file.go:19` +- **证据**: +```go +// cloud_space.go:15 +MaxStorage int64 `json:"max_storage"` // 最大存储空间 +``` +```go +// get.go:36-38 —— 100GB 硬编码,且仅用于回显 +TotalStorage: 100 * 1024 * 1024 * 1024, // 100GB +UsedStorage: 0, +MaxStorage: 100 * 1024 * 1024 * 1024, // 100GB +``` +```go +// upload_file.go:19 起 —— 全函数没有任何 size / 配额校验,in.Size 直接落库 +``` +- **影响**:`MaxStorage`/`UsedStorage` 在 `disk.UploadFile`、`album.UploadPhoto`、`note.InsertAttachment` 中**从未被读取**(全模块 grep `MaxStorage|Quota|quota` 只命中 space 的读/写与 limit 分页),配额形同虚设,单用户可无限写入;同时 `in.Size` 由客户端自报,即使将来加配额校验也可被伪造。 +- **建议**:上传前 `SELECT max_storage FROM cloud_space WHERE passport_id = ?` 并在事务内用 `UPDATE cloud_space SET used_storage = used_storage + ? WHERE passport_id = ? AND used_storage + ? <= max_storage` 做原子准入;size 由服务端按实际字节流统计/校验,不能信任客户端。 + +#### P1-8. `used_storage` 统计把用户全部文件读入内存求和;`GetDirTree` 逐层递归 Preload + +- **位置**:`module/base/cloud/internal/logic/space/get.go:61-68`、`internal/logic/space/get_by_key_identifier.go:52-59`、`internal/logic/disk/get_dir_tree.go:38`、`:44-61` +- **证据**: +```go +// get.go:61-68 —— 先 Count,再把所有行 Find 到 slice 里在 Go 里累加 +var files []models.CloudDiskFile +impl.DBService.Joins("JOIN cloud_disk_dirs ON cloud_disk_files.directory_id = cloud_disk_dirs.id"). + Where("cloud_disk_dirs.passport_id = ?", auth.ID).Find(&files) +for _, file := range files { usedStorage += file.Size } +``` +```go +// get_dir_tree.go:38 —— 只 Preload 一层,靠 loadSubdirs 递归时按需 lazy load +if err := query.Preload("Parent").Preload("Subdirectories").Preload("Files").First(&dir).Error; err != nil { +``` +- **影响**:(a) `used_storage` 本可 `SELECT COALESCE(SUM(size),0)` 一条聚合完成,现在是 **O(N) 行 + O(N) 内存**的搬运,文件数十万级时单次 `Space.Get` 就能吃掉大量内存与带宽;该接口还是"每次打开云盘首页"必调的。(b) `GetDirTree` 的递归里访问 `subdir.Subdirectories` 会触发 GORM lazy load,**深度 d 的树产生 1+d 次查询**,宽树则每次查询都返回全部列(含未用字段),是典型的 N+1 + 大对象拷贝。(c) `Files` Preload 无分页,单目录下 10 万文件一次性载入。 +- **建议**:聚合 SQL 化;`GetDirTree` 改为一次 `SELECT id,parent_id,name,path FROM cloud_disk_dir WHERE passport_id=?` + 内存建树(或 PostgreSQL 递归 CTE),并设最大深度/节点数;目录详情里的 Files 单独分页接口获取。 + +#### P1-9. 搜索与服务端分页 + +- **位置**:`module/base/cloud/internal/logic/disk/search_files.go:42`、`internal/logic/note/search_notes.go:40`、`internal/logic/private/search_private_data.go:40`、各 `list_*.go:25` 与 `:42` +- **证据**: +```go +// search_files.go:42 +searchKeyword := "%" + strings.ToLower(keyword) + "%" +query = query.Where("LOWER(cloud_disk_files.name) LIKE ? OR LOWER(cloud_disk_files.original_name) LIKE ?", searchKeyword, searchKeyword) +``` +```go +// list_dirs.go:25-27 —— 只设下限,无上限 +if in.GetPageSize() < 10 { + in.PageSize = 50 +} +``` +- **影响**:(a) `LOWER(col) LIKE '%kw%'` 前导通配符 + 函数包裹,任何普通 B-tree 索引都不可用,必然全表扫描;`note.title/content/tags`、`private.title/description/tags` 同样如此(`content` 还是 `type:text`)。(b) 全模块 14 处分页都是 `LIMIT/OFFSET`,深翻页性能随 offset 线性劣化,且 `page_size` 没有上限,`page_size=1e18` 会被截断成 `int` 后作为巨大 LIMIT 下发。(c) `offset` 由 `(page_no-1)*page_size` 计算,两个 int64 相乘存在溢出可能(虽非常规输入)。 +- **建议**:搜索改用 PostgreSQL `pg_trgm` GIN 索引(`name gin_trgm_ops`)或独立全文检索服务;`page_size` 上限(如 100);深分页改游标(`created_at,id` > last)。 + +#### P1-10. 全部数据库调用不透传 context,取消/超时/链路追踪失效 + +- **位置**:全模块 51 个 logic 文件(`impl.DBService` 调用点无一使用 `.WithContext(ctx)`) +- **证据**:以 `internal/logic/disk/list_dirs.go:34-45` 为例 +```go +if err := impl.DBService.Model(&models.CloudDiskDir{}).Where("passport_id = ?", auth.ID).Count(&total).Error; err != nil { +``` +(`grep "WithContext" module/base/cloud` → 无任何命中) +- **影响**:客户端断连或 deadline 到期后,正在执行的 SQL 不会被取消,长查询/全表扫描会继续占用连接直到自然结束;连接池被慢查询拖满后引发级联故障。同时因为没有 request-scoped context,任何 DB 层的 trace/超时治理都无法落地。 +- **建议**:统一改为 `impl.DBService.WithContext(ctx)`;给每个 RPC 设默认 statement_timeout。 + +#### P1-11. 多步写操作无事务(全模块 `Transaction` 0 处) + +- **位置**:`module/base/cloud/internal/logic/album/delete_album.go:45`、`internal/logic/note/delete_note.go:45`、`internal/logic/disk/move_dir.go:69`、`internal/logic/disk/update_dir.go:75` +- **证据**: +```go +// delete_note.go:45-54 —— 先删附件再删笔记,两步无事务 +if err := impl.DBService.Where("note_id = ?", note.ID).Delete(&models.NoteAttachment{}).Error; err != nil { ... } +if err := impl.DBService.Delete(¬e).Error; err != nil { ... } +``` +(`grep "Transaction|Begin\(\)" module/base/cloud` → 无任何命中;SDK 侧 `SkipDefaultTransaction: true`,`D:\work\bsm-sdk\core\database\new.go:90`) +- **影响**:第二步失败时留下孤儿附件/空相册;`move_dir`/`update_dir` 的批量 path 改写在同一次调用内可能部分成功(叠加 P1-2 的错误吞掉,静默不一致)。批量操作(`ImportBookmarks` 的 `CreateInBatches`)也无事务,中途失败只回滚当前批次。 +- **建议**:凡"父删子删"、"主记录 + 子节点路径更新"一律包 `Transaction`;`ImportBookmarks` 用单事务 + 幂等键。 + +#### P1-12. 软删除与唯一性/唯一索引的交互未处理 + +- **位置**:`module/base/cloud/internal/models/cloud_disk_dir.go:10`、`internal/logic/disk/create_dir.go:51`、`internal/logic/disk/upload_file.go:50` +- **证据**: +```go +// cloud_disk_dir.go:10 内嵌 Std_IICUDS,含 gorm.DeletedAt +// (D:\work\bsm-sdk\core\types\db.go:33)DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;..."` +``` +```go +// create_dir.go:51 —— 默认 scope 自动带 deleted_at IS NULL +if err := impl.DBService.Where("path = ? AND passport_id = ?", fullPath, auth.ID).First(&existingDir).Error; err == nil { +``` +- **影响**:(a) 应用层重名判断被软删除 scope 过滤,而 `identity` 上有 `uniqueIndex`(`db.go:30`)——注意该唯一索引不包含 `deleted_at`,因此"软删后重建同 identity"会撞唯一键;当前 identity 用 UUID 生成,撞键概率低,但**任何手工/迁移脚本重建 identity 都会失败**。另外 `path` 上根本没有唯一索引,path 唯一性完全靠"先查后插",并发下可插入两条同 path 记录。(b) 无回收站/恢复接口,`DeleteDir`/`DeleteFile` 失败后数据只能靠 DBA 从 `deleted_at` 捞(README 未提及回收站,功能缺口)。 +- **建议**:给 `(passport_id, path)` 与 `(passport_id, directory_id, name)` 建带 `WHERE deleted_at IS NULL` 的部分唯一索引;identity 若参与业务唯一性,索引需含 `deleted_at`;明确回收站语义并补齐恢复/彻底删除接口。 + +#### P1-13. `CloudSpace` 创建时 `identity`/`key_identifier` 硬编码为 "default",而 `key_identifier` 是全局唯一索引 + +- **位置**:`module/base/cloud/internal/logic/space/get.go:27-35`、`internal/models/cloud_space.go:12` +- **证据**: +```go +// get.go:27-35 +space = models.CloudSpace{ + Std_IICUDS: types.Std_IICUDS{Identity: "default"}, + Std_Passport: types.Std_Passport{PassportID: auth.ID, PassportIdentity: auth.Identity}, + KeyIdentifier: "default", +``` +```go +// cloud_space.go:12 +KeyIdentifier string `gorm:"uniqueIndex;size:32" json:"key_identifier"` +``` +- **影响**:`key_identifier` 是**全局唯一**索引且被硬编码为常量 "default",只有第一个用户能创建成功;第二个未初始化用户调用 `Space.Get` 时 `Create` 会因唯一键冲突返回 ErrDB,**云盘首页对该用户永久 500/报错**。同时 `Identity` 也用 "default",与 `Std_IICUDS` 的 `uniqueIndex` 冲突。这是确定性的线上故障(只要存在两个空间记录尚未创建的用户)。`GetByKeyIdentifier` 用 `key_identifier` 查询(`get_by_key_identifier.go:35`),硬编码后该接口失去意义。 +- **建议**:`Identity = utils.UUID()`、`KeyIdentifier = utils.ULID()`(或 `"default-" + passportIdentity`);用 `FirstOrCreate`/`INSERT ... ON CONFLICT DO NOTHING` 做并发安全初始化;把 `key_identifier` 的唯一索引改为 `(passport_id, key_identifier)`。 + +#### P1-14. 计数器读-改-写竞态(分享浏览量、笔记浏览量) + +- **位置**:`module/base/cloud/internal/logic/share/validate_share_password.go:48`、`internal/logic/note/increment_views.go:45` +- **证据**: +```go +// validate_share_password.go:47-49 +share.ViewCount++ +if err := impl.DBService.Save(&share).Error; err != nil { +``` +```go +// increment_views.go:45-47 +note.Views++ +if err := impl.DBService.Save(¬e).Error; err != nil { +``` +- **影响**:`SELECT` 后 `Save` 整行写回,并发请求互相覆盖,计数丢失(经典的 lost update)。`Save` 还会把整行(含 content/data 大字段)写回,产生不必要的大对象写放大。附带语义问题:`IncrementViews` 只有笔记作者本人能调用(`WHERE passport_id = auth.ID`),因此"浏览次数"统计的其实是**作者自阅次数**,与 README:193 "浏览次数统计"的预期不符;且 `IsPrivate=true` 的笔记没有任何访问控制实现(`list_notes.go:40` 不带 `is_private` 过滤,但也没有"他人访问"入口,属未实现)。 +- **建议**:改为 `Update("view_count", gorm.Expr("view_count + 1"))` 原子自增;`IncrementViews` 应允许非作者浏览计数(按资源 id + 限流),或明确语义并改名。 + +#### P1-15. 分享创建缺乏资源归属校验与参数约束;`ImportBookmarks` 无任何规模上限 + +- **位置**:`module/base/cloud/internal/logic/share/create_share.go:39`、`:68-71`、`internal/models/cloud_share.go:17`、`internal/logic/bookmark/import_bookmarks.go:38`、`:46` +- **证据**: +```go +// create_share.go:39-42 —— 客户端可直接指定 token +shareToken := in.ShareToken +if shareToken == "" { shareToken = utils.UUID() } +``` +```go +// create_share.go:68-71 —— share_type/resource_id 完全不校验资源是否存在、是否属于自己 +ShareType: in.ShareType, +ResourceID: uint(in.ResourceId), +``` +```go +// import_bookmarks.go:38-46 —— 客户端 JSON 直接 unmarshal 成切片,无长度上限 +var bookmarks []map[string]interface{} +if err := json.Unmarshal([]byte(in.Data), &bookmarks); err != nil { +``` +- **影响**:(a) `resource_id` 可为任意用户的资源,`CreateShare` 会为其生成分享令牌 → 只要下游分享访问按 `resource_id` 取资源,即是**越权分享他人文件**;(b) 客户端指定的 `ShareToken` 未做格式/长度校验,超 32 字符会在 `size:32` 写入时报 DB 错,且可构造可枚举 token;(c) `ImportBookmarks` 无条数/字节上限,超大 JSON 造成内存放大(gRPC 默认 4MB 消息上限是唯一约束,但仍可放大数倍),`CreateInBatches` 无事务、无幂等;(d) 客户端传入的 URL 未做 scheme 校验,可存入 `javascript:` 等。 +- **建议**:`CreateShare` 按 `share_type` 反查资源并要求 `passport_id = auth.ID`;`ShareToken` 一律服务端生成且校验格式;`ImportBookmarks` 限制条数(如 ≤5000)与总字节数,URL 只允许 http/https,`Format` 白名单。 + +### P2 + +#### P2-1. `GetByKeyIdentifier` 与 `Get` 的统计块完全重复 + +- **位置**:`module/base/cloud/internal/logic/space/get.go:52-100`、`internal/logic/space/get_by_key_identifier.go:43-91` +- **证据**:两段代码逐字符几乎相同(`Space.Get` 与 `Space.GetByKeyIdentifier` 各自维护一份 6 次 Count + 1 次全表求和),仅查询条件不同。 +- **影响**:任何统计口径调整都需要改两处,必然漂移(例如新增 bookmark 统计时漏改一处)。这是本模块唯一的"重复大段逻辑",其余 CRUD 的重复(分页/转换)见 P3-1。 +- **建议**:抽出 `func refreshSpaceStats(ctx, passportID) (space, error)`,两个入口共用。 + +#### P2-2. `CloudSpace` 的 6 个关联字段从不 Preload,永远返回空数组 + +- **位置**:`module/base/cloud/internal/models/cloud_space.go:23-28`、`internal/logic/space/get.go:102-116` +- **证据**: +```go +// cloud_space.go:23-28 +CloudDiskDirectorys []CloudDiskDir `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"` +Albums []CloudAlbum `gorm:"foreignKey:PassportID" json:"cloud_albums"` +``` +```go +// get.go:102 起的 reply 构造里没有任何 Preload(...),proto 的 14~19 字段永远是空 +reply = &pb.CloudSpace{ Id: ..., TotalStorage: ..., } +``` +- **影响**:`space.proto:38-43` 声明了 `cloud_disk_dirs/cloud_albums/cloud_notes/...` 六个 repeated 字段,客户端若依赖它们会永远拿到空数组;且这些字段名(`CloudDiskDirectorys` 拼写错误)与 proto(`cloud_disk_dirs`)不一致。 +- **建议**:要么在 `Get` 里显式分页 Preload,要么从 proto 删除这些字段;修正字段拼写。 + +#### P2-3. `DeleteAttachment` 的 identity 分支被自我覆盖,identity 参数完全无效 + +- **位置**:`module/base/cloud/internal/logic/note/delete_attachment.go:37` +- **证据**: +```go +} else { + // 注意:NoteAttachment模型没有identity字段,这里假设通过ID删除 + query = query.Where("note_attachments.id = ?", in.Id) +} +``` +- **影响**:注释即自认的缺陷。客户端按 identity 删除时 `in.Id == 0`,查询变成 `id = 0`,**永远匹配不到**(返回 ErrInvalidArgument),且没有报错提示;`NoteAttachment` 缺 Identity 字段导致其无法按身份引用(对比其它模型的 `Std_IICUDS`)。 +- **建议**:`NoteAttachment` 增加 Identity,或让 `DeleteAttachment` 接受 `(note_id, attachment_id)` 二元组并改为按 id 语义(同时修正 proto)。 + +#### P2-4. `InsertAttachment` 用 `string(rune(id))` 拼接返回详情,产出乱码 + +- **位置**:`module/base/cloud/internal/logic/note/insert_attachment.go:59` +- **证据**: +```go +Details: string(rune(attachment.ID)), +``` +- **影响**:`@typescript-eslint` 式的 go vet 会报 `conversion from int to string yields a string of one rune`。id=1 返回 `"\x01"`,id=65 返回 `"A"`——返回给客户端的 `details` 是控制字符而非数字,调用方无法按此字段回调/关联,是确定的接口缺陷(`StatusReply.details` 在其它接口里承载 identity/OK 等字符串语义)。 +- **建议**:`strconv.FormatUint(uint64(attachment.ID), 10)`,或与其它接口一致返回 `attachment.Identity`(需先补字段)。 + +#### P2-5. 缓存层完全未使用(README 承诺的缓存策略为零实现) + +- **位置**:`module/base/cloud/README.md:440-448`、`module/base/cloud/internal/impl/impl.go:13-16`、`module/base/cloud/service/dependencies.go:20-31` +- **证据**: +```go +// impl.go:13-16 —— 声明了三个缓存/注册中心句柄 +RedisService *redis.RedisClient +EtcdService *clientv3.Client +MemorySerice *cache.Cache +``` +``` +grep "impl\.(RedisService|MemorySerice|EtcdService)" module/base/cloud/internal → 无任何命中 +``` +- **影响**:README 表格列出的"文件元数据 30 分钟 / 相册 1 小时 / 书签 6 小时"缓存策略**在代码中不存在**,全部请求直打 PostgreSQL;`RedisService`/`MemorySerice` 是死变量(`MemorySerice` 还拼错了 Service)。同时文档承诺的缓存失效策略、连接复用在代码里无从谈起。 +- **建议**:要么实现缓存(热点:`Space.Get` 统计、目录树、用户信息),要么从 README 删除该章节;清理未使用依赖(redis/go-cache/etcd 若仅用于装配则保留但需注明)。 + +#### P2-6. 可观测性与运维面缺失:无健康检查、无 metrics、无 APM、无结构化日志 + +- **位置**:`module/base/cloud/etc/cloud_prod.yaml:32-35`(APM 全被注释)、`module/base/cloud/README.md:457-465`、`internal/logic/**` 的日志调用 +- **证据**: +```yaml +# cloud_prod.yaml:32-35 +# 链路追踪,性能监控,日志收集 +# APM: +# Platform: elasticAPM +``` +```go +// 全部错误日志形如(disk/get_file.go:40): +printer.Error("File not found: %v", err) +``` +- **影响**:README:461-464 宣称 `curl http://localhost:12102/health` 和 `/metrics` 可用,但模块内 `grep health|metrics` **只命中 README**,接口不存在。APM 在 prod 配置中被注释,等于生产无链路追踪。日志仅 40 余处 `printer.Error`,无请求 ID、无结构化字段、无耗时,且错误信息把 DB 错误直接 `%v` 打出(可能含 SQL 片段与参数,见 P2-7)。所有列表/查询接口无慢查询日志。 +- **建议**:补 `/healthz`(含 DB/Redis 探活)与 `/metrics`(Prometheus);启用 APM 并在 prod 配置中固化;统一结构化日志(trace_id/passport_id/latency)。 + +#### P2-7. GORM 默认 `Debug: true` 且非参数化查询,可能把分享口令/隐私密文写进标准输出 + +- **位置**:`D:\work\bsm-sdk\core\database\sql\postgresql.go:12-21`、`:46-48`、`D:\work\bsm-sdk\core\with\databases.go:18` +- **证据**: +```go +// postgresql.go:13-20 —— options == nil 时的默认值(cloud 调用 with.Databases(cfg, nil)) +options = &types.SqlOptions{ + MaxIdleConns: ..., MaxOpenConns: ..., ConnMaxLifetime: ..., + IsAutoMigrate: false, + LogStdout: false, + Debug: true, // ← 默认打开 +} +``` +```go +// postgresql.go:46-48 +if options.Debug { gormDb = gormDb.Debug() } +``` +```go +// with/databases.go:18 —— 另外整份 DBConf(含 DSN 与密码)被直接打印 +printer.Info("[BSM - %s] Databases: %v", vars.ServiceKey, cfg) +``` +- **影响**:(a) `Debug()` 让 GORM 打印所有 SQL 到 stdout,包含 `INSERT ... cloud_share(password)`(明文口令)、`cloud_private.data`(用户隐私密文/明文)、`cloud_note.content`;且 `postgres.Config` 未启用 `PreferSimpleProtocol`(第 34 行注释掉的正是该项)→ 参数内联,敏感值直接出现在日志。(b) 启动日志打印完整 DSN,密码进日志系统。 +- **建议**:`cloud` 侧显式传入 `&types.SqlOptions{IsAutoMigrate: true, Debug: false, ...}`;脱敏 DSN 后再打印;日志中禁止出现 password/data/content 字段。 + +#### P2-8. 不安全默认配置与配置校验缺失 + +- **位置**:`module/base/cloud/etc/cloud_prod.yaml:7`、`:10`、`:24`、`internal/config/config.go:35`、`cmd/main/main.go:18` +- **证据**: +```yaml +# cloud_prod.yaml:7 —— sslmode=disable + 占位密码 +- host=127.0.0.1 user=postgres password=CHANGE_ME dbname=rst_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai +# cloud_prod.yaml:10 +Cache: redis://null:CHANGE_ME@127.0.0.1:6379/ +# cloud_prod.yaml:24 +SecretKey: CHANGE_ME +``` +```go +// config.go:35 —— 只校验了 Service 和 Cache +conf.NotNil(Spec.Service, Spec.Cache) +``` +- **影响**:(a) 生产配置三处占位密钥 + PostgreSQL `sslmode=disable`(数据库链路明文);(b) `Databases`、`Gateway`、`Etcd`、`Rpc` 均未纳入 `NotNil` 校验,漏配时会在运行期 panic(`with.Databases` 里 `panic("No Database Source Found !")`,`D:\work\bsm-sdk\core\with\databases.go:14`),属于"启动即崩"的配置耦合;(c) `Service: initial` 与 `ServiceKey = "cloud"`(`cmd/main/main.go:15`)不一致,注册/路由 key 与实际服务名存在漂移风险;(d) 无 TLS 配置项,网关 `http.ListenAndServe`(`D:\work\bsm-sdk\core\service\service.go:126`)明文 HTTP,全模块无任何限流中间件。 +- **建议**:密钥走 Secret 管理(环境变量/配置中心)而非 yaml 占位;生产强制 `sslmode=require`;补齐配置校验并在启动时失败-快;网关加 TLS 终止 + 限流(尤其 `ValidateSharePassword`)。 + +#### P2-9. 写入路径无幂等性设计 + +- **位置**:`internal/logic/disk/create_dir.go:76`、`internal/logic/disk/upload_file.go:78`、`internal/logic/album/upload_photo.go:75`、`internal/logic/note/insert_attachment.go:53`、`internal/logic/bookmark/import_bookmarks.go:83` +- **证据**: +```go +// upload_file.go:56-59 —— "hash 去重"名不副实:没传 hash 就用 UUID 当 hash +fileHash := in.Hash +if fileHash == "" { + fileHash = utils.UUID() // 使用UUID作为默认哈希 +} +``` +- **影响**:所有创建接口都没有幂等键(无 `request_id`/客户端幂等 token),网络重试/网关重发会产生重复目录、重复照片、重复附件;`UploadFile` 的 `hash` 由客户端提供或退化为随机 UUID,README:140 宣称的"文件哈希去重"**实际没有任何去重逻辑**(全模块没有按 hash 查重的代码)。 +- **建议**:写接口引入幂等键(`(passport_id, request_id)` 唯一索引 + 命中即返回原结果);hash 由服务端对内容计算,并据此实现真正的秒传/去重。 + +#### P2-10. 无优雅退出:gRPC 仅 `GracefulStop`,HTTP 网关无关闭路径,etcd 租约未释放 + +- **位置**:`module/base/cloud/cmd/main/main.go:37`、`D:\work\bsm-sdk\core\service\service.go:142-144`、`:114` +- **证据**: +```go +// main.go:37 +defer srv.Stop() +// service.go:142-144 +func (s *Service) Stop() { s.GrpcSrv.GracefulStop() } +``` +```go +// service.go:113-115 —— Start 永久阻塞,HTTP server 引用未保存 + // 阻塞主线程 + select {} +``` +- **影响**:`defer` 在 `select{}` 之后永远不执行(`Run()` 不返回),`srv.Stop()` 实际是死代码;HTTP 网关用 `http.ListenAndServe` 且句柄未保留,无法优雅关闭;etcd 注册的租约监听 goroutine 无退出信号。K8s 滚动发布时会直接掐断在途请求。 +- **建议**:`Start` 返回 `stop chan`/接收 `context`,注册 `SIGTERM` 处理:先停 HTTP(`srv.Shutdown`)、再 `GracefulStop`、最后释放 etcd lease。 + +#### P2-11. 魔法数字与不一致的默认值散落各处 + +- **位置**:`internal/logic/space/get.go:36-38`(100GB ×2)、所有 `list_*.go` 的 `pageSize<10 → 50`(14 处)、`internal/logic/share/create_share.go:50` 与 `:53`(`7 * 24 * time.Hour` ×2)、`internal/logic/album/upload_photo.go:45-51`(TakenAt 解析失败静默替换为 `time.Now()`) +- **证据**: +```go +// create_share.go:50 与 :53 同一常量写了两遍 +expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期 +... +expiresAt = time.Now().Add(7 * 24 * time.Hour) // 默认7天过期 +``` +```go +// upload_photo.go:45-49 —— 解析失败不报错,用当前时间掩盖 +if t, err := time.Parse(time.RFC3339, in.TakenAt); err == nil { takenAt = t } else { takenAt = time.Now() } +``` +- **影响**:配额、分页、过期策略、拍摄时间兜底值无法统一调整;`TakenAt` 非法时静默写入错误元数据(用户看到"拍摄于今天"),是数据质量问题。 +- **建议**:抽 `internal/consts`(DefaultPageSize / MaxPageSize / DefaultQuota / DefaultShareTTL);时间解析失败应返回 `ErrInvalidArgument` 而非静默兜底。 + +#### P2-12. 模块内 0 个测试文件 + +- **位置**:`module/base/cloud/test/lint/`(空目录) +- **证据**: +``` +Get-ChildItem -Path "...\module\base\cloud\test" -Recurse -Force +→ 仅一个空目录 test\lint,0 个文件;全模块无 *_test.go +``` +- **影响**:README:338-355 描述的 `make test` / `make test-grpc` / 覆盖率 / 安全扫描在本模块无对应产物。所有归属校验、路径重写、配额、加解密逻辑全靠人工评审。 +- **建议**:按第 6 节"关键缺失用例清单"补齐,优先 P0/P1 对应的越权与路径一致性用例。 + +#### P2-13. 文档与实现大面积不一致 + +- **位置**:`module/base/cloud/README.md:119-142`、`:461-464`、`:469-499`、`:556-619`、`:310` +- **证据**: +```markdown +# README.md:461-464 —— 接口不存在(模块内 grep 无任何 health/metrics 实现) +curl http://localhost:12102/health +curl http://localhost:12102/metrics +``` +```markdown +# README.md:570 —— SQL 语句本身写错了 +CREATE TABLE-cloud_disk_file ( +``` +- **影响**:README 声称的"Redis 缓存""健康检查/APM""文件哈希去重""EXIF 信息提取"(`:170`)、`/cloud.swagger.json`(`:310`,但 proto **没有任何 `google.api.http` 注解**,见 `grep google.api.http proto/ → 无命中`,因此不会生成 swagger)、表结构(README 用复数表名,实现是 `SingularTable: true` 的单数表名,字段也对不上)均与实现不符;README:230-232 的 proto 消息名(`ListPrivateResponse`/`GetPrivateDataByTypeRequest`)与实际(`ListPrivateDataResponse`/`FetchRequest`)不一致。运维照文档操作会直接踩空。 +- **建议**:以代码为准重写 README 的 API/表结构/配置章节;删掉未实现特性或补实现;给 proto 加 http 注解以生成 swagger(若确实需要)。 + +#### P2-14. 分享 / 隐私数据的敏感字段无脱敏 + +- **位置**:`internal/logic/share/get_share.go:54`、`internal/logic/share/list_shares.go:57`、`internal/models/cloud_share.go:18` +- **证据**: +```go +// get_share.go:54 +Password: share.Password, +``` +- **影响**:`GetShare`/`ListShares` 把明文分享口令回传给调用方(虽为本人,但会进入客户端缓存/日志/浏览器历史);`CloudShareItem.password` 在 proto 中也没有标注为只写字段。配合 P2-7 的 SQL 日志,口令会有多个泄露面。 +- **建议**:出参不回传 password(或只回传 `has_password bool`);口令入参标记为敏感并从审计日志中排除。 + +### P3 + +#### P3-1. 大量复制粘贴:分页校验 + offset 计算 + 模型→pb 转换在 14+ 处重复 + +- **位置**:`internal/logic/disk/list_dirs.go:21-47`、`list_files.go:21-52`、`search_files.go:22-59`、`internal/logic/album/list_albums.go:21-47`、`list_photos.go:21-51`、`internal/logic/note/list_notes.go:21-47`、`search_notes.go:22-57`、`internal/logic/bookmark/list_bookmarks.go:21-46`、`internal/logic/private/list_private_data.go:21-46`、`search_private_data.go:22-56`、`get_private_data_by_type.go:23-57`、`internal/logic/share/list_shares.go:21-46`、`internal/logic/space/get.go:52-100`(共 14 处分页块 + 7 处各不相同的转换函数) +- **证据**: +```go +// list_dirs.go:22-27,在 12 个文件里逐字重复 +if in.GetPageNo() < 1 { in.PageNo = 1 } +if in.GetPageSize() < 10 { in.PageSize = 50 } +offset := (in.PageNo - 1) * in.PageSize +``` +- **影响**:分页语义/默认值变更需改 14 处;模型→pb 的字段搬运(每个文件 30~90 行)无任何复用,`CloudPhotoItem` 的构造在 `list_photos.go`/`get_photo.go`/`list_albums.go`/`get_album.go` 里出现了 4 次。 +- **建议**:抽 `pagination.Normalize(in)` 与 `converter` 包(`ToPhotoItem(models.CloudPhoto) *pb.CloudPhotoItem`),或引入 mapstruct 风格的集中转换。 + +#### P3-2. proto 中大量与本模块无关的消息,且缺少 HTTP 注解 + +- **位置**:`module/base/cloud/proto/const.proto:37-326` +- **证据**:`const.proto` 里包含 CMS/Mall/Market/Order/Feed/Group/Relation 等消息(`CmsSearchRequest`、`MallFetchRequest`、`OrderSummaryItem`、`FeedPostItem`、`RelationItem`…),而 cloud 模块只是共用该文件;所有 proto 均无 `google.api.http` 注解。 +- **影响**:const.proto 成为跨模块共享的"杂物间",任何模块改动都会触发 cloud 的 pb 重新生成(耦合);无 http 注解意味着无法生成 swagger/自定义路由(README 却宣称有 swagger)。 +- **建议**:cloud 只保留自用的 `Empty/FetchRequest/IdentRequest/StatusReply` 等,其余下沉到各模块自己的 const。 + +#### P3-3. 模型字段与索引设计不一致 + +- **位置**:`internal/models/cloud_disk_dir.go:18-20`、`internal/models/cloud_space.go:23-28`、`internal/models/cloud_note_attach.go:10-18` +- **证据**: +```go +// cloud_disk_dir.go:18-19 —— 自关联未写 references:ID(同仓库 mgt 模块的写法是 foreignKey:ParentID;references:ID) +Parent *CloudDiskDir `gorm:"foreignKey:ParentID" json:"parent"` +Subdirectories []CloudDiskDir `gorm:"foreignKey:ParentID" json:"subdirectories"` +``` +```go +// cloud_space.go:23 —— 字段名拼写错误 +CloudDiskDirectorys []CloudDiskDir `gorm:"foreignKey:PassportID" json:"cloud_disk_dirs"` +``` +```go +// cloud_note_attach.go:10-18 —— NoteAttachment 只有 ID,没有 Std_IICUDS +type NoteAttachment struct { + ID uint `gorm:"primaryKey" json:"id"` +``` +- **影响**:`Parent`/`Subdirectories` 与 `mgt` 模块的同类自关联写法不一致(缺 `references:ID`)——**推测**在 GORM 中该写法会让 `Parent` 关联的查询条件缺少预期约束(GORM 默认以主键作 reference,自引用场景需要显式 references 才可靠),`GetDir`/`GetDirTree` 返回的 `Parent` 可能是非父节点;此结论**未能运行期验证**,建议以集成测试确认。`CloudDiskDirectorys` 拼写错误会外泄到 JSON tag 之外(tag 是 `cloud_disk_dirs` 正确,Go 字段名错误影响可读性)。 +- **建议**:统一为 `gorm:"foreignKey:ParentID;references:ID"`;修正拼写;`NoteAttachment` 补齐 `Std_IICUDS`(同时解决 P2-3)。 + +#### P3-4. `cmd/cli` 是 Hello World 空壳 + +- **位置**:`module/base/cloud/cmd/cli/main.go:1-7` +- **证据**: +```go +func main() { + log.Println("Hello World!") +} +``` +- **影响**:README:79-80 将 `cmd/cli` 描述为"命令行工具",实际无任何功能;该二进制会被编译进发布产物(增加无用体积与攻击面)。 +- **建议**:删除该目录,或实现预期的运维命令(如空间重建、软删清理)。 + +#### P3-5. 统计字段/计数器写入即持久化,且部分字段永不更新 + +- **位置**:`internal/logic/space/get.go:97`、`internal/models/cloud_share.go:21`、`internal/logic/share/create_share.go:74` +- **证据**: +```go +// get.go:97 —— 读接口里做写操作(且失败被忽略,:98-100) +if err := impl.DBService.Save(&space).Error; err != nil { + printer.Error("Update space error: %v", err) + // 不返回错误,继续执行 +} +``` +```go +// cloud_share.go:21 / create_share.go:74 —— DownloadCount 有字段、有初始化,但全模块无任何自增逻辑 +DownloadCount int `gorm:"default:0" json:"download_count"` +``` +- **影响**:读接口产生写放大(每次打开首页都 UPDATE 一行);`DownloadCount` 是死字段(README:264 宣称"访问统计"只有一半实现);`ViewCount` 的 `Save` 失败被吞(`validate_share_password.go:51`)。 +- **建议**:统计改为异步/定时任务,或在读接口里先算后比、仅在变化时写;补下载计数或删字段。 + +--- + +## 4. 推荐优化方案 + +1. **补齐所有权数据模型(最高优先)** + - 目标:让每一张表都能自证归属。 + - 做法:`CloudDiskFile`、`CloudPhoto`、`NoteAttachment` 增加 `types.Std_Passport`;所有写路径用 `auth.ID/auth.Identity` 赋值;写一次性数据回填脚本(file 从目录回填、photo 从相册回填)。 + - 影响面与风险:触及所有 disk/album/note 写路径与模型,需同步迁移与回填;风险是历史数据中 photo 的 album 可能已被删(无法回填),需保留 NULL 并做隔离策略。 + +2. **统一「归属校验中间件」替代 51 处手写 WHERE** + - 目标:消除"某个 handler 忘了加 passport_id"这类漏网。 + - 做法:抽 `func owned(db *gorm.DB, table string, id uint, passportID uint) *gorm.DB`,或引入 GORM Scope(`db.Scopes(models.WithPassport(auth.ID))`)强制注入;对 P0/P1 涉及的全部查询逐条改造。 + - 影响面与风险:机械但面广;风险是 JOIN 场景需要表名限定(当前 `search_files.go:37` 就是靠 `cloud_disk_dirs.passport_id` 限定),改造时须保留 qualifier。 + +3. **目录树正确性:环检测 + 单次查询建树 + 批量路径更新** + - 目标:消除成环、`LIKE` 误匹配、逐条 UPDATE 与 N+1。 + - 做法:`MoveDir` 用递归 CTE/祖先链校验;路径前缀匹配带分隔符;子树 path 用单条 `UPDATE ... WHERE path LIKE '/a/%'`;`GetDirTree` 一次查全量后内存组树并限制深度/节点数。 + - 影响面与风险:需要 PostgreSQL 递归 CTE(当前驱动是 postgres,可行);风险是超大目录的一次性加载,需要上限与截断标记。 + +4. **加解密与分享口令的密码学修复** + - 目标:让"加密存储/口令保护"名副其实。 + - 做法:`Private` 改服务端密钥派生(Argon2id + 每记录 salt),拒绝客户端裸密钥与 `IsEncrypted=false`;`Share.Password` 改 bcrypt/argon2 哈希 + `ConstantTimeCompare` + 失败退避(复用已注入的 Redis)。 + - 影响面与风险:破坏现有客户端契约(`EncryptData/DecryptData` 的 `key` 入参语义变更),需版本化兼容或提供迁移窗口;口令哈希后无法回显,会影响 P2-14 的接口行为。 + +5. **配额与统计的原子化** + - 目标:让 `max_storage/used_storage` 真正生效且不拖垮读接口。 + - 做法:上传前用带条件的原子 UPDATE 做准入;`used_storage` 改为 `SUM(size)` 聚合(或异步物化 + 定时校准);计数类字段统一 `gorm.Expr("x + 1")`。 + - 影响面与风险:需要 size 由服务端计算(当前客户端自报),要改上传协议;物化统计需处理与真实值漂移。 + +6. **事务与 context 治理(一次性铺开)** + - 目标:消灭部分失败与不可取消查询。 + - 做法:所有 `.DBService` 调用加 `.WithContext(ctx)`;多步写包 `Transaction`;`move_dir/update_dir` 的静默 `err == nil` 全部改为返回错误。 + - 影响面与风险:全模块机械改造,回归面大;建议配合 7 中提到的测试用例。 + +7. **建立最小测试与安全回归网** + - 目标:把本次发现固化为可执行断言。 + - 做法:见第 5 节 TODO 的验收标准;优先写"跨用户访问必须 403/NotFound"、"根目录 GetDir 不 panic"、"移动目录到自身后代必须失败"、"路径重写只影响真实子树"四类用例。 + - 影响面与风险:需引入可用的 DB(testcontainers 或 sqlite 内存),当前模块完全无测试基建,前期投入较大。 + +8. **可观测性与运维闭环** + - 目标:故障可发现、可定位。 + - 做法:补 `/healthz`+`/metrics`;prod 打开 APM;关闭 GORM `Debug`、脱敏 DSN;`Start` 支持优雅退出与 SIGTERM。 + - 影响面与风险:低风险,独立于业务改造,可并行推进。 + +9. **配置与文档对齐** + - 目标:消除"照着 README 部署必踩坑"。 + - 做法:重写 README 的 API/表结构/缓存/监控章节;prod 配置移除 `CHANGE_ME`、强制 `sslmode`;`Service` 名与 `ServiceKey` 对齐;`NotNil` 补齐 Gateway/Databases。 + - 影响面与风险:纯文档/配置变更,风险最低,建议立即执行。 + +--- + +## 5. TODO 清单 + +- [ ] **P0-1** 为 `CloudPhoto` 补 `Std_Passport` 并在 `UploadPhoto`/`MovePhoto` 落库,历史数据回填|验收:上传一张照片后 `SELECT passport_id FROM cloud_photo WHERE id=?` 等于上传者 ID;另一用户按该 id 调用 `DeletePhoto` 返回失败|涉及:`module/base/cloud/internal/models/cloud_photo.go:11`、`module/base/cloud/internal/logic/album/upload_photo.go:54`、`module/base/cloud/internal/logic/album/move_photo.go:47` +- [ ] **P0-2** `SetCoverPhoto` 增加照片/相册归属断言|验收:用户 B 用用户 A 的 photo_id + 自己的 album_id 调用必须失败|涉及:`module/base/cloud/internal/logic/album/set_cover_photo.go:39` +- [ ] **P0-3** `DeleteAlbum` 包事务并给照片删除条件补 `passport_id`|验收:注入 album 删除失败时照片仍存在(事务回滚)|涉及:`module/base/cloud/internal/logic/album/delete_album.go:45` +- [ ] **P0-4** `EncryptData/DecryptData` 改为服务端密钥派生,拒绝补零/截断;`IsEncrypted` 服务端强制|验收:传 8 字节 key 返回 `ErrInvalidArgument`;`is_encrypted=false` 的创建请求被拒绝或强制置真|涉及:`module/base/cloud/internal/logic/private/encrypt_data.go:36`、`module/base/cloud/internal/logic/private/decrypt_data.go:39`、`module/base/cloud/internal/logic/private/create_private_data.go:54` +- [ ] **P0-5** 分享口令改哈希存储 + 常量时间比较 + 失败限流|验收:DB 中 `password` 不是明文;同 IP 连续 10 次错误口令后返回限流错误|涉及:`module/base/cloud/internal/models/cloud_share.go:18`、`module/base/cloud/internal/logic/share/validate_share_password.go:43` +- [ ] **P1-1** `MoveDir` 增加祖先链环检测;`GetDirTree` 增加深度/节点上限|验收:把 `/a` 移到 `/a/b` 返回 `ErrInvalidArgument`;构造 1000 层目录树调用不再无限递归|涉及:`module/base/cloud/internal/logic/disk/move_dir.go:46`、`module/base/cloud/internal/logic/disk/get_dir_tree.go:44` +- [ ] **P1-2** 子树 path 更新改为带分隔符的前缀匹配 + 单条 SQL + 错误返回 + 事务|验收:存在 `/a` 与 `/abc` 时移动 `/a`,`/abc` 的 path 不变;注入单条更新失败时整体回滚|涉及:`module/base/cloud/internal/logic/disk/move_dir.go:71`、`module/base/cloud/internal/logic/disk/update_dir.go:77` +- [ ] **P1-3** 消除 `*ParentID` 空指针并加 gRPC recover 拦截器|验收:对 `ParentId=0` 创建的根目录调用 `GetDir` 返回正常响应;人为 panic 时进程不退出|涉及:`module/base/cloud/internal/logic/disk/get_dir.go:91`、`module/base/cloud/internal/logic/disk/get_dir_tree.go:51`、`module/base/cloud/internal/server/new.go:23` +- [ ] **P1-4** `storage_path` 改由服务端生成并校验落盘根目录|验收:请求携带 `../../etc/passwd` 时返回值与传入值不同且落在允许根之下|涉及:`module/base/cloud/internal/logic/disk/upload_file.go:74`、`module/base/cloud/internal/logic/disk/update_file.go:61` +- [ ] **P1-5** 名称字段统一清洗(拒绝 `/`、`\`、`..`、控制字符、超长)|验收:`Name="../x"`、`Name="a/b"` 均返回 `ErrInvalidArgument`|涉及:`module/base/cloud/internal/logic/disk/create_dir.go:44`、`module/base/cloud/internal/logic/disk/update_dir.go:57` +- [ ] **P1-6** `CloudDiskFile` 补 `passport_id`;重名校验与插入加复合唯一索引兜底|验收:并发同名上传只有一条成功;跨用户同名上传互不影响|涉及:`module/base/cloud/internal/models/cloud_disk_file.go:9`、`module/base/cloud/internal/logic/disk/upload_file.go:50` +- [ ] **P1-7** 上传路径实现配额原子准入,size 由服务端计算|验收:超过 `max_storage` 的上传返回配额错误且 `used_storage` 不变|涉及:`module/base/cloud/internal/logic/disk/upload_file.go:19`、`module/base/cloud/internal/models/cloud_space.go:15` +- [ ] **P1-8** `used_storage` 改聚合 SQL;`GetDirTree` 改单次查询建树;目录详情 Files 分页|验收:10 万文件账号调用 `Space.Get` 不再加载全量行(用 SQL 日志验证只出现 1 条 SUM)|涉及:`module/base/cloud/internal/logic/space/get.go:61` +- [ ] **P1-9** 搜索加 `pg_trgm` GIN 索引;`page_size` 设上限|验收:`page_size=100000` 被拒绝或截断到上限;搜索走索引(EXPLAIN 不再是 Seq Scan)|涉及:`module/base/cloud/internal/logic/disk/search_files.go:42`、`module/base/cloud/internal/logic/note/list_notes.go:25` +- [ ] **P1-10** 全模块 DB 调用改 `WithContext(ctx)`|验收:`grep -c "WithContext" module/base/cloud/internal` 与 DBService 调用点数一致;客户端取消后 DB 侧查询被终止|涉及:`module/base/cloud/internal/logic/disk/list_dirs.go:34`(代表全部 51 个文件) +- [ ] **P1-11** 多步写操作包事务|验收:`DeleteAlbum`/`DeleteNote` 第二步失败时第一步回滚|涉及:`module/base/cloud/internal/logic/album/delete_album.go:45`、`module/base/cloud/internal/logic/note/delete_note.go:45` +- [ ] **P1-12** 增加 `(passport_id,path)`、`(passport_id,directory_id,name)` 部分唯一索引|验收:并发插入同 path 只有一条成功;软删后可重建同名|涉及:`module/base/cloud/internal/models/cloud_disk_dir.go:15`、`module/base/cloud/internal/logic/disk/create_dir.go:51` +- [ ] **P1-13** `CloudSpace` 初始化改用 UUID/ULID 并做并发安全 upsert|验收:连续两个新用户调用 `Space.Get` 均成功且各自有独立 `key_identifier`|涉及:`module/base/cloud/internal/logic/space/get.go:27` +- [ ] **P1-14** 计数器改原子自增|验收:100 并发 `ValidateSharePassword` 后 `view_count` 精确等于 100|涉及:`module/base/cloud/internal/logic/share/validate_share_password.go:48`、`module/base/cloud/internal/logic/note/increment_views.go:45` +- [ ] **P1-15** `CreateShare` 校验资源归属与 token 格式;`ImportBookmarks` 加条数/URL scheme 限制|验收:用他人 `resource_id` 创建分享失败;导入 10000 条被拒绝|涉及:`module/base/cloud/internal/logic/share/create_share.go:39`、`module/base/cloud/internal/logic/bookmark/import_bookmarks.go:38` +- [ ] **P2-7** 显式关闭 GORM `Debug` 并脱敏 DSN 日志|验收:启动日志中不含 `password=`;SQL 日志中不出现 `cloud_share.password` 明文|涉及:`D:\work\bsm-sdk\core\database\sql\postgresql.go:19`、`D:\work\bsm-sdk\core\with\databases.go:18` +- [ ] **P2-8** 生产配置去除占位密钥、启用 `sslmode=require`,补齐配置校验|验收:prod 配置无 `CHANGE_ME`;缺失 `Gateway`/`Databases` 时启动即报明确错误|涉及:`module/base/cloud/etc/cloud_prod.yaml:7`、`module/base/cloud/internal/config/config.go:35` +- [ ] **P2-6** 补 `/healthz` 与 `/metrics`,启用 APM|验收:`curl /healthz` 返回 200 且含 DB/Redis 状态;`/metrics` 暴露 Prometheus 指标|涉及:`module/base/cloud/README.md:461`、`module/base/cloud/etc/cloud_prod.yaml:32` +- [ ] **P2-12** 建立测试基建并补齐关键用例|验收:`go test ./...` 通过;覆盖"跨用户访问被拒"、"根目录 GetDir 不 panic"、"移动到后代失败"、"路径重写不误伤同前缀目录"四类用例|涉及:`module/base/cloud/test/`(当前为空) +- [ ] **P2-3** `DeleteAttachment` 语义修正(补 Identity 或改二元组)|验收:按 identity 删除附件成功,或该分支被移除且接口文档同步|涉及:`module/base/cloud/internal/logic/note/delete_attachment.go:37` +- [ ] **P2-4** `InsertAttachment` 的 `string(rune(id))` 修正|验收:返回值是十进制数字字符串|涉及:`module/base/cloud/internal/logic/note/insert_attachment.go:59` +- [ ] **P2-10** 实现优雅退出(HTTP Shutdown + SIGTERM + etcd 租约释放)|验收:发送 SIGTERM 后在途请求正常完成且进程退出码为 0|涉及:`module/base/cloud/cmd/main/main.go:37`、`D:\work\bsm-sdk\core\service\service.go:142` +- [ ] **P3-1** 抽公共分页与模型转换 helper|验收:14 处分页逻辑收敛为 1 个函数,`grep "PageSize < 10"` 只剩 1 处|涉及:`module/base/cloud/internal/logic/disk/list_dirs.go:22` +- [ ] **P3-3** 统一自关联 gorm tag 并修正拼写;`NoteAttachment` 补 `Std_IICUDS`|验收:`GetDir` 返回的 `parent` 的 id 等于该目录的 `parent_id`(集成测试断言)|涉及:`module/base/cloud/internal/models/cloud_disk_dir.go:18`、`module/base/cloud/internal/models/cloud_space.go:23` +- [ ] **P3-4** 删除或补全 `cmd/cli`|验收:模块内不存在无功能的 main 包|涉及:`module/base/cloud/cmd/cli/main.go:5` +- [ ] **P3-5** 统计字段改异步/仅变化时写,补 `DownloadCount` 或删字段|验收:连续调用 `Space.Get` 不产生 UPDATE 语句(SQL 日志验证)|涉及:`module/base/cloud/internal/logic/space/get.go:97` +- [ ] **P2-13** README 与实现对齐|验收:README 中列出的每个接口/配置项都能在代码中找到对应实现(逐条勾稽)|涉及:`module/base/cloud/README.md:440`、`module/base/cloud/README.md:461`、`module/base/cloud/README.md:570` + +--- + +## 6. 审计摘要(供汇总使用) + +- **问题数**:P0=5 P1=15 P2=14 P3=5(合计 39) +- **最高风险(一句话)**:照片(CloudPhoto)从未写入 `passport_id` 且接口层用客户端可控的 `album_id` 反查归属,导致跨用户照片的读取/更新/删除与相册级联删除均无有效所有权边界(P0-1/P0-2/P0-3)。 +- **最优先 3 个动作**: + 1. 给 `CloudPhoto`/`CloudDiskFile`/`NoteAttachment` 补 `Std_Passport` 并在全部写路径落库 + 历史数据回填,照片/文件级查询改为直接按 `passport_id` 过滤(P0-1、P0-2、P0-3、P1-6)。 + 2. 修 `MoveDir` 环检测与子树路径重写(前缀匹配 + 单条 SQL + 事务 + 错误返回),并给 `*ParentID` 解引用加守卫与 gRPC recover(P1-1、P1-2、P1-3)。 + 3. 修复隐私加解密(服务端密钥派生、拒绝补零/截断/客户端 `IsEncrypted`)与分享口令(哈希 + 常量时间比较 + 限流)(P0-4、P0-5)。 +- **未能覆盖/无法验证的部分**: + - 模块内 **0 个测试**、无迁移脚本、无可用数据库,所有运行期行为(GORM 自关联 `Parent` preload 语义、并发竞态的实际复现、索引缺失的实际执行计划)均为静态推断,其中 P3-3 的自关联 tag 问题已明确标注为「推测」。 + - `go vet ./...` 未执行(依赖 replace 到本地 `bsm-sdk/core`,按效率约束跳过);`gofmt -l .` 已执行,退出码 0、无输出。 + - 生成代码 `pb/*.pb.go`、`*_grpc.pb.go` 与除 `disk.pb.gw.go` 外的 gateway 文件未逐行审阅。 + - 模块外部:`module/base/all` 等调用方如何装配 `service.Expose`、是否在网关层额外挂了 JWT/限流中间件未审计——若上游已补限流,P0-5 的爆破风险等级可下调,但口令明文存储与明文比较的问题不受影响。 + - `cmd/cli` 为空壳,`test/lint` 为空目录,无可审内容。 diff --git a/docs/audit/module-base-cms.md b/docs/audit/module-base-cms.md new file mode 100644 index 0000000..e34619f --- /dev/null +++ b/docs/audit/module-base-cms.md @@ -0,0 +1,659 @@ +# 审计报告:module/base/cms + +## 1. 模块概览 + +- **模块路径**:`module/base/cms`(`bsm/full/module/base/cms`),Go 1.26.5,gRPC + grpc-gateway 微服务。 +- **服务清单**(`proto/`):`Post`(19 个 RPC:文章 CRUD/搜索/评论 CRUD/文章与评论的赞踩)、`Pages`(6)、`Category`(4)、`Tags`(4)、`Site`(5)。 +- **关键目录**:`cmd/{main,cli}`、`etc/{cms_dev,cms_prod,cms_test}.yaml`、`internal/{config,impl,logic,models,server,utils}`、`pb/`(生成代码,17 个文件)、`proto/`(6 个)、`service/{dependencies,expose}.go`、`test/{category,comment,pages,post,tags}`(32 个 `.http`,**无任何 Go 测试文件**)。 +- **运行方式**:两种。 + 1. 独立进程:`cmd/main/main.go` → `config.New("cms")` + `impl.NewImpl()` + `server.New(nil)` + SDK `service.New(...).Start()`(聚合配置 `services: [cms]`)。 + 2. 聚合进程:`pkgs/all/internal/service/cms.go:9 exposeCMS()` 调用本模块 `service.Expose()`,把 5 个服务的 gRPC 与 gateway 处理器挂到聚合 Server 上(`service/expose.go:20-38`)。 +- **依赖设施**:`internal/impl/impl.go` 注入 `DBService(*gorm.DB)`、`RedisService`、`MemoryService`、`EtcdService`;`service/dependencies.go` 由宿主注入。 +- **数据模型**:`cms_post`、`cms_pages`、`cms_category`、`cms_tags`、`cms_comment`、`cms_accessory`、`cms_relate_category`、`cms_relate_tags`、`cms_site`(`internal/models/*.go`,均通过 `database.MigrateTables` 注册 AutoMigrate)。 + +## 2. 审计范围与方法 + +**已读文件**(全部非 pb 的 Go 文件 + proto + etc + README + test 抽样) + +- 入口/装配:`cmd/main/main.go`、`cmd/cli/main.go`、`internal/config/config.go`、`internal/impl/impl.go`、`internal/server/{new,post_server,category_server,pages_server,tags_server,site_server}.go`、`service/{expose,dependencies}.go`。 +- 模型:`internal/models/{query,cms_post,cms_pages,cms_category,cms_tags,cms_comment,cms_site,cms_accessory,cms_relate_category,cms_relate_tags}.go`。 +- 逻辑:`internal/logic/post/`(19 个文件全部)、`internal/logic/pages/`(7 个)、`internal/logic/category/`(4 个)、`internal/logic/tags/`(4 个)、`internal/logic/site/`(5 个,全部为空实现)、`internal/utils/string.go`。 +- 契约:`proto/{post,pages,category,tags,site,const}.proto`;`pb/*.pb.gw.go` 的 URL pattern 抽样(`post.pb.gw.go:1321-1322`、`site.pb.gw.go:410-412`)。 +- 配置:`etc/cms_{dev,prod,test}.yaml`、`etc/supervisor.bsm-apps-cms.conf`、`README.md`(679 行)。 +- 宿主/相邻模块(用于判定鉴权边界):`pkgs/all/internal/service/cms.go`、`pkgs/all/internal/server/{server,authorization}.go`、`pkgs/all/etc/default_dev.yaml`、`pkgs/ecmall/internal/service/cms.go`。 + +**执行的命令与结果** + +| 命令 | 结果 | +|------|------| +| `Get-ChildItem -Recurse -File` | 非 pb 文件 108 个;pb 生成文件 17 个 | +| `gofmt -l .`(模块目录) | 无输出 → 全部文件已格式化 | +| `go vet ./...`(模块目录,后台执行) | `exit code: 0`,无任何告警 | +| `Get-ChildItem -Include *_test.go` | **0 个**,模块无任何 Go 单元测试 | +| `Select-String 'RedisService|MemoryService|Cache|limiter'` | 仅 `internal/impl/impl.go` 初始化处命中,业务逻辑零使用(无缓存、无限流) | +| `grep 'WithContext|Begin()|Commit|Rollback|go func'`(internal) | 无命中 → DB 调用未绑定请求 ctx,无显式事务 API/无 goroutine | +| 读 `$GOMODCACHE/gorm.io/gorm@v1.31.2/clause/limit.go:16-19` | 确认 `Limit(0)` 会生成 `LIMIT 0`(用于判定评论列表缺陷) | +| 读 `D:\work\bsm-sdk\core\service\{meta,service}.go` | 确认 `ParseMetaCtx` 仅解析 JWT、`service.New` 不给 gRPC Server 加拦截器 | + +**未覆盖/无法验证** + +- `pb/*.go` 生成代码细节(仅抽查 gateway pattern);`swagger/`、`scripts/`、`Dockerfile`、`Makefile` 在模块内**不存在**(README 声称存在)。 +- 运行时行为、真实数据库 schema(`cms_post` 是否存在遗留 `cms` 列只能从模型推断)、前端是否为富文本渲染 HTML(XSS 影响面为推测)。 +- `etc/cms_prod.yaml` 与线上实际投放配置的差异。 + +## 3. 问题清单 + +### P0 + +#### 1. 全模块写接口无归属/角色校验,任意已认证用户可修改、删除他人文章与评论(IDOR) + +- **位置**: + - `module/base/cms/internal/logic/post/modify.go:16,43` + - `module/base/cms/internal/logic/post/delete.go:29` + - `module/base/cms/internal/models/query.go:207` + - `module/base/cms/internal/logic/post/modify_comment.go:33` + - `module/base/cms/internal/logic/post/delete_comment.go:24` + - `module/base/cms/internal/models/query.go:306,312` + - `module/base/cms/internal/logic/{category/modify.go:16,tags/modify.go:17,tags/delete.go:16,pages/modify.go:21,pages/delete.go:19}` + - 鉴权边界:`pkgs/all/internal/server/authorization.go:42-53`、`pkgs/all/etc/default_dev.yaml:24-43` +- **证据**: +```go +// internal/logic/post/delete.go:29 +err = models.DeletePost(in.Identity, "") // Todo: +``` +```go +// internal/models/query.go:207-211 —— authorIdentity 参数声明后从未使用 +func DeletePost(identity, authorIdentity string) (err error) { + return impl.DBService.Transaction(func(tx *gorm.DB) error { + if err = tx.Where("identity = ?", identity).Delete(&CmsPost{}).Error; err != nil { +``` +```go +// internal/logic/post/modify_comment.go:17,33 —— 解析结果被丢弃,仅按 identity 更新 +_, err := service.ParseMetaCtx(ctx, nil) +... +if err := impl.DBService.Where("identity = ?", data.Identity).Updates(data).Error; err != nil { +``` +```go +// pkgs/all/internal/server/authorization.go:42-51 —— 只校验 JWT 签名/有效期,无角色 +func (a *authorization) unaryInterceptor(...) { if !a.isAnonymous(info.FullMethod) { ... a.validate(values[0]) ... } } +``` +- **影响**:聚合模式下 `/cms.*` 全部方法只要求"任意一个有效 JWT"(`default_dev.yaml:24-43` 的 Anonymous 白名单不含任何 `cms.` 项,但同一 HS256 密钥被 passport/mall 等所有模块共用)。因此任意 C 端用户 token 即可:改写/删除任意文章(含他人文章)、改写/删除任意评论(`ModifyComment` 甚至允许改 `post_identity`/`parent_id` 造成评论挂载错乱)、改删任意分类/标签/页面。模块内所有 `service.ParseMetaCtx(ctx, nil)` 的 `opts` 均为 `nil`,从不做角色(`RoleValue`)校验;`PostItem.owner_identity`/`author_identity` 从不参与写条件。同类读方向问题:`post.Fetch` 声明了 `author_identity` 过滤参数却从不使用(`internal/logic/post/fetch.go:24` 未传该字段),调用方以为拿到"我的文章"实为全量。 +- **建议**:在 `models` 写路径强制带上属主条件(`WHERE identity = ? AND author_identity = ?`);`ParseMetaCtx` 传入 `&service.ParseOptions{RoleValue: "admin"}` 或新增管理端角色校验拦截器;为 CMS 的写方法单独引入 admin 网关前缀/独立 token 受众(aud),避免复用 C 端密钥;删除 `DeletePost` 的占位空参。 + +### P1 + +#### 2. `Site` 服务 5 个接口全部为空实现,Create/Modify/Delete 返回"假成功" + +- **位置**:`module/base/cms/internal/logic/site/{create.go:20-27,modify.go:21-28,delete.go:27-32,fetch.go:18-22,get.go:24-26}`;注册位置 `module/base/cms/internal/server/new.go:36`、`module/base/cms/service/expose.go:32`、`module/base/cms/pb/site.pb.gw.go:410-412` +- **证据**: +```go +// internal/logic/site/create.go:20-27 —— 无任何 DB 调用,直接返回成功 + // TODO: valid code + // TODO: add your logic code & delete this line. + return &pb.StatusReply{Details: vars.OK, Timeseq: time.Now().UnixMilli()}, nil +``` +```go +// internal/logic/site/get.go:24-26 —— 只做参数非空校验后返回零值 + // TODO: add your logic code & delete this line. + return +``` +```go +// internal/logic/site/delete.go:23-31 —— 仅校验 id/identity 非空,未删除任何数据 + if in.Id == 0 && in.Identity == "" { return nil, errcode.ErrInvalidArgument } +``` +- **影响**:`cms.Site/Create|Modify|Delete` 已注册到 gRPC 与 gateway(URL `/cms.Site/*`),调用方收到 `details=OK` 但**没有任何数据落库**,是静默数据丢失(比报错更危险:前端会提示保存成功);`Fetch` 返回 `nil`(空响应体,无错误);`Get` 返回零值 `SiteItem` 且无 NotFound。同时 `models.CmsSite`(`internal/models/cms_site.go:24-26`)已注册 AutoMigrate,表会被建出来但永远无数据。`site_identity` 是本模块多站点隔离的唯一标识,Site 不可用意味着站点元数据(domain/seo/theme/configs)无管理入口。 +- **建议**:要么补齐 5 个方法的实现(含 `site_identity` 唯一性、默认站点保护、删除级联校验),要么从 `proto/site.proto` + `expose.go` + `new.go` 中移除该服务,避免暴露假成功接口;在补齐前先让 `Create/Modify/Delete` 返回 `codes.Unimplemented`。 + +#### 3. `Post.Search` 使用不存在的列 `Cms`,接口必然失败 + +- **位置**:`module/base/cms/internal/logic/post/search.go:37`;模型定义 `module/base/cms/internal/models/cms_post.go:20-69` +- **证据**: +```go +// search.go:37 +tx := impl.DBService.Model(&models.CmsPost{}).Where("title like ? or Cms like ?", "%"+in.Keyword+"%", "%"+in.Keyword+"%") +``` +```go +// models/cms_post.go:31 —— 正文列名为 content,模型中不存在 Cms 列 + Content string `gorm:"column:content;type:text;default:'';" json:"content"` +``` +- **影响**:`CmsPost` 的全部字段映射(`cms_post.go:23-68`)中没有 `Cms`,AutoMigrate 建出的表只有 `content`。Postgres 中 `Cms` 折叠为 `cms`,查询报 `column "cms" does not exist`(42703),逻辑层统一吞成 `errcode.ErrDB` → `/cms.Post/Search` 恒失败。(残留可能性:若线上表由更早版本模型迁移且遗留 `cms` 列则不会报错——**此点为推测**,需核对线上 `\d cms_post`。) +- **建议**:改为 `title like ? or content like ?`;补一条 Search 的集成测试;后续对 `string` 类型的关键字搜索考虑 `pg_trgm`/全文索引替代前后 `%` 模糊。 + +#### 4. `Post.Search` 结果切片先 `make(len)` 再 `append`,返回大量 `null` 且数量翻倍 + +- **位置**:`module/base/cms/internal/logic/post/search.go:43-45,70-73` +- **证据**: +```go +// search.go:43-45 +replyData := make([]*pb.PostItem, len(data)) +for _, val := range data { + replyData = append(replyData, &pb.PostItem{ +``` +- **影响**:`len(data)=N` 时返回 `2N` 个元素,前 N 个是 `nil` 指针 → JSON 序列化为 `null`,前端渲染/遍历必然 NPE 或出现空行;`Count` 与实际数据条数不再对应。对比正确写法见 `internal/logic/post/fetch.go:31`(`make([]*pb.PostItem, 0)`)。 +- **建议**:改为 `make([]*pb.PostItem, 0, len(data))`;对返回切片做非空断言的单测。 + +#### 5. 评论列表分页参数校验完全失效:缺省请求返回空列表 + +- **位置**:`module/base/cms/internal/logic/post/comment_list.go:18-19,34-46` +- **证据**: +```go +// comment_list.go:18-19 —— 先把未校验的值拷进局部变量 +page = in.GetPage() +size = in.GetSize() +// comment_list.go:34-42 —— 之后 clamp 的是 in.Page/in.Size(局部变量不受影响) +if in.GetPage() < 1 { in.Page = 1 } +if in.GetSize() < 10 { in.Size = 10 } +// comment_list.go:44-46 —— 查询用的是未 clamp 的 page/size +... .Count(&cnt).Limit(int(size)).Offset(int((page - 1) * size)).Find(&list) +``` +- **影响**:客户端不传 `page/size`(proto 默认 0)时 `size=0`,GORM 会生成 `LIMIT 0`(已核对 `gorm@v1.31.2/clause/limit.go:16-19`,`*Limit>=0` 即写入),**评论列表恒为空**而 `count` 正常,前端表现为"有评论数但列表空白";`page=0,size=10` 时 `Offset` 为 -10,被 GORM 静默丢弃(`limit.go:20` `Offset > 0` 才写入),分页回到第一页。第 34-42 行的三处 clamp 是死代码。 +- **建议**:先 clamp 再赋值(`page := in.GetPage(); if page < 1 { page = 1 }`),或直接在 models 层统一做默认值;为 page/size 加边界单测(0/负数/超大)。 + +#### 6. `ModifyPost` 写入损坏的附件数据,并且未传关联数组时会清空全部关联(数据丢失) + +- **位置**:`module/base/cms/internal/models/query.go:162-169,170-203`;同模式:`module/base/cms/internal/logic/pages/modify.go:68-75,76-101` +- **证据**: +```go +// models/query.go:162-169 —— 附件在事务外构造,post.ID 来自 logic 层新建的结构体(恒为 0),FilePath 被写成 identity + for _, v := range accessory { + accessoryPath = append(accessoryPath, CmsAccessory{ + Std_Identity: types.Std_Identity{Identity: utils.UUID()}, + PostId: post.ID, + PostIdentity: identity, + FilePath: v}) + } +``` +```go +// internal/logic/post/modify.go:23-43 —— data 为新建结构体,未设置 ID;accessory 实参是 AccessoryIdentityArray + var data = &models.CmsPost{ ... } + err = models.ModifyPost(in.Identity, data, in.AccessoryIdentityArray, in.CategoryIdentityArray, in.TagsIdentityArray) +``` +```go +// models/query.go:175-201 —— 先删全部旧关联,仅当入参非空才重建 + if err := tx.Where("post_identity = ?", identity).Delete(&CmsAccessory{}).Error; err != nil { ... } + if err := tx.Where("post_identity = ?", identity).Delete(&CmsRelateCategory{}).Error; err != nil { ... } + ... + if len(category) != 0 { if err := tx.Create(&categoryData).Error; err != nil { ... } } +``` +- **影响**:两个必然/高概率缺陷叠加: + 1. `PostId` 恒为 0(`update` 时 structure 没有 ID)→ 附件与文章的整型外键断开,而 `CmsPost.Accessories` 的关联键是 `foreignkey:PostId`(`models/cms_post.go:59`),因此文章详情/列表再也 preload 不到这批附件;`FilePath` 被写成附件 identity(`in.AccessoryIdentityArray` 是 identity 列表,不是路径),附件显示为随机 ID,`Title` 丢失。 + 2. 关联重建是"先全删、后按入参重建":只要调用方只改标题、不带 `category_identity_array/tags_identity_array/accessory_identity_array`(proto 中均为可选字段),文章的**全部分类、标签、附件关联被清空**且不报错。 + 页面侧同源缺陷:`internal/logic/pages/modify.go:71` 写 `PagesId: pages.ID`(同一 `data` 无 ID),`:81-99` 同样先删后条件重建。 +- **建议**:`ModifyPost/ModifyPages` 用 `identity` 反查真实记录 ID 后再构造附件;把 `accessory` 入参改为 `[]*pb.AccessoryItem`(保留 `file_path/title`);关联重建改为显式语义(nil=不改动,空切片=清空),或使用"差集更新";补一条"仅改标题后分类/标签保持不变"的回归测试。 + +#### 7. 文章详情 `GetByIdentity` 标签数据全为空、分类数据完全缺失 + +- **位置**:`module/base/cms/internal/logic/post/get_by_identity.go:57-80`、`module/base/cms/internal/models/query.go:242` +- **证据**: +```go +// models/query.go:242 —— 只 preload 一层,Categories/Tags 是关联表,未加载其 Category/Tags 子对象 +if err := tx.Model(&CmsPost{}).Preload("Accessories").Preload("Categories").Preload("Tags"). + Where(key+" = ?", val).First(post).Error; err != nil { +``` +```go +// get_by_identity.go:68-80 —— 用未加载的 val.Tags.Identity 填充,且未使用正确的 val.TagsIdentity + for _, val := range data.Tags { + res.TagsData = append(res.TagsData, &pb.TagsItem{Id: int64(val.ID), Identity: val.Tags.Identity, ...}) + } + for _, val := range data.Tags { + res.TagsIdentityArray = append(res.TagsIdentityArray, val.Tags.Identity) + } +``` +- **影响**:`val.Tags` 是零值 `CmsTags` → `TagsData` 的 `title/intro/cover_path` 全为空字符串;`tags_identity_array` 被填充成 N 个空串,前端用它反查/编辑标签必然失败。同一函数 `:57-80` 完全没有遍历 `data.Categories`,`category_data/category_identity_array` 恒为空(虽然 `Categories` 已被 preload)。`GetByKey`(`get_by_key.go:25-48`)更彻底:既不返回附件也不返回标签,与 `GetByIdentity` 响应不一致;反差对照 `post/fetch.go:63-72` 用的是 `Preload("Tags.Tags")` + `tags.TagsIdentity`(正确写法)。 +- **建议**:`GetPost` 改为 `Preload("Categories.Category").Preload("Tags.Tags")`;identity 数组一律用关联表的 `TagsIdentity`/`CategoryIdentity`;为详情接口补字段级断言测试。 + +#### 8. 页面详情:点击量更新条件用错字段;`tags_identity_array` 返回关联表 identity + +- **位置**:`module/base/cms/internal/logic/pages/get_by_identity.go:21,76-77,62`、`module/base/cms/internal/logic/pages/get_by_key.go:18,60` +- **证据**: +```go +// pages/get_by_identity.go:21,76-77 —— 查询用参数 key,但自增写死 key 列 +data, err := GetPages("Cms_pages.identity", in.Identity) +... +if err := impl.DBService.Model(&models.CmsPages{}).Where("key = ?", val). + UpdateColumn("hits", gorm.Expr("hits + 1")).Error; err != nil { +``` +```go +// pages/get_by_identity.go:61-62 —— val 是 CmsRelateTags,val.Identity 是关系表主键 +for _, val := range data.Tags { res.TagsIdentityArray = append(res.TagsIdentityArray, val.Identity) } +``` +- **影响**:`GetByIdentity` 传入的 `val` 是页面 identity,但自增条件恒为 `key = `:正常情况下匹配 0 行(hits 永不增长);若某页面的 `key` 恰好等于另一页面的 identity,则会**误增他人页面的点击量**。`tags_identity_array` 返回的是 `cms_relate_tags.identity` 而非标签 identity,导致按标签筛选/回显失败(`get_by_key.go:60` 同样问题,而 `post/fetch.go:71` 的写法是正确的 `tags.TagsIdentity`)。 +- **建议**:`GetPages` 的自增条件改为 `Where(key+" = ?", val)` 复用同一 key;标签 identity 统一用 `val.TagsIdentity`;为 hits 增加"按 identity 查询后 hits+1"的单测。 + +#### 9. `AddComment` 强依赖外部模块表 `mall_staff`,且存在未保护的 map 类型断言(panic 风险) + +- **位置**:`module/base/cms/internal/logic/post/add_comment.go:34-44` +- **证据**: +```go + authName := map[string]any{} + err = impl.DBService.Table("mall_staff").Take(&authName, "identity=?", auth.Identity).Error + if err != nil { printer.Error(err.Error()); return nil, errcode.ErrDB } + data.OwnerName = authName["name"].(string) +``` +- **影响**:3 个问题。① 分层/耦合违规:CMS 直接查商城模块的表 `mall_staff`(表名硬编码),跨模块 schema 耦合;② 任何非商城员工(例如 passport 普通用户,`default_dev.yaml` 的同一 JWT 密钥允许其通过鉴权)调用 `AddComment` 都会 `Take` 不到记录 → `ErrDB`,评论功能对 C 端不可用;③ 若该行 `name` 为 NULL 或列被改名,`authName["name"].(string)` 对 `nil` 做类型断言会 **panic**;gRPC 侧只有 `pkgs/all` 的 gin `Recovery()`(`pkgs/all/internal/server/server.go:35`)作用于 HTTP 中间件,gRPC 直连/内部调用链上无 recover 中间件,panic 会终止进程。 +- **建议**:删除跨模块表查询,`OwnerName` 改为由 token claim 或调用方传入;使用 `if v, ok := authName["name"].(string); ok` 兜底;为所有 gRPC handler 增加 recover 拦截器;`models.AddComment(comment, authorIdentity)`(`models/query.go:277`)的第二个参数从未被使用(dead param)。 + +#### 10. 文章/评论赞踩无去重、无下限保护(`TODO` 明确未实现),可无限刷量并出现负数 + +- **位置**:`module/base/cms/internal/models/query.go:346,266-275,347-356`;调用点 `internal/logic/post/{incr_post_like.go:38,desc_post_like.go:24,incr_post_unlike.go:23,desc_post_unlike.go:24,incr_comment_like.go:29,desc_comment_like.go:24,incr_comment_unlike.go:25,desc_comment_unlike.go:24}` +- **证据**: +```go +// models/query.go:346-352 —— TODO 仍在,去重完全未实现 +// TODO: 记录点赞对象避免重复点赞 +func IncrOrDescCommentField(identity, column string, desc bool) (err error) { + ... UpdateColumn(column, gorm.Expr(fmt.Sprintf(expr, column), 1)) +``` +```go +// incr_post_like.go:33-38 —— op_identity 只做非空校验,从不落库 + if in.GetOpIdentity() == "" || in.GetPostIdentity() == "" { return nil, errcode.ErrInvalidArgument } + err = models.IncrOrDescPostField(in.PostIdentity, "like_hits", false) +``` +- **影响**:同一 `op_identity` 可重复调用 `IncrPostLike` 无限刷高 `like_hits`(13 个赞踩接口同理,`IncrCommentLike` 连 `op_identity` 都没有校验,见 `incr_comment_like.go:25`,与其他接口不一致);`Desc*` 无 `like_hits > 0` 条件,可把计数减成负数(`comment_hits` 同理,见下条)。计数与真实互动记录无对账依据(无 `cms_like` 类记录表),数据不可修复。 +- **建议**:新增互动记录表/Redis Set(`post_identity+op_identity` 唯一)做幂等,计数只在首次记录时增减;`Desc*` 加 `AND like_hits > 0`;对同 IP/同用户加限流。 + +#### 11. `site_identity` 只写不读:所有列表/详情/搜索查询都不过滤站点 + +- **位置**:写入 `internal/logic/post/create.go:35,41`、`internal/logic/category/create.go:36,42`、`internal/logic/pages/create.go:38,44`;查询 `internal/models/query.go:117-139`、`internal/logic/post/search.go:37`、`internal/logic/category/fetch.go:20-26`、`internal/logic/pages/fetch.go:88-96`、`internal/logic/tags/fetch.go:20`、`internal/logic/post/get_by_identity.go:19` +- **证据**: +```go +// models/cms_post.go:23 —— 建了索引 +SiteIdentity string `gorm:"column:site_identity;type:varchar(36);index;"` +// models/query.go:117-135 —— PostList 的查询条件里没有 site_identity +func PostList(page, size int64, categoryIdentity, keyword string, userType int64) (...) { + tx := impl.DBService.Debug().Model(&CmsPost{}) + if categoryIdentity != "" { ... } + if keyword != "" { ... } +``` +- **影响**:Create 强校验 `site_identity` 必填,但所有读路径都忽略它 → 多站点部署时 A 站接口会返回 B 站的文章/页面/分类/标签;`cms_tags` 甚至连写入都未设置 `SiteIdentity`(`internal/logic/tags/create.go:34-39` 无该字段),标签是全局共享的。配合 P1-2(Site 服务未实现),多站点隔离实际不可用。 +- **建议**:所有查询签名透传 `siteIdentity` 并在 where 中强制过滤;为 `(site_identity, created_at)` 建复合索引;`cms_tags` 补 `SiteIdentity` 写入。 + +#### 12. 独立进程部署模式(`cmd/main`)无任何鉴权拦截器,且 gateway Mux 未初始化 + +- **位置**:`module/base/cms/internal/server/new.go:20-30`、`module/base/cms/cmd/main/main.go:31-44`、SDK `D:\work\bsm-sdk\core\service\service.go:49-51` +- **证据**: +```go +// internal/server/new.go:21-30 —— 自建 grpc.NewServer() 无拦截器;Mux 字段从未赋值 + grpcServ = grpc.NewServer() + srv := &Server{Ctx: context.Background(), Grpc: grpcServ, grpcConns: make(map[string]*grpc.ClientConn)} +// cmd/main/main.go:31,42 + s := server.New(nil) + ... GatewayMux: s.Mux, // nil +``` +```go +// SDK service.go:49-51 —— New 只做结构体组装,不加拦截器 +func New(srv *grpc.Server, opts *Options) *Service { return &Service{GrpcSrv: srv, Opts: opts} } +``` +- **影响**:独立部署(README "快速开始/make run" 描述的路径)下 gRPC Server 没有任何鉴权拦截器,全部 CMS 方法对内外网开放;同时 `s.Mux` 为 nil,`GatewayConf.Enable: true`(`etc/cms_dev.yaml:19-21`)时网关注册依赖 nil mux。模块内 `cmd/main/main.go` 也从未调用 `service.Expose()`,即独立模式下 gateway 处理器是否注册取决于 SDK 行为(未验证)。相比之下聚合模式(`pkgs/all`)有全局拦截器。 +- **建议**:独立模式改为复用宿主的鉴权装配(`grpc.ChainUnaryInterceptor(auth...)`),并在 `server.New` 中初始化 `Mux: gwRuntime.NewServeMux()`、显式调用 `service.Expose` 注册 handler;若独立模式已废弃,删除 `cmd/main` 与 `etc/cms_*.yaml` 避免误用。 + +### P2 + +#### 13. 模块内鉴权调用不一致:写接口 `category.Modify` 完全没有鉴权解析,`CommentList` 的校验被注释掉 + +- **位置**:`internal/logic/category/modify.go:16-36`、`internal/logic/post/comment_list.go:24-27`;对比 `internal/logic/category/create.go:30`、`internal/logic/category/delete.go:16` +- **证据**: +```go +// category/modify.go:16-17 —— 整个函数没有 service.ParseMetaCtx +func Modify(ctx context.Context, in *pb.ModifyCategoryRequest) (reply *pb.StatusReply, err error) { + if in.GetSiteIdentity() == "" || in.GetIdentity() == "" || len(in.GetIdentity()) > 255 { +``` +```go +// post/comment_list.go:24-27 + // _, err := service.ParseMetaCtx(ctx, nil) + // if err != nil { return nil, err } +``` +- **影响**:鉴权完全依赖宿主拦截器,模块自身不可独立保证安全(见 P1-12);`category.Modify` 与同包 Create/Delete 不一致,是 CODEOWNERS 审查时最易漏的一类。 +- **建议**:统一在模块内做 meta 解析并校验角色,或明确在 README 声明"鉴权由宿主负责"并删除散落的解析调用;删除被注释的校验代码。 + +#### 14. `tags.Create` 重复校验逻辑失效(死代码),重复标签时返回错误码错误,且未写入 `site_identity` + +- **位置**:`module/base/cms/internal/logic/tags/create.go:19,25-39` +- **证据**: +```go + var cnt int64 = 0 + ... + if err := impl.DBService.Model(&models.CmsTags{}).Where("title = ?", in.Title).First(&models.CmsTags{}).Error; err == nil { + return nil, errcode.ErrDB // 已存在却返回 ErrDB + } + if cnt > 0 { // cnt 从未被赋值,恒为 false + return nil, errcode.ErrAlreadyExists + } +``` +- **影响**:`cnt` 永远为 0,`ErrAlreadyExists` 分支不可达(dead code);重复标题返回 `ErrDB`(500 语义)而非 409;`CmsTags.SiteIdentity` 永不写入(见 P1-11);同时强制 `cover_path` 与 `intro` 非空(`:25`),这两个字段在 proto 中并无必填语义,且 DB 允许空串。 +- **建议**:用 `Count` 或 `ErrAlreadyExists` 正确判定;补 `SiteIdentity`;放宽必填校验并交由 DB 约束/前端校验。 + +#### 15. 唯一约束与软删除冲突:`category_key`/页面 `key` 唯一索引 + `gorm.Model` 软删除;`tags.Modify` 缺 identity 校验静默成功;删除不清理关联表 + +- **位置**:`internal/models/cms_category.go:20,25`、`internal/models/cms_pages.go:16,21`、`internal/logic/tags/modify.go:22-35`、`internal/logic/tags/delete.go:21-26`、`internal/models/query.go:370-388` +- **证据**: +```go +// cms_category.go:20,25 —— gorm.Model 带 DeletedAt(软删除),category_key 却是 uniqueIndex + gorm.Model + CategoryKey string `gorm:"column:category_key;type:varchar(255);not null;uniqueIndex;"` +``` +```go +// tags/modify.go:22,35 —— 只校验 title/cover/intro,不校验 identity + if in.GetTitle() == "" || in.GetCoverPath() == "" || in.GetIntro() == "" { return nil, errcode.ErrInvalidArgument } + if err := impl.DBService.Where("identity = ?", tagsData.Identity).Updates(tagsData).Error; err != nil { +``` +- **影响**:① 删除分类/标签/页面后不能再用相同的 `category_key`/`key`/`title` 重建(唯一索引包含已软删除行)→ 表现为"莫名 ErrDB";② `tags.Modify` 传空 identity 时 `WHERE identity = ''` 匹配 0 行仍返回 OK(静默成功);③ 删除标签不清理 `cms_relate_tags`,`Preload("Tags.Tags")` 取不到已删标签 → 文章标签列表出现标题为空的占位项(脏数据累积);④ `DeleteCategory`(`models/query.go:375-388`)用 `Find` 不校验 `RowsAffected`,删除不存在的 identity 返回成功,且不清理 `cms_relate_category`。 +- **建议**:唯一索引改为部分索引(`WHERE deleted_at IS NULL`)或统一硬删除;Modify 前校验 identity 存在(`RowsAffected`);删除时级联清理关联表并在同一事务内做计数校验。 + +#### 16. 生产代码遗留 `.Debug()`、`fmt.Println` 调试输出,日志无上下文 + +- **位置**:`.Debug()`:`internal/models/query.go:119`、`internal/logic/post/comment_list.go:44`、`internal/logic/category/fetch.go:20`、`internal/logic/pages/get_by_identity.go:71`;`fmt.Println`:`internal/logic/category/fetch.go:21`(打印整个请求)、`internal/logic/post/comment_list.go:30`、`internal/logic/post/delete_comment.go:26`、`internal/logic/tags/fetch.go:21`;`printer.Error`:13 处 +- **证据**: +```go +// models/query.go:119 +tx := impl.DBService.Debug().Model(&CmsPost{}) +// internal/logic/post/delete_comment.go:26 —— 空打印 + err = models.DeleteComment(in.Identity) + if err != nil { fmt.Println(); return nil, errcode.ErrDB } +``` +- **影响**:`Debug()` 让 GORM 以 Info 级别打印全部 SQL 与参数(含正文、评论内容、token 派生的 identity),既污染日志又放大 I/O 开销;`fmt.Println` 绕过日志框架(无级别、无采集、stdout 与 supervisor 的 stdout_logfile 混流);`printer.Error(err.Error())` 丢失请求标识、方法名、参数与堆栈,无法定位。 +- **建议**:删除所有 `.Debug()` 与 `fmt.Println`;统一 `printer.Error("cms.post.DeleteComment failed: identity=%s err=%v", ...)` 形式;接入 request-id 与慢查询日志。 + +#### 17. 数据库调用未绑定请求 `ctx`,无超时/重试/熔断/健康检查 + +- **位置**:全模块(`grep WithContext` 在 `internal/` 无命中);`internal/impl/impl.go:35-47`;`internal/config/config.go:34-56` +- **证据**: +```go +// internal/impl/impl.go:43 —— 只有启动期初始化,无健康检查/重试/连接池显式配置 + DBService = with.Databases(config.Spec.Databases, nil) +// internal/logic/post/get_by_identity.go:19 —— ctx 未传入数据访问层 + data, err := models.GetPost("identity", in.Identity) +``` +- **影响**:客户端断开或上游超时后 SQL 仍继续执行,慢查询会持续占用连接池;`config.New` 只做端口/IP 与非空校验(`config.go:39-49`),不校验连接串可达性、不设置 `MaxOpenConns/MaxIdleConns/ConnMaxLifetime`;无 DB 健康检查接口,启动即对外服务。 +- **建议**:数据访问统一 `impl.DBService.WithContext(ctx)`;为 DB 调用设 statement timeout;启动期做 `sql.DB.PingContext` 并失败即退出;暴露 `/healthz` 聚合 DB/Redis/Etcd 状态。 + +#### 18. 缓存设施完全未使用;列表接口无分页、全表扫描 + +- **位置**:`internal/impl/impl.go:16-30,37-46`(仅初始化);`internal/models/query.go:359-367`(`CategoryList` 全量)、`internal/logic/category/fetch.go:20-27`(全量 + 递归 Preload)、`internal/logic/tags/fetch.go:20`(全量) +- **证据**: +```go +// 全模块检索 RedisService / MemoryService:仅 impl.go 的声明与赋值命中 + MemoryService = with.Memory(nil) + RedisService = with.RedisCache(config.Spec.Cache) +// internal/logic/tags/fetch.go:20 —— 无 Limit/Offset + if err := impl.DBService.Model(&models.CmsTags{}).Order("created_at desc").Count(&cnt).Find(&tags).Error; err != nil { +``` +- **影响**:README 第 403-420 行声称的"文章列表 10 分钟 / 详情 30 分钟 / 分类 1 小时 / 标签 1 小时"缓存策略、以及"Redis 缓存 + 数据库优化"特性**全部不存在**,热点列表每次直查 DB;分类与标签接口一次性全量加载(分类还递归 `Children`),数据量增长后内存与 P99 双爆。 +- **建议**:为分类/标签/站点配置加缓存(写操作失效)或至少加分页;删除 README 中不实的缓存表,或补齐实现。 + +#### 19. `CmsPost.Types` 列类型与 Go 类型不一致;Search 无排序、强制 `page_size=50`;分页边界未校验 + +- **位置**:`internal/models/cms_post.go:26`、`internal/models/query.go:129-131`、`internal/logic/post/search.go:27-34,38` +- **证据**: +```go +// cms_post.go:26 —— int32 字段映射到 varchar(255) + Types int32 `gorm:"column:types;type:varchar(255);default:'';" json:"types"` +// models/query.go:130 + tx = tx.Where("cms_post.types = ?", userType) +``` +```go +// search.go:31-34 —— 期望"最大 50",实际是"强制 50" + if pageSize < 50 { pageSize = 50; offset = (pageNo - 1) * pageSize } +``` +- **影响**:`varchar` 列与 int 参数比较依赖隐式转换(Postgres 需按文本比较,语义脆弱;**是否直接报 42703/42883 取决于部署 schema,属推测**);`Search` 无 `ORDER BY` → 分页结果不稳定(同页可能重复/漏项);客户端传 `page_size=10` 会被放大到 50,传 0/负数也被放大到 50;`Search` 与 `Fetch` 的分页策略不一致。 +- **建议**:`Types` 改为 `int32` + `type:integer`(或 `smallint`)并迁移存量数据;`if pageSize <= 0 { pageSize = 10 }; if pageSize > 50 { pageSize = 50 }`;所有列表查询补 `Order("created_at desc, id desc")` 稳定排序。 + +#### 20. Create/Modify 字段映射不一致、`Updates(struct)` 无法清空字段 + +- **位置**:`internal/logic/post/create.go:40-56` 对比 `internal/logic/post/modify.go:23-42`;`internal/models/query.go:171`、`internal/logic/category/modify.go:33`、`internal/logic/tags/modify.go:35`、`internal/logic/post/modify_comment.go:33` +- **证据**: +```go +// create.go:40-56 —— 未映射 Lang/SourceOrigin/Rights/Extend*/Published(proto 中存在这些字段) + var data = &models.CmsPost{ SiteIdentity: ..., Title: ..., Content: ..., Types: in.PostType, ... } +// modify.go:34-41 —— Modify 却映射了 Lang/SourceOrigin/Rights/Extend* + Hash: utils.FormatKey(in.Key), Lang: in.Lang, SourceOrigin: in.SourceOrigin, Rights: in.Rights, ExtendUrl: in.ExtendUrl, +``` +- **影响**:创建接口无法写入语言/来源/权限/扩展字段与 `published`,只能靠再调一次 Modify(Modify 又要求 identity+site_identity);所有 `Updates(struct)` 调用会跳过零值字段 → `has_accessory=false`、`parent_id=0`(分类移到顶级)、`rights=""`、`comment_hits=0` 等都无法写入/清空,接口语义与 proto 注释不符。`pubished` 字段在详情中以 `0001-01-01 00:00:00` 返回(`get_by_identity.go:55`)。 +- **建议**:Create/Modify 共用同一个映射函数(避免漂移);需要清空语义的字段用 `map[string]any` + `Select` 显式更新,或用 proto3 optional/field_mask 表达"未设置"。 + +#### 21. dead code 与未实现项集中存在 + +- **位置**: + - `internal/models/query.go:23-38` `InitData()`(根分类初始化)**在模块内无任何调用点** → 根分类永远不会被创建; + - `internal/models/query.go:358-367` `CategoryList()` 无调用点(`internal/logic/category/fetch.go:15` 只留注释 `// list, cnt, err := logic.CategoryList()`); + - `internal/models/query.go:299-301` `ModifyComment()` 无调用点(logic 内联 DB 操作); + - `internal/logic/post/ext.go:8-17`、`internal/logic/pages/ext.go:8-17` `fmtKey()` 定义后无引用; + - `internal/models/query.go:207` `DeletePost(identity, authorIdentity)` 第二参数、`internal/models/query.go:277` `AddComment(comment, authorIdentity)` 第二参数无使用; + - `internal/logic/tags/create.go:31-33` 不可达分支(见 P2-14)。 +- **证据**: +```go +// models/query.go:23-37 —— 无调用点;且忽略 Create 错误、无并发/唯一保护 +func InitData() { + var cnt int64 = 0 + err := impl.DBService.Model(&CmsCategory{}).Where("identity=?", "_RootCategory").Count(&cnt).Error + if cnt == 0 || err != nil { ... impl.DBService.Create(data) } +} +``` +- **影响**:根分类初始化与分类列表查询各有一份实现(`fetch.go` 重写了一遍),维护时容易改一处漏一处;`InitData` 忽略错误且多副本并发启动时会创建重复根分类(`identity` 无唯一索引,`cms_category.go:21` 的 `Std_Identity` 无 unique 约束)。 +- **建议**:删除或接线 dead code(`InitData` 应移入显式迁移步骤并加唯一约束/`ON CONFLICT DO NOTHING`);用 `service.Expose` 之外的显式初始化钩子。 + +#### 22. 点击量自增无防刷,且在"读操作"里写库并包在事务中 + +- **位置**:`internal/models/query.go:236-255`、`internal/logic/pages/get_by_identity.go:67-81` +- **证据**: +```go +// models/query.go:240-251 —— 查询 + 计数更新包在同一事务,且每次调用都 +1 + return post, impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&CmsPost{}).Preload(...).Where(key+" = ?", val).First(post).Error; err != nil { return err } + if err := tx.Model(&CmsPost{}).Where(key+" = ?", val). + UpdateColumn("hits", gorm.Expr("hits + 1")).Error; err != nil { +``` +- **影响**:任何调用方(含后台管理、爬虫、自身服务重试)查看详情都会使 `hits` 增长,热门文章行会被高频 `UPDATE` 造成行锁竞争(Postgres 上每次详情 = 一次写事务 + 一次 WAL);无按用户/IP 去重、无限流。 +- **建议**:浏览量改为异步/Redis 计数 + 定期落库,或按 `op_identity+时间段` 去重;把读与写拆开(读事务内不写)。 + +#### 23. 富文本无 XSS 处理;附件路径由客户端任意指定 + +- **位置**:`internal/logic/post/create.go:48`、`internal/models/query.go:89`、`internal/logic/pages/create.go:111` +- **证据**: +```go +// post/create.go:48 —— 原样入库 + Content: in.Content, +// models/query.go:85-91 —— FilePath/Title 来自请求,无任何校验 + accessoryPath = append(accessoryPath, CmsAccessory{ ..., FilePath: v.FilePath, Title: v.Title }) +``` +- **影响**:`content` 为 text 且无任何 sanitize/转义,若前端使用 `v-html/innerHTML` 渲染(**推测**,前端未在本次范围)就是存储型 XSS,而写接口在 P0-1 下对任意登录用户开放;`file_path` 无协议/域名白名单,可写入 `javascript:`、内网地址或本地路径,被前端/下载服务消费时形成 XSS/SSRF 或路径穿越(CMS 自身不做上传,实际危害取决于消费方)。 +- **建议**:入库前用白名单 HTML sanitizer(如 bluemonday)过滤,或统一按纯文本渲染;附件路径校验为 `https://` + 允许域名,服务端生成路径。 + +#### 24. 评论:只加载一层子评论、顶层与回复混合分页、不校验文章存在、计数可为负 + +- **位置**:`internal/logic/post/comment_list.go:44-46`、`internal/models/query.go:277-297,303-334` +- **证据**: +```go +// comment_list.go:45-46 —— 只 Preload("Children") 一层,且分页未按 parent_id 区分顶层 + Order("created_at desc").Preload("Children").Count(&cnt). + Limit(int(size)).Offset(int((page - 1) * size)).Find(&list).Error; +``` +```go +// models/query.go:283,315-316 —— 计数无下限,重复删除/并发可减为负 + tx.Model(&CmsPost{}).Where("identity = ?", comment.PostIdentity).UpdateColumn("comment_hits", gorm.Expr("comment_hits + ?", 1)) + ... UpdateColumn("comment_hits", gorm.Expr("comment_hits - ?", 1)) +``` +- **影响**:三级及以上回复(`Children.Children`)永远不返回;分页集合包含顶层评论与各级回复,`count` 与"顶层评论数"不符,前端按页拼树会串位;`AddComment` 不校验 `post_identity` 是否真实存在(可给任意字符串写评论,`comment_hits` 自增 0 行静默成功);`DeleteComment` 只减一级父子计数,删除父评论时子评论被软删而子评论的计数不回滚,`comment_hits` 可长期为负;`ModifyComment` 允许改 `post_identity` 把评论搬到别的文章。 +- **建议**:列表只查 `parent_id = 0` 分页、子评论按父 ID 批量二次查询并限制深度;`AddComment` 校验文章存在;计数改 `GREATEST(comment_hits - 1, 0)` 或按实际行数重算;`ModifyComment` 禁止修改 `post_identity/parent_id`。 + +#### 25. 响应结构不统一(`StatusReply` 语义混乱、时间单位不一致) + +- **位置**:`internal/logic/post/create.go:68-73`、`internal/logic/post/modify.go:48-52`、`internal/logic/tags/delete.go:28-32`、`internal/logic/post/modify_comment.go:36`、`internal/logic/post/get_by_identity.go:39-40`、`internal/logic/site/create.go:24-27` +- **证据**: +```go +// post/create.go:68-72 vs tags/delete.go:28-32 vs site/create.go:24-27 + &pb.StatusReply{Code: 0, Message: vars.OK, Details: data.Identity, Timeseq: time.Now().Unix()} + &pb.StatusReply{Code: 0, Message: "OK", Timeseq: time.Now().Unix()} + &pb.StatusReply{Details: vars.OK, Timeseq: time.Now().UnixMilli()} +``` +- **影响**:`timeseq` 有的毫秒有的秒;`Message`/`Details` 混用(`Details` 时而承载 identity 时而承载 "OK");`ModifyComment/DeleteComment/Desc*Like` 返回空 `StatusReply{}`(code=0 但无任何信息,且 `code=0` 是否代表成功取决于客户端约定);`get_by_identity.go:39` 用 `time.Time.String()`(`2021-12-08 15:59:00 +0000 UTC`)而 `fetch.go:87` 用 `Format(time.DateTime)`,同一字段两种格式。 +- **建议**:统一响应构造器(code/message/details 语义固定)、统一 `timeseq` 单位与时间格式(RFC3339 或 `2006-01-02 15:04:05`)。 + +#### 26. 可观测性与运维缺口:无健康检查/指标、无结构化日志、APM 全部注释 + +- **位置**:`etc/cms_{dev,prod,test}.yaml:32-35`(APM 被注释)、README:422-430(声称 `/health`、`/metrics`)、模块内无 `health`/`metrics` 相关代码(grep 无命中) +- **证据**: +```yaml +# APM: +# Platform: elasticAPM +# Endpoint: http://127.0.0.1:14268/api/traces +``` +- **影响**:README 声称的 `curl http://localhost:12102/health`、`/metrics`、APM 集成在模块内均不存在(端口 12102 也与 `etc/*.yaml` 的 `Gateway.Port: 12409` 不一致),无健康检查意味着编排层无法判定实例可用性;日志为 `printer` 文本 + `fmt.Println` 混流,缺少结构化字段。 +- **建议**:注册 gRPC health service 并透出 `/healthz`;启用 APM/trace(`ParseMetaCtx` 已有 claim 可作 trace 维度);日志 JSON 化并带 request-id。 + +#### 27. 配置缺陷:注释中残留真实密钥、全环境占位符、无效 Anonymous 项、模块 yaml 在聚合模式下不被加载 + +- **位置**:`etc/cms_dev.yaml:24-29`、`etc/cms_prod.yaml:24-29`、`etc/cms_test.yaml:24-29`、`etc/cms_dev.yaml:15-16`、`pkgs/all/internal/service/cms.go:9-20` +- **证据**: +```yaml +SecretKey: CHANGE_ME +# Rpc: +# fts: +# Endpoint: https://api-v2.traingo.cn/fts/v2 +# SecretKey: 4ef05311358cd1c8f787281f08b38b1c # 真实形态的密钥留在仓库里 +MicroService: + Anonymous: + - cms.ping.hello # 无 cms.ping 服务/方法 +``` +- **影响**:① 仓库内保留 `4ef05311358cd1c8f787281f08b38b1c` 形态的密钥(即使被注释也属泄露面,需确认是否仍有效);② 三套环境 `DB/Cache/SecretKey` 全为 `CHANGE_ME`,无配置校验拒绝启动(`config.NotNil` 只校验 Service/Cache,`config.go:49`);③ `Anonymous: cms.ping.hello`(prod/test 写成 `Cms.ping.hello`,大小写不一致)指向不存在的服务,给人"已配置白名单"的错觉;④ 聚合模式下 `etc/cms_*.yaml` 根本不被读取(`exposeCMS` 只注入依赖),其中的 `SecretKey/Anonymous/Gateway` 均为误导性配置。 +- **建议**:轮换并移除示例密钥;生产配置走环境变量/密管(不落库);删除失效的 `Anonymous`;明确"模块 yaml 仅独立模式有效"或删除。 + +#### 28. 索引与约束缺口 + +- **位置**:`internal/models/cms_comment.go:20-30`(仅单列索引)、`internal/models/cms_relate_category.go:8-10`、`internal/models/cms_relate_tags.go:8-11`、`internal/models/query.go:58-73` +- **证据**: +```go +// cms_comment.go:20 —— 只有 post_identity 单列索引,而查询按 post_identity + created_at 排序分页 + PostIdentity string `gorm:"column:post_identity;type:varchar(36);index;"` +// cms_relate_tags.go:8-10 —— identity 唯一,但 (post_identity, tags_identity) 无唯一约束 + Identity string `gorm:"column:identity;type:varchar(36);uniqueIndex;"` +``` +- **影响**:评论列表(`comment_list.go:45` `Order("created_at desc")`)缺 `(post_identity, created_at desc)` 复合索引,数据量大后排序走磁盘;`cms_relate_category/cms_relate_tags` 允许同一文章重复关联同一分类/标签(`models/query.go:58-73` 不去重),导致 `PostList` 的 `left join` 计数放大(`query.go:121-133`);`cms_post` 缺 `(site_identity, created_at)`(与 P1-11 联动)。 +- **建议**:补复合索引与 `UNIQUE(post_identity, category_identity)` / `UNIQUE(post_identity, tags_identity)`;入参数组去重。 + +### P3 + +#### 29. `test/*.http` 全部过期:服务名前缀、方法名、域名、请求头均不匹配当前契约 + +- **位置**:`module/base/cms/test/**/*.http`(32 个文件) +- **证据**: +``` +test/post/post_add.http:1 POST http://127.0.0.1:12409/Cms.Post/Create +test/pages/search.http:1 POST http://127.0.0.1:12409/Cms.PostPages/Search +test/tags/tags_add.http:1 POST http://127.0.0.1:12409/Cms.Tags/AddTags +test/category/category_list.http:1 POST http://api.apinb.com/Cms.Category/Fetch +test/post/post_add.http:2 Cms-Type: application/json +``` +- **影响**:gateway 注册的 pattern 是小写 `cms.Post/Create`(`pb/post.pb.gw.go:1321-1322`、`pb/site.pb.gw.go:410-412`),且路由大小写敏感;`Cms.Post/DeletePost`、`Cms.Category/ModifyCategory`、`Cms.Tags/{AddTags,TagsList,ModifyTags,DeleteTags}`、`Cms.PostPages/*` 这些方法名在 proto 中都不存在(应为 `cms.Post/Delete`、`cms.Category/Modify`、`cms.Tags/{Create,Fetch,Modify,Delete}`、`cms.Pages/*`);`Cms.PostPages/Search` 更是无此 RPC;请求头应为 `Content-Type`。整套手工用例照抄执行会 404/405,等于没有可用验证手段。 +- **建议**:按 `pb/*.pb.gw.go` 生成/校正用例,或改用自动化集成测试(见 P3-33);删除指向公网域名 `api.apinb.com` 的残留用例。 + +#### 30. README 与实现严重不符 + +- **位置**:`module/base/cms/README.md:5-17,75-100,403-430,527-617` +- **证据**: +```markdown +- **⚡ 高性能**: Redis缓存 + 数据库优化 (README:14 —— 实际零缓存使用) +| 文章列表 | 10分钟 | 按分类+关键词缓存 | (README:409 —— 无任何缓存代码) +curl http://localhost:12102/health (README:426 —— 模块无 /health;端口应为 12409) +``` +- **影响**:README 声称 `swagger/`、`scripts/`、`Dockerfile`、`docker-compose.yml`、`Makefile`、`.githooks`、`build/`、`logs/` 均存在(实际模块内不存在,已用目录清单核对);声称"完善的错误处理和输入验证""监控:健康检查和APM集成"与实现不符;DDL 章节(README:531-617)与模型不一致(`cms_post.type` vs `types`、`key` vs `hash`、`cms` vs `content`;`cms_tags` 缺 `site_identity`);API 表格未记录已注册的 `Site` 服务;`Port: 12101/12102` 与实际 `12410/12409` 不一致。 +- **建议**:以代码为准重写 README(或标注"部分为规划");至少修正端口、DDL、缓存章节与服务清单,避免误导使用者按 `make run` 部署出一个无鉴权的独立实例(见 P1-12)。 + +#### 31. 注释/命名错误与陈旧路径 + +- **位置**:`internal/models/cms_site.go:28`、`internal/models/cms_pages.go:6,15`、`internal/models/cms_tags.go:6`、`internal/models/cms_comment.go:6`、`internal/models/cms_accessory.go:25`、`internal/logic/post/modify.go:18`(`// 修改文章` 出现在 pages)、`internal/logic/pages/modify.go:18` +- **证据**: +```go +// cms_site.go:28-30 —— 注释写成 CmsAccessory +// TableName .CmsAccessory 分类表 +func (c *CmsSite) TableName() string { return "cms_site" } +// cms_pages.go:15,6 —— 类型注释写成 CmsPost,文件头保留 /src/git.buka.tv/Cms/internal/models/post.go +// CmsPost 单页文章 +``` +- **影响**:`CmsSite`/`CmsPages` 的结构体注释与实际类型不符,`FilePath` 头注释指向旧仓库路径 `/src/git.buka.tv/...`(`cms_comment.go:6`、`cms_pages.go:6`、`cms_tags.go:6`),检索与新人理解成本上升;`cms_accessory.go:25` 注释"分类表"实为附件表。 +- **建议**:清理陈旧路径与错误注释,`golangci-lint` 的 `godot/revive` 纳入 CI。 + +#### 32. 硬编码表名(跨模块)与魔法数字 + +- **位置**:`internal/logic/post/add_comment.go:35`(`Table("mall_staff")`)、`internal/logic/pages/fetch.go:91,95,98`、`internal/logic/pages/get_by_identity.go:21`、`internal/models/query.go:18-21`(表名常量只覆盖 3/9 张表) +- **证据**: +```go +// pages/fetch.go:91,95 —— 表名硬编码进 SQL 字符串,未用模型 TableName +tx = tx.Where("Cms_pages.title like ?", "%"+keyword+"%") +tx = tx.Where("Cms_pages.type = ?", userType) +``` +- **影响**:表名/大小写依赖 Postgres 折叠语义,改表名或换库(如 MySQL 大小写敏感)即失效;`mall_staff` 为跨模块表名硬编码;`50`(pageSize 上限)、`255`(长度校验)、`10`(默认 size)散落在各 logic 文件中重复定义。 +- **建议**:统一用 `models` 的 `TableName()`/常量,跨模块数据改走对方 service 接口;把分页/长度常量集中到 `internal/config` 或 `const` 块。 + +#### 33. 无任何 Go 单元测试;`cmd/cli` 为空壳 + +- **位置**:模块内 `*_test.go` = 0 个(`Get-ChildItem -Include *_test.go | Measure-Object` → 0);`cmd/cli/main.go:5-7` +- **证据**: +```go +// cmd/cli/main.go —— README 声称"命令行工具" +func main() { log.Println("Hello World!") } +``` +- **影响**:本报告中的多数缺陷(Search 列名、`make(len)`+append、CommentList 分页、ModifyPost 附件、GetByIdentity 标签)都是单测可秒级捕获的类型;当前唯一的"测试"是 32 个失效的 `.http` 文件(见 P3-29),CI 无从回归。 +- **建议**:优先用 `sqlmock`/`sqlite` 或 dockertest 建 5 个最小用例(文章 CRUD 关联、评论分页、赞踩幂等、Search、分类唯一性),并把 `.http` 用例换成可执行的集成测试。 + +## 4. 推荐优化方案 + +1. **鉴权与授权分层(对应 P0-1、P1-12、P2-13)** + - 宿主层:为 CMS 写方法建立独立 admin 受众(aud)/密钥,不再与 C 端共用 HS256 key;`Anonymous` 列表不包含任何 `cms.` 方法。 + - 模块层:`service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "admin"})` 统一入口;数据访问强制属主条件(`identity + author_identity/owner_identity`),评论删除/修改仅允许作者或管理员。 + - 独立模式(`cmd/main`)补齐 `grpc.ChainUnaryInterceptor(auth, recovery, timeout, logging)` 与 `Mux` 初始化,否则删除该入口。 +2. **数据一致性(对应 P1-6、P1-7、P1-8、P1-10、P2-15、P2-24)** + - 写路径统一"先按 identity 查真实记录 → 组装附件(真实 ID/路径)→ 用显式字段集更新 → 差集重建关联",禁止"先删后按需重建"。 + - 互动计数引入幂等记录(`cms_interaction(post_identity, comment_identity, op_identity, type)` 唯一索引)+ Redis 计数,`Desc` 加下限保护;`comment_hits` 定期按实际评论数对账。 + - 唯一约束与软删除二选一:唯一索引加 `WHERE deleted_at IS NULL`,或分类/标签改硬删除并级联清理关联表。 +3. **查询正确性(对应 P1-3、P1-4、P1-5、P1-11、P2-19、P2-28)** + - `Search` 用 `title/content` 并加稳定排序;修正 `make(len)`+append;分页 clamp 前置;所有列表强制 `site_identity` 过滤;补 4 个复合索引。 + - 引入 repository 层封装(禁止 logic 直接拼 SQL/表名),统一 `WithContext` + `LIMIT/OFFSET` + 排序约定。 +4. **契约与文档(对应 P1-2、P2-20、P2-25、P3-29~33)** + - Site 服务:实现或下架;`StatusReply` 统一构造器;Create/Modify 复用同一映射;README 按代码重写;`.http` 用例按 gateway pattern 校正并纳入 CI。 +5. **可观测性与健壮性(对应 P2-16、P2-17、P2-26、P2-27)** + - 删除 `.Debug()`/`fmt.Println`,输出结构化日志(method、identity、site_identity、request-id、耗时);DB statement timeout + 连接池显式配置;`/healthz` 聚合依赖状态;配置启动强校验(拒绝 `CHANGE_ME`、校验 DSN 可达);移除仓库内示例密钥。 + +## 5. TODO 清单 + +- [ ] **P0-1** 为 CMS 全部写接口加属主/角色校验(`WHERE identity=? AND author_identity=?`,评论仅作者/管理员可改删)|验收:A 用户 token 调 `cms.Post/Delete`、`cms.Post/ModifyComment` 改删 B 用户数据返回 `PermissionDenied`,且 `ParseMetaCtx` 传入角色选项|涉及:`module/base/cms/internal/logic/post/modify.go:16`、`module/base/cms/internal/logic/post/delete.go:29`、`module/base/cms/internal/models/query.go:207`、`module/base/cms/internal/logic/post/delete_comment.go:24` +- [ ] **P1-2** 补齐或下架 `Site` 五个方法(不得返回假成功)|验收:`cms.Site/Create` 后 `Get/Fetch` 能查到记录;未实现前返回 `Unimplemented`|涉及:`module/base/cms/internal/logic/site/create.go:20`、`module/base/cms/internal/logic/site/get.go:24` +- [ ] **P1-3** 修正 Search 的列名|验收:带 `keyword` 调 `cms.Post/Search` 返回 200 且命中 `content`|涉及:`module/base/cms/internal/logic/post/search.go:37` +- [ ] **P1-4** 修正 Search 结果切片构造|验收:返回元素数等于实际行数且无 `null`|涉及:`module/base/cms/internal/logic/post/search.go:43` +- [ ] **P1-5** 修正评论列表分页 clamp 顺序|验收:`page/size` 缺省时返回前 10 条、`count` 与数据一致|涉及:`module/base/cms/internal/logic/post/comment_list.go:18-46` +- [ ] **P1-6** 修正 `ModifyPost/ModifyPages` 附件字段与关联重建语义|验收:modify 后 `post_id` 为真实 ID、`file_path` 为路径;仅改标题时分类/标签/附件不变|涉及:`module/base/cms/internal/models/query.go:162-201`、`module/base/cms/internal/logic/pages/modify.go:68-99` +- [ ] **P1-7** 文章详情返回标签与分类数据|验收:`GetByIdentity` 的 `tags_data[].title` 非空、`tags_identity_array` 为真实标签 identity、`category_data` 非空|涉及:`module/base/cms/internal/models/query.go:242`、`module/base/cms/internal/logic/post/get_by_identity.go:68-80` +- [ ] **P1-8** 修正页面详情 hits 更新条件与 tag identity|验收:`GetByIdentity` 使该页 hits+1 且不误增他页;`tags_identity_array` 为标签 identity|涉及:`module/base/cms/internal/logic/pages/get_by_identity.go:76`、`module/base/cms/internal/logic/pages/get_by_identity.go:62` +- [ ] **P1-9** 移除 `mall_staff` 依赖并修复类型断言 panic|验收:非商城员工用户可发评论;构造 `name` 为 NULL 的数据不再 panic|涉及:`module/base/cms/internal/logic/post/add_comment.go:35-40` +- [ ] **P1-10** 实现赞踩幂等与计数下限|验收:同一 `op_identity` 连续 100 次 `IncrPostLike` 后 `like_hits=1`;`Desc*` 不会低于 0|涉及:`module/base/cms/internal/models/query.go:346`、`module/base/cms/internal/logic/post/incr_post_like.go:38` +- [ ] **P1-11** 全部查询按 `site_identity` 过滤|验收:A 站 token 查询不到 B 站文章/页面/分类/标签|涉及:`module/base/cms/internal/models/query.go:117-139`、`module/base/cms/internal/logic/category/fetch.go:20`、`module/base/cms/internal/logic/tags/create.go:34` +- [ ] **P1-12** 独立模式补鉴权拦截器并初始化 `Mux`|验收:独立进程启动后无 token 调 `cms.Post/Delete` 返回 Unauthenticated|涉及:`module/base/cms/internal/server/new.go:20-30`、`module/base/cms/cmd/main/main.go:31-42` +- [ ] **P2-14** 修正 `tags.Create` 重复校验与 `site_identity`|验收:重复标题返回 `ErrAlreadyExists`,新标签带 `site_identity`|涉及:`module/base/cms/internal/logic/tags/create.go:19-39` +- [ ] **P2-15** 解决软删除与唯一约束冲突、Modify 静默成功、删除残留关联|验收:删除后可用同名 key 重建;空 identity 的 Modify 返回 `InvalidArgument`;删除标签后无残留关联行|涉及:`module/base/cms/internal/models/cms_category.go:20-25`、`module/base/cms/internal/logic/tags/modify.go:22-35` +- [ ] **P2-16** 清理 `.Debug()`/`fmt.Println`,日志结构化|验收:`grep -r 'Debug()\|fmt.Print' internal/` 无命中,日志含 method/identity/request-id|涉及:`module/base/cms/internal/models/query.go:119`、`module/base/cms/internal/logic/post/comment_list.go:30` +- [ ] **P2-17** DB 访问绑定 ctx 并设超时|验收:请求取消后 SQL 终止;启动期 Ping 失败即退出|涉及:`module/base/cms/internal/impl/impl.go:43`、`module/base/cms/internal/logic/post/get_by_identity.go:19` +- [ ] **P2-18** 为分类/标签加缓存或分页|验收:`cms.Tags/Fetch` 支持 page/size 且缓存命中率可观测|涉及:`module/base/cms/internal/logic/tags/fetch.go:20`、`module/base/cms/internal/logic/category/fetch.go:20` +- [ ] **P2-19** 统一 `Types` 列类型并修正分页/排序边界|验收:`type=1` 过滤有效、所有列表有稳定 `ORDER BY`、`page_size>50` 被夹到 50|涉及:`module/base/cms/internal/models/cms_post.go:26`、`module/base/cms/internal/logic/post/search.go:31-38` +- [ ] **P2-20** Create/Modify 字段映射对齐并支持清空语义|验收:Create 可写 `lang/source_origin/rights/extend_*/published`;`has_accessory` 可置 false|涉及:`module/base/cms/internal/logic/post/create.go:40-56`、`module/base/cms/internal/models/query.go:171` +- [ ] **P2-21** 清理/接线 dead code(`InitData`、`CategoryList`、`models.ModifyComment`、`fmtKey`、无用参数)|验收:`go vet` + `deadcode` 无告警,根分类由确定性的迁移步骤创建|涉及:`module/base/cms/internal/models/query.go:23`、`module/base/cms/internal/logic/post/ext.go:8` +- [ ] **P2-22** 浏览量异步化/去重|验收:详情接口无同步 UPDATE;同用户短时间重复刷新不重复计数|涉及:`module/base/cms/internal/models/query.go:248` +- [ ] **P2-23** 富文本 sanitize + 附件路径白名单|验收:`