fix(payment): 原交易已终态的预支付单不再交回客户端,关单上限按尝试而非状态收尾

真机反馈的三件事是同一串缺陷:「取消支付后待支付页每秒闪一次」「卡在支付处理中、刷新没反应」
「同一商品再下单时收银台报『订单状态异常,支付请求无效』」。开发库只读核对确认:
订单 P202609171613326977 的尝试 P202609171613329469 停在 CANCEL_UNCONFIRMED、
close_attempts=3、通道回执 23000000「原订单已为终态」,而订单一直是待支付。

- closePayment 补上第四支之外的第五支:汇付 23000000/10000016 说的是「原交易已终结」
(订单已完成或已关单),不是「现在不让关」。旧实现把它并入「其余拒绝」,于是那张已不可能
被支付的预支付单被记成「关不掉=可能还能付」并继续被 reusablePrepay 交给客户端。
现在落成 CLOSED 并结掉 PAYMENT_CLOSE_UNCONFIRMED。
- retryClose 到上限改为先 watchOnly(钱真来了先落成已支付)再 giveUpClosing;
giveUpClosing 不再按「流水当前是不是 CLOSING」决定要不要结束订单——第一次关单就被拒的
流水直接落在 CANCEL_UNCONFIRMED,旧写法让它永远结束不了(既付不了,也不被到期清理收掉,
同一商品再也下不了单)。订单收尾加一道「已有 SUCCEEDED 支付就不取消」的护栏。
- 人才端待支付页把「读到尝试已结束」收敛成 onPaymentAttemptClosed:只收掉等待状态、
最多重读一次订单,用户动一次手重置一次预算,切断 loadOrder ↔ checkPaymentStatus 环路
(那就是「每秒闪一次」和「刷新没反应」)。

回归:PendingPaymentConfirmationTests 新增四条(已终态落成已关闭、死凭证不再复用、
上限结束未经过 CLOSING 的订单、已收款订单不被上限取消)。全量 366 项,仅 testing.md
记录的 3 项基线失败。诊断与证据见 .scratch/pending-order-payment-flow/issues/09。
1个文件已添加
4个文件已修改
234 ■■■■■ 已修改文件
.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 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-payment/pending-payment.uvue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
@@ -69,3 +69,4 @@
- 2026-09-17 发布。规格见 [../spec.md](../spec.md) 的 D1–D10、D14–D16、D34–D37;其一票之内的执行顺序与验证方式见 [../plan.md](../plan.md) 的第一刀。
- 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) 的四条用例。
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
New file
@@ -0,0 +1,70 @@
# 09 已终态的预支付单被当成「还能付」,订单挂成僵尸
Status: ready-for-human
来源:2026-09-17 真机反馈(人才端「两分钱肉松饼」¥0.02)。现象有三个,根因是同一串状态机缺陷。
## 现象
1. 点支付 → 在微信收银台里关掉 → 回到待支付页,页面**每秒闪一次**(内容被反复换成「正在加载订单」)。
2. 反复「点支付再取消」几次后页面停在**支付处理中**,「刷新支付状态」没有反应;点「取消订单」同样落到
   这个状态;过一两分钟订单才真的变成已取消。
3. 期间在同一件商品上再下单,拉起收银台时微信弹「订单状态异常,支付请求无效」;只有等旧单真的被取消
   掉,才能在同一件商品上再下一单。
## 证据(开发库 `talent_card`,只读)
- 订单 `P202609171613326977`(id 534):`order_status=0` 一直没变,`expires_at` 16:28:32。
- 它的支付尝试 `P202609171613329469`(id 205):`status=CANCEL_UNCONFIRMED`、`close_attempts=3`、
  `provider_response_code=23000000`、`provider_response_message=原订单已为终态,无法发起关单操作`、
  `closed_at=16:13:36`,且 `last_reconciled_at` 每分钟还在被刷新——对账在它身上空转。
- 同一形态的订单 532 在 16:12:28 被结束(问题记录「close attempts exhausted」,16:19:59 被 resolved):
  它当时还停在 `CLOSING`,所以撞上了 `giveUpClosing` 里那唯一一条结束订单的路径。534 没撞上。
- 汇付错误码文档:`23000000` = 「原订单已为终态」/「关单状态为终态」(**订单已完成或已关单**),
  `10000016` = 「原订单已为终态,请发起查询交易获取」。这两句不是「现在不让关」,而是「它已经结束了」。
