yeg
2026-09-17 0c8ea845c81f25e29b1a1ec2727cc80328ce87c1
feat(payment): 取消订单不再等关单结论,按用户意图立刻生效

真机复测后产品定案:取消是高频操作,而「等一个对账周期」是每次都要付的代价。讨论结论记在
docs/adr/0003 的 2026-09-17 修订里,那里原本就写着这条路——「关单中期间允许直接落成已取消」
的前提是「取消后收到钱能自动退回付款人」,而这个前提在票 05 的悬账自动退款落地后已经成立。

- cancel:查单明确说钱已收下 → 不取消,落成已支付并告知用户(不变,这是「不谎报钱」的底线);
查单说没收款、或通道不可达 → 立刻取消订单并按用户意图返回已取消。
- 关单改成尽力而为:它的结论只决定这次尝试的本地终态(关掉了 → CLOSED,不必再盯;
没关掉 → CLOSING/CANCEL_UNCONFIRMED,由对账继续盯着并继续关),不再挡住取消。
- cancel_requested_at(V151)降级为审计事实,删掉随之失去消费者的
cancelOrdersAwaitingCloseConclusion() 对账扫描:等待态不存在了,留着就是死状态。
- 关单回执说「原交易可能已成功」而查单又查不到时同样立刻取消,PAYMENT_CLOSE_CONFLICT 问题记录
继续留给人工,钱真进来了走悬账退款,人才端订单显示「已退款」。
- 代价明确记账在 ADR 里:偶发「用户付了钱又被自动退回」,要求支付恰好完成在「查单之后、凭证被
关掉之前」那几秒里(多是用户自己在支付途中返回又点取消);多一笔收款+退款流水,手续费大概率
不退。

回归:新增/改写五条(关单处理中即时取消、通道不可达即时取消、取消时间落库、取消后凭证仍被
关掉、已收款订单不可取消),并把「未定局凭证挡住新建」那条改用放弃支付路径触达——取消现在会
直接结束订单,不再能用来构造待支付+CLOSING 的组合。全量 369 项,仅 testing.md 记录的 3 项基线
失败。ADR-0004 里那句「5 分钟一轮」同步改成 1 分钟。
7个文件已修改
244 ■■■■■ 已修改文件
.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 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/adr/0003-payment-converges-per-attempt.md 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/adr/0004-active-query-is-authoritative.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/pending-order-payment-flow/issues/04-payment-converges-per-attempt.md
@@ -71,3 +71,4 @@
- 两处**超出票面但为落地所必需**的改动,请在评审时留意:`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()`,条件与「取消」本身一致(不留未定局尝试、不取消已收款订单)。
- 2026-09-17 第三轮(产品决定,已落 `docs/adr/0003` 的修订):**取消不再等关单结论**。本票原先定的是「取消以关单回执为准」——理由是关单回执同时回答「关掉了没有」和「原交易是不是已成功」。真机复测发现通道对刚创建的微信交易最常见的答复就是「关单处理中」,于是每次取消都要等一个对账周期,用户看到的是「点了取消一直卡着」,而他根本没付过钱。adr/0003 的「考虑过但没选的」里本来就写着这条路:**关单中期间允许直接落成已取消**,前提是「取消后收到钱能自动退回付款人」——这个前提在票 05 的悬账自动退款落地后已经成立,所以改成选它。落地:`cancel` 只在查单确认「钱没被收下」之后立刻生效(查不通也立刻取消),关单改成尽力而为、其结论只决定这次尝试的本地终态;`cancel_requested_at` 降级为审计事实,随之删掉「等关单结论再落地」的对账扫描;代价(偶发「付了又退」,手续费大概率不退)明确记在 ADR 里。
.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md
@@ -115,3 +115,29 @@
- [ ] 真机:点取消 → 「取消处理中」→ 一分钟内自动进入「已取消订单」页(不再需要再点一次取消)。
- [ ] 真机:关单中那一分钟里点「刷新支付状态」应能拿到结论(而不是一直 支付处理中)。
## 2026-09-17 第三轮:取消不再等关单结论
真机复测的另一半结论指向一个产品决定,而不只是缺陷:用户点取消之后要等一个对账周期,而通道对刚创建的
微信交易**最常见的答复就是「关单处理中」**(实测三次取消全部如此)。他根本没付过钱,这笔钱没动过,
没有理由等。讨论后按「体验优先」定案,见 `docs/adr/0003` 的 2026-09-17 修订:
- `cancel` 只在查单确认「钱没有收下」之后立刻生效;查单说钱已收下就不取消(落成已支付并告知用户),
  查不通(通道不可达)也立刻取消。
