Jelajahi Sumber

feat(去企业化): 乐收赢商户映射可按租户寻址 - 下单不带 enterprise_id 走租户级

需求:乐收赢与原有企业逻辑完全不相干,商户映射应按**租户账号**寻址。
不新增列、不新增表 —— 租户级商户就是 pay_lsy_merchant 里 enterprise_id IS NULL
的那一行。原有的开放接口不变,老调用方一字不用改。

改动:
- LsyMerchantService.requireByTenant(tenantId):新增。查 enterprise_id IS NULL。
  与 requireByEnterprise 是**并存**不是替代。
- LsyMerchantService.save:删掉「企业 ID 不能为空」的 400 —— 那条校验的前提是列 NOT NULL
  (留空没有合法落点),列放开后前提消失,留空有了明确语义。enterprise_id 成为第 4 个
  可空列(空白 ⇒ NULL)。
- LeshouyingOpenController.requireMerchant:三个下单方法共用的分流 —— 空白走租户级、
  非空走企业级。**不做**「先租户级、查不到再退回企业级」的兜底链:那会把一次漏配
  (本该 404 的配置错误)静默降级成「用了另一个企业的商户号收款」。
- 三个 DTO 去掉 enterpriseId 上的 @NotBlank(字段保留,向后兼容靠它)。
- 管理端商户映射页:去掉 enterprise_id 必填规则;留空在列表里显示「租户级」标签
  (空单元格与「这列没数据」无法区分);表单初值由 "" 改 null。

新增 util/LsyStrings.blankToNull:实测 StrUtil **没有**「空白 ⇒ null」,
只有 CharSequenceUtil.emptyToNull,而它把 "   " **原样留下**。存成空串之后
任何 WHERE col IS NULL 都查不到这一行 —— 写入方以为建好了租户级映射,
下单方报「未配置商户号」。

验证(实测读数,非推断):
- 427/427 绿(基线 418 + 新增 9:service 5 + controller 4)
- 变异验证两向:
  · requireMerchant 的 isBlank → == null ⇒ 红 2("" 与 "   " 两条)
  · isBlank → isEmpty ⇒ 红 1(只有 "   ")⇒ 纯空白是**独立**钉子,不是被 "" 顺带过的
  · 摘掉 setEnterpriseId 的 blankToNull ⇒ 红 2(expected <null> but was <> / <   >)
  · 无害重写(三元 → if/else)⇒ 仍绿 18/18(钉子钉的是行为,不是语法)
- 前端:vue-tsc 本文件 0 错误,并用**主动探针**证明该文件确在检查范围内
  (否则「0 错误」可能只是没被检查 —— 该判断我第一次做错,正对照是空集)
- prettier:本文件在 HEAD 就不干净(exit=1),两处都在未触碰的区域 ⇒ 不跑 --write,
  否则制造无关 churn

⚠ 依赖未执行的 DDL:本提交删掉了 save 的 400 校验,而
pay_lsy_merchant.enterprise_id 目前仍是 NOT NULL(2026-09-22 只读核对:is_nullable=NO,
uk_lsy_merchant_tenant_level 不存在)。**先执行 db/lsy-tenant-level.sql 再部署本提交**,
否则留空保存会从 400 变成 500(NOT NULL 违约落进 GlobalExceptionHandler 兜底)。
alphaH 3 minggu lalu
induk
melakukan
bcdb2b9ea7

+ 26 - 13
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -4,7 +4,7 @@
       type="info"
       :closable="false"
       style="margin-bottom: 12px"
-      title="企业在本平台开通的商户号。不配置则无法为该企业下单。"
+      title="本平台开通的商户号。企业 ID 留空即「租户级」——该租户下所有下单都走这一行;填了企业 ID 则只有该企业可用。不配置则无法下单。"
     />
 
     <el-card shadow="never">
@@ -27,12 +27,14 @@
 
     <el-card shadow="never" style="margin-top: 12px">
       <el-table v-loading="loading" :data="list" border stripe>
