浏览代码

docs(乐收赢): 接入文档去掉 enterprise_id,样例签名按新报文重算

用户 2026-09-30 的质疑:「我看到文档中有 enterprise_id,早就说过 lsy 产品与系统原有
企业毫无关系,接口中应传商户号才对」。代码侧的去企业化 f529b44d / 3fb8df5e 已经做完,
但**对外文档一直没跟上** —— 而对接口的是这份文档,不是代码。

改动(收付款通道 = collection-openapi.md,第 1–9 章):

- §2 整段「`enterprise_id` 从哪来」→「`merchant_code` 从哪来」,并明确写上迁移提示
  「该字段已停用,带了返回 code=400」;同时点明 10 章那条通道另有自己的 enterprise_id,
  免得读者以为全平台都停了。
- §3.4 样例报文 `enterprise_id=ENT10001` → `merchant_code=M10001`,随之重算
  「排序后逐项编码」表、待签串、Signature 十六进制。
- §3.6 两条校验示例改成**实际会返回**的话;错误码表的 400 / 404 行补上商户号那两条。
- §5.1 / 5.2 / 5.3 三张请求参数表:`enterprise_id`(必填)→ `merchant_code`(选填,
  说明与「多条时不指定返回 400」的实现一致)。
- §2 商户号一行的措辞:「下单/建店/查单都要用 merchant_code」对本章不成立
  (本章的查单按订单号定位,根本不收这个字段),改为按通道分别指向 5.1 与 10 章。

**顺手修的同一类漂移(不是本次去企业化引入的)**:三张表的 `total_amount` 原写
「必须 > 0」,而 LsyAmountFloor 自 3f93d2c4 起把四个下单入口的下限提到 100 分 ——
照原文传 1 分会被 400 拒。§4 / §8 / §9 一并补上「下单最低 1 元」,并写明**退款不受此限**
(部分退款金额天然可以低于 1 元),10 章那条通道也不在内。

签名样例是**契约**不是装饰(测试类 javadoc 原话),样例换字段就必须同步换测试常量。
本次重算用两把**互相独立**的尺子量到同一个数:

  · `SignatureGenerator`(实现本身)—— 先观察到一次真实的 RED,失败消息里给出实际值
    `f6b153e6…`;
  · 一份独立的 Python 实现 —— **先在最老的向量上复现出旧的 `2454f991…`**(正对照,
    证明这把尺子与实现同刻度),再拿它量新向量。

另外写了一个「只读 md 自身」的自洽核对:从文档里分别取请求体、api_secret、期望
sign_str、期望十六进制,四处互相验(不从测试常量取值 —— 那样只能证明我抄得对)。
对它做了两向变异:改一个 hex 位 / 改 sign_str 里的金额 / 改请求体字段值 / 改
api_secret,各一份副本,**四处都红、原文件绿**。

★ 该核对脚本第一版只打印 True/False 却**没有退出码**,于是第一轮读到「四个变异全
exit=0」—— 那是**量具坏了**,不是文档对了;补上 `sys.exit` 后才是真读数。记在这里
是因为「用 `$?` 读一个不设退出码的脚本」会给出红绿同读数的假绿。

读数:`-Dtest='Lsy*,*Leshouying*,Har*'` 共 **785 条,0 失败**。

未覆盖(如实写):浏览器端渲染未验 —— 正文由 `docSlicer.ts` 按标题切片,本次
**没有动任何标题**,故切片锚点未受影响;但「页面上看得对不对」仍需人工看一眼。
alphaH 2 周之前
父节点
当前提交
7520c6ed8d

+ 31 - 21
frontend/src/views/module_payment/apikey/docs/collection-openapi.md

@@ -43,11 +43,13 @@
 |---|---|
 | `api_key` | 你的身份标识,放在 `Authorization` 头 |
 | `api_secret` | 签名密钥,**只用于本地计算签名,任何情况下都不上网传输** |
-| 商户号 | 该 `api_key` 名下可用的商户号。**一个租户可以有多个**:只有一个时什么都不用传,有多个时下单/建店/查单都要用 `merchant_code` 指定(见 10 章) |
+| 商户号(`merchant_code`) | 该 `api_key` 名下可用的商户号。**一个租户可以有多个**:只有一个时什么都不用传,有多个时**下单**要用 `merchant_code` 指定用哪一个(收付款通道见 5.1;门店与订单通道见 10 章) |
 
 还需向平台方提供一个**回调地址**(`notify_url`),或在下单时逐笔传入。
 