- 关单照做但只是尽力而为:结论只决定这次尝试的本地终态(`CLOSED`=不必再盯,`CLOSING`/
  `CANCEL_UNCONFIRMED`=继续盯并继续关)。
- `benefit_order.cancel_requested_at`(V151)降级为审计事实,随之删掉第二轮新增的
  `cancelOrdersAwaitingCloseConclusion()` 对账扫描——它服务的等待态没有了。
- 代价明确记账:偶发「用户付了钱又被自动退回」,需要支付恰好完成在「查单之后、凭证被关掉之前」那几秒;
  这笔钱走票 05 的悬账自动退款,人才端订单显示「已退款」。
**第三轮验收**
- [x] 回归:`PendingPaymentConfirmationTests.cancellingTakesEffectImmediatelyWhileTheProviderIsStillClosingThePrepay`
- [x] 回归:`PendingPaymentConfirmationTests.cancellingTakesEffectImmediatelyEvenWhenTheProviderCannotSayWhetherTheMoneyWasCollected`
- [x] 回归:`PendingPaymentConfirmationTests.cancellingTakesEffectAtOnceAndRecordsWhenTheUserAskedForIt`
- [x] 回归:`PendingPaymentConfirmationTests.aPrepayLeftAliveAfterACancelIsStillClosedByTheReconciliation`
- [x] 回归:`PendingPaymentConfirmationTests.anOrderWhoseMoneyWasCollectedIsNotCancellableEvenWithAnUndecidedPrepay`
- [x] 回归:`PaymentSchedulingConfigurationTests`(对账间隔 1 分钟、注解与配置默认值一致)
- [ ] 真机:点「取消订单」当轮就进「已取消订单」页,不再出现「取消处理中」。
- [ ] 真机:取消后同一商品立刻能再下单、能正常拉起收银台付款。
docs/adr/0003-payment-converges-per-attempt.md
@@ -3,8 +3,9 @@
一笔待支付订单的支付结果问不出来时,旧规则把整笔订单停摆:不许再付、也不许取消。这既堵死了用户
(放弃微信收银台是未支付订单最常见的路径),也让「关单」这个唯一能作废预支付单的手段永远不被调用——
因为关单被排在查单之后,而查单答「处理中」就直接返回。改为**按支付尝试收敛**:一笔订单同一时刻最多
只有一张活着的**预支付单**,重付优先复用本地已存的支付参数,其次关单作废,都走不通才新建;取消以通道
**关单回执**为准;「支付结果不确定」只约束它所属的那一次尝试,不约束整笔订单。订单到期先关单再取消,
只有一张活着的**预支付单**,重付优先复用本地已存的支付参数,其次关单作废,都走不通才新建;取消以查单
确认「钱没有收下」为准(2026-09-17 修订,原为「以关单回执为准」);「支付结果不确定」只约束它所属的
那一次尝试,不约束整笔订单。订单到期先关单再取消,
到期后不允许补付;带未定局尝试的订单不自动取消;通道确认收到、但订单已取消的钱自动退回付款人。
这条不是新决定,是补 2026-09-10 审计 §8 的后半段。那份报告给的修复方向是「**关单中状态**、通道关单与
@@ -21,4 +22,29 @@
- **把"关单中"做成用户可见的失败态。** 关单回执说「处理中」时,通道其实还可能在收钱,所以不能当成功,
  但它也不该是终点。做成可等待、可重试的中间态,才能既不假装关掉、又不把用户堵死。
- **关单中期间允许直接落成已取消。** 只有在取消后收到钱能自动退回付款人的前提下才成立(本 ADR 已包含
  自动退款),否则就是把悬账留给人工。
  自动退款),否则就是把悬账留给人工。**2026-09-17 修订后改成选它,见文末。**
