# 待支付订单的支付与取消改造(2026-09-17 起) 用户报告:人才端一笔待支付订单,关掉微信收银台之后再点「确认支付」和「取消订单」都只报 「支付结果确认中」,订单卡死。用户的判断是这套流程不符合团购 App 的常见做法,要求顺带把 **创建订单 → 支付 → 商户核销**整条链路上不合理的地方一起审出来。 **当前处于 grill 阶段,用户明确要求:grill 结束前不动任何代码。** 本目录只写文档。 编号约定:**发现**用 `A/B/C/D/E/F` + 序号,**待决问题**用 `Q` + 序号。 ## 根因 一句话:**「上一次支付结果不确定」被当成了整笔订单的停止符,而不是一次支付尝试的停止符。** 三处叠加(详细到 `file:line` 的推导见 `issues/01-pending-order-deadlock.md`): 1. 「订单支付」页点确认支付只建单并跳页,不发起支付 —— 那是**设计如此**,但按钮名和它承诺的行为不符。 2. 重付时 `HuifuPaymentService.beginMiniProgramPayment` 先用一次通道查单当闸门;通道对未付款的预支付单 答「处理中(`P`)」,代码把 `P` 判成「不确定」→ 抛错。**而本地其实存着那张预支付单的支付参数, 本来可以直接复用、让用户马上再拉起收银台。** 3. 取消订单同样以查单为前置门,并且**查单说 `P` 时连通道关单接口都不调用**——于是连「作废这张收银台」 都做不到。 在这之上,`expireDueOrders` / `expireOrder` 又刻意不自动取消带未定局流水的订单,所以订单永远不会自己到期 关闭;到期后前端还会把支付按钮置灰、把「取消订单」隐藏。结果是**从放弃支付那一刻起直到通道改口为止, 这张订单一直是死的**,并且一直挂在用户的「使用记录」里。唯一自愈通道(对账任务)还是堵的(见 C4)。 ## 全链路审查发现 ### A. 下单 → 发起支付 | # | 发现 | 依据 | | --- | --- | --- | | A1 | 第一次点「确认支付」只建单跳页,第二次点同一个名字的按钮才拉起收银台。多一次没有信息增量的点击,且按钮名与行为不符 | `payment-confirm.uvue:166-202`(无 `uni.requestPayment`)、`pending-payment.uvue:221-253` | | A2 | **不是缺陷,但要注意**:同(人才 + 商户 + 商品 + 券)存在未过期待支付订单时会被直接复用,行为本身是对的。副作用是它会把 B1 的受害者**送回同一笔卡死的订单**——付不了、退不出,只能等满 15 分钟 | `AppOrderPortalService.java:45,194-207` | ### B. 待支付 → 支付中 | # | 发现 | 依据 | | --- | --- | --- | | B1 | 放弃收银台后订单死锁(本次用户报的问题) | `issues/01-pending-order-deadlock.md` | | B2 | 「支付结果确认中」对用户既不可理解也不可操作,全仓没有订阅消息/推送兜底(`subscribeMessage` 零命中),用户只能自己反复回来看 | `pending-payment.uvue:46,314`;全仓 grep | | B3 | 支付结果轮询只有 5 次 × 1.5s = 7.5 秒,且只在 `requestPayment` 回调 success 后才开始轮询。回调稍慢就会弹「请稍后在订单中查看」,把用户推到「使用记录」里自己找 | `pending-payment.uvue:305-310` | ### C. 取消与到期 | # | 发现 | 依据 | | --- | --- | --- | | C1 | 取消订单必须先查通道、且查单是关单的前置门;查单答「处理中」时永久报错,不给可等待的中间状态 | `PendingOrderPortalService.java:239-243`、`PaymentReconciliationService.java:164-167` | | C2 | `expireDueOrders` / `expireOrder` 的排除表只列了 `CREATED / PREPAY_CREATED / CREATE_UNKNOWN`,**漏了 `CANCEL_UNCONFIRMED`**——「关不掉的预支付单」所在的订单可以被自动取消,而那张单子仍然可付 → 钱落在已取消订单上 | `PendingOrderPortalService.java:61-64, 280-283` | | C3 | 落到 C2 的钱(`PAYMENT_ORDER_CANCELLED` / `CLOSED_LATE_SUCCESS`)没有程序化退款出口:退款链路的硬门是 `OrderFundBoundaryService.requireRefundable()` 的 `orderStatus != 1`,而只有后台「支付对账」页的人会读到这类问题记录,四个 `@Scheduled` 任务没有一个消费它 | `OrderFundBoundaryService.java:112-123`;`PaymentSettlementService.java:90-96,112-116`;`admin/PaymentReconciliationAdminService.java:25-32` | | C4 | 对账任务 `reconcilePayments` 是 `order by p.id asc limit 100`,**没有游标或轮转**:未定局流水一旦堆积超过 100 条,最新那笔(也就是用户刚卡住那笔)永远轮不到。而这是 B1 死锁唯一的自愈通道 | `PaymentReconciliationService.java:260-268`;同款问题见审计 §17 | ### D. 到店消费(反扫) | # | 发现 | 依据 | | --- | --- | --- | | D1 | 商户扫人才码建好单后,**人才端没有任何通知**。用户必须自己想起来去「使用记录」里找这笔 15 分钟就过期的待支付订单 | `ManualConsumptionService.create`;全仓无 `subscribeMessage` | | D2 | 团购券已核销后退款要商家先**撤销核销**,撤销窗口 3 小时且仅限 `GROUP_BUY`。**用户 2026-09-17 确认保持 3 小时** | `MerchantWorkService.java:32,283,361` | **D1 已退出本次范围**,用户判定不属于支付链路业务域;已按 `AGENTS.md` 的约定整理成 `issues/02-store-consume-order-notification.md` 供转交。 ### E. 商户核销 | # | 发现 | 依据 | | --- | --- | --- | | E1 | 核销码 60 秒轮换,旧码立刻作废;商户扫码时刚好翻转就报「核销凭证已失效,请重新扫码」。相机启动/网络稍慢这个窗口偏紧 | `PendingUsePortalService.issueCode`;美团/点评是静态券码 + 动态条码 | | E2 | 接口同时下发 6 位数字 `displayCode`,但服务端**没有按数字码核销的入口**(全仓只有生成与返回)。商户看到数字会尝试手输,而手输没有路 | `PendingUsePortalService.java:63-64`;全仓 grep `displayCode` | ### F. 回调缺失(开发环境收不到第三方回调) 用户没有公网服务器,所以任何一个只靠回调推进的环节,在他这边都会表现为"bug"。本轮清点了全部五个 回调端点(都在 `PaymentCallbackController.java`)与它们推进的每一个状态,逐一核实有没有「查询对等物」。 结论:**大部分有**(支付成功、退款结果、交易确认/确认退款、结算记录、微信实名/开通、收款能力快照都有 查询或定时兜底),下面列出**没有**的,以及顺带查出的一批配置脆弱性。 | # | 发现 | 依据 | | --- | --- | --- | | F1 | 回调地址指向 `http://221.182.45.100:11451/...`,开发环境不可达 | `application.yml:85,92,93,94` | | F2 | **进件通过后新增的结算卡,状态只有回调能推进**。初次进件那张卡有查询对等物,但 `confirmOriginalCard` 只认 `initial_bank_card_id`;`GET /api/merchant/bank-cards` 是纯 SELECT,也没有任何用户可触发的刷新。**注意:连现有的人工刷新路径都没覆盖它**,所以这是真缺口,不是 ADR-0001 取舍的一部分 | `MerchantSettlementService.java:382-404`、`:290-293`、`confirmOriginalCard:163-181` | | F3 | `app.payment.huifu.merchant-business-notify-url` **在 yml 里根本没有这个键** → 绑卡回调从未在通道侧注册,**即使有公网也收不到**。这是生产 bug,不只是开发环境问题 | `HuifuUserBusinessOpenGateway.java:44`;`application.yml` 无此键 | | F4 | `funds_confirmation_status='reverting'` **永远没人再看**:补偿任务只扫 `('pending','confirming')`,而 `claim()` 对「同一天」的 `confirming` 也不再重认领,要等到第二天。`queryConfirm` 有实现但是**死代码**(全仓零调用)。后果:撤销核销可能被永久卡住 | `PaymentFundConfirmationRetry.java:61`;`PaymentFundConfirmationService.java:261-273`;`HuifuDelayAccountGateway.java:77-85` | | F5 | ~~进件进度没有任何定时刷新~~ → **不是缺口**。这是 ADR-0001 由用户明确选择的取舍(见下节),后果也已写在文档里:没有回调且无人刷新时进度保持最后已知状态。在开发环境表现为「必须点一下刷新」,不是 bug | 见「与 ADR-0001 的冲突」 | | F6 | `app.payment.huifu.reconciliation.fixed-delay-ms` 没写进 yml,靠注解默认 300000;但注解上 `enabled` 默认是 **false**,靠 yml 第 90 行打开——删掉那行,定时对账(支付与退款唯一的后台兜底)会**静默消失** | `PaymentReconciliationScheduler.java:13,17`;`application.yml:90` | | F7 | `PaymentFundConfirmationRetry` 的 javadoc 说有 `app.payment.fund-confirmation-retry.enabled` 开关,**这个键在代码里从未被读取、yml 里也不存在**——任务永远在跑 | `PaymentFundConfirmationRetry.java:40-41` | | F8 | 两个后台接口存在但前端没接:「查询通道状态」与「刷新退款」 | `AdminManagementController.java:38`;`OrderController.java:94-97` | ## 与 ADR-0001 的冲突(重要) `docs/adr/0001-merchant-onboarding-and-wechat-provisioning.md:5` 明确写着: > 本地开发环境无法接收汇付回调,由商户端"刷新进度"和管理后台刷新主动查询审核及业务结果…… > **保留回调处理,不增加定时查询**;没有回调且无人刷新时,进度会保持最后已知状态。 > **这一取舍由用户明确选择,不能将它擅自改为后台自动轮询。** 这正是「开发环境收不到回调」这件事的前身,而且当年是**有意**这么定的。ADR-0002 又补了一句 「**不新增任何定时任务**」。所以: - **F5 不是缺口**,是已定取舍。本轮的 Q10 曾把「给进件进度加定时刷新」列为建议,与 ADR-0001 冲突, 已撤回并改为单独一问(Q15)。 - **F2、F4 不冲突**:两者都是「在哪条已有路径上补查询」的问题——F2 扩展现有**人工刷新**路径 (不新增任务),F4 放宽**已有**的补偿任务(不新增任务)。 - 本轮写的 [ADR 0004](../docs/adr/0004-active-query-is-authoritative.md) 只记「查询路径必须存在」这个更 上位的判定,并显式声明不改变各链路「由谁触发查询」的既有取舍。人工刷新也是一条查询路径。 ## 已产出的文档 | 文档 | 内容 | | --- | --- | | `plan.md` | **三轮结论合并成的施工计划**(五刀切分、每刀的改动点与验证方式、明确不做的事) | | `CONTEXT.md`「支付尝试与预支付单」一节 | 新收术语:**预支付单**、**支付尝试**、**关单**、**支付结果不确定**、**关单中**、**悬账** | | `docs/adr/0003-payment-converges-per-attempt.md` | 支付按支付尝试收敛,不再「不确定即停摆」(补审计 §8 的后半段) | | `docs/adr/0004-active-query-is-authoritative.md` | 回调只作加速器,状态推进必须有查询对等物;禁止置付捷径 | | `issues/01-pending-order-deadlock.md` | 用户报的死锁:现象时间线、根因、证据、影响面、建议改法 | | `issues/02-store-consume-order-notification.md` | D1 的移交文档(现象、根因、证据、两条建议改法) | | `issues/03-stock-and-create-order-idempotency.md` | 审计 §13 的移交文档(库存不扣减、销量不递增、幂等键未落库) | ## 与 2026-09-10 审计的关系 **本次不是新问题,是那轮审计里「交易状态机」那一类未被修完的残留。** | 审计条 | 现状 | 与本次的关系 | | --- | --- | --- | | §8 本地取消/超时没有通道关单,晚到成功没有退款补偿 | **半修**,五项里「关单中状态」与「退款补偿」仍缺位 | **B1 / C1 / C2 / C3 就是这两项缺位**,已立 ADR-0003 | | §9 支付防重依赖请求键 | **仍开**:换个键仍能创建第二张预支付单 | Q2 立的模型正是这条 | | §10 回调不比对金额与收款主体 | 已修 | — | | §11 主动补查后到店订单没自动核销 | 已修 | — | | §12 改券接口缺身份校验 | 已修 | — | | §13 幂等键未落库、库存未扣减、销量不递增 | **仍开** | A2 是它的对半面;归属未判定,已写成交付文档 `issues/03-*`(Q18 ②) | | §14 退款处理中仍可核销;首笔部分退款后不能再退剩余 | 半修 | **已纳入第二刀**(Q18 ①):`refund_status` 互斥那半已修,部分退款可退剩余那半本次做 | | §16 政府补贴用当前配置重算 | **仍开**,属结算/报表口径 | 不在本次范围 | | §17 补查队列可能长期只查前 100 条 | **仍开** | C4 | | §1–§7、§15 | **已随 V140 退役提现失效** | 不在本次范围 | ## 第 1–3 轮结论(已定,用户 2026-09-17 全部确认) | # | 结论 | | --- | --- | | Q1 | **合并**:第一次点支付就建单 → 发起预支付 → 直接拉起微信收银台;放弃后落到待支付页 | | Q2 | **立「一笔订单同一时刻最多一张活着的预支付单」的模型**:复用优先 → 关单其次 → 才新建 | | Q3 | 客户端关掉收银台的信号**要回传服务端,但只当「放弃信号」**,绝不当结算依据 | | Q4 | 取消订单改为**「先关单、后解释」**;只能得到「处理中」时进入可等待的**「关单中」状态**并自动重试 | | Q5 | 到期边界:先关单再取消;`CANCEL_UNCONFIRMED` 纳入「不自动取消」;**到期后不允许补付**;前端显示「已关闭 / 支付处理中」;C4 一起修 | | Q6 | 悬账走**自动退款**;给已取消订单上的成功支付开窄口子,并给 `PAYMENT_ORDER_CANCELLED` 加自动消费者 | | Q7 | 范围先定为 A1、B1–B3、C1–C4,后扩到 D1 + E1/E2;**随后 D1 又被移出**(见 Q12) | | Q8 | 「关单中」的用户形态取 (b):点取消立即记下意图、显示「取消处理中」,后台在关单有结论时生效;重试**每分钟一次、最多 3 次**,之后落成已取消并保留 `CANCEL_UNCONFIRMED` 盯防 + 问题记录 | | Q9 | 自动退款**全自动**(定时扫问题记录 → 退款 → 标 resolved + 人才端显示「已退款」);到店消费不设例外但允许多一次失败再转人工 | | Q10 | **认「主动查询是权威、回调只作加速器」**;补 F2/F4(在既有路径上扩展,不新增任务);**F5 撤回** | | Q11 | 开发 profile 调短对账间隔 + 加「立即对账 / 立即刷新」入口(走真实对账逻辑);**不做**「模拟支付成功」置付捷径 | | Q12 | **整体退出范围**(D1 及配套通知能力都不做),改为移交文档 | | Q13 | 核销码:放宽轮换 + 新旧码短暂并存;**实现按 6 位数字码核销**(限次 + 同有效期,不泄露归属);不再引入第三套码 | | Q14 | 订单有效期保持 15 分钟且重开收银台**不重置**;已核销团购券的撤销窗口**保持 3 小时** | | Q15 | **F5 不动**:维持 ADR-0001 的人工刷新取舍,不新增进件进度的定时查询;开发环境用人工作触发解决 | | Q16 | F3 按「两种都兜住」处理:补上缺失的 `merchant-business-notify-url`,并让**回调地址为空时显式记录「回调未启用」** | | Q17 | B2/B3 取 (a):轮询退避到 30–60 秒并在页面显示「正在确认支付结果」,另给「刷新支付状态」按钮;**不引入**订阅消息 | | Q18 | ① 纳入审计 §14(部分退款可退剩余);② §13 库存/销量/幂等**归属未判定**,写成移交文档 `issues/03-*`,确认属于自己再折进第五刀;③ D2 记为「不动」 | | Q19 | **五刀切分**:① 解死锁 → ② 守钱 → ③ 改交互 → ④ 核销码 → ⑤ 扫尾 | ## 待决问题 **grill 已结束(2026-09-17 用户确认「成立」)**,frontier 空,规格已发布: [spec.md](spec.md),`Status: ready-for-agent`。 **开工前唯一要再确认的一个事实**:迁移编号取 `V149`(当前最大是 `V148`),按 `AGENTS.md` 先确认没被他人占用。第一刀需要一次迁移——我此前说过「大概率不需要迁移」,那是只算了新增 `status` 取值;C4(对账轮转)与 Q8(关单重试计数)都要按行记状态,所以需要给 `payment_transaction` 加对账轮转时间戳与关单重试计数两列。 规格里另记了一条**开发现场必踩的坑**:开发库的 Flyway 是关的、结构不由迁移维护,而现有初始化器 **不覆盖支付流水表**——只写迁移会让开发库缺列,直接复现 testing.md 记录的「`Unknown column` 就 500」。 新增列必须同时安排开发库的落地方式(迁移 + 初始化器同步加列,或给出可手工执行的 DDL 并在 5.7 上实测)。 ## 目录 - `spec.md`:**已发布的规格**(`Status: ready-for-agent`)——问题、方案、用户故事、实现决策、测试决策、范围外。 - `plan.md`:施工计划(五刀、逐刀改动点与验证方式、不做的事)。 **实现票**(2026-09-17 发布,`Status: ready-for-agent`,编号从 `04` 起以避开下面三份文档): | # | 票 | Blocked by | | --- | --- | --- | | 04 | [解死锁:按「复用 → 关单 → 新建」收敛](issues/04-payment-converges-per-attempt.md) | 无 | | 05 | [守钱:悬账自动退款与部分退款](issues/05-stranded-payment-refund-and-partial-refund.md) | 无 | | 06 | [改交互:一次点击即支付](issues/06-single-tap-payment.md) | 04 | | 07 | [核销码:轮换放宽与数字码核销](issues/07-verification-code-overlap-and-numeric-entry.md) | 无 | | 08 | [扫尾:查询对等物、配置与开发环境](issues/08-query-parity-and-config-cleanup.md) | 04 | 非票文档(诊断,以及按 `AGENTS.md` 约定写的范围外交接): - `issues/01-pending-order-deadlock.md`:用户报的死锁(诊断,不是票)。 - `issues/02-store-consume-order-notification.md`:D1(到店消费通知)的移交文档。 - `issues/03-stock-and-create-order-idempotency.md`:审计 §13(库存/销量/下单幂等)的移交文档。 ## 迁移编号让位(2026-09-17) 本轮新增的四个迁移原本取 V148–V151,但远端 `shinnosuke` 的两个提交(`d2d27be`、`38dd07d`)先推送了 `V148__policy_article_top_time`、`V149__clear_service_hero_placeholders`、`V150__merchant_hero_image`: 共同祖先只到 `V147`,双方各自独立占了同一段编号(git 不会报冲突,Flyway 会直接报「同一版本两个迁移」)。 合并时**本地让号**(远端那三个已推送,可能已在别人的环境里跑过),按原有先后顺延: | 原编号 | 现编号 | | --- | --- | | `V148__merchant_capability_sync_until` | `V151` | | `V149__payment_attempt_convergence` | `V152` | | `V150__merchant_numeric_code_attempt` | `V153` | | `V151__order_cancel_request` | `V154` | 两个 `scripts/dev-schema-*.sql` 同步改名,每个迁移文件头也留了让位记录。票面与 issue 里出现的旧编号 按当时的事实保留(历史记录),以后新增迁移请照 `AGENTS.md` 先看当前最大版本号再取。