Bladeren bron

feat(汇安融): 开放接口去上游化 —— 路径改 /payment/openapi/alipay、订单号前缀改 AP、biz 不再回吐

- 开放接口与后台接口路径、双鉴权白名单:har → alipay;老路径不再放行,调用方须同日切换
- 收款链接网页地址改 /#/checkout/alipay/<token>(前端路由名不动,免登录按名放行)
- order_no 前缀 HAR → AP(中性词,存量单不改)
- biz:留空按 trade_type 派生、响应体不再回吐;显式传值仍原样透传
- 开放文档与检查清单同步;新增 OpenapiAuthWiringTest 钉住新前缀白名单(原 M6 盲区)
alphaH 6 dagen geleden
bovenliggende
commit
d16f6f7539
24 gewijzigde bestanden met toevoegingen van 312 en 70 verwijderingen
  1. 1 1
      frontend/src/api/module_payment/har.ts
  2. 4 2
      frontend/src/plugins/permission.ts
  3. 6 4
      frontend/src/router/index.ts
  4. 5 5
      frontend/src/views/checkout/har/index.vue
  5. 4 4
      frontend/src/views/module_payment/apikey/docs/collection-openapi.md
  6. 8 8
      frontend/src/views/module_payment/apikey/docs/har-openapi.md
  7. 5 2
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  8. 10 7
      java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java
  9. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java
  10. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java
  11. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java
  12. 4 2
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java
  13. 5 3
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java
  14. 5 3
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java
  15. 29 5
      java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java
  16. 1 1
      java/src/main/resources/db/har-checkout-link.sql
  17. 1 1
      java/src/main/resources/db/har-order-refund-menu.sql
  18. 2 2
      java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java
  19. 96 0
      java/src/test/java/com/payment/platform/core/security/OpenapiAuthWiringTest.java
  20. 5 5
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java
  21. 2 2
      java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java
  22. 25 1
      java/src/test/java/com/payment/platform/module/payment/har/dto/HarDtoJsonContractTest.java
  23. 7 7
      java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java
  24. 83 1
      java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

+ 1 - 1
frontend/src/api/module_payment/har.ts

@@ -65,7 +65,7 @@ export interface HarCheckoutLinkVO {
   merchant_code: string;
   /** 商户显示名(**快照**)。可能为 null:商户行没填名字时不编一个出来 */
   merchant_name?: string | null;
-  /** 完整收款链接(形如 `https://<域名>/#/checkout/har/<token>`) */
+  /** 完整收款链接(形如 `https://<域名>/#/checkout/alipay/<token>`) */
   url: string;
 }
 

+ 4 - 2
frontend/src/plugins/permission.ts

@@ -16,9 +16,11 @@ import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
  * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
  *   语义不同,别合并。
  *
- * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/har/<token>`,
- *   路径多一段 `har` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
+ * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/alipay/<token>`,
+ *   路径多一段 `alipay` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
  *   按名字放行时,新公开页面必须有人在这里写一行,而不会因为路径恰好长得像就自动开放。
+ *   (路径段 2026-10-09 由 `har` 改为 `alipay`;**名字 `HarCheckout` 不动** ——
+ *     它是上面这条放行判据读的那个键,改名要把这里一起改,否则页面静默变回「跳登录」。)
  */
 const PUBLIC_ROUTE_NAMES = new Set(["Checkout", "HarCheckout"]);
 

+ 6 - 4
frontend/src/router/index.ts