## 2026-09-17 修订:取消不等关单结论
真机复测暴露:用户点「取消订单」之后要等关单结论才落地,而通道对刚创建的微信交易**最常见的答复就是
「关单处理中」**(实测三次取消全部如此),叠上对账扫描周期,用户看到的就是「点了取消一直卡着」。可他
根本没付过钱——这笔钱本来就没动,没有任何理由让他等。按上面「关单中期间允许直接落成已取消」那条的前提
(取消后收到钱能自动退回付款人),在本 ADR 的悬账自动退款落地之后已经成立,于是改为:
**取消在查单确认「钱没有收下」之后就生效,不再等关单回执。**
- 查单明确说钱已收下:不取消,落成已支付并告知用户。这条不变,它是「不谎报钱」的底线。
- 查单说没收款、或者问不通(通道不可达):按用户意图**立刻**取消订单。
- 关单照做,但只是尽力而为:它的结论只决定这次尝试的本地终态——关掉了就落成 `CLOSED`(不必再盯),
  没关掉就留成 `CLOSING`/`CANCEL_UNCONFIRMED`,由对账盯着并继续关。
- 关单回执说「原交易可能已成功」而查单又查不到时,同样立刻取消;那条 `PAYMENT_CLOSE_CONFLICT` 问题记录
  继续留给人工,钱真进来了走悬账退款,人才端订单显示「已退款」。
**代价记在这里**:偶发「用户付了钱又被自动退回」。它要求用户在点取消的同一刻正在完成支付——通常是
他自己在支付途中返回、又去点了取消——并且正好落在「查单之后、凭证被关掉之前」那几秒里。多走的是一笔
真实的收款+退款流水(手续费大概率不退,对账也要处理),换来的是取消立刻生效。这是产品明确选的取舍:
取消是高频操作,「等一分钟」是每次都要付的代价;「收了再退」是极少数情况。
同时把取消意图降级为审计事实:`benefit_order.cancel_requested_at`(V151)只记录用户何时点的取消,
**不再驱动任何行为**——它驱动的那个「等关单结论再落地」的对账扫描已经删掉,留它只是为了在
「取消后收到钱」那类偶发路径上把时间对得上。
docs/adr/0004-active-query-is-authoritative.md
@@ -12,7 +12,8 @@
  这是用户明确选择的取舍,本 ADR 不推翻它——它已经满足「存在查询路径」这一条。
- **收款能力快照**:由已有的 30 分钟定时核实与商户端「立即核实」共同保证
  ([ADR 0002](0002-merchant-capability-visibility.md))。
- **支付与退款**:读接口惰性查询 + 客户端的 5 分钟一轮后台对账。
- **支付与退款**:读接口惰性查询 + 服务端 1 分钟一轮的后台对账(关单补偿的快车道;慢车道每行另有
  `pending-after-minutes` 与 `last_reconciled_at` 退避)。
