Files
full/docs/audit/module-ec-order.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
2026-09-14 22:16:27 +08:00

72 KiB
Raw Blame History

审计报告module/ec/order

工作区根:D:\work\bsm-infra\full;被审对象:module/ec/order(购物车 / 优惠券 / 订单 / 汇总,含 sdk/typescript。 本报告仅记录有代码证据的问题。凡无法在当前代码内证实的推断,均显式标注「推测」。

1. 模块概览

  • 形态Go + gRPC + grpc-gateway 微服务go.mod module 名 bsm/full/module/ec/ordergo 1.26.5,依赖 git.apinb.com/bsm-sdk/core v0.2.1(本地 replace 到 D:\work\bsm-sdk\core)。
  • 服务定义proto/{cart,coupon,mgt,summary}.protoHTTP 路径由 protoc-gen-slc 生成,均为 POST /order.<Service>/<Method>,无 google.api.http 注解):
    • CartFetch / Create / Modify / DeleteC 端购物车)
    • CouponByStatus优惠券列表
    • MgtOrderCreate / OrderModify / OrderGet / OrderListByStore / OrderCancel / OrderReturnable / OrderApprove商家端订单
    • SummaryQuickCreateByProduct / Submit / Check / Get / List / Confirm / Cancel / SimulatePay / SimulateShipments / SimulateReceivingC 端订单 + 模拟支付/发货/收货)
  • 数据表internal/models,启动时 AutoMigrateorder_cartorder_couponorder_detailsorder_summary。金额一律 int64(分)。
    • order_summaryOrderNo varchar(36) index非唯一)、Status int32 indexPassportID/PassportIdentity indexStoreID/StoreIdentity indexIdentity varchar(36) uniqueIndexRefundPrice/CouponIdentity/CouponAmount/PayType/PayAmount/PayTradeNo/PayTime/Approve 等。
    • order_detailsSummaryIdentity indexOrderNo indexProductIDSpecIDNumberUnitPriceSalesPriceTotalPrice
  • 外部依赖:本模块没有任何跨服务 RPC 客户端config.Spec.Rpc 存在但 etc 中全部被注释,代码未引用);internal/impl.RedisCache 已初始化但全模块零使用service.Dependencies 支持由单体注入 DB/Redis/Cache。
  • 两种运行方式
    1. 独立进程 cmd/main/main.goserver.New(nil)grpc.NewServer()无任何拦截器)→ gRPC 监听 :12442Gateway 配置 Enable: true, Port: 12441
    2. 单体 pkgs/ecmallinternal/service/order.gomodule/ec/order/service.Expose):共用 grpc.NewServer(grpc.UnaryInterceptor(auth.unaryInterceptor)) + gin HTTP网关用真实 gwRuntime.NewServeMux(),并有全局 JWT 中间件(pkgs/ecmall/internal/server/authorization.go:55-66,仅校验 JWT 有效性,不做角色/权限判断)。
  • 鉴权模型handler 内部用 service.ParseMetaCtxD:\work\bsm-sdk\core\service\meta.go:19-49)解析 metadata authorization 并校验 JWTopts==nil 表示只要求任意有效 JWT任意角色含 C 端买家)RoleValue:"Mall_Admin" 表示要求 claims.Extend["role"]=="Mall_Admin"

2. 审计范围与方法

