系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第5篇 · 下一篇:第7篇

系列:Han Menu 外卖系统实践 · P5

面向读者:已经理解订单快照、幂等键和数据库事务,希望进一步掌握外部支付接入、可靠事件、状态机与失败恢复的开发者。

本篇依据 P5 提交 ecb4228 编写,承接 P4 提交 4142888。代码直接摘录实际关键实现;注明省略的片段不是独立完整文件。文中区分已经实现的行为、沙箱验收事实与后续演进方向。

当前只接入支付宝沙箱,不使用生产资金。后端提供 App 支付签名参数,真实交易验收使用测试工具中的网页沙箱收银台。Flutter 工程与 App SDK 真机端到端联调仍属于 P7。

1. 订单已经保存成功,接下来能否直接把状态改为已付款

P4 解决了订单在本地数据库中的一致性:检查身份、地址、门店和商品,保存不可变快照,再原子结算购物车。

支付开始后,业务跨出了这个数据库。

有些请求会成功,有些请求会失败,还有一类最重要的情况:我们暂时不知道请求有没有成功。

例如:

  1. 服务器向支付宝发起退款。
  2. 支付宝已经执行退款。
  3. 返回服务器的网络连接中断。
  4. 本地只得到一个超时异常。

此时,“没有收到成功响应”和“退款没有发生”是两件事。

如果换一个退款号再退一次,可能产生重复业务;如果直接标记退款失败,又可能误导顾客和后续处理。

因此本篇的主线是:先保存要完成的业务意图,再确认外部已经发生的事实;遇到不确定性时保留可恢复状态。

2. 把订单、支付、退款的职责分开

2.1 三种对象回答三个问题

对象 负责回答的问题 典型状态
Order 商家是否应继续接单、配送、完成,或者终止这笔交易 UNPAID、PAID、ACCEPTED、CANCELLED 等
Payment 渠道是否确认这笔支付已经发生 PENDING、SUCCEEDED、CLOSED
Refund 这次授权的退款是否已经得到渠道确认 PENDING、SUCCEEDED

退款发生后,原支付曾经成功的事实仍然存在。不能简单把 Payment.status 从 SUCCEEDED 改回 PENDING,假装从来没有收过这笔钱。

同样,订单已取消后收到迟到的成功付款,也不能只说“订单是终态,忽略通知”。需要记录真实资金事实,再执行退款补偿。

2.2 payment 不依赖 ordering 的内部模型

源码依赖关系如下:

flowchart LR
  O[ordering.application] --> PA[payment.api 支付能力]
  O --> PE[payment.events 结果事件类型]
  O --> IA[identity.api 员工授权]
  P[payment.application] --> CA[customer.api 顾客授权]
  P --> PD[payment.domain 支付与退款规则]
  G[payment.infrastructure 支付宝适配器] --> GP[payment.domain.PaymentGateway]

这里的箭头表示源码依赖。运行时,payment 发布结果事件,ordering 消费事件;这不要求 payment 反向导入订单类。

payment 只保存不透明的 businessRef。在当前场景里它是订单 UUID,但支付模块不负责解释订单是否已经接单或送达。

这种边界避免了“订单调用支付,支付又直接调用订单内部 Service”的循环依赖。

3. 先看状态图,再看 Controller

3.1 正常履约主线

stateDiagram-v2
  [*] --> UNPAID
  UNPAID --> PAID: 服务端确认付款
  PAID --> ACCEPTED: 员工接单
  ACCEPTED --> DELIVERING: 开始配送
  DELIVERING --> COMPLETED: 完成配送
  COMPLETED --> [*]

PAID 表示后端已经接受渠道付款事实,不是 App 本地显示了一个“成功”弹窗。

ACCEPTED 表示商家已经接单,也不是数据库收到一个任意 status="ACCEPTED" 的更新请求。

3.2 取消不是一条无条件箭头

flowchart TD
  R[收到合法取消请求] --> P{本地订单与支付情况}
  P -->|未付款且没有支付意图| C[CANCELLED]
  P -->|未付款且已有支付意图| X[CANCELLING]
  P -->|已付款且业务允许取消| F[REFUNDING]
  X -->|确认渠道已关闭| C
  X -->|渠道确认实际已付款| F
  F -->|退款查询确认成功| C

处理中的状态有明确职责:

  • CANCELLING:业务已经决定取消,渠道还可能存在有效的待支付交易。
  • REFUNDING:业务已经决定退出交易,但退款结果还没有确认。

让这些状态存在,比超时后随意选一个终态更诚实,也更容易恢复。

3.3 取消终态与退款状态分开表达

订单详情中的 lifecycle.refundStatus 独立取值为 NONE、PENDING、SUCCEEDED。

已经 CANCELLED 的订单遇到迟到成功付款,可以保持:

订单主状态:CANCELLED
退款状态:PENDING

等退款确认后再变为:

订单主状态:CANCELLED
退款状态:SUCCEEDED

这不会重新开放接单或配送,也没有抹去真实付款事实。

4. PaymentGateway 是端口,不是把 SDK 名字包一层

领域层定义需要的业务能力,核心签名如下,省略请求值类型与说明:

public interface PaymentGateway {
  String appParameters(TradeRequest request);
  TradeResult query(TradeRequest request);
  TradeResult close(TradeRequest request);
  void refund(RefundRequest request);
  boolean refundSucceeded(RefundRequest request);
  Notice verify(Map<String, String> parameters);
  void requireConfigured();
}

这些能力没有返回 AlipayTradeQueryResponse,也不要求领域对象知道支付宝的请求类。

TradeResult 使用本模块自己的状态:

record TradeResult(
    State state, String tradeNo, BigDecimal amount, Instant paidAt) {}

enum State {
  NOT_FOUND, PENDING, SUCCEEDED, CLOSED
}

这里特意保留 NOT_FOUND。没有查到交易,不一定意味着这笔支付永远不会发生,后面会用它解释取消竞争。

具体支付宝请求对象、字段名称、响应验签和错误映射都放在 AlipaySandboxGateway 中。

5. 支付意图:先为外部动作建立一个稳定身份

5.1 一个订单最多一个支付意图

Payment 保存本地支付 UUID、业务引用、顾客、金额、幂等键、请求摘要、固定截止时间,以及渠道事实和重试时间。

几个字段的用途要区分清楚:

字段 含义
id 本地支付标识;作为渠道 out_trade_no
businessRef 对应业务订单的引用
idempotencyKey 顾客这次创建支付请求的重试身份
fingerprint 这次请求对应的订单、金额、期限和版本摘要
tradeNo 渠道返回的支付宝交易号
closeRequested 本地已经要求终止这笔支付
nextAttemptAt 下次可领取处理的时间

本地支付号与支付宝交易号不是同一个东西。创建本地意图时,还没有支付宝交易号,是正常情况。

数据库约束摘要:

-- payment_intent 中的部分定义
id uuid PRIMARY KEY,
business_ref uuid NOT NULL UNIQUE,
trade_no varchar(64) UNIQUE,
UNIQUE (customer_id, idempotency_key)

它们分别阻止一个订单产生多个支付意图、同一渠道交易绑定到多个本地支付,以及同一顾客的支付幂等键被重复占用。

5.2 支付窗口从订单创建开始计算

