# 审计报告: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/order`,go 1.26.5,依赖 `git.apinb.com/bsm-sdk/core v0.2.1`(本地 replace 到 `D:\work\bsm-sdk\core`)。 - **服务定义**(`proto/{cart,coupon,mgt,summary}.proto`,HTTP 路径由 protoc-gen-slc 生成,均为 `POST /order./`,无 `google.api.http` 注解): - `Cart`:Fetch / Create / Modify / Delete(C 端购物车) - `Coupon`:ByStatus(优惠券列表) - `Mgt`:OrderCreate / OrderModify / OrderGet / OrderListByStore / OrderCancel / OrderReturnable / OrderApprove(商家端订单) - `Summary`:QuickCreateByProduct / Submit / Check / Get / List / Confirm / Cancel / **SimulatePay / SimulateShipments / SimulateReceiving**(C 端订单 + 模拟支付/发货/收货) - **数据表**(`internal/models`,启动时 `AutoMigrate`):`order_cart`、`order_coupon`、`order_details`、`order_summary`。金额一律 `int64`(分)。 - `order_summary`:`OrderNo varchar(36) index`(**非唯一**)、`Status int32 index`、`PassportID/PassportIdentity index`、`StoreID/StoreIdentity index`、`Identity varchar(36) uniqueIndex`、`RefundPrice/CouponIdentity/CouponAmount/PayType/PayAmount/PayTradeNo/PayTime/Approve` 等。 - `order_details`:`SummaryIdentity index`、`OrderNo index`、`ProductID`、`SpecID`、`Number`、`UnitPrice`、`SalesPrice`、`TotalPrice`。 - **外部依赖**:本模块**没有任何跨服务 RPC 客户端**(`config.Spec.Rpc` 存在但 etc 中全部被注释,代码未引用);`internal/impl.RedisCache` 已初始化但**全模块零使用**;`service.Dependencies` 支持由单体注入 DB/Redis/Cache。 - **两种运行方式**: 1. 独立进程 `cmd/main/main.go`:`server.New(nil)` → `grpc.NewServer()`(**无任何拦截器**)→ gRPC 监听 `:12442`;Gateway 配置 `Enable: true, Port: 12441`。 2. 单体 `pkgs/ecmall`(`internal/service/order.go` 调 `module/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.ParseMetaCtx`(`D:\work\bsm-sdk\core\service\meta.go:19-49`)解析 metadata `authorization` 并校验 JWT;`opts==nil` 表示**只要求任意有效 JWT(任意角色,含 C 端买家)**;`RoleValue:"Mall_Admin"` 表示要求 `claims.Extend["role"]=="Mall_Admin"`。 ## 2. 审计范围与方法 **已覆盖(全部非 pb 源码逐行阅读)** - logic:`cart/{create,fetch,modify,delete}.go`、`coupon/by_status.go`、`common/{get,no,reflect}.go`、`mgt/{order_create,order_modify,order_get,order_list_by_store,order_cancel,order_returnable,order_approve}.go`、`summary/{quick_create_by_product,submit,check,get,list,confirm,cancel,simulate_pay,simulate_shipments,simulate_receiving}.go` - models:`impl.go`、`order_cart.go`、`order_coupon.go`、`order_details.go`、`order_summary.go`、`query.go`、`types.go` - server:`new.go`、`cart_server.go`、`coupon_server.go`、`mgt_server.go`、`summary_server.go`;service:`expose.go`、`dependencies.go`;`cmd/main/main.go`;`internal/{config,impl}` - 配置:`etc/order_{dev,prod,test}.yaml`、`etc/supervisor.bsm-ec-order.conf`;`proto/*.proto`;`sdk/typescript/order/{index.ts,const.ts}`;`test/**`(rpc 测试 + .http 用例 + 空目录 `test/lint`) - 交叉验证(只读):`D:\work\bsm-sdk\core`(meta.go / jwt.go / db.go / conf / database/sql / gorm 驱动行为)、`pkgs/ecmall`(拦截器、网关、模块装配)、`module/ec/mall` 与 `module/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 test`(`test/rpc/*` 均为 `//go:build integration` 且依赖真实 DB/网关与 `BSM_ORDER_TOKEN`,本环境无法运行)。 - 另核对了 gorm v1.31.2 的三处行为(用于支撑结论,非推测):`callbacks/update.go:294` 的 `if (ok || !isZero) && field.Updatable`(struct `Updates` 跳过零值字段)、`schema/field.go:129` 的 `utils.CheckTruth(tagSetting["NOT NULL"], ...)`(tag key 不匹配即不生效)、`database/new.go:43` 的 `if 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:46`、`mgt/order_cancel.go:37` - **证据**: ```go // 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 的账号(普通买家即可,无需商家角色)只要拿到/猜到订单 `identity` 或 `order_no`,就能:读取他人订单的**收货人姓名、手机号、详细地址**(Get);把他人订单标记为**已支付**(SimulatePay);标记**已发货/已收货**(SimulateShipments/SimulateReceiving);**取消**他人订单(Cancel);**确认并核销他人订单所带优惠券**(Confirm)。`order_no` 为 18 位含 6 位伪随机数的可枚举串(见 P1-8),`identity`/`order_no` 还会通过列表、日志、前端透出。此外 Confirm/Cancel 只要 order_no 相同(该列**无唯一约束**)就会命中另一张订单。 - **建议**:所有按 `identity/order_no/Id` 定位订单的写操作,一律追加 `passport_identity = auth.Identity`(C 端)或 `store_identity = `(商家端)并校验 `RowsAffected`;`order_no` 查询改为「先按 identity 取,再校验归属」;为 `order_no` 建唯一索引。 #### 2. 「模拟支付」硬编码金额与交易号,无金额校验、无状态校验、无幂等 → 支付数据必然错账 - **位置**:`module/ec/order/internal/logic/summary/simulate_pay.go:25` - **证据**: ```go 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==1` 且 `pay_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` - **证据**: ```go 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 { ``` ```go // 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-24` 的 `grpc.NewServer()` 无任何拦截器)下,**无需任何 JWT** 即可匿名调用 gRPC `order.Mgt/OrderApprove`;单体部署下也只要求任意有效 JWT,**不需要 `Mall_Admin`**,也不校验订单 `store_identity` 归属 → 任何账号可把任意待审批订单置为「已退款/已退货」。 2. 审批通过只改 `status/approve`,**没有任何资金动作**:全模块检索 `RefundPrice` 只出现在模型定义与 proto 反射(`models/order_summary.go:25`、`logic/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", ...}, nil`(`order_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` - **证据**: ```go 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-53`、`module/ec/order/internal/logic/mgt/order_cancel.go:37`(取消不回滚) - `module/ec/order/internal/logic/summary/quick_create_by_product.go:148-159`(完全不扣库存) - **证据**: ```go // 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(并发抢空)被静默忽略 ``` ```go // 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:203` 用 `int32` 承载 `int64` 列(`mall_product_spec.stock` 为 bigint),超出 int32 时报 scan 错误并被误报为"库存不足"。 - **建议**:扣减统一改为 `UPDATE ... SET stock=stock-? WHERE id=? AND stock>=?` 并**必须校验 `RowsAffected==1`**,不足则回滚整个事务并返回明确错误码;扣减时机与回滚策略(下单占用 / 支付扣减)二选一并全链路一致;取消/超时关单释放库存(事务内 + 幂等标记);`stock` 用 `int64`;对"已扣未付"订单加超时关单任务。 #### 6. `confirm.go` 在 `err == nil` 分支调用 `err.Error()` → 必然 panic;全链路无 recover,可远程打崩进程 - **位置**:`module/ec/order/internal/logic/summary/confirm.go:62`(同型写法见 `submit.go:62,72` 之外的其它 `else` 分支) - **证据**: ```go 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-24` 的 `grpc.NewServer()` **没有任何 recovery 拦截器**,单体侧 `pkgs/ecmall/internal/server/server.go:32` 也只挂了 JWT 拦截器 → grpc-go 不默认 recover,panic 会终止整个进程(单体内即 14 个业务模块一起挂)。这是**远程可触发的 DoS**(且是"必然触发"而非概率事件)。 - **建议**:删除该行或改为 `printer.Error("coupon unavaiable: %s", in.CouponIdentity)`;同时为 gRPC 增加 recovery 拦截器(`grpc.UnaryInterceptor` 内 `defer recover()` → 返回内部错误码),HTTP 侧已有 gin.Recovery 但 gRPC 侧没有。 #### 7. 购物车 Modify/Delete 仅按自增 `id` 过滤,越权改/删任意用户购物车;Modify 批量分支错误被完全吞掉 - **位置**:`module/ec/order/internal/logic/cart/modify.go:24-33,40,47-51`、`cart/delete.go:22`、`cart/create.go:45-49` - **证据**: ```go // 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 ``` ```go // 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, ...}, nil`**(`cart/modify.go:47-51`),err 从未参与返回 → 事务失败也恒返回成功(已确认该处不是裸 `return`,是显式返回 nil)。`Create` 的"累加"分支(`cart/create.go:45-49`)在命中该 `cart_identity+product_identity+spec_id` 的行时会写入 `passport_identity=auth.Identity` 与一个**新的 `identity`(UUID)**,即把他人购物车行的归属改写为自己(越权写入 + 破坏 `identity` 唯一标识语义)。另外 `modify.go:36-40` 只校验 `Number != 0`,**负数可写入**。 - **建议**:所有购物车写操作追加 `passport_identity = auth.Identity`(或 `cart_identity` 归属校验)并检查 `RowsAffected`;`Modify` 显式返回事务错误、校验 `number > 0` 且设上限;`Create` 更新分支不得写 `passport_identity/identity` 字段(用 `Updates(map[string]any{"number": ...})` 或 `gorm.Expr`)。 ### P1 #### 8. 订单状态机无任何约束,`order_no` 非唯一且生成规则有歧义 → 非法流转与错单 - **位置**:`summary/simulate_pay.go:25`、`summary/simulate_shipments.go:29`、`summary/simulate_receiving.go:29`、`summary/cancel.go:34`、`mgt/order_cancel.go:37`、`internal/logic/common/no.go:10-13`、`internal/models/order_summary.go:18` - **证据**: ```go // 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:30`、`cancel.go:28`)且无归属校验 → 碰撞即操作错误订单/他人订单。③ 订单号使用 `math/rand`(非加密随机)且种子含时间 → 可预测/可枚举(**静态推断**),配合 P0-1 可用于定向攻击。 - **建议**:显式状态机(`map[from][]to` 或 `WHERE status IN (...)` 条件更新 + `RowsAffected` 校验),支付/发货/收货/取消各自限定前置状态;订单号改为 ULID/UUIDv7(`utils.UUID()` 已在用)或"日期+序列"并加**唯一索引**;`order_no` 相关的写操作必须先按 identity 定位再校验归属。 #### 9. 数量与金额可由客户端操纵(允许 0/负数量),且 `Submit` 忽略 `id/store_identity` 提交整车 - **位置**:`cart/modify.go:26,36-40`、`cart/create.go:27-49`、`summary/submit.go:45,98-157,134`、`mgt/order_create.go:41-45`、`summary/quick_create_by_product.go:29,102` - **证据**: ```go // 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` 同样只拒绝 0(`quick_create_by_product.go:29`)。 - **建议**:数量在入口统一校验 `1 <= number <= 上限`(并为 `int32*int64` 做溢出检查);`Submit` 按 `in.Id` 与 `passport_identity` 双重过滤,只结算选中行;金额计算后校验 `>= 0`。 #### 10. `Submit` 信任购物车里客户端写入的 `product_id/product_identity`,订单明细可与真实规格脱钩 - **位置**:`cart/create.go:35-42`(客户端写入)、`summary/submit.go:82,112,141` - **证据**: ```go // 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_id` 与 `product_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-97`、`summary/quick_create_by_product.go:102,128,149-159`、`summary/submit.go:123-146` - **证据**: ```go // 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`)且 `spec` 用 `Take` 取到**任意规格**(多规格商品会记错 `spec_id/spec_title`);`QuickCreateByProduct` 出现的订单头 `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-46`、`mgt/order_list_by_store.go:21-24,52-60`、`mgt/order_cancel.go:19-37`、`mgt/order_returnable.go:19` - **证据**: ```go // 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_id` 在 `OrderCreate`/`Submit`/`QuickCreateByProduct` 中来自客户端请求(`order_create.go:111`、`submit.go:163`、`quick_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,85`、`service` 注入路径 `internal/impl/with.go:41`、`D:\work\bsm-sdk\core\database\sql\postgresql.go:11-22` - **证据**: ```go // 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/Source,yaml 无法配置 ``` - **影响**:`order_dev/prod/test.yaml` 均为 `Driver: postgres`,因此每次都走 `NewPostgres`(`db.Debug()` 无条件生效);SDK 默认 `Debug: true` 使 MySQL 路径同样开启。GORM Debug 会把**每条 SQL 及其全部参数值**打到 stdout,包括 `order_summary.phone/contact/address`、`member_phone`、优惠券码、`pay_trade_no`;`etc/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:187`、`summary/confirm.go:77`、`mgt/order_approve.go:34`;`etc/order_prod.yaml:29-33`(Rpc 全部注释) - **证据**: ```go // 全模块 `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-Key`),`Submit`/`OrderCreate`/`QuickCreateByProduct` 重复点击即重复下单(**静态推断**);`SimulatePay` 重复回调覆盖支付字段(可直接证实)。DB 调用不传 `ctx`,客户端断开/超时不会取消慢查询,也没有任何超时预算。 - **建议**:明确支付/退款边界(order 只做状态编排,资金由 wallet 以幂等键驱动),引入本地消息表/outbox + 重试与死信,落地日对账任务;所有写接口要求幂等键并在唯一索引上兜底;DB 调用统一 `WithContext(ctx)` 并设置 statement timeout;跨服务调用加超时/重试/熔断。 #### 15. 错误处理与错误码语义混乱:NotFound 被吞/被映射为 ErrDB,空实现返回成功 - **位置**:`summary/check.go:26-30`、`summary/list.go:57-64`、`mgt/order_get.go:46-53`、`mgt/order_list_by_store.go:77-84`、`summary/get.go:27-31`、`mgt/order_returnable.go:41`、`mgt/order_modify.go:25-33`、`summary/cancel.go:53-60` - **证据**: ```go // 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` 过滤,"有订单"就返回 OK);`First` 的 NotFound 一律被报成 `ErrDB`(客户端无法区分"不存在"与"数据库故障"),而 `Find` 之后的 `ErrRecordNotFound` 判断是**死代码**(`mgt/order_get.go:48`、`order_list_by_store.go:79`、`summary/list.go:59`);`OrderModify` 是空实现却回 `OK`(商家以为改单成功);`Cancel` 在订单非未支付时也返回 `OK` 但什么都不做(静默失败)。调用方无法据此做重试/提示。 - **建议**:统一错误码映射(NotFound→`ErrRecordNotFound`,DB 错误→`ErrDB`,业务规则→专用 errcode),`First`/`Find` 明确区分;未实现的接口返回 `ErrUnimplemented` 或直接不注册;所有"无操作"分支返回明确错误码。 #### 16. 事务边界缺陷:券/订单/库存跨事务,事务返回值被丢弃,零值字段不落库 - **位置**:`summary/confirm.go:59,77`、`summary/cancel.go:41-52`、`mgt/order_approve.go:47`、`summary/quick_create_by_product.go:149-159`、`summary/submit.go:187-228`、`models/query.go:22-40` - **证据**: ```go // 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 失败不 Rollback,identity 参数未使用 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:294`:`if (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-57`、`mgt/order_list_by_store.go:46-77`、`cart/fetch.go:38-47`、`summary/submit.go:80-95,107-131`、`internal/impl/with.go:24-31` - **证据**: ```go // 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 / spec(N+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,且同一商品重复查询),结算链路延迟随购物车规模抖动;模块初始化了 Redis(`with.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-ok`、`auth.Owner` 断言无校验 → 与驱动/JWT 内容强耦合,不匹配即 panic - **位置**:`mgt/order_create.go:50-52,62,74-75,83-94,115-122`、`summary/quick_create_by_product.go:43,62,102,104,112-117,128-145` - **证据**: ```go // 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_types` 为 `int32`→integer→pgx 返回 `int32`;`mall_store.status` 为 `Std_IICUDS.Status int8`→smallint→`int16`;`id/price/sales_price/supply_id` 为 int64→bigint)。但:① `models.New` 同时支持 `mysql` 驱动,MySQL 下 `tinyint/smallint/int` 的 `ColumnTypeScanType` 分别是 `int8/int16/int32`(go-sql-driver 行为),届时 `product["status"].(int32)` 会 panic;② 任何列类型调整(如把 `status` 改为 `smallint`/`bigint`)都会 panic;③ `auth.Owner` 为 `any`(JWT `owner` claim,`D:\work\bsm-sdk\core\crypto\token\jwt.go:19`),`owner` 缺失/为字符串或 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 读取改为强类型 struct(`mall_product`/`mall_store`/`spec` 用局部 struct 映射)而不是 `map[string]any`;`auth.Owner` 用结构体或 `json` 反序列化到明确定义的 owner 类型后再取值。 #### 19. PII 明文外泄面:订单详情接口返回未脱敏的收货人信息,且无操作审计 - **位置**:`logic/common/reflect.go:62-64,80-81`、`summary/get.go:27`、`mgt/order_get.go:46`、`summary/confirm.go:60` - **证据**: ```go // 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-33`、`internal/server/new.go:26-30`、`service/expose.go:23-33` - **证据**: ```go // 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. 迁移机制不一致:启动即 AutoMigrate(DDL 权限/启动耦合),单体路径下 order 表不参与迁移且 `models.DBService` 恒为 nil - **位置**:`internal/models/impl.go:15-45`、`internal/impl/with.go:33-48`、`service/dependencies.go:19-29`、`pkgs/ecmall/internal/impl/impl.go:38` - **证据**: ```go // models/impl.go:41 —— 无条件 AutoMigrate(生产也执行 DDL),失败 log.Fatalln err = DBService.AutoMigrate(migrateTables...) // service/dependencies.go:26-28 —— 单体只注入 impl.DBService,models.DBService 保持 nil if deps.DB != nil { impl.DBService = deps.DB } ``` - **影响**:订单模块的 models 未注册到 SDK 的 `database.AppendMigrate`(对比 `module/ec/mall/internal/models/mall_product.go:64-66` 的 `init(){ database.AppendMigrate(...) }`),单体部署走 `with.Databases(cfg, nil)` → `database.NewDatabase(..., nil)` → `SetOptions(nil)` 默认 `IsAutoMigrate: false`,而 SDK 仅在 `if len(MigrateTables) > 0 && options.IsAutoMigrate` 时才 AutoMigrate(`D:\work\bsm-sdk\core\database\new.go:43`)→ **单体部署下 order(以及 mall/address)相关表都不会被自动迁移**;而 `models.DBService`(`models/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-72`、`summary/confirm.go:59`、`test/rpc/*`(大量 `re, _ := json.Marshal`) - **证据**: ```go // 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-151`、`internal/models/query.go:22-40`、`internal/models/types.go:23-55`、`logic/common/get.go:23-33`、`logic/common/reflect.go`(`total_price` 列被忽略) - **证据**: ```go // 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-501`、`sdk/typescript/order/const.ts:5-131`、`proto/summary.proto:112`、`proto/coupon.proto:25` - **证据**: ```ts // index.ts:243-258 —— 接口与工厂函数都是空的,没有任何请求方法 export interface Cart { } export function createCartClient(handler: RequestHandler): Cart { return { }; } // index.ts:280 —— 金额用 string;proto/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`),前端必然自行拼 URL;`amount` 为 string 而 DB 为 `int64` 分、`logistics_fee` 为 double 而其它金额为 int64 分 → 单位/精度不一致(浮点用于金额,违反本模块 int64 分约定);部分字段缺 `json_name` 导致生成 camelCase(`storeIdentity`、`zipCode`),与实际 `.http` 用例里发送的 `store_identity` 不一致(如 `test/mgt/order_list_by_store.http:6` 发 `store_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-50`(`address_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`) - **证据**: ```go // 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` 被注释后**运费永远为 0**(`Submit` 也硬编码 `LogisticsFee: 0`,`summary/submit.go:168`)。 - **建议**:要么实现这些字段(地址改用归属校验后的 `address_library` 快照、运费走运费模板),要么从 proto 中删除并在文档标注;禁止在核心链路保留大段注释代码。 #### 26. `OrderSummary.StoreID` 在多店提交时取值错误;`QuickCreateByProduct` 规格查询条件错误 - **位置**:`summary/submit.go:80-95,166`、`summary/quick_create_by_product.go:48-75` - **证据**: ```go // 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-40`、`D:\work\bsm-sdk\core\service\service.go:114,142-144`、`etc/order_prod.yaml:35-38` - **证据**: ```go defer srv.Stop() srv.Start() // Start() 内部以 `select {}` 阻塞(service.go:114)→ defer 永不执行 // etc/order_prod.yaml:35-38 —— APM 整段注释 ``` - **影响**:无 `SIGTERM` 处理与 drain,`defer srv.Stop()` 死代码,滚动发布时直接被杀(进行中的事务/请求被中断);无健康检查端点(独立部署 `MicrService.Enable: false`,etcd 也没注册);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-27`、`etc/order_prod.yaml:1-33`、`etc/order_test.yaml:1-27`、`etc/supervisor.bsm-ec-order.conf` - **证据**: ```yaml # 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` 配置形同虚设);未配置 `BindIP` → `CheckIP` 取本机 IP(`gateway` 仍监听 `0.0.0.0:12441`);supervisor 以 `root` 运行且日志无轮转。 - **建议**:配置按环境分离并接入密钥管理(不落库占位值)、生产启用 TLS/`sslmode=require`;清理无效 `Anonymous` 项;supervisor 降权 + logrotate/supervisor 轮转。 #### 29. 分层与边界:logic 直接拼 SQL 读写**其他模块的表**(无防腐层) - **位置**:`mgt/order_create.go:57,67,101,141`、`summary/submit.go:82,112,123,204,209,221`、`summary/quick_create_by_product.go:35,51,68,79`、`mgt/order_approve.go:60`、`cart/fetch.go:40-47` - **证据**: ```go // 直接以字符串表名跨模块读写: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` - **证据**: ```go 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:129`:`NotNull: utils.CheckTruth(tagSetting["NOT NULL"], tagSetting["NOTNULL"])`)→ `cart_identity` 列允许 NULL,与注释/预期不符(也说明该文件由生成器产出后未校验)。 - **建议**:修正为 `not null;`,并用迁移核对线上实际列定义。 #### 31. 魔法数字遍布状态与金额,无常量/枚举定义 - **位置**:`internal/models/order_summary.go:30,39,55`、`mgt/order_approve.go:30-92`、`summary/simulate_pay.go:25`、`mgt/order_list_by_store.go:36-37`、`summary/submit.go:139` - **证据**: ```go 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_type`(0-4)、金额常量全部硬编码散落各处,语义仅靠注释;`enum` 未在 proto 中定义(`const.proto` 用 `int32 status` + 注释),SDK 注释里还出现另一套语义(`sdk/typescript/order/index.ts:66` 写 "all/notarize/sign/signed/fulfil/accomplish")→ 前后端对状态含义理解不一致。 - **建议**:在 proto 定义 enum 并生成常量,Go 侧集中定义状态常量与状态机;SDK 注释与实现同步。 #### 32. 超长函数、被注释的代码块、注释与实现不符 - **位置**:`summary/submit.go`(247 行单函数)、`mgt/order_create.go`(170 行)、`summary/quick_create_by_product.go`(167 行,含 `88-97` 注释掉的配送员查询)、`summary/confirm.go:36-50`、`logic/common/no.go:9`、`internal/models/order_cart.go:11`(注释"订单详情"实为购物车)、`proto/cart.proto:34,41` - **证据**: ```go // 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.go`、`test/rpc/bisic_test.go:15`、`test/{cart,coupon,mgt,summary}/*.http`、`test/lint/`(空目录) - **证据**: ```go //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,70`、`coupon/by_status.go:18,28,34-36,52`、`summary/submit.go:236-246` - **证据**: ```go // common/reflect.go:33 —— 忽略 DB 列 order_details.total_price,改用现算 TotalPrice: int64(val.Number) * val.UnitPrice, // coupon/by_status.go:28,52 —— 忽略请求 status;amount 填的是 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 ``` - **影响**:`ByStatus` 的 `status` 过滤参数被忽略(返回全部券)、返回的 `amount` 实为 `intro`、`started/expired/status` 永不填充(`coupon.proto:20-29` 的字段全是死字段);`Coupon.ByStatus` 还要求 `Mall_Admin` 角色去查"自己的券"(语义错误);空购物车提交返回成功导致前端误判下单成功;`Address` 与 `Addr` 两字段内容重复/不一致。 - **建议**:修正券映射与状态过滤、明确角色要求;`Submit` 在购物车为空时返回明确错误;统一地址字段语义;`order_details.total_price` 明确以 DB 列为准。 ## 4. 推荐优化方案 1. **建立统一的"归属校验"中间件/helper(最高优先)**:在 logic 层入口强制 `auth := service.ParseMetaCtx(...)`,并提供 `loadOwnedOrder(ctx, identityOrNo)` 与 `loadOwnedCart(...)`,内部固定拼接 `passport_identity/store_identity`;所有按主键/编号定位资源的写操作必须校验 `RowsAffected == 1`。`OrderApprove` 等资金动作接口必须补 `ParseMetaCtx` + 角色 + 店铺归属。 2. **收口支付与退款边界**:`Simulate*` 三个接口从生产网关下线(或仅在 test 环境注册);真实支付由支付回调驱动,写入前校验 `status==1` 且 `pay_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 增加 recovery(panic → 内部错误码 + 结构化日志)、`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 建立回归用例(并发下单、券并发核销、支付幂等、状态机非法流转、越权矩阵),纳入 CI(含 `gofmt -l`、`go 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:27`、`summary/cancel.go:28`、`summary/confirm.go:30`、`summary/simulate_pay.go:25`、`summary/simulate_receiving.go:29`、`summary/simulate_shipments.go:29`、`logic/common/get.go:15`、`mgt/order_cancel.go:37` - [ ] **P0-2** 支付写入改为「校验 status==1 + pay_amount==total_price + pay_trade_no 唯一幂等」|验收:重复回调只生效一次,金额始终等于订单应付,取消单/已付单的支付请求被拒绝|涉及:`module/ec/order/internal/logic/summary/simulate_pay.go:25`、`internal/models/order_summary.go:57` - [ ] **P0-3** 补 `OrderApprove` 鉴权与归属校验,并把退款资金动作 + 库存回滚纳入同事务(库存按 `spec_id` 回滚、事务错误必须返回)|验收:无/非商家 token 调用被拒;跨店审批被拒;回滚库存只命中目标规格;事务失败返回错误码|涉及:`module/ec/order/internal/logic/mgt/order_approve.go:17`、`mgt/order_approve.go:47`、`mgt/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:209`、`summary/cancel.go:34`、`mgt/order_cancel.go:37`、`summary/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:62`、`internal/server/new.go:23` - [ ] **P0-7** 购物车 Modify/Delete/Create 加归属过滤与入参校验,Modify 返回事务错误|验收:改/删他人 `order_cart.id` 无效果且返回权限错误;`number<=0` 被拒;事务失败返回错误|涉及:`module/ec/order/internal/logic/cart/modify.go:26`、`cart/modify.go:40`、`cart/delete.go:22`、`cart/create.go:49` - [ ] **P1-8** 实现订单状态机(含 `order_no` 唯一索引与订单号方案替换)|验收:非法流转全部被拒并返回明确错误码;重复订单号不再可能|涉及:`module/ec/order/internal/logic/common/no.go:10`、`internal/models/order_summary.go:18`、`summary/simulate_receiving.go:29` - [ ] **P1-9/10/11** 统一数量与价格口径(`1<=number<=上限`、规格价为准、以服务端商品数据组装明细、两处下单同一价源)|验收:负数量被拒;同商品两入口单价一致;订单头金额等于明细合计|涉及:`module/ec/order/internal/logic/cart/modify.go:36`、`summary/submit.go:140`、`mgt/order_create.go:67`、`summary/quick_create_by_product.go:102` - [ ] **P1-12** 移除 `agency` 鉴权降级,管理端强制店铺归属,`partner_id` 服务端推导|验收:带 `agency` 的买家 token 无法读取他店订单;他店订单的 Cancel/Approve 被拒|涉及:`module/ec/order/internal/logic/mgt/order_get.go:22`、`mgt/order_list_by_store.go:22`、`mgt/order_list_by_store.go:60` - [ ] **P1-13** 关闭生产 GORM Debug SQL 日志并提供配置项|验收:生产日志中不再出现 SQL 参数与 PII;`Debug` 可由配置控制|涉及:`module/ec/order/internal/models/impl.go:85`、`internal/models/impl.go:61` - [ ] **P1-14** 支付/退款引入幂等键与对账/补偿机制|验收:重复提交同一幂等键只产生一次影响;存在日对账任务与差异告警|涉及:`module/ec/order/internal/logic/summary/submit.go:187`、`summary/confirm.go:77` - [ ] **P1-15** 统一错误码映射并清理空实现/静默成功|验收:NotFound 与 DB 错误可区分;未实现接口返回 `Unimplemented`|涉及:`module/ec/order/internal/logic/mgt/order_modify.go:25`、`summary/check.go:26`、`mgt/order_get.go:46` - [ ] **P1-16** 收敛事务边界(券/状态/库存同事务,零值字段用 map 更新)|验收:任一步失败全部回滚;`Cancel` 后券字段确实被清空且券状态恢复|涉及:`module/ec/order/internal/logic/summary/cancel.go:41`、`summary/confirm.go:59` - [ ] **P1-17** 消除 N+1 与分页风险、启用缓存|验收:购物车/结算的 SQL 条数与商品数无关;`page_size` 超限被拒;热点商品/规格命中缓存|涉及:`module/ec/order/internal/logic/cart/fetch.go:40`、`summary/submit.go:82`、`mgt/order_list_by_store.go:49` - [ ] **P1-18** 消除无校验类型断言与 panic 面|验收:断言全部 `comma-ok` 化或改强类型 struct;注入类型不匹配数据不再 panic|涉及:`module/ec/order/internal/logic/mgt/order_create.go:50`、`summary/quick_create_by_product.go:43` - [ ] **P2-20** 独立部署网关注册:交由全局模板修复(本模块验收标准仅为「若继续暴露网关,则 `/order.*` 路由可达」)|涉及:`module/ec/order/internal/server/new.go:26`、`service/expose.go:23` - [ ] **P2-21/22/23** 统一迁移机制、检查被忽略的 error、删除死代码|验收:order 模型纳入统一迁移;`gormDb.DB()` 错误被处理;死代码清除|涉及:`module/ec/order/internal/models/impl.go:41`、`internal/models/impl.go:66`、`internal/models/order_summary.go:83` - [ ] **P2-24/25/26** 修复 TS SDK 与 proto/实现不一致,落地或删除被忽略参数|验收:SDK 可完整调用四类接口;所有 proto 字段要么生效要么移除;跨店 `store_id` 正确|涉及:`module/ec/order/sdk/typescript/order/index.ts:254`、`module/ec/order/proto/summary.proto:112`、`summary/submit.go:91` - [ ] **P2-27/28** 补优雅退出/健康检查/可观测性与配置治理|验收:SIGTERM 后请求 drain 完成退出;`/healthz` 可用;日志含 request_id 且无占位凭证入库|涉及:`module/ec/order/cmd/main/main.go:37`、`module/ec/order/etc/order_prod.yaml:7` - [ ] **P2-29** 跨模块表访问收敛为服务接口/统一 repository|验收:order 模块不再直接写 `mall_product_spec`|涉及:`module/ec/order/internal/logic/summary/submit.go:209`、`mgt/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:19`、`module/ec/order/internal/logic/summary/submit.go:24`、`module/ec/order/test/rpc/summary_test.go:13` ## 6. 审计摘要(供汇总使用) - **问题数:P0=7 P1=12 P2=10 P3=5(合计 34 条,编号 1–34;其中 P3-34 为合并项,涵盖券映射/空购物车/地址字段等 4 处小问题)** - **最高风险(一句话)**:订单、购物车与优惠券的定位/写入几乎全部只信任客户端传入的 `identity/order_no/id` 且缺少归属校验,任意持有有效 JWT 的账号即可读取他人收货信息、把任意订单置为已支付/已发货/已收货、取消他人订单、核销任意优惠券;叠加「模拟支付」硬编码金额、退款审批接口零鉴权且不动资金、`confirm.go` 必然 panic(无 recovery),资金与库存一致性在本模块内无任何保障。 - **最优先 3 个动作**: 1. 立即下线/隔离 `Summary.SimulatePay|SimulateShipments|SimulateReceiving` 与 `Mgt.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 空实现对线上调用方式的影响未评估。