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.
98 KiB
审计报告:module/ec/mall
审计对象:
D:\work\bsm-infra\full\module\ec\mall(模块路径bsm/full/module/ec/mall) 审计方式:静态代码审计(只读),未修改任何代码文件。本报告为本次唯一产出。 代码规模:非 pb Go 文件 101 个(约 4.2k 行),另有 8 个 proto、pb 生成代码、TS SDK 与 http 冒烟脚本。
1. 模块概览
商城微服务,提供店铺、商品(含规格/图片/评论)、类目、公告、广告、运费模板、员工六大子域,对外同时暴露 gRPC 与 grpc-gateway HTTP(/mall.X/Y,另有宿主 /rpc/:module/:service/:method)。
- 入口:
cmd/main/main.go(独立进程:config.New→impl.NewImpl→server.New→service.New(...).Start());service/expose.go提供Expose(ExposeOptions)供宿主进程内嵌(实际宿主为pkgs/ecmall,见pkgs/ecmall/internal/service/mall.go:10)。cmd/cli/main.go仅log.Println("Hello World!")。 - 分层:
internal/logic/{ads,category,freight,notice,product,staff,store}(业务)→internal/models(GORM 模型 + 少量查询函数)→internal/impl(全局DBService/RedisService/EtcdService/MemorySerice)。internal/server/*为protoc-gen-slc生成的薄封装。 - 鉴权模型:三层。(1) 宿主网关
pkgs/ecmall/internal/server/authorization.go校验 HS256 JWT 签名与有效期;(2) gRPC 一元拦截器auth.unaryInterceptor同款校验;(3) 业务层service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"})校验claims.Extend["role"](SDK:bsm-sdk/core/service/meta.go:36-45)。店铺隔离(store_identity)不在任何一层强制校验,仅在创建时从 token 的owner写入。 - 配置:
etc/mall_{dev,test,prod}.yaml(postgres + redis + gateway)、etc/supervisor.bsm-ec-mall.conf。宿主配置pkgs/ecmall/etc/default_dev.yaml决定运行时 JWT 密钥与匿名白名单。 - 测试:仅
internal/password/password_test.go1 个 Go 单测;test/**/*.http为人工冒烟脚本(无断言)。
2. 审计范围与方法
2.1 已覆盖子域(逐个读源)
| 子域 | 覆盖文件 |
|---|---|
| 基础设施 | cmd/*、internal/{config,excode,impl}、internal/password/*、service/*、internal/server/*(全部 8 个) |
| store 店铺 | logic/store/* 全 10 个(apply_join/get_email/get_payment/get_setting/licensing/mini_code/search/set_email/set_payment/set_setting) |
| product 商品 | logic/product/* 全 21 个(item_/spec_/photo_/comment_/ref) |
| category 类目 | logic/category/* 全 5 个 |
| notice 公告 | logic/notice/* 全 5 个 |
| ads 广告 | logic/ads/* 全 6 个 |
| staff 员工 | logic/staff/* 全 8 个 |
| freight 运费(任务未列出,实际存在) | logic/freight/* 全 13 个 |
| models | internal/models/* 全 18 个文件逐一阅读 |
| proto / SDK / 配置 / 测试 | proto/*.proto(8 个)、sdk/typescript/**(端点覆盖逐条 grep + mall/index.ts 抽样)、etc/*(4 个)、test/**(抽样 4 个 *.http) |
2.2 为判定结论而交叉阅读的模块外代码(仅作证据,不属审计对象)
- SDK(
go.mod:65replace →D:\work\bsm-sdk\core):service/meta.go(角色校验语义)、crypto/encipher/encipher.go(token 生成/解析)、crypto/token/jwt.go、with/databases.go、database/sql/ext.go(SetOptions默认值)、env/env.go(默认密钥)。 - 宿主
pkgs/ecmall:internal/server/authorization.go、internal/server/server.go、internal/config/config.go、etc/default_dev.yaml。 - 相邻模块:
module/base/passport/internal/logic/{common,login}(token 签发范式)、module/ec/order(同款RoleValue用法对照)。 - 依赖源码:
gorm.io/gorm@v1.31.2的schema/relationship.go、callbacks/preload.go(用于验证关联标签与Preload名称的运行时行为)。
2.3 执行过的命令及结果
在 D:\work\bsm-infra\full\module\ec\mall 下执行(只读检查):
gofmt -l . → 无输出,exit 0(无格式问题)
go vet ./... → 无输出,exit 0(无静态可疑点)
未执行 go test(模块内仅 1 个单测,且无 DB/Redis 运行环境)。未做动态验证:本次审计没有启动服务、没有连库、没有发请求,因此所有"必然/可能报错"类结论均给出代码级推理链;凡无法通过代码定论者已标注「推测」。
2.4 未覆盖 / 未能验证的部分
pb/*.pb.go(生成代码,共约 60 万字节)仅按需查字段与 tag,未逐行审计。sdk/typescript/*.ts(8 个文件约 470KB,@ts-nocheck生成物)未逐行核对,仅比对端点清单与字段命名。test/**/*.http未实际执行(无运行环境),仅静态比对请求体与实现契约。- 无 DB/Redis/etcd,无法验证运行期 SQL 是否报错(如
store.Search的keyword列)、无法验证EmptyIN、唯一索引冲突、关联记录实际数据形态。 - 未审计相邻模块(
market/order/supply/address)主体,仅在item_detail.go跨表读market_supply处做了交叉确认。 - 跨模块已知缺陷(仅引用,不在本报告重复论证):本模块
internal/server/new.go:26-30构造Server时从不给Mux赋值(恒为nil),而service/expose.go:23-43把options.Gateway直接交给生成的pb.Register*HandlerServer(其首行即mux.Handle(...),无 nil 防御),独立部署入口cmd/main/main.go:34又把s.Mux(nil) 传下去且从不调用Expose。因此"独立部署时 HTTP 网关不可用"属仓库级模板缺陷,不作为本模块独立 P0 论断;本报告中所有涉及 HTTP 网关行为的表述均按宿主pkgs/ecmall(其ServeMux由pkgs/ecmall/internal/server/server.go:38正常构造)成立。
3. 问题清单
P0
1. JWT 签名密钥为仓库内公开占位符,且角色/店铺归属全部取自 token 声明 → 可伪造任意店铺管理员令牌实现全租户接管
- 位置:
pkgs/ecmall/etc/default_dev.yaml:22(宿主运行时密钥)、pkgs/ecmall/internal/config/config.go:72-74、pkgs/ecmall/internal/server/authorization.go:74-79、module/ec/mall/etc/mall_dev.yaml:27+internal/config/config.go:41、internal/logic/staff/login.go:88、internal/logic/product/item_create.go:106-108 - 证据:
# pkgs/ecmall/etc/default_dev.yaml:22 Authorization: Key: CHANGE_ME_32_BYTE_JWT_SECRET_KEY// pkgs/ecmall/internal/config/config.go:72-74 —— 只校验长度,占位符恰好 32 字节,直接通过 keyLength := len(Spec.Authorization.Key) if keyLength != 16 && keyLength != 24 && keyLength != 32 { panic("Authorization.Key must contain 16, 24, or 32 bytes")// pkgs/ecmall/internal/server/authorization.go:74-79 —— 只验签+验期,不校验内容来源 tokenValue, err := jwt.ParseWithClaims(raw, claims, func(tokenValue *jwt.Token) (any, error) { if tokenValue.Method != jwt.SigningMethodHS256 { ... } return a.key, nil }, jwt.WithExpirationRequired(), ..., jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))独立进程另有更差路径:// internal/logic/product/item_create.go:106-108 —— 角色与店铺归属完全信任 token 内容 owner := auth.Owner.(map[string]any) data.Store_ID = uint(owner["store_id"].(float64)) data.Store_Identity = owner["store_identity"].(string)internal/config/config.go:41只做encipher.New(env.Runtime.JwtSecretKey),从不把Spec.SecretKey写入env,而 mall 自身配置为SecretKey: CHANGE_ME(etc/mall_prod.yaml:27)→ 运行时密钥退化为 SDK 硬编码默认值Cblocksmesh2022C(bsm-sdk/core/env/env.go:JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"))。 - 影响:持有公开占位符(或 SDK 默认值)即可离线签发自洽的 HS256 JWT,并在 claims 中任填
extend.role="Mall_Admin"、owner.store_identity=<任意店铺>。由于第 2 条(写接口无店铺约束)同时存在,攻击者可直接接管任意店铺的全部商品/规格/价格/库存/广告/公告/类目/员工与支付配置。这是本模块最高风险项,且属于典型的"密钥入库 + 权限信息来自客户端可控声明"复合缺陷。 - 建议:密钥必须外置(env/secret manager)并在启动时做熵校验(拒绝含
CHANGE_ME的占位串、要求非可打印字典串);config.New需将Spec.SecretKey显式注入env.Runtime.JwtSecretKey;把store_id/store_identity从 token 声明下沉为服务端按claims.ID查库获得,禁止直接采信 claims 中的归属字段。
2. 全模块写操作缺少店铺(租户)隔离:按 id/identity 直改直删,or 条件可一次命中多店铺
- 位置(同类合并,共 14 处):
internal/logic/product/item_delete.go:28、item_modify.go:29、item_batch_op.go:27、spec_delete.go:28、photo_delete.go:28、internal/logic/ads/delete.go:28、ads/modify.go:38、internal/logic/notice/delete.go:28、notice/modify.go:34、internal/logic/category/delete.go:28、category/modify.go:30、internal/logic/staff/delete.go:28、internal/logic/freight/remove.go:28、internal/logic/store/set_setting.go:34 - 证据:
// internal/logic/product/item_delete.go:28 —— 无 store_identity 条件;or 语义导致 id 与 identity 均可单独命中 err = impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Delete(&models.MallProduct{}).Error// internal/logic/store/set_setting.go:34 —— Updates 作用于所有匹配行 if err := impl.DBService.Model(&models.MallStore{}).Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Updates(data).Error; err != nil {对照:模块内唯一写了店铺约束的函数// internal/logic/product/item_batch_op.go:27 —— 批量改状态,identity 列表完全由调用方给出 err = impl.DBService.Model(&models.MallProduct{}).Where("identity in ?", in.Identity).Update("status", in.Status).Errormodels/mall_product.go:74-76(ForbiddenProduct带store_identity = ?)以及正确的staff/create.go:56(按store_identity查重)均无调用点(grep 全仓仅命中定义处)——即"正确写法是死代码,错误写法是线上路径"。 - 影响:任一持有
Mall_Admin(含第 1 条的伪造令牌)的主体可读取/修改/删除任意店铺商品、SKU 价格库存、广告、公告、类目、员工账号与店铺基础配置;or连接使一次请求可同时影响多个店铺(Updates/Delete作用于全部匹配行),可被用作批量破坏。属越权 + 数据完整性双重问题。 - 建议:统一引入强制租户作用域(
scope := store_identity from server-side lookup),所有Where追加store_identity = ?;禁止id=? or identity=?形式,改为按 identity 单键定位并校验归属;为批量接口加条数上限与归属校验;补一条"越权改他人商品必须 403"的自动化用例。
3. 跨租户支付/邮件配置读写:ParseMetaCtx(ctx, nil) 无角色校验 + 目标店铺来自请求体 → 支付配置劫持与密钥泄露
- 位置:
internal/logic/store/set_payment.go:17,22-26、set_email.go:17,22-26、get_payment.go:16,26-31、get_email.go:15,25-32 - 证据:
// internal/logic/store/set_payment.go:17-26 _, err = service.ParseMetaCtx(ctx, nil) // nil ⇒ 仅验 JWT,不校验任何角色 ... if err := impl.DBService.Model(&models.MallStore{}).Where("identity = ?", in.GetIdentity()).Update("pay_configs", in.GetConfigs()).Error; err != nil {// bsm-sdk/core/service/meta.go:36-45 —— opts == nil 时完全跳过 checkRole if opts != nil { if !checkRole(claims, "role", opts.RoleValue) { return nil, errcode.ErrPermissionDenied } ... }// internal/logic/store/get_payment.go:26 —— 读取方同样无角色校验,店铺由入参指定 impl.DBService.Model(&models.MallStore{}).Select("pay_configs").Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Find(&sotre) - 影响:任意已登录用户(无需 Mall_Admin)即可覆盖任意店铺的
pay_configs/mail_configs(明文 JSON 字符串,无结构校验),把收款指向攻击者账户或破坏支付通道;反向可读取他人的支付与 SMTP 配置(mail_configs通常含账号口令)造成凭据泄露。此条不依赖第 1 条的伪造能力,用合法普通用户 token 即可触发,是当前最易实施的高危漏洞。 可达性链条(已逐环核对,非推测):普通用户经/passport.Login/Pwd拿到标准 HS256 JWT(module/base/passport/internal/logic/common/token.go:10用token.New(env.Runtime.JwtSecretKey).GenerateJwt签发)→ 宿主启动时已令env.NewEnv().JwtSecretKey = Spec.Authorization.Key(pkgs/ecmall/internal/config/config.go:79),与网关验签密钥一致(authorization.go:74-79)→ 通过网关 → 进入业务层service.ParseMetaCtx(ctx, nil),因opts == nil完全跳过checkRole(meta.go:36-45)→ 直接以入参identity写入pay_configs。全程无角色校验、无店铺归属校验。 - 建议:改为
ParseMetaCtx(ctx, &ParseOptions{RoleValue:"Mall_Admin"})并按服务端解析出的店铺作用域限定;pay_configs/mail_configs需 JSON Schema 校验并脱敏返回(敏感字段回显****);为配置写入加审计日志与二次确认。
4. 商品规格创建时库存字段被 stock_type 覆盖 → 库存必然被写坏
- 位置:
internal/logic/product/spec_create.go:96-98(requestToModelSpec见:79-108) - 证据:
// internal/logic/product/spec_create.go:96-98 if in.StockType != 2 { specification.Stock = int64(in.StockType) // 意图应是 specification.StockType = in.StockType }协议侧语义明确:// 同文件 :84-86 —— 先按入参写了正确的 Stock,随后被上面覆盖 StockType: 2, Stock: in.Stock,proto/product.proto:230stock_type = 7; // 库存设置:1-设置库存限制,2-不设置库存限制;而stock = 8 // 库存数量。冒烟脚本test/item/item_create.http:37-39也是按stock_type:2, stock:"1"提交。 - 影响:任何以
stock_type=1(启用库存限制)创建 SKU 的请求,都会把库存写成字面量1(或3等非法库存),与请求中的stock无关 → 库存数据被确定性写坏,直接引发超卖/少卖(StockType本身还恒为 2,"限制库存"永远不会生效)。属"必然数据损坏(含库存)"。 - 建议:改为
specification.StockType = in.StockType;库存/价格字段增加取值域校验(非负、上限);补一条"stock_type=1 且 stock=100 → 落库 stock=100 且 stock_type=1"的单测。
5. 无保护类型断言(claims/行字段)+ 未安装 recovery 拦截器 → 可控 panic(gRPC 直连下推测为进程退出)
- 位置:
internal/logic/product/item_create.go:106-108、internal/logic/ads/create.go:37-39、internal/logic/category/create.go:31-33、internal/logic/notice/create.go:37-39、internal/logic/staff/create.go:47-49、internal/logic/product/item_detail.go:35;拦截器缺失见pkgs/ecmall/internal/server/server.go:32 - 证据:
// internal/logic/staff/create.go:47-49 ownerStore := auth.Owner.(map[string]any) storeId := uint(ownerStore["store_id"].(float64)) storeIdentity := ownerStore["store_identity"].(string)// internal/logic/product/item_detail.go:35 —— 行字段无断言保护 reply.SupplyName = supply["name"].(string)// pkgs/ecmall/internal/server/server.go:32 —— 只装了鉴权拦截器,没有 recovery grpcServer := grpc.NewServer(grpc.UnaryInterceptor(auth.unaryInterceptor))token.Claims.Owner(bsm-sdk/core/crypto/token/jwt.go:19)与types.JwtClaims.Owner均为any,passport 签发时就是nil(module/base/passport/internal/logic/login/pwd.go:42传入nilowner)。 - 影响:只要 token 的
owner不是 JSON 对象(null/字符串/数组)或缺少store_id/store_identity,上述 5 个创建接口即 panic(断言本身必然 panic,这一点由代码确定)。后果路径分两种:HTTP 网关路径由net/http的每连接 recover 兜住,表现为连接中断;gRPC 直连路径(0.0.0.0:12300对外暴露)推测会导致进程退出——依据是服务只安装了鉴权拦截器(server.go:32)而未装 recovery,且 grpc-go 默认不 recover handler panic,此项未经实测。可达性依赖第 1 条(需能构造带extend.role="Mall_Admin"的令牌):若第 1 条修复而 recovery 仍未补齐,本条应降级为 P1(仅剩连接中断/单次请求失败,不再构成进程级 DoS)。 - 建议:统一用
owner, ok := auth.Owner.(map[string]any)+v, ok := row["name"].(string)的带 ok 形式;在 gRPC 侧追加grpc.ChainUnaryInterceptor(auth.unaryInterceptor, recoveryInterceptor);将store_id/store_identity改为服务端查库(同第 1 条建议)。
P1
6. 鉴权链路自我矛盾:唯一签发 extend.role 的登录接口产出的是 AES 密文而非 JWT,导致所有 Mall_Admin 端点恒为 403
- 位置:
internal/logic/staff/login.go:88;对照module/base/passport/internal/logic/common/token.go:9-10 - 证据:
// internal/logic/staff/login.go:88 —— GenerateTokenAes ⇒ AES-CBC+base64 密文 token, err := encipher.GenerateTokenAes(record.ID, record.Identity, "", record.Role, store, map[string]string{"role": record.Role})// bsm-sdk/core/crypto/encipher/encipher.go:44-53 —— 内部是 json → AESEncryptCBC,不是三段式 JWT byte, err := json.Marshal(claims) token, err := AesEncryptCBC(byte)// bsm-sdk/core/service/meta.go:31 —— 业务层用 JWT 解析器解析它 claims, err := token.New(env.Runtime.JwtSecretKey).ParseJwt(Authorizations[0])全仓 grep 结果:// module/base/passport/internal/logic/common/token.go:9-10 —— 平台真正的登录走标准 JWT,且 extend 里只有 rights token, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(id, identity, client, role, nil, extend) // extend = {"rights": ...}"role"写入 token 的位置只有staff/login.go:88一处;module/base/passport/**与module/ec/order/**均无。 - 影响:
/mall.Staff/Login返回的 token 既过不了网关的jwt.ParseWithClaims(authorization.go:74),也过不了业务层ParseJwt;而能过校验的 passport token 因Extend["role"]不存在,被checkRole判定为无权限(meta.go:51-59)。结论:当前不存在同时满足"通过校验"与"携带 role=Mall_Admin"的令牌,模块内 42 处RoleValue: "Mall_Admin"(含staff/fetch.go:17被注释的 1 处,其余 41 处生效)全部不可用(恒ErrPermissionDenied)。这既是功能线上故障,也意味着第 2 条的越权在当前配置下被"掩盖"——一旦有人为修通登录而只改动 token 格式(把GenerateTokenAes换成GenerateJwt),越权将立即变为可直接利用,故必须与第 2 条一起修。 - 建议:
staff.Login改用token.New(env.Runtime.JwtSecretKey).GenerateJwt(...)并统一 claims 结构(role 写入Extend["role"]);或在网关/业务侧统一改为读取顶层role声明并与 passport 的rights打通;同时补ParseTokenAes的兼容或彻底废弃 AES token(避免两套 token 并存)。
7. 手机验证码登录完全没有校验验证码 → 认证绕过(代码中以 TODO 明示)
- 位置:
internal/logic/staff/login.go:45-58 - 证据:
case 2: // 手机验证码登录 if in.Phone == "" || in.VerifyCode == "" || ValidatePhone(in.Phone) != nil { return nil, errcode.ErrInvalidArgument } err := impl.DBService.Where("phone = ?", in.Phone).First(&record).Error ... // TODO: 添加验证码校验逻辑 - 影响:只要知道员工手机号并随意填一个非空
verify_code,即可通过login_genre=2完成认证并获得该员工身份的令牌(另见proto/staff.proto:34-35)。/mall.Staff/Login又在宿主匿名白名单中(pkgs/ecmall/etc/default_dev.yaml:24-41列出/mall.Staff/Login),无需任何前置凭据。属账号接管;当前唯一"缓解"是第 6 条的 token 格式缺陷(不能依赖)。 - 建议:立即下线
login_genre=2或接入module/base/sender的验证码校验(含一次性消费、5 分钟过期、错误次数限制);接口层加 IP/手机号维度限流。补齐后需回归验证"错误验证码必须拒绝"。
8. staff.Fetch 的鉴权代码被注释 → 任意登录用户可枚举任意店铺员工账号/手机/邮箱
- 位置:
internal/logic/staff/fetch.go:16-20(对比同名接口logic/ads/fetch.go:15、logic/notice/fetch.go:16亦无鉴权) - 证据:
返回体包含
// internal/logic/staff/fetch.go:16-20 // parse authorization meta. // _, err = service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"}) // if err != nil { // return nil, err // } ... params = map[string]string{"store_identity": in.GetStoreIdentity()}, // 店铺由入参指定Account/Phone/Email/Role/Status(staff/fetch.go:62-74)。 - 影响:员工通讯录(含登录账号与手机号)可被任意已登录主体按任意
store_identity拉取,直接为第 7 条的手机号登录绕过提供目标清单,也是个人信息泄露。注意internal/logic/staff/ref.go:10-31中做了口令脱敏的ref()函数无调用点(实际走ModelToReply),脱敏逻辑形同废止。 - 建议:恢复角色与店铺作用域校验;响应按调用者角色裁剪字段(普通员工不可见他人手机/邮箱);删除或接入
ref()的脱敏路径。
9. store.Search 查询了模型不存在的列 keyword(模型列为 keywords)→ 该接口按定义必然报错
- 位置:
internal/logic/store/search.go:39;模型定义internal/models/mall_store.go:25 - 证据:
// internal/logic/store/search.go:39 impl.DBService.Model(&models.MallStore{}).Where("keyword = ?", in.GetKeyword()).Order("created_at desc")...// internal/models/mall_store.go:25 —— 实际列名为 keywords,且注释语义为 SEO 关键词,非搜索字段 Keywords string `gorm:"column:keywords;type:varchar(255);default:'';" json:"keywords"` // SEO 关键词 - 影响:店铺搜索接口在 postgres 下报
column "keyword" does not exist→ 统一返回ErrDB(功能不可用)。推测:本模块IsAutoMigrate=false(见第 28 条),实际表结构由外部维护,若运维手工加过keyword列则不报错,但按模型定义(keywords)查询语义仍然错误。即便列名修对,用=做店铺搜索也非预期(应为LIKE,且店铺检索通常按title)。另if pageSize < 50 { pageSize = 50 }(search.go:34-37)强制最小 50 且无上限。 - 建议:改为按
title/keywords LIKE ?检索并转义%/_;补齐pageSize上限(如 ≤100)。
10. ItemFetch 分类过滤使用从未填充的空切片 + 首次查询结果被整体丢弃(双重缺陷)
- 位置:
internal/logic/product/item_fetch.go:28,42-54,77-83 - 证据:
// :28 —— 声明后再无任何赋值 idList = make([]uint, 0) ... // :50-53 —— 判空看的是入参,过滤用的却是 idList if len(in.GetCategoryId()) != 0 { tx = tx.Where("product_category.category_id in ?", idList).// :42-46 先查一次(结果被 :77 的第二次查询完全覆盖),:77-83 再查一次 if err := impl.DBService.Model(&models.MallProduct{}).Where(params).Preload("Images")...Find(&product).Error; err != nil { ... if err := tx.Where(params).Preload("Images")....Find(&product).Error; err != nil { - 影响:(a) 只要传了
category_id,idList为空 → 商品列表恒为空(按分类浏览功能完全不可用)。该 SQL 形态已核对 GORM 源码确认为确定性行为:gorm.io/gorm@v1.31.2/clause/expression.go:193-195对空的IN值集生成IN (NULL),而NULL不匹配任何行;(b) 每次调用多执行一次带 3 个 Preload 的完整查询(含 2 次 Count),列表接口 DB 负载翻倍;(c)pageSize无上限(item_fetch.go:37-40仅兜底<=0),pageSize=1000000可拉起全表 + 关联全量。 - 建议:用
in.GetCategoryId()填充(或用 identity 列表)并改为子查询/JOIN;删除首次冗余查询;补pageSize上限;补"按分类过滤返回非空且不超过页大小"的用例。
11. CommentRe 回复关联字段取错(用自身 identity 而非父评论 identity)→ 回复永不挂到父评论
- 位置:
internal/logic/product/comment_re.go:24-31 - 证据:
if in.GetProductIdentity() == "" || ... || in.GetCommentIdentity() == "" { // 校验的是 CommentIdentity ... comment := models.MallProductComment{ ... CommentIdentity: in.Identity, // 写入的却是 Identity(回复自身的标识,通常为空) - 影响:
comment_identity恒为空串,MallProductComment.List(models/mall_product_comment.go:30,foreignKey:CommentIdentity)永远关联不到任何回复,CommentFetch的Preload("List")结果恒空 → 评论回复功能在数据层失效;同时"父评论必须存在且属于同一商品/店铺"的校验缺失。 - 建议:改为
CommentIdentity: in.GetCommentIdentity(),并校验父评论存在且store_identity与调用方一致;补一条"回复后按父评论查询能取到该回复"的用例。
12. CommentFetch 未 Preload Product/ProductSpec 却读取其字段 → 返回恒为空,且接口无鉴权、仅支持两级
- 位置:
internal/logic/product/comment_fetch.go:43,62-63,79-80 - 证据:
// :43 只 Preload 了 List impl.DBService.Model(&models.MallProductComment{}).Preload("List").Where(params)... // :62-63 却直接取关联对象字段(未加载 ⇒ 零值) Product: val.Product.Title, ProductSpec: val.ProductSpec.Title, - 影响:
product/product_spec字段永远是空字符串(前端无法展示"评论的商品/规格名");Preload("List")只展开一层,二级回复丢失;接口无任何鉴权且店铺由入参指定(同第 8 条)。 - 建议:追加
Preload("Product").Preload("ProductSpec")(注意先按第 30 条核对关联标签),或改为一次 JOIN 取名称;补鉴权与店铺作用域。
13. SpecModify 无租户校验 + Updates(struct) 忽略零值 → 无法把库存改为 0,且可跨店改价
- 位置:
internal/logic/product/spec_modify.go:17,34,62-67 - 证据:
// :34 无 store_identity 作用域;Updates(struct) 会跳过零值字段 err = impl.DBService.Where("identity=?", data.Identity).Updates(data).Error// :62-64 直接把 0 判为非法入参 if in.GetStock() == 0 { return errcode.ErrInvalidArgument } - 影响:(a) 任何
Mall_Admin可修改他人店铺 SKU 的price/direct_sales/wholesale/purchasing_price/stock(配合第 1 条可直接改价牟利);(b) 由于Updates忽略零值 + 入参校验拒绝 0,库存永远无法被清零,售罄商品无法表达;(c) 入参StoreIdentity原样写入模型(spec_create.go:82),可把 SKU 打上他人店铺标识。 - 建议:加店铺作用域;需要置零的字段改用
map[string]any或Select(...);用独立的下架/售罄接口表达状态而非依赖库存 0;StoreIdentity一律从服务端上下文取。
14. ItemDelete 只删主表,级联清理函数 DelProduct 为死代码 → 关联数据成孤儿
- 位置:
internal/logic/product/item_delete.go:28;internal/models/mall_product.go:205-246(未被调用) - 证据:
// item_delete.go:28 —— 仅删除 mall_product err = impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Delete(&models.MallProduct{}).Error// mall_product.go:206-246 —— 完整清理评论/图片/规格/分类关联的实现,全仓无调用点 func DelProduct(identity string) error { ... tx.Where("product_identity = ?", identity).Delete(&MallProductComment{}) ... } - 影响:删除商品后
mall_product_spec/mall_product_photos/product_category/product_spec/mall_product_comment中残留记录:新商品若复用 identity 会"继承"旧图片与 SKU;ItemFetch的Preload("Images")会把孤儿图片挂到别的商品上(当 identity 冲突时),并持续放大表体积。属数据完整性问题。 - 建议:
ItemDelete改为调用事务化的DelProduct(并确认其按 identity 一次删除多商品的能力,或改为列表循环);对关联表增加product_identity外键/清理定时任务;补"删除后关联表无残留"的用例。
15. 类目树无成环/父子/引用校验,删除不校验子节点与商品引用 → 环与孤儿
- 位置:
internal/logic/category/create.go:26-35、modify.go:25-30、delete.go:28;查询internal/models/query.go:74-87 - 证据:
// category/modify.go:30 —— parent_id/paths 直接来自入参,无"父节点存在/非自身/非后代"校验 err = impl.DBService.Select("title", "en_title", "parent_id", "paths", "intro", "icon", "sort", "status", "keys").Where("identity=?", in.GetIdentity()).Updates(&data).Error// category/delete.go:28 —— 删除前不检查是否存在子类目或商品关联 err = impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Delete(&models.MallCategory{}).Error// models/query.go:75 —— 只取 parent_id = 0 的根,依赖 Preload("Categories") 递归 tx := impl.DBService.Preload("Categories").Where("store_identity = ? ", identity).Where("parent_id = 0") - 影响:(a) 可把
parent_id指向自身或其自身后代,在库中形成环;(b) 删除父类目后子类目parent_id悬空成为孤儿,而Fetch以parent_id = 0为根过滤(models/query.go:75)→ 这些子类目及其子树永久不可见(既不出现在根列表,也无其它入口),只能靠直连数据库修复;(c)paths由客户端提供,可与真实层级不一致,导致面包屑/路径筛选错乱。 关于环的后果,需修正一个直觉判断:当前GetCategoryList只Preload("Categories")一层(见 P2-23),而category/ref.go:45-47的递归只在Types=="all"时对已加载的子集展开,第二层子节点的Categories恒为nil→ 因此当前代码路径不会因环而无限递归(该风险被"只加载两层"这一缺陷意外掩盖)。但环一旦形成即为潜伏故障:待 P2-23 修复为全量组树或改用递归预加载后,自环(A.parent_id = A.ID)会使refList在 A 上无限自递归,静态推断将导致栈溢出/请求挂死。 - 建议:
create/modify校验父类目存在、同店、非自身与非后代(走一次paths前缀判断即可);delete前校验无子节点且无product_category引用,或改为级联软删;paths由服务端按父节点自动拼接,禁止入参直写;修复 P2-23 前必须先加环检测,否则会把"数据错误"升级为"进程级故障"。
16. cnt == 0 分支中的 err.Error() 在 err 为 nil 时触发空指针解引用
- 位置:
internal/logic/product/spec_create.go:37-41、internal/logic/product/photo_create.go:36-40 - 证据:
// spec_create.go:37-41 cnt, err := tx.RowsAffected, tx.Error if cnt == 0 || err != nil { printer.Error(err.Error()) // cnt==0 且 err==nil 时 panic return nil, errcode.ErrDB } - 影响:GORM 在影响 0 行且无错误时
tx.Error == nil,此时err.Error()触发 nil 解引用。经 HTTP 网关表现为连接中断(net/http每连接 recover),经 gRPC 直连则推测会终止进程(同第 5 条)。属"错误处理反模式"引入的可控崩溃点。 - 建议:改为
if err != nil { printer.Error(err.Error()); return nil, errcode.ErrDB };cnt == 0单独返回业务错误(如ErrCreateFailed),不要复用err.Error()。
17. SpecDetail 无鉴权且返回进货价/代理价 → 商业敏感信息泄露
- 位置:
internal/logic/product/spec_detail.go:14-40(无ParseMetaCtx) - 证据:
// spec_detail.go:14-20 —— 全函数没有任何鉴权调用 func SpecDetail(ctx context.Context, in *pb.IdentRequest) (reply *pb.SpecItem, err error) { if in.GetIdentity() == "" { return nil, errcode.ErrInvalidArgument } var data models.MallProductSpec err = impl.DBService.Model(&models.MallProductSpec{}).Where("identity = ?", in.Identity).First(&data).Error相关未鉴权读接口另有:// spec_detail.go:38-39 —— 直接回传成本与代理价 PurchasingPrice: data.PurchasingPrice, Wholesale: data.Wholesale,internal/logic/product/item_detail.go:14(返回SupplyId/Notes等)、photo_list.go:14、ads/fetch.go:14、ads/by_pos.go:13、category/fetch.go:13、notice/fetch.go:14、comment_fetch.go:15。 - 影响:任何已登录用户只需拿到(或猜中)spec identity 即可精确获知竞争对手的进货价/代理价,属于价格体系的直接泄露;
ItemDetail还暴露供应商 ID。若这些路径在宿主匿名白名单内被开放,则连登录都不需要。 - 建议:读接口按角色裁剪字段(非店主不返回
purchasing_price/wholesale/supply_id);对 C 端开放的详情接口单独定义 DTO,避免直接复用管理端SpecItem。
18. 登录接口匿名开放且全模块无任何限流/防爆破机制
- 位置:
pkgs/ecmall/etc/default_dev.yaml:24-41(/mall.Staff/Login在Anonymous中)、internal/logic/staff/login.go:23-113;网关pkgs/ecmall/internal/server/server.go:27-42(无 rate limiter) - 证据:
# pkgs/ecmall/etc/default_dev.yaml Anonymous: - /passport.Login/Pwd ... - /mall.Staff/Login// internal/logic/staff/login.go:32-42 —— 账号密码校验,无失败计数/锁定/延迟 err := impl.DBService.Where("account = ?", in.Account).First(&record).Error ... if !password.Verify(record.Password, in.Password) { return nil, errcode.ErrPassword } - 影响:bcrypt(
bcrypt.DefaultCost)虽使单次校验约数十毫秒,但在无失败计数、无 IP/账号锁定、无验证码的情况下,弱口令(如第 21 条的默认123456与测试脚本用的 root 账号)可被长期暴力枚举;且"账号不存在"(ErrAccountNotFound,login.go:35)与"口令错误"(ErrPassword)返回不同错误码,构成账号枚举 oracle。 - 建议:网关层加 IP + 账号双维度限流(如 5 次/分钟、失败指数退避、N 次锁定);登录失败统一返回模糊错误;引入图形/滑块或短信二次校验;记录登录审计日志(成功/失败/IP/UA)。
P2
19. Select(...) 列表遗漏 status,导致公告/广告状态修改静默失效(同时 Updates(struct) 无法写零值)
- 位置:
internal/logic/notice/modify.go:32-34、internal/logic/ads/modify.go:35-38 - 证据:
// notice/modify.go:32-34 —— 组装了 Status,但 Select 白名单里没有 status Std_IICUDS: types.Std_IICUDS{Status: int8(in.GetStatus())}, err = impl.DBService.Model(&models.MallNotice{}).Select("title", "author", "content").Where("identity = ?", in.Identity).Updates(&data).Error// ads/modify.go:35-38 —— 同样把 status 排除在 Select 之外 Std_IICUDS: types.Std_IICUDS{Status: int8(in.GetStatus())}, err = impl.DBService.Select("title", "pos_key", "content", "type", "to_url").Where("id=?", in.GetId()).Updates(&data).Error - 影响:调用方传
status=-1(禁用)时接口返回Code:0 OK但数据库未变更 → 前台仍展示已"禁用"的公告/广告,运营动作失效且无任何提示。这是"假成功"的典型表现。 - 建议:把
status纳入Select;或统一改为map[string]any明确指定待更新字段与零值;返回值应回带RowsAffected,0 行时返回业务错误。
20. 写接口普遍不检查 RowsAffected,0 行也返回 Code:0 OK
- 位置:典型
internal/logic/notice/modify.go:34-45、internal/logic/ads/modify.go:38-48、internal/logic/category/modify.go:30-41、internal/logic/product/item_modify.go:29-37、以及全部delete.go - 证据:
// internal/logic/product/item_modify.go:29-36 if err := impl.DBService.Model(&models.MallProduct{}).Where("identity = ?", in.Identity).Update("status", in.Status).Error; err != nil { printer.Error(err.Error()); return nil, errcode.ErrDB } return &pb.IdentityStatusReply{Code: 0, Message: "OK", Timeseq: time.Now().UnixMilli()}, nil - 影响:identity 拼写错误、记录已删除、越权目标不存在等情形均被报成成功,客户端与运维无法区分"生效"与"没找到",也掩盖了第 2 条的越权尝试(无失败痕迹)。
- 建议:统一封装写入助手,
RowsAffected == 0时返回ErrNotFound;所有写操作打点审计日志(操作者、目标、影响行数)。
21. 数据库以 Debug 模式运行:默认 Debug: true 且代码中硬编码 .Debug()
- 位置:
internal/impl/impl.go:26、internal/logic/staff/set_profile.go:31;默认值见bsm-sdk/core/database/sql/ext.go(SetOptions(nil)→Debug: true) - 证据:
// internal/impl/impl.go:26 —— opts 传 nil,SetOptions 兜底 Debug: true(与 dev/prod 无关) DBService = with.Databases(config.Spec.Databases, nil)// internal/logic/staff/set_profile.go:31 —— 显式开启调试输出 err = impl.DBService.Debug().Where("identity = ?", auth.Identity).Updates(data).Error// internal/etc/mall_prod.yaml:7 —— 生产 DSN 与库名 - host=127.0.0.1 user=postgres password=CHANGE_ME dbname=ec_mall port=5432 sslmode=disable TimeZone=Asia/Shanghai - 影响:所有 SQL(含参数值,如员工手机号、邮箱、支付配置、口令哈希)被打印到 stdout/日志,既造成敏感信息落日志,也在高 QPS 下产生可观 IO/CPU 开销;生产环境与开发环境行为无差别。
- 建议:按环境显式传入
types.SqlOptions{Debug: cfg.Debug};删除代码中的.Debug();日志脱敏规则覆盖 SQL 参数。
22. 分页参数处理错误:强制最小 50、无最大值,部分列表接口完全没有分页
- 位置:
internal/logic/ads/fetch.go:34-37、notice/fetch.go:35-38、staff/fetch.go:37-40、comment_fetch.go:38-41、store/search.go:34-37(强制 50);internal/logic/category/fetch.go:30-33、spec_fetch.go:31-39、photo_list.go:22(无分页) - 证据:
// ads/fetch.go:34-37 —— 语义应为"兜底 50",实际把任意 <50 的请求放大到 50,且不做上限 if pageSize < 50 { pageSize = 50 offset = (pageNo - 1) * pageSize }// category/fetch.go:30-33 —— 一次性返回整棵类目树,Count 用 len(data) return &pb.CategoryReply{ Data: refList(data, category), Count: int64(len(data)) }, nil - 影响:客户端请求
pageSize=10实际拿到 50 条(协议违约);pageSize=10^6时单次查询可拉起整表 + 关联(ItemFetch更有 3 个 Preload);Count在类目/规格/图片接口返回的是"本次返回条数"而非总数,分页器会算出错误页数。 - 建议:抽出统一分页助手:
pageSize<=0 → 默认值、pageSize>100 → 100、返回真实COUNT(*);对类目树改为按层懒加载或缓存。
23. 类目树只 Preload 两层,三级及更深分类被静默截断
- 位置:
internal/models/query.go:75、internal/logic/category/ref.go:45-47 - 证据:
// models/query.go:75 —— 只加载一层子集 tx := impl.DBService.Preload("Categories").Where("store_identity = ? ", identity).Where("parent_id = 0")// category/ref.go:45-47 —— 递归引用子集的子集(其为 nil,故递归实质上只走两层) if types == "all" { low.Categories = refList(v.Categories, types) } - 影响:三级类目在响应中消失,且与线上数据是否真有三层无关——只要存在三层,第三层就永远不会出现(无任何日志或错误提示)。
- 建议:改为一次性全量拉取该店铺类目后在内存组树(配合第 22 条的缓存),或使用 GORM 的嵌套
Preload("Categories.Categories...")(深度固定,不推荐)/递归 CTE。
24. ItemCreate 完全忽略入参 specification,SKU 价格/库存不会落库,但请求契约与测试脚本都要求它生效
- 位置:
internal/logic/product/item_create.go:122-128;请求契约见test/item/item_create.http:29-40;被注释的实现见item_create.go:176-202 - 证据:
// item_create.go:122-128 —— 只用 SpecId 建立关联,未写入任何 price/stock specId := make([]*models.ProductSpec, 0, len(in.SpecId)) for _, v := range in.SpecId { specId = append(specId, &models.ProductSpec{Std_IICUDS: types.Std_IICUDS{Identity: utils.UUID()}, SpecId: v}) }// item_create.go:176-202 —— 唯一能写 price/stock 的函数体被整段注释,直接返回空切片 func itemToModelSpec(identity string) ([]*models.MallProductSpec, error) { var specification = make([]*models.MallProductSpec, 0) // for _, v := range in { ... data.Price = v.Price ... } return specification, nil } - 影响:调用方按
test/item/item_create.http提交完整specification(含 price/stock/purchasing_price)时,商品创建成功返回 identity,但 SKU 价格与库存从未写入 → 商品无价可卖(ref.go:79-82还会因此把line.Price/line.Stock取成 0)。属"静默丢数据",且是文档/测试与实现的三方不一致。 - 建议:实现
itemToModelSpec并在CreateProduct/ModifyProduct事务内一并写入规格;对未实现的入参字段要么返回ErrInvalidArgument,要么在文档中明确不支持,禁止静默忽略。
25. 用 JSON 序列化往返做 DTO 映射,字段类型不匹配会在自己读出的数据上崩掉
- 位置:
internal/logic/category/ref.go:12-23、internal/logic/product/comment_create.go:60-71 - 证据:
// category/ref.go:14-21 —— pb → models 走 marshal/unmarshal data, err := json.Marshal(in) if err != nil { return nil, err } err = json.Unmarshal(data, &category)// pb 侧 CategoryItem.CreatedAt 是 string(pb/category.pb.go:156 json:"created_at,omitempty") // models 侧 MallCategory.CreatedAt 继承自 time.Time(types.Std_IICUDS)// comment_create.go:60-70 同理;pb.CommentItem.Product 是 string,models.MallProductComment.Product 是 struct data, err := json.Marshal(in); err = json.Unmarshal(data, &commentProduct) - 影响:
CategoryModify/CommentCreate/CommentModify一旦收到非空的created_at(例如前端把Fetch出来的对象原样回传)或product/product_spec字符串(CommentFetch正好会返回它们),json.Unmarshal立即报错 → 接口返回ErrInternal,形成"读得出来、改不回去"的死结;反之,类型兼容但语义不存在的字段会被静默丢弃。 - 建议:删除 JSON 往返映射,改为显式字段赋值函数(
pbXToModel/modelToPbX),并在编译期保证新字段不被遗漏;对只读字段(created_at/status)从入参 struct 中剥离。
26. 事务处理不健壮,且全模块缺少原子更新/并发控制(并发结论为静态推断)
- 位置:
internal/models/mall_product.go:88-99,145-156,207-212 - 证据:
// mall_product.go:88-93 —— Begin 的 err 未检查,失败时 tx 为 nil 将 panic;recover 后也不返回错误 tx := impl.DBService.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }()// mall_product.go:139-153 —— 先在事务外查询原记录(pt),再开事务更新,非原子读改写 err := impl.DBService.Where("identity = ?", product.Identity).First(&pt).Error ... tx := impl.DBService.Begin() ... if err := tx.Where("identity = ?", product.Identity).Updates(product).Error; err != nil { - 影响:
Begin失败(连接耗尽/DB 不可用)时触发 nil 解引用;panic 被 recover 后函数返回零值nil,调用方(item_create.go:46)看到err == nil会误报成功;pt的读取在事务外,与后续删除/重建规格之间存在竞态窗口(静态推断:并发修改同一商品时,后提交者会以自己读取到的旧pt.ID/pt.Identity重建关联,可能造成规格/图片丢失)。 - 同类:全模块没有任何原子更新或并发控制原语(静态推断)。证据:对
module/ec/mall/internal/**全量 grepgorm.Expr|UpdateColumn|exprs.|clause.Locking|Locking{|version零命中(仅命中models/*.go头部注释* Version: 10)。因此价格/库存/销量的更新(spec_modify.go:34、item_modify.go:29、item_batch_op.go:27、ads/modify.go:38)全部是"整行覆盖写",既无stock = stock - n式原子表达式、也无乐观锁版本列或SELECT ... FOR UPDATE行锁 → 并发下最后写入者覆盖前者(lost update)。范围限定说明:本模块内不存在下单/扣减库存路径(库存扣减应在module/ec/order,不在本次审计范围),故本条在当前代码下的实际暴露面是"管理员并发编辑商品/规格时互相覆盖",而非超卖;若订单模块直接复用本模块的更新函数,则该风险会升级为超卖,需在订单链路一并核查(越界,未验证)。 - 建议:
tx := impl.DBService.Begin(); if tx.Error != nil { return tx.Error };recover 中记录日志并返回错误(或干脆依赖 GORM 的Transaction(func(tx *gorm.DB) error)闭包式 API,自动处理回滚与 panic);把读改写整体纳入同一事务并按需加行锁(clause.Locking{Strength: "UPDATE"})。
27. 分层与模块边界违规:模型/业务层直接读其他模块的表,且全程使用包级全局 DB
- 位置:
internal/logic/product/item_detail.go:31、internal/impl/impl.go:15、internal/models/*(各函数直接引用impl.DBService) - 证据:
// item_detail.go:31 —— 直连 market 模块的表 if err := impl.DBService.Table("market_supply").Take(&supply, "id = ?", reply.SupplyId).Error; err != nil {// impl.go:15 —— 全局可变单例,无接口抽象、无会话/事务传播 DBService *gorm.DB // 数据库服务 - 影响:跨模块表耦合使
market的 schema 变更可直接打挂商城详情接口;supply["name"].(string)无断言保护(同第 5 条);全局单例使单元测试无法注入 fake DB(这也解释了为何模块几乎没有测试),事务无法沿调用链传播。 - 建议:跨模块数据改走对应服务的 gRPC/接口(
ec/market已有服务);DBService通过依赖注入 + 接口暴露;详情接口对market_supply的读取加容错(缺列/缺行为空而非 500)。
28. 迁移与种子数据:AppendMigrate 注册但不生效,InitData() 无任何调用点
- 位置:
internal/models/*.go的init()(如mall_product.go:64-66)、internal/models/query.go:36-71、internal/impl/impl.go:26、pkgs/ecmall/etc/default_dev.yaml:74-76(宿主声明不做迁移与种子) - 证据:
// impl.go:26 —— opts=nil ⇒ SetOptions 兜底 IsAutoMigrate: false DBService = with.Databases(config.Spec.Databases, nil)// models/query.go:36-38 —— 唯一的 seed 入口,全仓 grep 无调用点 func InitData() { CreateStore("to b", "localhost") } - 影响:服务启动不会建表也不会建 root 账号,说明表结构完全依赖外部手工/其他工具维护(schema 漂移无告警);
InitData属死代码,但其中的password.Hash("123456")(query.go:58)一旦被重新启用就是一个弱口令后门(test/login_account.http:4正是 root/123456)。同时模型里AppendMigrate的 18 处注册全部成为误导性代码。 - 建议:明确迁移方案(独立 migrate 命令 + 版本化 SQL),删除或改造
AppendMigrate死注册;InitData若要保留,root 初始口令必须来自环境变量并强制首次改密,且不得硬编码123456。
29. 配置不安全默认与密钥管理缺失
- 位置:
internal/etc/mall_prod.yaml:7,10,13,27、internal/etc/mall_test.yaml、internal/etc/mall_dev.yaml:8、internal/etc/supervisor.bsm-ec-mall.conf:6、internal/config/config.go:38-41 - 证据:
# etc/mall_prod.yaml:7,10,13,27 - host=127.0.0.1 user=postgres password=CHANGE_ME dbname=ec_mall port=5432 sslmode=disable TimeZone=Asia/Shanghai Cache: redis://null:CHANGE_ME@127.0.0.1:6379/0 OpLog: http://api-v2.traingo.cn/oplog/v2 SecretKey: CHANGE_ME// internal/config/config.go:38-41 —— 只校验非空/端口,未校验密钥与连接安全 conf.NotNil(Spec.Service, Spec.Cache) encipher.New(env.Runtime.JwtSecretKey)# etc/supervisor.bsm-ec-mall.conf:6 user=root - 影响:生产配置使用占位凭据且无启动校验(密码错配/未替换会带病启动);
sslmode=disable使 DB 流量明文;OpLog为明文 HTTP 内网地址(业务操作日志可被中间人篡改/窃取);supervisor 以 root 运行放大逃逸影响;Spec.SecretKey未被接入 token 体系(与第 1 条相关)。 - 建议:配置改为模板 + 环境变量注入并增加启动期强校验(占位串、弱口令、
sslmode、密钥长度与熵);DB/Redis 开启 TLS;OpLog走 HTTPS 或内网 mTLS;进程降权运行。
30. 模型/协议层遗留与一致性缺陷(重复列名、无意义 tag、反向关联标签)
- 位置:
internal/models/mall_product.go:18-30,58-60、internal/models/mall_product_link_spec.go:15-16、internal/models/mall_product_comment.go:32-33、internal/models/mall_ads.go:13 - 证据:
// mall_product.go:18-30 —— Std_IICUDS 已含 status(int8),又定义 Status int32,两个字段映射同一列 types.Std_IICUDS ... Status int32 `gorm:"column:status;default:0;" json:"status"` // 商品状态:5-未上架,1-已上架...// mall_product_link_spec.go:15 —— foreignKey/references 写反(ID 是本表字段,SpecId 是目标表不存在的字段) Spec MallProductSpec `gorm:"foreignKey:ID;references:SpecId;"` // 关联分类// mall_ads.go:13 —— type 参数里混入了无意义的 measuring_name; Content string `gorm:"column:content;type:varchar(255);measuring_name;" json:"content"` - 影响说明:关于反向关联标签,我核对了
gorm.io/gorm@v1.31.2/schema/relationship.go(:506-514取 foreignKey、:566-578与:580-588解析 references、:473-485逐级重新猜测):显式references字段在目标 schema 中不存在时会触发reguessOrErr(),最终被重新推断为 has 关系并生成mall_product_spec.id = product_spec.spec_id——恰好是期望的连接条件,因此当前不会报错(Preload("Specs.Spec")可正常加载)。但这属于依赖 ORM 猜测兜底的隐式契约:升级 GORM、启用constraint或迁移到显式 join 时都可能改变语义。status重复列名的影响(哪一方在迁移/写入时胜出)推测取决于 GORM 字段解析顺序,未做运行验证。 - 建议:
MallProduct删除重复的Status(统一用Std_IICUDS.Status的 int8 或自定义Std_Status);关联标签改写为规范方向(foreignKey:SpecId;references:ID);清理无意义 tag;为所有状态字段定义常量枚举并集中校验。
31. 状态流转与枚举缺少校验:status 可被写入任意整数
- 位置:
internal/logic/product/item_modify.go:24-29、item_batch_op.go:27、item_create.go:148、internal/logic/category/ref.go(status 直通)、internal/logic/ads/modify.go:33 - 证据:
// item_modify.go:24-29 —— 只拒绝 0,其余任意值直接落库 if in.GetIdentity() == "" || in.GetStatus() == 0 { return nil, errcode.ErrInvalidArgument } if err := impl.DBService.Model(&models.MallProduct{}).Where("identity = ?", in.Identity).Update("status", in.Status).Error; err != nil {另注:// proto/product.proto:125 —— 契约明确"2-自动上架等待中(禁止web手动传入)",实现无任何拦截 int32 status = 2; // 商品状态:1-已上架,2-自动上架等待中(禁止web手动传入),3-禁用 ,4-草稿,5-未上架proto/product.proto:216与models/mall_product.go:30对同一状态字段的枚举说明不一致(前者0-未上架,后者5-未上架)。 - 影响:可把商品置为未定义状态(如 99、-1),列表筛选(
status = ?)与前台展示逻辑将出现"隐身商品";"自动上架等待中"可被手工写入绕过定时上架逻辑;ItemCreate的草稿特例(item_create.go:63status == 4跳过全部必填校验)允许创建任意残缺商品(无标题/封面/内容/类目)。 - 建议:定义
ProductStatus常量集合与合法迁移表(草稿→待上架→上架→禁用),在 logic 层统一校验并集中实现;草稿也应对标题/类目做最小校验;同步修正 proto 与模型的枚举注释。
32. 错误处理与日志不规范:print()、丢失上下文、错误码吞并
- 位置:
internal/logic/store/get_setting.go:21、get_payment.go:27、get_email.go:26、set_setting.go:35;internal/logic/product/*、其余 logic 普遍printer.Error(err.Error()) - 证据:
// get_setting.go:20-23 —— 使用内建 print(stderr、无级别/无时间/无 trace_id),随后统一吞成 ErrDB if err := impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).First(&data).Error; err != nil { print(err.Error()) return nil, errcode.ErrDB }// spec_create.go:47-48 —— 同上:丢弃原始错误 printer.Error(err.Error()); return nil, errcode.ErrDB - 影响:日志无级别、无请求标识、无结构化字段,无法与请求关联;
print绕过日志框架(不落stdout_logfile,见etc/supervisor.bsm-ec-mall.conf:8),排障时信息丢失;所有 DB 错误塌缩为ErrDB,前端无法区分"未找到/冲突/超时"。 - 建议:统一
printer.Error("ctx", zap...)结构化日志并携带trace_id/store_identity/operator;用fmt.Errorf("...: %w", err)包装后在统一错误映射层转为业务码;禁止print。
33. 员工手机号全局唯一 + 空串默认值 ⇒ 第二个未填手机号的员工必然唯一键冲突
- 位置:
internal/models/mall_staff.go:26、internal/logic/staff/create.go:27-29,56-70 - 证据:
// mall_staff.go:26 —— 全局唯一索引 + 默认空串 Phone string `gorm:"type:varchar(20);uniqueIndex;default:'';"` // 手机号// staff/create.go:27-28 —— 只强制 account/password,phone 可空 if in.GetAccount() == "" || in.GetPassword() == "" { return nil, errcode.ErrInvalidArgument } - 影响:(a) 创建第二个不填手机号的员工时违反唯一索引 → 直接
ErrDB(且提示为通用 DB 错误,难以定位);(b) 手机号跨店铺全局唯一,与多租户模型冲突(B 店无法登记与 A 店相同的手机号);(c) 账号唯一性只在店铺维度校验(create.go:56),但登录查询Where("account = ?")(login.go:32)是全局的 → 不同店铺存在同名 account 时,登录会取到First命中的任意一条记录(推测:取决于返回顺序,可能导致跨店登录到错误账号)。 - 建议:
Phone改为可空(*string)或以店铺为前缀的复合唯一索引;登录查询补store_identity维度或改为全局唯一 account;对唯一键冲突映射为明确的业务错误。
34. 员工自助能力缺失与账号变更无校验
- 位置:
internal/logic/staff/get_profile.go:18、set_password.go:19、set_profile.go:23-31 - 证据:
// get_profile.go:18 —— "获取个人信息"却要求 Mall_Admin 角色,普通 staff 无法查看自己资料 _, err = service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"})同时// set_profile.go:23-31 —— 允许改 account,且无唯一性校验、无 token 失效机制 data := &models.MallStaff{ Name: in.Name, Account: in.Account, Profile: in.Profile, Phone: in.Phone, Email: in.Email, Avatar: in.Avatar } err = impl.DBService.Debug().Where("identity = ?", auth.Identity).Updates(data).Errorset_password.go:23-25只校验两次输入一致,无长度/复杂度要求,且沿用 MD5 时代的Salt: ""写入(set_password.go:32)。 - 影响:店主之外的角色(
Role: "staff",staff/create.go:43硬编码)连自己的资料和改密入口都不可用(权限模型与业务语义错位);account可被改成与他人重复(再叠加第 33 条(c) 即账号混淆);口令可为"1"。 - 建议:把"自助类"接口(GetProfile/SetProfile/SetPassword)的角色要求降为"已登录",越权判定改为
claims.ID == 目标ID;account变更需唯一性校验 + 二次验证 + 使旧 token 失效;口令强制最小长度/复杂度(并移除Salt残留写入)。
35. 运费(freight)子域 11/13 个方法为空实现却返回成功
- 位置:
internal/logic/freight/create.go:24-30、modify.go:24-30、delete.go:25-31、detail.go:26、fetch.go:26-28、deny_region_{create,modify,delete,remove,fetch}.go(同类) - 证据:
// freight/create.go:24-30 // TODO: add your logic code & delete this line. return &pb.IdentityStatusReply{ Code: 0, Message: "OK", Timeseq: time.Now().Unix() }, nil仅// freight/detail.go:26 —— 直接 return,reply 与 err 均为 nil // TODO: add your logic code & delete this line. returnfreight/remove.go:28真正执行了Delete。模型MallFreight/MallFreightAttr/MallFreightDeny(models/mall_freight*.go)全部已定义并注册迁移但无任何读写。 补充核实(命名返回值恒为 nil 的判断已逐个用裸return确认):freight/detail.go:26、freight/fetch.go:28、freight/deny_region_fetch.go(末尾return)、store/mini_code.go:22、product/item_detail_by_spec.go:20均为对命名返回值(reply, err)的裸return,故确定返回(nil, nil);网关将输出空对象{}并携带成功码。此结论不做泛化——凡未出现裸return的实现(如freight/create.go:26显式构造了Code:0的 reply)按"返回假成功 reply"表述。 - 影响:运费模板与限售区域功能整体不可用,但接口一律返回
Code:0 OK,调用方(含前端、sdk/typescript/freight.ts生成的 9 个方法)会认为创建成功;Detail/Fetch返回空对象会让客户端解析出零值而非报错。属"假实现 + 假成功",且remove.go是唯一生效的写操作(同时具备第 2 条的越权问题)。 - 建议:未实现的 RPC 统一返回
errcode.ErrUnimplemented(SDK 已有该错误码,login.go:61在用),禁止返回Code:0;在 proto 注释与 SDK 文档中标注未实现状态;排期补齐或从服务中摘除,避免测试/前端基于假成功编码。
36. store.ApplyJoin 与 store.MiniCode 为空实现却返回成功
- 位置:
internal/logic/store/apply_join.go:14-27、internal/logic/store/mini_code.go:13-22 - 证据:
// apply_join.go:19-27 —— 只校验角色,随后直接成功 // TODO: valid code // TODO: add your logic code & delete this line. return &pb.IdentityStatusReply{ Code: 0, Message: "OK", Timeseq: time.Now().UnixMilli() }, nil同时// mini_code.go:18-22 —— 返回零值 reply,接口无意义 // TODO: add your logic code & delete this line. returnmodels/mall_apply.go(入驻申请表)无任何读写路径。 - 影响:店铺入驻申请提交后静默丢失且返回成功(用户以为已提交,平台侧无记录);小程序推荐码接口返回空对象。
ApplyJoin入参中携带的Password(proto/store.proto:72)若将来按现状实现会造成明文口令处理风险(当前未落库)。 - 建议:同第 35 条,未实现即返回
ErrUnimplemented;入驻流程需事务化落mall_apply并通知平台审核;口令入参一律改为哈希后再入表。
37. 未鉴权的读接口可枚举任意店铺数据(含未上架/草稿商品)
- 位置:
internal/logic/product/item_fetch.go:15-18、item_detail.go:14-20、item_detail_by_serial.go:16-22、photo_list.go:14-22、internal/logic/ads/fetch.go:15-17、internal/logic/notice/fetch.go:16-18、internal/logic/category/fetch.go:14-16、internal/logic/store/get_setting.go:16-23、internal/logic/store/licensing.go:15-28 - 证据:
// item_fetch.go:15-18 —— 无 ParseMetaCtx,店铺由入参决定,且无状态过滤 func ItemFetch(ctx context.Context, in *pb.MallFetchRequest) (reply *pb.ListReply, err error) { if in.GetStoreIdentity() == "" { return nil, errcode.ErrInvalidArgument }// item_detail.go:20 —— 按 identity 取任意商品(含草稿/禁用),无归属校验 err = impl.DBService.Preload("Specs.Spec").Preload("Images").Preload("Categories.Category").Where("identity = ?", in.Identity).First(data).Error// store/licensing.go:41-48 —— 未鉴权即可由 subdomain 换出店铺 identity err := impl.DBService.Model(&models.MallStore{}).Where("subdomain=?", domain).First(&store).Error return store.Identity, nil - 影响:任何登录用户可遍历任意
store_identity拉取商品/SKU/价格/类目/公告/广告;ItemDetail不区分status,未上架与草稿商品同样可读;Licensing提供 subdomain→identity 的免费映射(枚举前提)。若宿主把这些路径加入Anonymous,则无需登录。 - 建议:区分"C 端读"与"B 端读":C 端接口强制
status为已上架并隐藏成本字段,B 端接口强制店铺作用域;Licensing增加许可码校验与调用方来源校验(proto/store.proto:53的license字段当前完全未使用)。
P3
38. 密码实现与注释/文档严重不一致(实际 bcrypt,但注释与 README 仍描述 MD5 加盐)
- 位置:
internal/logic/staff/login.go:40、internal/logic/staff/set_password.go:32、internal/models/mall_staff.go:30、README.md:530 - 证据:
// password/password.go:5-12 —— 全模块唯一的哈希实现,纯 bcrypt,无任何 MD5/Salt 逻辑 hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) ... return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil// login.go:40 —— 注释仍写 MD5 // 密码校验 - 使用MD5加盐验证 if !password.Verify(record.Password, in.Password) {// set_password.go:32 —— 仍在写入已废弃的 Salt 空值 Salt: "",核实结论(针对"是否残留 MD5 逻辑"的专项核查):全模块# README.md:530 —— 表结构文档仍在宣传 salt 列 salt VARCHAR(32) NOT NULL,grep -i "md5|salt|sha1|sha256|Sum("仅命中 3 处——login.go:40(注释)、set_password.go:32(写空 Salt)、mall_staff.go:30(Salt 字段定义),没有任何 MD5/SHA 计算代码,password包只有 bcrypt(含password_test.go:5-13单测)。因此"已切换到 bcrypt"属实,但迁移与兼容策略缺失:Verify只走 bcrypt,历史 MD5 哈希用户无法登录,也没有"首次登录时按旧算法校验并重写为 bcrypt"的过渡逻辑;Salt列与 README 中的 salt 描述是纯遗留。 - 影响:存量 MD5 口令账号静默无法登录(无迁移路径、无提示);
Salt字段误导后续开发者以为存在加盐流程;文档与实现不一致会误导安全评审。 - 建议:修正注释与 README(删除 salt 列描述);明确迁移方案——若确有 MD5 存量,增加一次性兼容校验分支(
legacyMD5(salt+plain)命中即用 bcrypt 覆写并清空 Salt),迁移完成后删除Salt字段与分支;补充最大口令长度限制(bcrypt 72 字节)与成本参数配置化。
39. 死代码与未实现代码清单(逐条)
- 位置与证据:
// internal/logic/product/item_create.go:176-202 —— 整段函数体被注释,返回空切片 func itemToModelSpec(identity string) ([]*models.MallProductSpec, error) { ... return specification, nil }// internal/logic/staff/ref.go:10-31 —— 唯一做口令脱敏的函数,无调用点(实际用 fetch.go 的 ModelToReply) func ref(data []*models.MallStaff) (list []*pb.StaffItem) { ... Password: "***" ... }// internal/models/mall_product.go —— 4 个导出的数据访问函数全无调用点 func ForbiddenProduct(list []string, storeIdentity string) (int64, error) // :74(唯一带店铺约束的实现) func ProductDetail(identity string) (*MallProduct, error) // :80 func DelProduct(identity string) error // :206(级联删除,见 P1-14) func GetProductList(...) ([]*MallProduct, int32, error) // :249(含 WHERE 优先级缺陷,见 P3-42)// internal/models/mall_product_spec.go / mall_product_photos.go / mall_product_comment.go func DetailSpec / SpecList / DelSpec / PhotoList / DelPhoto / DelComment / CreateComment / ReComment / ModifyComment // 均无调用点// 空实现/占位:internal/logic/store/{apply_join,mini_code}.go、internal/logic/freight/*(11 个) // 未使用的模型:internal/models/{mall_product_attr.go, mall_apply.go}(已注册迁移但无任何读写) // 未使用的 proto 消息:AttrItem/ListItem/ListWithIdRequest/ListWithIdReply/StatisticReply/OrgBySpecIdReply/Subjoin/ProductBase // 空程序:cmd/cli/main.go(仅 log.Println("Hello World!")) // 未使用的依赖注入:internal/impl 的 RedisService/MemorySerice/EtcdService 初始化后全模块无引用(grep 仅命中 impl.go 自身) - 影响:约 101 个文件中相当比例是不可达代码,其中既有"正确但没人调用"的安全实现(
ForbiddenProduct的店铺约束、ref的口令脱敏),也有"调用但错误"的线上路径,极易在未来重构时误用;同时 README 宣传的 Redis 缓存(README.md:18,375-382)在代码中完全不存在,误导性能与容量评估。上述"无调用点"结论均为全仓 grep 实测,非推断:module/ec/mall/internal/logic/staff/*.go中ref(仅 1 处命中(即ref.go:10的定义本身);InitData在全仓.go文件中仅 7 处命中且全是func InitData()定义(module/ec/mall/internal/models/query.go:36为其中之一),没有任何调用点。 - 建议:删除死代码或接入正确路径(优先接入
ForbiddenProduct、DelProduct、ref脱敏);删除未使用的模型/proto 消息;impl中未使用的 Redis/Etcd/内存缓存要么落地使用(如第 22/23 条的缓存需求),要么移除初始化以免掩盖真实依赖。
40. 未实现事项(TODO)全量清单
- 证据(
grep -n "TODO"汇总,共 24 处):internal/logic/staff/login.go:58 // TODO: 添加验证码校验逻辑 ← 安全相关,见 P1-7 internal/logic/product/item_detail_by_spec.go:16,18 // TODO: valid code / add your logic ← 接口整体未实现(见下) internal/logic/product/item_modify.go:28、item_batch_op.go:26、photo_delete.go:34、spec_delete.go:33 internal/logic/notice/fetch.go:45、notice/modify.go:27 internal/logic/store/apply_join.go:19,21、mini_code.go:18,20、licensing.go:25,30、set_setting.go:39 internal/logic/freight/create.go:24、delete.go:25、detail.go:24、fetch.go:26、modify.go:24 internal/logic/freight/deny_region_create.go:24、deny_region_delete.go:24、deny_region_fetch.go:25、deny_region_modify.go:19,21、deny_region_remove.go:25item_detail_by_spec.go尤其严重:它是proto/product.proto:18声明并被 SDK 导出(sdk/typescript/mall/const.ts:155)的 RPC,实现只有参数校验 +return(返回 nil reply 与 nil error)。 - 影响:TODO 集中在 freight/store 两个子域,且多数实现仍返回"成功"(见 P2-35/36);
ItemDetailBySpec返回空 reply 会让网关输出{},客户端无法判断"无数据"还是"未实现"。这些 TODO 已进入对外契约(proto + 生成的 TS SDK),形成事实上的"已交付"假象。 - 建议:按 P2-35/36 的原则统一改为
ErrUnimplemented,并把 TODO 提升为可跟踪议题(含负责人与验收标准);ItemDetailBySpec应在 proto 层标注 deprecated 或尽快实现(ListWithIdReply等消息已为其准备就绪)。
41. ItemDetailBySerial 的 Preload 名称与模型关联名不符 → 被 GORM 静默忽略,规格/分类恒为空
- 位置:
internal/logic/product/item_detail_by_serial.go:22;模型internal/models/mall_product.go:58-60 - 证据:
// item_detail_by_serial.go:22 —— "Spec"/"Category" 不是关联名 err = impl.DBService.Preload("Spec").Preload("Images").Preload("Category").Where("store_identity=? and serial_id in ?", ...).Find(&data).Error运行时行为已核对 GORM 源码:// mall_product.go:58-60 —— 真实关联名为 Specs / Images / Categories Images []MallProductPhotos `gorm:"foreignKey:ProductIdentity;references:Identity;" json:"images"` Specs []ProductSpec `gorm:"foreignKey:ProductIdentity;references:Identity;" json:"spec"` Categories []ProductCategory `gorm:"foreignKey:ProductIdentity;references:Identity;" json:"category"`callbacks/preload.go:117-122对未匹配的关系名不做任何处理、也不报错(else if rel := relationships.Relations[name]; rel != nil {}无 else 分支)。 - 影响:按序列号查商品详情时
specification/categories永远为空数组(ref.go:46-75遍历空切片),前端"按序列号选规格下单"场景不可用;无任何日志提示,属静默失败。 - 建议:改为
Preload("Specs.Spec").Preload("Images").Preload("Categories.Category");对 Preload 名称建立编译期常量或在集成测试中断言非空。
42. 关键词查询的 SQL 优先级与 LIKE 通配符问题(GetProductList 为死代码,但模式值得纠正)
- 位置:
internal/models/mall_product.go:264-267;同类 LIKE 见internal/models/query.go:80、internal/logic/product/item_fetch.go:57、spec_fetch.go:29 - 证据:
// mall_product.go:264-267 —— 原始条件未加括号,与前面 Where 的 AND 混合后按 SQL 优先级成 (store AND title) OR sub_title if keyword != "" { tx = tx.Where("title LIKE ? OR sub_title LIKE ?", "%"+keyword+"%", "%"+keyword+"%") }// item_fetch.go:57 —— 参数化(无注入),但 % 与 _ 未转义,可被用于构造全表扫描 tx = tx.Where("mall_product.title like ?", "%"+in.GetKeyword()+"%") - 影响:
GetProductList的店铺隔离条件会被OR sub_title LIKE短路,理论上导致跨店数据进入结果集——但该函数当前无调用点(属死代码,见 P3-39),故不构成本次可复现的越权;真正的风险是同一写法被后续复制到ItemFetch。LIKE 通配符未转义则是普遍性问题(%入参可强制全表扫描,属轻量 DoS/信息面扩大)。结论:不作为越权漏洞计入,但必须修正。 - 建议:为 OR 组合显式加括号(
tx.Where("(title LIKE ? OR sub_title LIKE ?)"));统一封装escapeLike()转义\ % _;删除或修复死代码GetProductList。
43. sdk/typescript 与 proto/实现三方漂移
- 位置:
sdk/typescript/store.ts:1125-1197vssdk/typescript/mall/const.ts:305-359;sdk/typescript/mall/index.ts:9-27;module/ec/mall/test/store/licensing.http:7 - 证据:
# mall/const.ts 声明了全部 10 个 Store 端点(含 :311 URL_Store_Licensing),但 store.ts 的客户端只注册了 9 个,缺 Licensing sdk/typescript/mall/const.ts:311: export const URL_Store_Licensing = "/mall.Store/Licensing" sdk/typescript/store.ts:1125..1197: ApplyJoin/GetSetting/SetSetting/SetPayment/SetEmail/GetPayment/GetEmail/MiniCode/Search// sdk/typescript/mall/index.ts:9-27 —— const.proto 未写 json_name 的字段被生成为 camelCase,与其他字段的 snake_case 混用 /** 分类ID */ categoryId?: number[]; /** 最小价格 */ min_price?: number; // const.proto:51 显式写了 json_name="min_price"# test/store/licensing.http:7 —— 传数值,而 proto 是 string 且实现 switch 比较 "DOMAIN" "license_type":1,此外// internal/logic/store/licensing.go:17-24 licenseType := strings.ToUpper(in.GetLicenseType()) switch licenseType { case "DOMAIN": ... default: return nil, errcode.ErrInvalidArgument }sdk/typescript/mall/index.ts与 8 个客户端文件均带// @ts-nocheck(index.ts:3),生成物放弃类型检查;PhotoItem.types(proto/product.proto:248)与CommentItem.is_comment之外的enabled(test/item/comment/comment_create.http:13)在模型/实现中无对应处理。 - 影响:前端无法发现"少了一个端点"、无法通过类型检查捕获字段风格混用;冒烟脚本与实际契约矛盾(照此调用
Licensing必然失败),文档可信度下降;@ts-nocheck使 SDK 的字段错误只能在运行时暴露。 - 建议:重新生成 SDK 并补
Licensing;统一 proto 的json_name风格(全 snake_case 或全 camelCase);移除@ts-nocheck或至少对请求/响应类型启用检查;同步修正test/**/*.http使其与实现一致,并纳入 CI 做冒烟回归。
44. README 与实际仓库/实现大面积不符
- 位置:
README.md:3-7,38,46-75,217-247,302-399,496-553 - 证据:
# README.md:3,38 —— 声称 Go 1.25.1+ []# README.md:219-229 —— 文档表格称所有服务在端口 12101/12102 | Product | ItemCreate | 创建商品 | 12101 | # 实际配置(etc/mall_prod.yaml:2,24): Port: 12420 # gRPC(独立进程) Gateway: Port: 12419实际目录清单(# README.md:104,246,294-300,307-318 —— 引用了不存在的目录与命令 ├── 📁 scripts/ # 脚本文件 - **本地**: http://localhost:12102/mall.swagger.json make init-db / make backup-db / docker-compose up -dGet-ChildItem结果)中不存在swagger/、scripts/、Dockerfile、Makefile、docker-compose.yml、LICENSE;README.md:7还声称提供"订单处理"能力,而订单属module/ec/order。 - 影响:新人按文档操作必然失败(
make proto、docker-compose、swagger 地址、健康检查/health、/metrics全部不存在,README.md:395-399);端口/能力描述错误会误导网关与前端配置。文档可信度低会直接抬高后续维护与评审成本。 - 建议:按实际仓库结构重写 README(目录树、端口、缺失工具链),删除或补齐 swagger/脚本/Docker 产物;把健康检查与指标端点真正实现后再写入文档;README 更新纳入 PR 检查项。
45. 其他一致性与卫生问题(合并)
- 位置与证据:
- 时间格式化不统一:
internal/logic/store/get_setting.go:35(time.DateTime)、internal/logic/product/ref.go:30,115(字面量"2006-01-02 15:04:05")、internal/logic/ads/ref.go:22(vars.YYYY_MM_DD_HH_MM_SS)三种写法并存 → 同一响应体内时间格式可能不同。 Timesseq单位不统一:login.go:111用秒级vars.YYYY_MM_DD_HH_MM_SS格式化CreatedAt,而Timeseq字段在ads/create.go:51是Unix()(秒)、item_create.go:55是UnixMilli()(毫秒)→ 客户端无法判断时间戳单位。internal/logic/product/item_detail_by_spec.go:11-20:SpecDetail/DetailBySpec未实现的接口仍被注册(见 P3-40)。internal/logic/ads/by_pos.go:20:直接Find(&result)到[]*pb.AdsItem(protobuf 类型)绕过ref()映射,模型层与协议层耦合,且created_at会被 GORM 直接扫描进 string 字段(格式依赖驱动,与ref()的格式化结果不一致)。internal/logic/product/photo_create.go:25-33:不校验product_identity归属,可为他人店铺商品挂图。internal/models/mall_product_link_category.go:24/mall_product_link_spec.go:24:表名product_category/product_spec未加模块前缀,与其他mall_*表命名不一致,且ProductSpec/ProductCategory与MallProductSpec命名极易混淆。internal/logic/store/search.go:50-73与internal/logic/ads/ref.go:9-27、notice/ref.go:9-29、staff/fetch.go:56-79存在 4 份结构几乎相同的"模型转协议"循环,可抽公共泛型。
- 时间格式化不统一:
- 影响:以上均为可维护性/一致性缺陷,单独不致命,但使接口行为难以被客户端可靠消费(时间/单位/格式三处不一致),并持续制造重复代码。
- 建议:统一时间格式化常量与
Timeseq单位(建议统一毫秒并在 proto 注释声明);按 P3-39 清理死代码;把到协议的映射统一为一处(可用泛型或代码生成)。
4. 推荐优化方案
按"先堵血、再补隔离、后修正确性、最后治理"的顺序推进,每条都可独立验证。
-
密钥与令牌体系重构(对应 P0-1、P1-6、P2-29)
- 密钥外置 + 启动期熵校验:禁止
CHANGE_ME*、禁止 SDK 默认值;config.New中显式env.NewEnv().JwtSecretKey = Spec.SecretKey并校验长度与字符分布。 - 统一令牌形态:全面改用
token.GenerateJwt/ParseJwt(标准 HS256 JWT),废弃encipher.GenerateTokenAes;统一 claims 约定(Extend["role"]或顶层role,与 passport 的rights建立映射),并补一条"用真实登录令牌调用ItemCreate成功"的端到端用例。 - 令牌内容最小化:
owner不再承载店铺归属,改为服务端按claims.ID查库;对Owner做 nil 安全的结构体解析(而非any+ 断言)。
- 密钥外置 + 启动期熵校验:禁止
-
强制租户隔离(对应 P0-2、P0-3、P1-8、P1-12、P1-13、P2-37)
- 引入统一的作用域注入:在 gRPC 拦截器中解析令牌并写入
context(含store_id/store_identity/role),logic 层只从 context 取;所有Where必须带store_identity = ?。 - 建立"写入助手"
scopedUpdate/scopedDelete(ctx, model, identity, patch),内部强制店铺条件 + 返回RowsAffected;禁止裸Where("id=? or identity=?")。 - 对"自助类"接口(GetProfile/SetProfile/SetPassword)改为"本人"语义(
claims.ID == targetID),管理类接口保留Mall_Admin。
- 引入统一的作用域注入:在 gRPC 拦截器中解析令牌并写入
-
修复确定性数据损坏(对应 P0-4、P1-11、P1-14、P1-15、P2-24、P2-31)
spec_create.go:96-98改为写StockType;库存/价格入参加取值域校验与非负检查。comment_re.go:31改用in.GetCommentIdentity()并校验父评论归属。ItemDelete接入事务化DelProduct;类目create/modify增加成环与父节点校验,delete增加子节点/商品引用校验。ItemCreate真正落specification,或明确拒绝该字段;状态字段改为常量枚举 + 迁移表校验。
-
健壮性与可观测性(对应 P0-5、P1-16、P1-18、P2-19、P2-20、P2-21、P2-26、P2-32)
- 追加 gRPC recovery 拦截器;清除所有
x.(T)无保护断言,统一v, ok :=。 - 写入操作统一检查
RowsAffected,0 行返回ErrNotFound;Select白名单与业务字段保持一致(先修status)。 - 关闭全局 Debug(按环境传入
SqlOptions),删除代码内.Debug();日志结构化并携带trace_id/store_identity,敏感字段脱敏。 - 登录与全部写接口接入限流(网关层 IP+账号维度),登录失败统一模糊错误码。
- 追加 gRPC recovery 拦截器;清除所有
-
性能与一致性(对应 P1-9、P1-10、P2-22、P2-23、P2-25、P3-41、P3-42)
- 修
ItemFetch:填充分类 id 列表、删除冗余首查、pageSize加上限并返回真实Count;store.Search改按title/keywords LIKE并转义通配符。 ItemDetailBySerial修正 Preload 名称;CommentFetch补Preload("Product").Preload("ProductSpec")。- 类目树改为一次性加载 + 内存组树(或加缓存),并解决
Preload深度问题;MallProduct.Count等统计口径统一。 - 用显式
pb→model映射函数替换两处 JSON 往返;Begin()检查错误,改用闭包式Transaction并把读改写纳入同一事务。
- 修
-
契约与文档治理(对应 P2-35、P2-36、P3-38 至 P3-45)
- 未实现的 24 处 TODO 统一返回
ErrUnimplemented,并在 proto/SDK 中标注;建立 TODO 看板(负责人 + 验收标准)。 - 删除死代码(
itemToModelSpec、staff/ref.go、未用的 model/proto 消息、未接入的 Redis/Etcd/缓存),把ForbiddenProduct/DelProduct/脱敏ref接入正确路径。 - 重写 README(目录树/端口/工具链/能力边界),重新生成并修复 TS SDK(补
Licensing、去掉@ts-nocheck、统一json_name),修正test/**/*.http使其可执行并纳入 CI。 - 密码:加固口令策略(长度/复杂度/最大 72 字节)、补齐 MD5→bcrypt 迁移分支或明确弃用、删除
Salt遗留字段与 README 中的 salt 描述。
- 未实现的 24 处 TODO 统一返回
-
测试补齐(见第 6 节 P2 类用例)
- 目前模块仅 1 个单测。建议优先补:越权(他人店铺商品/规格/类目/广告/公告/员工/支付配置的读改删必须失败)、库存与价格写入回读一致(覆盖
stock_type=1)、ItemFetch分类过滤非空、CommentRe父评论关联、删除商品后关联表无残留、类目成环与孤儿、Preload字段非空断言、令牌端到端(登录→调用Mall_Admin接口)。
- 目前模块仅 1 个单测。建议优先补:越权(他人店铺商品/规格/类目/广告/公告/员工/支付配置的读改删必须失败)、库存与价格写入回读一致(覆盖
5. TODO 清单
- P0-1 外置 JWT 密钥并增加启动期熵校验,拒绝
CHANGE_ME*与 SDK 默认值,同时把Spec.SecretKey注入env|验收:使用占位符/默认密钥启动时进程拒绝启动;伪造任意 claims 的令牌不再通过验签|涉及:pkgs/ecmall/etc/default_dev.yaml:22、pkgs/ecmall/internal/config/config.go:72、module/ec/mall/internal/config/config.go:41 - P0-2 为全部写操作注入服务端店铺作用域,删除
id=? or identity=?写法|验收:跨店改/删商品、规格、广告、公告、类目、员工、店铺设置全部返回 403/404,且影响行数可审计|涉及:module/ec/mall/internal/logic/product/item_delete.go:28、module/ec/mall/internal/logic/store/set_setting.go:34(共 14 处) - P0-3
SetPayment/SetEmail/GetPayment/GetEmail改为校验Mall_Admin+ 服务端店铺作用域,并对返回配置脱敏|验收:普通员工 token 调用返回 403;响应中口令/密钥字段为掩码|涉及:module/ec/mall/internal/logic/store/set_payment.go:17、module/ec/mall/internal/logic/store/get_payment.go:16 - P0-4 修正规格库存赋值(
StockType与Stock混用)|验收:以stock_type=1, stock=100创建后回读stock=100且stock_type=1|涉及:module/ec/mall/internal/logic/product/spec_create.go:96 - P0-5 消除无保护类型断言并安装 gRPC recovery 拦截器|验收:构造
owner非对象的令牌调用 5 个创建接口时返回错误而非 panic;gRPC 直连不再导致进程退出|涉及:module/ec/mall/internal/logic/product/item_create.go:106、pkgs/ecmall/internal/server/server.go:32 - P1-6 统一令牌形态与 claims 约定,使登录令牌可被网关与业务层共同接受|验收:真实登录令牌可成功调用受
Mall_Admin保护的接口(端到端用例通过)|涉及:module/ec/mall/internal/logic/staff/login.go:88、module/base/passport/internal/logic/common/token.go:9 - P1-7 实现手机验证码校验或下线验证码登录分支|验收:错误/过期/重复使用的验证码一律拒绝,且验证码一次性消费|涉及:
module/ec/mall/internal/logic/staff/login.go:58 - P1-8 恢复
staff.Fetch的角色与店铺校验,按调用者裁剪字段|验收:跨店或低权调用返回 403,响应不含他人手机/邮箱|涉及:module/ec/mall/internal/logic/staff/fetch.go:16 - P1-9 修正店铺搜索查询列名并改为模糊匹配 + 分页上限|验收:
store.Search返回匹配结果且pageSize>100被截断|涉及:module/ec/mall/internal/logic/store/search.go:39 - P1-10 修复
ItemFetch分类过滤与冗余查询,补分页上限|验收:按分类过滤返回非空且不超过页大小;单次请求只执行一次列表查询|涉及:module/ec/mall/internal/logic/product/item_fetch.go:28 - P1-11 修正评论回复的父评论关联并校验父评论归属|验收:回复后按父评论查询能取到该回复;父评论不存在/跨店时返回错误|涉及:
module/ec/mall/internal/logic/product/comment_re.go:31 - P1-12 补齐
CommentFetch的关联预加载|验收:product/product_spec字段返回真实名称|涉及:module/ec/mall/internal/logic/product/comment_fetch.go:43 - P1-13
SpecModify增加店铺作用域与可置零更新方式|验收:跨店改价返回 403;库存可被改为 0 并回读为 0|涉及:module/ec/mall/internal/logic/product/spec_modify.go:34 - P1-14
ItemDelete接入事务化级联清理|验收:删除商品后关联规格/图片/分类/评论表无残留记录|涉及:module/ec/mall/internal/logic/product/item_delete.go:28、module/ec/mall/internal/models/mall_product.go:206 - P1-15 类目增加成环/父节点/子节点/引用校验(必须在修复 P2-23 全量组树之前完成环检测)|验收:把
parent_id指向自身或后代被拒绝;删除含子类目或已被商品引用的类目被拒绝;构造自环数据后枚举类目不会挂死|涉及:module/ec/mall/internal/logic/category/modify.go:30、module/ec/mall/internal/logic/category/delete.go:28 - P1-16 修复
err.Error()在err==nil时的空指针分支|验收:影响 0 行时返回业务错误且不 panic|涉及:module/ec/mall/internal/logic/product/spec_create.go:38、module/ec/mall/internal/logic/product/photo_create.go:37 - P1-17 为
SpecDetail及同类读接口补鉴权与字段裁剪|验收:非店主调用不返回purchasing_price/wholesale/supply_id|涉及:module/ec/mall/internal/logic/product/spec_detail.go:14 - P1-18 登录与写接口加限流/失败锁定,统一登录失败错误码|验收:连续失败达阈值后被限流;账号不存在与口令错误返回同一错误码|涉及:
module/ec/mall/internal/logic/staff/login.go:32、pkgs/ecmall/internal/server/server.go:27 - P2-19 修正
notice/ads修改接口的Select白名单以包含status|验收:传status=-1后回读为 -1|涉及:module/ec/mall/internal/logic/notice/modify.go:34、module/ec/mall/internal/logic/ads/modify.go:38 - P2-20 写操作统一校验
RowsAffected,0 行返回ErrNotFound|验收:目标不存在时不再返回Code:0|涉及:module/ec/mall/internal/logic/product/item_modify.go:29 - P2-21 关闭默认 Debug 并移除代码内
.Debug()|验收:非 dev 环境日志中不出现 SQL 语句与参数|涉及:module/ec/mall/internal/impl/impl.go:26、module/ec/mall/internal/logic/staff/set_profile.go:31 - P2-22 统一分页助手(默认值/上限/真实 Count),为无分页列表补分页|验收:
pageSize=10返回 ≤10 条,pageSize=10^6被截断,Count为总数|涉及:module/ec/mall/internal/logic/ads/fetch.go:34、module/ec/mall/internal/logic/category/fetch.go:30 - P2-23 修复类目树只加载两层的截断问题|验收:三层类目在
Fetch响应中完整返回|涉及:module/ec/mall/internal/models/query.go:75 - P2-24 落库
ProductItem.specification的规格价格与库存|验收:按test/item/item_create.http提交后 SKU 价格/库存与请求一致|涉及:module/ec/mall/internal/logic/product/item_create.go:122 - P2-25 用显式映射替换 JSON 往返 DTO 转换|验收:把
Fetch结果原样回传调用Modify不再报ErrInternal|涉及:module/ec/mall/internal/logic/category/ref.go:14、module/ec/mall/internal/logic/product/comment_create.go:62 - P2-26 事务与并发健壮化(检查
Begin错误、闭包式Transaction、读改写同事务、价格/库存改原子表达式或加乐观锁)|验收:并发修改商品不产生规格丢失;并发改库存不出现覆盖丢失(以版本号或stock = stock ± n保证);DB 不可用时返回错误而非 panic|涉及:module/ec/mall/internal/models/mall_product.go:88、module/ec/mall/internal/logic/product/spec_modify.go:34 - P2-27 解除跨模块表直连并抽象 DB 依赖|验收:
item_detail不再直接查market_supply;DB 可通过接口注入以便测试|涉及:module/ec/mall/internal/logic/product/item_detail.go:31、module/ec/mall/internal/impl/impl.go:15 - P2-28 明确迁移与种子方案,移除误导性
AppendMigrate与硬编码123456|验收:有版本化迁移可执行;启动不再依赖InitData;初始口令来自环境变量并强制首改|涉及:module/ec/mall/internal/models/query.go:36、module/ec/mall/internal/models/mall_product.go:64 - P2-29 配置安全加固(占位符校验、TLS、OpLog HTTPS、进程降权)|验收:含
CHANGE_ME或sslmode=disable的生产配置启动失败|涉及:module/ec/mall/etc/mall_prod.yaml:7、module/ec/mall/etc/supervisor.bsm-ec-mall.conf:6 - P2-30 清理模型层重复列名、无意义 tag、反向关联标签|验收:
MallProduct单列对应单一字段;关联标签方向规范且迁移无告警|涉及:module/ec/mall/internal/models/mall_product.go:30、module/ec/mall/internal/models/mall_product_link_spec.go:15 - P2-31 状态字段改常量枚举 + 合法迁移校验|验收:非法状态(如 99)被拒绝;草稿也需最小必填校验|涉及:
module/ec/mall/internal/logic/product/item_modify.go:29、module/ec/mall/internal/logic/product/item_create.go:63 - P2-32 统一结构化日志与错误包装,移除
print|验收:日志含trace_id/store_identity/operator;业务错误码可区分未找到/冲突|涉及:module/ec/mall/internal/logic/store/get_setting.go:21 - P2-33 员工手机号唯一索引与账号唯一性维度修正|验收:多个无手机号员工可创建;不同店铺同名 account 不会串号登录|涉及:
module/ec/mall/internal/models/mall_staff.go:26、module/ec/mall/internal/logic/staff/login.go:32 - P2-34 员工自助接口改为"本人"语义并加固改密/改号|验收:普通员工可查看/修改本人资料并改密;口令强度不足被拒绝;改账号需唯一校验|涉及:
module/ec/mall/internal/logic/staff/get_profile.go:18、module/ec/mall/internal/logic/staff/set_profile.go:31 - P2-35 freight 子域未实现方法统一返回
ErrUnimplemented|验收:13 个方法中未实现的 11 个不再返回Code:0|涉及:module/ec/mall/internal/logic/freight/create.go:24、module/ec/mall/internal/logic/freight/detail.go:26 - P2-36
ApplyJoin/MiniCode返回ErrUnimplemented或补齐实现|验收:入驻申请不再静默丢弃|涉及:module/ec/mall/internal/logic/store/apply_join.go:19、module/ec/mall/internal/logic/store/mini_code.go:18 - P2-37 未鉴权读接口按 C 端/B 端分流并做归属与状态过滤|验收:C 端不可读到草稿/未上架商品与成本字段|涉及:
module/ec/mall/internal/logic/product/item_fetch.go:15、module/ec/mall/internal/logic/store/licensing.go:41 - P3-38 修正密码相关注释与 README 并补齐迁移/策略|验收:注释与 README 不再出现 MD5/salt;存在明确的历史哈希迁移路径;口令长度策略生效|涉及:
module/ec/mall/internal/logic/staff/login.go:40、module/ec/mall/README.md:530 - P3-39 清理死代码并接入正确实现|验收:
DelProduct/ForbiddenProduct/ref(脱敏)被实际调用或删除;未使用的 model/proto/依赖初始化移除|涉及:module/ec/mall/internal/models/mall_product.go:74、module/ec/mall/internal/logic/staff/ref.go:10 - P3-40 TODO 全量收敛为可跟踪议题|验收:24 处 TODO 均有对应议题或已删除;
ItemDetailBySpec明确 deprecated 或实现|涉及:module/ec/mall/internal/logic/product/item_detail_by_spec.go:16 - P3-41 修正
ItemDetailBySerial的 Preload 名称|验收:按序列号查询返回非空specification/categories|涉及:module/ec/mall/internal/logic/product/item_detail_by_serial.go:22 - P3-42 修正 OR 条件括号与 LIKE 通配符转义|验收:含 OR 的条件与店铺过滤同级;入参
%/_不再扩大匹配范围|涉及:module/ec/mall/internal/models/mall_product.go:266、module/ec/mall/internal/logic/product/item_fetch.go:57 - P3-43 重新生成并修复 TS SDK 与测试脚本|验收:SDK 含
Licensing且与 proto 字段风格一致;test/**/*.http可实际执行通过|涉及:module/ec/mall/sdk/typescript/store.ts:1125、module/ec/mall/test/store/licensing.http:7 - P3-44 重写 README 使其与仓库/实现一致|验收:目录树、端口(12420/12419)、工具链、能力边界与实际一致;不存在的 Docker/swagger/scripts 引用被删除|涉及:
module/ec/mall/README.md:104 - P3-45 统一时间格式与
Timeseq单位,收敛重复的映射循环|验收:同一响应内时间格式与时间戳单位一致;4 份 ref 循环合并为一处|涉及:module/ec/mall/internal/logic/product/ref.go:30、module/ec/mall/internal/logic/ads/ref.go:22
6. 审计摘要(供汇总使用)
- 问题数:P0=5 P1=13 P2=19 P3=8
- 最高风险(一句话):仓库内公开的 JWT 签名密钥(
CHANGE_ME_32_BYTE_JWT_SECRET_KEY,仅校验长度的占位符可直接通过)叠加"角色与店铺归属全部采信 token 声明、且所有写操作无租户隔离",使攻击者可伪造店铺管理员令牌并接管任意店铺的商品、价格、库存、员工与支付配置;同时SetPayment/GetPayment等接口仅要求"已登录",无需伪造即可劫持任意店铺支付配置。 - 最优先 3 个动作:
- 立即轮换并外置 JWT 签名密钥(含宿主
pkgs/ecmall/etc/default_dev.yaml与 mall 自身SecretKey),启动期拒绝占位符/默认密钥,并把店铺归属改为服务端查库而非采信 claims; - 给全部写接口注入服务端店铺作用域(去掉
id=? or identity=?),并把SetPayment/SetEmail/GetPayment/GetEmail提升为Mall_Admin+ 脱敏返回; - 修复
spec_create.go:96的库存覆盖写入,并统一在 gRPC 侧加 recovery 拦截器 + 消除无保护类型断言(防数据损坏与进程级 DoS)。
- 立即轮换并外置 JWT 签名密钥(含宿主
- 未能覆盖/无法验证的部分:
- 未启动服务、未连 DB/Redis/etcd,无动态验证;
store.Search列名错误、唯一索引冲突、Preload名称失效等结论均为代码级推理(已核对 GORM v1.31.2 源码行为)。其中库存/价格的并发丢失更新结论为纯静态推断(依据:全模块 grep 无gorm.Expr/UpdateColumn/clause.Locking/版本列),未做并发压测;且本模块内无扣库存路径,超卖风险需在订单模块核查(越界,未验证)。
- 未启动服务、未连 DB/Redis/etcd,无动态验证;
- 已用源码消除的推测(不再存疑):空
IN切片的确切 SQL 形态(gorm@v1.31.2/clause/expression.go:193-195→IN (NULL),故ItemFetch分类过滤恒空为确定性结论);GORM 对未知Preload名称静默忽略(callbacks/preload.go:117-122无 else 分支,故ItemDetailBySerial的Preload("Spec"/"Category")失效为确定性结论);反向关联标签会被 GORM 重新猜测为 has 关系从而"恰好可用"(schema/relationship.go:473-485,566-588,617-621)。 - 报告中已主动修正的一处结论:P1-15 最初推断"类目成环会导致
refList无限递归",复核后确认当前代码不会(GetCategoryList只Preload一层,"两层缺陷" P2-23 意外屏蔽了递归),故已把该后果改为"数据不可见 + 潜伏故障(修复 P2-23 后才会变为栈溢出)",并据此调整了建议的修复顺序。- gRPC 直连 panic 导致进程退出的具体表现标注为「推测」(依据:未安装 recovery 拦截器 + grpc-go 默认不 recover handler panic),需实测确认。
MallProduct中status重复列名在迁移/写入时的实际胜出字段未验证。pb/*.pb.go(生成代码)与sdk/typescript/*.ts未逐行审计;test/**/*.http未实际执行。- 未审计相邻模块(
ec/market、ec/order、ec/supply、ec/address)主体,仅在跨表读取与鉴权范式处做交叉比对。 - 存量数据形态未知:无法确认是否存在历史 MD5 口令账号(因此 P3-38 的迁移影响范围为条件性结论)、无法确认表间关联记录是否已存在孤儿数据。