# 审计报告: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 拦截器 → 可控 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` - **证据**: ```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 传 nil,SetOptions 兜底 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 是 string(pb/category.pb.go:156 json:"created_at,omitempty") // models 侧 MallCategory.CreatedAt 继承自 time.Time(types.Std_IICUDS) ``` ```go // 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` - **证据**: ```go // mall_product.go:88-93 —— Begin 的 err 未检查,失败时 tx 为 nil 将 panic;recover 后也不返回错误 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 —— 使用内建 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 } ``` ```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/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` - **证据**: ```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 —— 直接 return,reply 与 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 个创建接口时返回错误而非 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 个动作: 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 的迁移影响范围为条件性结论)、无法确认表间关联记录是否已存在孤儿数据。