# 审计报告: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 + 附件路径白名单|验收:`