SeatFlow 选座页:座位按可选、锁定中、已售、已选四种状态着色,底部是已选座位和合计金额

影院选座与票务

SeatFlow

面向影院的在线选座票务系统,提供场次浏览、实时选座、锁座下单与模拟支付。

  • Java 21
  • Spring Boot
  • Redis
  • RocketMQ
03 / 05 · DOCUMENTATION

订单与支付状态机

支付、主动取消和超时消息竞争同一份订单状态,回调重复与消息提前到达分别处理。

订单与支付单分别记账

订单 0 / 1 / 2 / 3 对应待支付、已支付、已取消、已退款,其中退款流程为后续扩展。支付单独立记录一次支付尝试,失败的支付单不意味着订单失败,用户可在订单有效期内重新发起支付。

stateDiagram-v2
  [*] --> 待支付: 锁座并创建订单
  待支付 --> 已支付: 支付成功且订单CAS成功
  待支付 --> 已取消: 主动取消或到期取消CAS成功
  待支付 --> 待支付: 支付失败,可重试
  已支付 --> [*]
  已取消 --> [*]

关键是 UPDATE ... WHERE status = 0。支付和取消竞争时,只能有一个操作成功推进待支付订单;另一个必须读取已有状态决定后续动作,不能无条件覆盖。

三份状态各自说明什么

状态来源回答的问题不能单独推断的结论
order_info这笔交易目前待支付、已支付还是已取消支付渠道是否已经发生收款
payment_order一次支付尝试成功、失败还是等待结果座位是否已在 Redis 修复为已售
Redis 座位 Hash当前座位显示与预占状态数据库是否已有完整有效订单

用户点一次“支付”会进入支付尝试的生命周期,而订单可能经历失败后重试。把支付单和订单合成一份状态,会很难区分“支付失败但仍可再次付款”和“交易已结束、库存应释放”。

金额和订单归属也属于服务端检查范围。浏览器显示的支付按钮只是操作入口,不是授权依据;用户查询与操作订单时仍要检查订单所属用户。

延迟消息不是截止时间

下单事务提交后才向 RocketMQ 发送超时消息,避免消费者看见尚未提交的订单。发送失败记录日志,由定时对账补偿,不能用“发送成功”替代持久化订单。

消息可能重复、提前到达,或消费延迟。消费端先查询订单,已完成的订单跳过;仍待支付时,以数据库 expire_time 为准,未到期就按剩余时间重投。

这使超时逻辑对唤醒方式保持一致:正常延迟消息、提前消息重投和定时补偿最终都检查同一份业务截止时间。消费者积压会使释放晚于截止时间,因此“十五分钟支付有效期”不能解释为所有座位一定在第十五分钟的同一毫秒完成释放。

seatflow/seatflow-service/src/main/java/com/seatflow/service/mq/OrderTimeoutHandler.javaL35-52 @472bd43
public void handle(String orderNo) {
    OrderInfo order = orderInfoMapper.selectByOrderNo(orderNo);
    if (order == null || !Objects.equals(order.getStatus(), OrderService.STATUS_PENDING)) {
        log.info(
                "timeout message ignored: orderNo={}, status={}",
                orderNo,
                order == null ? null : order.getStatus());
        return;
    }
    LocalDateTime now = LocalDateTime.now();
    if (order.getExpireTime() != null && now.isBefore(order.getExpireTime())) {
        Duration remaining = Duration.between(now, order.getExpireTime());
        timeoutMessageSender.send(orderNo, remaining);
        log.info("order not expired, timeout message re-armed: orderNo={}, remaining={}", orderNo, remaining);
        return;
    }
    orderCancelService.cancelAndRelease(orderNo, CANCEL_REASON);
}
业务时间来自订单记录;消息负责唤醒检查,不是订单状态权威。

取消与库存释放

取消事务先竞争订单状态,再读取并删除 order_seat 有效占用。事务外释放 Redis 预占及限购额度;这样取消、库存释放失败可以被识别并补偿,而不是长时间把数据库事务和网络调用绑在一起。

只有 CAS 成功的取消执行者会拿到待释放座位集合。重复取消或重复超时消息不会再次推进订单,也不会重复走同一份有效占用删除。Redis 释放依然可能失败,取消成功和座位显示恢复之间允许有短暂时间差。