已覆盖(全部非 pb 源码逐行阅读)

  • logiccart/{create,fetch,modify,delete}.gocoupon/by_status.gocommon/{get,no,reflect}.gomgt/{order_create,order_modify,order_get,order_list_by_store,order_cancel,order_returnable,order_approve}.gosummary/{quick_create_by_product,submit,check,get,list,confirm,cancel,simulate_pay,simulate_shipments,simulate_receiving}.go
  • modelsimpl.goorder_cart.goorder_coupon.goorder_details.goorder_summary.goquery.gotypes.go
  • servernew.gocart_server.gocoupon_server.gomgt_server.gosummary_server.goserviceexpose.godependencies.gocmd/main/main.gointernal/{config,impl}
  • 配置:etc/order_{dev,prod,test}.yamletc/supervisor.bsm-ec-order.confproto/*.protosdk/typescript/order/{index.ts,const.ts}test/**rpc 测试 + .http 用例 + 空目录 test/lint
  • 交叉验证(只读):D:\work\bsm-sdk\coremeta.go / jwt.go / db.go / conf / database/sql / gorm 驱动行为)、pkgs/ecmall(拦截器、网关、模块装配)、module/ec/mallmodule/ec/address 的表模型用于核对被直接读写的跨模块表列类型、gorm v1.31.2 源码(scan.go:14-37 map 扫描类型来源)。

执行过的命令

  • gofmt -l . → 无输出(格式合规)。
  • go vet ./... → 无输出,退出码 0无静态告警
  • Select-String 全仓检索 module/ec/order 的引用方、GetSummaryList/GetSummaryCnt/QuicklyCreateOrder 等符号使用方、RefundPrice/wallet/stock/TODO/_ = 分布。
  • 未执行 go testtest/rpc/* 均为 //go:build integration 且依赖真实 DB/网关与 BSM_ORDER_TOKEN,本环境无法运行)。
  • 另核对了 gorm v1.31.2 的三处行为(用于支撑结论,非推测):callbacks/update.go:294if (ok || !isZero) && field.Updatablestruct Updates 跳过零值字段)、schema/field.go:129utils.CheckTruth(tagSetting["NOT NULL"], ...)tag key 不匹配即不生效)、database/new.go:43if len(MigrateTables) > 0 && options.IsAutoMigrate(单体传 nil options → 不迁移)。

证据等级说明:本报告中涉及并发(超卖、重复核销、状态竞争)与金额/资金一致性(对账、重复支付、幂等缺失)后果的描述,均为静态代码推断——依据是代码中缺少条件更新/行锁/事务/幂等键,未在真实 DB 上做并发压测或资金对账验证。单纯的状态字段赋值错误、符号错误、空指针解引用、错误被丢弃等属可直接证实的静态事实,不属推断。凡不能从源码确认的判断均单独标注「推测」。

未覆盖 / 无法验证

  • pb/(生成代码,按要求跳过;仅检索其网关 pattern 用于确认 HTTP 暴露路径)。
  • 实际运行时行为(无 DB/Redis/网关实例):并发下的真实超卖与重复核销只能做代码级推断。
  • 其他模块wallet/mall内部实现细节本报告只依据「order 模块是否调用它们」下结论。
  • TypeScript SDK 的消费端调用方(仓内未检索到使用者)。

3. 问题清单

P0

1. 订单类接口普遍缺少归属校验,任意登录用户可读/改任意订单IDOR

  • 位置
    • module/ec/order/internal/logic/summary/get.go:27
    • module/ec/order/internal/logic/summary/cancel.go:28
    • module/ec/order/internal/logic/summary/confirm.go:30
    • module/ec/order/internal/logic/summary/simulate_pay.go:20,25
    • module/ec/order/internal/logic/summary/simulate_shipments.go:29
    • module/ec/order/internal/logic/summary/simulate_receiving.go:29
    • module/ec/order/internal/logic/common/get.go:15,28
    • module/ec/order/internal/logic/mgt/order_get.go:46mgt/order_cancel.go:37
  • 证据
// summary/get.go:17-31  —— 只要求任意有效 JWT随后用客户端传入的 identity 直查
_, err = service.ParseMetaCtx(ctx, nil)
...
summary, err := common.GetOrderSummaryByIdentity(in.Identity)
// common/get.go:15
err = impl.DBService.Preload("OrderDetails").Where("identity = ?", identity).First(&summary).Error
// summary/cancel.go:28  —— 按 order_no 查,无 passport 过滤
err = impl.DBService.Where("order_no = ?", in.OrderNo).First(&summary).Error
// summary/confirm.go:30
err = impl.DBService.Where("order_no = ?", in.OrderNo).First(&summary).Error
// summary/simulate_pay.go:20,25
_, err = service.ParseMetaCtx(ctx, nil)
err = impl.DBService.Model(&models.OrderSummary{}).Where("identity in ?", in.Identity).Updates(...)
  • 影响:任何持有有效 JWT 的账号(普通买家即可,无需商家角色)只要拿到/猜到订单 identityorder_no,就能:读取他人订单的收货人姓名、手机号、详细地址Get把他人订单标记为已支付SimulatePay标记已发货/已收货SimulateShipments/SimulateReceiving取消他人订单Cancel确认并核销他人订单所带优惠券Confirmorder_no 为 18 位含 6 位伪随机数的可枚举串(见 P1-8identity/order_no 还会通过列表、日志、前端透出。此外 Confirm/Cancel 只要 order_no 相同(该列无唯一约束)就会命中另一张订单。
  • 建议:所有按 identity/order_no/Id 定位订单的写操作,一律追加 passport_identity = auth.IdentityC 端)或 store_identity = <token 中的店铺>(商家端)并校验 RowsAffectedorder_no 查询改为「先按 identity 取,再校验归属」;为 order_no 建唯一索引。

2. 「模拟支付」硬编码金额与交易号,无金额校验、无状态校验、无幂等 → 支付数据必然错账

  • 位置module/ec/order/internal/logic/summary/simulate_pay.go:25
  • 证据
err = impl.DBService.Model(&models.OrderSummary{}).Where("identity in ?", in.Identity).
	Updates(models.OrderSummary{Status: 2, PayTime: time.Now(), PayType: 4,
		PayRemark: "支付备注", PayTradeNo: "Pay12371937812897", PayAmount: 10000000}).Error
  • 影响:任意订单(金额可能是几百元或几十万元)支付成功后 pay_amount 恒为 10000000(分),pay_trade_no 恒为同一常量,与 total_price/trans_price 完全无关联;不校验订单当前状态(status=-1 已取消、status=2 已支付、status=4 已收货都可再刷),无任何幂等键,重复调用会覆盖 pay_time/pay_trade_no。结算/退款均以这些字段为基准时必然错账,且「任何有效 JWT 可给任意订单置为已支付」(配合 P0-1由此产生的资金/对账不一致后果为静态推断(未做真实对账验证),但字段值与订单金额无关、重复调用覆盖原值是可直接证实的事实。
  • 建议:支付入口只允许由支付网关回调(内部服务调用 + 签名/幂等键)触发;写入前校验 status==1pay_amount == total_price;用 pay_trade_no 唯一索引 + WHERE status=1 条件更新实现幂等;Updates 改为带条件的原子更新并检查 RowsAffected

3. 退款/售后审批链路:接口零鉴权、审批不动资金、库存回滚写错维度、事务错误被吞

  • 位置module/ec/order/internal/logic/mgt/order_approve.go:17-27,47-67,60,85-92
  • 证据
func OrderApprove(ctx context.Context, in *pb.OrderIdentRequest) (reply *pb.OrderStatusReply, err error) {
	// 验证输入参数是否有效。
	if in.GetIdentity() == "" { return nil, errcode.ErrInvalidArgument }
	var order models.OrderSummary
	if err := impl.DBService.Preload("OrderDetails").Where("identity=?", in.Identity).First(&order).Error; err != nil {
	// order_approve.go:47-67 —— 事务返回值未接收、错误未检查
	impl.DBService.Transaction(func(tx *gorm.DB) error {
		err = tx.Model(&models.OrderSummary{}).Where("identity=?", in.Identity).
			Updates(map[string]any{"approve": in.GetApprove(), "status": 8}).Error
		...
		err = tx.Table("mall_product_spec").Where("product_identity = ?", specs.ProductIdentity).
			UpdateColumn("stock", gorm.Expr("stock + ?", specs.Number)).Error
  • 影响
    1. 该 handler 完全不调用 ParseMetaCtx:独立部署(cmd/main/main.go:23 + internal/server/new.go:20-24grpc.NewServer() 无任何拦截器)下,无需任何 JWT 即可匿名调用 gRPC order.Mgt/OrderApprove;单体部署下也只要求任意有效 JWT不需要 Mall_Admin,也不校验订单 store_identity 归属 → 任何账号可把任意待审批订单置为「已退款/已退货」。
    2. 审批通过只改 status/approve没有任何资金动作:全模块检索 RefundPrice 只出现在模型定义与 proto 反射(models/order_summary.go:25logic/common/reflect.go:51从未被写入;模块内不存在 wallet 调用(grep wallet 零命中)→ 状态说"已退款",钱一分未退。
    3. 库存回滚按 product_identity 更新(order_approve.go:60),会命中该商品所有规格的行,且只回滚数量不区分 spec_id → 库存记到错误规格上(数据损坏、可被用于放大库存)。
    4. impl.DBService.Transaction(...)order_approve.go:47)作为语句调用、返回值完全未接收,函数末尾显式 return &pb.OrderStatusReply{Code: 0, Message: "OK", ...}, nilorder_approve.go:94-99)→ 事务失败仍返回成功(静态事实:返回值未接收 + 显式返回 nil
  • 建议:补鉴权(ParseMetaCtx + Mall_Admin + 店铺归属);退款/退货与钱包(或线下退款登记)在同一事务/同一 saga 内完成,落 refund_price;库存回滚必须 WHERE id = spec_id;事务错误必须检查并返回;引入 SELECT ... FOR UPDATE 或幂等状态机防止重复审批。

4. 优惠券核销无归属/有效期/门槛校验,非事务,且优惠金额被加到订单总价

  • 位置module/ec/order/internal/logic/summary/confirm.go:30,55-77
  • 证据
if in.CouponIdentity != "" {
	err = impl.DBService.Where("identity=?", in.CouponIdentity).First(&coupon).Error
	if err == nil {
		if coupon.Status == 2 {
			impl.DBService.Model(&models.OrderCoupon{}).Where("identity=?", in.CouponIdentity).Update("status", 3)
			couponAmount = coupon.Amount
		} else { printer.Error(err.Error()); return nil, errcode.ErrUnavailable }
	}
}
if couponAmount > 0 {
	summary.TotalPrice = summary.TotalPrice + couponAmount   // ← 符号错误:应减去
}
summary.Status = 1
err = impl.DBService.Where("order_no = ?", in.OrderNo).Updates(summary).Error
  • 影响
    1. 优惠券只按客户端传入的 coupon_identity 查询,不校验 passport_id 归属,也不校验 started/expired(两列从未被读取)与 condition 门槛("满减"条件被完全忽略)→ 任意用户可使用任意人的、已过期的、不满足门槛的优惠券。
    2. 券金额被加法total_price:用户使用优惠券反而要多付 coupon_amount(直接资金损失/客诉)。
    3. 券状态置 3已用与订单更新不在同一事务,且订单从不落 coupon_identity/coupon_amount(只写了 TotalPrice/Status)→ 券被吞掉后无法追溯,取消订单时 summary.CouponIdentity 为空(summary/cancel.go:48)导致恢复逻辑必然无效;若订单更新失败,券已被扣。
    4. 无并发保护:两笔订单可同时读到 status==2 并各自核销同一张券(扣减与判断之间没有 WHERE status=2 条件更新或行锁)→ 券超发(静态推断:依据是"先查后写"且无锁/无条件更新,未做并发实测)。
  • 建议:券的归属/有效期/门槛校验 + UPDATE ... SET status=3 WHERE identity=? AND status=2 AND passport_id=?(条件更新,检查 RowsAffected+ 订单金额 -= couponAmount,二者放同一事务;订单落 coupon_identity/coupon_amount;金额计算改为"应付 = Σ明细 - 优惠 + 运费",并写单测覆盖符号。

5. 库存一致性:提交订单不校验扣减结果(并发超卖)、取消订单从不回滚库存、快捷下单不扣库存

  • 位置
    • module/ec/order/internal/logic/summary/submit.go:203-218(不检查 RowsAffected
    • module/ec/order/internal/logic/summary/cancel.go:34-53module/ec/order/internal/logic/mgt/order_cancel.go:37(取消不回滚)
    • module/ec/order/internal/logic/summary/quick_create_by_product.go:148-159(完全不扣库存)
  • 证据
// submit.go:203-217 —— 先读后写,且条件更新的 RowsAffected 被丢弃
var stock int32
result := tx.Table("mall_product_spec").Select("stock").Where("id = ?", detail.SpecID).Scan(&stock)
if result.Error != nil || stock < detail.Number { return errors.New("库存不足或规格不存在") }
updateErr := tx.Table("mall_product_spec").
	Where("id = ? AND stock >= ?", detail.SpecID, detail.Number).
	UpdateColumn("stock", gorm.Expr("stock - ?", detail.Number)).Error
if updateErr != nil { ...; return updateErr }   // RowsAffected==0并发抢空被静默忽略
// summary/cancel.go:34-53 / mgt/order_cancel.go:37 —— 只改状态,无 stock 回滚
if summary.Status == 1 { ... values.Status = -1; err := impl.DBService.Where("order_no = ?", in.OrderNo).Updates(values) ... }
err = impl.DBService.Model(&models.OrderSummary{}).Where("identity=?", in.Identity).Update("status", -1).Error

对比:mgt/order_create.go:141-152 是正确写法(检查了 RowsAffected)。

  • 影响:① 两个用户并发提交最后一件商品时,预读都通过,条件更新其中一个 RowsAffected=0,但订单仍然创建 → 超卖(订单成立而库存未扣;静态推断:依据是丢弃 RowsAffected 且预读与更新非同一条语句,未做并发实测)。② order_create.go/submit.go下单时就扣库存,而所有取消路径都不回滚,也未支付超时关单(全模块无 ticker/cron→ 未支付订单(status=1)永久占用库存,库存只减不增mgt/order_approve.go 只在审批退货时回滚)。③ QuickCreateByProduct 建单不扣库存,可直接绕过库存限制。④ submit.go:203int32 承载 int64 列(mall_product_spec.stock 为 bigint超出 int32 时报 scan 错误并被误报为"库存不足"。
  • 建议:扣减统一改为 UPDATE ... SET stock=stock-? WHERE id=? AND stock>=?必须校验 RowsAffected==1,不足则回滚整个事务并返回明确错误码;扣减时机与回滚策略(下单占用 / 支付扣减)二选一并全链路一致;取消/超时关单释放库存(事务内 + 幂等标记);stockint64;对"已扣未付"订单加超时关单任务。

6. confirm.goerr == nil 分支调用 err.Error() → 必然 panic全链路无 recover可远程打崩进程

  • 位置module/ec/order/internal/logic/summary/confirm.go:62(同型写法见 submit.go:62,72 之外的其它 else 分支)
  • 证据
err = impl.DBService.Where("identity=?", in.CouponIdentity).First(&coupon).Error
if err == nil {
	if coupon.Status == 2 {
		...
	} else {
		printer.Error(err.Error())      // ← 此处 err 必为 nil → nil pointer dereference panic
		return nil, errcode.ErrUnavailable
	}
}
  • 影响:任何登录用户传入一张 status != 2(已使用/已禁用/状态未初始化)的券码,命中 Confirm 即触发 panic。internal/server/new.go:20-24grpc.NewServer() 没有任何 recovery 拦截器,单体侧 pkgs/ecmall/internal/server/server.go:32 也只挂了 JWT 拦截器 → grpc-go 不默认 recoverpanic 会终止整个进程(单体内即 14 个业务模块一起挂)。这是远程可触发的 DoS(且是"必然触发"而非概率事件)。
  • 建议:删除该行或改为 printer.Error("coupon unavaiable: %s", in.CouponIdentity);同时为 gRPC 增加 recovery 拦截器(grpc.UnaryInterceptordefer recover() → 返回内部错误码HTTP 侧已有 gin.Recovery 但 gRPC 侧没有。

7. 购物车 Modify/Delete 仅按自增 id 过滤,越权改/删任意用户购物车Modify 批量分支错误被完全吞掉

  • 位置module/ec/order/internal/logic/cart/modify.go:24-33,40,47-51cart/delete.go:22cart/create.go:45-49
  • 证据
// cart/modify.go:24-33 —— 无 passport 过滤err 赋值后在 47-51 行未被检查
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
	for _, update in in.Updates {
		err := tx.Table("order_cart").Where("id = ?", update.Id).Update("number", update.Number).Error
		if err != nil { printer.Error(err.Error()); return err }
	}
	return nil
})
} else {
	if in.GetNumber() == 0 { return nil, errcode.ErrInvalidArgument }
	err = impl.DBService.Table("order_cart").Where("id = ?", in.Id).Update("number", in.Number).Error
// cart/delete.go:22
err = impl.DBService.Where("id in ?", in.Id).Delete(&models.OrderCart{}).Error
// cart/create.go:45-49 —— 命中他人购物车行时把 passport_identity 改成自己
if cart.ID == 0 { err = impl.DBService.Create(&data).Error
} else { data.Number += cart.Number
	err = impl.DBService.Where("cart_identity=? and product_identity=? and spec_id = ? ", ...).Updates(&data).Error }
  • 影响order_cart.id 是自增主键可枚举Modify/Delete 均未按 passport_identity 限制 → 任意登录用户可把他人购物车的数量改成任意值(含 0/负数)或直接清空他人购物车,从而影响他人下单金额(submit.go:134 直接使用 item.Number × spec.Price)。Modify 的批量分支把 Transaction 的 error 赋给命名返回值 err,但函数末尾是显式 return &pb.OrderStatusReply{Code: 0, ...}, nilcart/modify.go:47-51err 从未参与返回 → 事务失败也恒返回成功(已确认该处不是裸 return,是显式返回 nilCreate 的"累加"分支(cart/create.go:45-49)在命中该 cart_identity+product_identity+spec_id 的行时会写入 passport_identity=auth.Identity 与一个新的 identityUUID,即把他人购物车行的归属改写为自己(越权写入 + 破坏 identity 唯一标识语义)。另外 modify.go:36-40 只校验 Number != 0负数可写入
  • 建议:所有购物车写操作追加 passport_identity = auth.Identity(或 cart_identity 归属校验)并检查 RowsAffectedModify 显式返回事务错误、校验 number > 0 且设上限;Create 更新分支不得写 passport_identity/identity 字段(用 Updates(map[string]any{"number": ...})gorm.Expr)。

P1

8. 订单状态机无任何约束,order_no 非唯一且生成规则有歧义 → 非法流转与错单

  • 位置summary/simulate_pay.go:25summary/simulate_shipments.go:29summary/simulate_receiving.go:29summary/cancel.go:34mgt/order_cancel.go:37internal/logic/common/no.go:10-13internal/models/order_summary.go:18
  • 证据
// simulate_receiving.go:29 —— 任意状态直接置 4已收货未校验是否已支付
err = impl.DBService.Model(&models.OrderSummary{}).Where("identity = ?", in.Identity).Update("status", 4).Error
// common/no.go:11-13 —— 月/日/时/分/秒不补零6 位随机数
t := time.Now().Local()
rand := fmt.Sprintf("%06v", rand.New(rand.NewSource(time.Now().UnixNano())).Int31n(1000000))
return fmt.Sprintf("%d%d%d%d%d%d%s", t.Year()-2000, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), rand)
// models/order_summary.go:18 —— order_no 仅普通 index无唯一约束
OrderNo string `gorm:"column:order_no;type:varchar(36);index;not null;" json:"order_no"`
  • 影响:① 状态可在 -1(已取消) → 2(已支付) → 3 → 4 间任意跳跃:未支付订单可被直接置为已收货;已支付/已发货订单可被 Cancel/OrderCancel-1不产生退款;重复支付无检测。② 订单号拼接不补零导致语义歧义1月15日11月5日 可产生同一前缀,可用字符串对比证实);同秒内 6 位随机数10^6 空间)在批量下单时存在碰撞风险(静态推断,未做批量实测),而 Confirm/Cancel order_no 为唯一定位键confirm.go:30cancel.go:28)且无归属校验 → 碰撞即操作错误订单/他人订单。③ 订单号使用 math/rand(非加密随机)且种子含时间 → 可预测/可枚举(静态推断),配合 P0-1 可用于定向攻击。
  • 建议:显式状态机(map[from][]toWHERE status IN (...) 条件更新 + RowsAffected 校验),支付/发货/收货/取消各自限定前置状态;订单号改为 ULID/UUIDv7utils.UUID() 已在用)或"日期+序列"并加唯一索引order_no 相关的写操作必须先按 identity 定位再校验归属。

9. 数量与金额可由客户端操纵(允许 0/负数量),且 Submit 忽略 id/store_identity 提交整车

  • 位置cart/modify.go:26,36-40cart/create.go:27-49summary/submit.go:45,98-157,134mgt/order_create.go:41-45summary/quick_create_by_product.go:29,102
  • 证据
// cart/modify.go:36-40 —— 仅拒绝 0负数放行批量分支26 行)完全不校验
if in.GetNumber() == 0 { return nil, errcode.ErrInvalidArgument }
err = impl.DBService.Table("order_cart").Where("id = ?", in.Id).Update("number", in.Number).Error
// summary/submit.go:45,134 —— 忽略 in.Id / in.StoreIdentity用整车 + 客户端数量
err = impl.DBService.Where("passport_identity = ?", auth.Identity).Find(&cart).Error
itemTotal := int64(item.Number) * spec.Price
// mgt/order_create.go:42 —— 只拒绝 0
if spec.GetProductIdentity() == "" || spec.GetNumber() == 0 { return nil, errcode.ErrInvalidArgument }
  • 影响:负数量会产生负金额订单trans_price/total_price < 0),配合 P0-2 的支付接口可将订单"付清"Submit 忽略请求中的 id(购物车行选择)与 store_identity,永远提交该用户整车数据 → 接口契约与实现不符(客户端无法只结算选中项),且数量上限缺失(int32 直接乘 int64 单价可溢出)。QuickCreateByProduct 同样只拒绝 0quick_create_by_product.go:29)。
  • 建议:数量在入口统一校验 1 <= number <= 上限(并为 int32*int64 做溢出检查);Submitin.Idpassport_identity 双重过滤,只结算选中行;金额计算后校验 >= 0

10. Submit 信任购物车里客户端写入的 product_id/product_identity,订单明细可与真实规格脱钩

  • 位置cart/create.go:35-42(客户端写入)、summary/submit.go:82,112,141
  • 证据
// cart/create.go:36-41 —— product_id/product_identity 全部来自请求体
var data = models.OrderCart{ CartIdentity: in.CartIdentity, ProductID: in.ProductId,
	ProductArgs: in.ProductArgs, Number: in.Number, ProductIdentity: in.ProductIdentity, SpecID: in.SpecId }
// summary/submit.go:140-142 —— 明细里的商品标识直接用购物车行的值,而价格来自 spec_id
ProductID: item.ProductID, ProductIdentity: item.ProductIdentity, SpecID: item.SpecID,
  • 影响Cart.Create 不校验 product_idproduct_identity 是否指向同一商品、也不校验 spec_id 属于该商品;Submit 只用 item.SpecID 取价(价安全),但把客户端给的 ProductIdentity 原样写入订单明细 → 订单明细的"商品"与"规格/单价"可指向不同商品(对账、按商品统计、后续发货/售后核销全部错位)。同时 mall_product 的查询用 item.ProductID(客户端可控),可用他人商品的价格下自己商品的单。
  • 建议:购物车写入时以服务端查询结果为准(由 spec_id 反查商品,落库服务端计算出的 product_id/product_identity,或至少做一致性校验);Submit 组装明细时一律使用服务端根据 spec_id 查出的商品字段。

11. 两条下单路径价格口径不一致,QuickCreateByProduct 订单头尾金额不一致且无事务

  • 位置mgt/order_create.go:67-97summary/quick_create_by_product.go:102,128,149-159summary/submit.go:123-146
  • 证据
// mgt/order_create.go:67-77 —— Take 未按 spec 过滤(取任意一条规格),单价取 product.sales_price
err = impl.DBService.Table("mall_product_spec").Take(&spec, "product_identity=? ", specs.ProductIdentity).Error
unit_price := product["sales_price"].(int64)
itemTotal := int64(specs.Number) * unit_price
// quick_create_by_product.go:102 vs :128 —— 订单头用 spec 价,明细用 product 价
TransPrice := (int64(in.Number) * spec["price"].(int64))
unit_price := product["sales_price"].(int64)
// quick_create_by_product.go:149-159 —— 先建 summary 再建 details无事务
err = impl.DBService.Create(summary).Error
...
err = impl.DBService.Create(details).Error
  • 影响OrderCreate 忽略规格价(用商品 sales_price)且 specTake 取到任意规格(多规格商品会记错 spec_id/spec_titleQuickCreateByProduct 出现的订单头 trans_price = number × 规格价,而 order_details.unit_price = 商品 sales_price → 同一订单头尾金额不一致(结算/对账无解);且 summary 与 details 分两次写、无事务 → 失败时留下孤儿订单头Submit 使用 spec.price,与 OrderCreate 口径不同 → 同一商品不同入口不同价。
  • 建议:统一价格来源(建议规格价 mall_product_spec.price,必要时叠加阶梯价),spec 查询必须带 id/identity 限定并校验存在性;QuickCreateByProduct 用事务包裹 summary+details并让 details.total_price 之和等于订单头金额(写后校验)。

12. 管理端跨店越权:agency 参数可绕开角色校验并使用请求指定的店铺;OrderListByStore 用客户端可控的 partner_id 过滤

  • 位置mgt/order_get.go:21-24,39-46mgt/order_list_by_store.go:21-24,52-60mgt/order_cancel.go:19-37mgt/order_returnable.go:19
  • 证据
// mgt/order_get.go:21-46
RoleValve := &service.ParseOptions{RoleValue: "Mall_Admin"}
if in.GetAgency() != "" { RoleValve = nil }          // ← 请求带 agency 即降级为"任意有效 JWT"
...
storeIdentity = in.GetStoreIdentity()                 // ← 店铺标识完全由客户端决定
err = impl.DBService.Model(&models.OrderSummary{}).Where("store_identity = ? and identity=?", storeIdentity, in.Identity)...
// mgt/order_list_by_store.go:60
tx := impl.DBService.Model(&models.OrderSummary{}).Where("store_identity = ?", storeIdentity).Where("partner_id = ?", auth.ID)
  • 影响:任何登录用户只要在 OrderGet/OrderListByStore 请求里带上 agency,就跳过 Mall_Admin 角色校验,并可用任意 store_identity 读取任意店铺的订单(含收货人/手机号)。OrderListByStore 仍以 partner_id = auth.ID 过滤,而 partner_idOrderCreate/Submit/QuickCreateByProduct 中来自客户端请求(order_create.go:111submit.go:163quick_create_by_product.go:107)→ 订单可见性可被下单方操纵(把订单藏起来或塞进他人列表)。OrderCancel/OrderReturnable/OrderApprove 则完全不校验 store_identity → 任一商家可操作其他店铺的订单(取消、发起售后、审批)。
  • 建议:鉴权开关不得依赖请求内容;agency 校验收敛为服务端权限表/角色判定;所有管理端写操作强制 store_identity 来自 token 且与订单一致(WHERE store_identity=? AND identity=? + RowsAffected 校验);partner_id 由服务端根据 token/归属推导,禁止客户端指定。

13. 生产默认开启全量 SQL Debug 日志(含全部参数)且无法通过配置关闭 → 敏感信息泄露

  • 位置internal/models/impl.go:22,41,54-63,85service 注入路径 internal/impl/with.go:41D:\work\bsm-sdk\core\database\sql\postgresql.go:11-22
  • 证据
// models/impl.go:41,85
err = DBService.AutoMigrate(migrateTables...)
...
func NewPostgres(...) { ... db = db.Debug(); return db, nil }   // 无条件 Debug
// impl/with.go:41 —— options 传 nil
err := models.New(config.Spec.Databases.Driver, config.Spec.Databases.Source, nil)
// SDK sql.SetOptions(nil) 默认 Debug: true且 conf.DBConf 只有 Driver/Sourceyaml 无法配置
  • 影响order_dev/prod/test.yaml 均为 Driver: postgres,因此每次都走 NewPostgresdb.Debug() 无条件生效SDK 默认 Debug: true 使 MySQL 路径同样开启。GORM Debug 会把每条 SQL 及其全部参数值打到 stdout包括 order_summary.phone/contact/addressmember_phone、优惠券码、pay_trade_noetc/supervisor.bsm-ec-order.conf 又把 stdout 落到 /data/app/logs/ec-order.log(无轮转)→ 收货人 PII 长期留存于日志文件。同时全量 SQL 日志带来明显性能与磁盘开销。
  • 建议NewPostgres 去掉无条件 Debug()options 由配置显式传入(并在 DBConf 增加 SqlOptions 字段),生产强制 Debug:false;日志层面统一接入 SDK logger 并做字段脱敏;宿主机日志加轮转。

14. 无跨服务资金一致性/补偿/对账,无幂等键、无超时/重试/熔断,ctx 未下传 DB

  • 位置:模块全局;典型 summary/submit.go:187summary/confirm.go:77mgt/order_approve.go:34etc/order_prod.yaml:29-33Rpc 全部注释)
  • 证据
// 全模块 `grep wallet` 零命中;所有写操作直接使用全局 DBService不带 ctx
err = impl.DBService.Where("order_no = ?", in.OrderNo).Updates(summary).Error   // confirm.go:77
err = impl.DBService.Transaction(func(tx *gorm.DB) error { ... })              // submit.go:187
  • 影响:订单"已支付/已退款/已取消"只改本地状态,没有任何钱包/账务调用(无 RPC 客户端、无 MQ、无 outbox、无对账任务→ 资金与订单状态必然长期不一致,且没有补偿入口(一致性后果为静态推断,依据是模块内不存在任何资金侧调用与对账代码,已全仓检索 wallet 零命中)。所有写接口无幂等键(request_id/Idempotency-KeySubmit/OrderCreate/QuickCreateByProduct 重复点击即重复下单(静态推断SimulatePay 重复回调覆盖支付字段可直接证实。DB 调用不传 ctx,客户端断开/超时不会取消慢查询,也没有任何超时预算。
  • 建议:明确支付/退款边界order 只做状态编排,资金由 wallet 以幂等键驱动),引入本地消息表/outbox + 重试与死信落地日对账任务所有写接口要求幂等键并在唯一索引上兜底DB 调用统一 WithContext(ctx) 并设置 statement timeout跨服务调用加超时/重试/熔断。

15. 错误处理与错误码语义混乱NotFound 被吞/被映射为 ErrDB空实现返回成功

  • 位置summary/check.go:26-30summary/list.go:57-64mgt/order_get.go:46-53mgt/order_list_by_store.go:77-84summary/get.go:27-31mgt/order_returnable.go:41mgt/order_modify.go:25-33summary/cancel.go:53-60
  • 证据
// summary/check.go:26-30 —— 文档说"检测未付款订单",实际取该用户任意一条订单,且 NotFound 被映射为 ErrDB
err = impl.DBService.Where("passport_id = ?", auth.ID).First(&summary).Error
if err != nil { printer.Error(err.Error()); return nil, errcode.ErrDB }
// mgt/order_get.go:46-53 —— Find 不会返回 ErrRecordNotFound该分支恒不成立
err = impl.DBService.Model(&models.OrderSummary{}).Where(...).Preload("OrderDetails").Find(&order).Error
if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errcode.ErrRecordNotFound } ... }
// mgt/order_modify.go:25-33 —— 未实现却返回成功
// TODO: valid code
return &pb.OrderStatusReply{Code: 0, Message: "OK", ...}, nil
  • 影响Check 语义错误(不按 status 过滤,"有订单"就返回 OKFirst 的 NotFound 一律被报成 ErrDB(客户端无法区分"不存在"与"数据库故障"),而 Find 之后的 ErrRecordNotFound 判断是死代码mgt/order_get.go:48order_list_by_store.go:79summary/list.go:59OrderModify 是空实现却回 OK(商家以为改单成功);Cancel 在订单非未支付时也返回 OK 但什么都不做(静默失败)。调用方无法据此做重试/提示。
  • 建议统一错误码映射NotFound→ErrRecordNotFoundDB 错误→ErrDB,业务规则→专用 errcodeFirst/Find 明确区分;未实现的接口返回 ErrUnimplemented 或直接不注册;所有"无操作"分支返回明确错误码。

16. 事务边界缺陷:券/订单/库存跨事务,事务返回值被丢弃,零值字段不落库

  • 位置summary/confirm.go:59,77summary/cancel.go:41-52mgt/order_approve.go:47summary/quick_create_by_product.go:149-159summary/submit.go:187-228models/query.go:22-40
  • 证据
// summary/cancel.go:35-41 —— 用 struct Updates 清空券字段:"" 与 0 是零值GORM 会跳过 → 实际未清空
values := &models.OrderSummary{ CouponIdentity: "", CouponAmount: 0.00 }
values.Status = -1
err := impl.DBService.Where("order_no = ?", in.OrderNo).Updates(values).Error
// summary/cancel.go:48 —— 券恢复与订单更新分属两个事务,且 identity 为空串
err = impl.DBService.Model(&models.OrderCoupon{}).Where("identity=?", summary.CouponIdentity).Update("status", 2).Error
// models/query.go:33-40 —— 未使用的工具函数Commit 失败不 Rollbackidentity 参数未使用
if err := tx.Create(&detail).Error; err != nil { tx.Rollback(); return err }
return tx.Commit().Error
  • 影响Cancel 想把 coupon_identity/coupon_amount 清零但因 GORM 零值跳过而永远清不掉(已核对 gorm v1.31.2 callbacks/update.go:294if (ok || !isZero) && field.Updatable —— struct 更新只写入非零字段),券恢复又与状态更新不在同一事务(部分成功即数据不一致);Confirm(券核销)/OrderApprove(状态+库存)同样跨事务或被吞错误;Submit 把"建单+建明细+逐条查改库存+清空购物车"塞进一个大事务并串行发起多次查询(长事务/锁持有时间偏长)。models.QuicklyCreateOrder 亦存在 Commit 失败不回滚的问题(该函数本身未被调用,见 P2-23
  • 建议:把"状态变更 + 券变更 + 库存变更"收敛到同一事务并用条件更新保证幂等;清空字段用 Updates(map[string]any{...})Select 显式字段;事务回调内的错误必须返回并检查;Submit 的库存预校验与扣减合并为条件更新,减少事务内查询次数。

17. 分页无上限、深分页 OFFSET、循环内 N+1 查询、缓存完全未使用

  • 位置summary/list.go:40-57mgt/order_list_by_store.go:46-77cart/fetch.go:38-47summary/submit.go:80-95,107-131internal/impl/with.go:24-31
  • 证据
// mgt/order_list_by_store.go:49-50 —— page_size 无上限
if in.GetPageSize() > 0 { size = int(in.PageSize) }
// cart/fetch.go:38-47 —— 循环内一条 RAW 查询N+1
for _, item := range cart {
	err := impl.DBService.Raw(`SELECT p.id, p.identity, ... FROM mall_product p WHERE p.identity = ?`, item.ProductIdentity).Scan(&product).Error
	if err == nil { ... }   // 查询失败被静默跳过(见 P2-21
}
// summary/submit.go:82,112-124 —— 循环内逐条查询 product / specN+1且 product 被查两遍)
err = impl.DBService.Select("store_identity").Table("mall_product").Where("id = ?", item.ProductID).First(&product).Error
  • 影响page_size 可由客户端传任意大(无 MAX 限制),配合 OFFSET (page-1)*size 深分页可拖垮 DB购物车/提交流程按商品条数线性放大 DB 往返N+1且同一商品重复查询结算链路延迟随购物车规模抖动模块初始化了 Rediswith.go:24-31)与内存缓存(service/Dependencies.Cache)却零使用,热门商品/规格/地址信息每次直查 DB。此外 order_summary(passport_identity, created_at)(store_identity, status, created_at) 复合索引,列表按 created_at desc 排序会走 filesort推测,取决于数据量与执行计划)。
  • 建议page_size 服务端封顶(如 50/100并改用游标created_at < ? + id)分页;cart.Fetch/Submit 改为按 identity/id 集合批量查询(IN)一次取回;商品/规格/地址读加 Redis 缓存与失效策略;按查询模式补复合索引。

18. map[string]any 类型断言无 comma-okauth.Owner 断言无校验 → 与驱动/JWT 内容强耦合,不匹配即 panic

  • 位置mgt/order_create.go:50-52,62,74-75,83-94,115-122summary/quick_create_by_product.go:43,62,102,104,112-117,128-145
  • 证据
// mgt/order_create.go:50-52,62 —— 三个无校验断言
ownerStore := auth.Owner.(map[string]any)
storeId := uint(ownerStore["store_id"].(float64))
storeIdentity := ownerStore["store_identity"].(string)
if product["status"].(int32) != 1 { return nil, errcode.ErrUnknown }
// quick_create_by_product.go:43 —— int16另一处 int32依赖列类型
if store["status"].(int16) != 1 { return nil, errcode.ErrUnknown }
  • 影响:当前生产配置为 postgres这些断言恰好成立(已核对列类型:mall_product.status/gas_typesint32→integer→pgx 返回 int32mall_store.statusStd_IICUDS.Status int8→smallint→int16id/price/sales_price/supply_id 为 int64→bigint。但models.New 同时支持 mysql 驱动MySQL 下 tinyint/smallint/intColumnTypeScanType 分别是 int8/int16/int32go-sql-driver 行为),届时 product["status"].(int32) 会 panic② 任何列类型调整(如把 status 改为 smallint/bigint)都会 panicauth.OwneranyJWT owner claimD:\work\bsm-sdk\core\crypto\token\jwt.go:19owner 缺失/为字符串或 map 中缺 store_id 键时 .(map[string]any)/.(float64) panic④ 结合 P0-6 的"无 recovery"panic = 进程退出。MySQL 场景标注推测(未在 MySQL 实例上验证)。
  • 建议:所有断言改 v, ok := m[k].(T); if !ok { return errcode.ErrInternal }DB 读取改为强类型 structmall_product/mall_store/spec 用局部 struct 映射)而不是 map[string]anyauth.Owner 用结构体或 json 反序列化到明确定义的 owner 类型后再取值。

19. PII 明文外泄面:订单详情接口返回未脱敏的收货人信息,且无操作审计

  • 位置logic/common/reflect.go:62-64,80-81summary/get.go:27mgt/order_get.go:46summary/confirm.go:60
  • 证据
// common/reflect.go:62-64,80-81 —— 明文返回
Address: summary.Province + summary.City + summary.Area + summary.Address,
Contact: summary.Phone, ...
MemberPhone: summary.MemberPhone,
  • 影响:结合 P0-1/P1-12 的越权读取,攻击者可批量拉取他人姓名/手机号/详细地址;退款/审批等资金动作无操作审计日志(谁在何时批准了哪笔退款无从追溯),仅有 printer.Error 形式的错误日志。
  • 建议:对非归属方的读取直接拒绝(优先),并对返回的手机号做掩码;资金动作写审计日志(操作者 identity、订单号、前后状态、金额、request_id

P2

20. 独立部署时 HTTP 网关未注册任何路由nil mux——全局模板缺陷本模块仅引用

  • 位置cmd/main/main.go:23-33internal/server/new.go:26-30service/expose.go:23-33
  • 证据
// internal/server/new.go:26-30 —— Mux 字段从未被赋值(无 gwRuntime.NewServeMux()
srv := &Server{ Ctx: context.Background(), Grpc: grpcServ, grpcConns: make(map[string]*grpc.ClientConn) }
// cmd/main/main.go:31-32 —— 把 nil 传给网关;且 main 从不调用 service.Expose
GatewayConf: config.Spec.Gateway, GatewayMux: s.Mux,
  • 影响:独立部署下 Gateway.Enable: true:12441)但无任何 /order.* 路由handler 注册只发生在 service/expose.go 的单体路径),请求落到 http.DefaultServeMux该缺陷由模块模板(internal/server/new.go + service/expose.go)统一引入,所有模块同构,不在本模块独立论证;本模块只需按 P2 修复或改为不暴露网关。
  • 建议:待全局模板统一修复(server.New 内建 gwRuntime.NewServeMux() 并注册 handler生成代码对 nil mux 加防御),本模块不单独改造。

21. 迁移机制不一致:启动即 AutoMigrateDDL 权限/启动耦合),单体路径下 order 表不参与迁移且 models.DBService 恒为 nil

  • 位置internal/models/impl.go:15-45internal/impl/with.go:33-48service/dependencies.go:19-29pkgs/ecmall/internal/impl/impl.go:38
  • 证据
// models/impl.go:41 —— 无条件 AutoMigrate生产也执行 DDL失败 log.Fatalln
err = DBService.AutoMigrate(migrateTables...)
// service/dependencies.go:26-28 —— 单体只注入 impl.DBServicemodels.DBService 保持 nil
if deps.DB != nil { impl.DBService = deps.DB }
  • 影响:订单模块的 models 未注册到 SDK 的 database.AppendMigrate(对比 module/ec/mall/internal/models/mall_product.go:64-66init(){ database.AppendMigrate(...) }),单体部署走 with.Databases(cfg, nil)database.NewDatabase(..., nil)SetOptions(nil) 默认 IsAutoMigrate: false,而 SDK 仅在 if len(MigrateTables) > 0 && options.IsAutoMigrate 时才 AutoMigrateD:\work\bsm-sdk\core\database\new.go:43)→ 单体部署下 order以及 mall/address相关表都不会被自动迁移;而 models.DBServicemodels/query.go:22 直接使用)在单体下为 nil → 任何直接调用 models.DBService 的代码都会 nil panic当前 QuicklyCreateOrder 未被调用,属潜在雷)。独立部署则在每次启动执行 AutoMigrate(需要 DDL 权限、且与启动强耦合,迁移失败直接 log.Fatalln)。
  • 建议:统一使用 database.AppendMigrate 注册模型并把迁移放到独立迁移流程(IsAutoMigrate 由环境控制);删除 models.DBService 这一全局二义入口,统一走 impl.DBService

22. 错误被忽略与 _ =gormDb.DB() 错误被丢弃后直接解引用

  • 位置internal/models/impl.go:66-72summary/confirm.go:59test/rpc/*(大量 re, _ := json.Marshal
  • 证据
// models/impl.go:66-72 —— err 被丢弃sqlDB 可能为 nil
sqlDB, _ := gormDb.DB()
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
// summary/confirm.go:59 —— Update 错误未检查(券已置为已用,却不知道是否成功)
impl.DBService.Model(&models.OrderCoupon{}).Where("identity=?", in.CouponIdentity).Update("status", 3)
  • 影响:连接池配置静默失效甚至 nil panic券核销失败无感知配合 P0-4 直接导致券状态与订单不一致)。
  • 建议:检查并返回 (*sql.DB, error);所有写操作检查 error 与 RowsAffected

23. 死代码与"引用不存在列"的残缺 SQL 工具函数

  • 位置internal/models/order_summary.go:70-151internal/models/query.go:22-40internal/models/types.go:23-55logic/common/get.go:23-33logic/common/reflect.gototal_price 列被忽略)
  • 证据
// models/order_summary.go:130-131 —— 条件传错变量;且 pay_status/logistics_status/invoice_status/
// type/buyer_identity/merchant_identity/sign_status 等列在 OrderSummary 上并不存在
if payStatus != 0 { tx = tx.Where("pay_status  = ?", maxPrice) }
// models/query.go:22 —— 全仓无调用方identity 参数未使用Commit 失败不 Rollback
func QuicklyCreateOrder(identity string, order *OrderSummary, detail []*OrderDetails) error {
  • 影响GetSummaryList/GetSummaryCnt 一旦被调用即 SQL 报错(列不存在)且 pay_status 过滤条件用的是 maxPrice(逻辑错误);QuicklyCreateOrder/ListWithBuyerModel/GetOrderSummaryByNo 无任何调用方(已全仓检索确认),属死代码,且把"事务+DB 访问"放在 models 包的公共函数里破坏分层。
  • 建议:删除死代码;需要的查询按真实表结构重写并补测试;order_details.total_price 要么使用要么删除(当前回复中由 Number*UnitPrice 现算:logic/common/reflect.go:33)。

24. TypeScript SDK 与 proto 严重不一致(客户端为空实现),字段类型/命名混乱

  • 位置sdk/typescript/order/index.ts:242-360,496-501sdk/typescript/order/const.ts:5-131proto/summary.proto:112proto/coupon.proto:25
  • 证据
// index.ts:243-258 —— 接口与工厂函数都是空的,没有任何请求方法
export interface Cart { }
export function createCartClient(handler: RequestHandler): Cart { return { }; }
// index.ts:280 —— 金额用 stringproto/coupon.proto:25 `string amount`
amount?: string;
// proto/summary.proto:112 —— 运费用 double全模块金额单位为 int64 分)
double logistics_fee = 5 [json_name="logistics_fee"];
  • 影响SDK 无法调用任何接口(只有 URL 常量表 const.ts),前端必然自行拼 URLamount 为 string 而 DB 为 int64 分、logistics_fee 为 double 而其它金额为 int64 分 → 单位/精度不一致(浮点用于金额,违反本模块 int64 分约定);部分字段缺 json_name 导致生成 camelCasestoreIdentityzipCode),与实际 .http 用例里发送的 store_identity 不一致(如 test/mgt/order_list_by_store.http:6store_identity,服务端按 json_name 绑定 → 该过滤参数被静默忽略,落到 token 店铺)。
  • 建议:重新生成并补齐 client 方法(含请求/响应类型与 URL 常量对应);所有金额字段统一 int64(分)并去掉 double给全部字段补 json_name,统一 snake_case 入参。

25. 请求字段被忽略/被注释掉:接口契约与实现不符

  • 位置summary/submit.go:45,55-76(忽略 in.Id/in.StoreIdentity)、summary/confirm.go:36-50address_identity 整段注释、remark/logistics_fee 忽略)、summary/quick_create_by_product.go:88-97,119(配送员查询与 member_identity 注释)、summary/simulate_shipments.go:29(忽略 item_idetity/car_identity)、mgt/order_create.go(忽略 delivery_time
  • 证据
// summary/confirm.go:36-53 —— 地址更新整段被注释,运费恒为 0但确认逻辑仍照旧执行
if in.AddressIdentity != "" {
	/*
		err = impl.DBService.Where("id=?", in.AddressId).First(&address).Error
		... logisFee = GetLogisticsFee(summary.TotalPrice, address.Province)
	*/
}
if logisFee > 0 { summary.TotalPrice = summary.TotalPrice + logisFee }
  • 影响:调用方按 proto 传参却不生效(改地址/备注/运费全部无效,Confirm 只改总价与状态),线上表现为"参数正确但数据没变",且 GetLogisticsFee 被注释后运费永远为 0Submit 也硬编码 LogisticsFee: 0summary/submit.go:168)。
  • 建议:要么实现这些字段(地址改用归属校验后的 address_library 快照、运费走运费模板),要么从 proto 中删除并在文档标注;禁止在核心链路保留大段注释代码。

