.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