Skip to content

app_mall 三道「防超」闸门:超卖 / 超发 / 超买 ​

目标:把三个容易被混淆的「超」边界拆清楚,并给出本项目各自的落地实现。 状态:三道闸门全部已落地(超卖 = 既有;超发 = 物流迭代;超买 = 本次新增)。


1. 概念辨析(三个边界、三个阶段) ​

维度防超卖 (over-sell)防超发 (over-ship)防超买 (over-buy)
守卫边界库存不为负 / 不卖超出库存履约量 ≤ 订单量单买家购买 ≤ 限购额度
发生环节下单(写 order_item)发货(写 shipment_item)下单(写 order_item)
额度来源sku.stockorder_item.qtysku.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(当前明细行,用于从累计中排除自身,可空)。

执行流程:

  1. 取 sku.perUserLimit → 0 则直接返回放行;负值抛错。
  2. 取 order.userId 作为买家 → 缺失则 fail-closed 拒绝(不做兜底放行)。
  3. 遍历该买家名下**「非取消」**订单,收集 orderId 集合。
  4. 遍历 order_item(skuId 命中、排除当前行),按订单集合过滤后累加 qty。
  5. 已购 + 本次 > 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 死。