26. OrderSummary.StoreID 在多店提交时取值错误;QuickCreateByProduct 规格查询条件错误

  • 位置summary/submit.go:80-95,166summary/quick_create_by_product.go:48-75
  • 证据
// submit.go:91-93,166 —— StoreId 只取第一件商品的店铺,却用于每个店铺分组
if k == 0 { StoreId = product.StoreId }
...
StoreID: StoreId,
// quick_create_by_product.go:50-53,68 —— 商品按 serial_id 查,规格却仍按 product_identity 查
if pi == "gas_by_kg" || pi == "gas_by_bottle" {
	err = impl.DBService.Table("mall_product").Take(&product, "store_identity=? and serial_id=?", in.StoreIdentity, pi).Error
...
err = impl.DBService.Table("mall_product_spec").Take(&spec, "product_identity=?", in.ProductIdentity).Error
  • 影响:跨店铺购物车提交时,除第一个店铺外所有订单的 store_id 都是错的(与 store_identity 不一致,商家端按 store_id 统计/关联时错位);燃气类商品(gas_by_kg/gas_by_bottle)走 serial_id 分支时,in.ProductIdentity 并非真实 product_identity,规格查询取不到(或取到无关商品的规格),且 spec_identity 参数被完全忽略 → 燃气下单失败或错价。
  • 建议StoreID 在分组循环内按组设置;规格查询统一使用服务端解析后的商品 identity/spec identity并删除/实现 spec_identity

27. 无优雅退出、无健康检查、无可观测性

  • 位置cmd/main/main.go:36-40D:\work\bsm-sdk\core\service\service.go:114,142-144etc/order_prod.yaml:35-38
  • 证据
defer srv.Stop()
srv.Start()   // Start() 内部以 `select {}` 阻塞service.go:114→ defer 永不执行
// etc/order_prod.yaml:35-38 —— APM 整段注释
  • 影响:无 SIGTERM 处理与 draindefer srv.Stop() 死代码,滚动发布时直接被杀(进行中的事务/请求被中断);无健康检查端点(独立部署 MicrService.Enable: falseetcd 也没注册APM/tracing 关闭、无指标埋点、日志无 request_id/订单号上下文(printer.Error(err.Error()) 是唯一日志手段)。
  • 建议signal.NotifyContext + GracefulStop + HTTP shutdown暴露 /healthz//readyz;开启 APM 或至少接入结构化日志(含 request_id、order_no、user identity

28. 配置卫生:三套环境配置几乎相同、占位凭证入库、无环境隔离

  • 位置etc/order_dev.yaml:1-27etc/order_prod.yaml:1-33etc/order_test.yaml:1-27etc/supervisor.bsm-ec-order.conf
  • 证据
# order_prod.yaml:7,10,27 —— 三份文件内容一致(含 dev/test
- 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/
SecretKey: CHANGE_ME
  • 影响prod/dev/test 使用同一 DSN 与占位密码(sslmode=disable 明文连接);SecretKey 在本模块未被使用JWT 密钥走 env.Runtime.JwtSecretKey)易误导;Anonymous: [order.ping.hello] 指向不存在的方法(anon 配置形同虚设);未配置 BindIPCheckIP 取本机 IPgateway 仍监听 0.0.0.0:12441supervisor 以 root 运行且日志无轮转。
  • 建议:配置按环境分离并接入密钥管理(不落库占位值)、生产启用 TLS/sslmode=require;清理无效 Anonymoussupervisor 降权 + logrotate/supervisor 轮转。

29. 分层与边界logic 直接拼 SQL 读写其他模块的表(无防腐层)

  • 位置mgt/order_create.go:57,67,101,141summary/submit.go:82,112,123,204,209,221summary/quick_create_by_product.go:35,51,68,79mgt/order_approve.go:60cart/fetch.go:40-47
  • 证据
// 直接以字符串表名跨模块读写mall_product / mall_product_spec / mall_store / address_library
err = impl.DBService.Table("mall_product_spec").Take(&spec, "product_identity=? ", specs.ProductIdentity).Error
result := tx.Table("mall_product_spec").Where("id = ? AND stock >= ?", v.SpecID, v.Number).UpdateColumn("stock", gorm.Expr("stock - ?", v.Number))
  • 影响order 模块直接写 mall 模块的库存表、读 address 库,绕过了 mall/address 模块的领域规则(库存校验、状态校验、地址归属),任意一方改表结构/语义都会静默破坏订单侧;也是 P0-5库存只减不增与 P1-9/10信任客户端 product_id难以收敛的根因。SQL 均用参数占位(未发现注入,LIKE 亦参数化:order_list_by_store.go:75)。
  • 建议:改为调用 mall/address 的服务接口(或在单体内部使用其 model 层 API把库存操作封装为 mall 域的原子接口;若必须同库直连,至少抽出统一 repository 并加契约测试。

P3

30. order_cart 的 gorm tag 语法错误导致 NOT NULL 未生效

  • 位置internal/models/order_cart.go:19
  • 证据
CartIdentity string `gorm:"column:cart_identity;type:varchar(36);index;not null';" json:"cart_identity"`
  • 影响tag 中多了 'not null'GORM 以 ; 拆分设置项后 key 为 NOT NULL',无法匹配(已核对 gorm v1.31.2 schema/field.go:129NotNull: utils.CheckTruth(tagSetting["NOT NULL"], tagSetting["NOTNULL"]))→ cart_identity 列允许 NULL与注释/预期不符(也说明该文件由生成器产出后未校验)。
  • 建议:修正为 not null;,并用迁移核对线上实际列定义。

31. 魔法数字遍布状态与金额,无常量/枚举定义

  • 位置internal/models/order_summary.go:30,39,55mgt/order_approve.go:30-92summary/simulate_pay.go:25mgt/order_list_by_store.go:36-37summary/submit.go:139
  • 证据
Status: 1,                      // 未支付
PayAmount: 10000000, PayTradeNo: "Pay12371937812897", PayType: 4, PayRemark: "支付备注",
case 1: ... "status": 7 ... case 2: ... "status": 8 ... case 3: ... "status": 6
  • 影响:订单状态(-1/1..8)、approve-2..4)、pay_type0-4、金额常量全部硬编码散落各处语义仅靠注释enum 未在 proto 中定义(const.protoint32 status + 注释SDK 注释里还出现另一套语义(sdk/typescript/order/index.ts:66 写 "all/notarize/sign/signed/fulfil/accomplish")→ 前后端对状态含义理解不一致。
  • 建议:在 proto 定义 enum 并生成常量Go 侧集中定义状态常量与状态机SDK 注释与实现同步。

32. 超长函数、被注释的代码块、注释与实现不符

  • 位置summary/submit.go247 行单函数)、mgt/order_create.go170 行)、summary/quick_create_by_product.go167 行,含 88-97 注释掉的配送员查询)、summary/confirm.go:36-50logic/common/no.go:9internal/models/order_cart.go:11(注释"订单详情"实为购物车)、proto/cart.proto:34,41
  • 证据