订单的实际实现:

public Instant expiresAt() {
  return createdAt.plusSeconds(900);
}

也就是:

t_{expire}=t_{orderCreated}+900\text{秒}

不会因为顾客在第十四分钟才点击支付,就重新得到十五分钟。

支付意图会保存同一个截止时间。参数重新签发也使用这个时间,避免重试不断延长原购买约定。

5.3 绑定支付意图也改变订单版本

public void attachPayment(UUID id, long expectedVersion, Instant now) {
  requireVersion(expectedVersion);
  if (status != Status.UNPAID || paymentId != null || !now.isBefore(expiresAt())) {
    conflict("订单不能创建新的支付单");
  }
  paymentId = Objects.requireNonNull(id);
}

支付引用进入订单后,JPA 持久化版本会推进。这为后续取消、接单和其他并发操作提供同一订单上的竞争保护。

但这又引出一个问题:第一次创建支付成功后,原请求重试携带的订单版本必然已经旧了。

6. 创建支付也需要幂等,但作用域与 P4 下单不同

P4 的幂等键对应“创建订单”;P5 的幂等键对应“为订单建立支付意图”。它们位于不同用例和存储中,不能混成一条通用“请求是否执行过”的全局记录。

6.1 订单端先判断是否为已登记请求

OrderLifecycleService.reserve() 的实际实现:

public PaymentOperations.Intent reserve(
    CustomerIdentity identity, UUID id, String key, long version) {
  customers.lockActive(identity);
  var order = ownLocked(identity, id);
  if (!payments.submitted(identity.customerId(), key)) {
    order.requireVersion(version);
  }
  var intent =
      payments.reserve(
          order.id(), identity.customerId(), order.total(), order.expiresAt(), key, version);
  if (order.lifecycle().paymentId() == null) {
    order.attachPayment(intent.id(), version, clock.instant());
    orders.update(order);
  } else if (!order.lifecycle().paymentId().equals(intent.id())) {
    throw new OrderException(OrderException.Reason.STATE_CONFLICT, "支付单与订单不匹配");
  }
  return intent;
}

只有未登记的新请求,才先要求当前订单版本。已登记键也不是直接放行:payment 模块还必须对请求摘要进行核对。

因此,原始版本 0 的请求成功后,即使订单已变为版本 1,同键、同原始请求仍然可以返回同一个支付单。

如果把相同键里的版本改成 1,它就不再是原请求,会被摘要比较拒绝。

6.2 支付模块核对完整业务输入

以下方法处在 Propagation.MANDATORY 边界,参与调用方已经开启的订单事务:

public PaymentOperations.Intent reserve(
    UUID businessRef,
    UUID customerId,
    BigDecimal amount,
    Instant expiresAt,
    String key,
    long orderVersion) {
  if (key == null || !key.matches("[A-Za-z0-9._:-]{1,128}")) {
    throw new PaymentException(PaymentException.Reason.INVALID_INPUT, "支付幂等键格式不合法");
  }
  String fingerprint =
      Hashing.sha256()
          .hashString(
              businessRef + ":" + amount.toPlainString() + ":" + expiresAt + ":" + orderVersion,
              StandardCharsets.UTF_8)
          .toString();
  var existing = repository.submitted(customerId, key);
  if (existing.isPresent()) {
    var payment = existing.orElseThrow();
    payment.requireSameRequest(fingerprint);
    return intent(payment, true);
  }
  if (repository.forBusiness(businessRef).isPresent()) {
    throw new PaymentException(PaymentException.Reason.CONFLICT, "订单已存在支付单,请查询原支付单");
  }
  var payment =
      Payment.create(
          businessRef, customerId, amount, key, fingerprint, clock.instant(), expiresAt);
  repository.add(payment);
  return intent(payment, false);
}

摘要包括 businessRef、金额、固定截止时间和原订单版本。金额来自订单快照,不从请求 JSON 读取。

换一个新键也不能让同一订单创建第二个支付:除了幂等键唯一约束,还有 business_ref 唯一约束和应用检查。

一个键是否用于重试,与能不能为同一订单换键重开交易,是两条不同规则。

6.3 首次创建与重放的 HTTP 响应

POST /api/v1/orders/<订单UUID>/payments
Authorization: Bearer <顾客令牌>
Idempotency-Key: tutorial-payment-001
Content-Type: application/json

{"version": 0}

版本应以实际订单为准。首次成功返回 201,同键同请求重放返回 200,均包含 Location 和 Idempotency-Replayed。

返回的是同一个支付标识,但签名串可以重新生成。幂等保证业务意图不重复,并不要求每次 HTTP 响应的所有字节都完全一致。

7. 为什么不能用一个大事务包住整个支付过程

7.1 数据库回滚不能撤销外部世界

下面是一段错误思路的伪代码,不是项目实现:

开始数据库事务
  锁订单
  请求外部支付或退款
  更新本地状态
提交数据库事务

这里至少有两个问题:

  1. 网络等待期间持续占用数据库连接和锁。
  2. 渠道已经成功、本地事务却失败时,本地回滚无法撤销渠道动作。

更大的事务没有把两个系统变成一个原子操作。

7.2 项目把“登记意图”和“准备参数”分开

/** 在订单意图事务结束后生成 App 参数,渠道能力不能包在订单长事务中. */
@Service
@Transactional(propagation = Propagation.NEVER)
public class OrderPaymentService {
  private final OrderLifecycleService lifecycle;
  private final PaymentOperations payments;

  /** 组合短事务登记与外部支付契约. */
  public OrderPaymentService(OrderLifecycleService lifecycle, PaymentOperations payments) {
    this.lifecycle = lifecycle;
    this.payments = payments;
  }

  /** 服务端取订单金额,客户端只提交订单版本及幂等键. */
  public Creation create(CustomerIdentity identity, UUID id, String key, long version) {
    payments.requireConfigured();
    var intent = lifecycle.reserve(identity, id, key, version);
    return new Creation(payments.parameters(intent.id(), identity.customerId()), intent.replayed());
  }

  /** 资源创建结果保留是否重放,HTTP 可区分首次创建与同键重试. */
  public record Creation(PaymentOperations.AppPayment payment, boolean replayed) {}
}

这个外层服务使用 Propagation.NEVER。它的含义是:如果调用时已经存在事务,就拒绝执行,而不是挂起已有事务后继续。

lifecycle.reserve() 经由另一个 Spring Bean 的代理进入短事务。它返回时,订单支付引用与支付意图已经共同提交。随后才读取支付状态并生成 App 参数。

这里的 sdkExecute 是本地签名,不是向支付宝发起扣款请求。 即便如此,项目仍把参数生成放在明确的事务外边界,保持与渠道适配层一致的约束。

如果意图已经提交,但参数生成或响应返回失败,原键仍然可以找到已经保存的支付身份。不能因为这次响应失败,就任意生成另一个支付号。

7.3 真正网络调用使用三段式流程

sequenceDiagram
  participant W as PaymentReconciliation
  participant T as PaymentTransactions
  participant D as PostgreSQL
  participant G as 支付宝沙箱
  W->>T: 领取待处理支付
  T->>D: 锁行、检查nextAttemptAt、推进处理窗口
  D-->>T: 提交并释放锁
  T-->>W: 独立请求快照
  W->>G: 查单或关单,数据库事务之外
  G-->>W: 响应或网络异常
  W->>T: 应用可信事实或登记重试
  T->>D: 短事务保存状态及结果事件
  D-->>T: 提交

