From c46ef64cd3cc90da78bc7946a9a0666d035a680e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 17:23:41 +0800
Subject: [PATCH] fix(payment): 取消意图落库自动落地,对账间隔回到设计的一分钟
---
talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql | 11 ++
talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql | 27 +++++
talent-card-server/src/main/resources/application.yml | 9 +
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java | 67 +++++++++++++
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md | 47 +++++++++
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java | 21 +++
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java | 22 +++-
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java | 13 ++
.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md | 1
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java | 32 ++++++
10 files changed, 238 insertions(+), 12 deletions(-)
diff --git a/.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md b/.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
index d1a8c19..d74cce7 100644
--- a/.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
+++ b/.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
@@ -70,3 +70,4 @@
- 2026-09-17 实现完成,状态转 `ready-for-human`。落地要点:`payment_transaction` 新增 `last_reconciled_at`(对账公平轮转 + 退避)与 `close_attempts`(关单重试上限 3);对账取行改为「空值优先、最久未处理优先」,`CLOSING`/`CANCEL_UNCONFIRMED` 走 1 分钟快车道;关单回执四分支按 D7 落地,`CLOSING` 为可等待中间态;支付发起改为**复用优先**(可复用时不查通道),新建前置不变量由 `blockingAttempt` 把关;`cancel` 在未定局时返回 `cancellationPending` 而不是抛错;新增 `POST /api/app/pending-orders/{orderNo}/payment/abandon`;人才端三态渲染、退避轮询与「刷新支付状态」在 `rencaika/pages/pending-payment/`。
- 两处**超出票面但为落地所必需**的改动,请在评审时留意:`PaymentIssueRecorder.resolve` 原先在「同一条问题被反复结掉又开出」时会撞唯一键 `(issue_type, provider, business_no, status)`,已改为先清掉被取代的结论行再落新结论;两个支付用例的「腾窗口」语句也把 `CLOSING` 纳入,否则新状态会把历史残留带进对账窗口、干扰别的用例。
- 2026-09-17 真机回来一个本票没覆盖到的第五支:关单回执说的可能是「**原交易已终态**」(汇付 `23000000`/`10000016`,含义是订单已完成或已关单),而不是「现在不让关」。旧实现把它并入「其余拒绝」,于是那张已经不可能被支付的预支付单被记成 `CANCEL_UNCONFIRMED`(还能付)并继续被 `reusablePrepay` 交给客户端,微信收银台直接报「订单状态异常,支付请求无效」;同时 `giveUpClosing` 只在当前状态是 `CLOSING` 时才结束订单,第一次关单就被拒的流水因此永远结束不了,订单变成一笔既付不了、同一商品也再下不了单的僵尸订单。两支都已按「已终态=不可能再被支付」补齐,回归见 [09-terminal-prepay-zombie-order.md](09-terminal-prepay-zombie-order.md) 的四条用例。
+- 2026-09-17 真机第二轮暴露本票另外两处没兑现的地方(详见 [09-terminal-prepay-zombie-order.md](09-terminal-prepay-zombie-order.md)):①**关单补偿实际五分钟一轮**——`CLOSE_RETRY_MINUTES=1` 只是资格窗口,扫描本身由 `fixed-delay-ms` 决定,默认 300000,于是「关单每分钟重试一次、最多三次」实为「每五分钟一次、最多十五分钟」。间隔默认值改为 60000(注解与 yml 同步),并在 `PaymentSchedulingConfigurationTests` 里钉住「两处默认值一致」+「间隔不得超过快车道的 1 分钟窗口」。②**取消意图没有落库**——本票写的「取消的意图先被接受并记录」当时只落了一个响应字段:关单后来真的关掉时没人去取消那笔订单(`giveUpClosing` 只在到上限时跑),用户点的取消随页面关闭而丢失。新增 `benefit_order.cancel_requested_at`(V151)与对账里的 `cancelOrdersAwaitingCloseConclusion()`,条件与「取消」本身一致(不留未定局尝试、不取消已收款订单)。
diff --git a/.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md b/.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
index a5d7e03..c723f0f 100644
--- a/.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
+++ b/.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
@@ -68,3 +68,50 @@
第二单」是第 1–3 条(服务端状态机)。第 3 条是存量缺陷,只是这次被真机路径踩出来了。
- 客户端没有可编译的反馈回路(本机没有 HBuilderX、没有 uni CLI),所以 `pending-payment.uvue` 的改动
只有代码级推理,真机验收留在上面那两条。
+
+## 2026-09-17 第二轮:修完之后真机仍然「点了取消就卡住」
+
+第一轮部署后真机复测(订单 539 于 17:10、订单 540 于 17:15)又报同一句「点了取消还是卡住」。开发库
+只读核对显示:第一轮的修复**已经生效**,只是还剩两个洞。
+
+**已生效的证据**(订单 539,用户最终付款成功):
+
+| 尝试 | 结果 |
+| --- | --- |
+| `P202609171710318912`(attempt 1) | `CLOSED`、`close_attempts=3`、`23000000 原订单已为终态` |
+| `P202609171713414940`(attempt 2) | `SUCCEEDED` 17:13:51,订单 539 落成待核销 |
+
+旧实现下第一张死凭证会被记成 `CANCEL_UNCONFIRMED` 并一直交回客户端;现在是 `CLOSED`,再点支付时新建了
+**第二张**预支付单(`attempt_no=2`)并付成功——第 1、2 条根因确实修掉了。同一时刻库里两笔存量僵尸
+(`P202609171610554772`、`P202609171613329469`)也走到了「close attempts exhausted」(17:15:06)。
+
+**新发现的两个洞**:
+
+1. **关单补偿实际是五分钟一轮,不是设计说的一分钟**。`CLOSE_RETRY_MINUTES=1` 只是「资格窗口」(距上次
+ 处理满一分钟才再关),而扫描本身由 `fixed-delay-ms` 决定——默认 `300000`。于是「关单每分钟重试一次、
+ 最多三次」实为「每五分钟一次、最多十五分钟」。订单 540 在 17:15:21 拿到「关单处理中」后落在 `CLOSING`,
+ 用户在前台等的一分钟里根本没有任何重试发生。
+2. **取消意图没有落库**。`cancel` 在关单没结论时只回一句 `cancellationPending`,订单不动;而关单**后来真的
+ 关掉**时(走 `closePayment` 的成功/已终态分支)没有任何人去取消那笔订单,`giveUpClosing`(唯一会取消
+ 订单的地方)只在重试到上限时才跑。用户点的取消随着页面关掉就丢了,订单还是「待支付」——他只能再点一次
+ 取消(这一次会立刻成功,因为已经没有未定局尝试),或者等 15 分钟超时被清理。
+
+**第二轮的修复**:
+
+- `benefit_order.cancel_requested_at`(V151,开发库等价 DDL 已实测落列):`cancel` 在关单没结论时把受理
+ 时间落库;对账每轮先查单/关单、再执行 `cancelOrdersAwaitingCloseConclusion()`——每笔尝试都有结论、
+ 且没有 `SUCCEEDED` 支付时,替用户把订单收成已取消。
+- 对账间隔默认值 `300000 → 60000`(注解与 yml 两处同步)。慢车道每行仍有 `pending-after-minutes` 与
+ `last_reconciled_at` 双重退避,同一行不会因为扫描变密而被重复查询;快车道这才真的做到「一分钟一轮」。
+ `PaymentSchedulingConfigurationTests` 改为断言「注解默认值与配置默认值一致」并额外钉住这个 1 分钟的
+ 约束——它同时是关单补偿的重试间隔。
+
+**第二轮验收**
+
+- [x] 回归:`PendingPaymentConfirmationTests.cancellingWhileTheCloseIsUndecidedRecordsTheIntentAndWaits`
+- [x] 回归:`PendingPaymentConfirmationTests.theRecordedCancelLandsOnceTheCloseHasAConclusion`
+- [x] 回归:`PendingPaymentConfirmationTests.aRecordedCancelNeverCancelsAnOrderWhoseMoneyWasCollected`
+- [x] 迁移在 5.7(开发库,`scripts/apply-sql-file.ps1`)与 8.0(测试库 Flyway)上都执行过。
+- [ ] 真机:点取消 → 「取消处理中」→ 一分钟内自动进入「已取消订单」页(不再需要再点一次取消)。
+- [ ] 真机:关单中那一分钟里点「刷新支付状态」应能拿到结论(而不是一直 支付处理中)。
+
diff --git a/talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql b/talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql
new file mode 100644
index 0000000..0b788c1
--- /dev/null
+++ b/talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql
@@ -0,0 +1,11 @@
+-- 开发库(局域网共享的 talent_card,MySQL 5.7,Flyway 关闭)手工补列。
+--
+-- 与 V151__order_cancel_request.sql 等价,只是不含存储过程(JDBC 无法直接执行 DELIMITER 批处理),
+-- 所以只能执行一次:重复执行会在「列已存在」上报错,这是安全的失败。
+--
+-- 执行方式:
+-- cd talent-card-server
+-- .\scripts\apply-sql-file.ps1 -File scripts\dev-schema-v151-order-cancel-request.sql -Yes
+
+alter table benefit_order
+ add column cancel_requested_at datetime default null comment '人才端点过取消、等关单结论的时间';
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java
index 780f541..3813516 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java
@@ -21,7 +21,18 @@
@Value("${app.payment.huifu.reconciliation.pending-after-minutes:5}") private int pendingAfterMinutes;
public PaymentReconciliationScheduler(PaymentReconciliationService service) { this.service = service; }
- @Scheduled(fixedDelayString = "${app.payment.huifu.reconciliation.fixed-delay-ms:300000}")
+ /**
+ * 对账节奏:一分钟一轮。
+ *
+ * <p>这个间隔决定的是**关单补偿的下限**,不只是「多久查一次」:通道答「关单处理中」的尝试落在
+ * {@code CLOSING},用户此刻正看着页面等结论,而关单重试的资格条件是「距上次处理满一分钟」——
+ * 扫描本身若五分钟才跑一次,设计里那句「关单每分钟重试一次、最多三次」就变成「每五分钟一次、
+ * 最多十五分钟」,用户看到的就是「点了取消一直卡着」。
+ *
+ * <p>调快不会加重通道负担:慢车道(未定局的支付与退款)每行都带 {@code pending-after-minutes}
+ * 与 {@code last_reconciled_at} 双重退避,同一行不会因为扫描变密而被重复查询。
+ */
+ @Scheduled(fixedDelayString = "${app.payment.huifu.reconciliation.fixed-delay-ms:60000}")
public void reconcilePendingTransactions() {
if (!enabled) return;
try {
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
index 168415c..b79e4b9 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -66,7 +66,37 @@
public Map<String, Object> reconcile(int pendingAfterMinutes) {
int paymentChecked = reconcilePayments(pendingAfterMinutes);
int refundChecked = reconcileRefunds(pendingAfterMinutes);
- return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
+ // 关单结论回来之后,替已经点过取消的用户把订单收掉。放在查单/关单之后:同一轮里先把这次尝试
+ // 问出结论,再决定这笔订单能不能结束。
+ int cancelledOnRequest = cancelOrdersAwaitingCloseConclusion();
+ return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked,
+ "cancelledOnRequest", cancelledOnRequest);
+ }
+
+ /**
+ * 落地「用户点过取消、但当时关单还没有结论」的订单。
+ *
+ * <p>取消在通道说「关单处理中」时只能先受理(见 {@code PendingOrderPortalService.cancel}),订单要等
+ * 每笔尝试都有了结论才能结束。那之后必须有人替用户把这一步走完:关单关掉了就取消,通道说钱收下了就
+ * 交给结算把它翻成已支付——否则用户点的取消会随着页面关掉而丢失,订单一直挂在「待支付」,他只能再点
+ * 一次取消,或者等到 15 分钟超时被清理收掉。
+ *
+ * <p>条件与「取消」本身一致:不留未定局的尝试(它们还可能被支付),也不取消已经收到钱的订单。
+ */
+ private int cancelOrdersAwaitingCloseConclusion() {
+ return jdbc.update("""
+ update benefit_order o set o.order_status = 2
+ where o.order_status = 0 and o.deleted = 0 and o.cancel_requested_at is not null
+ and not exists (
+ select 1 from payment_transaction p
+ where p.order_id = o.id
+ and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ )
+ and not exists (
+ select 1 from payment_transaction p
+ where p.order_id = o.id and p.status = 'SUCCEEDED'
+ )
+ """);
}
/** What the provider said about the payments of one order whose local state is still undecided. */
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
index 99c8f5f..cf782fc 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
@@ -246,9 +246,13 @@
if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消");
if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
// 关单没有结论(通道答「处理中」或干脆没答上来):这次「支付结果不确定」只属于它所属的
- // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受并记录,订单本身
- // 不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就
+ // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受**并落库**,订单
+ // 本身不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就
// 翻成已支付并告知用户)。页面按「取消处理中」渲染。
+ //
+ // 意图必须落库:结论可能几分钟后才回来(关单补偿在后台跑),只把 cancelPending 交给客户端
+ // 等于赌页面一直开着。旧实现就是这样,结果关单真关掉之后没人收那笔订单,用户点的取消丢了。
+ recordCancelRequest(orderNo, talentId);
return cancellationPending(orderNo, talentId, confirmation.paymentNo());
}
// 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。
@@ -289,6 +293,19 @@
return result;
}
+ /**
+ * 把「用户点了取消」落库,等关单有了结论由对账替他把订单收掉。
+ *
+ * <p>只写第一条时间戳:用户反复点取消不该把受理时间往后推,那是「他最早什么时候要取消」。
+ */
+ private void recordCancelRequest(String orderNo, Long talentId) {
+ jdbcTemplate.update("""
+ update benefit_order set cancel_requested_at = now()
+ where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0
+ and cancel_requested_at is null
+ """, orderNo, talentId);
+ }
+
/** 单个订单的到期结束:条件写在 SQL 里,跨实例只有一个赢家。未定局的支付尝试照上面同样的理由排除。 */
private void expireOrder(String orderNo, Long talentId) {
jdbcTemplate.update("""
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index bb759e5..926f961 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -96,12 +96,15 @@
# pending-after-minutes 就问一次通道,通道确认收款就落成本地已支付,对不上账的记一条问题记录。
# 关掉它就只剩「有人点了取消/重申支付」这一个补救入口,回调一丢订单会一直停在待支付。
# fixed-delay-ms 写进配置(注解里也有同样的默认值):间隔不该只靠注解默认值生效。
- # 本机开发要更快看到收敛时,用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 把间隔调短(例如 30000),
- # 或者在后台订单列表点「立即对账」人工触发一次——它跑的是同一套真实对账逻辑,不是模拟支付。
+ # 默认一分钟:它同时是关单补偿的重试间隔——通道答「关单处理中」的那笔尝试要靠扫描才会被重试,
+ # 扫描慢了,「关单每分钟重试一次、最多三次」就变成十几分钟,用户在前台看到的就是一直卡着。
+ # 慢车道每行另有 pending-after-minutes 与 last_reconciled_at 退避,调快不会重复打通道。
+ # 要更慢(例如生产上想省通道调用)用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 覆盖;
+ # 要立刻收敛,在后台订单列表点「立即对账」——它跑的是同一套真实对账逻辑,不是模拟支付。
reconciliation:
enabled: ${HUIFU_RECONCILIATION_ENABLED:true}
pending-after-minutes: ${HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES:5}
- fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000}
+ fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:60000}
merchant-onboarding-notify-url: ${HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-onboarding-notify}
merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify}
merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify}
diff --git a/talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql b/talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql
new file mode 100644
index 0000000..a197477
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql
@@ -0,0 +1,27 @@
+-- 「取消意图」要能跨请求活到关单结论回来的那一刻。
+--
+-- 用户在通道说「关单处理中」时点了取消,服务端当次只能回一句「取消处理中」——真正该取消的时机是
+-- 几分钟后关单有了结论。旧实现没有把这次意图落库,于是关单真的关掉之后没有任何人去收那笔订单:
+-- 用户点的取消丢了,订单还是「待支付」,页面上看着就是「点了取消还是卡住」,只能再点一次取消,
+-- 或者等到订单 15 分钟过期被清理收掉。
+--
+-- cancel_requested_at 记的是「这次取消请求被受理的时间」,由对账在「每笔尝试都有结论」之后落地
+-- (见 PaymentReconciliationService.cancelOrdersAwaitingCloseConclusion)。
+--
+-- 与 V149 同样的理由,这一列必须同样落到开发库:开发库(局域网共享的 talent_card)Flyway 关闭、
+-- 结构不由迁移维护,只写迁移会让开发库缺列,取消接口直接以 Unknown column 500。
+-- 等价的纯 DDL 在 scripts/dev-schema-v151-order-cancel-request.sql。
+DROP PROCEDURE IF EXISTS add_order_cancel_request_column;
+DELIMITER //
+CREATE PROCEDURE add_order_cancel_request_column()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'benefit_order'
+ AND column_name = 'cancel_requested_at') THEN
+ ALTER TABLE benefit_order
+ ADD COLUMN cancel_requested_at DATETIME DEFAULT NULL COMMENT '人才端点过取消、等关单结论的时间';
+ END IF;
+END //
+DELIMITER ;
+CALL add_order_cancel_request_column();
+DROP PROCEDURE add_order_cancel_request_column;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java
index 4762c1c..7d5ab2e 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java
@@ -4,6 +4,8 @@
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import org.junit.jupiter.api.Test;
@@ -28,16 +30,14 @@
String yml = configurationFile();
assertThat(yml)
.as("对账间隔要写进配置,不能只靠注解默认值")
- .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000}");
+ .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:")
+ .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}");
assertThat(yml)
.as("交易确认补偿的开关要真的存在且可读")
.contains("enabled: ${FUND_CONFIRMATION_RETRY_ENABLED:true}");
assertThat(yml)
.as("悬账自动退款的开关要真的存在且可读")
.contains("enabled: ${STRANDED_REFUND_ENABLED:true}");
- assertThat(yml)
- .as("定时对账的开关默认与代码一致(删掉配置行不会让兜底静默消失)")
- .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}");
}
@Test
@@ -47,7 +47,12 @@
.isEqualTo("${app.payment.huifu.reconciliation.enabled:true}");
assertThat(PaymentReconciliationScheduler.class.getMethod("reconcilePendingTransactions")
.getAnnotation(Scheduled.class).fixedDelayString())
- .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:300000}");
+ .as("注解默认值必须与配置里的默认值一致:两边不一致时,删掉配置行就会悄悄改变行为")
+ .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:" + configuredReconciliationDelay() + "}");
+ assertThat(configuredReconciliationDelay())
+ .as("扫描间隔同时是关单补偿的重试间隔(快车道的资格窗口是 1 分钟,见 CLOSE_RETRY_MINUTES):"
+ + "比它长,设计里那句「关单每分钟重试一次、最多三次」就不成立,用户看到的是点了取消一直卡着")
+ .isEqualTo("60000");
}
@Test
@@ -80,6 +85,13 @@
return field.getAnnotation(Value.class).value();
}
+ /** 配置里写的对账间隔默认值(毫秒),供「两处默认值必须一致」的断言使用。 */
+ private String configuredReconciliationDelay() throws Exception {
+ Matcher matcher = Pattern.compile("HUIFU_RECONCILIATION_FIXED_DELAY_MS:(\\d+)").matcher(configurationFile());
+ assertThat(matcher.find()).as("配置里要给出对账间隔的默认值").isTrue();
+ return matcher.group(1);
+ }
+
private String configurationFile() throws Exception {
try (var stream = getClass().getResourceAsStream("/application.yml")) {
assertThat(stream).as("application.yml 必须在类路径上").isNotNull();
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
index 816097f..d47310a 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -472,6 +472,73 @@
}
@Test
+ void cancellingWhileTheCloseIsUndecidedRecordsTheIntentAndWaits() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ // 通道答「关单处理中」:取消只能先受理。
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "P", "resp_code", "00000100", "resp_desc", "关单处理中", "huifu_id", huifuId)));
+
+ assertThat(pendingOrderService.cancel(orderNo, talentId)).containsEntry("cancellationPending", true);
+
+ assertThat(jdbc.queryForObject("select cancel_requested_at from benefit_order where order_no = ?", Object.class, orderNo))
+ .as("取消的意图必须落库:结论几分钟后才回来,只把 cancelPending 交给客户端等于赌页面一直开着")
+ .isNotNull();
+
+ // 尝试还没结论时不能取消:那张预支付单还可能被付进来(关单补偿这一轮同样只拿到「处理中」)。
+ reconcileOnce(paymentNo);
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("未定局的尝试还可能收钱,这时候取消会把已收下的钱变成没有出口的悬账")
+ .isZero();
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSING");
+ }
+
+ @Test
+ void theRecordedCancelLandsOnceTheCloseHasAConclusion() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "P", "resp_code", "00000100", "resp_desc", "关单处理中", "huifu_id", huifuId)));
+ assertThat(pendingOrderService.cancel(orderNo, talentId)).containsEntry("cancellationPending", true);
+
+ // 通道在补偿那一轮改口:关单成功。
+ clearInvocations(paymentGateway);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+ when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P", "huifu_id", huifuId)));
+
+ reconcileOnce(paymentNo);
+
+ assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+ .isEqualTo("CLOSED");
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("关单关掉之后,用户点过的取消要自动落地——否则他点的取消丢了,订单还挂在待支付")
+ .isEqualTo(2);
+ }
+
+ @Test
+ void aRecordedCancelNeverCancelsAnOrderWhoseMoneyWasCollected() {
+ String orderNo = pendingOrder();
+ String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED");
+ pendingPayment(orderNo, 2000L, "SUCCEEDED");
+ jdbc.update("update benefit_order set cancel_requested_at = now() where order_no = ?", orderNo);
+ when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+ "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+
+ reconcileOnce(paymentNo);
+
+ assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+ .as("已经收到的钱比用户的取消意图更重要,取消它等于把真钱变成悬账")
+ .isZero();
+ }
+
+ @Test
void theCloseCapNeverEndsAnOrderWhoseMoneyWasAlreadyCollected() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED");
--
Gitblit v1.7.1