同时明确一条边界:**不允许为了开发便利开「跳过真实通道」的置付捷径。** 2026-09-09 审计 §7 已把
「后台跳过真实支付、直接置为已支付」列为高风险——它破坏的不变量是「任何成功资金事实都能定位本地凭证」,
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -66,37 +66,7 @@
    public Map<String, Object> reconcile(int pendingAfterMinutes) {
        int paymentChecked = reconcilePayments(pendingAfterMinutes);
        int refundChecked = reconcileRefunds(pendingAfterMinutes);
        // 关单结论回来之后,替已经点过取消的用户把订单收掉。放在查单/关单之后:同一轮里先把这次尝试
        // 问出结论,再决定这笔订单能不能结束。
        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'
                  )
                """);
        return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
    }
    /** What the provider said about the payments of one order whose local state is still undecided. */
@@ -131,12 +101,16 @@
    }
    /**
     * 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
     * 取消订单前的通道确认:问清这笔订单有没有收到钱,并尽最大努力把还没付款的预支付单在通道侧关掉。
     *
     * <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
     * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就落成已支付并拒绝
     * 取消,说还在处理中就让这次尝试停在 {@code CLOSING} 并交给后台重试,只有确认关掉、或通道明确拒绝
     * 关单(见 {@link #keepWatchableAfterRefusedClose})时才继续取消。
     * <p><b>只有「钱收没收下」决定取消能不能落地</b>:收下了就落成已支付并拒绝取消;没有就立刻取消。
     * 关单只是顺带做的(它便宜,而且拿到了「已关闭」这条结论,本地就不用再盯着这张凭证),
     * 它的结论**不再**挡住取消——等它返回「处理中」再落地,用户看到的就是「点了取消一直卡着」,
     * 而「关单中期间直接取消」的代价(取消后才收到的钱)已经由悬账自动退款兜住(判定见
     * {@code docs/adr/0003} 的 2026-09-17 修订)。
     *
     * <p>关单结论决定的是这次尝试的本地终态:通道说关掉了就落成 {@code CLOSED}(不必再盯),
     * 说「处理中」或拒绝就留成 {@code CLOSING}/{@code CANCEL_UNCONFIRMED},由对账继续盯着并把凭证关掉。
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public PendingPaymentConfirmation closePendingPayments(String orderNo) {
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
@@ -10,7 +10,6 @@
import com.talentcard.server.business.MoneyUtils;
import com.talentcard.server.business.payment.PaymentReconciliationService;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentState;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -239,29 +238,24 @@
        if (longNumber(order.get("orderStatus")) == 2) return view(order);
        if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消");
        // 回调没到达时「待支付」只说明本地不知道,钱可能已经被通道收下。取消是不可逆的,所以先问通道:
        // 已经收下的钱必须落成已支付(人才端才有退款入口);通道说没收款的预支付单还要在通道侧关掉,
        // 否则用户手上的预支付单仍然能付进来。这一问必须排在本地写状态之前——它在自己的事务里,
        // 本方法一旦先加锁,它就会等自己的锁。
        // 已经收下的钱必须落成已支付(人才端才有退款入口)。这一问必须排在本地写状态之前——它在自己的
        // 事务里,本方法一旦先加锁,它就会等自己的锁。
        //
        // 但**取消不等关单结论**:查单说没收款就立刻取消,凭证作废交给后台(ADR-0003 的 2026-09-17 修订)。
        // 关单在通道侧是异步的,等它返回「处理中」再落地,用户看到的就是「点了取消一直卡着」;而
        // 「关单中期间直接取消」本来就被那条 ADR 允许——前提「取消后收到钱能自动退回付款人」已经落地。
        PendingPaymentConfirmation confirmation = paymentReconciliationService.closePendingPayments(orderNo);
        if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消");
        if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
            // 关单没有结论(通道答「处理中」或干脆没答上来):这次「支付结果不确定」只属于它所属的
            // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受**并落库**,订单
            // 本身不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就
            // 翻成已支付并告知用户)。页面按「取消处理中」渲染。
            //
            // 意图必须落库:结论可能几分钟后才回来(关单补偿在后台跑),只把 cancelPending 交给客户端
            // 等于赌页面一直开着。旧实现就是这样,结果关单真关掉之后没人收那笔订单,用户点的取消丢了。
            recordCancelRequest(orderNo, talentId);
            return cancellationPending(orderNo, talentId, confirmation.paymentNo());
        }
        // 记下用户是什么时候点的取消。它不驱动任何行为,只是审计事实:万一真出现「取消后收到钱」,
        // 排查时要能对上「用户几点点的取消、钱几点到的」。
        recordCancelRequest(orderNo, talentId);
        // 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。
        expireOrder(orderNo, talentId);
        order = find(orderNo, talentId);
        if (longNumber(order.get("orderStatus")) == 2) return view(order);
        // 走到这里,通道侧每笔尝试都已经有了结论(关掉、或明确没收过钱)。这里只剩下取消与确认之间
        // 新起的支付尝试:它们没经过通道确认,所以不能记成已关闭,而是留成待对账状态——预支付单可能还
        // 能被支付,对账任务会继续盯着并留下问题记录。
        // 剩下的是还没经过通道确认的尝试(取消与确认之间新起的):不能记成已关闭——那张预支付单还可能
        // 被支付——而是留成待对账状态,由对账继续盯着并留下问题记录。关单还没结论的(CLOSING)保持原状,
        // 它同样在盯防名单里;通道明确说关掉了的,在关单那一步就已经落成 CLOSED 了。
        jdbcTemplate.update("""
            update payment_transaction
            set status = 'CANCEL_UNCONFIRMED', closed_at = coalesce(closed_at, now()),