这些方法分布在不同 Bean,不是为了机械增加一层接口。它解决的是 Spring 代理事务边界:同类内部调用一个带 @Transactional 的方法,不能被想当然地当成经过了外部事务代理。

8. 官方 SDK 放在哪里,密钥怎样流动

P5 使用官方 v2 协议 SDK com.alipay.sdk:alipay-sdk-java:4.40.996.ALL。这是本项目提交固定的版本,不是对读者阅读时“最新版”的承诺。

项目只使用 JSON、公钥 RSA2 与默认 JDK HTTP 实现,并排除未使用的 dom4j、BouncyCastle 和 OkHttp 依赖。是否排除某个依赖必须基于实际调用路径和完整验证,不能把这份排除清单不加分析地复制到证书模式或其他 SDK 用法中。

8.1 应用私钥与支付宝公钥不能互换

材料 保存或使用位置 用途
应用私钥 本地受保护配置/环境变量 服务端请求或 App 参数签名
应用公钥 与应用私钥配对,由渠道识别 渠道验证应用签名
支付宝公钥 后端配置 验证支付宝通知和相关响应
顾客 Bearer token 本系统认证链 授权顾客操作自己的订单与支付

顾客 Bearer 认证和渠道 RSA2 验签验证的是不同主体。支付宝通知不需要假扮成某个顾客登录。

项目没有把私钥返回给 App,也不会把私钥写进博客示例。

8.2 网关与日志都要有明确边界

当前适配器只接受固定沙箱地址:

https://openapi-sandbox.dl.alipaydev.com/gateway.do

应用 ID、商家 PID、私钥和支付宝公钥从 .env 或环境变量读取;配置对象的 toString() 隐藏凭证。

适配器构造时还关闭 SDK 的原始请求与调试日志:

AlipayLogger.setNeedEnableLogger(false);
AlipayLogger.setJDKDebugEnabled(false);

连接超时设置为 3 秒,读取超时为 8 秒。业务只保存固定的 CHANNEL_UNAVAILABLE 分类,而不是把包含签名和渠道报文的异常原文返回给客户端。

9. App 参数是服务器签名的支付请求,不是支付成功凭证

实际参数生成方法如下:

public String appParameters(TradeRequest trade) {
  requireConfigured();
  try {
    var notify = URI.create(settings.notifyUrl());
    if (!"https".equals(notify.getScheme())
        || notify.getHost() == null
        || notify.getUserInfo() != null) {
      throw unavailable();
    }
    var model = new AlipayTradeAppPayModel();
    model.setOutTradeNo(trade.paymentId().toString());
    model.setTotalAmount(trade.amount().toPlainString());
    model.setSubject("Han Menu 外卖订单");
    model.setProductCode("QUICK_MSECURITY_PAY");
    model.setSellerId(settings.sellerId());
    model.setTimeExpire(CHANNEL_TIME.format(trade.expiresAt().atZone(CHANNEL_ZONE)));
    var request = new AlipayTradeAppPayRequest();
    request.setBizModel(model);
    request.setNotifyUrl(notify.toASCIIString());
    return client().sdkExecute(request).getBody();
  } catch (AlipayApiException | IllegalArgumentException | NullPointerException exception) {
    throw unavailable();
  }
}

金额、商家和交易号都来自服务端。time_expire 使用订单固定截止时间转换成支付宝要求的 Asia/Shanghai 格式;应用内部仍然使用 UTC Instant

例如 UTC 的 00:15:00Z 与上海时间的 08:15:00 表达同一时刻,不能把本地格式字符串再次当作 UTC 解析。

返回参数中的几个重要字段:

{
  "id": "本地支付UUID",
  "status": "PENDING",
  "amount": 18.50,
  "currency": "CNY",
  "channel": "ALIPAY_SANDBOX",
  "invocation": "APP",
  "orderString": "由SDK产生的不透明签名参数"
}

这是响应字段示意,省略 expiresAt 和 version。App 可以拿 orderString 调用客户端 SDK,但不能用本地 SDK 返回结果直接把订单改为 PAID。

已成功、已关闭、已过期或正在关闭的支付,不再得到新的有效签名参数,重放响应中的 orderString 可以为 null。

10. 通知验签不是只写一个 rsaCheck 就结束

10.1 先分离认证入口

通知使用独立安全链,只开放精确 POST 路径 /api/v1/payment-notifications/alipay

它不使用顾客或员工 token。顾客支付查询则仍然经过独立顾客安全链,资源归属从认证身份取得。

10.2 签名、应用和业务绑定是不同层次的检查

适配器的实际验签方法:

public Notice verify(Map<String, String> input) {
  requireConfigured();
  try {
    if (input.size() > 64
        || input.entrySet().stream()
            .anyMatch(entry -> entry.getValue() == null || entry.getValue().length() > 4096)) {
      throw invalid();
    }
    requireEqual("RSA2", input.get("sign_type"));
    requireEqual(settings.appId(), input.get("app_id"));
    requireEqual(settings.sellerId(), input.get("seller_id"));
    if (!AlipaySignature.rsaCheckV1(
        new HashMap<>(input), settings.publicKey(), "UTF-8", "RSA2")) {
      throw invalid();
    }
    var state = state(input.get("trade_status"));
    var paidAt =
        state == State.SUCCEEDED
            ? LocalDateTime.parse(input.get("gmt_payment"), CHANNEL_TIME)
                .atZone(CHANNEL_ZONE)
                .toInstant()
            : null;
    var tradeNo = input.get("trade_no");
    if (tradeNo == null || !tradeNo.matches("[0-9]{16,64}")) {
      throw invalid();
    }
    return new Notice(
        UUID.fromString(input.get("out_trade_no")),
        new TradeResult(state, tradeNo, amount(input.get("total_amount")), paidAt));
  } catch (AlipayApiException
      | IllegalArgumentException
      | java.time.DateTimeException
      | NullPointerException exception) {
    throw invalid();
  }
}

这段代码完成协议层检查:

  • 固定 RSA2。
  • app_id 对应当前沙箱应用。
  • seller_id 对应预期商家。
  • 用支付宝公钥验证表单签名。
  • 解析可识别的渠道状态和付款时间。
  • 验证本地支付 UUID、渠道交易号格式与精确金额。

这里给 SDK 传入 HashMap 副本,避免 SDK 的参数处理影响调用方继续持有的原映射。

但签名通过,仍不等于某个数据库支付单可以被随意修改。

后续还要找到已经存在的支付单,并核对金额和已绑定的渠道号。不能因为通知带着一个新的 UUID,就创建一条“成功支付”记录。

10.3 控制器先拒绝重复参数,成功回执放在提交之后

核心 HTTP 处理如下,省略路由注解:

ResponseEntity<String> notify(@RequestParam MultiValueMap<String, String> parameters) {
  if (parameters.size() > 64
      || parameters.values().stream().anyMatch(values -> values.size() != 1)) {
    return ResponseEntity.badRequest().body("failure");
  }
  try {
    var values = new HashMap<String, String>();
    parameters.forEach((key, value) -> values.put(key, value.getFirst()));
    var notice = gateway.verify(values);
    transactions.observe(notice.paymentId(), notice.result());
    return ResponseEntity.ok("success");
  } catch (RuntimeException exception) {
    return ResponseEntity.badRequest().body("failure");
  }
}