- 回调事件表里 `P202609171613329469` 在 16:13:45 有一条 `PENDING_QUERY` 事件:通道确实就这笔尝试
  来过通知,只是本地没有据此收敛。
## 根因
1. **关单回执「原交易已终态」被并入「其余拒绝」**(`PaymentReconciliationService.closePayment`):汇付
   说的是「这张预支付单已经不可能被支付」,我们记成了 `CANCEL_UNCONFIRMED`(关不掉=可能还能付)并留
   一条 open 问题记录。
2. **`reusablePrepay` 把这张死掉的预支付单连同支付参数交回客户端**(`HuifuPaymentService`):微信收银台
   拿到一个已终态的 `prepay_id`,弹的就是「订单状态异常,支付请求无效」。
3. **关单重试到上限只结束「当时还停在 `CLOSING`」的订单**(`retryClose` 的分支 + `giveUpClosing` 的判断):
   流水第一次关单就被拒时直接落在 `CANCEL_UNCONFIRMED`,到上限后既没有 `giveUpClosing`,也不会被到期
   清理收掉(未定局的尝试被排除在自动取消之外)。于是订单永远是「待支付」:既付不了(同一订单不允许
   再建预支付单、复用又拿到死凭证),同一商品也再下不了单(`findReusableOrder` 仍然复用它)。
4. **客户端 `loadOrder ↔ checkPaymentStatus` 互相触发**(`pending-payment.uvue`):`checkPaymentStatus`
   读到 `CANCELLED` 就重读订单,而读订单又会查一次支付状态——无限循环,每轮都把内容换成加载态,用户看到
   的就是「每秒闪一次」;「刷新支付状态」只是又进同一圈,所以看着「没反应」。
## 修复
- `closePayment` 增加「原交易已终态」这一支(`CloseResult.originalTradeAlreadyTerminal`,码
  `23000000`/`10000016`):落成 `CLOSED` 并结掉 `PAYMENT_CLOSE_UNCONFIRMED`。走到这里的「取消」与
  「关单补偿」都是先查单、通道说过这笔钱没收下才来关单的;钱真收下了会有异步通知结算(迟到的成功记成
  「取消后才收到钱」,交给悬账退款与人工)。
- `retryClose` 到上限时先 `watchOnly`(钱真的来了先落成已支付)再 `giveUpClosing`;`giveUpClosing` 不再
  按「当前是不是 `CLOSING`」来结束订单,而是按「这次尝试到上限了」,并加一道「这笔订单上已有
  `SUCCEEDED` 支付就不许取消」的护栏。
- 客户端 `onPaymentAttemptClosed()`:读到尝试已结束就只把等待状态收掉、最多重读一次订单;用户动一次手
  (继续支付/取消订单/刷新支付状态)重置一次预算,环路从「无限」变成「每轮最多一次」。
## 验收
- [x] 回归:`PendingPaymentConfirmationTests.abandoningAPrepayTheProviderReportsAsTerminalMarksItClosed`
- [x] 回归:`PendingPaymentConfirmationTests.aPrepayTheProviderReportsAsTerminalIsNotHandedBackToTheClient`
- [x] 回归:`PendingPaymentConfirmationTests.theCloseCapEndsTheOrderEvenWhenTheAttemptNeverSatInClosing`
- [x] 回归:`PendingPaymentConfirmationTests.theCloseCapNeverEndsAnOrderWhoseMoneyWasAlreadyCollected`
- [ ] 真机:点支付 → 关掉收银台 → 页面不再每秒闪;点「取消订单」当轮就进「已取消订单」页。
- [ ] 真机:同一商品在旧单被取消后立刻能再下一单,且收银台不再弹「订单状态异常,支付请求无效」。
- [ ] 主控台里 `P202609171613326977`(订单 534/尝试 205)在部署新版后由对账收尾成已取消。
## Comments
- 2026-09-17 诊断并修复。原报告的「闪烁」是第 4 条(客户端环路),「卡在支付处理中」与「同商品下不了
  第二单」是第 1–3 条(服务端状态机)。第 3 条是存量缺陷,只是这次被真机路径踩出来了。
- 客户端没有可编译的反馈回路(本机没有 HBuilderX、没有 uni CLI),所以 `pending-payment.uvue` 的改动
  只有代码级推理,真机验收留在上面那两条。
