Browse Source

质押修改

xrh 2 months ago
parent
commit
c7e0f14157

+ 4 - 2
bex-cloud-staking-api/src/main/java/com/bex/staking/controller/StakingController.java

@@ -2,6 +2,7 @@ package com.bex.staking.controller;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.bex.common.model.R;
+import com.bex.staking.model.dto.ClaimProfitReq;
 import com.bex.staking.model.dto.StakingOrderQuery;
 import com.bex.staking.model.dto.StakingOrderReq;
 import com.bex.staking.model.vo.*;
@@ -186,12 +187,13 @@ public class StakingController {
      * {@code X-User-Id} 用户上下文。支持领取指定订单或全部订单的待领取收益。
      *
      * @param userId 用户 UID,由 {@code X-User-Id} 请求头透传
-     * @param orderId 订单 ID可选,不传或传 null 时领取全部订单收益)
+     * @param req 领取请求,包含可选的 orderId(不传或传 null 时领取全部订单收益)
      * @return 领取成功返回 {@code R.ok(true)}
      */
     @PostMapping("/profit/claim")
     public R<Boolean> claimProfit(@RequestHeader("X-User-Id") Long userId,
-                                  @RequestParam(value = "orderId", required = false) Long orderId) {
+                                  @RequestBody(required = false) ClaimProfitReq req) {
+        Long orderId = req != null ? req.getOrderId() : null;
         return R.ok(stakingService.claimProfit(userId, orderId));
     }
 }

+ 151 - 0
bex-cloud-staking-api/src/main/java/com/bex/staking/controller/StakingTaskController.java

