2026-09-24-har-refund.md 10.0 KB

汇安融退款 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 给汇安融通道加退款 —— 租户通过开放 API 对一笔「支付成功」的订单发起全额退款,结果由既有轮询/通知收敛。

Architecture: 新开放 API 端点 POST /payment/openapi/har/refund → HarOrderService.openRefund: 条件更新把订单从 1 支付成功 抢占成 4 退款中(先落状态、再调上游)→ HarClient.refund (kf.alipay.trade.refund → /api/alipay/trade/refund)→ 成功保持 4(由轮询/通知收敛到 2), 上游明确拒绝则条件回退到 1,结果未知(超时)保持 4。不新建退款单表。

Tech Stack: Java 17 + Spring Boot + MyBatis-Plus + JUnit5/Mockito;Vue3(仅文档)

Spec: .claude/plan/2026-09-16-har-alipay-channel.md(本通道设计文档)、 docs/支付宝支付接口接入规范260924.docx 5.4 节(上游规范)

Global Constraints

  • 验签失败一律拒绝,不设跳过开关。
  • 上游字段名直接沿用,不做映射层。
  • 业务状态字段用 trade_status / create_status,不用 status。
  • 仓库中不得有凭据;只打印凭据长度而非值。
  • 状态写入只允许条件更新(UPDATE ... WHERE id=? AND trade_status=?),同乐收赢模块。
  • 不 push;SQL 文件由用户编写、仅由用户执行。

事实依据(全部来自规范 docx 与现有代码,非推测)

# 事实 出处
F1 退款 method = kf.alipay.trade.refund,url = /api/alipay/trade/refund docx 5.4 章标题 + 接口声明表第 4 行 + 请求参数表表头行(三处一致)
F2 表 8 请求参数只有 order_no(varchar20) 与 third_order_no(varchar32),没有金额字段 ⇒ 全额退款 docx 表 8
F3 表 8 的 method 备注格写 kf.jh.stage.trade.refund —— 与 precreate/query 同款复制粘贴缺陷;precreate 已实测证明该格不可信 docx 表 8 vs 表 2;260924 联调实证
F4 表 9 响应的 detail 结构完全未公开(只有「detail | 返回详细内容 | json」一行) docx 表 9;设计文档 L52
F5 表 12 通知携带 status 与 refund_time;通知的 status 枚举是 0/1/2/8(无 4),查询的枚举含 4 docx 表 12 / 5.3 响应表
F6 HarOrderStatus.PENDING = {0, 4}、FINAL = {1, 2, 8};reschedulePoll 已按「4 是非终态」排期 HarOrderStatus.java、HarOrderService:344
F7 通知处理已写 trade_status 与 refund_time,回调载荷已带 status/refund_time HarNotifyService:109-123、HarCallbackService:193-195
F8 轮询按 next_query_time IS NOT NULL AND trade_status IN (0,4) 捞单 HarOrderService:380-387

设计裁定

  1. 入口只做开放 API,管理端不加退款按钮 —— 与乐收赢同形(退款发起在 LeshouyingOpenController:229,管理端 LeshouyingRefundController 是只读视图)。 汇安融订单在管理端已有列表/详情/同步,退款后的状态就显示在同一列上。
  2. 不建 pay_har_refund 表。 理由三条,缺一不可: ① 上游只支持全额退款(F2),一笔订单最多一条退款记录,没有「多笔/部分」要管理; ② 退款状态本就是订单状态的一个取值(F6),再造一张表就有了两个真相源; ③ 上游 detail 结构未公开(F4),没有可存的字段,建表只能存我们猜的列。 幂等由状态机承担:第二笔在 trade_status 已是 4/2 时被拒。
  3. 状态写入顺序:先条件更新到 4,再调上游。 反过来(先调上游再写)的致命窗口是 超时:钱可能已经退了,而本地还是 1 支付成功(终态、不轮询),调用方一重试就是第二笔退款指令。 先写 4 则超时后停在「退款中」,轮询会去问上游(F6/F8),自己收敛。
  4. 上游明确拒绝(response_code 非成功码)⇒ 条件回退到 1;回退同样带 WHERE trade_status='4',避免把中途到达的「退款成功」通知覆盖掉。
  5. 结果未知(抛 HarApiException,如超时)⇒ 不回退,保持 4 交给轮询,并把这句话 原样告诉调用方(「结果未知,已置为退款中」),不谎报成功也不谎报失败。
  6. 不解析表 9 的 detail(F4:结构未公开)。猜字段名就是捏造;收敛靠通知(F7)与轮询。
  7. 本方法不加 @Transactional —— 同 HarOrderService.create 的理由:上游抛异常时 本地状态必须存活,回滚会把「退款中」一起抹掉,正好退回第 3 条要避免的那个窗口。

文件清单

