Эх сурвалжийг харах

docs(统一支付): 评审修复——文档与实现对齐

alphaH 1 долоо хоног өмнө
parent
commit
0366f3048c

+ 15 - 14
frontend/src/views/module_payment/apikey/docs/unified-openapi.md

@@ -4,10 +4,10 @@
 
 - **认证**:平台 **API Key**(请求头 `Authorization: ApiKey <key>` 与 `Signature`,按请求 JSON body 计算 HMAC-SHA256)。这**不是**上游的 MD5 签名 —— 上游签名由平台在服务端完成,调用方不需要、也不应该持有上游 `appSecret`。
 - **金额**:全链路金额的单位(unit)都是**分**(整型 int),1 元 = 100 分。不要按「元」传小数。
-- **报文体**:JSON,字段 snake_case。响应统一为 `{ code, msg, data }`,`code=0` 表示成功。
+- **报文体**:JSON,字段 snake_case。响应核心字段为 `{ code, msg, data }`,另含冗余的 `status_code`、`success`(调用方只需读 `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」——平台不替调用方挑。
+- **`mch_no`**:**统一下单**与**获取渠道用户 ID** 在租户配置了多条商户号时**必须**指定,否则返回 400「当前租户配置了多个商户号,请在请求中指定 mch_no」——平台不替调用方挑。订单查询、退款查询与发起退款**可不传**:两个查询接口不按 `mch_no` 过滤;发起退款仅在传入时做一致性校验,与订单快照不一致返回 404。
 
 端点一览:
 
@@ -44,7 +44,7 @@ POST /payment/openapi/unified/order/create
 | `channel_extra` | object | 否 | 按 `way_code` 传入的额外参数,见下方规则。平台收到后序列化成紧凑 JSON 字符串再发上游 |
 | `ext_param` | string | 否 | 上游原样返回的透传参数 |
 
-> 分账(`division_mode`)不在本期范围:请求里不会转发该字段,传入也按未知字段忽略,不报错。
+> 分账(`division_mode`)不在本期范围,**请勿传**:当前请求 DTO 不识别未知字段,传入会在反序列化阶段报错(HTTP 500)。
 
 **16 种 `wayCode` 与 `channelExtra` 规则**
 
@@ -65,9 +65,9 @@ POST /payment/openapi/unified/order/create
 | `WX_H5` | 微信 H5 | 无必传;已取得渠道用户 ID 时可选传 `openid`。返回 `orderState=0`、`payDataType=payurl`,`payData` 为承接页地址 |
 | `WX_NATIVE` | 微信扫码 | 可选 `payDataType=codeUrl/codeImgUrl` |
 | `YSF_BAR` | 云闪付条码 | 必传付款码 |
-| `YSF_JSAPI` | 云闪付 JSAPI | 文档未展开,按通用 JSAPI 透传 |
+| `YSF_JSAPI` | 云闪付 JSAPI | 可选 `openid`;本期只转发该字段,其余参数不转发 |
 
-**付款码键名**:条码类 `wayCode` 的付款码字段,平台**同时接受** `auth_code` 与 `authCode` 两个键名;转发上游时按平台配置只发其中一个(默认 `auth_code`)。调用方任选一种写法即可。
+**付款码键名**:条码类 `wayCode` 的付款码字段,平台**同时接受** `auth_code` 与 `authCode` 两个键名;转发上游时默认只发 `auth_code`,平台配置为 `both` 时两个键都发。调用方任选一种写法即可。
 
 **`payDataType` 默认值**:不传 `payDataType` 时,平台**不注入**默认值,由上游取它自己的默认值;上表括号里的默认值只是上游行为说明。二维码类可选 `codeUrl` / `codeImgUrl`。
 
@@ -82,7 +82,7 @@ POST /payment/openapi/unified/order/create
 
 **响应 `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`。
+`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`、`next_query_time`(内部调度时间,可忽略)。
 
 > `refundable_amount = amount - SUM(退款金额 WHERE 退款 state ∈ {0,1,2})` —— 在途退款也占用额度,与退款闸门同一公式。
 >
@@ -100,7 +100,7 @@ POST /payment/openapi/unified/order/query
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `mch_no` | string | 否 | 多商户配置时必填 |
+| `mch_no` | string | 否 | 可不传;本接口不按 `mch_no` 过滤 |
 | `order_no` | string | 二选一 | 平台订单号 |
 | `out_trade_no` | string | 二选一 | 调用方订单号 |
 
@@ -134,7 +134,7 @@ POST /payment/openapi/unified/refund
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `mch_no` | string | 否 | 多商户配置时必填,且必须与订单快照一致 |
+| `mch_no` | string | 否 | 可不传;传入时必须与订单快照一致,不一致返回 404 |
 | `order_no` | string | 二选一 | 平台订单号 |
 | `out_trade_no` | string | 二选一 | 调用方订单号 |
 | `refund_amount` | int | 是 | 退款金额,**单位分**,必须 > 0 |
@@ -145,14 +145,15 @@ POST /payment/openapi/unified/refund
 
 **规则**
 
-- 只有 `trade_status = 2`(支付成功)的订单可以退款,否则返回 400「订单未支付成功,不能退款」。
+- `trade_status = 5`(已退款)再发起退款:返回 400「已无可退金额」。
+- 其他 `trade_status != 2` 的订单:返回 400「订单未支付成功,不能退款」。
 - 同一 `out_refund_no`、**参数相同**重复提交:返回已有退款单;**参数不同**:返回 `code=409`。
-- 退款金额不得超过 `refundable_amount`(在途退款也占用额度);超额返回 400。
+- 退款金额不得超过 `refundable_amount`(在途退款也占用额度);超额返回 400「退款金额超过可退金额,当前可退 {available} 分」。
 - 同一订单可多次部分退款,每次用不同的 `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`。
+`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`、`next_query_time`(内部调度时间,可忽略)。
 
 **退款 `state` 枚举**
 
@@ -176,7 +177,7 @@ POST /payment/openapi/unified/refund/query
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `mch_no` | string | 否 | 多商户配置时必填 |
+| `mch_no` | string | 否 | 可不传;本接口不按 `mch_no` 过滤 |
 | `refund_no` | string | 二选一 | 平台退款单号 |
 | `out_refund_no` | string | 二选一 | 调用方退款单号 |
 
@@ -200,7 +201,7 @@ POST /payment/openapi/unified/channel-user/jump-url
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `mch_no` | string | 否 | 多商户配置时必填 |
+| `mch_no` | string | 否 | 租户配置了多个商户号时必填 |
 | `redirect_url` | string | 是 | 授权完成后浏览器跳回的地址,必须是 `http/https` |
 | `if_code` | string | 否 | 渠道标识:`fuioupay`(服务商模式)/ `wxpay`(自有公众号)/ `AUTO`;空则用商户配置,默认 `fuioupay` |
 | `ext_param` | string | 否 | 透传参数,原样带到上游 |
@@ -287,4 +288,4 @@ POST /payment/openapi/unified/channel-user/jump-url
 - [ ] **`pay_data` 渲染**:先校验协议只允许 `http/https`;表单 HTML 只放进受信页面。
 - [ ] **回调幂等**:用 `notify_id` 去重,重复通知不重复记账。
 - [ ] **渠道用户 ID** 走平台的服务端接口换 URL,浏览器与前端代码里不出现 `appSecret`。
-- [ ] 配置了多个 `mch_no` 时,每个请求都显式指定它。
+- [ ] 配置了多个商户号时,**统一下单**与**获取渠道用户 ID** 请求显式指定 `mch_no`;查询与退款接口可不传。

+ 1 - 10
frontend/src/views/module_payment/apikey/index.vue

@@ -1463,7 +1463,7 @@ const openedDocGroups = ref<string[]>([]);
  * 2026-09-30:用户裁定两组都上,并把叫法改成业务口径 ——
  * 「门店与订单」→「支付宝收单」(汇安融通道)、「收付款接口」→「银联收单」(乐收赢通道)。
  * 这里翻成 true。正文、切片器、分组本来都已就位,**没有动 md、切片器或模板结构**;
- * 两个 `<h3>` 只是侧边栏的分组标题(硬编码在这份文件里,不来自 md),
+ * 两个分组标题只是侧边栏的显示文案(硬编码在这份文件里,不来自 md),
  * 所以改名不会碰到切片器的「标题对不上就整条消失」那条契约。
  */
 const SHOW_COLLECTION_DOC = true;
@@ -1884,15 +1884,6 @@ function downloadApiKeyCsv() {
   padding: 0 20px;
 }
 
-.sidebar-section h3 {
-  font-size: 14px;
-  font-weight: bold;
-  color: #606266;
-  margin-bottom: 12px;
-  text-transform: uppercase;
-  letter-spacing: 0.5px;
-}
-
 .sidebar-section ul {
   list-style: none;
   padding: 0;

+ 48 - 2
java/src/test/java/com/payment/platform/module/payment/unified/UnifiedDocSectionsTest.java

@@ -61,6 +61,17 @@ class UnifiedDocSectionsTest {
             "unified_notify",
             "unified_checklist");
 
+    /** spec §10.2 的侧边栏标题,与 key 一一对应。 */
+    private static final List<String> EXPECTED_TITLES = List.of(
+            "概述",
+            "统一下单",
+            "订单查询",
+            "统一退款",
+            "退款查询",
+            "获取渠道用户 ID",
+            "异步通知",
+            "联调检查清单");
+
     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");
@@ -89,6 +100,10 @@ class UnifiedDocSectionsTest {
         List<String> parts = matches(ts, Pattern.compile("parts:\\s*\\[\\s*\"([^\"]+)\"\\s*\\]"));
         assertEquals(EXPECTED_H2, parts, "parts 必须逐字等于 8 个 H2 标题(含 `## ` 前缀)");
 
+        // 侧边栏显示名也逐条钉住:改了 title 而 key 不变时,用户看到的名字就漂了。
+        List<String> titles = matches(ts, Pattern.compile("title:\\s*\"([^\"]+)\""));
+        assertEquals(EXPECTED_TITLES, titles, "8 个 title 必须按 spec §10.2 顺序且逐字");
+
         assertTrue(ts.contains("buildSections(raw, SECTIONS)"), "必须复用 docSlicer 的 buildSections");
         assertTrue(ts.contains("export const unifiedSections"), "必须导出 unifiedSections");
     }
