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

58 KiB
Raw Blame History

审计报告module/base/initial

1. 模块概览

module/base/initial 是 BSM 平台的客户端初始化与基础字典服务,为「客户端启动 -> 拉配置 -> 查版本 -> 取国家/行政区划/字典」这一条链路提供能力。绝对路径 D:\work\bsm-infra\full\module\base\initial,模块名 bsm/full/module/base/initialgo.mod:1Go 1.26.5git.apinb.com/bsm-sdk/core 通过 replace 指向本地 D:\work\bsm-sdk\corego.mod:72)。

项目 内容
服务形态 gRPC + gRPC-gatewayHTTP 转 gRPC
端口 gRPC 12101 / Gateway 12102etc/initial_dev.yaml:2,26README.md:171-187
两个 proto 服务 CheckHello/Config/UpdatesDataCountry/Areas/Datas
数据表 initial_configinitial_appsinitial_countryinitial_areasinitial_datasinternal/models/*.go
缓存 Redisinternal/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.gotest/grpc/main.go 是打线上地址的手工脚本

关键调用链(cmd/main/main.go:18-43config.New("Initial")impl.NewImpl()(建 Redis/DB/Etcdserver.New(nil) 注册两个 gRPC 服务 → service.New(...).Start()无任何 gRPC 拦截器)。聚合模式下改由 service.Exposeservice/expose.go:18-29)把 pkgs/all 的 DB/Redis 注入到 impl 包变量,并用 gin + grpc-gateway + JWT 中间件pkgs/all 服务器暴露同样的 handler。

2. 审计范围与方法

已完整读取(逐行)

  • 全部非 pb 生产代码:internal/config/config.gointernal/excode/ex.gointernal/impl/impl.gointernal/logic/check/{hello,config,config_cache,updates}.gointernal/logic/data/{country,country_cache,areas,areas_cache,datas,datas_cache}.gointernal/models/{query,initial_apps,initial_areas,initial_config,initial_country,initial_datas}.gointernal/server/{new,check_server,data_server}.goservice/{expose,dependencies}.gocmd/main/main.gocmd/cli/main.gotest/grpc/main.go
  • 配置:etc/initial_{dev,test,prod}.yamletc/supervisor.bsm-apps-initial.confgo.mod
  • 接口定义:proto/{check,data,const}.proto;路由注册阅读了 pb/*.pb.gw.gopattern_* / 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.gostatement.gochainable_api.go
  • 部署上下文:pkgs/all/internal/server/{server,authorization}.gopkgs/all/internal/config/config.gopkgs/all/cmd/main/main.gopkgs/all/etc/default_dev.yamlpkgs/all/internal/service/initial.gopkgs/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 **/*.sqlfull/ 下无结果),表结构只能从 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.Osproto/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:29statement.go:86-92raw==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。
  • 建议:改为参数化/白名单——把优先级排序挪出 SQLCASE 用绑定变量: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,而 errSet 的结果(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-24datas_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
  • 证据
err = impl.DBService.Where("enabled = ?", true).Where(key, val).Where("show_town = ?", in.ShowTown).
    Order("sort_order ASC, name ASC").

模型只有 10 列,没有 enabledshow_townsort_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-49README.md:147-156)完全由一列数据决定,无从校验。若列实际存在(例如由仓库外 SQL 建出),则模型与库结构长期脱钩、AutoMigrate 一旦被打开会按模型删列/加列,存在数据损坏风险。pkgs/ecmall/AGENT.md:479 也把该不一致记为已知缺口。标注:本仓库无法验证线上真实列,「必然失败」为推测,但「代码与模型/文档不一致」是确证的。
  • 建议:把 Enabled boolShowTown boolSortOrder int32 正式加入 InitialAreas 模型(并同步 TableName 与索引),或把过滤/排序改为模型内已有字段;把建表与 seed SQL 纳入仓库(scripts/)并在 CI 校验;为 Areas 增加一条「已知国家存在数据时必须非空」的集成测试(见 P1-7

4. 鉴权在两种部署形态下不一致:独立部署 6 个接口全匿名,聚合部署 initial.* 全部落在白名单之外

  • 位置module/base/initial/etc/initial_dev.yaml:15-21test/prod 同)、module/base/initial/cmd/main/main.go:20pkgs/all/etc/default_dev.yaml:21-43
  • 证据:本模块把全部方法登记为匿名,但该列表在两种形态下都不生效——独立部署 MicroService.Enable: falseetc/initial_prod.yaml:14service.Start 只在 MsConf.Enable 为真时才把匿名表写进 etcdD:\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: trueinitial_prod.yaml:24-26Config/Updates/Areas/Country/Datas/Hello 全部无需任何凭据,也没有限流。

