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.
58 KiB
审计报告: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.2clause/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) - 证据:
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):
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 - 证据:
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):
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-34vsmodule/base/initial/internal/models/initial_areas.go:7-18 - 证据:
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):
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):
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:
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 - 证据:
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 行」+「!= 比较」两步。由此产生的具体缺陷:
- 降级误判:客户端 2.0.0,库中最新发布为 1.9.0 ⇒
!=成立 ⇒ 返回「有新版本」并要求升级到更旧版本。没有任何client > latest的分支。 - 预发布:客户端
1.2.3-beta.1,库中1.2.3⇒ 判定需升级(语义上正确);但库中最新为1.2.3-beta.2时,正式版客户端1.2.3也被判定需「升级」到 beta ——无 prerelease 优先级规则。 - 大小写/空白不归一:
V1.2.3、1.2.3与1.2.3全部判为「有新版本」,客户端会无限收到更新提示(若更新后版本串仍不匹配,形成更新循环)。 - 数据驱动的最新版本不可靠:
pubdate为可空时间列(models/initial_apps.go:19Pubdate time.Time无 default/not null),漏填或补录旧版本会把旧行排到最前;First只有pubdate DESC, id DESC,同一pubdate下结果依赖 id,不保证与客户端能接受的最高版本一致。 pubdate时区:.Format("2006-01-02 15:04:05")(第 46 行)直接输出 Gotime.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 - 证据:
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内容一致) - 证据:
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),随后原样进入查询与缓存键:
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):
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 的appclaim 与请求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 常量:
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 同构) - 证据:
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):
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 - 证据:
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
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 行)。死代码/未使用项:
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):
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。健康检查是常量返回:
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 - 证据:
conf.NotNil(Spec.Service, Spec.Cache)
encipher.New(env.Runtime.JwtSecretKey)
只校验 Service 与 Cache:Databases、Gateway、MicroService 全都不校验,而下一行就把可能为 nil 的配置直接交给初始化器(impl.go:26,28):
DBService = with.Databases(config.Spec.Databases, nil)
EtcdService = with.Etcd(config.Spec.Etcd)
未设置 BSM_JwtSecretKey 时会静默采用硬编码默认值:
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 - 证据:
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:
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 - 证据:
if in.GetCountryCode() == "" && in.GetCountryId() == 0 {
in.CountryId = 1 // 默认中国
}
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):
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 构造响应:
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 - 证据:
if standalone {
reflection.Register(srv.Grpc)
}
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 文件不存在、/health404);缓存与版本行为的错误描述会把排障引向错误方向(例如相信「配置 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 - 证据:
package models
func main() {
log.Println("Hello Cli")
log.Println("Done!")
}
grpcConns map[string]*grpc.ClientConn // 连接池
- 影响:
models/query.go为空文件、cmd/cli是模板残留、grpcConns声明了一个从未使用的「连接池」,会误导后续开发者以为存在跨服务调用能力。 - 建议:删除或实现;若
cmd/cli计划做运维工具,补齐子命令或在 README 标注「未实现」。
21. excode 中的业务错误码从未使用,错误语义退化为通用 ErrDB/ErrInvalidArgument
- 位置:
internal/excode/ex.go:7-11vsinternal/logic/check/*.go、internal/logic/data/*.go - 证据:定义了 5 个有意义的错误(分别标识 app/os/arch/version/country 缺失),但业务代码统一返回通用错误:
ErrInvalidApp = errcode.ErrNotFound(404, "app")
ErrInvalidOS = errcode.ErrNotFound(404, "os")
...
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,仍被写入缓存:
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. 推荐优化方案
按「先止血、再补护栏、最后重构」三层推进,每层都可独立验收。
第一层(本周内,止血)
internal/logic/check/config_cache.go:21去掉字符串拼接:优先级排序改为 Go 侧归并(在config.go已有的 map 归并处按 SQL 返回顺序「首个胜出」),或使用clause.OrderByColumn仅作用于固定列名;同时给os加字符白名单。(P1-1、P3-17 一并解决)- 四个
*_cache.go统一错误处理模板:DB 错误先return nil, err,仅 DB 成功后才写缓存,Set失败只printer.Warn;空结果不写长 TTL。(P1-2、P2-11、P3-22) initial_areas与代码对齐:确认线上列是否存在,缺失则补列或改查询;把建表/seed SQL 收入scripts/并纳入 CI。(P1-3)- 明确并统一鉴权:
pkgs/all的Authorization.Anonymous与模块MicroService.Anonymous用同一份来源(或至少交叉校验),独立部署补 gRPC 拦截器。(P1-4、P1-8)
第二层(两周内,补护栏)
- 引入语义化版本比较,落地三态判定与 UTC 时间输出;给
initial_apps加(app,os,arch,pubdate)索引。(P1-5、P2-14) - 建立测试基线:
internal/logic/check与internal/logic/data的表驱动单测(版本比较、去重归并、缓存键构造、参数边界),缓存层用 miniredis/sqlmock,Areas增加集成用例(见第 5 节清单)。(P1-6) - 配置治理:拆分三套环境配置并加启动自检(生产模式检测
CHANGE_ME/*_dev拒绝启动);补齐NotNil校验矩阵;关闭 SQL Debug、脱敏 DSN、生产sslmode>=require;删除硬编码 JWT 默认密钥。(P1-7、P2-13) - 缓存与数据库并发护栏:为热点键加 singleflight,Redis 故障时降级到
MemorySerice(此时该字段才有真实用途),区分redis.Nil与连接错误;Updates增加短 TTL 缓存。(P2-9、P2-10、P2-14)
第三层(一个月内,可维护性)
- 可观测性:结构化日志(
request_id/method/耗时/结果码),ctx贯穿 DB/Redis,Hello改为真实 readiness 探针,配置Log段落并在生产用 INFO。(P2-12、P3-18) - 清债:删除
excode(或按语义使用)、models/query.go、cmd/cli、grpcConns、Dependencies.Cache死字段;Areas默认国家常量与缓存键命名规范化;Config响应按 key 排序。(P3-15、P3-16、P3-17、P3-20、P3-21) - 文档重写: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 个动作:
- 修掉
internal/logic/check/config_cache.go:21的Order字符串拼接(改为 Go 侧归并 +os白名单),并统一两套部署的鉴权白名单(etc/initial_prod.yaml:15-21vspkgs/all/etc/default_dev.yaml:24-43)。 - 修复
internal/logic/data/areas_cache.go:33-38覆盖 DB 错误并缓存空值的缺陷(四个*_cache.go同模板处理),同时与库表对齐enabled/show_town/sort_order并把 seed SQL 纳入仓库。 - 用语义化版本比较替换
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的相关章节,未展开审计。
- 真实数据库 schema 与数据:仓库内无任何