-        <el-table-column
-          prop="enterprise_id"
-          label="企业 ID"
-          min-width="180"
-          show-overflow-tooltip
-        />
+        <!-- ★ 留空的 enterprise_id 必须显示成「租户级」而不是空白格:
+             空单元格与「这一列没数据」无法区分,管理员看不出哪一行是租户级兜底映射。 -->
+        <el-table-column label="企业 ID" min-width="180">
+          <template #default="{ row }">
+            <el-tag v-if="!row.enterprise_id" type="warning" size="small">租户级</el-tag>
+            <span v-else>{{ row.enterprise_id }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="mch_no" label="商户号" min-width="160" />
         <el-table-column prop="shop_no" label="门店号" min-width="140" />
         <el-table-column prop="merchant_name" label="商户名称" min-width="180" />
@@ -66,8 +68,13 @@
 
     <el-dialog v-model="formVisible" :title="form.id ? '编辑商户映射' : '新增商户映射'" width="560px">
       <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <!-- ★ 选填(2026-09-22 去企业化):留空 ⇒ 这一行是**租户级**映射,
+             下单时不带 enterprise_id 的请求都会落到它上面。 -->
         <el-form-item label="企业 ID" prop="enterprise_id">
-          <el-input v-model="form.enterprise_id" placeholder="请输入企业 ID" />
+          <el-input
+            v-model="form.enterprise_id"
+            placeholder="可空 —— 留空即租户级(该租户下所有下单可用)"
+          />
         </el-form-item>
         <el-form-item label="商户号" prop="mch_no">
           <el-input v-model="form.mch_no" placeholder="请输入商户号" />
@@ -128,16 +135,19 @@ const FORM_FIELDS = [
 /**
  * ★ 键名必须 snake_case:后端 `@RequestBody LsyMerchantEntity` 走全局 SNAKE_CASE 反序列化。
  *
- * ★ 可空列(V1.8 DDL:`shop_no` / `merchant_name` / `description`)的初值是 `null` 而**不是** `""`:
- *   这三列的语义是「空 ⇒ 库里 NULL」,而 `""` 与 `NULL` 在库里是两回事 ——
+ * ★ 可空列(V1.8 DDL:`shop_no` / `merchant_name` / `description`,2026-09-22 起加上
+ *   `enterprise_id`)的初值是 `null` 而**不是** `""`:
+ *   这四列的语义是「空 ⇒ 库里 NULL」,而 `""` 与 `NULL` 在库里是两回事 ——
  *   表格里都显示空白,但 `WHERE shop_no IS NULL` 查不到 `''`,于是数据悄悄查不出来。
  *   初值给 `""` 会让「打开新增表单、什么都不填直接保存」写出空串。
+ *   `enterprise_id` 上这条尤其致命:租户级映射就是靠 `enterprise_id IS NULL` 找的,
+ *   存成 `''` 之后下单方直接报「未配置商户号」。
  *   `el-input` 把 `null` 当空串显示(不会报错也不会显示 "null"),界面无差别。
  */
 function emptyForm(): Record<string, any> {
   return {
     id: null,
-    enterprise_id: "",
+    enterprise_id: null,
     mch_no: "",
     shop_no: null,
     merchant_name: null,
@@ -177,8 +187,10 @@ function buildForm(row?: any): Record<string, any> {
 
 const form = ref<Record<string, any>>(emptyForm());
 
+// ★ `enterprise_id` 没有规则 —— 它是选填的(留空 = 租户级)。
+//   不要加回 required:那会把「配租户级映射」这条正常路径堵死在表单校验上,
+//   而服务端早就不认这个前提了(LsyMerchantService.save 只校验 mch_no)。
 const rules = {
-  enterprise_id: [{ required: true, message: "企业 ID 必填", trigger: "blur" }],
   mch_no: [{ required: true, message: "商户号必填", trigger: "blur" }],
 };
 
@@ -223,7 +235,8 @@ async function handleSubmit() {
 }
 
 async function handleDelete(row: any) {
-  await ElMessageBox.confirm(`确认删除商户映射「${row.mch_no}」?删除后该企业将无法下单。`, "提示", {
+  const scope = row.enterprise_id ? `企业「${row.enterprise_id}」` : "本租户";
+  await ElMessageBox.confirm(`确认删除商户映射「${row.mch_no}」?删除后${scope}将无法下单。`, "提示", {
     type: "warning",
   });
   await deleteLsyMerchant(row.id);

+ 36 - 10
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.controller.open;
 
 import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
@@ -15,6 +16,7 @@ import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
 import com.payment.platform.module.payment.leshouying.service.*;
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import com.payment.platform.module.payment.leshouying.util.LsyStrings;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -69,14 +71,15 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "BARCODE", "000");
-        order.setEnterpriseId(dto.getEnterpriseId());
+        // 归一化:空白 ⇒ NULL。存成 "" 或 "   " 的话,这一行永远查不回来
+        // (requireByTenant 找的是 IS NULL,见 LsyStrings)。
+        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
-                tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -98,14 +101,14 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "QRCODE", null);
-        order.setEnterpriseId(dto.getEnterpriseId());
+        // 归一化:空白 ⇒ NULL(同 barcodePay 的说明)
+        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
-                tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -125,14 +128,14 @@ public class LeshouyingOpenController {
 
         LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                 apiKeyId(), "H5", dto.getPayType());
-        order.setEnterpriseId(dto.getEnterpriseId());
+        // 归一化:空白 ⇒ NULL(同 barcodePay 的说明)
+        order.setEnterpriseId(LsyStrings.blankToNull(dto.getEnterpriseId()));
         order.setTotalAmount(dto.getTotalAmount());
         order.setOrderBody(dto.getOrderBody());
         order.setAttach(dto.getAttach());
         order.setNotifyUrl(dto.getNotifyUrl());
 
-        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
-                tenantId, dto.getEnterpriseId());
+        LsyMerchantEntity merchant = requireMerchant(tenantId, dto.getEnterpriseId());
         order.setMchNo(merchant.getMchNo());
         order.setShopNo(merchant.getShopNo());
         // ★ 落库必须在 mch_no/shopNo 装配之后:V1.8 的 pay_lsy_order.mch_no 是 NOT NULL 且无默认值
@@ -342,11 +345,34 @@ public class LeshouyingOpenController {
         return v instanceof Long ? (Long) v : null;
     }
 
+    /**
+     * 三个下单方法共用的商户映射分流(2026-09-22 去企业化)。
+     *
+     * <p>{@code enterpriseId} 空白 ⇒ 租户级({@link LsyMerchantService#requireByTenant});
+     * 非空 ⇒ 企业级({@link LsyMerchantService#requireByEnterprise})。两条路径**并存**,
+     * 不是替代 —— 传了 {@code enterpriseId} 的老调用方一字不用改,走的还是原来那条。
+     *
+     * <p><b>判空用 {@code StrUtil.isBlank} 而不是 {@code == null}:</b>报文里给一个
+     * {@code "enterprise_id": ""} 或 {@code "   "} 与不传这个键,在业务上是同一个意思
+     * (「不绑企业」)。若只判 null,空白串会落进企业级分支,
+     * 而 requireByEnterprise 转头就抛 400「enterprise_id 不能为空」——
+     * 同样是「不绑企业」的两种写法,一种能用一种报错。
+     *
+     * <p>★ 明确**不**做「先查租户级、查不到再退回企业级」的兜底链:
+     * 那会让一次漏配商户映射(本该 404 的配置错误)静默降级成「用了另一个企业的商户号收款」。
+     * 选哪条路由请求体里的一个字段唯一决定,不清楚就报错。
+     */
+    private LsyMerchantEntity requireMerchant(Long tenantId, String enterpriseId) {
+        return StrUtil.isBlank(enterpriseId)
+                ? merchantService.requireByTenant(tenantId)
+                : merchantService.requireByEnterprise(tenantId, enterpriseId);
+    }
+
     /**
      * 只**构造**订单实体,**不落库**。
      * ★ 落库由调用方在全部字段装配完毕后显式 {@code orderMapper.insert(order)} ——
      *   因为 {@code pay_lsy_order.mch_no} 是 {@code NOT NULL} 且无默认值(V1.8 建表语句),
-     *   而 mch_no 要先查商户(requireByEnterprise)才能拿到。
+     *   而 mch_no 要先查商户(见 {@link #requireMerchant})才能拿到。
      */
     private LsyOrderEntity newOrder(String outTradeNo, Long tenantId, Long apiKeyId,
                                     String scene, String payType) {

+ 4 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyBarcodePayDTO.java

@@ -13,7 +13,10 @@ public class LsyBarcodePayDTO {
     @NotBlank(message = "out_trade_no 不能为空")
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
-    @NotBlank(message = "enterprise_id 不能为空")
+    /**
+     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级。
+     * 字段保留、只去掉 `@NotBlank`;分流见 `LeshouyingOpenController.requireMerchant`。
+     */
     private String enterpriseId;
     /** 用户付款码,18 位纯数字,以 10-15 开头 */
     @NotBlank(message = "auth_code 不能为空")

+ 5 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyH5CreateDTO.java

@@ -13,7 +13,11 @@ public class LsyH5CreateDTO {
     @NotBlank
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
-    @NotBlank private String enterpriseId;
+    /**
+     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级。
+     * 字段保留、只去掉 `@NotBlank`;分流见 `LeshouyingOpenController.requireMerchant`。
+     */
+    private String enterpriseId;
     @NotNull @Positive private Long totalAmount;
     /**
      * 必填:300 支付宝 / 400 微信。H5 不支持自动识别。

+ 8 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyQrCreateDTO.java

@@ -13,7 +13,14 @@ public class LsyQrCreateDTO {
     @NotBlank
     @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
     private String outTradeNo;
-    @NotBlank private String enterpriseId;
+    /**
+     * 选填(2026-09-22 去企业化)。留空 ⇒ 走**租户级**商户映射;传值 ⇒ 仍走企业级,
+     * 老调用方一字不用改。
+     *
+     * ★ 去掉的是 `@NotBlank`,不是字段本身 —— 字段必须保留,向后兼容靠它。
+     *   留空不是「没填」,是一个明确语义,分流见 `LeshouyingOpenController.requireMerchant`。
+     */
+    private String enterpriseId;
     @NotNull @Positive private Long totalAmount;
     // 长度上界与 pay_lsy_order 列定义对齐(见 LsyBarcodePayDTO 同类字段的说明)。
     @Size(max = 128, message = "order_body 不能超过 128 位")

+ 55 - 18
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -8,6 +8,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
+import com.payment.platform.module.payment.leshouying.util.LsyStrings;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -53,6 +54,35 @@ public class LsyMerchantService {
         return m;
     }
 
+    /**
+     * 取**租户级**商户映射(2026-09-22 去企业化)。
+     *
+     * <p>租户级商户 = {@code pay_lsy_merchant} 里 {@code enterprise_id IS NULL} 的那一行。
+     * 不新增列、不新增表;唯一性由局部唯一索引 {@code uk_lsy_merchant_tenant_level}
+     * (建在 {@code (tenant_id) WHERE enterprise_id IS NULL} 上)保证 ——
+     * 原有的 {@code uk_lsy_merchant_tenant_ent} 建在 {@code (tenant_id, enterprise_id)} 上,
+     * 而 PostgreSQL 唯一索引里 NULL 互不相等,光靠它拦不住同租户多行租户级商户。
+     *
+     * <p><b>为什么必须带 {@code isNull}:</b>少了它,查询退化成「本租户的任意一行商户映射」,
+     * 捞到**企业级**那一行也照常返回 —— 而企业级绑企业、租户级不绑,两者语义不同,
+     * 静默串用是这次改造最想防的事。回归在
+     * {@code LsyMerchantServiceTest.requireByTenant_queriesNullEnterpriseId}。
+     *
+     * <p>与 {@link #requireByEnterprise} 是**并存**关系,不是替代:传了 {@code enterpriseId}
+     * 的调用方仍走企业级路径(分流见 {@code LeshouyingOpenController} 的三个下单方法)。
+     */
+    public LsyMerchantEntity requireByTenant(Long tenantId) {
+        LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getTenantId, tenantId)
+                .isNull(LsyMerchantEntity::getEnterpriseId)
+                .last("LIMIT 1"));
+        if (m == null) {
+            throw new BusinessException(404,
+                    "该租户未配置商户号,请先在「商户映射」中配置");
+        }
+        return m;
+    }
+
     /**
      * 按乐收赢商户号反查映射记录。
      *
@@ -106,12 +136,18 @@ public class LsyMerchantService {
      * ({@code enterprise_id} / {@code mch_no} / {@code shop_no} / {@code merchant_name} /
      * {@code description})。「这一列没出现在请求里」与「操作者把这一列清空了」在服务端**不可区分** ——
      * 两者反序列化后是同一个实体(没传的键落在实体上就是 null)—— 载体本身表达不了「没传」。
-     * 缺键按清空处理是**故意的**(P3 要求「清空 ⇒ NULL」),但**只对 3 个可空列成立**
-     * ({@code shop_no} / {@code merchant_name} / {@code description} ⇒ 写 NULL);
-     * {@code enterprise_id} / {@code mch_no} 没有「被清空」这个结果 —— 缺键或空白一律 **400**
-     * (方法开头那两个校验)。需要部分更新的调用方要换一个能表达**存在性**的载体
+     * 缺键按清空处理是**故意的**(P3 要求「清空 ⇒ NULL」),对**4 个可空列**成立
+     * ({@code enterprise_id} / {@code shop_no} / {@code merchant_name} / {@code description}
+     * ⇒ 写 NULL);只有 {@code mch_no} 没有「被清空」这个结果 —— 缺键或空白一律 **400**
+     * (方法开头那个校验)。需要部分更新的调用方要换一个能表达**存在性**的载体
      * (例如 {@code Map} 或专用 DTO),不要复用本方法。
      *
+     * <p>★ 2026-09-22 去企业化:{@code enterprise_id} 从「必填、空白即 400」变成了第 4 个
+     * 可空列 —— 清空它 ⇒ 这一行变成**租户级**映射({@link #requireByTenant} 找的就是
+     * {@code enterprise_id IS NULL})。这条语义变化由
+     * {@code save_updateBranch_blankEnterpriseId_writesNull} 与
+     * {@code save_insertBranch_blankEnterpriseId_inserts} 覆盖。
+     *
      * <p><b>新增</b>:默认值({@code review_status=APPROVED}、{@code status=ENABLED})**只在这里**补。
      * 编辑分支若也补,就会出现这条静默数据缺陷:请求体不带这两列(管理端表单不投影它们)
      * ⇒ 后端填默认值 ⇒ 整行写回 ⇒ 库里的 {@code status='DISABLED'} / {@code review_status='PENDING'}
@@ -125,7 +161,8 @@ public class LsyMerchantService {
      *       (新列在编辑路径上写不进去,而本方法的返回值不体现这件事:调用方拿到的是回读行,
      *       回读行里那个字段仍是库里的旧值);</li>
      *   <li>{@code wrapper.set(col, null)} 能真正写 NULL,而全局 {@code updateStrategy = NOT_NULL}
-     *       会把实体上的 null 从 SET 里剔除 —— 可空列的「清空」语义只能靠它表达(见 {@link #blankToNull})。</li>
+     *       会把实体上的 null 从 SET 里剔除 —— 可空列的「清空」语义只能靠它表达
+     *       (见 {@link LsyStrings#blankToNull})。</li>
      * </ol>
      * {@code updated_time} 显式刷成当前时刻:{@code update(null, wrapper)} 不经过实体,
      * 拿不到 {@code AutoFillMetaObjectHandler} 的 {@code strictUpdateFill}。
@@ -140,18 +177,23 @@ public class LsyMerchantService {
         if (StrUtil.isBlank(entity.getMchNo())) {
             throw new BusinessException(400, "商户号不能为空");
         }
-        if (StrUtil.isBlank(entity.getEnterpriseId())) {
-            throw new BusinessException(400, "企业 ID 不能为空");
-        }
-        // 可空列(V1.8:shop_no / merchant_name / description 可空):空 ⇒ NULL。
+        // ⚠ 2026-09-22 去企业化:这里原本还有一条「企业 ID 不能为空」的 400。
+        //   那条校验的前提是 pay_lsy_merchant.enterprise_id 为 NOT NULL(留空没有合法落点);
+        //   列放开后前提消失,留空有了明确语义 —— **租户级商户**(见 requireByTenant)。
+        //   想回到「必须填企业」的话,加回的是校验,而不是把列改回 NOT NULL。
+        //
+        // 可空列(shop_no / merchant_name / description / enterprise_id):空 ⇒ NULL。
         // 收敛点放在本层而不是前端:管理端页面经这里写库,而前端只能「不产生空串」,
         // 拦不住别人 POST 一个空串进来。
-        // ★ 可空列由下面三行**正面枚举**:将来新增可空列,必须同时加一行 blankToNull,
+        // ★ 可空列由下面四行**正面枚举**:将来新增可空列,必须同时加一行 blankToNull,
         //   否则新列会开始接受空串 —— 而 save_updateBranch_writesExactlyTheSixOwnedColumns
         //   的集合相等断言不会提醒你(它盯的是编辑分支的 SET 列表,管不到这里)。
-        entity.setShopNo(blankToNull(entity.getShopNo()));
-        entity.setMerchantName(blankToNull(entity.getMerchantName()));
-        entity.setDescription(blankToNull(entity.getDescription()));
+        // ★ enterprise_id 尤其不能漏:存成 "   " 而不是 NULL,requireByTenant 找的是 IS NULL,
+        //   那一行就永远查不到了(写入方以为建好了租户级映射,下单方报「未配置商户号」)。
+        entity.setEnterpriseId(LsyStrings.blankToNull(entity.getEnterpriseId()));
+        entity.setShopNo(LsyStrings.blankToNull(entity.getShopNo()));
+        entity.setMerchantName(LsyStrings.blankToNull(entity.getMerchantName()));
+        entity.setDescription(LsyStrings.blankToNull(entity.getDescription()));
 
         if (entity.getId() == null) {
             // ★ 默认值只在新增分支补 —— 见方法注释
@@ -187,11 +229,6 @@ public class LsyMerchantService {
         return merchantMapper.selectById(entity.getId());
     }
 
-    /** 空白 ⇒ null;非空白**原样**返回(只处理「空」,不动用户内容 —— 不 trim) */
-    private static String blankToNull(String v) {
-        return StrUtil.isBlank(v) ? null : v;
-    }
-
     @Transactional
     public void delete(Long id) {
         if (merchantMapper.selectById(id) == null) {

+ 25 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyStrings.java

@@ -0,0 +1,25 @@
+package com.payment.platform.module.payment.leshouying.util;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 乐收赢模块的字符串口径工具。
+ *
+ * <p><b>存在的理由:{@link StrUtil} 没有「空白 ⇒ null」。</b>
+ * 它只有 {@code CharSequenceUtil.emptyToNull}(实测 5.8.34 的公开方法表),
+ * 那个把 {@code ""} 变 null,却把 {@code "   "} **原样留下**。
+ * 两者在数据库里是两回事:可空列存了空串之后,任何 {@code WHERE col IS NULL} 都查不到这一行,
+ * 而写入方以为写的是"没值"。本仓已经因为这个栽过一次(见 {@code LsyMerchantService.save} 的 D-2)。
+ *
+ * <p>不要因为"就一行"而在各处内联这个三元表达式 —— 口径分叉正是这么来的。
+ */
+public final class LsyStrings {
+
+    private LsyStrings() {
+    }
+
+    /** 空白(null / 空串 / 纯空白)⇒ {@code null};非空白**原样**返回(不 trim,不动用户内容)。 */
+    public static String blankToNull(String v) {
+        return StrUtil.isBlank(v) ? null : v;
+    }
+}

+ 102 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

@@ -118,6 +118,108 @@ class LeshouyingOpenControllerTest {
                 "★ 同理:enterprise_id 也必须在 insert 前装配");
     }
 
+    // ───────────── 去企业化(2026-09-22):不传 enterprise_id ⇒ 租户级;传了 ⇒ 企业级 ─────────────
+
+    @Test
+    void barcodePay_blankEnterpriseId_usesTenantLevelMerchant() {
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
+        when(orderService.createBarcodeOrder(any(), any(), any())).thenReturn(result("SUCCESS"));
+
+        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setEnterpriseId("");
+        dto.setAuthCode("134567890123456789");
+        dto.setTotalAmount(100L);
+        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
+
+        controller.barcodePay(dto);
+
+        verify(merchantService).requireByTenant(TENANT);
+        verify(merchantService, never()).requireByEnterprise(any(), any());
+        assertTenantLevelOrderRow(atInsert);
+    }
+
+    @Test
+    void qrcodeCreate_blankEnterpriseId_usesTenantLevelMerchant() {
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyQrCreateDTO dto = new LsyQrCreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setEnterpriseId(null);          // 缺键与空白等价
+        dto.setTotalAmount(100L);
+        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
+
+        controller.qrcodeCreate(dto);
+
+        verify(merchantService).requireByTenant(TENANT);
+        verify(merchantService, never()).requireByEnterprise(any(), any());
+        assertTenantLevelOrderRow(atInsert);
+    }
+
+    @Test
+    void h5Create_blankEnterpriseId_usesTenantLevelMerchant() {
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant());
+        when(orderService.createH5Order(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyH5CreateDTO dto = new LsyH5CreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setEnterpriseId("   ");
+        dto.setTotalAmount(100L);
+        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
+
+        controller.h5Create(dto);
+
+        verify(merchantService).requireByTenant(TENANT);
+        verify(merchantService, never()).requireByEnterprise(any(), any());
+        assertTenantLevelOrderRow(atInsert);
+    }
+
+    /**
+     * 反向:带了 {@code enterprise_id} 就**不许**去碰租户级。
+     *
+     * <p>分流是「按入参选一条路」,不是「先租户级、查不到再退回企业级」那种兜底 ——
+     * 后者会把一个显式指定了企业的请求静默落到另一个商户号上,出金口径随之改变。
+     */
+    @Test
+    void qrcodeCreate_withEnterpriseId_neverConsultsTenantLevel() {
+        when(merchantService.requireByEnterprise(any(), eq("ENT1"))).thenReturn(merchant());
+        when(orderService.createQrOrder(any(), any())).thenReturn(result("PAY_SUCCESS"));
+
+        LsyQrCreateDTO dto = new LsyQrCreateDTO();
+        dto.setOutTradeNo("OUT001");
+        dto.setEnterpriseId("ENT1");
+        dto.setTotalAmount(100L);
+        List<LsyOrderEntity> atInsert = snapshotOrderAtInsert();
+
+        controller.qrcodeCreate(dto);
+
+        verify(merchantService, never()).requireByTenant(any());
+        assertEquals(1, atInsert.size());
+        assertEquals("ENT1", atInsert.get(0).getEnterpriseId(), "企业级下单照旧带上企业");
+    }
+
+    /** 落库那一刻的快照(用法与理由见 {@code barcodePay_mapsDtoToEntityAndReturnsResult}) */
+    private List<LsyOrderEntity> snapshotOrderAtInsert() {
+        List<LsyOrderEntity> atInsert = new ArrayList<>();
+        doAnswer(inv -> {
+            LsyOrderEntity src = inv.getArgument(0);
+            LsyOrderEntity snap = new LsyOrderEntity();
+            snap.setMchNo(src.getMchNo());
+            snap.setEnterpriseId(src.getEnterpriseId());
+            atInsert.add(snap);
+            return 1;
+        }).when(orderMapper).insert(any());
+        return atInsert;
+    }
+
+    private static void assertTenantLevelOrderRow(List<LsyOrderEntity> atInsert) {
+        assertEquals(1, atInsert.size(), "落库应恰好一次");
+        assertEquals("Y52211090004", atInsert.get(0).getMchNo(), "商户号取自租户级映射");
+        assertNull(atInsert.get(0).getEnterpriseId(),
+                "租户级下单的 enterprise_id 必须是 NULL —— 落成空串后,任何 `IS NULL` 的查询都找不到这一行");
+    }
+
     @Test
     void barcodePay_duplicateOutTradeNo_returnsExistingWithoutNewUpstreamCall() {
         LsyOrderEntity existing = new LsyOrderEntity();

+ 115 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.payment.platform.common.exception.BusinessException;
@@ -104,6 +105,71 @@ class LsyMerchantServiceTest {
         assertEquals(404, ex.getCode());
     }
 
+    // ─────────────────────── requireByTenant(租户级商户,2026-09-22 去企业化) ───────────────────────
+
+    @Test
+    void requireByTenant_found_returnsEntity() {
+        LsyMerchantEntity m = new LsyMerchantEntity();
+        m.setMchNo("Y52211090004");
+        when(merchantMapper.selectOne(any())).thenReturn(m);
+
+        assertEquals("Y52211090004", service.requireByTenant(1L).getMchNo());
+    }
+
+    @Test
+    void requireByTenant_missing_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByTenant(1L));
+
+        assertEquals(404, ex.getCode());
+        assertTrue(ex.getMessage().contains("未配置商户号"), "实测文案: " + ex.getMessage());
+    }
+
+    /**
+     * ★ 本组最要紧的一条:钉住**查询条件里带 {@code enterprise_id IS NULL}**。
+     *
+     * <p>上面两条用例只喂 mock、不看 SQL,漏掉 {@code isNull} 时它们**照样全绿** ——
+     * 而查询已经退化成「该租户的任意一行商户映射」,捞到**企业级**那一行也照常返回。
+     * 企业级与租户级的语义完全不同(前者绑企业、后者不绑),静默串用是本轮改造最想防的事。
+     *
+     * <p>条件串按 {@code AND} 切开做**集合断言**,不用 contains —— 同文件里
+     * {@code review_status} 含 {@code status} 子串的坑是同一类。
+     */
+    @Test
+    void requireByTenant_queriesNullEnterpriseId() {
+        when(merchantMapper.selectOne(any())).thenReturn(row(EDIT_ID));
+
+        service.requireByTenant(7L);
+
+        ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(captor.capture());
+        LambdaQueryWrapper<?> w = captor.getValue();
+        String seg = w.getSqlSegment();
+
+        // ★ 实测(不要照模型写):getSqlSegment() **包含** .last() 追加的那一段,
+        //   形状是 `(tenant_id = #{…MPGENVAL1} AND enterprise_id IS NULL) LIMIT 1`。
+        //   条件里没有嵌套括号,所以第一个 ')' 就是条件组的收尾。
+        assertTrue(seg.startsWith("("), "WHERE 应以条件组开头。实测: [" + seg + "]");
+        int close = seg.indexOf(')');
+        assertTrue(close > 0, "WHERE 条件组应有收尾括号。实测: [" + seg + "]");
+        assertEquals("LIMIT 1", seg.substring(close + 1).trim(),
+                "selectOne 必须带 LIMIT 1(租户级映射理论上唯一,但别依赖它)。实测: [" + seg + "]");
+
+        Set<String> conds = Arrays.stream(seg.substring(1, close).split(" AND "))
+                .map(String::trim).collect(Collectors.toSet());
+
+        assertTrue(conds.contains("enterprise_id IS NULL"),
+                "必须带 enterprise_id IS NULL —— 漏了它就会捞到企业级商户。实测条件: " + conds);
+        assertEquals(1, conds.stream().filter(c -> c.startsWith("tenant_id = ")).count(),
+                "必须恰有一条租户条件(少了会跨租户串号)。实测条件: " + conds);
+        assertEquals(2, conds.size(), "条件应恰好两条。实测条件: " + conds);
+
+        assertEquals(7L, w.getParamNameValuePairs().values().stream()
+                        .filter(Long.class::isInstance).findFirst().orElse(null),
+                "租户条件的占位符应解析成入参 tenantId。实测: " + w.getParamNameValuePairs());
+    }
+
     // ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
 
     @Test
@@ -241,6 +307,34 @@ class LsyMerchantServiceTest {
                 "实测参数表: " + w.getParamNameValuePairs());
     }
 
+    /**
+     * 去企业化:企业字段留空 ⇒ 写 NULL(变成租户级映射)。
+     *
+     * <p>改前这里是 **400**(那时 {@code pay_lsy_merchant.enterprise_id} 是 {@code NOT NULL},
+     * 留空没有合法落点)。列放开后 400 的前提消失,留空有了明确语义。
+     */
+    @Test
+    void save_updateBranch_blankEnterpriseId_writesNull() {
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+        when(merchantMapper.selectById(any())).thenReturn(row(EDIT_ID));
+
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setId(EDIT_ID);
+        body.setEnterpriseId("");          // 操作者把一条企业级映射改成租户级
+        body.setMchNo("Y52211090004");
+
+        service.save(body);                // 不得抛 400
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        assertTrue(setColumns(w).contains("enterprise_id"),
+                "enterprise_id 仍必须在 SET 里(当成 skip 就会保留旧企业)。实测: " + w.getSqlSet());
+        // 与三个可空列一起,共 4 个 NULL —— 空串会以 "" 入参数表,
+        // 而 requireByTenant 找的是 IS NULL,那一行将永远查不到
+        assertEquals(4, w.getParamNameValuePairs().values().stream().filter(v -> v == null).count(),
+                "enterprise_id 应与三个可空列一样以 NULL 入 SET。实测参数表: " + w.getParamNameValuePairs());
+        assertNull(body.getEnterpriseId(), "空 ⇒ NULL 的收敛点在 service 层");
+    }
+
     @Test
     void save_updateBranch_zeroAffectedRows_throws404() {
         // 「id 打不中目标行」:精度丢失后对不上、或这行已被另一个页签删掉
@@ -289,6 +383,27 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).updateById(any());
     }
 
+    /** 去企业化:不填企业也能新增一条映射(租户级)。改前这里是 400。 */
+    @Test
+    void save_insertBranch_blankEnterpriseId_inserts() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setEnterpriseId("   ");       // 只有空白 —— 必须与 null 同等对待
+        body.setMchNo("Y52211090004");
+        when(merchantMapper.insert(any())).thenAnswer(inv -> {
+            ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
+            return 1;
+        });
+        when(merchantMapper.selectById(9L)).thenReturn(row(9L));
+
+        service.save(body);                // 不得抛 400
+
+        ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
+        verify(merchantMapper).insert(inserted.capture());
+        assertNull(inserted.getValue().getEnterpriseId(),
+                "租户级商户的 enterprise_id 必须是 NULL —— 存成 \"   \" 会让 requireByTenant 永远查不到它");
+        verify(merchantMapper, never()).update(any(), any());
+    }
+
     // ─────────────────────────────────── 夹具 ───────────────────────────────────
 
     /** 页面送来的编辑请求体:只有 `id` + 5 个业务键(`merchant/index.vue` 的 FORM_FIELDS),服务端字段一个都不带 */