Files
full/docs/audit/module-ec-mall.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

1014 lines
98 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告module/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.go` 1 个 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:65` replace → `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`
- **证据**
```yaml
# pkgs/ecmall/etc/default_dev.yaml:22
Authorization:
Key: CHANGE_ME_32_BYTE_JWT_SECRET_KEY
```
```go
// 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")
```
```go
// 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()}))
```
```go
// 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`
- **证据**
```go
// 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
```
```go
// 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 {
```
```go
// internal/logic/product/item_batch_op.go:27 —— 批量改状态identity 列表完全由调用方给出
err = impl.DBService.Model(&models.MallProduct{}).Where("identity in ?", in.Identity).Update("status", in.Status).Error
```
对照:模块内**唯一写了店铺约束**的函数 `models/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`
- **证据**
```go
// 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 {
```
```go
// bsm-sdk/core/service/meta.go:36-45 —— opts == nil 时完全跳过 checkRole
if opts != nil {
if !checkRole(claims, "role", opts.RoleValue) { return nil, errcode.ErrPermissionDenied }
...
}
```
```go
// 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`
- **证据**
```go
// internal/logic/product/spec_create.go:96-98
if in.StockType != 2 {
specification.Stock = int64(in.StockType) // 意图应是 specification.StockType = in.StockType
}
```
```go
// 同文件 :84-86 —— 先按入参写了正确的 Stock随后被上面覆盖
StockType: 2,
Stock: in.Stock,
```
协议侧语义明确:`proto/product.proto:230` `stock_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 拦截器 → 可控 panicgRPC 直连下推测为进程退出)
- **位置**`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`
- **证据**
```go
// internal/logic/staff/create.go:47-49
ownerStore := auth.Owner.(map[string]any)
storeId := uint(ownerStore["store_id"].(float64))
storeIdentity := ownerStore["store_identity"].(string)
```
```go
// internal/logic/product/item_detail.go:35 —— 行字段无断言保护
reply.SupplyName = supply["name"].(string)
```
```go
// 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` 传入 `nil` owner
- **影响**:只要 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`
- **证据**
```go
// 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})
```
```go
// bsm-sdk/core/crypto/encipher/encipher.go:44-53 —— 内部是 json → AESEncryptCBC不是三段式 JWT
byte, err := json.Marshal(claims)
token, err := AesEncryptCBC(byte)
```
```go
// bsm-sdk/core/service/meta.go:31 —— 业务层用 JWT 解析器解析它
claims, err := token.New(env.Runtime.JwtSecretKey).ParseJwt(Authorizations[0])
```
```go
// 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": ...}
```
全仓 grep 结果:`"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`
- **证据**
```go
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` 亦无鉴权)
- **证据**
```go
// 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`
- **证据**
```go
// internal/logic/store/search.go:39
impl.DBService.Model(&models.MallStore{}).Where("keyword = ?", in.GetKeyword()).Order("created_at desc")...
```
```go
// 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`
- **证据**
```go
// :28 —— 声明后再无任何赋值
idList = make([]uint, 0)
...
// :50-53 —— 判空看的是入参,过滤用的却是 idList
if len(in.GetCategoryId()) != 0 {
tx = tx.Where("product_category.category_id in ?", idList).
```
```go
// :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`
- **证据**
```go
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`
- **证据**
```go
// :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`
- **证据**
```go
// :34 无 store_identity 作用域Updates(struct) 会跳过零值字段
err = impl.DBService.Where("identity=?", data.Identity).Updates(data).Error
```
```go
// :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`(未被调用)
- **证据**
```go
// item_delete.go:28 —— 仅删除 mall_product
err = impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Delete(&models.MallProduct{}).Error
```
```go
// 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`
- **证据**
```go
// 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
```
```go
// category/delete.go:28 —— 删除前不检查是否存在子类目或商品关联
err = impl.DBService.Where("id=? or identity=?", in.GetId(), in.GetIdentity()).Delete(&models.MallCategory{}).Error
```
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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
```
```go
// 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
- **证据**
```yaml
# pkgs/ecmall/etc/default_dev.yaml
Anonymous:
- /passport.Login/Pwd
...
- /mall.Staff/Login
```
```go
// 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`
- **证据**
```go
// 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
```
```go
// 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`
- **证据**
```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`
- **证据**
```go
// internal/impl/impl.go:26 —— opts 传 nilSetOptions 兜底 Debug: true与 dev/prod 无关)
DBService = with.Databases(config.Spec.Databases, nil)
```
```go
// internal/logic/staff/set_profile.go:31 —— 显式开启调试输出
err = impl.DBService.Debug().Where("identity = ?", auth.Identity).Updates(data).Error
```
```go
// 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`(无分页)
- **证据**
```go
// ads/fetch.go:34-37 —— 语义应为"兜底 50",实际把任意 <50 的请求放大到 50且不做上限
if pageSize < 50 {
pageSize = 50
offset = (pageNo - 1) * pageSize
}
```
```go
// 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`
- **证据**
```go
// models/query.go:75 —— 只加载一层子集
tx := impl.DBService.Preload("Categories").Where("store_identity = ? ", identity).Where("parent_id = 0")
```
```go
// 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`
- **证据**
```go
// 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})
}
```
```go
// 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`
- **证据**
```go
// 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)
```
```go
// pb 侧 CategoryItem.CreatedAt 是 stringpb/category.pb.go:156 json:"created_at,omitempty"
// models 侧 MallCategory.CreatedAt 继承自 time.Timetypes.Std_IICUDS
```
```go
// comment_create.go:60-70 同理pb.CommentItem.Product 是 stringmodels.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`
- **证据**
```go
// mall_product.go:88-93 —— Begin 的 err 未检查,失败时 tx 为 nil 将 panicrecover 后也不返回错误
tx := impl.DBService.Begin()
defer func() {
if r := recover(); r != nil { tx.Rollback() }
}()
```
```go
// 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/**` 全量 grep `gorm.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`
- **证据**
```go
// item_detail.go:31 —— 直连 market 模块的表
if err := impl.DBService.Table("market_supply").Take(&supply, "id = ?", reply.SupplyId).Error; err != nil {
```
```go
// 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`(宿主声明不做迁移与种子)
- **证据**
```go
// impl.go:26 —— opts=nil ⇒ SetOptions 兜底 IsAutoMigrate: false
DBService = with.Databases(config.Spec.Databases, nil)
```
```go
// 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`
- **证据**
```yaml
# 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
```
```go
// internal/config/config.go:38-41 —— 只校验非空/端口,未校验密钥与连接安全
conf.NotNil(Spec.Service, Spec.Cache)
encipher.New(env.Runtime.JwtSecretKey)
```
```ini
# 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`
- **证据**
```go
// 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-已上架...
```
```go
// mall_product_link_spec.go:15 —— foreignKey/references 写反ID 是本表字段SpecId 是目标表不存在的字段)
Spec MallProductSpec `gorm:"foreignKey:ID;references:SpecId;"` // 关联分类
```
```go
// 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`
- **证据**
```go
// 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 {
```
```protobuf
// 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:63` `status == 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())`
- **证据**
```go
// get_setting.go:20-23 —— 使用内建 printstderr、无级别/无时间/无 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
}
```
```go
// 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`
- **证据**
```go
// mall_staff.go:26 —— 全局唯一索引 + 默认空串
Phone string `gorm:"type:varchar(20);uniqueIndex;default:'';"` // 手机号
```
```go
// staff/create.go:27-28 —— 只强制 account/passwordphone 可空
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`
- **证据**
```go
// get_profile.go:18 —— "获取个人信息"却要求 Mall_Admin 角色,普通 staff 无法查看自己资料
_, err = service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"})
```
```go
// 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).Error
```
同时 `set_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`(同类)
- **证据**
```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
```
```go
// freight/detail.go:26 —— 直接 returnreply 与 err 均为 nil
// TODO: add your logic code & delete this line.
return
```
仅 `freight/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`
- **证据**
```go
// 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
```
```go
// mini_code.go:18-22 —— 返回零值 reply接口无意义
// TODO: add your logic code & delete this line.
return
```
同时 `models/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`
- **证据**
```go
// 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 }
```
```go
// item_detail.go:20 —— 按 identity 取任意商品(含草稿/禁用),无归属校验
err = impl.DBService.Preload("Specs.Spec").Preload("Images").Preload("Categories.Category").Where("identity = ?", in.Identity).First(data).Error
```
```go
// 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`
- **证据**
```go
// password/password.go:5-12 —— 全模块唯一的哈希实现,纯 bcrypt无任何 MD5/Salt 逻辑
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
...
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
```
```go
// login.go:40 —— 注释仍写 MD5
// 密码校验 - 使用MD5加盐验证
if !password.Verify(record.Password, in.Password) {
```
```go
// set_password.go:32 —— 仍在写入已废弃的 Salt 空值
Salt: "",
```
```markdown
# README.md:530 —— 表结构文档仍在宣传 salt 列
salt VARCHAR(32) NOT NULL,
```
**核实结论(针对"是否残留 MD5 逻辑"的专项核查)**:全模块 `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. 死代码与未实现代码清单(逐条)
- **位置与证据**
```go
// internal/logic/product/item_create.go:176-202 —— 整段函数体被注释,返回空切片
func itemToModelSpec(identity string) ([]*models.MallProductSpec, error) { ... return specification, nil }
```
```go
// internal/logic/staff/ref.go:10-31 —— 唯一做口令脱敏的函数,无调用点(实际用 fetch.go 的 ModelToReply
func ref(data []*models.MallStaff) (list []*pb.StaffItem) { ... Password: "***" ... }
```
```go
// 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
```
```go
// internal/models/mall_product_spec.go / mall_product_photos.go / mall_product_comment.go
func DetailSpec / SpecList / DelSpec / PhotoList / DelPhoto / DelComment / CreateComment / ReComment / ModifyComment // 均无调用点
```
```go
// 空实现/占位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:25
```
`item_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`
- **证据**
```go
// 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
```
```go
// 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"`
```
运行时行为已核对 GORM 源码:`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`
- **证据**
```go
// 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+"%")
}
```
```go
// 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-1197` vs `sdk/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
```
```ts
// 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"
```
```http
# test/store/licensing.http:7 —— 传数值,而 proto 是 string 且实现 switch 比较 "DOMAIN"
"license_type":1,
```
```go
// 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`
- **证据**
```markdown
# README.md:3,38 —— 声称 Go 1.25.1+
[![Go Version](https://img.shields.io/badge/Go-1.25.1-blue.svg)]
```
```markdown
# README.md:219-229 —— 文档表格称所有服务在端口 12101/12102
| Product | ItemCreate | 创建商品 | 12101 |
# 实际配置etc/mall_prod.yaml:2,24
Port: 12420 # gRPC独立进程
Gateway: Port: 12419
```
```markdown
# README.md:104,246,294-300,307-318 —— 引用了不存在的目录与命令
├── 📁 scripts/ # 脚本文件
- **本地**: http://localhost:12102/mall.swagger.json
make init-db / make backup-db / docker-compose up -d
```
实际目录清单(`Get-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. 推荐优化方案
按"先堵血、再补隔离、后修正确性、最后治理"的顺序推进,每条都可独立验证。
1. **密钥与令牌体系重构(对应 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` + 断言)。
2. **强制租户隔离(对应 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`。
3. **修复确定性数据损坏(对应 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`,或明确拒绝该字段;状态字段改为常量枚举 + 迁移表校验。
4. **健壮性与可观测性(对应 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+账号维度),登录失败统一模糊错误码。
5. **性能与一致性(对应 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` 并把读改写纳入同一事务。
6. **契约与文档治理(对应 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 描述。
7. **测试补齐(见第 6 节 P2 类用例)**
- 目前模块仅 1 个单测。建议优先补:越权(他人店铺商品/规格/类目/广告/公告/员工/支付配置的读改删必须失败)、库存与价格写入回读一致(覆盖 `stock_type=1`)、`ItemFetch` 分类过滤非空、`CommentRe` 父评论关联、删除商品后关联表无残留、类目成环与孤儿、`Preload` 字段非空断言、令牌端到端(登录→调用 `Mall_Admin` 接口)。
## 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 个创建接口时返回错误而非 panicgRPC 直连不再导致进程退出|涉及:`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 个动作:
1. 立即轮换并外置 JWT 签名密钥(含宿主 `pkgs/ecmall/etc/default_dev.yaml` 与 mall 自身 `SecretKey`),启动期拒绝占位符/默认密钥,并把店铺归属改为服务端查库而非采信 claims
2. 给全部写接口注入服务端店铺作用域(去掉 `id=? or identity=?`),并把 `SetPayment/SetEmail/GetPayment/GetEmail` 提升为 `Mall_Admin` + 脱敏返回;
3. 修复 `spec_create.go:96` 的库存覆盖写入,并统一在 gRPC 侧加 recovery 拦截器 + 消除无保护类型断言(防数据损坏与进程级 DoS
- 未能覆盖/无法验证的部分:
- 未启动服务、未连 DB/Redis/etcd无动态验证`store.Search` 列名错误、唯一索引冲突、`Preload` 名称失效等结论均为代码级推理(已核对 GORM v1.31.2 源码行为)。其中**库存/价格的并发丢失更新结论为纯静态推断**(依据:全模块 grep 无 `gorm.Expr`/`UpdateColumn`/`clause.Locking`/版本列),未做并发压测;且本模块内无扣库存路径,超卖风险需在订单模块核查(越界,未验证)。
- 已用源码消除的推测(不再存疑):空 `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 的迁移影响范围为条件性结论)、无法确认表间关联记录是否已存在孤儿数据。