yeg
2026-09-17 c4baf2f1909ee78e610f371aad86df917839be5c
feat(payment): 接通关单查询,作废中的凭证不再交回客户端

真机复测(订单 544):「取消」已经很快,但反复「继续支付 → 关掉收银台」时每次都有一段「支付处理中」
——关单 18:06:03 答「处理中」,直到 18:07:15 才对账那一跳拿到结论,用户等了 72 秒。两条根因:

- 关单的查询对等物一直没接。汇付有专门的关单查询接口,文档写明「关单处理中应继续调用关单查询确认
结果」;我们只发关单、不问结论,结论只能等下一轮扫描,而通道几秒就关完了。副作用是快车道每次重发
关单都会让 close_attempts 增长,本属正常的记录会被判到上限,而「到上限」会顺手把订单取消。
- reusablePrepay 把 CLOSING/CANCEL_UNCONFIRMED 也算可复用(理由是「还可能被支付」),但那是我们
已经请通道关掉的凭证,通道可能早就关完了:交回去就换来微信收银台「订单状态异常,支付请求无效」。

改动:

- 新增 HuifuPaymentCloseQueryCommand 与 gateway.closeQueryMiniProgramPayment(SDK
V2TradePaymentScanpayClosequeryRequest,原交易定位信息与关单一致走 extend_info)。
- PaymentReconciliationService 新增 probeCloseConclusion/concludeUndecidedCloses:只有「关单成功」
算结论,落成 CLOSED 并结掉问题记录;还在处理中/关单失败/查询没答上来一律按「没有结论」处理。
- 三处调用:beginMiniProgramPayment(点继续支付时先问一次,关掉就当场建新凭证)、paymentStatus
(「刷新支付状态」真的能推进关单结论)、retryClose(先问再决定是否重发关单,不再重复计数)。
- reusablePrepay 只复用 PREPAY_CREATED。
- 「一张订单同时只有一张可被支付的凭证」没有放松:关单还没结论时支付接口仍返回 PAYMENT_PROCESSING,
不新建第二张(审计 §9 已复现过两张都能付的后果)。

回归三条:作废中的凭证关掉后立刻拿到新凭证、关单还在处理中时只等待且不新建、对账用关单查询拿结论
且不重发关单。全量 372 项,仅 testing.md 记录的 3 项基线失败。ADR-0004 的「查询对等物」清单补上这条。
1个文件已添加
6个文件已修改
214 ■■■■■ 已修改文件
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/adr/0004-active-query-is-authoritative.md 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentCloseQueryCommand.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
@@ -141,3 +141,36 @@
- [x] 回归:`PaymentSchedulingConfigurationTests`(对账间隔 1 分钟、注解与配置默认值一致)
- [ ] 真机:点「取消订单」当轮就进「已取消订单」页,不再出现「取消处理中」。
- [ ] 真机:取消后同一商品立刻能再下单、能正常拉起收银台付款。
## 2026-09-17 第四轮:关单的查询对等物,以及不再把作废中的凭证交回客户端
真机复测(订单 544,18:05):「取消」已经很快,但反复「继续支付 → 关掉收银台」时,每次都有一段
「支付处理中」——库里看到的是 18:06:03 关单答「处理中」,直到 18:07:15 才由对账那一跳拿到结论,
用户在前台等了 72 秒。根因两条:
1. **关单的查询对等物一直没接**。汇付有专门的「关单查询」接口,文档写明「关单处理中应继续调用关单查询
   确认结果」。我们只发关单、不问结论,结论只能等下一轮扫描——而通道几秒就关完了。顺带一个副作用:
   快车道每次重发关单都会让 `close_attempts` 增长,一条本来正常的记录会被判到上限,而「到上限」会顺手
   把订单取消掉(`giveUpClosing`)。
