Appearance
app_mall 三道「防超」闸门:超卖 / 超发 / 超买
目标:把三个容易被混淆的「超」边界拆清楚,并给出本项目各自的落地实现。 状态:三道闸门全部已落地(超卖 = 既有;超发 = 物流迭代;超买 = 本次新增)。
1. 概念辨析(三个边界、三个阶段)
| 维度 | 防超卖 (over-sell) | 防超发 (over-ship) | 防超买 (over-buy) |
|---|---|---|---|
| 守卫边界 | 库存不为负 / 不卖超出库存 | 履约量 ≤ 订单量 | 单买家购买 ≤ 限购额度 |
| 发生环节 | 下单(写 order_item) | 发货(写 shipment_item) | 下单(写 order_item) |
| 额度来源 | sku.stock | order_item.qty | sku.perUserLimit |
| 聚合复杂度 | 单行单字段 | 多行发货明细汇总 | 跨订单、按买家汇总 |
| 守卫机制 | 原生 WHERE stock>=qty 条件扣减 | 脚本聚合 → throw 回滚 | 脚本聚合 → throw 回滚 |
| 硬边界层级 | DB 层(单行原子) | 业务层(事务内聚合) | 业务层(事务内聚合) |
| 失败后果 | 下单被拒,库存不变 | 发货单不落库,订单不变 | 明细不落库,库存不扣 |
一句话:超卖护库存、超发护履约、超买护买家。
2. 防超卖(既有实现)
落点:model/app_mall_order_item.json
第 1 层 — 软校验(validationRules,写前拦截)
json
"库存校验": {
"checkType": "associateQuery", "queryModelName": "app_mall_sku",
"queryFilter": "id=skuId AND status='ON' AND stock>=qty",
"triggerEvents": ["create"], "message": "库存不足或商品已下架"
}绝大多数超卖在此被拦下,但它是 read-then-write,不防并发 race。
第 2 层 — 硬扣减(businessRules,DB 级原子)
json
"原子扣库存": {
"transactionMode": "joined", "onError": "abort",
"actions": [{
"type": "increment", "targetModelName": "app_mall_sku",
"fieldMappings": [{ "targetField": "stock", "value": "-$qty$" }],
"matchConditions": "id=skuId AND stock>=qty"
}]
}increment 实际执行 UPDATE app_mall_sku SET stock=stock-qty WHERE id=? AND stock>=qty。 WHERE stock>=qty 即并发护城河:并发抢最后 1 件时只有一个 UPDATE 命中行, 输家 0 行受影响 → abort → 整条 order_item 插入回滚。库存永不为负。
3. 防超发(物流迭代落地)
落点:model/app_mall_shipment_item.json + api/shipment_recompute.json
shipment_item的 create/update 各挂一条businessRule:transactionMode=joined+onError=abort+callApi → mall/shipment_recompute。- 脚本排除当前行、聚合
app_mall_shipment_item已发量,比对app_mall_order_item.qty;projectedItem > 应发或projectedTotal > 总应发即throw。 - 正常则重算
order.shipStatus(UN_SHIPPED / PART_SHIPPED / ALL_SHIPPED), 并首单PAID → SHIPPED。
与防超卖的本质差异:超卖守卫单行单字段,一条
WHERE够用; 超发要「多行汇总后的量」,无法用单条WHERE表达,必须脚本聚合后决策。
4. 防超买(本次新增)
4.1 配置字段
model/app_mall_sku.json 新增 perUserLimit(Number,默认 0):
0/ 未配置 = 不限购(脚本内直接放行,零开销)> 0= 该 SKU 每位买家的累计购买上限(件)- 负值 = 配置非法,显式报错(不做静默兜底)
字段已同步 fields / _allFields 两处镜像,并在 SKU grid 视图补列。
4.2 校验脚本
api/purchase_limit_check.json(type: script,category: mall,accessRoles: ["Admin"])
入参:orderId、skuId、qty(必填);id(当前明细行,用于从累计中排除自身,可空)。
执行流程:
- 取
sku.perUserLimit→0则直接返回放行;负值抛错。 - 取
order.userId作为买家 → 缺失则 fail-closed 拒绝(不做兜底放行)。 - 遍历该买家名下**「非取消」**订单,收集
orderId集合。 - 遍历
order_item(skuId命中、排除当前行),按订单集合过滤后累加qty。 已购 + 本次 > perUserLimit→throw,joined + abort回滚整条明细插入与库存扣减。
额度口径说明(与库存对齐):下单即扣库存、取消才回补(app_mall_stock_refund), 故取消单应释放限购额度,未取消单(含未支付)均占用额度——两者口径一致,避免 「库存已占但额度未占」的错位。
实现细节:脚本内 forEachPage 按 500/页分页遍历(上限 200 页), 避免单次 pageSize 截断导致漏算而被绕过;in 算子在 app_mall 资产无先例, 故改用「脚本内 JS 过滤」而非依赖 in 条件。
4.3 触发挂载
model/app_mall_order_item.json 的 businessRules 最前面新增两条(fail-fast,早于扣库存):
限购校验(创建)—triggerEvent: create限购校验(更新)—triggerEvent: update(防「先下小额、事后改大」绕过)
均为 transactionMode: joined + onError: abort + callApi → mall/purchase_limit_check, fieldMappings 传 orderId / skuId / qty / id。
5. 事务正确性(三道闸门共用)
ModelDataServiceImpl 在事务内先建主记录、再执行 businessRule; joined 规则随主事务同步执行,callApi 经 ApiExecutorService.execute (@Transactional(rollbackFor=Exception.class))运行。
因此脚本在主事务内可见刚插入的未提交行,脚本 throw → 链式回滚整条插入。 防超发 / 防超买 的原子性据此成立,与库存 increment 同范式。
6. 测试
| 场景 | 覆盖 |
|---|---|
tests/scenarios/01-stock-oversell.js | 防超卖:并发抢 1 件,只 1 单落库、库存不穿负 |
tests/scenarios/10-order-ship.js | 防超发:8+2 分批发货、超发被拒且订单零副作用 |
tests/scenarios/11-purchase-limit.js | 防超买:2 件(=上限)通过、再买 1 件被拒且不落库不扣库存、perUserLimit=0 后买 5 件通过 |
跑法:cd front/packages/app-mall/tests && bash run-all.sh (单跑:npm run test:11)
⚠️ 联调待确认:被拒请求对外错误码可能是
10001或500(取决于异常兜底包装),测试断言为code !== 0并打印实际码,未 pin 死。