@@ -284,19 +278,13 @@
        return view(find(orderNo, talentId));
    }
    /** 取消已受理、等通道关单结论:订单仍是待支付,人才端按「取消处理中」渲染。 */
    private Map<String, Object> cancellationPending(String orderNo, Long talentId, String paymentNo) {
        Map<String, Object> result = view(find(orderNo, talentId));
        result.put("cancellationPending", true);
        result.put("paymentNo", paymentNo);
        result.put("paymentStatus", "PAYMENT_PROCESSING");
        return result;
    }
    /**
     * 把「用户点了取消」落库,等关单有了结论由对账替他把订单收掉。
     * 记下用户是什么时候点的取消。
     *
     * <p>只写第一条时间戳:用户反复点取消不该把受理时间往后推,那是「他最早什么时候要取消」。
     * <p>它**不驱动任何行为**:取消在查单之后立刻生效,不再有「等关单结论再落地」这一步。留着它是因为
     * 「取消后收到钱」那条偶发路径要能对上时间——用户什么时候点的取消、钱什么时候到的。
     *
     * <p>只写第一条时间戳:用户反复点取消不该把受理时间往后推。
     */
    private void recordCancelRequest(String orderNo, Long talentId) {
        jdbcTemplate.update("""
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -190,24 +190,24 @@
                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
                "org_trans_stat", "S", "huifu_id", huifuId)));
        // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
        // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但用户点的取消照样立刻生效,
        // 那条「关单回执说原交易可能已成功」的问题记录留给人工,钱真进来了走悬账退款。
        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
        assertThat(result)
                .as("关单没有结论时,取消的意图被受理但要等结论,而不是给一个永久报错")
                .containsEntry("cancellationPending", true);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        assertThat(((Number) result.get("orderStatus")).intValue()).isEqualTo(2);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("关单中:既不能当已关闭(通道还可能收钱),也不该是终点")
                .as("关单没有结论就等于这张凭证还可能被付,所以不能记成已关闭——它继续被对账盯着")
                .isEqualTo("CLOSING");
        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
                .as("关单中期间只能复用,不允许新建第二张可被支付的预支付单")
                .as("订单取消不新建支付尝试")
                .isEqualTo(1);
        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo))
                .as("通道说原交易可能已成功这件事必须留在问题池里给人工")
                .isEqualTo(1L);
    }
    @Test
    void cancellingWaitsForTheCloseConclusionWhileTheProviderIsStillClosingThePrepay() {
    void cancellingTakesEffectImmediatelyWhileTheProviderIsStillClosingThePrepay() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
@@ -217,9 +217,12 @@
        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
        assertThat(result).containsEntry("cancellationPending", true);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        assertThat(result)
                .as("取消不再等关单结论:通道说「关单处理中」是最常见的答复,等它就是把用户晾在「取消处理中」")
                .doesNotContainKey("cancellationPending");
        assertThat(((Number) result.get("orderStatus")).intValue()).isEqualTo(2);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("关单还在处理中:凭证没被证明作废,留成可盯防状态")
                .isEqualTo("CLOSING");
        assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
                .as("关单重试次数要落库,否则重试上限永远到不了")
@@ -227,7 +230,7 @@
    }
    @Test
    void cancellingWaitsForTheProviderWhenItCannotSayWhetherTheMoneyWasCollected() {
    void cancellingTakesEffectImmediatelyEvenWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any()))
@@ -235,11 +238,12 @@
        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
        assertThat(result).containsEntry("cancellationPending", true);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        assertThat(((Number) result.get("orderStatus")).intValue())
                .as("通道不可达时也按用户意图立刻取消:钱真进来了走悬账退款,而不是把用户堵在等待里")
                .isEqualTo(2);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("an unanswerable payment question must not destroy the only record of the money")
                .isEqualTo("PREPAY_CREATED");
                .as("问不出结论的尝试要留成可盯防状态,不能丢")
                .isEqualTo("CANCEL_UNCONFIRMED");
        assertThat(openIssueCount("PAYMENT_QUERY_FAILED", paymentNo)).isEqualTo(1L);
    }
@@ -298,14 +302,14 @@
    @Test
    void askingToPayAgainIsRefusedWhenAnUndecidedPrepayCannotBeReused() {
        String orderNo = pendingOrder();
        // 先取消,通道答「处理中」→ 关单没有结论,这次尝试停在 CLOSING 且订单仍待支付;
        // 用户在收银台里点了关闭(放弃支付):关单答「处理中」,这次尝试停在 CLOSING,订单仍待支付。
        // 它没有留下可复用的支付参数,因此既不能复用、也不许新建。
        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)));
        pendingOrderService.cancel(orderNo, talentId);
        reconciliationService.abandonPendingPayments(orderNo, paymentNo);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("CLOSING");
        clearInvocations(paymentGateway);
@@ -422,9 +426,11 @@
        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);
        // 用户在收银台里点了关闭(放弃支付):关单答「处理中」,订单仍是待支付,由后台继续关。
        reconciliationService.abandonPendingPayments(orderNo, paymentNo);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .isEqualTo("CLOSING");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        // 快车道每分钟一轮。每轮都把行推回窗口内(共享库里的残留行不影响本用例:断言只按自己的流水号过滤)。
        for (int round = 0; round < 3; round++) reconcileOnce(paymentNo);
@@ -472,42 +478,40 @@
    }
    @Test
    void cancellingWhileTheCloseIsUndecidedRecordsTheIntentAndWaits() {
    void cancellingTakesEffectAtOnceAndRecordsWhenTheUserAskedForIt() {
        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);
        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
        assertThat(((Number) result.get("orderStatus")).intValue())
                .as("取消在查单说「钱没收下」之后就生效,不等关单结论")
                .isEqualTo(2);
        assertThat(jdbc.queryForObject("select cancel_requested_at from benefit_order where order_no = ?", Object.class, orderNo))
                .as("取消的意图必须落库:结论几分钟后才回来,只把 cancelPending 交给客户端等于赌页面一直开着")
                .as("记下用户是什么时候点的取消:它不驱动行为,但「取消后收到钱」那条偶发路径要对得上时间")
                .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))
                .as("关单还没结论=凭证可能还能被付,所以留成可盯防状态")
                .isEqualTo("CLOSING");
    }
    @Test
    void theRecordedCancelLandsOnceTheCloseHasAConclusion() {
    void aPrepayLeftAliveAfterACancelIsStillClosedByTheReconciliation() {
        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);
        pendingOrderService.cancel(orderNo, talentId);
        clearInvocations(paymentGateway);
        // 通道在补偿那一轮改口:关单成功。
        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(
@@ -516,26 +520,34 @@
        reconcileOnce(paymentNo);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("取消不等关单,但凭证还是要在通道侧被关掉——否则用户手上那张单一直能付")
                .isEqualTo("CLOSED");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("关单关掉之后,用户点过的取消要自动落地——否则他点的取消丢了,订单还挂在待支付")
                .as("订单已经是取消状态,关单补偿不该把它改回来")
                .isEqualTo(2);
    }
    @Test
    void aRecordedCancelNeverCancelsAnOrderWhoseMoneyWasCollected() {
    void anOrderWhoseMoneyWasCollectedIsNotCancellableEvenWithAnUndecidedPrepay() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED");
        pendingPayment(orderNo, 2000L);
        // 同一订单上另一笔尝试已经成功(回调没到,本地的「待支付」只是不知道),关单那边说关掉了。
        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)));
                "trans_stat", "F", "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);
        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("已支付");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("已经收到的钱比用户的取消意图更重要,取消它等于把真钱变成悬账")
                .as("已经收到的钱比用户的取消意图更重要:取消它等于把真钱变成悬账")
                .isZero();
        assertThat(count("select count(*) from payment_transaction where order_no = ? and status = 'SUCCEEDED'", orderNo))
                .as("被拒绝的取消绝不能动那笔已经收下的钱")
                .isEqualTo(1);
    }
    @Test