fix(payment): 取消意图落库自动落地,对账间隔回到设计的一分钟
第一轮修复在真机上已生效(订单 539 的第一张死凭证落成 CLOSED 后,再点支付新建了 attempt 2 并付成功),
但真机复测仍报「点了取消还是卡住」。只读核对开发库后找到本票没兑现的两处:
- 关单补偿实际是五分钟一轮:CLOSE_RETRY_MINUTES=1 只是「距上次处理满一分钟」的资格窗口,扫描本身由
fixed-delay-ms 决定,默认 300000。于是「关单每分钟重试一次、最多三次」实为「每五分钟一次、最多
十五分钟」——订单 540 在 17:15:21 拿到「关单处理中」后,用户等的那一分钟里没有任何重试发生。
间隔默认值改为 60000,注解与 yml 两处同步;慢车道每行仍有 pending-after-minutes 与
last_reconciled_at 双重退避,同一行不会因扫描变密而被重复查询。
- 取消意图没有落库:cancel 在关单没结论时只回 cancellationPending,订单不动;而关单后来真的关掉时
没有任何人去取消那笔订单(giveUpClosing 只在重试到上限时跑)。用户点的取消随页面关闭而丢失,订单
仍挂在待支付,只能再点一次取消或等 15 分钟超时。新增 benefit_order.cancel_requested_at(V151)
记录受理时间,对账每轮在查单/关单之后执行 cancelOrdersAwaitingCloseConclusion()——每笔尝试都有
结论且没有 SUCCEEDED 支付时,替用户把订单收成已取消。
开发库已用 scripts/apply-sql-file.ps1 落列(等价 DDL 见 scripts/dev-schema-v151-order-cancel-request.sql),
测试库由 Flyway 落列。回归三条:取消受理即落库且未定局时不取消、关单有结论后自动落地、
已收款订单绝不被取消意图取消。PaymentSchedulingConfigurationTests 改为断言「注解与配置的默认值一致」
并额外钉住那 1 分钟约束。全量 369 项,仅 testing.md 记录的 3 项基线失败。
| | |
| | | - 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) 的四条用例。 |
| | | - 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()`,条件与「取消」本身一致(不留未定局尝试、不取消已收款订单)。 |
| | |
| | | 第二单」是第 1–3 条(服务端状态机)。第 3 条是存量缺陷,只是这次被真机路径踩出来了。 |
| | | - 客户端没有可编译的反馈回路(本机没有 HBuilderX、没有 uni CLI),所以 `pending-payment.uvue` 的改动 |
| | | 只有代码级推理,真机验收留在上面那两条。 |
| | | |
| | | ## 2026-09-17 第二轮:修完之后真机仍然「点了取消就卡住」 |
| | | |
| | | 第一轮部署后真机复测(订单 539 于 17:10、订单 540 于 17:15)又报同一句「点了取消还是卡住」。开发库 |
| | | 只读核对显示:第一轮的修复**已经生效**,只是还剩两个洞。 |
| | | |
| | | **已生效的证据**(订单 539,用户最终付款成功): |
| | | |
| | | | 尝试 | 结果 | |
| | | | --- | --- | |
| | | | `P202609171710318912`(attempt 1) | `CLOSED`、`close_attempts=3`、`23000000 原订单已为终态` | |
| | | | `P202609171713414940`(attempt 2) | `SUCCEEDED` 17:13:51,订单 539 落成待核销 | |
| | | |
| | | 旧实现下第一张死凭证会被记成 `CANCEL_UNCONFIRMED` 并一直交回客户端;现在是 `CLOSED`,再点支付时新建了 |
| | | **第二张**预支付单(`attempt_no=2`)并付成功——第 1、2 条根因确实修掉了。同一时刻库里两笔存量僵尸 |
| | | (`P202609171610554772`、`P202609171613329469`)也走到了「close attempts exhausted」(17:15:06)。 |
| | | |
| | | **新发现的两个洞**: |
| | | |
| | | 1. **关单补偿实际是五分钟一轮,不是设计说的一分钟**。`CLOSE_RETRY_MINUTES=1` 只是「资格窗口」(距上次 |
| | | 处理满一分钟才再关),而扫描本身由 `fixed-delay-ms` 决定——默认 `300000`。于是「关单每分钟重试一次、 |
| | | 最多三次」实为「每五分钟一次、最多十五分钟」。订单 540 在 17:15:21 拿到「关单处理中」后落在 `CLOSING`, |
| | | 用户在前台等的一分钟里根本没有任何重试发生。 |
| | | 2. **取消意图没有落库**。`cancel` 在关单没结论时只回一句 `cancellationPending`,订单不动;而关单**后来真的 |
| | | 关掉**时(走 `closePayment` 的成功/已终态分支)没有任何人去取消那笔订单,`giveUpClosing`(唯一会取消 |
| | | 订单的地方)只在重试到上限时才跑。用户点的取消随着页面关掉就丢了,订单还是「待支付」——他只能再点一次 |
| | | 取消(这一次会立刻成功,因为已经没有未定局尝试),或者等 15 分钟超时被清理。 |
| | | |
| | | **第二轮的修复**: |
| | | |
| | | - `benefit_order.cancel_requested_at`(V151,开发库等价 DDL 已实测落列):`cancel` 在关单没结论时把受理 |
| | | 时间落库;对账每轮先查单/关单、再执行 `cancelOrdersAwaitingCloseConclusion()`——每笔尝试都有结论、 |
| | | 且没有 `SUCCEEDED` 支付时,替用户把订单收成已取消。 |
| | | - 对账间隔默认值 `300000 → 60000`(注解与 yml 两处同步)。慢车道每行仍有 `pending-after-minutes` 与 |
| | | `last_reconciled_at` 双重退避,同一行不会因为扫描变密而被重复查询;快车道这才真的做到「一分钟一轮」。 |
| | | `PaymentSchedulingConfigurationTests` 改为断言「注解默认值与配置默认值一致」并额外钉住这个 1 分钟的 |
| | | 约束——它同时是关单补偿的重试间隔。 |
| | | |
| | | **第二轮验收** |
| | | |
| | | - [x] 回归:`PendingPaymentConfirmationTests.cancellingWhileTheCloseIsUndecidedRecordsTheIntentAndWaits` |
| | | - [x] 回归:`PendingPaymentConfirmationTests.theRecordedCancelLandsOnceTheCloseHasAConclusion` |
| | | - [x] 回归:`PendingPaymentConfirmationTests.aRecordedCancelNeverCancelsAnOrderWhoseMoneyWasCollected` |
| | | - [x] 迁移在 5.7(开发库,`scripts/apply-sql-file.ps1`)与 8.0(测试库 Flyway)上都执行过。 |
| | | - [ ] 真机:点取消 → 「取消处理中」→ 一分钟内自动进入「已取消订单」页(不再需要再点一次取消)。 |
| | | - [ ] 真机:关单中那一分钟里点「刷新支付状态」应能拿到结论(而不是一直 支付处理中)。 |
| | | |
| New file |
| | |
| | | -- 开发库(局域网共享的 talent_card,MySQL 5.7,Flyway 关闭)手工补列。 |
| | | -- |
| | | -- 与 V151__order_cancel_request.sql 等价,只是不含存储过程(JDBC 无法直接执行 DELIMITER 批处理), |
| | | -- 所以只能执行一次:重复执行会在「列已存在」上报错,这是安全的失败。 |
| | | -- |
| | | -- 执行方式: |
| | | -- cd talent-card-server |
| | | -- .\scripts\apply-sql-file.ps1 -File scripts\dev-schema-v151-order-cancel-request.sql -Yes |
| | | |
| | | alter table benefit_order |
| | | add column cancel_requested_at datetime default null comment '人才端点过取消、等关单结论的时间'; |
| | |
| | | @Value("${app.payment.huifu.reconciliation.pending-after-minutes:5}") private int pendingAfterMinutes; |
| | | public PaymentReconciliationScheduler(PaymentReconciliationService service) { this.service = service; } |
| | | |
| | | @Scheduled(fixedDelayString = "${app.payment.huifu.reconciliation.fixed-delay-ms:300000}") |
| | | /** |
| | | * 对账节奏:一分钟一轮。 |
| | | * |
| | | * <p>这个间隔决定的是**关单补偿的下限**,不只是「多久查一次」:通道答「关单处理中」的尝试落在 |
| | | * {@code CLOSING},用户此刻正看着页面等结论,而关单重试的资格条件是「距上次处理满一分钟」—— |
| | | * 扫描本身若五分钟才跑一次,设计里那句「关单每分钟重试一次、最多三次」就变成「每五分钟一次、 |
| | | * 最多十五分钟」,用户看到的就是「点了取消一直卡着」。 |
| | | * |
| | | * <p>调快不会加重通道负担:慢车道(未定局的支付与退款)每行都带 {@code pending-after-minutes} |
| | | * 与 {@code last_reconciled_at} 双重退避,同一行不会因为扫描变密而被重复查询。 |
| | | */ |
| | | @Scheduled(fixedDelayString = "${app.payment.huifu.reconciliation.fixed-delay-ms:60000}") |
| | | public void reconcilePendingTransactions() { |
| | | if (!enabled) return; |
| | | try { |
| | |
| | | public Map<String, Object> reconcile(int pendingAfterMinutes) { |
| | | int paymentChecked = reconcilePayments(pendingAfterMinutes); |
| | | int refundChecked = reconcileRefunds(pendingAfterMinutes); |
| | | return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked); |
| | | // 关单结论回来之后,替已经点过取消的用户把订单收掉。放在查单/关单之后:同一轮里先把这次尝试 |
| | | // 问出结论,再决定这笔订单能不能结束。 |
| | | 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' |
| | | ) |
| | | """); |
| | | } |
| | | |
| | | /** What the provider said about the payments of one order whose local state is still undecided. */ |
| | |
| | | if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消"); |
| | | if (confirmation.state() == PendingPaymentState.UNCONFIRMED) { |
| | | // 关单没有结论(通道答「处理中」或干脆没答上来):这次「支付结果不确定」只属于它所属的 |
| | | // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受并记录,订单本身 |
| | | // 不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就 |
| | | // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受**并落库**,订单 |
| | | // 本身不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就 |
| | | // 翻成已支付并告知用户)。页面按「取消处理中」渲染。 |
| | | // |
| | | // 意图必须落库:结论可能几分钟后才回来(关单补偿在后台跑),只把 cancelPending 交给客户端 |
| | | // 等于赌页面一直开着。旧实现就是这样,结果关单真关掉之后没人收那笔订单,用户点的取消丢了。 |
| | | recordCancelRequest(orderNo, talentId); |
| | | return cancellationPending(orderNo, talentId, confirmation.paymentNo()); |
| | | } |
| | | // 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。 |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 把「用户点了取消」落库,等关单有了结论由对账替他把订单收掉。 |
| | | * |
| | | * <p>只写第一条时间戳:用户反复点取消不该把受理时间往后推,那是「他最早什么时候要取消」。 |
| | | */ |
| | | private void recordCancelRequest(String orderNo, Long talentId) { |
| | | jdbcTemplate.update(""" |
| | | update benefit_order set cancel_requested_at = now() |
| | | where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0 |
| | | and cancel_requested_at is null |
| | | """, orderNo, talentId); |
| | | } |
| | | |
| | | /** 单个订单的到期结束:条件写在 SQL 里,跨实例只有一个赢家。未定局的支付尝试照上面同样的理由排除。 */ |
| | | private void expireOrder(String orderNo, Long talentId) { |
| | | jdbcTemplate.update(""" |
| | |
| | | # pending-after-minutes 就问一次通道,通道确认收款就落成本地已支付,对不上账的记一条问题记录。 |
| | | # 关掉它就只剩「有人点了取消/重申支付」这一个补救入口,回调一丢订单会一直停在待支付。 |
| | | # fixed-delay-ms 写进配置(注解里也有同样的默认值):间隔不该只靠注解默认值生效。 |
| | | # 本机开发要更快看到收敛时,用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 把间隔调短(例如 30000), |
| | | # 或者在后台订单列表点「立即对账」人工触发一次——它跑的是同一套真实对账逻辑,不是模拟支付。 |
| | | # 默认一分钟:它同时是关单补偿的重试间隔——通道答「关单处理中」的那笔尝试要靠扫描才会被重试, |
| | | # 扫描慢了,「关单每分钟重试一次、最多三次」就变成十几分钟,用户在前台看到的就是一直卡着。 |
| | | # 慢车道每行另有 pending-after-minutes 与 last_reconciled_at 退避,调快不会重复打通道。 |
| | | # 要更慢(例如生产上想省通道调用)用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 覆盖; |
| | | # 要立刻收敛,在后台订单列表点「立即对账」——它跑的是同一套真实对账逻辑,不是模拟支付。 |
| | | reconciliation: |
| | | enabled: ${HUIFU_RECONCILIATION_ENABLED:true} |
| | | pending-after-minutes: ${HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES:5} |
| | | fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000} |
| | | fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:60000} |
| | | merchant-onboarding-notify-url: ${HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-onboarding-notify} |
| | | merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify} |
| | | merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify} |
| New file |
| | |
| | | -- 「取消意图」要能跨请求活到关单结论回来的那一刻。 |
| | | -- |
| | | -- 用户在通道说「关单处理中」时点了取消,服务端当次只能回一句「取消处理中」——真正该取消的时机是 |
| | | -- 几分钟后关单有了结论。旧实现没有把这次意图落库,于是关单真的关掉之后没有任何人去收那笔订单: |
| | | -- 用户点的取消丢了,订单还是「待支付」,页面上看着就是「点了取消还是卡住」,只能再点一次取消, |
| | | -- 或者等到订单 15 分钟过期被清理收掉。 |
| | | -- |
| | | -- cancel_requested_at 记的是「这次取消请求被受理的时间」,由对账在「每笔尝试都有结论」之后落地 |
| | | -- (见 PaymentReconciliationService.cancelOrdersAwaitingCloseConclusion)。 |
| | | -- |
| | | -- 与 V149 同样的理由,这一列必须同样落到开发库:开发库(局域网共享的 talent_card)Flyway 关闭、 |
| | | -- 结构不由迁移维护,只写迁移会让开发库缺列,取消接口直接以 Unknown column 500。 |
| | | -- 等价的纯 DDL 在 scripts/dev-schema-v151-order-cancel-request.sql。 |
| | | DROP PROCEDURE IF EXISTS add_order_cancel_request_column; |
| | | DELIMITER // |
| | | CREATE PROCEDURE add_order_cancel_request_column() |
| | | BEGIN |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'benefit_order' |
| | | AND column_name = 'cancel_requested_at') THEN |
| | | ALTER TABLE benefit_order |
| | | ADD COLUMN cancel_requested_at DATETIME DEFAULT NULL COMMENT '人才端点过取消、等关单结论的时间'; |
| | | END IF; |
| | | END // |
| | | DELIMITER ; |
| | | CALL add_order_cancel_request_column(); |
| | | DROP PROCEDURE add_order_cancel_request_column; |
| | |
| | | |
| | | import java.lang.reflect.Field; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | import org.junit.jupiter.api.Test; |
| | |
| | | String yml = configurationFile(); |
| | | assertThat(yml) |
| | | .as("对账间隔要写进配置,不能只靠注解默认值") |
| | | .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000}"); |
| | | .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:") |
| | | .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}"); |
| | | assertThat(yml) |
| | | .as("交易确认补偿的开关要真的存在且可读") |
| | | .contains("enabled: ${FUND_CONFIRMATION_RETRY_ENABLED:true}"); |
| | | assertThat(yml) |
| | | .as("悬账自动退款的开关要真的存在且可读") |
| | | .contains("enabled: ${STRANDED_REFUND_ENABLED:true}"); |
| | | assertThat(yml) |
| | | .as("定时对账的开关默认与代码一致(删掉配置行不会让兜底静默消失)") |
| | | .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}"); |
| | | } |
| | | |
| | | @Test |
| | |
| | | .isEqualTo("${app.payment.huifu.reconciliation.enabled:true}"); |
| | | assertThat(PaymentReconciliationScheduler.class.getMethod("reconcilePendingTransactions") |
| | | .getAnnotation(Scheduled.class).fixedDelayString()) |
| | | .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:300000}"); |
| | | .as("注解默认值必须与配置里的默认值一致:两边不一致时,删掉配置行就会悄悄改变行为") |
| | | .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:" + configuredReconciliationDelay() + "}"); |
| | | assertThat(configuredReconciliationDelay()) |
| | | .as("扫描间隔同时是关单补偿的重试间隔(快车道的资格窗口是 1 分钟,见 CLOSE_RETRY_MINUTES):" |
| | | + "比它长,设计里那句「关单每分钟重试一次、最多三次」就不成立,用户看到的是点了取消一直卡着") |
| | | .isEqualTo("60000"); |
| | | } |
| | | |
| | | @Test |
| | |
| | | return field.getAnnotation(Value.class).value(); |
| | | } |
| | | |
| | | /** 配置里写的对账间隔默认值(毫秒),供「两处默认值必须一致」的断言使用。 */ |
| | | private String configuredReconciliationDelay() throws Exception { |
| | | Matcher matcher = Pattern.compile("HUIFU_RECONCILIATION_FIXED_DELAY_MS:(\\d+)").matcher(configurationFile()); |
| | | assertThat(matcher.find()).as("配置里要给出对账间隔的默认值").isTrue(); |
| | | return matcher.group(1); |
| | | } |
| | | |
| | | private String configurationFile() throws Exception { |
| | | try (var stream = getClass().getResourceAsStream("/application.yml")) { |
| | | assertThat(stream).as("application.yml 必须在类路径上").isNotNull(); |
| | |
| | | } |
| | | |
| | | @Test |
| | | void cancellingWhileTheCloseIsUndecidedRecordsTheIntentAndWaits() { |
| | | 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); |
| | | |
| | | assertThat(jdbc.queryForObject("select cancel_requested_at from benefit_order where order_no = ?", Object.class, orderNo)) |
| | | .as("取消的意图必须落库:结论几分钟后才回来,只把 cancelPending 交给客户端等于赌页面一直开着") |
| | | .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)) |
| | | .isEqualTo("CLOSING"); |
| | | } |
| | | |
| | | @Test |
| | | void theRecordedCancelLandsOnceTheCloseHasAConclusion() { |
| | | 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); |
| | | |
| | | // 通道在补偿那一轮改口:关单成功。 |
| | | 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( |
| | | "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 order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("关单关掉之后,用户点过的取消要自动落地——否则他点的取消丢了,订单还挂在待支付") |
| | | .isEqualTo(2); |
| | | } |
| | | |
| | | @Test |
| | | void aRecordedCancelNeverCancelsAnOrderWhoseMoneyWasCollected() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED"); |
| | | 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))); |
| | | |
| | | reconcileOnce(paymentNo); |
| | | |
| | | assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)) |
| | | .as("已经收到的钱比用户的取消意图更重要,取消它等于把真钱变成悬账") |
| | | .isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void theCloseCapNeverEndsAnOrderWhoseMoneyWasAlreadyCollected() { |
| | | String orderNo = pendingOrder(); |
| | | String paymentNo = pendingPayment(orderNo, 2000L, "CANCEL_UNCONFIRMED"); |