fix(payment): 复用集合精确到「关单没生效」的凭证,点继续支付不再毫无反应

真机复测(订单 P202609171817109793):「点继续支付没反应」,最后只能取消订单。库里全程只有
一条支付尝试,状态 CANCEL_UNCONFIRMED,关单回执是 90000000「交易失败,请重试」——**关单请求本身
失败**,也就是说那张凭证根本没被关掉、还是可付的。

根因是上一轮砍过头:reusablePrepay 把 CLOSING 与 CANCEL_UNCONFIRMED 一起排除了,但这两种状态含义
不同——CLOSING 是「关单可能已生效」(可能死了),CANCEL_UNCONFIRMED 是「通道明确说这次关单没生效」
(拒绝关单/关单请求失败),凭证还活着。把它也排除之后,每次点继续支付都只能走到 blockingAttempt
返回 PAYMENT_PROCESSING,客户端又把按钮禁掉,用户看到的就是「点了没反应」。

- 可复用集合改为 PREPAY_CREATED + CANCEL_UNCONFIRMED;CLOSING 仍然排除(那次关单可能已经生效)。
- 关单查询探针移到复用判断之前:CLOSING 的凭证先问一次关单结论,关掉了就落成已关闭、下面给一张
新的;没生效就复用它。正常复用(PREPAY_CREATED)不额外打通道。
- 客户端 confirmPayment 在「凭证正在作废」时不再直接 return(那等于什么反应都没有),改为给一句
解释并立刻触发一次真实查询(服务端会问关单结论)。

