浏览代码

docs(统一支付): 开放文档与侧边栏折叠

alphaH 1 周之前
父节点
当前提交
eea868606c

+ 290 - 0
frontend/src/views/module_payment/apikey/docs/unified-openapi.md

@@ -0,0 +1,290 @@
+## 1. 概述
+
+「统一支付平台」是平台接入的**另一条产品线**。它与「支付宝收单」**不是同一套接口**:端点路径、金额单位、状态枚举都不同,两份文档的字段不可混用、枚举值不可互相套用。
+
+- **认证**:平台 **API Key**(请求头 `Authorization: ApiKey <key>` 与 `Signature`,按请求 JSON body 计算 HMAC-SHA256)。这**不是**上游的 MD5 签名 —— 上游签名由平台在服务端完成,调用方不需要、也不应该持有上游 `appSecret`。
+- **金额**:全链路金额的单位(unit)都是**分**(整型 int),1 元 = 100 分。不要按「元」传小数。
+- **报文体**:JSON,字段 snake_case。响应统一为 `{ code, msg, data }`,`code=0` 表示成功。
+- **业务错误**:按平台约定返回 **HTTP 200 + body `code`**(参数校验为 HTTP 400、认证失败为 HTTP 401/403)。判断成功请以 `code == 0` 为准,不要只看 HTTP 状态。
+- **租户**:只从 API Key 取,请求体里没有 `tenant_id`。
+- **`mch_no`**:租户只有一条商户配置时可以省略;配置了多条时**必须**在请求里指定,否则返回 400「当前租户配置了多个商户号,请在请求中指定 mch_no」——平台不替调用方挑。
+
+端点一览:
+
+| # | 方法 | 路径 | 说明 |
+|---|---|---|---|
+| 1 | POST | `/payment/openapi/unified/order/create` | 统一下单 |
+| 2 | POST | `/payment/openapi/unified/order/query` | 订单查询 |
+| 3 | POST | `/payment/openapi/unified/refund` | 统一退款 |
+| 4 | POST | `/payment/openapi/unified/refund/query` | 退款查询 |
+| 5 | POST | `/payment/openapi/unified/channel-user/jump-url` | 获取渠道用户跳转 URL |
+
+---
+
+## 2. 统一下单
+
+```
+POST /payment/openapi/unified/order/create
+```
+
+**请求字段**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `mch_no` | string | 否 | 租户配置了多个商户号时必填 |
+| `amount` | int | 是 | 金额,**单位分**,必须 > 0 |
+| `way_code` | string | 是 | 支付方式,见下方 16 种之一 |
+| `subject` | string | 是 | 商品标题,≤128 字符 |
+| `body` | string | 否 | 商品描述,≤512 字符;为空时平台用 `subject` 顶上 |
+| `out_trade_no` | string | 是 | 调用方订单号,≤64 字符,**幂等键**,缺失返回 400 |
+| `client_ip` | string | 否 | 发起支付的客户端 IP;不传取请求来源 IP |
+| `notify_url` | string | 否 | 本笔订单的下游回调地址,≤512 字符;传了才推这个地址 |
+| `return_url` | string | 否 | 上游同步跳转地址(浏览器),≤512 字符 |
+| `expired_time` | int | 否 | 过期时间(秒),默认 7200 |
+| `channel_extra` | object | 否 | 按 `way_code` 传入的额外参数,见下方规则。平台收到后序列化成紧凑 JSON 字符串再发上游 |
+| `ext_param` | string | 否 | 上游原样返回的透传参数 |
+
+> 分账(`division_mode`)不在本期范围:请求里不会转发该字段,传入也按未知字段忽略,不报错。
+
+**16 种 `wayCode` 与 `channelExtra` 规则**
+
+| `wayCode` | 支付方式 | `channelExtra` 规则 |
+|---|---|---|
+| `QR_CASHIER` | 聚合扫码(用户扫商家) | 可选 `payDataType=codeUrl/codeImgUrl`(上游默认 `codeUrl`) |
+| `AUTO_BAR` | 聚合条码(商家扫用户) | 必传付款码 |
+| `ALI_BAR` | 支付宝条码 | 必传付款码 |
+| `ALI_JSAPI` | 支付宝生活号 | 必传 `buyerUserId` |
+| `ALI_APP` | 支付宝 APP | 无额外参数 |
+| `ALI_WAP` | 支付宝 WAP | 可选 `payDataType=form/codeImgUrl/payUrl`(上游默认 `payUrl`) |
+| `ALI_PC` | 支付宝 PC 网站 | 可选 `payDataType=form/payUrl`(上游默认 `payUrl`) |
+| `ALI_QR` | 支付宝二维码 | 可选 `payDataType=codeUrl/codeImgUrl` |
+| `WX_BAR` | 微信条码 | 必传付款码 |
+| `WX_JSAPI` | 微信公众号 | 必传 `openid`;服务商模式另传 `wxAppId` |
+| `WX_LITE` | 微信小程序 | 必传 `openid`;服务商模式另传 `wxAppId` |
+| `WX_APP` | 微信 APP | 无额外参数 |
+| `WX_H5` | 微信 H5 | 无必传;已取得渠道用户 ID 时可选传 `openid`。返回 `orderState=0`、`payDataType=payurl`,`payData` 为承接页地址 |
+| `WX_NATIVE` | 微信扫码 | 可选 `payDataType=codeUrl/codeImgUrl` |
+| `YSF_BAR` | 云闪付条码 | 必传付款码 |
+| `YSF_JSAPI` | 云闪付 JSAPI | 文档未展开,按通用 JSAPI 透传 |
+
+**付款码键名**:条码类 `wayCode` 的付款码字段,平台**同时接受** `auth_code` 与 `authCode` 两个键名;转发上游时按平台配置只发其中一个(默认 `auth_code`)。调用方任选一种写法即可。
+
+**`payDataType` 默认值**:不传 `payDataType` 时,平台**不注入**默认值,由上游取它自己的默认值;上表括号里的默认值只是上游行为说明。二维码类可选 `codeUrl` / `codeImgUrl`。
+
+**敏感值**:付款码、`openid`、`buyerUserId` 的值**不落库、不进日志、不回显**。平台只保存 `channelExtra` 的键名,值只在本次请求的内存里使用一次。请调用方同样不要把这些值写进自己的日志。
+
+**幂等与重复提交**
+
+- 同一 `out_trade_no`、**参数相同**重复提交:返回已有订单,**不会**再打上游、不会重复扣款。
+- 同一 `out_trade_no`、**参数不同**:返回 `code=409`。
+- 缺 `out_trade_no`:返回 `code=400`。
+- 上游调用失败/超时时,平台可能已保留本地订单。不要直接换单号重发:用本单 `out_trade_no` 调订单查询确认,或原样重放(幂等键会把已有订单还给你)。
+
+**响应 `data`(订单 VO)**
+
+`order_no`、`out_trade_no`、`mch_no`、`way_code`、`amount`、`refunded_amount`、`refundable_amount`、`trade_status`、`upstream_pay_order_id`、`upstream_channel_order_no`、`pay_data_type`、`pay_data`、`err_code`、`err_msg`、`pay_time`、`created_time`、`updated_time`、`notify_status`。
+
+> `refundable_amount = amount - SUM(退款金额 WHERE 退款 state ∈ {0,1,2})` —— 在途退款也占用额度,与退款闸门同一公式。
+>
+> **`pay_data` 的渲染安全**:`pay_data` 是上游原样返回的内容(二维码 URL / 表单 HTML / 跳转链接)。渲染前**必须校验协议,只允许 `http/https`**;表单 HTML 只能放进受信页面,不要直接插进你的站点 DOM(防 XSS)。
+
+---
+
+## 3. 订单查询
+
+```
+POST /payment/openapi/unified/order/query
+```
+
+**请求字段**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `mch_no` | string | 否 | 多商户配置时必填 |
+| `order_no` | string | 二选一 | 平台订单号 |
+| `out_trade_no` | string | 二选一 | 调用方订单号 |
+
+`order_no` 与 `out_trade_no` 至少传一个;都传时以 `order_no` 为准。
+
+**响应**:与下单相同的订单 VO。
+
+**`trade_status` 状态枚举**
+
+| 值 | 含义 | 是否终态 |
+|---|---|---|
+| `0` | 订单生成 | 否 |
+| `1` | 支付中 | 否 |
+| `2` | 支付成功 | 否(可转 5) |
+| `3` | 支付失败 | 是 |
+| `4` | 已撤销 | 是 |
+| `5` | 已退款 | 是 |
+| `6` | 订单关闭 | 是 |
+
+状态直接采用上游枚举,平台不做本地转义。
+
+---
+
+## 4. 统一退款
+
+```
+POST /payment/openapi/unified/refund
+```
+
+**请求字段**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `mch_no` | string | 否 | 多商户配置时必填,且必须与订单快照一致 |
+| `order_no` | string | 二选一 | 平台订单号 |
+| `out_trade_no` | string | 二选一 | 调用方订单号 |
+| `refund_amount` | int | 是 | 退款金额,**单位分**,必须 > 0 |
+| `refund_reason` | string | 是 | 退款原因,≤64 字符 |
+| `out_refund_no` | string | 是 | 调用方退款单号,≤64 字符,**幂等键**,缺失返回 400 |
+| `notify_url` | string | 否 | 本笔退款的下游回调地址,≤512 字符 |
+| `ext_param` | string | 否 | 透传参数 |
+
+**规则**
+
+- 只有 `trade_status = 2`(支付成功)的订单可以退款,否则返回 400「订单未支付成功,不能退款」。
+- 同一 `out_refund_no`、**参数相同**重复提交:返回已有退款单;**参数不同**:返回 `code=409`。
+- 退款金额不得超过 `refundable_amount`(在途退款也占用额度);超额返回 400。
+- 同一订单可多次部分退款,每次用不同的 `out_refund_no`。
+
+**响应 `data`(退款 VO)**
+
+`refund_no`、`out_refund_no`、`order_no`、`mch_no`、`amount`、`state`、`upstream_refund_order_id`、`upstream_channel_order_no`、`err_code`、`err_msg`、`success_time`、`created_time`、`updated_time`、`notify_status`。
+
+**退款 `state` 枚举**
+
+| 值 | 含义 | 是否终态 |
+|---|---|---|
+| `0` | 退款生成 | 否 |
+| `1` | 退款中 | 否 |
+| `2` | 退款成功 | 是 |
+| `3` | 退款失败 | 是 |
+| `4` | 退款关闭 | 是 |
+
+---
+
+## 5. 退款查询
+
+```
+POST /payment/openapi/unified/refund/query
+```
+
+**请求字段**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `mch_no` | string | 否 | 多商户配置时必填 |
+| `refund_no` | string | 二选一 | 平台退款单号 |
+| `out_refund_no` | string | 二选一 | 调用方退款单号 |
+
+`refund_no` 与 `out_refund_no` 至少传一个;都传时以 `refund_no` 为准。
+
+**响应**:与退款相同的退款 VO。`state=2/3/4` 是终态,不需要再轮询。
+
+> 不要只依赖回调判断退款结果:回调可能因网络原因延迟或失败(最多投递 3 次)。退款查询是最终依据。
+
+---
+
+## 6. 获取渠道用户 ID
+
+```
+POST /payment/openapi/unified/channel-user/jump-url
+```
+
+上游获取渠道用户 ID 的接口是 GET + 浏览器 302,并且要用 `appSecret` 对 URL 参数签名。**因此这一步必须由服务端完成**:调用方用自己的 API Key 向平台换一条已签名的上游 URL,再让浏览器 302 过去。**浏览器不接触密钥**,调用方也不应自己拼接上游地址。
+
+**请求字段**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `mch_no` | string | 否 | 多商户配置时必填 |
+| `redirect_url` | string | 是 | 授权完成后浏览器跳回的地址,必须是 `http/https` |
+| `if_code` | string | 否 | 渠道标识:`fuioupay`(服务商模式)/ `wxpay`(自有公众号)/ `AUTO`;空则用商户配置,默认 `fuioupay` |
+| `ext_param` | string | 否 | 透传参数,原样带到上游 |
+
+**响应**
+
+| 字段 | 说明 |
+|---|---|
+| `url` | 含签名的完整上游跳转地址(`appSecret` 只参与签名,不出现在 URL 里) |
+
+拿到 `url` 后把**用户浏览器**跳过去。授权完成后,上游会回跳 `redirect_url` 并带上 `channelUserId`、`channelAppId`、`appId`、`extParam`:
+
+- `WX_JSAPI` / `WX_LITE`:把 `channelUserId` 放进 `channel_extra.openid`,`channelAppId` 放进 `channel_extra.wxAppId`;
+- `ALI_JSAPI`:把 `channelUserId` 放进 `channel_extra.buyerUserId`。
+
+---
+
+## 7. 异步通知
+
+回调是 **form POST**(`application/x-www-form-urlencoded`),字段固定为:
+
+| 字段 | 值 |
+|---|---|
+| `notify_id` | 确定性事件 ID:订单事件为 `uo_<order_no>_<trade_status>`,退款事件为 `ur_<refund_no>_<state>`。同一事件的重试保持同一个 id,接收方可直接按它去重 |
+| `timestamp` | 当前毫秒时间戳 |
+| `content` | JSON 字符串,结构见下 |
+
+**`content` 结构**
+
+```json
+{
+  "event": "ORDER",
+  "order": {
+    "order_no": "UP...",
+    "out_trade_no": "C...",
+    "mch_no": "M...",
+    "way_code": "QR_CASHIER",
+    "amount": 100,
+    "refunded_amount": 0,
+    "refundable_amount": 100,
+    "trade_status": "2",
+    "upstream_pay_order_id": "P...",
+    "upstream_channel_order_no": "...",
+    "pay_time": "2026-10-20T12:00:00+08:00",
+    "err_code": null,
+    "err_msg": null
+  },
+  "upstream": { "state": "2", "successTime": 1760000000000 }
+}
+```
+
+退款事件的 `event` 为 `REFUND`,带 `refund` 对象(`refund_no`、`out_refund_no`、`order_no`、`amount`、`state`、`upstream_refund_order_id`、`success_time`、`err_code`、`err_msg`),并**同时带 `order` 的当前快照**(`trade_status`、`refunded_amount`、`refundable_amount`)。
+
+**事件类型**
+
+| 本地变化 | 推 ORDER | 推 REFUND | 说明 |
+|---|---|---|---|
+| `trade_status` 变为 `2` | 是 | — | 支付成功 |
+| `trade_status` 变为 `3/4/6` | 是 | — | 失败 / 撤销 / 关闭 |
+| `trade_status` 从 `2` 变为 `5`(全额退款导致) | 是 | 见下行 | 订单终态 5 |
+| 退款 `state` 变为 `2/3/4` | — | 是 | 退款成功 / 失败 / 关闭 |
+| 部分退款成功(订单仍为 `2`) | **否** | 是 | 订单状态没变;退款事件里带订单快照,新的 `refunded_amount` 从那里取 |
+
+`trade_status` 变为 `1` 与退款 `state` 变为 `1` 是过程态,**不推送**。
+
+**投递与去重**
+
+- 接收方返回任意 **2xx** 即视为成功;非 2xx 或异常记为 `FAILED`。
+- 失败后平台每分钟重试一次,`notify_count` **含首次**,即**最多投递 3 次**(首次 + 2 次重试)。超过后不再自动重试。
+- `notify_id` 在同一事件的所有重试里不变,接收方**可去重**:收到相同 `notify_id` 直接返回 2xx 即可。
+- 回调地址优先级:订单/退款快照 `callback_url` → API Key 配置的 `return_url` → 租户 `open_conf.return_url`。退款先看退款快照,再看订单快照。
+- 地址为空时平台记为 `FAILED`(不会静默跳过);补配地址后重试仍可能捞起。
+- 下游回调**不带签名**。接收方如果对来源有疑问,应以订单查询 / 退款查询的结果为准,不要仅凭回调报文发货或退款。
+
+---
+
+## 8. 联调检查清单
+
+- [ ] 用 **1 分或 1 元的小额实弹**跑通「下单 → 支付 → 订单查询 → 全额退款 → 退款查询」,并核对金额单位是**分**。
+- [ ] **回调地址公网可达**:`notify_url` 不要填 `localhost`、`127.0.0.1` 或内网地址,测试环境也要能被平台访问到。平台拒绝 `localhost`、`127.0.0.0/8`、`::1`、`0.0.0.0`、`169.254.169.254`。
+- [ ] **退款查询**单独验证:不只等回调,用 `refund_no` / `out_refund_no` 主动查一次,确认 `state=2`。
+- [ ] **幂等重放**:同一 `out_trade_no` 原样重复下单,应返回同一订单;改参数重放应返回 `code=409`。退款同理(`out_refund_no`)。
+- [ ] **16 种 `wayCode`** 按业务需要逐个确认 `channelExtra`:条码类传付款码,`ALI_JSAPI` 传 `buyerUserId`,`WX_JSAPI`/`WX_LITE` 传 `openid`(服务商模式另传 `wxAppId`)。
+- [ ] **`pay_data` 渲染**:先校验协议只允许 `http/https`;表单 HTML 只放进受信页面。
+- [ ] **回调幂等**:用 `notify_id` 去重,重复通知不重复记账。
+- [ ] **渠道用户 ID** 走平台的服务端接口换 URL,浏览器与前端代码里不出现 `appSecret`。
+- [ ] 配置了多个 `mch_no` 时,每个请求都显式指定它。

