|
@@ -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 <[]> but was <[LeshouyingCashierController#newOrder(...)]>)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @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();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|