46 KiB
mall(店铺与商品经营)代码审计报告
| 项 | 内容 |
|---|---|
| 审计对象 | module/ec/mall |
| 服务域 | 电商-店铺与商品经营 |
| 审计日期 | 2026-09-22 |
| 代码规模 | Go 文件 123 个(pb/ 生成代码 22 个,手写 101 个,手写约 5014 行;含生成代码共 21486 行);proto/ 8 个 1026 行;internal/logic/ 67 个文件(覆盖 7 个服务域) |
| 入口 | cmd/main(gRPC + HTTP Gateway 单进程)、cmd/cli(占位)、聚合入口 pkgs/all 经 service.Expose 复用共享 gRPC/Gateway |
| 对外协议 | gRPC + HTTP Gateway,路径 /mall.<Service>/<Method>,全部为 POST |
| 结论摘要 | 接口数量大、分层一致,但越权面很宽:13 处写操作与 3 处读操作只按 id/identity 定位、从不携带 token 中的 store_identity;Staff.Fetch 鉴权被注释、手机验证码登录从不校验验证码、店铺支付/邮件配置可被任意已登录用户读改。另有 12 个明文占位接口(运费模板 5 个 + 禁运区域 5 个 + 入驻 + 小程序码 + 按规格查询)、item_fetch 分类过滤恒空、store/search 列名错误、spec_create 把 stock_type 当库存写入等确定性缺陷。密码已统一为 bcrypt,仅注释残留 MD5 字样。 |
1. 服务定位与职责
电商后台的经营域服务,围绕"店铺"组织全部资源:店铺基础配置与支付/邮件配置、店铺员工与其登录、商品(含规格、图片、评论、分类)、分类树、广告位、公告、运费模板与禁运区域。租户隔离通过 JWT owner 中的 store_id/store_identity 表达(bsm-sdk/core/types/encipher.go:8、签发见 internal/logic/staff/login.go:88)。
不负责:订单与购物车(module/ec/order)、地址库(module/ec/address)、供应商主数据(module/ec/market,本模块通过跨库表 market_supply 直读)。
2. 代码结构与入口
| 路径 | 职责 |
|---|---|
cmd/main/main.go |
独立进程入口:config.New → impl.NewImpl → server.New(nil) → service.New → srv.Start() |
cmd/cli/main.go |
命令行占位入口,仅 log.Println("Hello World!")(cmd/cli/main.go:6) |
internal/config/config.go |
配置结构(Base/Databases/MicroService/Rpc/Gateway/Apm/Etcd)与端口、日志、密钥初始化 |
internal/impl/impl.go |
初始化 Memory、Redis、DB、Etcd 四个全局实例 |
internal/password/password.go |
bcrypt 封装:Hash/Verify(唯一有测试的包) |
internal/excode/ex.go |
店铺域错误码 ErrStoreNotFound/ErrStoreDisabled |
internal/models/** |
16 张表的 GORM 模型 + 少量模型层 SQL 工具函数 |
internal/logic/{staff,store,product,category,freight,ads,notice}/** |
业务逻辑,67 个文件,按服务域分包 |
internal/server/** |
protoc-gen-slc 生成的 gRPC 服务端桩(7 个 server + new.go 注册) |
pb/** |
protoc 生成的 pb / grpc / gateway 代码(22 个文件,本次只看接口签名) |
service/expose.go、service/dependencies.go |
聚合宿主注入:注册 7 组 gRPC + Gateway handler,并允许外部覆盖 Redis/Etcd/DB/Cache |
proto/*.proto |
8 个契约文件(const 为公共消息块) |
etc/mall_{dev,test,prod}.yaml |
三套配置,均为 postgres + Port: 12420/Gateway.Port: 12419 |
test/readme.md |
仅一行 "restful test",无实际用例 |
依赖注入:service.Dependencies(service/dependencies.go:12-17)在 pkgs/all 中由共享连接覆盖 impl 全局量;Expose 同时把 internal/server 的 7 个服务注册到宿主的 gRPC Server 与 Gateway Mux(service/expose.go:23-43)。
3. 接口清单
共 7 个服务、60 个 RPC,全部映射为 POST /mall.<Service>/<Method>(pb/*.pb.gw.go 的 MustPattern,如 pb/staff.pb.gw.go:536-542、"审核" 列取自各 logic 文件实际调用的 service.ParseMetaCtx 参数)。
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
|---|---|---|---|---|
| Login | /mall.Staff/Login |
员工登录(账密 / 手机验证码) | 无需 | logic/staff/login.go:23 |
| Fetch | /mall.Staff/Fetch |
员工列表 | 无(鉴权被注释) | logic/staff/fetch.go:15(注释见 :17-20) |
| Create | /mall.Staff/Create |
创建员工 | Mall_Admin | logic/staff/create.go:20 |
| Delete | /mall.Staff/Delete |
删除员工 | Mall_Admin | logic/staff/delete.go:16 |
| GetProfile | /mall.Staff/GetProfile |
员工资料 | Mall_Admin | logic/staff/get_profile.go:16 |
| SetProfile | /mall.Staff/SetProfile |
改自己资料 | Mall_Admin | logic/staff/set_profile.go:16 |
| SetPassword | /mall.Staff/SetPassword |
改自己密码 | Mall_Admin | logic/staff/set_password.go:18 |
| ApplyJoin | /mall.Store/ApplyJoin |
申请入驻 | Mall_Admin | logic/store/apply_join.go:12 占位 |
| Licensing | /mall.Store/Licensing |
店铺许可授权 | 无 | logic/store/licensing.go:15 半占位 |
| GetSetting | /mall.Store/GetSetting |
读店铺基础配置 | 无 | logic/store/get_setting.go:14 |
| SetSetting | /mall.Store/SetSetting |
写店铺基础配置 | Mall_Admin(不校验店铺归属) | logic/store/set_setting.go:15 |
| SetPayment | /mall.Store/SetPayment |
写支付配置 | token(任意角色) | logic/store/set_payment.go:16 |
| SetEmail | /mall.Store/SetEmail |
写邮件配置 | token(任意角色) | logic/store/set_email.go:16 |
| GetPayment | /mall.Store/GetPayment |
读支付配置 | token(任意角色) | logic/store/get_payment.go:14 |
| GetEmail | /mall.Store/GetEmail |
读邮件配置 | token(任意角色) | logic/store/get_email.go:14 |
| MiniCode | /mall.Store/MiniCode |
小程序推荐码 | Mall_Admin | logic/store/mini_code.go:11 占位(返回 nil) |
| Search | /mall.Store/Search |
店铺搜索 | Mall_Admin | logic/store/search.go:16 |
| ItemFetch | /mall.Product/ItemFetch |
商品列表 | 无 | logic/product/item_fetch.go:15 |
| ItemDetail | /mall.Product/ItemDetail |
商品详情 | 无 | logic/product/item_detail.go:14 |
| ItemDetailBySerial | /mall.Product/ItemDetailBySerial |
按序号批量取商品 | 无 | logic/product/item_detail_by_serial.go:16 |
| ItemDetailBySpec | /mall.Product/ItemDetailBySpec |
按规格编号取商品 | 无 | logic/product/item_detail_by_spec.go:11 占位(返回 nil,nil) |
| ItemCreate | /mall.Product/ItemCreate |
新建/更新商品 | Mall_Admin | logic/product/item_create.go:18 |
| ItemDelete | /mall.Product/ItemDelete |
删除商品 | Mall_Admin(不校验店铺归属) | logic/product/item_delete.go:16 |
| ItemModify | /mall.Product/ItemModify |
上下架/禁用 | Mall_Admin(不校验店铺归属) | logic/product/item_modify.go:16 |
| ItemBatchOp | /mall.Product/ItemBatchOp |
批量改状态 | Mall_Admin(不校验店铺归属) | logic/product/item_batch_op.go:16 |
| SpecFetch | /mall.Product/SpecFetch |
规格列表 | Mall_Admin(不校验店铺归属) | logic/product/spec_fetch.go:15 |
| SpecCreate | /mall.Product/SpecCreate |
新建规格 | Mall_Admin | logic/product/spec_create.go:17 |
| SpecModify | /mall.Product/SpecModify |
改规格 | Mall_Admin(不校验店铺归属) | logic/product/spec_modify.go:15 |
| SpecDelete | /mall.Product/SpecDelete |
删规格 | Mall_Admin(不校验店铺归属) | logic/product/spec_delete.go:16 |
| SpecDetail | /mall.Product/SpecDetail |
规格详情 | 无 | logic/product/spec_detail.go:14 |
| PhotoCreate | /mall.Product/PhotoCreate |
加图片 | Mall_Admin | logic/product/photo_create.go:17 |
| PhotoDelete | /mall.Product/PhotoDelete |
删图片 | Mall_Admin(不校验店铺归属) | logic/product/photo_delete.go:16 |
| PhotoList | /mall.Product/PhotoList |
图片列表 | 无 | logic/product/photo_list.go:14 |
| CommentFetch | /mall.Product/CommentFetch |
评论列表 | 无 | logic/product/comment_fetch.go:15 |
| CommentCreate | /mall.Product/CommentCreate |
发评论/回复 | is_comment=true 时任意 token,否则 Mall_Admin |
logic/product/comment_create.go:18-24 |
| CommentRe | /mall.Product/CommentRe |
商家回复 | Mall_Admin | logic/product/comment_re.go:17 |
| CommentDelete | /mall.Product/CommentDelete |
删评论 | Mall_Admin(不校验店铺归属) | logic/product/comment_delete.go:16 |
| CommentModify | /mall.Product/CommentModify |
改评论 | Mall_Admin(不校验店铺归属) | logic/product/comment_modify.go:15 |
| Fetch | /mall.Category/Fetch |
分类树 | 无 | logic/category/fetch.go:13 |
| Create | /mall.Category/Create |
建分类 | Mall_Admin | logic/category/create.go:16 |
| Delete | /mall.Category/Delete |
删分类 | Mall_Admin(不校验店铺归属) | logic/category/delete.go:16 |
| Modify | /mall.Category/Modify |
改分类 | Mall_Admin(不校验店铺归属) | logic/category/modify.go:15 |
| Fetch | /mall.Freight/Fetch |
运费模板列表 | Mall_Admin | logic/freight/fetch.go:11 占位 |
| Create | /mall.Freight/Create |
建运费模板 | Mall_Admin | logic/freight/create.go:13 占位 |
| Modify | /mall.Freight/Modify |
改运费模板 | Mall_Admin | logic/freight/modify.go:13 占位 |
| Delete | /mall.Freight/Delete |
删运费模板 | token(任意角色) | logic/freight/delete.go:13 占位 |
| Detail | /mall.Freight/Detail |
运费模板详情 | Mall_Admin | logic/freight/detail.go:12 占位 |
| DenyRegionFetch | /mall.Freight/DenyRegionFetch |
禁运区域列表 | Mall_Admin | logic/freight/deny_region_fetch.go:11 占位 |
| DenyRegionCreate | /mall.Freight/DenyRegionCreate |
建禁运区域 | Mall_Admin | logic/freight/deny_region_create.go:13 占位 |
| DenyRegionDelete | /mall.Freight/DenyRegionDelete |
删禁运区域 | token(任意角色) | logic/freight/deny_region_delete.go:12 占位 |
| DenyRegionModify | /mall.Freight/DenyRegionModify |
改禁运区域 | Mall_Admin | logic/freight/deny_region_modify.go:12 占位 |
| ByPos | /mall.Ads/ByPos |
按广告位取广告 | 无 | logic/ads/by_pos.go:13 |
| Fetch | /mall.Ads/Fetch |
广告列表 | 无 | logic/ads/fetch.go:14 |
| Create | /mall.Ads/Create |
建广告 | Mall_Admin | logic/ads/create.go:18 |
| Modify | /mall.Ads/Modify |
改广告 | Mall_Admin(不校验店铺归属) | logic/ads/modify.go:17 |
| Delete | /mall.Ads/Delete |
删广告 | Mall_Admin(不校验店铺归属) | logic/ads/delete.go:16 |
| Fetch | /mall.Notice/Fetch |
公告列表 | 无 | logic/notice/fetch.go:14 |
| Create | /mall.Notice/Create |
建公告 | Mall_Admin | logic/notice/create.go:18 |
| Modify | /mall.Notice/Modify |
改公告 | Mall_Admin(不校验店铺归属) | logic/notice/modify.go:17 |
| Delete | /mall.Notice/Delete |
删公告 | Mall_Admin(不校验店铺归属) | logic/notice/delete.go:16 |
契约以 proto 声明的类型为准(
proto/*.proto),实现层的取参方式与 proto 一致;但pb/生成代码只读了接口签名,未逐行审计。
声明但未实现 / 占位的接口(已核实)
| 接口 | 位置 | 现状 |
|---|---|---|
Store.ApplyJoin |
logic/store/apply_join.go:19-27 |
校验后仅剩两行 // TODO,直接返回 Code:0 "OK",不写 mall_apply、不建店铺与管理员 |
Store.Licensing |
logic/store/licensing.go:25,30 |
只按 subdomain 查店铺返回 identity;请求中的 license_type/license 未参与校验(// TODO: valid code),// TODO: add your logic code 仍保留 |
Store.MiniCode |
logic/store/mini_code.go:18-22 |
// TODO 后直接 return,MiniCodeReply 为 nil,调用方拿到空消息 |
Product.ItemDetailBySpec |
logic/product/item_detail_by_spec.go:16-20 |
// TODO 后 return 空 ListItem + nil error |
Freight.{Fetch,Create,Modify,Delete,Detail} |
logic/freight/{fetch.go:26,create.go:24,modify.go:24,delete.go:25,detail.go:24} |
5 个方法只做入参非空检查后返回固定 Code:0 "OK"(Fetch/Detail 直接裸 return 空 reply),mall_freight/mall_freight_attr 表无任何读写 |
Freight.{DenyRegionFetch,DenyRegionCreate,DenyRegionDelete,DenyRegionModify} |
logic/freight/deny_region_{fetch.go:25,create.go:24,delete.go:24,modify.go:19-21}.go |
同上,5 个方法全为空实现,mall_freight_deny 表无任何读写 |
Freight.Remove、Freight.DenyRegionRemove |
logic/freight/remove.go:16、logic/freight/deny_region_remove.go:13 |
反向问题:这两个方法写了真实删库逻辑,但 pb.FreightServer 接口只有 DenyRegionDelete/DenyRegionModify(pb/freight_grpc.pb.go 的 FreightServer),internal/server/freight_server.go 也只转调 freight.Delete/freight.DenyRegionDelete → 二者是不可达的死代码 |
models.InitData |
internal/models/query.go:36 |
默认店铺 + root 账号的初始化函数全仓无调用点,入口不会执行 |
其余残留 // TODO: add your logic code & delete this line. 但功能已实现的文件:logic/product/item_modify.go:28、logic/product/spec_delete.go:33、logic/product/photo_delete.go:34、logic/notice/modify.go:27、logic/notice/fetch.go:45、logic/staff/fetch.go:49。
4. 数据模型与表
16 张表全部经 GORM AutoMigrate 注册(模型 init() 调 database.AppendMigrate)。
| 表名 | 模型文件 | 主键 / 唯一键 | 说明 |
|---|---|---|---|
mall_store |
internal/models/mall_store.go:15 |
id PK;identity 唯一索引(Std_IICUDS) |
店铺;pay_configs/mail_configs 为 text |
mall_staff |
internal/models/mall_staff.go:19 |
id PK;identity 唯一索引;phone 唯一索引;account 普通索引 |
员工;password 存 bcrypt 串 |
mall_apply |
internal/models/mall_apply.go:16 |
id PK(gorm.Model) |
入驻申请;无 identity 列,且全仓无任何读写代码 |
mall_product |
internal/models/mall_product.go:17 |
id PK;identity 唯一索引;serial_id 索引 |
商品主体 |
mall_product_spec |
internal/models/mall_product_spec.go:11 |
id PK;identity 唯一索引 |
规格;价格单位为分 |
product_spec |
internal/models/mall_product_link_spec.go:9 |
id PK;identity 唯一索引 |
商品-规格关联表(spec_id) |
product_category |
internal/models/mall_product_link_category.go:9 |
id PK;identity 唯一索引 |
商品-分类关联表 |
mall_product_photos |
internal/models/mall_product_photos.go:17 |
id PK;identity 唯一索引;product_id 索引 |
商品相册 |
mall_product_comment |
internal/models/mall_product_comment.go:17 |
id PK;identity 唯一索引 |
评论,自关联 List(comment_identity) |
mall_product_attr |
internal/models/mall_product_attr.go:8 |
id PK |
商品属性;全仓无读写代码 |
mall_category |
internal/models/mall_category.go:10 |
id PK;identity 唯一索引 |
分类树(自关联 Categories) |
mall_ads |
internal/models/mall_ads.go:8 |
id PK;identity 唯一索引 |
广告 |
mall_notice |
internal/models/mall_notice.go:8 |
id PK;identity 唯一索引 |
公告 |
mall_freight |
internal/models/mall_freight.go:10 |
id PK;identity 唯一索引 |
运费模板;无读写代码 |
mall_freight_attr |
internal/models/mall_freight_attr.go:9 |
id PK;identity 唯一索引 |
计费规则;无读写代码 |
mall_freight_deny |
internal/models/mall_freight_deny.go:10 |
id PK;identity 唯一索引 |
禁运区域;无读写代码 |
表 mall_store(internal/models/mall_store.go:15)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK | 自增主键(Std_IICUDS) |
identity |
varchar(36) | uniqueIndex | 店铺唯一标识(UUID) |
created_at/updated_at/deleted_at |
TIMESTAMP | deleted_at 索引 |
软删除 |
status |
int8 | default 0,索引 | 1 正常 / -1 禁止 |
logo/title |
varchar(255) | title not null |
店铺标识与名称 |
intro/template/keywords |
varchar(255) | default '' |
简介、模板、SEO 关键词 |
subdomain |
varchar(255) | not null |
子域名,Licensing/Search 依赖 |
configs |
text | default '' |
基础配置(JSON 字符串) |
pay_configs |
text | default '' |
支付配置(含支付凭据) |
mail_configs |
text | default '' |
邮件服务器配置(含 SMTP 凭据) |
approve |
int8 | default 0 | 0 待审 / 1 通过 / 2 失败 |
表 mall_staff(internal/models/mall_staff.go:19)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id/identity/created_at/updated_at/deleted_at/status |
同 Std_IICUDS |
identity 唯一索引 |
— |
store_id |
uint | PK 成员(Std_Store) |
所属店铺 ID |
store_identity |
varchar(36) | 索引 | 所属店铺标识 |
name |
varchar(255) | 索引,default '' |
姓名 |
account |
varchar(255) | 普通索引(非唯一),default '' |
登录账号;跨店可重名 |
password |
varchar(255) | default '' |
bcrypt 哈希(internal/password/password.go) |
phone |
varchar(20) | uniqueIndex,default '' |
手机号(验证码登录用) |
email/avatar/profile |
varchar(255) | default '' |
邮箱、头像、简介 |
role |
varchar(36) | 索引,default '' |
Mall_Admin / staff |
salt |
varchar(255) | default '' |
遗留盐字段,SetPassword 会显式清空(logic/staff/set_password.go:32) |
last_login_at |
TIMESTAMP | — | 最后登录时间 |
表 mall_product(internal/models/mall_product.go:17)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id/identity/时间/status |
同 Std_IICUDS |
identity 唯一索引 |
status:1 上架 / 2 待上架 / 3 禁用 / 4 草稿 / 5 未上架 |
store_id/store_identity |
uint / varchar(36) | 索引用 | 所属店铺(冗余存储) |
serial_id |
varchar(36) | Index | 产品序号(gas_by_kg/gas_by_bottle 作特殊标识) |
title |
varchar(255) | not null |
商品标题 |
sub_title/measuring_name/brand/standard_number/putaway_time/notes/args/category_paths |
varchar(255) | default | 副标题、计量单位、品牌、标准号、上架时间、备注、参数 JSON、分类路径 |
sell_max/sell_min/wastage/stock_type/stock |
int32 | default | 限购、损耗率、库存计数方式、库存 |
supply_id/template_id/li_template_id/recent_number/limitation/sale_total |
int64 | default | 供应商、运费模板、限区模板、近 30 天销量、区域限制、销售总数 |
sales_price/cost_price/partner_per/star |
int64 | default | 售价/成本价/分成/评分(sales_price 本模块从不写入) |
recommend/types/gas_types/comment_total/view_total/image_total/sort |
int8/int32 | default | 推荐位、商品类型、燃气类型、计数、排序 |
content |
text | default '' |
商品详情 |
| 关联 | — | foreignKey:ProductIdentity;references:Identity |
Images、Specs、Categories(mall_product.go:58-60) |
表 mall_product_spec(internal/models/mall_product_spec.go:11)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK(gorm.Model) |
自增主键 |
identity |
varchar(36) | 唯一索引 | 规格唯一标识(UUID) |
product_identity 经 product_spec 关联 |
— | product_spec.spec_id |
商品归属靠关联表表达 |
title |
varchar(255) | not null |
规格名称 |
stock_type |
int32 | default 2 | 1 设置库存限制 / 2 不限制 |
stock |
int64 | default 0 | 库存数量(订单侧扣减目标,module/ec/order/internal/logic/summary/submit.go:209) |
price |
int64 | default 0 | 销售价(分) |
direct_sales |
int64 | default 0 | 直营价(分) |
purchasing_price |
int64 | — | 进货价(分),被匿名接口返回 |
wholesale |
int64 | — | 代理价(分),被匿名接口返回 |
tp_type/ds_tp_type |
int32 | default 2 | 阶梯价开关 |
img/spec_no/keyword/serial_number |
varchar | default '' |
预览图、规格编号、关键词、产品编号 |
store_identity |
varchar(36) | — | 店铺标识(由请求传入,见 6.1) |
无身份维度的跨库读取:
logic/product/item_detail.go:31直接查market_supply(market 模块的表);logic/product/item_fetch.go:51-63直接 joinproduct_category/mall_category/product_spec/mall_product_spec。
5. 核心流程
flowchart TD
A["POST /mall.Staff/Login"] --> B["LoginGenre"]
B --> C{"1 账密"}
B --> D{"2 手机验证码"}
C --> E["按 account 查 mall_staff(无店铺维度)"]
E --> F["password.Verify(bcrypt)"]
D --> G["按 phone 查 mall_staff"]
G --> H["只校验手机号格式,从不校验 verify_code"]
F --> I["查店铺 status=1"]
H --> I
I --> J["encipher.GenerateTokenAes(Owner=Std_Store)"]
J --> K["更新 last_login_at 并返回 token"]
flowchart TD
A["POST /mall.Product/ItemCreate"] --> B["ParseMetaCtx 需 Mall_Admin"]
B --> C["checkParam(status=4 草稿可跳过)"]
C --> D{"identity 为空"}
D -->|是| E["itemToModel + 从 owner 取 store_id/store_identity"]
D -->|否| F["itemToModel(不回填 store 字段)"]
E --> G["CreateProduct:事务内写主表/规格/图片/分类"]
F --> H["ModifyProduct:先按 identity 查原记录,再事务内先删后建关联"]
G --> I["返回 IdentityStatusReply"]
H --> I
J["POST /mall.Product/ItemFetch"] --> K["第一次分页查询(结果被丢弃)"]
K --> L["按 category_id/keyword/价格区间/sort 组条件"]
L --> M["第二次分页查询 + Preload Images/Specs.Spec/Categories"]
M --> N["ModelsToProductItems 拼装返回"]
6. 审计发现
6.1 安全
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/staff/login.go:45-58 |
手机验证码登录从不校验验证码。login_genre=2 分支只检查 phone/verify_code 非空与手机号正则(:46),随后按手机号查库(:50)并签发 token(:88),校验验证码的位置只留 // TODO: 添加验证码校验逻辑(:58)。任意已注册手机号 + 任意非空 verify_code 即可取得该员工 JWT。 |
| 高 | internal/logic/staff/fetch.go:17-20 |
鉴权代码被注释:Fetch 的 ParseMetaCtx 整段注释掉,/mall.Staff/Fetch 匿名可用。入参只有 store_identity(:28),返回员工 account/phone/email/role/status(:62-73)——只需知道店铺标识即可批量拉取员工手机号与邮箱。 |
| 高 | internal/logic/store/{get_payment.go:16,26,set_payment.go:17,26,get_email.go:15,25,set_email.go:17,26} |
四处都用 service.ParseMetaCtx(ctx, nil),只验 token 不验角色,且定位条件全部取自请求:读接口 Where("id=? or identity=?")、写接口 Where("identity = ?")(set_payment.go:26、set_email.go:26)。任意已登录账号(含 role=staff)可读/改任意店铺的 pay_configs/mail_configs。 |
| 高 | internal/logic/store/get_setting.go:14-23 |
GetSetting 完全没有鉴权调用,入参 id/identity 任一非空即返回该店铺基础配置(含 configs)。 |
| 高 | internal/logic/store/set_setting.go:17,34 |
解析出的 auth 被丢弃(_),更新条件用请求里的 id/identity,任意 Mall_Admin 可改他人店铺的名称、子域名、模板、configs。 |
| 高 | internal/logic/product/{item_modify.go:29,item_batch_op.go:27,item_delete.go:28,spec_modify.go:34,spec_delete.go:28,photo_delete.go:28}、internal/logic/category/{delete.go:28,modify.go:30}、internal/logic/ads/delete.go:28、internal/logic/notice/delete.go:28、internal/logic/freight/remove.go:28、internal/logic/staff/delete.go:28 |
13 处写操作一律只按 id/identity 定位对象,从不带上 token 中的 store_identity。同角色(Mall_Admin)可跨店铺修改/删除商品、规格、图片、评论、分类、广告、公告、运费模板、员工。反例参考:internal/models/mall_product.go:74 ForbiddenProduct 是唯一带 Where("store_identity = ?") 的批量更新实现,但没有任何调用点。 |
| 高 | internal/logic/product/spec_detail.go:14-20、internal/logic/product/item_detail.go:14-24 |
两个接口无鉴权,返回体经 internal/logic/product/ref.go:143-144(PurchasingPrice/Wholesale)与 :54(DirectSales)带出进货价与代理价;SpecDetail 还直接透传 PurchasingPrice(spec_detail.go:38)。 |
| 高 | internal/logic/product/spec_fetch.go:17,22,27 |
仅要求 Mall_Admin 与入参 store_identity,不校验该店铺是否属于调用方,一次请求即可批量拉走他店所有规格的价格、库存、进货价、代理价(无分页上限)。 |
| 高 | internal/logic/store/licensing.go:15-38 |
Licensing 只用 domain 查 subdomain 后返回店铺 identity(:41-49),请求中的许可码 in.License 从未参与校验(:25 // TODO: valid code)。知道子域名即可换取店铺标识。 |
| 中 | internal/logic/product/spec_create.go:82 |
StoreIdentity: in.StoreIdentity 直接采信请求参数,规格所属店铺可被伪造;item_create.go:104-109 只在新建分支用 token 里的店铺字段,更新分支不回填。 |
| 中 | internal/logic/product/item_detail.go:29-36 |
商品详情根据 supply_id 直查跨库表 market_supply 并回填 SupplyName,两个服务共享同一份库/表结构,无编译期约束。 |
| 中 | internal/logic/staff/set_profile.go:31 |
业务查询上残留 impl.DBService.Debug(),会为该条 SQL 打开 GORM 调试日志(含参数)。 |
| 低 | internal/logic/store/search.go:16-21 |
店铺搜索要求 Mall_Admin(:18),但结果集是跨店铺的店铺列表(ModelToReply,:50-73),角色语义不清。 |
6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/product/item_fetch.go:28,50-54 |
idList 声明为空切片(:28)后从未把 in.CategoryId 填入,过滤条件变成 product_category.category_id in ?(空集合,GORM 生成 IN (NULL))→ 按分类筛选时结果恒为空。 |
| 高 | internal/logic/product/item_fetch.go:60-64 |
价格区间:条件是 `if min != 0 |
| 高 | internal/logic/product/item_fetch.go:42-46 与 :77-83 |
同一组分页条件被执行了两次(第一次 Count+Find 到 product,第二次整体覆盖),第一次查询的结果完全作废,DB 开销翻倍。 |
| 高 | internal/logic/store/search.go:39 |
Where("keyword = ?", in.GetKeyword()),而 mall_store 的列名是 keywords(internal/models/mall_store.go:25,keyword 是 mall_product 的列)→ 店铺搜索必然报 Unknown column,接口不可用。 |
| 高 | internal/logic/product/spec_create.go:96-98 |
if in.StockType != 2 { specification.Stock = int64(in.StockType) }:把"库存设置开关"(1 设置限制 / 2 不限制)当库存数量写入,客户端传 stock_type=1 时库存被覆盖为 1;真正的 in.Stock 已在 :85 赋过值,随后被这一行改写。 |
| 高 | internal/logic/product/spec_create.go:37-40、internal/logic/product/photo_create.go:36-38 |
cnt, err := tx.RowsAffected, tx.Error; if cnt == 0 || err != nil { printer.Error(err.Error()) }:cnt==0 而 err==nil 时对 nil 调用 err.Error() → panic,服务 500。 |
| 高 | internal/logic/product/item_detail_by_serial.go:22 |
Preload("Spec")/Preload("Category") 与模型声明的关联名 Specs/Categories(internal/models/mall_product.go:59-60)不一致。GORM 无法解析未声明的关联(unsupported relation),规格与分类不会加载且查询直接报错 → 该接口目前无法正常返回数据(对比同模块 item_detail.go:20 用的是正确名 Specs.Spec)。 |
| 中 | internal/logic/product/comment_modify.go:22,31 |
校验要求 comment 与 nick_name 非空(:22),但 Select("score","reply","product_identity","store_identity","spec_identity") 把这两列排除在更新范围外 → 评论正文和昵称永远改不了。 |
| 中 | internal/logic/product/comment_re.go:24,31 |
要求 comment_identity 非空(:24),写入时却用 CommentIdentity: in.Identity(:31)→ 被回复评论标识写成了回复自身。 |
| 中 | internal/logic/product/comment_delete.go:28 |
Where("id=? or identity=? or comment_identity=?", in.GetId(), in.GetIdentity(), in.GetIdentity()),第三个条件把同一个 identity 当作 comment_identity,一次删除会连带删掉"以它为被回复对象"的所有回复。 |
| 中 | internal/logic/ads/modify.go:35-38、internal/logic/notice/modify.go:32-34 |
两处都在结构体里设置 Status,但 Select 白名单不含 status(ads 为 title,pos_key,content,type,to_url,notice 为 title,author,content)→ 广告/公告的启用与禁用实际不生效。 |
| 中 | internal/logic/store/get_setting.go:26-38 |
返回体未设置 Status(proto StoreBasic.status 恒为 0,proto/store.proto:86),前端无法区分店铺是否停用。 |
| 中 | internal/logic/staff/login.go:71,79-81 |
查询已带 status = 1,随后的 if storeData.Status == 0 永不成立(死判断);login.go:32 按 account 查而不带店铺维度,mall_staff.account 只是普通索引(internal/models/mall_staff.go:23)而非唯一键 → 跨店同名账号时取到任意一条记录,可能落到错误店铺。 |
| 中 | internal/logic/product/item_create.go:38-44、internal/models/mall_product.go:153 |
更新走 ModifyProduct,其 tx.Where("identity = ?", product.Identity).Updates(product) 传的是结构体,GORM 跳过零值字段 → 已填字段无法清空(如把 sell_max 改回 0、把 notes 清空)。 |
| 中 | internal/logic/category/fetch.go:19-24、internal/models/query.go:75 |
types(all/first)只影响是否递归子集,GetCategoryList 恒以 parent_id = 0 过滤顶层,两个取值语义无差别;types=first 的层级语义未落地。 |
| 中 | internal/logic/ads/by_pos.go:20 |
Find(&result) 的目标是 []*pb.AdsItem(protobuf 结构体),直接让 GORM 把 mall_ads 行扫进 pb 类型,字段映射依赖命名约定而非显式列;同模块其它查询都先落 models.MallAds 再 ref() 转换(ads/fetch.go:39-49)。 |
| 低 | internal/logic/product/item_modify.go:24 |
if in.GetIdentity() == "" || in.GetStatus() == 0 拒绝 status=0,但 proto 未定义 0,实际等价于"必须传 status";上传未上架时无法回退到 0。 |
| 低 | 多处 | 时间戳单位混用:item_create.go:55 用 UnixMilli(),ads/create.go:51、category/modify.go:40、freight/create.go:29、freight/deny_region_* 用 Unix()(秒),item_delete.go:37、photo_delete.go:39 用 UnixNano()。 |
| 低 | internal/logic/store/get_payment.go:26、internal/logic/store/get_email.go:25 |
用 Find 而非 First:查不到店铺时返回空 Configs 而不报错,调用方无法区分"无配置"与"店铺不存在"。 |
6.3 未完成实现
完整占位清单见第 3 节末尾表格。归纳:
- 运费与禁运区域整体未实现:
Freight服务 9 个 RPC 中 9 个为空实现(Create/Modify/Delete/Detail/Fetch/DenyRegion*),mall_freight、mall_freight_attr、mall_freight_deny三张表建好了但无任何读写代码。前端调用会拿到Code:0 "OK"的假成功。 - 入驻与授权:
Store.ApplyJoin返回假成功(logic/store/apply_join.go:19-27),mall_apply表全仓无读写;Store.Licensing的许可码校验缺失(logic/store/licensing.go:25)。 - 小程序码:
Store.MiniCode为空(logic/store/mini_code.go:18-22),返回 nil reply。 - 按规格查询:
Product.ItemDetailBySpec为空(logic/product/item_detail_by_spec.go:16-20)。 - 批量操作:
Product.ItemBatchOp已实现,但只用Where("identity in ?")(logic/product/item_batch_op.go:27),已有的ForbiddenProduct(带店铺约束,internal/models/mall_product.go:74)未被接线。 - 反向未接线:
freight/remove.go:16、freight/deny_region_remove.go:13实现完整但不在 proto 服务接口中(pb/freight_grpc.pb.go的FreightServer只有DenyRegionFetch/Create/Delete/Modify),internal/server/freight_server.go未转调 → 死代码。 - 初始化未接线:
internal/models/query.go:36 InitData(建默认店铺 +root/123456管理员)无调用点。
6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
|---|---|---|
| 中 | internal/config/config.go:38 |
conf.NotNil(Spec.Service, Spec.Cache) 未校验 Databases,而 internal/impl/impl.go:26 直接用 config.Spec.Databases;漏配时 DB 为 nil,首个查询即空指针。 |
| 中 | internal/server/new.go:26-30 + cmd/main/main.go:35 |
Server 结构体有 Mux 字段(new.go:16)但 New() 从未初始化,main.go:35 把它作为 GatewayMux 传给 SDK;Server.Mux 为 nil 时 SDK 的 http.ListenAndServe(addr, mux)(bsm-sdk/core/service/service.go:126)实际挂的是 http.DefaultServeMux,service/expose.go:23-43 注册的 8 组 HTTP 路由在独立进程里不会生效(全部 404)。 |
| 中 | internal/logic/store/search.go:34、internal/logic/ads/fetch.go:34、internal/logic/notice/fetch.go:35、internal/logic/product/comment_fetch.go:38、internal/logic/staff/fetch.go:37 |
分页一律写成 if pageSize < 50 { pageSize = 50 },只有下限没有上限;item_fetch.go:37 只在 <= 0 时兜底 10。客户端传 page_size=1000000 即触发全表扫描与超大响应。 |
| 中 | internal/logic/store/{get_setting.go:21,set_setting.go:35,get_payment.go:27,get_email.go:26} |
错误上报用 Go 内置 print(err.Error())(写 stderr、无级别、无结构),与同模块其它位置的 printer.Error 混用。 |
| 中 | internal/models/mall_product.go:74,80,206,249、internal/models/mall_product_spec.go:41,48,58、internal/models/mall_product_photos.go:38,48、internal/models/mall_product_comment.go:46,52,58,64 |
13 个模型层函数全仓无调用者(ForbiddenProduct、ProductDetail、DelProduct、GetProductList、DetailSpec、SpecList、DelSpec、PhotoList、DelPhoto、DelComment、CreateComment、ReComment、ModifyComment)。其中 DelProduct(事务级联删评论/图片/规格/分类)比实际使用的单表删除(item_delete.go:28)更完整,却没有接线。 |
| 中 | internal/logic/product/item_detail.go:35 |
reply.SupplyName = supply["name"].(string) 无保护类型断言,且 supply 来自跨库表 market_supply;该列为 NULL 或类型变化即 panic。 |
| 低 | internal/impl/impl.go:22 + service/dependencies.go:30 |
MemorySerice 拼写错误(应为 MemoryService),且除依赖注入赋值外全模块无使用点。 |
| 低 | 全模块 | 仅 1 个测试文件 internal/password/password_test.go(bcrypt 正反例);test/readme.md 仅一行 "restful test";67 个 logic 文件 0 测试。 |
| 低 | cmd/cli/main.go:6 |
CLI 入口只打印 "Hello World!"。 |
| 低 | README.md:39,219-229,375-389 与 etc/mall_prod.yaml:2,5-7,24 |
README 要求 MySQL 8.0+、端口写 12101/12102,实际配置是 Driver: postgres、Port: 12420、Gateway.Port: 12419;README 的"缓存策略表/连接池/读写分离"在代码中不存在(impl.go:24-28 只建了连接,无缓存读写)。 |
| 低 | etc/mall_dev.yaml:19-20(prod/test 同) |
Anonymous: - mall.ping.hello,但 8 个 proto 文件与全部 gw 路由中都不存在 ping 方法,是死配置。 |
| 低 | internal/logic/staff/login.go:40 |
注释写"使用MD5加盐验证",实际调用 password.Verify(bcrypt,:41)。全仓已无 MD5 实现(md5 仅此一处注释命中);但历史以 MD5 入库的账号 bcrypt.CompareHashAndPassword 必然失败,等于无法登录,且 mall_staff.salt 字段(mall_staff.go:30)无迁移处理。 |
7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| M1 | 高 | 手机验证码登录不校验验证码 | 任意账号接管 |
| M2 | 高 | 支付/邮件配置可被任意已登录用户读改(4 个接口 + 无鉴权的 GetSetting) | 店铺资金与邮件凭据泄露、被篡改 |
| M3 | 高 | 12 处写操作无店铺归属校验(商品/规格/图片/分类/广告/公告/运费/员工),评论的改删另见 M13 | 跨店数据被改删 |
| M4 | 高 | Staff.Fetch 鉴权被注释 |
员工手机号/邮箱批量泄露 |
| M5 | 高 | 进货价/代理价经无鉴权的 ItemDetail、SpecDetail 与无归属校验的 SpecFetch 外泄 |
商业成本信息泄露 |
| M6 | 高 | Licensing 不校验许可码 |
店铺标识可被枚举获取 |
| M7 | 高 | item_fetch 分类过滤恒空、价格区间单边失效、重复查询 |
商品列表功能不可用 + DB 压力翻倍 |
| M8 | 高 | store/search.go:39 列名错误 |
店铺搜索完全不可用 |
| M9 | 高 | spec_create.go:96-98 库存被写为 stock_type |
库存数据错误、影响下单扣减 |
| M10 | 高 | spec_create.go:38、photo_create.go:37 nil error 调 Error() |
服务 panic |
| M11 | 高 | item_detail_by_serial.go:22 Preload 关联名不存在 |
接口不可用 |
| M12 | 高 | 12 个接口空实现/假成功(运费 9 个 + 入驻 + 小程序码 + 按规格查询) | 业务功能缺失且无错误反馈 |
| M13 | 中 | 评论修改/回复/删除的目标字段写错或被 Select 排除 |
评论功能行为异常、误删 |
| M14 | 中 | 广告/公告状态更新被 Select 白名单排除 |
启停失效 |
| M15 | 中 | 登录跨店同名账号、店铺状态死判断 | 登录归属不确定 |
| M16 | 中 | GORM 分页无上限、print 上报、死代码、无测试 |
可维护性与抗压能力 |
| M17 | 中 | Databases 未校验、Server.Mux 未初始化 |
启动后运行期崩溃 / 独立进程 HTTP 全 404 |
| M18 | 低 | README 与配置不符、Anonymous 死配置、MD5 注释残留 |
认知成本与迁移风险 |
8. 修复建议(务实项)
- 补验证码校验(M1):
login.go:58的 TODO 必须落地——按phone取出服务端存的一次性验证码,比对in.VerifyCode及其有效期,成功后立即删除;在此之前建议直接对该分支返回ErrUnimplemented,不要签发 token。 - 恢复
Staff.Fetch鉴权并收敛返回字段(M4):取消fetch.go:17-20的注释,改为&service.ParseOptions{RoleValue: "Mall_Admin"},并把store_identity从入参改为取 token 中的owner,ModelToReply去掉phone/email(或按需脱敏)。 - 给所有写操作补店铺归属约束(M3):统一在
Where后追加store_identity = ?(值取自auth.Owner)。已有现成范例:internal/models/mall_product.go:75的ForbiddenProduct,把item_batch_op.go:27、item_modify.go:29、item_delete.go:28、spec_*、photo_delete.go:28、分类/广告/公告/员工的对应Delete/Modify/Updates都对齐这个写法;SpecCreate的StoreIdentity(spec_create.go:82)改为从 token 取。 - 收口配置类接口(M2):
get_payment/set_payment/get_email/set_email把ParseMetaCtx(ctx, nil)改成RoleValue: "Mall_Admin",并把identity换成 token 中的store_identity(或校验两者相等后拒绝不匹配);GetSetting补上与SetSetting相同的鉴权;返回pay_configs/mail_configs时对凭据字段做掩码。 item_fetch三处修复(M7):item_fetch.go:28改为idList = in.GetCategoryId()并删掉:42-46的第一次查询;价格过滤拆成"只传 min / 只传 max / 都传"三分支;join 规格后加Group("mall_product.id")或改用DISTINCT。store/search.go:39改列名:Where("keywords like ?", "%"+in.GetKeyword()+"%")(或按业务决定不加过滤),并补page_size上限。spec_create.go修正:删除:96-98这行,库存只认in.Stock;把:38-40与photo_create.go:37-39的err.Error()改成先判err != nil再打印(或直接return errcode.ErrDB)。item_detail_by_serial.go:22改关联名:Preload("Specs.Spec")、Preload("Categories.Category"),与item_detail.go:20保持一致。comment_modify.go:31:把comment、nickname加进Select白名单;comment_re.go:31改为CommentIdentity: in.GetCommentIdentity();comment_delete.go:28去掉第三个comment_identity=?条件或改用独立入参。ads/modify.go:38、notice/modify.go:34:把status加进Select,或改用map[string]any显式指定要更新的列。- 占位接口二选一:
Freight的 9 个方法与ApplyJoin/MiniCode/ItemDetailBySpec要么按 proto 实现,要么暂时返回errcode.ErrUnimplemented,不要返回Code:0 "OK";同时把freight/remove.go、freight/deny_region_remove.go接到pb.FreightServer上(或删除,避免两份实现并存)。 - 配置与启动:
config.go:38的conf.NotNil补上Databases;internal/server/new.go初始化Mux: gwRuntime.NewServeMux(),否则独立进程的 HTTP 入口无任何路由。 - 分页上限:在
search.go:34、ads/fetch.go:34、notice/fetch.go:35、comment_fetch.go:38、staff/fetch.go:37、item_fetch.go:37统一补if pageSize > 200 { pageSize = 200 }。 - 清理与测试:删除
set_profile.go:31的.Debug()、get_setting.go等 4 处print(err.Error())与 6 处 TODO 残留;为login(含验证码分支)、item_fetch、spec_create、item_create各补 1 条正例 + 1 条边界用例;README 的驱动与端口按etc/*.yaml更新。
本报告只列出与现有实现直接相关的修复项,不引入新的分层或抽象封装。仓储里"为 models 层加统一查询构造器""引入 DTO/VO 转换层""把跨库表访问抽象为 RPC 客户端"一类改造不在此列。
9. 整改记录(2026-09-22)
本节记录按本报告结论执行的代码整改。整改遵循最小修正原则:未引入新框架、抽象层、DTO/VO、事件总线,未拆分服务边界,未修改任何
proto/*.proto与生成的pb/*.go;新增/修改注释均为中文;口令类摘要统一使用 bcrypt(验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:GOWORK=off go build ./...+GOWORK=off go vet ./...+gofmt -l(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import,属本机既有问题)。
| 编号 | 级别 | 问题 | 处理结果 |
|---|---|---|---|
| M1 | 高 | 员工手机验证码登录不校验验证码 | 已修复:按 /SMS/Code/ + 手机号的既有 Redis 键规则真实校验,成功后删除键 |
| M2 | 高 | 支付/邮件配置可被任意已登录用户读改(4 接口 + 无鉴权的 GetSetting) |
已修复:统一增加「当前登录者属于该店铺」的归属校验,越权返回 ErrPermissionDenied;SetSetting 亦从「凭请求 identity 直接 Updates」改为以 token 中的店铺归属为准,并检查 RowsAffected,避免改到他人店铺却静默成功 |
| M3 | 高 | 12 处写操作无店铺归属校验(商品/规格/图片/分类/广告/公告/运费/员工) | 已修复:逐处补归属条件(条件中直接带归属字段,RowsAffected == 0 即视为越权或不存在) |
| M4 | 高 | Staff.Fetch 鉴权被注释,员工手机号/邮箱批量泄露 |
已修复:恢复鉴权 |
| M5 | 高 | 进货价/代理价经 ItemDetail、SpecDetail、SpecFetch 外泄 |
已修复:匿名或非本店调用者不下发成本类价格字段,本店可正常查看 |
| M6 | 高 | Licensing 不校验许可码,店铺标识可被枚举 |
已修复(安全降级):要求已登录并校验归属。完整许可校验缺少数据源,未做 |
| M7 | 高 | item_fetch 分类过滤恒空、价格区间单边失效、重复查询 |
已修复:修正过滤条件与重复查询 |
| M8 | 高 | store/search.go 列名错误导致店铺搜索完全不可用 |
已修复:按模型真实列名修正 |
| M9 | 高 | spec_create.go 库存被写为 stock_type |
已修复:库存写入正确字段 |
| M10 | 高 | spec_create.go/photo_create.go nil error 调 Error() 导致 panic |
已修复:先判 err 非空再记录 |
| M11 | 高 | item_detail_by_serial.go Preload 关联名不存在 |
已修复:按模型真实关联名修正 |
| M12 | 高 | 12 个接口空实现/假成功(运费 9 个 + 入驻 + 小程序码 + 按规格查询) | 已修复:全部改为显式返回 errcode.ErrUnimplemented,不再假成功;方法签名与 proto 保持不变。其中 freight.DenyRegionRemove 在首轮整改中被遗漏,本轮已补齐(共 9 个运费接口) |
未纳入本轮范围
报告中「中」「低」级别的项(分页上限、死代码、README 与实现不符、单测缺失、可维护性等)本轮未处理;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
本轮整改未修改任何
proto/*.proto与pb/*.go,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。