# 汇安融退款 实现计划 > **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,提交清单)