@@ -52,12 +52,14 @@ export const constantRoutes: RouteRecordRaw[] = [
     component: () => import("@/views/checkout/index.vue"),
   },
   {
-    // 汇安融顾客页(收款链接 `/#/checkout/har/<token>` 的落点)—— 免登录,同上一段。
+    // 汇安融顾客页(收款链接 `/#/checkout/alipay/<token>` 的落点)—— 免登录,同上一段。
     // ★ 为什么是**三段**路径而乐收赢是两段:`HarCheckoutLinkService#buildUrl` 的
-    //   PATH_PREFIX 是 `/#/checkout/har/`,`har` 是路径里的**字面段**,不是参数。
-    //   若按乐收赢写成 `checkout/:token`,`har` 会被当成 token ⇒ 顾客页拿着 "har"
+    //   PATH_PREFIX 是 `/#/checkout/alipay/`,`alipay` 是路径里的**字面段**,不是参数。
+    //   若按乐收赢写成 `checkout/:token`,`alipay` 会被当成 token ⇒ 顾客页拿着 "alipay"
     //   去查链接,一律「链接无效」。
-    path: "/checkout/har/:token",
+    //   (路径段 2026-10-09 由 `har` 改来 —— 这串出现在顾客眼前,不带上游品牌;
+    //     与后端 PATH_PREFIX 必须**同名**改,改单边就是整页 404。)
+    path: "/checkout/alipay/:token",
     name: "HarCheckout",
     meta: { hidden: true, title: "收款" },
     component: () => import("@/views/checkout/har/index.vue"),

+ 5 - 5
frontend/src/views/checkout/har/index.vue

@@ -112,12 +112,12 @@
 
 <script setup lang="ts">
 /**
- * 汇安融顾客页 —— 收款链接 `/#/checkout/har/<token>` 的落点。**免登录**。
+ * 汇安融顾客页 —— 收款链接 `/#/checkout/alipay/<token>` 的落点。**免登录**。
  * 逐条对标乐收赢同款页面(`views/checkout/index.vue`),差异只有下面这几处:
  *
  * 【与乐收赢那份的差异(都是通道契约,不是风格)】
- *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `har` **字面量**
- *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/har/`);
+ *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `alipay` **字面量**
+ *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/alipay/`);
  *   · 金额单位是**元**(乐收赢是分):提交发 `{ price: <元> }`,回显读 `price`;
  *     HAR 全通道如此(同收银台/开放 API)。
  *   · 下单应答里是 `pay_url` / `order_no`(乐收赢是 `code_url` / `out_trade_no`)。
@@ -135,8 +135,8 @@
  * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
  *
  * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
- * `<base>/#/checkout/har/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
- * 拼成 `/checkout/har/<token>` 会命中静态路由表末尾的通配 404。
+ * `<base>/#/checkout/alipay/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
+ * 拼成 `/checkout/alipay/<token>` 会命中静态路由表末尾的通配 404。
  */
 defineOptions({
   name: "HarCheckout",

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

@@ -24,13 +24,13 @@
 | 退款 | 退款查询 | `POST /payment/openapi/pay/refund/query` |
 | 收款 | 收款链接 —— 生成一条**顾客自填金额**的收款页链接(桌牌 / 菜单用) | `POST /payment/openapi/pay/checkout/link` |
 
-**门店与订单通道 —— 路径前缀 `/payment/openapi/har`(详解见 10)**
+**门店与订单通道 —— 路径前缀 `/payment/openapi/alipay`(详解见 10)**
 
 | 能力 | 场景 | 接口 |
 |---|---|---|
-| 门店 | 创建门店 | `POST /payment/openapi/har/shop/create` |
-| 收款 | 下单收款(被扫 / 主扫由 `auth_code` 决定) | `POST /payment/openapi/har/order/create` |
-| 查询 | 订单查询 | `POST /payment/openapi/har/order/query` |
+| 门店 | 创建门店 | `POST /payment/openapi/alipay/shop/create` |
+| 收款 | 下单收款(被扫 / 主扫由 `auth_code` 决定) | `POST /payment/openapi/alipay/order/create` |
+| 查询 | 订单查询 | `POST /payment/openapi/alipay/order/query` |
 
 所有接口均为 **POST + JSON**,请求体必须带签名。两条通道的**认证与签名算法完全相同**(第 2、3 章对两者都适用),但**响应信封、金额单位、状态取值不同** —— 跨通道搬代码前,请先读 10 章开头的那张差异表。
 

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

@@ -1,6 +1,6 @@
 ## 10. 门店与订单接口
 
-本章是**另一条通道**,路径前缀 `/payment/openapi/har`,与 5 章的收款接口不是同一个接口族。
+本章是**另一条通道**,路径前缀 `/payment/openapi/alipay`,与 5 章的收款接口不是同一个接口族。
 
 **相同的部分**:请求头与签名算法(3.2 / 3.3 逐字适用)、错误处理总则(3.6)。
 
@@ -8,7 +8,7 @@
 
 | 项 | 5 章收款接口 | 本章 |
 |---|---|---|
-| 路径前缀 | `/payment/openapi/pay` | `/payment/openapi/har` |
+| 路径前缀 | `/payment/openapi/pay` | `/payment/openapi/alipay` |
 | 响应信封 | 三个下单接口 A 型,其余 B 型 | **4 个接口全部 B 型** |
 | 金额 | 整数,单位**分** | 小数,单位**元** |
 | 订单状态 | 文本枚举(`SUCCESS` / `PAYING`…),见 6 | **数字字符串**(`"0"` / `"1"`…),见 10.6 |
@@ -39,7 +39,7 @@
 为你的一个线下门店建号。平台侧**没有门店查询接口**,本接口的返回就是你日后核对门店状态的依据,请把 `shop_id` 存下来。
 
 ```
-POST /payment/openapi/har/shop/create
+POST /payment/openapi/alipay/shop/create
 ```
 
 **请求**
@@ -95,7 +95,7 @@ POST /payment/openapi/har/shop/create
 | **不传** | 主扫 —— 平台返回一条收款链接,你渲染成二维码给用户扫 | 只下单,等用户付款 |
 
 ```
-POST /payment/openapi/har/order/create
+POST /payment/openapi/alipay/order/create
 ```
 
 **请求**
@@ -106,7 +106,7 @@ POST /payment/openapi/har/order/create
 | `price` | number | 是 | 金额,**单位元**,必须 > 0。与 5 章的「整数分」不同,见下 |
 | `title` | string | 是 | 商品名称,≤64 字符 |
 | `trade_type` | string | 是 | `alipay_un_stage` 支付宝支付 / `alipay_stage` 花呗分期 |
-| `biz` | string | 否 | 业务类型。**非分期传 `har_ali_un_stage`** |
+| `biz` | string | 否 | 业务类型。**留空即可** —— 由平台按 `trade_type` 自动填,你不用知道它的取值。显式传值时原样透传 |
 | `out_biz_no` | string | 否 | 你自己的订单号,≤64 字符,原样存下供你查询 |
 | `auth_code` | string | 否 | 用户的付款码。有值走被扫,不传走主扫 |
 | `stage_num` | string | 否 | 分期期数:`3` / `6` / `12` / `24`。`trade_type=alipay_stage` 时必填 |
@@ -161,7 +161,7 @@ POST /payment/openapi/har/order/create
 ### 10.3 订单查询
 
 ```
-POST /payment/openapi/har/order/query
+POST /payment/openapi/alipay/order/query
 ```
 
 **请求**(`order_no` 与 `out_biz_no` **至少提供一个**;都提供时以 `order_no` 为准)
@@ -191,7 +191,7 @@ POST /payment/openapi/har/order/query
 对一笔**已支付成功**的订单发起退款。
 
 ```
-POST /payment/openapi/har/refund
+POST /payment/openapi/alipay/refund
 ```
 
 **请求**(`order_no` 与 `out_biz_no` **至少提供一个**,规则同 10.3)
@@ -263,7 +263,7 @@ Content-Type: application/x-www-form-urlencoded
 
 ```json
 {
-  "third_order_no": "HAR1234567890",
+  "third_order_no": "AP1234567890",
   "out_biz_no": "T20260924001",
   "order_no": "2026092499001",
   "platform_trade_no": "2026092499002",

+ 5 - 2
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -45,8 +45,11 @@ public class SecurityConfig {
             // 万一 filter 因路径或装配问题漏跑,请求会走到 Controller 拿明确响应,
             // 而不是被 AuthorizationFilter 丢一个空 403。
             "/payment/openapi/pay/**",
-            // 汇安融开放接口:同上,由 TenantApiKeyAuthFilter 独立认证
-            "/payment/openapi/har/**",
+            // 汇安融开放接口:同上,由 TenantApiKeyAuthFilter 独立认证。
+            // ★ 前缀词 2026-10-09 由 har 改为 alipay(对调用方去上游品牌)——
+            //   必须与 TenantApiKeyAuthFilter.OPENAPI_PREFIXES 里那条**同名**改,
+            //   只改一处就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过)。
+            "/payment/openapi/alipay/**",
             "/system/auth/login",
             "/system/auth/login/mini",
             "/system/auth/login/sms",

+ 10 - 7
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -80,7 +80,7 @@ import java.util.UUID;
  *
  * ———————————————————————————————————————
  * 作用范围:
- *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/har/**
+ *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/alipay/**
  *   (前缀判定, 见 OPENAPI_PREFIXES)
  *   /payment/openapi/conf 等管理接口走 JWT 认证, 不经过此 filter
  */
@@ -112,10 +112,11 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/pay/refund/query",
             // 汇安融开放接口(门店/订单/退款)。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
             // 本通道这份清单由 HarOpenControllerTest#everyEndpoint_isListedInTheAuthFilterInventory 核对。
-            "/payment/openapi/har/shop/create",
-            "/payment/openapi/har/order/create",
-            "/payment/openapi/har/order/query",
-            "/payment/openapi/har/refund",
+            // 前缀词 2026-10-09 由 har 改为 alipay(对调用方去上游品牌,见下面 OPENAPI_PREFIXES 的注)。
+            "/payment/openapi/alipay/shop/create",
+            "/payment/openapi/alipay/order/create",
+            "/payment/openapi/alipay/order/query",
+            "/payment/openapi/alipay/refund",
     };
 
     /**
@@ -140,12 +141,14 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             //   HAR 那一侧自己没带 —— 它只把 3 条端点加进了上面那份逐条清单,
             //   而**本数组才是认证判定读的那个**(见本数组第 1 条 javadoc)。
             //   不补的后果不是「少一层保护」而是**零鉴权**:SecurityConfig.WHITE_LIST 已
-            //   permitAll 掉 /payment/openapi/har/**,filter 又不认这个前缀 ⇒ 该前缀下
+            //   permitAll 掉 /payment/openapi/alipay/**,filter 又不认这个前缀 ⇒ 该前缀下
             //   没有任何 filter 认证,且不会有测试变红。
+            //   (前缀词 2026-10-09 由 har 改成 alipay —— 对调用方去上游品牌。白名单那条
+            //     与这份前缀必须**同名**改:只改一处就重新落回上面那个零鉴权形态。)
             //   HAR 的管理端在 /payment/har/**(不走 openapi)、通知在 /payment/notify/har,
             //   都不被本前缀覆盖;这条边界由 LeshouyingAdminWiringTest 的
             //   adminPaths_mustNotBeCoveredByApiKeyFilterPrefixes 动态守住。
-            "/payment/openapi/har",
+            "/payment/openapi/alipay",
     };
 
     private static final String AUTH_PREFIX = "ApiKey ";

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java

@@ -73,7 +73,7 @@ public class HarOrderController {
      * 退款(全额,调用上游)。
      *
      * <p>这是本控制器第一个**动钱**的端点,也是本通道管理端唯一的退款入口 ——
-     * 在此之前只有开放 API 能退({@code POST /payment/openapi/har/refund})。
+     * 在此之前只有开放 API 能退({@code POST /payment/openapi/alipay/refund})。
      *
      * <p>请求体可省:只为了带一句原因,留空由服务端补默认值(上游要求非空)。
      * 权限串**不复用** sync / create:能查一笔单不等于能动这笔钱。

+ 2 - 2
java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

@@ -40,9 +40,9 @@ import org.springframework.web.bind.annotation.RestController;
  * 不带而该租户又有多条时直接报 400,不由系统挑(见 {@link #requireMerchant})。
  */
 @Slf4j
-@Tag(name = "汇安融-开放API")
+@Tag(name = "支付宝收单-开放API")
 @RestController
-@RequestMapping("/payment/openapi/har")
+@RequestMapping("/payment/openapi/alipay")
 @RequiredArgsConstructor
 public class HarOpenController {
 

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java

@@ -32,7 +32,7 @@ public class HarCheckoutLinkVO {
     private String merchantName;
 
     /**
-     * 完整收款链接(形如 {@code https://<域名>/#/checkout/har/<token>})。
+     * 完整收款链接(形如 {@code https://<域名>/#/checkout/alipay/<token>})。
      *
      * <p>★ 基址由 {@code HarCheckoutLinkService#buildUrl} 推导(配置优先,否则按当前请求的
      * Host 推导)—— 控制器**不自己拼**:那是第二个副本,而两处漂移的后果是

+ 4 - 2
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java

@@ -51,9 +51,11 @@ public class HarOrderCreateDTO {
     private String tradeType;
 
     /**
-     * 业务类型。选填。规范 260924 订单创建参数表新增 biz 行:非分期传 {@code har_ali_un_stage}。
+     * 业务类型。选填。**留空即可** —— 平台按 {@code trade_type} 自动填
+     * (非分期 ⇒ {@code har_ali_un_stage})。
      *
-     * <p>同样是原样透传 —— 取值规则是给调用方的指示,不是后端要推导的东西。
+     * <p>2026-10-09 起由服务端派生(此前要求调用方照抄规范 260924 参数表的取值,
+     * 那串带上游品牌);显式传值时仍原样透传 —— 不校验取值,与 trade_type 同口径。
      */
     private String biz;
 

+ 5 - 3
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java

@@ -8,6 +8,11 @@ import lombok.Data;
 import java.math.BigDecimal;
 import java.time.OffsetDateTime;
 
+/**
+ * 订单应答 VO。**不含 biz**(2026-10-09 去上游化):那串取值带上游品牌,且自平台按
+ * {@code trade_type} 派生后调用方既不用填也不用回读(见 {@code HarOrderService#resolveBiz})。
+ * 实体与库表仍保留该列(内部实现;后台表单仍可显式传,是透传不是回读)。
+ */
 @Data
 public class HarOrderVO {
 
@@ -25,8 +30,6 @@ public class HarOrderVO {
     private BigDecimal price;
     private String title;
     private String tradeType;
-    /** 业务类型。非分期传 har_ali_un_stage(规范 260924 订单创建参数表 biz 行) */
-    private String biz;
     private String stageNum;
     private String percent;
     private Integer payTimeout;
@@ -62,7 +65,6 @@ public class HarOrderVO {
         vo.setPrice(e.getPrice());
         vo.setTitle(e.getTitle());
         vo.setTradeType(e.getTradeType());
-        vo.setBiz(e.getBiz());
         vo.setStageNum(e.getStageNum());
         vo.setPercent(e.getPercent());
         vo.setPayTimeout(e.getPayTimeout());

+ 5 - 3
java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java

@@ -50,12 +50,14 @@ public class HarCheckoutLinkService {
 
     /**
      * 顾客页是 hash 路由({@code createWebHashHistory})⇒ 路径必须在 {@code #} 之后。
-     * 写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404。
+     * 写成 {@code /checkout/alipay/{token}} 会命中静态路由表末尾的通配 404。
      *
-     * <p>★ {@code /har/} 这一段把本通道与乐收赢的 {@code /#/checkout/{token}} 分开:
+     * <p>★ {@code /alipay/} 这一段把本通道与乐收赢的 {@code /#/checkout/{token}} 分开:
      * 两条路由都是两段/三段的区别,落在前端两张页面上,互不遮蔽。
+     * (2026-10-09 由 {@code /har/} 改来 —— 这串会出现在顾客眼前,不带上游品牌;
+     * 前端 {@code router/index.ts} 的路径段必须与此处**同名**,改单边就是整页 404。)
      */
-    private static final String PATH_PREFIX = "/#/checkout/har/";
+    private static final String PATH_PREFIX = "/#/checkout/alipay/";
 
     /** 未知 / 停用**共用一句**:不告诉打开链接的人「这条链接存在但被停用」。 */
     private static final String MSG_INVALID = "收款链接无效或已停用";

+ 29 - 5
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -44,16 +44,19 @@ import java.util.List;
 /**
  * 汇安融订单服务。
  *
- * 订单号策略:order_no 由我方生成(HAR 前缀 + 雪花 ID),作为 third_order_no 传上游。
+ * 订单号策略:order_no 由我方生成(AP 前缀 + 雪花 ID),作为 third_order_no 传上游。
  * 企业自己的单号存 out_biz_no,不直接透传 —— 不同企业的单号可能重复,
  * 而上游的 third_order_no 需要全局唯一。
+ *
+ * <p>AP 前缀是 2026-10-09 从 HAR 改来的(去上游化):order_no 出现在开放 API 响应、
+ * 顾客收款页与上游的 third_order_no 里,前缀不带上游品牌。存量单不改。
  */
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class HarOrderService {
 
-    private static final String ORDER_NO_PREFIX = "HAR";
+    private static final String ORDER_NO_PREFIX = "AP";
 
     private static final DateTimeFormatter TIME_FORMATTER =
             DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -75,6 +78,15 @@ public class HarOrderService {
      */
     private static final String DEFAULT_REFUND_REASON = "商户申请退款";
 
+    /**
+     * {@code trade_type=alipay_un_stage}(支付宝支付,非分期)时上游要求的 {@code biz} 取值。
+     *
+     * <p>这串取自规范 260924 的订单创建参数表,**带上游品牌** —— 2026-10-09 起不再让
+     * 调用方填、也不用知道:请求体留空时由 {@link #resolveBiz} 派生,落库与发上游同一份
+     * (配套改动:{@link HarOrderVO} 不再回吐 biz,开放文档改为「留空即可」)。
+     */
+    private static final String BIZ_ALIPAY_UN_STAGE = "har_ali_un_stage";
+
     private final HarOrderMapper orderMapper;
     private final HarMerchantService merchantService;
     /**
@@ -444,7 +456,7 @@ public class HarOrderService {
         entity.setPrice(dto.getPrice());
         entity.setTitle(dto.getTitle());
         entity.setTradeType(dto.getTradeType());
-        entity.setBiz(dto.getBiz());
+        entity.setBiz(resolveBiz(dto));
         entity.setStageNum(dto.getStageNum());
         entity.setPercent(dto.getPercent());
         entity.setPayTimeout(dto.getPayTimeout());
@@ -704,6 +716,18 @@ public class HarOrderService {
                 .atZone(ZoneId.systemDefault()).toOffsetDateTime();
     }
 
+    /**
+     * 解析 biz(2026-10-09 去上游化):调用方显式给了就原样透传(后台表单与既有调用方
+     * 仍按老口径传);留空时按 trade_type 派生 —— 非分期({@code alipay_un_stage})
+     * ⇒ {@link #BIZ_ALIPAY_UN_STAGE};分期与其余取值没有规范依据,保持空(不替上游编)。
+     */
+    private static String resolveBiz(HarOrderCreateDTO dto) {
+        if (StrUtil.isNotBlank(dto.getBiz())) {
+            return dto.getBiz();
+        }
+        return "alipay_un_stage".equals(dto.getTradeType()) ? BIZ_ALIPAY_UN_STAGE : null;
+    }
+
     private HarPrecreateRequest buildPrecreateRequest(HarOrderCreateDTO dto,
                                                       HarMerchantEntity merchant,
                                                       String orderNo,
@@ -713,7 +737,7 @@ public class HarOrderService {
         req.setTitle(dto.getTitle());
         req.setStageNum(dto.getStageNum());
         req.setTradeType(dto.getTradeType());
-        req.setBiz(dto.getBiz());
+        req.setBiz(resolveBiz(dto));
         req.setPercent(dto.getPercent());
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);
@@ -735,7 +759,7 @@ public class HarOrderService {
         req.setAuthCode(dto.getAuthCode());
         req.setStageNum(dto.getStageNum());
         req.setTradeType(dto.getTradeType());
-        req.setBiz(dto.getBiz());
+        req.setBiz(resolveBiz(dto));
         req.setPercent(dto.getPercent());
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);

+ 1 - 1
java/src/main/resources/db/har-checkout-link.sql

@@ -38,7 +38,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_har_checkout_token
 CREATE UNIQUE INDEX IF NOT EXISTS uk_har_checkout_tenant_mch
     ON pay_har_checkout_link (tenant_id, merchant_code);
 
-COMMENT ON TABLE pay_har_checkout_link IS '汇安融收款链接(一商户一条;打开 /#/checkout/har/{token} 输金额后当场建主扫单)';
+COMMENT ON TABLE pay_har_checkout_link IS '汇安融收款链接(一商户一条;打开 /#/checkout/alipay/{token} 输金额后当场建主扫单)';
 COMMENT ON COLUMN pay_har_checkout_link.id            IS '主键ID(MyBatis-Plus ASSIGN_ID 雪花)';
 COMMENT ON COLUMN pay_har_checkout_link.tenant_id     IS '租户ID(发放时取自商户行,不来自请求体)';
 COMMENT ON COLUMN pay_har_checkout_link.merchant_code IS '汇安融商户编号(钱进哪个商户由它决定;下单时回 pay_har_merchant 复核一遍)';

+ 1 - 1
java/src/main/resources/db/har-order-refund-menu.sql

@@ -11,7 +11,7 @@
 --
 -- 谁拿到这个权限:
 --   role 1 = ADMIN、role 2 = tenant 都授予 —— 与现状一致,租户本来就能通过
---   开放 API(POST /payment/openapi/har/refund)退自己商户的单,后台按钮只是同一能力的界面。
+--   开放 API(POST /payment/openapi/alipay/refund)退自己商户的单,后台按钮只是同一能力的界面。
 --   若要**只给 ADMIN、不给租户**:执行完把最后那段 role 2 的 INSERT 换成它下面注释里的 DELETE。
 
 -- ---------- 权限点:挂在「订单管理」页面下 ----------

+ 2 - 2
java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java

@@ -66,12 +66,12 @@ class HarCheckoutWiringTest {
      * 反面对照:管理端(要登录)的汇安融路径**不得**被白名单覆盖。
      *
      * <p>★ 否定式读数配正对照:先用一条**确实被放行**的同前缀路径证明匹配器与读取姿势是好的
-     * ({@code /payment/openapi/har/**} 命中),再断言管理端两条不被命中 ——
+     * ({@code /payment/openapi/alipay/**} 命中),再断言管理端两条不被命中 ——
      * 否则「没命中」可能只是量具坏了(本仓吃过这个亏)。
      */
     @Test
     void adminHarEndpoints_areNotWhitelisted() throws Exception {
-        assertTrue(whitelisted("/payment/openapi/har/barcode/pay"),
+        assertTrue(whitelisted("/payment/openapi/alipay/barcode/pay"),
                 "正对照:同前缀的开放接口确实被放行 —— 证明匹配器读取姿势是好的");
 
         assertFalse(whitelisted("/payment/har/merchant"),

+ 96 - 0
java/src/test/java/com/payment/platform/core/security/OpenapiAuthWiringTest.java

@@ -0,0 +1,96 @@
+package com.payment.platform.core.security;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 白名单 × 认证前缀的**一致性**守卫。
+ *
+ * <p>凡是 {@code SecurityConfig.WHITE_LIST} 里以 {@code /payment/openapi/} 开头、
+ * 被 permitAll 放行的前缀,{@code TenantApiKeyAuthFilter} 必须同样认领它
+ * ({@code shouldNotFilter} 返回 false = 需要 API Key 认证)。
+ *
+ * <p><b>为什么单开一类</b>:2026-09-24 的零鉴权事故就是这两份清单**只改了一边** ——
+ * 白名单放行了 {@code /payment/openapi/har/**}、filter 的 {@code OPENAPI_PREFIXES}
+ * 没加,该前缀下「被放行 + 没有任何 filter 认证」,而当时**没有任何测试会变红**
+ * (当事注释原文,见 {@code OUTPUT_PREFIXES} 上方那段)。2026-10-09 前缀改名为
+ * {@code alipay} 又是一次「两处必须同名改」的动作,所以把这条一致性从注释里的
+ * 告诫升级成一个会变红的用例。
+ *
+ * <p><b>本条不是重复劳动</b>:{@code HarCheckoutWiringTest} 守的是「顾客页被放行」,
+ * {@code LeshouyingWiringTest} 守的是「乐收赢路径需要认证」,两者都按**字面量**列举
+ * 自己那条通道;没有任何一条用例读 {@code WHITE_LIST} 再回头找 filter 对账。
+ * (变异验证 2026-10-09:只把 {@code OPENAPI_PREFIXES} 的 alipay 那条改回 har、
+ * 白名单保持 alipay —— 改前 30 个既有 wiring 用例**全绿**,本类是本条修法的红点。)
+ */
+class OpenapiAuthWiringTest {
+
+    /** 被测 filter;三个依赖字段在 shouldNotFilter 路径上都不被触碰,故可传 null(同 LeshouyingWiringTest)。 */
+    private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
+
+    /**
+     * 遍历白名单里每一条 openapi 前缀:permitAll 放行的,filter 必须认领(不跳过)。
+     *
+     * <p>bare 前缀(不带尾段)与带尾段两种形状都断言:filter 的判定式是
+     * {@code equals(prefix) || startsWith(prefix + "/")},两半各对应其中一种。
+     */
+    @Test
+    void everyPermitAllOpenapiWhitelistEntry_isClaimedByTheAuthFilter() throws Exception {
+        String[] whiteList = readWhiteList();
+
+        int checked = 0;
+        for (String entry : whiteList) {
+            if (!entry.startsWith("/payment/openapi/")) {
+                continue;
+            }
+            String bare = entry.endsWith("/**")
+                    ? entry.substring(0, entry.length() - 3) : entry;
+            checked++;
+            assertFalse(filter.shouldNotFilter(realRequest(bare)),
+                    "白名单放行 " + entry + " 而 filter 不认这个前缀 ⇒ 该前缀下零鉴权(2026-09-24 事故形状): " + bare);
+            assertFalse(filter.shouldNotFilter(realRequest(bare + "/probe")),
+                    "同上(带尾段形状): " + bare + "/probe");
+        }
+        assertTrue(checked >= 3,
+                "一条 /payment/openapi/ 白名单都没扫到,说明这条用例的量具坏了(读数 0 不算通过): checked=" + checked);
+    }
+
+    /**
+     * 正对照:同一支 filter 对**免登录顾客页**必须跳过(true)。
+     *
+     * <p>少了这条,「filter 恒返回 false」的坏量具也能让上面整条用例全绿 ——
+     * 那种形状下断言虽然全中,却什么都没守住。
+     */
+    @Test
+    void positiveControl_checkoutPageIsSkipped() {
+        assertTrue(filter.shouldNotFilter(realRequest("/payment/har/checkout/tok")),
+                "顾客页不是下游系统,filter 必须跳过 —— 正对照,证明本量具能给出 true");
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static String[] readWhiteList() throws Exception {
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        return (String[]) f.get(null);
+    }
+
+    /**
+     * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
+     * ★ 若两者相等(旧写法),测试就分辨不出被测代码用的是哪个访问器 ——
+     *   同 LeshouyingWiringTest#realRequest。
+     */
+    private static MockHttpServletRequest realRequest(String path) {
+        MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
+        req.setContextPath("/api/v1");
+        req.setServletPath(path);
+        req.setRequestURI("/api/v1" + path);
+        assertNotEquals(req.getServletPath(), req.getRequestURI(),
+                "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
+        return req;
+    }
+}

+ 5 - 5
java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java

@@ -132,7 +132,7 @@ class HarMerchantControllerTest {
         when(linkService.issue(3L, "M003", "商户表里的名字"))
                 .thenReturn(link("M003", "链接行里的名字", "tok3"));
         when(linkService.buildUrl(same("tok3"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok3");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok3");
 
         HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
 
@@ -142,7 +142,7 @@ class HarMerchantControllerTest {
         assertEquals("M003", vo.getMerchantCode(), "商户编号要回给页面显示(操作员得知道自己在给谁取链接)");
         assertEquals("链接行里的名字", vo.getMerchantName(),
                 "显示名取自**链接行**(顾客打开链接时看到的就是它),不是商户行那一列");
-        assertEquals("https://pay.example.com/#/checkout/har/tok3", vo.getUrl(),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok3", vo.getUrl(),
                 "URL 必须来自服务层的 buildUrl(基址推导那套逻辑只有一份,"
                         + "控制器自己拼一个就是第二个副本)");
     }
@@ -164,14 +164,14 @@ class HarMerchantControllerTest {
         when(merchantService.requireById(1L)).thenReturn(row);
         when(linkService.issue(5L, "M005", "五号商户")).thenReturn(link("M005", "五号商户", "tok5"));
         when(linkService.buildUrl(same("tok5"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok5");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok5");
 
         HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
 
         verify(merchantService).requireById(1L);
         verify(merchantService, never()).requireByIdForTenant(anyLong(), any());
         verify(linkService).issue(5L, "M005", "五号商户");
-        assertEquals("https://pay.example.com/#/checkout/har/tok5", vo.getUrl(),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok5", vo.getUrl(),
                 "超管没有自己的租户号 —— 租户号必须取自那一行,两种调用方才都通");
     }
 
@@ -199,7 +199,7 @@ class HarMerchantControllerTest {
                 .thenReturn(merchant(1L, null, "M001", null));
         when(linkService.issue(null, "M001", null)).thenReturn(link("M001", null, "tok1"));
         when(linkService.buildUrl(same("tok1"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok1");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok1");
 
         controller.checkoutLink(1L);
 

+ 2 - 2
java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java

@@ -212,8 +212,8 @@ class HarOpenControllerTest {
         assertEquals("/refund", HarOpenController.class
                         .getDeclaredMethod("refund", HarOpenRefundDTO.class)
                         .getAnnotation(PostMapping.class).value()[0],
-                "租户文档 10.4 承诺的路径是 /payment/openapi/har/refund");
-        assertEquals("/payment/openapi/har",
+                "租户文档 10.4 承诺的路径是 /payment/openapi/alipay/refund");
+        assertEquals("/payment/openapi/alipay",
                 HarOpenController.class.getAnnotation(RequestMapping.class).value()[0]);
     }
 

+ 25 - 1
java/src/test/java/com/payment/platform/module/payment/har/dto/HarDtoJsonContractTest.java

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.payment.platform.common.base.PaymentBaseEntity;
 import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -12,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * 钉住汇安融三个管理端 VO 的 {@code id} 序列化形态:必须是 JSON <b>字符串</b>。
+ * 钉住汇安融管理端 VO 的 JSON 形态:{@code id} 必须是字符串,订单响应体不得暴露 {@code biz}。
  *
  * <p>为什么值得一个测试:{@link PaymentBaseEntity} 的 {@code id} 是
  * {@code IdType.ASSIGN_ID}(雪花,19 位),超过 JS 的 {@code Number.MAX_SAFE_INTEGER}
@@ -96,6 +97,29 @@ class HarDtoJsonContractTest {
         assertIdIsExactString(jsonOf(vo));
     }
 
+    /**
+     * 订单响应体**不含 biz 键**(2026-10-09 去上游化)。
+     *
+     * <p>那串取值带上游品牌(规范 260924 的 {@code har_ali_un_stage}),且自从平台按
+     * {@code trade_type} 自动派生后,调用方既不用填也不该看到;管理页也没有任何一列读它
+     * (表单仍可显式传 —— 那是透传,不是回读)。
+     *
+     * <p>断言「键不在报文里」而不是「值为 null」:值为 null 的字段照样占着这个键、
+     * 出现在文档与 SDK 生成器里 —— 那还是暴露。夹具特意给实体填上非空 biz,
+     * 证明这条钉的是 VO 的字段集,不是「数据库里恰好没有」。
+     */
+    @Test
+    void orderVoResponse_doesNotCarryBizKey() {
+        HarOrderEntity e = new HarOrderEntity();
+        e.setOrderNo("AP1");
+        e.setBiz("har_ali_un_stage");
+        HarOrderVO vo = HarOrderVO.of(e);
+
+        JsonNode node = jsonOf(vo);
+        assertTrue(node.path("biz").isMissingNode(),
+                "响应体不得再出现 biz 键。实测报文: " + node);
+    }
+
     /**
      * {@code isTextual} 才是承重的那条:数值节点上调 {@code asText()} 同样拿得到
      * 正确数字串(Java 侧没丢精度,丢精度发生在浏览器),只断言 {@code asText()}

+ 7 - 7
java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java

@@ -38,14 +38,14 @@ import static org.mockito.Mockito.*;
  *
  * <p>形态逐条对标 {@code LsyCheckoutLinkServiceTest},差异只有两处**通道事实**:
  * 定位列是 {@code merchant_code}(HAR 没有 mch_no),以及路径前缀带
- * {@code /har/} 段({@code /#/checkout/har/{token}},与乐收赢那条并存不冲突)。
+ * {@code /alipay/} 段({@code /#/checkout/alipay/{token}},与乐收赢那条并存不冲突)。
  *
  * <p>本类钉的四件事都是**会静默出错**的那一类:
  * <ol>
  *   <li><b>重复点按钮不能换 token</b> —— 链接是印出去/贴出去的东西,换一次等于让客户手上那张失效;</li>
  *   <li><b>停用的链接必须 404</b> —— {@code status} 列如果没人读,它就是本仓警告过的死字段;</li>
  *   <li><b>拼出来的 URL 形态</b> —— 顾客侧是 hash 路由({@code createWebHashHistory}),
- *       写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404;</li>
+ *       写成 {@code /checkout/alipay/{token}} 会命中静态路由表末尾的通配 404;</li>
  *   <li><b>安全论证依赖的 schema 事实写在脚本里</b> —— 免登录按 token 定位之所以合规,
  *       唯一依据是「token 有全局唯一索引」(通则 (甲)·预防)。索引只写在 SQL 文件里,
  *       所以这里直接读那个文件钉住它。</li>
@@ -281,8 +281,8 @@ class HarCheckoutLinkServiceTest {
 
         String url = service.buildUrl("tok", req);
 
-        assertEquals("https://qcsj88888.com/#/checkout/har/tok", url,
-                "顾客侧是 hash 路由:路径必须是 /#/checkout/har/{token},写 /checkout/har/{token} 会 404");
+        assertEquals("https://qcsj88888.com/#/checkout/alipay/tok", url,
+                "顾客侧是 hash 路由:路径必须是 /#/checkout/alipay/{token},写 /checkout/alipay/{token} 会 404");
     }
 
     @Test
@@ -295,7 +295,7 @@ class HarCheckoutLinkServiceTest {
         req.addHeader("X-Forwarded-Proto", "https");
         req.addHeader("X-Forwarded-Host", "pay.example.com");
 
-        assertEquals("https://pay.example.com/#/checkout/har/tok", service.buildUrl("tok", req));
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok", service.buildUrl("tok", req));
     }
 
     @Test
@@ -306,7 +306,7 @@ class HarCheckoutLinkServiceTest {
         req.setServerName("192.168.1.9");
         req.setServerPort(8080);
 
-        assertEquals("http://192.168.1.9:8080/#/checkout/har/tok", service.buildUrl("tok", req));
+        assertEquals("http://192.168.1.9:8080/#/checkout/alipay/tok", service.buildUrl("tok", req));
     }
 
     @Test
@@ -316,7 +316,7 @@ class HarCheckoutLinkServiceTest {
         req.setServerName("ignored");
         req.setScheme("http");
 
-        assertEquals("https://pay.example.com/#/checkout/har/tok", service.buildUrl("tok", req),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok", service.buildUrl("tok", req),
                 "yml 里配了 har.checkout-base-url 就以它为准(反代只转发路径、Host 是内网名时用得上)");
     }
 

+ 83 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -177,7 +177,7 @@ class HarOrderServiceTest {
         assertEquals(vo.getOrderNo(), captor.getValue().getThirdOrderNo());
         assertNotEquals("BIZ-0001", captor.getValue().getThirdOrderNo());
         assertEquals("BIZ-0001", vo.getOutBizNo());
-        assertTrue(vo.getOrderNo().startsWith("HAR"));
+        assertTrue(vo.getOrderNo().startsWith("AP"));
     }
 
     /** 下单时排入轮询:上游通知窗口只有约 25 秒,超时后只能靠主动查询收敛 */
@@ -260,6 +260,88 @@ class HarOrderServiceTest {
         assertEquals("har_ali_un_stage", req.getValue().getBiz());
     }
 
+    /**
+     * 订单号前缀是**中性**的 {@code AP},不带上游品牌(2026-10-09 去上游化)。
+     *
+     * <p>order_no 出现在开放 API 响应、顾客收款页和上游的 third_order_no 里 ——
+     * 是调用方每天都看得到的字符串,HAR 前缀等于把上游名字印在上面。
+     * 存量单不动(改不了也不该改),这里只钉新生成的那一串。
+     */
+    @Test
+    void create_orderNoUsesNeutralPrefix_notUpstreamBrand() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        String orderNo = captor.getValue().getOrderNo();
+        assertTrue(orderNo.startsWith("AP"), "订单号前缀应为中性词 AP。实测: " + orderNo);
+        assertFalse(orderNo.startsWith("HAR"), "订单号不得带上游品牌。实测: " + orderNo);
+    }
+
+    /**
+     * {@code biz} 留空 + 非分期 ⇒ 平台按 trade_type 派生上游要的取值(2026-10-09 去上游化:
+     * 这串带上游品牌,从调用方契约里拿掉,文档改为「留空即可」)。
+     * 落库与发给上游必须是**同一份** —— 两份不同的话对账时对不上。
+     */
+    @Test
+    void create_bizBlank_nonStage_derivesTheUpstreamValue() {
+        HarOrderCreateDTO dto = dto(); // dto() 的 trade_type 已是 alipay_un_stage,biz 未设
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("har_ali_un_stage", req.getValue().getBiz(),
+                "调用方留空时由平台派生 —— 这串不再要求调用方知道");
+
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("har_ali_un_stage", entity.getValue().getBiz(),
+                "落库的与发给上游的必须是同一份");
+    }
+
+    /** 留空 + 分期 ⇒ 保持空:分期没有对应的规范取值,不替上游编一个。 */
+    @Test
+    void create_bizBlank_stageTradeType_leavesItBlank() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType("alipay_stage");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertNull(req.getValue().getBiz(), "分期没有派生依据,保持空而不是编一个");
+    }
+
+    /**
+     * 显式给了 biz ⇒ 原样透传、派生不覆盖(后台表单与既有调用方那条路)。
+     *
+     * <p>夹具特意用分期 + 一个与派生值**不同**的串:若实现变成「无条件派生」,
+     * 这条立刻红 —— 拿非分期 + 派生值同串作夹具的话,两种实现在改前改后都绿。
+     */
+    @Test
+    void create_bizExplicit_passesThroughEvenOnStage() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType("alipay_stage");
+        dto.setBiz("caller_knows_best");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("caller_knows_best", req.getValue().getBiz(),
+                "显式传了就不许被派生覆盖");
+    }
+
     /**
      * 上游**明确拒绝**(参数错误之类)⇒ 本地这一行必须跟着收尾。
      *