// quick_create_by_product.go:88-97 —— 死注释块
// // 查询配送员信息
// member := map[string]any{}
// err = impl.DBService.Table("delivery_member").Take(&member, "identity=?", in.MemberIdentity).Error
// cart.proto:34,41 —— 注释与字段语义错位
int64 product_id = 2 [json_name = "product_id"];  // 商品ID
int64 unit_price = 9 ...  // 实际单价
  • 影响:下单/结算流程难以维护与回归注释与字段含义错位TS 生成物里 OrderDetails.product_id 注释为"商品规格ID"、spec_id 注释为"商品规格id")会误导调用方。
  • 建议:把 Submit/OrderCreate/QuickCreateByProduct 拆分为"取数→校验→计价→落库"小函数;清理注释代码;修正 proto/SDK 注释。

33. 测试覆盖严重不足(无断言、忽略错误、无并发/幂等/越权用例)

  • 位置test/rpc/{cart,coupon,mgt,summary}_test.gotest/rpc/bisic_test.go:15test/{cart,coupon,mgt,summary}/*.httptest/lint/(空目录)
  • 证据
//go:build integration
...
res, err := clent.Create(ctx, &pb.CartAddRequest{ CartIdentity: "10b8b3a0-9679-17d0-bb86-0ef402f390d6", ... })
if err != nil { fmt.Println(err) }          // 无断言、忽略错误、硬编码样本 ID
  • 影响:现有测试全部需要真实环境(BSM_ORDER_TOKEN + 生产样本 ID且只打印不校验缺失:并发下单/超卖、优惠券并发核销与超发、重复支付/回调幂等、状态机非法流转(已取消→已支付、未支付→已收货)、越权(他人订单/购物车/券)、金额符号与分摊、空购物车提交、分页边界、OrderApprove 鉴权。test/lint 为空目录,无 lint 门禁。
  • 建议:引入 sqlite/testcontainers 的单元测试与表驱动用例覆盖上述清单;为每个 P0 加回归测试(尤其 confirm 的券符号与 panic 分支、submit 的库存 RowsAffected、购物车归属校验)。

34. 其他一致性/冗余问题(合并)

  • 位置logic/common/reflect.go:33,62,70coupon/by_status.go:18,28,34-36,52summary/submit.go:236-246
  • 证据
// common/reflect.go:33 —— 忽略 DB 列 order_details.total_price改用现算
TotalPrice: int64(val.Number) * val.UnitPrice,
// coupon/by_status.go:28,52 —— 忽略请求 statusamount 填的是 intro
err = impl.DBService.Where("passport_id=?", auth.ID).Find(&coupon).Error
Amount: v.Intro,
// summary/submit.go:236-246 —— 空购物车也返回 Code:0 成功Identity 为空串)
firstOrderNo := ""; if len(summary) > 0 { firstOrderNo = summary[0].OrderNo }
return &pb.OrderStatusReply{Code: 0, Identity: firstOrderNo, ...}, nil
  • 影响ByStatusstatus 过滤参数被忽略(返回全部券)、返回的 amount 实为 introstarted/expired/status 永不填充(coupon.proto:20-29 的字段全是死字段);Coupon.ByStatus 还要求 Mall_Admin 角色去查"自己的券"(语义错误);空购物车提交返回成功导致前端误判下单成功;AddressAddr 两字段内容重复/不一致。
  • 建议:修正券映射与状态过滤、明确角色要求;Submit 在购物车为空时返回明确错误;统一地址字段语义;order_details.total_price 明确以 DB 列为准。

4. 推荐优化方案

  1. 建立统一的"归属校验"中间件/helper最高优先:在 logic 层入口强制 auth := service.ParseMetaCtx(...),并提供 loadOwnedOrder(ctx, identityOrNo)loadOwnedCart(...),内部固定拼接 passport_identity/store_identity;所有按主键/编号定位资源的写操作必须校验 RowsAffected == 1OrderApprove 等资金动作接口必须补 ParseMetaCtx + 角色 + 店铺归属。
  2. 收口支付与退款边界Simulate* 三个接口从生产网关下线(或仅在 test 环境注册);真实支付由支付回调驱动,写入前校验 status==1pay_amount == total_price,用 pay_trade_no 唯一索引做幂等;退款必须与 wallet 协同(本地消息表 + 重试 + 日对账),并落 refund_price
  3. 优惠券重写券归属/有效期/门槛校验 → 条件更新核销WHERE status=2→ 订单落 coupon_identity/coupon_amount → 金额 -= couponAmount,全部在同一事务内;金额计算抽成纯函数并单测(含符号、分摊、分币舍入)。
  4. 库存一致性:统一"条件更新 + 校验 RowsAffected"的扣减函数;明确扣减时机(建议支付成功扣减,或下单占用+超时释放),取消/关单/退款全链路释放库存;增加未支付超时关单任务(status=1 且 created_at < now-15min)。
  5. 状态机收敛:定义 proto enum + Go 常量 + 允许的迁移表,所有状态变更走同一个 transition(identity, fromSet, to, extra) 函数(WHERE status IN (...) + RowsAffected 校验),并写审计日志。
  6. 健壮性基础设施gRPC 增加 recoverypanic → 内部错误码 + 结构化日志)、context 全链路下传与超时、DB statement timeout写接口幂等键与限流signal.NotifyContext 优雅退出 + /healthz
  7. 可观测性与安全基线:生产关闭 GORM Debug SQL 日志并做日志脱敏/轮转;开启 APM日志带 request_id/order_no/passport_identity;配置按环境分离、密钥由密钥管理下发、启用 TLS。
  8. 数据模型与契约order_no 加唯一索引并改用 ULID/UUID(passport_identity, created_at)(store_identity, status, created_at) 复合索引;金额统一 int64 分(去掉 proto 中的 double修复 SDK补齐 client 方法)与 proto/实现字段对齐(删除或实现被忽略的参数)。
  9. 跨模块边界:库存/商品/地址访问改为 mall/address 服务接口或统一 repository禁止在 order 模块直接拼表名写他域数据。
  10. 测试门禁:为每个 P0/P1 建立回归用例(并发下单、券并发核销、支付幂等、状态机非法流转、越权矩阵),纳入 CIgofmt -lgo vet、lint

5. TODO 清单

  • P0-1 为全部订单读写入口补归属校验(passport_identity/store_identity + RowsAffected)|验收:以 A 用户 token 对 B 的订单执行 Get/Cancel/Confirm/SimulatePay/SimulateShipments/SimulateReceiving 全部返回权限错误且数据不变|涉及:module/ec/order/internal/logic/summary/get.go:27summary/cancel.go:28summary/confirm.go:30summary/simulate_pay.go:25summary/simulate_receiving.go:29summary/simulate_shipments.go:29logic/common/get.go:15mgt/order_cancel.go:37
  • P0-2 支付写入改为「校验 status==1 + pay_amount==total_price + pay_trade_no 唯一幂等」|验收:重复回调只生效一次,金额始终等于订单应付,取消单/已付单的支付请求被拒绝|涉及:module/ec/order/internal/logic/summary/simulate_pay.go:25internal/models/order_summary.go:57
  • P0-3OrderApprove 鉴权与归属校验,并把退款资金动作 + 库存回滚纳入同事务(库存按 spec_id 回滚、事务错误必须返回)|验收:无/非商家 token 调用被拒;跨店审批被拒;回滚库存只命中目标规格;事务失败返回错误码|涉及:module/ec/order/internal/logic/mgt/order_approve.go:17mgt/order_approve.go:47mgt/order_approve.go:60
  • P0-4 重写优惠券核销(归属/有效期/门槛 + 条件更新 + 事务 + 金额改为相减 + 订单落券字段)|验收:他人券/过期券/不满足门槛券被拒;并发核销同一券仅一次成功;订单 total_price 减少 coupon_amount 且落 coupon_identity|涉及:module/ec/order/internal/logic/summary/confirm.go:55
  • P0-5 库存扣减校验 RowsAffected 并统一回滚策略(取消/超时/退款释放库存)|验收:并发抢最后一件仅一单成功;取消未支付订单后库存恢复;QuickCreateByProduct 也走同一扣减函数|涉及:module/ec/order/internal/logic/summary/submit.go:209summary/cancel.go:34mgt/order_cancel.go:37summary/quick_create_by_product.go:148
  • P0-6 删除 confirm.go:62 的 nil err.Error() 调用,并为 gRPC 增加 recovery 拦截器|验收:券 status!=2 时返回业务错误码而非 panic人工注入 panic 后进程存活且返回内部错误|涉及:module/ec/order/internal/logic/summary/confirm.go:62internal/server/new.go:23
  • P0-7 购物车 Modify/Delete/Create 加归属过滤与入参校验Modify 返回事务错误|验收:改/删他人 order_cart.id 无效果且返回权限错误;number<=0 被拒;事务失败返回错误|涉及:module/ec/order/internal/logic/cart/modify.go:26cart/modify.go:40cart/delete.go:22cart/create.go:49
  • P1-8 实现订单状态机(含 order_no 唯一索引与订单号方案替换)|验收:非法流转全部被拒并返回明确错误码;重复订单号不再可能|涉及:module/ec/order/internal/logic/common/no.go:10internal/models/order_summary.go:18summary/simulate_receiving.go:29
  • P1-9/10/11 统一数量与价格口径(1<=number<=上限、规格价为准、以服务端商品数据组装明细、两处下单同一价源)|验收:负数量被拒;同商品两入口单价一致;订单头金额等于明细合计|涉及:module/ec/order/internal/logic/cart/modify.go:36summary/submit.go:140mgt/order_create.go:67summary/quick_create_by_product.go:102
  • P1-12 移除 agency 鉴权降级,管理端强制店铺归属,partner_id 服务端推导|验收:带 agency 的买家 token 无法读取他店订单;他店订单的 Cancel/Approve 被拒|涉及:module/ec/order/internal/logic/mgt/order_get.go:22mgt/order_list_by_store.go:22mgt/order_list_by_store.go:60
  • P1-13 关闭生产 GORM Debug SQL 日志并提供配置项|验收:生产日志中不再出现 SQL 参数与 PIIDebug 可由配置控制|涉及:module/ec/order/internal/models/impl.go:85internal/models/impl.go:61
  • P1-14 支付/退款引入幂等键与对账/补偿机制|验收:重复提交同一幂等键只产生一次影响;存在日对账任务与差异告警|涉及:module/ec/order/internal/logic/summary/submit.go:187summary/confirm.go:77
  • P1-15 统一错误码映射并清理空实现/静默成功验收NotFound 与 DB 错误可区分;未实现接口返回 Unimplemented|涉及:module/ec/order/internal/logic/mgt/order_modify.go:25summary/check.go:26mgt/order_get.go:46
  • P1-16 收敛事务边界(券/状态/库存同事务,零值字段用 map 更新)|验收:任一步失败全部回滚;Cancel 后券字段确实被清空且券状态恢复|涉及:module/ec/order/internal/logic/summary/cancel.go:41summary/confirm.go:59
  • P1-17 消除 N+1 与分页风险、启用缓存|验收:购物车/结算的 SQL 条数与商品数无关;page_size 超限被拒;热点商品/规格命中缓存|涉及:module/ec/order/internal/logic/cart/fetch.go:40summary/submit.go:82mgt/order_list_by_store.go:49
  • P1-18 消除无校验类型断言与 panic 面|验收:断言全部 comma-ok 化或改强类型 struct注入类型不匹配数据不再 panic涉及module/ec/order/internal/logic/mgt/order_create.go:50summary/quick_create_by_product.go:43
  • P2-20 独立部署网关注册:交由全局模板修复(本模块验收标准仅为「若继续暴露网关,则 /order.* 路由可达」)|涉及:module/ec/order/internal/server/new.go:26service/expose.go:23
  • P2-21/22/23 统一迁移机制、检查被忽略的 error、删除死代码验收order 模型纳入统一迁移;gormDb.DB() 错误被处理;死代码清除|涉及:module/ec/order/internal/models/impl.go:41internal/models/impl.go:66internal/models/order_summary.go:83
  • P2-24/25/26 修复 TS SDK 与 proto/实现不一致落地或删除被忽略参数验收SDK 可完整调用四类接口;所有 proto 字段要么生效要么移除;跨店 store_id 正确|涉及:module/ec/order/sdk/typescript/order/index.ts:254module/ec/order/proto/summary.proto:112summary/submit.go:91
  • P2-27/28 补优雅退出/健康检查/可观测性与配置治理验收SIGTERM 后请求 drain 完成退出;/healthz 可用;日志含 request_id 且无占位凭证入库|涉及:module/ec/order/cmd/main/main.go:37module/ec/order/etc/order_prod.yaml:7
  • P2-29 跨模块表访问收敛为服务接口/统一 repository验收order 模块不再直接写 mall_product_spec|涉及:module/ec/order/internal/logic/summary/submit.go:209mgt/order_create.go:141
  • P3-30..34 修正 gorm tag、抽出状态/金额常量与 enum、拆分超长函数、清理注释代码与注释错位、补齐测试与 lint 门禁|验收:order_cart.cart_identity 为 NOT NULL状态以 enum 常量表达;单函数 <80 行P0/P1 回归用例全部纳入 CI涉及module/ec/order/internal/models/order_cart.go:19module/ec/order/internal/logic/summary/submit.go:24module/ec/order/test/rpc/summary_test.go:13

6. 审计摘要(供汇总使用)

  • 问题数P0=7 P1=12 P2=10 P3=5合计 34 条,编号 134其中 P3-34 为合并项,涵盖券映射/空购物车/地址字段等 4 处小问题)
  • 最高风险(一句话):订单、购物车与优惠券的定位/写入几乎全部只信任客户端传入的 identity/order_no/id 且缺少归属校验,任意持有有效 JWT 的账号即可读取他人收货信息、把任意订单置为已支付/已发货/已收货、取消他人订单、核销任意优惠券;叠加「模拟支付」硬编码金额、退款审批接口零鉴权且不动资金、confirm.go 必然 panic无 recovery资金与库存一致性在本模块内无任何保障。
  • 最优先 3 个动作
    1. 立即下线/隔离 Summary.SimulatePay|SimulateShipments|SimulateReceivingMgt.OrderApprove(或对其加 Mall_Admin + 归属校验),并为所有订单/购物车/券读写加 passport_identity/store_identity 过滤 —— 直接封堵 P0-1/P0-3/P0-7。
    2. confirm.go:62 的 nil panic 与券金额符号错误(优惠券应相减、券核销与订单更新同事务、校验券归属/有效期/门槛)—— 封堵 P0-4/P0-6 与多收钱问题。
    3. 库存扣减统一为「条件更新 + 校验 RowsAffected」,取消/关单/退款全链路释放库存,并把支付写入改为带幂等键与金额校验 —— 封堵 P0-2/P0-5 的超卖与库存只减不增。
  • 未能覆盖/无法验证的部分
    1. 未执行运行时/集成测试(test/rpc 需真实 DB/网关与 BSM_ORDER_TOKEN),并发超卖、重复核销、重复支付只能给出代码级推断,未做压测实测。
    2. 未在 MySQL 驱动下实测P1-18 关于 ColumnTypeScanType 导致类型断言 panic 的部分标注为「推测」;当前 postgres 配置下已核对列类型,断言恰好成立)。
    3. 未审阅 pb/ 生成代码内部实现(仅检索网关路由模式确认暴露路径),也未审阅 wallet/mall 模块内部是否已有对账机制本报告只依据「order 模块未调用它们」下结论)。
    4. 线上真实执行计划与索引使用情况P1-17 复合索引建议)依赖数据量与 DB 统计信息,未做 EXPLAIN 验证。
    5. 前端/SDK 实际调用方未在仓内检索到TS SDK 空实现对线上调用方式的影响未评估。