Selaa lähdekoodia

docs(汇安融): 接入文档补 10.4 退款,回调/状态两节顺延为 10.5/10.6

租户侧文档原先在能力表里写着「本章没有退款接口」,现在退款端点已开放,
那句话变成假话 —— 本节把它改成「只有全额退款,没有金额可填,见 10.4」,
并按端点数量把三处「三个接口」改成四个。

新的 10.4 只写实测/代码里能核到的东西:
- 只有全额退款(上游 5.4 的 biz 没有金额字段);
- 同步返回时 trade_status 是 "4" **不是** "2",回执不代表到账;
- 结果收敛到 "2"(钱已退回)或回到 "1"(退款没有发生);
- 只有 trade_status="1" 的订单能退,其它状态 code=500;
- 重复调用**不会重复退款**(第二次被状态挡下),但它**不是幂等接口** ——
  拿到的是错误而不是第一次的结果,所以不能写「重试到成功为止」;
- 失败两条路处理相反:上游明确拒绝 ⇒ 订单退回 "1",可重新发起;
  上游未响应 ⇒ 订单停在 "4" 且**不可重发**,只能查。两者都是 code=500,
  只能靠 msg 区分 —— 这一条是本节最要紧的,所以单独加了警示框。

顺带订正两处因本次改动而失真的说法:10.2 响应表里 `refund_time` 原写
「本章无退款接口,该字段恒为空」;10.6 补一句「退款是否到账只看 "2"」。
联调检查清单加了 3 条退款项。

harDoc.ts 只改一句注释里的节号枚举(10.1~10.5 ⇒ 10.1~10.6),不动切片逻辑。

读数:
- 切片依赖的 `parts` 两个标题**整行字面**命中数各为 1(grep -F -x),
  第 10 章内 H3 顺序 10.1→10.6 单调;
- `pnpm ts:check` 输出与既有基线 `logs/_fe-tscheck-before.txt` **逐字节相同**
  (52272 B,rc=2 是该命令的已知基线);
- 该负读数配了正对照:往 harDoc.ts 注入 `const __probe: number = "x";` ⇒
  输出出现 `harDoc.ts(29,7): error TS2322`(证明 tsc 确实在读这个文件),
  还原后 sha256 回到 0d5ccb9490a1ad60。
alphaH 2 viikkoa sitten
vanhempi
sitoutus
ff7f517b31

+ 65 - 10
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -9,15 +9,15 @@
 | 项 | 5 章收款接口 | 本章 |
 |---|---|---|
 | 路径前缀 | `/payment/openapi/pay` | `/payment/openapi/har` |
-| 响应信封 | 三个下单接口 A 型,其余 B 型 | **3 个接口全部 B 型** |
+| 响应信封 | 三个下单接口 A 型,其余 B 型 | **4 个接口全部 B 型** |
 | 金额 | 整数,单位**分** | 小数,单位**元** |
-| 订单状态 | 文本枚举(`SUCCESS` / `PAYING`…),见 6 | **数字字符串**(`"0"` / `"1"`…),见 10.5 |
+| 订单状态 | 文本枚举(`SUCCESS` / `PAYING`…),见 6 | **数字字符串**(`"0"` / `"1"`…),见 10.6 |
 | 下单幂等 | 同 `out_trade_no` 重发返回原单 | **不幂等**,每次调用都新建一笔,见 10.2 |
-| 退款 | 有(5.6 / 5.7) | **本章没有退款接口** |
+| 退款 | 有(5.6 / 5.7),可指定金额 | **只有全额退款**,没有金额可填,见 10.4 |
 
 > ⚠️ **「不幂等」与「金额单位是元」这两条最容易出事。** 前者会让你在超时重发时收到两笔扣款,后者会把 1 元写成 1 分。请读完 10.2 的注意框再动手。
 
-**关于寻址**:本章三个接口都按**你这条 API Key 所属的租户账号**寻址,取该租户名下由平台运营报备好的商户配置。你不需要在请求里指定归属。
+**关于寻址**:本章四个接口都按**你这条 API Key 所属的租户账号**寻址,取该租户名下由平台运营报备好的商户配置。你不需要在请求里指定归属。
 
 **关于 `merchant_code`(新增,2026-09-24)**:平台**允许一个租户报备多个商户号**(例如你名下有多家门店主体,各自独立结算)。这时「这笔钱收进哪一个商户」必须由你在请求里说清楚,用 `merchant_code` 指定。
 