@@ -0,0 +1,151 @@
+package com.bex.staking.controller;
+
+import com.bex.common.model.R;
+import com.bex.staking.engine.ClearingEngine;
+import com.bex.staking.engine.SettleResult;
+import com.bex.staking.engine.SettleSummary;
+import com.bex.staking.engine.SettlementEngine;
+import com.bex.staking.entity.StakingOrder;
+import com.bex.staking.mq.AssetEventProducer;
+import com.bex.staking.mq.AssetEventRetryService;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 质押任务测试控制器。
+ *
+ * <p>提供手动触发定时任务的接口,用于测试和调试。
+ * 注意:此控制器仅用于测试环境,生产环境应禁用或限制访问权限。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/staking/task")
+@RequiredArgsConstructor
+public class StakingTaskController {
+
+    private static final DateTimeFormatter BIZ_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    private final SettlementEngine settlementEngine;
+
+    /** 上次结算的业务日期(用于 nextDay 模式自动递增) */
+    private static volatile Integer lastSettledBizDate = null;
+
+    /**
+     * 手动触发每日收益结算任务。
+     *
+     * <p>支持指定业务日期进行测试,可模拟多日连续结算。
+     *
+     * @param bizDate 业务日期(可选,格式 yyyyMMdd),不传则使用当前日期
+     * @param nextDay 是否自动使用下一天(可选,与 bizDate 互斥)
+     * @return 结算结果汇总
+     */
+    @GetMapping("/settle/daily")
+    public R<SettleResultVO> triggerDailySettlement(
+            @RequestParam(required = false) Integer bizDate,
+            @RequestParam(required = false, defaultValue = "false") Boolean nextDay) {
+        log.info("手动触发每日收益结算任务");
+
+        // 优先使用指定的业务日期
+        if (bizDate != null) {
+            log.info("使用指定业务日期: {}", bizDate);
+        } else if (Boolean.TRUE.equals(nextDay)) {
+            // 自动使用下一天(基于上次结算日期)
+            bizDate = getNextBizDate();
+            log.info("自动使用下一天业务日期: {}", bizDate);
+        } else {
+            // 默认使用当前日期
+            bizDate = Integer.parseInt(LocalDate.now().format(BIZ_DATE_FORMATTER));
+            log.info("使用当前业务日期: {}", bizDate);
+        }
+
+        try {
+            List<StakingOrder> allOrders = settlementEngine.loadHoldingStakingOrders();
+            log.info("待结算订单数: {}", allOrders.size());
+
+            SettleSummary summary = settlementEngine.settleDaily(allOrders, bizDate);
+
+            SettleResultVO result = new SettleResultVO();
+            result.setBizDate(bizDate);
+            result.setTotal(summary.getTotal());
+            result.setSuccessCount(summary.getSuccessCount());
+            result.setSkippedCount(summary.getSkippedCount());
+            result.setFailedCount(summary.getFailedCount());
+
+            if (summary.getFailedCount() > 0) {
+                StringBuilder failedDetails = new StringBuilder();
+                for (SettleResult failed : summary.getFailedResults()) {
+                    failedDetails.append(String.format("orderNo=%s, reason=%s; ", failed.orderNo(), failed.message()));
+                }
+                result.setFailedDetails(failedDetails.toString());
+            }
+
+            // 更新上次结算日期
+            lastSettledBizDate = bizDate;
+
+            log.info("每日收益结算完成: {}", result);
+            return R.ok(result);
+
+        } catch (Exception e) {
+            log.error("手动触发每日收益结算任务异常", e);
+            return R.fail("结算任务执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取下一个业务日期。
+     * 如果上次结算日期为空,则使用当前日期;否则使用上次日期 + 1天。
+     *
+     * @return 下一个业务日期(yyyyMMdd)
+     */
+    private synchronized int getNextBizDate() {
+        if (lastSettledBizDate == null) {
+            // 第一次调用,使用当前日期
+            return Integer.parseInt(LocalDate.now().format(BIZ_DATE_FORMATTER));
+        }
+
+        // 上次日期 + 1天
+        String dateStr = String.valueOf(lastSettledBizDate);
+        LocalDate lastDate = LocalDate.of(
+                Integer.parseInt(dateStr.substring(0, 4)),
+                Integer.parseInt(dateStr.substring(4, 6)),
+                Integer.parseInt(dateStr.substring(6, 8)));
+        LocalDate nextDate = lastDate.plusDays(1);
+        return Integer.parseInt(nextDate.format(BIZ_DATE_FORMATTER));
+    }
+
+    /**
+     * 重置上次结算日期(用于重新开始测试)。
+     */
+    @GetMapping("/settle/reset")
+    public R<Void> resetSettlementDate() {
+        lastSettledBizDate = null;
+        log.info("已重置上次结算日期");
+        return R.ok();
+    }
+
+    /**
+     * 结算结果返回对象。
+     */
+    @lombok.Data
+    public static class SettleResultVO {
+        /** 业务日期(yyyyMMdd) */
+        private int bizDate;
+        /** 订单总数 */
+        private int total;
+        /** 成功数量 */
+        private int successCount;
+        /** 跳过数量(幂等/已过期等) */
+        private int skippedCount;
+        /** 失败数量 */
+        private int failedCount;
+        /** 失败详情 */
+        private String failedDetails;
+    }
+}

+ 13 - 0
bex-cloud-staking-core/src/main/java/com/bex/staking/model/dto/ClaimProfitReq.java

@@ -0,0 +1,13 @@
+package com.bex.staking.model.dto;
+
+import lombok.Data;
+
+/**
+ * 领取收益请求 DTO。
+ */
+@Data
+public class ClaimProfitReq {
+
+    /** 订单 ID(可选,不传或传 null 时领取全部订单收益) */
+    private Long orderId;
+}

+ 1 - 1
bex-cloud-staking-core/src/main/java/com/bex/staking/util/AmountFormatter.java

@@ -228,7 +228,7 @@ public final class AmountFormatter {
             StringBuilder sb = new StringBuilder();
             for (int i = 0; i < tiers.size(); i++) {
                 if (i > 0) {
-                    sb.append(", ");
+                    sb.append("");
                 }
                 sb.append(plain(tiers.get(i), 0));  // 不使用千分位
             }

+ 3 - 33
bex-cloud-staking-service/src/main/java/com/bex/staking/engine/SubscriptionValidator.java

@@ -3,7 +3,6 @@ package com.bex.staking.engine;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.json.JSONArray;
 import cn.hutool.json.JSONUtil;
-import com.bex.staking.constant.StakingConstants;
 import com.bex.staking.constant.StakingErrorCode;
 import com.bex.staking.enums.SubscriptionMode;
 import com.bex.staking.exception.StakingException;
@@ -16,14 +15,12 @@ import java.util.List;
  * 认购额度校验器(纯函数)。
  *
  * <p>对应设计文档"引擎组件 - SubscriptionValidator"。负责对质押下单与私募认购的数量进行额度校验,
- * 校验规则统一适用于质押与私募两类业务(需求 5.4、5.5、12.1、12.2),并附加所有模式通用的最小下限校验
- * (需求 5.2、5.3):
+ * 校验规则统一适用于质押与私募两类业务(需求 5.4、5.5、12.1、12.2):
  *
  * <ul>
  *   <li><b>固定档位(FIXED)</b>:解析产品 {@code tier_config} JSON 数组(形如 {@code [1000,5000,10000]}),
  *       认购数量必须等于其中某一档位值。
  *   <li><b>无限制(UNLIMITED)</b>:认购数量需满足 {@code minAmount ≤ x ≤ maxAmount}。
- *   <li><b>最小下限(通用)</b>:所有模式下认购数量均不得小于 {@link StakingConstants#MIN_STAKE_AMOUNT}(100 BEX)。
  * </ul>
  *
  * <p>本类为不依赖 Spring 的纯静态工具,核心方法均为可独立测试的纯函数,便于属性测试(任务 4.7)直接调用;
@@ -34,9 +31,9 @@ public final class SubscriptionValidator {
     private SubscriptionValidator() {}
 
     /**
-     * 综合额度校验(纯函数):先校验通用最小下限,再校验模式相关额度
+     * 综合额度校验(纯函数):校验认购数量是否满足对应认购模式的额度约束
      *
-     * <p>校验通过当且仅当:数量不小于 100 BEX 最小下限,且满足对应认购模式的额度约束。
+     * <p>校验通过当且仅当满足对应认购模式的额度约束。
      *
      * @param amount 认购数量 x(BEX)
      * @param mode 认购模式:FIXED 固定档位 / UNLIMITED 无限制区间
@@ -51,28 +48,9 @@ public final class SubscriptionValidator {
             String tierConfig,
             BigDecimal minAmount,
             BigDecimal maxAmount) {
-        // 通用最小下限校验:所有模式均要求数量 ≥ 100 BEX
-        if (!meetsMinimum(amount)) {
-            return false;
-        }
-        // 模式相关额度校验
         return matchesMode(amount, mode, tierConfig, minAmount, maxAmount);
     }
 
-    /**
-     * 通用最小下限校验(纯函数):数量须不小于 {@link StakingConstants#MIN_STAKE_AMOUNT}(100 BEX)。
-     *
-     * @param amount 认购数量 x(BEX),为空视为不通过
-     * @return 数量不小于 100 BEX 返回 true,否则返回 false
-     */
-    public static boolean meetsMinimum(BigDecimal amount) {
-        if (amount == null) {
-            return false;
-        }
-        // amount ≥ 100 即通过(compareTo 忽略标度差异,如 100 与 100.0 等价)
-        return amount.compareTo(StakingConstants.MIN_STAKE_AMOUNT) >= 0;
-    }
-
     /**
      * 模式相关额度校验(纯函数,不含最小下限校验)。
      *
@@ -176,7 +154,6 @@ public final class SubscriptionValidator {
      * <p>供下单引擎(OrderEngine)等业务层调用。校验顺序与提示文案:
      *
      * <ol>
-     *   <li>数量小于 100 BEX 最小下限 → 提示"质押数量最小 100 BEX";
      *   <li>固定档位不匹配 → 提示"质押数量必须等于产品配置的档位之一";
      *   <li>无限制区间越界 → 提示"质押数量需在产品允许的认购区间内"。
      * </ol>
@@ -194,16 +171,9 @@ public final class SubscriptionValidator {
             String tierConfig,
             BigDecimal minAmount,
             BigDecimal maxAmount) {
-        // 1. 通用最小下限校验(需求 5.2、5.3)
-        if (!meetsMinimum(amount)) {
-            throw new StakingException(
-                    StakingErrorCode.INVALID_AMOUNT,
-                    "质押数量最小 " + StakingConstants.MIN_STAKE_AMOUNT.stripTrailingZeros().toPlainString() + " BEX");
-        }
         if (mode == null) {
             throw new StakingException(StakingErrorCode.INVALID_AMOUNT, "认购模式不能为空");
         }
-        // 2. 模式相关额度校验(需求 5.4、5.5、12.1、12.2)
         switch (mode) {
             case FIXED -> {
                 if (!matchesTier(amount, tierConfig)) {

+ 3 - 7
bex-cloud-staking-service/src/main/java/com/bex/staking/grpc/MarketPriceClientImpl.java

@@ -46,21 +46,17 @@ public class MarketPriceClientImpl implements MarketPriceClient {
             String lastPrice = response.getLastPrice();
             if (lastPrice == null || lastPrice.isBlank()) {
                 log.error("行情服务返回 BEX/USDT 价格为空: symbol={}", BEX_USDT_SYMBOL);
-                throw new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "行情服务返回 BEX/USDT 价格为空");
+                return BigDecimal.ZERO;
             }
             BigDecimal price = new BigDecimal(lastPrice);
             if (price.signum() <= 0) {
                 log.error("行情服务返回 BEX/USDT 价格非正: symbol={}, lastPrice={}", BEX_USDT_SYMBOL, lastPrice);
-                throw new StakingException(
-                        StakingErrorCode.ASSET_GRPC_FAILED, "行情服务返回 BEX/USDT 价格非正:" + lastPrice);
+                return BigDecimal.ZERO;
             }
             return price;
-        } catch (StakingException e) {
-            throw e;
         } catch (Exception e) {
             log.error("获取 BEX/USDT 价格 gRPC 调用异常: symbol={}", BEX_USDT_SYMBOL, e);
-            throw new StakingException(
-                    StakingErrorCode.ASSET_GRPC_FAILED, "行情服务获取 BEX/USDT 价格调用异常:" + GrpcClientUtils.rootCauseMessage(e), e);
+            return BigDecimal.ZERO;
         }
     }
 

+ 25 - 2
bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/OrderQueryServiceImpl.java

@@ -357,12 +357,12 @@ public class OrderQueryServiceImpl implements OrderQueryService {
         List<StakingProfitJournal> journals = stakingProfitJournalMapper.selectList(
                 new LambdaQueryWrapper<StakingProfitJournal>()
                         .eq(StakingProfitJournal::getOrderNo, order.getOrderNo())
-                        .orderByAsc(StakingProfitJournal::getSettleDate));
+                        .orderByDesc(StakingProfitJournal::getSettleDate));
 
         List<StakingProfitItemVO> profitList = new ArrayList<>();
         for (StakingProfitJournal journal : journals) {
             ProfitStatus profitStatus = ProfitStatus.fromValue(journal.getStatus());
-            Long profitDate = toEpochMilli(journal.getCreatedAt());
+            Long profitDate = settleDateToEpochMilli(journal.getSettleDate());
             ProfitItemAssembly item =
                     new ProfitItemAssembly(true, profitStatus, profitDate, journal.getProfitAmount(), displayScale);
             profitList.add(stakingProfitItemConverter.toVo(item));
@@ -436,4 +436,27 @@ public class OrderQueryServiceImpl implements OrderQueryService {
         }
         return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
     }
+
+    /**
+     * 将结算业务日期(YYYYMMDD 格式整数)转换为毫秒时间戳(该日期 00:00:00)。
+     *
+     * @param settleDate 结算日期(如 20240602),可空
+     * @return 毫秒时间戳,入参为空时返回 {@code null}
+     */
+    private Long settleDateToEpochMilli(Integer settleDate) {
+        if (settleDate == null) {
+            return null;
+        }
+        String dateStr = String.valueOf(settleDate);
+        if (dateStr.length() != 8) {
+            return null;
+        }
+        int year = Integer.parseInt(dateStr.substring(0, 4));
+        int month = Integer.parseInt(dateStr.substring(4, 6));
+        int day = Integer.parseInt(dateStr.substring(6, 8));
+        return LocalDateTime.of(year, month, day, 0, 0, 0)
+                .atZone(ZoneId.systemDefault())
+                .toInstant()
+                .toEpochMilli();
+    }
 }

+ 0 - 227
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/SubscriptionValidatorTest.java

@@ -1,227 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.enums.SubscriptionMode;
-import com.bex.staking.exception.StakingException;
-import java.math.BigDecimal;
-import java.util.List;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link SubscriptionValidator} 认购额度校验器单元测试。
- *
- * <p>覆盖固定档位、无限制区间与通用最小下限三类校验的典型场景与边界值(需求 5.2、5.3、5.4、5.5、12.1、12.2)。
- */
-class SubscriptionValidatorTest {
-
-    private static final String TIER_CONFIG = "[1000,5000,10000]";
-
-    @Nested
-    @DisplayName("固定档位(FIXED)额度校验")
-    class FixedModeTests {
-
-        @Test
-        @DisplayName("数量等于某一档位时通过")
-        void passesWhenAmountEqualsTier() {
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("1000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("5000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("10000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-        }
-
-        @Test
-        @DisplayName("数量等于档位但标度不同(1000.00)时通过")
-        void passesWhenAmountEqualsTierWithDifferentScale() {
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("1000.00"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-        }
-
-        @Test
-        @DisplayName("数量不等于任何档位时不通过")
-        void failsWhenAmountNotInTiers() {
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("2000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-        }
-
-        @Test
-        @DisplayName("档位配置为空时不通过")
-        void failsWhenTierConfigBlank() {
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("1000"), SubscriptionMode.FIXED, "", null, null));
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("1000"), SubscriptionMode.FIXED, null, null, null));
-        }
-    }
-
-    @Nested
-    @DisplayName("无限制区间(UNLIMITED)额度校验")
-    class UnlimitedModeTests {
-
-        @Test
-        @DisplayName("数量落在区间内时通过")
-        void passesWhenWithinRange() {
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("500"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("100"),
-                    new BigDecimal("1000")));
-        }
-
-        @Test
-        @DisplayName("数量等于区间边界时通过")
-        void passesAtRangeBoundaries() {
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("100"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("100"),
-                    new BigDecimal("1000")));
-            assertTrue(SubscriptionValidator.isValid(
-                    new BigDecimal("1000"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("100"),
-                    new BigDecimal("1000")));
-        }
-
-        @Test
-        @DisplayName("数量超过上限时不通过")
-        void failsWhenAboveMax() {
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("1001"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("100"),
-                    new BigDecimal("1000")));
-        }
-
-        @Test
-        @DisplayName("数量低于区间下限(且 ≥100 最小值)时不通过")
-        void failsWhenBelowMin() {
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("150"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("200"),
-                    new BigDecimal("1000")));
-        }
-    }
-
-    @Nested
-    @DisplayName("通用最小下限(100 BEX)校验")
-    class MinimumTests {
-
-        @Test
-        @DisplayName("数量为 100 时满足最小下限")
-        void meetsMinimumAt100() {
-            assertTrue(SubscriptionValidator.meetsMinimum(new BigDecimal("100")));
-        }
-
-        @Test
-        @DisplayName("数量为 99.999... 时不满足最小下限")
-        void failsBelow100() {
-            assertFalse(SubscriptionValidator.meetsMinimum(new BigDecimal("99.999999999999999999")));
-        }
-
-        @Test
-        @DisplayName("无限制模式下数量低于 100 即使落在区间内也不通过")
-        void failsWhenBelowGlobalMinimumEvenIfInRange() {
-            assertFalse(SubscriptionValidator.isValid(
-                    new BigDecimal("50"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("10"),
-                    new BigDecimal("1000")));
-        }
-
-        @Test
-        @DisplayName("空数量不满足最小下限")
-        void failsWhenNull() {
-            assertFalse(SubscriptionValidator.meetsMinimum(null));
-        }
-    }
-
-    @Nested
-    @DisplayName("tier_config JSON 解析")
-    class ParseTiersTests {
-
-        @Test
-        @DisplayName("正常解析档位数组")
-        void parsesValidArray() {
-            List<BigDecimal> tiers = SubscriptionValidator.parseTiers(TIER_CONFIG);
-            assertEquals(3, tiers.size());
-            assertEquals(0, new BigDecimal("1000").compareTo(tiers.get(0)));
-            assertEquals(0, new BigDecimal("10000").compareTo(tiers.get(2)));
-        }
-
-        @Test
-        @DisplayName("非法 JSON 返回空列表")
-        void returnsEmptyOnInvalidJson() {
-            assertTrue(SubscriptionValidator.parseTiers("not-json").isEmpty());
-            assertTrue(SubscriptionValidator.parseTiers("").isEmpty());
-            assertTrue(SubscriptionValidator.parseTiers(null).isEmpty());
-        }
-    }
-
-    @Nested
-    @DisplayName("业务层封装 validate:抛出 StakingException(40003)")
-    class ValidateThrowsTests {
-
-        @Test
-        @DisplayName("数量低于 100 抛出 40003")
-        void throwsWhenBelowMinimum() {
-            StakingException ex = assertThrows(
-                    StakingException.class,
-                    () -> SubscriptionValidator.validate(
-                            new BigDecimal("99"), SubscriptionMode.UNLIMITED, null, BigDecimal.ZERO, new BigDecimal("1000")));
-            assertEquals(StakingErrorCode.INVALID_AMOUNT, ex.getCode());
-        }
-
-        @Test
-        @DisplayName("固定档位不匹配抛出 40003")
-        void throwsWhenTierNotMatched() {
-            StakingException ex = assertThrows(
-                    StakingException.class,
-                    () -> SubscriptionValidator.validate(
-                            new BigDecimal("2000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null));
-            assertEquals(StakingErrorCode.INVALID_AMOUNT, ex.getCode());
-        }
-
-        @Test
-        @DisplayName("无限制区间越界抛出 40003")
-        void throwsWhenOutOfRange() {
-            StakingException ex = assertThrows(
-                    StakingException.class,
-                    () -> SubscriptionValidator.validate(
-                            new BigDecimal("5000"),
-                            SubscriptionMode.UNLIMITED,
-                            null,
-                            new BigDecimal("100"),
-                            new BigDecimal("1000")));
-            assertEquals(StakingErrorCode.INVALID_AMOUNT, ex.getCode());
-        }
-
-        @Test
-        @DisplayName("合法数量不抛异常")
-        void doesNotThrowWhenValid() {
-            SubscriptionValidator.validate(
-                    new BigDecimal("1000"), SubscriptionMode.FIXED, TIER_CONFIG, null, null);
-            SubscriptionValidator.validate(
-                    new BigDecimal("500"),
-                    SubscriptionMode.UNLIMITED,
-                    null,
-                    new BigDecimal("100"),
-                    new BigDecimal("1000"));
-        }
-    }
-}