@@ -141,8 +156,32 @@ class UnifiedDocSectionsTest {
         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 开关必须保留");
+
+        // v-if 必须落在 name="collection" 的**那个** el-collapse-item 开标签上,
+        // 而不是随便出现在文件某处 —— 只做 contains 时把它挪到别的组仍然会绿。
+        Matcher collectionItem = Pattern
+                .compile("<el-collapse-item\\b[^>]*\\bname=\"collection\"[^>]*>")
+                .matcher(vue);
+        assertTrue(collectionItem.find(), "找不到 name=\"collection\" 的 el-collapse-item 开标签");
+        assertTrue(collectionItem.group().contains("v-if=\"SHOW_COLLECTION_DOC\""),
+                "SHOW_COLLECTION_DOC 必须挂在 collection 这个 el-collapse-item 上");
+
+        // 折叠样式:选择器与关键属性逐条钉住(缺 border: none 会多一层默认边框;
+        // 缺 36px 高度会让 header 高出一截,视觉上就不是紧凑侧栏)。
+        assertTrue(cssRule(vue, ".docs-collapse").contains("border: none"),
+                ".docs-collapse 必须去掉 el-collapse 默认边框");
+        String headerRule = cssRule(vue, ".docs-collapse :deep(.el-collapse-item__header)");
+        for (String decl : List.of("height: 36px", "line-height: 36px",
+                "border-bottom: none", "font-weight: 600")) {
+            assertTrue(headerRule.contains(decl),
+                    "header 规则缺声明: " + decl + ",实际块=" + headerRule);
+        }
+        assertTrue(cssRule(vue, ".docs-collapse :deep(.el-collapse-item__wrap)")
+                        .contains("border-bottom: none"),
+                "wrap 规则必须有 border-bottom: none");
+        assertTrue(cssRule(vue, ".docs-collapse :deep(.el-collapse-item__content)")
+                        .contains("padding-bottom: 8px"),
+                "content 规则必须有 padding-bottom: 8px");
     }
 
     private static List<String> h2Lines(String md) {
@@ -171,6 +210,13 @@ class UnifiedDocSectionsTest {
         }
     }
 
+    /** 取 scoped 样式里某个选择器规则块的花括号正文;找不到即失败。 */
+    private static String cssRule(String css, String selector) {
+        Matcher m = Pattern.compile(Pattern.quote(selector) + "\\s*\\{([^}]*)\\}").matcher(css);
+        assertTrue(m.find(), "找不到 CSS 规则: " + selector);
+        return m.group(1);
+    }
+
     /** 先断言存在,再读并把 CRLF 归一化成 LF —— 缺文件时给出「文件不存在」而不是 NPE。 */
     private static String readNormalized(Path path) throws IOException {
         assertNotNull(path, "路径为空");