从零重构外卖系统(六):接入支付宝沙箱,怎样把支付、退款和订单履约做成可恢复的业务流程
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · P5
面向读者:已经理解订单快照、幂等键和数据库事务,希望进一步掌握外部支付接入、可靠事件、状态机与失败恢复的开发者。
本篇依据 P5 提交
ecb4228编写,承接 P4 提交4142888。代码直接摘录实际关键实现;注明省略的片段不是独立完整文件。文中区分已经实现的行为、沙箱验收事实与后续演进方向。当前只接入支付宝沙箱,不使用生产资金。后端提供 App 支付签名参数,真实交易验收使用测试工具中的网页沙箱收银台。Flutter 工程与 App SDK 真机端到端联调仍属于 P7。
1. 订单已经保存成功,接下来能否直接把状态改为已付款
P4 解决了订单在本地数据库中的一致性:检查身份、地址、门店和商品,保存不可变快照,再原子结算购物车。
支付开始后,业务跨出了这个数据库。
有些请求会成功,有些请求会失败,还有一类最重要的情况:我们暂时不知道请求有没有成功。
例如:
- 服务器向支付宝发起退款。
- 支付宝已经执行退款。
- 返回服务器的网络连接中断。
- 本地只得到一个超时异常。
此时,“没有收到成功响应”和“退款没有发生”是两件事。
如果换一个退款号再退一次,可能产生重复业务;如果直接标记退款失败,又可能误导顾客和后续处理。
因此本篇的主线是:先保存要完成的业务意图,再确认外部已经发生的事实;遇到不确定性时保留可恢复状态。
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);
}
也就是:
不会因为顾客在第十四分钟才点击支付,就重新得到十五分钟。
支付意图会保存同一个截止时间。参数重新签发也使用这个时间,避免重试不断延长原购买约定。
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 数据库回滚不能撤销外部世界
下面是一段错误思路的伪代码,不是项目实现:
开始数据库事务
锁订单
请求外部支付或退款
更新本地状态
提交数据库事务
这里至少有两个问题:
- 网络等待期间持续占用数据库连接和锁。
- 渠道已经成功、本地事务却失败时,本地回滚无法撤销渠道动作。
更大的事务没有把两个系统变成一个原子操作。
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 的结果,要求:
还要有非空渠道交易号。如果已经绑定了一个交易号,后续结果必须仍然对应它。
不能把“签名是真的”与“这笔钱属于眼前这张支付单”混为一谈。
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 就立刻认为“这笔支付永远不会发生”,判断依据是不够的。
本项目的明确策略是:
前面 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,也没有退款金额和退款请求号。
如果代码在检查退款状态之前,就要求返回原交易号和退款号,会发生什么?
- 本地已经有 PENDING 退款意图。
- 工作器先查询退款。
- 返回成功码,但尚无退款记录。
- 代码因为“缺少交易字段”抛错。
- 工作器不断重试查询,始终没有机会发起第一笔退款。
问题出在把“查询接口成功执行”误认为“查询到了目标业务事实”。
修正后的实际方法:
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,没有时返回未确认;存在明确成功记录后,才严格核对交易、退款标识及金额。
当前是全额退款,因此还要求:
这段代码不能不加修改地用作部分退款方案。
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 验收记录:
- 使用配置好的沙箱应用完成签名查单。
- 创建真实待付款渠道交易,完成主动关单与订单取消。
- 使用沙箱买家余额完成 0.01 元模拟支付。
- 接收真实 HTTPS 异步通知,后端验签后返回 success。
- 本地支付变为 SUCCEEDED,订单变为 PAID;主动查询也确认了金额与付款时间。
- 修正首次空退款查询后,再次用新交易完成自动全额退款及查询确认。
- 最终订单为 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 外卖系统实践 · 从第一篇开始