seatflow/seatflow-service/src/main/java/com/seatflow/service/order/OrderCancelTxService.javaL27-44 @472bd43
@Transactional
public CancelResult cancelPending(String orderNo, String reason) {
    int changed = orderInfoMapper.cancelPending(orderNo, reason);
    if (changed == 0) {
        return new CancelResult(false, List.of());
    }
    OrderInfo order = orderInfoMapper.selectByOrderNo(orderNo);
    if (order == null) {
        log.error("order not found after cancel CAS: orderNo={}", orderNo);
        return new CancelResult(true, List.of());
    }
    List<Long> seatIds = orderSeatMapper.listByOrderId(order.getId()).stream()
            .map(OrderSeat::getSeatId)
            .toList();
    orderSeatMapper.deleteByOrderId(order.getId());
    log.info("order cancelled: orderNo={}, reason={}, seatCount={}", orderNo, reason, seatIds.size());
    return new CancelResult(true, seatIds);
}

重复回调为什么不能直接返回

回调先检查 HMAC-SHA256 签名、字段完整性和时间戳偏差,再以 callback_id 唯一键记录通知。唯一键用于识别重复投递,但不能把“收到过通知”等同于“所有副作用已完成”。

例如第一次回调已把订单更新为已支付,Redis 写入失败。重复回调仍应识别已支付订单,重试座位标记为已售。PaymentCallbackService 的 insertCallbackLog 标记重复后继续状态检查,markSold 是修复入口。

签名校验与幂等各有职责。HMAC 校验通知内容与签名是否匹配,时间戳校验限制接受的时间偏差,callback_id 唯一键识别是否收到过同一通知,支付单和订单 CAS 约束业务状态推进。通过其中任意一项,都不能跳过另外几项。

seatflow/seatflow-service/src/main/java/com/seatflow/service/payment/PaymentCallbackService.javaL149-163 @472bd43
private boolean insertCallbackLog(CallbackRequest request) {
    PaymentCallbackLog callbackLog = new PaymentCallbackLog();
    callbackLog.setCallbackId(request.callbackId());
    callbackLog.setPaymentNo(request.paymentNo());
    callbackLog.setRawBody(rawBody(request));
    callbackLog.setResult(RESULT_ACCEPTED);
    try {
        paymentCallbackLogMapper.insert(callbackLog);
        return true;
    } catch (DuplicateKeyException e) {
        log.info("duplicate payment callback, self-heal continues: callbackId={}", request.callbackId());
        paymentCallbackLogMapper.markDuplicate(request.callbackId());
        return false;
    }
}
重复通知不跳过后续自愈;幂等需要同时覆盖数据库状态和 Redis 副作用。

支付与取消竞争的结果

顺序或故障应有结果后续动作
支付先成功推进订单,随后收到超时消息订单维持已支付消费端跳过取消
取消先成功,随后收到支付成功订单保持已取消,出现资金差异记录差异,自动退款尚未实现
同一个成功通知重复投递不重复推进状态仍允许修复已支付座位
支付失败,订单尚未结束订单仍待支付,库存继续预占用户在有效期内重试
数据库状态与 Redis 座位短暂不一致不能只根据页面判断交易失败结合回调重试与对账收敛

当前回调服务在数据库事务中执行支付单、通知日志和订单更新,同时调用 Redis 标记座位已售;Redis 不属于该数据库事务。相较于取消链路的事务外释放,回调链路存在另一种跨存储时序,仍需要已售孤儿清理和已支付座位修复,不能把两条链路描述成相同的事务边界。

已接受的边界

订单已经取消后才收到支付成功,真实后端记录差异日志及 discrepancy 指标,自动退款尚未实现。不能声称系统已经完成资金闭环。order_info.paid_at 目前预留,真实支付时间需关联 payment_order,不能把空值解释为未支付。

当前前端显式模拟失败后保留待支付订单并允许重试;自动成功回调只处理仍待支付的支付单,避免覆盖用户刚选择的失败结果。浏览器演示没有后台自动回调,用显式成功、失败按钮控制流程。

页面用轮询刷新支付单和订单状态,没有以 WebSocket 推送替代查询。轮询看到的短暂旧状态与业务失败应分开判断:是否支付成功看服务端记录,是否应释放看订单最终状态,是否存在资金差异看回调和对账信息。