Files
full/docs/audit/module-base-cms.md

660 lines
61 KiB
Markdown
Raw Normal View History

# 审计报告module/base/cms
## 1. 模块概览
- **模块路径**`module/base/cms``bsm/full/module/base/cms`Go 1.26.5gRPC + 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` 列只能从模型推断)、前端是否为富文本渲染 HTMLXSS 影响面为推测)。
- `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 与 gatewayURL `/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 层新建的结构体(恒为 0FilePath 被写成 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 为新建结构体,未设置 IDaccessory 实参是 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 是 CmsRelateTagsval.Identity 是关系表主键
for _, val := range data.Tags { res.TagsIdentityArray = append(res.TagsIdentityArray, val.Identity) }
```
- **影响**`GetByIdentity` 传入的 `val` 是页面 identity但自增条件恒为 `key = <identity>`:正常情况下匹配 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-2Site 服务未实现),多站点隔离实际不可用。
- **建议**:所有查询签名透传 `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*/Publishedproto 中存在这些字段)
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`,只能靠再调一次 ModifyModify 又要求 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-29CI 无从回归。
- **建议**:优先用 `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 + 附件路径白名单|验收:`<script>` 入库被过滤;非 https 白名单域名的 `file_path` 被拒|涉及:`module/base/cms/internal/logic/post/create.go:48``module/base/cms/internal/models/query.go:89`
- [ ] **P2-24** 评论树分层分页、校验文章存在、计数下限|验收:三层回复可返回;`comment_hits` 不低于 0不存在的 `post_identity` 返回 NotFound涉及`module/base/cms/internal/logic/post/comment_list.go:45``module/base/cms/internal/models/query.go:277-334`
- [ ] **P2-25** 统一响应构造与时间格式|验收:所有接口 `code/message/details/timeseq` 语义一致,时间为 RFC3339涉及`module/base/cms/internal/logic/post/create.go:68``module/base/cms/internal/logic/tags/delete.go:28`
- [ ] **P2-26** 增加健康检查与 APM/结构化日志|验收:`/healthz` 返回依赖状态APM 配置生效|涉及:`module/base/cms/etc/cms_prod.yaml:33`
- [ ] **P2-27** 配置加固(拒绝 `CHANGE_ME`、清理示例密钥、删除失效 `Anonymous`)|验收:占位配置启动失败并给出明确错误|涉及:`module/base/cms/etc/cms_prod.yaml:7-24``module/base/cms/internal/config/config.go:49`
- [ ] **P2-28** 补复合索引与关联唯一约束|验收:`(post_identity, created_at desc)``(site_identity, created_at)``UNIQUE(post_identity, tags_identity)` 存在且 `EXPLAIN` 无全表排序|涉及:`module/base/cms/internal/models/cms_comment.go:20``module/base/cms/internal/models/cms_relate_tags.go:8`
- [ ] **P3-29** 校正/重写 `test/*.http`(服务名小写 `cms.`、方法名与 proto 一致、`Content-Type`)|验收:全部用例可执行通过|涉及:`module/base/cms/test/post/post_add.http:1``module/base/cms/test/pages/search.http:1`
- [ ] **P3-30** 按代码重写 README端口、DDL、缓存章节、Site 服务、目录结构验收README 与实际端口/表字段逐一核对通过|涉及:`module/base/cms/README.md:403-430``module/base/cms/README.md:531-617`
- [ ] **P3-31** 清理错误注释与陈旧路径|验收:无 `/src/git.buka.tv` 残留、结构体注释与类型一致|涉及:`module/base/cms/internal/models/cms_site.go:28``module/base/cms/internal/models/cms_pages.go:6`
- [ ] **P3-32** 去硬编码表名/魔法数字|验收:无 `Table("mall_staff")` 与字符串表名,分页常量集中定义|涉及:`module/base/cms/internal/logic/post/add_comment.go:35``module/base/cms/internal/logic/pages/fetch.go:91`
- [ ] **P3-33** 建立最小单测集并接线 CI验收`go test ./...` 覆盖文章关联写入、评论分页、赞踩幂等、Search、分类唯一性CI 必过|涉及:`module/base/cms/internal/logic/post/search.go:37``module/base/cms/internal/logic/post/comment_list.go:44`
## 6. 审计摘要(供汇总使用)
- 问题数P0=1 P1=11 P2=16 P3=5合计 33
- 最高风险一句话CMS 全部写接口只校验"任意有效 JWT"而无角色/归属校验,任意 C 端登录用户可通过 `/cms.Post/Delete``/cms.Post/ModifyComment` 等改删他人文章与评论(`logic/post/delete.go:29` 甚至显式传空 `authorIdentity` 并留 `// Todo:`),且 `Site` 服务五个接口全为空实现却返回"成功"。
- 最优先 3 个动作:
1) 给所有写路径补属主/角色校验(模块内 `ParseMetaCtx` 带角色 + SQL 带 `author_identity/owner_identity`),并为 CMS 使用独立管理端密钥/受众。
2) 修复必然/高概率数据损坏:`Search` 列名与切片构造(`search.go:37,43`)、`CommentList` 分页恒空(`comment_list.go:18-46`)、`ModifyPost` 附件 `post_id=0`/`file_path=identity` 与"空数组清空关联"`models/query.go:162-201`)。
3) 明确 `Site` 服务去留(实现或下架,禁止假成功),并补齐 5 个最小单测用例接入 CI当前模块 0 个 `*_test.go`32 个 `.http` 用例全部失效)。
- 未能覆盖/无法验证的部分:真实数据库 schema`cms_post` 是否遗留 `cms` 列 → 影响 P1-3 是"必然报错"还是"可能报错");前端是否以 HTML 渲染 `content`XSS 实际可利用性);独立部署模式(`cmd/main` + SDK `service.Start`)的网关/鉴权实际运行结果;`etc/cms_prod.yaml` 与线上投放配置的差异;`pb/` 生成代码内部逻辑(未逐行审阅)。