Переглянути джерело

feat(乐收赢): 所有租户下单最低 1 元,低于一元在入口拒绝并提示

规则落在 `LsyAmountFloor.MIN = 100`(分)。四个租户下单入口的请求体上,把
`@NotNull @Positive` 换成 `@NotNull @Min(value = LsyAmountFloor.MIN, message = ...)`:
被扫、主扫、H5(开放 API)与收银台,一次全挂 —— 漏掉任一个都照样能下出 1 分钱的单。

替换而不是与 `@Positive` 并列:并列会让同一格报两条违规,GlobalExceptionHandler
把它们用逗号接起来,调用方收到两句自相重复的话。`@NotNull` 必须留着 ——
`@Min` 对 null 是放行的。

租户实际收到的(HTTP 400,走 GlobalExceptionHandler:36):

  {"code":400,"msg":"totalAmount: total_amount 不能低于 1 元(100 分)"}
  {"code":400,"msg":"amount: amount 不能低于 1 元(100 分)"}

★ 这条是**我们自己的业务线**,不是对上游限制的转述。相关读数只有一条:
2026-09-29 用生产商户号 852609290114 做被扫干跑,1 分被上游拒
(23000003 交易金额不足以支付手续费)。那是按费率算出的手续费下限、随费率模板变,
真实门槛没有量出来,而 1 元比它**低**。所以这条线拦的是「小到没有业务意义」,
不是「上游会拒」—— 这两件事在这条规则里不能互相解释。

范围:只覆盖乐收赢通道的**下单**。退款金额不在内(部分退款天然可以低于 1 元);
汇安融通道不在内(别的模块没有影响就不动);单笔上限仍未设(没实测过,不编数字)。

LsyAmountFloorTest(5 条,全部走真实 Validator,不启 Spring 上下文):
  - 钉住入口清单本身:少一个入口会红,而不是静默地少测一个
  - 99 / 1 / 0 / -1 在四个入口全部拦下,且提示语逐字比对
  - 100 / 101 / 12345 在四个入口全部放行 —— 只有拦下侧的话,
    写反成 @Min(101) 或 @Min(1000) 照样全绿
  - 金额为 null 仍然拦下:@Min 对 null 放行,@NotNull 不能被它顶掉,
    否则一个不带金额的请求会一路走到库里那句 NOT NULL
  - 反射扫两个控制器的 handler,四个请求体都挂着 @Valid
    ★ 扫描范围限定在「方法上挂着请求映射注解」的那些:收银台有一个同名的私有装配
      方法 newOrder(Long, LsyMerchantEntity, LsyCashierCreateDTO),拿的是同一个 DTO,
      却不在任何请求路径上,本来就不该有 @Valid。本类第一版把 getDeclaredMethods()
      整个扫了,当场红在这一条上。

LeshouyingCashierControllerTest 加一条端到端:POST amount=99 ⇒ 400 且 $.msg 全文比对。
经过的是真实的 GlobalExceptionHandler(本类 standaloneSetup 挂的就是它),
所以它同时证明约束触发的那条路落在 400、而不是兜底的 500。

两向变异 7 条,读数在 logs/_mutate-floor.txt,全部与预期相符:
  M1~M4 每个被改的 DTO **各注入一条**,四条红的都点名了自己那个入口
       (被扫 / 主扫 / H5 / 收银台)—— 「只改了三处」这种漏法瞒不过去
  M5    MIN 改成 101 而提示语不动 ⇒ 「一元及以上」红,钉住 MIN↔MESSAGE 的漂移
  M6    摘掉主扫 handler 的 @Valid ⇒ 反射那条红(注解挂上了却不会跑)
  M7    只改一行注释 ⇒ 全量 930 仍绿(无害方向)
每条变异还原后复量 sha256,与变异前逐字节一致。

930 绿 / 0 红(本批新增 6 条:LsyAmountFloorTest 5 + 收银台端到端 1)。
不带 SQL,不需要执行任何脚本。★ 要重新部署才生效。
alphaH 2 тижнів тому
батько
коміт
3f93d2c4a6