回归一条(关单没生效的凭证必须复用回去),第四轮那三条继续有效:全量 373 项,仅 docs/agents/testing.md
记录的 3 项基线失败。该文件另补一节:测试库跑在 WSL 的 docker 里,WSL 空闲关机会让测试全部报连接失败
(或 Hibernate 的 dialect 报错),恢复方式与「必须挂住 VM」的坑一并记下。
6个文件已修改
131 ■■■■■ 已修改文件
.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 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/agents/testing.md 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-payment/pending-payment.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
@@ -72,3 +72,4 @@
- 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()`,条件与「取消」本身一致(不留未定局尝试、不取消已收款订单)。
- 2026-09-17 第三轮(产品决定,已落 `docs/adr/0003` 的修订):**取消不再等关单结论**。本票原先定的是「取消以关单回执为准」——理由是关单回执同时回答「关掉了没有」和「原交易是不是已成功」。真机复测发现通道对刚创建的微信交易最常见的答复就是「关单处理中」,于是每次取消都要等一个对账周期,用户看到的是「点了取消一直卡着」,而他根本没付过钱。adr/0003 的「考虑过但没选的」里本来就写着这条路:**关单中期间允许直接落成已取消**,前提是「取消后收到钱能自动退回付款人」——这个前提在票 05 的悬账自动退款落地后已经成立,所以改成选它。落地:`cancel` 只在查单确认「钱没被收下」之后立刻生效(查不通也立刻取消),关单改成尽力而为、其结论只决定这次尝试的本地终态;`cancel_requested_at` 降级为审计事实,随之删掉「等关单结论再落地」的对账扫描;代价(偶发「付了又退」,手续费大概率不退)明确记在 ADR 里。
- 2026-09-17 第四/五轮补充(详见 [09-terminal-prepay-zombie-order.md](09-terminal-prepay-zombie-order.md)):本票的「复用优先」需要精确到**凭什么状态可以复用**。可复用集合最终定为 `PREPAY_CREATED`(从没被要求关过)与 `CANCEL_UNCONFIRMED`(要求关过,但通道明确说这次关单没生效——凭证还活着);`CLOSING` 不在其中,因为它可能已经关掉。另外补上了关单的查询对等物(汇付关单查询),支付发起、刷新支付状态、对账快车道都会先问一次结论——用户的等待因此从「等一轮对账」缩短到「通道真正关完的时间」。
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
@@ -174,3 +174,36 @@
- [x] 回归:`PendingPaymentConfirmationTests.askingToPayAgainWaitsOnlyWhileTheProviderIsStillClosingThePrepay`
- [x] 回归:`PendingPaymentConfirmationTests.theReconciliationConcludesAClosedPrepayThroughTheCloseQueryWithoutSendingAnotherClose`
- [ ] 真机:反复「继续支付 → 关掉收银台」时,等待应缩短到通道真正关完的时间(秒级),而不是固定一分钟。
## 2026-09-17 第五轮:「点继续支付没反应」——上一轮把复用规则砍过头了
真机复测(订单 `P202609171817109793`,18:17):点支付 → 关掉收银台 → 再点「继续支付」**没有任何反应**,
最后只能取消订单。库里的证据:
| 字段 | 值 |
| --- | --- |
| 支付尝试 | 全程只有 1 条(`attempt_no=1`),用户点了几次「继续支付」都没有新建 |
| 状态 | `CANCEL_UNCONFIRMED`,`close_attempts=2` |
| 通道回执 | `90000000 交易失败,请重试`(**关单请求本身失败**,不是被拒绝) |
| 订单 | 18:17:51 被用户取消 |
根因是第四轮那条改动砍过头了:`reusablePrepay` 把 `CLOSING` 与 `CANCEL_UNCONFIRMED` 一起排除。但这两者的
含义不同——`CLOSING` 是「关单可能已经生效」(可能死了),而 `CANCEL_UNCONFIRMED` 是**通道明确说这次关单
没生效**(拒绝关单、或关单请求本身失败):凭证还活着、还是可付的,正是最该复用的一张。把它也排除之后,
每次点「继续支付」都只能走到 `blockingAttempt` 返回 `PAYMENT_PROCESSING`,客户端把按钮禁掉,用户看到的就是
「点了没反应」。
修复:
- 可复用集合改为 `PREPAY_CREATED` + `CANCEL_UNCONFIRMED`(`CLOSING` 仍然排除:那次关单可能已经生效)。
- 关单查询探针**移到复用判断之前**:`CLOSING` 的凭证先问一次关单结论,关掉了就落成已关闭、下面给一张新的;
  没生效就复用它。正常复用(`PREPAY_CREATED`)不额外打通道。
- 客户端:`confirmPayment` 在「凭证正在作废」时不再直接 return(那等于什么反应都没有),改成给一句解释并
  立刻触发一次真实查询(服务端会问关单结论)。
**第五轮验收**
- [x] 回归:`PendingPaymentConfirmationTests.askingToPayAgainReusesAPrepayTheCloseNeverTookEffectOn`
- [x] 回归(第四轮保留):作废中的凭证关掉后拿到新凭证、关单仍处理中时只等待不新建、对账不重发关单
- [x] 全量 373 项,仅 `docs/agents/testing.md` 记录的 3 项基线失败
- [ ] 真机:点支付 → 关掉收银台 → 再点「继续支付」应立刻再次拉起收银台
docs/agents/testing.md
@@ -117,6 +117,38 @@
的回退在途)状态。以后新写这类「后台任务只处理了我这条」的用例,照它的做法先腾窗口,不要再依赖测试库的
累积状况。
## 测试库跑不起来时,先看 WSL(2026-09-17 实测)
测试库那个 8.0.36 实例跑在 **WSL 的 docker 里**(发行版 `Ubuntu-26.04`,容器 `talent-card-mysql-1`,
`127.0.0.1:8134->3306`)。WSL 虚拟机会在空闲时自己关掉,容器跟着停——于是测试全部报
```
Unable to obtain connection from database: Communications link failure
Caused by: java.net.ConnectException: Connection refused: getsockopt
```
或者更绕的一句(连接失败被 Hibernate 翻译过之后的表象):
```
Unable to determine Dialect without JDBC metadata (please set 'jakarta.persistence.jdbc.url' ...)
```
判断与恢复:
```bash
wsl.exe -d Ubuntu-26.04 -u root -- docker ps          # 容器在不在
wsl.exe -d Ubuntu-26.04 -u root -- docker start talent-card-mysql-1 talent-card-redis-1
```
**光起容器不够**:跑测试期间要有东西把 VM 挂住,否则命令一返回、VM 又空闲关掉,测试照样撞在停机缝里
(现象是容器每次 `docker ps` 都显示 `Up 2 seconds`)。用一个长命进程持有它,跑完再结束:
```bash
wsl.exe -d Ubuntu-26.04 -u root -- bash -lc "sleep 1800"   # 后台挂着,测试跑完再停
```
本机 Windows 侧没有 mysqld、没有 Docker——**别在 Windows 里找 8.0 实例**,它只在 WSL 里。
## 测试库的状态是共享的
Spring 测试共用一个 schema,测试之间不做清理(没有 `@Transactional` 回滚)。因此:
rencaika/pages/pending-payment/pending-payment.uvue
@@ -296,7 +296,15 @@
        uni.navigateTo({ url: '/pages/coupon-select/coupon-select?source=pending-payment&orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) + '&merchantId=' + encodeURIComponent(merchantId.value) + '&productId=' + encodeURIComponent(productId.value) + '&priceVersion=' + priceVersion.value })
    }
    const confirmPayment = () => {
        if (isSubmitting.value || cancellationPending.value || paymentProcessing.value) return
        if (isSubmitting.value) return
        if (cancellationPending.value || paymentProcessing.value) {
            // 上一张支付凭证正在作废:这时直接返回等于什么反应都没有(用户会一直点、以为按钮坏了)。
            // 服务端每次都会真去问一次关单结论(关单查询),问出来这一秒就能继续付,所以把这次点击
            // 变成一次真实查询,并给一句解释。
            uni.showToast({ title: '正在作废上一次的支付凭证,请稍候再试', icon: 'none' })
            refreshPaymentStatus()
            return
        }
        if (isExpired.value) {
            uni.showToast({ title: '订单已关闭,请重新下单', icon: 'none' })
            return
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -46,6 +46,11 @@
    }
    public Map<String, Object> beginMiniProgramPayment(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
        // 上一张凭证被要求关过(关单中/关单没结论)时,先问一次关单结论再决定能不能复用:
        // 关单其实已经成功 → 它当场落成已关闭,下面给用户建一张新的;关单没生效 → 它还是可付的,
        // 下面复用回去。这一步只在「被要求关过」的凭证上打通道,正常的复用(PREPAY_CREATED)不受影响。
        reconciliationService.concludeUndecidedCloses(orderNo);
        // 复用优先:本地已经持有这张订单的预支付单的支付参数、订单又还待支付时,直接把它交回客户端。
        // 这里刻意**不查通道**:查通道只会得到「处理中」这种没有结论的答复,而本地已有可用凭证——
        // 拿这个没有结论的答复把整笔订单停摆,正是用户报的死锁(见 docs/adr/0003 的 D3)。
@@ -53,10 +58,6 @@
        if (reusable != null) {
            return pendingResult(string(reusable.get("paymentNo")), string(reusable.get("clientPaymentPayload")));
        }
        // 上一张凭证正在作废(关单中/关单没结论)时先问一次关单结论:通道往往早就关掉了,只是没人问过。
        // 问出来它当场落成已关闭,下面就能给用户建一张新的;问不出来才需要等(那说明关单真的还在处理)。
        reconciliationService.concludeUndecidedCloses(orderNo);
        // 本地没有可复用的凭证时,通道是唯一知道钱有没有收下的地方。上一次支付可能已经被通道收下、
        // 回调却没到:那笔钱必须先落成本地已支付,否则这里会再发起一次支付(同一笔订单付第二次),
@@ -105,14 +106,22 @@
    /**
     * 本地是否还持有这张订单的预支付单的支付参数。
     *
     * <p>四个条件缺一不可:订单仍待支付且未过期、流水**从没被要求关过**、支付参数还在本地、
     * <p>四个条件缺一不可:订单仍待支付且未过期、流水状态在可复用之列、支付参数还在本地、
     * 流水金额与订单当前实付一致。金额必须相等是因为预支付单是按金额建的:改了优惠券之后再拿旧单
     * 去收款会收错钱。流水上没有记 {@code price_version},金额相等是这里能拿到的最强判据。
     *
     * <p>{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} **不在可复用之列**:那是我们已经请通道关掉的凭证,
     * 通道可能早就关完了,把它交回客户端只会换来微信收银台一句「订单状态异常,支付请求无效」。
     * 它们的结论由关单查询给出({@link PaymentReconciliationService#concludeUndecidedCloses}):
     * 问出来是已关闭,用户下一次点支付就能拿到一张全新的凭证。
     * <p>可复用之列只有两种,差别在于「有没有被要求关过」:
     * <ul>
     *   <li>{@code PREPAY_CREATED}:从没被要求关过,凭证当然还能付。</li>
     *   <li>{@code CANCEL_UNCONFIRMED}:要求关过,**但通道明确说这次关单没生效**(拒绝关单、关单请求
     *       本身失败)。既然没关掉,它就还是那张可付的凭证——交回去,用户能付,也不会因此多出第二张
     *       可付的凭证。</li>
     * </ul>
     *
     * <p>{@code CLOSING}(关单中)**不在可复用之列**:那次关单可能已经生效,凭证可能已经死了,
     * 交回去只会换来微信收银台一句「订单状态异常,支付请求无效」。它的结论由关单查询给出
     * ({@link PaymentReconciliationService#concludeUndecidedCloses}):问出来是已关闭,用户下一次点
     * 支付就拿到一张全新的凭证。
     */
    private Map<String, Object> reusablePrepay(String orderNo, Long talentId) {
        List<Map<String, Object>> rows = rows("""
@@ -122,7 +131,7 @@
                where p.provider = ? and p.order_no = ? and o.talent_id = ?
                  and o.deleted = 0 and o.order_status = 0
                  and (o.expires_at is null or o.expires_at > now())
                  and p.status in ('PREPAY_CREATED')
                  and p.status in ('PREPAY_CREATED', 'CANCEL_UNCONFIRMED')
                  and coalesce(p.client_payment_payload, '') <> ''
                  and p.amount = o.pay_amount
                order by p.id desc
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -300,6 +300,32 @@
    }
    @Test
    void askingToPayAgainReusesAPrepayTheCloseNeverTookEffectOn() {
        String orderNo = pendingOrder();
        String payload = "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}";
        // 用户在收银台里点了关闭 → 关单请求本身失败(90000000 交易失败,请重试)→ 流水落在 CANCEL_UNCONFIRMED。
        // 关单没生效,这张凭证就还是可付的,也是最该复用的一张。
        String paymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", payload);
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "90000000", "resp_desc", "交易失败,请重试", "huifu_id", huifuId)));
        reconciliationService.abandonPendingPayments(orderNo, paymentNo);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .isEqualTo("CANCEL_UNCONFIRMED");
        clearInvocations(paymentGateway);
        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "reuse-unconfirmed-" + suffix);
        assertThat(result)
                .as("关单没生效的凭证必须交回去:用户要的是付钱,不是等我们把一张可付的凭证作废掉")
                .containsEntry("paymentStatus", "PAYMENT_PENDING")
                .containsEntry("paymentNo", paymentNo);
        verify(paymentGateway, never()).createMiniProgramPayment(any());
        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
                .as("复用不新建支付流水")
                .isEqualTo(1);
    }
    @Test
    void askingToPayAgainAfterAClosedPrepayGetsAFreshOne() {
        String orderNo = pendingOrder();
        // 第一次支付:拉起收银台后用户关掉了它 → 关单答「处理中」,凭证落在 CLOSING 且本地还留着参数。