Files
full/docs/audit/module-base-initial.md
yanweidong 63aeedc2fe 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.
2026-09-14 22:16:27 +08:00

656 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告module/base/initial
## 1. 模块概览
`module/base/initial` 是 BSM 平台的**客户端初始化与基础字典服务**,为「客户端启动 -> 拉配置 -> 查版本 -> 取国家/行政区划/字典」这一条链路提供能力。绝对路径 `D:\work\bsm-infra\full\module\base\initial`,模块名 `bsm/full/module/base/initial``go.mod:1`Go 1.26.5`git.apinb.com/bsm-sdk/core` 通过 `replace` 指向本地 `D:\work\bsm-sdk\core``go.mod:72`)。
| 项目 | 内容 |
| --- | --- |
| 服务形态 | gRPC + gRPC-gatewayHTTP 转 gRPC |
| 端口 | gRPC 12101 / Gateway 12102`etc/initial_dev.yaml:2,26``README.md:171-187` |
| 两个 proto 服务 | `Check`Hello/Config/Updates`Data`Country/Areas/Datas |
| 数据表 | `initial_config``initial_apps``initial_country``initial_areas``initial_datas``internal/models/*.go` |
| 缓存 | Redis`internal/logic/*/*_cache.go`+ 内存缓存变量(未被任何业务读取) |
| 非 pb 源码量 | 24 个 `.go` 文件,其中 `internal/` 18 个、`service/` 2 个、`cmd/` 2 个、`test/` 1 个 |
| 调用方 | 独立进程 `cmd/main/main.go`,或被 `pkgs/all`(聚合单体)与 `pkgs/ecmall` 通过 `service.Expose` 内嵌 |
| 测试 | **无任何 `_test.go`**`test/grpc/main.go` 是打线上地址的手工脚本 |
关键调用链(`cmd/main/main.go:18-43``config.New("Initial")``impl.NewImpl()`(建 Redis/DB/Etcd`server.New(nil)` 注册两个 gRPC 服务 → `service.New(...).Start()`**无任何 gRPC 拦截器**)。聚合模式下改由 `service.Expose``service/expose.go:18-29`)把 `pkgs/all` 的 DB/Redis 注入到 `impl` 包变量,并用 **gin + grpc-gateway + JWT 中间件**`pkgs/all` 服务器暴露同样的 handler。
## 2. 审计范围与方法
**已完整读取(逐行)**
- 全部非 pb 生产代码:`internal/config/config.go``internal/excode/ex.go``internal/impl/impl.go``internal/logic/check/{hello,config,config_cache,updates}.go``internal/logic/data/{country,country_cache,areas,areas_cache,datas,datas_cache}.go``internal/models/{query,initial_apps,initial_areas,initial_config,initial_country,initial_datas}.go``internal/server/{new,check_server,data_server}.go``service/{expose,dependencies}.go``cmd/main/main.go``cmd/cli/main.go``test/grpc/main.go`
- 配置:`etc/initial_{dev,test,prod}.yaml``etc/supervisor.bsm-apps-initial.conf``go.mod`
- 接口定义:`proto/{check,data,const}.proto`;路由注册阅读了 `pb/*.pb.gw.go``pattern_*` / `RegisterXHandlerServer` 段落(未逐行读 pb 生成体)。
- 依赖实现(决定本模块运行时行为,已在报告中标注为外部证据):`D:\work\bsm-sdk\core\{with/*,cache/redis/*,conf/*,vars/cache.go,env/env.go,errcode/errcode.go,printer/print.go,logger/logger.go,database/*}`,以及 GORM v1.31.2 `clause/order_by.go``statement.go``chainable_api.go`
- 部署上下文:`pkgs/all/internal/server/{server,authorization}.go``pkgs/all/internal/config/config.go``pkgs/all/cmd/main/main.go``pkgs/all/etc/default_dev.yaml``pkgs/all/internal/service/initial.go``pkgs/ecmall/AGENT.md:473-481`
**方法**:先建立「入口 → 路由 → handler → logic → cache → model → SDK」的完整数据流再沿 6 个维度取证;每条结论均给出 `文件:行号` 与原始代码片段。对无法在仓库内验证的(真实库表结构、线上数据、真实 env 变量)明确标注「推测/无法验证」。
**静态检查**(在 `D:\work\bsm-infra\full\module\base\initial` 执行):
- `gofmt -l .` → 无输出,退出码 0格式合规
- `go vet ./...` → 无输出,退出码 0无 vet 报告)。
**未覆盖**pb 生成代码逐行阅读、真实数据库 schema 与数据、线上 Redis 内容、`D:\work\bsm-sdk\core` 之外的第三方实现。本模块**没有** `.sql`/seed 文件(`glob **/*.sql``full/` 下无结果),表结构只能从 GORM model 反推。
## 3. 问题清单
### P0
无。
(说明:唯一具备「注入」性质的缺陷在 `check/config_cache.go:21`,但其可达性依赖部署形态——聚合模式下被 JWT 中间件挡在前面,独立部署下才可由未认证调用方直接触发,且 PostgreSQL `ORDER BY` 位置的单条注入以读放大/报错外泄为主,未发现可稳定堆叠写入的路径,故定级 P1 而非 P0。详见 P1-1。
### P1
#### 1. Config 接口把入参 `os` 直接拼进 `ORDER BY`,构成 SQL 注入面
- **位置**`module/base/initial/internal/logic/check/config_cache.go:20-22`(数据源为 `pb.ConfigRequest.Os``proto/check.proto:37`
- **证据**
```go
err = impl.DBService.Where("app = ? AND (os = ? OR os = ?)", app, os, "ALL").
Order("CASE WHEN os = '" + os + "' THEN 0 ELSE 1 END, id DESC").
Find(&configs).Error
```
`Config` 对该字段的唯一校验是非空,没有字符白名单(`internal/logic/check/config.go:15-17`
```go
if in == nil || in.App == "" || in.Os == "" {
return nil, errcode.ErrInvalidArgument
}
```
GORM 对 `Order(string)` 不做参数化——`chainable_api.go:323-330` 把字符串包成 `clause.OrderByColumn{Column: clause.Column{Name: v, Raw: true}}``clause/order_by.go:29``statement.go:86-92``raw==true` 时直接 `writer.WriteString(str)` 写入 SQL。HTTP 入口 `POST /initial.Check/Config` 的 body 直接映射到该字段(`pb/check.pb.gw.go:80` `local_request_Check_Config_0`)。
- **影响**:未认证(独立部署,见 P1-4调用方可控 `os` 值注入任意 SQL 片段到 `ORDER BY`,例如 `os = "ALL' THEN 0 ELSE 1 END, (SELECT ...) --"`:可用于布尔盲注(借排序/报错差异探测任意表)、构造昂贵排序造成 CPU 放大、或通过 `pg_sleep` / 大表笛卡尔排序做 DoS。缓存键也含该值注入失败不影响「键已写入」同一 payload 会被缓存 30 分钟(`config_cache.go:13,27`。PostgreSQL 单条 `ORDER BY` 位置通常无法堆叠 `;` 写入,故未定级 P0。
- **建议**:改为参数化/白名单——把优先级排序挪出 SQL`CASE` 用绑定变量:`Order("CASE WHEN os = ? THEN 0 ELSE 1 END, id DESC", os)` 在 GORM 中并不可靠,推荐 `clause.OrderByColumn` 仅用于固定列名,或用 `gorm.Expr` + 绑定);更简单稳妥的做法是查回后在 Go 侧按 `os == 目标 os` 优先归并;同时在 proto/handler 层限制 `os``^[A-Za-z0-9_.-]{1,32}$`
#### 2. `Areas` 数据库错误被覆盖并写入缓存,故障期间所有国家行政区划长期返回空
- **位置**`module/base/initial/internal/logic/data/areas_cache.go:33-38`
- **证据**
```go
err = impl.DBService.Where("enabled = ?", true).Where(key, val).Where("show_town = ?", in.ShowTown).
Order("sort_order ASC, name ASC").
Find(&areas).Error
err = impl.RedisService.Set(cacheKey, areas, vars.DefaultTTL)
return areas, err
```
第 33 行的查询错误在 37 行被 `Set` 的返回值**无条件覆盖**`areas` 此时为 `nil`,却被成功写入 Redis。上层只判断 `err`,而 `err``Set` 的结果(`internal/logic/data/areas.go:20-24`
```go
areas, err := GetAreasByCache(in)
if err != nil {
printer.Error("GetAreasByCache", err)
return nil, errcode.ErrDB
}
```
调用链中**没有任何位置检查过 DB 错误**`grep` 确认 `GetAreasByCache` 仅被 `areas.go:20` 调用)。
- **影响**:任一时刻的 DB 抖动/超时(以及 P1-3 的列不存在错误)都会被「缓存化」——空数组被写入 Redis 并保留 `vars.DefaultTTL` = 30 分钟(`D:\work\bsm-sdk\core\vars\cache.go:16`)。此后 30 分钟内所有 `Areas` 请求都命中该空值直接返回「成功但无数据」,客户端省市区选择器全空,且**无任何错误日志**`printer.Error` 只在 DB 错误路径上被调用,而该路径已不可达)。同类错误见 `country_cache.go:19-24``datas_cache.go:22-28`
- **建议**:拆分错误——`if err = DB...Error; err != nil { return nil, err }`,仅在 DB 成功后再 `Set`,且 `Set` 失败只记警告不影响返回;对空结果做「不缓存 / 短 TTL 空值标记」处理以防缓存穿透;返回错误时保留 `errcode.ErrDB` 语义。
#### 3. `Areas` 依赖的 `enabled` / `show_town` / `sort_order` 列在模块内不存在,且 seed SQL 缺失
- **位置**`module/base/initial/internal/logic/data/areas_cache.go:33-34` vs `module/base/initial/internal/models/initial_areas.go:7-18`
- **证据**
```go
err = impl.DBService.Where("enabled = ?", true).Where(key, val).Where("show_town = ?", in.ShowTown).
Order("sort_order ASC, name ASC").
```
模型只有 10 列,**没有** `enabled``show_town``sort_order`(也无 `deleted_at`/`status`
```go
type InitialAreas struct {
Id uint `gorm:"column:id;" json:"id"`
CountryID uint `gorm:"index" json:"country_id"`
CountryCode string `gorm:"column:country_code;index;type:varchar(255);index;" json:"country_code"`
Pid uint `gorm:"column:pid;index;" json:"pid"`
Deep int32 `gorm:"column:deep;" json:"deep"`
Name string `gorm:"column:name;type:varchar(255);" json:"name"`
PinyinPrefix string `gorm:"column:pinyin_prefix;type:varchar(255);index;" json:"pinyin_prefix"`
Pinyin string `gorm:"column:pinyin;type:varchar(255);" json:"pinyin"`
ExtId string `gorm:"column:ext_id;type:varchar(255);" json:"ext_id"`
ExtName string `gorm:"column:ext_name;" json:"ext_name"`
}
```
模型没有 `TableName()` 之外的软删声明,`database.AppendMigrate` 又因 `IsAutoMigrate=false` 从不执行(`D:\work\bsm-sdk\core\database\sql\postgresql.go:17,43`),表结构只能来自仓库外的 SQL而 README 引用的 `scripts/initial_areas_202509081640.sql` 在仓库中**不存在**`full/``**/*.sql` 无匹配;模块根目录只有 `cmd/etc/internal/logs/pb/proto/service/test` + `go.mod/go.sum/README.md`)。
- **影响**:若目标库确实没有这三列,`Find` 必然返回 `column "enabled" does not exist`,而该错误被问题 2 吞掉并缓存空值 ⇒ **`Areas` 接口 100% 静默返回空**客户端无法选择任何行政区划同时「show_town 控制是否输出乡镇」这一文档语义(`proto/data.proto:48-49``README.md:147-156`)完全由一列数据决定,无从校验。若列实际存在(例如由仓库外 SQL 建出),则模型与库结构长期脱钩、`AutoMigrate` 一旦被打开会**按模型删列/加列**,存在数据损坏风险。`pkgs/ecmall/AGENT.md:479` 也把该不一致记为已知缺口。标注:本仓库无法验证线上真实列,**「必然失败」为推测**,但「代码与模型/文档不一致」是确证的。
- **建议**:把 `Enabled bool``ShowTown bool``SortOrder int32` 正式加入 `InitialAreas` 模型(并同步 `TableName` 与索引),或把过滤/排序改为模型内已有字段;把建表与 seed SQL 纳入仓库(`scripts/`)并在 CI 校验;为 `Areas` 增加一条「已知国家存在数据时必须非空」的集成测试(见 P1-7
#### 4. 鉴权在两种部署形态下不一致:独立部署 6 个接口全匿名,聚合部署 `initial.*` 全部落在白名单之外
- **位置**`module/base/initial/etc/initial_dev.yaml:15-21`test/prod 同)、`module/base/initial/cmd/main/main.go:20``pkgs/all/etc/default_dev.yaml:21-43`
- **证据**:本模块把全部方法登记为匿名,但**该列表在两种形态下都不生效**——独立部署 `MicroService.Enable: false``etc/initial_prod.yaml:14``service.Start` 只在 `MsConf.Enable` 为真时才把匿名表写进 etcd`D:\work\bsm-sdk\core\service\service.go:66-90`),且 `server.New(nil)``grpc.NewServer()` **不带任何拦截器**`internal/server/new.go:20-24`
```go
standalone := grpcServ == nil
if standalone {
grpcServ = grpc.NewServer()
}
```
即:只要 12101/12102 可达(`Gateway.Enable: true``initial_prod.yaml:24-26`**`Config`/`Updates`/`Areas`/`Country`/`Datas`/`Hello` 全部无需任何凭据**,也没有限流。
聚合形态下白名单是另一套机制且**不含 initial**`pkgs/all/etc/default_dev.yaml:24-43` 只有 `/passport.*``/market.Agency/Login``/mall.Staff/Login``/rest/*`),而 `pkgs/all/internal/server/authorization.go:55-66,96-103` 对不在 `anonymous` 中的路径强制校验 `Authorization` JWT
```go
requestPath := canonicalHTTPPath(r.URL.Path)
if !a.isAnonymous(requestPath) {
if err := a.validate(r.Header.Get("Authorization")); err != nil {
writeHTTPAuthorizationError(w, err)
```
并且 `pkgs/all/etc/default_dev.yaml:46-62``Services` 明确启用了 `initial``registerXHandlerServer` 是 local handlergRPC 拦截器对其无效(`pb/data.pb.gw.go:119-123` 的生成注释原文“GRPC interceptors will not work for this type of registration”因此聚合形态下 `/initial.Check/Config` 等只能靠上述 HTTP 中间件,未带 JWT 必然被拒。
- **影响**:两个方向的真实风险——(a) 独立部署下 `Config` 无鉴权,任何能访问 12102 的人只要猜出 `app`/`os`(如 `test-app`/`linux`,见 `test/grpc/main.go:117-120`)即可读取该应用全部配置键值;`initial_config.value` 是自由文本(`internal/models/initial_config.go:14`**若其中存有第三方密钥/内网地址即为凭据泄露**(本次无 seed 数据,无法验证内容,标注推测);(b) 聚合部署下客户端「首次启动无 token」却要求 token 才能取初始配置,`Anonymous` 白名单漏配**很可能直接打断客户端初始化链路**`/initial.Check/Config``/initial.Data/*` 全部 401。两套配置互相矛盾说明该端点的鉴权定位从未被固化。
- **建议**:明确 `initial` 的公开边界(`Hello`/`Updates`/`Country` 可匿名,`Config`/`Datas` 建议至少校验调用方身份或 `Secret-Key`),并在两处配置中一致落地:独立部署补上 gRPC 拦截器(`grpc.UnaryInterceptor`)与网关侧中间件,聚合部署把确定要公开的路径写进 `Authorization.Anonymous`;为 `initial_config` 增加「敏感键」隔离(独立表或加密列),避免 `Config` 直接回吐密钥。
#### 5. 版本判断仅做字符串全等,无法识别预发布/降级/大小写/重复发布
- **位置**`module/base/initial/internal/logic/check/updates.go:23-25,39-48`
- **证据**
```go
err = impl.DBService.Where("app = ? AND os = ? AND arch = ?", in.App, in.Os, in.Arch).
Order("pubdate DESC, id DESC").
First(&data).Error
...
if in.Version != data.Version {
return &pb.CheckForUpdatesReply{ Identity: data.Identity, Version: data.Version, ... }, nil
}
```
只有「按 `pubdate DESC, id DESC` 取 1 行」+「`!=` 比较」两步。由此产生的具体缺陷:
1. **降级误判**:客户端 2.0.0,库中最新发布为 1.9.0 ⇒ `!=` 成立 ⇒ 返回「有新版本」并要求升级到更旧版本。没有任何 `client > latest` 的分支。
2. **预发布**:客户端 `1.2.3-beta.1`,库中 `1.2.3` ⇒ 判定需升级(语义上正确);但库中最新为 `1.2.3-beta.2` 时,正式版客户端 `1.2.3` 也被判定需「升级」到 beta ——无 prerelease 优先级规则。
3. **大小写/空白不归一**`V1.2.3``1.2.3 ``1.2.3` 全部判为「有新版本」,客户端会无限收到更新提示(若更新后版本串仍不匹配,形成更新循环)。
4. **数据驱动的最新版本不可靠**`pubdate` 为可空时间列(`models/initial_apps.go:19` `Pubdate time.Time` 无 default/not null漏填或补录旧版本会把**旧行**排到最前;`First` 只有 `pubdate DESC, id DESC`,同一 `pubdate` 下结果依赖 id不保证与客户端能接受的最高版本一致。
5. **`pubdate` 时区**`.Format("2006-01-02 15:04:05")`(第 46 行)直接输出 Go `time.Time` 的本地时区表示,未做 UTC 归一DSN 为 `TimeZone=Asia/Shanghai``etc/initial_prod.yaml:7`)而进程 `TZ` 未在本模块配置(`grep TZ` 无结果),两地不一致时返回时间会偏移。
README 声称「多平台支持/版本比较算法」(`README.md:131-135`),实际不存在任何比较算法。
- **影响**:客户端升级提示错误(误报/漏报/升级循环)是终端用户可见故障;服务端无法区分「客户端更旧」与「客户端更新」,也就无法做强制升级/灰度。
- **建议**:引入语义化版本比较(可用 `github.com/coreos/go-semver`——已在 `go.mod:26` 的间接依赖中存在,或自实现 `major.minor.patch[-pre]` 解析);判定改为三态 `cmp(client, latest)``<0` 返回更新、`==0` 无更新、`>0` 返回「无更新」(或独立字段标识客户端过新);对版本串做 `strings.TrimSpace` + 统一大小写;`pubdate` 缺失时用 `created_at` 兜底并对 `pubdate` 建索引;时间统一按 UTC 存储、按调用方时区或固定 `+08:00` 输出。
#### 6. 测试覆盖为零:无 `_test.go``test/` 只是指向线上地址的手工脚本
- **位置**:模块内 `glob **/*_test.go` 无结果;`module/base/initial/test/grpc/main.go:18,46-53`
- **证据**
```go
const (
address = "api.apinb.com:10020" // 本地测试地址
)
...
conn, err := grpc.Dial(address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
```
`test/http/*.http` 同样是打到 `http://api.apinb.com/...` 的样例请求(`test/http/check.hello.http:1``test/http/data.country.http:1``test/readme.md` 全文只有一行 `restful test`。没有 `httptest`/`bufconn`/`sqlmock`/`miniredis` 等任何可在 CI 中运行的测试。
- **影响**:本报告中的 P1-1、P1-2、P1-3、P1-5 全部属于「有测试就能拦住」的类型:`os` 注入可被一条含引号的入参用例发现空值缓存可被一条「DB 报错 → 再查询」用例发现;列不存在可被一条集成用例发现;版本比较的 4 类边界可被表驱动用例覆盖。当前状态等于这四个高风险逻辑(版本比较、缓存失效、行政区划边界、注入)**零回归保护**,且 `go vet`/`gofmt` 全绿会给人「质量合格」的错觉。
- **建议**:见第 5 节第 5 条「关键缺失用例清单」,先在 CI 中加入表驱动的纯逻辑测试(版本比较、去重归并、缓存键构造)与 sqlmock/miniredis 级别的缓存测试。
#### 7. `initial_prod.yaml` 与 dev/test 逐字节相同:指向开发库、`sslmode=disable`、微服务注册关闭
- **位置**`module/base/initial/etc/initial_prod.yaml:1-40`(与 `initial_dev.yaml``initial_test.yaml` 内容一致)
- **证据**
```yaml
Databases:
Driver: postgres
Source:
- 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/
MicroService:
Enable: false
```
三个环境文件除文件名外完全一致dev/test/prod 的 `dbname` 都是 `rst_dev``host` 都是 `127.0.0.1``MicroService.Enable` 都是 `false``Gateway.Enable` 都是 `true`)。
- **影响**:把 `initial_prod.yaml` 当作生产配置直接部署,会把**生产流量打进名为 `rst_dev` 的开发库**(数据污染与相互覆盖),或因为本机没有该库而启动即 `panic``with.Databases` 在连接失败时 panic`D:\work\bsm-sdk\core\with\databases.go:24``sslmode=disable` 使数据库凭据与配置内容在链路上明文;`MicroService.Enable: false` 使该服务**不进 etcd**,聚合/网关注册发现链路拿不到它(与 P1-4 的匿名表失效是同一根因)。
- **建议**生产配置改由部署系统在启动时注入env/configmap仓库内只保留 `.example` 模板并显式标注「禁止直用」;`sslmode` 在生产设为 `require`/`verify-full`;三个环境至少要让 `dbname`/`host`/`sslmode`/`MicroService.Enable` 明显可区分,并加一条启动自检:`prod` 模式检测到 `CHANGE_ME``dbname=*_dev` 时拒绝启动并 `exit 1`
#### 8. `Config` 无任何调用方身份校验,可枚举读取任意应用配置;配合缓存键膨胀可放大 DB 压力
- **位置**`module/base/initial/internal/logic/check/config.go:13-30``internal/logic/check/config_cache.go:13`
- **证据**`app` 只做非空校验(`config.go:15`),随后原样进入查询与缓存键:
```go
key := impl.RedisService.BuildKey("config", app, os)
...
err = impl.DBService.Where("app = ? AND (os = ? OR os = ?)", app, os, "ALL").
```
`BuildKey` 的实现是纯字符串拼接、无长度与字符约束(`D:\work\bsm-sdk\core\cache\redis\cache.go:19-26`
```go
func (c *RedisClient) BuildKey(prefix string, params ...any) string {
var key strings.Builder
key.WriteString(vars.CacheKeyPrefix + prefix)
for _, param := range params {
key.WriteString(fmt.Sprintf(":%v", param))
}
return key.String()
}
```
配合 `app` 无枚举限制,攻击者可提交海量 `(app, os)` 组合:每个新组合都是一次 Redis miss + 一次 DB 查询 + 一次 `Set`TTL 30 分钟Redis 中出现无上限的 `bsm:config:<app>:<os>` 键。
- **影响**:不同应用的配置属于不同租户/产品边界,`Config` 不校验调用方与 `app` 的归属关系,等价于「知道 app 名即可读其全部配置」;同时构成一个低成本缓存污染/DB 放大面(无鉴权+无限流,见 P1-4。是否真实泄露密钥取决于 `initial_config.value` 的内容,本仓库无数据,标注**推测**;接口本身缺乏归属校验是确证的。
- **建议**:给 `app` 建立受控枚举(字典表或白名单)并校验调用方(至少 `Secret-Key`/JWT 的 `app` claim 与请求 `app` 一致);对 `app`/`os` 做长度与字符白名单;为空结果设短 TTL 空值标记并加单飞singleflight防止缓存穿透`Config` 增加按调用方的限流。
### P2
#### 9. 缓存策略与文档不符,且 `version` 语义未落地:配置更新最长 30 分钟不可见
- **位置**`module/base/initial/internal/logic/check/config_cache.go:27``data/{country_cache.go:23,areas_cache.go:37,datas_cache.go:27}``README.md:324-329`
- **证据**:四类数据全部使用同一个 TTL 常量:
```go
err = impl.RedisService.Set(key, configs, vars.DefaultTTL)
```
`vars.DefaultTTL = 30 * time.Minute``D:\work\bsm-sdk\core\vars\cache.go:16`)。而 README 的性能表宣称「配置数据 10 分钟 / 国家数据 24 小时 / 地区数据 12 小时 / 系统数据 6 小时」,四类**没有任何一类与实现一致**。同时 `ConfigItem` 携带 `version``proto/check.proto:49``InitialConfig.Version` 也是模型字段(`models/initial_config.go:15`),但缓存键 `BuildKey("config", app, os)` 只含 `app`/`os`**不含 version**,也没有任何写侧失效逻辑(`grep Delete(` 在模块内无结果)。
- **影响**:运营在后台改了配置/版本号,客户端最长 30 分钟仍拿到旧值(`version` 变化对缓存无意义,客户端无法用它判断是否需要重取);反过来,若把 TTL 调短,`Config` 又会在每次过期后对 DB 产生尖峰(无单飞、无预热)。文档与实现不符会让排障时误判「为什么改了没生效」。
- **建议**:明确并文档化每类数据的真实 TTL或改用按数据类型的常量表`version`(或 `updated_at` 的最大值)纳入缓存键或作为「配置清单」单独缓存,使 `version` 变化即可让客户端判定失效;写侧通过发布事件/版本号主动 `Delete` 相关键;为热点键加 singleflight。
#### 10. Redis 故障与缓存未命中不可区分Redis 一旦不可用,全部请求直穿数据库
- **位置**`module/base/initial/internal/logic/check/config_cache.go:15-18`country/areas/datas 同构)
- **证据**
```go
err := impl.RedisService.Get(key, &configs)
if err == nil {
return configs, nil
}
// 任何 err 都会被当成 "miss",直接落库
err = impl.DBService.Where(...).Find(&configs).Error
```
SDK 的 `Get` 把所有情况都压成一个 `error`,且 `redis.Nil` 也被包装成新错误(`D:\work\bsm-sdk\core\cache\redis\cache.go:37-42`
```go
data, err := c.Client.Get(c.Ctx, key).Result()
if err != nil {
return errcode.NewError(500, err.Error())
}
return json.Unmarshal([]byte(data), result)
```
即「键不存在」「连接失败」「反序列化失败」三种语义完全无法区分。
- **影响**Redis 连接失败(或 `Get` 反序列化失败)时,所有缓存读都变成 miss`Country`/`Datas`(全表)与 `Config`/`Areas`(按参数)会在每个请求上打 DB形成「缓存雪崩 → DB 被打满」的连锁故障在最坏情况下Redis 报错但 `Set` 也报错)`err` 被返回,功能整体不可用,但此前的每一次请求都已经打到 DB。
- **建议**:在 SDK 层区分 `redis.Nil` 与连接错误;对连接错误使用本地内存兜底缓存(`impl.MemorySerice` 已存在但无人使用,见问题 11或降级返回上一份成功结果为缓存读加单飞与短 TTL 的「miss 标记」,避免同一 key 并发击穿。
#### 11. 多处把数据库错误覆盖 / 判定错误,且死代码与未使用依赖普遍
- **位置**`internal/logic/data/country_cache.go:19-24``internal/logic/data/datas_cache.go:22-28``internal/impl/impl.go:16,22``internal/excode/ex.go:7-11``internal/models/query.go:1``cmd/cli/main.go:8-9``internal/server/new.go:27`
- **证据**
```go
err = impl.DBService.Where("enabled = ?", true).
Order("sort_order ASC, name ASC").
Find(&country).Error
err = impl.RedisService.Set(cacheKey, country, vars.DefaultTTL)
return country, err
```
```go
err = impl.DBService.Order("data_type ASC, id ASC").Find(&datas).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, errcode.ErrDB
}
```
`Find`(切片查询)在 GORM 中不会返回 `gorm.ErrRecordNotFound`,该判断恒为真分支之外的死条件;且此处错误虽被检查,成功路径紧接着又用 `Set` 的返回值覆盖了它(第 27-28 行)。死代码/未使用项:
```go
MemorySerice *cache.Cache // 内存缓存服务
...
MemorySerice = with.Memory(nil)
```
`impl.MemorySerice` 全模块只有这两处出现(`grep` 结果仅有 `impl.go:16,22``service/dependencies.go:30` 的赋值),无任何读点;`excode` 包 5 个错误变量(`ErrInvalidApp`/`ErrInvalidOS`/`ErrInvalidArch`/`ErrInvalidVersion`/`ErrInvalidCountry`)无引用;`models/query.go` 全文只有 `package models` 一行;`cmd/cli/main.go` 只打印两行字符串;`Server.grpcConns``new.go:17`)从未被写入或读取。
- **影响**DB 错误被覆盖的后果与 P1-2 同类(静默空结果 + 30 分钟缓存);`gorm.ErrRecordNotFound` 的死判断会误导后续维护者以为「查不到会被当成 DB 错误处理」;未使用的内存缓存与 `Dependencies.Cache` 注入字段(`service/dependencies.go:16,29-31`)让人误以为有二级缓存,实际没有——`pkgs/ecmall/AGENT.md:290-292` 也已把该字段记为跨模块的「死字段」。
- **建议**统一错误处理模板DB 错误先返回,`Set` 失败仅记日志);删除 `gorm.ErrRecordNotFound` 死判断;移除 `MemorySerice`/`Dependencies.Cache`/`excode`/`models/query.go`/`grpcConns` 或补上真实用途(如用内存缓存做 Redis 降级);`cmd/cli` 要么实现要么删除。
#### 12. 日志、上下文与健康检查三处可观测性缺失
- **位置**`internal/logic/*/*.go`4 处 `printer.Error`)、`internal/logic/check/hello.go:12-31``internal/config/config.go:33``internal/server/new.go:27`
- **证据**:错误只经 `printer.Error` 输出到 stdout不带请求上下文、不带结构化字段且该函数固定写 stdout`D:\work\bsm-sdk\core\printer\print.go:13-16,37-40`
```go
logger = log.New(os.Stdout, "", 0)
...
func Error(format string, a ...any) {
message := fmt.Sprintf("\033[31m[Error] "+format+"\033[0m\n", a...)
```
`Spec.Log = conf.InitLoggerConf(Spec.Log)``etc/*.yaml` 中没有 `Log:` 段落而走默认值 `Level: DEBUG, Dir: "./logs/", Console/File: true``D:\work\bsm-sdk\core\conf\new.go:110-123`),本模块实际没有任何地方使用该 logger。健康检查是常量返回
```go
return &pb.StatusReply{
Code: 0,
Message: vars.OK,
...
```
代码里紧跟着注释「可以在这里添加更多的健康检查逻辑 / 例如检查数据库连接、Redis连接等」但未实现第 23-24 行)。上下文的处理也不完整:`ctx` 参数在全部 6 个 handler 中都没有传给 DB 与 RedisDB 查询用 `impl.DBService``.WithContext(ctx)`Redis 用客户端自持的 `context.Background()``D:\work\bsm-sdk\core\cache\redis\redis.go:28,74`)。
- **影响**:线上排障时看不到「哪个请求、哪个参数、哪条 SQL」失败`printer` 的 ANSI 颜色码还会污染文件日志;`Hello` 永远返回 OKDB/Redis 全挂时负载均衡仍认为实例健康,故障实例不会摘除;客户端断连或超时后 DB 查询仍在跑(无法取消),在 P1-2/P2-10 的放量场景下加剧资源占用。聚合模式下 gin 只会记录 HTTP 层日志,模块内的 `printer` 输出与请求无法关联。
- **建议**:改用 SDK 的结构化 logger 并在 handler 入口记录 `request_id`/method/耗时/结果码;把 `ctx` 贯穿到 `DBService.WithContext(ctx)` 与 Redis`RedisClient``GetCtx/SetCtx` 或按请求传 ctx`Hello` 至少 `Ping` 一次 DB 与 Redis 并按结果返回非 0 code或拆出独立的 readiness 探针);为 `initial_*` 配置补齐 `Log` 段落,生产用 `INFO`
#### 13. 配置与密钥:硬编码默认 JWT 密钥、生产库禁用 SSL、配置校验只覆盖两项
- **位置**`module/base/initial/internal/config/config.go:36-41``internal/impl/impl.go:26``etc/initial_prod.yaml:7``D:\work\bsm-sdk\core\env\env.go:19`
- **证据**
```go
conf.NotNil(Spec.Service, Spec.Cache)
encipher.New(env.Runtime.JwtSecretKey)
```
只校验 `Service``Cache``Databases``Gateway``MicroService` 全都不校验,而下一行就把可能为 nil 的配置直接交给初始化器(`impl.go:26,28`
```go
DBService = with.Databases(config.Spec.Databases, nil)
EtcdService = with.Etcd(config.Spec.Etcd)
```
未设置 `BSM_JwtSecretKey` 时会静默采用硬编码默认值:
```go
JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),
```
数据库连接串让生产链路明文(`sslmode=disable``initial_prod.yaml:7`),且该 DSN 会经由 `with.Databases` 打印出来(`D:\work\bsm-sdk\core\with\databases.go:18` `printer.Info("... Databases: %v", cfg)`),包含账号密码。另外 SQL 层默认 `Debug: true``D:\work\bsm-sdk\core\database\sql\postgresql.go:19,46-48`),会把每条 SQL 及其参数值打到标准输出——`initial_config.value` 的内容会因此进入日志。
- **影响**(a) 默认 JWT 密钥是公开常量,任何忘记注入 env 的部署都可被伪造 token在聚合形态下这直接等价于绕过鉴权(b) 凭据明文传输 + 启动日志打印 DSN凭据进入日志系统(c) 配置缺项不会在启动时报错,而是在首个请求上 panic`datas_cache.go:22` 之类会 `nil pointer`)或直接 panic 退出(`with.Databases` 对 nil 配置 `panic("No Database Source Found !")`)。
- **建议**:删除硬编码默认密钥并要求启动时强制提供(缺失即 `exit 1``encipher.New` 失败要有明确错误路径DSN 打印前脱敏;生产 `sslmode` 强制 `require` 以上;把 `NotNil` 扩展到 `Databases.Driver``Databases.Source``Gateway.Enable` 时的 `Gateway.Port``MicroService.Enable` 时的 `Etcd.Endpoints`;关闭 SQL 的 `Debug` 或仅在 dev 打开。
#### 14. `Areas` 无分页无上限,`Updates` 无缓存无索引:两个可预期的性能瓶颈
- **位置**`internal/logic/data/areas_cache.go:33-35``internal/logic/check/updates.go:23-25`
- **证据**
```go
err = impl.DBService.Where("enabled = ?", true).Where(key, val).Where("show_town = ?", in.ShowTown).
Order("sort_order ASC, name ASC").
Find(&areas).Error
```
`Areas` 按「国家 + 是否含乡镇」全量返回,没有 `Limit`,也没有分页/流式字段(`proto/data.proto:43-50``AreasRequest` 只有 `country_id`/`country_code`/`show_town`)。一个国家含乡镇的完整层级(省→市→区县→乡镇)在中国场景下是数万行量级,单响应可达数 MB。`Updates` 则是每次请求都打 DB
```go
err = impl.DBService.Where("app = ? AND os = ? AND arch = ?", in.App, in.Os, in.Arch).
Order("pubdate DESC, id DESC").
First(&data).Error
```
无缓存、无 `LIMIT` 之外的裁剪,且 `initial_apps` 上**没有任何模型级索引**`models/initial_apps.go:11-20` 的字段均无 `index` tag模型只用 `Std_IICUDS``id` 主键与 `identity` 唯一索引),`app/os/arch` 三列组合过滤 + `pubdate` 排序只能走全表扫描 + 排序。
- **影响**`Areas(show_town=true)` 每次缓存未命中都要全量装配并序列化整个国家的行政区划,多客户端同时首启会形成大对象传输与 GC 压力;`Updates` 是客户端启动/定时检查都会调的高频接口,在无索引的表上随发布记录增长退化为「每请求一次 seq scan + sort」叠加 P1-4 的无限流放大成 DoS 面。
- **建议**:为 `initial_apps``(app, os, arch, pubdate DESC)` 复合索引与 `(app, os, arch)` 唯一约束/校验;`Updates` 结果按 `(app,os,arch)` 走短 TTL如 1-5 分钟)缓存或内存缓存;`Areas` 增加按 `pid`/`deep` 的增量查询或分页参数,至少对 `show_town=true` 的响应做体积上限与 gRPC 消息大小校验;`initial_areas``(country_id, show_town, sort_order)`(或 `country_code` 版本)复合索引。
### P3
#### 15. 行政区划默认值与缓存键构造:魔法数字 `1`、可读性差的键、无输入兜底
- **位置**`internal/logic/data/areas.go:15-17``internal/logic/data/areas_cache.go:18-26`
- **证据**
```go
if in.GetCountryCode() == "" && in.GetCountryId() == 0 {
in.CountryId = 1 // 默认中国
}
```
```go
if in.GetCountryCode() != "" {
key = "country_code = ?"
val = in.GetCountryCode()
} else if in.GetCountryId() != 0 {
key = "country_id = ?"
val = in.GetCountryId()
}
cacheKey := impl.RedisService.BuildKey("areas", key, val, in.ShowTown)
```
`1` 是硬编码的「中国」主键proto 注释也把它写成默认契约,`proto/data.proto:44`),但库里主键是否稳定为 1 无从保证;`key` 直接沿用了 SQL 片段字符串作为缓存键的一部分(生成形如 `bsm:areas:country_code = ?:CN:false` 的键),语义与实现耦合。当 `country_code==""``country_id==0`(例如显式传 `{"show_town":true}` 的直连 gRPC 调用,不经过 `Areas` 函数的兜底、直接调用 cache 层时;以及未来新增调用方时)`val``nil`SQL 退化为不带国家过滤的 `WHERE enabled=true AND show_town=false`
- **影响**:默认国家靠主键值隐含约定,数据重建/换库即失效;`nil` 过滤会把**所有国家**的行政区划混在一起返回(当前 HTTP 路径被 `Areas` 兜底掩盖,属于潜在越界返回面);缓存键含 `?` 与空格,人工排障与批量失效都困难。
- **建议**:把默认国家常量抽出(或按 `iso2='CN'` 解析出 id缓存键改用规则化命名`areas:cc:CN:town:0` / `areas:cid:1:town:0`);在 cache 层对 `key==""` 直接返回参数错误而不是全表查询。
#### 16. 输入校验风格不一致nil 防护时有时无
- **位置**`internal/logic/check/config.go:15``internal/logic/check/updates.go:16``internal/logic/data/areas.go:15``internal/logic/data/{country.go:11,datas.go:12}`
- **证据**`Config`/`Updates` 检查 `in == nil``Areas``Country`/`Datas` 完全不检查而直接调用方法(`in.GetCountryCode()`、未使用 `in`
```go
func Areas(ctx context.Context, in *pb.AreasRequest) (reply *pb.AreasReply, err error) {
if in.GetCountryCode() == "" && in.GetCountryId() == 0 {
```
`pb.AreasRequest``GetXxx` 生成方法本身对 nil receiver 安全(`pb/data.pb.go` 生成体),但 `areas.go:16``in.CountryId = 1` 是直接字段写入nil 时会 panic。
- **影响**:行为不一致,未来若增加内部调用方(当前 gRPC handler 总是传入非 nil`pb/data.pb.gw.go:88`nil 输入会在 `areas.go:16` 直接 panic`Updates``in.Version` 不做任何格式/长度校验,超长字符串会原样进入查询与错误信息。
- **建议**:统一在 logic 入口做 nil + 参数校验(与 `errcode.ErrInvalidArgument`/`excode` 中已有但未使用的错误码对齐),对 `app`/`os`/`arch`/`version`/`country_code` 增加长度与字符白名单。
#### 17. `Config` 响应顺序不确定;对「无配置」不返回明确结果
- **位置**`internal/logic/check/config.go:33-50`
- **证据**:去重结果存于 map随后直接 `range` 该 map 构造响应:
```go
configMap := make(map[string]*models.InitialConfig)
for _, item := range configs {
if existing, exists := configMap[item.Key]; !exists || item.Os != "ALL" {
configMap[item.Key] = item
} else if existing.Os == "ALL" && item.Os != "ALL" {
configMap[item.Key] = item
}
}
for _, item := range configMap {
data = append(data, &pb.ConfigItem{...})
}
```
Go map 迭代顺序随机,同一份数据两次调用的 `data` 顺序不同;客户端若做「按顺序覆盖」或做响应 hash 比较会不稳定。查询侧辛苦构造的 `Order("CASE WHEN os = '...' THEN 0 ELSE 1 END, id DESC")` 优先级排序在 map 化之后**完全失效**——多条同 `key``os` 的记录中哪一条胜出取决于 map 迭代顺序,而 SQL 里 `id DESC`(最新优先)的本意被丢弃。
- **影响**:响应不稳定,无法用响应对账/缓存比对;「同 key 同 os 多行取最新」的意图未实现,可能返回过期配置行。
- **建议**:按 `configs` 的有序切片归并(保持 SQL 顺序,首个胜出即退出该 key响应按 `key` 排序输出;`key` 建议在库层加 `(app, os, key)` 唯一约束以消除歧义;无配置时返回空切片并保持 200当前已是空切片但建议在注释/文档中明确。
#### 18. 独立部署暴露 gRPC 反射,且健康检查不反映真实依赖状态
- **位置**`internal/server/new.go:36-38``internal/logic/check/hello.go:26-31`
- **证据**
```go
if standalone {
reflection.Register(srv.Grpc)
}
```
```go
return &pb.StatusReply{
Code: 0,
Message: vars.OK,
Details: "",
Timeseq: time.Now().Unix(),
}, nil
```
- **影响**:反射服务把全部方法签名(含未在 proto 中公开的内部方法)暴露给任何能连 12101 的调用方,为攻击者省去探测成本;`Hello` 恒返回 OK用作健康检查时无法发现 DB/Redis 故障(与 P2-12 同一根因,此处单列接口暴露面)。
- **建议**:仅在 dev 或显式开关下注册反射;`Hello` 区分 liveness恒 OK与 readiness探测 DB/Redis或新增独立探针接口。
#### 19. 文档与仓库实际内容严重不符README 描述了不存在的交付物与特性)
- **位置**`module/base/initial/README.md`(多处)对照模块实际内容
- **证据**(逐条可验证):
| README 声称 | 实际 |
| --- | --- |
| `README.md:47,51,55,57``make deps/proto/build/run` | 模块内**没有** `Makefile` |
| `README.md:64,252-264` docker-compose / `Dockerfile` / `docker-compose.yml` | 两者都不存在 |
| `README.md:93,216-217` `swagger/` 目录与 `make swagger` | 目录不存在,`pb/` 下无 swagger 产物 |
| `README.md:94,240-246,275-280` `scripts/``make init-db` | 目录不存在,全仓库无 `.sql` |
| `README.md:82` 项目结构含 `internal/cache/` | 只有 `logic/*/*_cache.go`,无该目录 |
| `README.md:17,342-346` “健康检查和 APM 集成”“`/health``/metrics`” | 模块未接入 APM`service.go` 未挂载 `/health``/metrics` |
| `README.md:18,324-329` “性能优化/缓存策略”表10 分/24 小时/12 小时/6 小时) | 四类全部 `vars.DefaultTTL` = 30 分钟(见 P2-9 |
| `README.md:131-135` “版本比较算法” | 仅字符串 `!=`(见 P1-5 |
| `README.md:35` “Go 1.25.1+”与徽章 | `go.mod:3``go 1.26.5` |
| `README.md:349-389` 的配置示例(`Service`/`Port`/`Gateway`/`MicroService.Registry: etcd://`/`APM` | `conf.MicroServiceConf``Registry` 字段,且 `etc/*.yaml``Log`/`APM` 段落(`D:\work\bsm-sdk\core\conf\types.go:23-31` |
| `README.md:382-389` 环境变量表(`SERVICE_ENV`/`CONFIG_FILE`/`LOG_LEVEL`/`TZ` | 实际使用的是 `BSM_RuntimeMode`/`BSM_Prefix`/`BSM_JwtSecretKey`/`BSM_Workspace``D:\work\bsm-sdk\core\env\env.go:18-28` |
| `README.md:291-318` Kubernetes YAML | 仓库内不存在 |
| `README.md:445-496` 仅给出 `initial_config`/`initial_apps`/`initial_country` 的 DDL | 缺 `initial_areas`/`initial_datas`;且与模型不一致(如 `initial_config.id SERIAL` vs GORM `id` 主键无 `autoIncrement` 声明) |
- **影响**:按 README 操作会连续失败(`make` 无目标、SQL 文件不存在、`/health` 404缓存与版本行为的错误描述会把排障引向错误方向例如相信「配置 10 分钟生效」)。
- **建议**:要么补齐 `Makefile`/`scripts/`/Docker 交付物,要么把 README 收敛为「仅描述接口与真实行为」并删除不存在的内容;表结构 DDL 以迁移脚本为唯一来源README 只链接;缓存 TTL、版本判定规则在 README 里按实现改写。
#### 20. `internal/` 内残留生成期占位与无用产物
- **位置**`internal/models/query.go:1``cmd/cli/main.go:1-10``internal/server/new.go:17`
- **证据**
```go
package models
```
```go
func main() {
log.Println("Hello Cli")
log.Println("Done!")
}
```
```go
grpcConns map[string]*grpc.ClientConn // 连接池
```
- **影响**`models/query.go` 为空文件、`cmd/cli` 是模板残留、`grpcConns` 声明了一个从未使用的「连接池」,会误导后续开发者以为存在跨服务调用能力。
- **建议**:删除或实现;若 `cmd/cli` 计划做运维工具,补齐子命令或在 README 标注「未实现」。
#### 21. `excode` 中的业务错误码从未使用,错误语义退化为通用 `ErrDB`/`ErrInvalidArgument`
- **位置**`internal/excode/ex.go:7-11` vs `internal/logic/check/*.go``internal/logic/data/*.go`
- **证据**:定义了 5 个有意义的错误(分别标识 app/os/arch/version/country 缺失),但业务代码统一返回通用错误:
```go
ErrInvalidApp = errcode.ErrNotFound(404, "app")
ErrInvalidOS = errcode.ErrNotFound(404, "os")
...
```
```go
return nil, errcode.ErrInvalidArgument
...
return nil, errcode.ErrDB
```
- **影响**客户端无法区分「参数错了」「app 不存在」「DB 故障」,只能统一按失败处理;`ErrDB` 把参数错误与基础设施故障混为一谈(例如 `Areas` 的列不存在错误会以 `ErrDB` 返回,掩盖了配置问题)。
- **建议**:按语义使用这些错误码(参数类用 `excode`,基础设施类用 `errcode.ErrDB`),并在 `Areas`/`Config` 的错误日志中保留原始 `err`
#### 22. 空结果被缓存为 `nil`,在 Redis 侧形成语义异常的值
- **位置**`internal/logic/data/areas_cache.go:37``internal/logic/data/country_cache.go:23``internal/logic/data/datas_cache.go:27`
- **证据**`Find` 未命中时切片保持 `nil`,仍被写入缓存:
```go
err = impl.RedisService.Set(cacheKey, areas, vars.DefaultTTL)
```
SDK 用 `json.Marshal(nil slice)``null` 存入 Redis读取时 `json.Unmarshal([]byte("null"), result)` 对**非 nil 指针**`&areas`)赋 `null` 实际会把切片置回 `nil` 而非报错(`D:\work\bsm-sdk\core\cache\redis\cache.go:55,42`)。
- **影响**`null` 值在 Redis 中可被人工或其它服务的 `SET` 覆写成任意 JSON若存在其它写该 key 的代码路径,即构成缓存投毒面),而读取侧不做任何结构校验;当前模块内没有该 key 的其它写入方,故未升级定级。
- **建议**:空结果使用显式的空数组(`make([]T, 0)`)与独立短 TTL缓存值加版本/来源前缀或校验字段,读取时校验类型与长度上限,避免被外部写入的异常 JSON 影响。
## 4. 推荐优化方案
按「先止血、再补护栏、最后重构」三层推进,每层都可独立验收。
**第一层(本周内,止血)**
1. `internal/logic/check/config_cache.go:21` 去掉字符串拼接:优先级排序改为 Go 侧归并(在 `config.go` 已有的 map 归并处按 SQL 返回顺序「首个胜出」),或使用 `clause.OrderByColumn` 仅作用于固定列名;同时给 `os` 加字符白名单。P1-1、P3-17 一并解决)
2. 四个 `*_cache.go` 统一错误处理模板DB 错误先 `return nil, err`,仅 DB 成功后才写缓存,`Set` 失败只 `printer.Warn`;空结果不写长 TTL。P1-2、P2-11、P3-22
3. `initial_areas` 与代码对齐:确认线上列是否存在,缺失则补列或改查询;把建表/seed SQL 收入 `scripts/` 并纳入 CI。P1-3
4. 明确并统一鉴权:`pkgs/all``Authorization.Anonymous` 与模块 `MicroService.Anonymous` 用同一份来源(或至少交叉校验),独立部署补 gRPC 拦截器。P1-4、P1-8
**第二层(两周内,补护栏)**
5. 引入语义化版本比较,落地三态判定与 UTC 时间输出;给 `initial_apps``(app,os,arch,pubdate)` 索引。P1-5、P2-14
6. 建立测试基线:`internal/logic/check``internal/logic/data` 的表驱动单测(版本比较、去重归并、缓存键构造、参数边界),缓存层用 miniredis/sqlmock`Areas` 增加集成用例(见第 5 节清单P1-6
7. 配置治理:拆分三套环境配置并加启动自检(生产模式检测 `CHANGE_ME`/`*_dev` 拒绝启动);补齐 `NotNil` 校验矩阵;关闭 SQL Debug、脱敏 DSN、生产 `sslmode>=require`;删除硬编码 JWT 默认密钥。P1-7、P2-13
8. 缓存与数据库并发护栏:为热点键加 singleflightRedis 故障时降级到 `MemorySerice`(此时该字段才有真实用途),区分 `redis.Nil` 与连接错误;`Updates` 增加短 TTL 缓存。P2-9、P2-10、P2-14
**第三层(一个月内,可维护性)**
9. 可观测性:结构化日志(`request_id`/method/耗时/结果码),`ctx` 贯穿 DB/Redis`Hello` 改为真实 readiness 探针,配置 `Log` 段落并在生产用 INFO。P2-12、P3-18
10. 清债:删除 `excode`(或按语义使用)、`models/query.go``cmd/cli``grpcConns``Dependencies.Cache` 死字段;`Areas` 默认国家常量与缓存键命名规范化;`Config` 响应按 key 排序。P3-15、P3-16、P3-17、P3-20、P3-21
11. 文档重写README 只描述已实现行为接口、TTL、版本规则、真实 env 变量不存在交付物要么补齐要么删除表结构以迁移脚本为唯一来源。P3-19
## 5. TODO 清单
- [ ] **P1-1** 消除 `Order` 中的字符串拼接,`os` 走白名单或改 Go 侧排序|验收:提交 `os``'``--``;` 的请求不会再进入 SQL 文本(通过对 `impl.DBService` 的语句记录断言),且命中/未命中缓存两条路径均返回参数错误|涉及:`module/base/initial/internal/logic/check/config_cache.go:21``module/base/initial/internal/logic/check/config.go:15`
- [ ] **P1-2** 修复 `Areas` 缓存覆盖 DB 错误并缓存空值验收mock DB 返回 error 时 `GetAreasByCache` 返回非 nil errorRedis 中**不出现**该 key恢复 DB 后下一次请求能取到数据|涉及:`module/base/initial/internal/logic/data/areas_cache.go:33-38`
- [ ] **P1-3** `initial_areas` 列与代码对齐并入库 seed SQL验收`enabled`/`show_town`/`sort_order` 三列在模型与建表脚本中同时存在,`Areas(CountryId=1, ShowTown=false)` 在集成环境返回非空且元素字段完整|涉及:`module/base/initial/internal/models/initial_areas.go:7-18``module/base/initial/internal/logic/data/areas_cache.go:33-34`
- [ ] **P1-4** 统一两种部署形态的鉴权白名单|验收:`pkgs/all/etc/default_dev.yaml``Authorization.Anonymous` 与模块匿名表一致;未带 JWT 调 `/initial.Check/Config` 在聚合模式下返回预期结果(公开则 200私有则明确的 401独立模式下私有接口必须带凭据涉及`module/base/initial/etc/initial_prod.yaml:15-21``pkgs/all/etc/default_dev.yaml:24-43`
- [ ] **P1-5** 实现语义化版本比较与 UTC 时间输出|验收:表驱动用例覆盖「客户端更新/相同/更旧/预发布/大小写/空白/`pubdate` 缺失/同 pubdate 多行」8 类,均返回预期 `Version``Summary` 是否为空|涉及:`module/base/initial/internal/logic/check/updates.go:23-48`
- [ ] **P1-6** 建立可在 CI 运行的最小测试集|验收:`go test ./...` 通过且覆盖版本比较、去重归并、缓存键构造、缓存失效DB error 不缓存)、`Areas` 参数边界CI 中执行且失败即阻断|涉及:`module/base/initial/internal/logic/check/``module/base/initial/internal/logic/data/`
- [ ] **P1-7** 三套环境配置分离并加启动自检验收prod 配置不再指向 `rst_dev`;生产模式检测到 `password=CHANGE_ME``dbname``_dev` 时进程启动失败并给出明确原因|涉及:`module/base/initial/etc/initial_prod.yaml:7``module/base/initial/internal/config/config.go:36`
- [ ] **P1-8** `Config` 增加调用方与 `app` 归属校验及参数白名单|验收:越权读取未授权 `app` 的请求被拒绝;`app` 超长/异常字符被拒;按调用方限流生效|涉及:`module/base/initial/internal/logic/check/config.go:15``module/base/initial/internal/logic/check/config_cache.go:13`
- [ ] **P2-9** 明确并文档化各类数据 TTL`version` 纳入失效判定验收README 的 TTL 表与 `vars` 常量一一对应;配置 `version` 变化后客户端能在约定时间内看到新值|涉及:`module/base/initial/internal/logic/check/config_cache.go:27``module/base/initial/README.md:324-329`
- [ ] **P2-10** 区分缓存未命中与 Redis 故障并加降级|验收:断开 Redis 后接口仍可用(走内存兜底或上一份成功值)且日志有明确告警;不再出现全量直穿 DB涉及`module/base/initial/internal/logic/check/config_cache.go:15-18`
- [ ] **P2-11** 统一 DB 错误处理并清理死代码|验收:`grep "err = impl.RedisService.Set"` 后不再存在覆盖 DB 错误的模式;`gorm.ErrRecordNotFound` 死判断、`MemorySerice``excode``models/query.go``cmd/cli``grpcConns` 有明确取舍|涉及:`module/base/initial/internal/logic/data/country_cache.go:19-24``module/base/initial/internal/logic/data/datas_cache.go:22-28``module/base/initial/internal/impl/impl.go:16`
- [ ] **P2-12** 结构化日志 + ctx 贯穿 + readiness 探针|验收:一次失败请求能在日志中用 `request_id` 串起入口与 DB/Redis客户端取消后 DB 查询随之取消DB/Redis 不可用时探针返回非健康|涉及:`module/base/initial/internal/logic/check/hello.go:26-31``module/base/initial/internal/config/config.go:33`
- [ ] **P2-13** 配置与密钥加固|验收:缺失 `BSM_JwtSecretKey` 时启动失败而非使用默认值;启动日志中 DSN 已脱敏;生产 `sslmode>=require``NotNil` 覆盖 DB/Gateway/Etcd 必填项|涉及:`module/base/initial/internal/config/config.go:36-41``module/base/initial/etc/initial_prod.yaml:7`
- [ ] **P2-14** `Updates` 加缓存与索引,`Areas` 加分页/上限|验收:`EXPLAIN` 显示 `initial_apps``(app,os,arch,pubdate)` 索引;`Updates` 命中缓存时无 SQL`Areas` 大国家响应有体积上限或分页参数|涉及:`module/base/initial/internal/logic/check/updates.go:23-25``module/base/initial/internal/logic/data/areas_cache.go:33-35`
- [ ] **P3-15** 抽出默认国家常量并规范化缓存键命名|验收:代码中不再出现裸 `1` 作为国家主键;缓存键形如 `bsm:areas:cc:CN:town:0`cache 层对空条件返回参数错误|涉及:`module/base/initial/internal/logic/data/areas.go:15-17``module/base/initial/internal/logic/data/areas_cache.go:18-26`
- [ ] **P3-16** 统一 nil/参数校验风格|验收:全部 logic 入口对 nil 与字段长度/字符做一致校验,缺失时返回 `ErrInvalidArgument` 类错误而非 panic涉及`module/base/initial/internal/logic/data/areas.go:15``module/base/initial/internal/logic/check/config.go:15`
- [ ] **P3-17** `Config` 响应顺序确定化|验收:同一数据连续两次调用返回完全相同的顺序(按 `key` 排序),且同 key 同 os 多行时稳定返回 `id` 最大的一条|涉及:`module/base/initial/internal/logic/check/config.go:33-50`
- [ ] **P3-18** 反射与健康检查接口收敛|验收:非 dev 环境不暴露 gRPC reflectionreadiness 能反映 DB/Redis 状态|涉及:`module/base/initial/internal/server/new.go:36-38``module/base/initial/internal/logic/check/hello.go:12-31`
- [ ] **P3-19** README 与仓库对齐验收README 中每个命令/路径/配置项都能在仓库中找到对应物或已被删除TTL 表、版本规则、env 变量与实现一致|涉及:`module/base/initial/README.md:47,64,93,131-135,324-329,382-389`
- [ ] **P3-20** 清理生成期占位代码|验收:`models/query.go``cmd/cli/main.go``grpcConns` 删除或有真实实现与文档|涉及:`module/base/initial/internal/models/query.go:1``module/base/initial/internal/server/new.go:17`
- [ ] **P3-21** 错误码语义化|验收:参数类错误使用 `excode` 中的对应错误码,基础设施类使用 `ErrDB`客户端可区分「参数错误」「app 不存在」「DB 故障」|涉及:`module/base/initial/internal/excode/ex.go:7-11`
- [ ] **P3-22** 缓存空值与投毒防护|验收:空结果写入显式空数组 + 短 TTL读取缓存值做类型/长度校验,异常值被丢弃并回源|涉及:`module/base/initial/internal/logic/data/areas_cache.go:37`
**关键缺失测试用例清单P1-6 的验收输入)**
| 用例 | 期望 |
| --- | --- |
| `Updates`:客户端 2.0.0 / 最新 1.9.0 | 不提示更新(当前实现误判为有更新) |
| `Updates`:客户端 `1.2.3` / 最新 `1.2.3-beta.2` | 不提示更新(无 prerelease 规则,当前误判) |
| `Updates`:客户端 `V1.2.3` / 最新 `1.2.3` | 视为相同,不提示更新(当前误判) |
| `Updates`:两条记录 `pubdate` 相同、`Version` 不同 | 稳定返回最高版本(当前依赖 id |
| `Updates``pubdate` 为零值 | 不 panic`pubdate` 字段输出可控(当前输出 `0001-01-01` |
| `Updates`:无任何记录 | 返回 `Identity/Version = "0"` 且无错误 |
| 缓存DB 首次查询返回 error | `Get*ByCache` 返回 errorRedis 无该 key当前静默缓存空值 |
| 缓存Redis 正常但 key 不存在 | 回源 DB 后写入缓存,第二次调用不再打 DB |
| 缓存Redis 连接失败 | 有降级/告警,不产生每请求打 DB 的放大 |
| 缓存:空结果 | 使用短 TTL 空值标记,不写 30 分钟长 TTL |
| `Areas``country_code="CN"``country_id=1` | 返回同一集合(一致性) |
| `Areas``country_code` 不存在 | 返回空且无错误(或明确的 not found不返回全量 |
| `Areas``show_town=true` | 含乡镇层级,且响应体积在上限内 |
| `Areas``country_code=""``country_id=0` | 默认中国或参数错误,绝不返回所有国家混合数据 |
| `Config`:同一 key 同时存在 `os=ALL``os=linux` | 返回 `linux` 那条,且顺序稳定 |
| `Config``os``'``;``--`、超长字符串 | 参数错误SQL 文本中不出现该值 |
| `Config`:同一 key 同 os 多行id 递增) | 稳定返回 id 最大者 |
## 6. 审计摘要(供汇总使用)
- 问题数P0=0 P1=8 P2=6 P3=8合计 22
- 最高风险(一句话):`Config` 接口把未认证调用方可控的 `os` 直接拼进 `ORDER BY` 形成 SQL 注入面,同时独立部署下全部 6 个初始配置/字典接口无任何鉴权与限流,可直接枚举读取任意应用的配置键值。
- 最优先 3 个动作:
1. 修掉 `internal/logic/check/config_cache.go:21``Order` 字符串拼接(改为 Go 侧归并 + `os` 白名单),并统一两套部署的鉴权白名单(`etc/initial_prod.yaml:15-21` vs `pkgs/all/etc/default_dev.yaml:24-43`)。
2. 修复 `internal/logic/data/areas_cache.go:33-38` 覆盖 DB 错误并缓存空值的缺陷(四个 `*_cache.go` 同模板处理),同时与库表对齐 `enabled`/`show_town`/`sort_order` 并把 seed SQL 纳入仓库。
3. 用语义化版本比较替换 `internal/logic/check/updates.go:39` 的字符串 `!=`,并落地第 5 节「关键缺失测试用例清单」中的版本比较与缓存失效用例,让 CI 能拦住上述回归。
- 未能覆盖/无法验证的部分:
- **真实数据库 schema 与数据**:仓库内无任何 `.sql`/seed`full/``**/*.sql` 无匹配),`initial_areas` 是否真的缺 `enabled`/`show_town`/`sort_order` 只能从模型与文档反推,故 P1-3 中「查询必然失败」标注为推测。
- **`initial_config.value` 的实际内容**:无法判断是否真的存放第三方密钥/内网地址,故 P1-8 的「凭据泄露」标注为推测;「无归属校验、可枚举读取」是确证的。
- **`pkgs/all` 的线上 whitelist 与部署形态**:只审计了仓库中的 `pkgs/all/etc/default_dev.yaml`,未发现 prod/test 版本P1-4 中「聚合部署下客户端取不到初始配置」基于该 dev 配置,实际生产白名单可能不同。
- **pb 生成代码未逐行审计**(仅阅读路由注册与 `pattern_*`),未发现生成代码层面的问题。
- **未做动态验证**:无可用数据库/Redis 实例,未运行服务、未执行注入验证与并发压测;`go vet`/`gofmt` 通过不代表运行时行为正确。
- **聚合模式与生态依赖**`pkgs/ecmall` 等其它调用方的行为只读了 `pkgs/ecmall/AGENT.md` 的相关章节,未展开审计。