Bläddra i källkod

docs(汇安融): 退款计划与交付说明入库

计划与交付说明此前只在工作区(未跟踪),按本仓惯例入 `.claude/plan/`。

交付说明写的是**交付那一刻的实测状态**,逐条记了还没验证的东西,其中两条
值得单独看:

1. 退款端到端一次都没跑过 —— method 取 `kf.alipay.trade.refund` 是推断
   (规范三处写它、表 8 的备注格写 `kf.jh.stage.trade.refund`,而 precreate
   的先例证明备注格那列不可信),不是上游确认过的事实。

2. 退款收敛靠既有轮询 `findPendingForPoll`,它用 `HarOrderStatus.PENDING = {0,4}`,
   而全测试树里 `HarOrderStatus.PENDING` / `isPending` / `FINAL` 的引用数为 0
   (grep 实测)⇒ 谁把 `4` 从这个集合里去掉,退款会永远停在「退款中」
   且没有任何测试变红。本批没有补这条钉子,如实记在交付说明里。

本提交只加两份 md,不动任何代码。
alphaH 3 veckor sedan
förälder
incheckning
a8ccef3791
2 ändrade filer med 199 tillägg och 0 borttagningar
  1. 49 0
      .claude/plan/2026-09-24-har-refund-交付说明.md
  2. 150 0
      .claude/plan/2026-09-24-har-refund.md

+ 49 - 0
.claude/plan/2026-09-24-har-refund-交付说明.md

@@ -0,0 +1,49 @@
+# 汇安融退款 交付说明(2026-09-24)
+
+计划:`.claude/plan/2026-09-24-har-refund.md`(同批入库)
+
+## 一、提交清单(5 个,均未 push)
+
+| 提交 | 内容 |
+|---|---|
+| `4b896a0` | 上游客户端加退款 —— `kf.alipay.trade.refund`(`HarClient#refund` + 出站 URL 用例) |
+| `b36f397` | 退款服务 —— 先抢占「退款中」,再调上游(`HarOrderService#openRefund` + 12 条用例) |
+| `2f54088` | 退款端点 —— `POST /payment/openapi/har/refund`(控制器 + 5 条用例 + 认证清单一行) |
+| `ff7f517` | 租户文档补 10.4 退款,回调/状态顺延为 10.5/10.6 |
+| `a631b9f` | 顺带:`LeshouyingPropertiesTest` 的 yml 取值限定段内(并入 HAR 时 `har.base-url` 排在 `leshouying.base-url` 之前导致取错键;与退款无关,先前遗留未提交) |
+
+设计裁定(见计划):只有开放 API 入口、不建 `pay_har_refund` 表、先抢占后退款、
+上游明确拒绝才回退、结果未知不回退、不解析上游 detail、不加 `@Transactional`。
+
+## 二、验证读数
+
+- `mvn test` 全量 **871 条,0 失败 0 错误**(`logs/_har-refund-full.log`)
+- `HarOrderServiceTest` 46 条(原 34 + 新 12)、`HarOpenControllerTest` 11 条(原 6 + 新 5)
+- 两向变异(脚本 `logs/_har-refund-svc-mutate.py`、`logs/_har-ctl-mutate.py`;每次还原后复核 sha256 与注入前逐字节一致):
+
+| 变异体 | 结果 |
+|---|---|
+| 去掉抢占守卫 `WHERE trade_status='1'` | 红 1(`openRefund_preemptCarriesPaidGuardAndSchedulesPoll`) |
+| 上游调用挪到抢占之前 | 红 3(含 `openRefund_preemptsToRefundingBeforeCallingUpstream`) |
+| 超时分支也回退 | 红 1(`openRefund_upstreamTimeout_keepsRefundingState`) |
+| 认证清单漏列 `/refund` | 红 1(`everyEndpoint_isListedInTheAuthFilterInventory`) |
+| 控制器不设闸 | 红 3 |
+| 闸挪到 `openRefund` 之后 | 红 2(404 照样抛得出,是 `verifyNoInteractions` 抓住的顺序) |
+
+## 三、部署要求
+
+**新端点,必须重新部署才生效。** 无 SQL、无配置项、无菜单权限串改动。
+
+## 四、未验证 / 未做(逐条,均为交付时的实测状态)
+
+1. **退款端到端从未真机跑过** —— 一条真实退款都还没发起。
+2. **method 取值未被上游确认**:我们发 `kf.alipay.trade.refund`。上游规范里三处写它、表 8 的 `method` 备注格写 `kf.jh.stage.trade.refund`;precreate 的先例证明备注格那一列不可信,所以按多数取值 —— 但这是推断,不是实测。
+3. **上游表 9 的 `detail` 结构从未公开**,我们不解析它。成功判据只用了 `biz_content` / `response_code` / `msg`。若上游实际语义与此不符,只会在联调中暴露。
+4. **退款收敛(`4` → `2`)没有任何用例钉住**:`findPendingForPoll` 用 `HarOrderStatus.PENDING = {0,4}`,而全测试树里 `HarOrderStatus.PENDING` / `isPending` / `FINAL` 的引用数为 **0**。谁把这个集合里的 `4` 去掉,退款会永远停在「退款中」而**没有任何测试变红**。
+5. **管理端无退款入口**(裁定:只做开放 API)。`pay_har_refund` 表不存在(裁定:全额退款 + 状态是订单状态的一个维度 + detail 不公开 ⇒ 没有列可存)。
+6. **不支持部分退款、请求里没有金额** —— 上游 5.4 的 biz 只有 `order_no` / `third_order_no`。
+
+## 五、与本批无关的悬置项
+
+- `third_shop_code` 取值契约仍与上游互相矛盾(明文 19 位 ⇒ 被私钥解密报错;342 字符密文 ⇒ 「最长 32 位」)。已按单变量方式重发请求待上游答复。
+- 被扫(`auth_code`)仍未联调 —— 它由 `auth_code` 非空触发(`HarOrderService.java:339`),我们没有模式开关。

+ 150 - 0
.claude/plan/2026-09-24-har-refund.md

@@ -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,提交清单)