302 lines
41 KiB
Markdown
302 lines
41 KiB
Markdown
# 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("订单状态异常")` 返回(非 errcode,gRPC 侧表现为 Unknown);状态判断只排除 -1/6/7/8,已完成的 status=5 也可再次申请售后。 |
|
||
| 中 | `logic/coupon/by_status.go:28,52` | `ByStatus` 完全忽略入参 `status`(`Status.status`,`proto/coupon.proto:13`)→ 查询恒返回该用户全部券;返回体 `Amount: v.Intro` 把描述串当金额(`CouponItem.amount` 是 string,`proto/coupon.proto:25`),`started`/`expired`/`status` 三个字段未填充。 |
|
||
| 中 | `logic/summary/check.go:23-30` | 注释是"检测是否有未确认及付款的订单",实现是按 `passport_id` 取**第一条**订单且无任何状态过滤(`:26`);查不到时返回 `ErrDB` 而非明确的"无订单"。 |
|
||
| 中 | `logic/mgt/order_get.go:46-50` | 用 `Find` 而非 `First`:查不到不返回错误,`:48-50` 的 `gorm.ErrRecordNotFound` 分支是死分支,接口返回空的 `summary` 而非 404。 |
|
||
| 中 | `logic/summary/submit.go:45-52,195` | 空购物车也走通全流程:`summary`/`details` 为空切片时 `CreateInBatches(details, len(details))` 的 `batchSize` 为 0(`:195`),接口仍返回 `Code:0`,`order_no` 为空字符串。 |
|
||
| 中 | `internal/models/order_summary.go:72,86,131-138` | `GetSummaryCnt`/`GetSummaryList` 使用 `type`、`buyer_identity`、`merchant_identity`、`sign_status`、`pay_status`、`logistics_status`、`invoice_status` 等 `OrderSummary` 模型中不存在的列(模型字段见 `:12-62`),这些查询必然报 `Unknown column`。二函数与 `internal/models/query.go:22 QuicklyCreateOrder` 均无调用者。`:131` 还把 `maxPrice` 误传给了 `pay_status` 的条件。 |
|
||
| 低 | `internal/models/query.go:23,86` | 这两个函数用的是 `models.DBService`,而全部业务逻辑用的是 `impl.DBService`(赋值见 `internal/impl/with.go:46`)。聚合入口经 `service/dependencies.go:27` 只覆盖 `impl.DBService`,`models.DBService` 保持 nil → 一旦这些函数被调用即空指针。 |
|
||
| 低 | `logic/summary/submit.go:55-76` | `in.Address != nil` 时才用请求内地址,否则查地址库;但 `models.OrderAddress`(`internal/models/types.go:7-18`)含 `Email`/`ZipCode` 字段,而 `order_summary` 没有对应列,这两项落不了库。 |
|
||
|
||
### 6.3 未完成实现
|
||
|
||
见第 3 节末尾表格。要点:
|
||
|
||
- **管理端改单为假成功**:`logic/mgt/order_modify.go:25-33` 只有 TODO,返回 `Code:0 "OK"`,但接口已在 `internal/server/mgt_server.go:24` 与 `pb/mgt.pb.gw.go:537` 接线,调用方会误以为改单成功。
|
||
- **三个"模拟"接口是唯一的状态推进手段**:`simulate_pay.go:17,25`、`simulate_shipments.go:17,29`、`simulate_receiving.go:17,29` 注释自述为模拟,硬编码交易号/金额/支付类型,但没有真实支付与物流对接的实现存在。
|
||
- **注释掉的代码块**:`quick_create_by_product.go:88-97`(配送员查询)、`:119-120`(`DeliveryIdentity` 赋值)、`confirm.go:37-49`(地址查询与运费计算),对应的请求字段 `member_identity`、`delivery_time`、`logistics_fee`、`remark` 因此无效果。
|
||
- **状态语义只在注释里**:`order_summary.go:30`(订单状态)与 `:39`(approve 编码)、`order_approve.go:29` 的取值说明都没有枚举定义,`order_returnable.go:24` 又硬编码了 `1/2/3`。
|
||
- **README 未编写**:`module/ec/order/README.md:1-2` 只有标题。
|
||
|
||
### 6.4 健壮性与可维护性
|
||
|
||
| 级别 | 位置 | 问题 |
|
||
| --- | --- | --- |
|
||
| 中 | `service/dependencies.go:19-29` + `pkgs/all/internal/service/order.go:15` | `Dependencies` 声明了 `Cache *cache.Cache`,`applyDependencies` 却不处理该字段;`pkgs/all` 仍传入 `Cache: impl.MemoryService` → 依赖注入字段无效(与 `module/base/logs` 同类问题)。 |
|
||
| 中 | `internal/impl/with.go:24-31` | `config.Spec.Cache == ""` 时 `RedisCache` 为 nil,但 `:30` 无条件取 `RedisCache.DB` → 空指针;且 `printer.Info` 的格式化参数与占位符不符。 |
|
||
| 中 | `logic/summary/quick_create_by_product.go:43,62,102,104,112-117,128-129,133,136-137,140,143-145`、`logic/mgt/order_create.go:62,74-75,83,85-86,89-94,115-122`、`logic/mgt/order_get.go:42-43`、`logic/mgt/order_list_by_store.go:55-56` | 大量对 GORM `map[string]any` 结果的**无保护类型断言**(`store["status"].(int16)`、`product["status"].(int32)`、`spec["price"].(int64)`、`address["province"].(string)` 等)。相关列在两边都有 `default` 但**没有 `not null`**(如 `mall_store.status` 定义于 `bsm-sdk/core/types/db.go:34`),任一列为 NULL 或两侧驱动返回类型变化即 panic;`order_get.go:42-43` 在 token 的 `owner` 不是对象时同样 panic。【信息不足】未在真实数据库确认各列的可空性与驱动返回的具体类型。 |
|
||
| 中 | 全模块 | 无单元测试:`test/rpc/*.go` 均为 `//go:build integration` 的手工联调用例(`summary_test.go:1`、`cart_test.go:1`),依赖真实服务端口 `127.0.0.1:12442` 与环境变量 `BSM_ORDER_TOKEN`(`test/rpc/bisic_test.go:13,15,17`),断言只打印返回消息,无任何 `t.Fatal` 判断;`logic/`、`models/`、`internal/impl/` 0 测试。 |
|
||
| 中 | `internal/logic/common/no.go:10-14` + `internal/models/order_summary.go:18` | 订单号 = 秒级时间戳拼接 + 6 位随机数,`order_no` 只有普通索引(非唯一)→ 同秒并发存在重号可能;而 `summary/cancel.go:28`、`summary/confirm.go:30`、`common/get.go:28` 都用 `First`(取任意一条),重号会导致操作到错误订单。 |
|
||
| 中 | `logic/summary/list.go:43-45`、`logic/mgt/order_list_by_store.go:49-51` | 只要 `page_size > 0` 就直接 `Limit(int(in.PageSize))`,**没有上限**;`OrderListByStore` 还预加载了全部明细(`:60` `Preload("OrderDetails")`)→ 一次请求可拉走全表订单与明细。 |
|
||
| 低 | `internal/models/impl.go:31,36,42` | 驱动不支持、DB 打开失败、AutoMigrate 失败三处都走 `log.Fatalln`(直接退出进程),与 `internal/impl/with.go:35,44,53` 的 `panic` 风格并存,启动期错误处理方式不统一。 |
|
||
| 低 | `etc/order_{dev,test,prod}.yaml:10` | `Cache: redis://null:...@127.0.0.1:6379/`(无 DB 序号),而 `internal/impl/with.go:30` 会打印 `RedisCache.DB`;`RedisCache` 除该打印外全模块无使用点(死代码)。 |
|
||
| 低 | `etc/order_{dev,test,prod}.yaml:19` | `Anonymous: - order.ping.hello`,但 order 的 proto 与 gw 路由(`pb/*.pb.gw.go` 共 22 个方法)中没有 `ping`,是死配置。 |
|
||
| 低 | `logic/mgt/order_cancel.go:29-36`、`logic/mgt/order_get.go:34-37` | 重复的参数校验(`in.Identity == ""` 判断两次),`order_cancel.go:29` 与 `:34` 的注释还互相错位。 |
|
||
| 低 | `internal/models/impl.go:85` | `NewPostgres` 中 `db = db.Debug()` 与 `NewMysql` 的可选 Debug 行为不一致(详见 6.1)。 |
|
||
|
||
## 7. 风险汇总
|
||
|
||
| 编号 | 级别 | 问题 | 影响面 |
|
||
| --- | --- | --- | --- |
|
||
| O1 | 高 | 模拟支付/发货/收货在生产可达、无角色校验、金额硬编码 | 资金与订单状态被任意篡改 |
|
||
| O2 | 高 | `OrderApprove` 完全无鉴权 | 退款/退货被匿名审批 + 库存被改写 |
|
||
| O3 | 高 | 跨服务直连同库,直接读写 `mall_product`/`mall_product_spec`/`mall_store`/`address_library` | 任一表结构变更即 order 运行期报错;订单侧可直接改商品库存 |
|
||
| O4 | 高 | 优惠券把金额加到订单总额(`confirm.go:72`) | 金额计算错误,用户应付变高 |
|
||
| O5 | 高 | `submit` 扣库存不检查 `RowsAffected` | 超卖(订单成功但未扣库存) |
|
||
| O6 | 高 | 多店铺下单 `store_id` 恒为 0 | 订单归属错误,店铺维度统计/查询失真 |
|
||
| O7 | 高 | `Submit` 忽略选中条目、清空全车 | 用户被强制下整辆购物车 |
|
||
| O8 | 高 | 快速下单/管理端建单单价取自从不写入的 `sales_price` | 成交金额恒为 0 |
|
||
| O9 | 高 | 取消订单不返库存 + 状态机无约束 + 取消失败静默成功 | 库存丢失、状态错乱 |
|
||
| O10 | 高 | 订单详情越权(`Summary.Get`)、地址越权使用(4 处)、购物车越权(读/写/删) | 用户 PII 泄露与数据被篡改 |
|
||
| O11 | 高 | 购物车返回成本价(`cart/fetch.go:57`) | 商业成本信息泄露 |
|
||
| O12 | 中 | 优惠券归属/有效期/门槛均不校验 | 券被滥用 |
|
||
| O13 | 中 | 无事务的建单(`quick_create_by_product` 两次 Create)、事务错误被丢弃(`order_approve.go:47`) | 脏数据 |
|
||
| O14 | 中 | 退货入库按 `product_identity` 回补 | 库存虚增 |
|
||
| O15 | 中 | 运费/备注/优惠额字段全链路未落库 | 订单金额要素缺失 |
|
||
| O16 | 中 | 分页无上限 + `Preload` 全明细 | 服务被单请求拖垮 |
|
||
| O17 | 中 | 无保护类型断言遍布订单逻辑 | 运行期 panic |
|
||
| O18 | 中 | 无单元测试、双份 DB 全局、订单号无唯一约束 | 可维护性与数据一致性 |
|
||
| O19 | 低 | README 空缺、`Anonymous` 死配置、`RedisCache` 死代码、Debug 常开 | 认知成本与日志噪音 |
|
||
|
||
## 8. 修复建议(务实项)
|
||
|
||
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 条针对的是运行期约束缺失这一实际问题,最小动作是集中查询位置并注明依赖,不是新建抽象层。
|