# 审计报告: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-gateway(HTTP 转 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 handler,gRPC 拦截器对其无效(`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::` 键。 - **影响**:不同应用的配置属于不同租户/产品边界,`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 与 Redis(DB 查询用 `impl.DBService` 无 `.WithContext(ctx)`,Redis 用客户端自持的 `context.Background()`,`D:\work\bsm-sdk\core\cache\redis\redis.go:28,74`)。 - **影响**:线上排障时看不到「哪个请求、哪个参数、哪条 SQL」失败,`printer` 的 ANSI 颜色码还会污染文件日志;`Hello` 永远返回 OK,DB/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. 缓存与数据库并发护栏:为热点键加 singleflight,Redis 故障时降级到 `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 error,Redis 中**不出现**该 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 reflection;readiness 能反映 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` 返回 error,Redis 无该 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` 的相关章节,未展开审计。