.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 且本地还留着参数。