-> **`enterprise_id` 从哪来**:它是你在本平台的**企业标识**。下单前该企业必须已在平台开通报备,否则下单会失败并提示「该企业未配置商户号,请联系平台运营登记」—— 商户号由平台运营登记,你的系统这边改不了,遇到这条提示直接联系平台运营即可。
+> **`merchant_code` 从哪来**:它是平台运营**登记在你名下**的乐收赢商户号,你的系统这边改不了。下单时不传,平台会用你名下唯一的那一个;一个都没登记会返回 `code=404`「该租户未配置商户号,请联系平台运营登记」,传了一个不存在的号会返回 `code=404`「商户号不存在: …」。遇到这两条都直接联系平台运营。
+>
+> ⚠️ **收付款通道与企业无关,不要再传 `enterprise_id`。** 该字段已停用:带了它(非空)会返回 `code=400`「本接口已不再使用 enterprise_id,请改用 merchant_code 指定商户号」。请求体里**多传不认识的字段一律被拒**(见 3.6),所以这不是「传了会被忽略」。(10 章那条通道另有自己的 `enterprise_id`,与本章无关。)
 
 ---
 
@@ -188,7 +190,7 @@ function sortStringify(obj) {
 ```json
 {
   "out_trade_no": "T20260922001",
-  "enterprise_id": "ENT10001",
+  "merchant_code": "M10001",
   "auth_code": "280812345678901234",
   "total_amount": 100,
   "pay_type": "000",
@@ -209,7 +211,7 @@ doc_demo_secret_9f8e7d6c5b4a
 |---|---|
 | `attach` | `table-1` |
 | `auth_code` | `280812345678901234` |
-| `enterprise_id` | `ENT10001` |
+| `merchant_code` | `M10001` |
 | `order_body` | `%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81` |
 | `out_trade_no` | `T20260922001` |
 | `pay_type` | `000` |
@@ -218,13 +220,13 @@ doc_demo_secret_9f8e7d6c5b4a
 **待签串**:
 
 ```
-attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81&out_trade_no=T20260922001&pay_type=000&total_amount=100
+attach=table-1&auth_code=280812345678901234&merchant_code=M10001&order_body=%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81&out_trade_no=T20260922001&pay_type=000&total_amount=100
 ```
 
 **Signature**:
 
 ```
-2454f99173419a38a41a573bee26e555673d12beee61f9f4481af7d8d2eb3a70
+f6b153e6c542461ea27ab291c30a5737bdaea8bbf3c7bf03ccdbfe7132bf6a11
 ```
 
 如果算出来不是这个值,**先别调接口** —— 把待签串打印出来逐字符比对,多半是编码规则或数字格式不一致。这个样例已由平台侧的自动化测试钉住,不会无故变化。
@@ -268,9 +270,9 @@ attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E
 
 | 出错环节 | HTTP 状态码 | 响应体 |
 |---|---|---|
-| 参数校验失败(缺字段、金额非正、单号超长、**多了不认识的字段**等) | **400** | `{"code":400,"msg":"字段名: 具体原因", ...}` |
+| 参数校验失败(缺字段、金额低于下限、单号超长、**多了不认识的字段**等) | **400** | `{"code":400,"msg":"字段名: 具体原因", ...}` |
 | 认证 / 签名失败 | **401** | `{"code":10401,"msg":"...", ...}` |
-| 业务逻辑错误(订单不存在、状态不允许、退款超余额、无权操作该企业等) | **200** | `{"code":404 或 400 或 403 或 500,"msg":"...", ...}` |
+| 业务逻辑错误(订单不存在、状态不允许、退款超余额、商户号不存在或未登记等) | **200** | `{"code":404 或 400 或 403 或 500,"msg":"...", ...}` |
 | 路径不存在(URL 拼错) | **404** | `{"code":404,"msg":"接口不存在", ...}` |
 | 服务端未预期异常 | **500** | `{"code":500,"msg":"服务器内部错误", ...}` |
 
@@ -282,10 +284,11 @@ attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E
 
 所以请以 `code == 0` 作为唯一成功判据。第三行是最容易被误判的一类:**HTTP 200 不等于成功**。
 
-> ⚠️ **校验错误的 `msg` 里,字段名是驼峰式,不是下划线式。** 例如少传 `enterprise_id`,返回的是
-> `{"code":400,"msg":"enterpriseId: 不能为空"}`;`total_amount` 传 0,返回
-> `{"code":400,"msg":"totalAmount: 必须是正数"}`。报文全用下划线,但这条消息里的字段名沿用服务端
-> 内部命名,**不要拿它直接去匹配你请求体里的键名**。要据 `msg` 定位参数时,请忽略大小写与下划线差异。
+> ⚠️ **校验错误的 `msg` 里,字段名是驼峰式,不是下划线式。** 例如少传 `out_trade_no`,返回的是
+> `{"code":400,"msg":"outTradeNo: out_trade_no 不能为空"}`;`total_amount` 传 0,返回
+> `{"code":400,"msg":"totalAmount: total_amount 不能低于 1 元(100 分)"}`。报文全用下划线,但冒号
+> **前**那个字段名沿用服务端内部命名,**不要拿它直接去匹配你请求体里的键名**。要据 `msg` 定位参数时,
+> 请忽略大小写与下划线差异。
 
 常见错误码:
 
@@ -294,8 +297,8 @@ attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E
 | `0` | 成功 |
 | `400` | 参数错误,`msg` 会指明是哪个字段 |
 | `401` | 认证上下文缺失(正常情况下由 10401 覆盖,此码防御性保留) |
-| `403` | 无权操作指定的企业(10 章专用,见 10 章开头) |
-| `404` | 订单 / 退款单不存在,或不属于你的 `api_key`;10 章里还包括「商户编号不存在或不属于你」 |
+| `403` | 无权操作指定的企业(**10 章专用**,`msg` 为「无权限操作该企业」;收付款通道不使用此码) |
+| `404` | 订单 / 退款单不存在,或不属于你的 `api_key`;商户号不存在或本租户一个都没登记(见 2 章);10 章里还包括「商户编号不存在或不属于当前租户」 |
 | `500` | 业务处理未成功(如关单未生效、撤销被拒),**可重试** |
 | `10401` | 认证失败:缺少 `Authorization` / `Signature` 头、API Key 无效或已过期、签名验证不通过 |
 
@@ -305,6 +308,8 @@ attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E
 
 **金额单位是「分」。** 所有金额字段(`total_amount`、`receipt_amount`、`refund_fee`、`refunded_amount`)都是**整数,单位为分**。`100` 表示 1.00 元。
 
+**下单金额有下限:1 元(100 分)。** 收付款通道的三个下单接口(5.1 / 5.2 / 5.3)低于该值一律返回 `code=400`「totalAmount: total_amount 不能低于 1 元(100 分)」。这条下限**不适用于退款**(5.6)—— 部分退款金额天然可以低于 1 元。也不适用于 10 章那条通道。
+
 **单号规则。** `out_trade_no`(商户订单号)与 `out_refund_no`(商户退款单号)由**你方生成**,要求:
 
 - 长度 1–64 字符;
@@ -338,9 +343,9 @@ POST /payment/openapi/pay/barcode/pay
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `out_trade_no` | string | 是 | 你的商户订单号,见 4 |
-| `enterprise_id` | string | 是 | 收款企业标识 |
+| `merchant_code` | string | 否 | 本笔收款用哪个商户号。你名下只有一个时**不用传**;登记了多个则必须传,不传返回 `code=400`「当前租户配置了多个商户编号,请在请求中指定 merchant_code」 |
 | `auth_code` | string | 是 | 用户的付款码,由扫码枪读取 |
-| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `total_amount` | long | 是 | 金额(分),**最低 1 元(100 分)** |
 | `pay_type` | string | 否 | `000` 自动识别(默认)/ `300` 支付宝 / `400` 微信 |
 | `order_body` | string | 否 | 商品描述,≤128 字符 |
 | `attach` | string | 否 | 你的透传字段,会原样出现在回调里,≤128 字符 |
@@ -378,8 +383,8 @@ POST /payment/openapi/pay/qrcode/create
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `out_trade_no` | string | 是 | 你的商户订单号 |
-| `enterprise_id` | string | 是 | 收款企业标识 |
-| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `merchant_code` | string | 否 | 本笔收款用哪个商户号,规则同 5.1 |
+| `total_amount` | long | 是 | 金额(分),**最低 1 元(100 分)** |
 | `order_body` | string | 否 | 商品描述,≤128 字符 |
 | `attach` | string | 否 | 透传字段,≤128 字符 |
 | `notify_url` | string | 否 | 本笔回调地址,≤512 字符 |
@@ -415,8 +420,8 @@ POST /payment/openapi/pay/h5/create
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
 | `out_trade_no` | string | 是 | 你的商户订单号 |
-| `enterprise_id` | string | 是 | 收款企业标识 |
-| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `merchant_code` | string | 否 | 本笔收款用哪个商户号,规则同 5.1 |
+| `total_amount` | long | 是 | 金额(分),**最低 1 元(100 分)** |
 | `pay_type` | string | 是 | **`300` 支付宝 / `400` 微信**,必须明确指定 |
 | `order_body` | string | 否 | 商品描述,≤128 字符 |
 | `attach` | string | 否 | 透传字段,≤128 字符 |
@@ -703,6 +708,8 @@ Content-Type: application/x-www-form-urlencoded
 - [ ] `Authorization` 头格式为 `ApiKey <key>`(一个空格)
 - [ ] `Signature` 为**小写**十六进制
 - [ ] 金额参与签名时按**整数**发送(不要出现 `100.0`)
+- [ ] 报文里**没有** `enterprise_id`;名下登记了多个商户号时,下单带了 `merchant_code`
+- [ ] 下单金额不低于 1 元(100 分)
 - [ ] 以响应体 `code == 0` 判成败,而非 HTTP 状态码
 - [ ] 下单使用业务唯一的 `out_trade_no`,重复调用能接受「订单已存在」的返回
 - [ ] 回调接口能正确处理表单编码,并把 `content` 字段**再解析一次** JSON
@@ -716,8 +723,11 @@ Content-Type: application/x-www-form-urlencoded
 **Q:一直返回「签名验证失败」(`code=10401`)怎么办?**
 把待签串打印出来与 3.4 的样例逐字符比对。绝大多数情况是这三类:数字被序列化成了 `100.0`;URL 编码把空格编成了 `+` 而不是 `%20`;或者 `Signature` 用了大写。
 
+**Q:我代码里原来传的 `enterprise_id`,现在报 `code=400`?**
+该字段已停用,改用 `merchant_code`(见 2 章)。平台**不忽略**不认识的字段,所以这是必改项,不是「传了没用」。
+
 **Q:`total_amount` 到底是元还是分?**
-分。`100` = 1.00 元。
+分。`100` = 1.00 元。下单最低 1 元(100 分),低于它返回 `code=400`。
 
 **Q:主扫下单返回 `PAYING`,是不是失败了?**
 不是。主扫下单成功**必然**返回 `PAYING`。等回调,或主动查询。

+ 21 - 5
java/src/test/java/com/payment/platform/core/security/LsyOpenApiDocSignatureTest.java

@@ -30,19 +30,35 @@ class LsyOpenApiDocSignatureTest {
     /** 与文档样例一致的 API 密钥。仅用于文档演示,不是任何环境的真实密钥。 */
     private static final String DOC_API_SECRET = "doc_demo_secret_9f8e7d6c5b4a";
 
-    /** 文档样例的请求体(被扫支付)。 */
+    /**
+     * 文档样例的请求体(被扫支付)。
+     *
+     * <p>★ 2026-09-30 去企业化:这一格原先是 {@code enterprise_id=ENT10001}。它必须换掉 ——
+     * 样例是**抄给接入方跑的**,而 {@code enterprise_id} 现在会收到 400
+     * 「本接口已不再使用 enterprise_id,请改用 merchant_code 指定商户号」
+     * (见 {@code LeshouyingOpenController#requireMerchant})。留着它等于让接入方
+     * 照着一份必然失败的报文排查签名。
+     */
     private static final String DOC_SIGN_STR =
             "attach=table-1"
                     + "&auth_code=280812345678901234"
-                    + "&enterprise_id=ENT10001"
+                    + "&merchant_code=M10001"
                     + "&order_body=%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81"
                     + "&out_trade_no=T20260922001"
                     + "&pay_type=000"
                     + "&total_amount=100";
 
-    /** 文档样例的 Signature 请求头取值。 */
+    /**
+     * 文档样例的 Signature 请求头取值。
+     *
+     * <p>★ 2026-09-30 随上面那一格的替换而重算。重算用**两把独立的尺子**量到同一个数:
+     * 本仓的 {@code SignatureGenerator}(读数见 {@code docSampleSignatureMatchesImplementation}
+     * 的那次 RED),与一份独立的 Python 实现;后者先在**旧**向量上复现出旧的
+     * {@code 2454f991…}(正对照)才被用来量新向量。旧值不是「算错了」——
+     * 它对应的是旧的样例报文,报文换了它就必然换。
+     */
     private static final String DOC_SIGNATURE =
-            "2454f99173419a38a41a573bee26e555673d12beee61f9f4481af7d8d2eb3a70";
+            "f6b153e6c542461ea27ab291c30a5737bdaea8bbf3c7bf03ccdbfe7132bf6a11";
 
     /**
      * 文档样例的请求体。
@@ -53,7 +69,7 @@ class LsyOpenApiDocSignatureTest {
     private static Map<String, Object> docSampleBody() {
         Map<String, Object> body = new LinkedHashMap<>();
         body.put("out_trade_no", "T20260922001");
-        body.put("enterprise_id", "ENT10001");
+        body.put("merchant_code", "M10001");
         body.put("auth_code", "280812345678901234");
         body.put("total_amount", 100);
         body.put("pay_type", "000");