使用 MultiValueMap 可以检测 sign 等字段重复出现,避免框架先把多个值折叠成一个,导致验签和业务解释不一致。

transactions.observe() 是经过代理调用的事务方法。它返回后,支付事实与事件登记才已提交;随后 Controller 才返回文本 success

此时订单的异步消费者可能还没执行完成。因此 success 表示这份通知已被可靠接收,并不表示全部后续履约处理都已经同步完成。

当前控制器把校验或持久化异常统一返回 400 failure,没有细分所有第三方通知故障。应用还保留主动查询,不能把资金正确性只押在渠道通知重试上。

11. 用 Payment 聚合处理重复和乱序渠道事实

这是 P5 最值得逐行理解的方法之一:

public boolean observe(PaymentGateway.TradeResult result, Instant now) {
  if (result.state() != PaymentGateway.State.NOT_FOUND) {
    if (result.amount() == null
        || amount.compareTo(result.amount()) != 0
        || result.tradeNo() == null
        || result.tradeNo().isBlank()
        || (tradeNo != null && !tradeNo.equals(result.tradeNo()))) {
      throw new PaymentException(PaymentException.Reason.INVALID_NOTIFICATION, "渠道交易标识或金额不匹配");
    }
    tradeNo = result.tradeNo();
  }
  boolean changed = false;
  if (result.state() == PaymentGateway.State.SUCCEEDED && status != Status.SUCCEEDED) {
    if (result.paidAt() == null) {
      throw new PaymentException(PaymentException.Reason.INVALID_NOTIFICATION, "成功交易缺少付款时间");
    }
    status = Status.SUCCEEDED;
    paidAt = result.paidAt();
    changed = true;
  } else if (status == Status.PENDING
      && (result.state() == PaymentGateway.State.CLOSED
          || (result.state() == PaymentGateway.State.NOT_FOUND
              && !now.isBefore(expiresAt.plusSeconds(120))))) {
    status = Status.CLOSED;
    changed = true;
  }
  lastFailure = null;
  schedule(now);
  return changed;
}

11.1 先检查引用与金额

对于不是 NOT_FOUND 的结果,要求:

A_{channel}=A_{payment}

还要有非空渠道交易号。如果已经绑定了一个交易号,后续结果必须仍然对应它。

不能把“签名是真的”与“这笔钱属于眼前这张支付单”混为一谈。

11.2 真实成功不能被迟到关闭覆盖

代码只允许尚未成功的支付变为 SUCCEEDED。关闭分支又明确要求当前状态仍是 PENDING。

所以已记录 SUCCEEDED 后,再收到一个延迟的 CLOSED,不会把付款事实清掉。

11.3 CLOSED 后的真实成功仍须记账

SUCCEEDED 分支没有排除当前 CLOSED。这表达的是“我们后来得到了更强的付款事实”,并不表示系统重新打开了支付宝上已经关闭的交易。

后续由订单决定如何补偿。忽略真实成功,会让本地业务状态看起来很整齐,却无法说明渠道实际发生的钱款。

11.4 changed 控制是否发布新结果事件

重复成功通知仍然可以完成安全检查,但不会每次都产生新的支付状态事件。

即使某次事件本身后来被重复投递,订单消费者还要幂等处理。生产端减少重复与消费端承受重复,是两层不同保护。

12. 主动查单与关单:不要相信一次网络请求的表面结果

支付工作器的实际实现:

public void payment(UUID id) {
  var claimed = transactions.claimPayment(id);
  if (claimed.isEmpty()) {
    return;
  }
  var payment = claimed.orElseThrow();
  try {
    var result = gateway.query(payment.request());
    if (result.state() == PaymentGateway.State.PENDING
        && (payment.closeRequested() || !clock.instant().isBefore(payment.expiresAt()))) {
      result = gateway.close(payment.request());
    }
    transactions.observe(id, result);
  } catch (RuntimeException exception) {
    // 渠道异常可能含密钥或原始报文;只保存稳定分类,由原意图支持后续恢复。
    transactions.paymentFailed(id);
  }
}

工作器先取得已持久化任务,再访问渠道。只有渠道仍然是 PENDING,且本地请求关闭或已经过期,才尝试关单。

12.1 查单也要核对业务字段

支付宝适配器中,SDK 执行成功响应处理后,还会检查:

requireEqual(trade.paymentId().toString(), response.getOutTradeNo());
var amount = amount(response.getTotalAmount());
if (amount.compareTo(trade.amount()) != 0) {
  throw invalid();
}
var state = state(response.getTradeStatus());
var paidAt = response.getSendPayDate() == null
    ? null : response.getSendPayDate().toInstant();
if (state == State.SUCCEEDED && paidAt == null) {
  throw invalid();
}

SDK 负责协议签名,业务适配器负责交易号、金额和领域事实的完整性;两者都需要。

12.2 关单请求之后再次查询

public TradeResult close(TradeRequest trade) {
  try {
    var model = new AlipayTradeCloseModel();
    model.setOutTradeNo(trade.paymentId().toString());
    var request = new AlipayTradeCloseRequest();
    request.setBizModel(model);
    var response = client().execute(request);
    // 即使关单成功也再查一次,已付款竞争和丢失响应由真实渠道事实决定。
    if (response.isSuccess()) {
      requireEqual(trade.paymentId().toString(), response.getOutTradeNo());
    }
    return query(trade);
  } catch (AlipayApiException exception) {
    throw unavailable();
  }
}

即使关单返回成功,也不只根据那一个响应直接推进订单终态。重新查询有助于发现付款与关单之间的竞争。

如果网络异常使本次调用无法确认,工作器保存失败分类与下一次尝试时间,不把异常解释成“关单已成功”。

13. “没有交易”为什么不能马上等同于“已关闭”

App 支付签名参数可以先生成,渠道交易可能要到 App 实际调用时才创建。

考虑下面这个顺序:

sequenceDiagram
  participant A as App
  participant O as 后端
  participant G as 支付宝
  O-->>A: 返回有效的App签名参数
  A->>O: 请求取消订单
  O->>G: 查询交易
  G-->>O: NOT_FOUND
  Note over A,G: App仍可能持有尚未过期的签名参数
  A->>G: 延迟使用原参数发起支付

如果收到 NOT_FOUND 就立刻认为“这笔支付永远不会发生”,判断依据是不够的。

本项目的明确策略是:

\operatorname{confirmClosed} =\operatorname{channelClosed} \;\lor\; (\operatorname{notFound}\land now\ge t_{expire}+120\text{秒})

前面 Payment.observe() 中的关闭分支正是在实现这个策略。

两分钟是本项目采用的缓冲策略,不是对支付宝所有时钟误差、延迟或网络行为的普遍保证。固定参数过期时间、关单查询、关闭后的复查和迟到成功补偿,需要一起构成完整处理方案。

已 CLOSED 的支付会在原截止时间后一天内,按每十分钟的间隔安排继续复查。停止周期复查后,仍可接收迟到的有效成功通知并进行补偿。