@@ -26,7 +26,7 @@
 
 > ⚠️ **你租户下配了多个商户号、而你又不传 `merchant_code` 时,接口会直接**拒绝**(`code=400`,`msg` 为 `当前租户配置了多个商户编号,请在请求中指定 merchant_code`),不会替你挑一个。**这是有意的** —— 挑错了钱会进另一个商户的账户,而且全程没有任何报错,要等到对账才发现。**只配了一个商户号的调用方不受影响**,什么都不用改。
 
-> **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。三个接口都填同一个 `merchant_code` 即可。
+> **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。四个接口都填同一个 `merchant_code` 即可。
 
 **门店与下单的关系**:10.2 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
 
@@ -114,15 +114,15 @@ POST /payment/openapi/har/order/create
 | `upstream_order_no` | 上游订单号。下单成功才有值,下单超时时为空 |
 | `platform_trade_no` | 平台交易号,支付成功后才有值 |
 | `pay_url` | **主扫**:收款链接,渲染成二维码即可。**被扫**:为空 |
-| `trade_status` | 订单状态,见 10.5 |
+| `trade_status` | 订单状态,见 10.6 |
 | `price` | 订单金额(元) |
 | `title` / `trade_type` / `stage_num` / `percent` | 原样返回 |
 | `shop_code` / `third_shop_code` | 原样返回 |
 | `trade_time` | 支付成功时间 |
 | `rcv_amount` | 实收金额(元),支付成功后有值 |
 | `fee_amount` / `channel_amount` | 手续费 / 渠道金额 |
-| `refund_time` | 退款时间。本章无退款接口,该字段恒为空 |
-| `notify_status` | 平台向你推送回调的结果,见 10.4 |
+| `refund_time` | 退款时间。退款到账(`trade_status` 为 `"2"`)后才有值 |
+| `notify_status` | 平台向你推送回调的结果,见 10.5 |
 | `created_time` / `updated_time` | 记录时间 |
 
 > ⚠️ **本接口不幂等 —— 重复调用会产生两笔订单、两次扣款。** 每次调用都会生成一个新的 `order_no` 并向上游发起一笔新的交易,平台**不做** `out_biz_no` 去重(也不做唯一约束)。超时后不要直接重发:先用 **10.3 订单查询** 按 `out_biz_no` 查一次,查到就用那一笔的结果。
@@ -163,7 +163,58 @@ POST /payment/openapi/har/order/query
 
 ---
 