2. **正在作废的凭证仍会被交回客户端**。`reusablePrepay` 把 `CLOSING`/`CANCEL_UNCONFIRMED` 也算可复用,
   理由是「它还可能被支付」。但那是我们已经请通道关掉的凭证,通道可能早就关完了——交回去就换来微信
   收银台一句「订单状态异常,支付请求无效」(第一轮报的那个弹窗,这是它的另一条入口)。
落地:
- `HuifuMiniProgramPaymentGateway.closeQueryMiniProgramPayment` + `HuifuPaymentCloseQueryCommand`
  (SDK `V2TradePaymentScanpayClosequeryRequest`,原交易定位信息与关单一样走 `extend_info`)。
- `PaymentReconciliationService.probeCloseConclusion`/`concludeUndecidedCloses`:只有「关单成功」算结论;
  还在处理中、关单失败、查询没答上来一律按「没有结论」处理,绝不因为问不到就说它关掉了。
- 三处调用:支付发起(`beginMiniProgramPayment`)、刷新支付状态(`paymentStatus`)、对账快车道
  (`retryClose` 先问再决定要不要重发关单)。
- `reusablePrepay` 只复用 `PREPAY_CREATED`:没被要求关过的凭证才交回客户端。
- 「一张订单同时只有一张可被支付的凭证」这条不变量**没有放松**:关单还没结论时支付接口仍然返回
  `PAYMENT_PROCESSING` 而不是新建第二张(审计 §9 已复现过两张都能付的后果)。
**第四轮验收**
- [x] 回归:`PendingPaymentConfirmationTests.askingToPayAgainAfterAClosedPrepayGetsAFreshOne`
- [x] 回归:`PendingPaymentConfirmationTests.askingToPayAgainWaitsOnlyWhileTheProviderIsStillClosingThePrepay`
- [x] 回归:`PendingPaymentConfirmationTests.theReconciliationConcludesAClosedPrepayThroughTheCloseQueryWithoutSendingAnotherClose`
- [ ] 真机:反复「继续支付 → 关掉收银台」时,等待应缩短到通道真正关完的时间(秒级),而不是固定一分钟。
docs/adr/0004-active-query-is-authoritative.md
@@ -42,3 +42,9 @@
  `queryConfirm` 已实现却是死代码。补法是**放宽已有的补偿任务**并接上那个查询,不是新增任务。
- 生产/联调环境是否配置公网回调地址:`app.payment.huifu.merchant-business-notify-url` 在 yml 里缺失,
  绑卡回调从未在通道侧注册——这是生产 bug,与开发环境无关,单独修。
- **关单(作废预支付单)的查询对等物**:2026-09-17 补齐。原先只发关单、不问结论:关单回执说「处理中」
  之后就等后台扫描周期再发一次关单,于是用户在前台看着「支付处理中」等一分钟起步(实测 72 秒),而通道
  其实几秒就关完了——汇付文档明确写着「关单处理中应继续调用关单查询确认结果」,那条查询我们一直没接。
  现在支付发起、刷新支付状态、对账快车道都会先问一次关单查询:关掉了就当场落成已关闭,用户下一次点
  「继续支付」直接拿到一张全新凭证;还在处理中才需要等。对账快车道也因此不再重复发起关单,
  「已发起关单」不会因为等待而被重复计数到上限。
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -54,6 +54,10 @@
            return pendingResult(string(reusable.get("paymentNo")), string(reusable.get("clientPaymentPayload")));
        }
        // 上一张凭证正在作废(关单中/关单没结论)时先问一次关单结论:通道往往早就关掉了,只是没人问过。
        // 问出来它当场落成已关闭,下面就能给用户建一张新的;问不出来才需要等(那说明关单真的还在处理)。
        reconciliationService.concludeUndecidedCloses(orderNo);
        // 本地没有可复用的凭证时,通道是唯一知道钱有没有收下的地方。上一次支付可能已经被通道收下、
        // 回调却没到:那笔钱必须先落成本地已支付,否则这里会再发起一次支付(同一笔订单付第二次),
        // 或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