14. 把重试保存在数据库里,而不是写一个 while(true)

14.1 领取任务只占用短事务

支付聚合中的领取方法:

public boolean claim(Instant now) {
  if (nextAttemptAt == null || nextAttemptAt.isAfter(now)) {
    return false;
  }
  nextAttemptAt = now.plusSeconds(60);
  return true;
}

应用服务的领取方法:

public Optional<Payment> claimPayment(UUID id) {
  var payment = repository.lockPayment(id);
  if (!payment.claim(clock.instant())) {
    return Optional.empty();
  }
  repository.update(payment);
  return Optional.of(payment);
}

读取同一支付的两个工作器会通过数据库行锁串行检查 nextAttemptAt。正常情况下,一个领取成功后,另一个看到未来的处理窗口,就不会马上再次执行。

数据库锁在返回请求快照之前已经释放。网络期间不保持那把行锁。

14.2 进程退出后,任务仍然存在

如果工作器领取后就退出,数据库里 nextAttemptAt 仍然存在。等六十秒处理窗口过去,其他执行者可以重新领取。

这比只把任务放进内存队列更容易恢复,但它不是一个严格的“外部调用恰好一次”保证。

执行时间超过窗口、进程恢复与重试竞争等情况,都可能使外部请求被再次发送。所以仍然必须使用稳定交易号/退款号,并在保存结果时做行锁和幂等处理。

不要把一个时间窗口宣传成具有 fencing token 的完整分布式租约协议。

14.3 当前调度规则

private void schedule(Instant now) {
  nextAttemptAt = switch (status) {
    case SUCCEEDED -> null;
    case PENDING -> now.plusSeconds(30);
    case CLOSED -> now.isBefore(expiresAt.plusSeconds(86400))
        ? now.plusSeconds(600) : null;
  };
}

定时器默认 fixedDelay 为十秒,每次查询最多一百个待处理支付和一百个待处理退款。

fixedDelay 不是“任何任务都必然在十秒内完成”。处理时间、网络超时与批次积压都会影响实际延迟。当前实现适合单店学习场景,没有声称已经完成大规模调度吞吐和告警治理。

15. 支付结果如何可靠地到达订单模块

15.1 一次应用事实,同时登记事件

PaymentTransactions 使用类级 @Transactional,关键方法如下:

public void observe(UUID id, PaymentGateway.TradeResult result) {
  var payment = repository.lockPayment(id);
  boolean changed = payment.observe(result, clock.instant());
  repository.update(payment);
  if (payment.status() == Payment.Status.SUCCEEDED && payment.closeRequested()) {
    requestRefund(payment);
  }
  if (changed) {
    events.publishEvent(
        new PaymentResult(
            payment.id(),
            payment.businessRef(),
            payment.customerId(),
            payment.amount(),
            payment.status().name(),
            payment.paidAt(),
            clock.instant()));
  }
}

如果订单已经要求关闭,而查单发现支付实际上成功,就在同一事务中建立唯一退款意图。

状态真正改变时发布 PaymentResult。Spring Modulith 为对应事务监听器登记待投递记录,登记与本次业务变更共同提交。

15.2 消费者使用独立事务

/** 可靠消费支付结果;独立事务失败时保留 Modulith 登记供恢复任务重投. */
@Component
class PaymentResultListener {
  private final OrderLifecycleService lifecycle;

  PaymentResultListener(OrderLifecycleService lifecycle) {
    this.lifecycle = lifecycle;
  }

  @ApplicationModuleListener
  public void payment(PaymentResult result) {
    lifecycle.paymentResult(result);
  }

  @ApplicationModuleListener
  public void refund(RefundResult result) {
    lifecycle.refundResult(result);
  }
}

支付模块提交后,订单监听器再执行自己的事务。它失败时,不把已经确认的支付改回未支付,而是留下未完成事件,供后续重投。

sequenceDiagram
  participant P as payment短事务
  participant D as PostgreSQL
  participant L as ordering监听器
  P->>D: 更新支付为SUCCEEDED
  P->>D: 登记PaymentResult待投递记录
  D-->>P: 一起提交
  L->>D: 独立事务锁订单并处理付款事实
  alt 消费成功
    L->>D: 保存订单状态
    L->>D: 记录投递完成
  else 消费失败
    L->>D: 回滚订单消费事务
    Note over L,D: 保留未完成登记,后续重投
  end

这里仍然可能发生业务消费已经完成、完成标记却没有及时写好的故障窗口。重投时,消费者必须能够再次安全处理同一事实。

项目不是依赖消息只投递一次,而是让重复处理不会产生错误的业务结果。

15.3 恢复任务控制并发与批次

@Scheduled(fixedDelay = 60000)
void recoverEvents() {
  publications.resubmitIncompletePublications(
      org.springframework.modulith.events.ResubmissionOptions.defaults()
          .withMinAge(Duration.ofMinutes(1))
          .withBatchSize(100)
          .withMaxInFlight(10));
}

其中 withBatchSize(100) 是读取/处理批次配置,withMaxInFlight(10) 限制同时在途的重投。不能仅凭 batchSize 就断言“一次方法调用总共只会处理 100 条”,总处理过程还取决于框架重投实现。

项目也保留启动时重投未完成登记的配置。完成后采用删除登记模式,因此这张表用于可靠投递,不是永久审计账本,也不是事件溯源存储。

16. 取消、成功付款和退款结果,怎样避免互相覆盖

16.1 聚合决定哪些取消是合法的

public void requestCancellation(long expectedVersion, CancelReason reason, Instant now) {
  requireVersion(expectedVersion);
  boolean allowed =
      switch (reason) {
        case CUSTOMER -> status == Status.UNPAID || status == Status.PAID;
        case TIMEOUT -> status == Status.UNPAID && !now.isBefore(expiresAt());
        case MERCHANT_REJECTED -> status == Status.PAID;
        case MERCHANT_CANCELLED -> status == Status.PAID || status == Status.ACCEPTED;
        case PAYMENT_CLOSED -> false;
      };
  if (!allowed) {
    conflict("当前订单状态不能执行该取消操作");
  }
  cancelReason = reason;
  if (status == Status.UNPAID) {
    if (paymentId == null) {
      finishCancellation(now);
    } else {
      status = Status.CANCELLING;
    }
  } else {
    status = Status.REFUNDING;
    refundStatus = RefundStatus.PENDING;
  }
}

规则由固定原因枚举区分:

取消来源 允许的当前状态
顾客 UNPAID、PAID
超时任务 到期 UNPAID
商家拒单 PAID
商家取消 PAID、ACCEPTED

顾客不能在商家接单后随意取消;商家也不能通过这个用例取消已经开始配送或完成的订单。

应用服务组织授权、锁定和保存,聚合负责状态规则。不能只在 Controller 里检查状态,再给实体一个任意 setter。

16.2 超时扫描必须在锁内再次判断

public void expire(UUID id) {
  var order = orders.lock(id);
  if (order.status() == Order.Status.UNPAID && !clock.instant().isBefore(order.expiresAt())) {
    order.requestCancellation(order.version(), Order.CancelReason.TIMEOUT, clock.instant());
    saveCancellation(order);
  }
}

扫描到的“过期待付款”只是候选。等这个方法拿到订单锁时,付款消费者可能已经把订单推进为 PAID。

