项目:xyMall | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本文选取三个文件做逐段带码走读:后端的 OrderServiceImpl.createOrder(订单创建,全库最重的事务编排)、SeckillStockRedisService 与 SeckillServiceImpl.grab/onOrderClosed(秒杀扣减与兜底闭环,全库工程密度最高的链路)、前端的 CheckoutView.vue(结算页金额试算,三端共享业务规则在前端的镜像)。选它们的理由不是"最复杂",而是"最能代表这个项目的设计水平"——三个文件分别是事务内编排、跨存储一致性、前后端规则镜像三类问题的样板答案,读透它们,其余 30 个模块基本可以按图索骥(《07-模块详解》的模块索引 + 《05-核心流程》的链路叙事为背景板,本文不重复流程图,只做逐行解读)。
走读体例约定:每个文件按"代码段引用 → 逐段解读 → 设计意图 → 边界处理"展开,每处代码引用保持原样缩进与注释(含中文注释,它们是作者留给读者的第一手设计说明),段落后给出该文件教会我们什么的小结。为兼容文档渲染,本文所有代码块统一使用四反引号围栏。引用的行号以当前工作区文件为准;机制级流程与跨文件时序见《05-核心流程》,本文不重复画图。
文件:xy-mall/server/src/main/java/com/xiaoye/mall/server/modules/order/service/impl/OrderServiceImpl.java(895 行)。方法签名与事务注解先行:
@Override
@Transactional(rollbackFor = Exception.class)
public String createOrder(Long userId, SaveOrderContext context) {解读:rollbackFor = Exception.class 是这里第一个值得停留的细节——Spring 默认只对 RuntimeException/Error 回滚,声明受检异常也回滚,意味着任何一步抛出的异常(包括未来有人新增的受检异常)都会撤销整个订单。方法入参不是散参而是 SaveOrderContext(cartItemIds/remark/addressId/userCouponId/usePoints/invoiceType/invoiceTitle/invoiceTaxNo/groupBuyId/teamId 十个字段),这是全库约定的"复杂入参聚合"模式(《00-项目总览》第 5 节约定):调用方(Controller/幂等层/未来可能的 RPC 层)只传一个对象,新增字段不改签名。
List<ShoppingCart> carts = (cartItemIds == null || cartItemIds.isEmpty())
? shoppingCartService.list(Wrappers.<ShoppingCart>lambdaQuery()
.eq(ShoppingCart::getUserId, userId))
: shoppingCartService.list(Wrappers.<ShoppingCart>lambdaQuery()
.in(ShoppingCart::getCartItemId, cartItemIds)
.eq(ShoppingCart::getUserId, userId));
if (carts.isEmpty()) {
throw new BizException("没有可结算的购物车商品");
}
List<Long> goodsIds = carts.stream().map(ShoppingCart::getGoodsId).collect(Collectors.toList());
Map<Long, Goods> goodsMap = goodsService.listByIds(goodsIds).stream()
.collect(Collectors.toMap(Goods::getGoodsId, Function.identity(), (a, b) -> a));解读:购物车查询带了一个容易被略过的条件——无论走哪个分支,.eq(ShoppingCart::getUserId, userId) 都在。这行是"归属校验前置"约定的落点:调用方传来的 cartItemIds 是不可信输入,如果不带 userId 条件,攻击者改包传别人的 cartItemId 就能替别人下单。商品取数用 listByIds 一次性批量拉回并转 Map,接下来的循环里全部 O(1) 查内存。
设计意图:把"数据库按主键 batch 查 + 内存 Map 匹配"固化为循环取数的标准姿势,从根上消灭购物车循环内的逐行查库(审计报告 P3 曾把这类 N+1 列为整改项,本文件是整改后的形态——类内注释"拼团与 SKU 一次性批量查询,避免购物车循环内逐行查库"直说了动机)。
边界处理:cartItemIds 为空时不是报错,而是"全量结算该用户购物车"——对应前端"购物车页直接点结算"的路径;cartItemIds 非空则对应"立即购买/结算页只勾选部分"的路径。空购物车抛的是 400 语义的 BizException,文案"没有可结算的购物车商品"可在《15-接口全量参考》4.2 的典型错误清单里对上号。
for (ShoppingCart cart : carts) {
Goods goods = goodsMap.get(cart.getGoodsId());
if (goods == null || !Integer.valueOf(0).equals(goods.getGoodsSellStatus())) {
throw new BizException("商品已下架或不存在");
}
// 预售商品:开售时间校验(定金下单模式)
if (Integer.valueOf(1).equals(goods.getPresale())
&& goods.getPresaleStart() != null && LocalDateTime.now().isBefore(goods.getPresaleStart())) {
throw new BizException("预售商品尚未开售:" + goods.getGoodsName());
}
// SKU 粒度校验库存并取价:无 SKU 的历史数据退回主商品价
int unitPrice = goods.getSellingPrice();
String skuName = cart.getSkuAttrs() == null ? "" : cart.getSkuAttrs();
if (cart.getSkuId() != null && cart.getSkuId() > 0) {
GoodsSku sku = skuMap.get(cart.getSkuId());
if (sku == null || !sku.getGoodsId().equals(cart.getGoodsId())) {
throw new BizException("商品SKU已失效:" + goods.getGoodsName());
}
if (sku.getStock() < cart.getGoodsCount()) {
throw new BizException("商品库存不足:" + goods.getGoodsName());
}
unitPrice = sku.getPrice();
skuName = sku.getSkuName();
} else if (goods.getStockNum() < cart.getGoodsCount()) {
throw new BizException("商品库存不足:" + goods.getGoodsName());
}解读:这段循环完成三件事:在售校验(goodsSellStatus=0)、预售开售窗口校验、SKU 归属与库存校验并取价。注意取价优先级的写法——先取主商品价作为默认值,skuId > 0 时被 SKU 价覆盖,后面还会被拼团价、预售定金再覆盖两次。这是一个"基线价 + 逐层覆盖"的结构,比 if/else 全排列干净得多。
设计意图:sku == null || !sku.getGoodsId().equals(cart.getGoodsId()) 这行是全段最见功力的防御——它不仅查 SKU 存在,还查 SKU 是否属于这个商品。V7 迁移把购物车唯一键升级为 (user_id, goods_id, sku_id)(《04-数据模型》第 2 节),但历史脏数据可能持有"商品 A 的行挂了商品 B 的 skuId",这行校验把这类数据漂移挡在下单入口。else if (goods.getStockNum() < ...) 分支则照顾 V4 之前无 SKU 的历史订单数据——"无 SKU 回落主商品价与主表库存"是《05-核心流程》流程二反复强调的兼容规则。
边界处理:每个失败分支的异常文案都带商品名("预售商品尚未开售:xxx"),用户看到的不是"参数错误"而是能指认具体商品的提示;同时 skuId > 0 的哨兵约定与 order_item.sku_id=0 表无 SKU(《04-数据模型》3.8)贯穿一致——下单时 item.setSkuId(cart.getSkuId() == null ? 0L : ...) 把 null 归一为 0,后续按订单项回补库存的 restoreStock 才能据此选择回补 SKU 还是主表。
long couponDiscount = 0;
if (context.getUserCouponId() != null) {
couponDiscount = couponService.discountFor(userId, context.getUserCouponId(),
totalPrice, goodsMap.values());
}
// 满减活动(与优惠券平行):取满足门槛的最优一项
long promoDiscount = promotionFullService.bestDiscountFor(Math.max(totalPrice - couponDiscount, 0));
// 积分抵扣:按商品总额(券后)计算,受开关/比例/上限百分比约束(后台 sys_config 可配);
// 此处先计算金额,扣减在订单落库后执行(与核销优惠券同事务)
int pointsUsed = 0;
int pointsDeduct = 0;
if (Boolean.TRUE.equals(context.getUsePoints())) {
int[] used = computePointsDeduction(userId, (int) Math.max(totalPrice - couponDiscount - promoDiscount, 0));
pointsUsed = used[0];
pointsDeduct = used[1];
}
// PLUS 会员折扣(后台可配比例,默认 95 折):作用于券/满减/积分后的金额
long plusDiscount = 0;
if (userPlusService.isActive(userId)) {
int plusPercent = sysConfigService.getInt(SysConfigService.Keys.PLUS_DISCOUNT_PERCENT, 95);
long base = Math.max(totalPrice - couponDiscount - promoDiscount - pointsDeduct, 0);
plusDiscount = base * (100 - Math.min(Math.max(plusPercent, 1), 100)) / 100L;
}解读:四层优惠依次是券(校验门槛/范围后返回优惠额)→ 满减(对券后金额取最优一档)→ 积分(纯函数计算,返回使用积分与抵扣金额两个值)→ PLUS(对前三层之后的金额打折)。每一层的计算基数都精确指向上层的结果:满减基数是 totalPrice - couponDiscount,积分基数再减 promoDiscount,PLUS 基数再减 pointsDeduct——顺序在这里不是风格而是契约,改顺序等于改金额(《11-扩展开发指南》SOP 四把"新增营销层必须写注释说明位次"列为硬规则的原因)。
设计意图:注释"此处先计算金额,扣减在订单落库后执行(与核销优惠券同事务)"点出了本段最重要的结构决策——计算与扣款分离。如果在这一步就 points = points - n,而后续任何一步(比如库存 CAS 失败)抛异常回滚,事务虽能保证回滚,但"扣款失败抛出的异常"与"库存失败抛出的异常"会互相掩盖,排障困难;更重要的是把资金变动集中到落库之后,让"扣积分、核销券"的失败语义(抛"积分不足,下单失败")清晰可测。computePointsDeduction 委托纯函数 PointsCalculator.calculate(8 个边界用例见《09》2.1),sys_config 的三个参数(开关/比例/上限)通过 SysConfigService.Keys 常量读取——配置键的全库唯一口径(《11》SOP 五)。
边界处理:每个基数都有 Math.max(..., 0) 下限保护——券后金额理论上不会为负(券面额按订单金额封顶,CouponCalculator.flatDiscountCappedAtAmount 用例),但防御性下限让这一性质不依赖上游实现。PLUS 比例的 Math.min(Math.max(plusPercent, 1), 100) 把运营在后台误配 0 或 200 的值夹回合法区间,避免出现负折扣或超额折扣。
for (ShoppingCart cart : carts) {
// SKU 单扣 SKU 库存,无 SKU 的历史数据扣主表库存
if (cart.getSkuId() != null && cart.getSkuId() > 0) {
boolean skuUpdated = goodsSkuMapper.update(null, Wrappers.<GoodsSku>lambdaUpdate()
.setSql("stock = stock - " + cart.getGoodsCount())
.eq(GoodsSku::getSkuId, cart.getSkuId())
.ge(GoodsSku::getStock, cart.getGoodsCount())) > 0;
if (!skuUpdated) {
throw new BizException("商品库存不足,下单失败");
}
stockLogService.record(cart.getGoodsId(), cart.getSkuId(), -cart.getGoodsCount(), "下单", order.getOrderNo());
} else {
boolean updated = goodsService.update(Wrappers.<Goods>lambdaUpdate()
.setSql("stock_num = stock_num - " + cart.getGoodsCount())
.eq(Goods::getGoodsId, cart.getGoodsId())
.ge(Goods::getStockNum, cart.getGoodsCount()));
if (!updated) {
throw new BizException("商品库存不足,下单失败");
}
stockLogService.record(cart.getGoodsId(), 0L, -cart.getGoodsCount(), "下单", order.getOrderNo());
}
}解读:这是全系统防超卖的权威闸门。要点有三:其一,setSql("stock = stock - n") 是数据库端原子运算(而非 Java 读旧值再覆盖——审计 P1-1 曾揪出余额扣款正是这个反模式,后改为同款写法);其二,.ge(GoodsSku::getStock, count) 把"库存够不够"压进 UPDATE 的 WHERE 里,一条 SQL 完成"查 + 判 + 扣",影响行数为 0 即并发失败;其三,扣减成功立刻写 stock_log 流水(负数表示减少、reason="下单"、带订单号),库存从此每一分变动可追溯。
设计意图:为什么校验段已经查过库存(1.2 节的 sku.getStock() < cart.getGoodsCount())这里还要条件更新?因为校验是给用户看的(提前友好报错),条件更新才是给并发看的(最终正确性)——两段之间隔着其他用户的三次握手,任何依赖"校验通过所以扣得动"的假设都会超卖。这也是《05-核心流程》流程二第 6 步"条件更新影响行数为 0 即抛异常回滚一切"的原文出处。
边界处理:异常文案刻意与 1.2 节的"商品库存不足:商品名"不同——这里不带商品名,因为走到这里说明是并发竞争失败而非用户可纠正的配置问题;stock_log 的 skuId 参数用 0L 与主表扣减分支对应,与 StockLogService 的"sku_id=0 表主表库存"语义(《07-模块详解》4.5)闭环。核销段(限篇幅未引用)对券走 markUsed 条件核销、积分走 deductPoints 的 ge(User::getPoints, pointsUsed) 条件扣减,与库存同构——同一事务内三个资金面各自 CAS,任何一处失败整体回滚。
/** 订单号唯一键冲突(同用户同毫秒极小概率碰撞)时换号重试一次 */
private void saveOrderWithRetry(OrderMaster order) {
for (int attempt = 0; ; attempt++) {
order.setOrderNo(generateOrderNo(order.getUserId()));
try {
this.save(order);
return;
} catch (DataIntegrityViolationException e) {
if (attempt >= 2) {
throw new BizException("下单繁忙,请重试");
}
}
}
}解读:订单号 userId + 毫秒时间戳 + 4 位随机(generateOrderNo,格式决策见《16-决策记录ADR》ADR-8),唯一性靠 uk_order_no 唯一键兜底,冲突时捕获 DataIntegrityViolationException 换号重来,最多 3 次。这个模式把"ID 生成"从"必须全局协调"降级为"失败重试"——单实例下同用户同毫秒碰撞是极小概率,重试成本近零。
设计意图:用数据库唯一键做最终裁决者,应用层的生成逻辑可以任意简化。它的对称实现出现在秒杀侧(SeckillServiceImpl.grab 内联同构的 for-retry 建单),两处注释措辞几乎一致——这是"模式在手写代码中复制"的样本,也是未来抽公共件的候选信号。
边界处理:重试上限 3 次后抛"下单繁忙,请重试"——宁可让用户重试也不无限循环;order.setOrderNo(...) 每轮先改号再插入,保证失败残留(MyBatis-Plus 首级缓存里的旧号)不会污染下一轮。
第一,校验与并发防护是两套系统:循环里的库存校验服务于体验(快速、带商品名的失败),UPDATE 的条件谓词服务于正确性(原子、无间隙),两者缺一不可且不可互相替代。第二,计算与副作用分离是长事务的呼吸节奏:先算完所有金额,落库,再统一执行核销与扣款——资金操作的失败语义因此可枚举、可测试。第三,流水表是并发修复的底气:正因为每次扣减都写 stock_log,取消/超时/售后的回补(restoreStock)才能与下单严格互逆(集成测试 cancelOrderRestoresStockAndCouponPointsSymmetrically 验证的就是这条守恒律,《09》第 3 节)。第四,防御性归一(skuId 的 null→0、比例的 min/max 夹逼、基数的 max 0 下限)散落在每个关键点,它们单独看都是小事,合起来才是"演示环境跑了一年没出资金事故"的原因。
文件:xy-mall/server/src/main/java/com/xiaoye/mall/server/modules/seckill/service/SeckillStockRedisService.java(175 行)与 SeckillServiceImpl.java(435 行)。先看类注释,它本身就是决策记录:
/**
* 秒杀库存 Redis 预扣(Lua 单脚本原子:库存判减 + 每人每场幂等流水,借鉴 NFTurbo
* CollectionInventoryRedisService 的脚本组织:幂等查重、错误码经返回值传出)。
*
* <p>一致性设计(修正上一版"Redis 前置"被删除的原因——回滚/漂移误判):
* <ul>
* <li>DB 仍为权威:预扣成功后继续走原 MySQL 条件扣减建单,任何失败立即脚本回补 Redis;</li>
* <li>崩溃窗口的幽灵流水由对账 Job 以 DB 为准清理(见 SeckillReconcileJob);</li>
* <li>Redis 挂掉自动降级纯 DB 路径,秒杀不中断。</li>
* </ul>
*/解读:五个要点全部写在注释里:设计出处(NFTurbo)、上一版被删的原因(回滚/漂移误判——《16-决策记录ADR》ADR-5 的完整故事)、三方职责边界(Redis 挡量、DB 权威、对账纠偏)。读这个文件必须先读注释,否则三个 Lua 脚本的每个细节都会显得偏执。
private static final DefaultRedisScript<Long> DEDUCT_SCRIPT = new DefaultRedisScript<>("""
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then
return -2
end
local stock = redis.call('GET', KEYS[1])
if not stock then
return -3
end
if tonumber(stock) <= 0 then
return -1
end
redis.call('HSET', KEYS[2], ARGV[1], ARGV[2])
redis.call('DECR', KEYS[1])
return 0
""", Long.class);解读:KEYS[1] 是库存 key(seckill:stock:{id},字符串整数),KEYS[2] 是流水 key(seckill:grab:log:{id},hash:userId → 预扣毫秒),ARGV[1] 是 userId,ARGV[2] 是当前毫秒时间戳。脚本四段:HEXISTS 判重(同一用户重复请求直接 -2,幂等拦截)→ GET 判 key 存在(-3 触发上层预热重试)→ tonumber(stock) <= 0 判售罄(-1,不落任何写操作)→ HSET 记流水 + DECR 扣库存(0,预扣成功)。四段在 Redis 单线程模型下是一个不可分割的原子操作——不存在"两个请求同时看到库存 1 都扣成功"的窗口。
设计意图:把"判重、判存、记账、扣减"压进一个脚本的收益是流水与库存永不脱节:任何一条预扣流水对应的库存减量都必然发生过(同一原子操作),反之亦然。这为对账 Job 的"幽灵流水"判定提供了逻辑基础——流水在而 DB 无有效记录,只能是"预扣后建单前崩溃",可以放心清理。错误码用返回值而非异常传出(注释"错误码经返回值传出"),让上层用 switch 精确分支而非字符串匹配异常消息。
边界处理:tonumber(stock) 显式转型防字符串比较陷阱(Redis 的 GET 返回 bulk string,"10" <= 0 在 Lua 里语义不正确);key 缺失返回 -3 而非自动创建——脚本不做预热决策,把"用多少预热"的策略留给 Java 层(它知道 DB 当前库存与活动结束时间)。
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
redis.call('HDEL', KEYS[2], ARGV[1])
if redis.call('GET', KEYS[1]) then
redis.call('INCR', KEYS[1])
end
return 1
""", Long.class);
/** 过期时长:活动结束 + 2 小时,下限 1 分钟 */
private Duration ttlMillis(LocalDateTime endTime) {
if (endTime == null) {
return MIN_TTL;
}
long untilEnd = Duration.between(LocalDateTime.now(), endTime).toMillis()
+ Duration.ofHours(2).toMillis();
return Duration.ofMillis(Math.max(untilEnd, MIN_TTL.toMillis()));
}解读:RELEASE(回补)同样只有两行但要害:HDEL 删流水无条件执行(该用户回到"未抢"状态),INCR 却有条件——库存 key 存在才加 1(注释"不存在则不复活,DB 为准")。这个不对称是精心设计的:如果 Redis 已过期/被 evict 后无条件 INCR,会凭空造出一个孤零零的库存 key(值为 1),与 DB 的真实库存毫无关系,反而制造漂移。TTL 策略"活动结束 + 2 小时、下限 1 分钟"对应两个边界:+2 小时保证关单回补窗口(关单最晚发生在截止后 30 分钟 + 轮询周期)内 key 仍活着;下限 1 分钟防运营把结束时间配成过去时导致 key 刚建就过期(warmRedisQuietly 传 null endTime 时同样落回 MIN_TTL)。
设计意图:release 的调用方有三类(DB 扣减失败回滚、订单超时关闭 onOrderClosed、对账 Job 清幽灵流水),"删流水 + 条件加库存"的语义对三类调用方都正确——这是"一个原语服务多个场景"的范例。对照 WARM 脚本的 SETNX 语义(不覆盖已有值),可以总结出该服务的四条不变式:库存 key 要么不存在要么等于可信值;流水存在则库存一定被减过;key 的生命周期覆盖整个活动 + 回补窗口;一切漂移最终由 DB 纠偏。
boolean redisReserved = reserveRedis(seckill, userId, exist != null);
try {
// 原子扣减剩余库存(带状态与时间、库存条件)。
// DB 条件更新为权威防超卖闸门,Redis 仅前置挡量,二者不互信(漂移由对账 Job 纠偏)
boolean stockOk = this.update(Wrappers.<Seckill>lambdaUpdate()
.eq(Seckill::getSeckillId, seckillId)
.eq(Seckill::getSeckillStatus, STATUS_ON)
.le(Seckill::getStartTime, now)
.ge(Seckill::getEndTime, now)
.ge(Seckill::getSeckillStock, 1)
.setSql("seckill_stock = seckill_stock - 1"));
if (!stockOk) {
throw new BizException("手慢了,该场次已抢完或已结束");
}
// ……(写 seckill_success 复用/插入、建 order_master + order_item、回写 order_no)
return order.getOrderNo();
} catch (RuntimeException e) {
// DB 任何失败都会回滚扣减,同步回补 Redis 名额,消除"回滚漂移"误判
compensateRedis(seckillId, userId, redisReserved);
throw e;
}解读:方法级 @DistributeLock(scene="seckill:grab", keyExpression="#seckillId + ':' + #userId", waitMs=2000, leaseMs=10000)(注解在方法上方,本段未引用)先把同一用户同一场次的并发串行化,随后是四道前置校验(场次存在/上架/时间窗/限购),然后进入本段结构:先预扣(拿到 redisReserved 标志),再 try 包住全部 DB 写,catch 里按标志回补。DB 条件更新的 WHERE 带了五个条件(场次 id、上架状态、开售、未结束、库存 ≥1),注释里"二者不互信"五个字是整条链路的灵魂:Redis 说有名额,DB 可以说没了(对账间隙漂移);Redis 说没名额,DB 也可以说还有(Redis 降级时根本没预扣)——最终裁决权永远在 DB。
设计意图:reserveRedis 的三分支处理(见 2.4)返回"是否持有 Redis 名额"这个布尔值,是补偿机制的钥匙:只有真的预扣过才需要回补,Redis 降级路径(返回 false)绝不能去 release——否则会把别人扣的名额加回去。catch (RuntimeException e) 而非 catch (Exception e) 与方法上的 @Transactional 语义对齐:回滚的是运行时异常,补偿也只对运行时异常负责。
边界处理:compensateRedis 内部再 try/catch——回补本身失败只 log error("对账 Job 将纠偏"),绝不在 catch 里再抛异常覆盖原始异常。建单段的订单号 for-retry(与走读一 1.5 同构)限制 3 次;seckill_success 的复用插入双路径(复用行走 state=-1→0 条件更新,新行走 insert + DataIntegrityViolationException 兜底限购)覆盖了"唯一键 uk_seckill_user 含已取消旧记录"这个数据结构带来的约束(《04-数据模型》3.5)。
var result = seckillStockRedisService.deduct(seckillId, userId);
// 幽灵流水自愈:DB 只有已取消旧记录,说明流水来自崩溃残留,清除后重扣
if (result == SeckillStockRedisService.DeductResult.ALREADY_GRABBED
&& hasOldCanceledRecord) {
seckillStockRedisService.release(seckillId, userId);
result = seckillStockRedisService.deduct(seckillId, userId);
}
if (result == SeckillStockRedisService.DeductResult.KEY_MISSING) {
seckillStockRedisService.warm(seckillId, nz(seckill.getSeckillStock()), seckill.getEndTime());
result = seckillStockRedisService.deduct(seckillId, userId);
}解读:两个自愈分支都发生在"Redis 与 DB 的视图不一致"时。第一个:Redis 说"你抢过了"(ALREADY_GRABBED),但 DB 里该用户只有 state=-1 的已取消旧记录(hasOldCanceledRecord 参数由调用方查库传入)——说明 Redis 流水是"上次预扣成功后进程崩溃、没来得及建单"的残留,于是 release 清流水再重扣一次,用户不必等对账 Job 下一分钟才轮到自己。第二个:KEY_MISSING(库存 key 不存在,实例重启或后台绕过服务层改库),按 DB 当前库存 warm 后重试。
设计意图:把"可自愈的不一致"留在请求路径上即时修复(用户无感),把"无法本地判定的不一致"留给对账 Job(SeckillReconcileJob 每分钟以 DB 覆写 + 交叉核验 5 分钟以上幽灵流水,《05-核心流程》流程四第 ⑥ 步)。这是分布式缓存一致性里"读时修复"与"后台对账"的黄金搭档。
边界处理:自愈分支都有兜底——预热后仍 KEY_MISSING 则 return false(降级 DB 路径)而不是无限重试;整个方法外层 catch 一切异常降级("秒杀 Redis 预扣不可用,降级 DB 路径"warn 日志是《12-FAQ》条目 34 的现象来源)。
for (SeckillSuccess record : records) {
boolean updated = seckillSuccessMapper.update(null, Wrappers.<SeckillSuccess>lambdaUpdate()
.eq(SeckillSuccess::getSeckillSuccessId, record.getSeckillSuccessId())
.eq(SeckillSuccess::getState, 0)
.set(SeckillSuccess::getState, -1)) > 0;
if (updated) {
// 释放被该秒杀单占用的秒杀库存(DB 权威 +1,Redis 同步回补防预扣侧误拒)
this.update(Wrappers.<Seckill>lambdaUpdate()
.eq(Seckill::getSeckillId, record.getSeckillId())
.setSql("seckill_stock = seckill_stock + 1"));
try {
seckillStockRedisService.release(record.getSeckillId(), record.getUserId());
} catch (Exception e) {
log.warn("秒杀关闭回补 Redis 失败(对账 Job 将纠偏):{} - {}", record.getSeckillId(), record.getUserId(), e.getMessage());
}
}
}解读:秒杀单 30 分钟未支付时,订单侧的 closeIfTimeout → releaseOrRestore → isSeckillOrder 判定后进入本方法。三步各司其职:抢购记录 state 0→-1 的条件更新保证并发关单(延迟队列与 DB 轮询两路同时触发)只有一个成功;CAS 成功的执行者做 DB 库存 +1(seckill_stock 是权威库存);Redis 回补 try/catch 包裹(release 的"删流水 + 条件加库存"语义见 2.2)。state=-1 之后,该用户重新获得抢购资格,且下次抢购会走 2.4 节的"复用已取消记录"分支——闭环由此合拢。
设计意图:只有 CAS 胜出者才回补(if (updated)),这个结构在走读一 1.5 的订单号重试、closeIfTimeout、refundAndClose 里反复出现——**"条件更新胜者负责善后"**是全库并发安全的通用范式,它让同一时刻永远只有一个执行者做补偿,幂等性由数据库行级锁保证而非应用层锁。
边界处理:records 循环容忍同 orderNo 多条记录(数据异常防御);Redis 回补失败不抛出(关单是主目标,回补失败有对账兜底);方法级 @Transactional 让三步要么全成要么全回滚——DB 两步是原子的,Redis 一步是最终一致的。
第一,一致性设计的完整表述要包含"不一致时谁说了算、何时发现、如何修复":这个模块的答案(DB 权威、对账 Job 每分钟、覆盖纠偏 + 幽灵清理)全部可追溯到注释与 Job 代码,任何一环缺失,Lua 预扣就从优化变成事故源。第二,错误码经返回值、异常只表达基础设施故障:Lua 的 0/-1/-2/-3 与 DeductResult 枚举让业务分支精确可测,而连接异常走独立的降级通道,两种失败被分开建模。第三,补偿要有"是否需要补偿"的凭据(redisReserved 标志),盲目补偿比不补偿更危险。第四,注释是给未来的自己写的决策记录:类注释里那句"修正上一版被删除的原因",两年后就是本套文档 ADR-5 的一手证据——写注释的成本远低于考古成本。
文件:xy-mall-web/src/views/CheckoutView.vue(425 行)。走读金额试算的核心段:
const total = computed(() => goods.value.reduce((s, g) => s + (g.sellingPrice || 0) * g.goodsCount, 0))
// 运费规则后台可配(sys_config),默认满 99 包邮/运费 10 元,与后端一致
const {
freeShippingAt: FREE_SHIPPING_AT,
shippingFee: SHIPPING_FEE,
pointsDeductEnabled,
pointsDeductRate,
pointsDeductMaxPercent,
load: loadPublicConfig
} = usePublicConfig()
loadPublicConfig()
const shippingFee = computed(() => (total.value >= FREE_SHIPPING_AT.value ? 0 : SHIPPING_FEE.value))解读:total 是一个纯 computed——商品单价乘数量求和,sellingPrice || 0 的兜底防后端某行缺价导致 NaN 污染整个合计。运费四参数(包邮阈值/运费/积分三参)全部来自 usePublicConfig 组合式函数——它拉取 /api/config/public 并做单例缓存(《07-模块详解》9.1),使前端规则与后端 sys_config 同源:运营在后台把包邮阈值从 9900 改成 5900,前端试算与文案即时跟随,不需要发版。
设计意图:注释"运费规则后台可配(sys_config),默认满 99 包邮/运费 10 元,与后端一致"点明本文件的角色——后端规则的展示镜像。前端要"像后端一样算",但不"替后端算":试算值只用于交互反馈,提交后以 OrderServiceImpl 落库值为准(《02-页面设计》6.2 的结论)。这解释了为什么 usePublicConfig 要兜底默认值——配置接口失败时前端仍能用与后端种子一致的默认值给出合理试算,而不是白屏。
/** 券后商品总额(积分抵扣基数,与后端一致) */
const pointsBase = computed(() => Math.max(0, total.value - couponDiscount.value))
const pointsDeductEstimate = computed(() => {
if (!pointsDeductEnabled.value || pointsBase.value <= 0) return 0
const rate = pointsDeductRate.value
const maxByPercent = Math.floor((pointsBase.value * Math.min(pointsDeductMaxPercent.value, 100)) / 100)
let used = Math.min(myPoints.value, Math.floor((maxByPercent * rate) / 100))
if (used <= 0) return 0
const deduct = Math.floor((used * 100) / rate)
used = Math.floor((deduct * rate) / 100)
return used > 0 ? deduct : 0
})解读:这是后端 PointsCalculator.calculate 的 TypeScript 镜像,连取整不变式都逐行对应:先按上限百分比算出可抵金额上限 maxByPercent,反算所需积分 used(受持有量 myPoints 截断),再正算抵扣金额 deduct,最后用 deduct 反算 used 并回写——最后这一步就是单测 roundingNeverDeductsWithoutValue 验证的"使用积分反算价值不超过抵扣金额"不变式(《09》2.1):rate=3 这类非整除配置下,5 积分正算抵 166 分,但 166 分反算只需 4 积分(4×3=12 分价值 ≤166),把 used 回写为 4,杜绝"花 5 积分只抵 1.66 元"的取整套利。
设计意图:注释"与后端 computePointsDeduction 同规则的前端预估"(变量命名 pointsBase/pointsDeductEstimate 也刻意对齐后端语义)表明作者清楚这里存在规则双写的固有风险,并选择用"同源参数 + 同构算法 + 后端最终裁决"三件套管理它,而不是放弃前端试算(放弃的代价是用户提交前看不到能省多少,转化率受损)。真正的裁决在后端:提交时 OrderServiceImpl.createOrder 用自己的 computePointsDeduction 重算,前端传的只有 usePoints 布尔开关——前端永远不传金额,这是防"改包价下单"的根(《08-安全与权限设计》第 4 节末段)。
边界处理:!pointsDeductEnabled || pointsBase <= 0 双短路;Math.min(pointsDeductMaxPercent, 100) 夹逼配置;used <= 0 提前返回 0——每一步都对应后端纯函数的一个边界用例,前后端的测试与实现形成镜像。
const promoDiscount = computed(() => {
const base = Math.max(0, total.value - couponDiscount.value)
let best = 0
for (const p of promotions.value) {
if (base >= (p.threshold || 0) && (p.discount || 0) > best) best = p.discount || 0
}
return best
})
const promoHint = computed(() => {
if (promoDiscount.value > 0) return ''
for (const p of promotions.value) {
const gap = (p.threshold || 0) - total.value
if (gap > 0) return `再买 ¥${(gap / 100).toFixed(2)} 可享「${p.title}」`
}
return ''
})
const plusDiscount = computed(() => {
if (!plus.value.active) return 0
const base = Math.max(0, total.value - couponDiscount.value - appliedPointsDeduct.value)
return Math.floor((base * (100 - Math.min(Math.max(plus.value.discountPercent, 1), 100))) / 100)
})
/** 应付总额 = 商品总额 - 券优惠 - 积分抵扣 - PLUS 折扣 + 运费 */
const payable = computed(() =>
Math.max(0, total.value - couponDiscount.value - appliedPointsDeduct.value - plusDiscount.value) + shippingFee.value
)解读:满减是"多档取最优"的镜像(遍历生效活动,取满足门槛且减免最大的那一项,与 PromotionFullServiceImpl.bestDiscountFor 同构);promoHint 是体验层独有的"凑单提示"——没享受满减时算出与最近门槛的差额(gap / 100 分转元),这是只存在于前端、不影响正确性的增值交互。PLUS 层的基数再次对齐后端(券后减积分抵扣后),比例同样 min/max 夹逼。payable 是整条链路的出口:四层优惠逐项相减、下限 0、加运费——一行注释把公式写在代码上方,成为三端都可引用的"白话契约"。
设计意图:整条 computed 链天然形成依赖图(total → couponDiscount → promoDiscount → pointsDeduct → plusDiscount → payable),Vue 的响应式系统让任何一环变化(换券、勾积分、PLUS 状态返回)自动级联重算——用框架能力免费获得了"表格重算引擎",不需要手写任何 invalidate 逻辑。这与后端"固定顺序逐层计算"共享同一个 DAG 结构,前后端的同构是有意维持的。
边界处理:每个基数 Math.max(0, ...);payable 在四层优惠总额超过货款时下限为 0 但运费不参与优惠(与后端"积分抵扣不参与包邮判定"同口径——运费在优惠之外追加,createOrder 中 shippingFee 同样独立计算);toFixed(2) 的分转元展示放最后一步,计算全程保持整数分(与 ADR-9 的金额纪律一致)。
/** 选中商品持久化:URL 优先,缺失时从 sessionStorage 恢复,避免刷新/回退丢结算数据 */
const STORAGE_KEY = 'xy-checkout-ids'
function initialIds(): number[] {
const fromQuery = parseIds(String(route.query.ids || ''))
if (fromQuery.length) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fromQuery))
return fromQuery
}
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]') as number[]
} catch {
return []
}
}
const ids = ref<number[]>(initialIds())解读:结算的商品清单以 cartItemIds 表达,来源两级:URL query(/checkout?ids=101,102,从购物车/立即购买跳转携带)优先,缺失时从 sessionStorage 恢复(用户在结算页刷新或从支付页回退)。JSON.parse 包 try/catch——sessionStorage 被污染(旧版本格式/手工修改)时静默回空数组,页面进入空态而非白屏。watch(route.query.ids) 让用户在结算页间跳转(例如返回购物车改了勾选再来)时同步更新暂存。
设计意图:结算清单只存 id 不存快照——价格/名称每次由 /cart/settle 现查,避免"暂存了一个已改价/已下架的旧快照"这类经典脏数据;快照职责交给后端下单时的 order_item(名/价/图快照,走读一 1.2 的 items 构造)。这是"前端存意图(id),后端存事实(快照)"的分工。
边界处理:parseIds 的 filter(Boolean) 顺手清掉空串与非数字;恢复失败静默兜底;ids 变化不触发任何写入后端(只影响本地查询参数)。
第一,规则镜像要镜像到"取整不变式"级别:只对齐公式不对齐取整,前端试算与后端落库就会差一分钱——用户看不懂"应付 10.00 提交后扣 10.01",信任损失远大于几分钱。第二,前端永远不传金额:金额类输入(券 id、积分开关、地址 id)全是"选择",不是"出价",后端用同一套参数重算——这是电商防篡改的地基,也是本文件所有 computed 甘愿"重复计算"的正当性来源。第三,配置驱动 + 同源默认值让运营参数的热改不需要前端发版,而兜底默认值让配置服务故障不致白屏。第四,响应式 DAG 是前端最划算的设计投资:把金额计算组织成纯 computed 链,交互联动的复杂度被框架吸收,代码只剩业务本身。
三个文件合起来,可以看到 xyMall 代码库的一个统一气质:每个困难问题都被拆成"一个原子操作 + 一个兜底机制 + 一份注释"。订单创建把并发正确性压给 CAS 条件更新、把可追溯性压给流水表、把决策理由写进注释;秒杀把原子性压给 Lua、把漂移压给对账 Job、把设计史写进类注释;结算页把正确性压给后端重算、把体验留给响应式 DAG、把同源性压给 usePublicConfig。三者的"兜底"形态不同(DB 条件更新 / 对账纠偏 / 后端裁决),但结构同型——这正是《05-核心流程》第 6 节"公共设计语汇"在单个文件尺度上的投影。对想深入这个代码库的读者,建议按本文顺序读(事务编排 → 跨存储一致性 → 前端镜像),再回到《05-核心流程》把五条链路的时序图补全,最后用《09-测试与质量保障》的测试矩阵核对自己对边界条件的理解——三层都走完,这个项目就没有"读不懂的角落"了。