-### 10.4 回调通知
+### 10.4 订单退款
+
+对一笔**已支付成功**的订单发起退款。
+
+```
+POST /payment/openapi/har/refund
+```
+
+**请求**(`order_no` 与 `out_biz_no` **至少提供一个**,规则同 10.3)
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它三个接口同一个值 |
+| `order_no` | string | 二选一 | 平台订单号(10.2 响应里的 `order_no`) |
+| `out_biz_no` | string | 二选一 | 你自己的订单号 |
+
+> ⚠️ **本接口只有全额退款,请求里没有金额字段。** 这条通道的上游不支持部分退款 —— 发起即退整笔。需要退一部分的场景请先联系平台。
+
+**响应**(B 型,`data` 内):结构与 10.3 完全相同(同一份订单)。
+
+> ⚠️ **返回时 `trade_status` 是 `"4"`(退款中),不是 `"2"`。** 同步返回只代表平台已把退款指令发给上游,**不代表钱已经退回用户账上**。
+
+**结果怎么收敛**:`"4"` 由平台的查询任务持续向上游确认,直到拿到终态(上游若推了通知,平台也会转推给你,见 10.5):
+
+| 最终 `trade_status` | 含义 |
+|---|---|
+| `"2"` | **退款成功,钱已退回** |
+| `"1"` | 退款**没有发生**,订单回到已支付 |
+
+> ⚠️ **不要把 `"4"` 当成已退款,也不要凭它给用户承诺到账。** 用户看到的是钱有没有回来,不是接口返回了什么。同 10.5 的提醒:通知可能丢,**请对停在 `"4"` 的订单做主动查询兜底**。
+
+> ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.3 那句 `code=500` 的 `订单不存在`。
+
+**重复调用会怎样** —— 这是最容易误解的一处:
+
+- 退款**不会重复发生**。第二次调用时订单已不在 `"1"`,会被上面那句错误挡下。
+- 但它**不是幂等接口**:重复调用拿到的是**错误**,不是第一次的结果。所以不要写「重试到成功为止」,要按下面的分支处理。
+
+**失败分两条路,处理方式相反**:
+
+| 返回 | 订单状态 | 你该做什么 |
+|---|---|---|
+| `code=500`,`msg` 形如 `退款失败: <原因>` | **退回 `"1"`** | 上游明确拒绝了,什么都没发生。可用 10.3 确认状态是 `"1"` 后**重新发起** |
+| `code=500`,`msg` 为 `退款结果未知(上游未响应),订单已置为退款中,请稍后查询订单状态确认` | **保持 `"4"`** | 上游没应答,**这笔退款可能已经发生**。**不要重发** —— 用 10.3 查到终态为止 |
+
+> ⚠️ **两条路都是 `code=500`,只能靠 `msg` 区分。** 拿不准时一律按「结果未知」处理(不重发、去查询):重发一笔可能已经退掉的款是真实资金损失,多查一次只是麻烦。
+
+> ⚠️ **「退款失败就再退一次」的前提是订单已回到 `"1"`,不是你上次调用的返回。** 并发重复发起时只有一次会成功,另一次会被状态挡下 —— 但两次的返回里只有一次是「已受理」,别把两者搞混。
+
+---
+
+### 10.5 回调通知
 
 订单状态变化时,平台会向你推送一条回调。
 
@@ -224,7 +275,7 @@ Content-Type: application/x-www-form-urlencoded
 
 ---
 
-### 10.5 状态说明
+### 10.6 状态说明
 
 **`trade_status` 是数字字符串**(JSON 里带引号,不是数字):
 
@@ -237,6 +288,7 @@ Content-Type: application/x-www-form-urlencoded
 | `"8"` | 交易关闭 | 是 |
 
 判断「是否收到过钱」用 `"1"` / `"2"`;判断「是否还要等」用 `"0"` / `"4"`。
+判断「退款是否已经到账」只看 `"2"` —— 停在 `"4"` 表示平台还在向上游确认,**不代表已退**(见 10.4)。
 
 **门店 `create_status`**:`SUCCESS` / `FAILED` / `PENDING`,见 10.1。
 
@@ -261,4 +313,7 @@ Content-Type: application/x-www-form-urlencoded
 - [ ] 回调接口能处理表单编码,并把 `content` **再解析一次** JSON
 - [ ] 回调按 `out_biz_no` 做幂等
 - [ ] 对长期停在 `"0"` 的订单有主动查询兜底
+- [ ] 退款只对 `trade_status = "1"` 的订单发起,且发起前用 10.3 确认过没有退过
+- [ ] 退款返回 `退款结果未知…` 时**不重发**,改用 10.3 查到终态
+- [ ] 退款结果按 `"2"`(已到账)判定,不把 `"4"` 当已退款
 - [ ] 门店 `create_status` 收到 `PENDING` 时走人工核实,不自行重发

+ 1 - 1
frontend/src/views/module_payment/apikey/harDoc.ts

@@ -11,7 +11,7 @@ import { buildSections, type DocSectionDef } from "./docSlicer";
  * 侧边栏条目定义。键即 `activeSection` 的值。
  * `parts` 里的标题必须与 md 里的标题**逐字一致**(含空格、全角括号)。
  *
- * 整章只给一个条目,不再拆成 10.1~10.5 —— 章首那张差异表(金额单位、幂等性)
+ * 整章只给一个条目,不再拆成 10.1~10.6 —— 章首那张差异表(金额单位、幂等性)
  * 必须先读,拆开会让读者直接跳到 10.2 下单。
  * 注意:`take("## 10. …")` 会一并吞掉其下所有 `###` 子块,所以这里**不能**改写成
  * 「## 10 + 各 ### 分开列」—— 那样差异表会被重复渲染五次。