因此拿锁后重新检查状态和时间,不能把扫描结果当成永远有效的事实。

16.3 迟到付款返回的是“需要补偿”,不是“恢复订单”

public boolean paymentSucceeded(
    UUID payment, BigDecimal amount, Instant paymentTime, Instant now) {
  requirePayment(payment, amount);
  if (paidAt != null) {
    return refundStatus == RefundStatus.PENDING;
  }
  paidAt = Objects.requireNonNull(paymentTime);
  if (refundStatus == RefundStatus.SUCCEEDED) {
    return false;
  }
  if (status == Status.CANCELLED
      || status == Status.CANCELLING
      || !paymentTime.isBefore(expiresAt())) {
    if (cancelReason == null) {
      cancelReason = CancelReason.TIMEOUT;
    }
    refundStatus = RefundStatus.PENDING;
    if (status != Status.CANCELLED) {
      status = Status.REFUNDING;
    }
    return true;
  }
  if (status == Status.UNPAID) {
    status = Status.PAID;
  }
  return false;
}

方法返回 true,表示需要登记退款。这里没有在聚合内部调用支付宝。

几个边界需要一起理解:

  • 已有 paidAt 时,重复成功不会重新接单。
  • 退款已经成功时,迟到付款事件不会再生成一笔退款。
  • CANCELLED 保持取消终态,只把退款状态设为 PENDING。
  • CANCELLING 或过期付款进入 REFUNDING,等待退款确认。
  • 当前仍是合法 UNPAID,才正常推进为 PAID。

判断是否过期使用的是传入的付款时间,不只是“现在收到事件的时间”。但如果订单已经因为取消流程进入取消状态,即使后来才发现之前已付款,也仍按取消意图退款,不恢复履约。

16.4 退款事件也可能先到

付款结果与退款结果由不同异步消费任务处理,不能只按“代码里谁先发布”推断谁先被消费。

项目允许 CANCELLING 状态接收对应退款成功,先结束订单取消。随后收到付款成功时,发现退款已经完成,就不再开放订单或重复退款。

这就是为什么订单既核对 paymentId 和金额,又保留独立 refundStatus/refundId,而不是只比较一个状态字符串的大小。

17. 全额退款要有自己的稳定标识和确认过程

17.1 为什么退款不直接复用订单 ID

支付单描述收款事实,退款单描述一笔退出该收款的业务意图。它需要自己的 ID、创建时间、处理状态、确认时间和重试时间。

当前只支持一笔支付对应一个全额退款:

payment_id uuid NOT NULL UNIQUE REFERENCES payment_intent(id)

这个外键在 payment 模块内部,不跨到 ordering 表。

退款创建使用原支付金额:

public static Refund create(Payment payment, Instant now) {
  if (payment.status() != Payment.Status.SUCCEEDED) {
    throw new PaymentException(PaymentException.Reason.CONFLICT, "支付未成功,不能退款");
  }
  return new Refund(
      UUID.randomUUID(),
      payment.id(),
      payment.businessRef(),
      payment.customerId(),
      payment.tradeNo(),
      payment.amount(),
      now,
      Status.PENDING,
      null,
      now,
      null,
      0);
}

没有把客户端传来的 refundAmount 填进去,也没有用固定的测试金额代替业务金额。

17.2 请求受理后仍然查询确认

实际退款工作器:

public void refund(UUID id) {
  var claimed = transactions.claimRefund(id);
  if (claimed.isEmpty()) {
    return;
  }
  var refund = claimed.orElseThrow();
  try {
    if (!gateway.refundSucceeded(refund.request())) {
      gateway.refund(refund.request());
    }
    if (gateway.refundSucceeded(refund.request())) {
      transactions.refundConfirmed(id);
    } else {
      transactions.refundFailed(id);
    }
  } catch (RuntimeException exception) {
    transactions.refundFailed(id);
  }
}

固定的退款 UUID 被转换为支付宝的 out_request_no。网络重试不会换一个新退款号。

本次请求返回成功并不直接触发 refundConfirmed。代码还会执行独立查询,只有查询确认后才发布退款完成事件。

如果受理响应丢失,下一轮先查询相同退款号。已经查到成功时,就不需要再发起另一笔退款。

17.3 失败时保存的是“尚未确认”

Refund 聚合只使用 PENDING 和 SUCCEEDED。当前没有把任何渠道临时错误直接设计成不可恢复 FAILED 终态。

这不意味着永远重试就是完整运维方案。永久配置错误或长期异常仍需要人工关注;当前 lastFailure 只有固定分类,没有实现完整告警、指数退避、人工处理队列或运营退款后台。

这些属于后续增强,不能在教程中写成已经实现的能力。

18. 一次真实沙箱联调带来的修正:10000 不等于退款已经发生

这是 P5 中很有教学价值的一个具体问题。

首次查询尚未发起的退款时,沙箱返回了这样的业务形状,以下只保留关键字段,省略真实响应签名等内容:

{
  "code": "10000"
}

没有 refund_status,也没有退款金额和退款请求号。

如果代码在检查退款状态之前,就要求返回原交易号和退款号,会发生什么?

  1. 本地已经有 PENDING 退款意图。
  2. 工作器先查询退款。
  3. 返回成功码,但尚无退款记录。
  4. 代码因为“缺少交易字段”抛错。
  5. 工作器不断重试查询,始终没有机会发起第一笔退款。

问题出在把“查询接口成功执行”误认为“查询到了目标业务事实”。

修正后的实际方法:

public boolean refundSucceeded(RefundRequest refund) {
  try {
    var model = new AlipayTradeFastpayRefundQueryModel();
    model.setOutTradeNo(refund.paymentId().toString());
    model.setTradeNo(refund.tradeNo());
    model.setOutRequestNo(refund.refundId().toString());
    var request = new AlipayTradeFastpayRefundQueryRequest();
    request.setBizModel(model);
    var response = client().execute(request);
    if (!response.isSuccess()) {
      if ("ACQ.TRADE_NOT_EXIST".equals(response.getSubCode())) {
        return false;
      }
      throw unavailable();
    }
    // 官方退款查询在尚无退款记录时也可能返回 10000 且没有业务字段。
    // 只有明确的退款成功才校验交易引用和金额,空记录必须允许发起同一退款意图。
    if (!"REFUND_SUCCESS".equals(response.getRefundStatus())) {
      return false;
    }
    requireEqual(refund.paymentId().toString(), response.getOutTradeNo());
    requireEqual(refund.tradeNo(), response.getTradeNo());
    requireEqual(refund.refundId().toString(), response.getOutRequestNo());
    if (amount(response.getRefundAmount()).compareTo(refund.amount()) != 0
        || amount(response.getTotalAmount()).compareTo(refund.amount()) != 0) {
      throw invalid();
    }
    return true;
  } catch (AlipayApiException exception) {
    throw unavailable();
  }
}

顺序是关键:先确认有没有明确的 REFUND_SUCCESS,没有时返回未确认;存在明确成功记录后,才严格核对交易、退款标识及金额。

当前是全额退款,因此还要求:

A_{refund}=A_{payment}=A_{channelOriginal}

这段代码不能不加修改地用作部分退款方案。