@@ -101,13 +105,14 @@
    /**
     * 本地是否还持有这张订单的预支付单的支付参数。
     *
     * <p>四个条件缺一不可:订单仍待支付且未过期、流水属于「本地尚未定局」的状态、支付参数还在本地、
     * <p>四个条件缺一不可:订单仍待支付且未过期、流水**从没被要求关过**、支付参数还在本地、
     * 流水金额与订单当前实付一致。金额必须相等是因为预支付单是按金额建的:改了优惠券之后再拿旧单
     * 去收款会收错钱。流水上没有记 {@code price_version},金额相等是这里能拿到的最强判据。
     *
     * <p>{@code CLOSING}(关单中)与 {@code CANCEL_UNCONFIRMED}(关不掉的预支付单)同样在可复用之列:
     * 它们正是「通道侧这张单还可能被支付」的状态,此时新建一张预支付单等于同一笔订单可能被付两次
     * (D2),所以只能复用。
     * <p>{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} **不在可复用之列**:那是我们已经请通道关掉的凭证,
     * 通道可能早就关完了,把它交回客户端只会换来微信收银台一句「订单状态异常,支付请求无效」。
     * 它们的结论由关单查询给出({@link PaymentReconciliationService#concludeUndecidedCloses}):
     * 问出来是已关闭,用户下一次点支付就能拿到一张全新的凭证。
     */
    private Map<String, Object> reusablePrepay(String orderNo, Long talentId) {
        List<Map<String, Object>> rows = rows("""
@@ -117,7 +122,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', 'CLOSING', 'CANCEL_UNCONFIRMED')
                  and p.status in ('PREPAY_CREATED')
                  and coalesce(p.client_payment_payload, '') <> ''
                  and p.amount = o.pay_amount
                order by p.id desc
@@ -273,7 +278,9 @@
        Map<String, Object> order = orders.get(0);
        if (number(order.get("orderStatus")) == 1) return completedResult(paymentNo == null ? "" : paymentNo);
        // 回调没到、本地还停在待支付时,通道是唯一知道钱有没有收下的地方。问一次就能把状态收敛回来,
        // 人才端也就不会一直停在待支付页。
        // 人才端也就不会一直停在待支付页。「关单中」的凭证也在这里问一次关单结论:它往往早已关掉,
        // 用户点「刷新支付状态」就该拿到那个结论,而不是等到下一轮对账。
        reconciliationService.concludeUndecidedCloses(orderNo);
        PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo);
        if (confirmation.collected()) return completedResult(confirmation.paymentNo());
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -8,6 +8,7 @@
import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -286,6 +287,10 @@
            return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
        }
        if (close.processing()) {
            // 关单在通道侧是异步的:紧接着问一次关单查询,通道其实已经关掉了就直接落成已关闭——
            // 让用户等下一轮扫描(一分钟)没有任何道理,通道早就给了结论。
            PendingPaymentConfirmation concluded = probeCloseConclusion(transaction);
            if (concluded != null) return concluded;
            issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount,
                    "Provider is still closing the payment");
            markClosing(transaction, close.code(), close.message());
@@ -397,6 +402,45 @@
    }
    /**
     * 把一笔订单里「关单还没结论」的尝试都问一遍关单查询,有结论就地落地。
     *
     * <p>给用户主动触发的路径用(点「继续支付」、点「刷新支付状态」):关单在通道侧是异步的,结论往往
     * 几秒后就有了,只是没人问过——等到下一轮对账再发现,用户看到的就是「点了继续支付却一直支付处理中」。
     * 有结论时这张凭证已证明作废,接着就能给用户建一张新的。
     */
    public void concludeUndecidedCloses(String orderNo) {
        for (Map<String, Object> row : undecidedPayments(orderNo, null)) {
            String status = text(row.get("status"));
            if (CLOSING_STATUS.equals(status) || CANCEL_UNCONFIRMED_STATUS.equals(status)) probeCloseConclusion(row);
        }
    }
    /**
     * 问一次关单的结论。
     *
     * <p>只有「关单成功」才算结论:落成已关闭并结掉问题记录。其余一律返回 {@code null}——还在处理中、
     * 关单失败、查询本身没答上来,都按「没有结论」处理,绝不因为问不到就说它关掉了。
     */
    private PendingPaymentConfirmation probeCloseConclusion(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        String huifuId = text(transaction.get("huifuId"));
        if (huifuId.isBlank()) return null;
        Map<String, Object> response;
        try {
            response = paymentGateway.closeQueryMiniProgramPayment(new HuifuPaymentCloseQueryCommand(
                    BusinessIdGenerator.next("Q"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")),
                    requestDate(transaction.get("createdAt"))));
        } catch (RuntimeException exception) {
            return null;
        }
        CloseResult close = CloseResult.from(response);
        if (!close.closed()) return null;
        markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message());
        issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
        return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
    }
    /**
     * 取这一轮要处理的未定局流水。
     *
     * <p>按 {@code last_reconciled_at} 轮转(空值优先、其次最久没被处理的),取代旧实现的「最早 100 条」:
@@ -459,6 +503,9 @@
     */
    private void retryClose(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        // 先问关单结论:关单往往早就成功了,只是没人问过。问出来就不必再发起一次关单——
        // 既快(用户可能正等着),也不会把「已发起关单」重复计数到上限上。
        if (probeCloseConclusion(transaction) != null) return;
        if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) {
            // 到上限:关单已经问不出更多结论,再打通道只是重复。先查一次(钱真的来了要先落成已支付,
            // 那一支会把订单翻成已支付,下面的收尾就不会再取消它),再给这次尝试一个终止条件。
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java
@@ -12,6 +12,7 @@
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.huifu.bspay.sdk.opps.core.request.V2TradePaymentScanpayCloseRequest;
import com.huifu.bspay.sdk.opps.core.request.V2TradePaymentScanpayClosequeryRequest;
import com.huifu.bspay.sdk.opps.core.request.V3TradePaymentJspayRequest;
import com.huifu.bspay.sdk.opps.core.request.V3TradePaymentScanpayQueryRequest;
import com.talentcard.server.common.BusinessException;
@@ -133,6 +134,37 @@
        }
    }
    /**
     * 查一次关单的结论。
     *
     * <p>关单回执答「处理中」时,结论只能靠这条查询(汇付文档:关单处理中应继续调用关单查询确认结果)。
     * 没有它,我们只能在后台扫描里反复发起关单——用户在前台等着,而那本可以是几秒。
     */
    public Map<String, Object> closeQueryMiniProgramPayment(HuifuPaymentCloseQueryCommand command) {
        ensureConfigured();
        ensureCollectingMerchant(command.huifuId());
        initializeSdk();
        V2TradePaymentScanpayClosequeryRequest request = new V2TradePaymentScanpayClosequeryRequest();
        request.setReqDate(REQUEST_DATE.format(LocalDateTime.now()));
        request.setReqSeqId(command.requestNo());
        request.setHuifuId(command.huifuId().trim());
        request.setOrgReqDate(command.requestDate());
        Map<String, Object> extendInfo = new LinkedHashMap<>();
        extendInfo.put("org_req_seq_id", command.paymentNo());
        if (!blank(command.providerTransactionNo())) extendInfo.put("org_hf_seq_id", command.providerTransactionNo());
        request.setExtendInfo(extendInfo);
        try {
            Map<String, Object> response = BasePayClient.request(request, false);
            log.info("Huifu mini-program payment close-query response: paymentNo={}, response={}",
                    command.paymentNo(), logJson(response));
            return response;
        } catch (Exception exception) {
            log.warn("Huifu mini-program payment close-query failed: paymentNo={}, type={}, message={}",
                    command.paymentNo(), exception.getClass().getSimpleName(), safeMessage(exception.getMessage()));
            throw new HuifuPaymentGatewayException("汇付关单查询失败,请稍后重试", isOutcomeUnknown(exception), exception);
        }
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "汇付支付尚未启用");
        if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getMerchantId())
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentCloseQueryCommand.java
New file
@@ -0,0 +1,12 @@
package com.talentcard.server.business.payment.huifu;
/**
 * 查询一次关单请求的结论。
 *
 * <p>关单在通道侧是异步的:发起时可能只拿到「关单处理中」,结论要另问一次(汇付文档也写明「关单处理中
 * 时应继续调用关单查询确认结果」)。没有这条查询,我们就只能靠后台扫描周期反复发起关单——既慢
 * (用户在前台等着),又会把「已发起关单」这个动作重复计数。
 *
 * <p>原交易用 {@code paymentNo}(下单时的请求流水号)与下单日期定位,有通道全局流水号时一并带上。
 */
public record HuifuPaymentCloseQueryCommand(String requestNo, String paymentNo, String huifuId, String providerTransactionNo, String requestDate) {}
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -300,6 +300,71 @@
    }
    @Test
    void askingToPayAgainAfterAClosedPrepayGetsAFreshOne() {
        String orderNo = pendingOrder();
        // 第一次支付:拉起收银台后用户关掉了它 → 关单答「处理中」,凭证落在 CLOSING 且本地还留着参数。
        String closedPaymentNo = pendingPayment(orderNo, 2000L, "CLOSING", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
        jdbc.update("update payment_transaction set close_attempts = 1 where payment_no = ?", closedPaymentNo);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        // 关单在通道侧其实早就成功了:关单查询会给这个结论(汇付文档:处理中应查关单查询确认结果)。
        when(paymentGateway.closeQueryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P", "huifu_id", huifuId)));
        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, "reuse-after-close-" + suffix);
        assertThat(result).containsEntry("paymentStatus", "PAYMENT_PENDING");
        assertThat(String.valueOf(result.get("paymentNo")))
                .as("正在作废的凭证可能早就被通道关掉了,交回去只会换来微信收银台报「订单状态异常」")
                .isNotEqualTo(closedPaymentNo);
        verify(paymentGateway).createMiniProgramPayment(any());
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, closedPaymentNo))
                .as("关单查询给了结论,这张凭证就该落成已关闭")
                .isEqualTo("CLOSED");
    }
    @Test
    void askingToPayAgainWaitsOnlyWhileTheProviderIsStillClosingThePrepay() {
        String orderNo = pendingOrder();
        String closingPaymentNo = pendingPayment(orderNo, 2000L, "CLOSING", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        // 关单查询说「还在处理中」:这张凭证可能还能被付,绝不能新建第二张(审计 §9)。
        when(paymentGateway.closeQueryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "resp_desc", "关单处理中", "huifu_id", huifuId)));
        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "waiting-close-" + suffix);
        assertThat(result).containsEntry("paymentStatus", "PAYMENT_PROCESSING");
        verify(paymentGateway, never()).createMiniProgramPayment(any());
        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
                .as("关单还没有结论时仍然只允许一张可能被支付的凭证")
                .isEqualTo(1);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, closingPaymentNo))
                .isEqualTo("CLOSING");
    }
    @Test
    void theReconciliationConcludesAClosedPrepayThroughTheCloseQueryWithoutSendingAnotherClose() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L, "CLOSING");
        when(paymentGateway.closeQueryMiniProgramPayment(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 close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
                .as("关单早就成功了,不该再发起一次、也不该把重试次数花在这种情况上")
                .isZero();
        verify(paymentGateway, never()).closeMiniProgramPayment(any());
    }
    @Test
    void askingToPayAgainIsRefusedWhenAnUndecidedPrepayCannotBeReused() {
        String orderNo = pendingOrder();
        // 用户在收银台里点了关闭(放弃支付):关单答「处理中」,这次尝试停在 CLOSING,订单仍待支付。