聚合形态下白名单是另一套机制且不含 initialpkgs/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-62Services 明确启用了 initialregisterXHandlerServer 是 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
  • 证据
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.31.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/Shanghaietc/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.gotest/ 只是指向线上地址的手工脚本

  • 位置:模块内 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:1test/http/data.country.http:1test/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.yamlinitial_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_devhost 都是 127.0.0.1MicroService.Enable 都是 falseGateway.Enable 都是 true)。

  • 影响:把 initial_prod.yaml 当作生产配置直接部署,会把生产流量打进名为 rst_dev 的开发库(数据污染与相互覆盖),或因为本机没有该库而启动即 panicwith.Databases 在连接失败时 panicD:\work\bsm-sdk\core\with\databases.go:24sslmode=disable 使数据库凭据与配置内容在链路上明文;MicroService.Enable: false 使该服务不进 etcd,聚合/网关注册发现链路拿不到它(与 P1-4 的匿名表失效是同一根因)。
  • 建议生产配置改由部署系统在启动时注入env/configmap仓库内只保留 .example 模板并显式标注「禁止直用」;sslmode 在生产设为 require/verify-full;三个环境至少要让 dbname/host/sslmode/MicroService.Enable 明显可区分,并加一条启动自检:prod 模式检测到 CHANGE_MEdbname=*_dev 时拒绝启动并 exit 1

