41 KiB
order(购物车与订单生命周期)代码审计报告
| 项 | 内容 |
|---|---|
| 审计对象 | module/ec/order |
| 服务域 | 电商-购物车与订单生命周期 |
| 审计日期 | 2026-09-22 |
| 代码规模 | Go 文件 62 个(pb/ 生成代码 13 个,手写 49 个,手写约 3098 行;含生成代码共 11922 行);proto/ 5 个 618 行;internal/logic/ 25 个文件(cart/coupon/mgt/summary/common);test/rpc/ 6 个文件 |
| 入口 | cmd/main(gRPC + HTTP Gateway 单进程)、聚合入口 pkgs/all 经 service.Expose 复用共享 gRPC/Gateway |
| 对外协议 | gRPC + HTTP Gateway,路径 /order.<Service>/<Method>,全部为 POST |
| 结论摘要 | 订单状态推进的 3 个"模拟"接口(支付/发货/收货)在配置中已开启 Gateway 且无角色校验,可被任意已登录账号调用、并把订单推到已支付/已发货;OrderApprove 完全无鉴权。跨服务数据库耦合是结构性问题:etc/order_prod.yaml 的 DSN 就是 mall 的库,代码直接读写 mall_product/mall_product_spec/mall_store/address_library。业务侧有 3 处确定性错误会直接产生错误金额或错误库存:优惠券计算把金额加到总额(confirm.go:72)、submit 扣库存不检查 RowsAffected(submit.go:209-217)、多店铺下单 store_id 恒为 0(submit.go:82,166)。管理端 OrderModify 为假成功占位。 |
1. 服务定位与职责
围绕一张购物车与一张订单主表,提供两条下单链路(购物车提交 Summary.Submit、单商品快速下单 Summary.QuickCreateByProduct / 管理端 Mgt.OrderCreate)以及订单的查询、确认(运费/优惠券)、取消、售后审批、模拟交易推进。
不负责:商品与规格主数据(module/ec/mall)、收货地址(module/ec/address)、运费模板(mall 侧未实现)、真实支付与物流对接(本模块用"模拟"接口代替)。
2. 代码结构与入口
| 路径 | 职责 |
|---|---|
cmd/main/main.go |
独立进程入口:config.New → impl.NewImpl → server.New(nil) → service.New → srv.Start() |
internal/config/config.go |
配置结构(Base/Databases/MicroService/Rpc/Gateway/Apm/Etcd)与端口、密钥初始化 |
internal/impl/impl.go、internal/impl/with.go |
Redis / DB / Etcd 初始化;withDatabases 缺少源时直接 panic |
internal/models/impl.go |
独立的 DB 装配层(models.New/NewMysql/NewPostgres),持有 models.DBService 并 AutoMigrate 4 张表 |
internal/models/*.go |
4 个 GORM 模型 + 跨库表的本地投影结构体(Product/Spec/OrderAddress)+ 3 个未接线查询函数 |
internal/logic/common/{no,get,reflect}.go |
订单号生成、按 identity/order_no 取订单、模型到 pb 的转换 |
internal/logic/{cart,coupon,mgt,summary}/** |
业务逻辑,25 个文件 |
internal/server/** |
protoc-gen-slc 生成的 gRPC 服务端桩(4 个 server + new.go 注册) |
pb/** |
protoc 生成的 pb / grpc / gateway 代码(13 个文件,本次只看接口签名) |
service/expose.go、service/dependencies.go |
聚合宿主注入:注册 4 组 gRPC + Gateway handler,并允许外部覆盖 Redis/Etcd/DB/Cache |
proto/*.proto |
5 个契约文件(const 为公共消息块) |
etc/order_{dev,test,prod}.yaml |
三套配置,均为 postgres + Port: 12442/Gateway.Port: 12441 |
test/rpc/*.go |
6 个手工联调用例(全部依赖 //go:build integration 与 BSM_ORDER_TOKEN 环境变量) |
README.md |
仅一行标题 # order(README.md:1-2),无任何说明 |
依赖注入:service.Dependencies(service/dependencies.go:12-17)由 pkgs/all 传入共享连接覆盖 impl 全局量(pkgs/all/internal/service/order.go:10-19);注意 applyDependencies(service/dependencies.go:19-29)未处理 Cache 字段。
3. 接口清单
共 4 个服务、22 个 RPC,全部映射为 POST /order.<Service>/<Method>(pb/*.pb.gw.go 的 MustPattern,如 pb/summary.pb.gw.go:728-737)。"鉴权"列取自各 logic 文件实际调用的 service.ParseMetaCtx 参数。
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
|---|---|---|---|---|
| Fetch | /order.Cart/Fetch |
购物车列表 | token(任意角色) | logic/cart/fetch.go:16-17 |
| Create | /order.Cart/Create |
加入购物车 | token(任意角色) | logic/cart/create.go:20-21 |
| Modify | /order.Cart/Modify |
改数量 | token(任意角色) | logic/cart/modify.go:17-19 |
| Delete | /order.Cart/Delete |
删除购物车项 | token(任意角色) | logic/cart/delete.go:15-17 |
| ByStatus | /order.Coupon/ByStatus |
按状态取优惠券 | Mall_Admin | logic/coupon/by_status.go:16-18 |
| OrderCreate | /order.Mgt/OrderCreate |
管理端建单 | Mall_Admin + Owner 非空 | logic/mgt/order_create.go:21-30 |
| OrderModify | /order.Mgt/OrderModify |
修改订单 | Mall_Admin + Owner 非空 | logic/mgt/order_modify.go:14-27 占位(假成功) |
| OrderGet | /order.Mgt/OrderGet |
订单详情(B 端) | agency 为空时 Mall_Admin,非空时任意 token |
logic/mgt/order_get.go:19-32 |
| OrderListByStore | /order.Mgt/OrderListByStore |
店铺订单列表 | 同 OrderGet | logic/mgt/order_list_by_store.go:19-32 |
| OrderCancel | /order.Mgt/OrderCancel |
取消订单 | Mall_Admin + Owner 非空 | logic/mgt/order_cancel.go:17-26 |
| OrderReturnable | /order.Mgt/OrderReturnable |
申请退款/退货 | Mall_Admin | logic/mgt/order_returnable.go:18-22 |
| OrderApprove | /order.Mgt/OrderApprove |
售后审批 | 无任何鉴权 | logic/mgt/order_approve.go:17-27 |
| QuickCreateByProduct | /order.Summary/QuickCreateByProduct |
单商品快速下单 | Mall_Admin | logic/summary/quick_create_by_product.go:21-26 |
| Submit | /order.Summary/Submit |
购物车提交下单 | 带 address 字段时任意 token,否则 Mall_Admin |
logic/summary/submit.go:24-33 |
| Check | /order.Summary/Check |
查未付款订单 | Mall_Admin | logic/summary/check.go:17-22 |
| Get | /order.Summary/Get |
订单详情(C 端) | token(任意角色) | logic/summary/get.go:15-20 |
| List | /order.Summary/List |
我的订单列表 | Mall_Admin | logic/summary/list.go:19-24 |
| Confirm | /order.Summary/Confirm |
确认订单(运费/优惠券) | token(任意角色) | logic/summary/confirm.go:16-21 |
| Cancel | /order.Summary/Cancel |
取消订单(C 端) | token(任意角色) | logic/summary/cancel.go:17-22 |
| SimulatePay | /order.Summary/SimulatePay |
模拟支付 | token(任意角色) | logic/summary/simulate_pay.go:18-23 |
| SimulateShipments | /order.Summary/SimulateShipments |
模拟发货 | token(任意角色) | logic/summary/simulate_shipments.go:18-23 |
| SimulateReceiving | /order.Summary/SimulateReceiving |
模拟收货 | token(任意角色) | logic/summary/simulate_receiving.go:18-23 |
声明但未实现 / 占位 / 模拟的接口(已核实)
| 接口 | 位置 | 现状 |
|---|---|---|
Mgt.OrderModify |
logic/mgt/order_modify.go:25-33 |
鉴权与 auth.Owner == nil 判空之后只剩两行 // TODO,直接返回 Code:0 "OK",不修改任何数据。internal/server/mgt_server.go:24-26 已把它接到 gRPC 与 Gateway(pb/mgt.pb.gw.go:537),属对外可达的假成功接口。 |
Summary.SimulatePay |
logic/summary/simulate_pay.go:17,25 |
注释即"模拟支付"。真实写库,但交易号、金额、支付类型、备注全为硬编码常量:PayTradeNo: "Pay12371937812897"、PayAmount: 10000000(=100000.00 元)、PayType: 4(余额)、PayRemark: "支付备注",且与订单实际 trans_price 无关。 |
Summary.SimulateShipments |
logic/summary/simulate_shipments.go:17,29 |
注释即"模拟发货"。只写 status=3 与 delivery_identity,不校验当前状态、不产生物流单。 |
Summary.SimulateReceiving |
logic/summary/simulate_receiving.go:17,29 |
注释即"模拟收货"。只把 status 置 4。 |
| 配送员信息查询 | logic/summary/quick_create_by_product.go:88-97 |
整段查询 delivery_member 的代码被注释,member_identity 请求参数无任何效果(:119-120 的赋值同样被注释)。 |
| 地址与运费计算 | logic/summary/confirm.go:36-50 |
if in.AddressIdentity != "" 分支内只有一段被注释的地址查询与 GetLogisticsFee 调用,函数体为空实现。 |
models.QuicklyCreateOrder / models.GetSummaryCnt / models.GetSummaryList |
internal/models/query.go:22、internal/models/order_summary.go:70,83 |
三个模型层函数全仓无调用者,且其中 SQL 引用了模型与表结构中不存在的列(见 6.2)。 |
README.md |
module/ec/order/README.md:1-2 |
只有标题,无接口/部署/数据模型说明。 |
4. 数据模型与表
本模块自身有 4 张表(internal/models/impl.go:15-20 的 migrateTables 自动迁移),另外直接读写 4 张他服务的表。
自有表
| 表名 | 模型文件 | 主键 / 唯一键 | 说明 |
|---|---|---|---|
order_summary |
internal/models/order_summary.go:12 |
id PK;identity 唯一索引;order_no 普通索引;store_id/store_identity/status 索引 |
订单主表 |
order_details |
internal/models/order_details.go:9 |
id PK(gorm.Model);summary_identity/product_identity 索引;order_no 索引 |
订单明细 |
order_cart |
internal/models/order_cart.go:15 |
id PK;identity 唯一索引;cart_identity 索引;product_identity 索引 |
购物车,无 passport_identity 唯一性约束 |
order_coupon |
internal/models/order_coupon.go:10 |
id PK;identity 唯一索引 |
优惠券,归属用 passport_id/passport_identity |
表 order_summary(internal/models/order_summary.go:12)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id/created_at/updated_at/deleted_at |
uint/TIMESTAMP | PK + 软删除 | gorm.Model |
identity |
varchar(36) | uniqueIndex | 订单唯一标识(UUID) |
passport_id/passport_identity |
uint / varchar(36) | 索引 | 下单人(Std_Passport);Submit 写的是当前登录人 |
order_no |
varchar(36) | 索引(非唯一) | 18 位订单号,生成见 logic/common/no.go:10-14 |
store_id/store_identity |
uint / varchar(36) | 索引 | 店铺(Submit 写入值有缺陷,见 6.2) |
partner_id |
int32 | default 0 | 分销商 ID,直接取自请求 |
total_price/trans_price/refund_price/logistics_fee/coupon_amount |
int64 | default 0 | 订单金额/实付/已退/运费/优惠额(后两项从不被写入,见 6.2) |
coupon_identity |
varchar(64) | default '' |
优惠券标识(全仓从不写入) |
remark/args |
text | default '' |
备注 / 附加参数 |
status |
int32 | default 0,索引 | 1 未支付 / 2 已支付 / 3 已发货 / 4 已收货 / 5 已完成 / 6 已收货退款 / 7 未发货退款 / 8 已退货 / -1 已取消 |
address_identity |
varchar(36) | — | 地址库标识 |
county/province/city/area/address/contact/phone |
varchar(255/50) | default '' |
下单时冗余落库的收货地址(PII) |
approve |
int8 | default 0 | -2 未通过 / 0 默认 / 1 申请退款 / 2 申请退货 / 3 申请退款退货 / 4 申请通过 |
reason |
varchar(255) | default '' |
售后原因 |
logistics_number/delivery_time/delivery_address/delivery_identity/car_identity/car_brand/car_version/license_number/member_name/member_phone |
varchar | — | 配送信息;SimulateShipments 只写 delivery_identity |
pay_type/pay_amount/pay_trade_no/pay_time/pay_remark |
int8/int64/varchar/time/text | default | 支付信息;SimulatePay 全部硬编码 |
order_details |
— | foreignKey:SummaryIdentity;references:Identity |
关联明细(order_summary.go:61) |
表 order_details(internal/models/order_details.go:9)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK | 自增主键 |
summary_identity |
varchar(36) | Index | 所属订单标识 |
order_no |
varchar(36) | index,not null |
订单号(冗余) |
product_id/product_identity |
int64 / varchar(36) | not null / Index |
商品 |
spec_id |
int64 | — | 指向 mall_product_spec.id(跨库外键语义) |
type/title/spec_title/spec_no/cover_image/product_args |
int8/varchar/text | default | 类型、标题、规格名、规格编号、封面、参数 |
number |
int32 | default 0 | 数量,在 Submit 中为 0 时跳过扣库存 |
unit_price/sales_price/total_price |
int64 | default 0 | 实际单价/原价/小计(单位分) |
gas_types |
int32 | default 1 | 燃气类型 |
supply_id |
int64 | default 0 | 供应商 ID(来自跨库商品表) |
表 order_cart(internal/models/order_cart.go:15)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK | 自增主键,被 Cart.Modify/Cart.Delete 直接当作唯一入口条件 |
identity |
varchar(36) | uniqueIndex | 购物车行标识 |
cart_identity |
varchar(36) | index,not null |
购物车分组标识(来自请求,未登录购物用) |
passport_id/passport_identity |
uint / varchar(36) | 索引 | 归属用户(Cart.Create 只写 passport_identity,:43) |
product_id/product_identity/spec_id |
int64/varchar(36)/int64 | 索引 | 商品与规格(spec_id 指向 mall_product_spec.id) |
number |
int32 | default 0 | 数量(无正值校验) |
product_args |
text | default '' |
下单前由前端回传的商品参数快照 |
直接读写的外部表(跨服务数据库耦合)
| 表 | 归属模块 | order 侧的读写位置 |
|---|---|---|
mall_product |
module/ec/mall |
读:logic/summary/submit.go:82,112、logic/summary/quick_create_by_product.go:51,53、logic/mgt/order_create.go:57 |
mall_product_spec |
module/ec/mall |
读:submit.go:123,204、quick_create_by_product.go:68、order_create.go:67;写:submit.go:209(stock - n)、order_create.go:141(stock - n)、order_approve.go:60(stock + n) |
mall_store |
module/ec/mall |
读:quick_create_by_product.go:35 |
address_library |
module/ec/address |
读:submit.go:68、quick_create_by_product.go:79、order_create.go:101 |
delivery_member |
未在本仓库中找到对应模型 | 读:quick_create_by_product.go:90(整段被注释) |
两侧数据库连接配置指向同一个库:etc/order_prod.yaml:7 与 module/ec/mall/etc/mall_prod.yaml:7 的 DSN 均为 dbname=ec_mall。order 侧这些查询用 Table("...") 加本地投影结构体(internal/models/query.go:3 的 Product、:16 的 Spec、internal/models/types.go:7 的 OrderAddress),没有 GORM 模型、没有编译期约束。
5. 核心流程
flowchart TD
A["POST /order.Summary/Submit"] --> B["ParseMetaCtx:带 address 时任意 token"]
B --> C["按 passport_identity 取整辆购物车"]
C --> D{"请求带回 address"}
D -->|是| E["直接用请求里的地址字段"]
D -->|否| F["按 address_identity 直查 address_library(不校验归属)"]
E --> G["逐项直查 mall_product / mall_product_spec"]
F --> G
G --> H["按 store_identity 分组,用库里的 spec.price 计算金额"]
H --> I["事务:写 order_summary + order_details"]
I --> J["逐条 stock >= n 条件更新 mall_product_spec 库存(不检查影响行数)"]
J --> K["按 passport_identity 清空购物车"]
K --> L["返回首个 order_no"]
flowchart TD
A["POST /order.Summary/SimulatePay"] --> B["ParseMetaCtx(nil):任意已登录角色"]
B --> C["按请求传入的 identity 列表批量更新 order_summary"]
C --> D["status=2, pay_type=4, pay_amount=10000000, pay_trade_no=Pay1237...897"]
E["POST /order.Summary/SimulateShipments"] --> F["ParseMetaCtx(nil) + member_identity 非空"]
F --> G["status=3, delivery_identity=入参"]
H["POST /order.Summary/SimulateReceiving"] --> I["ParseMetaCtx(nil)"]
I --> J["status=4"]
K["POST /order.Mgt/OrderApprove"] --> L["无鉴权"]
L --> M["按 approve 分支改 status 或回补 mall_product_spec.stock"]
6. 审计发现
6.1 安全
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | logic/summary/simulate_pay.go:20,25、logic/summary/simulate_shipments.go:20,29、logic/summary/simulate_receiving.go:20,29 |
模拟支付/发货/收货在生产可达且无角色校验。三个 RPC 在 proto/summary.proto:33,36,39 声明、internal/server/summary_server.go:53-66 已注册、pb/summary.pb.gw.go:735-737 暴露为 POST 路由;实现只用 service.ParseMetaCtx(ctx, nil)(只验 token 有效性,不做角色判断,见 bsm-sdk/core/service/meta.go:36-45),随后按请求传入的 identity 列表直接改状态。任何已登录账号(含 role=staff)都能把任意订单标记为已支付、已发货、已收货。etc/order_prod.yaml:23 的 Gateway.Enable: true,SDK 网关绑定 0.0.0.0(bsm-sdk/core/service/service.go:106),生产网络直接可达。 |
| 高 | logic/summary/simulate_pay.go:25 |
支付信息硬编码:PayTradeNo: "Pay12371937812897"、PayAmount: 10000000、PayType: 4(余额)、PayRemark: "支付备注"。金额与订单 trans_price 完全无关,等于以任意金额标记已支付,账实不符。 |
| 高 | logic/mgt/order_approve.go:17-27 |
OrderApprove 没有任何 ParseMetaCtx 调用:匿名即可审批退款/退货,并触发 case 2 的库存回补(:60)。 |
| 高 | logic/summary/get.go:17,27 + logic/common/get.go:15 |
Get 只验 token,GetOrderSummaryByIdentity 按 identity 直查,不校验订单与调用方的关系(既非 passport_identity 也非 store_identity)。任意已登录用户可读任意订单,返回体经 logic/common/reflect.go:58-64 带出省市区详细地址、联系人、电话。 |
| 高 | logic/mgt/order_get.go:21-24,39-46、logic/mgt/order_list_by_store.go:21-24,52-57 |
两处的 agency 分支在请求带 agency 时把 ParseOptions 置为 nil(任意角色可过),并把 store_identity 直接取请求参数,order_get.go:46 的查询只有 store_identity + identity 两个条件 → 可读任意店铺订单。 |
| 高 | logic/mgt/order_cancel.go:37、logic/summary/cancel.go:28,41、logic/mgt/order_returnable.go:19,49 |
都只判角色、不判订单归属:OrderCancel 按 identity 直改 status=-1;OrderReturnable 按 identity 直改 approve/reason;Cancel 按 order_no 查询与更新。同角色可操作他店订单。 |
| 高 | logic/cart/create.go:27,49、logic/cart/modify.go:26,40、logic/cart/delete.go:22 |
购物车写操作缺归属约束:Cart.Create 的查重与更新条件是 cart_identity + product_identity + spec_id(不带 passport_identity),命中后 Updates(&data) 会把该行的 passport_identity 改成调用者(:43,49);Cart.Modify/Cart.Delete 只按 id 定位(modify.go:26,40、delete.go:22),id 为自增主键可枚举。 |
| 高 | logic/cart/fetch.go:31 |
Where("cart_identity=? or passport_identity=?", in.GetCarIdentity(), auth.Identity):or 语义使传入任意 cart_identity 即可读到他人的购物车,与当前账号无关。 |
| 高 | logic/summary/submit.go:68、logic/summary/quick_create_by_product.go:79、logic/mgt/order_create.go:101 |
三处都按 identity 直查 address_library,从不校验 owner_identity/owner_id(该列确实存在于 module/ec/address/internal/models/address_library.go:17)。可用任意地址标识下单,并把他人姓名、电话、详细地址写入订单(address["contact"].(string)、address["detail"].(string) 等,order_create.go:115-121)。 |
| 高 | logic/cart/fetch.go:57 |
UnitPrice: product.CostPrice——购物车返回体把成本价当作"实际单价"给前端(:40-47 的子查询只取了 cost_price 与最低销售价)。 |
| 中 | logic/coupon/by_status.go:16-28 |
鉴权到位但业务越权面未收敛:Where("passport_id=?", auth.ID) 已按归属过滤(正确);但 logic/summary/confirm.go:56 按 identity 取券时既不校验 passport_id/passport_identity,也不校验 started/expired 有效期与 condition 门槛(字段见 internal/models/order_coupon.go:13,17-19)→ 可用他人券、过期券、未达门槛券。 |
| 中 | logic/cart/create.go:37-41、logic/cart/modify.go:26,36-40 |
number 无正值校验,可写入 0 或负数;负数数量在 submit.go:202 因 detail.Number > 0 判假而跳过扣库存,直接生成负数量明细与错误的金额。 |
| 中 | internal/impl/with.go:85 |
NewPostgres 中硬编码 db = db.Debug()(NewMysql 是按 options.Debug 判断,:61-63),生产环境同样输出含参数的 SQL 日志。 |
| 低 | logic/summary/submit.go:206 |
库存不足时用标准库 log.Printf 输出,与同文件其它位置的 printer.Error(:47 等)混用,日志格式与级别不受统一管控。 |
6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | logic/summary/confirm.go:60,71-73 |
优惠券把金额加到总额:couponAmount = coupon.Amount 之后 summary.TotalPrice = summary.TotalPrice + couponAmount。用券后应付金额变大。同时从不回写 summary.CouponIdentity/CouponAmount(全仓仅 cancel.go:36-37 清零、order_summary.go:26-27 定义),订单上看不到用了哪张券、抵了多少。 |
| 高 | logic/summary/cancel.go:48 |
取消订单要按 summary.CouponIdentity 把券置回可用,但该字段从未被赋值 → Where("identity=?", "") 更新不到任何行,券永远停在 status=3;confirm.go:59 的 Update 也未检查 RowsAffected。 |
| 高 | logic/summary/submit.go:200-218 |
超卖:先 Scan(&stock) 试算(:204),再用 Where("id = ? AND stock >= ?", ...).UpdateColumn("stock", stock - n)(:209-212),但从不检查 RowsAffected。并发下条件更新影响 0 行时不会报错,订单仍然提交成功、库存未扣。对比 logic/mgt/order_create.go:150-152 是正确写法(有 RowsAffected == 0 判断)。 |
| 高 | logic/summary/submit.go:82,91-93,166 |
Select("store_identity") 未取 id,而 :92 用的是 product.StoreId → 该值恒为 0;且只在 k == 0 时赋值一次。多店铺下单时,所有订单摘要写入同一个 store_id(0 或首店 ID),店铺维度统计与后续按店查询全部失真。 |
| 高 | logic/summary/submit.go:45,221-225 |
忽略 SubmitRequest.id(proto/summary.proto:70 明确注释为"购物车的ID数据"):下单按 passport_identity 取全车,成功后按 passport_identity 清空全车。用户勾选部分商品下单会下整辆购物车,其余条目被删除。 |
| 高 | logic/summary/quick_create_by_product.go:128,138、logic/mgt/order_create.go:75,77 |
单价取自 mall_product.sales_price,而 mall 侧的商品创建/更新逻辑从不写这一列(module/ec/mall/internal/logic/product/item_create.go:137-160 的 itemToModel 未映射 SalesPrice,该列仅有模型定义 mall_product.go:40)→ 实际为默认 0,快速下单与管理端建单的成交金额恒为 0;mall 侧仅在排序里读它(module/ec/mall/internal/logic/product/item_fetch.go:73)。 |
| 高 | logic/summary/cancel.go:34-53、logic/mgt/order_cancel.go:37 |
取消订单不返还库存:库存在 submit.go:209-211 / order_create.go:141-143 已扣减,取消时只改状态。summary/cancel.go:34 还在 status != 1 时静默返回 Code:0 "OK",既不取消也不报错。 |
| 高 | logic/mgt/order_cancel.go:37、logic/summary/simulate_pay.go:25、logic/summary/simulate_shipments.go:29、logic/summary/simulate_receiving.go:29 |
订单状态机无任何约束:均可从任意状态跃迁(已发货→已取消、已取消→已支付、未支付→已收货)。summary/cancel.go:34 是唯一有状态判断的实现(但失败被静默吞掉)。 |
| 中 | logic/summary/quick_create_by_product.go:102,128,138 |
同一订单内金额口径不一致:trans_price/total_price 用规格价 spec["price"](:102),明细 unit_price 用商品价 product["sales_price"](:128,138)→ 订单总额与明细之和不相等;logic/mgt/order_create.go:75,77,87 同样用商品价而 SpecID 取自另一条规格(:90)。 |
| 中 | logic/summary/quick_create_by_product.go:50-53,68 |
规格按 product_identity 取第一条(:68,用 Take,请求里的 spec_identity 未被使用);而特殊商品分支(serial_id 为 gas_by_kg/gas_by_bottle)在 :51 用序列号找到商品后,:68 仍用 in.ProductIdentity(即 gas_by_kg 这类字符串)去查 product_identity → 必然 not found,特殊商品无法下单。:53 的商品查询也未带 store_identity 条件(仅 :51 分支有),可给 A 店订单塞 B 店商品。 |
| 中 | logic/summary/quick_create_by_product.go:149-159 |
订单摘要与明细分两次独立 Create,没有事务:明细写入失败会留下无明细的订单主记录。对比 submit.go:187-228 与 order_create.go:131-156 都用了事务。 |
| 中 | logic/mgt/order_approve.go:47-67 |
impl.DBService.Transaction(func...) 的返回值被丢弃(:47 未接收 error),事务失败对外仍返回成功;退货入库用 Where("product_identity = ?", specs.ProductIdentity)(:60)——按商品维度给该商品下所有规格加库存(一个商品多条规格即库存虚增),而正确目标是订单明细里的 spec_id。 |
| 中 | logic/summary/confirm.go:24,36-53、logic/summary/submit.go:168 |
运费与备注不落库:logisFee 唯一赋值在被注释的分支内(:47),恒为 0,if logisFee > 0(:51)永不成立;ConfirmRequest.logistics_fee(proto/summary.proto:112)与 remark(:111)从未被使用;LogisticsFee 全仓只在 submit.go:168 写 0。 |
| 中 | logic/mgt/order_returnable.go:40-41 |
用裸 errors.New("订单状态异常") 返回(非 errcode,gRPC 侧表现为 Unknown);状态判断只排除 -1/6/7/8,已完成的 status=5 也可再次申请售后。 |
| 中 | logic/coupon/by_status.go:28,52 |
ByStatus 完全忽略入参 status(Status.status,proto/coupon.proto:13)→ 查询恒返回该用户全部券;返回体 Amount: v.Intro 把描述串当金额(CouponItem.amount 是 string,proto/coupon.proto:25),started/expired/status 三个字段未填充。 |
| 中 | logic/summary/check.go:23-30 |
注释是"检测是否有未确认及付款的订单",实现是按 passport_id 取第一条订单且无任何状态过滤(:26);查不到时返回 ErrDB 而非明确的"无订单"。 |
| 中 | logic/mgt/order_get.go:46-50 |
用 Find 而非 First:查不到不返回错误,:48-50 的 gorm.ErrRecordNotFound 分支是死分支,接口返回空的 summary 而非 404。 |
| 中 | logic/summary/submit.go:45-52,195 |
空购物车也走通全流程:summary/details 为空切片时 CreateInBatches(details, len(details)) 的 batchSize 为 0(:195),接口仍返回 Code:0,order_no 为空字符串。 |
| 中 | internal/models/order_summary.go:72,86,131-138 |
GetSummaryCnt/GetSummaryList 使用 type、buyer_identity、merchant_identity、sign_status、pay_status、logistics_status、invoice_status 等 OrderSummary 模型中不存在的列(模型字段见 :12-62),这些查询必然报 Unknown column。二函数与 internal/models/query.go:22 QuicklyCreateOrder 均无调用者。:131 还把 maxPrice 误传给了 pay_status 的条件。 |
| 低 | internal/models/query.go:23,86 |
这两个函数用的是 models.DBService,而全部业务逻辑用的是 impl.DBService(赋值见 internal/impl/with.go:46)。聚合入口经 service/dependencies.go:27 只覆盖 impl.DBService,models.DBService 保持 nil → 一旦这些函数被调用即空指针。 |
| 低 | logic/summary/submit.go:55-76 |
in.Address != nil 时才用请求内地址,否则查地址库;但 models.OrderAddress(internal/models/types.go:7-18)含 Email/ZipCode 字段,而 order_summary 没有对应列,这两项落不了库。 |
6.3 未完成实现
见第 3 节末尾表格。要点:
- 管理端改单为假成功:
logic/mgt/order_modify.go:25-33只有 TODO,返回Code:0 "OK",但接口已在internal/server/mgt_server.go:24与pb/mgt.pb.gw.go:537接线,调用方会误以为改单成功。 - 三个"模拟"接口是唯一的状态推进手段:
simulate_pay.go:17,25、simulate_shipments.go:17,29、simulate_receiving.go:17,29注释自述为模拟,硬编码交易号/金额/支付类型,但没有真实支付与物流对接的实现存在。 - 注释掉的代码块:
quick_create_by_product.go:88-97(配送员查询)、:119-120(DeliveryIdentity赋值)、confirm.go:37-49(地址查询与运费计算),对应的请求字段member_identity、delivery_time、logistics_fee、remark因此无效果。 - 状态语义只在注释里:
order_summary.go:30(订单状态)与:39(approve 编码)、order_approve.go:29的取值说明都没有枚举定义,order_returnable.go:24又硬编码了1/2/3。 - README 未编写:
module/ec/order/README.md:1-2只有标题。
6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
|---|---|---|
| 中 | service/dependencies.go:19-29 + pkgs/all/internal/service/order.go:15 |
Dependencies 声明了 Cache *cache.Cache,applyDependencies 却不处理该字段;pkgs/all 仍传入 Cache: impl.MemoryService → 依赖注入字段无效(与 module/base/logs 同类问题)。 |
| 中 | internal/impl/with.go:24-31 |
config.Spec.Cache == "" 时 RedisCache 为 nil,但 :30 无条件取 RedisCache.DB → 空指针;且 printer.Info 的格式化参数与占位符不符。 |
| 中 | logic/summary/quick_create_by_product.go:43,62,102,104,112-117,128-129,133,136-137,140,143-145、logic/mgt/order_create.go:62,74-75,83,85-86,89-94,115-122、logic/mgt/order_get.go:42-43、logic/mgt/order_list_by_store.go:55-56 |
大量对 GORM map[string]any 结果的无保护类型断言(store["status"].(int16)、product["status"].(int32)、spec["price"].(int64)、address["province"].(string) 等)。相关列在两边都有 default 但没有 not null(如 mall_store.status 定义于 bsm-sdk/core/types/db.go:34),任一列为 NULL 或两侧驱动返回类型变化即 panic;order_get.go:42-43 在 token 的 owner 不是对象时同样 panic。【信息不足】未在真实数据库确认各列的可空性与驱动返回的具体类型。 |
| 中 | 全模块 | 无单元测试:test/rpc/*.go 均为 //go:build integration 的手工联调用例(summary_test.go:1、cart_test.go:1),依赖真实服务端口 127.0.0.1:12442 与环境变量 BSM_ORDER_TOKEN(test/rpc/bisic_test.go:13,15,17),断言只打印返回消息,无任何 t.Fatal 判断;logic/、models/、internal/impl/ 0 测试。 |
| 中 | internal/logic/common/no.go:10-14 + internal/models/order_summary.go:18 |
订单号 = 秒级时间戳拼接 + 6 位随机数,order_no 只有普通索引(非唯一)→ 同秒并发存在重号可能;而 summary/cancel.go:28、summary/confirm.go:30、common/get.go:28 都用 First(取任意一条),重号会导致操作到错误订单。 |
| 中 | logic/summary/list.go:43-45、logic/mgt/order_list_by_store.go:49-51 |
只要 page_size > 0 就直接 Limit(int(in.PageSize)),没有上限;OrderListByStore 还预加载了全部明细(:60 Preload("OrderDetails"))→ 一次请求可拉走全表订单与明细。 |
| 低 | internal/models/impl.go:31,36,42 |
驱动不支持、DB 打开失败、AutoMigrate 失败三处都走 log.Fatalln(直接退出进程),与 internal/impl/with.go:35,44,53 的 panic 风格并存,启动期错误处理方式不统一。 |
| 低 | etc/order_{dev,test,prod}.yaml:10 |
Cache: redis://null:...@127.0.0.1:6379/(无 DB 序号),而 internal/impl/with.go:30 会打印 RedisCache.DB;RedisCache 除该打印外全模块无使用点(死代码)。 |
| 低 | etc/order_{dev,test,prod}.yaml:19 |
Anonymous: - order.ping.hello,但 order 的 proto 与 gw 路由(pb/*.pb.gw.go 共 22 个方法)中没有 ping,是死配置。 |
| 低 | logic/mgt/order_cancel.go:29-36、logic/mgt/order_get.go:34-37 |
重复的参数校验(in.Identity == "" 判断两次),order_cancel.go:29 与 :34 的注释还互相错位。 |
| 低 | internal/models/impl.go:85 |
NewPostgres 中 db = db.Debug() 与 NewMysql 的可选 Debug 行为不一致(详见 6.1)。 |
7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| O1 | 高 | 模拟支付/发货/收货在生产可达、无角色校验、金额硬编码 | 资金与订单状态被任意篡改 |
| O2 | 高 | OrderApprove 完全无鉴权 |
退款/退货被匿名审批 + 库存被改写 |
| O3 | 高 | 跨服务直连同库,直接读写 mall_product/mall_product_spec/mall_store/address_library |
任一表结构变更即 order 运行期报错;订单侧可直接改商品库存 |
| O4 | 高 | 优惠券把金额加到订单总额(confirm.go:72) |
金额计算错误,用户应付变高 |
| O5 | 高 | submit 扣库存不检查 RowsAffected |
超卖(订单成功但未扣库存) |
| O6 | 高 | 多店铺下单 store_id 恒为 0 |
订单归属错误,店铺维度统计/查询失真 |
| O7 | 高 | Submit 忽略选中条目、清空全车 |
用户被强制下整辆购物车 |
| O8 | 高 | 快速下单/管理端建单单价取自从不写入的 sales_price |
成交金额恒为 0 |
| O9 | 高 | 取消订单不返库存 + 状态机无约束 + 取消失败静默成功 | 库存丢失、状态错乱 |
| O10 | 高 | 订单详情越权(Summary.Get)、地址越权使用(4 处)、购物车越权(读/写/删) |
用户 PII 泄露与数据被篡改 |
| O11 | 高 | 购物车返回成本价(cart/fetch.go:57) |
商业成本信息泄露 |
| O12 | 中 | 优惠券归属/有效期/门槛均不校验 | 券被滥用 |
| O13 | 中 | 无事务的建单(quick_create_by_product 两次 Create)、事务错误被丢弃(order_approve.go:47) |
脏数据 |
| O14 | 中 | 退货入库按 product_identity 回补 |
库存虚增 |
| O15 | 中 | 运费/备注/优惠额字段全链路未落库 | 订单金额要素缺失 |
| O16 | 中 | 分页无上限 + Preload 全明细 |
服务被单请求拖垮 |
| O17 | 中 | 无保护类型断言遍布订单逻辑 | 运行期 panic |
| O18 | 中 | 无单元测试、双份 DB 全局、订单号无唯一约束 | 可维护性与数据一致性 |
| O19 | 低 | README 空缺、Anonymous 死配置、RedisCache 死代码、Debug 常开 |
认知成本与日志噪音 |
8. 修复建议(务实项)
- 物理下线模拟接口(O1):把
SimulatePay/SimulateShipments/SimulateReceiving从proto/summary.proto:33,36,39移除或改为仅内部注册(不挂 Gateway);在实现落地前,最直接的做法是三个方法体统一返回errcode.ErrUnimplemented,并把logic/summary/simulate_pay.go:25的硬编码PayTradeNo/PayAmount删除。若必须保留,至少改成ParseOptions{RoleValue: "Mall_Admin"}并在更新前加status = 1(支付)/status = 2(发货)/status = 3(收货)的条件与订单归属校验。 OrderApprove补鉴权与归属校验(O2):order_approve.go:17前加service.ParseMetaCtx(ctx, &service.ParseOptions{RoleValue: "Mall_Admin"}),:24与:34/:48/:73/:86的Where补store_identity(取自 token owner);:47接收Transaction的返回值并判断;:60的Where("product_identity = ?")改为Where("id = ?", specs.SpecID)。- 优惠券计算改减并落库(O4、O15):
confirm.go:72改为summary.TotalPrice - couponAmount(并加下限判断);同时把summary.CouponIdentity = in.CouponIdentity、summary.CouponAmount = couponAmount写入,这样cancel.go:48的退券逻辑才能生效;:56取券时补passport_id、started/expired、condition校验。 - 库存扣减与返还(O5、O9、O14):
submit.go:209-217参照order_create.go:150-152增加if result.RowsAffected == 0 { return errors.New("库存不足") },并删掉:204的预读(条件更新已足够);summary/cancel.go:34-53与mgt/order_cancel.go:37在状态置 -1 的同事务内按order_details.spec_id回补库存;cancel.go在状态不允许取消时应返回错误而不是Code:0。 - 修正
Submit的三处错误(O6、O7):submit.go:82的Select补上id;删除:91-93的k == 0逻辑,改为按store_identity分组后就地取该组商品的店铺 ID;:45改为按in.GetId()过滤购物车(为空时再退化为整车),:221-225只删除已下单的那些order_cart.id;:45之后加len(cart) == 0的显式报错。 - 金额口径统一(O8、O13):
quick_create_by_product.go:102,128,138与order_create.go:75,77,87统一使用mall_product_spec.price(quick_create_by_product.go:68已取到 spec),order_create.go:67的规格查询改为按请求传入的规格标识(SpecList增加spec_id/spec_no)而不是product_identity取任意一条;quick_create_by_product.go:149-159的两条Create包进DBService.Transaction。 - 地址归属校验(O10):
submit.go:68、quick_create_by_product.go:79、order_create.go:101的地址查询补owner_identity = ?(取自 token),并在:79处把in.AddressIdentity与查询结果一并校验;summary/get.go:27按调用方身份收敛查询条件(买家用passport_identity,商家用store_identity)。 - 购物车归属收敛(O10、O11):
cart/modify.go:26,40、cart/delete.go:22的Where补passport_identity = ?;cart/create.go:27,49的查重条件补passport_identity;cart/fetch.go:31的or改为按passport_identity过滤、cart_identity仅作附加条件;cart/fetch.go:57的UnitPrice改用product.SalesPrice,不要返回CostPrice。 - 输入校验(O12):
cart/create.go:39与cart/modify.go:26,40增加number > 0校验;order_returnable.go:40改用errcode。 - 降低跨库耦合的运行期风险(O3):至少把 order 侧对
mall_product/mall_product_spec/mall_store/address_library的查询集中到internal/models/query.go一处(当前散落在 8 个文件),并给每个投影结构体补注释说明依赖的列名与归属服务;etc/order_prod.yaml:7的 DSN 与 mall 指向同一库这一点应在配置注释中显式写明,避免被误改为独立库。 - 分页与日志(O16、O19):
summary/list.go:43-45、mgt/order_list_by_store.go:49-51增加if size > 200 { size = 200 };internal/impl/with.go:85去掉硬编码.Debug();submit.go:206改用printer.Error。 - 类型断言防抖(O17):
quick_create_by_product.go、order_create.go、order_get.go、order_list_by_store.go中所有.(T)改为逗号断言(v, ok := m["k"].(T))并在!ok时返回errcode.ErrDB/ErrRecordNotFound。 - 订单号与约束(O18):
internal/models/order_summary.go:18的order_no改为uniqueIndex,并在Submit/OrderCreate生成冲突时重试;internal/models/query.go:23,86若确定不用则删除,要用则改为impl.DBService。 - 清理与测试:删除
internal/models/order_summary.go:70-151、internal/models/query.go:22三个坏 SQL 函数(或按模型重写后接线);删掉order_cancel.go:29-36的重复校验与etc/*.yaml:19的order.ping.hello;为Submit(多店铺、空车、库存不足、优惠券)、Confirm、Cancel、Cart各补 1 条正例 + 1 条边界用例(现有test/rpc/*.go只打印返回,无断言);补写README.md。
本报告只列出与现有实现直接相关的修复项,不引入新的分层或抽象封装。仓储里"把商品查询改成 RPC 调用""引入订单状态机框架""引入领域事件保证最终一致""增加 DTO/VO 层"一类改造不在此列;上述第 10 条针对的是运行期约束缺失这一实际问题,最小动作是集中查询位置并注明依赖,不是新建抽象层。