+ 14 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyBarcodePayDTO.java

@@ -1,10 +1,11 @@
 package com.payment.platform.module.payment.leshouying.dto;
 
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
 import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Pattern;
-import jakarta.validation.constraints.Positive;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
@@ -21,8 +22,18 @@ public class LsyBarcodePayDTO {
     /** 用户付款码,18 位纯数字,以 10-15 开头 */
     @NotBlank(message = "auth_code 不能为空")
     private String authCode;
-    /** 金额,单位分 */
-    @NotNull @Positive(message = "total_amount 必须大于 0")
+    /**
+     * 金额,单位**分**。
+     *
+     * <p>{@code @Min} 顶掉原来的 {@code @Positive(message = "total_amount 必须大于 0")}
+     * —— 不是改提示语,是换门槛:1 分是正数,旧注解一路放行到上游,
+     * 回来的是通道那句「金额不足以支付手续费」。
+     * 理由与「为什么不并列」同 {@code LsyQrCreateDTO.totalAmount}。
+     *
+     * <p>下限为什么是 1 元、覆盖与不覆盖什么,见 {@link LsyAmountFloor}。
+     */
+    @NotNull
+    @Min(value = LsyAmountFloor.MIN, message = "total_amount " + LsyAmountFloor.MESSAGE)
     private Long totalAmount;
     /**
      * 000 自动识别 / 300 支付宝 / 400 微信,默认 000。

+ 9 - 4
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCashierCreateDTO.java

@@ -1,7 +1,8 @@
 package com.payment.platform.module.payment.leshouying.dto;
 
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Positive;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
@@ -21,9 +22,13 @@ public class LsyCashierCreateDTO {
     /**
      * 收款金额,单位**分**。
      *
-     * <p>{@code @Positive} 不是洁癖:金额是操作员唯一的输入,也是唯一会带着错误值
+     * <p>{@code @Min} 不是洁癖:金额是操作员唯一的输入,也是唯一会带着错误值
      * 走到上游去的字段。0 与负数在收银场景里没有含义,而静默接受它们等于让一笔
-     * 「0 元收款」以成功的姿态出现在页面上。
+     * 「0 元收款」以成功的姿态出现在页面上;1 分钱同样没有含义,且上游会以
+     * 「金额不足以支付手续费」回绝 —— 那句话该由我们说出来,见 {@link LsyAmountFloor}。
+     *
+     * <p>★ <b>替换掉的是 {@code @Positive}</b>(不是并列):并列会让同一格报两条违规,
+     * {@code GlobalExceptionHandler} 把它们逗号接起来,操作员会看到两句自相重复的话。
      *
      * <p>★ <b>没有上界</b> —— 上游对单笔金额的上限我们**没有实测过**,编一个数字出来
      * 会变成一条我们自己也解释不清的规则。联调时实测出真实上限后再补。
@@ -36,7 +41,7 @@ public class LsyCashierCreateDTO {
      * 该加的是「超过某阈值才确认」,而那个阈值是个业务决定,不由本类拍。
      */
     @NotNull
-    @Positive
+    @Min(value = LsyAmountFloor.MIN, message = "amount " + LsyAmountFloor.MESSAGE)
     private Long amount;
 
     /**

+ 14 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyH5CreateDTO.java

@@ -1,10 +1,11 @@
 package com.payment.platform.module.payment.leshouying.dto;
 
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
 import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Pattern;
-import jakarta.validation.constraints.Positive;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
@@ -18,7 +19,18 @@ public class LsyH5CreateDTO {
      * 字段保留、只去掉 `@NotBlank`;分流见 `LeshouyingOpenController.requireMerchant`。
      */
     private String enterpriseId;
-    @NotNull @Positive private Long totalAmount;
+    /**
+     * 金额,单位**分**。
+     *
+     * <p>{@code @Min} 顶掉 {@code @Positive}(理由同 {@code LsyQrCreateDTO.totalAmount}:
+     * 并列会给同一格两条违规,渲染成两句自相重复的话);
+     * {@code @NotNull} 留着,{@code @Min} 对 null 是放行的。
+     *
+     * <p>下限为什么是 1 元、覆盖与不覆盖什么,见 {@link LsyAmountFloor}。
+     */
+    @NotNull
+    @Min(value = LsyAmountFloor.MIN, message = "total_amount " + LsyAmountFloor.MESSAGE)
+    private Long totalAmount;
     /**
      * 必填:300 支付宝 / 400 微信。H5 不支持自动识别。
      *

+ 16 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyQrCreateDTO.java

@@ -1,10 +1,11 @@
 package com.payment.platform.module.payment.leshouying.dto;
 
+import com.payment.platform.module.payment.leshouying.util.LsyAmountFloor;
 import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Pattern;
-import jakarta.validation.constraints.Positive;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
@@ -21,7 +22,20 @@ public class LsyQrCreateDTO {
      *   留空不是「没填」,是一个明确语义,分流见 `LeshouyingOpenController.requireMerchant`。
      */
     private String enterpriseId;
-    @NotNull @Positive private Long totalAmount;
+    /**
+     * 金额,单位**分**。
+     *
+     * <p>{@code @Min} 顶掉原来的 {@code @Positive}(而不是并列):两者对同一格会给
+     * **两条**违规,{@code GlobalExceptionHandler} 把它们用逗号接起来,
+     * 调用方会收到「不能低于 1 元」和「必须大于 0」两句自相重复的话。
+     * {@code @Min(100)} 已经蕴含了正数,留 {@code @Positive} 没有额外信息。
+     * {@code @NotNull} **必须留** —— {@code @Min} 对 null 是放行的。
+     *
+     * <p>下限为什么是 1 元、以及它覆盖与不覆盖什么,见 {@link LsyAmountFloor}。
+     */
+    @NotNull
+    @Min(value = LsyAmountFloor.MIN, message = "total_amount " + LsyAmountFloor.MESSAGE)
+    private Long totalAmount;
     // 长度上界与 pay_lsy_order 列定义对齐(见 LsyBarcodePayDTO 同类字段的说明)。
     @Size(max = 128, message = "order_body 不能超过 128 位")
     private String orderBody;

+ 61 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyAmountFloor.java

@@ -0,0 +1,61 @@
+package com.payment.platform.module.payment.leshouying.util;
+
+/**
+ * 乐收赢通道上**租户下单金额的下限**:1 元(100 分)。
+ *
+ * <h2>这条规则的来路</h2>
+ * 2026-09-29 的裁定:<i>「所有租户下单行为最低 1 元做一下,低于 1 元要拒绝并提示」</i>。
+ * 它是一条**我们自己的业务线**,不是对上游限制的转述 —— 下面这条读数很容易被当成
+ * 「上游要求 1 元」,而它不是:
+ *
+ * <ul>
+ *   <li>实测(2026-09-29,生产商户号 {@code 852609290114},被扫干跑、假付款码):
+ *       1 分被上游拒,{@code upstream_result_msg = 通道返回错误:23000003交易金额不足以支付手续费}。</li>
+ *   <li>那是**费率算出来的手续费下限**,随商户的费率模板变:模板 {@code RT0045}
+ *       (万分之四十五)下,手续费要够 1 分,金额得在 2.23 元量级。</li>
+ *   <li>★ 那个真实门槛我们**没有量出来** —— 只量到「1 分被拒」这一个点。
+ *       而 1 元比它**低**,所以这条线拦不住所有会被上游拒的金额;
+ *       它拦的是「金额小到没有业务意义」的那一类。</li>
+ * </ul>
+ *
+ * <h2>为什么不是 @Positive 那一档就够</h2>
+ * {@code @Positive} 只挡 0 与负数。1 分是正数,一路放行到上游,回来的是通道报错
+ * —— 操作员看到的是「乐收赢说金额不够」,而不是「我们这儿最低 1 元」。
+ * 规则该在**我们能解释的那一层**被说出来。
+ *
+ * <h2>覆盖范围(以及**不**覆盖什么)</h2>
+ * 只覆盖**乐收赢通道的下单**四个入口:被扫 / 主扫 / H5(开放 API)与收银台。
+ * <ul>
+ *   <li>**退款金额不在内**:部分退款的金额天然可以低于 1 元(退 1.5 元里的一半)。</li>
+ *   <li>**汇安融通道不在内**:那条通道没有这条规则,也不该由乐收赢的上游口径推出来
+ *       (本仓既有约定:别的模块没影响就不动)。</li>
+ *   <li>**单笔上限仍没有**:上游对上限我们同样没实测过,编一个数字出来会变成一条
+ *       我们自己也解释不清的规则。</li>
+ * </ul>
+ *
+ * <h2>为什么要一个常量类而不是四处内联</h2>
+ * 同 {@link LsyBizNoPattern}:四个 DTO 各写一份 {@code 100L} 与一份提示语,
+ * 就是四处会各自漂的同一个数。**改一处必须改另一处**。
+ * 「四处是不是都挂了、挂的是不是同一个数」由 {@code LsyAmountFloorTest} 钉住。
+ *
+ * <p>★ {@link #MIN} 与 {@link #MESSAGE} 之间**没有编译期的绑定**(注解的
+ * {@code message} 要求编译期常量,拼不出来),所以两者漂开的可能是真实存在的
+ * —— 那是测试在管的事,不是这里。
+ */
+public final class LsyAmountFloor {
+
+    /** 下限本身,单位**分**。供 `@Min(value = ...)` 用。 */
+    public static final long MIN = 100L;
+
+    /**
+     * 与 {@link #MIN} 配套的提示语。
+     *
+     * <p>各字段自行前缀自己的字段名,如 {@code "total_amount " + MESSAGE} ——
+     * 同 {@link LsyBizNoPattern#MESSAGE} 的做法。最终由
+     * {@code GlobalExceptionHandler} 渲染成 {@code totalAmount: total_amount 不能低于 1 元(100 分)}。
+     */
+    public static final String MESSAGE = "不能低于 1 元(100 分)";
+
+    private LsyAmountFloor() {
+    }
+}

+ 25 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java

@@ -191,6 +191,31 @@ class LeshouyingCashierControllerTest {
         verifyNoInteractions(merchantService, orderService, orderMapper);
     }
 
+    /**
+     * 低于 1 元 ⇒ 400,且**提示语原样到达调用方**。
+     *
+     * <p>上面那条只断言了 {@code code == 400},与「有没有提示」无关;而「最低 1 元」
+     * 这条规则要求的是「拒绝**并提示**」,所以这里断到 {@code $.msg} 的全文。
+     * 经过的是真实的 {@code GlobalExceptionHandler}(本类的 {@code standaloneSetup}
+     * 挂的就是它),所以这条同时证明:约束触发的那条路落在 400 上,而不是兜底的 500。
+     *
+     * <p>99 是门槛内侧一格。放行侧是 {@code AMOUNT}(12345)与 100,
+     * 边界钉在 99/100 之间。
+     */
+    @Test
+    void create_belowOneYuan_is400WithFloorHint() throws Exception {
+        login(TENANT, false);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":99}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value("amount: amount 不能低于 1 元(100 分)"));
+
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
     /**
      * 请求体里出现服务端决定的键({@code tenant_id})⇒ 400 + 一句人话,
      * **不是** HTTP 500「服务器内部错误」。

+ 267 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/dto/LsyAmountFloorTest.java

@@ -0,0 +1,267 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import com.payment.platform.module.payment.leshouying.controller.LeshouyingCashierController;
+import com.payment.platform.module.payment.leshouying.controller.open.LeshouyingOpenController;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Valid;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 「**所有**租户下单行为最低 1 元」这条规则的**入口**校验。
+ *
+ * <p>【为什么钉在这一层】金额是租户唯一能自己决定的那一格钱。四个下单入口把它接进来的地方
+ * 就是这四个 DTO;控制器里再判一次也只是第二道。
+ * 而本仓所有 {@code Leshouying*ControllerTest} 都是**普通方法直调**控制器
+ * ({@code controller.barcodePay(dto)}),Spring 的参数校验**一步都不参与** ——
+ * 把 DTO 上的约束整行删掉,那些用例一条都不会红。
+ * 所以这里用 {@code Validation.buildDefaultValidatorFactory()} 直接跑注解本身,
+ * 与 {@code LeshouyingOpenControllerTest.h5CreateDTO_unsupportedPayType_failsValidation}
+ * 同一套写法(Hibernate Validator 在单测 classpath 上,已实测)。
+ *
+ * <p>【为什么四个入口写在同一个类里】规则说的是「所有」。拆到四个测试类里去的话,
+ * 「只改了三处」不会有任何一条红 —— 而漏掉的那一处照样能下出 1 分钱的单,
+ * 上游再回一句「金额不足以支付手续费」,操作员看到的是通道报错而不是我们的规则。
+ * 那张表少一行,就是少一个入口。
+ *
+ * <p>【断言只用字面量,不引用生产常量】门槛值(100)与提示语都直接写在断言里。
+ * 引用常量会让两边一起漂 —— 常量改了、提示语没跟上,测试照样绿,等于把要盯的东西
+ * 交给被盯的人。写成字面量之后:改 {@code MIN} 而不改提示语 ⇒ 这条红;
+ * 改提示语而不改 {@code MIN} ⇒ 这条也红。
+ */
+class LsyAmountFloorTest {
+
+    /** 门槛本身,单位**分**。字面量,故意不从生产代码取。 */
+    private static final long FLOOR = 100L;
+
+    /** 提示语的尾段,字面量。各入口在它前面拼自己的字段名。 */
+    private static final String HINT = "不能低于 1 元(100 分)";
+
+    /**
+     * 一个下单入口。
+     *
+     * @param bean  DTO 类名(只用于把失败信息说清楚)
+     * @param field DTO 的 Java 字段名 —— {@code getPropertyPath()} 给的就是它
+     * @param wire  字段在报文里的名字(下划线式),提示语里用的是这个
+     * @param build 按金额造一个**其余字段全合法**的 DTO
+     */
+    private record Entry(String bean, String field, String wire, Function<Long, Object> build) {
+    }
+
+    /**
+     * 本仓的四个租户下单入口 —— 依据是全仓 grep {@code orderMapper.insert}:
+     * {@code LeshouyingOpenController} 三处(:89 被扫 / :118 主扫 / :145 H5)+
+     * {@code LeshouyingCashierController} 一处(:180 收银台)。
+     * 退款的金额**不在**这条规则里(部分退款的金额天然可以低于 1 元)。
+     */
+    private static Map<String, Entry> entries() {
+        Map<String, Entry> m = new LinkedHashMap<>();
+
+        m.put("被扫 POST /payment/openapi/pay/barcode/pay", new Entry(
+                "LsyBarcodePayDTO", "totalAmount", "total_amount", cents -> {
+                    LsyBarcodePayDTO d = new LsyBarcodePayDTO();
+                    d.setOutTradeNo("OUT001");
+                    d.setAuthCode("134567890123456789");
+                    d.setTotalAmount(cents);
+                    return d;
+                }));
+
+        m.put("主扫 POST /payment/openapi/pay/qrcode/create", new Entry(
+                "LsyQrCreateDTO", "totalAmount", "total_amount", cents -> {
+                    LsyQrCreateDTO d = new LsyQrCreateDTO();
+                    d.setOutTradeNo("OUT002");
+                    d.setTotalAmount(cents);
+                    return d;
+                }));
+
+        m.put("H5 POST /payment/openapi/pay/h5/create", new Entry(
+                "LsyH5CreateDTO", "totalAmount", "total_amount", cents -> {
+                    LsyH5CreateDTO d = new LsyH5CreateDTO();
+                    d.setOutTradeNo("OUT003");
+                    d.setPayType("300");
+                    d.setTotalAmount(cents);
+                    return d;
+                }));
+
+        m.put("收银台 POST /payment/collection/cashier/qrcode", new Entry(
+                "LsyCashierCreateDTO", "amount", "amount", cents -> {
+                    LsyCashierCreateDTO d = new LsyCashierCreateDTO();
+                    d.setAmount(cents);
+                    return d;
+                }));
+
+        return m;
+    }
+
+    // ───────────────────────────── 一、表本身 ─────────────────────────────
+
+    /**
+     * 钉的是**表**,不是某一条逻辑:哪天有人因为某一行红了就把那一行删掉,
+     * 这里会以「少了一个入口」红,而不是静默地少测一个入口。
+     */
+    @Test
+    void 四个下单入口一个都不能少() {
+        assertEquals(
+                List.of("LsyBarcodePayDTO", "LsyQrCreateDTO", "LsyH5CreateDTO", "LsyCashierCreateDTO"),
+                entries().values().stream().map(Entry::bean).toList(),
+                "下单入口的清单被改动了。增删入口都要先回答:新入口的金额有没有下限?");
+    }
+
+    /**
+     * 上面三条测的是**注解本身**。注解挂上去了、控制器那头却没有 {@code @Valid}
+     * 的话,约束一步都不会跑 —— 上面三条照样全绿,而 1 分钱的单照下。
+     * 这条补的正是那半步。
+     *
+     * <p>做法是**扫控制器**而不是再列一份名单:凡是形参类型是这四个 DTO 的
+     * **HTTP handler**,它的那个形参必须挂 {@code @Valid}。
+     * 顺带断言「扫出来正好 4 个」—— 这样新增一个下单入口(新的金额型请求体)时,
+     * 这里会以「不是 4 个」红,而不是静默地不检查它。
+     *
+     * <p>★ 「handler」的判据是**方法上挂着请求映射注解**,不是「public」也不只是参数类型:
+     * 收银台那侧有一个同名的私有装配方法
+     * {@code LeshouyingCashierController#newOrder(Long, LsyMerchantEntity, LsyCashierCreateDTO)}
+     * —— 它拿的是同一个 DTO,却是个纯函数式的落库装配器、不在任何请求路径上,
+     * 自然不该有 {@code @Valid}。本类第一版就是把 {@code getDeclaredMethods()} 整个扫了,
+     * 它当场红在这一条上(读数:expected &lt;[]&gt; but was &lt;[LeshouyingCashierController#newOrder(...)]&gt;)。
+     */
+    @Test
+    void 四个入口的请求体都挂了Valid() {
+        Set<Class<?>> dtoTypes = Set.of(LsyBarcodePayDTO.class, LsyQrCreateDTO.class,
+                LsyH5CreateDTO.class, LsyCashierCreateDTO.class);
+
+        List<String> missing = new ArrayList<>();
+        List<String> found = new ArrayList<>();
+        for (Class<?> c : List.of(LeshouyingOpenController.class, LeshouyingCashierController.class)) {
+            for (Method m : c.getDeclaredMethods()) {
+                if (!isHandler(m)) {
+                    continue;
+                }
+                for (Parameter p : m.getParameters()) {
+                    if (!dtoTypes.contains(p.getType())) {
+                        continue;
+                    }
+                    found.add(p.getType().getSimpleName());
+                    if (p.getAnnotation(Valid.class) == null) {
+                        missing.add(c.getSimpleName() + "#" + m.getName()
+                                + "(" + p.getType().getSimpleName() + ")");
+                    }
+                }
+            }
+        }
+
+        assertEquals(List.of(), missing,
+                "这些下单入口的请求体没有 @Valid ⇒ DTO 上的金额下限一步都不会跑");
+        assertEquals(4, found.size(),
+                "扫出来的「金额型请求体」不是 4 个,而是 " + found
+                        + " —— 下单入口清单变了,本类的表要跟着改(新入口的金额有没有下限?)");
+    }
+
+    /** 是请求路径上的方法,而不是同名的私有装配方法。 */
+    private static boolean isHandler(Method m) {
+        return Stream.of(PostMapping.class, GetMapping.class, PutMapping.class,
+                        DeleteMapping.class, PatchMapping.class, RequestMapping.class)
+                .anyMatch(a -> m.getAnnotation(a) != null);
+    }
+
+    // ───────────────────────────── 二、门槛以下 ─────────────────────────────
+
+    /**
+     * 低于 1 元的四个值,在四个入口上**全部**要被拦下,且提示语要写清门槛。
+     *
+     * <p>99 是边界内侧、1 是「用户说的 0.01 元」量级、0 与 -1 是原本 {@code @Positive}
+     * 就在管的那两个值 —— 换注解时最容易顺手把负数一起放走。
+     */
+    @Test
+    void 低于一元_四个入口全部拦下并给出门槛提示() {
+        entries().forEach((where, e) -> {
+            for (long cents : new long[]{99L, 1L, 0L, -1L}) {
+                Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(cents));
+                ConstraintViolation<Object> v = onField(violations, e.field());
+
+                assertNotNull(v, where + ":" + e.bean() + " 收了 " + cents
+                        + " 分没有报错。低于 1 元必须**在入口**拒绝(下游只会回一句"
+                        + "「金额不足以支付手续费」,操作员看到的是通道报错而不是我们的规则)");
+                assertEquals(e.wire() + " " + HINT, v.getMessage(),
+                        where + ":" + e.bean() + " 的提示语不对");
+            }
+        });
+    }
+
+    // ───────────────────────────── 三、门槛本身与之上 ─────────────────────────────
+
+    /**
+     * 门槛是「**不低于** 1 元」⇒ 100 分本身必须放行,101 与更大的值同理。
+     *
+     * <p>这条与上一条合起来把边界钉在 99/100 之间。只有上一条(拦下侧)的话,
+     * 一个写反成 {@code @Min(101)} 或 {@code @Min(1000)} 的实现照样全绿。
+     */
+    @Test
+    void 一元及以上_四个入口全部放行() {
+        entries().forEach((where, e) -> {
+            for (long cents : new long[]{FLOOR, 101L, 12345L}) {
+                Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(cents));
+                assertNull(onField(violations, e.field()),
+                        where + ":" + e.bean() + " 把 " + cents + " 分判成了违规 —— "
+                                + "门槛是「不低于 1 元」,100 分本身必须过。实测违规: "
+                                + describe(violations));
+            }
+        });
+    }
+
+    // ───────────────────────────── 四、不传金额 ─────────────────────────────
+
+    /**
+     * {@code @Min} 对 {@code null} **视为通过**,所以 {@code @NotNull} 必须留在原地。
+     *
+     * <p>这条钉的是一个具体的退化路径:谁把 {@code @NotNull @Positive} 整行换成
+     * 一个 {@code @Min},一个**不带金额**的下单请求就会一路走到落库 —— 而库里
+     * {@code pay_lsy_order.total_amount} 是 NOT NULL,那时炸的是一句数据库约束错。
+     */
+    @Test
+    void 不传金额_四个入口仍然拦下() {
+        entries().forEach((where, e) -> {
+            Set<ConstraintViolation<Object>> violations = validator().validate(e.build().apply(null));
+            assertNotNull(onField(violations, e.field()),
+                    where + ":" + e.bean() + " 在金额为 null 时没报错。"
+                            + "@Min 对 null 是放行的,@NotNull 不能被它顶掉");
+        });
+    }
+
+    // ───────────────────────────── 量具 ─────────────────────────────
+
+    /** 只看**这一个字段**上的违规:DTO 里还有别的必填字段,别的字段红了不算数。 */
+    private static ConstraintViolation<Object> onField(Set<ConstraintViolation<Object>> vs, String field) {
+        return vs.stream()
+                .filter(v -> field.equals(v.getPropertyPath().toString()))
+                .findFirst().orElse(null);
+    }
+
+    private static String describe(Set<ConstraintViolation<Object>> vs) {
+        return vs.stream()
+                .map(v -> v.getPropertyPath() + "=" + v.getMessage())
+                .toList().toString();
+    }
+
+    private static Validator validator() {
+        return Validation.buildDefaultValidatorFactory().getValidator();
+    }
+}