+ 175 - 105
frontend/src/views/module_payment/apikey/index.vue

@@ -386,109 +386,148 @@
           <!-- 左侧导航 -->
           <div class="docs-sidebar">
             <el-scrollbar>
-              <div class="sidebar-section">
-                <h3>认证</h3>
-                <ul>
-                  <li @click="activeSection = 'auth'" :class="{ active: activeSection === 'auth' }">
-                    认证方式
-                  </li>
-                  <li
-                    @click="activeSection = 'signature'"
-                    :class="{ active: activeSection === 'signature' }"
-                  >
-                    签名验证
-                  </li>
-                  <li
-                    @click="activeSection = 'notes'"
-                    :class="{ active: activeSection === 'notes' }"
-                  >
-                    注意事项
-                  </li>
-                </ul>
-              </div>
-              <div class="sidebar-section">
-                <h3>账户接口</h3>
-                <ul>
-                  <li
-                    @click="activeSection = 'transfer'"
-                    :class="{ active: activeSection === 'transfer' }"
-                  >
-                    发起转账
-                  </li>
-                  <li
-                    @click="activeSection = 'transfer_query'"
-                    :class="{ active: activeSection === 'transfer_query' }"
-                  >
-                    查询转账
-                  </li>
-                  <li
-                    @click="activeSection = 'balance'"
-                    :class="{ active: activeSection === 'balance' }"
-                  >
-                    账户余额
-                  </li>
-                </ul>
-              </div>
-              <div class="sidebar-section">
-                <h3>回调通知</h3>
-                <ul>
-                  <li
-                    @click="activeSection = 'callback'"
-                    :class="{ active: activeSection === 'callback' }"
-                  >
-                    通知接口
-                  </li>
-                </ul>
-              </div>
               <!--