P5 为“成功空结果允许发起首次退款”“明确成功结果必须匹配原交易和金额”补充了回归测试,并用新的真实沙箱交易验证自动退款闭环。修正来自对真实协议语义的核对,不是把校验删除来追求一次成功响应。

19. 员工履约:付款是前置事实,版本保护操作竞争

接单与配送方法很短,但它们清楚表达了业务前置状态:

public void accept(long expectedVersion, Instant now) {
  requireVersion(expectedVersion);
  requireStatus(Status.PAID);
  status = Status.ACCEPTED;
  acceptedAt = now;
}
public void deliver(long expectedVersion, Instant now) {
  requireVersion(expectedVersion);
  requireStatus(Status.ACCEPTED);
  status = Status.DELIVERING;
  deliveredAt = now;
}
public void complete(long expectedVersion, Instant now) {
  requireVersion(expectedVersion);
  requireStatus(Status.DELIVERING);
  status = Status.COMPLETED;
  completedAt = now;
}

OrderLifecycleService 先调用 identity 的公开 requireStaff,再锁订单行,然后调用这些领域行为。

它校验的是数据库里的当前员工状态和安全版本,不是只相信请求上下文中的角色字符串。ADMIN 和普通 STAFF 都可以按当前规则处理履约,停用员工的旧会话会被拒绝。

接单与取消同时发生

假设订单 PAID,版本为 2:

  • 员工提交“接单,version=2”。
  • 顾客同时提交“取消,version=2”。

两者会竞争同一订单行锁。先完成的事务推进状态和版本,后执行的请求不能继续依据旧版本修改。

最终可以是 ACCEPTED,也可以是 REFUNDING,但不能把同一个旧版本的两个相冲突动作都当作成功。

数据库悲观锁让状态判断发生在明确的顺序里,客户端版本则表达“我基于哪一次看到的状态作决定”。两者结合,保护的是不同层面的并发语义。

20. 从模型回到具体 HTTP 契约

20.1 顾客与渠道新增操作

方法与路径 请求或响应重点
POST /api/v1/orders/{id}/payments 原订单版本、支付幂等键;首次 201、重放 200
GET /api/v1/payments/{id} 本人支付状态、固定金额、期限、退款引用
POST /api/v1/payments/{id}/refresh 支付单版本,不是订单版本
GET /api/v1/refunds/{id} 本人退款状态与确认时间
POST /api/v1/payment-notifications/alipay 第三方表单协议与 RSA2 验签

读取支付单只查询已持久化状态。主动刷新也受任务处理窗口约束,不保证每次点击都立即向渠道发送一次 HTTP。

异步消费者存在时,短时间内“支付已成功、订单仍未显示 PAID”是可能的。客户端应继续查询服务端状态,而不是自己把订单推进成已支付。

20.2 员工新增操作

所有路径以 /api/v1/management/orders 为前缀:

方法与子路径 含义
GET 根路径 可按 status 筛选的分页摘要
GET /{id} 履约详情与收货快照
POST /{id}/acceptance 接单
POST /{id}/rejection 拒单并申请全额退款
POST /{id}/cancellation 配送前的商家取消
POST /{id}/delivery 开始配送
POST /{id}/completion 完成配送

所有写动作都提交当前订单 version。管理列表使用投影和可选状态条件,避免为摘要查询加载完整地址和所有明细。

P5 新增 12 个 HTTP 操作,并扩展了 P4 原有顾客取消语义。它没有公开一个允许顾客随意传入退款金额的通用退款接口。

21. Flyway 演进与 JPA 并发需要一起落地

P5 使用新迁移 V6 扩展订单表,并建立 payment_intent、payment_refund。没有修改 V1—V5,也不重新清空已经形成的数据。

订单新增支付引用、付款与履约时刻、取消原因、退款状态与退款引用。数据库约束的一段实际内容如下:

ALTER TABLE ordering_order ADD CONSTRAINT ordering_fulfillment_facts CHECK (
  (status NOT IN ('PAID', 'ACCEPTED', 'DELIVERING', 'COMPLETED')
      OR paid_at IS NOT NULL)
  AND (status NOT IN ('ACCEPTED', 'DELIVERING', 'COMPLETED')
      OR accepted_at IS NOT NULL)
  AND (status NOT IN ('DELIVERING', 'COMPLETED')
      OR delivered_at IS NOT NULL)
  AND (status <> 'COMPLETED' OR completed_at IS NOT NULL)
);

这是对已有规则的数据库兜底,不负责代替完整的状态机。

支付行锁仍使用 Spring Data 声明:

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<PaymentEntity> findLockedById(UUID id);

JPA 实体同时拥有 @Version Long version。更新时先对照领域快照版本,再交给 ORM 脏检查与版本条件保护。

订单的 payment_id 与支付的 business_ref 没有跨模块外键。payment_refund 对 payment_intent 的外键则属于同一模块内部关系。

22. 自动化测试应分开证明协议、业务和恢复能力

22.1 RSA2 测试使用生成的测试密钥

AlipaySandboxGatewayTest 在测试进程里生成应用和渠道两组 RSA 密钥:

  • 用官方 SDK 生成 App 参数,再验证应用签名。
  • 生成合法渠道通知,验证状态、金额和时间解析。
  • 篡改 app_id、seller_id、签名、金额、支付号或状态,应被拒绝。
  • 生产网关或缺失凭证不能用于当前沙箱适配器。
  • 空退款查询不是已完成退款;明确成功结果必须核对金额与引用。

测试密钥不来自 .env 的真实沙箱配置,因此常规构建不会依赖真实渠道可用性。

22.2 数据库测试验证真实事务,不只是 mock 调用次数

支付状态与事件登记一起回滚的实际测试:

void paymentStateAndReliableEventRegistrationRollbackTogether() {
  final UUID payment = createPayment();
  transactions.executeWithoutResult(
      status -> {
        payments.observe(payment, result(PaymentGateway.State.SUCCEEDED));
        org.springframework.orm.jpa.SharedEntityManagerCreator.createSharedEntityManager(
                entityManagerFactory)
            .flush();
        assertThat(count("event_publication")).isEqualTo(1);
        status.setRollbackOnly();
      });
  assertThat(count("event_publication")).isZero();
  assertThat(payments.view(customer, payment).status()).isEqualTo("PENDING");
  assertThat(orders.detail(customer, orderId).status()).isEqualTo("UNPAID");
}

测试中的显式 flush 是为了让同一事务里的 JDBC 断言看到已经发送到数据库的 ORM 写入。它不是提交,后面的回滚仍然必须撤销支付与事件登记。

测试源码可以使用 JDBC 做底层断言,不代表业务代码可以绕过项目的 JPA 持久化约定。

22.3 消费失败不能只靠一条日志证明可恢复

失败重投测试会让订单消费者第一次抛错,然后检查 FAILED 登记仍在,最后按恢复配置重投并等待订单进入 PAID。

测试也验证网络调用时没有活动数据库事务,例如:

assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse();

这个断言放在渠道测试替身里,检查的是调用发生时的实际事务环境。

重点场景如下:

场景 必须保留的事实
同键创建支付重试 同一支付标识与原截止时间
非所属顾客查询支付或退款 返回 404
关单响应未知 订单仍在处理中,任务可重试
退款已执行但响应丢失 查询原退款号恢复,不重复退款
退款查询仍未确认 订单保持 REFUNDING
取消后的迟到付款 主订单不复活,登记全额退款
退款结果先于付款事件 最终仍然取消,不重复补偿
付款与事件登记事务回滚 两者一起撤销
消费者失败后重投 支付事实保留,订单最终正确推进
接单与取消并发 同一旧版本只能一个操作成功

23. 真实沙箱联调:证明什么,也明确没有证明什么

23.1 本阶段实际验证了哪些事情

根据 P5 验收记录:

  1. 使用配置好的沙箱应用完成签名查单。
  2. 创建真实待付款渠道交易,完成主动关单与订单取消。
  3. 使用沙箱买家余额完成 0.01 元模拟支付。
  4. 接收真实 HTTPS 异步通知,后端验签后返回 success。
  5. 本地支付变为 SUCCEEDED,订单变为 PAID;主动查询也确认了金额与付款时间。
  6. 修正首次空退款查询后,再次用新交易完成自动全额退款及查询确认。
  7. 最终订单为 CANCELLED,退款为 SUCCEEDED。

这些是真实沙箱协议与交易状态,不是真实生产资金,也不是测试替身返回成功。

验收使用测试工具里的网页支付收银台触发交易。生产后端交付的是 App 签名参数接口,尚未创建 Flutter 工程。因此不能写成“Flutter Android/iOS 真机支付已经全部联调完成”。

23.2 配置示例只放占位信息

ALIPAY_APP_ID='<沙箱应用ID>'
ALIPAY_SELLER_ID='<绑定商家PID>'
ALIPAY_PRIVATE_KEY='<Java PKCS8应用私钥,不公开>'
ALIPAY_PUBLIC_KEY='<支付宝公钥>'
ALIPAY_GATEWAY='https://openapi-sandbox.dl.alipaydev.com/gateway.do'
ALIPAY_NOTIFY_URL='https://<当前可达域名>/api/v1/payment-notifications/alipay'

这些占位值不能直接用于运行。真实配置只放被 Git 忽略的 .env 或环境变量,.env 权限设为 600。

当前代码生成 App 参数时要求有效格式的 HTTPS notifyUrl。格式合法仍不代表公网能够访问,联调时还需要验证实际回调可达性。

23.3 项目提供显式的手动验收工具

# 先准备现有中间件并通过完整质量门禁。
./scripts/verify.sh

# 单独终端启动仅通知转发器,默认转发至本机验收应用 8185。
python3 scripts/alipay-notify-relay.py

# 另行把 HTTPS 隧道指向 127.0.0.1:8191,配置 ALIPAY_NOTIFY_URL。
# 然后启动真实沙箱验收应用与本机控制页。
ALIPAY_SANDBOX_ACCEPTANCE=true ./scripts/alipay-sandbox-acceptance.sh

手动验收还要准备控制台提供的沙箱买家信息;创建待付款渠道交易的工具操作使用 ALIPAY_SANDBOX_BUYER_ID

工具创建测试库中的随机 schema,使用固定 0.01 元模拟订单。业务应用监听本机 8185,验收控制页监听本机 8186;SandboxAcceptance 位于测试源码,不打包进业务可执行 JAR。

浏览器打开 http://127.0.0.1:8186/checkout,在明确的沙箱收银台使用沙箱买家完成付款。

可以读取状态、发起订单取消,再等待真实退款确认:

curl http://127.0.0.1:8186/status

curl -X POST \
  -H 'X-Han-Menu-Probe: local' \
  http://127.0.0.1:8186/cancel

curl http://127.0.0.1:8186/status

只有订单和退款最终状态符合预期,才算这段业务完成,不能只看 /cancel 有没有返回 200。

工具的 /create-channel 用于未付款关单验收,/new 用于建立下一笔模拟订单;它们同样要求本机控制请求头。

结束时使用工具正常停止入口:

curl -X POST \
  -H 'X-Han-Menu-Probe: local' \
  http://127.0.0.1:8186/stop

正常停止会关闭上下文并清理本次随机 schema。之后还要停止临时转发器与隧道,移除失效的临时通知地址。

23.4 只让通知路径出现在公网

flowchart LR
  A[支付宝沙箱通知] --> H[临时HTTPS入口]
  H --> R[127.0.0.1:8191 通知转发器]
  R --> N[127.0.0.1:8185 验签通知控制器]
  C[本机验收控制页 8186] --> APP[隔离测试应用]

临时入口指向受限转发器,不指向整个 Spring Boot 应用,也不指向验收控制页。

转发器只转发精确通知 POST,最终仍由真实后端验签。隧道提供可达性,不提供业务上的付款证明。

临时域名会失效。文章不提供本次运行的真实临时 URL,也不会把它当作读者可以长期使用的部署配置。

24. P5 完成之后,哪些能力仍应诚实地标为后续工作

P5 提交验收记录为全量 119 项测试通过,零失败、零跳过。其中既包含前几阶段回归,也包含新增领域、RSA2 协议适配及真实数据库测试;不能把所有测试都称为真实支付宝交易测试。

当前还没有:

  • Flutter 工程、App SDK 真机调用和 Android/iOS 平台差异验收。
  • 生产商户、生产资金、证书模式或其他支付渠道适配。
  • 部分退款、多个退款分摊、优惠券与复杂费用退款规则。
  • 任意顾客退款金额端点或完整退款运营后台。
  • 完整人工处理队列、告警体系、退款重试终止策略和大规模调度优化。
  • P6 的来单通知、经营统计与报表。

这些边界不会削弱已经实现的闭环。相反,它们让读者知道何时可以复用当前设计,何时需要重新定义业务规则。

练习一:关闭响应丢失

关单接口已经执行成功,但本地只拿到超时。下一步应该直接取消订单,还是继续查单?请画出一条能够在进程重启后恢复的路径。

参考思路:持久化取消意图,保留 CANCELLING,通过原支付号继续确认;本地异常不能替代渠道事实。

练习二:删除处理中状态

如果只保留 UNPAID、PAID、CANCELLED,会把“等待关单”和“等待退款”的信息放在哪里?会不会导致客户端把未确认结果误解为已完成?

练习三:收到两次退款成功事件

第一次已把订单取消,但完成登记前进程退出。第二次事件到达时,应该依据什么避免重复业务?请结合 refundId、refundStatus 与订单终态回答。

练习四:支持部分退款

原来的 UNIQUE(payment_id) 是否还适用?如何约束已确认退款与仍在处理中的退款总额?不再全额退款后,当前金额等式又该怎样调整?

练习五:提高并发处理能力

如果渠道偶尔耗时超过六十秒,时间窗口能否保证只有一个 HTTP 请求在执行?请区分“减少重复领取”“外部请求幂等”“结果幂等”和“严格租约排他”四个概念。

练习六:App 返回成功,服务端仍然 PENDING

请设计客户端展示与恢复流程:允许再次查单、显示处理中、查询服务端订单状态,但不能新增一个“由 App 通知支付成功”的可信写入口。

后续 P6 可以在已经确认的订单事实之上处理来单通知与经营统计。它们同样需要明确事件含义和消费幂等,而不是通过跨模块查询任意业务表来拼接结果。

延伸阅读

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第5篇 · 下一篇:第7篇