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