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