8. Config 无任何调用方身份校验,可枚举读取任意应用配置;配合缓存键膨胀可放大 DB 压力

  • 位置module/base/initial/internal/logic/check/config.go:13-30internal/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 查询 + 一次 SetTTL 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:27data/{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.MinuteD:\work\bsm-sdk\core\vars\cache.go:16)。而 README 的性能表宣称「配置数据 10 分钟 / 国家数据 24 小时 / 地区数据 12 小时 / 系统数据 6 小时」,四类没有任何一类与实现一致。同时 ConfigItem 携带 versionproto/check.proto:49InitialConfig.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-18country/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 反序列化失败)时,所有缓存读都变成 missCountry/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-24internal/logic/data/datas_cache.go:22-28internal/impl/impl.go:16,22internal/excode/ex.go:7-11internal/models/query.go:1cmd/cli/main.go:8-9internal/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,22service/dependencies.go:30 的赋值),无任何读点;excode 包 5 个错误变量(ErrInvalidApp/ErrInvalidOS/ErrInvalidArch/ErrInvalidVersion/ErrInvalidCountry)无引用;models/query.go 全文只有 package models 一行;cmd/cli/main.go 只打印两行字符串;Server.grpcConnsnew.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/*/*.go4 处 printer.Error)、internal/logic/check/hello.go:12-31internal/config/config.go:33internal/server/new.go:27
  • 证据:错误只经 printer.Error 输出到 stdout不带请求上下文、不带结构化字段且该函数固定写 stdoutD:\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: trueD:\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 与 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) 与 RedisRedisClientGetCtx/SetCtx 或按请求传 ctxHello 至少 Ping 一次 DB 与 Redis 并按结果返回非 0 code或拆出独立的 readiness 探针);为 initial_* 配置补齐 Log 段落,生产用 INFO

13. 配置与密钥:硬编码默认 JWT 密钥、生产库禁用 SSL、配置校验只覆盖两项

  • 位置module/base/initial/internal/config/config.go:36-41internal/impl/impl.go:26etc/initial_prod.yaml:7D:\work\bsm-sdk\core\env\env.go:19
  • 证据
conf.NotNil(Spec.Service, Spec.Cache)
encipher.New(env.Runtime.JwtSecretKey)

只校验 ServiceCacheDatabasesGatewayMicroService 全都不校验,而下一行就把可能为 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=disableinitial_prod.yaml:7),且该 DSN 会经由 with.Databases 打印出来(D:\work\bsm-sdk\core\with\databases.go:18 printer.Info("... Databases: %v", cfg)),包含账号密码。另外 SQL 层默认 Debug: trueD:\work\bsm-sdk\core\database\sql\postgresql.go:19,46-48),会把每条 SQL 及其参数值打到标准输出——initial_config.value 的内容会因此进入日志。

  • 影响(a) 默认 JWT 密钥是公开常量,任何忘记注入 env 的部署都可被伪造 token在聚合形态下这直接等价于绕过鉴权(b) 凭据明文传输 + 启动日志打印 DSN凭据进入日志系统(c) 配置缺项不会在启动时报错,而是在首个请求上 panicdatas_cache.go:22 之类会 nil pointer)或直接 panic 退出(with.Databases 对 nil 配置 panic("No Database Source Found !"))。
  • 建议:删除硬编码默认密钥并要求启动时强制提供(缺失即 exit 1encipher.New 失败要有明确错误路径DSN 打印前脱敏;生产 sslmode 强制 require 以上;把 NotNil 扩展到 Databases.DriverDatabases.SourceGateway.Enable 时的 Gateway.PortMicroService.Enable 时的 Etcd.Endpoints;关闭 SQL 的 Debug 或仅在 dev 打开。

14. Areas 无分页无上限,Updates 无缓存无索引:两个可预期的性能瓶颈

  • 位置internal/logic/data/areas_cache.go:33-35internal/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-50AreasRequest 只有 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_IICUDSid 主键与 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-17internal/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 层时;以及未来新增调用方时)valnilSQL 退化为不带国家过滤的 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:15internal/logic/check/updates.go:16internal/logic/data/areas.go:15internal/logic/data/{country.go:11,datas.go:12}
  • 证据Config/Updates 检查 in == nilAreasCountry/Datas 完全不检查而直接调用方法(in.GetCountryCode()、未使用 in
func Areas(ctx context.Context, in *pb.AreasRequest) (reply *pb.AreasReply, err error) {
    if in.GetCountryCode() == "" && in.GetCountryId() == 0 {

pb.AreasRequestGetXxx 生成方法本身对 nil receiver 安全(pb/data.pb.go 生成体),但 areas.go:16in.CountryId = 1 是直接字段写入nil 时会 panic。

  • 影响:行为不一致,未来若增加内部调用方(当前 gRPC handler 总是传入非 nilpb/data.pb.gw.go:88nil 输入会在 areas.go:16 直接 panicUpdatesin.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 化之后完全失效——多条同 keyos 的记录中哪一条胜出取决于 map 迭代顺序,而 SQL 里 id DESC(最新优先)的本意被丢弃。

  • 影响:响应不稳定,无法用响应对账/缓存比对;「同 key 同 os 多行取最新」的意图未实现,可能返回过期配置行。
  • 建议:按 configs 的有序切片归并(保持 SQL 顺序,首个胜出即退出该 key响应按 key 排序输出;key 建议在库层加 (app, os, key) 唯一约束以消除歧义;无配置时返回空切片并保持 200当前已是空切片但建议在注释/文档中明确。

18. 独立部署暴露 gRPC 反射,且健康检查不反映真实依赖状态

  • 位置internal/server/new.go:36-38internal/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,57make 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 模块未接入 APMservice.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:3go 1.26.5
README.md:349-389 的配置示例(Service/Port/Gateway/MicroService.Registry: etcd:///APM conf.MicroServiceConfRegistry 字段,且 etc/*.yamlLog/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_WorkspaceD:\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:1cmd/cli/main.go:1-10internal/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-11 vs internal/logic/check/*.gointernal/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:37internal/logic/data/country_cache.go:23internal/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. 推荐优化方案

按「先止血、再补护栏、最后重构」三层推进,每层都可独立验收。

第一层(本周内,止血)

  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/allAuthorization.Anonymous 与模块 MicroService.Anonymous 用同一份来源(或至少交叉校验),独立部署补 gRPC 拦截器。P1-4、P1-8

第二层(两周内,补护栏)

  1. 引入语义化版本比较,落地三态判定与 UTC 时间输出;给 initial_apps(app,os,arch,pubdate) 索引。P1-5、P2-14
  2. 建立测试基线:internal/logic/checkinternal/logic/data 的表驱动单测(版本比较、去重归并、缓存键构造、参数边界),缓存层用 miniredis/sqlmockAreas 增加集成用例(见第 5 节清单P1-6
  3. 配置治理:拆分三套环境配置并加启动自检(生产模式检测 CHANGE_ME/*_dev 拒绝启动);补齐 NotNil 校验矩阵;关闭 SQL Debug、脱敏 DSN、生产 sslmode>=require;删除硬编码 JWT 默认密钥。P1-7、P2-13
  4. 缓存与数据库并发护栏:为热点键加 singleflightRedis 故障时降级到 MemorySerice(此时该字段才有真实用途),区分 redis.Nil 与连接错误;Updates 增加短 TTL 缓存。P2-9、P2-10、P2-14

第三层(一个月内,可维护性)

  1. 可观测性:结构化日志(request_id/method/耗时/结果码),ctx 贯穿 DB/RedisHello 改为真实 readiness 探针,配置 Log 段落并在生产用 INFO。P2-12、P3-18
  2. 清债:删除 excode(或按语义使用)、models/query.gocmd/cligrpcConnsDependencies.Cache 死字段;Areas 默认国家常量与缓存键命名规范化;Config 响应按 key 排序。P3-15、P3-16、P3-17、P3-20、P3-21
  3. 文档重写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:21module/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-18module/base/initial/internal/logic/data/areas_cache.go:33-34
  • P1-4 统一两种部署形态的鉴权白名单|验收:pkgs/all/etc/default_dev.yamlAuthorization.Anonymous 与模块匿名表一致;未带 JWT 调 /initial.Check/Config 在聚合模式下返回预期结果(公开则 200私有则明确的 401独立模式下私有接口必须带凭据涉及module/base/initial/etc/initial_prod.yaml:15-21pkgs/all/etc/default_dev.yaml:24-43
  • P1-5 实现语义化版本比较与 UTC 时间输出|验收:表驱动用例覆盖「客户端更新/相同/更旧/预发布/大小写/空白/pubdate 缺失/同 pubdate 多行」8 类,均返回预期 VersionSummary 是否为空|涉及: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_MEdbname_dev 时进程启动失败并给出明确原因|涉及:module/base/initial/etc/initial_prod.yaml:7module/base/initial/internal/config/config.go:36
  • P1-8 Config 增加调用方与 app 归属校验及参数白名单|验收:越权读取未授权 app 的请求被拒绝;app 超长/异常字符被拒;按调用方限流生效|涉及:module/base/initial/internal/logic/check/config.go:15module/base/initial/internal/logic/check/config_cache.go:13
  • P2-9 明确并文档化各类数据 TTLversion 纳入失效判定验收README 的 TTL 表与 vars 常量一一对应;配置 version 变化后客户端能在约定时间内看到新值|涉及:module/base/initial/internal/logic/check/config_cache.go:27module/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 死判断、MemorySericeexcodemodels/query.gocmd/cligrpcConns 有明确取舍|涉及:module/base/initial/internal/logic/data/country_cache.go:19-24module/base/initial/internal/logic/data/datas_cache.go:22-28module/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-31module/base/initial/internal/config/config.go:33
  • P2-13 配置与密钥加固|验收:缺失 BSM_JwtSecretKey 时启动失败而非使用默认值;启动日志中 DSN 已脱敏;生产 sslmode>=requireNotNil 覆盖 DB/Gateway/Etcd 必填项|涉及:module/base/initial/internal/config/config.go:36-41module/base/initial/etc/initial_prod.yaml:7
  • P2-14 Updates 加缓存与索引,Areas 加分页/上限|验收:EXPLAIN 显示 initial_apps(app,os,arch,pubdate) 索引;Updates 命中缓存时无 SQLAreas 大国家响应有体积上限或分页参数|涉及:module/base/initial/internal/logic/check/updates.go:23-25module/base/initial/internal/logic/data/areas_cache.go:33-35
  • P3-15 抽出默认国家常量并规范化缓存键命名|验收:代码中不再出现裸 1 作为国家主键;缓存键形如 bsm:areas:cc:CN:town:0cache 层对空条件返回参数错误|涉及:module/base/initial/internal/logic/data/areas.go:15-17module/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:15module/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-38module/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.gocmd/cli/main.gogrpcConns 删除或有真实实现与文档|涉及:module/base/initial/internal/models/query.go:1module/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
Updatespubdate 为零值 不 panicpubdate 字段输出可控(当前输出 0001-01-01
Updates:无任何记录 返回 Identity/Version = "0" 且无错误
缓存DB 首次查询返回 error Get*ByCache 返回 errorRedis 无该 key当前静默缓存空值
缓存Redis 正常但 key 不存在 回源 DB 后写入缓存,第二次调用不再打 DB
缓存Redis 连接失败 有降级/告警,不产生每请求打 DB 的放大
缓存:空结果 使用短 TTL 空值标记,不写 30 分钟长 TTL
Areascountry_code="CN"country_id=1 返回同一集合(一致性)
Areascountry_code 不存在 返回空且无错误(或明确的 not found不返回全量
Areasshow_town=true 含乡镇层级,且响应体积在上限内
Areascountry_code=""country_id=0 默认中国或参数错误,绝不返回所有国家混合数据
Config:同一 key 同时存在 os=ALLos=linux 返回 linux 那条,且顺序稳定
Configos';--、超长字符串 参数错误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:21Order 字符串拼接(改为 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/seedfull/**/*.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 的相关章节,未展开审计。