yeg
2026-09-17 c46ef64cd3cc90da78bc7946a9a0666d035a680e
fix(payment): 取消意图落库自动落地,对账间隔回到设计的一分钟

第一轮修复在真机上已生效(订单 539 的第一张死凭证落成 CLOSED 后,再点支付新建了 attempt 2 并付成功),
但真机复测仍报「点了取消还是卡住」。只读核对开发库后找到本票没兑现的两处:

- 关单补偿实际是五分钟一轮:CLOSE_RETRY_MINUTES=1 只是「距上次处理满一分钟」的资格窗口,扫描本身由
fixed-delay-ms 决定,默认 300000。于是「关单每分钟重试一次、最多三次」实为「每五分钟一次、最多
十五分钟」——订单 540 在 17:15:21 拿到「关单处理中」后,用户等的那一分钟里没有任何重试发生。
间隔默认值改为 60000,注解与 yml 两处同步;慢车道每行仍有 pending-after-minutes 与
last_reconciled_at 双重退避,同一行不会因扫描变密而被重复查询。
- 取消意图没有落库:cancel 在关单没结论时只回 cancellationPending,订单不动;而关单后来真的关掉时
没有任何人去取消那笔订单(giveUpClosing 只在重试到上限时跑)。用户点的取消随页面关闭而丢失,订单
仍挂在待支付,只能再点一次取消或等 15 分钟超时。新增 benefit_order.cancel_requested_at(V151)
记录受理时间,对账每轮在查单/关单之后执行 cancelOrdersAwaitingCloseConclusion()——每笔尝试都有
结论且没有 SUCCEEDED 支付时,替用户把订单收成已取消。

开发库已用 scripts/apply-sql-file.ps1 落列(等价 DDL 见 scripts/dev-schema-v151-order-cancel-request.sql),
测试库由 Flyway 落列。回归三条:取消受理即落库且未定局时不取消、关单有结论后自动落地、
已收款订单绝不被取消意图取消。PaymentSchedulingConfigurationTests 改为断言「注解与配置的默认值一致」
并额外钉住那 1 分钟约束。全量 369 项,仅 testing.md 记录的 3 项基线失败。
2个文件已添加
8个文件已修改
250 ■■■■■ 已修改文件
.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.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()`,条件与「取消」本身一致(不留未定局尝试、不取消已收款订单)。
.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)上都执行过。
- [ ] 真机:点取消 → 「取消处理中」→ 一分钟内自动进入「已取消订单」页(不再需要再点一次取消)。
- [ ] 真机:关单中那一分钟里点「刷新支付状态」应能拿到结论(而不是一直 支付处理中)。
talent-card-server/scripts/dev-schema-v151-order-cancel-request.sql
New file
@@ -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 '人才端点过取消、等关单结论的时间';
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 {
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. */
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("""
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}
talent-card-server/src/main/resources/db/migration/V151__order_cancel_request.sql
New file
@@ -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;
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();
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");