动作 文件 职责
Create java/.../har/client/dto/HarRefundRequest.java 上游退款业务参数(order_no / third_order_no)
Modify java/.../har/client/HarClient.java METHOD_REFUND + refund(...)
Create java/.../har/dto/HarOpenRefundDTO.java 开放 API 退款入参
Modify java/.../har/service/HarOrderService.java openRefund + 抽出 requireOpenOrder(与 openQuery 共用归属比对)
Modify java/.../har/controller/open/HarOpenController.java POST /refund
Modify frontend/src/views/module_payment/apikey/docs/har-openapi.md 10.4 退款 + 改掉「本章没有退款接口」那行
Test java/.../har/service/HarOrderServiceTest.java 退款服务层用例
Test java/.../har/client/HarClientOutboundUrlTest.java 出站 URL 正控
Test java/.../har/controller/open/HarOpenControllerTest.java(新建或并入既有) 端点接线

Task 1: 上游客户端 —— HarRefundRequest + HarClient.refund

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarRefundRequest.java
  • Modify: java/src/main/java/com/payment/platform/module/payment/har/client/HarClient.java
  • Test: java/src/test/java/com/payment/platform/module/payment/har/client/HarClientOutboundUrlTest.java

Interfaces:

  • Produces: HarClient.refund(HarRefundRequest) → HarResponse;HarRefundRequest#setOrderNo/setThirdOrderNo

  • [ ] Step 1: 写失败测试 —— refund_hitsTheAlipayRefundPath:起本地 HttpServer, 调 new HarClient(props).refund(req),断言收到的 path == /kf-alipay-api/api/alipay/trade/refund、form 的 method == kf.alipay.trade.refund

  • [ ] Step 2: 跑测试看它红 —— HarClient.refund 不存在 ⇒ 编译失败即红

  • [ ] Step 3: 实现 —— DTO 照 HarQueryRequest 写(@JsonInclude(NON_NULL)); HarClient 加常量与方法

  • [ ] Step 4: 跑测试看它绿

  • [ ] Step 5: Commit

Task 2: 服务层 —— openRefund + requireOpenOrder 抽取

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/har/dto/HarOpenRefundDTO.java
  • Modify: java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java
  • Test: java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

Interfaces:

  • Consumes: Task 1 的 HarClient.refund
  • Produces: HarOrderService.openRefund(HarOpenRefundDTO) → HarOrderVO

  • [ ] Step 1: 写失败测试(逐条,每条先看它红)

    • openRefund_paidOrder_preemptsToRefundingBeforeCallingUpstream(InOrder:orderMapper.update 先于 harClient.refund)
    • openRefund_success_keepsRefundingAndSchedulesPoll(next_query_time 非空)
    • openRefund_preemptIsConditional_onPaidStatus(捕获 UpdateWrapper,SET 含 trade_status,WHERE 含 id 与 trade_status)
    • openRefund_preemptMissed_throwsAndDoesNotCallUpstream(update 返回 0 ⇒ 抛且不调上游)
    • openRefund_waitPayOrder_rejected / _refundingOrder_rejected / _refundedOrder_rejected
    • openRefund_upstreamTimeout_keepsRefundingState(抛 HarApiException ⇒ 状态不回退)
    • openRefund_upstreamNonSuccess_rollsBackToPaid(条件回退,WHERE 仍带 trade_status='4')
    • openRefund_sendsUpstreamOrderNoAsOrderNo / _fallsBackToThirdOrderNo
    • openRefund_otherEnterpriseOrder_throwsOrderNotFound(越权口径同 openQuery)
  • [ ] Step 2: 跑测试看全红

  • [ ] Step 3: 实现 —— 抽 requireOpenOrder(openQuery 改为复用它,行为逐字不变)

  • [ ] Step 4: 跑 HarOrderServiceTest 全绿,并跑既有 openQuery 用例确认抽取没改行为

  • [ ] Step 5: Commit

Task 3: 端点 —— POST /payment/openapi/har/refund

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

  • [ ] Step 1: 写失败测试 —— 断言 /refund 路由存在、requireMerchant 在 openRefund 之前被调用

  • [ ] Step 2: 跑测试看它红

  • [ ] Step 3: 实现 —— 照 orderQuery 写,requireMerchant(dto.getEnterpriseId(), dto.getMerchantCode()) 第一行

  • [ ] Step 4: 跑测试看它绿

  • [ ] Step 5: Commit

Task 4: 租户文档

Files:

  • Modify: frontend/src/views/module_payment/apikey/docs/har-openapi.md

  • [ ] Step 1: 改第 16 行「本章没有退款接口」→ 指向新增的 10.4

  • [ ] Step 2: 新增 10.4 退款(原 10.4/10.5 顺延为 10.5/10.6),含:请求字段表、 B 型响应、全额退款、不幂等性说明(同一笔订单第二笔会被拒)、 「同步返回不代表已退款到账,以 10.6 的状态 / 回调为准」

  • [ ] Step 3: 跑 pnpm --dir frontend ts:check 前后差分(本仓基线非零,只看差分)

  • [ ] Step 4: Commit

Task 5: 全量验证

  • Step 1: mvn test(需库隧道)—— 记录总数与失败数
  • Step 2: 两向变异 —— 把条件更新的 WHERE trade_status='1' 去掉 ⇒ 对应用例必须红;还原 ⇒ 必须绿
  • Step 3: 交付说明(未 push,提交清单)