Files
full/docs/order.md

326 lines
44 KiB
Markdown
Raw Permalink Normal View History

2026-09-22 18:53:53 +08:00
# 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 条针对的是运行期约束缺失这一实际问题,最小动作是集中查询位置并注明依赖,不是新建抽象层。
2026-09-22 21:15:34 +08:00
## 9. 整改记录2026-09-22
> 本节记录按本报告结论执行的代码整改。整改遵循**最小修正**原则未引入新框架、抽象层、DTO/VO、事件总线未拆分服务边界**未修改任何 `proto/*.proto` 与生成的 `pb/*.go`**;新增/修改注释均为中文;口令类摘要统一使用 bcrypt验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:`GOWORK=off go build ./...` + `GOWORK=off go vet ./...` + `gofmt -l`(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import属本机既有问题
| 编号 | 级别 | 问题 | 处理结果 |
| --- | --- | --- | --- |
| O1 | 高 | 模拟支付/发货/收货在生产可达、无角色校验、金额硬编码 | 已修复:补 `service.ParseMetaCtx` 鉴权,并加运行模式开关(仅 dev 可执行,非 dev 直接拒绝),硬编码支付参数改为从配置读取 |
| O2 | 高 | `OrderApprove` 完全无鉴权 | 已修复:补鉴权,并校验审批值与订单当前状态,非法状态跃迁被拒绝 |
| O3 | 高 | 跨服务直连同库(`mall_product`/`mall_product_spec`/`mall_store`/`address_library` | 部分处理:**架构现状保留**(未做服务化改造);本次只补齐必要过滤条件、检查全部 `.Error`、对非本用户数据的写入补归属校验 |
| O4 | 高 | 优惠券把金额加到订单总额 | 已修复:改为减法,并补归属/有效期/使用门槛校验(仅使用模型实际存在的字段) |
| O5 | 高 | 扣库存不检查 `RowsAffected`(超卖) | 已修复:改为原子条件更新 `stock = stock - ? WHERE id = ? AND stock >= ?``RowsAffected == 0` 即回滚事务并返回库存不足 |
| O6 | 高 | 多店铺下单 `store_id` 恒为 0 | 已修复:`store_id` 改从商品/店铺明细的实际归属取,订单归属正确 |
| O7 | 高 | `Submit` 忽略选中条目、清空全车 | 已修复:只处理请求中选中的购物车条目,未结算条目保留 |
| O8 | 高 | 快速下单/管理端建单单价取自从不写入的 `sales_price`,成交金额恒为 0 | 已修复:改用模型实际存在的价格列,成交金额不再为 0 |
| O9 | 高 | 取消订单不返库存 + 状态机无约束 + 取消失败静默成功 | 已修复:取消时在事务内回补库存;校验当前状态是否允许取消;检查所有 `.Error`,不再静默成功 |
| O10 | 高 | 订单详情越权、地址越权使用4 处)、购物车读/写/删越权 | 已修复:全部补 `auth.Identity` 归属校验,越权返回 `ErrPermissionDenied` |
| O11 | 高 | 购物车返回成本价 | 已修复:响应不下发成本价;同文件吞掉查询错误的 `if err == nil` 一并修掉 |
### 未纳入本轮范围
报告中「中」「低」级别的项分页上限、死代码、README 与实现不符、单测缺失、可维护性等)**本轮未处理**;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
> 本轮整改未修改任何 `proto/*.proto` 与 `pb/*.go`,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。