-                下面两组各由一支切片器从**各自的** md 切出来 —— 侧边栏、卡片标题、正文
-                三处同源。标题在 md 里被改字则对应条目自动消失(不是留下一个空白页)。
-                两条通道是两个产品、两份文档,不合并(2026-09-24 拆分)。
+                7 组可收起;默认全部收起(openedDocGroups 为空数组)。
+                组名与视觉顺序固定为 auth / account / callback / har / collection / unified / other。
               -->
-
-              <!-- 支付宝收单(harDoc.ts ← docs/har-openapi.md)—— 2026-09-30 由「门店与订单」改名 -->
-              <div class="sidebar-section">
-                <h3>支付宝收单</h3>
-                <ul>
-                  <li
-                    v-for="(section, key) in harSections"
-                    :key="key"
-                    @click="activeSection = key"
-                    :class="{ active: activeSection === key }"
-                  >
-                    {{ section.title }}
-                  </li>
-                </ul>
-              </div>
-
-              <!-- 银联收单(collectionDoc.ts ← docs/collection-openapi.md)—— 2026-09-30 由「收付款接口」改名并露出 -->
-              <div v-if="SHOW_COLLECTION_DOC" class="sidebar-section">
-                <h3>银联收单</h3>
-                <ul>
-                  <li
-                    v-for="(section, key) in collectionSections"
-                    :key="key"
-                    @click="activeSection = key"
-                    :class="{ active: activeSection === key }"
-                  >
-                    {{ section.title }}
-                  </li>
-                </ul>
-              </div>
-              <div class="sidebar-section">
-                <h3>其他</h3>
-                <ul>
-                  <li
-                    @click="activeSection = 'errors'"
-                    :class="{ active: activeSection === 'errors' }"
-                  >
-                    常见错误
-                  </li>
-                  <li @click="activeSection = 'php'" :class="{ active: activeSection === 'php' }">
-                    PHP示例代码
-                  </li>
-                </ul>
-              </div>
+              <el-collapse v-model="openedDocGroups" class="docs-collapse">
+                <el-collapse-item title="认证" name="auth">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        @click="activeSection = 'auth'"
+                        :class="{ active: activeSection === 'auth' }"
+                      >
+                        认证方式
+                      </li>
+                      <li
+                        @click="activeSection = 'signature'"
+                        :class="{ active: activeSection === 'signature' }"
+                      >
+                        签名验证
+                      </li>
+                      <li
+                        @click="activeSection = 'notes'"
+                        :class="{ active: activeSection === 'notes' }"
+                      >
+                        注意事项
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+                <el-collapse-item title="账户接口" name="account">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        @click="activeSection = 'transfer'"
+                        :class="{ active: activeSection === 'transfer' }"
+                      >
+                        发起转账
+                      </li>
+                      <li
+                        @click="activeSection = 'transfer_query'"
+                        :class="{ active: activeSection === 'transfer_query' }"
+                      >
+                        查询转账
+                      </li>
+                      <li
+                        @click="activeSection = 'balance'"
+                        :class="{ active: activeSection === 'balance' }"
+                      >
+                        账户余额
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+                <el-collapse-item title="回调通知" name="callback">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        @click="activeSection = 'callback'"
+                        :class="{ active: activeSection === 'callback' }"
+                      >
+                        通知接口
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+                <!--
+                  下面三组各由一支切片器从**各自的** md 切出来 —— 侧边栏、卡片标题、正文
+                  三处同源。标题在 md 里被改字则对应条目自动消失(不是留下一个空白页)。
+                  三条产品线是三份文档,不合并(2026-09-24 拆分两条,2026-10-20 增加统一支付)。
+                -->
+
+                <!-- 支付宝收单(harDoc.ts ← docs/har-openapi.md)—— 2026-09-30 由「门店与订单」改名 -->
+                <el-collapse-item title="支付宝收单" name="har">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        v-for="(section, key) in harSections"
+                        :key="key"
+                        @click="activeSection = key"
+                        :class="{ active: activeSection === key }"
+                      >
+                        {{ section.title }}
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+
+                <!-- 银联收单(collectionDoc.ts ← docs/collection-openapi.md)—— 2026-09-30 由「收付款接口」改名并露出 -->
+                <el-collapse-item
+                  v-if="SHOW_COLLECTION_DOC"
+                  title="银联收单"
+                  name="collection"
+                >
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        v-for="(section, key) in collectionSections"
+                        :key="key"
+                        @click="activeSection = key"
+                        :class="{ active: activeSection === key }"
+                      >
+                        {{ section.title }}
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+
+                <!-- 统一支付收单(unifiedDoc.ts ← docs/unified-openapi.md)—— 2026-10-20 新增,R52 排在 collection 之后 -->
+                <el-collapse-item title="统一支付收单" name="unified">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        v-for="(section, key) in unifiedSections"
+                        :key="key"
+                        @click="activeSection = key"
+                        :class="{ active: activeSection === key }"
+                      >
+                        {{ section.title }}
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+
+                <el-collapse-item title="其他" name="other">
+                  <div class="sidebar-section">
+                    <ul>
+                      <li
+                        @click="activeSection = 'errors'"
+                        :class="{ active: activeSection === 'errors' }"
+                      >
+                        常见错误
+                      </li>
+                      <li
+                        @click="activeSection = 'php'"
+                        :class="{ active: activeSection === 'php' }"
+                      >
+                        PHP示例代码
+                      </li>
+                    </ul>
+                  </div>
+                </el-collapse-item>
+              </el-collapse>
             </el-scrollbar>
           </div>
 