rencaika/pages/pending-payment/pending-payment.uvue
@@ -128,6 +128,9 @@
    let paymentPollingElapsedMs = 0
    let currentPaymentNo = ''
    let expiryCancellationRequested = false
    // 读到「这次支付尝试已经有结论」之后是否已经重读过订单。用户每动一次手都重置,
    // 用来把「读订单 → 查支付状态 → 再读订单」这条环路限制在一轮以内。
    let reloadedAfterClosedAttempt = false
    // 从「确认支付」页刚提交过一次支付(waiting=1):打开页面就直接进入等待结论的状态,
    // 而不是先显示一次「继续支付」按钮让用户以为钱没付出去。
    let waitingForSubmittedPayment = false
@@ -252,7 +255,21 @@
        stopPaymentPolling()
        paymentPollingAttempts = 0
        paymentPollingElapsedMs = 0
        reloadedAfterClosedAttempt = false
        checkPaymentStatus(true)
    }
    // 这次支付尝试已经有了结论(预支付单关掉了,或者关不掉但留给对账继续盯),订单本身可能仍是待支付。
    // 这里最多再读一次订单:读到已取消就跳走,读到还待支付就停在「继续支付」上,让用户重新选。
    // 绝不能无条件重读——读订单会再问一次支付状态,两个请求互相触发,页面会一直重载、每秒闪一次,
    // 「刷新支付状态」也就永远刷不出新东西。
    const onPaymentAttemptClosed = () => {
        paymentChecking.value = false
        isSubmitting.value = false
        paymentProcessing.value = false
        cancellationPending.value = false
        if (reloadedAfterClosedAttempt) return
        reloadedAfterClosedAttempt = true
        loadOrder()
    }
    const expireOrder = () => {
        if (expiryCancellationRequested || orderId.value == '' || talentId.value == '') {
@@ -289,6 +306,7 @@
        paymentPollingElapsedMs = 0
        paymentChecking.value = false
        isSubmitting.value = true
        reloadedAfterClosedAttempt = false
        createPendingOrderPayment(orderId.value, talentId.value, priceVersion.value, (data: any) => {
            currentPaymentNo = data != null && data.paymentNo != null ? '' + data.paymentNo : ''
            if (data != null && data.paymentStatus == 'PAID') {
@@ -325,6 +343,7 @@
            success: (result: any) => {
                if (result.confirm !== true) return
                isSubmitting.value = true
                reloadedAfterClosedAttempt = false
                cancelPendingOrder(orderId.value, talentId.value, (data: any) => {
                    isSubmitting.value = false
                    // 关单没有结论时服务端不会取消订单,只把取消意图记下来(cancellationPending):
@@ -411,8 +430,8 @@
            paymentChecking.value = false
            isSubmitting.value = false
            if (data != null && data.paymentStatus == 'CANCELLED') {
                // 本地流水已关闭/关不掉:订单本身可能仍是待支付,重新读一次订单就能拿到最新状态。
                loadOrder()
                // 本地流水已关闭/关不掉:这次尝试结束了,订单本身可能仍是待支付,重读一次订单拿最新状态。
                onPaymentAttemptClosed()
                return
            }
            if (pollWhenPending) uni.showToast({ title: '支付结果还没确认,可点「刷新支付状态」再查一次', icon: 'none' })
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -19,6 +19,7 @@
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * Second confirmation path for payments whose callback did not arrive or whose outcome was unknown.
@@ -221,6 +222,8 @@
     *       这次尝试停在 {@code CLOSING} 等后台重试,并记一条问题记录给人工。</li>
     *   <li>关单处理中:这次尝试停在新状态 {@code CLOSING}(关单中)——它既不能当已关闭(通道还可能
     *       收钱),也不该是终点,做成可等待、可重试的中间态。</li>
     *   <li>原交易已经终结({@code 23000000}/{@code 10000016}):不是「现在不让关」,而是「它已经
     *       结束了」——那张预支付单不可能再被支付,落成已关闭,不再交回客户端。</li>
     *   <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
     *       {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
     *       会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
@@ -267,6 +270,17 @@
            markClosing(transaction, close.code(), close.message());
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        if (close.originalTradeAlreadyTerminal()) {
            // 关单回执说的不是「现在不让关」,而是「原交易已经终结」。此时再关只会一直拿到同一句拒绝
            // (旧实现就把它当成了「关不掉=可能还能付」),而一张死掉的预支付单被交回客户端时,微信
            // 收银台会直接报「订单状态异常,支付请求无效」;订单也会挂成一个既付不了又取消不了的僵尸。
            // 这里落成已关闭:取消那条路与关单补偿都是先查过单、通道说过这笔钱没收下才走到关单的,
            // 「放弃支付」那条路则是用户刚关掉收银台——钱真收下了会有异步通知把它结算掉(迟到的成功
            // 记成「取消后才收到钱」,交给悬账退款与人工),不会无声地消失。
            markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message());
            issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
            return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
        }
        if (close.processing()) {
            issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount,
                    "Provider is still closing the payment");
@@ -281,10 +295,14 @@
     *
     * <p>到上限时通道既没说关掉、也没说钱收下了,而这张预支付单还可能被支付。此时订单不能继续挂着
     * 「待支付」——那样它既付不了(不允许在同一订单上再建一张预支付单)也永远不会被自动取消,变成一笔
     * 僵尸订单。所以这里把订单落成已取消,并把这次尝试留成 {@code CANCEL_UNCONFIRMED}:
     * 订单状态不由自动取消的排除表管理,而是由这次明确的「不再重试」结论结束;
     * 预支付单继续被对账盯着,万一用户后来真的付了,会记成「取消后才收到钱」的问题记录,
     * 僵尸订单:同一件商品再也下不了单。所以这里把订单落成已取消,并把这次尝试留成
     * {@code CANCEL_UNCONFIRMED}:订单状态不由自动取消的排除表管理,而是由这次明确的「不再重试」结论
     * 结束;预支付单继续被对账盯着,万一用户后来真的付了,会记成「取消后才收到钱」的问题记录,
     * 交给悬账自动退款与人工处理,而不是无声地留下没有出口的悬账。
     *
     * <p>这个终止条件按「这次尝试」而不是按它的当前状态来下:流水可能从没经过 {@code CLOSING}
     * (关单第一次就被拒,直接落在 {@code CANCEL_UNCONFIRMED}),只认 {@code CLOSING} 的话这类订单
     * 永远结束不了。
     */
    private void giveUpClosing(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
@@ -297,8 +315,12 @@
        issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close attempts exhausted", amount,
                "Payment could not be closed after " + MAX_CLOSE_ATTEMPTS + " attempts; the prepay stays watchable");
        jdbc.update("""
                update benefit_order set order_status = 2
                where order_no = ? and deleted = 0 and order_status = 0
                update benefit_order o set order_status = 2
                where o.order_no = ? and o.deleted = 0 and o.order_status = 0
                  and not exists (
                    select 1 from payment_transaction p
                    where p.order_id = o.id and p.status = 'SUCCEEDED'
                  )
                """, orderNo);
    }
@@ -334,8 +356,11 @@
                """, status, code, message, paymentNo);
    }
    /** 通道对关单请求的答复。只有「关掉了」和「原交易已成功」是明确结论,其余一律当作没确认下来。 */
    /** 通道对关单请求的答复。只要还没说「关掉了/原交易已成功/原交易已终结」,就当作没确认下来。 */
    private record CloseResult(String code, String message, String status, String originalStatus) {
        /** 汇付「原订单已为终态」的两个返回码:原交易已终结(已关单或已完成),无法再关单。 */
        private static final Set<String> TERMINAL_CODES = Set.of("23000000", "10000016");
        private static CloseResult from(Map<String, Object> response) {
            Map<String, Object> data = response == null ? Map.of()
                    : (response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response);
@@ -350,6 +375,16 @@
        boolean originalTradeSucceeded() {
            return "S".equalsIgnoreCase(originalStatus);
        }
        /**
         * 通道拒绝关单,理由是原交易已经终结。
         *
         * <p>这与「未满一分钟不允许关单」这类拒绝不是一回事:后者说的是「现在不让关」,预支付单还活着;
         * 这一句说的是「它已经结束了」,那张预支付单不可能再被支付。
         */
        boolean originalTradeAlreadyTerminal() {
            return TERMINAL_CODES.contains(code);
        }
        boolean processing() {
@@ -421,9 +456,10 @@
    private void retryClose(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) {
            // 到上限:先给还停在「关单中」的尝试一个终止条件(落成已取消并结束订单),之后只查不关。
            if (CLOSING_STATUS.equals(text(transaction.get("status")))) giveUpClosing(transaction);
            else watchOnly(transaction);
            // 到上限:关单已经问不出更多结论,再打通道只是重复。先查一次(钱真的来了要先落成已支付,
            // 那一支会把订单翻成已支付,下面的收尾就不会再取消它),再给这次尝试一个终止条件。
            watchOnly(transaction);
            giveUpClosing(transaction);
            return;
        }
        issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -59,6 +59,7 @@
    private String huifuId;
    private String suffix;
    private long orderSequence;
    private long paymentSequence;
    @BeforeEach
    void prepareData() {
@@ -73,6 +74,7 @@
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        orderSequence = 0;
        paymentSequence = 0;
    }
    @Test
@@ -369,6 +371,48 @@
    }
    @Test
    void abandoningAPrepayTheProviderReportsAsTerminalMarksItClosed() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
        // 通道的答复是「原交易已终态」而不是「现在不让关」:那张预支付单已经不可能再被支付。
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,无法发起关单操作", "huifu_id", huifuId)));
        reconciliationService.abandonPendingPayments(orderNo, paymentNo);
        verify(paymentGateway, never()).queryMiniProgramPayment(any());
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("通道说原交易已终态:这张预支付单不可能再被支付,本地要落成已关闭而不是「可能还能付」")
                .isEqualTo("CLOSED");
        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo))
                .as("一条已经被证伪的「可能还能付」不该留在问题池里")
                .isZero();
    }
    @Test
    void aPrepayTheProviderReportsAsTerminalIsNotHandedBackToTheClient() {
        String orderNo = pendingOrder();
        String deadPaymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,无法发起关单操作", "huifu_id", huifuId)));
        reconciliationService.abandonPendingPayments(orderNo, deadPaymentNo);
        clearInvocations(paymentGateway);
        // 用户回头再点支付:本地那张凭证已经死了,把它交回去只会让微信收银台报「订单状态异常」。
        when(paymentGateway.createMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "pay_info", "{\"appId\":\"wx-test\",\"timeStamp\":\"2\"}")));
        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "after-terminal-" + suffix);
        assertThat(result).containsEntry("paymentStatus", "PAYMENT_PENDING");
        assertThat(String.valueOf(result.get("paymentNo")))
                .as("交给客户端重新拉起收银台的必须是一张全新的预支付单")
                .isNotEqualTo(deadPaymentNo);
        verify(paymentGateway).createMiniProgramPayment(any());
        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo)).isEqualTo(2);
    }
    @Test
    void thePrepayCloseStopsRetryingAtTheCapAndEndsTheOrderWithAWatcher() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
@@ -402,6 +446,46 @@
        reconcileOnce(paymentNo);
        assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
                .isEqualTo(3);
    }
    @Test
    void theCloseCapEndsTheOrderEvenWhenTheAttemptNeverSatInClosing() {
        // 用户报的僵尸订单:关单第一次就被拒(未满一分钟),流水直接落在「取消未确认」,
        // 重试到上限时它已经不是 CLOSING——旧实现只在那一个状态上结束订单,于是这笔订单
        // 既付不了(同一订单上不许再建预支付单)也永远不会被自动取消(未定局的尝试被排除在
        // 到期取消之外),同一件商品再也下不了单。
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED");
        jdbc.update("update payment_transaction set close_attempts = 3 where payment_no = ?", paymentNo);
        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("关单上限就是这次尝试的终止条件:订单要落成已取消,否则它是一笔永远下不了第二次单的僵尸订单")
                .isEqualTo(2);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("订单结束了,这张预支付单仍要留在盯防名单里:钱真要来了由悬账退款接住")
                .isEqualTo("CANCEL_UNCONFIRMED");
        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isEqualTo(1L);
    }
    @Test
    void theCloseCapNeverEndsAnOrderWhoseMoneyWasAlreadyCollected() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED");
        jdbc.update("update payment_transaction set close_attempts = 3 where payment_no = ?", paymentNo);
        // 同一订单上另一笔尝试已经成功:订单行还没来得及被结算翻成已支付,关单补偿不能把它取消。
        pendingPayment(orderNo, 2000L, "SUCCEEDED");
        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
@@ -582,7 +666,7 @@
    }
    private String pendingPayment(String orderNo, long amount, String status, String clientPaymentPayload) {
        String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence;
        String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence + "-" + (++paymentSequence);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,