Files
full/docs/order.md
2026-09-22 18:53:53 +08:00

302 lines
41 KiB
Markdown
Raw Blame History

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