@@ -1388,6 +1427,7 @@ import ApiKeyAPI, {
 import OpenAPI from "@/api/module_payment/openapi";
 import { collectionSections } from "./collectionDoc";
 import { harSections } from "./harDoc";
+import { unifiedSections } from "./unifiedDoc";
 import type { DocSection } from "./docSlicer";
 import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
 import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
@@ -1408,6 +1448,12 @@ defineOptions({
 const activeTab = ref("management");
 const activeSection = ref("auth");
 
+/**
+ * 文档侧边栏展开的组名。空数组 = 默认全部收起(spec §10.1)。
+ * 收起只影响侧边栏导航,右侧正文仍照常显示当前 activeSection。
+ */
+const openedDocGroups = ref<string[]>([]);
+
 /**
  * 「银联收单」这一组是否露出。
  *
@@ -1422,9 +1468,13 @@ const activeSection = ref("auth");
  */
 const SHOW_COLLECTION_DOC = true;
 
-// 两条通道的正文合成一张查找表:正文渲染与卡片标题只认 key,不关心它属于哪一组。
-// 侧边栏仍按通道分两组渲染(见模板)。
-const mdSections: Record<string, DocSection> = { ...collectionSections, ...harSections };
+// 三条产品线的正文合成一张查找表:正文渲染与卡片标题只认 key,不关心它属于哪一组。
+// 侧边栏仍按通道/产品分组渲染(见模板)。
+const mdSections: Record<string, DocSection> = {
+  ...collectionSections,
+  ...harSections,
+  ...unifiedSections,
+};
 const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
 const dataFormRef = ref();
 const submitLoading = ref(false);
@@ -1518,7 +1568,7 @@ function toggleExpand(section: string) {
 }
 
 function getSectionTitle() {
-  // 两条通道的标题都与侧边栏同源(collectionDoc.ts / harDoc.ts),不在这里再抄一份
+  // 三条产品线的标题都与侧边栏同源(collectionDoc.ts / harDoc.ts / unifiedDoc.ts),不在这里再抄一份
   const md = mdSections[activeSection.value];
   if (md) return md.title;
   const titles: Record<string, string> = {
@@ -1809,6 +1859,26 @@ function downloadApiKeyCsv() {
   overflow: hidden;
 }
 
+/* 侧边栏折叠:只压掉 el-collapse 自带的边框与 header 高度,不动全局 collapse 样式 */
+.docs-collapse {
+  border: none;
+}
+
+.docs-collapse :deep(.el-collapse-item__header) {
+  height: 36px;
+  line-height: 36px;
+  border-bottom: none;
+  font-weight: 600;
+}
+
+.docs-collapse :deep(.el-collapse-item__wrap) {
+  border-bottom: none;
+}
+
+.docs-collapse :deep(.el-collapse-item__content) {
+  padding-bottom: 8px;
+}
+
 .sidebar-section {
   margin-bottom: 24px;
   padding: 0 20px;

+ 27 - 0
frontend/src/views/module_payment/apikey/unifiedDoc.ts

@@ -0,0 +1,27 @@
+/**
+ * 统一支付平台(汇安融通道)开放文档的侧边栏条目定义(正文 `docs/unified-openapi.md`)。
+ *
+ * 切片逻辑在 `docSlicer.ts`,本文件只写「哪些标题拼成哪一条」。
+ * 这是继「银联收单」「支付宝收单」之后的**第三条产品线、第三份文档**,互不合并:
+ * 接口路径、金额单位、状态枚举都与「支付宝收单」不同,读者不能拿错文档。
+ */
+import raw from "./docs/unified-openapi.md?raw";
+import { buildSections, type DocSectionDef } from "./docSlicer";
+
+/**
+ * 侧边栏条目定义。键即 `activeSection` 的值(spec §10.2)。
+ * `parts` 里的标题必须与 md 里的标题**逐字一致**(含序号、空格、全角括号)。
+ * 标题对不上时 `buildSections` 会整条丢弃,页面不报错 —— 改标题时两处一起改。
+ */
+const SECTIONS: DocSectionDef[] = [
+  { key: "unified_overview", title: "概述", parts: ["## 1. 概述"] },
+  { key: "unified_create", title: "统一下单", parts: ["## 2. 统一下单"] },
+  { key: "unified_query", title: "订单查询", parts: ["## 3. 订单查询"] },
+  { key: "unified_refund", title: "统一退款", parts: ["## 4. 统一退款"] },
+  { key: "unified_refund_query", title: "退款查询", parts: ["## 5. 退款查询"] },
+  { key: "unified_channel_user", title: "获取渠道用户 ID", parts: ["## 6. 获取渠道用户 ID"] },
+  { key: "unified_notify", title: "异步通知", parts: ["## 7. 异步通知"] },
+  { key: "unified_checklist", title: "联调检查清单", parts: ["## 8. 联调检查清单"] },
+];
+
+export const unifiedSections = buildSections(raw, SECTIONS);

+ 180 - 0
java/src/test/java/com/payment/platform/module/payment/unified/UnifiedDocSectionsTest.java

@@ -0,0 +1,180 @@
+package com.payment.platform.module.payment.unified;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 统一支付开放文档的静默失效防线(spec §10.2 / §10.3)。
+ *
+ * <p>{@code buildSections} 在 {@code parts} 的标题与 md 对不上时**整条丢弃**,页面不报错、
+ * 只是少一条 —— 所以这里逐字核对:8 个 H2 标题各恰好一次、切片器的 8 个 key 与 parts
+ * 与标题一一对应、16 个 wayCode 与关键事实都在正文里、侧边栏 7 组按 R52 顺序。
+ *
+ * <p>★ CRLF 警告:本仓 {@code core.autocrlf=true}、无 .gitattributes,工作区的 md 是 CRLF。
+ * 必须先 {@code replace("\r\n", "\n")} 归一化再按行切;{@code split("\n")} 配 {@code $}
+ * 锚定会一行标题都匹配不上({@code docSlicer.ts} 2026-09-24 踩过同款,侧边栏整组静默清空)。
+ *
+ * <p>Surefire 的工作目录是模块根 {@code java/},故用相对路径读前端源码。
+ */
+class UnifiedDocSectionsTest {
+
+    private static final Path DOC_TS =
+            Path.of("../frontend/src/views/module_payment/apikey/unifiedDoc.ts");
+    private static final Path DOC_MD =
+            Path.of("../frontend/src/views/module_payment/apikey/docs/unified-openapi.md");
+    private static final Path INDEX_VUE =
+            Path.of("../frontend/src/views/module_payment/apikey/index.vue");
+
+    /** brief 的 8 个 H2,逐字(含序号与空格)。 */
+    private static final List<String> EXPECTED_H2 = List.of(
+            "## 1. 概述",
+            "## 2. 统一下单",
+            "## 3. 订单查询",
+            "## 4. 统一退款",
+            "## 5. 退款查询",
+            "## 6. 获取渠道用户 ID",
+            "## 7. 异步通知",
+            "## 8. 联调检查清单");
+
+    /** spec §10.2 的 key,顺序即侧边栏顺序。 */
+    private static final List<String> EXPECTED_KEYS = List.of(
+            "unified_overview",
+            "unified_create",
+            "unified_query",
+            "unified_refund",
+            "unified_refund_query",
+            "unified_channel_user",
+            "unified_notify",
+            "unified_checklist");
+
+    private static final List<String> WAY_CODES = List.of(
+            "QR_CASHIER", "AUTO_BAR", "ALI_BAR", "ALI_JSAPI", "ALI_APP", "ALI_WAP", "ALI_PC", "ALI_QR",
+            "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP", "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
+
+    @Test
+    void md恰好有八个H2标题且每个恰好一次() throws IOException {
+        List<String> h2 = h2Lines(readNormalized(DOC_MD));
+
+        assertEquals(EXPECTED_H2.size(), h2.size(), "H2 标题数量不是 8(第 9 个 H2 即失败): " + h2);
+        for (String title : EXPECTED_H2) {
+            assertEquals(1, h2.stream().filter(title::equals).count(),
+                    "标题必须恰好出现一次: " + title + ",实际全量 H2=" + h2);
+        }
+        assertEquals(Set.copyOf(EXPECTED_H2), new HashSet<>(h2), "H2 标题集合与约定不一致: " + h2);
+    }
+
+    @Test
+    void 切片器八个键与parts逐字对应md标题() throws IOException {
+        String ts = readNormalized(DOC_TS);
+
+        List<String> keys = matches(ts, Pattern.compile("key:\\s*\"([^\"]+)\""));
+        assertEquals(EXPECTED_KEYS, keys, "8 个 key 必须按 spec §10.2 顺序且逐字");
+        assertEquals(8, new HashSet<>(keys).size(), "8 个 key 不得重复");
+        assertTrue(keys.stream().noneMatch(String::isBlank), "key 不得为空");
+
+        List<String> parts = matches(ts, Pattern.compile("parts:\\s*\\[\\s*\"([^\"]+)\"\\s*\\]"));
+        assertEquals(EXPECTED_H2, parts, "parts 必须逐字等于 8 个 H2 标题(含 `## ` 前缀)");
+
+        assertTrue(ts.contains("buildSections(raw, SECTIONS)"), "必须复用 docSlicer 的 buildSections");
+        assertTrue(ts.contains("export const unifiedSections"), "必须导出 unifiedSections");
+    }
+
+    @Test
+    void 正文覆盖十六种wayCode与九条事实的关键片段() throws IOException {
+        String md = readNormalized(DOC_MD);
+
+        assertTrue(md.contains("16 种"), "正文必须写明「16 种」wayCode");
+        for (String wayCode : WAY_CODES) {
+            assertTrue(md.contains(wayCode), "正文缺 wayCode: " + wayCode);
+        }
+
+        // 九条事实的可检索锚点(逐条对应 brief 第 28-37 行)
+        for (String token : List.of(
+                "另一条产品线", "API Key", "分", "channelExtra", "幂等",
+                "notify_id", "最多投递 3 次", "可去重", "服务端", "pay_data", "http/https", "unit")) {
+            assertTrue(md.contains(token), "正文缺关键片段: " + token);
+        }
+    }
+
+    @Test
+    void 侧边栏七组按R52顺序且默认全部收起() throws IOException {
+        String vue = readNormalized(INDEX_VUE);
+
+        for (String group : List.of("auth", "account", "callback", "har", "collection", "unified", "other")) {
+            assertTrue(vue.contains("name=\"" + group + "\""),
+                    "侧边栏缺 el-collapse-item name=\"" + group + "\"");
+        }
+        assertEquals(7, countOccurrences(vue, "<el-collapse-item"),
+                "侧边栏应恰好 7 个 el-collapse-item");
+
+        int auth = vue.indexOf("name=\"auth\"");
+        int account = vue.indexOf("name=\"account\"");
+        int callback = vue.indexOf("name=\"callback\"");
+        int har = vue.indexOf("name=\"har\"");
+        int collection = vue.indexOf("name=\"collection\"");
+        int unified = vue.indexOf("name=\"unified\"");
+        int other = vue.indexOf("name=\"other\"");
+        assertTrue(auth < account && account < callback && callback < har && har < collection
+                        && collection < unified && unified < other,
+                "7 组视觉顺序必须是 auth / account / callback / har / collection / unified / other");
+
+        assertTrue(vue.contains("const openedDocGroups = ref<string[]>([]);"),
+                "openedDocGroups 必须默认空数组(全部收起)");
+        assertTrue(vue.contains("v-model=\"openedDocGroups\""), "el-collapse 必须绑定 openedDocGroups");
+        assertTrue(vue.contains("const activeSection = ref(\"auth\");"),
+                "activeSection 默认仍必须是 auth");
+        assertTrue(vue.contains("...unifiedSections"), "mdSections 必须合并 unifiedSections");
+        assertTrue(vue.contains("v-for=\"(section, key) in unifiedSections\""),
+                "unified 组必须遍历 unifiedSections");
+        assertTrue(vue.contains("title=\"统一支付收单\""), "缺第 7 组标题「统一支付收单」");
+        assertTrue(vue.contains("v-if=\"SHOW_COLLECTION_DOC\""),
+                "collection 组原有的 SHOW_COLLECTION_DOC 开关必须保留");
+    }
+
+    private static List<String> h2Lines(String md) {
+        List<String> out = new ArrayList<>();
+        for (String line : md.split("\n")) {
+            if (line.startsWith("## ")) out.add(line);
+        }
+        return out;
+    }
+
+    private static List<String> matches(String text, Pattern pattern) {
+        List<String> out = new ArrayList<>();
+        Matcher m = pattern.matcher(text);
+        while (m.find()) out.add(m.group(1));
+        return out;
+    }
+
+    private static int countOccurrences(String text, String needle) {
+        int count = 0;
+        int from = 0;
+        while (true) {
+            int at = text.indexOf(needle, from);
+            if (at < 0) return count;
+            count++;
+            from = at + needle.length();
+        }
+    }
+
+    /** 先断言存在,再读并把 CRLF 归一化成 LF —— 缺文件时给出「文件不存在」而不是 NPE。 */
+    private static String readNormalized(Path path) throws IOException {
+        assertNotNull(path, "路径为空");
+        assertTrue(Files.isRegularFile(path), "文件不存在: " + path.toAbsolutePath());
+        return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n");
+    }
+}