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。
| | |
| | | - 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) 的四条用例。 |
| New file |
| | |
| | | # 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` 的改动 |
| | | 只有代码级推理,真机验收留在上面那两条。 |
| | |
| | | let paymentPollingElapsedMs = 0 |
| | | let currentPaymentNo = '' |
| | | let expiryCancellationRequested = false |
| | | // 读到「这次支付尝试已经有结论」之后是否已经重读过订单。用户每动一次手都重置, |
| | | // 用来把「读订单 → 查支付状态 → 再读订单」这条环路限制在一轮以内。 |
| | | let reloadedAfterClosedAttempt = false |
| | | // 从「确认支付」页刚提交过一次支付(waiting=1):打开页面就直接进入等待结论的状态, |
| | | // 而不是先显示一次「继续支付」按钮让用户以为钱没付出去。 |
| | | let waitingForSubmittedPayment = false |
| | |
| | | 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 == '') { |
| | |
| | | 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') { |
| | |
| | | success: (result: any) => { |
| | | if (result.confirm !== true) return |
| | | isSubmitting.value = true |
| | | reloadedAfterClosedAttempt = false |
| | | cancelPendingOrder(orderId.value, talentId.value, (data: any) => { |
| | | isSubmitting.value = false |
| | | // 关单没有结论时服务端不会取消订单,只把取消意图记下来(cancellationPending): |
| | |
| | | paymentChecking.value = false |
| | | isSubmitting.value = false |
| | | if (data != null && data.paymentStatus == 'CANCELLED') { |
| | | // 本地流水已关闭/关不掉:订单本身可能仍是待支付,重新读一次订单就能拿到最新状态。 |
| | | loadOrder() |
| | | // 本地流水已关闭/关不掉:这次尝试结束了,订单本身可能仍是待支付,重读一次订单拿最新状态。 |
| | | onPaymentAttemptClosed() |
| | | return |
| | | } |
| | | if (pollWhenPending) uni.showToast({ title: '支付结果还没确认,可点「刷新支付状态」再查一次', icon: 'none' }) |
| | |
| | | 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. |
| | |
| | | * 这次尝试停在 {@code CLOSING} 等后台重试,并记一条问题记录给人工。</li> |
| | | * <li>关单处理中:这次尝试停在新状态 {@code CLOSING}(关单中)——它既不能当已关闭(通道还可能 |
| | | * 收钱),也不该是终点,做成可等待、可重试的中间态。</li> |
| | | * <li>原交易已经终结({@code 23000000}/{@code 10000016}):不是「现在不让关」,而是「它已经 |
| | | * 结束了」——那张预支付单不可能再被支付,落成已关闭,不再交回客户端。</li> |
| | | * <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为 |
| | | * {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了, |
| | | * 会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li> |
| | |
| | | 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"); |
| | |
| | | * |
| | | * <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")); |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | """, 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); |
| | |
| | | |
| | | boolean originalTradeSucceeded() { |
| | | return "S".equalsIgnoreCase(originalStatus); |
| | | } |
| | | |
| | | /** |
| | | * 通道拒绝关单,理由是原交易已经终结。 |
| | | * |
| | | * <p>这与「未满一分钟不允许关单」这类拒绝不是一回事:后者说的是「现在不让关」,预支付单还活着; |
| | | * 这一句说的是「它已经结束了」,那张预支付单不可能再被支付。 |
| | | */ |
| | | boolean originalTradeAlreadyTerminal() { |
| | | return TERMINAL_CODES.contains(code); |
| | | } |
| | | |
| | | boolean processing() { |
| | |
| | | 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"); |
| | |
| | | private String huifuId; |
| | | private String suffix; |
| | | private long orderSequence; |
| | | private long paymentSequence; |
| | | |
| | | @BeforeEach |
| | | void prepareData() { |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | @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); |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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, |