Browse Source

质押多语言处理

dq 4 weeks ago
parent
commit
158bf7546f
50 changed files with 241 additions and 8932 deletions
  1. 13 9
      bex-cloud-staking-api/src/main/java/com/bex/staking/controller/StakingController.java
  2. 1 1
      bex-cloud-staking-core/src/main/java/com/bex/staking/util/AmountFormatter.java
  3. 0 130
      bex-cloud-staking-core/src/test/java/com/bex/staking/util/AmountFormatterRoundTripPropertyTest.java
  4. 0 158
      bex-cloud-staking-core/src/test/java/com/bex/staking/util/AmountFormatterTest.java
  5. 0 97
      bex-cloud-staking-core/src/test/java/com/bex/staking/util/ProductDisplayCountDownPropertyTest.java
  6. 0 111
      bex-cloud-staking-core/src/test/java/com/bex/staking/util/ProductDisplayMapperTest.java
  7. 0 111
      bex-cloud-staking-core/src/test/java/com/bex/staking/util/StatusMappingTest.java
  8. 0 70
      bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/MapperTestApplication.java
  9. 0 412
      bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/StakingMapperIntegrationTest.java
  10. 0 347
      bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/StakingOptimisticLockConcurrencyTest.java
  11. 0 41
      bex-cloud-staking-entity/src/test/resources/application-test.yml
  12. 0 121
      bex-cloud-staking-entity/src/test/resources/schema-h2.sql
  13. 44 2
      bex-cloud-staking-service/src/main/java/com/bex/staking/engine/HomeAssembler.java
  14. 5 4
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/OrderQueryService.java
  15. 4 3
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/ProductQueryService.java
  16. 14 11
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/StakingService.java
  17. 71 6
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/OrderQueryServiceImpl.java
  18. 80 2
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/ProductQueryServiceImpl.java
  19. 9 9
      bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/StakingServiceImpl.java
  20. 0 318
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/AvailableBalanceConstraintPropertyTest.java
  21. 0 218
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ClearingIdempotencyPropertyTest.java
  22. 0 287
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DailySettlementLedgerPropertyTest.java
  23. 0 151
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DeflationCalculatorTest.java
  24. 0 227
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DeflationRedemptionPropertyTest.java
  25. 0 220
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DeflationThresholdInvariantPropertyTest.java
  26. 0 80
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ExpireTimeInvariantPropertyTest.java
  27. 0 154
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/IdempotencyBarrierTest.java
  28. 0 173
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/InitialValueInvariantPropertyTest.java
  29. 0 195
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/OrderEngineDeductRollbackTest.java
  30. 0 352
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/OrderIdempotencyPropertyTest.java
  31. 0 167
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitCalculatorDailyProfitPropertyTest.java
  32. 0 231
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitCalculatorMaturityInterestPropertyTest.java
  33. 0 229
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitClaimLedgerPropertyTest.java
  34. 0 269
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/RaisedCapacityInvariantPropertyTest.java
  35. 0 621
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/RedissonLockConcurrencyTest.java
  36. 0 151
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/SettlementEngineTest.java
  37. 0 144
      bex-cloud-staking-service/src/test/java/com/bex/staking/engine/SubscriptionAmountValidationPropertyTest.java
  38. 0 307
      bex-cloud-staking-service/src/test/java/com/bex/staking/grpc/AssetGrpcClientImplTest.java
  39. 0 184
      bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventConsumerTest.java
  40. 0 487
      bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventLocalMessageTableIntegrationTest.java
  41. 0 204
      bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventProducerImplTest.java
  42. 0 204
      bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventRetryServiceTest.java
  43. 0 270
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimIdempotencyPropertyTest.java
  44. 0 233
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimServiceImplCreditFailureRollbackTest.java
  45. 0 162
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimServiceImplOptimisticLockTest.java
  46. 0 181
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/LockProgressInvariantPropertyTest.java
  47. 0 348
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/OrderTabFilterPropertyTest.java
  48. 0 356
      bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/RecommendationFilterPropertyTest.java
  49. 0 44
      bex-cloud-staking-service/src/test/resources/application-test.yml
  50. 0 120
      bex-cloud-staking-service/src/test/resources/schema-h2.sql

+ 13 - 9
bex-cloud-staking-api/src/main/java/com/bex/staking/controller/StakingController.java

@@ -52,21 +52,22 @@ public class StakingController {
     /**
      * 获取质押首页信息(GET /api/v1/staking/home)。
      *
-     * <p>透传 {@code X-User-Id} 用户上下文,委托 {@link StakingService#getHome(Long)} 聚合可用余额、累计收益与
+     * <p>透传 {@code X-User-Id} 用户上下文,委托 {@link StakingService#getHome(Long, String)} 聚合可用余额、累计收益与
      * 待领取收益。
      *
      * @param userId 用户 UID,由 {@code X-User-Id} 请求头透传
      * @return 契约首页概览 {@link StakingHomeVO}
      */
     @GetMapping("/home")
-    public R<StakingHomeVO> stake(@RequestHeader("X-User-Id") Long userId) {
-        return R.ok(stakingService.getHome(userId));
+    public R<StakingHomeVO> stake(@RequestHeader("X-User-Id") Long userId,
+                                  @RequestHeader(value = "localeName") String localeName) {
+        return R.ok(stakingService.getHome(userId, localeName));
     }
 
     /**
      * 获取质押产品列表(GET /api/v1/staking/recommendationPage)。
      *
-     * <p>委托 {@link StakingService#pageProducts(Integer, Integer, Boolean)}:{@code isRecommendation=true}
+     * <p>委托 {@link StakingService#pageProducts(Integer, Integer, Boolean, String)}:{@code isRecommendation=true}
      * 仅返回推荐产品,否则返回全部产品。
      *
      * @param page 页码,缺省 1
@@ -78,8 +79,9 @@ public class StakingController {
     public R<Page<StakingProductVO>> list(
             @RequestParam(value = "page", defaultValue = "1") Integer page,
             @RequestParam(value = "size", defaultValue = "10") Integer size,
-            @RequestParam(value = "isRecommendation", defaultValue = "true") Boolean isRecommendation) {
-        return R.ok(stakingService.pageProducts(page, size, isRecommendation));
+            @RequestParam(value = "isRecommendation", defaultValue = "true") Boolean isRecommendation,
+            @RequestHeader(value = "localeName") String localeName) {
+        return R.ok(stakingService.pageProducts(page, size, isRecommendation, localeName));
     }
 
     /**
@@ -118,7 +120,7 @@ public class StakingController {
     /**
      * 我的质押订单列表接口(POST /api/v1/staking/order/page)。
      *
-     * <p>透传 {@code X-User-Id} 用户上下文,委托 {@link StakingService#pageOrders(Long, StakingOrderQuery)}
+     * <p>透传 {@code X-User-Id} 用户上下文,委托 {@link StakingService#pageOrders(Long, StakingOrderQuery, String)}
      * 按 {@code tabType}(all / ongoing / redeemed)过滤并分页。
      *
      * @param userId 用户 UID,由 {@code X-User-Id} 请求头透传
@@ -127,8 +129,10 @@ public class StakingController {
      */
     @PostMapping("/order/page")
     public R<Page<StakingOrderListVO>> getStakingOrderList(
-            @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody StakingOrderQuery query) {
-        return R.ok(stakingService.pageOrders(userId, query));
+            @RequestHeader("X-User-Id") Long userId,
+            @RequestHeader(value = "localeName") String localeName,
+            @Valid @RequestBody StakingOrderQuery query) {
+        return R.ok(stakingService.pageOrders(userId, query, localeName));
     }
 
     /**

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

@@ -203,7 +203,7 @@ public final class AmountFormatter {
      */
     public static String thresholdRule(BigDecimal thresholdPercent) {
         BigInteger intPercent = thresholdPercent.setScale(0, RoundingMode.HALF_UP).toBigInteger();
-        return String.format(Locale.ROOT, "%d%% 通缩", intPercent);
+        return String.format(Locale.ROOT, "%d%% ", intPercent);
     }
 
     /**

+ 0 - 130
bex-cloud-staking-core/src/test/java/com/bex/staking/util/AmountFormatterRoundTripPropertyTest.java

@@ -1,130 +0,0 @@
-package com.bex.staking.util;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.constant.StakingConstants;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.math.RoundingMode;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.From;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link AmountFormatter} 金额格式化往返一致性属性测试。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 19:对任意高精度金额 {@code x}({@link BigDecimal}),
- * 先按约定 scale 与展示规则格式化为字符串、再解析回 {@link BigDecimal},应等于 {@code x} 在该 scale 下的
- * 舍入值(即 {@code parse(format(x)) == round(x, scale)}),不丢失有效精度。
- *
- * <p>解析往返方式:去除单位后缀(" BEX"/" USDT")、去除千分位逗号后,用 {@link BigDecimal#BigDecimal(String)}
- * 解析回 {@link BigDecimal},再与 {@code x.setScale(scale, HALF_UP)} 以 {@link BigDecimal#compareTo} 断言相等
- *(避免 {@code equals} 受 scale 影响)。
- */
-class AmountFormatterRoundTripPropertyTest {
-
-    /** BEX 本金/余额展示 scale(整数) */
-    private static final int BEX_SCALE = 0;
-
-    /** USDT 价格展示 scale(6 位小数) */
-    private static final int PRICE_SCALE = 6;
-
-    /** USDT 价值展示 scale(4 位小数) */
-    private static final int VALUE_SCALE = 4;
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 高精度金额生成器:非负 {@link BigDecimal},scale 取 [0, 18],整数部分覆盖到 10^30。
-     *
-     * <p>通过随机 unscaledValue 与随机 scale 组合,覆盖整数、长尾小数、千分位边界与零等输入空间。
-     */
-    @Provide
-    Arbitrary<BigDecimal> highPrecisionAmount() {
-        Arbitrary<BigInteger> unscaled = Arbitraries.bigIntegers()
-                .between(BigInteger.ZERO, BigInteger.TEN.pow(30));
-        Arbitrary<Integer> scale = Arbitraries.integers().between(0, 18);
-        return Combinators.combine(unscaled, scale).as(BigDecimal::new);
-    }
-
-    /** BEX 收益展示 scale 生成器:[0, 8],覆盖常见的 4、6 位小数展示档位。 */
-    @Provide
-    Arbitrary<Integer> displayScale() {
-        return Arbitraries.integers().between(0, 8);
-    }
-
-    // ==================== 往返断言辅助 ====================
-
-    /**
-     * 解析往返断言:去单位后缀、去千分位逗号后解析回 {@link BigDecimal},与 {@code x} 在指定 scale 下的
-     * {@code HALF_UP} 舍入值以 {@link BigDecimal#compareTo} 比较。
-     *
-     * @param x 原始高精度金额
-     * @param formatted 格式化后的展示字符串(形如 "1,250.0000 USDT")
-     * @param unit 单位后缀(如 "BEX"/"USDT"),为 {@code null} 表示无单位
-     * @param scale 该展示规则约定的小数位
-     */
-    private void assertRoundTrip(BigDecimal x, String formatted, String unit, int scale) {
-        String numeric = formatted;
-        if (unit != null && !unit.isEmpty()) {
-            String suffix = StakingConstants.UNIT_BEX.equals(unit) || StakingConstants.UNIT_USDT.equals(unit)
-                    ? " " + unit
-                    : unit;
-            assertThat(numeric).endsWith(suffix);
-            numeric = numeric.substring(0, numeric.length() - suffix.length());
-        }
-        // 去除千分位逗号
-        numeric = numeric.replace(",", "");
-
-        BigDecimal parsed = new BigDecimal(numeric);
-        BigDecimal expected = x.setScale(scale, RoundingMode.HALF_UP);
-
-        // 解析回的小数位应与约定 scale 一致(不丢失有效精度,也不引入多余精度)
-        assertThat(parsed.scale()).isEqualTo(scale);
-        // parse(format(x)) == round(x, scale)
-        assertThat(parsed.compareTo(expected)).isZero();
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 19: 金额格式化往返一致性
-    // 对任意高精度金额 x:parse(bex(x)) == round(x, 0)(千分位整数 + " BEX")
-    // Validates: Requirements 13.3
-    @Property(tries = 100)
-    void bexAmountRoundTrips(@ForAll("highPrecisionAmount") BigDecimal x) {
-        String formatted = AmountFormatter.bex(x);
-        assertRoundTrip(x, formatted, StakingConstants.UNIT_BEX, BEX_SCALE);
-    }
-
-    // Feature: staking-service, Property 19: 金额格式化往返一致性
-    // 对任意高精度金额 x 与展示 scale:parse(bex(x, scale)) == round(x, scale)(千分位 + 固定小数 + " BEX")
-    // Validates: Requirements 13.3
-    @Property(tries = 100)
-    void bexAmountWithScaleRoundTrips(
-            @ForAll("highPrecisionAmount") BigDecimal x, @ForAll("displayScale") int scale) {
-        String formatted = AmountFormatter.bex(x, scale);
-        assertRoundTrip(x, formatted, StakingConstants.UNIT_BEX, scale);
-    }
-
-    // Feature: staking-service, Property 19: 金额格式化往返一致性
-    // 对任意高精度金额 x:parse(value(x)) == round(x, 4)(千分位 + 4 位小数 + " USDT")
-    // Validates: Requirements 13.3
-    @Property(tries = 100)
-    void usdtValueRoundTrips(@ForAll("highPrecisionAmount") BigDecimal x) {
-        String formatted = AmountFormatter.value(x);
-        assertRoundTrip(x, formatted, StakingConstants.UNIT_USDT, VALUE_SCALE);
-    }
-
-    // Feature: staking-service, Property 19: 金额格式化往返一致性
-    // 对任意高精度金额 x:parse(usdt(x)) == round(x, 6)(6 位小数 + " USDT",无千分位)
-    // Validates: Requirements 13.3
-    @Property(tries = 100)
-    void usdtPriceRoundTrips(@ForAll("highPrecisionAmount") BigDecimal x) {
-        String formatted = AmountFormatter.usdt(x);
-        assertRoundTrip(x, formatted, StakingConstants.UNIT_USDT, PRICE_SCALE);
-    }
-}

+ 0 - 158
bex-cloud-staking-core/src/test/java/com/bex/staking/util/AmountFormatterTest.java

@@ -1,158 +0,0 @@
-package com.bex.staking.util;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.math.BigDecimal;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link AmountFormatter} 展示样本单元测试。
- *
- * <p>用固定原始值断言格式化输出严格等于已确认的 Mock 契约样本,确保后端展示字符串与 APP 端
- * 完全一致(契约保真)。所有断言使用 {@link org.junit.jupiter.api.Assertions#assertEquals}
- * 严格比较字符串相等(区分大小写与空格)。
- *
- * <p>样本来源:设计文档"展示格式化策略 - 格式化规则表"。
- *
- * <p>Validates: Requirements 13.3
- */
-@DisplayName("AmountFormatter 展示样本格式化")
-class AmountFormatterTest {
-
-    @Nested
-    @DisplayName("APR / 年化:整数百分比 + %")
-    class Apr {
-
-        @Test
-        @DisplayName("apr(24) -> \"24%\"")
-        void formatsIntegerPercent() {
-            // 样本 "24%":年化以整数百分比展示
-            assertEquals("24%", AmountFormatter.apr(new BigDecimal("24")));
-        }
-
-        @Test
-        @DisplayName("小数年化按 HALF_UP 舍入为整数")
-        void roundsToIntegerHalfUp() {
-            // 23.5 -> 24(HALF_UP 向上)
-            assertEquals("24%", AmountFormatter.apr(new BigDecimal("23.5")));
-            // 23.4 -> 23(向下)
-            assertEquals("23%", AmountFormatter.apr(new BigDecimal("23.4")));
-        }
-    }
-
-    @Nested
-    @DisplayName("BEX 金额(本金 / 余额):千分位整数 + BEX")
-    class Bex {
-
-        @Test
-        @DisplayName("bex(1000) -> \"1,000 BEX\"")
-        void formatsThousandWithGrouping() {
-            // 样本 "1,000 BEX":千分位分组
-            assertEquals("1,000 BEX", AmountFormatter.bex(new BigDecimal("1000")));
-        }
-
-        @Test
-        @DisplayName("bex(100) -> \"100 BEX\"")
-        void formatsHundredWithoutGroupingSeparator() {
-            // 样本 "100 BEX":不足千位无分组符
-            assertEquals("100 BEX", AmountFormatter.bex(new BigDecimal("100")));
-        }
-    }
-
-    @Nested
-    @DisplayName("BEX 收益:千分位 + 固定小数位 + BEX")
-    class BexWithScale {
-
-        @Test
-        @DisplayName("bex(0.666666, 6) -> \"0.666666 BEX\"")
-        void formatsSixDecimals() {
-            // 样本 "0.666666 BEX":保留 6 位小数
-            assertEquals("0.666666 BEX", AmountFormatter.bex(new BigDecimal("0.666666"), 6));
-        }
-
-        @Test
-        @DisplayName("bex(60, 4) -> \"60.0000 BEX\"")
-        void formatsFourDecimalsWithTrailingZeros() {
-            // 样本 "60.0000 BEX":补足 4 位小数
-            assertEquals("60.0000 BEX", AmountFormatter.bex(new BigDecimal("60"), 4));
-        }
-    }
-
-    @Nested
-    @DisplayName("百分比:周期利率 / 日利率")
-    class Percent {
-
-        @Test
-        @DisplayName("percent(6) -> \"6.0000%\"")
-        void formatsPeriodRateFourDecimals() {
-            // 样本 "6.0000%":周期利率保留 4 位小数
-            assertEquals("6.0000%", AmountFormatter.percent(new BigDecimal("6")));
-        }
-
-        @Test
-        @DisplayName("dailyPercent(0.066666) -> \"0.066666%\"")
-        void formatsDailyRateSixDecimals() {
-            // 样本 "0.066666%":日利率保留 6 位小数
-            assertEquals("0.066666%", AmountFormatter.dailyPercent(new BigDecimal("0.066666")));
-        }
-    }
-
-    @Nested
-    @DisplayName("USDT 价格 / 价值")
-    class Usdt {
-
-        @Test
-        @DisplayName("usdt(1.25) -> \"1.250000 USDT\"")
-        void formatsPriceSixDecimalsNoGrouping() {
-            // 样本 "1.250000 USDT":价格 6 位小数、无千分位
-            assertEquals("1.250000 USDT", AmountFormatter.usdt(new BigDecimal("1.25")));
-        }
-
-        @Test
-        @DisplayName("value(1250) -> \"1,250.0000 USDT\"")
-        void formatsValueGroupedFourDecimals() {
-            // 样本 "1,250.0000 USDT":价值千分位 + 4 位小数
-            assertEquals("1,250.0000 USDT", AmountFormatter.value(new BigDecimal("1250")));
-        }
-    }
-
-    @Nested
-    @DisplayName("进度 / 通缩规则")
-    class ProgressAndThreshold {
-
-        @Test
-        @DisplayName("progress(30, 90) -> \"30 / 90\"")
-        void formatsProgress() {
-            // 样本 "30 / 90":已结算天数 / 总天数
-            assertEquals("30 / 90", AmountFormatter.progress(30L, 90L));
-        }
-
-        @Test
-        @DisplayName("thresholdRule(80) -> \"80% 通缩\"")
-        void formatsThresholdRule() {
-            // 样本 "80% 通缩":通缩触发阈值
-            assertEquals("80% 通缩", AmountFormatter.thresholdRule(new BigDecimal("80")));
-        }
-
-        @Test
-        @DisplayName("thresholdRule 带小数阈值按 HALF_UP 舍入为整数")
-        void formatsThresholdRuleFromDecimal() {
-            // 80.00 -> "80% 通缩"
-            assertEquals("80% 通缩", AmountFormatter.thresholdRule(new BigDecimal("80.00")));
-        }
-    }
-
-    @Nested
-    @DisplayName("剩余天数")
-    class RemainDay {
-
-        @Test
-        @DisplayName("remainDay(60) -> \"60 天\"")
-        void formatsRemainDay() {
-            // 样本 "60 天"
-            assertEquals("60 天", AmountFormatter.remainDay(60L));
-        }
-    }
-}

+ 0 - 97
bex-cloud-staking-core/src/test/java/com/bex/staking/util/ProductDisplayCountDownPropertyTest.java

@@ -1,97 +0,0 @@
-package com.bex.staking.util;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.enums.ProductStatus;
-import com.bex.staking.util.ProductDisplayMapper.ProductDisplay;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link ProductDisplayMapper} 即将开启倒计时规则属性测试。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 3:对任意产品,{@code countDown} 字段非空
- * 当且仅当其展示状态为"即将开启",且非空时其值等于该产品的募集开始时间戳;展示状态为"可质押"或
- * "已结束"时 {@code countDown} 必为空。
- *
- * <p>生成器随机覆盖 {@link ProductStatus} 全部取值,并通过 start + duration 派生募集时间窗口,
- * 随后针对 {@code now} 系统性覆盖三类区间({@code now < start}、{@code start <= now <= end}、
- * {@code now > end})及其边界({@code start-1}、{@code start}、{@code end}、{@code end+1}),
- * 以验证倒计时不变量在整个输入空间上恒成立。
- */
-class ProductDisplayCountDownPropertyTest {
-
-    /**
-     * 一次产品展示推导的输入场景(不可变)。
-     *
-     * @param status 产品内部上架状态
-     * @param start 募集开始时间戳(毫秒)
-     * @param end 募集结束时间戳(毫秒,{@code end >= start})
-     * @param now 当前时间戳(毫秒)
-     */
-    record Scenario(ProductStatus status, long start, long end, long now) {}
-
-    /**
-     * 场景生成器:智能约束输入空间。
-     *
-     * <p>先随机生成 {@code status}、{@code start} 与非负 {@code duration}(保证 {@code end >= start}),
-     * 再基于已确定的 {@code start}/{@code end} 生成 {@code now},使其覆盖三类区间与全部边界。
-     */
-    @Provide
-    Arbitrary<Scenario> scenarios() {
-        Arbitrary<ProductStatus> statusArb = Arbitraries.of(ProductStatus.values());
-        // 募集开始时间:非负毫秒时间戳,上界覆盖到约公元 2096 年
-        Arbitrary<Long> startArb = Arbitraries.longs().between(0L, 4_000_000_000_000L);
-        // 募集时长:非负(含 0,对应 start == end 的退化窗口),上界约 31 年
-        Arbitrary<Long> durationArb = Arbitraries.longs().between(0L, 1_000_000_000_000L);
-
-        return Combinators.combine(statusArb, startArb, durationArb).flatAs((status, start, duration) -> {
-            long end = start + duration;
-            // now 选择器:等概率覆盖三类区间与四个边界点
-            Arbitrary<Long> nowArb = Arbitraries.oneOf(
-                    Arbitraries.just(start - 1), // 边界:恰好早于募集开始 → 即将开启
-                    Arbitraries.just(start), // 边界:恰好等于募集开始 → 可质押(若 ACTIVE)
-                    Arbitraries.just(end), // 边界:恰好等于募集结束 → 仍在窗口内
-                    Arbitraries.just(end + 1), // 边界:恰好晚于募集结束 → 已结束
-                    Arbitraries.longs().between(Long.MIN_VALUE / 2, start - 1), // now < start
-                    Arbitraries.longs().between(start, end), // start <= now <= end
-                    Arbitraries.longs().between(end + 1, Long.MAX_VALUE / 2)); // now > end
-            return nowArb.map(now -> new Scenario(status, start, end, now));
-        });
-    }
-
-    // Feature: staking-service, Property 3: 即将开启倒计时规则
-    // 对任意产品:countDown != null ⟺ status == "即将开启";即将开启时 countDown == saleStartTime;
-    // 可质押 / 已结束时 countDown 必为空。
-    // Validates: Requirements 2.7, 2.8, 2.9
-    @Property(tries = 100)
-    void countDownNonNullIffUpcoming(@ForAll("scenarios") Scenario s) {
-        ProductDisplay display = ProductDisplayMapper.map(s.status(), s.start(), s.end(), s.now());
-        String status = display.status();
-        Long countDown = display.countDown();
-
-        boolean isUpcoming = ProductDisplayMapper.DISPLAY_UPCOMING.equals(status);
-
-        // 双向不变量:countDown 非空 当且仅当 展示状态为"即将开启"
-        assertThat(countDown != null).isEqualTo(isUpcoming);
-
-        if (isUpcoming) {
-            // 即将开启时,countDown 必等于募集开始时间戳(需求 2.8)
-            assertThat(countDown).isEqualTo(s.start());
-        } else {
-            // 否则展示状态只能是"可质押"或"已结束",且 countDown 必为空(需求 2.7、2.9)
-            assertThat(status).isIn(ProductDisplayMapper.DISPLAY_AVAILABLE, ProductDisplayMapper.DISPLAY_ENDED);
-            assertThat(countDown).isNull();
-        }
-
-        // 便捷方法与 map() 推导结果必须一致,保证装配层无论走哪个入口都满足同一不变量
-        assertThat(ProductDisplayMapper.displayStatus(s.status(), s.start(), s.end(), s.now()))
-                .isEqualTo(status);
-        assertThat(ProductDisplayMapper.countDown(s.status(), s.start(), s.end(), s.now()))
-                .isEqualTo(countDown);
-    }
-}

+ 0 - 111
bex-cloud-staking-core/src/test/java/com/bex/staking/util/ProductDisplayMapperTest.java

@@ -1,111 +0,0 @@
-package com.bex.staking.util;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import com.bex.staking.enums.ProductStatus;
-import com.bex.staking.util.ProductDisplayMapper.ProductDisplay;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link ProductDisplayMapper} 展示状态推导单元测试。
- *
- * <p>覆盖"即将开启 / 可质押 / 已结束"三类推导分支及边界(now 恰好等于募集开始/结束时间),
- * 以及 countDown 仅在"即将开启"时非空的契约(需求 2.7/2.8/2.9)。
- */
-class ProductDisplayMapperTest {
-
-    private static final long START = 1_000_000L;
-    private static final long END = 2_000_000L;
-
-    @Test
-    @DisplayName("now 早于募集开始时间 → 即将开启,countDown 取募集开始时间")
-    void upcomingBeforeSaleStart() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.ACTIVE, START, END, START - 1);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_UPCOMING);
-        assertThat(display.countDown()).isEqualTo(START);
-    }
-
-    @Test
-    @DisplayName("即将开启的判定优先于内部状态:DISABLED 但未到募集开始时间仍为即将开启")
-    void upcomingTakesPrecedenceOverStatus() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.DISABLED, START, END, START - 100);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_UPCOMING);
-        assertThat(display.countDown()).isEqualTo(START);
-    }
-
-    @Test
-    @DisplayName("now 恰好等于募集开始时间且 ACTIVE → 可质押,countDown 为空")
-    void availableAtSaleStartBoundary() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.ACTIVE, START, END, START);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_AVAILABLE);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("处于募集期内且 ACTIVE → 可质押,countDown 为空")
-    void availableDuringSalePeriod() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.ACTIVE, START, END, (START + END) / 2);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_AVAILABLE);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("now 恰好等于募集结束时间且 ACTIVE → 仍可质押(未结束)")
-    void availableAtSaleEndBoundary() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.ACTIVE, START, END, END);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_AVAILABLE);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("已过募集结束时间 → 已结束,countDown 为空")
-    void endedAfterSaleEnd() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.ACTIVE, START, END, END + 1);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_ENDED);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("已售罄(SOLD_OUT)即使在募集期内 → 已结束,countDown 为空")
-    void endedWhenSoldOut() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.SOLD_OUT, START, END, (START + END) / 2);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_ENDED);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("已下架(DISABLED)且已到募集开始时间 → 已结束,countDown 为空")
-    void endedWhenDisabled() {
-        ProductDisplay display = ProductDisplayMapper.map(ProductStatus.DISABLED, START, END, START + 10);
-
-        assertThat(display.status()).isEqualTo(ProductDisplayMapper.DISPLAY_ENDED);
-        assertThat(display.countDown()).isNull();
-    }
-
-    @Test
-    @DisplayName("便捷方法 displayStatus / countDown 与 map 结果一致")
-    void convenienceMethodsConsistentWithMap() {
-        assertThat(ProductDisplayMapper.displayStatus(ProductStatus.ACTIVE, START, END, START - 1))
-                .isEqualTo(ProductDisplayMapper.DISPLAY_UPCOMING);
-        assertThat(ProductDisplayMapper.countDown(ProductStatus.ACTIVE, START, END, START - 1))
-                .isEqualTo(START);
-        assertThat(ProductDisplayMapper.countDown(ProductStatus.ACTIVE, START, END, START))
-                .isNull();
-    }
-
-    @Test
-    @DisplayName("status 为空抛出非法参数异常")
-    void nullStatusThrows() {
-        assertThatThrownBy(() -> ProductDisplayMapper.map(null, START, END, START))
-                .isInstanceOf(IllegalArgumentException.class);
-    }
-}

+ 0 - 111
bex-cloud-staking-core/src/test/java/com/bex/staking/util/StatusMappingTest.java

@@ -1,111 +0,0 @@
-package com.bex.staking.util;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.PositionStatus;
-import com.bex.staking.enums.ProfitStatus;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link StatusMapping} 状态与展示字符串映射单元测试。
- *
- * <p>验证订单状态映射、收益流水状态映射、待结算状态文案、以及 OrderStatus ↔ PositionStatus 双向映射契约。
- */
-class StatusMappingTest {
-
-    @Nested
-    @DisplayName("订单状态展示映射")
-    class OrderStatusDisplay {
-
-        @Test
-        @DisplayName("HOLDING / MATURED → 进行中")
-        void holdingAndMaturedMapToOngoing() {
-            assertThat(StatusMapping.displayOf(OrderStatus.HOLDING)).isEqualTo("进行中");
-            assertThat(StatusMapping.displayOf(OrderStatus.MATURED)).isEqualTo("进行中");
-        }
-
-        @Test
-        @DisplayName("DEFLATED → 通缩赎回")
-        void deflatedMapsToDeflation() {
-            assertThat(StatusMapping.displayOf(OrderStatus.DEFLATED)).isEqualTo("通缩赎回");
-        }
-
-        @Test
-        @DisplayName("SETTLED → 已完成")
-        void settledMapsToCompleted() {
-            assertThat(StatusMapping.displayOf(OrderStatus.SETTLED)).isEqualTo("已完成");
-        }
-
-        @Test
-        @DisplayName("订单状态为空抛出非法参数异常")
-        void nullOrderStatusThrows() {
-            assertThatThrownBy(() -> StatusMapping.displayOf((OrderStatus) null))
-                    .isInstanceOf(IllegalArgumentException.class);
-        }
-    }
-
-    @Nested
-    @DisplayName("收益流水状态展示映射")
-    class ProfitStatusDisplay {
-
-        @Test
-        @DisplayName("收益流水 UNCLAIMED → 收益待领取,CLAIMED → 已领取")
-        void profitStatusMapping() {
-            assertThat(StatusMapping.displayOf(ProfitStatus.UNCLAIMED)).isEqualTo("收益待领取");
-            assertThat(StatusMapping.displayOf(ProfitStatus.CLAIMED)).isEqualTo("已领取");
-        }
-
-        @Test
-        @DisplayName("收益状态为空抛出非法参数异常")
-        void nullProfitStatusThrows() {
-            assertThatThrownBy(() -> StatusMapping.displayOf((ProfitStatus) null))
-                    .isInstanceOf(IllegalArgumentException.class);
-        }
-    }
-
-    @Nested
-    @DisplayName("待结算状态文案")
-    class WaitSettleStatus {
-
-        @Test
-        @DisplayName("等待结算收益项 → 等待 10:00 结算")
-        void waitSettleStatusText() {
-            assertThat(StatusMapping.waitSettleStatus()).isEqualTo("等待 10:00 结算");
-        }
-    }
-
-    @Nested
-    @DisplayName("OrderStatus ↔ PositionStatus 双向映射")
-    class PositionStatusMapping {
-
-        @Test
-        @DisplayName("HOLDING → ACTIVE, MATURED → MATURED, SETTLED/DEFLATED → REDEEMED")
-        void orderToPositionStatus() {
-            assertThat(StatusMapping.toPositionStatus(OrderStatus.HOLDING)).isEqualTo(PositionStatus.ACTIVE);
-            assertThat(StatusMapping.toPositionStatus(OrderStatus.MATURED)).isEqualTo(PositionStatus.MATURED);
-            assertThat(StatusMapping.toPositionStatus(OrderStatus.SETTLED)).isEqualTo(PositionStatus.REDEEMED);
-            assertThat(StatusMapping.toPositionStatus(OrderStatus.DEFLATED)).isEqualTo(PositionStatus.REDEEMED);
-        }
-
-        @Test
-        @DisplayName("ACTIVE → HOLDING, MATURED → MATURED, REDEEMED → SETTLED")
-        void positionToOrderStatus() {
-            assertThat(StatusMapping.toOrderStatus(PositionStatus.ACTIVE)).isEqualTo(OrderStatus.HOLDING);
-            assertThat(StatusMapping.toOrderStatus(PositionStatus.MATURED)).isEqualTo(OrderStatus.MATURED);
-            assertThat(StatusMapping.toOrderStatus(PositionStatus.REDEEMED)).isEqualTo(OrderStatus.SETTLED);
-        }
-
-        @Test
-        @DisplayName("PositionStatus 为空抛出非法参数异常")
-        void nullPositionStatusThrows() {
-            assertThatThrownBy(() -> StatusMapping.toPositionStatus(null))
-                    .isInstanceOf(IllegalArgumentException.class);
-            assertThatThrownBy(() -> StatusMapping.toOrderStatus(null))
-                    .isInstanceOf(IllegalArgumentException.class);
-        }
-    }
-}

+ 0 - 70
bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/MapperTestApplication.java

@@ -1,70 +0,0 @@
-package com.bex.staking.mapper;
-
-import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
-import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
-import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
-import org.apache.ibatis.reflection.MetaObject;
-import org.mybatis.spring.annotation.MapperScan;
-import org.springframework.boot.SpringBootConfiguration;
-import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-import org.springframework.context.annotation.Bean;
-
-import java.time.LocalDateTime;
-
-/**
- * 实体模块 Mapper 集成测试的最小化 Spring Boot 引导配置。
- *
- * <p>实体模块({@code bex-cloud-staking-entity})本身不含 Spring Boot 启动类,
- * 因此在测试源码下提供本类作为测试上下文入口:</p>
- *
- * <ul>
- *   <li>{@link EnableAutoConfiguration} 启用数据源 / MyBatis-Plus 自动装配(测试使用 H2 内存库);</li>
- *   <li>{@link MapperScan} 扫描 {@code com.bex.staking.mapper} 下的全部 Mapper 接口;</li>
- *   <li>注册 {@link MybatisPlusInterceptor}(分页 + 乐观锁)与 {@link MetaObjectHandler}
- *       (自动填充 {@code createdAt} / {@code updatedAt}),与生产配置保持一致。</li>
- * </ul>
- *
- * <p>数据库方案:因当前 CI / 运行环境无 Docker,集成测试采用 H2({@code MODE=MySQL})内存数据库,
- * 由 {@code src/test/resources/schema-h2.sql} 建表并完整保留 {@code uk_user_idem}、
- * {@code uk_order_date} 等唯一约束以验证约束生效。</p>
- */
-@SpringBootConfiguration
-@EnableAutoConfiguration
-@MapperScan("com.bex.staking.mapper")
-public class MapperTestApplication {
-
-    /**
-     * MyBatis-Plus 拦截器:注册乐观锁插件。
-     *
-     * <p>乐观锁插件用于支撑 {@code @Version} 字段的并发更新({@code updateById} 时自动追加版本条件)。
-     * 本任务不涉及自定义分页查询,故未引入分页插件(其位于 {@code mybatis-plus-jsqlparser} 模块)。</p>
-     */
-    @Bean
-    public MybatisPlusInterceptor mybatisPlusInterceptor() {
-        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
-        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
-        return interceptor;
-    }
-
-    /**
-     * 字段自动填充处理器:插入时填充创建 / 更新时间,更新时填充更新时间。
-     *
-     * <p>与公共模块 {@code com.bex.common.config.MyMetaObjectHandler} 行为一致,
-     * 在测试上下文中独立注册以避免引入公共模块的其它组件扫描。</p>
-     */
-    @Bean
-    public MetaObjectHandler metaObjectHandler() {
-        return new MetaObjectHandler() {
-            @Override
-            public void insertFill(MetaObject metaObject) {
-                this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
-                this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
-            }
-
-            @Override
-            public void updateFill(MetaObject metaObject) {
-                this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
-            }
-        };
-    }
-}

+ 0 - 412
bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/StakingMapperIntegrationTest.java

@@ -1,412 +0,0 @@
-package com.bex.staking.mapper;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProfitJournal;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.ProductType;
-import com.bex.staking.enums.ProfitStatus;
-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;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.dao.DuplicateKeyException;
-import org.springframework.test.context.ActiveProfiles;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * Mapper CRUD 与数据库约束集成测试(任务 2.3)。
- *
- * <p>被测组件:任务 2.1 的实体({@code com.bex.staking.entity})与任务 2.2 的 Mapper
- * ({@code com.bex.staking.mapper})。本测试通过真实的数据库交互验证:</p>
- *
- * <ol>
- *   <li><b>基础 CRUD</b>:insert / selectById / update / 逻辑删除后查不到;</li>
- *   <li><b>唯一约束 {@code uk_user_idem(user_id, idempotent_key)}</b>:重复下单被拒绝(需求 5.10);</li>
- *   <li><b>唯一约束 {@code uk_order_date(order_no, settle_date)}</b>:每日结算重复派发被拒绝(需求 9.7);</li>
- *   <li><b>逻辑删除</b>:{@code @TableLogic} 标记后 MyBatis-Plus 基础查询自动过滤。</li>
- * </ol>
- *
- * <p><b>数据库方案:</b>由于当前运行 / CI 环境无 Docker,集成测试采用 H2({@code MODE=MySQL})
- * 内存数据库(见 {@code application-test.yml} 与 {@code schema-h2.sql}),完整保留
- * {@code uk_user_idem}、{@code uk_order_date} 等唯一约束。如需更贴近生产,可在具备 Docker 的
- * 环境改用 Testcontainers(MySQL 8),建表脚本与断言可直接复用。</p>
- *
- * <p>金额断言统一使用 {@link BigDecimal#compareTo} 以忽略 scale 差异。每个测试方法使用
- * {@link Transactional} 自动回滚,保证用例间数据隔离。</p>
- *
- * <p>Validates: Requirements 5.10, 9.7
- */
-@SpringBootTest
-@ActiveProfiles("test")
-@Transactional
-@DisplayName("Staking Mapper CRUD 与约束集成测试 (H2/MySQL 模式)")
-class StakingMapperIntegrationTest {
-
-    @Autowired
-    private StakingOrderMapper orderMapper;
-
-    @Autowired
-    private StakingProfitJournalMapper profitJournalMapper;
-
-    // ------------------------------------------------------------------------
-    // 测试夹具构造
-    // ------------------------------------------------------------------------
-
-    /**
-     * 构造一笔合法的持仓订单。仅设置 NOT NULL 且无数据库默认值的字段,
-     * 其余金额 / 计数字段由数据库默认值(DEFAULT 0)补齐。
-     */
-    private StakingOrder newOrder(Long userId, String orderNo, String idempotentKey) {
-        return StakingOrder.builder()
-                .orderNo(orderNo)
-                .userId(userId)
-                .productId(9001L)
-                .productType(ProductType.STAKING.name())
-                .idempotentKey(idempotentKey)
-                .stakeAmount(new BigDecimal("1000"))
-                .entryPrice(new BigDecimal("1.25"))
-                .initialValueUsdt(new BigDecimal("1250"))
-                .status(OrderStatus.HOLDING.getValue())
-                .lockStartTime(1_700_000_000_000L)
-                .expireTime(1_700_086_400_000L)
-                .build();
-    }
-
-    /**
-     * 构造一条合法的收益流水。
-     */
-    private StakingProfitJournal newJournal(String journalNo, String orderNo, Integer settleDate) {
-        return StakingProfitJournal.builder()
-                .journalNo(journalNo)
-                .orderNo(orderNo)
-                .userId(8001L)
-                .profitAmount(new BigDecimal("0.066666"))
-                .settleDate(settleDate)
-                .status(ProfitStatus.UNCLAIMED.getValue())
-                .build();
-    }
-
-    // ------------------------------------------------------------------------
-    // 1. 基础 CRUD
-    // ------------------------------------------------------------------------
-
-    @Nested
-    @DisplayName("基础 CRUD")
-    class BasicCrud {
-
-        @Test
-        @DisplayName("insert 后可由 selectById 查回,且雪花主键与审计字段被自动填充")
-        void insertThenSelectById() {
-            StakingOrder order = newOrder(1001L, "STK-CRUD-1", "idem-crud-1");
-            int rows = orderMapper.insert(order);
-
-            assertEquals(1, rows, "插入应影响 1 行");
-            assertNotNull(order.getId(), "主键应由雪花算法自动回填");
-            assertNotNull(order.getCreatedAt(), "createdAt 应由自动填充处理器写入");
-            assertNotNull(order.getUpdatedAt(), "updatedAt 应由自动填充处理器写入");
-
-            StakingOrder loaded = orderMapper.selectById(order.getId());
-            assertNotNull(loaded, "应能按 ID 查回订单");
-            assertEquals("STK-CRUD-1", loaded.getOrderNo());
-            assertEquals(OrderStatus.HOLDING.getValue(), loaded.getStatus());
-            assertEquals(0, loaded.getStakeAmount().compareTo(new BigDecimal("1000")));
-            // 未显式设置、依赖数据库默认值的字段应为 0
-            assertEquals(0, loaded.getCumulativeProfit().compareTo(BigDecimal.ZERO));
-            assertEquals(0, loaded.getPendingProfit().compareTo(BigDecimal.ZERO));
-            assertEquals(0, loaded.getSettledDays());
-            assertEquals(0, loaded.getVersion(), "version 默认应为 0");
-            assertEquals(0, loaded.getDeleted(), "deleted 默认应为 0(未删)");
-        }
-
-        @Test
-        @DisplayName("update 可更新订单状态与收益字段")
-        void updateOrder() {
-            StakingOrder order = newOrder(1002L, "STK-CRUD-2", "idem-crud-2");
-            orderMapper.insert(order);
-
-            StakingOrder update = new StakingOrder();
-            update.setId(order.getId());
-            update.setStatus(OrderStatus.SETTLED.getValue());
-            update.setCumulativeProfit(new BigDecimal("12.34"));
-            update.setVersion(order.getVersion()); // 乐观锁需要当前版本
-            int rows = orderMapper.updateById(update);
-            assertEquals(1, rows, "更新应影响 1 行");
-
-            StakingOrder loaded = orderMapper.selectById(order.getId());
-            assertEquals(OrderStatus.SETTLED.getValue(), loaded.getStatus());
-            assertEquals(0, loaded.getCumulativeProfit().compareTo(new BigDecimal("12.34")));
-        }
-
-        @Test
-        @DisplayName("DECIMAL(36,18) 长尾小数精度往返保持一致")
-        void highPrecisionDecimalRoundTrip() {
-            StakingOrder order = newOrder(1003L, "STK-CRUD-3", "idem-crud-3");
-            BigDecimal precise = new BigDecimal("123.123456789012345678");
-            order.setStakeAmount(precise);
-            orderMapper.insert(order);
-
-            StakingOrder loaded = orderMapper.selectById(order.getId());
-            assertEquals(0, loaded.getStakeAmount().compareTo(precise), "高精度金额应精确往返");
-        }
-    }
-
-    // ------------------------------------------------------------------------
-    // 2. 唯一约束 uk_user_idem (需求 5.10)
-    // ------------------------------------------------------------------------
-
-    @Nested
-    @DisplayName("唯一约束 uk_user_idem (user_id, idempotent_key) —— 需求 5.10")
-    class UserIdempotentConstraint {
-
-        @Test
-        @DisplayName("相同 (userId, idempotentKey) 重复插入应抛唯一约束冲突")
-        void duplicateUserIdemRejected() {
-            Long userId = 2001L;
-            String idem = "idem-dup";
-            orderMapper.insert(newOrder(userId, "STK-IDEM-A", idem));
-
-            // 相同 (user_id, idempotent_key) 但 order_no 不同 —— 仅 uk_user_idem 冲突
-            StakingOrder dup = newOrder(userId, "STK-IDEM-B", idem);
-            assertThrows(DuplicateKeyException.class, () -> orderMapper.insert(dup), "重复幂等键下单应被唯一约束拒绝");
-        }
-
-        @Test
-        @DisplayName("相同 idempotentKey 不同 userId 可分别插入(约束按用户隔离)")
-        void sameIdemDifferentUserAllowed() {
-            String idem = "idem-shared";
-            orderMapper.insert(newOrder(2002L, "STK-IDEM-C", idem));
-            orderMapper.insert(newOrder(2003L, "STK-IDEM-D", idem));
-
-            Long count = orderMapper.selectCount(new LambdaQueryWrapper<StakingOrder>()
-                    .eq(StakingOrder::getIdempotentKey, idem));
-            assertEquals(2L, count, "不同用户使用相同幂等键应互不影响");
-        }
-
-        @Test
-        @DisplayName("全局唯一订单号 uk_order_no 重复插入应抛唯一约束冲突")
-        void duplicateOrderNoRejected() {
-            orderMapper.insert(newOrder(2004L, "STK-SAME-NO", "idem-no-1"));
-            StakingOrder dup = newOrder(2005L, "STK-SAME-NO", "idem-no-2");
-            assertThrows(DuplicateKeyException.class, () -> orderMapper.insert(dup), "重复订单号应被唯一约束拒绝");
-        }
-    }
-
-    // ------------------------------------------------------------------------
-    // 3. 唯一约束 uk_order_date (需求 9.7)
-    // ------------------------------------------------------------------------
-
-    @Nested
-    @DisplayName("唯一约束 uk_order_date (order_no, settle_date) —— 需求 9.7")
-    class OrderDateConstraint {
-
-        @Test
-        @DisplayName("相同 (orderNo, settleDate) 重复派发收益流水应抛唯一约束冲突")
-        void duplicateOrderDateRejected() {
-            String orderNo = "STK-SETTLE-1";
-            int settleDate = 20240530;
-            profitJournalMapper.insert(newJournal("JNL-1", orderNo, settleDate));
-
-            // 同一订单同一业务日期再次派发 —— uk_order_date 冲突(每日结算幂等关键)
-            StakingProfitJournal dup = newJournal("JNL-2", orderNo, settleDate);
-            assertThrows(
-                    DuplicateKeyException.class,
-                    () -> profitJournalMapper.insert(dup),
-                    "同一订单同一业务日期重复结算应被唯一约束拒绝");
-        }
-
-        @Test
-        @DisplayName("相同 orderNo 不同 settleDate 可分别插入(按业务日期逐日派发)")
-        void sameOrderDifferentDateAllowed() {
-            String orderNo = "STK-SETTLE-2";
-            profitJournalMapper.insert(newJournal("JNL-3", orderNo, 20240530));
-            profitJournalMapper.insert(newJournal("JNL-4", orderNo, 20240531));
-
-            int day1 = profitJournalMapper.countByOrderAndDate(orderNo, 20240530);
-            int day2 = profitJournalMapper.countByOrderAndDate(orderNo, 20240531);
-            assertEquals(1, day1, "20240530 应有且仅有 1 条流水");
-            assertEquals(1, day2, "20240531 应有且仅有 1 条流水");
-        }
-
-        @Test
-        @DisplayName("countByOrderAndDate 可用于结算前幂等查重")
-        void countByOrderAndDateForIdempotency() {
-            String orderNo = "STK-SETTLE-3";
-            int settleDate = 20240601;
-            // 派发前应为 0(可派发)
-            assertEquals(0, profitJournalMapper.countByOrderAndDate(orderNo, settleDate));
-
-            profitJournalMapper.insert(newJournal("JNL-5", orderNo, settleDate));
-            // 派发后应为 1(应跳过重复派发)
-            assertEquals(1, profitJournalMapper.countByOrderAndDate(orderNo, settleDate));
-        }
-    }
-
-    // ------------------------------------------------------------------------
-    // 4. 逻辑删除 (@TableLogic)
-    // ------------------------------------------------------------------------
-
-    @Nested
-    @DisplayName("逻辑删除:deleted 标记后基础查询自动过滤")
-    class LogicDelete {
-
-        @Test
-        @DisplayName("deleteById 后 selectById 返回 null(被逻辑删除过滤)")
-        void selectByIdFiltersDeleted() {
-            StakingOrder order = newOrder(3001L, "STK-DEL-1", "idem-del-1");
-            orderMapper.insert(order);
-            Long id = order.getId();
-
-            int rows = orderMapper.deleteById(id);
-            assertEquals(1, rows, "逻辑删除应影响 1 行");
-
-            StakingOrder loaded = orderMapper.selectById(id);
-            assertNull(loaded, "逻辑删除后基础查询应自动过滤、查不到");
-        }
-
-        @Test
-        @DisplayName("逻辑删除后 selectCount / selectList 不再统计被删记录")
-        void countAndListExcludeDeleted() {
-            StakingOrder keep = newOrder(3002L, "STK-DEL-2", "idem-del-2");
-            StakingOrder remove = newOrder(3002L, "STK-DEL-3", "idem-del-3");
-            orderMapper.insert(keep);
-            orderMapper.insert(remove);
-
-            orderMapper.deleteById(remove.getId());
-
-            Long count = orderMapper.selectCount(new LambdaQueryWrapper<StakingOrder>()
-                    .eq(StakingOrder::getUserId, 3002L));
-            assertEquals(1L, count, "逻辑删除后该用户仅剩 1 条有效订单");
-
-            boolean containsRemoved = orderMapper
-                    .selectList(new LambdaQueryWrapper<StakingOrder>().eq(StakingOrder::getUserId, 3002L))
-                    .stream()
-                    .anyMatch(o -> o.getId().equals(remove.getId()));
-            assertTrue(!containsRemoved, "列表查询不应包含被逻辑删除的订单");
-        }
-
-        @Test
-        @DisplayName("逻辑删除后可复用相同 (userId, idempotentKey) 再次下单(物理行仍在但约束按全表)")
-        void uniqueConstraintStillCoversLogicallyDeletedRow() {
-            // 说明:逻辑删除仅置 deleted=1,物理行仍在,故 uk_user_idem 仍会与其冲突。
-            // 该用例固化此行为,提醒后续幂等设计以约束 + 业务查询(deleted=0)共同保证。
-            Long userId = 3003L;
-            String idem = "idem-del-reuse";
-            StakingOrder first = newOrder(userId, "STK-DEL-4", idem);
-            orderMapper.insert(first);
-            orderMapper.deleteById(first.getId());
-
-            StakingOrder reuse = newOrder(userId, "STK-DEL-5", idem);
-            assertThrows(
-                    DuplicateKeyException.class,
-                    () -> orderMapper.insert(reuse),
-                    "逻辑删除不移除物理行,uk_user_idem 仍覆盖该行");
-        }
-    }
-
-    // ------------------------------------------------------------------------
-    // 5. 游标分页扫描持仓质押订单 scanHoldingOrders(需求 8.1)
-    // ------------------------------------------------------------------------
-
-    @Nested
-    @DisplayName("游标分页扫描持仓质押订单 scanHoldingOrders —— 需求 8.1")
-    class ScanHoldingOrders {
-
-        @Test
-        @DisplayName("lastId=0 时返回所有持仓质押订单(按 id 升序)")
-        void scanFromBeginning() {
-            StakingOrder o1 = newOrder(4001L, "STK-SCAN-1", "idem-scan-1");
-            StakingOrder o2 = newOrder(4002L, "STK-SCAN-2", "idem-scan-2");
-            orderMapper.insert(o1);
-            orderMapper.insert(o2);
-
-            List<StakingOrder> result = orderMapper.scanHoldingOrders(0L, 100);
-            assertTrue(result.size() >= 2, "应至少包含 2 条持仓质押订单");
-            // 验证按 id 升序
-            for (int i = 1; i < result.size(); i++) {
-                assertTrue(result.get(i).getId() > result.get(i - 1).getId(), "结果应按 id 升序排列");
-            }
-        }
-
-        @Test
-        @DisplayName("lastId 大于 0 时仅返回 id 更大的记录(游标翻页)")
-        void scanWithLastId() {
-            StakingOrder o1 = newOrder(4011L, "STK-SCAN-3", "idem-scan-3");
-            StakingOrder o2 = newOrder(4012L, "STK-SCAN-4", "idem-scan-4");
-            StakingOrder o3 = newOrder(4013L, "STK-SCAN-5", "idem-scan-5");
-            orderMapper.insert(o1);
-            orderMapper.insert(o2);
-            orderMapper.insert(o3);
-
-            // 第一批:limit=2
-            List<StakingOrder> batch1 = orderMapper.scanHoldingOrders(0L, 2);
-            assertTrue(batch1.size() <= 2, "第一批最多 2 条");
-            if (batch1.size() < 2) {
-                return; // 数据不足,跳过翻页验证
-            }
-
-            // 第二批:以第一批最后一条的 id 作为 lastId
-            Long lastId = batch1.get(batch1.size() - 1).getId();
-            List<StakingOrder> batch2 = orderMapper.scanHoldingOrders(lastId, 2);
-            for (StakingOrder order : batch2) {
-                assertTrue(order.getId() > lastId, "第二批所有记录 id 应大于 lastId");
-            }
-        }
-
-        @Test
-        @DisplayName("limit 限制单批返回数量")
-        void scanWithLimit() {
-            StakingOrder o1 = newOrder(4021L, "STK-SCAN-6", "idem-scan-6");
-            StakingOrder o2 = newOrder(4022L, "STK-SCAN-7", "idem-scan-7");
-            StakingOrder o3 = newOrder(4023L, "STK-SCAN-8", "idem-scan-8");
-            orderMapper.insert(o1);
-            orderMapper.insert(o2);
-            orderMapper.insert(o3);
-
-            List<StakingOrder> result = orderMapper.scanHoldingOrders(0L, 2);
-            assertTrue(result.size() <= 2, "limit=2 时单批最多返回 2 条");
-        }
-
-        @Test
-        @DisplayName("仅扫描 status=HOLDING 且 product_type=STAKING 的订单,排除其他状态和产品类型")
-        void scanOnlyHoldingStaking() {
-            StakingOrder holding = newOrder(4031L, "STK-SCAN-HOLDING", "idem-scan-holding");
-            orderMapper.insert(holding);
-
-            // 插入一笔 SETTLED 状态的订单
-            StakingOrder settled = newOrder(4032L, "STK-SCAN-SETTLED", "idem-scan-settled");
-            settled.setStatus(OrderStatus.SETTLED.getValue());
-            orderMapper.insert(settled);
-
-            // 插入一笔私募产品订单
-            StakingOrder privateOrder = newOrder(4033L, "STK-SCAN-PRIVATE", "idem-scan-private");
-            privateOrder.setProductType(ProductType.PRIVATE_SALE.name());
-            orderMapper.insert(privateOrder);
-
-            List<StakingOrder> result = orderMapper.scanHoldingOrders(0L, 100);
-            // 结果中不应包含 SETTLED 或私募订单
-            for (StakingOrder order : result) {
-                assertEquals(OrderStatus.HOLDING.getValue(), order.getStatus(), "应仅包含 HOLDING 状态订单");
-                assertEquals(ProductType.STAKING.name(), order.getProductType(), "应仅包含 STAKING 产品类型订单");
-            }
-        }
-
-        @Test
-        @DisplayName("无匹配记录时返回空列表")
-        void scanEmptyResult() {
-            // 使用一个极大的 lastId,确保查不到数据
-            List<StakingOrder> result = orderMapper.scanHoldingOrders(Long.MAX_VALUE, 100);
-            assertNotNull(result, "结果不应为 null");
-            assertTrue(result.isEmpty(), "无匹配记录时应返回空列表");
-        }
-    }
-}

+ 0 - 347
bex-cloud-staking-entity/src/test/java/com/bex/staking/mapper/StakingOptimisticLockConcurrencyTest.java

@@ -1,347 +0,0 @@
-package com.bex.staking.mapper;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProduct;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.ProductType;
-import java.math.BigDecimal;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.ActiveProfiles;
-
-/**
- * MyBatis-Plus {@code @Version} 乐观锁并发集成测试(任务 17.4 乐观锁部分)。
- *
- * <p>被测组件:{@link StakingOrder} / {@link StakingProduct} 的 {@code @Version} 字段,配合
- * {@code OptimisticLockerInnerInterceptor}(在 {@link MapperTestApplication} 中注册)。验证设计文档
- * "测试策略 - MyBatis-Plus @Version:并发更新冲突触发重试" 所述语义:</p>
- *
- * <ol>
- *   <li><b>并发更新仅一个成功(需求 9.5、11.8、14.5)</b>:多个线程读到相同 {@code version} 后并发
- *       {@code updateById},乐观锁保证有且仅有一个成功(影响行数 1),其余失败(影响行数 0),杜绝丢失更新;</li>
- *   <li><b>冲突后重读重试成功(需求 9.6)</b>:版本冲突的更新重新查询最新 {@code version} 后重试可成功,
- *       对应 {@code SettlementEngine} 的乐观锁有限次重试逻辑;</li>
- *   <li><b>{@code increaseRaisedAmount} 乐观锁累加并发(需求 5.7、9.5)</b>:两线程并发以相同 {@code version}
- *       累加已募集金额,仅一个成功且 {@code version} 自增一次、{@code raised_amount} 仅累加一次。</li>
- * </ol>
- *
- * <p><b>数据库方案:</b>复用任务 2.3 的 H2({@code MODE=MySQL})内存库测试基建
- * ({@link MapperTestApplication} + {@code schema-h2.sql} + {@code application-test.yml}),因当前运行 / CI
- * 环境无 Docker,未使用 Testcontainers(MySQL 8)。乐观锁是数据库无关的应用层机制(拦截器改写 SQL),H2
- * 与 MySQL 行为一致,结论可迁移到生产。</p>
- *
- * <p><b>并发与隔离:</b>本测试<strong>不使用</strong> {@code @Transactional}——并发线程需读到彼此已提交的数据,
- * 必须真实落库(H2 {@code DB_CLOSE_DELAY=-1} 共享内存库 + 连接池多连接)。各用例使用唯一 {@code order_no} /
- * {@code product_sn} 隔离,互不干扰。金额断言统一使用 {@link BigDecimal#compareTo} 忽略 scale 差异。</p>
- *
- * <p>Validates: Requirements 9.5, 9.6, 11.8, 14.5, 5.7
- */
-@SpringBootTest
-@ActiveProfiles("test")
-@DisplayName("Staking @Version 乐观锁并发集成测试 (H2/MySQL 模式)")
-class StakingOptimisticLockConcurrencyTest {
-
-    @Autowired
-    private StakingOrderMapper orderMapper;
-
-    @Autowired
-    private StakingProductMapper productMapper;
-
-    // ------------------------------------------------------------------------
-    // 测试夹具构造
-    // ------------------------------------------------------------------------
-
-    /** 构造一笔合法的持仓订单(version 默认 0)。 */
-    private StakingOrder newOrder(Long userId, String orderNo, String idempotentKey) {
-        return StakingOrder.builder()
-                .orderNo(orderNo)
-                .userId(userId)
-                .productId(9001L)
-                .productType(ProductType.STAKING.getValue())
-                .idempotentKey(idempotentKey)
-                .stakeAmount(new BigDecimal("1000"))
-                .entryPrice(new BigDecimal("1.25"))
-                .initialValueUsdt(new BigDecimal("1250"))
-                .cumulativeProfit(BigDecimal.ZERO)
-                .pendingProfit(BigDecimal.ZERO)
-                .status(OrderStatus.HOLDING.getValue())
-                .lockStartTime(1_700_000_000_000L)
-                .expireTime(1_700_086_400_000L)
-                .build();
-    }
-
-    /** 构造一个合法的质押产品(已募集 0、总募集上限 100000、version 默认 0)。 */
-    private StakingProduct newProduct(String productSn, BigDecimal totalCapacity) {
-        return StakingProduct.builder()
-                .productSn(productSn)
-                .title("乐观锁并发测试产品")
-                .productType(ProductType.STAKING.getValue())
-                .status("ACTIVE")
-                .displayStatus("可质押")
-                .isRecommend(false)
-                .isShowTwap(false)
-                .subMode("UNLIMITED")
-                .minAmount(new BigDecimal("100"))
-                .maxAmount(new BigDecimal("100000"))
-                .totalCapacity(totalCapacity)
-                .raisedAmount(BigDecimal.ZERO)
-                .interestType("PERCENTAGE")
-                .apr(new BigDecimal("24"))
-                .periodRate(new BigDecimal("6"))
-                .fixedInterestAmount(BigDecimal.ZERO)
-                .lockDays(90)
-                .redeemType("NORMAL")
-                .deflationRate(new BigDecimal("80.00"))
-                .displayScale(4)
-                .saleStartTime(1_700_000_000_000L)
-                .saleEndTime(1_700_086_400_000L)
-                .build();
-    }
-
-    // ------------------------------------------------------------------------
-    // 1. 并发 updateById:相同 version 仅一个成功(需求 9.5、11.8)
-    // ------------------------------------------------------------------------
-
-    @Test
-    @DisplayName("两个快照持相同 version 并发更新订单账本,乐观锁保证仅一个成功(影响行数1)、另一个失败(影响行数0)")
-    void concurrentUpdateById_onlyOneSucceeds() throws Exception {
-        // 预置一条订单,version=0
-        StakingOrder seed = newOrder(50001L, "STK-OPT-1", "idem-opt-1");
-        orderMapper.insert(seed);
-        Long id = seed.getId();
-        assertNotNull(id);
-
-        // 两个线程读到相同 version(均为当前库中 version=0 的同一行)
-        StakingOrder snapshotA = orderMapper.selectById(id);
-        StakingOrder snapshotB = orderMapper.selectById(id);
-        assertEquals(0, snapshotA.getVersion(), "初始 version 应为 0");
-        assertEquals(snapshotA.getVersion(), snapshotB.getVersion(), "两个线程应读到相同 version");
-
-        // 并发以相同 version 更新(A 累加收益、B 推进状态),用闭锁让两线程尽量同时发起
-        CountDownLatch startGate = new CountDownLatch(1);
-        ExecutorService pool = Executors.newFixedThreadPool(2);
-        try {
-            Callable<Integer> updateA = () -> {
-                StakingOrder u = new StakingOrder();
-                u.setId(id);
-                u.setVersion(snapshotA.getVersion()); // 乐观锁条件 version=0
-                u.setCumulativeProfit(new BigDecimal("10"));
-                u.setPendingProfit(new BigDecimal("10"));
-                startGate.await();
-                return orderMapper.updateById(u);
-            };
-            Callable<Integer> updateB = () -> {
-                StakingOrder u = new StakingOrder();
-                u.setId(id);
-                u.setVersion(snapshotB.getVersion()); // 乐观锁条件 version=0
-                u.setStatus(OrderStatus.SETTLED.getValue());
-                startGate.await();
-                return orderMapper.updateById(u);
-            };
-
-            Future<Integer> fa = pool.submit(updateA);
-            Future<Integer> fb = pool.submit(updateB);
-            startGate.countDown(); // 同时放行
-
-            int rowsA = fa.get(10, TimeUnit.SECONDS);
-            int rowsB = fb.get(10, TimeUnit.SECONDS);
-
-            // 乐观锁语义:基于同一 version 的两次更新,有且仅有一次命中(影响行数 1),另一次落空(影响行数 0)
-            assertEquals(1, rowsA + rowsB, "相同 version 并发更新应有且仅有一次成功,杜绝丢失更新");
-            assertTrue(
-                    (rowsA == 1 && rowsB == 0) || (rowsA == 0 && rowsB == 1),
-                    "成功与失败应各一:rowsA=" + rowsA + ", rowsB=" + rowsB);
-        } finally {
-            pool.shutdownNow();
-        }
-
-        // 成功的更新使 version 自增到 1
-        StakingOrder reloaded = orderMapper.selectById(id);
-        assertEquals(1, reloaded.getVersion(), "成功更新后 version 应自增为 1");
-    }
-
-    @Test
-    @DisplayName("多线程并发以相同 version 更新同一订单,仅一个成功,其余全部因版本冲突落空")
-    void concurrentUpdateById_multiThread_onlyOneWins() throws Exception {
-        StakingOrder seed = newOrder(50002L, "STK-OPT-2", "idem-opt-2");
-        orderMapper.insert(seed);
-        Long id = seed.getId();
-
-        int threads = 8;
-        int baseVersion = orderMapper.selectById(id).getVersion(); // 所有线程读到相同初始 version
-        CountDownLatch startGate = new CountDownLatch(1);
-        CountDownLatch ready = new CountDownLatch(threads);
-        AtomicInteger successCount = new AtomicInteger();
-        ExecutorService pool = Executors.newFixedThreadPool(threads);
-        try {
-            for (int i = 0; i < threads; i++) {
-                final int seq = i;
-                pool.submit(() -> {
-                    StakingOrder u = new StakingOrder();
-                    u.setId(id);
-                    u.setVersion(baseVersion); // 全部基于相同 version
-                    u.setCumulativeProfit(new BigDecimal(seq + 1));
-                    ready.countDown();
-                    try {
-                        startGate.await();
-                        if (orderMapper.updateById(u) == 1) {
-                            successCount.incrementAndGet();
-                        }
-                    } catch (InterruptedException e) {
-                        Thread.currentThread().interrupt();
-                    }
-                    return null;
-                });
-            }
-            ready.await(10, TimeUnit.SECONDS); // 等所有线程就绪
-            startGate.countDown(); // 同时放行制造最大竞争
-            pool.shutdown();
-            assertTrue(pool.awaitTermination(15, TimeUnit.SECONDS), "并发任务应在限期内完成");
-        } finally {
-            pool.shutdownNow();
-        }
-
-        assertEquals(1, successCount.get(), "基于相同 version 的并发更新应仅一个成功");
-        assertEquals(baseVersion + 1, orderMapper.selectById(id).getVersion(), "version 应恰好自增一次");
-    }
-
-    // ------------------------------------------------------------------------
-    // 2. 冲突后重读重试成功(需求 9.6,对应 SettlementEngine 重试)
-    // ------------------------------------------------------------------------
-
-    @Test
-    @DisplayName("版本冲突的更新重新查询最新 version 后重试可成功(模拟结算乐观锁冲突重试 9.6)")
-    void staleUpdateSucceedsAfterReadAndRetry() {
-        StakingOrder seed = newOrder(50003L, "STK-OPT-3", "idem-opt-3");
-        orderMapper.insert(seed);
-        Long id = seed.getId();
-
-        // 线程读到 version=0 的旧快照
-        StakingOrder stale = orderMapper.selectById(id);
-        assertEquals(0, stale.getVersion());
-
-        // 另一路更新先行提交,使库中 version 推进到 1
-        StakingOrder winner = new StakingOrder();
-        winner.setId(id);
-        winner.setVersion(0);
-        winner.setCumulativeProfit(new BigDecimal("5"));
-        assertEquals(1, orderMapper.updateById(winner), "首个更新应成功");
-
-        // 旧快照(version=0)再更新:版本已过期,应落空(影响行数 0)
-        StakingOrder staleUpdate = new StakingOrder();
-        staleUpdate.setId(id);
-        staleUpdate.setVersion(stale.getVersion()); // 仍为 0,已过期
-        staleUpdate.setPendingProfit(new BigDecimal("7"));
-        assertEquals(0, orderMapper.updateById(staleUpdate), "过期 version 更新应因乐观锁落空");
-
-        // 重读最新 version 后重试,应成功(对应 SettlementEngine 的有限次重试)
-        StakingOrder fresh = orderMapper.selectById(id);
-        assertEquals(1, fresh.getVersion(), "重读应得到最新 version=1");
-        StakingOrder retry = new StakingOrder();
-        retry.setId(id);
-        retry.setVersion(fresh.getVersion());
-        retry.setPendingProfit(new BigDecimal("7"));
-        assertEquals(1, orderMapper.updateById(retry), "重读最新 version 后重试应成功");
-
-        StakingOrder finalState = orderMapper.selectById(id);
-        assertEquals(2, finalState.getVersion(), "两次成功更新后 version 应为 2");
-        assertEquals(0, finalState.getPendingProfit().compareTo(new BigDecimal("7")), "重试更新的字段应已落库");
-        assertEquals(0, finalState.getCumulativeProfit().compareTo(new BigDecimal("5")), "首个更新的字段应保留");
-    }
-
-    // ------------------------------------------------------------------------
-    // 3. increaseRaisedAmount 乐观锁累加并发(需求 5.7、9.5)
-    // ------------------------------------------------------------------------
-
-    @Test
-    @DisplayName("两线程并发以相同 version 累加已募集金额,仅一个成功,raised_amount 仅累加一次、version 自增一次")
-    void concurrentIncreaseRaisedAmount_onlyOneSucceeds() throws Exception {
-        StakingProduct product = newProduct("PRD-OPT-1", new BigDecimal("100000"));
-        productMapper.insert(product);
-        Long id = product.getId();
-
-        int baseVersion = productMapper.selectById(id).getVersion();
-        BigDecimal amount = new BigDecimal("1000");
-
-        CountDownLatch startGate = new CountDownLatch(1);
-        ExecutorService pool = Executors.newFixedThreadPool(2);
-        try {
-            Callable<Integer> task = () -> {
-                startGate.await();
-                // 两线程均以相同 version 累加,乐观锁保证仅一个生效
-                return productMapper.increaseRaisedAmount(id, amount, baseVersion);
-            };
-            Future<Integer> f1 = pool.submit(task);
-            Future<Integer> f2 = pool.submit(task);
-            startGate.countDown();
-
-            int rows1 = f1.get(10, TimeUnit.SECONDS);
-            int rows2 = f2.get(10, TimeUnit.SECONDS);
-            assertEquals(1, rows1 + rows2, "相同 version 并发累加应仅一个成功,避免重复累加导致超募");
-        } finally {
-            pool.shutdownNow();
-        }
-
-        StakingProduct reloaded = productMapper.selectById(id);
-        assertEquals(baseVersion + 1, reloaded.getVersion(), "version 应恰好自增一次");
-        assertEquals(
-                0,
-                reloaded.getRaisedAmount().compareTo(amount),
-                "raised_amount 应仅累加一次(=" + amount + "),丢失更新被乐观锁阻止");
-    }
-
-    @Test
-    @DisplayName("两次累加分别基于各自最新 version 串行执行,raised_amount 正确累计两次")
-    void sequentialIncreaseRaisedAmount_accumulatesTwice() {
-        StakingProduct product = newProduct("PRD-OPT-2", new BigDecimal("100000"));
-        productMapper.insert(product);
-        Long id = product.getId();
-        BigDecimal amount = new BigDecimal("1500");
-
-        int v0 = productMapper.selectById(id).getVersion();
-        assertEquals(1, productMapper.increaseRaisedAmount(id, amount, v0), "首次累加应成功");
-
-        // 冲突后重读最新 version 重试(对应下单时募集上限乐观锁累加的重试)
-        int v1 = productMapper.selectById(id).getVersion();
-        assertEquals(v0 + 1, v1, "首次累加后 version 自增");
-        assertEquals(1, productMapper.increaseRaisedAmount(id, amount, v1), "重读最新 version 后第二次累加应成功");
-
-        StakingProduct reloaded = productMapper.selectById(id);
-        assertEquals(
-                0,
-                reloaded.getRaisedAmount().compareTo(amount.add(amount)),
-                "两次有效累加后 raised_amount 应为 " + amount.add(amount));
-        assertEquals(v0 + 2, reloaded.getVersion(), "两次累加后 version 自增两次");
-    }
-
-    @Test
-    @DisplayName("超出总募集上限时 increaseRaisedAmount 落空(影响行数0),不累加、不自增 version")
-    void increaseRaisedAmount_rejectsWhenExceedCapacity() {
-        // 总募集上限仅 1000,单次累加 1500 必然超限
-        StakingProduct product = newProduct("PRD-OPT-3", new BigDecimal("1000"));
-        productMapper.insert(product);
-        Long id = product.getId();
-
-        int v0 = productMapper.selectById(id).getVersion();
-        int rows = productMapper.increaseRaisedAmount(id, new BigDecimal("1500"), v0);
-        assertEquals(0, rows, "超出募集上限的累加应落空");
-
-        StakingProduct reloaded = productMapper.selectById(id);
-        assertEquals(0, reloaded.getRaisedAmount().compareTo(BigDecimal.ZERO), "超限累加不应改变 raised_amount");
-        assertEquals(v0, reloaded.getVersion(), "落空的累加不应自增 version");
-    }
-}

+ 0 - 41
bex-cloud-staking-entity/src/test/resources/application-test.yml

@@ -1,41 +0,0 @@
-# =============================================================================
-# 实体模块 Mapper 集成测试配置(H2 内存数据库,MySQL 兼容模式)
-#
-# 说明:
-#   - 因运行环境无 Docker,集成测试采用 H2(MODE=MySQL)替代 Testcontainers(MySQL 8);
-#   - DATABASE_TO_LOWER=TRUE 使大小写不敏感,贴近 MySQL 默认行为;
-#   - 启动时执行 schema-h2.sql 建表(含 uk_user_idem / uk_order_date 唯一约束);
-#   - MyBatis-Plus 全局配置与生产保持一致:assign_id 主键、deleted 逻辑删除、mapper XML 位置。
-# =============================================================================
-spring:
-  cloud:
-    nacos:
-      server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
-      username: ${NACOS_USERNAME:nacos}
-      password: ${NACOS_PASSWORD:nacos}
-      config:
-        namespace: ${NACOS_NAMESPACE:public}
-        group: ${NACOS_GROUP:DEFAULT_GROUP}
-      discovery:
-        namespace: ${NACOS_NAMESPACE:public}
-        enabled: false
-  datasource:
-    url: jdbc:h2:mem:bex_staking_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
-    driver-class-name: org.h2.Driver
-    username: sa
-    password: ""
-  sql:
-    init:
-      mode: always
-      schema-locations: classpath:schema-h2.sql
-
-mybatis-plus:
-  mapper-locations: classpath*:mapper/*.xml
-  global-config:
-    db-config:
-      id-type: assign_id
-      logic-delete-field: deleted
-      logic-delete-value: 1
-      logic-not-delete-value: 0
-  configuration:
-    map-underscore-to-camel-case: true

+ 0 - 121
bex-cloud-staking-entity/src/test/resources/schema-h2.sql

@@ -1,121 +0,0 @@
--- =============================================================================
--- H2(MySQL 兼容模式)测试建表脚本 —— 用于 Mapper CRUD 与唯一约束集成测试
---
--- 设计说明:
---   1. 由生产脚本 bex_staking.sql 改写为 H2 可执行的等价 DDL:
---      - 去除 MySQL 专有的 BIGINT UNSIGNED / ENGINE=InnoDB / ON UPDATE CURRENT_TIMESTAMP;
---      - 仅保留主键与唯一约束(uk_order_no / uk_user_idem / uk_order_date / uk_event_no 等),
---        非唯一索引对约束/CRUD 测试无影响,故省略以提升 H2 兼容性。
---   2. 关键唯一约束完整保留,确保可验证:
---      - staking_order.uk_user_idem(user_id, idempotent_key)  —— 下单幂等(需求 5.10)
---      - staking_profit_journal.uk_order_date(order_no, settle_date) —— 每日结算幂等(需求 9.7)
---   3. deleted / version 字段保留,用于验证 MyBatis-Plus 逻辑删除与乐观锁。
--- =============================================================================
-
-DROP TABLE IF EXISTS `staking_product`;
-DROP TABLE IF EXISTS `staking_order`;
-DROP TABLE IF EXISTS `staking_profit_journal`;
-DROP TABLE IF EXISTS `staking_asset_event`;
-
--- 1. 产品定义主表
-CREATE TABLE `staking_product` (
-  `id`                    BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `product_sn`            VARCHAR(64)  NOT NULL COMMENT '产品唯一编号',
-  `title`                 VARCHAR(128) NOT NULL COMMENT '产品名称',
-  `product_type`          VARCHAR(32)  NOT NULL COMMENT '产品类型: PRIVATE_SALE, STAKING',
-  `status`                VARCHAR(32)  NOT NULL COMMENT '产品状态: ACTIVE, SOLD_OUT, DISABLED',
-  `display_status`        VARCHAR(32)  NOT NULL COMMENT '对外展示状态',
-  `is_recommend`          TINYINT      NOT NULL DEFAULT 0 COMMENT '是否推荐',
-  `is_show_twap`          TINYINT      NOT NULL DEFAULT 0 COMMENT '是否展示TWAP价格',
-  `sub_mode`              VARCHAR(32)  NOT NULL COMMENT '认购模式: FIXED, UNLIMITED',
-  `tier_config`           VARCHAR(512) DEFAULT NULL COMMENT '固定档位JSON',
-  `min_amount`            DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '最小认购限额',
-  `max_amount`            DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '最大认购限额',
-  `total_capacity`        DECIMAL(36,18) NOT NULL COMMENT '总募集上限',
-  `raised_amount`         DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '已募集金额',
-  `interest_type`         VARCHAR(32)  NOT NULL COMMENT '利息类型: PERCENTAGE, FIXED_AMOUNT',
-  `apr`                   DECIMAL(12,6) NOT NULL DEFAULT 0 COMMENT '年化收益率',
-  `period_rate`           DECIMAL(12,6) NOT NULL DEFAULT 0 COMMENT '周期收益率',
-  `fixed_interest_amount` DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '单位本金固定利息',
-  `lock_days`             INT NOT NULL COMMENT '锁仓天数',
-  `redeem_type`           VARCHAR(32)  NOT NULL COMMENT '赎回机制: NORMAL, DEFLATIONARY',
-  `deflation_rate`        DECIMAL(5,2) NOT NULL DEFAULT 80.00 COMMENT '通缩触发收益占比阈值(%)',
-  `display_scale`         INT NOT NULL DEFAULT 4 COMMENT '展示小数位',
-  `sale_start_time`       BIGINT NOT NULL COMMENT '募集开始时间戳(毫秒)',
-  `sale_end_time`         BIGINT NOT NULL COMMENT '募集结束时间戳(毫秒)',
-  `lock_start_time`       BIGINT DEFAULT NULL COMMENT '锁仓开始时间戳(毫秒)',
-  `lock_end_time`         BIGINT DEFAULT NULL COMMENT '锁仓到期时间戳(毫秒)',
-  `version`               INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `deleted`               TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0未删 1已删',
-  `created_at`            DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`            DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_product_sn` UNIQUE (`product_sn`)
-);
-
--- 2. 用户质押/私募订单表
-CREATE TABLE `staking_order` (
-  `id`                 BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `order_no`           VARCHAR(64) NOT NULL COMMENT '全局唯一订单编号',
-  `user_id`            BIGINT NOT NULL COMMENT '用户UID',
-  `product_id`         BIGINT NOT NULL COMMENT '产品ID',
-  `product_type`       VARCHAR(32) NOT NULL COMMENT '产品类型',
-  `idempotent_key`     VARCHAR(128) NOT NULL COMMENT '幂等键, 防重复下单',
-  `stake_amount`       DECIMAL(36,18) NOT NULL COMMENT '质押/认购本金(P)',
-  `entry_price`        DECIMAL(36,18) NOT NULL COMMENT '入场价格(Pt0)',
-  `initial_value_usdt` DECIMAL(36,18) NOT NULL COMMENT '初始USDT价值(V0)',
-  `cumulative_profit`  DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '累计已结算收益(R)',
-  `pending_profit`     DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '待领取收益余额',
-  `claimed_profit`     DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '已领取收益累计',
-  `settled_days`       INT NOT NULL DEFAULT 0 COMMENT '已结算天数',
-  `status`             VARCHAR(32) NOT NULL COMMENT '订单状态',
-  `return_principal`   DECIMAL(36,18) DEFAULT NULL COMMENT '清算返还本金(R)',
-  `burn_amount`        DECIMAL(36,18) DEFAULT NULL COMMENT '销毁数量(P-R)',
-  `expire_price`       DECIMAL(36,18) DEFAULT NULL COMMENT '到期清算价格(Pt)',
-  `burn_tx_hash`       VARCHAR(128) DEFAULT NULL COMMENT '销毁划转交易哈希',
-  `lock_start_time`    BIGINT NOT NULL COMMENT '锁仓开始时间戳(毫秒)',
-  `expire_time`        BIGINT NOT NULL COMMENT '订单到期时间戳(毫秒)',
-  `version`            INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `deleted`            TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
-  `created_at`         DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`         DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_order_no` UNIQUE (`order_no`),
-  CONSTRAINT `uk_user_idem` UNIQUE (`user_id`, `idempotent_key`)
-);
-
--- 3. 收益派发流水日志表
-CREATE TABLE `staking_profit_journal` (
-  `id`            BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `journal_no`    VARCHAR(64) NOT NULL COMMENT '流水编号',
-  `order_no`      VARCHAR(64) NOT NULL COMMENT '订单编号',
-  `user_id`       BIGINT NOT NULL COMMENT '用户UID',
-  `profit_amount` DECIMAL(36,18) NOT NULL COMMENT '本次派发收益金额',
-  `settle_date`   INT NOT NULL COMMENT '结算业务日期 YYYYMMDD',
-  `status`        VARCHAR(32) NOT NULL COMMENT '状态: UNCLAIMED, CLAIMED',
-  `claim_time`    BIGINT DEFAULT NULL COMMENT '领取时间戳',
-  `version`       INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_journal_no` UNIQUE (`journal_no`),
-  CONSTRAINT `uk_order_date` UNIQUE (`order_no`, `settle_date`)
-);
-
--- 4. 资产账变本地消息表
-CREATE TABLE `staking_asset_event` (
-  `id`            BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `event_no`      VARCHAR(64) NOT NULL COMMENT '事件唯一编号(幂等 reference_id)',
-  `order_no`      VARCHAR(64) NOT NULL COMMENT '关联订单编号',
-  `user_id`       BIGINT NOT NULL COMMENT '用户UID',
-  `event_type`    VARCHAR(32) NOT NULL COMMENT '类型',
-  `amount`        DECIMAL(36,18) NOT NULL COMMENT '金额',
-  `status`        VARCHAR(32) NOT NULL COMMENT '状态',
-  `retry_count`   INT NOT NULL DEFAULT 0 COMMENT '重试次数',
-  `tx_hash`       VARCHAR(128) DEFAULT NULL COMMENT '账变交易哈希',
-  `version`       INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_event_no` UNIQUE (`event_no`)
-);

+ 44 - 2
bex-cloud-staking-service/src/main/java/com/bex/staking/engine/HomeAssembler.java

@@ -14,7 +14,10 @@ import java.math.BigDecimal;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
 
 /**
  * 质押首页聚合服务(HomeAssembler)。
@@ -57,6 +60,12 @@ public class HomeAssembler {
     private final AssetGrpcClient assetGrpcClient;
     private final StakingOrderMapper stakingOrderMapper;
     private final StakingHomeConverter stakingHomeConverter;
+    private final ObjectMapper objectMapper;
+
+    private static final String DEFAULT_LANGUAGE = "zh";
+
+    @Value("${staking.constants.HOME_SETTLE_RULE}")
+    private String HOME_SETTLE_RULE;
 
     /**
      * 聚合并返回质押首页资产概览。
@@ -72,11 +81,12 @@ public class HomeAssembler {
      *       (单位置 "BEX"、填充 {@code settleRule},需求 1.2、1.3)。</li>
      * </ol>
      *
-     * @param userId 用户 UID,由 {@code X-User-Id} 透传的用户上下文提供;为空时抛出 40001
+     * @param userId     用户 UID,由 {@code X-User-Id} 透传的用户上下文提供;为空时抛出 40001
+     * @param localeName
      * @return 契约 {@link StakingHomeVO}({@code code} 非空字段已按契约装配)
      * @throws StakingException 用户上下文为空(40001)或资产服务调用失败(50001)时抛出
      */
-    public StakingHomeVO getHome(Long userId) {
+    public StakingHomeVO getHome(Long userId, String localeName) {
         // 1. 用户上下文校验:缺 X-User-Id / 用户上下文为空 → 40001(需求 1.7)
         if (userId == null) {
             throw new StakingException(StakingErrorCode.MISSING_USER_ID, "缺少用户身份信息,请重新登录");
@@ -111,6 +121,7 @@ public class HomeAssembler {
         // 4. 组装首页聚合命令,交由装配层格式化为契约 VO(单位置 BEX、填充 settleRule)(需求 1.2、1.3)
         HomeAssembly assembly = new HomeAssembly(availableBalance, totalProfit, pendingProfit);
         StakingHomeVO vo = stakingHomeConverter.toVo(assembly);
+        vo.setSettleRule(resolveLocalizedText(HOME_SETTLE_RULE, localeName));
 
         log.info(
                 "质押首页聚合完成: userId={}, availableBalance={}, totalProfit={}, pendingProfit={}, orderCount={}",
@@ -121,4 +132,35 @@ public class HomeAssembler {
                 orders == null ? 0 : orders.size());
         return vo;
     }
+
+    /**
+     * 解析 JSON 格式的多语言文案
+     *
+     * @param text      多语言文案(JSON格式或普通文本)
+     * @param localeName 语言标识(如zh/en)
+     * @return 解析后的文本
+     */
+    private String resolveLocalizedText(String text, String localeName) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        String lang = (localeName == null || localeName.isBlank()) ? DEFAULT_LANGUAGE : localeName;
+        try {
+            JsonNode node = objectMapper.readTree(text);
+            if (!node.isObject()) {
+                return text;
+            }
+            JsonNode val = node.get(lang);
+            if (val != null && !val.asText().isEmpty()) {
+                return val.asText();
+            }
+            JsonNode zh = node.get(DEFAULT_LANGUAGE);
+            if (zh != null) {
+                return zh.asText();
+            }
+            return "";
+        } catch (Exception e) {
+            return text;
+        }
+    }
 }

+ 5 - 4
bex-cloud-staking-service/src/main/java/com/bex/staking/service/OrderQueryService.java

@@ -13,7 +13,7 @@ import com.bex.staking.model.vo.StakingRedeemOrderDetailVO;
  * 「REST 接口契约映射」中的以下三个端点,由 {@code StakingService} 门面编排调用:
  *
  * <ul>
- *   <li>{@code POST /api/v1/staking/order/page} → {@link #pageOrders(Long, StakingOrderQuery)}(需求 6);
+ *   <li>{@code POST /api/v1/staking/order/page} → {@link #pageOrders(Long, StakingOrderQuery, String)}(需求 6);
  *   <li>{@code GET /api/v1/staking/order/detail/{orderId}} → {@link #getOngoingDetail(Long, Long)}(需求 7);
  *   <li>{@code GET /api/v1/staking/redeem/detail/{orderId}} → {@link #getRedeemDetail(Long, Long)}(需求 8)。
  * </ul>
@@ -41,11 +41,12 @@ public interface OrderQueryService {
      * 每条订单由 {@code StakingOrderListConverter} 按状态分组装配为契约 {@link StakingOrderListVO}(需求 6.5~6.7),
      * 其中进行中订单的 {@code progressPercent} 取 0~100 的整数锁仓进度(需求 6.8)。
      *
-     * @param userId 用户 UID,由 {@code X-User-Id} 透传的用户上下文提供
-     * @param query 订单查询条件(标签类型、页码、每页数量)
+     * @param userId     用户 UID,由 {@code X-User-Id} 透传的用户上下文提供
+     * @param query      订单查询条件(标签类型、页码、每页数量)
+     * @param localeName
      * @return 契约订单列表分页 {@link Page}(保留 total / current / size)
      */
-    Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query);
+    Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query, String localeName);
 
     /**
      * 查询进行中订单详情(本金收益、价格价值、每日收益进度)。

+ 4 - 3
bex-cloud-staking-service/src/main/java/com/bex/staking/service/ProductQueryService.java

@@ -29,12 +29,13 @@ public interface ProductQueryService {
      *   <li>每条记录由 {@code StakingProductConverter} 装配为契约 {@link StakingProductVO}(需求 2.6~2.9)。
      * </ul>
      *
-     * @param page 页码,缺省 1
-     * @param size 每页数量,缺省 10
+     * @param page             页码,缺省 1
+     * @param size             每页数量,缺省 10
      * @param isRecommendation 是否仅查询推荐产品,可空
+     * @param localeName
      * @return 契约产品列表分页 {@link Page}(保留 total / current / size)
      */
-    Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation);
+    Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation, String localeName);
 
     /**
      * 查询质押产品详情(含基于基准数量的收益预估)。

+ 14 - 11
bex-cloud-staking-service/src/main/java/com/bex/staking/service/StakingService.java

@@ -22,12 +22,12 @@ import java.math.BigDecimal;
  * <table border="1">
  *   <caption>设计文档「REST 接口契约映射」——门面方法到组件的编排映射</caption>
  *   <tr><th>门面方法</th><th>端点</th><th>编排目标组件</th><th>需求</th></tr>
- *   <tr><td>{@link #getHome(Long)}</td><td>{@code GET /home}</td><td>{@code HomeAssembler.getHome}</td><td>1.1</td></tr>
- *   <tr><td>{@link #pageProducts(Integer, Integer, Boolean)}</td><td>{@code GET /recommendationPage}</td><td>{@code ProductQueryService.pageProducts}</td><td>2.1</td></tr>
+ *   <tr><td>{@link #getHome(Long, String)}</td><td>{@code GET /home}</td><td>{@code HomeAssembler.getHome}</td><td>1.1</td></tr>
+ *   <tr><td>{@link #pageProducts(Integer, Integer, Boolean, String)}</td><td>{@code GET /recommendationPage}</td><td>{@code ProductQueryService.pageProducts}</td><td>2.1</td></tr>
  *   <tr><td>{@link #getDetail(Long)}</td><td>{@code GET /detail/{id}}</td><td>{@code ProductQueryService.getDetail}</td><td>3.1</td></tr>
  *   <tr><td>{@link #preview(Long, Long, BigDecimal)}</td><td>{@code GET /preview}</td><td>{@code PreviewEngine.preview}</td><td>4.1</td></tr>
  *   <tr><td>{@link #confirmStake(Long, StakingOrderReq)}</td><td>{@code POST /stake/confirm}</td><td>{@code OrderEngine.confirmStake}</td><td>5.1</td></tr>
- *   <tr><td>{@link #pageOrders(Long, StakingOrderQuery)}</td><td>{@code POST /order/page}</td><td>{@code OrderQueryService.pageOrders}</td><td>6.1</td></tr>
+ *   <tr><td>{@link #pageOrders(Long, StakingOrderQuery, String)}</td><td>{@code POST /order/page}</td><td>{@code OrderQueryService.pageOrders}</td><td>6.1</td></tr>
  *   <tr><td>{@link #getOngoingDetail(Long, Long)}</td><td>{@code GET /order/detail/{orderId}}</td><td>{@code OrderQueryService.getOngoingDetail}</td><td>7.1</td></tr>
  *   <tr><td>{@link #getRedeemDetail(Long, Long)}</td><td>{@code GET /redeem/detail/{orderId}}</td><td>{@code OrderQueryService.getRedeemDetail}</td><td>8.1</td></tr>
  *   <tr><td>{@link #claimProfit(Long)}</td><td>{@code POST /profit/claim}</td><td>{@code ClaimService.claimProfit}</td><td>10.1</td></tr>
@@ -48,10 +48,11 @@ public interface StakingService {
      * 待领取收益合计,装配为契约 {@link StakingHomeVO}。{@code X-User-Id} 缺失({@code userId} 为空)时由
      * 编排组件抛出 {@code StakingException(40001)}(需求 1.7)。
      *
-     * @param userId 用户 UID,由 {@code X-User-Id} 透传的用户上下文提供
+     * @param userId     用户 UID,由 {@code X-User-Id} 透传的用户上下文提供
+     * @param localeName
      * @return 契约首页概览 {@link StakingHomeVO}
      */
-    StakingHomeVO getHome(Long userId);
+    StakingHomeVO getHome(Long userId, String localeName);
 
     /**
      * 分页查询质押产品列表(推荐 / 全部,编排 {@code ProductQueryService.pageProducts})。
@@ -59,12 +60,13 @@ public interface StakingService {
      * <p>对应 {@code GET /api/v1/staking/recommendationPage}(需求 2.1):{@code page} 缺省 1、{@code size}
      * 缺省 10;{@code isRecommendation=true} 仅返回推荐产品,否则返回全部产品(需求 2.2~2.5)。
      *
-     * @param page 页码,缺省 1
-     * @param size 每页数量,缺省 10
+     * @param page             页码,缺省 1
+     * @param size             每页数量,缺省 10
      * @param isRecommendation 是否仅查询推荐产品,可空
+     * @param localeName
      * @return 契约产品列表分页 {@link Page}
      */
-    Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation);
+    Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation, String localeName);
 
     /**
      * 查询质押产品详情(编排 {@code ProductQueryService.getDetail})。
@@ -109,11 +111,12 @@ public interface StakingService {
      * <p>对应 {@code POST /api/v1/staking/order/page}(需求 6.1):按 {@code tabType}(all / ongoing / redeemed)
      * 过滤并分页,装配为契约 {@link StakingOrderListVO}。
      *
-     * @param userId 用户 UID
-     * @param query 订单查询条件(标签类型、页码、每页数量)
+     * @param userId     用户 UID
+     * @param query      订单查询条件(标签类型、页码、每页数量)
+     * @param localeName
      * @return 契约订单列表分页 {@link Page}
      */
-    Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query);
+    Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query, String localeName);
 
     /**
      * 查询进行中订单详情(编排 {@code OrderQueryService.getOngoingDetail})。

+ 71 - 6
bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/OrderQueryServiceImpl.java

@@ -40,7 +40,10 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
 
 /**
  * 订单查询服务实现。
@@ -48,7 +51,7 @@ import org.springframework.stereotype.Service;
  * <p>{@link OrderQueryService} 的默认实现,对应需求 6「我的质押订单列表查询」、需求 7「进行中订单详情查询」、
  * 需求 8「通缩赎回订单详情查询」,以及设计文档「REST 接口契约映射」第 6/7/8 项端点。
  *
- * <h2>订单列表({@link #pageOrders},需求 6.1~6.9)</h2>
+ * <h2>订单列表({@link OrderQueryService#pageOrders},需求 6.1~6.9)</h2>
  *
  * <p>按 {@code tabType} 映射订单状态集合后调用 {@link StakingOrderMapper#pageByUserAndStatus} 物理分页查询:
  *
@@ -113,9 +116,33 @@ public class OrderQueryServiceImpl implements OrderQueryService {
     private final StakingOngoingOrderDetailConverter stakingOngoingOrderDetailConverter;
     private final StakingRedeemOrderDetailConverter stakingRedeemOrderDetailConverter;
     private final StakingProfitItemConverter stakingProfitItemConverter;
+    private final ObjectMapper objectMapper;
+
+    @Value("${staking.constants.ORDER_DISPLAY_HOLDING}")
+    private String ORDER_DISPLAY_HOLDING;
+
+    @Value("${staking.constants.ORDER_DISPLAY_DEFLATED}")
+    private String ORDER_DISPLAY_DEFLATED;
+
+    @Value("${staking.constants.ORDER_DISPLAY_SETTLED}")
+    private String ORDER_DISPLAY_SETTLED;
+
+    @Value("${staking.constants.PROFIT_MODE_TEXT}")
+    private String PROFIT_MODE_TEXT;
+
+    private static final String DEFAULT_LANGUAGE = "zh";
+
+    /** 订单展示状态:持仓中(HOLDING)。也用于尚未清算的已到期(MATURED)订单 */
+    public static final String ORDER_HOLDING = "进行中";
+
+    /** 订单展示状态:通缩赎回(DEFLATED) */
+    public static final String ORDER_DEFLATED = "通缩赎回";
+
+    /** 订单展示状态:常规赎回完成(SETTLED) */
+    public static final String ORDER_SETTLED = "已完成";
 
     @Override
-    public Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query) {
+    public Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query, String localeName) {
         // 1. 分页参数兜底:page 缺省 1、size 缺省 10(需求 6.9)
         Integer reqPage = query == null ? null : query.getPage();
         Integer reqSize = query == null ? null : query.getSize();
@@ -136,11 +163,17 @@ public class OrderQueryServiceImpl implements OrderQueryService {
 
         // 5. 逐条按状态分组装配为契约 VO;进行中订单计算锁仓进度百分比(需求 6.5~6.8)
         List<StakingOrderListVO> voRecords = records.stream()
-                .map(order -> toListVo(order, productCache))
+                .map(order -> toListVo(order, productCache, localeName))
                 .toList();
 
         // 6. 组装新的 Page<StakingOrderListVO>,保留 total / current / size(需求 6.9)
         Page<StakingOrderListVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+
+        for (StakingOrderListVO voRecord : voRecords) {
+            voRecord.setOrderStatus(ORDER_HOLDING.equals(voRecord.getOrderStatus()) ? resolveLocalizedText(ORDER_DISPLAY_HOLDING,localeName)
+                    : ORDER_DEFLATED.equals(voRecord.getOrderStatus()) ? resolveLocalizedText(ORDER_DISPLAY_DEFLATED,localeName) : resolveLocalizedText(ORDER_DISPLAY_SETTLED,localeName));
+            voRecord.setProfitMode(resolveLocalizedText(PROFIT_MODE_TEXT,localeName));
+        }
         voPage.setRecords(voRecords);
         log.debug(
                 "我的质押订单分页查询: userId={}, tabType={}, page={}, size={}, total={}",
@@ -259,13 +292,14 @@ public class OrderQueryServiceImpl implements OrderQueryService {
      * <p>从产品缓存取 {@code productName}、{@code displayScale}、{@code lockDays};进行中订单(HOLDING/MATURED)
      * 计算锁仓进度百分比 {@code progressPercent}(需求 6.8),已赎回订单进度传 {@code null}(列表项不展示进度)。
      *
-     * @param order 订单实体
+     * @param order        订单实体
      * @param productCache 产品缓存
+     * @param localeName
      * @return 契约 {@link StakingOrderListVO}
      */
-    private StakingOrderListVO toListVo(StakingOrder order, Map<Long, StakingProduct> productCache) {
+    private StakingOrderListVO toListVo(StakingOrder order, Map<Long, StakingProduct> productCache, String localeName) {
         StakingProduct product = order.getProductId() == null ? null : productCache.get(order.getProductId());
-        String productName = product == null ? "" : product.getTitle();
+        String productName = product == null ? "" : resolveLocalizedText(product.getTitle(), localeName);
         int displayScale = resolveDisplayScale(product);
 
         Integer progressPercent = null;
@@ -459,4 +493,35 @@ public class OrderQueryServiceImpl implements OrderQueryService {
                 .toInstant()
                 .toEpochMilli();
     }
+
+    /**
+     * 解析 JSON 格式的多语言文案
+     *
+     * @param text      多语言文案(JSON格式或普通文本)
+     * @param localeName 语言标识(如zh/en)
+     * @return 解析后的文本
+     */
+    private String resolveLocalizedText(String text, String localeName) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        String lang = (localeName == null || localeName.isBlank()) ? DEFAULT_LANGUAGE : localeName;
+        try {
+            JsonNode node = objectMapper.readTree(text);
+            if (!node.isObject()) {
+                return text;
+            }
+            JsonNode val = node.get(lang);
+            if (val != null && !val.asText().isEmpty()) {
+                return val.asText();
+            }
+            JsonNode zh = node.get(DEFAULT_LANGUAGE);
+            if (zh != null) {
+                return zh.asText();
+            }
+            return "";
+        } catch (Exception e) {
+            return text;
+        }
+    }
 }

+ 80 - 2
bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/ProductQueryServiceImpl.java

@@ -19,7 +19,10 @@ import java.math.RoundingMode;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
 
 /**
  * 产品查询服务实现。
@@ -28,7 +31,7 @@ import org.springframework.stereotype.Service;
  * 以及设计文档「REST 接口契约映射」中的 {@code GET /api/v1/staking/recommendationPage} 与
  * {@code GET /api/v1/staking/detail/{id}} 端点。
  *
- * <h2>列表分页({@link #pageProducts},需求 2.1~2.6、2.10)</h2>
+ * <h2>列表分页({@link ProductQueryService#pageProducts},需求 2.1~2.6、2.10)</h2>
  *
  * <p>构造 MyBatis-Plus {@link Page} 作为物理分页参数({@code page} 缺省 1、{@code size} 缺省 10),调用
  * {@link StakingProductMapper#pageProducts} 完成推荐过滤({@code isRecommendation=true} 仅推荐,
@@ -72,12 +75,42 @@ public class ProductQueryServiceImpl implements ProductQueryService {
     /** 展示日利率百分比的计算精度(小数位),与 {@link ProfitCalculator#SCALE} 对齐为 18 位。 */
     private static final int DAILY_RATE_SCALE = ProfitCalculator.SCALE;
 
+    private static final String DEFAULT_LANGUAGE = "zh";
+
+
     private final StakingProductMapper stakingProductMapper;
     private final StakingProductConverter stakingProductConverter;
     private final StakingProductDetailConverter stakingProductDetailConverter;
 
+    private final ObjectMapper objectMapper;
+
+    @Value("${staking.constants.PROFIT_MODE_TEXT}")
+    private String PROFIT_MODE_TEXT;
+
+    @Value("${staking.constants.DEFLATION}")
+    private String DEFLATION;
+
+    @Value("${staking.constants.DISPLAY_AVAILABLE}")
+    private String DISPLAY_AVAILABLE;
+
+    @Value("${staking.constants.DISPLAY_UPCOMING}")
+    private String DISPLAY_UPCOMING;
+
+    @Value("${staking.constants.DISPLAY_ENDED}")
+    private String DISPLAY_ENDED;
+
+    /** 展示状态:可质押(处于募集期且状态正常) */
+    public static final String AVAILABLE = "可质押";
+
+    /** 展示状态:即将开启(尚未到募集开始时间) */
+    public static final String UPCOMING = "即将开启";
+
+    /** 展示状态:已结束(已售罄 / 已过募集期 / 已下架) */
+    public static final String ENDED = "已结束";
+
+
     @Override
-    public Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation) {
+    public Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation, String localeName) {
         // 1. 分页参数兜底:page 缺省 1、size 缺省 10(需求 2.2、2.3)
         long current = (page == null || page < 1) ? DEFAULT_PAGE : page;
         long pageSize = (size == null || size < 1) ? DEFAULT_SIZE : size;
@@ -86,12 +119,25 @@ public class ProductQueryServiceImpl implements ProductQueryService {
         Page<StakingProduct> pageParam = new Page<>(current, pageSize);
         IPage<StakingProduct> entityPage = stakingProductMapper.pageProducts(pageParam, isRecommendation);
 
+        List<StakingProduct> records = entityPage.getRecords();
+        for (StakingProduct record : records) {
+            record.setTitle(resolveLocalizedText(record.getTitle(), localeName));
+        }
+
         // 3. 逐条装配为契约 VO;now 用于推导展示状态与倒计时(需求 2.6~2.9)
         long now = System.currentTimeMillis();
         List<StakingProductVO> voRecords = entityPage.getRecords().stream()
                 .map(product -> stakingProductConverter.toVo(product, now))
                 .toList();
 
+        for (StakingProductVO voRecord : voRecords) {
+            voRecord.setProfitMode(resolveLocalizedText(PROFIT_MODE_TEXT, localeName));
+            voRecord.setThresholdRule(voRecord.getThresholdRule() + resolveLocalizedText(DEFLATION, localeName));
+            voRecord.setStatus(AVAILABLE.equals(voRecord.getStatus()) ? resolveLocalizedText(DISPLAY_AVAILABLE, localeName)
+                    : UPCOMING.equals(voRecord.getStatus()) ? resolveLocalizedText(DISPLAY_UPCOMING, localeName)
+                      : resolveLocalizedText(DISPLAY_ENDED, localeName));
+        }
+
         // 4. 组装新的 Page<StakingProductVO>,保留 total / current / size(需求 2.10)
         Page<StakingProductVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
         voPage.setRecords(voRecords);
@@ -149,4 +195,36 @@ public class ProductQueryServiceImpl implements ProductQueryService {
         }
         return StakingConstants.MIN_STAKE_AMOUNT;
     }
+
+
+    /**
+     * 解析 JSON 格式的多语言文案
+     *
+     * @param text      多语言文案(JSON格式或普通文本)
+     * @param localeName 语言标识(如zh/en)
+     * @return 解析后的文本
+     */
+    private String resolveLocalizedText(String text, String localeName) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        String lang = (localeName == null || localeName.isBlank()) ? DEFAULT_LANGUAGE : localeName;
+        try {
+            JsonNode node = objectMapper.readTree(text);
+            if (!node.isObject()) {
+                return text;
+            }
+            JsonNode val = node.get(lang);
+            if (val != null && !val.asText().isEmpty()) {
+                return val.asText();
+            }
+            JsonNode zh = node.get(DEFAULT_LANGUAGE);
+            if (zh != null) {
+                return zh.asText();
+            }
+            return "";
+        } catch (Exception e) {
+            return text;
+        }
+    }
 }

+ 9 - 9
bex-cloud-staking-service/src/main/java/com/bex/staking/service/impl/StakingServiceImpl.java

@@ -63,16 +63,16 @@ public class StakingServiceImpl implements StakingService {
     /** 收益领取服务:编排 {@code claimProfit}(需求 10.1)。 */
     private final ClaimService claimService;
 
-    /** {@inheritDoc} 委托 {@link HomeAssembler#getHome(Long)}(需求 1.1)。 */
+    /** {@inheritDoc} 委托 {@link HomeAssembler#getHome(Long, String)}(需求 1.1)。 */
     @Override
-    public StakingHomeVO getHome(Long userId) {
-        return homeAssembler.getHome(userId);
+    public StakingHomeVO getHome(Long userId, String localeName) {
+        return homeAssembler.getHome(userId, localeName);
     }
 
-    /** {@inheritDoc} 委托 {@link ProductQueryService#pageProducts(Integer, Integer, Boolean)}(需求 2.1)。 */
+    /** {@inheritDoc} 委托 {@link ProductQueryService#pageProducts(Integer, Integer, Boolean, String)}(需求 2.1)。 */
     @Override
-    public Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation) {
-        return productQueryService.pageProducts(page, size, isRecommendation);
+    public Page<StakingProductVO> pageProducts(Integer page, Integer size, Boolean isRecommendation, String localeName) {
+        return productQueryService.pageProducts(page, size, isRecommendation, localeName);
     }
 
     /** {@inheritDoc} 委托 {@link ProductQueryService#getDetail(Long)}(需求 3.1)。 */
@@ -93,10 +93,10 @@ public class StakingServiceImpl implements StakingService {
         return orderEngine.confirmStake(userId, req);
     }
 
-    /** {@inheritDoc} 委托 {@link OrderQueryService#pageOrders(Long, StakingOrderQuery)}(需求 6.1)。 */
+    /** {@inheritDoc} 委托 {@link OrderQueryService#pageOrders(Long, StakingOrderQuery, String)}(需求 6.1)。 */
     @Override
-    public Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query) {
-        return orderQueryService.pageOrders(userId, query);
+    public Page<StakingOrderListVO> pageOrders(Long userId, StakingOrderQuery query, String localeName) {
+        return orderQueryService.pageOrders(userId, query, localeName);
     }
 
     /** {@inheritDoc} 委托 {@link OrderQueryService#getOngoingDetail(Long, Long)}(需求 7.1)。 */

+ 0 - 318
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/AvailableBalanceConstraintPropertyTest.java

@@ -1,318 +0,0 @@
-//package com.bex.staking.engine;
-//
-//import static org.assertj.core.api.Assertions.assertThat;
-//import static org.assertj.core.api.Assertions.assertThatThrownBy;
-//import static org.mockito.ArgumentMatchers.any;
-//import static org.mockito.ArgumentMatchers.anyInt;
-//import static org.mockito.ArgumentMatchers.anyLong;
-//import static org.mockito.ArgumentMatchers.anyString;
-//import static org.mockito.ArgumentMatchers.eq;
-//import static org.mockito.Mockito.lenient;
-//import static org.mockito.Mockito.mock;
-//import static org.mockito.Mockito.never;
-//import static org.mockito.Mockito.times;
-//import static org.mockito.Mockito.verify;
-//import static org.mockito.Mockito.when;
-//
-//import com.bex.staking.constant.StakingErrorCode;
-//import com.bex.staking.entity.StakingOrder;
-//import com.bex.staking.entity.StakingProduct;
-//import com.bex.staking.enums.ProductType;
-//import com.bex.staking.enums.SubscriptionMode;
-//import com.bex.staking.exception.StakingException;
-//import com.bex.staking.grpc.AssetGrpcClient;
-//import com.bex.staking.grpc.MarketPriceClient;
-//import com.bex.staking.mapper.StakingOrderMapper;
-//import com.bex.staking.mapper.StakingProductMapper;
-//import com.bex.staking.model.dto.StakingOrderReq;
-//import java.math.BigDecimal;
-//import java.math.BigInteger;
-//import net.jqwik.api.Arbitraries;
-//import net.jqwik.api.Arbitrary;
-//import net.jqwik.api.Combinators;
-//import net.jqwik.api.Example;
-//import net.jqwik.api.ForAll;
-//import net.jqwik.api.Property;
-//import net.jqwik.api.Provide;
-//
-///**
-// * 可用余额约束的属性测试(PBT,jqwik)。
-// *
-// * <p>Feature: staking-service, Property 8: 可用余额约束。
-// *
-// * <p>验证:对任意用户可用余额 {@code B} 与下单数量 {@code x},在其他校验均通过的前提下,
-// * {@code OrderEngine.confirmStake(...)} 下单被拒绝(抛 {@link StakingErrorCode#INSUFFICIENT_BALANCE}=40004)
-// * <b>当且仅当</b> {@code x > B}。
-// *
-// * <h2>被测口径(驱动真实 OrderEngine)</h2>
-// *
-// * <p>余额校验逻辑位于 {@code OrderEngine.confirmStake(...)} 第 4 步({@code OrderEngine.java:147}):
-// *
-// * <pre>{@code
-// * BigDecimal availableBalance = assetGrpcClient.getAvailableBalance(userId, CURRENCY_BEX);
-// * if (stakingNum.compareTo(availableBalance) > 0) {
-// *     throw new StakingException(StakingErrorCode.INSUFFICIENT_BALANCE, "可用余额不足");
-// * }
-// * }</pre>
-// *
-// * <p>本测试不复刻该判定式,而是以 Mockito mock 全部依赖({@link StakingOrderMapper}、
-// * {@link StakingProductMapper}、{@link MarketPriceClient}、{@link AssetGrpcClient}),构造"其他校验均通过"
-// * 的场景,仅让可用余额 {@code B} 与下单数量 {@code x} 变化,断言真实 {@code confirmStake} 的余额约束不变量:
-// *
-// * <ul>
-// *   <li>{@code x > B}:抛 {@link StakingException} 且错误码为 40004,且未触达扣减 / 落库;</li>
-// *   <li>{@code x ≤ B}(含 {@code x == B} 边界与等值不同标度形式):不因余额被拒,继续到扣减 + 落库,
-// *       mock 扣减成功与 insert 成功,断言返回 {@code true}。</li>
-// * </ul>
-// *
-// * <h2>"其他校验均通过"的场景构造</h2>
-// *
-// * <ul>
-// *   <li>产品为 {@link SubscriptionMode#UNLIMITED} 模式、宽松区间({@code minAmount=100}、{@code maxAmount} 极大),
-// *       使 {@link SubscriptionValidator} 额度校验通过;</li>
-// *   <li>{@code stakingNum ≥ 100}(过最小下限)且落在区间内;</li>
-// *   <li>{@code findExistingOrder} 查询返回 {@code null}(无幂等命中);产品存在;</li>
-// *   <li>{@code getBexUsdtPrice} 返回正价格;{@code increaseRaisedAmount} 返回 1(募集通过);
-// *       {@code deductAvailableBalance} 不抛异常(扣减成功);{@code insert} 返回 1(落库成功)。</li>
-// * </ul>
-// *
-// * <p>说明:{@link com.bex.staking.annotation.Idempotent} 切面在单元测试中不生效(直接 {@code new} 构造
-// * {@code OrderEngine} 调用,AOP 不介入),故 {@code confirmStake} 的幂等查重与校验逻辑会直接执行。
-// *
-// * <p>每次属性迭代在方法体内构造独立的 mock 与 stub,保证迭代之间互不干扰(调用计数不累加)。
-// *
-// * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定大小关系(忽略标度差异,
-// * 与生产代码内部判定方式保持一致)。
-// *
-// * <p>Validates: Requirements 5.6
-// */
-//class AvailableBalanceConstraintPropertyTest {
-//
-//    /** 测试用户 UID。 */
-//    private static final long USER_ID = 1001L;
-//
-//    /** 测试产品 ID。 */
-//    private static final long PRODUCT_ID = 9001L;
-//
-//    /** 计价币种,固定为 BEX。 */
-//    private static final String CURRENCY_BEX = "BEX";
-//
-//    /** 入场价格(BEX/USDT),任意正值即可,不影响余额校验。 */
-//    private static final BigDecimal ENTRY_PRICE = new BigDecimal("0.5");
-//
-//    // ==================== 被测装配:构造一台依赖全部为 mock 的 OrderEngine ====================
-//
-//    /** 一次迭代所用的全部 mock 依赖与待测引擎。 */
-//    private record Fixture(
-//            OrderEngine engine,
-//            StakingOrderMapper orderMapper,
-//            StakingProductMapper productMapper,
-//            MarketPriceClient priceClient,
-//            AssetGrpcClient assetClient) {}
-//
-//    /**
-//     * 构造"除余额外其他校验均通过"的被测装配。
-//     *
-//     * <p>每次调用创建全新 mock,保证属性迭代之间相互独立({@code verify} 调用计数不跨迭代累加)。
-//     * 产品采用 UNLIMITED 模式 + 宽松区间,使额度校验对任意 {@code stakingNum ≥ 100} 均通过;
-//     * 募集累加、查价、扣减、落库均 stub 为成功路径,仅可用余额由参数 {@code balance} 决定。
-//     *
-//     * @param balance mock 的可用余额 B
-//     * @return 装配好的 {@link Fixture}
-//     */
-//    private Fixture newFixture(BigDecimal balance) {
-//        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-//        StakingProductMapper productMapper = mock(StakingProductMapper.class);
-//        MarketPriceClient priceClient = mock(MarketPriceClient.class);
-//        AssetGrpcClient assetClient = mock(AssetGrpcClient.class);
-//
-//        // 幂等查重:无既有订单
-//        lenient().when(orderMapper.selectOne(any())).thenReturn(null);
-//        // 产品存在且为宽松 UNLIMITED 产品
-//        lenient().when(productMapper.selectById(PRODUCT_ID)).thenReturn(looseProduct());
-//        // 可用余额 = 入参 B
-//        when(assetClient.getAvailableBalance(USER_ID, CURRENCY_BEX)).thenReturn(balance);
-//        // 查价正常
-//        lenient().when(priceClient.getBexUsdtPrice()).thenReturn(ENTRY_PRICE);
-//        // 募集上限累加成功
-//        lenient()
-//                .when(productMapper.increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt()))
-//                .thenReturn(1);
-//        // 订单落库成功
-//        lenient().when(orderMapper.insert(any(StakingOrder.class))).thenReturn(1);
-//
-//        // 依赖顺序与 OrderEngine 字段声明一致:orderMapper、productMapper、priceClient、assetClient
-//        OrderEngine engine = new OrderEngine(orderMapper, productMapper, priceClient, assetClient);
-//        return new Fixture(engine, orderMapper, productMapper, priceClient, assetClient);
-//    }
-//
-//    /**
-//     * 构造一个宽松的无限制(UNLIMITED)模式质押产品:{@code minAmount=100}、{@code maxAmount} 极大、
-//     * 募集额度充足、版本号为 0,使额度校验与募集校验对任意 {@code stakingNum ≥ 100} 均通过。
-//     *
-//     * @return 宽松产品实体
-//     */
-//    private StakingProduct looseProduct() {
-//        return StakingProduct.builder()
-//                .id(PRODUCT_ID)
-//                .productType(ProductType.STAKING.getValue())
-//                .subMode(SubscriptionMode.UNLIMITED.getValue())
-//                .minAmount(new BigDecimal("100"))
-//                .maxAmount(new BigDecimal("1E+30"))
-//                .totalCapacity(new BigDecimal("1E+40"))
-//                .raisedAmount(BigDecimal.ZERO)
-//                .lockDays(90)
-//                .lockStartTime(0L)
-//                .version(0)
-//                .deleted(0)
-//                .build();
-//    }
-//
-//    /**
-//     * 构造下单请求。
-//     *
-//     * @param stakingNum 下单数量 x
-//     * @return 下单请求 DTO
-//     */
-//    private StakingOrderReq reqOf(BigDecimal stakingNum) {
-//        StakingOrderReq req = new StakingOrderReq();
-//        req.setProductId(PRODUCT_ID);
-//        req.setStakingNum(stakingNum);
-//        req.setIdempotentKey("idem-balance-" + stakingNum.toPlainString());
-//        return req;
-//    }
-//
-//    // ==================== 生成器:金额 ≥ 100(过最小下限) ====================
-//
-//    /**
-//     * 任意"过最小下限"的高精度金额生成器:值域 {@code [100, 100 + 10^15]},随机 scale ∈ [0, 8],
-//     * 覆盖整数、长尾小数与不同数量级,用作可用余额 {@code B} 与下单数量 {@code x}。
-//     *
-//     * <p>下限取 100 是为了保证 {@link SubscriptionValidator} 的最小下限(100 BEX)与区间校验恒通过,
-//     * 从而将"是否被拒"完全归因于余额约束。
-//     */
-//    @Provide
-//    Arbitrary<BigDecimal> amountAtLeastMin() {
-//        return Combinators.combine(
-//                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(15)),
-//                        Arbitraries.integers().between(0, 8))
-//                .as((unscaled, scale) -> new BigDecimal(unscaled, scale).add(new BigDecimal("100")));
-//    }
-//
-//    /** 严格正的高精度增量生成器:unscaledValue ∈ [1, 10^15],scale ∈ [0, 8],用于构造 x<B 或 x>B。 */
-//    @Provide
-//    Arbitrary<BigDecimal> positiveDelta() {
-//        return Combinators.combine(
-//                        Arbitraries.bigIntegers().between(BigInteger.ONE, BigInteger.TEN.pow(15)),
-//                        Arbitraries.integers().between(0, 8))
-//                .as(BigDecimal::new);
-//    }
-//
-//    // ==================== Property 8:可用余额约束(驱动真实 OrderEngine.confirmStake) ====================
-//
-//    // Feature: staking-service, Property 8: 可用余额约束
-//    // x < B:不因余额被拒 —— 继续到扣减 + 落库,confirmStake 返回 true,且扣减/落库各发生一次
-//    // 构造 B = x + δ(δ>0),则恒有 x < B
-//    // Validates: Requirements 5.6
-//    @Property(tries = 100)
-//    void amountBelowBalanceIsAccepted(
-//            @ForAll("amountAtLeastMin") BigDecimal x, @ForAll("positiveDelta") BigDecimal delta) {
-//        BigDecimal balance = x.add(delta); // B = x + δ > x,恒有 x < B
-//        Fixture f = newFixture(balance);
-//
-//        Boolean result = f.engine().confirmStake(USER_ID, reqOf(x));
-//
-//        assertThat(result)
-//                .as("x < B 应下单成功(x=%s, B=%s)", x.toPlainString(), balance.toPlainString())
-//                .isTrue();
-//        // 走到扣减与落库:余额未拒绝
-//        verify(f.assetClient(), times(1))
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//        verify(f.orderMapper(), times(1)).insert(any(StakingOrder.class));
-//    }
-//
-//    // Feature: staking-service, Property 8: 可用余额约束
-//    // x == B:边界应通过(compareTo == 0,非 > 0),confirmStake 返回 true
-//    // Validates: Requirements 5.6
-//    @Property(tries = 100)
-//    void amountEqualToBalanceIsAccepted(@ForAll("amountAtLeastMin") BigDecimal x) {
-//        Fixture f = newFixture(x); // B == x
-//
-//        Boolean result = f.engine().confirmStake(USER_ID, reqOf(x));
-//
-//        assertThat(result)
-//                .as("x == B(边界)应下单成功(x=B=%s)", x.toPlainString())
-//                .isTrue();
-//        verify(f.assetClient(), times(1))
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//        verify(f.orderMapper(), times(1)).insert(any(StakingOrder.class));
-//    }
-//
-//    // Feature: staking-service, Property 8: 可用余额约束
-//    // x == B(等值不同标度,如 B=100 → x=100.00):compareTo 标度无关,仍应通过
-//    // Validates: Requirements 5.6
-//    @Property(tries = 100)
-//    void amountEqualToBalanceDifferentScaleIsAccepted(
-//            @ForAll("amountAtLeastMin") BigDecimal balance, @ForAll int extraScale) {
-//        int safeExtraScale = Math.floorMod(extraScale, 9); // [0, 8]
-//        BigDecimal x = balance.setScale(balance.scale() + safeExtraScale); // 数值相等、标度不同
-//        Fixture f = newFixture(balance);
-//
-//        Boolean result = f.engine().confirmStake(USER_ID, reqOf(x));
-//
-//        assertThat(result)
-//                .as("x == B(等值不同标度)应下单成功(x=%s, B=%s)", x.toPlainString(), balance.toPlainString())
-//                .isTrue();
-//        verify(f.orderMapper(), times(1)).insert(any(StakingOrder.class));
-//    }
-//
-//    // Feature: staking-service, Property 8: 可用余额约束
-//    // x > B:必拒绝 —— 抛 StakingException(40004),且未触达扣减与落库
-//    // 构造 x = B + δ(δ>0),则恒有 x > B
-//    // Validates: Requirements 5.6
-//    @Property(tries = 100)
-//    void amountAboveBalanceIsRejected(
-//            @ForAll("amountAtLeastMin") BigDecimal balance, @ForAll("positiveDelta") BigDecimal delta) {
-//        BigDecimal x = balance.add(delta); // x = B + δ > B,恒有 x > B
-//        Fixture f = newFixture(balance);
-//
-//        assertThatThrownBy(() -> f.engine().confirmStake(USER_ID, reqOf(x)))
-//                .as("x > B 应被拒绝并抛 40004(x=%s, B=%s)", x.toPlainString(), balance.toPlainString())
-//                .isInstanceOf(StakingException.class)
-//                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-//                        .isEqualTo(StakingErrorCode.INSUFFICIENT_BALANCE));
-//
-//        // 余额校验位于扣减与落库之前:拒绝后两者均不应发生
-//        verify(f.assetClient(), never())
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//        verify(f.orderMapper(), never()).insert(any(StakingOrder.class));
-//    }
-//
-//    // ==================== 锚定示例(边界与典型场景) ====================
-//
-//    // Feature: staking-service, Property 8: 可用余额约束
-//    // 边界锚定:x == B 恰好通过;x 比 B 多出最小精度增量即被拒(40004)
-//    // Validates: Requirements 5.6
-//    @Example
-//    void boundaryAnchorAtEqualBalance() {
-//        BigDecimal balance = new BigDecimal("100");
-//
-//        // x == B:通过
-//        Fixture f1 = newFixture(balance);
-//        assertThat(f1.engine().confirmStake(USER_ID, reqOf(new BigDecimal("100"))))
-//                .isTrue();
-//
-//        // x == B(不同标度等值):通过
-//        Fixture f2 = newFixture(balance);
-//        assertThat(f2.engine().confirmStake(USER_ID, reqOf(new BigDecimal("100.000000"))))
-//                .isTrue();
-//
-//        // x = B + 极小量:拒绝(40004)
-//        Fixture f3 = newFixture(balance);
-//        assertThatThrownBy(() ->
-//                        f3.engine().confirmStake(USER_ID, reqOf(new BigDecimal("100.000000000000000001"))))
-//                .isInstanceOf(StakingException.class)
-//                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-//                        .isEqualTo(StakingErrorCode.INSUFFICIENT_BALANCE));
-//    }
-//}

+ 0 - 218
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ClearingIdempotencyPropertyTest.java

@@ -1,218 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-
-import com.bex.staking.entity.StakingAssetEvent;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.ProductType;
-import com.bex.staking.grpc.MarketPriceClient;
-import com.bex.staking.mapper.StakingAssetEventMapper;
-import com.bex.staking.mapper.StakingOrderMapper;
-import com.bex.staking.mapper.StakingProductMapper;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link ClearingEngine#clearOne} 到期清算幂等性属性测试(Property 17)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 17「到期清算幂等性」。被测对象是
- * {@link ClearingEngine#clearOne(StakingOrder, StakingProduct, BigDecimal)} 的幂等跳过分支:当订单已处于
- * {@code SETTLED} 或 {@code DEFLATED} 终态时,再次清算必须不改变订单状态、返还本金、销毁数量与账本,
- * 且不产生任何额外的资产账变副作用(需求 11.10)。
- *
- * <p>测试以 Mockito 模拟 {@link ClearingEngine} 的四个依赖({@link StakingOrderMapper}、
- * {@link StakingProductMapper}、{@link StakingAssetEventMapper}、{@link MarketPriceClient}),在
- * {@code @Property} 方法内直接 {@code new} 出被测引擎并调用 {@link ClearingEngine#clearOne}。由于
- * {@code clearOne} 在终态分支会立即返回 {@link ClearingOutcome#skipped},不经 Spring 事务代理即可验证其纯逻辑,
- * 故 {@code @Lazy self} 字段无需注入(仅 {@link ClearingEngine#clear()} 批处理才使用)。
- *
- * <p>对任意已处于终态({@code SETTLED} / {@code DEFLATED})、携带任意高精度金额字段的订单,断言:
- *
- * <ul>
- *   <li>返回结果 {@link ClearingOutcome#cleared()} 为 {@code false}(被幂等跳过,未执行清算);
- *   <li>返回的 {@link ClearingOutcome#targetStatus()} 等于订单原终态(状态不变);
- *   <li>{@code orderMapper.updateById} 从未被调用(不改订单状态、返还本金、销毁数量与账本);
- *   <li>{@code assetEventMapper.insert} 从未被调用(无额外资产账变事件副作用);
- *   <li>{@code productMapper} 与 {@code marketPriceClient} 完全无交互(终态分支不读取产品与价格);
- *   <li>跳过结果的返还本金、销毁数量、利息、事件条数均为零(无任何账本变更)。
- * </ul>
- *
- * <p>金额一律以 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定相等(忽略标度差异)。
- */
-class ClearingIdempotencyPropertyTest {
-
-    // ==================== 场景与生成器 ====================
-
-    /**
-     * 已处于终态的待清算订单场景:订单本身 + 用于调用的到期价格。
-     *
-     * @param order 已处于 SETTLED / DEFLATED 终态的订单(携带任意金额字段与产品类型)
-     * @param expirePrice 调用 {@code clearOne} 时传入的到期价格 Pt(任意正值,幂等分支不应使用它)
-     */
-    record TerminalOrderScenario(StakingOrder order, BigDecimal expirePrice) {}
-
-    /**
-     * 任意非负高精度金额生成器:随机 unscaledValue({@code [0, 10^18]})与随机 scale({@code [0, 18]})组合,
-     * 覆盖整数、长尾小数(对齐数据库 {@code DECIMAL(36,18)} 精度)与不同数量级。
-     */
-    private Arbitrary<BigDecimal> nonNegativeAmount() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(18)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /** 任意正高精度金额生成器(用于到期价格 Pt,恒大于 0)。 */
-    private Arbitrary<BigDecimal> positiveAmount() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ONE, BigInteger.TEN.pow(12)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /**
-     * 终态订单场景生成器。
-     *
-     * <p>随机覆盖两种终态({@code SETTLED} / {@code DEFLATED})与两种产品类型({@code STAKING} /
-     * {@code PRIVATE_SALE}),并为订单的全部金额字段(本金、返还本金、销毁数量、累计 / 待领取 / 已领取收益、
-     * 初始价值、入场价格、到期价格)注入任意高精度值,使订单内部账本处于任意状态。版本号与已结算天数也随机取值。
-     */
-    @Provide
-    Arbitrary<TerminalOrderScenario> terminalOrders() {
-        Arbitrary<OrderStatus> terminalStatus =
-                Arbitraries.of(OrderStatus.SETTLED, OrderStatus.DEFLATED);
-        Arbitrary<ProductType> productType = Arbitraries.of(ProductType.STAKING, ProductType.PRIVATE_SALE);
-
-        return Combinators.combine(
-                        terminalStatus,
-                        productType,
-                        nonNegativeAmount(),
-                        nonNegativeAmount(),
-                        nonNegativeAmount(),
-                        nonNegativeAmount(),
-                        positiveAmount(),
-                        Arbitraries.longs().between(1L, 9_999_999L))
-                .as((status, type, stakeAmount, returnPrincipal, burnAmount, cumulativeProfit, expirePrice, userId) -> {
-                    StakingOrder order = StakingOrder.builder()
-                            .orderNo("STK-PBT-17-" + userId)
-                            .userId(userId)
-                            .productId(9000L + (userId % 100))
-                            .productType(type.getValue())
-                            .status(status.getValue())
-                            .stakeAmount(stakeAmount)
-                            .entryPrice(expirePrice)
-                            .initialValueUsdt(stakeAmount.multiply(expirePrice))
-                            .cumulativeProfit(cumulativeProfit)
-                            .pendingProfit(cumulativeProfit)
-                            .claimedProfit(BigDecimal.ZERO)
-                            .settledDays(0)
-                            // 已落库的清算结果字段:再次清算不得改变它们
-                            .returnPrincipal(returnPrincipal)
-                            .burnAmount(burnAmount)
-                            .expirePrice(expirePrice)
-                            .version(1)
-                            .build();
-                    return new TerminalOrderScenario(order, expirePrice);
-                });
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 17: 到期清算幂等性
-    // 对任意已处于 SETTLED / DEFLATED 终态的订单再次清算,验证:被幂等跳过、状态不变、无订单更新、无资产账变事件副作用。
-    // Validates: Requirements 11.10
-    @Property(tries = 100)
-    void clearingTerminalOrderIsIdempotentAndHasNoSideEffects(
-            @ForAll("terminalOrders") TerminalOrderScenario scenario) {
-        StakingOrder order = scenario.order();
-        OrderStatus originalStatus = OrderStatus.fromValue(order.getStatus());
-
-        // 记录调用前的账本字段快照(用于断言"再次清算不改变订单字段")
-        BigDecimal originalReturnPrincipal = order.getReturnPrincipal();
-        BigDecimal originalBurnAmount = order.getBurnAmount();
-        BigDecimal originalCumulativeProfit = order.getCumulativeProfit();
-        BigDecimal originalExpirePrice = order.getExpirePrice();
-        Integer originalVersion = order.getVersion();
-
-        // 在属性方法内构造 mock 依赖与被测引擎(self 无需注入,clearOne 终态分支不经事务代理)
-        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-        StakingProductMapper productMapper = mock(StakingProductMapper.class);
-        StakingAssetEventMapper assetEventMapper = mock(StakingAssetEventMapper.class);
-        MarketPriceClient marketPriceClient = mock(MarketPriceClient.class);
-        ClearingEngine clearingEngine =
-                new ClearingEngine(orderMapper, productMapper, assetEventMapper, marketPriceClient);
-
-        // 被测调用:对已处于终态的订单再次执行清算(产品参数对终态分支无意义,传 null 验证不会被解引用)
-        ClearingOutcome outcome = clearingEngine.clearOne(order, null, scenario.expirePrice());
-
-        // 1) 被幂等跳过:未执行清算
-        assertThat(outcome.cleared())
-                .as("已处于终态的订单再次清算应被幂等跳过(cleared=false)")
-                .isFalse();
-
-        // 2) 目标状态等于订单原终态(状态不变)
-        assertThat(outcome.targetStatus())
-                .as("跳过结果的目标状态应等于订单原终态")
-                .isEqualTo(originalStatus);
-        assertThat(outcome.orderNo())
-                .as("跳过结果应回传原订单编号")
-                .isEqualTo(order.getOrderNo());
-
-        // 3) 跳过结果无任何账本变更:返还本金 / 销毁数量 / 利息 / 事件条数均为零
-        assertThat(outcome.returnPrincipal().compareTo(BigDecimal.ZERO))
-                .as("跳过结果返还本金应为 0")
-                .isZero();
-        assertThat(outcome.burnAmount().compareTo(BigDecimal.ZERO))
-                .as("跳过结果销毁数量应为 0")
-                .isZero();
-        assertThat(outcome.interest().compareTo(BigDecimal.ZERO))
-                .as("跳过结果利息应为 0")
-                .isZero();
-        assertThat(outcome.eventsCreated())
-                .as("跳过结果写入事件条数应为 0")
-                .isZero();
-
-        // 4) 不更新订单:不改状态、返还本金、销毁数量与账本(需求 11.10)
-        // 注:BaseMapper 存在 updateById(T) 与 updateById(Collection) 重载,需用带类型的匹配器消除歧义
-        verify(orderMapper, never()).updateById(any(StakingOrder.class));
-
-        // 5) 无额外资产账变事件副作用
-        verify(assetEventMapper, never()).insert(any(StakingAssetEvent.class));
-
-        // 6) 终态分支不读取产品与到期价格:产品 Mapper 与价格客户端完全无交互
-        verifyNoInteractions(productMapper);
-        verifyNoInteractions(marketPriceClient);
-
-        // 7) 订单实体自身的关键字段保持不变(再次清算未就地修改任何账本字段)
-        assertThat(order.getStatus())
-                .as("订单状态不应被改变")
-                .isEqualTo(originalStatus.getValue());
-        assertThat(order.getReturnPrincipal().compareTo(originalReturnPrincipal))
-                .as("订单返还本金不应被改变")
-                .isZero();
-        assertThat(order.getBurnAmount().compareTo(originalBurnAmount))
-                .as("订单销毁数量不应被改变")
-                .isZero();
-        assertThat(order.getCumulativeProfit().compareTo(originalCumulativeProfit))
-                .as("订单累计收益不应被改变")
-                .isZero();
-        assertThat(order.getExpirePrice().compareTo(originalExpirePrice))
-                .as("订单到期价格不应被改变")
-                .isZero();
-        assertThat(order.getVersion())
-                .as("订单乐观锁版本号不应被改变")
-                .isEqualTo(originalVersion);
-    }
-}

+ 0 - 287
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DailySettlementLedgerPropertyTest.java

@@ -1,287 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.OrderStatus;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link SettlementEngine#planSettlement} 每日结算账本属性测试(Property 15)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 15「每日结算幂等、账本守恒与不超发」。被测对象是无副作用的
- * 纯函数 {@link SettlementEngine#planSettlement(StakingOrder, BigDecimal, int)} 与其结果值对象
- * {@link SettlementPlan}。测试以纯函数模拟"一个订单从 {@code settledDays=0} 起、按业务日期序列连续结算"的过程,
- * 完全避免数据库与 Spring 依赖。
- *
- * <p>对任意持仓订单与任意天数序列(其中业务日期可能重复、序列长度可能超过锁仓天数)验证:
- *
- * <ul>
- *   <li><b>(a) 幂等</b>:同一订单在同一业务日期({@code YYYYMMDD})重复触发结算,只产生一条收益流水且账本只累加
- *       一次。纯函数层面建模为:调用方对"当日已结算"的业务日期跳过(不调用 {@code planSettlement}),账本恒等。</li>
- *   <li><b>(b) 账本守恒</b>:任意时刻订单 {@code cumulativeProfit} 等于其全部收益流水金额之和;每次派发使
- *       {@code pendingProfit} 与 {@code cumulativeProfit} 同步增加<strong>相同</strong>金额 {@code profitAmount}。</li>
- *   <li><b>(c) 不超发</b>:无论触发多少次,{@code settledDays} 永不超过锁仓天数 {@code lockDays};达到 {@code lockDays}
- *       后不再派发({@code shouldDispatch=false})且账本不再变化。</li>
- * </ul>
- *
- * <p>金额一律以 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定相等(忽略标度差异,
- * 如 {@code 1.0} 与 {@code 1.00} 视为相等),与生产代码内部判定方式保持一致。
- */
-class DailySettlementLedgerPropertyTest {
-
-    // ==================== 场景与生成器 ====================
-
-    /**
-     * 单次结算触发:携带业务日期与(若需派发时使用的)当日收益。
-     *
-     * @param bizDate 业务日期标识({@code YYYYMMDD} 的抽象,序列中可能重复以模拟重复触发)
-     * @param dailyProfit 当日收益(每日可不同;仅在该日期首次派发时生效)
-     */
-    record DayTrigger(int bizDate, BigDecimal dailyProfit) {}
-
-    /**
-     * 多日结算场景:锁仓天数与一串结算触发序列。
-     *
-     * <p>业务日期取自 {@code [1, dateUpperBound]} 的小取值域,使序列中大概率出现重复触发(验证幂等 a);
-     * 序列长度可达取值域的数倍,且不同日期数量可能超过 {@code lockDays}(验证不超发 c)。
-     *
-     * @param lockDays 产品锁仓天数(派发上限,含 0 边界)
-     * @param triggers 结算触发序列
-     */
-    record DailySettlementScenario(int lockDays, List<DayTrigger> triggers) {}
-
-    /**
-     * 任意非负高精度当日收益生成器:随机 unscaledValue({@code [0, 10^15]})与随机 scale({@code [0, 18]})组合,
-     * 覆盖整数、长尾小数(对齐数据库 {@code DECIMAL(36,18)} 精度)与不同数量级。
-     */
-    private Arbitrary<BigDecimal> dailyProfit() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(15)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /**
-     * 多日结算场景生成器。
-     *
-     * <p>先生成 {@code lockDays ∈ [0, 60]} 与 {@code extra ∈ [0, 20]},由 {@code dateUpperBound = max(1, lockDays + extra)}
-     * 控制业务日期取值域:可能多于 {@code lockDays}(覆盖锁仓到期后的多余触发),也保证日期可重复(覆盖同日重复触发)。
-     * 触发序列长度可达 {@code 2 × dateUpperBound + 5},使重复与超发两类输入都被充分覆盖。
-     */
-    @Provide
-    Arbitrary<DailySettlementScenario> scenarios() {
-        return Arbitraries.integers().between(0, 60).flatMap(lockDays -> Arbitraries.integers()
-                .between(0, 20)
-                .flatMap(extra -> {
-                    int dateUpperBound = Math.max(1, lockDays + extra);
-                    Arbitrary<DayTrigger> triggerArb = Combinators.combine(
-                                    Arbitraries.integers().between(1, dateUpperBound), dailyProfit())
-                            .as(DayTrigger::new);
-                    return triggerArb
-                            .list()
-                            .ofMinSize(1)
-                            .ofMaxSize(2 * dateUpperBound + 5)
-                            .map(triggers -> new DailySettlementScenario(lockDays, triggers));
-                }));
-    }
-
-    /** 构造一个持仓中、账本清零、已结算 0 天的质押订单(模拟起始状态)。 */
-    private StakingOrder freshHoldingOrder() {
-        return StakingOrder.builder()
-                .orderNo("STK-PBT-15")
-                .userId(1001L)
-                .productId(9001L)
-                .productType("STAKING")
-                .status(OrderStatus.HOLDING.getValue())
-                .stakeAmount(new BigDecimal("1000"))
-                .cumulativeProfit(BigDecimal.ZERO)
-                .pendingProfit(BigDecimal.ZERO)
-                .settledDays(0)
-                .build();
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 15: 每日结算幂等、账本守恒与不超发
-    // 模拟一个订单按业务日期序列(含重复日期、可能超过锁仓天数)连续结算,验证 (a) 幂等、(b) 账本守恒、(c) 不超发。
-    // Validates: Requirements 9.3, 9.7, 9.8
-    @Property(tries = 100)
-    void dailySettlementSequenceIsIdempotentConservesLedgerAndNeverOverflows(
-            @ForAll("scenarios") DailySettlementScenario scenario) {
-        int lockDays = scenario.lockDays();
-        StakingOrder order = freshHoldingOrder();
-
-        // 已结算业务日期集合:建模数据库 uk_order_date / countByOrderAndDate 的幂等屏障
-        Set<Integer> settledDates = new HashSet<>();
-        // 累计派发的收益流水(每条 = 某业务日期首次派发的 profitAmount)
-        List<BigDecimal> journals = new ArrayList<>();
-        // 收益流水金额之和(账本守恒的预期值)
-        BigDecimal journalSum = BigDecimal.ZERO;
-
-        for (DayTrigger trigger : scenario.triggers()) {
-            BigDecimal oldCumulative = order.getCumulativeProfit();
-            BigDecimal oldPending = order.getPendingProfit();
-            int oldSettledDays = order.getSettledDays();
-
-            // (a) 幂等:当日已结算(业务日期已有流水)则调用方跳过,根本不调用 planSettlement,账本恒等
-            if (settledDates.contains(trigger.bizDate())) {
-                // 账本三项保持不变,且不新增流水
-                assertThat(order.getCumulativeProfit().compareTo(oldCumulative))
-                        .as("重复触发同一业务日期不应改变累计收益")
-                        .isZero();
-                assertThat(order.getPendingProfit().compareTo(oldPending))
-                        .as("重复触发同一业务日期不应改变待领取收益")
-                        .isZero();
-                assertThat(order.getSettledDays())
-                        .as("重复触发同一业务日期不应改变已结算天数")
-                        .isEqualTo(oldSettledDays);
-                continue;
-            }
-
-            SettlementPlan plan = SettlementEngine.planSettlement(order, trigger.dailyProfit(), lockDays);
-
-            // (c) 不超发:任何计划下,已结算天数都不得超过锁仓天数,且单调不减
-            assertThat(plan.newSettledDays())
-                    .as("已结算天数永不超过锁仓天数 lockDays")
-                    .isLessThanOrEqualTo(lockDays);
-            assertThat(plan.newSettledDays())
-                    .as("已结算天数单调不减")
-                    .isGreaterThanOrEqualTo(oldSettledDays);
-
-            if (plan.shouldDispatch()) {
-                BigDecimal cumulativeDelta = plan.newCumulativeProfit().subtract(oldCumulative);
-                BigDecimal pendingDelta = plan.newPendingProfit().subtract(oldPending);
-
-                // (b) 账本守恒:累计增量 == 待领取增量 == 本次派发金额
-                assertThat(cumulativeDelta.compareTo(plan.profitAmount()))
-                        .as("累计收益增量应等于本次派发金额")
-                        .isZero();
-                assertThat(pendingDelta.compareTo(plan.profitAmount()))
-                        .as("待领取收益增量应等于本次派发金额(与累计同步增加相同金额)")
-                        .isZero();
-                // (c) 派发使已结算天数恰好 +1
-                assertThat(plan.newSettledDays())
-                        .as("派发使已结算天数 +1")
-                        .isEqualTo(oldSettledDays + 1);
-
-                journals.add(plan.profitAmount());
-                journalSum = journalSum.add(plan.profitAmount());
-                settledDates.add(trigger.bizDate());
-                // 应用账本累加(回填到订单,进入下一日)
-                order.setCumulativeProfit(plan.newCumulativeProfit());
-                order.setPendingProfit(plan.newPendingProfit());
-                order.setSettledDays(plan.newSettledDays());
-            } else {
-                // (c) 锁仓期满:不派发、收益为 0、账本三项保持不变(达到 lockDays 后账本不再变化)
-                assertThat(plan.profitAmount().compareTo(BigDecimal.ZERO))
-                        .as("锁仓期满不派发时收益为 0")
-                        .isZero();
-                assertThat(plan.newCumulativeProfit().compareTo(oldCumulative))
-                        .as("锁仓期满不应改变累计收益")
-                        .isZero();
-                assertThat(plan.newPendingProfit().compareTo(oldPending))
-                        .as("锁仓期满不应改变待领取收益")
-                        .isZero();
-                assertThat(plan.newSettledDays())
-                        .as("锁仓期满不应改变已结算天数")
-                        .isEqualTo(oldSettledDays);
-            }
-
-            // (b) 每步不变量:累计收益恒等于全部收益流水金额之和;待领取(从未领取)亦等于该和
-            assertThat(order.getCumulativeProfit().compareTo(journalSum))
-                    .as("任意时刻累计收益 = 全部收益流水之和")
-                    .isZero();
-            assertThat(order.getPendingProfit().compareTo(journalSum))
-                    .as("未领取场景下待领取收益 = 全部收益流水之和")
-                    .isZero();
-        }
-
-        // 终态不变量:流水条数 == 已结算天数 <= 锁仓天数;每个已派发业务日期恰好一条流水
-        assertThat(order.getSettledDays())
-                .as("已结算天数应等于派发的流水条数")
-                .isEqualTo(journals.size());
-        assertThat(order.getSettledDays())
-                .as("终态已结算天数不超过锁仓天数")
-                .isLessThanOrEqualTo(lockDays);
-        assertThat(journals.size())
-                .as("流水条数应等于派发过收益的不同业务日期数(每日至多一条)")
-                .isEqualTo(settledDates.size());
-    }
-
-    // Feature: staking-service, Property 15: 每日结算幂等、账本守恒与不超发
-    // 聚焦 (a)(c):订单已达锁仓天数(matured)后,重复触发结算任意多次,账本三项恒不变、永不派发、不超发。
-    // Validates: Requirements 9.3, 9.7, 9.8
-    @Property(tries = 100)
-    void repeatedTriggerAfterMaturityLeavesLedgerInvariant(
-            @ForAll("maturedLedgers") BigDecimal cumulative,
-            @ForAll("maturedLedgers") BigDecimal pending,
-            @ForAll int lockDaysSeed,
-            @ForAll("repeatTriggers") List<BigDecimal> extraDailyProfits) {
-        // 锁仓天数取 [0, 120]
-        int lockDays = Math.floorMod(lockDaysSeed, 121);
-        // 构造一个已到期(settledDays == lockDays)的订单
-        StakingOrder matured = StakingOrder.builder()
-                .orderNo("STK-PBT-15-MATURED")
-                .userId(1002L)
-                .productId(9002L)
-                .productType("STAKING")
-                .status(OrderStatus.HOLDING.getValue())
-                .stakeAmount(new BigDecimal("1000"))
-                .cumulativeProfit(cumulative)
-                .pendingProfit(pending)
-                .settledDays(lockDays)
-                .build();
-
-        for (BigDecimal daily : extraDailyProfits) {
-            SettlementPlan plan = SettlementEngine.planSettlement(matured, daily, lockDays);
-
-            assertThat(plan.shouldDispatch())
-                    .as("已到期订单不应再派发收益")
-                    .isFalse();
-            assertThat(plan.profitAmount().compareTo(BigDecimal.ZERO))
-                    .as("不派发时收益为 0")
-                    .isZero();
-            assertThat(plan.newCumulativeProfit().compareTo(cumulative))
-                    .as("重复触发不改变累计收益(幂等)")
-                    .isZero();
-            assertThat(plan.newPendingProfit().compareTo(pending))
-                    .as("重复触发不改变待领取收益(幂等)")
-                    .isZero();
-            assertThat(plan.newSettledDays())
-                    .as("已结算天数恒等于锁仓天数,不超发")
-                    .isEqualTo(lockDays);
-
-            // 回填后再次触发,验证多次重复仍恒等
-            matured.setCumulativeProfit(plan.newCumulativeProfit());
-            matured.setPendingProfit(plan.newPendingProfit());
-            matured.setSettledDays(plan.newSettledDays());
-        }
-    }
-
-    /** 已到期订单账本生成器:任意非负高精度金额(覆盖 0 与长尾小数)。 */
-    @Provide
-    Arbitrary<BigDecimal> maturedLedgers() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(15)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /** 到期后重复触发的当日收益序列(长度 1~30,金额任意非负)。 */
-    @Provide
-    Arbitrary<List<BigDecimal>> repeatTriggers() {
-        return maturedLedgers().list().ofMinSize(1).ofMaxSize(30);
-    }
-}

+ 0 - 151
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DeflationCalculatorTest.java

@@ -1,151 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.RedeemType;
-import java.math.BigDecimal;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link DeflationCalculator} 通缩赎回计算器单元测试(示例 / 边界)。
- *
- * <p>用固定输入断言通缩赎回数学模型的关键场景:触发/未触发、{@code R = min(P, V0/Pt)} 的两个分支、
- * 销毁数量恒非负、阈值边界(恰等于 θ)、常规赎回机制始终走 SETTLED 等。金额使用
- * {@link BigDecimal#compareTo} 断言数值相等(忽略 scale 差异)。
- *
- * <p>Validates: Requirements 11.3, 11.4, 11.6, 13.4
- */
-@DisplayName("DeflationCalculator 通缩赎回计算")
-class DeflationCalculatorTest {
-
-    private static final BigDecimal THRESHOLD = StakingConstants.DEFAULT_DEFLATION_THRESHOLD; // 0.80
-
-    @Nested
-    @DisplayName("通缩触发(DEFLATIONARY 且占比 ≥ θ)")
-    class Deflated {
-
-        @Test
-        @DisplayName("Pt 上涨使 V0/Pt < P 时,返还 V0/Pt 并销毁差额")
-        void returnsValueByExpirePriceWhenLessThanPrincipal() {
-            // P=1000, V0=1250(Pt0=1.25), 到期价 Pt=2.5 → V0/Pt=500 < P,返还 500,销毁 500
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("2.5");
-            BigDecimal cumulativeProfit = new BigDecimal("800"); // 占比 0.80 = θ,触发
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.DEFLATIONARY, THRESHOLD);
-
-            assertEquals(OrderStatus.DEFLATED, r.targetStatus());
-            assertEquals(0, r.returnPrincipal().compareTo(new BigDecimal("500")));
-            assertEquals(0, r.burnAmount().compareTo(new BigDecimal("500")));
-        }
-
-        @Test
-        @DisplayName("V0/Pt ≥ P 时,返还本金封顶为 P,销毁为 0")
-        void capsReturnAtPrincipalWhenValueExceedsPrincipal() {
-            // P=1000, V0=1250, Pt=1.0 → V0/Pt=1250 > P,min 取 P=1000,销毁 0
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("1.0");
-            BigDecimal cumulativeProfit = new BigDecimal("900"); // 占比 0.90 ≥ θ,触发
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.DEFLATIONARY, THRESHOLD);
-
-            assertEquals(OrderStatus.DEFLATED, r.targetStatus());
-            assertEquals(0, r.returnPrincipal().compareTo(p));
-            assertEquals(0, r.burnAmount().compareTo(BigDecimal.ZERO));
-        }
-
-        @Test
-        @DisplayName("占比恰好等于阈值 θ 即触发(边界,闭区间)")
-        void triggersAtExactThreshold() {
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("2");
-            BigDecimal cumulativeProfit = new BigDecimal("800"); // 0.80 == θ
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.DEFLATIONARY, THRESHOLD);
-
-            assertEquals(OrderStatus.DEFLATED, r.targetStatus());
-        }
-
-        @Test
-        @DisplayName("burnAmount = P − returnPrincipal 且恒 ≥ 0")
-        void burnAmountIsNonNegativeAndConsistent() {
-            BigDecimal p = new BigDecimal("777.123456789");
-            BigDecimal v0 = new BigDecimal("1000");
-            BigDecimal pt = new BigDecimal("3.3");
-            BigDecimal cumulativeProfit = p; // 占比 1.0 ≥ θ
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.DEFLATIONARY, THRESHOLD);
-
-            assertTrue(r.burnAmount().signum() >= 0, "销毁数量恒非负");
-            assertEquals(0, r.burnAmount().compareTo(p.subtract(r.returnPrincipal())));
-        }
-    }
-
-    @Nested
-    @DisplayName("常规赎回(SETTLED)")
-    class Settled {
-
-        @Test
-        @DisplayName("赎回机制为 NORMAL 时始终全额返还、无销毁")
-        void normalRedeemAlwaysSettles() {
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("5"); // 即便价格大涨,常规机制不触发通缩
-            BigDecimal cumulativeProfit = new BigDecimal("9999"); // 占比远超 θ 也不触发
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.NORMAL, THRESHOLD);
-
-            assertEquals(OrderStatus.SETTLED, r.targetStatus());
-            assertEquals(0, r.returnPrincipal().compareTo(p));
-            assertEquals(0, r.burnAmount().compareTo(BigDecimal.ZERO));
-        }
-
-        @Test
-        @DisplayName("通缩机制但占比低于阈值时走常规赎回")
-        void deflationaryButBelowThresholdSettles() {
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("2");
-            BigDecimal cumulativeProfit = new BigDecimal("799.5"); // 占比 0.7995 < 0.80(scale18 仍低于阈值)
-
-            ClearingResult r = DeflationCalculator.compute(p, v0, pt, cumulativeProfit, RedeemType.DEFLATIONARY, THRESHOLD);
-
-            assertEquals(OrderStatus.SETTLED, r.targetStatus());
-            assertEquals(0, r.returnPrincipal().compareTo(p));
-            assertEquals(0, r.burnAmount().compareTo(BigDecimal.ZERO));
-        }
-    }
-
-    @Nested
-    @DisplayName("参数校验")
-    class Validation {
-
-        @Test
-        @DisplayName("任一关键参数为空抛 NullPointerException")
-        void rejectsNullArguments() {
-            BigDecimal p = new BigDecimal("1000");
-            BigDecimal v0 = new BigDecimal("1250");
-            BigDecimal pt = new BigDecimal("2");
-            BigDecimal profit = new BigDecimal("800");
-
-            assertThrows(
-                    NullPointerException.class,
-                    () -> DeflationCalculator.compute(null, v0, pt, profit, RedeemType.DEFLATIONARY, THRESHOLD));
-            assertThrows(
-                    NullPointerException.class,
-                    () -> DeflationCalculator.compute(p, v0, pt, profit, null, THRESHOLD));
-            assertThrows(
-                    NullPointerException.class,
-                    () -> DeflationCalculator.compute(p, v0, pt, profit, RedeemType.DEFLATIONARY, null));
-        }
-    }
-}

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

@@ -1,227 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.RedeemType;
-import com.bex.staking.util.StatusMapping;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.math.RoundingMode;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link DeflationCalculator} 通缩赎回公式与状态映射不变量属性测试。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 13:对任意原始本金 {@code P}、初始价值 {@code V0}、
- * 到期价格 {@code Pt}({@code P>0, V0>0, Pt>0})与累计收益占比:
- *
- * <ul>
- *   <li>触发通缩(赎回机制为通缩且占比 ≥ θ)时:{@code returnPrincipal = min(P, V0/Pt)}、
- *       {@code burnAmount = P − returnPrincipal}(恒 {@code ≥ 0})、状态 = {@link OrderStatus#DEFLATED}
- *       (展示"通缩赎回");
- *   <li>否则(常规机制,或占比未达阈值):{@code returnPrincipal = P}、{@code burnAmount = 0}、
- *       状态 = {@link OrderStatus#SETTLED}(展示"已完成")。
- * </ul>
- *
- * <p>数值约定:除法 {@code V0/Pt} 与占比 {@code R_cum/P} 均显式指定 scale={@value #DIVIDE_SCALE}
- * 与 {@link RoundingMode#HALF_UP};金额均以 {@link BigDecimal} 生成并以 {@link BigDecimal#compareTo} 断言,
- * 避免 {@code equals} 受 scale 影响。
- *
- * <p>分支覆盖:通过专门的生成器构造"占比 ≥ θ"(触发)与"占比 < θ"(不触发)两类场景,并覆盖
- * {@code Pt > Pt0}、{@code Pt < Pt0}、{@code Pt} 接近 0 的边界(由价格生成器跨 [1e-18, 1e24] 范围保证);
- * NORMAL 机制无论占比高低始终 SETTLED。
- */
-class DeflationRedemptionPropertyTest {
-
-    /** 金额除法统一 scale(与 {@link DeflationCalculator} 及数据库 DECIMAL(36,18) 对齐) */
-    private static final int DIVIDE_SCALE = 18;
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 正数金额生成器:严格正 {@link BigDecimal},unscaledValue ∈ [1, 10^24]、scale ∈ [0, 18]。
-     *
-     * <p>用于本金 P 与初始价值 V0,覆盖整数、长尾小数与大额边界,恒 {@code > 0}。
-     */
-    @Provide
-    Arbitrary<BigDecimal> positiveAmount() {
-        Arbitrary<BigInteger> unscaled = Arbitraries.bigIntegers().between(BigInteger.ONE, BigInteger.TEN.pow(24));
-        Arbitrary<Integer> scale = Arbitraries.integers().between(0, 18);
-        return Combinators.combine(unscaled, scale).as(BigDecimal::new);
-    }
-
-    /**
-     * 非负金额生成器:unscaledValue ∈ [0, 10^24]、scale ∈ [0, 18],恒 {@code >= 0}。
-     *
-     * <p>用于累计收益 R_cum(可为 0)。
-     */
-    @Provide
-    Arbitrary<BigDecimal> nonNegativeAmount() {
-        Arbitrary<BigInteger> unscaled = Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(24));
-        Arbitrary<Integer> scale = Arbitraries.integers().between(0, 18);
-        return Combinators.combine(unscaled, scale).as(BigDecimal::new);
-    }
-
-    /**
-     * 正价格生成器:unscaledValue ∈ [1, 10^24]、scale ∈ [0, 18]。
-     *
-     * <p>取值跨 [1e-18, 1e24],覆盖 {@code Pt} 接近 0 的边界、以及相对入场价格 Pt0 偏高/偏低两类情形
-     *(在公式中体现为 {@code V0/Pt} 偏小/偏大,从而触发 {@code R<P} 与 {@code R=P}(min 取 P)两种结果)。
-     */
-    @Provide
-    Arbitrary<BigDecimal> positivePrice() {
-        Arbitrary<BigInteger> unscaled = Arbitraries.bigIntegers().between(BigInteger.ONE, BigInteger.TEN.pow(24));
-        Arbitrary<Integer> scale = Arbitraries.integers().between(0, 18);
-        return Combinators.combine(unscaled, scale).as(BigDecimal::new);
-    }
-
-    /** 通缩阈值 θ 生成器:[0.10, 1.50](scale=2),覆盖默认 0.80。 */
-    @Provide
-    Arbitrary<BigDecimal> threshold() {
-        return Arbitraries.integers().between(10, 150).map(i -> BigDecimal.valueOf(i, 2));
-    }
-
-    /** 触发场景的超额占比 margin 生成器:[0.05, 1.00](scale=2),保证 θ+margin 严格大于 θ。 */
-    @Provide
-    Arbitrary<BigDecimal> overMargin() {
-        return Arbitraries.integers().between(5, 100).map(i -> BigDecimal.valueOf(i, 2));
-    }
-
-    /** 不触发场景的占比缩放系数 生成器:[0.00, 0.90](scale=2),保证 θ×fraction 严格小于 θ。 */
-    @Provide
-    Arbitrary<BigDecimal> underFraction() {
-        return Arbitraries.integers().between(0, 90).map(i -> BigDecimal.valueOf(i, 2));
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 13: 通缩赎回公式与状态映射不变量
-    // 对任意 P, V0, Pt 与占比 ≥ θ:触发通缩 → returnPrincipal = min(P, V0/Pt), burnAmount = P − R ≥ 0, 状态 = DEFLATED("通缩赎回")
-    // Validates: Requirements 8.4, 8.5, 11.3, 11.4, 11.5, 11.6
-    @Property(tries = 100)
-    void deflationTriggeredFollowsFormula(
-            @ForAll("positiveAmount") BigDecimal principal,
-            @ForAll("positiveAmount") BigDecimal initialValue,
-            @ForAll("positivePrice") BigDecimal expirePrice,
-            @ForAll("threshold") BigDecimal threshold,
-            @ForAll("overMargin") BigDecimal overMargin) {
-        // 构造累计收益占比 = θ + margin(≥ θ):cumulativeProfit = P × (θ + margin)
-        BigDecimal cumulativeProfit = principal.multiply(threshold.add(overMargin));
-
-        ClearingResult result = DeflationCalculator.compute(
-                principal, initialValue, expirePrice, cumulativeProfit, RedeemType.DEFLATIONARY, threshold);
-
-        // 期望返还本金 R = min(P, V0/Pt)
-        BigDecimal expectedReturn = principal.min(initialValue.divide(expirePrice, DIVIDE_SCALE, RoundingMode.HALF_UP));
-
-        // 状态 = DEFLATED(11.5)
-        assertThat(result.targetStatus()).isEqualTo(OrderStatus.DEFLATED);
-        // returnPrincipal = min(P, V0/Pt)(11.3, 8.4)
-        assertThat(result.returnPrincipal()).usingComparator(BigDecimal::compareTo).isEqualTo(expectedReturn);
-        // burnAmount = P − R(11.4, 8.5)
-        assertThat(result.burnAmount())
-                .usingComparator(BigDecimal::compareTo)
-                .isEqualTo(principal.subtract(expectedReturn));
-        // 关键不变量:burnAmount 恒 ≥ 0
-        assertThat(result.burnAmount().signum()).isGreaterThanOrEqualTo(0);
-        // 展示字符串映射正确:DEFLATED → "通缩赎回"
-        assertThat(StatusMapping.displayOf(result.targetStatus())).isEqualTo(StatusMapping.ORDER_DISPLAY_DEFLATED);
-    }
-
-    // Feature: staking-service, Property 13: 通缩赎回公式与状态映射不变量
-    // 对任意 P, V0, Pt 与占比 < θ(通缩机制但未达阈值):returnPrincipal = P, burnAmount = 0, 状态 = SETTLED("已完成")
-    // Validates: Requirements 8.4, 8.5, 11.3, 11.4, 11.5, 11.6
-    @Property(tries = 100)
-    void belowThresholdSettlesFully(
-            @ForAll("positiveAmount") BigDecimal principal,
-            @ForAll("positiveAmount") BigDecimal initialValue,
-            @ForAll("positivePrice") BigDecimal expirePrice,
-            @ForAll("threshold") BigDecimal threshold,
-            @ForAll("underFraction") BigDecimal underFraction) {
-        // 构造累计收益占比 = θ × fraction(fraction ≤ 0.90,严格 < θ):cumulativeProfit = P × θ × fraction
-        BigDecimal cumulativeProfit = principal.multiply(threshold).multiply(underFraction);
-
-        ClearingResult result = DeflationCalculator.compute(
-                principal, initialValue, expirePrice, cumulativeProfit, RedeemType.DEFLATIONARY, threshold);
-
-        // 未触发通缩:状态 = SETTLED(11.6)
-        assertThat(result.targetStatus()).isEqualTo(OrderStatus.SETTLED);
-        // returnPrincipal = P
-        assertThat(result.returnPrincipal()).usingComparator(BigDecimal::compareTo).isEqualTo(principal);
-        // burnAmount = 0
-        assertThat(result.burnAmount().signum()).isZero();
-        // 展示字符串映射正确:SETTLED → "已完成"
-        assertThat(StatusMapping.displayOf(result.targetStatus())).isEqualTo(StatusMapping.ORDER_DISPLAY_SETTLED);
-    }
-
-    // Feature: staking-service, Property 13: 通缩赎回公式与状态映射不变量
-    // 对任意 P, V0, Pt 与任意累计收益:NORMAL 机制无论占比高低,始终 returnPrincipal = P, burnAmount = 0, 状态 = SETTLED
-    // Validates: Requirements 11.6
-    @Property(tries = 100)
-    void normalRedeemAlwaysSettles(
-            @ForAll("positiveAmount") BigDecimal principal,
-            @ForAll("positiveAmount") BigDecimal initialValue,
-            @ForAll("positivePrice") BigDecimal expirePrice,
-            @ForAll("nonNegativeAmount") BigDecimal cumulativeProfit,
-            @ForAll("threshold") BigDecimal threshold) {
-        ClearingResult result = DeflationCalculator.compute(
-                principal, initialValue, expirePrice, cumulativeProfit, RedeemType.NORMAL, threshold);
-
-        // NORMAL 机制永不触发通缩:状态恒为 SETTLED
-        assertThat(result.targetStatus()).isEqualTo(OrderStatus.SETTLED);
-        assertThat(result.returnPrincipal()).usingComparator(BigDecimal::compareTo).isEqualTo(principal);
-        assertThat(result.burnAmount().signum()).isZero();
-        assertThat(StatusMapping.displayOf(result.targetStatus())).isEqualTo(StatusMapping.ORDER_DISPLAY_SETTLED);
-    }
-
-    // Feature: staking-service, Property 13: 通缩赎回公式与状态映射不变量
-    // 对任意输入(任意赎回机制、任意累计收益):burnAmount 恒 ≥ 0、0 ≤ returnPrincipal ≤ P,
-    // 且状态与展示字符串映射与重算的触发条件严格一致(覆盖随机出现的触发 / 不触发两分支)
-    // Validates: Requirements 8.4, 8.5, 11.3, 11.4, 11.5, 11.6
-    @Property(tries = 100)
-    void invariantsHoldForAnyInput(
-            @ForAll("positiveAmount") BigDecimal principal,
-            @ForAll("positiveAmount") BigDecimal initialValue,
-            @ForAll("positivePrice") BigDecimal expirePrice,
-            @ForAll("nonNegativeAmount") BigDecimal cumulativeProfit,
-            @ForAll RedeemType redeemType,
-            @ForAll("threshold") BigDecimal threshold) {
-        ClearingResult result = DeflationCalculator.compute(
-                principal, initialValue, expirePrice, cumulativeProfit, redeemType, threshold);
-
-        // 通用不变量:burnAmount 恒 ≥ 0;0 ≤ returnPrincipal ≤ P
-        assertThat(result.burnAmount().signum()).isGreaterThanOrEqualTo(0);
-        assertThat(result.returnPrincipal().signum()).isGreaterThanOrEqualTo(0);
-        assertThat(result.returnPrincipal().compareTo(principal)).isLessThanOrEqualTo(0);
-        // returnPrincipal + burnAmount = P(本金守恒,无凭空增减)
-        assertThat(result.returnPrincipal().add(result.burnAmount()))
-                .usingComparator(BigDecimal::compareTo)
-                .isEqualTo(principal);
-
-        // 按计算器同样的口径重算触发条件,断言状态与公式一致
-        BigDecimal profitRatio = cumulativeProfit.divide(principal, DIVIDE_SCALE, RoundingMode.HALF_UP);
-        boolean triggered = redeemType == RedeemType.DEFLATIONARY && profitRatio.compareTo(threshold) >= 0;
-
-        if (triggered) {
-            BigDecimal expectedReturn =
-                    principal.min(initialValue.divide(expirePrice, DIVIDE_SCALE, RoundingMode.HALF_UP));
-            assertThat(result.targetStatus()).isEqualTo(OrderStatus.DEFLATED);
-            assertThat(result.returnPrincipal()).usingComparator(BigDecimal::compareTo).isEqualTo(expectedReturn);
-            assertThat(result.burnAmount())
-                    .usingComparator(BigDecimal::compareTo)
-                    .isEqualTo(principal.subtract(expectedReturn));
-            assertThat(StatusMapping.displayOf(result.targetStatus())).isEqualTo(StatusMapping.ORDER_DISPLAY_DEFLATED);
-        } else {
-            assertThat(result.targetStatus()).isEqualTo(OrderStatus.SETTLED);
-            assertThat(result.returnPrincipal()).usingComparator(BigDecimal::compareTo).isEqualTo(principal);
-            assertThat(result.burnAmount().signum()).isZero();
-            assertThat(StatusMapping.displayOf(result.targetStatus())).isEqualTo(StatusMapping.ORDER_DISPLAY_SETTLED);
-        }
-    }
-}

+ 0 - 220
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/DeflationThresholdInvariantPropertyTest.java

@@ -1,220 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import com.bex.staking.constant.StakingConstants;
-import java.math.BigDecimal;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * 通缩触发阈值不变量的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 5: 通缩触发阈值不变量。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 5:对任意初始本金 {@code P} 与通缩阈值 {@code θ}
- *(默认 0.80),预览的 {@code triggerThreshold} 与进行中订单详情的 {@code shrinkThreshold} 都应等于
- * {@code P × θ}。
- *
- * <h2>被测口径与一致性说明</h2>
- *
- * <p>通缩阈值 θ 在两处由产品 {@code deflationRate}(百分比口径,如 80.00)经 {@code movePointLeft(2)}
- * 转为比例 0.80;产品未配置时回退默认阈值 {@link StakingConstants#DEFAULT_DEFLATION_THRESHOLD}(0.80)。
- * 阈值(BEX 数量)= 本金 {@code P × θ},为「精确乘法、不二次舍入」的内联计算,两处公式完全一致:
- *
- * <ul>
- *   <li><b>预览口径</b>:{@code PreviewEngine.preview(...)}
- *       第 125~127 行:{@code BigDecimal threshold = resolveThreshold(product.getDeflationRate());}、
- *       {@code BigDecimal triggerThreshold = stakingNum.multiply(threshold);}(需求 4.4),其中
- *       {@code PreviewEngine.resolveThreshold(...)} 为 {@code deflationRatePercent.movePointLeft(2)},
- *       空值回退 {@link StakingConstants#DEFAULT_DEFLATION_THRESHOLD};</li>
- *   <li><b>进行中订单详情口径</b>:进行中订单详情的 {@code shrinkThreshold = P × θ}(需求 7.4),
- *       其阈值 θ 同样由产品 {@code deflationRate.movePointLeft(2)} 得到,与 {@code ClearingEngine.resolveThreshold(...)}
- *       (第 320~324 行,口径相同:{@code deflationRatePercent.movePointLeft(2)},空值回退默认 0.80)一致,
- *       本金 {@code P} 取订单 {@code stakeAmount}(即下单时的 {@code stakingNum})。</li>
- * </ul>
- *
- * <p>由于两处计算分别依赖 Spring/Mapper/gRPC/Redis(预览)与订单查询服务装配(进行中详情),
- * 且 {@code resolveThreshold} 为私有方法,本属性测试聚焦「阈值 = P × θ」与「θ = deflationRate/100」
- * 这一纯数值不变量:以与生产代码完全相同的算式表达两处口径,验证其精确性、与 {@code compareTo} 的一致性、
- * 正比性,并断言「预览口径」与「进行中详情口径」为同一公式(同一 {@code P}、同一 {@code deflationRate}
- * → 相同阈值),从而覆盖 Property 5 的不变量。金额一律使用 {@link BigDecimal} 生成,并以
- * {@link BigDecimal#compareTo} 断言数值相等(忽略 scale 差异)。
- *
- * <p>Validates: Requirements 4.4, 7.4
- */
-class DeflationThresholdInvariantPropertyTest {
-
-    /** 百分比 → 比例的标度移位位数(如 deflationRate 80.00 → 0.80),与生产代码 {@code PERCENT_SHIFT} 一致。 */
-    private static final int PERCENT_SHIFT = 2;
-
-    // ==================== 被测口径(与 PreviewEngine / ClearingEngine 的 resolveThreshold 完全一致) ====================
-
-    /**
-     * 将产品通缩阈值(百分比,如 80.00)转换为比例阈值(如 0.80);为空时回退默认阈值
-     * {@link StakingConstants#DEFAULT_DEFLATION_THRESHOLD}(0.80)。
-     *
-     * <p>等价于 {@code PreviewEngine.resolveThreshold(...)} 与 {@code ClearingEngine.resolveThreshold(...)}
-     * 的私有实现(两处口径完全一致)。
-     */
-    private static BigDecimal resolveThreshold(BigDecimal deflationRatePercent) {
-        if (deflationRatePercent == null) {
-            return StakingConstants.DEFAULT_DEFLATION_THRESHOLD;
-        }
-        return deflationRatePercent.movePointLeft(PERCENT_SHIFT);
-    }
-
-    /**
-     * 预览引擎计算触发阈值的口径:{@code triggerThreshold = P × θ}(精确乘法)。
-     *
-     * <p>等价于 {@code PreviewEngine.preview(...)} 第 125~127 行内联计算。
-     */
-    private static BigDecimal previewTriggerThreshold(BigDecimal principal, BigDecimal deflationRatePercent) {
-        return principal.multiply(resolveThreshold(deflationRatePercent));
-    }
-
-    /**
-     * 进行中订单详情计算收缩阈值的口径:{@code shrinkThreshold = P × θ}(精确乘法,需求 7.4)。
-     *
-     * <p>本金 {@code P} 取订单 {@code stakeAmount};θ 由产品 {@code deflationRate} 经
-     * {@code movePointLeft(2)} 得到,口径与 {@code ClearingEngine.resolveThreshold(...)} 一致。
-     */
-    private static BigDecimal ongoingShrinkThreshold(BigDecimal principal, BigDecimal deflationRatePercent) {
-        return principal.multiply(resolveThreshold(deflationRatePercent));
-    }
-
-    // ==================== 生成器 ====================
-
-    /** 正本金 P:[100, 1e9],scale=18,覆盖最小下限、整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> principal() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("100"), new BigDecimal("1000000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /**
-     * 通缩阈值百分比 deflationRate:[0.01, 100.00],scale=2(数据库 {@code DECIMAL(5,2)} 口径),
-     * 覆盖默认 80.00 与其他阈值(如 50.00、100.00、12.34 等)。
-     */
-    @Provide
-    Arbitrary<BigDecimal> deflationRatePercent() {
-        return Arbitraries.integers().between(1, 10000).map(i -> BigDecimal.valueOf(i, 2));
-    }
-
-    /** 正比性缩放因子 k:(0, 1000],scale=6,覆盖整数倍与小数倍。 */
-    @Provide
-    Arbitrary<BigDecimal> scaleFactor() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000001"), new BigDecimal("1000"))
-                .ofScale(6)
-                .filter(v -> v.signum() > 0);
-    }
-
-    // ==================== Property 5:θ = deflationRate / 100 口径不变量 ====================
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 阈值比例口径:θ == deflationRate / 100(movePointLeft(2)),如 80.00 → 0.80
-    // Validates: Requirements 4.4, 7.4
-    @Property(tries = 100)
-    void thresholdRatioEqualsDeflationRateOverHundred(@ForAll("deflationRatePercent") BigDecimal deflationRatePercent) {
-        BigDecimal actual = resolveThreshold(deflationRatePercent);
-        BigDecimal expected = deflationRatePercent.divide(new BigDecimal("100"));
-
-        assertEquals(0, actual.compareTo(expected), "通缩阈值比例 θ 应等于 deflationRate / 100");
-    }
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 缺省回退:deflationRate 为空时 θ == DEFAULT_DEFLATION_THRESHOLD(0.80)
-    // Validates: Requirements 4.4, 7.4
-    @Example
-    void nullDeflationRateFallsBackToDefaultThreshold() {
-        BigDecimal actual = resolveThreshold(null);
-        assertEquals(0, actual.compareTo(new BigDecimal("0.80")), "deflationRate 为空时应回退默认阈值 0.80");
-        assertEquals(
-                0,
-                actual.compareTo(StakingConstants.DEFAULT_DEFLATION_THRESHOLD),
-                "缺省阈值应等于 StakingConstants.DEFAULT_DEFLATION_THRESHOLD");
-    }
-
-    // ==================== Property 5:阈值 = P × θ 公式不变量 ====================
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 公式不变量:triggerThreshold == P × θ(精确乘法,不二次舍入);默认阈值 0.80 与其他阈值均覆盖
-    // Validates: Requirements 4.4, 7.4
-    @Property(tries = 100)
-    void triggerThresholdEqualsPrincipalTimesRatio(
-            @ForAll("principal") BigDecimal principal, @ForAll("deflationRatePercent") BigDecimal deflationRatePercent) {
-
-        BigDecimal actual = previewTriggerThreshold(principal, deflationRatePercent);
-        BigDecimal expected = principal.multiply(deflationRatePercent.movePointLeft(PERCENT_SHIFT));
-
-        assertEquals(0, actual.compareTo(expected), "触发阈值应等于 P × θ(P × deflationRate/100)");
-    }
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 默认阈值锚定:deflationRate=80.00 → θ=0.80;triggerThreshold == P × 0.80
-    // Validates: Requirements 4.4, 7.4
-    @Property(tries = 100)
-    void defaultThresholdEqualsEightyPercentOfPrincipal(@ForAll("principal") BigDecimal principal) {
-        BigDecimal actual = previewTriggerThreshold(principal, new BigDecimal("80.00"));
-        BigDecimal expected = principal.multiply(new BigDecimal("0.80"));
-
-        assertEquals(0, actual.compareTo(expected), "默认 80% 阈值下触发阈值应等于 P × 0.80");
-    }
-
-    // ==================== Property 5:预览口径 ≡ 进行中详情口径 ====================
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 预览与进行中详情一致:同一 P、同一 deflationRate 下,预览 triggerThreshold 与进行中详情 shrinkThreshold 相等
-    // Validates: Requirements 4.4, 7.4
-    @Property(tries = 100)
-    void previewAndOngoingDetailShareSameThreshold(
-            @ForAll("principal") BigDecimal principal, @ForAll("deflationRatePercent") BigDecimal deflationRatePercent) {
-
-        BigDecimal triggerThreshold = previewTriggerThreshold(principal, deflationRatePercent);
-        BigDecimal shrinkThreshold = ongoingShrinkThreshold(principal, deflationRatePercent);
-
-        assertEquals(
-                0,
-                triggerThreshold.compareTo(shrinkThreshold),
-                "预览 triggerThreshold 应与进行中详情 shrinkThreshold 相等(同一 P × θ 公式)");
-    }
-
-    // ==================== Property 5:正比性 ====================
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 正比性:threshold(k × P, θ) == k × threshold(P, θ)(阈值与本金成正比)
-    // Validates: Requirements 4.4, 7.4
-    @Property(tries = 100)
-    void thresholdIsProportionalToPrincipal(
-            @ForAll("principal") BigDecimal principal,
-            @ForAll("deflationRatePercent") BigDecimal deflationRatePercent,
-            @ForAll("scaleFactor") BigDecimal k) {
-
-        BigDecimal scaledThreshold = previewTriggerThreshold(principal.multiply(k), deflationRatePercent);
-        BigDecimal thresholdScaled =
-                previewTriggerThreshold(principal, deflationRatePercent).multiply(k);
-
-        assertEquals(
-                0,
-                scaledThreshold.compareTo(thresholdScaled),
-                "触发阈值应与本金成正比:threshold(k×P) == k×threshold(P)");
-    }
-
-    // ==================== 锚定示例(防止公式整体性错误) ====================
-
-    // Feature: staking-service, Property 5: 通缩触发阈值不变量
-    // 口径锚定(与设计 Mock 样本一致):P=1000, deflationRate=80.00 → shrinkThreshold = 800 BEX
-    // Validates: Requirements 4.4, 7.4
-    @Example
-    void thresholdAnchorMatchesMockSample() {
-        BigDecimal threshold = ongoingShrinkThreshold(new BigDecimal("1000"), new BigDecimal("80.00"));
-        assertEquals(0, threshold.compareTo(new BigDecimal("800")), "P=1000, 阈值 80% → 触发阈值 = 800 BEX");
-    }
-}

+ 0 - 80
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ExpireTimeInvariantPropertyTest.java

@@ -1,80 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import com.bex.staking.constant.StakingConstants;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.constraints.IntRange;
-import net.jqwik.api.constraints.LongRange;
-
-/**
- * 订单到期时间不变量的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 6: 到期时间不变量。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 6:对任意锁仓天数 {@code lockDays} 与锁仓开始时间
- * {@code lockStart},订单到期时间满足 {@code expireTime − lockStart = lockDays × 86_400_000}(毫秒)。
- *
- * <p>口径一致性说明:到期时间的计算口径在两处被测组件中完全一致,均使用
- * {@link StakingConstants#MILLIS_PER_DAY}({@code 86_400_000L}):
- *
- * <ul>
- *   <li>{@code PreviewEngine}:第 130 行
- *       {@code long expireTime = lockStart + (long) lockDays * StakingConstants.MILLIS_PER_DAY;}(需求 4.5);</li>
- *   <li>{@code OrderEngine#buildHoldingOrder}:第 247 行
- *       {@code long expireTime = lockStartTime + (long) product.getLockDays() * StakingConstants.MILLIS_PER_DAY;}。</li>
- * </ul>
- *
- * <p>本属性测试聚焦「{@code expireTime − lockStart = lockDays × 86_400_000}」这一纯数值不变量,
- * 不引入 Spring 依赖,直接复用与两处实现完全相同的算式进行验证。
- *
- * <p>生成器范围:{@code lockStart ∈ [0, 4e12]}(覆盖 0 与远超当前时间戳的未来毫秒)、
- * {@code lockDays ∈ [0, 3650]}(0 天到 10 年),上界控制保证
- * {@code lockStart + lockDays × 86_400_000 ≤ 4e12 + 3650 × 86_400_000 ≈ 4.3e12} 远小于
- * {@link Long#MAX_VALUE},不会发生 long 溢出。
- *
- * <p>Validates: Requirements 4.5
- */
-class ExpireTimeInvariantPropertyTest {
-
-    /** 锁仓开始时间上界:4e12 毫秒(约公元 2096 年,覆盖合理的未来时间戳)。 */
-    private static final long LOCK_START_MAX = 4_000_000_000_000L;
-
-    /** 锁仓天数上界:3650 天(约 10 年),与既有引擎测试的天数量级一致,且避免 long 溢出。 */
-    private static final int LOCK_DAYS_MAX = 3650;
-
-    /**
-     * 计算订单到期时间,算式与 {@code PreviewEngine}/{@code OrderEngine} 完全一致:
-     * {@code expireTime = lockStart + lockDays × MILLIS_PER_DAY}。
-     */
-    private static long computeExpireTime(long lockStart, int lockDays) {
-        return lockStart + (long) lockDays * StakingConstants.MILLIS_PER_DAY;
-    }
-
-    // Feature: staking-service, Property 6: 到期时间不变量
-    // 常量口径前置校验:MILLIS_PER_DAY 必须等于 86_400_000(24 × 60 × 60 × 1000)
-    // Validates: Requirements 4.5
-    @Example
-    void millisPerDayEqualsExpectedConstant() {
-        assertEquals(86_400_000L, StakingConstants.MILLIS_PER_DAY, "一天毫秒数常量必须等于 86_400_000");
-    }
-
-    // Feature: staking-service, Property 6: 到期时间不变量
-    // 对任意 lockStart、lockDays:expireTime − lockStart == lockDays × 86_400_000
-    // Validates: Requirements 4.5
-    @Property(tries = 100)
-    void expireTimeMinusLockStartEqualsLockDaysTimesMillisPerDay(
-            @ForAll @LongRange(min = 0L, max = LOCK_START_MAX) long lockStart,
-            @ForAll @IntRange(min = 0, max = LOCK_DAYS_MAX) int lockDays) {
-
-        long expireTime = computeExpireTime(lockStart, lockDays);
-
-        // 不变量:expireTime − lockStart = lockDays × 86_400_000
-        assertEquals(
-                (long) lockDays * 86_400_000L,
-                expireTime - lockStart,
-                "到期时间与锁仓开始时间之差应等于 lockDays × 86_400_000 毫秒");
-    }
-}

+ 0 - 154
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/IdempotencyBarrierTest.java

@@ -1,154 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.bex.staking.annotation.Idempotent;
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.model.dto.StakingOrderReq;
-import java.lang.reflect.Method;
-import java.util.concurrent.TimeUnit;
-import org.aspectj.lang.ProceedingJoinPoint;
-import org.aspectj.lang.reflect.MethodSignature;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.redisson.api.RLock;
-import org.redisson.api.RedissonClient;
-
-/**
- * {@link IdempotencyBarrier} 幂等屏障切面单元测试。
- *
- * <p>以 Mockito 模拟 {@link RedissonClient} 与 {@link RLock},验证切面在锁获取成功 / 失败两种场景下的行为,
- * 覆盖需求 14.1(基于 Redisson 锁防重入)、14.2(处理中重复请求拒绝并返回 40008)、14.3(业务完成释放锁)、
- * 14.4(设置 leaseTime 自动释放防死锁)。物理超时与真实 Redis 行为由集成测试任务 17.4 覆盖,不在本测试范围。
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("幂等屏障切面 IdempotencyBarrier 测试")
-class IdempotencyBarrierTest {
-
-    @Mock
-    private RedissonClient redissonClient;
-
-    @Mock
-    private RLock rLock;
-
-    @Mock
-    private ProceedingJoinPoint joinPoint;
-
-    @Mock
-    private MethodSignature methodSignature;
-
-    private IdempotencyBarrier barrier;
-
-    /** 携带 @Idempotent 注解的样例目标方法,用于提取注解与方法签名。 */
-    @Idempotent(prefix = "stake:confirm", key = "#userId + ':' + #req.idempotentKey")
-    public Boolean sampleConfirmStake(Long userId, StakingOrderReq req) {
-        return Boolean.TRUE;
-    }
-
-    private Idempotent idempotentAnnotation;
-    private Method sampleMethod;
-
-    @BeforeEach
-    void setUp() throws NoSuchMethodException {
-        barrier = new IdempotencyBarrier(redissonClient);
-        sampleMethod = IdempotencyBarrierTest.class.getMethod(
-                "sampleConfirmStake", Long.class, StakingOrderReq.class);
-        idempotentAnnotation = sampleMethod.getAnnotation(Idempotent.class);
-    }
-
-    private StakingOrderReq buildReq(String idempotentKey) {
-        StakingOrderReq req = new StakingOrderReq();
-        req.setProductId(1L);
-        req.setIdempotentKey(idempotentKey);
-        return req;
-    }
-
-    @Test
-    @DisplayName("获取锁成功时执行业务方法、按 leaseTime 加锁并在完成后释放锁(需求 14.1/14.3/14.4)")
-    void shouldProceedAndReleaseLockWhenLockAcquired() throws Throwable {
-        Long userId = 1001L;
-        StakingOrderReq req = buildReq("idem-key-A");
-        when(joinPoint.getSignature()).thenReturn(methodSignature);
-        when(methodSignature.getMethod()).thenReturn(sampleMethod);
-        when(joinPoint.getArgs()).thenReturn(new Object[] {userId, req});
-        when(redissonClient.getLock(eq("staking:idem:stake:confirm:1001:idem-key-A")))
-                .thenReturn(rLock);
-        when(rLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
-        when(rLock.isHeldByCurrentThread()).thenReturn(true);
-        when(joinPoint.proceed()).thenReturn(Boolean.TRUE);
-
-        Object result = barrier.around(joinPoint, idempotentAnnotation);
-
-        assertThat(result).isEqualTo(Boolean.TRUE);
-        verify(joinPoint, times(1)).proceed();
-        verify(rLock, times(1)).unlock();
-
-        // 校验 leaseTime(注解默认 10s)作为自动释放租约传入 tryLock(需求 14.4)
-        ArgumentCaptor<Long> waitTimeCaptor = ArgumentCaptor.forClass(Long.class);
-        ArgumentCaptor<Long> leaseTimeCaptor = ArgumentCaptor.forClass(Long.class);
-        verify(rLock).tryLock(waitTimeCaptor.capture(), leaseTimeCaptor.capture(), eq(TimeUnit.SECONDS));
-        assertThat(waitTimeCaptor.getValue()).isEqualTo(idempotentAnnotation.waitTime());
-        assertThat(leaseTimeCaptor.getValue()).isEqualTo(idempotentAnnotation.leaseTime());
-    }
-
-    @Test
-    @DisplayName("锁被占用(处理中)时抛出 40008 重复请求异常且不执行业务方法(需求 14.2)")
-    void shouldThrowDuplicateRequestWhenLockOccupied() throws Throwable {
-        Long userId = 1002L;
-        StakingOrderReq req = buildReq("idem-key-B");
-        when(joinPoint.getSignature()).thenReturn(methodSignature);
-        when(methodSignature.getMethod()).thenReturn(sampleMethod);
-        when(joinPoint.getArgs()).thenReturn(new Object[] {userId, req});
-        when(redissonClient.getLock(eq("staking:idem:stake:confirm:1002:idem-key-B")))
-                .thenReturn(rLock);
-        // 锁被占用:tryLock 返回 false
-        when(rLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(false);
-
-        assertThatThrownBy(() -> barrier.around(joinPoint, idempotentAnnotation))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> {
-                    StakingException se = (StakingException) ex;
-                    assertThat(se.getCode()).isEqualTo(StakingErrorCode.DUPLICATE_REQUEST);
-                    assertThat(se.getMessage()).isEqualTo(StakingConstants.DUPLICATE_REQUEST_TIP);
-                });
-
-        // 未获取到锁:不执行业务方法、不释放锁(需求 14.2)
-        verify(joinPoint, never()).proceed();
-        verify(rLock, never()).unlock();
-    }
-
-    @Test
-    @DisplayName("业务方法抛出异常时仍在 finally 中释放锁")
-    void shouldReleaseLockWhenBusinessThrows() throws Throwable {
-        Long userId = 1003L;
-        StakingOrderReq req = buildReq("idem-key-C");
-        when(joinPoint.getSignature()).thenReturn(methodSignature);
-        when(methodSignature.getMethod()).thenReturn(sampleMethod);
-        when(joinPoint.getArgs()).thenReturn(new Object[] {userId, req});
-        when(redissonClient.getLock(eq("staking:idem:stake:confirm:1003:idem-key-C")))
-                .thenReturn(rLock);
-        when(rLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
-        when(rLock.isHeldByCurrentThread()).thenReturn(true);
-        when(joinPoint.proceed()).thenThrow(new IllegalStateException("业务异常"));
-
-        assertThatThrownBy(() -> barrier.around(joinPoint, idempotentAnnotation))
-                .isInstanceOf(IllegalStateException.class)
-                .hasMessage("业务异常");
-
-        verify(rLock, times(1)).unlock();
-    }
-}

+ 0 - 173
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/InitialValueInvariantPropertyTest.java

@@ -1,173 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.math.BigDecimal;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * 初始价值(V0)不变量的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 4: 初始价值不变量(V0)。
- *
- * <p>验证:对任意质押数量 {@code stakingNum} 与入场价格 {@code entryPrice},预览与下单计算所得初始价值满足
- * {@code initialValue = stakingNum × entryPrice},且二者口径一致、下单持久化的 {@code initialValueUsdt} 与之相等。
- *
- * <h2>被测口径与一致性说明</h2>
- *
- * <p>初始价值 V0 在两个引擎中均为「精确乘法、不二次舍入」的内联计算,公式完全相同:
- *
- * <ul>
- *   <li>{@code PreviewEngine.preview(...)} 第 117 行:
- *       {@code BigDecimal initialValue = stakingNum.multiply(entryPrice);}(需求 4.3);</li>
- *   <li>{@code OrderEngine.confirmStake(...)} 第 158 行:
- *       {@code BigDecimal initialValueUsdt = stakingNum.multiply(entryPrice);}(需求 5.8),
- *       该值随后由 {@code buildHoldingOrder(...)} 原样写入订单 {@code initialValueUsdt} 字段持久化。</li>
- * </ul>
- *
- * <p>由于两引擎依赖 Spring/Mapper/gRPC/Redis,本属性测试聚焦「V0 = stakingNum × entryPrice」这一纯数值不变量:
- * 以与生产代码相同的 {@code BigDecimal.multiply} 表达 V0,验证其精确性、与 {@code compareTo} 的一致性、正比性,
- * 并断言「预览口径」与「下单口径」为同一公式(同输入必同输出),从而覆盖「预览与下单一致」「持久化值相等」
- * 这一不变量。金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 断言数值相等(忽略 scale 差异)。
- *
- * <p>Validates: Requirements 4.3, 5.8
- */
-class InitialValueInvariantPropertyTest {
-
-    // ==================== 被测口径(与 PreviewEngine.java:117 / OrderEngine.java:158 完全一致) ====================
-
-    /**
-     * 预览引擎计算 V0 的口径:{@code initialValue = stakingNum × entryPrice}(精确乘法)。
-     *
-     * <p>等价于 {@code PreviewEngine.preview(...)} 第 117 行内联计算。
-     */
-    private static BigDecimal previewInitialValue(BigDecimal stakingNum, BigDecimal entryPrice) {
-        return stakingNum.multiply(entryPrice);
-    }
-
-    /**
-     * 下单引擎计算并持久化 V0 的口径:{@code initialValueUsdt = stakingNum × entryPrice}(精确乘法)。
-     *
-     * <p>等价于 {@code OrderEngine.confirmStake(...)} 第 158 行内联计算,结果原样写入订单字段。
-     */
-    private static BigDecimal orderInitialValueUsdt(BigDecimal stakingNum, BigDecimal entryPrice) {
-        return stakingNum.multiply(entryPrice);
-    }
-
-    // ==================== 生成器 ====================
-
-    /** 正质押数量 stakingNum:[100, 1e9],scale=18,覆盖最小下限、整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> stakingNum() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("100"), new BigDecimal("1000000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 正入场价格 entryPrice:(0, 1e6],scale=18,覆盖小于 1 与大于 1 的价格、长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> entryPrice() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000000000000000001"), new BigDecimal("1000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 正比性缩放因子 k:(0, 1000],scale=6,覆盖整数倍与小数倍。 */
-    @Provide
-    Arbitrary<BigDecimal> scaleFactor() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000001"), new BigDecimal("1000"))
-                .ofScale(6)
-                .filter(v -> v.signum() > 0);
-    }
-
-    // ==================== Property 4:V0 公式不变量 ====================
-
-    // Feature: staking-service, Property 4: 初始价值不变量(V0)
-    // 公式不变量:initialValue == stakingNum × entryPrice(精确乘法,不二次舍入)
-    // Validates: Requirements 4.3, 5.8
-    @Property(tries = 100)
-    void initialValueEqualsStakingNumTimesEntryPrice(
-            @ForAll("stakingNum") BigDecimal stakingNum, @ForAll("entryPrice") BigDecimal entryPrice) {
-
-        BigDecimal actual = previewInitialValue(stakingNum, entryPrice);
-        BigDecimal expected = stakingNum.multiply(entryPrice);
-
-        assertEquals(0, actual.compareTo(expected), "初始价值应等于 stakingNum × entryPrice(精确乘法)");
-    }
-
-    // Feature: staking-service, Property 4: 初始价值不变量(V0)
-    // 精确性:V0 的标度等于两因子标度之和(BigDecimal.multiply 不引入舍入),与精确有理数乘积一致
-    // Validates: Requirements 4.3, 5.8
-    @Property(tries = 100)
-    void initialValueIsExactNoRounding(
-            @ForAll("stakingNum") BigDecimal stakingNum, @ForAll("entryPrice") BigDecimal entryPrice) {
-
-        BigDecimal v0 = previewInitialValue(stakingNum, entryPrice);
-
-        // multiply 为精确运算:结果标度 == 两因子标度之和(无舍入丢失)
-        assertEquals(
-                stakingNum.scale() + entryPrice.scale(),
-                v0.scale(),
-                "精确乘法结果标度应为两因子标度之和(无二次舍入)");
-        // 反向校验:V0 / entryPrice 在足够精度下应精确还原为 stakingNum(乘法可逆,无精度丢失)
-        BigDecimal recovered = v0.divide(entryPrice);
-        assertEquals(0, recovered.compareTo(stakingNum), "V0 / entryPrice 应精确还原 stakingNum(乘法无精度丢失)");
-    }
-
-    // ==================== Property 4:预览口径 ≡ 下单口径 ≡ 持久化值 ====================
-
-    // Feature: staking-service, Property 4: 初始价值不变量(V0)
-    // 预览与下单一致:同一 (stakingNum, entryPrice) 下,预览的 initialValue 与下单持久化的 initialValueUsdt 相等
-    // Validates: Requirements 4.3, 5.8
-    @Property(tries = 100)
-    void previewAndOrderShareSameInitialValue(
-            @ForAll("stakingNum") BigDecimal stakingNum, @ForAll("entryPrice") BigDecimal entryPrice) {
-
-        BigDecimal previewValue = previewInitialValue(stakingNum, entryPrice);
-        BigDecimal orderPersistedValue = orderInitialValueUsdt(stakingNum, entryPrice);
-
-        assertEquals(
-                0,
-                previewValue.compareTo(orderPersistedValue),
-                "预览 initialValue 应与下单持久化的 initialValueUsdt 相等(同一 V0 公式)");
-    }
-
-    // ==================== Property 4:正比性 ====================
-
-    // Feature: staking-service, Property 4: 初始价值不变量(V0)
-    // 正比性:V0(k × stakingNum, entryPrice) == k × V0(stakingNum, entryPrice)(初始价值与质押数量成正比)
-    // Validates: Requirements 4.3, 5.8
-    @Property(tries = 100)
-    void initialValueIsProportionalToStakingNum(
-            @ForAll("stakingNum") BigDecimal stakingNum,
-            @ForAll("entryPrice") BigDecimal entryPrice,
-            @ForAll("scaleFactor") BigDecimal k) {
-
-        BigDecimal scaledValue = previewInitialValue(stakingNum.multiply(k), entryPrice);
-        BigDecimal valueScaled = previewInitialValue(stakingNum, entryPrice).multiply(k);
-
-        assertEquals(
-                0,
-                scaledValue.compareTo(valueScaled),
-                "初始价值应与质押数量成正比:V0(k×stakingNum) == k×V0(stakingNum)");
-    }
-
-    // ==================== 锚定示例(防止公式整体性错误) ====================
-
-    // Feature: staking-service, Property 4: 初始价值不变量(V0)
-    // 口径锚定(与设计 Mock 样本一致):stakingNum=1000, entryPrice=1.25 → V0 = 1,250 USDT
-    // Validates: Requirements 4.3, 5.8
-    @Example
-    void initialValueAnchorMatchesMockSample() {
-        BigDecimal v0 = previewInitialValue(new BigDecimal("1000"), new BigDecimal("1.250000"));
-        assertEquals(0, v0.compareTo(new BigDecimal("1250")), "stakingNum=1000, entryPrice=1.25 → V0 = 1250 USDT");
-    }
-}

+ 0 - 195
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/OrderEngineDeductRollbackTest.java

@@ -1,195 +0,0 @@
-//package com.bex.staking.engine;
-//
-//import static org.assertj.core.api.Assertions.assertThat;
-//import static org.assertj.core.api.Assertions.assertThatThrownBy;
-//import static org.mockito.ArgumentMatchers.any;
-//import static org.mockito.ArgumentMatchers.anyInt;
-//import static org.mockito.ArgumentMatchers.anyLong;
-//import static org.mockito.ArgumentMatchers.anyString;
-//import static org.mockito.ArgumentMatchers.eq;
-//import static org.mockito.Mockito.never;
-//import static org.mockito.Mockito.verify;
-//import static org.mockito.Mockito.when;
-//
-//import com.bex.staking.constant.StakingErrorCode;
-//import com.bex.staking.entity.StakingOrder;
-//import com.bex.staking.entity.StakingProduct;
-//import com.bex.staking.enums.ProductType;
-//import com.bex.staking.enums.SubscriptionMode;
-//import com.bex.staking.exception.StakingException;
-//import com.bex.staking.grpc.AssetGrpcClient;
-//import com.bex.staking.grpc.MarketPriceClient;
-//import com.bex.staking.mapper.StakingOrderMapper;
-//import com.bex.staking.mapper.StakingProductMapper;
-//import com.bex.staking.model.dto.StakingOrderReq;
-//import java.math.BigDecimal;
-//import org.junit.jupiter.api.BeforeEach;
-//import org.junit.jupiter.api.DisplayName;
-//import org.junit.jupiter.api.Test;
-//import org.junit.jupiter.api.extension.ExtendWith;
-//import org.mockito.InOrder;
-//import org.mockito.Mock;
-//import org.mockito.Mockito;
-//import org.mockito.junit.jupiter.MockitoExtension;
-//
-///**
-// * {@link OrderEngine#confirmStake} 下单扣减失败回滚单元测试(任务 7.5)。
-// *
-// * <p>对应设计文档"错误处理 - 下单扣减失败(5.12)"与需求 5.12:当资产服务扣减本金的同步 gRPC 调用失败时,
-// * 下单引擎应整体回滚、不创建订单、不写入募集金额,并直接返回错误码 {@code 50001}
-// * ({@link StakingErrorCode#ASSET_GRPC_FAILED})。
-// *
-// * <p>本测试以 Mockito 模拟全部依赖({@link StakingProductMapper}、{@link MarketPriceClient}、
-// * {@link AssetGrpcClient}、{@link StakingOrderMapper}),让 {@code confirmStake} 走到第 8 步"扣减本金"时由
-// * {@link AssetGrpcClient#deductAvailableBalance} 抛出 {@link StakingException}(50001),验证:
-// *
-// * <ol>
-// *   <li>抛出 {@link StakingException} 且错误码为 {@code 50001}(需求 5.12);
-// *   <li>不创建订单:{@code stakingOrderMapper.insert(..)} 从未被调用(扣减失败发生在订单落库之前);
-// *   <li>调用顺序为"先乐观锁累加募集额 → 再扣减本金",扣减抛异常后不再触达 {@code insert},
-// *       佐证"扣减失败不产生订单"。
-// * </ol>
-// *
-// * <p><b>关于"不累加募集金额"的说明:</b>{@code confirmStake} 标注
-// * {@code @Transactional(rollbackFor = Exception.class)},{@link StakingProductMapper#increaseRaisedAmount}
-// * 的乐观锁累加与订单落库处于同一本地事务内,且扣减本金的 gRPC 调用置于事务内、落库之前。真实运行环境下,
-// * 一旦扣减抛出异常,Spring 事务代理会回滚该事务,已执行的 {@code increaseRaisedAmount} 累加随之被撤销
-// * (即"不写募集金额"由 {@code @Transactional} 的事务语义保证,该回滚行为由集成测试 / 事务语义覆盖)。
-// *
-// * <p>在本单元测试中,{@code OrderEngine} 通过 {@code new} 直接构造、依赖均为 Mockito mock,不存在事务代理,
-// * 故无法真正验证数据库层面的回滚;因此本测试通过"{@code never insert} + 异常码 50001 + 调用顺序"三点
-// * 证明"扣减失败不创建订单",并以注释明确募集额回滚由事务语义保证。
-// *
-// * <p>Requirements: 5.12
-// */
-//@ExtendWith(MockitoExtension.class)
-//@DisplayName("OrderEngine.confirmStake 下单扣减失败回滚测试(5.12)")
-//class OrderEngineDeductRollbackTest {
-//
-//    /** 测试用户 UID。 */
-//    private static final long USER_ID = 1001L;
-//
-//    /** 测试产品 ID。 */
-//    private static final long PRODUCT_ID = 9001L;
-//
-//    /** 计价币种,固定为 BEX。 */
-//    private static final String CURRENCY_BEX = "BEX";
-//
-//    /** 订单 Mapper(被 mock):用于幂等查重与订单落库。 */
-//    @Mock
-//    private StakingOrderMapper stakingOrderMapper;
-//
-//    /** 产品 Mapper(被 mock):用于查询产品定义与乐观锁累加募集额。 */
-//    @Mock
-//    private StakingProductMapper stakingProductMapper;
-//
-//    /** 行情价格客户端(被 mock):返回入场价格。 */
-//    @Mock
-//    private MarketPriceClient marketPriceClient;
-//
-//    /** 资产 gRPC 客户端(被 mock):返回足够余额,扣减本金时抛出 50001。 */
-//    @Mock
-//    private AssetGrpcClient assetGrpcClient;
-//
-//    private OrderEngine orderEngine;
-//
-//    @BeforeEach
-//    void setUp() {
-//        // 依赖顺序与 OrderEngine 字段声明一致:orderMapper、productMapper、priceClient、assetClient
-//        orderEngine = new OrderEngine(stakingOrderMapper, stakingProductMapper, marketPriceClient, assetGrpcClient);
-//    }
-//
-//    /**
-//     * 构造一个合法的无限制(UNLIMITED)模式质押产品,募集额度充足、版本号为 0。
-//     *
-//     * @return 合法产品实体
-//     */
-//    private StakingProduct validProduct() {
-//        return StakingProduct.builder()
-//                .id(PRODUCT_ID)
-//                .productType(ProductType.STAKING.getValue())
-//                .subMode(SubscriptionMode.UNLIMITED.getValue())
-//                .minAmount(new BigDecimal("100"))
-//                .maxAmount(new BigDecimal("1000000"))
-//                .totalCapacity(new BigDecimal("10000000"))
-//                .raisedAmount(BigDecimal.ZERO)
-//                .lockDays(90)
-//                .version(0)
-//                .deleted(0)
-//                .build();
-//    }
-//
-//    /**
-//     * 构造一个合法的下单请求。
-//     *
-//     * @return 下单请求 DTO
-//     */
-//    private StakingOrderReq validReq() {
-//        StakingOrderReq req = new StakingOrderReq();
-//        req.setProductId(PRODUCT_ID);
-//        req.setStakingNum(new BigDecimal("1000"));
-//        req.setIdempotentKey("idem-key-deduct-fail");
-//        return req;
-//    }
-//
-//    @Test
-//    @DisplayName("资产扣减失败:抛 StakingException(50001)、不创建订单(需求 5.12)")
-//    void confirmStakeShouldRollbackAndNotCreateOrderWhenDeductFails() {
-//        StakingProduct product = validProduct();
-//        StakingOrderReq req = validReq();
-//
-//        // 1. 幂等查重:不存在相同 (userId, idempotentKey) 的订单
-//        when(stakingOrderMapper.selectOne(any())).thenReturn(null);
-//        // 2. 产品存在且合法
-//        when(stakingProductMapper.selectById(PRODUCT_ID)).thenReturn(product);
-//        // 3. 可用余额充足(大于质押数量 1000)
-//        when(assetGrpcClient.getAvailableBalance(USER_ID, CURRENCY_BEX)).thenReturn(new BigDecimal("100000"));
-//        // 4. 行情价格正常返回
-//        when(marketPriceClient.getBexUsdtPrice()).thenReturn(new BigDecimal("0.5"));
-//        // 5. 乐观锁累加募集额成功(返回 1)
-//        when(stakingProductMapper.increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt()))
-//                .thenReturn(1);
-//        // 6. 扣减本金失败:抛出资产服务调用失败异常(50001)
-//        Mockito.doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务扣减失败"))
-//                .when(assetGrpcClient)
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//
-//        // 执行并断言:抛出 StakingException 且错误码为 50001(需求 5.12)
-//        assertThatThrownBy(() -> orderEngine.confirmStake(USER_ID, req))
-//                .isInstanceOf(StakingException.class)
-//                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-//                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-//
-//        // 断言:不创建订单——扣减失败发生在订单落库之前,insert 从未被调用
-//        verify(stakingOrderMapper, never()).insert(any(StakingOrder.class));
-//    }
-//
-//    @Test
-//    @DisplayName("资产扣减失败:调用顺序为先累加募集额再扣减本金,扣减抛异常后不再落库(需求 5.12)")
-//    void confirmStakeShouldDeductAfterRaiseAndSkipInsertOnFailure() {
-//        StakingProduct product = validProduct();
-//        StakingOrderReq req = validReq();
-//
-//        when(stakingOrderMapper.selectOne(any())).thenReturn(null);
-//        when(stakingProductMapper.selectById(PRODUCT_ID)).thenReturn(product);
-//        when(assetGrpcClient.getAvailableBalance(USER_ID, CURRENCY_BEX)).thenReturn(new BigDecimal("100000"));
-//        when(marketPriceClient.getBexUsdtPrice()).thenReturn(new BigDecimal("0.5"));
-//        when(stakingProductMapper.increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt()))
-//                .thenReturn(1);
-//        Mockito.doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务扣减失败"))
-//                .when(assetGrpcClient)
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//
-//        assertThatThrownBy(() -> orderEngine.confirmStake(USER_ID, req)).isInstanceOf(StakingException.class);
-//
-//        // 调用顺序:先乐观锁累加募集额(increaseRaisedAmount),再同步扣减本金(deductAvailableBalance)。
-//        // 扣减位于落库之前,真实环境下扣减失败将触发 @Transactional 回滚已累加的募集额(不写募集金额)。
-//        InOrder inOrder = Mockito.inOrder(stakingProductMapper, assetGrpcClient);
-//        inOrder.verify(stakingProductMapper).increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt());
-//        inOrder.verify(assetGrpcClient)
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//
-//        // 扣减失败后不再触达订单落库
-//        verify(stakingOrderMapper, never()).insert(any(StakingOrder.class));
-//    }
-//}

+ 0 - 352
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/OrderIdempotencyPropertyTest.java

@@ -1,352 +0,0 @@
-//package com.bex.staking.engine;
-//
-//import static org.assertj.core.api.Assertions.assertThat;
-//import static org.mockito.ArgumentMatchers.any;
-//import static org.mockito.ArgumentMatchers.anyInt;
-//import static org.mockito.ArgumentMatchers.anyLong;
-//import static org.mockito.ArgumentMatchers.anyString;
-//import static org.mockito.ArgumentMatchers.eq;
-//import static org.mockito.Mockito.doAnswer;
-//import static org.mockito.Mockito.mock;
-//import static org.mockito.Mockito.times;
-//import static org.mockito.Mockito.verify;
-//import static org.mockito.Mockito.when;
-//
-//import com.bex.staking.entity.StakingOrder;
-//import com.bex.staking.entity.StakingProduct;
-//import com.bex.staking.enums.OrderStatus;
-//import com.bex.staking.enums.ProductType;
-//import com.bex.staking.enums.SubscriptionMode;
-//import com.bex.staking.grpc.AssetGrpcClient;
-//import com.bex.staking.grpc.MarketPriceClient;
-//import com.bex.staking.mapper.StakingOrderMapper;
-//import com.bex.staking.mapper.StakingProductMapper;
-//import com.bex.staking.model.dto.StakingOrderReq;
-//import java.math.BigDecimal;
-//import java.util.ArrayList;
-//import java.util.HashMap;
-//import java.util.List;
-//import java.util.Map;
-//import net.jqwik.api.Arbitraries;
-//import net.jqwik.api.Arbitrary;
-//import net.jqwik.api.Example;
-//import net.jqwik.api.ForAll;
-//import net.jqwik.api.Property;
-//import net.jqwik.api.Provide;
-//import net.jqwik.api.constraints.AlphaChars;
-//import net.jqwik.api.constraints.StringLength;
-//import org.springframework.dao.DuplicateKeyException;
-//
-///**
-// * {@link OrderEngine#confirmStake} 写接口幂等性(确认质押部分)属性测试(PBT,jqwik,Property 10)。
-// *
-// * <p>对应设计文档 Correctness Properties 的 Property 10「写接口幂等性」中的确认质押分支:<em>对任意</em>
-// * 携带相同 {@code idempotentKey} 的确认质押请求重复或并发提交 N(N≥1)次,系统最终只创建一笔订单,且对资产服务
-// * 的本金扣减净次数恰为一次。
-// *
-// * <h2>被测组件的三重幂等保障</h2>
-// *
-// * <p>{@link OrderEngine#confirmStake(Long, StakingOrderReq)} 通过以下三重机制保证幂等(见其类注释):
-// *
-// * <ol>
-// *   <li><b>{@code @Idempotent} 分布式锁</b>:基于 Redisson 对同一 {@code (userId, idempotentKey)} 的并发请求互斥
-// *       (需求 14.1、14.2)。<b>该切面在直接 {@code new OrderEngine(...)} 的单元测试中不生效</b>,分布式锁的物理
-// *       行为由集成测试 17.4 覆盖,不在本属性测试范围内。
-// *   <li><b>幂等查重(findExistingOrder)</b>:按 {@code (userId, idempotentKey)} 查询是否已存在订单,命中则直接
-// *       返回成功结果,<b>不进入后续扣减与落库</b>(需求 5.11)。
-// *   <li><b>数据库唯一约束 {@code uk_user_idem(user_id, idempotent_key)}</b>:作为落库兜底,并发穿透至 insert 时
-// *       触发 {@link DuplicateKeyException},引擎据此复用既有订单结果(需求 5.10)。
-// * </ol>
-// *
-// * <p>本测试在锁串行化语义下,聚焦验证「幂等查重 + 唯一约束兜底」保证的两个不变量:<b>只创建一笔订单</b>(需求
-// * 5.10)与<b>本金净扣减恰好一次</b>(需求 5.11)。
-// *
-// * <h2>内存 mock 建模(不依赖数据库、Redisson、gRPC、Spring)</h2>
-// *
-// * <ul>
-// *   <li><b>{@link StakingOrderMapper}</b>:用内存 {@link Map}(键为 {@code idempotentKey})模拟。
-// *       {@code selectOne}(即 findExistingOrder)按 {@code (userId, idempotentKey)} 返回已存在订单;
-// *       {@code insert} 时若该键已存在则抛 {@link DuplicateKeyException}(模拟 {@code uk_user_idem} 落库冲突),
-// *       否则存入并返回 1。
-// *   <li><b>{@link AssetGrpcClient}#deductAvailableBalance</b>:用内存计数器按调用记录 {@code referenceId}
-// *       (即 orderNo),统计本金实际扣减的净次数。注意每次 {@code confirmStake} 都会生成新的 orderNo,因此
-// *       幂等关键不在「下游按 referenceId 去重」,而在「重复请求在扣减<em>之前</em>被幂等查重拦截、根本不进入扣减」。
-// *   <li><b>{@link StakingProductMapper}</b>:mock 产品存在、{@code increaseRaisedAmount} 返回 1(募集额度充足)。
-// *   <li><b>{@link MarketPriceClient}</b>:返回正的 BEX/USDT 入场价格。
-// * </ul>
-// *
-// * <p>「并发重复提交」在分布式锁串行化后等价于<strong>顺序重复提交</strong>:每个请求开始时,前一个已成功的请求
-// * 已落库且对当前请求可见,故当前请求的幂等查重命中既有订单、直接返回,不再扣减、不再落库——这正是
-// * {@code selectOne} 在内存模型中返回已存订单所表达的语义。
-// *
-// * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定数值关系(忽略标度差异,
-// * 对齐数据库 {@code DECIMAL(36,18)} 精度与生产代码内部判定方式)。
-// *
-// * <p>Validates: Requirements 5.10, 5.11, 14.1, 14.2
-// */
-//class OrderIdempotencyPropertyTest {
-//
-//    /** 固定测试用户 UID。 */
-//    private static final long USER_ID = 70001L;
-//
-//    /** 计价币种,固定为 BEX。 */
-//    private static final String CURRENCY_BEX = "BEX";
-//
-//    /** 测试产品 ID。 */
-//    private static final long PRODUCT_ID = 9001L;
-//
-//    /** 充足的可用余额(远大于最大可能的质押数量 1e9),确保余额校验恒通过。 */
-//    private static final BigDecimal HUGE_BALANCE = new BigDecimal("1000000000000");
-//
-//    // ==================== 生成器 ====================
-//
-//    /** 合法质押数量:[100, 1e9],scale=18,恒满足 UNLIMITED 模式 minAmount(100) ≤ x ≤ maxAmount(1e9)。 */
-//    @Provide
-//    Arbitrary<BigDecimal> stakingNum() {
-//        return Arbitraries.bigDecimals()
-//                .between(new BigDecimal("100"), new BigDecimal("1000000000"))
-//                .ofScale(18)
-//                .filter(v -> v.compareTo(new BigDecimal("100")) >= 0);
-//    }
-//
-//    /** 正的 BEX/USDT 入场价格:(0, 1e5],scale=18。 */
-//    @Provide
-//    Arbitrary<BigDecimal> price() {
-//        return Arbitraries.bigDecimals()
-//                .between(new BigDecimal("0.000001"), new BigDecimal("100000"))
-//                .ofScale(18)
-//                .filter(v -> v.signum() > 0);
-//    }
-//
-//    /** 重复提交次数 N(1~12),模拟并发请求经分布式锁串行化后的顺序重复提交。 */
-//    @Provide
-//    Arbitrary<Integer> repeatCount() {
-//        return Arbitraries.integers().between(1, 12);
-//    }
-//
-//    // ==================== 测试夹具 ====================
-//
-//    /**
-//     * 内存下单夹具:装配 mock 依赖的 {@link OrderEngine},并暴露内存订单表与扣减引用号列表用于断言。
-//     *
-//     * @param orderEngine 被测下单引擎(mock 依赖、无 AOP / 事务代理)
-//     * @param orderStore 内存订单表(键为 idempotentKey),模拟 {@code uk_user_idem} 唯一性
-//     * @param deductRefs 每次本金扣减记录的 referenceId(orderNo),其长度即净扣减次数
-//     */
-//    private record OrderFixture(
-//            OrderEngine orderEngine, Map<String, StakingOrder> orderStore, List<String> deductRefs) {}
-//
-//    /**
-//     * 构造一个无限制(UNLIMITED)模式的合法质押产品:最小 100 BEX、最大与总募集上限充足、版本号为 0。
-//     *
-//     * @return 合法产品实体
-//     */
-//    private StakingProduct validProduct() {
-//        return StakingProduct.builder()
-//                .id(PRODUCT_ID)
-//                .productType(ProductType.STAKING.getValue())
-//                .subMode(SubscriptionMode.UNLIMITED.getValue())
-//                .minAmount(new BigDecimal("100"))
-//                .maxAmount(new BigDecimal("1000000000"))
-//                .totalCapacity(new BigDecimal("100000000000000"))
-//                .raisedAmount(BigDecimal.ZERO)
-//                .lockDays(90)
-//                .lockStartTime(1_700_000_000_000L)
-//                .version(0)
-//                .deleted(0)
-//                .build();
-//    }
-//
-//    /**
-//     * 装配内存版 {@link OrderEngine}:
-//     *
-//     * <ul>
-//     *   <li>{@code stakingOrderMapper.selectOne} 按内存订单表返回已存在订单(幂等查重命中);
-//     *   <li>{@code stakingOrderMapper.insert} 在键已存在时抛 {@link DuplicateKeyException}(模拟 uk_user_idem),
-//     *       否则存入并返回 1;
-//     *   <li>{@code assetGrpcClient.deductAvailableBalance} 记录每次扣减的 referenceId(orderNo),统计净扣减次数;
-//     *   <li>产品存在、募集额度充足、价格为正、余额充足。
-//     * </ul>
-//     *
-//     * @return 装配完成的测试夹具
-//     */
-//    private OrderFixture buildFixture() {
-//        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-//        StakingProductMapper productMapper = mock(StakingProductMapper.class);
-//        MarketPriceClient marketPriceClient = mock(MarketPriceClient.class);
-//        AssetGrpcClient assetGrpcClient = mock(AssetGrpcClient.class);
-//
-//        // 内存订单表:键为 idempotentKey,模拟 (user_id, idempotent_key) 的唯一性约束 uk_user_idem
-//        Map<String, StakingOrder> orderStore = new HashMap<>();
-//        // 每次本金扣减记录的 referenceId(orderNo),列表长度即本金净扣减次数
-//        List<String> deductRefs = new ArrayList<>();
-//
-//        // 幂等查重:selectOne 返回内存中相同 idempotentKey 的既有订单(命中则不再扣减、不再落库)
-//        when(orderMapper.selectOne(any())).thenAnswer(inv -> {
-//            for (StakingOrder o : orderStore.values()) {
-//                if (USER_ID == o.getUserId()) {
-//                    return o;
-//                }
-//            }
-//            return null;
-//        });
-//        // 落库:键已存在 → 抛 DuplicateKeyException(模拟 uk_user_idem 冲突);否则存入
-//        when(orderMapper.insert(any(StakingOrder.class))).thenAnswer(inv -> {
-//            StakingOrder order = inv.getArgument(0);
-//            if (orderStore.containsKey(order.getIdempotentKey())) {
-//                throw new DuplicateKeyException("Duplicate entry for key 'uk_user_idem'");
-//            }
-//            orderStore.put(order.getIdempotentKey(), order);
-//            return 1;
-//        });
-//
-//        // 产品存在且合法;募集额度充足(乐观锁累加恒返回 1)
-//        when(productMapper.selectById(PRODUCT_ID)).thenReturn(validProduct());
-//        when(productMapper.increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt()))
-//                .thenReturn(1);
-//
-//        // 价格为正、可用余额充足(恒大于任意质押数量)
-//        when(marketPriceClient.getBexUsdtPrice()).thenReturn(new BigDecimal("0.5"));
-//        when(assetGrpcClient.getAvailableBalance(anyLong(), anyString())).thenReturn(HUGE_BALANCE);
-//
-//        // 本金扣减:记录 referenceId(参数下标 3 为 orderNo),用于统计净扣减次数
-//        doAnswer(inv -> {
-//                    deductRefs.add(inv.getArgument(3));
-//                    return null;
-//                })
-//                .when(assetGrpcClient)
-//                .deductAvailableBalance(anyLong(), anyString(), anyString(), anyString(), anyString());
-//
-//        OrderEngine orderEngine = new OrderEngine(orderMapper, productMapper, marketPriceClient, assetGrpcClient);
-//        return new OrderFixture(orderEngine, orderStore, deductRefs);
-//    }
-//
-//    /**
-//     * 构造携带指定参数的下单请求。
-//     *
-//     * @param idempotentKey 幂等键
-//     * @param stakingNum 质押数量
-//     * @return 下单请求 DTO
-//     */
-//    private StakingOrderReq buildReq(String idempotentKey, BigDecimal stakingNum) {
-//        StakingOrderReq req = new StakingOrderReq();
-//        req.setProductId(PRODUCT_ID);
-//        req.setStakingNum(stakingNum);
-//        req.setIdempotentKey(idempotentKey);
-//        return req;
-//    }
-//
-//    // ==================== Property 10:写接口幂等性(确认质押部分) ====================
-//
-//    // Feature: staking-service, Property 10: 写接口幂等性(确认质押部分)
-//    // 对任意相同 (userId, idempotentKey) 的 N(1~12)次顺序重复提交(模拟并发请求经分布式锁串行化):
-//    //   (1) 仅创建一笔订单(内存订单表中该 key 只有一条);
-//    //   (2) 本金净扣减恰好一次(扣减计数 == 1);
-//    //   (3) 全部 N 次调用均返回 true(首次创建,后续命中幂等查重)。
-//    // Validates: Requirements 5.10, 5.11, 14.1, 14.2
-//    @Property(tries = 100)
-//    void repeatedConfirmStakeCreatesOneOrderAndDeductsOnce(
-//            @ForAll("stakingNum") BigDecimal stakingNum,
-//            @ForAll("repeatCount") int repeats,
-//            @ForAll @AlphaChars @StringLength(min = 8, max = 24) String idempotentKey) {
-//        OrderFixture fixture = buildFixture();
-//        StakingOrderReq req = buildReq(idempotentKey, stakingNum);
-//
-//        // 串行重复提交 N 次(锁序列化语义:前一次已落库且对后续可见)
-//        List<Boolean> results = new ArrayList<>();
-//        for (int i = 0; i < repeats; i++) {
-//            results.add(fixture.orderEngine().confirmStake(USER_ID, req));
-//        }
-//
-//        // (3) 全部调用均返回 true
-//        assertThat(results).as("全部 %d 次重复提交均应返回 true", repeats).containsOnly(Boolean.TRUE);
-//
-//        // (1) 仅创建一笔订单:内存订单表中该 idempotentKey 仅一条
-//        assertThat(fixture.orderStore()).as("相同幂等键重复提交只应创建一笔订单").hasSize(1);
-//        assertThat(fixture.orderStore()).as("唯一订单的键应为请求的 idempotentKey").containsKey(idempotentKey);
-//
-//        // 唯一订单的关键字段:归属用户、状态 HOLDING、质押数量、幂等键一致
-//        StakingOrder created = fixture.orderStore().get(idempotentKey);
-//        assertThat(created.getUserId()).as("订单应归属下单用户").isEqualTo(USER_ID);
-//        assertThat(created.getStatus()).as("新建订单状态应为 HOLDING").isEqualTo(OrderStatus.HOLDING.getValue());
-//        assertThat(created.getStakeAmount().compareTo(stakingNum))
-//                .as("订单质押数量应等于请求数量")
-//                .isZero();
-//
-//        // (2) 本金净扣减恰好一次:后续重复请求在扣减之前被幂等查重拦截
-//        assertThat(fixture.deductRefs()).as("无论重复提交多少次,本金净扣减只发生一次").hasSize(1);
-//        // 唯一一次扣减的 referenceId 即新建订单的 orderNo
-//        assertThat(fixture.deductRefs().get(0))
-//                .as("唯一一次扣减的 referenceId 应为新建订单的 orderNo")
-//                .isEqualTo(created.getOrderNo());
-//    }
-//
-//    // Feature: staking-service, Property 10: 写接口幂等性(确认质押部分)
-//    // 子性质(唯一约束 uk_user_idem 兜底,需求 5.10):当幂等查重未命中(pre-check 读穿透)但落库触发
-//    // uk_user_idem 唯一约束冲突时,confirmStake 捕获 DuplicateKeyException、复用既有订单结果返回 true,
-//    // 不创建第二笔订单(既有订单数量不变)。
-//    // 说明:该兜底分支发生在本金扣减之后,因此该请求自身会扣减一次;在分布式锁失效的极端竞态下净扣减一次
-//    // 由分布式锁保证(集成测试 17.4),本子性质仅锚定「唯一约束兜底不产生重复订单」这一不变量。
-//    // Validates: Requirements 5.10, 14.2
-//    @Property(tries = 100)
-//    void duplicateKeyFallbackReusesExistingOrderWithoutCreatingNew(
-//            @ForAll("stakingNum") BigDecimal stakingNum,
-//            @ForAll @AlphaChars @StringLength(min = 8, max = 24) String idempotentKey) {
-//        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-//        StakingProductMapper productMapper = mock(StakingProductMapper.class);
-//        MarketPriceClient marketPriceClient = mock(MarketPriceClient.class);
-//        AssetGrpcClient assetGrpcClient = mock(AssetGrpcClient.class);
-//
-//        // 既有(竞态获胜方已提交)订单,落库冲突后由 findExistingOrder 复用
-//        StakingOrder winner = StakingOrder.builder()
-//                .orderNo("STK-WINNER-0001")
-//                .userId(USER_ID)
-//                .productId(PRODUCT_ID)
-//                .idempotentKey(idempotentKey)
-//                .stakeAmount(stakingNum)
-//                .status(OrderStatus.HOLDING.getValue())
-//                .build();
-//
-//        // 幂等查重时序模拟:pre-check 读穿透返回 null(未命中);落库抛 uk_user_idem 冲突后再次查重返回既有订单
-//        when(orderMapper.selectOne(any())).thenReturn(null).thenReturn(winner);
-//        // 落库直接触发唯一约束冲突(模拟并发穿透至 uk_user_idem)
-//        when(orderMapper.insert(any(StakingOrder.class)))
-//                .thenThrow(new DuplicateKeyException("Duplicate entry for key 'uk_user_idem'"));
-//
-//        when(productMapper.selectById(PRODUCT_ID)).thenReturn(validProduct());
-//        when(productMapper.increaseRaisedAmount(eq(PRODUCT_ID), any(BigDecimal.class), anyInt()))
-//                .thenReturn(1);
-//        when(marketPriceClient.getBexUsdtPrice()).thenReturn(new BigDecimal("0.5"));
-//        when(assetGrpcClient.getAvailableBalance(anyLong(), anyString())).thenReturn(HUGE_BALANCE);
-//
-//        OrderEngine orderEngine = new OrderEngine(orderMapper, productMapper, marketPriceClient, assetGrpcClient);
-//        StakingOrderReq req = buildReq(idempotentKey, stakingNum);
-//
-//        Boolean result = orderEngine.confirmStake(USER_ID, req);
-//
-//        // 落库唯一约束兜底命中:复用既有订单结果返回 true
-//        assertThat(result).as("uk_user_idem 冲突后应复用既有订单返回 true").isTrue();
-//        // 仅尝试落库一次(冲突后不再重试创建),未产生第二笔订单
-//        verify(orderMapper, times(1)).insert(any(StakingOrder.class));
-//    }
-//
-//    // ==================== 边界锚定示例 ====================
-//
-//    // Feature: staking-service, Property 10: 写接口幂等性(确认质押部分)
-//    // 边界:同一 (userId, idempotentKey) 连续提交 5 次(固定 1000 BEX),仅创建一笔订单、净扣减一次、全部返回 true。
-//    // Validates: Requirements 5.10, 5.11, 14.1, 14.2
-//    @Example
-//    void fiveRepeatedSubmissionsCreateSingleOrderAndDeductOnce() {
-//        OrderFixture fixture = buildFixture();
-//        StakingOrderReq req = buildReq("idem-key-fixed-5x", new BigDecimal("1000"));
-//
-//        for (int i = 0; i < 5; i++) {
-//            assertThat(fixture.orderEngine().confirmStake(USER_ID, req))
-//                    .as("第 %d 次提交应返回 true", i + 1)
-//                    .isTrue();
-//        }
-//
-//        assertThat(fixture.orderStore()).as("连续 5 次提交只应创建一笔订单").hasSize(1);
-//        assertThat(fixture.deductRefs()).as("连续 5 次提交本金只扣减一次").hasSize(1);
-//    }
-//}

+ 0 - 167
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitCalculatorDailyProfitPropertyTest.java

@@ -1,167 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.math.BigDecimal;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-import net.jqwik.api.constraints.IntRange;
-
-/**
- * {@link ProfitCalculator} 质押每日收益计算的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 14: 每日收益计算不变量。
- *
- * <p>验证质押订单单日派发收益的「公式不变量」与「正比性」:
- *
- * <ul>
- *   <li><b>公式不变量</b>:单日收益 {@code = 本金 × 日收益率},其中日收益率
- *       {@code dailyRate = periodRate / 100 / periodDay}(periodRate 为百分数口径,如 6 表示 6%),
- *       在约定 scale=18、HALF_UP 下「先求日利率系数、再与本金精确相乘」,<b>不二次舍入</b>;</li>
- *   <li><b>日利率口径</b>:{@code dailyRate(periodRate, periodDay)} 等于
- *       {@code periodRate.divide(100 × periodDay, 18, HALF_UP)};</li>
- *   <li><b>正比性</b>:单日收益与订单本金严格成正比,即 {@code dailyProfit(k × P) == k × dailyProfit(P)}
- *       (精确相乘,不二次舍入)。</li>
- * </ul>
- *
- * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 断言数值相等(忽略 scale 差异)。
- *
- * <p>Validates: Requirements 9.2
- */
-class ProfitCalculatorDailyProfitPropertyTest {
-
-    /** 百分数转小数的除数:100(与实现口径一致)。 */
-    private static final BigDecimal HUNDRED = new BigDecimal("100");
-
-    // ==================== 生成器 ====================
-
-    /** 正本金 P:(0, 1e9],scale=18,覆盖整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> positivePrincipal() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000000000000000001"), new BigDecimal("1000000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 周期收益率 periodRate(百分数口径,如 6 表示 6%):(0, 1000],scale=8,覆盖整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> positivePeriodRate() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.00000001"), new BigDecimal("1000"))
-                .ofScale(8)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 正比性缩放因子 k:(0, 1000],scale=6,覆盖整数倍与小数倍。 */
-    @Provide
-    Arbitrary<BigDecimal> scaleFactor() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000001"), new BigDecimal("1000"))
-                .ofScale(6)
-                .filter(v -> v.signum() > 0);
-    }
-
-    // ==================== 参考实现(表达需求公式,约定 scale=18、HALF_UP) ====================
-
-    /** 参考:日收益率 = periodRate / 100 / periodDay = periodRate / (100 × periodDay),scale=18、HALF_UP。 */
-    private static BigDecimal expectedDailyRate(BigDecimal periodRate, int periodDay) {
-        BigDecimal divisor = HUNDRED.multiply(BigDecimal.valueOf(periodDay));
-        return periodRate.divide(divisor, ProfitCalculator.SCALE, ProfitCalculator.ROUNDING);
-    }
-
-    // ==================== Property 14:日利率口径不变量 ====================
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 日利率口径:dailyRate(periodRate, periodDay) == periodRate / (100 × periodDay)(scale=18, HALF_UP)
-    // Validates: Requirements 9.2
-    @Property(tries = 100)
-    void dailyRateMatchesFormula(
-            @ForAll("positivePeriodRate") BigDecimal periodRate, @ForAll @IntRange(min = 1, max = 3650) int periodDay) {
-
-        BigDecimal actual = ProfitCalculator.dailyRate(periodRate, periodDay);
-        BigDecimal expected = expectedDailyRate(periodRate, periodDay);
-
-        assertEquals(0, actual.compareTo(expected), "日利率应等于 periodRate / 100 / periodDay(scale=18, HALF_UP)");
-    }
-
-    // ==================== Property 14:单日收益公式不变量 ====================
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 公式不变量:dailyProfit(P, periodRate, periodDay) == P × dailyRate(periodRate, periodDay)(精确相乘,不二次舍入)
-    // Validates: Requirements 9.2
-    @Property(tries = 100)
-    void dailyProfitEqualsPrincipalTimesDailyRate(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positivePeriodRate") BigDecimal periodRate,
-            @ForAll @IntRange(min = 1, max = 3650) int periodDay) {
-
-        BigDecimal actual = ProfitCalculator.dailyProfit(principal, periodRate, periodDay);
-        BigDecimal expected = principal.multiply(ProfitCalculator.dailyRate(periodRate, periodDay));
-
-        assertEquals(0, actual.compareTo(expected), "单日收益应等于 本金 × 日收益率(精确相乘,不二次舍入)");
-    }
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 公式不变量(整体口径):dailyProfit == P × periodRate / 100 / periodDay
-    // Validates: Requirements 9.2
-    @Property(tries = 100)
-    void dailyProfitMatchesEndToEndFormula(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positivePeriodRate") BigDecimal periodRate,
-            @ForAll @IntRange(min = 1, max = 3650) int periodDay) {
-
-        BigDecimal actual = ProfitCalculator.dailyProfit(principal, periodRate, periodDay);
-        BigDecimal expected = principal.multiply(expectedDailyRate(periodRate, periodDay));
-
-        assertEquals(0, actual.compareTo(expected), "单日收益应等于 本金 × (periodRate / 100 / periodDay)");
-    }
-
-    // ==================== Property 14:正比性 ====================
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 正比性:dailyProfit(k × P, ...) == k × dailyProfit(P, ...)(单日收益与本金成正比)
-    // Validates: Requirements 9.2
-    @Property(tries = 100)
-    void dailyProfitIsProportionalToPrincipal(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positivePeriodRate") BigDecimal periodRate,
-            @ForAll @IntRange(min = 1, max = 3650) int periodDay,
-            @ForAll("scaleFactor") BigDecimal k) {
-
-        BigDecimal scaledProfit = ProfitCalculator.dailyProfit(principal.multiply(k), periodRate, periodDay);
-        BigDecimal profitScaled =
-                ProfitCalculator.dailyProfit(principal, periodRate, periodDay).multiply(k);
-
-        assertEquals(0, scaledProfit.compareTo(profitScaled), "单日收益应与本金成正比:dailyProfit(k×P) == k×dailyProfit(P)");
-    }
-
-    // ==================== 锚定示例(防止公式整体性错误) ====================
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 整除锚定:periodRate=9(%), periodDay=90 → dailyRate = 9/9000 = 0.001;P=1000 → 单日收益 = 1.0 BEX
-    // Validates: Requirements 9.2
-    @Example
-    void dailyProfitAnchorCleanDivision() {
-        BigDecimal dailyRate = ProfitCalculator.dailyRate(new BigDecimal("9"), 90);
-        assertEquals(0, dailyRate.compareTo(new BigDecimal("0.001")), "periodRate=9%, periodDay=90 → 日利率 0.001");
-
-        BigDecimal dailyProfit = ProfitCalculator.dailyProfit(new BigDecimal("1000"), new BigDecimal("9"), 90);
-        assertEquals(0, dailyProfit.compareTo(new BigDecimal("1")), "P=1000, 日利率 0.001 → 单日收益 1.0 BEX");
-    }
-
-    // Feature: staking-service, Property 14: 每日收益计算不变量
-    // 口径锚定(与设计 Mock 样本一致):periodRate=6(%), periodDay=90, P=1000
-    // → 单日收益 = 1000 × dailyRate(6,90)(精确相乘),与 dailyRate×P 严格一致
-    // Validates: Requirements 9.2
-    @Example
-    void dailyProfitAnchorMatchesMockSample() {
-        BigDecimal dailyProfit = ProfitCalculator.dailyProfit(new BigDecimal("1000"), new BigDecimal("6"), 90);
-        BigDecimal expected = new BigDecimal("1000").multiply(ProfitCalculator.dailyRate(new BigDecimal("6"), 90));
-        assertEquals(0, dailyProfit.compareTo(expected), "P=1000, periodRate=6%, periodDay=90 → 单日收益 = 本金 × 日利率");
-    }
-}

+ 0 - 231
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitCalculatorMaturityInterestPropertyTest.java

@@ -1,231 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.enums.InterestType;
-import java.math.BigDecimal;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-import net.jqwik.api.constraints.IntRange;
-
-/**
- * {@link ProfitCalculator} 私募到期利息计算的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 18: 私募到期利息计算不变量。
- *
- * <p>验证两类利息口径的「公式不变量」与「正比性」:
- *
- * <ul>
- *   <li><b>百分比类型</b>:到期利息 {@code = P × 年化收益率 × (锁仓天数 / 365)},其中 apr 为百分数(需 ÷100),
- *       在约定 scale=18、HALF_UP 下「先求年化系数、再与本金精确相乘」;</li>
- *   <li><b>固定金额类型</b>:到期利息 {@code = 单位本金固定利息额 × (P / 单位本金)},
- *       在约定 scale=18、HALF_UP 下「先求单位本金系数、再与本金精确相乘」;</li>
- *   <li><b>正比性</b>:利息与本金严格成正比,即 {@code f(k × P) == k × f(P)}(精确相乘,不二次舍入)。</li>
- * </ul>
- *
- * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 断言数值相等(忽略 scale 差异)。
- *
- * <p>Validates: Requirements 12.3, 12.4
- */
-class ProfitCalculatorMaturityInterestPropertyTest {
-
-    /** 百分数转小数的除数:100(与实现口径一致)。 */
-    private static final BigDecimal HUNDRED = new BigDecimal("100");
-
-    /** 一年天数:365(取自核心层常量,与实现口径一致)。 */
-    private static final BigDecimal DAYS_PER_YEAR = new BigDecimal(StakingConstants.DAYS_PER_YEAR);
-
-    // ==================== 生成器 ====================
-
-    /** 正本金 P:(0, 1e9],scale=18,覆盖整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> positivePrincipal() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000000000000000001"), new BigDecimal("1000000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 年化收益率 apr(百分数口径,如 24 表示 24%):(0, 1000],scale=8。 */
-    @Provide
-    Arbitrary<BigDecimal> positiveApr() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.00000001"), new BigDecimal("1000"))
-                .ofScale(8)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 单位本金固定利息额 fixedInterest(BEX 绝对数量):[0, 1e6],scale=18,允许为 0(无利息)。 */
-    @Provide
-    Arbitrary<BigDecimal> nonNegativeFixedInterest() {
-        return Arbitraries.bigDecimals()
-                .between(BigDecimal.ZERO, new BigDecimal("1000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() >= 0);
-    }
-
-    /** 单位本金基准 baseAmount(BEX 绝对数量,必须为正):[1, 1e6],scale=8。 */
-    @Provide
-    Arbitrary<BigDecimal> positiveBaseAmount() {
-        return Arbitraries.bigDecimals()
-                .between(BigDecimal.ONE, new BigDecimal("1000000"))
-                .ofScale(8)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /** 正比性缩放因子 k:(0, 1000],scale=6,覆盖整数倍与小数倍。 */
-    @Provide
-    Arbitrary<BigDecimal> scaleFactor() {
-        return Arbitraries.bigDecimals()
-                .between(new BigDecimal("0.000001"), new BigDecimal("1000"))
-                .ofScale(6)
-                .filter(v -> v.signum() > 0);
-    }
-
-    // ==================== 参考实现(表达需求公式,约定 scale=18、HALF_UP) ====================
-
-    /** 参考:百分比到期利息 = P × apr/100 × (lockDays/365),按约定单次求系数(scale18,HALF_UP)后精确相乘。 */
-    private static BigDecimal expectedPercentage(BigDecimal principal, BigDecimal apr, int lockDays) {
-        BigDecimal divisor = HUNDRED.multiply(DAYS_PER_YEAR); // 36500
-        BigDecimal factor = apr.multiply(BigDecimal.valueOf(lockDays))
-                .divide(divisor, ProfitCalculator.SCALE, ProfitCalculator.ROUNDING);
-        return principal.multiply(factor);
-    }
-
-    /** 参考:固定金额到期利息 = fixedInterest × (P/baseAmount),按约定单次求系数(scale18,HALF_UP)后精确相乘。 */
-    private static BigDecimal expectedFixedAmount(
-            BigDecimal principal, BigDecimal fixedInterest, BigDecimal baseAmount) {
-        BigDecimal factor = fixedInterest.divide(baseAmount, ProfitCalculator.SCALE, ProfitCalculator.ROUNDING);
-        return principal.multiply(factor);
-    }
-
-    // ==================== Property 18:百分比类型公式不变量 ====================
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 百分比类型:到期利息 = P × 年化收益率 × (锁仓天数 / 365)
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void percentageInterestMatchesFormula(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positiveApr") BigDecimal apr,
-            @ForAll @IntRange(min = 0, max = 3650) int lockDays) {
-
-        BigDecimal actual = ProfitCalculator.maturityInterestByPercentage(principal, apr, lockDays);
-        BigDecimal expected = expectedPercentage(principal, apr, lockDays);
-
-        assertEquals(0, actual.compareTo(expected), "百分比到期利息应等于 P × apr/100 × (lockDays/365)");
-    }
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 统一分派入口 maturityInterest(PERCENTAGE, ...) 与专用方法结果一致
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void percentageDispatchMatchesDedicatedMethod(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positiveApr") BigDecimal apr,
-            @ForAll @IntRange(min = 0, max = 3650) int lockDays) {
-
-        BigDecimal viaDispatch =
-                ProfitCalculator.maturityInterest(InterestType.PERCENTAGE, principal, apr, lockDays, null, null);
-        BigDecimal viaDedicated = ProfitCalculator.maturityInterestByPercentage(principal, apr, lockDays);
-
-        assertEquals(0, viaDispatch.compareTo(viaDedicated), "分派入口与专用方法(百分比)结果应一致");
-    }
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 百分比类型正比性:f(k × P) == k × f(P)(利息与本金成正比)
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void percentageInterestIsProportionalToPrincipal(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("positiveApr") BigDecimal apr,
-            @ForAll @IntRange(min = 0, max = 3650) int lockDays,
-            @ForAll("scaleFactor") BigDecimal k) {
-
-        BigDecimal scaledInterest =
-                ProfitCalculator.maturityInterestByPercentage(principal.multiply(k), apr, lockDays);
-        BigDecimal interestScaled =
-                ProfitCalculator.maturityInterestByPercentage(principal, apr, lockDays).multiply(k);
-
-        assertEquals(0, scaledInterest.compareTo(interestScaled), "百分比利息应与本金成正比:f(k×P) == k×f(P)");
-    }
-
-    // ==================== Property 18:固定金额类型公式不变量 ====================
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 固定金额类型:到期利息 = 单位本金固定利息额 × (P / 单位本金)
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void fixedAmountInterestMatchesFormula(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("nonNegativeFixedInterest") BigDecimal fixedInterest,
-            @ForAll("positiveBaseAmount") BigDecimal baseAmount) {
-
-        BigDecimal actual = ProfitCalculator.maturityInterestByFixedAmount(principal, fixedInterest, baseAmount);
-        BigDecimal expected = expectedFixedAmount(principal, fixedInterest, baseAmount);
-
-        assertEquals(0, actual.compareTo(expected), "固定金额到期利息应等于 fixedInterest × (P/baseAmount)");
-    }
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 统一分派入口 maturityInterest(FIXED_AMOUNT, ...) 与专用方法结果一致
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void fixedAmountDispatchMatchesDedicatedMethod(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("nonNegativeFixedInterest") BigDecimal fixedInterest,
-            @ForAll("positiveBaseAmount") BigDecimal baseAmount) {
-
-        BigDecimal viaDispatch =
-                ProfitCalculator.maturityInterest(InterestType.FIXED_AMOUNT, principal, null, 0, fixedInterest, baseAmount);
-        BigDecimal viaDedicated =
-                ProfitCalculator.maturityInterestByFixedAmount(principal, fixedInterest, baseAmount);
-
-        assertEquals(0, viaDispatch.compareTo(viaDedicated), "分派入口与专用方法(固定金额)结果应一致");
-    }
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 固定金额类型正比性:f(k × P) == k × f(P)(利息与本金成正比)
-    // Validates: Requirements 12.3, 12.4
-    @Property(tries = 100)
-    void fixedAmountInterestIsProportionalToPrincipal(
-            @ForAll("positivePrincipal") BigDecimal principal,
-            @ForAll("nonNegativeFixedInterest") BigDecimal fixedInterest,
-            @ForAll("positiveBaseAmount") BigDecimal baseAmount,
-            @ForAll("scaleFactor") BigDecimal k) {
-
-        BigDecimal scaledInterest =
-                ProfitCalculator.maturityInterestByFixedAmount(principal.multiply(k), fixedInterest, baseAmount);
-        BigDecimal interestScaled = ProfitCalculator.maturityInterestByFixedAmount(principal, fixedInterest, baseAmount)
-                .multiply(k);
-
-        assertEquals(0, scaledInterest.compareTo(interestScaled), "固定金额利息应与本金成正比:f(k×P) == k×f(P)");
-    }
-
-    // ==================== 锚定示例(防止公式整体性错误) ====================
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 百分比锚定:P=1000, apr=24(%), lockDays=365 → 1000 × 0.24 = 240
-    // Validates: Requirements 12.3
-    @Example
-    void percentageAnchorOneYearFullApr() {
-        BigDecimal interest =
-                ProfitCalculator.maturityInterestByPercentage(new BigDecimal("1000"), new BigDecimal("24"), 365);
-        assertEquals(0, interest.compareTo(new BigDecimal("240")), "P=1000, apr=24%, 365 天 → 利息 240 BEX");
-    }
-
-    // Feature: staking-service, Property 18: 私募到期利息计算不变量
-    // 固定金额锚定:fixedInterest=60 / baseAmount=1000,P=2000 → 60 × (2000/1000) = 120
-    // Validates: Requirements 12.4
-    @Example
-    void fixedAmountAnchorDoublePrincipal() {
-        BigDecimal interest = ProfitCalculator.maturityInterestByFixedAmount(
-                new BigDecimal("2000"), new BigDecimal("60"), new BigDecimal("1000"));
-        assertEquals(0, interest.compareTo(new BigDecimal("120")), "fixedInterest=60/1000, P=2000 → 利息 120 BEX");
-    }
-}

+ 0 - 229
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/ProfitClaimLedgerPropertyTest.java

@@ -1,229 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.engine.ProfitClaimCalculator.OrderClaimLedger;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.OrderStatus;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.util.ArrayList;
-import java.util.List;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link ProfitClaimCalculator} 收益领取账本守恒属性测试(Property 16)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 16「收益领取账本守恒」。被测对象是无副作用的纯函数
- * {@link ProfitClaimCalculator#settleOrder(BigDecimal, BigDecimal)} 与
- * {@link ProfitClaimCalculator#aggregatePendingProfit(java.util.Collection)} 及其结果值对象
- * {@link OrderClaimLedger},完全避免数据库、Redisson、gRPC 与 Spring 依赖。
- *
- * <p>对任意待领取收益 {@code pendingProfit} 的用户,成功领取后验证:
- *
- * <ul>
- *   <li><b>待领取归零</b>:{@code newPendingProfit == 0}。
- *   <li><b>领取金额匹配</b>:{@code claimedAmount} 恰好等于领取前的 {@code pendingProfit}。
- *   <li><b>已领取增量匹配</b>:{@code newClaimedProfit == claimedProfit + pendingProfit}。
- *   <li><b>总收益守恒</b>:{@code newPendingProfit + newClaimedProfit == pendingProfit + claimedProfit},
- *       即「已领取 + 待领取」在领取前后保持不变。
- * </ul>
- *
- * <p>金额一律以 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定相等(忽略标度差异,
- * 如 {@code 1.0} 与 {@code 1.00} 视为相等),与生产代码内部判定方式保持一致。
- */
-class ProfitClaimLedgerPropertyTest {
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 任意非负高精度金额生成器:随机 unscaledValue({@code [0, 10^15]})与随机 scale({@code [0, 18]})组合,
-     * 覆盖 0、整数、长尾小数(对齐数据库 {@code DECIMAL(36,18)} 精度)与不同数量级。
-     */
-    @Provide
-    Arbitrary<BigDecimal> nonNegativeAmount() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(15)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /** 多订单待领取收益列表生成器(长度 1~30,金额任意非负,覆盖含 0 的混合场景)。 */
-    @Provide
-    Arbitrary<List<BigDecimal>> pendingProfitList() {
-        return nonNegativeAmount().list().ofMinSize(1).ofMaxSize(30);
-    }
-
-    /** 构造一个持仓中、携带指定待领取/已领取账本的质押订单(仅用于聚合与多订单领取场景)。 */
-    private StakingOrder orderWithPending(BigDecimal pending) {
-        return StakingOrder.builder()
-                .orderNo("STK-PBT-16")
-                .userId(2001L)
-                .productId(9101L)
-                .productType("STAKING")
-                .status(OrderStatus.HOLDING.getValue())
-                .stakeAmount(new BigDecimal("1000"))
-                .cumulativeProfit(pending)
-                .pendingProfit(pending)
-                .claimedProfit(BigDecimal.ZERO)
-                .build();
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 16: 收益领取账本守恒
-    // 对任意非负 pendingProfit、claimedProfit:settleOrder 后待领取归零、领取金额 == 原待领取、
-    // 已领取增量 == 原待领取、总收益(已领取 + 待领取)守恒。
-    // Validates: Requirements 10.2, 10.3
-    @Property(tries = 100)
-    void settleOrderZeroesPendingAndConservesTotalProfit(
-            @ForAll("nonNegativeAmount") BigDecimal pendingProfit,
-            @ForAll("nonNegativeAmount") BigDecimal claimedProfit) {
-        OrderClaimLedger ledger = ProfitClaimCalculator.settleOrder(pendingProfit, claimedProfit);
-
-        // 待领取归零
-        assertThat(ledger.newPendingProfit().compareTo(BigDecimal.ZERO))
-                .as("领取后待领取收益必须归零")
-                .isZero();
-
-        // 本次领取金额 == 领取前的待领取收益(需求 10.3 划转金额)
-        assertThat(ledger.claimedAmount().compareTo(pendingProfit))
-                .as("本次领取金额应恰好等于领取前的待领取收益")
-                .isZero();
-
-        // 已领取增量恰好等于领取前的待领取收益
-        BigDecimal claimedDelta = ledger.newClaimedProfit().subtract(claimedProfit);
-        assertThat(claimedDelta.compareTo(pendingProfit))
-                .as("已领取收益增量应恰好等于领取前的待领取收益")
-                .isZero();
-
-        // newClaimedProfit == claimedProfit + pendingProfit
-        assertThat(ledger.newClaimedProfit().compareTo(claimedProfit.add(pendingProfit)))
-                .as("领取后已领取收益应等于原已领取 + 原待领取")
-                .isZero();
-
-        // 总收益守恒:newPending + newClaimed == pending + claimed
-        BigDecimal totalBefore = pendingProfit.add(claimedProfit);
-        BigDecimal totalAfter = ledger.newPendingProfit().add(ledger.newClaimedProfit());
-        assertThat(totalAfter.compareTo(totalBefore))
-                .as("总收益(已领取 + 待领取)在领取前后保持不变")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 16: 收益领取账本守恒
-    // 对已领取过(pending=0)的订单再次结算:领取金额为 0、账本不变(支撑领取幂等的账本守恒边界)。
-    // Validates: Requirements 10.2, 10.3
-    @Property(tries = 100)
-    void settleAlreadyClaimedOrderIsNoOp(@ForAll("nonNegativeAmount") BigDecimal claimedProfit) {
-        OrderClaimLedger ledger = ProfitClaimCalculator.settleOrder(BigDecimal.ZERO, claimedProfit);
-
-        assertThat(ledger.claimedAmount().compareTo(BigDecimal.ZERO))
-                .as("待领取为 0 时再次领取的金额应为 0")
-                .isZero();
-        assertThat(ledger.newPendingProfit().compareTo(BigDecimal.ZERO))
-                .as("待领取仍为 0")
-                .isZero();
-        assertThat(ledger.newClaimedProfit().compareTo(claimedProfit))
-                .as("已领取收益不变")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 16: 收益领取账本守恒
-    // 入参 null 一律按 0 处理(计算健壮性):settleOrder(null, null) → 全 0。
-    // Validates: Requirements 10.2, 10.3
-    @Property(tries = 100)
-    void settleOrderTreatsNullAsZero(
-            @ForAll("nonNegativeAmount") BigDecimal pendingProfit,
-            @ForAll("nonNegativeAmount") BigDecimal claimedProfit) {
-        // pending=null 视为 0:领取金额 0、已领取保持原值
-        OrderClaimLedger nullPending = ProfitClaimCalculator.settleOrder(null, claimedProfit);
-        assertThat(nullPending.claimedAmount().compareTo(BigDecimal.ZERO))
-                .as("pending=null 视为 0,领取金额应为 0")
-                .isZero();
-        assertThat(nullPending.newClaimedProfit().compareTo(claimedProfit))
-                .as("pending=null 时已领取保持原值")
-                .isZero();
-
-        // claimed=null 视为 0:已领取从 0 起算,增量 == 待领取
-        OrderClaimLedger nullClaimed = ProfitClaimCalculator.settleOrder(pendingProfit, null);
-        assertThat(nullClaimed.newClaimedProfit().compareTo(pendingProfit))
-                .as("claimed=null 视为 0,领取后已领取应等于原待领取")
-                .isZero();
-        assertThat(nullClaimed.newPendingProfit().compareTo(BigDecimal.ZERO))
-                .as("领取后待领取归零")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 16: 收益领取账本守恒
-    // 聚合不变量:aggregatePendingProfit(orders) == Σ pendingProfit_i(null 订单 / null 字段按 0 处理)。
-    // Validates: Requirements 10.2, 10.3
-    @Property(tries = 100)
-    void aggregatePendingProfitEqualsSumOfPending(@ForAll("pendingProfitList") List<BigDecimal> pendings) {
-        List<StakingOrder> orders = new ArrayList<>();
-        BigDecimal expected = BigDecimal.ZERO;
-        for (BigDecimal pending : pendings) {
-            orders.add(orderWithPending(pending));
-            expected = expected.add(pending);
-        }
-
-        // 注入若干 null 订单与 null 字段订单(均应按 0 处理,不影响合计)
-        orders.add(null);
-        orders.add(orderWithPending(null));
-
-        BigDecimal actual = ProfitClaimCalculator.aggregatePendingProfit(orders);
-        assertThat(actual.compareTo(expected))
-                .as("聚合待领取收益应等于各订单待领取之和(null 按 0 处理)")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 16: 收益领取账本守恒
-    // 多订单领取场景:聚合待领取 == Σ pending_i;逐笔 settleOrder 后 Σ 已领取增量 == 聚合待领取、
-    // Σ 待领取 == 0,且总收益(Σ 已领取 + Σ 待领取)守恒。
-    // Validates: Requirements 10.2, 10.3
-    @Property(tries = 100)
-    void multiOrderClaimConservesAggregateLedger(@ForAll("pendingProfitList") List<BigDecimal> pendings) {
-        List<StakingOrder> orders = new ArrayList<>();
-        for (BigDecimal pending : pendings) {
-            orders.add(orderWithPending(pending));
-        }
-
-        // 聚合待领取(领取前)
-        BigDecimal aggregatePending = ProfitClaimCalculator.aggregatePendingProfit(orders);
-
-        BigDecimal totalBefore = BigDecimal.ZERO;
-        for (StakingOrder order : orders) {
-            totalBefore = totalBefore.add(order.getPendingProfit()).add(order.getClaimedProfit());
-        }
-
-        // 逐笔领取结算
-        BigDecimal sumClaimedDelta = BigDecimal.ZERO;
-        BigDecimal sumNewPending = BigDecimal.ZERO;
-        BigDecimal totalAfter = BigDecimal.ZERO;
-        for (StakingOrder order : orders) {
-            OrderClaimLedger ledger = ProfitClaimCalculator.settleOrder(order.getPendingProfit(), order.getClaimedProfit());
-            sumClaimedDelta = sumClaimedDelta.add(ledger.newClaimedProfit().subtract(order.getClaimedProfit()));
-            sumNewPending = sumNewPending.add(ledger.newPendingProfit());
-            totalAfter = totalAfter.add(ledger.newPendingProfit()).add(ledger.newClaimedProfit());
-        }
-
-        // Σ 已领取增量 == 聚合待领取
-        assertThat(sumClaimedDelta.compareTo(aggregatePending))
-                .as("全部订单已领取增量之和应等于聚合待领取收益")
-                .isZero();
-
-        // Σ 待领取 == 0
-        assertThat(sumNewPending.compareTo(BigDecimal.ZERO))
-                .as("全部订单领取后待领取之和应为 0")
-                .isZero();
-
-        // 总收益守恒:领取前后总额不变
-        assertThat(totalAfter.compareTo(totalBefore))
-                .as("领取前后总收益(Σ 已领取 + Σ 待领取)保持不变")
-                .isZero();
-    }
-}

+ 0 - 269
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/RaisedCapacityInvariantPropertyTest.java

@@ -1,269 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.math.BigDecimal;
-import java.util.List;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * 募集上限不变量的属性测试(PBT,jqwik,Property 9)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 9「募集上限不变量」。
- *
- * <p>验证:对任意针对同一产品的下单序列,任意被接受的下单完成后,产品 {@code raisedAmount} 始终满足
- * {@code raisedAmount ≤ totalCapacity};任何会导致超出上限的下单都被拒绝。
- *
- * <h2>被测逻辑与内存模型的对应关系</h2>
- *
- * <p>募集上限校验在 SQL 层完成,见 {@code StakingProductMapper.xml} 的 {@code increaseRaisedAmount}:
- *
- * <pre>{@code
- * UPDATE staking_product
- * SET raised_amount = raised_amount + #{amount}, version = version + 1
- * WHERE id = #{id} AND version = #{version} AND deleted = 0
- *   AND raised_amount + #{amount} <= total_capacity
- * }</pre>
- *
- * <p>该语句返回受影响行数:满足 {@code raised_amount + amount <= total_capacity} 时累加并返回 1(接受),
- * 否则不更新返回 0(拒绝)。{@code OrderEngine.confirmStake(...)} 据返回 0 抛出 {@code 40005}
- * ({@code StakingErrorCode.CAPACITY_EXCEEDED})拒绝下单(见 OrderEngine 第 6 步)。
- *
- * <p>本属性测试以纯内存模型 {@link CapacityModel#tryRaise(BigDecimal)} 模拟该条件累加语义:当且仅当
- * {@code raised + amount ≤ totalCapacity} 时执行 {@code raised += amount} 并返回 {@code true}(对应 SQL 返回 1),
- * 否则保持 {@code raised} 不变并返回 {@code false}(对应 SQL 返回 0)。模型的判定条件与生产 SQL 的 WHERE 子句
- * {@code raised_amount + amount <= total_capacity} 完全对应,从而在不依赖数据库与 Spring 的前提下验证该不变量。
- *
- * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定数值大小关系(忽略标度差异,
- * 对齐数据库 {@code DECIMAL(36,18)} 精度与生产代码内部判定方式)。
- *
- * <p>Validates: Requirements 5.7
- */
-class RaisedCapacityInvariantPropertyTest {
-
-    /** {@code DECIMAL(36,18)} 的最小单位(1e-18),用于「超出上限 1 单位」的边界用例。 */
-    private static final BigDecimal ONE_UNIT = new BigDecimal("0.000000000000000001");
-
-    // ==================== 内存模型(与 increaseRaisedAmount SQL 语义一致) ====================
-
-    /**
-     * 募集上限内存模型,模拟 {@code StakingProductMapper.xml} 的 {@code increaseRaisedAmount} 条件累加语义。
-     *
-     * <p>持有当前已募集金额 {@code raised} 与总募集上限 {@code totalCapacity};{@link #tryRaise(BigDecimal)}
-     * 等价于一次乐观锁累加 SQL 的执行结果。
-     */
-    static final class CapacityModel {
-
-        /** 当前已募集金额(对应 staking_product.raised_amount)。 */
-        private BigDecimal raised;
-
-        /** 总募集上限(对应 staking_product.total_capacity),构造后不变。 */
-        private final BigDecimal totalCapacity;
-
-        CapacityModel(BigDecimal initialRaised, BigDecimal totalCapacity) {
-            this.raised = initialRaised;
-            this.totalCapacity = totalCapacity;
-        }
-
-        /**
-         * 尝试累加一笔下单金额,模拟 increaseRaisedAmount 的 WHERE 条件
-         * {@code raised_amount + amount <= total_capacity}。
-         *
-         * @param amount 本笔下单金额(正数)
-         * @return 满足 {@code raised + amount ≤ totalCapacity} 时累加并返回 {@code true}(对应 SQL 返回 1,接受);
-         *     否则保持 {@code raised} 不变并返回 {@code false}(对应 SQL 返回 0,拒绝)
-         */
-        boolean tryRaise(BigDecimal amount) {
-            if (raised.add(amount).compareTo(totalCapacity) <= 0) {
-                raised = raised.add(amount);
-                return true;
-            }
-            return false;
-        }
-
-        BigDecimal raised() {
-            return raised;
-        }
-
-        BigDecimal totalCapacity() {
-            return totalCapacity;
-        }
-    }
-
-    /**
-     * 下单序列场景:同一产品的总募集上限、起始已募集金额与一串下单金额序列。
-     *
-     * @param totalCapacity 总募集上限(正数)
-     * @param initialRaised 起始已募集金额({@code [0, totalCapacity]},覆盖全新产品与已部分募集产品)
-     * @param orderAmounts 针对同一产品的下单金额序列(每笔为正数)
-     */
-    record CapacityScenario(BigDecimal totalCapacity, BigDecimal initialRaised, List<BigDecimal> orderAmounts) {}
-
-    // ==================== 生成器 ====================
-
-    /** 正总募集上限 totalCapacity:[1, 1e9],scale=18,覆盖整数与长尾小数。 */
-    @Provide
-    Arbitrary<BigDecimal> totalCapacity() {
-        return Arbitraries.bigDecimals()
-                .between(BigDecimal.ONE, new BigDecimal("1000000000"))
-                .ofScale(18)
-                .filter(v -> v.signum() > 0);
-    }
-
-    /**
-     * 下单序列场景生成器。
-     *
-     * <p>先生成 {@code totalCapacity},再据其构造:起始已募集金额 {@code initialRaised ∈ [0, totalCapacity]};
-     * 每笔下单金额 {@code ∈ (0, 2 × totalCapacity]}——上界达到上限的 2 倍,使序列同时覆盖「单笔即超过上限(立即拒绝)」
-     * 与「单笔不超上限但多笔累加后超过剩余额度(后续拒绝)」两类输入;序列长度 1~40,保证接受与拒绝都被充分覆盖。
-     */
-    @Provide
-    Arbitrary<CapacityScenario> scenarios() {
-        return totalCapacity().flatMap(cap -> {
-            Arbitrary<BigDecimal> initialRaised = Arbitraries.bigDecimals()
-                    .between(BigDecimal.ZERO, cap)
-                    .ofScale(18)
-                    .filter(v -> v.signum() >= 0);
-            Arbitrary<BigDecimal> amount = Arbitraries.bigDecimals()
-                    .between(ONE_UNIT, cap.multiply(new BigDecimal("2")))
-                    .ofScale(18)
-                    .filter(v -> v.signum() > 0);
-            return Combinators.combine(
-                            initialRaised, amount.list().ofMinSize(1).ofMaxSize(40))
-                    .as((init, amounts) -> new CapacityScenario(cap, init, amounts));
-        });
-    }
-
-    // ==================== Property 9:募集上限不变量 ====================
-
-    // Feature: staking-service, Property 9: 募集上限不变量
-    // 对任意同一产品的下单序列逐笔 tryRaise(模拟 increaseRaisedAmount 的条件累加),验证:
-    //   (1) 任意时刻 raised ≤ totalCapacity 恒成立(不变量);
-    //   (2) 每笔被接受 ⟺ 接受前 raised + amount ≤ totalCapacity;
-    //   (3) 被拒绝的下单不改变 raised。
-    // Validates: Requirements 5.7
-    @Property(tries = 100)
-    void raisedAmountNeverExceedsCapacityAndOverflowOrdersRejected(
-            @ForAll("scenarios") CapacityScenario scenario) {
-        CapacityModel model = new CapacityModel(scenario.initialRaised(), scenario.totalCapacity());
-
-        // 初始不变量:起始 raised ≤ totalCapacity
-        assertThat(model.raised().compareTo(model.totalCapacity()))
-                .as("初始 raisedAmount 应满足 raisedAmount ≤ totalCapacity")
-                .isLessThanOrEqualTo(0);
-
-        for (BigDecimal amount : scenario.orderAmounts()) {
-            BigDecimal beforeRaised = model.raised();
-            // 接受前的判定条件,与 SQL WHERE 子句 raised_amount + amount <= total_capacity 完全对应
-            boolean expectedAccept =
-                    beforeRaised.add(amount).compareTo(model.totalCapacity()) <= 0;
-
-            boolean accepted = model.tryRaise(amount);
-
-            // (2) 接受 ⟺ 接受前 raised + amount ≤ totalCapacity
-            assertThat(accepted)
-                    .as("下单被接受当且仅当 接受前 raised + amount ≤ totalCapacity(对应 increaseRaisedAmount 返回 1)")
-                    .isEqualTo(expectedAccept);
-
-            if (accepted) {
-                // 被接受:raised 恰好累加 amount
-                assertThat(model.raised().compareTo(beforeRaised.add(amount)))
-                        .as("被接受的下单应使 raised 恰好累加 amount")
-                        .isZero();
-            } else {
-                // (3) 被拒绝:raised 保持不变(对应 increaseRaisedAmount 返回 0,OrderEngine 抛 40005)
-                assertThat(model.raised().compareTo(beforeRaised))
-                        .as("被拒绝的下单不改变 raised")
-                        .isZero();
-            }
-
-            // (1) 不变量:任意一笔(无论接受或拒绝)之后 raised ≤ totalCapacity 恒成立
-            assertThat(model.raised().compareTo(model.totalCapacity()))
-                    .as("任意时刻 raisedAmount ≤ totalCapacity")
-                    .isLessThanOrEqualTo(0);
-        }
-    }
-
-    // Feature: staking-service, Property 9: 募集上限不变量
-    // 子性质:raised 随被接受的下单单调不减,且最终 raised 等于起始值加上全部被接受金额之和(账本守恒)。
-    // Validates: Requirements 5.7
-    @Property(tries = 100)
-    void raisedIsMonotonicAndEqualsInitialPlusAcceptedSum(@ForAll("scenarios") CapacityScenario scenario) {
-        CapacityModel model = new CapacityModel(scenario.initialRaised(), scenario.totalCapacity());
-        BigDecimal acceptedSum = BigDecimal.ZERO;
-
-        for (BigDecimal amount : scenario.orderAmounts()) {
-            BigDecimal beforeRaised = model.raised();
-            boolean accepted = model.tryRaise(amount);
-
-            if (accepted) {
-                acceptedSum = acceptedSum.add(amount);
-            }
-
-            // 单调不减:raised 永不因下单而减少
-            assertThat(model.raised().compareTo(beforeRaised))
-                    .as("raised 随下单序列单调不减")
-                    .isGreaterThanOrEqualTo(0);
-        }
-
-        // 账本守恒:最终 raised == 起始 raised + 全部被接受金额之和
-        assertThat(model.raised().compareTo(scenario.initialRaised().add(acceptedSum)))
-                .as("最终 raised 应等于 起始 raised 加上全部被接受下单金额之和")
-                .isZero();
-    }
-
-    // ==================== 边界锚定示例 ====================
-
-    // Feature: staking-service, Property 9: 募集上限不变量
-    // 边界:恰好等于上限的下单被接受(raised + amount == totalCapacity),且接受后 raised == totalCapacity。
-    // Validates: Requirements 5.7
-    @Example
-    void orderExactlyAtCapacityIsAccepted() {
-        CapacityModel model = new CapacityModel(BigDecimal.ZERO, new BigDecimal("1000"));
-
-        boolean accepted = model.tryRaise(new BigDecimal("1000"));
-
-        assertThat(accepted).as("恰好等于上限的下单应被接受").isTrue();
-        assertThat(model.raised().compareTo(new BigDecimal("1000")))
-                .as("接受后 raised 应恰好等于 totalCapacity")
-                .isZero();
-        assertThat(model.raised().compareTo(model.totalCapacity()))
-                .as("接受后仍满足 raisedAmount ≤ totalCapacity")
-                .isLessThanOrEqualTo(0);
-    }
-
-    // Feature: staking-service, Property 9: 募集上限不变量
-    // 边界:超出上限 1 个最小单位(1e-18)的下单被拒绝,且 raised 保持不变。
-    // Validates: Requirements 5.7
-    @Example
-    void orderExceedingCapacityByOneUnitIsRejected() {
-        CapacityModel model = new CapacityModel(BigDecimal.ZERO, new BigDecimal("1000"));
-
-        boolean accepted = model.tryRaise(new BigDecimal("1000").add(ONE_UNIT));
-
-        assertThat(accepted).as("超出上限 1 个最小单位的下单应被拒绝").isFalse();
-        assertThat(model.raised().compareTo(BigDecimal.ZERO))
-                .as("被拒绝的下单不改变 raised")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 9: 募集上限不变量
-    // 边界:已募集达到上限后,任意正数下单都被拒绝,raised 恒等于 totalCapacity。
-    // Validates: Requirements 5.7
-    @Example
-    void furtherOrdersRejectedOnceCapacityReached() {
-        CapacityModel model = new CapacityModel(new BigDecimal("1000"), new BigDecimal("1000"));
-
-        assertThat(model.tryRaise(ONE_UNIT)).as("达到上限后任意正数下单都应被拒绝").isFalse();
-        assertThat(model.tryRaise(new BigDecimal("0.5"))).as("达到上限后任意正数下单都应被拒绝").isFalse();
-        assertThat(model.raised().compareTo(model.totalCapacity()))
-                .as("被拒绝的下单后 raised 仍恒等于 totalCapacity")
-                .isZero();
-    }
-}

+ 0 - 621
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/RedissonLockConcurrencyTest.java

@@ -1,621 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import com.bex.staking.annotation.Idempotent;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.exception.StakingException;
-import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.Proxy;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.locks.Condition;
-import java.util.concurrent.locks.ReentrantLock;
-import org.aspectj.lang.annotation.Aspect;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.redisson.api.RLock;
-import org.redisson.api.RedissonClient;
-import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
-
-/**
- * Redisson 分布式锁并发集成测试(任务 17.4 Redisson 锁部分)。
- *
- * <p>被测组件:{@link IdempotencyBarrier}({@link Aspect} 切面,基于 Redisson {@link RLock} 的
- * {@code tryLock(waitTime, leaseTime, unit)})。本测试通过 Spring AOP({@link AspectJProxyFactory})将
- * <strong>真实的</strong> {@code IdempotencyBarrier} 切面织入一个标注 {@link Idempotent} 的样例 Bean,对其方法
- * 发起多线程并发调用,验证设计文档"测试策略 - Redisson:验证锁获取/释放与 leaseTime 自动释放"所述语义:</p>
- *
- * <ol>
- *   <li><b>并发互斥与重复请求拒绝(需求 14.1、14.2)</b>:相同幂等键的并发请求中,有且仅有一个获取锁并执行业务,
- *       其余因 {@code waitTime=0} 锁被占用立即抛出 {@code StakingException(40008)};</li>
- *   <li><b>不同键互不阻塞</b>:不同幂等键的并发请求各自获取独立锁,全部成功执行;</li>
- *   <li><b>业务完成释放锁(需求 14.3)</b>:同一键串行调用,前一次完成后释放锁,后一次可再次获取并执行;</li>
- *   <li><b>leaseTime 到期自动释放(需求 14.4)</b>:持锁线程未主动释放时,超过 {@code leaseTime} 后锁可被重新获取
- *       (分别在锁层面与切面层面各验证一次)。</li>
- * </ol>
- *
- * <h2>环境说明(方案退化)</h2>
- *
- * <p>本运行 / CI 环境<strong>无 Docker、无可用 Redis、本地仓库亦无嵌入式 Redis 依赖</strong>
- * ({@code embedded-redis} 等),无法启动真实 {@code RedissonClient}。为不引入不可用依赖而阻塞,本测试采用
- * <strong>内存版 RLock 语义模拟</strong>({@link SimulatedLock})替代真实 Redis:它忠实复现 Redisson
- * {@code RLock} 的关键并发契约——按键互斥、可重入、{@code tryLock(waitTime, leaseTime)} 的等待/占用语义、以及
- * <strong>显式 leaseTime 到期自动释放</strong>(不含 watchdog 续约,因切面始终显式传入 leaseTime)。被测对象始终是
- * 生产代码 {@code IdempotencyBarrier},仅"锁后端"被替换为内存模拟,故对锁获取/释放、40008 拒绝、leaseTime
- * 自动释放等行为的验证结论可迁移到真实 Redis 部署。锁键拼接、SpEL 求值等切面逻辑则完全走真实实现。</p>
- *
- * <p>若后续环境具备 Docker 或可用 Redis,可将 {@link #redissonClient} 替换为真实
- * {@code Redisson.create(config)},本测试用例与断言可直接复用。</p>
- *
- * <p>Validates: Requirements 14.3, 14.4(并附带覆盖 14.1、14.2)
- */
-@DisplayName("Redisson 分布式锁并发集成测试 (内存 RLock 语义模拟驱动真实切面)")
-class RedissonLockConcurrencyTest {
-
-    /** 模拟的 RedissonClient:getLock 返回按键唯一、由 {@link SimulatedLock} 支撑的 RLock 代理。 */
-    private RedissonClient redissonClient;
-
-    /** 按锁键缓存的 RLock 代理实例(保证同一键多线程拿到同一把锁)。 */
-    private Map<String, RLock> rlockByKey;
-
-    /** 织入真实 IdempotencyBarrier 切面后的样例服务代理。 */
-    private SampleIdempotentService proxy;
-
-    /** 样例服务目标对象(持有执行计数与同步闩)。 */
-    private SampleIdempotentService target;
-
-    @BeforeEach
-    void setUp() {
-        rlockByKey = new ConcurrentHashMap<>();
-        redissonClient = mock(RedissonClient.class);
-        // getLock 在并发下被多线程调用:以 ConcurrentHashMap.computeIfAbsent 保证按键唯一、线程安全
-        when(redissonClient.getLock(anyString())).thenAnswer(inv -> {
-            String key = inv.getArgument(0);
-            return rlockByKey.computeIfAbsent(key, RedissonLockConcurrencyTest::newSimulatedRLock);
-        });
-
-        // 用 Spring AOP 将真实 IdempotencyBarrier 切面织入样例 Bean(CGLIB 代理具体类)
-        IdempotencyBarrier barrier = new IdempotencyBarrier(redissonClient);
-        target = new SampleIdempotentService();
-        AspectJProxyFactory factory = new AspectJProxyFactory(target);
-        factory.addAspect(barrier);
-        factory.setProxyTargetClass(true);
-        proxy = factory.getProxy();
-    }
-
-    @AfterEach
-    void tearDown() {
-        // 释放可能仍阻塞在 proceedLatch 上的样例方法,避免线程泄漏
-        target.releaseAll();
-    }
-
-    // ========================================================================
-    // 1. 并发互斥:相同键仅一个执行,其余 40008(需求 14.1、14.2)
-    // ========================================================================
-
-    @Test
-    @DisplayName("相同幂等键的 6 个并发请求仅一个获取锁执行,其余 5 个被拒绝返回 40008")
-    void concurrentSameKey_onlyOneProceeds() throws Exception {
-        int n = 6;
-        String key = "user-1001:idem-A";
-        target.useBlocking(); // 获胜线程进入业务后阻塞,保证其余线程在其持锁期间发起竞争
-
-        ConcurrentLinkedQueue<Outcome> outcomes = new ConcurrentLinkedQueue<>();
-        CountDownLatch startGate = new CountDownLatch(1);
-        ExecutorService pool = Executors.newFixedThreadPool(n);
-        try {
-            for (int i = 0; i < n; i++) {
-                pool.submit(() -> {
-                    try {
-                        startGate.await();
-                        String r = proxy.blocking(key);
-                        outcomes.add(Outcome.success(r));
-                    } catch (StakingException e) {
-                        outcomes.add(Outcome.rejected(e.getCode()));
-                    } catch (Throwable t) {
-                        outcomes.add(Outcome.error(t));
-                    }
-                    return null;
-                });
-            }
-            startGate.countDown(); // 同时放行,制造最大竞争
-
-            // 等待"获胜线程已进入业务"且"其余 5 个线程已全部失败返回"(获胜线程仍阻塞持锁)
-            assertThat(target.awaitEntered(5, TimeUnit.SECONDS)).as("应有一个线程获取锁进入业务").isTrue();
-            awaitUntil(() -> outcomes.size() >= n - 1, 5_000);
-
-            // 此刻获胜线程仍持锁阻塞,落败的 5 个请求必然已被 40008 拒绝
-            long rejected = outcomes.stream().filter(o -> o.type == Outcome.Type.REJECTED).count();
-            assertThat(rejected).as("应有 n-1 个并发请求被幂等屏障拒绝").isEqualTo(n - 1);
-
-            target.releaseAll(); // 放行获胜线程完成
-            awaitUntil(() -> outcomes.size() == n, 5_000);
-        } finally {
-            pool.shutdownNow();
-        }
-
-        long success = outcomes.stream().filter(o -> o.type == Outcome.Type.SUCCESS).count();
-        long rejected = outcomes.stream().filter(o -> o.type == Outcome.Type.REJECTED).count();
-        long errored = outcomes.stream().filter(o -> o.type == Outcome.Type.ERROR).count();
-
-        assertThat(success).as("有且仅有一个请求获取锁并执行业务").isEqualTo(1);
-        assertThat(rejected).as("其余请求均被拒绝返回 40008").isEqualTo(n - 1);
-        assertThat(errored).as("不应出现非预期异常").isZero();
-        assertThat(target.executionCount()).as("业务方法应仅真正执行一次").isEqualTo(1);
-        outcomes.stream()
-                .filter(o -> o.type == Outcome.Type.REJECTED)
-                .forEach(o -> assertThat(o.code).isEqualTo(StakingErrorCode.DUPLICATE_REQUEST));
-    }
-
-    // ========================================================================
-    // 2. 不同键互不阻塞:各自获取独立锁全部成功
-    // ========================================================================
-
-    @Test
-    @DisplayName("不同幂等键的并发请求各自获取独立锁,全部成功执行、互不阻塞")
-    void concurrentDifferentKeys_allProceed() throws Exception {
-        int n = 8;
-        ConcurrentLinkedQueue<Outcome> outcomes = new ConcurrentLinkedQueue<>();
-        CountDownLatch startGate = new CountDownLatch(1);
-        ExecutorService pool = Executors.newFixedThreadPool(n);
-        try {
-            for (int i = 0; i < n; i++) {
-                final String key = "user-" + i + ":idem-" + i; // 互不相同的键
-                pool.submit(() -> {
-                    try {
-                        startGate.await();
-                        outcomes.add(Outcome.success(proxy.fast(key)));
-                    } catch (StakingException e) {
-                        outcomes.add(Outcome.rejected(e.getCode()));
-                    } catch (Throwable t) {
-                        outcomes.add(Outcome.error(t));
-                    }
-                    return null;
-                });
-            }
-            startGate.countDown();
-            pool.shutdown();
-            assertThat(pool.awaitTermination(15, TimeUnit.SECONDS)).isTrue();
-        } finally {
-            pool.shutdownNow();
-        }
-
-        long success = outcomes.stream().filter(o -> o.type == Outcome.Type.SUCCESS).count();
-        assertThat(success).as("不同键不应互相阻塞,应全部成功").isEqualTo(n);
-        assertThat(target.executionCount()).as("每个键各执行一次").isEqualTo(n);
-        assertThat(rlockByKey).as("应为每个不同键各创建一把锁").hasSize(n);
-    }
-
-    // ========================================================================
-    // 3. 业务完成释放锁:同键可串行复用(需求 14.3)
-    // ========================================================================
-
-    @Test
-    @DisplayName("同一幂等键串行调用:前一次完成释放锁后,后一次可再次获取并执行(需求 14.3)")
-    void lockReleasedAfterCompletion_sequentialReuseSucceeds() {
-        String key = "user-2002:idem-seq";
-        // 三次串行调用同一键,若锁未在完成后释放,第二次起将被 40008 拒绝
-        assertThat(proxy.fast(key)).isEqualTo("ok:" + key);
-        assertThat(proxy.fast(key)).isEqualTo("ok:" + key);
-        assertThat(proxy.fast(key)).isEqualTo("ok:" + key);
-        assertThat(target.executionCount()).as("串行复用应每次都成功执行").isEqualTo(3);
-
-        // 锁应已完全释放(无人持有)
-        SimulatedLock lock = currentSimulatedLock("staking:idem:sample:fast:" + key);
-        assertThat(lock).isNotNull();
-        assertThat(lock.isLocked()).as("业务完成后锁应已释放").isFalse();
-    }
-
-    // ========================================================================
-    // 4a. leaseTime 自动释放(锁层面,需求 14.4)
-    // ========================================================================
-
-    @Test
-    @DisplayName("锁层面:持锁线程不释放时,超过 leaseTime 后锁被自动释放、可被重新获取(需求 14.4)")
-    void leaseTimeAutoRelease_atLockLevel() throws Exception {
-        RLock lock = redissonClient.getLock("staking:idem:lease-test");
-        long leaseMillis = 400L;
-
-        // 线程 T1 获取锁后"崩溃"——永不主动释放
-        CountDownLatch acquired = new CountDownLatch(1);
-        Thread holder = new Thread(() -> {
-            try {
-                boolean ok = lock.tryLock(0, leaseMillis, TimeUnit.MILLISECONDS);
-                if (ok) {
-                    acquired.countDown();
-                }
-                // 故意不调用 unlock(),模拟持锁进程异常
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-            }
-        });
-        holder.start();
-        assertThat(acquired.await(2, TimeUnit.SECONDS)).as("T1 应成功获取锁").isTrue();
-
-        // 主线程立即尝试:锁被占用,应失败
-        assertThat(lock.tryLock(0, leaseMillis, TimeUnit.MILLISECONDS))
-                .as("leaseTime 未到期时锁应仍被占用,获取失败")
-                .isFalse();
-
-        // 等待超过 leaseTime 后再次尝试:锁应已自动释放,可重新获取
-        Thread.sleep(leaseMillis + 300L);
-        assertThat(lock.tryLock(0, leaseMillis, TimeUnit.MILLISECONDS))
-                .as("超过 leaseTime 后锁应自动释放并可被重新获取,避免死锁")
-                .isTrue();
-        lock.unlock();
-        holder.join(2_000);
-    }
-
-    // ========================================================================
-    // 4b. leaseTime 自动释放(切面层面,需求 14.4)
-    // ========================================================================
-
-    @Test
-    @DisplayName("切面层面:慢业务持锁超过 leaseTime 后,同键的另一并发请求因锁自动释放得以执行(需求 14.4)")
-    void leaseTimeAutoRelease_viaAspectAllowsReentryAfterExpiry() throws Exception {
-        String key = "user-3003:idem-lease";
-        target.useSlowShortLease(); // 业务进入后阻塞,且 @Idempotent leaseTime=1s
-
-        ExecutorService pool = Executors.newFixedThreadPool(2);
-        try {
-            // 线程 A:获取锁、进入业务后阻塞(远超 1s 的 leaseTime)
-            Future<Outcome> fa = pool.submit(() -> {
-                try {
-                    return Outcome.success(proxy.slowShortLease(key));
-                } catch (StakingException e) {
-                    return Outcome.rejected(e.getCode());
-                } catch (Throwable t) {
-                    return Outcome.error(t);
-                }
-            });
-            assertThat(target.awaitEntered(3, TimeUnit.SECONDS)).as("线程 A 应先获取锁进入业务").isTrue();
-
-            // 等待超过 leaseTime(1s),使 A 的锁租约到期自动释放
-            Thread.sleep(1_400L);
-
-            // 线程 B:同键再次请求。若 leaseTime 未自动释放,将被 40008 拒绝;自动释放后应能获取锁执行
-            Future<Outcome> fb = pool.submit(() -> {
-                try {
-                    return Outcome.success(proxy.slowShortLease(key));
-                } catch (StakingException e) {
-                    return Outcome.rejected(e.getCode());
-                } catch (Throwable t) {
-                    return Outcome.error(t);
-                }
-            });
-
-            // B 进入业务即说明它成功获取了锁(执行计数升至 2)
-            awaitUntil(() -> target.executionCount() >= 2, 5_000);
-            assertThat(target.executionCount())
-                    .as("A 的锁租约到期自动释放后,B 应成功获取锁并执行")
-                    .isEqualTo(2);
-
-            target.releaseAll(); // 放行 A、B 完成
-            Outcome oa = fa.get(5, TimeUnit.SECONDS);
-            Outcome ob = fb.get(5, TimeUnit.SECONDS);
-            assertThat(oa.type).as("线程 A 不应异常").isIn(Outcome.Type.SUCCESS, Outcome.Type.REJECTED);
-            assertThat(ob.type).as("线程 B 应成功执行(锁已自动释放)").isEqualTo(Outcome.Type.SUCCESS);
-        } finally {
-            pool.shutdownNow();
-        }
-    }
-
-    // ========================================================================
-    // 辅助:获取当前键对应的 SimulatedLock(用于断言锁状态)
-    // ========================================================================
-
-    private SimulatedLock currentSimulatedLock(String lockKey) {
-        RLock rlock = rlockByKey.get(lockKey);
-        return rlock == null ? null : SIM_BY_PROXY.get(rlock);
-    }
-
-    /** 轮询等待条件成立,最多等待 {@code timeoutMillis} 毫秒。 */
-    private static void awaitUntil(java.util.function.BooleanSupplier condition, long timeoutMillis)
-            throws InterruptedException {
-        long deadline = System.currentTimeMillis() + timeoutMillis;
-        while (System.currentTimeMillis() < deadline) {
-            if (condition.getAsBoolean()) {
-                return;
-            }
-            Thread.sleep(10L);
-        }
-        if (!condition.getAsBoolean()) {
-            throw new AssertionError("等待条件超时(" + timeoutMillis + "ms)");
-        }
-    }
-
-    // ========================================================================
-    // 样例被测 Bean:标注 @Idempotent,由真实切面拦截
-    // ========================================================================
-
-    /**
-     * 携带 {@link Idempotent} 注解的样例服务,模拟"确认质押 / 收益领取"等高频写接口。
-     *
-     * <p>提供三种业务方法:</p>
-     *
-     * <ul>
-     *   <li>{@link #fast(String)}:立即返回(leaseTime 30s,足够长不会在测试期内到期);</li>
-     *   <li>{@link #blocking(String)}:进入后阻塞在 {@code proceedLatch},用于制造并发互斥窗口;</li>
-     *   <li>{@link #slowShortLease(String)}:进入后阻塞,且 {@code leaseTime=1s},用于验证租约自动释放。</li>
-     * </ul>
-     */
-    public static class SampleIdempotentService {
-
-        private final AtomicInteger executions = new AtomicInteger();
-        private volatile CountDownLatch enteredLatch = new CountDownLatch(1);
-        private volatile CountDownLatch proceedLatch = new CountDownLatch(0);
-        private volatile boolean blockMode = false;
-
-        void useBlocking() {
-            this.executions.set(0);
-            this.enteredLatch = new CountDownLatch(1);
-            this.proceedLatch = new CountDownLatch(1);
-            this.blockMode = true;
-        }
-
-        void useSlowShortLease() {
-            this.executions.set(0);
-            this.enteredLatch = new CountDownLatch(1);
-            this.proceedLatch = new CountDownLatch(1);
-            this.blockMode = true;
-        }
-
-        int executionCount() {
-            return executions.get();
-        }
-
-        boolean awaitEntered(long timeout, TimeUnit unit) throws InterruptedException {
-            return enteredLatch.await(timeout, unit);
-        }
-
-        void releaseAll() {
-            CountDownLatch latch = this.proceedLatch;
-            while (latch.getCount() > 0) {
-                latch.countDown();
-            }
-        }
-
-        /** 立即返回的幂等方法(leaseTime 30s,长于测试时长,确保仅靠主动释放回收锁)。 */
-        @Idempotent(prefix = "sample:fast", key = "#key", leaseTime = 30L, unit = TimeUnit.SECONDS)
-        public String fast(String key) {
-            executions.incrementAndGet();
-            return "ok:" + key;
-        }
-
-        /** 进入后阻塞的幂等方法(leaseTime 30s),用于并发互斥窗口。 */
-        @Idempotent(prefix = "sample:blocking", key = "#key", leaseTime = 30L, unit = TimeUnit.SECONDS)
-        public String blocking(String key) throws InterruptedException {
-            executions.incrementAndGet();
-            enteredLatch.countDown();
-            if (blockMode) {
-                proceedLatch.await(5, TimeUnit.SECONDS);
-            }
-            return "ok:" + key;
-        }
-
-        /** 进入后阻塞、leaseTime=1s 的幂等方法,用于验证 leaseTime 到期自动释放。 */
-        @Idempotent(prefix = "sample:slow", key = "#key", leaseTime = 1L, unit = TimeUnit.SECONDS)
-        public String slowShortLease(String key) throws InterruptedException {
-            executions.incrementAndGet();
-            enteredLatch.countDown();
-            if (blockMode) {
-                proceedLatch.await(5, TimeUnit.SECONDS);
-            }
-            return "ok:" + key;
-        }
-    }
-
-    // ========================================================================
-    // 内存版 RLock 语义模拟
-    // ========================================================================
-
-    /** RLock 代理 → 其后端 SimulatedLock 的映射,便于测试断言锁内部状态。 */
-    private static final Map<RLock, SimulatedLock> SIM_BY_PROXY = new ConcurrentHashMap<>();
-
-    /**
-     * 创建一把由 {@link SimulatedLock} 支撑的 {@link RLock} 动态代理。
-     *
-     * <p>仅实现 {@code IdempotencyBarrier} 实际调用的三个方法:{@code tryLock(long,long,TimeUnit)}、
-     * {@code unlock()}、{@code isHeldByCurrentThread()}(外加 {@code isLocked()}/{@code getName()} 供断言),
-     * 其余方法抛出 {@link UnsupportedOperationException},避免无意中依赖未模拟的 Redisson 行为。</p>
-     */
-    private static RLock newSimulatedRLock(String key) {
-        SimulatedLock sim = new SimulatedLock();
-        InvocationHandler handler = (proxyObj, method, args) -> {
-            switch (method.getName()) {
-                case "tryLock":
-                    if (args != null && args.length == 3) {
-                        return sim.tryLock((Long) args[0], (Long) args[1], (TimeUnit) args[2]);
-                    }
-                    throw new UnsupportedOperationException("未模拟的 tryLock 重载");
-                case "unlock":
-                    if (args == null || args.length == 0) {
-                        sim.unlock();
-                        return null;
-                    }
-                    throw new UnsupportedOperationException("未模拟的 unlock 重载");
-                case "isHeldByCurrentThread":
-                    return sim.isHeldByCurrentThread();
-                case "isLocked":
-                    return sim.isLocked();
-                case "getName":
-                    return key;
-                case "toString":
-                    return "SimulatedRLock[" + key + "]";
-                case "hashCode":
-                    return System.identityHashCode(proxyObj);
-                case "equals":
-                    return proxyObj == args[0];
-                default:
-                    throw new UnsupportedOperationException("SimulatedRLock 未模拟的方法: " + method.getName());
-            }
-        };
-        RLock rlock = (RLock) Proxy.newProxyInstance(
-                RedissonLockConcurrencyTest.class.getClassLoader(), new Class<?>[] {RLock.class}, handler);
-        SIM_BY_PROXY.put(rlock, sim);
-        return rlock;
-    }
-
-    /**
-     * 忠实复现 Redisson {@link RLock} 关键并发契约的内存锁:
-     *
-     * <ul>
-     *   <li>按线程互斥、可重入;</li>
-     *   <li>{@code tryLock(waitTime, leaseTime, unit)}:锁空闲即获取并按 leaseTime 设定租约;被他人占用时在
-     *       waitTime 内等待,超时返回 {@code false};</li>
-     *   <li><b>leaseTime 到期自动释放</b>:持锁超过 leaseTime 后,下一次 {@code tryLock}/{@code isHeldByCurrentThread}
-     *       检查时惰性回收(对应 Redisson 显式 leaseTime 到期释放、无 watchdog 续约的语义);</li>
-     *   <li>{@code unlock()}:仅持有者可释放,非持有者(如租约已到期被他人重获)调用视为无操作。</li>
-     * </ul>
-     */
-    static final class SimulatedLock {
-
-        private final ReentrantLock guard = new ReentrantLock();
-        private final Condition freed = guard.newCondition();
-        private Thread owner;
-        private int holdCount;
-        private long leaseExpireAtMillis;
-
-        boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
-            long waitNanos = unit.toNanos(Math.max(0L, waitTime));
-            long leaseMillis = leaseTime > 0 ? unit.toMillis(leaseTime) : -1L;
-            long deadline = System.nanoTime() + waitNanos;
-            guard.lockInterruptibly();
-            try {
-                while (true) {
-                    expireIfNeeded();
-                    if (owner == null) {
-                        owner = Thread.currentThread();
-                        holdCount = 1;
-                        renewLease(leaseMillis);
-                        return true;
-                    }
-                    if (owner == Thread.currentThread()) {
-                        // 可重入
-                        holdCount++;
-                        renewLease(leaseMillis);
-                        return true;
-                    }
-                    long remaining = deadline - System.nanoTime();
-                    if (remaining <= 0) {
-                        return false;
-                    }
-                    // 限制单次等待时长,确保能及时醒来重新检查租约到期
-                    long sleepNanos = remaining;
-                    if (leaseExpireAtMillis != Long.MAX_VALUE) {
-                        long leaseLeft = leaseExpireAtMillis - System.currentTimeMillis();
-                        long leaseLeftNanos =
-                                leaseLeft > 0 ? TimeUnit.MILLISECONDS.toNanos(leaseLeft) : 1_000_000L;
-                        sleepNanos = Math.min(remaining, Math.max(leaseLeftNanos, 1_000_000L));
-                    }
-                    freed.awaitNanos(sleepNanos);
-                }
-            } finally {
-                guard.unlock();
-            }
-        }
-
-        void unlock() {
-            guard.lock();
-            try {
-                if (owner != Thread.currentThread()) {
-                    // 租约已到期且被他人重新获取:当前线程已非持有者,按无操作处理(不抛异常以保证测试稳定)
-                    return;
-                }
-                holdCount--;
-                if (holdCount <= 0) {
-                    release();
-                }
-            } finally {
-                guard.unlock();
-            }
-        }
-
-        boolean isHeldByCurrentThread() {
-            guard.lock();
-            try {
-                expireIfNeeded();
-                return owner == Thread.currentThread();
-            } finally {
-                guard.unlock();
-            }
-        }
-
-        boolean isLocked() {
-            guard.lock();
-            try {
-                expireIfNeeded();
-                return owner != null;
-            } finally {
-                guard.unlock();
-            }
-        }
-
-        private void renewLease(long leaseMillis) {
-            leaseExpireAtMillis = leaseMillis > 0 ? System.currentTimeMillis() + leaseMillis : Long.MAX_VALUE;
-        }
-
-        private void expireIfNeeded() {
-            if (owner != null && System.currentTimeMillis() >= leaseExpireAtMillis) {
-                release();
-            }
-        }
-
-        private void release() {
-            owner = null;
-            holdCount = 0;
-            leaseExpireAtMillis = 0L;
-            freed.signalAll();
-        }
-    }
-
-    // ========================================================================
-    // 测试结果记录
-    // ========================================================================
-
-    /** 单次并发调用的结果归类。 */
-    private static final class Outcome {
-        enum Type {
-            SUCCESS,
-            REJECTED,
-            ERROR
-        }
-
-        final Type type;
-        final String value;
-        final int code;
-        final Throwable error;
-
-        private Outcome(Type type, String value, int code, Throwable error) {
-            this.type = type;
-            this.value = value;
-            this.code = code;
-            this.error = error;
-        }
-
-        static Outcome success(String value) {
-            return new Outcome(Type.SUCCESS, value, 0, null);
-        }
-
-        static Outcome rejected(int code) {
-            return new Outcome(Type.REJECTED, null, code, null);
-        }
-
-        static Outcome error(Throwable t) {
-            return new Outcome(Type.ERROR, null, -1, t);
-        }
-    }
-}

+ 0 - 151
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/SettlementEngineTest.java

@@ -1,151 +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.assertTrue;
-
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.OrderStatus;
-import java.math.BigDecimal;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
-/**
- * {@link SettlementEngine} 纯逻辑与汇总的单元测试(示例 / 边界)。
- *
- * <p>聚焦无需数据库的核心账本累加逻辑 {@link SettlementEngine#planSettlement}(账本守恒、不超发)
- * 与批量汇总 {@link SettleSummary} 的计数正确性。涉及数据库的幂等查重、乐观锁重试、虚拟线程并发由属性测试
- * (任务 9.2)与集成测试(任务 17)覆盖。
- *
- * <p>Requirements: 9.3, 9.8
- */
-class SettlementEngineTest {
-
-    /** 构造一个持仓中、指定账本的质押订单。 */
-    private StakingOrder order(BigDecimal cumulative, BigDecimal pending, int settledDays) {
-        return StakingOrder.builder()
-                .orderNo("STK-TEST-1")
-                .userId(1001L)
-                .productId(9001L)
-                .status(OrderStatus.HOLDING.getValue())
-                .stakeAmount(new BigDecimal("1000"))
-                .cumulativeProfit(cumulative)
-                .pendingProfit(pending)
-                .settledDays(settledDays)
-                .build();
-    }
-
-    @Nested
-    @DisplayName("planSettlement 账本累加纯逻辑")
-    class PlanSettlement {
-
-        @Test
-        @DisplayName("正常派发:累计与待领取同步增加相同金额,已结算天数 +1(需求 9.3)")
-        void dispatchAccumulatesLedger() {
-            StakingOrder o = order(new BigDecimal("10"), new BigDecimal("4"), 30);
-            BigDecimal daily = new BigDecimal("0.666666");
-
-            SettlementPlan plan = SettlementEngine.planSettlement(o, daily, 90);
-
-            assertTrue(plan.shouldDispatch(), "未达锁仓期应派发");
-            assertEquals(0, plan.profitAmount().compareTo(daily));
-            assertEquals(0, plan.newCumulativeProfit().compareTo(new BigDecimal("10.666666")), "累计 = 原累计 + 当日收益");
-            assertEquals(0, plan.newPendingProfit().compareTo(new BigDecimal("4.666666")), "待领取 = 原待领取 + 当日收益");
-            assertEquals(31, plan.newSettledDays(), "已结算天数 +1");
-        }
-
-        @Test
-        @DisplayName("账本守恒:累计增量恰好等于待领取增量(需求 9.3)")
-        void cumulativeIncrementEqualsPendingIncrement() {
-            StakingOrder o = order(new BigDecimal("12.5"), new BigDecimal("7.25"), 10);
-            BigDecimal daily = new BigDecimal("1.111111111111111111");
-
-            SettlementPlan plan = SettlementEngine.planSettlement(o, daily, 90);
-
-            BigDecimal cumulativeDelta = plan.newCumulativeProfit().subtract(o.getCumulativeProfit());
-            BigDecimal pendingDelta = plan.newPendingProfit().subtract(o.getPendingProfit());
-            assertEquals(0, cumulativeDelta.compareTo(pendingDelta), "累计增量应等于待领取增量");
-            assertEquals(0, cumulativeDelta.compareTo(daily), "增量应等于当日收益");
-        }
-
-        @Test
-        @DisplayName("不超发:已结算天数达到锁仓天数则跳过派发、账本不变(需求 9.8)")
-        void skipWhenSettledDaysReachLockDays() {
-            StakingOrder o = order(new BigDecimal("60"), new BigDecimal("60"), 90);
-
-            SettlementPlan plan = SettlementEngine.planSettlement(o, new BigDecimal("0.666666"), 90);
-
-            assertFalse(plan.shouldDispatch(), "已达锁仓期不应派发");
-            assertEquals(0, plan.profitAmount().compareTo(BigDecimal.ZERO), "跳过时收益为 0");
-            assertEquals(0, plan.newCumulativeProfit().compareTo(new BigDecimal("60")), "账本累计不变");
-            assertEquals(0, plan.newPendingProfit().compareTo(new BigDecimal("60")), "账本待领取不变");
-            assertEquals(90, plan.newSettledDays(), "已结算天数不变");
-        }
-
-        @Test
-        @DisplayName("不超发边界:派发后已结算天数恒不超过锁仓天数(需求 9.8)")
-        void settledDaysNeverExceedLockDays() {
-            // 倒数第二天 settledDays=89, lockDays=90 → 可派发, 派发后 90, 不超过
-            StakingOrder lastDay = order(new BigDecimal("59"), new BigDecimal("59"), 89);
-            SettlementPlan plan = SettlementEngine.planSettlement(lastDay, new BigDecimal("0.666666"), 90);
-            assertTrue(plan.shouldDispatch());
-            assertEquals(90, plan.newSettledDays());
-            assertTrue(plan.newSettledDays() <= 90, "已结算天数不得超过锁仓天数");
-        }
-
-        @Test
-        @DisplayName("空账本字段防御:null 累计/待领取/天数按 0 处理")
-        void nullLedgerFieldsTreatedAsZero() {
-            StakingOrder o = order(null, null, 0);
-            o.setSettledDays(null);
-
-            SettlementPlan plan = SettlementEngine.planSettlement(o, new BigDecimal("1"), 90);
-
-            assertTrue(plan.shouldDispatch());
-            assertEquals(0, plan.newCumulativeProfit().compareTo(new BigDecimal("1")));
-            assertEquals(0, plan.newPendingProfit().compareTo(new BigDecimal("1")));
-            assertEquals(1, plan.newSettledDays());
-        }
-    }
-
-    @Nested
-    @DisplayName("SettleSummary 汇总计数")
-    class Summary {
-
-        @Test
-        @DisplayName("成功 / 跳过 / 失败按状态正确计数, 失败明细可追溯")
-        void recordCountsByStatus() {
-            SettleSummary summary = new SettleSummary();
-            summary.record(SettleResult.success("A"));
-            summary.record(SettleResult.success("B"));
-            summary.record(SettleResult.skippedDuplicate("C"));
-            summary.record(SettleResult.skippedMatured("D"));
-            summary.record(SettleResult.failed("E", "乐观锁冲突"));
-
-            assertEquals(5, summary.getTotal());
-            assertEquals(2, summary.getSuccessCount());
-            assertEquals(2, summary.getSkippedCount(), "幂等跳过 + 锁仓期满跳过");
-            assertEquals(1, summary.getFailedCount());
-            assertEquals(1, summary.getFailedResults().size());
-            assertEquals("E", summary.getFailedResults().get(0).orderNo());
-        }
-    }
-
-    @Nested
-    @DisplayName("SettleResult 状态判定")
-    class Result {
-
-        @Test
-        @DisplayName("isSuccess / isSkipped / isFailed 互斥且正确")
-        void statusPredicates() {
-            assertTrue(SettleResult.success("A").isSuccess());
-            assertTrue(SettleResult.skippedDuplicate("B").isSkipped());
-            assertTrue(SettleResult.skippedMatured("C").isSkipped());
-            assertTrue(SettleResult.failed("D", "x").isFailed());
-
-            assertFalse(SettleResult.success("A").isSkipped());
-            assertFalse(SettleResult.skippedDuplicate("B").isSuccess());
-        }
-    }
-}

+ 0 - 144
bex-cloud-staking-service/src/test/java/com/bex/staking/engine/SubscriptionAmountValidationPropertyTest.java

@@ -1,144 +0,0 @@
-package com.bex.staking.engine;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.enums.SubscriptionMode;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.util.List;
-import java.util.stream.Collectors;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link SubscriptionValidator} 认购额度校验正确性属性测试。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 7:对任意产品(固定档位或无限制区间)与任意认购数量
- * {@code x}:当模式为固定档位时,校验通过当且仅当 {@code x} 等于档位配置中的某一档位值;当模式为无限制时,
- * 校验通过当且仅当 {@code minAmount ≤ x ≤ maxAmount}。
- *
- * <p>被测入口为 {@link SubscriptionValidator#matchesMode}(模式相关额度校验,<b>不</b>含 100 BEX 最小下限),
- * 以避免与最小下限校验耦合(最小下限属于需求 5.2/5.3,由 {@code meetsMinimum} 与 {@code isValid} 覆盖)。
- *
- * <p>金额一律以 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定相等与区间归属(忽略标度差异,
- * 如 {@code 1000} 与 {@code 1000.00} 视为相等),与生产代码内部判定方式保持一致。
- */
-class SubscriptionAmountValidationPropertyTest {
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 固定档位场景:一组互异的正整数档位与一个待校验数量 {@code x}。
-     *
-     * @param tiers 档位列表(互异正整数,标度为 0)
-     * @param x 待校验认购数量
-     */
-    record FixedScenario(List<BigDecimal> tiers, BigDecimal x) {}
-
-    /**
-     * 无限制区间场景:满足 {@code min ≤ max} 的区间与一个待校验数量 {@code x}。
-     *
-     * @param min 最小认购限额
-     * @param max 最大认购限额({@code max ≥ min})
-     * @param x 待校验认购数量
-     */
-    record RangeScenario(BigDecimal min, BigDecimal max, BigDecimal x) {}
-
-    /** 档位取值生成器:[1, 10^12] 的正整数 BigDecimal(标度 0,保证 JSON 往返无损)。 */
-    private Arbitrary<BigDecimal> tierValue() {
-        return Arbitraries.bigIntegers()
-                .between(BigInteger.ONE, BigInteger.TEN.pow(12))
-                .map(BigDecimal::new);
-    }
-
-    /**
-     * 任意非负高精度金额生成器:随机 unscaledValue([0, 10^15])与随机 scale([0, 8])组合,
-     * 覆盖整数、长尾小数与不同数量级。
-     */
-    private Arbitrary<BigDecimal> arbitraryAmount() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ZERO, BigInteger.TEN.pow(15)),
-                        Arbitraries.integers().between(0, 8))
-                .as(BigDecimal::new);
-    }
-
-    /**
-     * 固定档位场景生成器:先生成 1~6 个互异正整数档位,再生成 {@code x},使其等概率覆盖
-     * "命中某档位(可能带不同标度)"与"任意数量(大概率非档位)"两类输入,以双向验证不变量。
-     */
-    @Provide
-    Arbitrary<FixedScenario> fixedScenarios() {
-        Arbitrary<List<BigDecimal>> tiersArb =
-                tierValue().list().ofMinSize(1).ofMaxSize(6).uniqueElements();
-
-        return tiersArb.flatMap(tiers -> {
-            // 命中型 x:从档位中任选其一,并随机附加额外的尾随零(标度不同但数值相等)
-            Arbitrary<BigDecimal> memberX = Arbitraries.of(tiers)
-                    .flatMap(t -> Arbitraries.integers().between(0, 8).map(t::setScale));
-            // 任意型 x:覆盖整个非负金额空间(偶尔也可能恰好等于某档位,由 compareTo 判定)
-            Arbitrary<BigDecimal> anyX = arbitraryAmount();
-            return Arbitraries.oneOf(memberX, anyX).map(x -> new FixedScenario(tiers, x));
-        });
-    }
-
-    /**
-     * 无限制区间场景生成器:先生成 {@code min} 与非负 {@code span} 派生 {@code max = min + span}
-     *(保证 {@code min ≤ max}),再生成 {@code x},使其覆盖区间内、区间外(上/下越界)及边界点
-     *({@code min}、{@code max} 及其等值不同标度形式)。
-     */
-    @Provide
-    Arbitrary<RangeScenario> rangeScenarios() {
-        return Combinators.combine(arbitraryAmount(), arbitraryAmount()).flatAs((min, span) -> {
-            BigDecimal max = min.add(span);
-            Arbitrary<BigDecimal> boundaryX = Arbitraries.oneOf(
-                    Arbitraries.just(min), // 下边界
-                    Arbitraries.just(max), // 上边界
-                    Arbitraries.just(min.subtract(BigDecimal.ONE)), // 下越界
-                    Arbitraries.just(max.add(BigDecimal.ONE)), // 上越界
-                    // 边界等值的不同标度形式(仅追加尾随零,数值不变),验证 compareTo 标度无关
-                    Arbitraries.integers().between(0, 8).map(s -> min.setScale(min.scale() + s)),
-                    Arbitraries.integers().between(0, 8).map(s -> max.setScale(max.scale() + s)));
-            return Arbitraries.oneOf(arbitraryAmount(), boundaryX).map(x -> new RangeScenario(min, max, x));
-        });
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 7: 认购额度校验正确性(质押与私募统一)
-    // 固定档位模式:matchesMode 通过 ⟺ x 等于档位配置中的某一档位值(以 compareTo 判定 membership)
-    // Validates: Requirements 5.4, 5.5, 12.1, 12.2
-    @Property(tries = 100)
-    void fixedModePassesIffAmountEqualsSomeTier(@ForAll("fixedScenarios") FixedScenario s) {
-        // 由生成的档位列表构造产品 tier_config JSON(整数 toPlainString,JSON 往返无损)
-        String tierConfig =
-                s.tiers().stream().map(BigDecimal::toPlainString).collect(Collectors.joining(",", "[", "]"));
-
-        // 预期:x 等于某一档位(以 compareTo 判定,忽略标度差异)
-        boolean expected = s.tiers().stream().anyMatch(t -> t.compareTo(s.x()) == 0);
-
-        boolean actual = SubscriptionValidator.matchesMode(
-                s.x(), SubscriptionMode.FIXED, tierConfig, null, null);
-
-        // 双向不变量:校验通过 当且仅当 x 命中某一档位
-        assertThat(actual).isEqualTo(expected);
-    }
-
-    // Feature: staking-service, Property 7: 认购额度校验正确性(质押与私募统一)
-    // 无限制模式:matchesMode 通过 ⟺ minAmount ≤ x ≤ maxAmount(以 compareTo 判定区间归属)
-    // Validates: Requirements 5.4, 5.5, 12.1, 12.2
-    @Property(tries = 100)
-    void unlimitedModePassesIffWithinRange(@ForAll("rangeScenarios") RangeScenario s) {
-        // 预期:min ≤ x ≤ max(以 compareTo 判定,忽略标度差异)
-        boolean expected = s.x().compareTo(s.min()) >= 0 && s.x().compareTo(s.max()) <= 0;
-
-        boolean actual = SubscriptionValidator.matchesMode(
-                s.x(), SubscriptionMode.UNLIMITED, null, s.min(), s.max());
-
-        // 双向不变量:校验通过 当且仅当 x 落在闭区间 [min, max] 内
-        assertThat(actual).isEqualTo(expected);
-    }
-}

+ 0 - 307
bex-cloud-staking-service/src/test/java/com/bex/staking/grpc/AssetGrpcClientImplTest.java

@@ -1,307 +0,0 @@
-package com.bex.staking.grpc;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
-
-import com.bex.proto.asset.AssetServiceGrpc;
-import com.bex.proto.asset.BatchAirdropCreditRequest;
-import com.bex.proto.asset.BatchAirdropCreditResponse;
-import com.bex.proto.asset.DeductAvailableBalanceRequest;
-import com.bex.proto.asset.DeductAvailableBalanceResponse;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.exception.StakingException;
-import io.grpc.Status;
-import io.grpc.StatusRuntimeException;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-/**
- * {@link AssetGrpcClientImpl} 资产服务 gRPC 客户端单元测试。
- *
- * <p>以 Mockito 模拟 {@link AssetServiceGrpc.AssetServiceBlockingStub},在不依赖真实 gRPC 通道的前提下验证
- * 客户端实现对设计文档"gRPC 资产服务集成"约定的遵守,覆盖需求:
- *
- * <ul>
- *   <li>需求 15.1:通过 gRPC 调用下游资产服务执行本金扣减、收益到账、本金返还;
- *   <li>需求 15.4:金额一律以 {@link String} 形式传入 gRPC 请求消息(避免浮点精度丢失);
- *   <li>需求 15.3:下游返回失败或 gRPC 调用异常时,统一映射为
- *       {@link StakingException}(错误码 {@link StakingErrorCode#ASSET_GRPC_FAILED} = 50001)。
- * </ul>
- *
- * <p>同时验证 {@code referenceId} 作为下游幂等标识被正确透传到请求消息(扣减→{@code reference_id}、
- * 入账→{@code request_id})。
- *
- * <p>说明:{@link AssetGrpcClientImpl#burnTransfer} 当前临时复用 {@code DeductAvailableBalance} 从用户可用余额扣减销毁数量,
- * 待 asset 团队补充 {@code BurnTransfer} RPC 后切换为正式实现。
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("资产 gRPC 客户端 AssetGrpcClientImpl 测试")
-class AssetGrpcClientImplTest {
-
-    /** 资产服务阻塞式 Stub(被 mock 的下游依赖)。 */
-    @Mock
-    private AssetServiceGrpc.AssetServiceBlockingStub assetStub;
-
-    private AssetGrpcClientImpl assetGrpcClient;
-
-    @BeforeEach
-    void setUp() {
-        assetGrpcClient = new AssetGrpcClientImpl(assetStub);
-    }
-
-    // ============================ deductAvailableBalance ============================
-
-    @Test
-    @DisplayName("扣减本金:金额以字符串传参、reference_id 透传、account_type=FUND(需求 15.1/15.4)")
-    void deductAvailableBalanceShouldPassAmountAsStringAndReferenceId() {
-        // 下游返回成功
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        // 金额以高精度字符串传入(保留 18 位小数),用于断言"以字符串传参且值正确"
-        String amount = "1000.500000000000000000";
-        String referenceId = "STK20240101001";
-        assetGrpcClient.deductAvailableBalance(1001L, "BEX", amount, referenceId, "质押下单扣减本金");
-
-        // 捕获实际发送给 gRPC stub 的请求消息
-        ArgumentCaptor<DeductAvailableBalanceRequest> captor =
-                ArgumentCaptor.forClass(DeductAvailableBalanceRequest.class);
-        verify(assetStub).deductAvailableBalance(captor.capture());
-        DeductAvailableBalanceRequest request = captor.getValue();
-
-        // 金额字段类型为 String 且值正确(proto amount 字段为 string)
-        assertThat(request.getAmount()).isInstanceOf(String.class).isEqualTo(amount);
-        // reference_id 正确透传
-        assertThat(request.getReferenceId()).isEqualTo(referenceId);
-        // 其余关键字段
-        assertThat(request.getUserId()).isEqualTo(1001L);
-        assertThat(request.getCurrency()).isEqualTo("BEX");
-        assertThat(request.getAccountType()).isEqualTo("FUND");
-        assertThat(request.getRemark()).isEqualTo("质押下单扣减本金");
-    }
-
-    @Test
-    @DisplayName("扣减本金:下游返回 success=false 时映射为 StakingException(50001)(需求 15.3)")
-    void deductAvailableBalanceShouldMapBusinessFailureTo50001() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(false)
-                        .setMessage("余额不足")
-                        .build());
-
-        assertThatThrownBy(() ->
-                        assetGrpcClient.deductAvailableBalance(1001L, "BEX", "100", "STK-REF-1", "扣减"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    @Test
-    @DisplayName("扣减本金:gRPC 调用抛异常时映射为 StakingException(50001)(需求 15.3)")
-    void deductAvailableBalanceShouldMapGrpcExceptionTo50001() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenThrow(new StatusRuntimeException(Status.UNAVAILABLE));
-
-        assertThatThrownBy(() ->
-                        assetGrpcClient.deductAvailableBalance(1001L, "BEX", "100", "STK-REF-1", "扣减"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    // ============================ creditProfit ============================
-
-    @Test
-    @DisplayName("收益到账:金额以字符串传 amount_per_user、reference_id 透传为 request_id(需求 15.1/15.4)")
-    void creditProfitShouldPassAmountAsStringAndReferenceId() {
-        when(assetStub.batchAirdropCredit(any(BatchAirdropCreditRequest.class)))
-                .thenReturn(BatchAirdropCreditResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        String amount = "0.666666000000000000";
-        String referenceId = "EVT-PROFIT-001";
-        assetGrpcClient.creditProfit(2002L, "BEX", amount, referenceId, "质押收益到账");
-
-        ArgumentCaptor<BatchAirdropCreditRequest> captor =
-                ArgumentCaptor.forClass(BatchAirdropCreditRequest.class);
-        verify(assetStub).batchAirdropCredit(captor.capture());
-        BatchAirdropCreditRequest request = captor.getValue();
-
-        // 金额字段类型为 String 且值正确(proto amount_per_user 字段为 string)
-        assertThat(request.getAmountPerUser()).isInstanceOf(String.class).isEqualTo(amount);
-        // referenceId 透传为下游幂等键 request_id
-        assertThat(request.getRequestId()).isEqualTo(referenceId);
-        // 单用户入账:user_ids 仅包含目标用户
-        assertThat(request.getUserIdsList()).containsExactly(2002L);
-        assertThat(request.getCurrency()).isEqualTo("BEX");
-    }
-
-    @Test
-    @DisplayName("收益到账:下游返回 success=false 时映射为 StakingException(50001)(需求 15.3)")
-    void creditProfitShouldMapBusinessFailureTo50001() {
-        when(assetStub.batchAirdropCredit(any(BatchAirdropCreditRequest.class)))
-                .thenReturn(BatchAirdropCreditResponse.newBuilder()
-                        .setSuccess(false)
-                        .setMessage("入账失败")
-                        .build());
-
-        assertThatThrownBy(() -> assetGrpcClient.creditProfit(2002L, "BEX", "1", "EVT-1", "到账"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    @Test
-    @DisplayName("收益到账:gRPC 调用抛异常时映射为 StakingException(50001)(需求 15.3)")
-    void creditProfitShouldMapGrpcExceptionTo50001() {
-        when(assetStub.batchAirdropCredit(any(BatchAirdropCreditRequest.class)))
-                .thenThrow(new StatusRuntimeException(Status.INTERNAL));
-
-        assertThatThrownBy(() -> assetGrpcClient.creditProfit(2002L, "BEX", "1", "EVT-1", "到账"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    // ============================ returnPrincipal ============================
-
-    @Test
-    @DisplayName("本金返还:金额以字符串传 amount_per_user、reference_id 透传为 request_id(需求 15.1/15.4)")
-    void returnPrincipalShouldPassAmountAsStringAndReferenceId() {
-        when(assetStub.batchAirdropCredit(any(BatchAirdropCreditRequest.class)))
-                .thenReturn(BatchAirdropCreditResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        String amount = "5000.000000000000000000";
-        String referenceId = "EVT-RETURN-001";
-        assetGrpcClient.returnPrincipal(3003L, "BEX", amount, referenceId, "质押到期本金返还");
-
-        ArgumentCaptor<BatchAirdropCreditRequest> captor =
-                ArgumentCaptor.forClass(BatchAirdropCreditRequest.class);
-        verify(assetStub).batchAirdropCredit(captor.capture());
-        BatchAirdropCreditRequest request = captor.getValue();
-
-        assertThat(request.getAmountPerUser()).isInstanceOf(String.class).isEqualTo(amount);
-        assertThat(request.getRequestId()).isEqualTo(referenceId);
-        assertThat(request.getUserIdsList()).containsExactly(3003L);
-        assertThat(request.getCurrency()).isEqualTo("BEX");
-    }
-
-    @Test
-    @DisplayName("本金返还:下游返回 success=false 时映射为 StakingException(50001)(需求 15.3)")
-    void returnPrincipalShouldMapBusinessFailureTo50001() {
-        when(assetStub.batchAirdropCredit(any(BatchAirdropCreditRequest.class)))
-                .thenReturn(BatchAirdropCreditResponse.newBuilder()
-                        .setSuccess(false)
-                        .setMessage("返还失败")
-                        .build());
-
-        assertThatThrownBy(() -> assetGrpcClient.returnPrincipal(3003L, "BEX", "1", "EVT-1", "返还"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    // ============================ burnTransfer(临时复用 DeductAvailableBalance)============================
-
-    @Test
-    @DisplayName("销毁划转:临时复用 DeductAvailableBalance,金额以字符串传参、reference_id 透传(需求 15.1/15.4)")
-    void burnTransferShouldDelegateToDeductAvailableBalance() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        String amount = "50.000000000000000000";
-        String referenceId = "EVT-BURN-001";
-        String result = assetGrpcClient.burnTransfer(4004L, "BEX", amount, referenceId, "通缩赎回销毁");
-
-        // 返回 referenceId 作为临时 burnTxHash
-        assertThat(result).isEqualTo(referenceId);
-
-        // 验证调用了 deductAvailableBalance,且参数正确
-        ArgumentCaptor<DeductAvailableBalanceRequest> captor =
-                ArgumentCaptor.forClass(DeductAvailableBalanceRequest.class);
-        verify(assetStub).deductAvailableBalance(captor.capture());
-        DeductAvailableBalanceRequest request = captor.getValue();
-
-        assertThat(request.getUserId()).isEqualTo(4004L);
-        assertThat(request.getCurrency()).isEqualTo("BEX");
-        assertThat(request.getAmount()).isEqualTo(amount);
-        assertThat(request.getReferenceId()).isEqualTo(referenceId);
-        assertThat(request.getAccountType()).isEqualTo("FUND");
-        assertThat(request.getRemark()).isEqualTo("通缩赎回销毁");
-    }
-
-    @Test
-    @DisplayName("销毁划转:remark 为 null 时使用默认备注'销毁划转'")
-    void burnTransferShouldUseDefaultRemarkWhenNull() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        assetGrpcClient.burnTransfer(4004L, "BEX", "100", "EVT-BURN-002", null);
-
-        ArgumentCaptor<DeductAvailableBalanceRequest> captor =
-                ArgumentCaptor.forClass(DeductAvailableBalanceRequest.class);
-        verify(assetStub).deductAvailableBalance(captor.capture());
-        assertThat(captor.getValue().getRemark()).isEqualTo("销毁划转");
-    }
-
-    @Test
-    @DisplayName("销毁划转:下游返回 success=false 时映射为 StakingException(50001)(需求 15.3)")
-    void burnTransferShouldMapBusinessFailureTo50001() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(false)
-                        .setMessage("余额不足")
-                        .build());
-
-        assertThatThrownBy(() ->
-                        assetGrpcClient.burnTransfer(4004L, "BEX", "100", "EVT-BURN-003", "销毁"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    @Test
-    @DisplayName("销毁划转:gRPC 调用抛异常时映射为 StakingException(50001)(需求 15.3)")
-    void burnTransferShouldMapGrpcExceptionTo50001() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenThrow(new StatusRuntimeException(Status.UNAVAILABLE));
-
-        assertThatThrownBy(() ->
-                        assetGrpcClient.burnTransfer(4004L, "BEX", "100", "EVT-BURN-004", "销毁"))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    @Test
-    @DisplayName("扣减成功路径不应触达入账方法(行为隔离校验)")
-    void deductShouldNotInvokeAirdrop() {
-        when(assetStub.deductAvailableBalance(any(DeductAvailableBalanceRequest.class)))
-                .thenReturn(DeductAvailableBalanceResponse.newBuilder()
-                        .setSuccess(true)
-                        .build());
-
-        assetGrpcClient.deductAvailableBalance(1001L, "BEX", "100", "STK-REF-1", "扣减");
-
-        verify(assetStub, never()).batchAirdropCredit(any(BatchAirdropCreditRequest.class));
-    }
-}

+ 0 - 184
bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventConsumerTest.java

@@ -1,184 +0,0 @@
-package com.bex.staking.mq;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.entity.StakingAssetEvent;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.AssetEventStatus;
-import com.bex.staking.enums.AssetEventType;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.grpc.AssetGrpcClient;
-import com.bex.staking.mapper.StakingAssetEventMapper;
-import com.bex.staking.mapper.StakingOrderMapper;
-import tools.jackson.databind.ObjectMapper;
-import java.math.BigDecimal;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-/**
- * {@link AssetEventConsumer} 资产账变事件消费者单元测试。
- *
- * <p>以 Mockito 模拟 {@link AssetGrpcClient}、{@link StakingAssetEventMapper}、{@link StakingOrderMapper},
- * 在不依赖真实 RocketMQ / gRPC 的前提下验证消费者对设计「本地事务 + 异步账变解耦」与需求 11.4、11.9、15.2、15.3
- * 的遵守:
- *
- * <ul>
- *   <li>CREDIT_PROFIT 成功 → 调用收益到账并置 CONFIRMED(需求 11.9、15.2);
- *   <li>下游失败(StakingException 50001)→ 置 FAILED,不重抛、不影响本地订单(需求 15.3);
- *   <li>事件已 CONFIRMED → 幂等跳过,不重复调用下游(需求 15.2);
- *   <li>BURN 成功 → 回填事件与订单 burnTxHash 并置 CONFIRMED(需求 11.4)。
- * </ul>
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("资产账变事件消费者 AssetEventConsumer 测试")
-class AssetEventConsumerTest {
-
-    @Mock
-    private StakingAssetEventMapper assetEventMapper;
-
-    @Mock
-    private StakingOrderMapper stakingOrderMapper;
-
-    @Mock
-    private AssetGrpcClient assetGrpcClient;
-
-    private final ObjectMapper objectMapper = new ObjectMapper();
-
-    private AssetEventConsumer consumer;
-
-    @BeforeEach
-    void setUp() {
-        consumer = new AssetEventConsumer(assetEventMapper, stakingOrderMapper, assetGrpcClient, objectMapper);
-    }
-
-    private StakingAssetEvent event(String eventNo, AssetEventType type, AssetEventStatus status, BigDecimal amount) {
-        return StakingAssetEvent.builder()
-                .eventNo(eventNo)
-                .orderNo("STK20240101001")
-                .userId(1001L)
-                .eventType(type.getValue())
-                .amount(amount)
-                .status(status.getValue())
-                .retryCount(0)
-                .version(0)
-                .build();
-    }
-
-    private AssetEventMessage message(String eventNo, AssetEventType type, String amount) {
-        return new AssetEventMessage(eventNo, "STK20240101001", 1001L, type.getValue(), amount);
-    }
-
-    @Test
-    @DisplayName("CREDIT_PROFIT 成功:调用收益到账(BEX/eventNo),事件置 CONFIRMED(需求 11.9/15.2)")
-    void creditProfitSuccessShouldMarkConfirmed() {
-        String eventNo = "EVT_STK20240101001_CREDIT_PROFIT";
-        StakingAssetEvent stored =
-                event(eventNo, AssetEventType.CREDIT_PROFIT, AssetEventStatus.SENT, new BigDecimal("12.5"));
-        when(assetEventMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(stored);
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-
-        consumer.handle(message(eventNo, AssetEventType.CREDIT_PROFIT, "12.5"));
-
-        // 调用收益到账:币种 BEX、金额字符串、referenceId=eventNo(需求 15.2/15.4)
-        verify(assetGrpcClient)
-                .creditProfit(eq(1001L), eq(StakingConstants.UNIT_BEX), eq("12.5"), eq(eventNo), anyString());
-        // 事件置 CONFIRMED
-        ArgumentCaptor<StakingAssetEvent> captor = ArgumentCaptor.forClass(StakingAssetEvent.class);
-        verify(assetEventMapper).updateById(captor.capture());
-        assertThat(captor.getValue().getStatus()).isEqualTo(AssetEventStatus.CONFIRMED.getValue());
-        // 非销毁场景不触达订单
-        verifyNoInteractions(stakingOrderMapper);
-    }
-
-    @Test
-    @DisplayName("CREDIT_PROFIT 下游失败(50001):事件置 FAILED,不重抛、不影响本地订单(需求 15.3)")
-    void creditProfitFailureShouldMarkFailedWithoutRethrow() {
-        String eventNo = "EVT_FAIL_CREDIT_PROFIT";
-        StakingAssetEvent stored =
-                event(eventNo, AssetEventType.CREDIT_PROFIT, AssetEventStatus.SENT, new BigDecimal("8"));
-        when(assetEventMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(stored);
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-        org.mockito.Mockito.doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务不可用"))
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        // 不应向上抛出(不向 MQ 重抛)
-        consumer.handle(message(eventNo, AssetEventType.CREDIT_PROFIT, "8"));
-
-        ArgumentCaptor<StakingAssetEvent> captor = ArgumentCaptor.forClass(StakingAssetEvent.class);
-        verify(assetEventMapper).updateById(captor.capture());
-        assertThat(captor.getValue().getStatus()).isEqualTo(AssetEventStatus.FAILED.getValue());
-    }
-
-    @Test
-    @DisplayName("事件已 CONFIRMED:幂等跳过,不调用下游、不更新(需求 15.2)")
-    void confirmedEventShouldBeSkippedIdempotently() {
-        String eventNo = "EVT_DONE_RETURN_PRINCIPAL";
-        StakingAssetEvent stored =
-                event(eventNo, AssetEventType.RETURN_PRINCIPAL, AssetEventStatus.CONFIRMED, new BigDecimal("100"));
-        when(assetEventMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(stored);
-
-        consumer.handle(message(eventNo, AssetEventType.RETURN_PRINCIPAL, "100"));
-
-        verifyNoInteractions(assetGrpcClient);
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("事件不存在:记录告警跳过,不调用下游(需求 15.2)")
-    void missingEventShouldBeSkipped() {
-        when(assetEventMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
-
-        consumer.handle(message("EVT_NOT_EXIST", AssetEventType.CREDIT_PROFIT, "1"));
-
-        verifyNoInteractions(assetGrpcClient);
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("BURN 成功:回填事件与订单 burnTxHash,事件置 CONFIRMED(需求 11.4)")
-    void burnSuccessShouldBackfillTxHashAndMarkConfirmed() {
-        String eventNo = "EVT_STK20240101001_BURN";
-        StakingAssetEvent stored =
-                event(eventNo, AssetEventType.BURN, AssetEventStatus.SENT, new BigDecimal("200"));
-        StakingOrder order = StakingOrder.builder()
-                .orderNo("STK20240101001")
-                .userId(1001L)
-                .version(0)
-                .build();
-        when(assetEventMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(stored);
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-        when(assetGrpcClient.burnTransfer(anyLong(), anyString(), anyString(), anyString(), anyString()))
-                .thenReturn("0xBURNTXHASH");
-        when(stakingOrderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-
-        consumer.handle(message(eventNo, AssetEventType.BURN, "200"));
-
-        // 回填订单 burnTxHash
-        ArgumentCaptor<StakingOrder> orderCaptor = ArgumentCaptor.forClass(StakingOrder.class);
-        verify(stakingOrderMapper).updateById(orderCaptor.capture());
-        assertThat(orderCaptor.getValue().getBurnTxHash()).isEqualTo("0xBURNTXHASH");
-        // 事件回填 tx_hash 并置 CONFIRMED
-        ArgumentCaptor<StakingAssetEvent> eventCaptor = ArgumentCaptor.forClass(StakingAssetEvent.class);
-        verify(assetEventMapper).updateById(eventCaptor.capture());
-        assertThat(eventCaptor.getValue().getStatus()).isEqualTo(AssetEventStatus.CONFIRMED.getValue());
-        assertThat(eventCaptor.getValue().getTxHash()).isEqualTo("0xBURNTXHASH");
-    }
-}

+ 0 - 487
bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventLocalMessageTableIntegrationTest.java

@@ -1,487 +0,0 @@
-package com.bex.staking.mq;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.reset;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.annotation.DbType;
-import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
-import com.baomidou.mybatisplus.autoconfigure.MybatisPlusLanguageDriverAutoConfiguration;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
-import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
-import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
-import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.entity.StakingAssetEvent;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.enums.AssetEventStatus;
-import com.bex.staking.enums.AssetEventType;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.ProductType;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.grpc.AssetGrpcClient;
-import com.bex.staking.mapper.StakingAssetEventMapper;
-import com.bex.staking.mapper.StakingOrderMapper;
-import tools.jackson.databind.ObjectMapper;
-import java.math.BigDecimal;
-import java.sql.Connection;
-import java.sql.PreparedStatement;
-import java.sql.Timestamp;
-import java.time.LocalDateTime;
-import java.util.ArrayList;
-import java.util.List;
-import javax.sql.DataSource;
-import org.apache.ibatis.reflection.MetaObject;
-import org.apache.rocketmq.client.producer.SendResult;
-import org.apache.rocketmq.client.producer.SendStatus;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mockito;
-import org.mybatis.spring.annotation.MapperScan;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.SpringBootConfiguration;
-import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.annotation.Bean;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.jdbc.datasource.SimpleDriverDataSource;
-import org.springframework.jdbc.datasource.init.DataSourceInitializer;
-import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
-import org.springframework.messaging.Message;
-import org.springframework.test.context.ActiveProfiles;
-
-/**
- * RocketMQ + 本地消息表集成测试(任务 17.3)。
- *
- * <p>对应设计文档「本地事务 + 异步账变解耦」「Testing Strategy · 集成测试」与需求 15.2、15.3:验证资产账变事件在本地
- * 消息表 {@code staking_asset_event} 上的<b>端到端状态流转链路</b>——
- * 「本地落库(PENDING) → 生产者派发(SENT) → 消费(CONFIRMED)」以及「消费失败(FAILED) → 指数退避重试 → 再消费成功」
- * 与「重试达上限进入死信、保持 FAILED」。这是组件<b>串联</b>的链路验证(少量代表性用例,非 PBT);各组件自身的分支
- * 行为已由单元测试 {@link AssetEventProducerImplTest}、{@link AssetEventConsumerTest}、
- * {@link AssetEventRetryServiceTest} 覆盖。
- *
- * <h2>测试方案(H2 + 链路串联,复用任务 2.3 测试基建)</h2>
- *
- * <p>真实 RocketMQ broker 与资产 gRPC 无法在测试中启动,故集成测试聚焦「本地消息表状态流转链路」:
- *
- * <ul>
- *   <li>用 H2({@code MODE=MySQL})内存库 + 真实 {@link StakingAssetEventMapper} / {@link StakingOrderMapper}
- *       装配本地消息表({@code schema-h2.sql} 建表、保留 {@code uk_event_no} 与 {@code version} 乐观锁);
- *   <li>装配<b>真实</b> {@link AssetEventProducerImpl}、{@link AssetEventConsumer}、{@link AssetEventRetryService};
- *   <li>{@code mock} {@link RocketMQTemplate}({@code syncSend} 返回 {@link SendStatus#SEND_OK} 并<b>捕获消息体</b>,
- *       由测试取出后转交消费者,从而在不依赖 broker 的前提下串联生产者与消费者);
- *   <li>{@code mock} {@link AssetGrpcClient}(按链路控制下游账变成功 / 抛 {@link StakingErrorCode#ASSET_GRPC_FAILED}
- *       50001 失败)。
- * </ul>
- *
- * <p>为避免业务模块重型基础设施(RocketMQ / Redisson / gRPC / Druid 等)自动装配在测试启动时连接外部依赖,
- * 测试上下文采用 {@link ImportAutoConfiguration} <b>白名单</b>仅装配 MyBatis-Plus,并显式提供 H2 {@link DataSource}
- * 与建表初始化器,被测组件以普通 Bean 形式注册({@link AssetEventConsumer} 的 {@code @RocketMQMessageListener}
- * 注解在无 RocketMQ 容器注册器时仅为元数据,不会启动监听容器)。
- *
- * <p>Validates: Requirements 15.2, 15.3
- */
-@SpringBootTest(
-        classes = AssetEventLocalMessageTableIntegrationTest.ChainTestConfig.class,
-        webEnvironment = SpringBootTest.WebEnvironment.NONE)
-@ActiveProfiles("test")
-@DisplayName("RocketMQ + 本地消息表集成测试 (任务 17.3, H2 链路串联)")
-class AssetEventLocalMessageTableIntegrationTest {
-
-    @Autowired
-    private StakingAssetEventMapper assetEventMapper;
-
-    @Autowired
-    private StakingOrderMapper stakingOrderMapper;
-
-    @Autowired
-    private AssetEventProducer assetEventProducer;
-
-    @Autowired
-    private AssetEventConsumer assetEventConsumer;
-
-    @Autowired
-    private AssetEventRetryService assetEventRetryService;
-
-    @Autowired
-    private RocketMQTemplate rocketMQTemplate;
-
-    @Autowired
-    private AssetGrpcClient assetGrpcClient;
-
-    @Autowired
-    private CapturedMessages capturedMessages;
-
-    @Autowired
-    private DataSource dataSource;
-
-    // ------------------------------------------------------------------------
-    // 测试夹具:清库 + 重置 mock,确保用例间隔离
-    // ------------------------------------------------------------------------
-
-    @BeforeEach
-    void setUp() {
-        // 清空两张表,避免唯一约束 / 残留数据跨用例污染
-        assetEventMapper.delete(new LambdaQueryWrapper<>());
-        stakingOrderMapper.delete(new LambdaQueryWrapper<>());
-        capturedMessages.clear();
-        reset(rocketMQTemplate, assetGrpcClient);
-        // 生产者发送:默认返回 SEND_OK,并捕获消息体(payload)供消费者消费
-        when(rocketMQTemplate.syncSend(anyString(), any(Message.class))).thenAnswer(invocation -> {
-            Message<?> message = invocation.getArgument(1);
-            capturedMessages.add(String.valueOf(message.getPayload()));
-            return sendOk();
-        });
-    }
-
-    // ========================================================================
-    // 链路 1:本地落库(PENDING) → 派发(SENT) → 消费(CONFIRMED) —— 成功链路(需求 15.2)
-    // ========================================================================
-
-    @Test
-    @DisplayName("链路1 成功:PENDING → producer 派发置 SENT 并捕获消息 → consumer 消费(收益到账成功) → CONFIRMED")
-    void happyPathPendingToConfirmed() {
-        // 准备:本地落库一条 PENDING 收益到账事件(清算 / 领取链路落库的等价模拟)
-        String eventNo = "EVT_CHAIN1_CREDIT_PROFIT";
-        StakingAssetEvent event = pendingEvent(eventNo, AssetEventType.CREDIT_PROFIT, new BigDecimal("12.500000"));
-        assetEventMapper.insert(event);
-        // 下游收益到账成功
-        doNothing().when(assetGrpcClient).creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        // 1. 生产者扫描 PENDING 并派发 → 置 SENT,捕获 MQ 消息体
-        AssetEventDispatchStats stats = assetEventProducer.dispatchPendingEvents();
-        assertThat(stats.scanned()).isEqualTo(1);
-        assertThat(stats.sent()).isEqualTo(1);
-        assertThat(stats.failed()).isZero();
-        assertThat(reload(eventNo).getStatus()).isEqualTo(AssetEventStatus.SENT.getValue());
-        assertThat(capturedMessages.all()).hasSize(1);
-
-        // 2. 将捕获到的 MQ 消息体交给消费者消费(模拟 broker 投递)
-        assetEventConsumer.onMessage(capturedMessages.last());
-
-        // 3. 下游收益到账被调用:BEX / referenceId=eventNo(需求 15.2、15.4);
-        //    金额以字符串透传,DECIMAL(36,18) 往返后 scale 为 18,按 BigDecimal 数值断言(compareTo,忽略 scale)
-        ArgumentCaptor<String> amountCaptor = ArgumentCaptor.forClass(String.class);
-        verify(assetGrpcClient)
-                .creditProfit(
-                        eq(1001L),
-                        eq(StakingConstants.UNIT_BEX),
-                        amountCaptor.capture(),
-                        eq(eventNo),
-                        anyString());
-        assertThat(new BigDecimal(amountCaptor.getValue())).isEqualByComparingTo(new BigDecimal("12.5"));
-        // 4. 事件最终置 CONFIRMED
-        assertThat(reload(eventNo).getStatus()).isEqualTo(AssetEventStatus.CONFIRMED.getValue());
-    }
-
-    // ========================================================================
-    // 链路 2:消费失败(FAILED) → 指数退避重试 → 重置 PENDING → 再派发 → 再消费成功(CONFIRMED)(需求 15.3)
-    // ========================================================================
-
-    @Test
-    @DisplayName("链路2 失败重试:消费失败置 FAILED → retryService 越过退避重置 PENDING(retry+1)并重发 → 再消费成功置 CONFIRMED")
-    void failureThenRetrySucceeds() {
-        String eventNo = "EVT_CHAIN2_RETURN_PRINCIPAL";
-        StakingAssetEvent event = pendingEvent(eventNo, AssetEventType.RETURN_PRINCIPAL, new BigDecimal("5000"));
-        assetEventMapper.insert(event);
-
-        // 第一次消费:下游本金返还失败(50001)→ 事件置 FAILED
-        doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务暂不可用"))
-                .when(assetGrpcClient)
-                .returnPrincipal(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        assetEventProducer.dispatchPendingEvents();
-        assertThat(reload(eventNo).getStatus()).isEqualTo(AssetEventStatus.SENT.getValue());
-        assetEventConsumer.onMessage(capturedMessages.last());
-
-        StakingAssetEvent failed = reload(eventNo);
-        assertThat(failed.getStatus()).isEqualTo(AssetEventStatus.FAILED.getValue());
-
-        // 将 updated_at 调早 1 小时,越过指数退避间隔(retryCount=0 → 1000ms),使本批可重发
-        backdateUpdatedAt(eventNo, LocalDateTime.now().minusHours(1));
-
-        // 第二次下游本金返还成功
-        reset(assetGrpcClient);
-        doNothing().when(assetGrpcClient).returnPrincipal(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        // 重试服务:扫描 FAILED → 越过退避 → 重置 PENDING(retryCount+1) → 触发重发置 SENT
-        AssetEventRetryStats retryStats = assetEventRetryService.retryFailedEvents();
-        assertThat(retryStats.scanned()).isEqualTo(1);
-        assertThat(retryStats.retried()).isEqualTo(1);
-        assertThat(retryStats.deadLetter()).isZero();
-
-        StakingAssetEvent afterRetry = reload(eventNo);
-        // 重发成功后事件为 SENT,且重试次数 +1(PENDING → 派发 → SENT 一次性完成)
-        assertThat(afterRetry.getStatus()).isEqualTo(AssetEventStatus.SENT.getValue());
-        assertThat(afterRetry.getRetryCount()).isEqualTo(1);
-
-        // 再次消费重发出的消息:下游成功 → 置 CONFIRMED
-        assetEventConsumer.onMessage(capturedMessages.last());
-        assertThat(reload(eventNo).getStatus()).isEqualTo(AssetEventStatus.CONFIRMED.getValue());
-    }
-
-    // ========================================================================
-    // 链路 3:重试次数达上限 → 进入死信告警、保持 FAILED 不再重发(需求 15.3)
-    // ========================================================================
-
-    @Test
-    @DisplayName("链路3 死信:retryCount 达上限的 FAILED 事件计入死信、保持 FAILED 不重发")
-    void maxRetryEntersDeadLetter() {
-        String eventNo = "EVT_CHAIN3_BURN";
-        StakingAssetEvent event = pendingEvent(eventNo, AssetEventType.BURN, new BigDecimal("200"));
-        event.setStatus(AssetEventStatus.FAILED.getValue());
-        event.setRetryCount(AssetEventRetryService.MAX_RETRY);
-        assetEventMapper.insert(event);
-        // 调早 updated_at,排除"退避未到"导致的跳过,确保命中死信判定
-        backdateUpdatedAt(eventNo, LocalDateTime.now().minusHours(1));
-
-        AssetEventRetryStats stats = assetEventRetryService.retryFailedEvents();
-
-        assertThat(stats.scanned()).isEqualTo(1);
-        assertThat(stats.deadLetter()).isEqualTo(1);
-        assertThat(stats.retried()).isZero();
-        // 死信事件不重发、不变更状态:仍为 FAILED 且重试次数不变
-        StakingAssetEvent afterDeadLetter = reload(eventNo);
-        assertThat(afterDeadLetter.getStatus()).isEqualTo(AssetEventStatus.FAILED.getValue());
-        assertThat(afterDeadLetter.getRetryCount()).isEqualTo(AssetEventRetryService.MAX_RETRY);
-        // 未触发任何 MQ 重发
-        assertThat(capturedMessages.all()).isEmpty();
-    }
-
-    // ========================================================================
-    // 链路 4:BURN 销毁成功 → 回填事件与订单 burnTxHash 并置 CONFIRMED(需求 11.4、15.2)
-    // ========================================================================
-
-    @Test
-    @DisplayName("链路4 销毁:PENDING(BURN) → 派发 SENT → 消费(销毁成功) → 回填事件/订单 burnTxHash 并置 CONFIRMED")
-    void burnPathBackfillsTxHashAndConfirms() {
-        // 预置关联订单(用于回填 burnTxHash)
-        StakingOrder order = StakingOrder.builder()
-                .orderNo("STK_CHAIN4_001")
-                .userId(1001L)
-                .productId(9001L)
-                .productType(ProductType.STAKING.name())
-                .idempotentKey("idem-chain4")
-                .stakeAmount(new BigDecimal("1000"))
-                .entryPrice(new BigDecimal("1.25"))
-                .initialValueUsdt(new BigDecimal("1250"))
-                .status(OrderStatus.DEFLATED.getValue())
-                .lockStartTime(1_700_000_000_000L)
-                .expireTime(1_700_086_400_000L)
-                .build();
-        stakingOrderMapper.insert(order);
-
-        String eventNo = "EVT_CHAIN4_BURN";
-        StakingAssetEvent event = StakingAssetEvent.builder()
-                .eventNo(eventNo)
-                .orderNo("STK_CHAIN4_001")
-                .userId(1001L)
-                .eventType(AssetEventType.BURN.getValue())
-                .amount(new BigDecimal("200"))
-                .status(AssetEventStatus.PENDING.getValue())
-                .retryCount(0)
-                .build();
-        assetEventMapper.insert(event);
-
-        when(assetGrpcClient.burnTransfer(anyLong(), anyString(), anyString(), anyString(), anyString()))
-                .thenReturn("0xBURNTXHASH");
-
-        assetEventProducer.dispatchPendingEvents();
-        assetEventConsumer.onMessage(capturedMessages.last());
-
-        // 事件置 CONFIRMED 并回填 tx_hash
-        StakingAssetEvent confirmed = reload(eventNo);
-        assertThat(confirmed.getStatus()).isEqualTo(AssetEventStatus.CONFIRMED.getValue());
-        assertThat(confirmed.getTxHash()).isEqualTo("0xBURNTXHASH");
-        // 订单回填 burnTxHash
-        StakingOrder reloadOrder = stakingOrderMapper.selectOne(
-                new LambdaQueryWrapper<StakingOrder>().eq(StakingOrder::getOrderNo, "STK_CHAIN4_001"));
-        assertThat(reloadOrder.getBurnTxHash()).isEqualTo("0xBURNTXHASH");
-    }
-
-    // ------------------------------------------------------------------------
-    // 辅助方法
-    // ------------------------------------------------------------------------
-
-    /** 构造一条处于 PENDING 的资产账变事件。 */
-    private StakingAssetEvent pendingEvent(String eventNo, AssetEventType type, BigDecimal amount) {
-        return StakingAssetEvent.builder()
-                .eventNo(eventNo)
-                .orderNo("STK_CHAIN_001")
-                .userId(1001L)
-                .eventType(type.getValue())
-                .amount(amount)
-                .status(AssetEventStatus.PENDING.getValue())
-                .retryCount(0)
-                .build();
-    }
-
-    /** 按 eventNo 重新查询事件,读取最新落库状态。 */
-    private StakingAssetEvent reload(String eventNo) {
-        return assetEventMapper.selectOne(
-                new LambdaQueryWrapper<StakingAssetEvent>().eq(StakingAssetEvent::getEventNo, eventNo));
-    }
-
-    /**
-     * 将事件的 {@code updated_at} 调整为指定时间(绕过 MyBatis-Plus 自动填充,直接执行 SQL)。
-     *
-     * <p>重试退避判定依赖 {@code updatedAt};测试中将其调早以越过指数退避间隔,避免真实等待。
-     */
-    private void backdateUpdatedAt(String eventNo, LocalDateTime time) {
-        try (Connection conn = dataSource.getConnection();
-                PreparedStatement ps =
-                        conn.prepareStatement("UPDATE staking_asset_event SET updated_at = ? WHERE event_no = ?")) {
-            ps.setTimestamp(1, Timestamp.valueOf(time));
-            ps.setString(2, eventNo);
-            ps.executeUpdate();
-        } catch (Exception e) {
-            throw new IllegalStateException("调整 updated_at 失败: " + eventNo, e);
-        }
-    }
-
-    private static SendResult sendOk() {
-        SendResult result = new SendResult();
-        result.setSendStatus(SendStatus.SEND_OK);
-        result.setMsgId("MSG-" + System.nanoTime());
-        return result;
-    }
-
-    /** 捕获生产者发送到 MQ 的消息体(payload),供消费者在测试中消费。 */
-    static class CapturedMessages {
-        private final List<String> payloads = new ArrayList<>();
-
-        void add(String payload) {
-            payloads.add(payload);
-        }
-
-        String last() {
-            if (payloads.isEmpty()) {
-                throw new IllegalStateException("尚无捕获的 MQ 消息");
-            }
-            return payloads.get(payloads.size() - 1);
-        }
-
-        List<String> all() {
-            return payloads;
-        }
-
-        void clear() {
-            payloads.clear();
-        }
-    }
-
-    /**
-     * 集成测试最小化上下文:仅装配 MyBatis-Plus(白名单),显式提供 H2 数据源与建表初始化器,
-     * 并注册真实的生产者 / 消费者 / 重试组件 + mock 的 RocketMQTemplate / AssetGrpcClient。
-     *
-     * <p>不引入业务模块的 RocketMQ / Redisson / gRPC / Druid 自动装配,避免测试启动连接外部依赖。
-     */
-    @SpringBootConfiguration
-    @ImportAutoConfiguration({MybatisPlusLanguageDriverAutoConfiguration.class, MybatisPlusAutoConfiguration.class})
-    @MapperScan("com.bex.staking.mapper")
-    static class ChainTestConfig {
-
-        /** H2(MySQL 兼容模式)内存数据源,连接参数取自 {@code application-test.yml}。 */
-        @Bean
-        DataSource dataSource(
-                @Value("${spring.datasource.url}") String url,
-                @Value("${spring.datasource.username}") String username,
-                @Value("${spring.datasource.password}") String password)
-                throws Exception {
-            return new SimpleDriverDataSource(new org.h2.Driver(), url, username, password);
-        }
-
-        /** 建表初始化器:执行 {@code schema-h2.sql}(含 uk_event_no 唯一约束、version 乐观锁字段)。 */
-        @Bean
-        DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
-            ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
-            populator.addScript(new ClassPathResource("schema-h2.sql"));
-            DataSourceInitializer initializer = new DataSourceInitializer();
-            initializer.setDataSource(dataSource);
-            initializer.setDatabasePopulator(populator);
-            return initializer;
-        }
-
-        /** MyBatis-Plus 拦截器:乐观锁(先注册)+ 分页,与生产配置一致。 */
-        @Bean
-        MybatisPlusInterceptor mybatisPlusInterceptor() {
-            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
-            interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
-            interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
-            return interceptor;
-        }
-
-        /** 审计字段自动填充:插入填充 createdAt/updatedAt,更新填充 updatedAt(与生产一致)。 */
-        @Bean
-        MetaObjectHandler metaObjectHandler() {
-            return new MetaObjectHandler() {
-                @Override
-                public void insertFill(MetaObject metaObject) {
-                    this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
-                    this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
-                }
-
-                @Override
-                public void updateFill(MetaObject metaObject) {
-                    this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
-                }
-            };
-        }
-
-        @Bean
-        ObjectMapper objectMapper() {
-            return new ObjectMapper();
-        }
-
-        @Bean
-        RocketMQTemplate rocketMQTemplate() {
-            return Mockito.mock(RocketMQTemplate.class);
-        }
-
-        @Bean
-        AssetGrpcClient assetGrpcClient() {
-            return Mockito.mock(AssetGrpcClient.class);
-        }
-
-        @Bean
-        CapturedMessages capturedMessages() {
-            return new CapturedMessages();
-        }
-
-        @Bean
-        AssetEventProducer assetEventProducer(
-                StakingAssetEventMapper assetEventMapper,
-                RocketMQTemplate rocketMQTemplate,
-                ObjectMapper objectMapper) {
-            return new AssetEventProducerImpl(assetEventMapper, rocketMQTemplate, objectMapper);
-        }
-
-        @Bean
-        AssetEventConsumer assetEventConsumer(
-                StakingAssetEventMapper assetEventMapper,
-                StakingOrderMapper stakingOrderMapper,
-                AssetGrpcClient assetGrpcClient,
-                ObjectMapper objectMapper) {
-            return new AssetEventConsumer(assetEventMapper, stakingOrderMapper, assetGrpcClient, objectMapper);
-        }
-
-        @Bean
-        AssetEventRetryService assetEventRetryService(
-                StakingAssetEventMapper assetEventMapper, AssetEventProducer assetEventProducer) {
-            return new AssetEventRetryService(assetEventMapper, assetEventProducer);
-        }
-    }
-}

+ 0 - 204
bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventProducerImplTest.java

@@ -1,204 +0,0 @@
-package com.bex.staking.mq;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyList;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.bex.staking.constant.StakingConstants;
-import com.bex.staking.entity.StakingAssetEvent;
-import com.bex.staking.enums.AssetEventStatus;
-import com.bex.staking.enums.AssetEventType;
-import com.bex.staking.mapper.StakingAssetEventMapper;
-import tools.jackson.databind.ObjectMapper;
-import java.math.BigDecimal;
-import java.util.List;
-import org.apache.rocketmq.client.producer.SendResult;
-import org.apache.rocketmq.client.producer.SendStatus;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import org.apache.rocketmq.spring.support.RocketMQHeaders;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.messaging.Message;
-
-/**
- * {@link AssetEventProducerImpl} 资产账变事件生产者单元测试。
- *
- * <p>以 Mockito 模拟 {@link RocketMQTemplate} 与 {@link StakingAssetEventMapper},在不依赖真实 RocketMQ 的前提下
- * 验证生产者对设计「本地事务 + 异步账变解耦」与需求 15.2 的遵守:
- *
- * <ul>
- *   <li>扫描 PENDING 事件并发送 RocketMQ 事件(CREDIT_PROFIT / RETURN_PRINCIPAL / BURN),发送成功后置 SENT;
- *   <li>目的地为 {@code staking_asset_topic:{eventType}},消息 KEY 为 {@code eventNo}(幂等 reference_id);
- *   <li>消息体携带 event_no / order_no / user_id / event_type / amount,金额以字符串承载(避免精度丢失);
- *   <li>发送失败不置 SENT,事件保留 PENDING 待下次扫描重发。
- * </ul>
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("资产账变事件生产者 AssetEventProducerImpl 测试")
-class AssetEventProducerImplTest {
-
-    @Mock
-    private StakingAssetEventMapper assetEventMapper;
-
-    @Mock
-    private RocketMQTemplate rocketMQTemplate;
-
-    private final ObjectMapper objectMapper = new ObjectMapper();
-
-    private AssetEventProducerImpl producer;
-
-    @BeforeEach
-    void setUp() {
-        producer = new AssetEventProducerImpl(assetEventMapper, rocketMQTemplate, objectMapper);
-    }
-
-    private StakingAssetEvent pendingEvent(String eventNo, AssetEventType type, BigDecimal amount) {
-        return StakingAssetEvent.builder()
-                .eventNo(eventNo)
-                .orderNo("STK20240101001")
-                .userId(1001L)
-                .eventType(type.getValue())
-                .amount(amount)
-                .status(AssetEventStatus.PENDING.getValue())
-                .retryCount(0)
-                .version(0)
-                .build();
-    }
-
-    private SendResult sendOk() {
-        SendResult result = new SendResult();
-        result.setSendStatus(SendStatus.SEND_OK);
-        result.setMsgId("MSG-1");
-        return result;
-    }
-
-    @Test
-    @DisplayName("派发成功:发送到 topic:eventType、KEY=eventNo、消息体携带字符串金额,并置 SENT(需求 15.2/15.4)")
-    void dispatchShouldSendWithKeyAndPayloadThenMarkSent() throws Exception {
-        StakingAssetEvent event =
-                pendingEvent("EVT_STK20240101001_RETURN_PRINCIPAL", AssetEventType.RETURN_PRINCIPAL, new BigDecimal("5000.000000000000000000"));
-        when(rocketMQTemplate.syncSend(any(String.class), any(Message.class))).thenReturn(sendOk());
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-
-        boolean ok = producer.dispatch(event);
-
-        assertThat(ok).isTrue();
-
-        // 捕获目的地与消息
-        ArgumentCaptor<String> destCaptor = ArgumentCaptor.forClass(String.class);
-        @SuppressWarnings("unchecked")
-        ArgumentCaptor<Message<String>> msgCaptor = ArgumentCaptor.forClass(Message.class);
-        verify(rocketMQTemplate).syncSend(destCaptor.capture(), msgCaptor.capture());
-
-        // 目的地 = topic:tag(eventType)
-        assertThat(destCaptor.getValue())
-                .isEqualTo(StakingConstants.TOPIC_STAKING_ASSET + ":" + AssetEventType.RETURN_PRINCIPAL.getValue());
-
-        // 消息 KEY = eventNo(幂等 reference_id)
-        Message<String> sent = msgCaptor.getValue();
-        assertThat(sent.getHeaders().get(RocketMQHeaders.KEYS)).isEqualTo(event.getEventNo());
-
-        // 消息体携带 event_no/order_no/user_id/event_type/amount,金额为字符串
-        AssetEventMessage body = objectMapper.readValue(sent.getPayload(), AssetEventMessage.class);
-        assertThat(body.eventNo()).isEqualTo(event.getEventNo());
-        assertThat(body.orderNo()).isEqualTo("STK20240101001");
-        assertThat(body.userId()).isEqualTo(1001L);
-        assertThat(body.eventType()).isEqualTo(AssetEventType.RETURN_PRINCIPAL.getValue());
-        assertThat(body.amount()).isEqualTo("5000.000000000000000000");
-
-        // 发送成功后置 SENT 并落库
-        ArgumentCaptor<StakingAssetEvent> updateCaptor = ArgumentCaptor.forClass(StakingAssetEvent.class);
-        verify(assetEventMapper).updateById(updateCaptor.capture());
-        assertThat(updateCaptor.getValue().getStatus()).isEqualTo(AssetEventStatus.SENT.getValue());
-    }
-
-    @Test
-    @DisplayName("发送失败(非 SEND_OK):不置 SENT,事件保留 PENDING 待重发(需求 15.2)")
-    void dispatchShouldKeepPendingWhenSendNotOk() {
-        StakingAssetEvent event = pendingEvent("EVT_X_BURN", AssetEventType.BURN, new BigDecimal("100"));
-        SendResult flushFailed = new SendResult();
-        flushFailed.setSendStatus(SendStatus.FLUSH_DISK_TIMEOUT);
-        when(rocketMQTemplate.syncSend(any(String.class), any(Message.class))).thenReturn(flushFailed);
-
-        boolean ok = producer.dispatch(event);
-
-        assertThat(ok).isFalse();
-        // 未置 SENT,事件仍为 PENDING
-        assertThat(event.getStatus()).isEqualTo(AssetEventStatus.PENDING.getValue());
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("发送抛异常:不置 SENT,事件保留 PENDING 待重发(需求 15.2)")
-    void dispatchShouldKeepPendingWhenSendThrows() {
-        StakingAssetEvent event = pendingEvent("EVT_Y_CREDIT_PROFIT", AssetEventType.CREDIT_PROFIT, new BigDecimal("0.666666"));
-        when(rocketMQTemplate.syncSend(any(String.class), any(Message.class)))
-                .thenThrow(new RuntimeException("broker unavailable"));
-
-        boolean ok = producer.dispatch(event);
-
-        assertThat(ok).isFalse();
-        assertThat(event.getStatus()).isEqualTo(AssetEventStatus.PENDING.getValue());
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("非 PENDING 事件:跳过,不发送也不更新")
-    void dispatchShouldSkipNonPendingEvent() {
-        StakingAssetEvent event = pendingEvent("EVT_Z_BURN", AssetEventType.BURN, new BigDecimal("100"));
-        event.setStatus(AssetEventStatus.SENT.getValue());
-
-        boolean ok = producer.dispatch(event);
-
-        assertThat(ok).isFalse();
-        verify(rocketMQTemplate, never()).syncSend(any(String.class), any(Message.class));
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("批量派发:扫描 PENDING 逐条发送并汇总统计(成功置 SENT,失败保留 PENDING)")
-    void dispatchPendingEventsShouldScanAndSummarize() {
-        StakingAssetEvent e1 =
-                pendingEvent("EVT_1_RETURN_PRINCIPAL", AssetEventType.RETURN_PRINCIPAL, new BigDecimal("100"));
-        StakingAssetEvent e2 = pendingEvent("EVT_2_BURN", AssetEventType.BURN, new BigDecimal("20"));
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of(e1, e2));
-        // 第一条发送成功,第二条发送失败
-        when(rocketMQTemplate.syncSend(eq(StakingConstants.TOPIC_STAKING_ASSET + ":" + AssetEventType.RETURN_PRINCIPAL.getValue()), any(Message.class)))
-                .thenReturn(sendOk());
-        when(rocketMQTemplate.syncSend(eq(StakingConstants.TOPIC_STAKING_ASSET + ":" + AssetEventType.BURN.getValue()), any(Message.class)))
-                .thenThrow(new RuntimeException("send fail"));
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-
-        AssetEventDispatchStats stats = producer.dispatchPendingEvents();
-
-        assertThat(stats.scanned()).isEqualTo(2);
-        assertThat(stats.sent()).isEqualTo(1);
-        assertThat(stats.failed()).isEqualTo(1);
-        // 仅成功的一条置 SENT
-        assertThat(e1.getStatus()).isEqualTo(AssetEventStatus.SENT.getValue());
-        assertThat(e2.getStatus()).isEqualTo(AssetEventStatus.PENDING.getValue());
-    }
-
-    @Test
-    @DisplayName("批量派发:无 PENDING 事件返回空统计,不触达 MQ")
-    void dispatchPendingEventsShouldReturnEmptyWhenNoPending() {
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of());
-
-        AssetEventDispatchStats stats = producer.dispatchPendingEvents();
-
-        assertThat(stats.scanned()).isZero();
-        assertThat(stats.sent()).isZero();
-        assertThat(stats.failed()).isZero();
-        verify(rocketMQTemplate, never()).syncSend(any(String.class), any(Message.class));
-    }
-}

+ 0 - 204
bex-cloud-staking-service/src/test/java/com/bex/staking/mq/AssetEventRetryServiceTest.java

@@ -1,204 +0,0 @@
-package com.bex.staking.mq;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyList;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.bex.staking.entity.StakingAssetEvent;
-import com.bex.staking.enums.AssetEventStatus;
-import com.bex.staking.enums.AssetEventType;
-import com.bex.staking.mapper.StakingAssetEventMapper;
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.util.List;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-/**
- * {@link AssetEventRetryService} 资产账变事件失败重试组件单元测试。
- *
- * <p>验证设计「关键失败处理策略 · 清算/销毁账变(15.3)」与需求 15.3:失败事件按 {@code retry_count} 指数退避重发,
- * 达到上限进入死信告警。覆盖:
- *
- * <ul>
- *   <li>指数退避纯函数 {@link AssetEventRetryService#backoffMillis(int)} 的取值、封顶与溢出保护;
- *   <li>退避判定纯函数 {@link AssetEventRetryService#isBackoffElapsed} 的时间窗口语义;
- *   <li>退避到期事件重置为 PENDING、{@code retryCount + 1} 并触发重发;
- *   <li>退避未到事件本批跳过;
- *   <li>达到上限事件进入死信告警、保持 FAILED 不再重发。
- * </ul>
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("资产账变事件失败重试组件 AssetEventRetryService 测试")
-class AssetEventRetryServiceTest {
-
-    @Mock
-    private StakingAssetEventMapper assetEventMapper;
-
-    @Mock
-    private AssetEventProducer assetEventProducer;
-
-    private long toMillis(LocalDateTime time) {
-        return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
-    }
-
-    private StakingAssetEvent failedEvent(String eventNo, int retryCount, LocalDateTime updatedAt) {
-        return StakingAssetEvent.builder()
-                .eventNo(eventNo)
-                .orderNo("STK20240101001")
-                .userId(1001L)
-                .eventType(AssetEventType.BURN.getValue())
-                .amount(new BigDecimal("100"))
-                .status(AssetEventStatus.FAILED.getValue())
-                .retryCount(retryCount)
-                .version(retryCount)
-                .updatedAt(updatedAt)
-                .build();
-    }
-
-    @Nested
-    @DisplayName("指数退避纯函数 backoffMillis")
-    class BackoffMillis {
-
-        @Test
-        @DisplayName("退避间隔 = baseDelay × 2^retryCount(baseDelay=1000ms)")
-        void shouldBeExponential() {
-            assertThat(AssetEventRetryService.backoffMillis(0)).isEqualTo(1_000L);
-            assertThat(AssetEventRetryService.backoffMillis(1)).isEqualTo(2_000L);
-            assertThat(AssetEventRetryService.backoffMillis(2)).isEqualTo(4_000L);
-            assertThat(AssetEventRetryService.backoffMillis(3)).isEqualTo(8_000L);
-            assertThat(AssetEventRetryService.backoffMillis(4)).isEqualTo(16_000L);
-        }
-
-        @Test
-        @DisplayName("负数 retryCount 退避为基础间隔,封顶为 MAX_BACKOFF_MILLIS")
-        void shouldClampAndGuardOverflow() {
-            assertThat(AssetEventRetryService.backoffMillis(-1)).isEqualTo(AssetEventRetryService.BASE_DELAY_MILLIS);
-            // retryCount 较大时退避封顶,且不发生移位溢出(不为负、不为 0)
-            assertThat(AssetEventRetryService.backoffMillis(20))
-                    .isEqualTo(AssetEventRetryService.MAX_BACKOFF_MILLIS);
-            assertThat(AssetEventRetryService.backoffMillis(64))
-                    .isEqualTo(AssetEventRetryService.MAX_BACKOFF_MILLIS);
-            assertThat(AssetEventRetryService.backoffMillis(Integer.MAX_VALUE))
-                    .isEqualTo(AssetEventRetryService.MAX_BACKOFF_MILLIS);
-        }
-    }
-
-    @Nested
-    @DisplayName("退避判定纯函数 isBackoffElapsed")
-    class IsBackoffElapsed {
-
-        @Test
-        @DisplayName("updatedAt 为空视为可立即重发")
-        void shouldAllowWhenUpdatedAtNull() {
-            assertThat(AssetEventRetryService.isBackoffElapsed(null, 0, System.currentTimeMillis()))
-                    .isTrue();
-        }
-
-        @Test
-        @DisplayName("距上次更新超过退避间隔可重发,否则跳过")
-        void shouldRespectBackoffWindow() {
-            LocalDateTime updatedAt = LocalDateTime.of(2024, 1, 1, 10, 0, 0);
-            long base = toMillis(updatedAt);
-            // retryCount=1 退避 2000ms
-            long backoff = AssetEventRetryService.backoffMillis(1);
-
-            // 刚好到达边界(==)可重发
-            assertThat(AssetEventRetryService.isBackoffElapsed(updatedAt, 1, base + backoff))
-                    .isTrue();
-            // 超过边界可重发
-            assertThat(AssetEventRetryService.isBackoffElapsed(updatedAt, 1, base + backoff + 1))
-                    .isTrue();
-            // 未到边界跳过
-            assertThat(AssetEventRetryService.isBackoffElapsed(updatedAt, 1, base + backoff - 1))
-                    .isFalse();
-        }
-    }
-
-    @Test
-    @DisplayName("退避到期:重置为 PENDING、retryCount+1 并触发重发(需求 15.3)")
-    void shouldResetToPendingAndDispatchWhenBackoffElapsed() {
-        // updatedAt 很久以前,确保退避间隔已过
-        StakingAssetEvent event = failedEvent("EVT_RETRY_1", 1, LocalDateTime.now().minusHours(1));
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of(event));
-        when(assetEventMapper.updateById(any(StakingAssetEvent.class))).thenReturn(1);
-        when(assetEventProducer.dispatch(any(StakingAssetEvent.class))).thenReturn(true);
-
-        AssetEventRetryStats stats = service().retryFailedEvents();
-
-        assertThat(stats.scanned()).isEqualTo(1);
-        assertThat(stats.retried()).isEqualTo(1);
-        assertThat(stats.deadLetter()).isZero();
-        assertThat(stats.skipped()).isZero();
-
-        // 落库前状态被重置为 PENDING 且 retryCount+1
-        ArgumentCaptor<StakingAssetEvent> captor = ArgumentCaptor.forClass(StakingAssetEvent.class);
-        verify(assetEventMapper).updateById(captor.capture());
-        assertThat(captor.getValue().getStatus()).isEqualTo(AssetEventStatus.PENDING.getValue());
-        assertThat(captor.getValue().getRetryCount()).isEqualTo(2);
-        // 触发重发
-        verify(assetEventProducer).dispatch(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("退避未到:本批跳过,不重置也不重发")
-    void shouldSkipWhenBackoffNotElapsed() {
-        // 刚刚更新,退避间隔(retryCount=2 -> 4000ms)尚未过
-        StakingAssetEvent event = failedEvent("EVT_RETRY_2", 2, LocalDateTime.now());
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of(event));
-
-        AssetEventRetryStats stats = service().retryFailedEvents();
-
-        assertThat(stats.scanned()).isEqualTo(1);
-        assertThat(stats.retried()).isZero();
-        assertThat(stats.skipped()).isEqualTo(1);
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-        verify(assetEventProducer, never()).dispatch(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("达到重试上限:进入死信告警,保持 FAILED 不再重发(需求 15.3)")
-    void shouldEnterDeadLetterWhenMaxRetryReached() {
-        StakingAssetEvent event =
-                failedEvent("EVT_DEAD", AssetEventRetryService.MAX_RETRY, LocalDateTime.now().minusHours(1));
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of(event));
-
-        AssetEventRetryStats stats = service().retryFailedEvents();
-
-        assertThat(stats.scanned()).isEqualTo(1);
-        assertThat(stats.deadLetter()).isEqualTo(1);
-        assertThat(stats.retried()).isZero();
-        // 死信不重发、不改状态
-        assertThat(event.getStatus()).isEqualTo(AssetEventStatus.FAILED.getValue());
-        verify(assetEventMapper, never()).updateById(any(StakingAssetEvent.class));
-        verify(assetEventProducer, never()).dispatch(any(StakingAssetEvent.class));
-    }
-
-    @Test
-    @DisplayName("无失败事件:返回空统计,不触达 Mapper 更新与生产者")
-    void shouldReturnEmptyWhenNoFailedEvents() {
-        when(assetEventMapper.scanByStatus(anyList(), anyInt(), anyInt())).thenReturn(List.of());
-
-        AssetEventRetryStats stats = service().retryFailedEvents();
-
-        assertThat(stats.scanned()).isZero();
-        assertThat(stats.retried()).isZero();
-        assertThat(stats.deadLetter()).isZero();
-        assertThat(stats.skipped()).isZero();
-        verify(assetEventProducer, never()).dispatch(any(StakingAssetEvent.class));
-    }
-
-    private AssetEventRetryService service() {
-        return new AssetEventRetryService(assetEventMapper, assetEventProducer);
-    }
-}

+ 0 - 270
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimIdempotencyPropertyTest.java

@@ -1,270 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProfitJournal;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.enums.ProfitStatus;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.grpc.AssetGrpcClient;
-import com.bex.staking.mapper.StakingOrderMapper;
-import com.bex.staking.mapper.StakingProfitJournalMapper;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.util.ArrayList;
-import java.util.List;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Combinators;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-
-/**
- * {@link ClaimServiceImpl#claimProfit(Long)} 写接口幂等性(收益领取部分)属性测试(Property 10)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 10「写接口幂等性」中的收益领取分支:<em>对任意</em> 用户的
- * 并发收益领取请求,同一笔待领取收益只被领取一次(资产到账净额等于一次待领取金额)。
- *
- * <h2>被测语义与建模</h2>
- *
- * <p>{@link ClaimServiceImpl#claimProfit(Long)} 经 {@code @Idempotent} 注解保护,由 {@code IdempotencyBarrier}
- * 切面基于 Redisson 分布式锁对「同一用户」维度串行化并发领取请求(锁行为已由 {@code IdempotencyBarrierTest} 覆盖)。
- * 在直接 {@code new ClaimServiceImpl(...)} 的单元测试中,切面与 {@code @Transactional} 均不生效,因此本测试将
- * 「并发领取」建模为锁串行化后的<strong>顺序重复领取</strong>,聚焦验证账本层面的核心幂等不变量——
- * 「待领取归零后重复领取不再到账」,即同一笔待领取收益只被领取一次。
- *
- * <p>用 Mockito 构造内存 mapper / 资产客户端,完全避免数据库、Redisson、gRPC 与 Spring 依赖:
- *
- * <ul>
- *   <li><b>{@link StakingOrderMapper}</b>:{@code selectList} 在调用时动态返回内存订单中 {@code pendingProfit>0}
- *       的订单;{@code updateById} 为空实现(订单对象为同一引用,业务侧 setter 直接改写内存状态)。
- *   <li><b>{@link StakingProfitJournalMapper}</b>:{@code selectList} 动态返回 {@code UNCLAIMED} 流水;
- *       {@code updateById} 为空实现。
- *   <li><b>{@link AssetGrpcClient}</b>:{@code creditProfit} 用内存累加器记录每次到账金额与调用次数。</li>
- * </ul>
- *
- * <p>由于第一次领取后所有订单 {@code pendingProfit} 已归零、流水已置 {@code CLAIMED},第二次起聚合待领取为 0,
- * 在调用 {@code creditProfit} 之前即抛出 {@link StakingErrorCode#NO_PROFIT_TO_CLAIM}(40006),故资产到账实际只
- * 成功一次——净到账金额恰等于一次初始待领取合计。
- *
- * <p>金额一律以 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定相等(忽略标度差异,
- * 如 {@code 1.0} 与 {@code 1.00} 视为相等),与生产代码内部判定方式保持一致。
- */
-class ClaimIdempotencyPropertyTest {
-
-    /** 固定测试用户 UID。 */
-    private static final long USER_ID = 30001L;
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 严格正高精度金额生成器:随机 unscaledValue({@code [1, 10^15]})与随机 scale({@code [0, 18]})组合,
-     * 取值恒 {@code > 0}(最小 {@code 10^-18}),覆盖整数、长尾小数(对齐数据库 {@code DECIMAL(36,18)} 精度)
-     * 与不同数量级。
-     */
-    @Provide
-    Arbitrary<BigDecimal> positiveAmount() {
-        return Combinators.combine(
-                        Arbitraries.bigIntegers().between(BigInteger.ONE, BigInteger.TEN.pow(15)),
-                        Arbitraries.integers().between(0, 18))
-                .as(BigDecimal::new);
-    }
-
-    /** 多笔待领取订单的待领取金额列表(长度 1~5,金额恒为正)。 */
-    @Provide
-    Arbitrary<List<BigDecimal>> positivePendingList() {
-        return positiveAmount().list().ofMinSize(1).ofMaxSize(5);
-    }
-
-    /** 重复领取次数 N(1~10),模拟并发领取经锁串行化后的顺序重复请求。 */
-    @Provide
-    Arbitrary<Integer> repeatCount() {
-        return Arbitraries.integers().between(1, 10);
-    }
-
-    // ==================== 测试场景容器 ====================
-
-    /** 内存账本:维护用户订单与收益流水,并记录每次资产到账金额(用于净额与次数断言)。 */
-    private record ClaimFixture(
-            ClaimServiceImpl service, List<StakingOrder> orders, List<BigDecimal> credited) {}
-
-    /**
-     * 装配一个内存版 {@link ClaimServiceImpl}:根据给定的各订单待领取金额构造内存订单与对应 UNCLAIMED 流水,
-     * 并以 Mockito 让 mapper 在调用时按当前内存状态动态过滤、让资产客户端累加到账金额。
-     *
-     * @param pendings 各订单的初始待领取金额(恒为正)
-     * @return 装配完成的测试夹具
-     */
-    private ClaimFixture buildFixture(List<BigDecimal> pendings) {
-        List<StakingOrder> orders = new ArrayList<>();
-        List<StakingProfitJournal> journals = new ArrayList<>();
-        long journalId = 1L;
-        int idx = 0;
-        for (BigDecimal pending : pendings) {
-            String orderNo = "STK-PBT-10-" + idx;
-            orders.add(StakingOrder.builder()
-                    .orderNo(orderNo)
-                    .userId(USER_ID)
-                    .productId(9001L)
-                    .productType("STAKING")
-                    .status(OrderStatus.HOLDING.getValue())
-                    .stakeAmount(new BigDecimal("1000"))
-                    .cumulativeProfit(pending)
-                    .pendingProfit(pending)
-                    .claimedProfit(BigDecimal.ZERO)
-                    .build());
-            journals.add(StakingProfitJournal.builder()
-                    .id(journalId++)
-                    .journalNo("JNL-PBT-10-" + idx)
-                    .orderNo(orderNo)
-                    .userId(USER_ID)
-                    .profitAmount(pending)
-                    .settleDate(20240101 + idx)
-                    .status(ProfitStatus.UNCLAIMED.getValue())
-                    .build());
-            idx++;
-        }
-
-        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-        StakingProfitJournalMapper journalMapper = mock(StakingProfitJournalMapper.class);
-        AssetGrpcClient assetGrpcClient = mock(AssetGrpcClient.class);
-
-        // selectList 动态返回 pendingProfit>0 的订单(订单对象为同一引用,归零后自动被过滤掉)
-        when(orderMapper.selectList(any())).thenAnswer(invocation -> orders.stream()
-                .filter(o -> o.getPendingProfit() != null && o.getPendingProfit().signum() > 0)
-                .toList());
-        // selectList 动态返回 UNCLAIMED 流水(置 CLAIMED 后自动被过滤掉)
-        when(journalMapper.selectList(any())).thenAnswer(invocation -> journals.stream()
-                .filter(j -> ProfitStatus.UNCLAIMED.getValue().equals(j.getStatus()))
-                .toList());
-        // updateById 为空实现:实体对象为同一引用,业务侧 setter 已直接改写内存状态
-        // 使用具体类型匹配器以消解 BaseMapper#updateById(T) 与 updateById(Collection<T>) 的重载歧义
-        when(orderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        when(journalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-
-        // creditProfit:累加每次到账金额(参数下标 2 为金额字符串),记录调用次数
-        List<BigDecimal> credited = new ArrayList<>();
-        doAnswer(invocation -> {
-                    String amount = invocation.getArgument(2);
-                    credited.add(new BigDecimal(amount));
-                    return null;
-                })
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl service = new ClaimServiceImpl(orderMapper, journalMapper, assetGrpcClient);
-        return new ClaimFixture(service, orders, credited);
-    }
-
-    // ==================== 属性测试 ====================
-
-    // Feature: staking-service, Property 10: 写接口幂等性(收益领取部分)
-    // 对任意多笔待领取订单与任意重复领取次数 N(1~10):首次领取成功且净到账 == 一次初始待领取合计,
-    // 后续 N-1 次因待领取已归零抛 40006、不再到账;终态各订单 pendingProfit==0、claimedProfit==初始待领取。
-    // Validates: Requirements 10.5, 14.1, 14.2
-    @Property(tries = 100)
-    void repeatedClaimCreditsPendingProfitExactlyOnce(
-            @ForAll("positivePendingList") List<BigDecimal> pendings, @ForAll("repeatCount") int repeats) {
-        // 初始待领取合计(应恰好被到账一次的净额)
-        BigDecimal totalInitialPending =
-                pendings.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
-
-        ClaimFixture fixture = buildFixture(pendings);
-
-        // 第一次领取:成功
-        Boolean first = fixture.service().claimProfit(USER_ID);
-        assertThat(first).as("首次领取应成功返回 true").isTrue();
-
-        // 后续 N-1 次重复领取:待领取已归零,应在到账前抛出 40006(无可领取收益)
-        for (int i = 1; i < repeats; i++) {
-            assertThatThrownBy(() -> fixture.service().claimProfit(USER_ID))
-                    .as("待领取归零后重复领取应抛出无可领取收益异常")
-                    .isInstanceOf(StakingException.class)
-                    .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                            .as("重复领取错误码应为 40006")
-                            .isEqualTo(StakingErrorCode.NO_PROFIT_TO_CLAIM));
-        }
-
-        // 资产到账恰好成功一次(同一笔待领取收益只被领取一次)
-        assertThat(fixture.credited()).as("无论重复领取多少次,资产到账只成功一次").hasSize(1);
-        // 净到账金额 == 一次初始待领取合计
-        assertThat(fixture.credited().get(0).compareTo(totalInitialPending))
-                .as("净到账金额应等于一次初始待领取合计")
-                .isZero();
-
-        // 终态:各订单待领取归零、已领取累计等于其初始待领取(账本守恒)
-        BigDecimal totalClaimed = BigDecimal.ZERO;
-        for (int i = 0; i < fixture.orders().size(); i++) {
-            StakingOrder order = fixture.orders().get(i);
-            assertThat(order.getPendingProfit().signum())
-                    .as("领取后订单待领取应归零")
-                    .isZero();
-            assertThat(order.getClaimedProfit().compareTo(pendings.get(i)))
-                    .as("领取后订单已领取应等于其初始待领取")
-                    .isZero();
-            totalClaimed = totalClaimed.add(order.getClaimedProfit());
-        }
-        assertThat(totalClaimed.compareTo(totalInitialPending))
-                .as("全部订单已领取合计应等于一次初始待领取合计")
-                .isZero();
-    }
-
-    // Feature: staking-service, Property 10: 写接口幂等性(收益领取部分)
-    // 混入零待领取订单:仅 pendingProfit>0 的订单参与领取,净到账 == 正待领取之和;
-    // 零待领取订单始终不被触达(pending/claimed 恒为 0)。重复领取仍只到账一次。
-    // Validates: Requirements 10.5, 14.1, 14.2
-    @Property(tries = 100)
-    void claimIgnoresZeroPendingOrdersAndCreditsNetOnce(
-            @ForAll("positivePendingList") List<BigDecimal> positivePendings,
-            @ForAll int zeroOrderCountSeed,
-            @ForAll("repeatCount") int repeats) {
-        // 追加 0~3 笔零待领取订单(不应参与领取)
-        int zeroCount = Math.floorMod(zeroOrderCountSeed, 4);
-        List<BigDecimal> pendings = new ArrayList<>(positivePendings);
-        int positiveSize = positivePendings.size();
-        for (int i = 0; i < zeroCount; i++) {
-            pendings.add(BigDecimal.ZERO);
-        }
-        BigDecimal totalPositive =
-                positivePendings.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
-
-        ClaimFixture fixture = buildFixture(pendings);
-
-        assertThat(fixture.service().claimProfit(USER_ID))
-                .as("存在正待领取时首次领取应成功")
-                .isTrue();
-        for (int i = 1; i < repeats; i++) {
-            assertThatThrownBy(() -> fixture.service().claimProfit(USER_ID))
-                    .isInstanceOf(StakingException.class)
-                    .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                            .isEqualTo(StakingErrorCode.NO_PROFIT_TO_CLAIM));
-        }
-
-        // 净到账只发生一次,且等于正待领取之和(零待领取订单不计入)
-        assertThat(fixture.credited()).as("净到账只发生一次").hasSize(1);
-        assertThat(fixture.credited().get(0).compareTo(totalPositive))
-                .as("净到账金额应等于正待领取之和(忽略零待领取订单)")
-                .isZero();
-
-        // 零待领取订单始终未被触达
-        for (int i = positiveSize; i < fixture.orders().size(); i++) {
-            StakingOrder zeroOrder = fixture.orders().get(i);
-            assertThat(zeroOrder.getPendingProfit().signum())
-                    .as("零待领取订单待领取应保持 0")
-                    .isZero();
-            assertThat(zeroOrder.getClaimedProfit().signum())
-                    .as("零待领取订单已领取应保持 0(从未被领取)")
-                    .isZero();
-        }
-    }
-}

+ 0 - 233
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimServiceImplCreditFailureRollbackTest.java

@@ -1,233 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProfitJournal;
-import com.bex.staking.enums.ProfitStatus;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.grpc.AssetGrpcClient;
-import com.bex.staking.mapper.StakingOrderMapper;
-import com.bex.staking.mapper.StakingProfitJournalMapper;
-import java.math.BigDecimal;
-import java.util.List;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InOrder;
-import org.mockito.Mock;
-import org.mockito.Mockito;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-/**
- * {@link ClaimServiceImpl#claimProfit(Long)} 领取到账失败回滚单元测试。
- *
- * <p>对应需求 10.6「IF Asset_Client 调用资产服务到账失败,THEN THE Claim_Module SHALL 回滚收益流水状态并返回
- * {@code code} 非 0 的错误响应」以及设计文档「Error Handling - 领取到账失败(10.6)」:
- *
- * <blockquote>采用「先置流水为处理中并落库 → 调用资产到账 → 成功置 CLAIMED」。失败则回滚流水状态为 UNCLAIMED 并返回
- * {@code 50001},待领取收益不丢失。</blockquote>
- *
- * <h2>测试边界说明(为何以「异常传播 + 调用顺序 + 事务契约」为断言核心)</h2>
- *
- * <p>{@link ClaimServiceImpl#claimProfit(Long)} 通过 {@code @Transactional(rollbackFor = Exception.class)} 保证
- * 「先落库的流水(CLAIMED)与订单账本(pending 归零)更新」在到账异常时随事务回滚——即流水恢复 UNCLAIMED、待领取收益
- * 不丢失。但在本单元测试中以 {@code new ClaimServiceImpl(...)} 直接实例化被测对象,<b>不经过 Spring 事务代理</b>,
- * 因此真实环境下的「数据库行回滚」不会在内存对象上发生。故本测试从「事务回滚得以生效的前提条件」出发进行断言:
- *
- * <ul>
- *   <li><b>到账失败路径成立</b>:mock {@link AssetGrpcClient#creditProfit} 抛出 {@link StakingException}(50001),
- *       且该异常从 {@code claimProfit} 原样向外传播(未被吞掉)——这是 {@code @Transactional} 能触发回滚的前提;
- *   <li><b>到账确实被尝试</b>:{@code creditProfit} 恰好被调用一次,且到账金额等于本次聚合的待领取收益总额
- *       (即「待领取收益不丢失」所保护的金额口径);
- *   <li><b>先落库 → 再到账的顺序</b>:流水与订单账本的 {@code updateById}(落库)发生在 {@code creditProfit}(到账)
- *       之前,正是这一顺序使得「到账失败 → 回滚先前落库」在事务环境下能够恢复流水为 UNCLAIMED、待领取不丢失。
- * </ul>
- *
- * <p>真实事务环境下「流水回滚 UNCLAIMED、订单账本恢复」的端到端验证由集成测试(任务 17,含真实/嵌入式事务)覆盖;
- * 本测试聚焦服务层在到账失败时的「异常传播契约 + 落库到账顺序契约」。
- *
- * <p>Requirements: 10.6
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("收益领取到账失败回滚 ClaimServiceImpl.claimProfit 测试(需求 10.6)")
-class ClaimServiceImplCreditFailureRollbackTest {
-
-    private static final Long USER_ID = 1001L;
-
-    @Mock
-    private StakingOrderMapper stakingOrderMapper;
-
-    @Mock
-    private StakingProfitJournalMapper stakingProfitJournalMapper;
-
-    @Mock
-    private AssetGrpcClient assetGrpcClient;
-
-    /** 构造一笔有待领取收益的持仓订单。 */
-    private StakingOrder orderWithPending(BigDecimal pending, BigDecimal claimed) {
-        return StakingOrder.builder()
-                .id(9001L)
-                .orderNo("STK-CLAIM-1")
-                .userId(USER_ID)
-                .stakeAmount(new BigDecimal("1000"))
-                .pendingProfit(pending)
-                .claimedProfit(claimed)
-                .build();
-    }
-
-    /** 构造一条待领取(UNCLAIMED)收益流水。 */
-    private StakingProfitJournal unclaimedJournal(Long id, BigDecimal profit) {
-        return StakingProfitJournal.builder()
-                .id(id)
-                .journalNo("JNL-" + id)
-                .orderNo("STK-CLAIM-1")
-                .userId(USER_ID)
-                .profitAmount(profit)
-                .settleDate(20240101)
-                .status(ProfitStatus.UNCLAIMED.getValue())
-                .build();
-    }
-
-    @Test
-    @DisplayName("到账失败:抛出 StakingException(50001) 且异常向外传播(触发事务回滚的前提,需求 10.6)")
-    void creditFailureShouldThrow50001AndPropagate() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 乐观锁更新成功(updateById 返回 1)
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        // 模拟资产到账失败:creditProfit 抛出 50001(与 AssetGrpcClient 失败映射约定一致)
-        doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务到账失败"))
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        // 断言:异常原样抛出且错误码为 50001(未被吞掉 → @Transactional 才能据此回滚)
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.ASSET_GRPC_FAILED));
-    }
-
-    @Test
-    @DisplayName("到账失败:creditProfit 恰好被调用一次,且到账金额等于待领取收益总额(待领取不丢失口径,需求 10.6)")
-    void creditFailureShouldAttemptCreditWithFullPendingAmount() {
-        StakingOrder order1 = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingOrder order2 = StakingOrder.builder()
-                .id(9002L)
-                .orderNo("STK-CLAIM-2")
-                .userId(USER_ID)
-                .pendingProfit(new BigDecimal("3.5"))
-                .claimedProfit(BigDecimal.ZERO)
-                .build();
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("13.5"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(order1, order2));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 乐观锁更新成功(updateById 返回 1)
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务到账失败"))
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID)).isInstanceOf(StakingException.class);
-
-        // 到账被尝试且仅一次;金额等于聚合待领取总额 10 + 3.5 = 13.5(即失败回滚所保护、不可丢失的金额)
-        ArgumentCaptor<String> amountCaptor = ArgumentCaptor.forClass(String.class);
-        verify(assetGrpcClient, times(1))
-                .creditProfit(eq(USER_ID), eq("BEX"), amountCaptor.capture(), anyString(), anyString());
-        assertThat(new BigDecimal(amountCaptor.getValue())).isEqualByComparingTo(new BigDecimal("13.5"));
-    }
-
-    @Test
-    @DisplayName("到账失败:先落库(流水/订单 updateById)后到账(creditProfit)的顺序契约(需求 10.6)")
-    void creditFailureShouldPersistBeforeCrediting() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 乐观锁更新成功(updateById 返回 1)
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        doThrow(new StakingException(StakingErrorCode.ASSET_GRPC_FAILED, "资产服务到账失败"))
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID)).isInstanceOf(StakingException.class);
-
-        // 验证「先置流水/订单落库 → 再调用资产到账」的顺序:正是该顺序让到账失败时回滚先前落库成为可能
-        InOrder inOrder = Mockito.inOrder(stakingProfitJournalMapper, stakingOrderMapper, assetGrpcClient);
-        inOrder.verify(stakingProfitJournalMapper).updateById(any(StakingProfitJournal.class));
-        inOrder.verify(stakingOrderMapper).updateById(any(StakingOrder.class));
-        inOrder.verify(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        // 落库阶段:到账失败前流水已被置为 CLAIMED(真实事务环境下该行更新随异常回滚为 UNCLAIMED)
-        assertThat(journal.getStatus()).isEqualTo(ProfitStatus.CLAIMED.getValue());
-    }
-
-    @Test
-    @DisplayName("对照(成功路径):到账成功时返回 true、流水置 CLAIMED、订单待领取归零并累加已领取(需求 10.2/10.3)")
-    void creditSuccessShouldSettleLedgerAndReturnTrue() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 乐观锁更新成功(updateById 返回 1)
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        // 到账成功:creditProfit 正常返回(不抛异常)
-        doNothing()
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        Boolean result = claimService.claimProfit(USER_ID);
-
-        // 成功返回 true(需求 10.1)
-        assertThat(result).isTrue();
-        // 流水置 CLAIMED 并记录领取时间(需求 10.3)
-        assertThat(journal.getStatus()).isEqualTo(ProfitStatus.CLAIMED.getValue());
-        assertThat(journal.getClaimTime()).isNotNull();
-        // 订单账本守恒:待领取归零、已领取 = 原已领取 + 原待领取 = 5 + 10 = 15(需求 10.3)
-        assertThat(order.getPendingProfit()).isEqualByComparingTo(BigDecimal.ZERO);
-        assertThat(order.getClaimedProfit()).isEqualByComparingTo(new BigDecimal("15"));
-        // 到账恰好一次
-        verify(assetGrpcClient, times(1)).creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-    }
-}

+ 0 - 162
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/ClaimServiceImplOptimisticLockTest.java

@@ -1,162 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doNothing;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.bex.staking.constant.StakingErrorCode;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProfitJournal;
-import com.bex.staking.enums.ProfitStatus;
-import com.bex.staking.exception.StakingException;
-import com.bex.staking.grpc.AssetGrpcClient;
-import com.bex.staking.mapper.StakingOrderMapper;
-import com.bex.staking.mapper.StakingProfitJournalMapper;
-import java.math.BigDecimal;
-import java.util.List;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-/**
- * {@link ClaimServiceImpl#claimProfit(Long)} 乐观锁冲突单元测试。
- *
- * <p>验证当 {@code updateById} 因乐观锁版本冲突返回 0 时,{@code claimProfit} 应抛出
- * {@link StakingException}(错误码 {@link StakingErrorCode#OPTIMISTIC_LOCK_CONFLICT}),
- * 以触发 {@code @Transactional} 回滚,避免账本不一致。
- *
- * <p>参照 {@link com.bex.staking.engine.OptimisticLockUtils#updateWithOptimisticLock} 的模式。
- */
-@ExtendWith(MockitoExtension.class)
-@DisplayName("收益领取乐观锁冲突 ClaimServiceImpl.claimProfit 测试")
-class ClaimServiceImplOptimisticLockTest {
-
-    private static final Long USER_ID = 2001L;
-
-    @Mock
-    private StakingOrderMapper stakingOrderMapper;
-
-    @Mock
-    private StakingProfitJournalMapper stakingProfitJournalMapper;
-
-    @Mock
-    private AssetGrpcClient assetGrpcClient;
-
-    /** 构造一笔有待领取收益的持仓订单。 */
-    private StakingOrder orderWithPending(BigDecimal pending, BigDecimal claimed) {
-        return StakingOrder.builder()
-                .id(9001L)
-                .orderNo("STK-LOCK-1")
-                .userId(USER_ID)
-                .stakeAmount(new BigDecimal("1000"))
-                .pendingProfit(pending)
-                .claimedProfit(claimed)
-                .build();
-    }
-
-    /** 构造一条待领取(UNCLAIMED)收益流水。 */
-    private StakingProfitJournal unclaimedJournal(Long id, BigDecimal profit) {
-        return StakingProfitJournal.builder()
-                .id(id)
-                .journalNo("JNL-LOCK-" + id)
-                .orderNo("STK-LOCK-1")
-                .userId(USER_ID)
-                .profitAmount(profit)
-                .settleDate(20240101)
-                .status(ProfitStatus.UNCLAIMED.getValue())
-                .build();
-    }
-
-    @Test
-    @DisplayName("流水乐观锁冲突:stakingProfitJournalMapper.updateById 返回 0 时抛出 StakingException(40010)")
-    void journalOptimisticLockConflictShouldThrow40010() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 模拟流水乐观锁冲突:updateById 返回 0
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(0);
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.OPTIMISTIC_LOCK_CONFLICT));
-    }
-
-    @Test
-    @DisplayName("订单乐观锁冲突:stakingOrderMapper.updateById 返回 0 时抛出 StakingException(40010)")
-    void orderOptimisticLockConflictShouldThrow40010() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        // 流水更新成功
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        // 模拟订单乐观锁冲突:updateById 返回 0
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(0);
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID))
-                .isInstanceOf(StakingException.class)
-                .satisfies(ex -> assertThat(((StakingException) ex).getCode())
-                        .isEqualTo(StakingErrorCode.OPTIMISTIC_LOCK_CONFLICT));
-    }
-
-    @Test
-    @DisplayName("乐观锁冲突异常消息包含实体描述")
-    void optimisticLockConflictMessageShouldContainEntityDesc() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(0);
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        assertThatThrownBy(() -> claimService.claimProfit(USER_ID))
-                .isInstanceOf(StakingException.class)
-                .hasMessageContaining("收益流水");
-    }
-
-    @Test
-    @DisplayName("对照(无冲突路径):updateById 返回正数时正常完成领取")
-    void noConflictShouldCompleteSuccessfully() {
-        StakingOrder order = orderWithPending(new BigDecimal("10"), new BigDecimal("5"));
-        StakingProfitJournal journal = unclaimedJournal(7001L, new BigDecimal("10"));
-
-        when(stakingOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(order));
-        when(stakingProfitJournalMapper.selectList(any(LambdaQueryWrapper.class)))
-                .thenReturn(List.of(journal));
-        when(stakingProfitJournalMapper.updateById(any(StakingProfitJournal.class))).thenReturn(1);
-        when(stakingOrderMapper.updateById(any(StakingOrder.class))).thenReturn(1);
-        doNothing()
-                .when(assetGrpcClient)
-                .creditProfit(anyLong(), anyString(), anyString(), anyString(), anyString());
-
-        ClaimServiceImpl claimService =
-                new ClaimServiceImpl(stakingOrderMapper, stakingProfitJournalMapper, assetGrpcClient);
-
-        Boolean result = claimService.claimProfit(USER_ID);
-
-        assertThat(result).isTrue();
-    }
-}

+ 0 - 181
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/LockProgressInvariantPropertyTest.java

@@ -1,181 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import com.bex.staking.util.AmountFormatter;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.Method;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.constraints.IntRange;
-
-/**
- * 锁仓进度不变量的属性测试(PBT,jqwik)。
- *
- * <p>Feature: staking-service, Property 12: 锁仓进度不变量。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 12:对任意已结算天数 {@code settledDays} 与总锁仓天数
- * {@code lockDays}({@code 0 ≤ settledDays ≤ lockDays}),{@code progressPercent} 恒落在闭区间
- * {@code [0, 100]} 内,且随 {@code settledDays} 单调不减;{@code settleDayInfo} 等于格式化的
- * {@code "settledDays / lockDays"}。
- *
- * <p>被测组件:
- *
- * <ul>
- *   <li>{@code com.bex.staking.service.impl.OrderQueryServiceImpl#calcProgressPercent(Integer, int)}
- *       (private,逻辑为 {@code clamp(settledDays × 100 / lockDays, 0, 100)},见 OrderQueryServiceImpl
- *       的 calcProgressPercent 方法)。该方法仅依赖静态常量({@code PROGRESS_MIN=0}、{@code PROGRESS_MAX=100}),
- *       不读取任何实例字段,因此本测试通过反射构造一个所有依赖均为 {@code null} 的实例并直接调用<b>真实生产方法</b>,
- *       而非镜像逻辑,保证被验证的就是线上代码本身。</li>
- *   <li>{@link AmountFormatter#progress(long, long)}(core 模块,public,返回 {@code "settledDays / lockDays"})。
- *       {@code settleDayInfo} 由它格式化,本测试直接调用真实方法验证其等于 {@code String.format("%d / %d", ...)}。</li>
- * </ul>
- *
- * <p>金额/天数均以整数生成。{@code lockDays} 取 {@code [1, 3650]}(1 天到约 10 年),{@code settledDays} 取
- * {@code [0, lockDays]},覆盖 {@code 0%}({@code settledDays=0})与 {@code 100%}({@code settledDays=lockDays})
- * 边界;{@code lockDays=0}(除零保护返回 0)由 {@link #lockDaysZeroReturnsZero()} 示例覆盖。
- *
- * <p>Validates: Requirements 6.8, 7.3
- */
-class LockProgressInvariantPropertyTest {
-
-    /** 进度百分比下界(与生产代码 {@code PROGRESS_MIN} 一致)。 */
-    private static final int PROGRESS_MIN = 0;
-
-    /** 进度百分比上界(与生产代码 {@code PROGRESS_MAX} 一致)。 */
-    private static final int PROGRESS_MAX = 100;
-
-    /** 锁仓天数上界:3650 天(约 10 年),与既有引擎测试的天数量级一致。 */
-    private static final int LOCK_DAYS_MAX = 3650;
-
-    /** 反射缓存的真实生产方法 {@code OrderQueryServiceImpl#calcProgressPercent(Integer, int)}。 */
-    private static final Method CALC_PROGRESS_PERCENT;
-
-    /** 反射构造的 {@code OrderQueryServiceImpl} 实例(所有依赖为 null,方法不读取实例字段)。 */
-    private static final Object SERVICE_INSTANCE;
-
-    static {
-        try {
-            // @RequiredArgsConstructor 生成的全参构造器:传入与参数个数等量的 null(calcProgressPercent 不依赖这些字段)
-            Constructor<?> ctor = OrderQueryServiceImpl.class.getDeclaredConstructors()[0];
-            ctor.setAccessible(true);
-            SERVICE_INSTANCE = ctor.newInstance(new Object[ctor.getParameterCount()]);
-
-            CALC_PROGRESS_PERCENT =
-                    OrderQueryServiceImpl.class.getDeclaredMethod("calcProgressPercent", Integer.class, int.class);
-            CALC_PROGRESS_PERCENT.setAccessible(true);
-        } catch (ReflectiveOperationException e) {
-            throw new ExceptionInInitializerError(e);
-        }
-    }
-
-    /**
-     * 调用真实生产方法 {@code OrderQueryServiceImpl#calcProgressPercent}。
-     *
-     * @param settledDays 已结算天数
-     * @param lockDays 总锁仓天数
-     * @return 生产方法返回的进度百分比
-     */
-    private static int callRealCalcProgressPercent(Integer settledDays, int lockDays) {
-        try {
-            return (int) CALC_PROGRESS_PERCENT.invoke(SERVICE_INSTANCE, settledDays, lockDays);
-        } catch (ReflectiveOperationException e) {
-            throw new IllegalStateException("反射调用 calcProgressPercent 失败", e);
-        }
-    }
-
-    // ==================== 不变量 1:progressPercent ∈ [0, 100] ====================
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 对任意 0 ≤ settledDays ≤ lockDays:真实 calcProgressPercent 恒落在闭区间 [0, 100]
-    // Validates: Requirements 6.8, 7.3
-    @Property(tries = 100)
-    void progressPercentAlwaysWithinClosedRange(
-            @ForAll @IntRange(min = 1, max = LOCK_DAYS_MAX) int lockDays,
-            @ForAll @IntRange(min = 0, max = LOCK_DAYS_MAX) int settledDaysRaw) {
-        // 约束到合法输入空间:0 ≤ settledDays ≤ lockDays
-        int settledDays = Math.min(settledDaysRaw, lockDays);
-
-        int percent = callRealCalcProgressPercent(settledDays, lockDays);
-
-        assertThat(percent)
-                .as("settledDays=%d, lockDays=%d 时进度百分比应落在 [0, 100]", settledDays, lockDays)
-                .isBetween(PROGRESS_MIN, PROGRESS_MAX);
-    }
-
-    // ==================== 不变量 2:随 settledDays 单调不减(固定 lockDays) ====================
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 对同一 lockDays,settledDays 从 a 增到 b(a ≤ b ≤ lockDays):progress(b) ≥ progress(a)
-    // Validates: Requirements 6.8, 7.3
-    @Property(tries = 100)
-    void progressPercentMonotonicNonDecreasingInSettledDays(
-            @ForAll @IntRange(min = 1, max = LOCK_DAYS_MAX) int lockDays,
-            @ForAll @IntRange(min = 0, max = LOCK_DAYS_MAX) int rawA,
-            @ForAll @IntRange(min = 0, max = LOCK_DAYS_MAX) int rawB) {
-        // 约束:a ≤ b ≤ lockDays
-        int a = Math.min(rawA, lockDays);
-        int b = Math.min(rawB, lockDays);
-        if (a > b) {
-            int tmp = a;
-            a = b;
-            b = tmp;
-        }
-
-        int percentA = callRealCalcProgressPercent(a, lockDays);
-        int percentB = callRealCalcProgressPercent(b, lockDays);
-
-        assertThat(percentB)
-                .as("固定 lockDays=%d 时,settledDays 从 %d 增到 %d,进度应单调不减", lockDays, a, b)
-                .isGreaterThanOrEqualTo(percentA);
-    }
-
-    // ==================== 不变量 3:settleDayInfo == "settledDays / lockDays" ====================
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 对任意 0 ≤ settledDays ≤ lockDays:AmountFormatter.progress 等于 String.format("%d / %d", settledDays, lockDays)
-    // Validates: Requirements 6.8, 7.3
-    @Property(tries = 100)
-    void settleDayInfoEqualsFormattedProgress(
-            @ForAll @IntRange(min = 1, max = LOCK_DAYS_MAX) int lockDays,
-            @ForAll @IntRange(min = 0, max = LOCK_DAYS_MAX) int settledDaysRaw) {
-        int settledDays = Math.min(settledDaysRaw, lockDays);
-
-        String settleDayInfo = AmountFormatter.progress(settledDays, lockDays);
-
-        assertThat(settleDayInfo)
-                .as("settleDayInfo 应等于 \"settledDays / lockDays\" 格式")
-                .isEqualTo(String.format("%d / %d", settledDays, lockDays));
-    }
-
-    // ==================== 边界示例 ====================
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 边界:settledDays=0 → 0%、settledDays=lockDays → 100%
-    // Validates: Requirements 6.8, 7.3
-    @Example
-    void boundaryStartAndCompletion() {
-        assertThat(callRealCalcProgressPercent(0, 90)).isEqualTo(0);
-        assertThat(callRealCalcProgressPercent(90, 90)).isEqualTo(100);
-        // settleDayInfo 契约样本:"30 / 90"
-        assertThat(AmountFormatter.progress(30, 90)).isEqualTo("30 / 90");
-    }
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 边界:lockDays<=0 时除零保护返回 0,仍落在 [0,100]
-    // Validates: Requirements 6.8, 7.3
-    @Example
-    void lockDaysZeroReturnsZero() {
-        assertThat(callRealCalcProgressPercent(0, 0)).isEqualTo(0);
-        assertThat(callRealCalcProgressPercent(5, 0)).isEqualTo(0);
-    }
-
-    // Feature: staking-service, Property 12: 锁仓进度不变量
-    // 边界:settledDays 为 null 时按 0 处理(列表项进度计算的兜底),返回 0
-    // Validates: Requirements 6.8, 7.3
-    @Example
-    void nullSettledDaysTreatedAsZero() {
-        assertThat(callRealCalcProgressPercent(null, 90)).isEqualTo(0);
-    }
-}

+ 0 - 348
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/OrderTabFilterPropertyTest.java

@@ -1,348 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.bex.staking.converter.StakingOngoingOrderDetailConverter;
-import com.bex.staking.converter.StakingOrderListConverter;
-import com.bex.staking.converter.StakingProfitItemConverter;
-import com.bex.staking.converter.StakingRedeemOrderDetailConverter;
-import com.bex.staking.entity.StakingOrder;
-import com.bex.staking.entity.StakingProduct;
-import com.bex.staking.enums.OrderStatus;
-import com.bex.staking.mapper.StakingOrderMapper;
-import com.bex.staking.mapper.StakingProductMapper;
-import com.bex.staking.mapper.StakingProfitJournalMapper;
-import com.bex.staking.model.dto.StakingOrderQuery;
-import com.bex.staking.model.vo.StakingOrderListVO;
-import com.bex.staking.util.StatusMapping;
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-import org.mapstruct.factory.Mappers;
-
-/**
- * {@link OrderQueryServiceImpl#pageOrders} 订单标签过滤正确性属性测试(PBT,jqwik,Property 11)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 11「订单标签过滤正确性」:<em>对任意</em> 用户订单集合,
- * {@code tabType="ongoing"} 返回项全部为进行中(HOLDING / MATURED)订单,{@code tabType="redeemed"} 返回项
- * 全部为已赎回(SETTLED 或 DEFLATED)订单,{@code tabType="all"} 返回全部订单;三者结果均为订单全集的子集,
- * 且 ongoing 与 redeemed 的并集不超过 all。
- *
- * <h2>被测语义</h2>
- *
- * <p>{@link OrderQueryServiceImpl#pageOrders(Long, StakingOrderQuery)} 先由私有方法 {@code resolveStatusList}
- * 将 {@code tabType} 映射为订单状态集合({@code ongoing → [HOLDING, MATURED]}、
- * {@code redeemed → [SETTLED, DEFLATED]}、{@code all / null → null} 不限状态),再交由
- * {@link StakingOrderMapper#pageByUserAndStatus} 在 SQL 层按状态集合过滤分页。{@code resolveStatusList} 为私有,
- * 因此本测试通过 mock {@code pageByUserAndStatus}「捕获传入的 statusList 参数并按其对内存订单集合过滤」来间接验证
- * {@code tabType → statusList} 映射的正确性,同时验证返回结果的子集与并集不变量。
- *
- * <h2>内存建模(不依赖数据库、Spring)</h2>
- *
- * <ul>
- *   <li><b>{@link StakingOrderMapper#pageByUserAndStatus}</b>:捕获第 3 个参数 {@code statusList},对内存订单集合
- *       按「{@code statusList == null} 返回全部;否则仅返回 {@code status ∈ statusList} 的订单」过滤后填充
- *       {@link Page},精确复刻 SQL 的过滤语义。</li>
- *   <li><b>{@link StakingProductMapper#selectBatchIds}</b>:返回携带 {@code lockDays} / {@code displayScale} 的产品,
- *       供进行中订单 {@code progressPercent} 计算(避免 NPE,不影响过滤语义)。</li>
- *   <li><b>{@link StakingOrderListConverter}</b>:使用 MapStruct 真实实现({@code Mappers.getMapper}),其装配出的
- *       {@code orderStatus} 展示字符串(进行中 / 通缩赎回 / 已完成)即为反查返回项源状态的依据。</li>
- *   <li>其余详情 / 收益项 Converter 在列表查询路径上未被触达,以 mock 占位。</li>
- * </ul>
- *
- * <p>为聚焦过滤正确性而非物理分页截断,每次查询使用足够大的页大小,且 mock 将<strong>全部</strong>命中订单填入
- * 单页返回——故 {@code all} 必返回全集、{@code ongoing}/{@code redeemed} 必返回各自状态集合的全部命中项,子集与
- * 并集不变量得以稳定验证。
- *
- * <p>Validates: Requirements 6.2, 6.3, 6.4
- */
-class OrderTabFilterPropertyTest {
-
-    /** 固定测试用户 UID。 */
-    private static final long USER_ID = 60001L;
-
-    /** 测试产品 ID(全部订单共用同一产品,简化产品缓存装配)。 */
-    private static final long PRODUCT_ID = 8001L;
-
-    /** 进行中标签:映射状态集合 {HOLDING, MATURED}(需求 6.3)。 */
-    private static final Set<OrderStatus> ONGOING_STATUSES = EnumSet.of(OrderStatus.HOLDING, OrderStatus.MATURED);
-
-    /** 已赎回标签:映射状态集合 {SETTLED, DEFLATED}(需求 6.4)。 */
-    private static final Set<OrderStatus> REDEEMED_STATUSES = EnumSet.of(OrderStatus.SETTLED, OrderStatus.DEFLATED);
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 随机订单状态分布生成器:长度 0~30 的状态列表,每个元素独立取自
-     * {@code {HOLDING, MATURED, SETTLED, DEFLATED}},覆盖空集、单状态聚集与混合分布等场景。
-     */
-    @Provide
-    Arbitrary<List<OrderStatus>> orderStatusDistribution() {
-        return Arbitraries.of(OrderStatus.values()).list().ofMinSize(0).ofMaxSize(30);
-    }
-
-    // ==================== 测试夹具 ====================
-
-    /**
-     * 订单标签过滤测试夹具:装配 mock 依赖的 {@link OrderQueryServiceImpl},并保留内存源订单集合用于断言。
-     *
-     * @param service 被测订单查询服务(mock 依赖、无 Spring / 事务代理)
-     * @param sourceOrders 内存源订单集合(本次属性迭代生成的全集)
-     */
-    private record TabFilterFixture(OrderQueryServiceImpl service, List<StakingOrder> sourceOrders) {}
-
-    /**
-     * 构造一个携带锁仓天数与展示小数位的产品,供进行中订单的 {@code progressPercent} 计算使用。
-     *
-     * @return 合法产品实体(id = {@link #PRODUCT_ID},lockDays = 90,displayScale = 4)
-     */
-    private StakingProduct buildProduct() {
-        return StakingProduct.builder()
-                .id(PRODUCT_ID)
-                .title("BEX 3个月质押")
-                .productType("STAKING")
-                .lockDays(90)
-                .displayScale(4)
-                .deflationRate(new BigDecimal("80"))
-                .version(0)
-                .deleted(0)
-                .build();
-    }
-
-    /**
-     * 按索引与状态构造一笔订单。全部金额字段填充为非空值,使任意状态下的 Converter 装配都不会抛 NPE。
-     *
-     * @param idx 订单序号(同时作为雪花 id 与 orderNo 后缀,保证唯一可反查)
-     * @param status 订单状态
-     * @return 订单实体
-     */
-    private StakingOrder buildOrder(long idx, OrderStatus status) {
-        return StakingOrder.builder()
-                .id(idx)
-                .orderNo("STK-PBT-11-" + idx)
-                .userId(USER_ID)
-                .productId(PRODUCT_ID)
-                .productType("STAKING")
-                .status(status.getValue())
-                .stakeAmount(new BigDecimal("1000"))
-                .cumulativeProfit(new BigDecimal("20"))
-                .pendingProfit(BigDecimal.ZERO)
-                .claimedProfit(BigDecimal.ZERO)
-                .returnPrincipal(new BigDecimal("1000"))
-                .burnAmount(new BigDecimal("20"))
-                .settledDays(30)
-                .lockStartTime(1_690_000_000_000L)
-                .expireTime(1_700_000_000_000L)
-                .version(0)
-                .deleted(0)
-                .build();
-    }
-
-    /**
-     * 装配内存版 {@link OrderQueryServiceImpl}:
-     *
-     * <ul>
-     *   <li>{@code pageByUserAndStatus} 捕获 statusList 参数,对内存订单集合按其过滤后填充 {@link Page};</li>
-     *   <li>{@code selectBatchIds} 返回携带 lockDays / displayScale 的产品;</li>
-     *   <li>列表项装配使用真实 {@link StakingOrderListConverter},其余 Converter 以 mock 占位。</li>
-     * </ul>
-     *
-     * @param statuses 本次迭代的订单状态分布
-     * @return 装配完成的测试夹具
-     */
-    private TabFilterFixture buildFixture(List<OrderStatus> statuses) {
-        List<StakingOrder> orders = new ArrayList<>();
-        for (int i = 0; i < statuses.size(); i++) {
-            orders.add(buildOrder(i, statuses.get(i)));
-        }
-
-        StakingOrderMapper orderMapper = mock(StakingOrderMapper.class);
-        StakingProductMapper productMapper = mock(StakingProductMapper.class);
-        StakingProfitJournalMapper journalMapper = mock(StakingProfitJournalMapper.class);
-
-        // 捕获 statusList(第 3 个参数)并按其对内存订单集合过滤:null 不限、非空仅保留 status ∈ statusList
-        when(orderMapper.pageByUserAndStatus(any(), any(), any())).thenAnswer(invocation -> {
-            IPage<StakingOrder> page = invocation.getArgument(0);
-            @SuppressWarnings("unchecked")
-            List<String> statusList = invocation.getArgument(2);
-            List<StakingOrder> filtered = orders.stream()
-                    .filter(o -> statusList == null || statusList.contains(o.getStatus()))
-                    .toList();
-            page.setRecords(filtered);
-            page.setTotal(filtered.size());
-            return page;
-        });
-
-        // 产品缓存:批量查询返回唯一产品(供 progressPercent 计算)
-        when(productMapper.selectBatchIds(any())).thenReturn(List.of(buildProduct()));
-
-        // 列表查询路径只用到列表项 Converter,使用真实 MapStruct 实现以贴近生产装配
-        StakingOrderListConverter orderListConverter = Mappers.getMapper(StakingOrderListConverter.class);
-        StakingOngoingOrderDetailConverter ongoingConverter = mock(StakingOngoingOrderDetailConverter.class);
-        StakingRedeemOrderDetailConverter redeemConverter = mock(StakingRedeemOrderDetailConverter.class);
-        StakingProfitItemConverter profitItemConverter = mock(StakingProfitItemConverter.class);
-
-        OrderQueryServiceImpl service = new OrderQueryServiceImpl(
-                orderMapper,
-                productMapper,
-                journalMapper,
-                orderListConverter,
-                ongoingConverter,
-                redeemConverter,
-                profitItemConverter);
-        return new TabFilterFixture(service, orders);
-    }
-
-    /**
-     * 构造带指定标签与足够大页大小的订单查询请求(页大小足够大以避免物理分页截断)。
-     *
-     * @param tabType 标签类型(all / ongoing / redeemed)
-     * @return 订单查询 DTO
-     */
-    private StakingOrderQuery query(String tabType) {
-        StakingOrderQuery q = new StakingOrderQuery();
-        q.setTabType(tabType);
-        q.setPage(1);
-        q.setSize(1000);
-        return q;
-    }
-
-    /**
-     * 提取分页结果中各订单项的订单编号集合,用于子集 / 并集断言与源状态反查。
-     *
-     * @param page 订单列表分页结果
-     * @return 订单编号列表(保持返回顺序)
-     */
-    private List<String> orderNosOf(Page<StakingOrderListVO> page) {
-        return page.getRecords().stream().map(StakingOrderListVO::getOrderNo).toList();
-    }
-
-    // ==================== Property 11:订单标签过滤正确性 ====================
-
-    // Feature: staking-service, Property 11: 订单标签过滤正确性
-    // 对任意用户订单集合(随机状态分布 ∈ {HOLDING, MATURED, SETTLED, DEFLATED}):
-    //   (1) ongoing 返回项源状态全部 ∈ {HOLDING, MATURED}(需求 6.3);
-    //   (2) redeemed 返回项源状态全部 ∈ {SETTLED, DEFLATED}(需求 6.4);
-    //   (3) all 返回全部订单(需求 6.2);
-    //   (4) ongoing / redeemed / all 返回项均为订单全集的子集;
-    //   (5) ongoing 结果数 + redeemed 结果数 ≤ all 结果数(并集不超过全集)。
-    // Validates: Requirements 6.2, 6.3, 6.4
-    @Property(tries = 100)
-    void orderTabFilterIsCorrect(@ForAll("orderStatusDistribution") List<OrderStatus> statuses) {
-        TabFilterFixture fixture = buildFixture(statuses);
-        OrderQueryServiceImpl service = fixture.service();
-
-        // 源订单全集:orderNo → 源状态,用于反查返回项的真实状态
-        Map<String, OrderStatus> statusByOrderNo = fixture.sourceOrders().stream()
-                .collect(Collectors.toMap(StakingOrder::getOrderNo, o -> OrderStatus.fromValue(o.getStatus())));
-        Set<String> allSourceOrderNos = statusByOrderNo.keySet();
-
-        Page<StakingOrderListVO> allPage = service.pageOrders(USER_ID, query("all"));
-        Page<StakingOrderListVO> ongoingPage = service.pageOrders(USER_ID, query("ongoing"));
-        Page<StakingOrderListVO> redeemedPage = service.pageOrders(USER_ID, query("redeemed"));
-
-        List<String> allNos = orderNosOf(allPage);
-        List<String> ongoingNos = orderNosOf(ongoingPage);
-        List<String> redeemedNos = orderNosOf(redeemedPage);
-
-        // (3) all 返回全部订单:数量与编号集合均与源全集一致
-        assertThat(allNos).as("tabType=all 应返回全部订单").hasSize(fixture.sourceOrders().size());
-        assertThat(allNos).as("tabType=all 返回编号集合应等于源订单全集").containsExactlyInAnyOrderElementsOf(allSourceOrderNos);
-
-        // (1) ongoing 返回项源状态全部 ∈ {HOLDING, MATURED},且展示状态恒为「进行中」
-        for (StakingOrderListVO vo : ongoingPage.getRecords()) {
-            assertThat(statusByOrderNo.get(vo.getOrderNo()))
-                    .as("ongoing 返回项 %s 源状态应 ∈ {HOLDING, MATURED}", vo.getOrderNo())
-                    .isIn(ONGOING_STATUSES);
-            assertThat(vo.getOrderStatus())
-                    .as("ongoing 返回项展示状态应为「进行中」")
-                    .isEqualTo(StatusMapping.ORDER_DISPLAY_HOLDING);
-        }
-        // ongoing 应恰好覆盖源集合中所有进行中订单(不漏不多)
-        long expectedOngoing = fixture.sourceOrders().stream()
-                .filter(o -> ONGOING_STATUSES.contains(OrderStatus.fromValue(o.getStatus())))
-                .count();
-        assertThat(ongoingNos).as("ongoing 数量应等于源集合中进行中订单数").hasSize((int) expectedOngoing);
-
-        // (2) redeemed 返回项源状态全部 ∈ {SETTLED, DEFLATED},且展示状态 ∈ {通缩赎回, 已完成}
-        for (StakingOrderListVO vo : redeemedPage.getRecords()) {
-            assertThat(statusByOrderNo.get(vo.getOrderNo()))
-                    .as("redeemed 返回项 %s 源状态应 ∈ {SETTLED, DEFLATED}", vo.getOrderNo())
-                    .isIn(REDEEMED_STATUSES);
-            assertThat(vo.getOrderStatus())
-                    .as("redeemed 返回项展示状态应 ∈ {通缩赎回, 已完成}")
-                    .isIn(StatusMapping.ORDER_DISPLAY_DEFLATED, StatusMapping.ORDER_DISPLAY_SETTLED);
-        }
-        long expectedRedeemed = fixture.sourceOrders().stream()
-                .filter(o -> REDEEMED_STATUSES.contains(OrderStatus.fromValue(o.getStatus())))
-                .count();
-        assertThat(redeemedNos).as("redeemed 数量应等于源集合中已赎回订单数").hasSize((int) expectedRedeemed);
-
-        // (4) 三者返回项均为订单全集的子集
-        assertThat(allSourceOrderNos).as("all 返回项应为源全集子集").containsAll(allNos);
-        assertThat(allSourceOrderNos).as("ongoing 返回项应为源全集子集").containsAll(ongoingNos);
-        assertThat(allSourceOrderNos).as("redeemed 返回项应为源全集子集").containsAll(redeemedNos);
-
-        // (5) ongoing 与 redeemed 并集不超过 all:数量之和 ≤ all 数量,且二者互不相交
-        assertThat(ongoingNos.size() + redeemedNos.size())
-                .as("ongoing 与 redeemed 结果数之和不应超过 all 结果数")
-                .isLessThanOrEqualTo(allNos.size());
-        // 互不相交:使用 Collections.disjoint 以兼容任一集合为空的边界(doesNotContainAnyElementsOf 不接受空入参)
-        assertThat(java.util.Collections.disjoint(ongoingNos, redeemedNos))
-                .as("ongoing 与 redeemed 应互不相交")
-                .isTrue();
-    }
-
-    // ==================== 边界锚定示例 ====================
-
-    // Feature: staking-service, Property 11: 订单标签过滤正确性
-    // 边界:空订单集合时,all / ongoing / redeemed 均返回空结果。
-    // Validates: Requirements 6.2, 6.3, 6.4
-    @Example
-    void emptyOrderSetReturnsEmptyForAllTabs() {
-        TabFilterFixture fixture = buildFixture(List.of());
-        OrderQueryServiceImpl service = fixture.service();
-
-        assertThat(orderNosOf(service.pageOrders(USER_ID, query("all")))).isEmpty();
-        assertThat(orderNosOf(service.pageOrders(USER_ID, query("ongoing")))).isEmpty();
-        assertThat(orderNosOf(service.pageOrders(USER_ID, query("redeemed")))).isEmpty();
-    }
-
-    // Feature: staking-service, Property 11: 订单标签过滤正确性
-    // 边界:四种状态各一笔,ongoing 命中 HOLDING/MATURED 两笔、redeemed 命中 SETTLED/DEFLATED 两笔、all 命中四笔,
-    // 且 ongoing 数 + redeemed 数 == all 数。
-    // Validates: Requirements 6.2, 6.3, 6.4
-    @Example
-    void oneOrderPerStatusPartitionsAllExactly() {
-        TabFilterFixture fixture = buildFixture(
-                List.of(OrderStatus.HOLDING, OrderStatus.MATURED, OrderStatus.SETTLED, OrderStatus.DEFLATED));
-        OrderQueryServiceImpl service = fixture.service();
-
-        List<String> allNos = orderNosOf(service.pageOrders(USER_ID, query("all")));
-        List<String> ongoingNos = orderNosOf(service.pageOrders(USER_ID, query("ongoing")));
-        List<String> redeemedNos = orderNosOf(service.pageOrders(USER_ID, query("redeemed")));
-
-        assertThat(allNos).as("all 应返回四笔订单").hasSize(4);
-        assertThat(ongoingNos).as("ongoing 应命中 HOLDING / MATURED 两笔").hasSize(2);
-        assertThat(redeemedNos).as("redeemed 应命中 SETTLED / DEFLATED 两笔").hasSize(2);
-        assertThat(ongoingNos.size() + redeemedNos.size())
-                .as("ongoing 与 redeemed 恰好划分 all")
-                .isEqualTo(allNos.size());
-    }
-}

+ 0 - 356
bex-cloud-staking-service/src/test/java/com/bex/staking/service/impl/RecommendationFilterPropertyTest.java

@@ -1,356 +0,0 @@
-package com.bex.staking.service.impl;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.bex.staking.converter.StakingProductConverter;
-import com.bex.staking.converter.StakingProductDetailConverter;
-import com.bex.staking.entity.StakingProduct;
-import com.bex.staking.mapper.StakingProductMapper;
-import com.bex.staking.model.vo.StakingProductVO;
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import net.jqwik.api.Arbitraries;
-import net.jqwik.api.Arbitrary;
-import net.jqwik.api.Example;
-import net.jqwik.api.ForAll;
-import net.jqwik.api.Property;
-import net.jqwik.api.Provide;
-import net.jqwik.api.constraints.IntRange;
-import org.mockito.ArgumentCaptor;
-
-/**
- * {@link ProductQueryServiceImpl#pageProducts} 推荐过滤正确性属性测试(PBT,jqwik,Property 2)。
- *
- * <p>对应设计文档 Correctness Properties 的 Property 2「推荐过滤正确性」:<em>对任意</em>产品集合,当
- * {@code isRecommendation=true} 时返回的所有产品都满足 {@code isRecommend=true};当 {@code isRecommendation=false}
- * (或 {@code null})时返回结果为输入产品集合(按分页)的<b>无过滤子集</b>,不因推荐标记被剔除。
- *
- * <h2>被测口径与推荐过滤的归属</h2>
- *
- * <p>{@link ProductQueryServiceImpl#pageProducts(Integer, Integer, Boolean)} 自身<b>不实现</b>推荐过滤逻辑,而是把
- * {@code isRecommendation} 透传给 {@link StakingProductMapper#pageProducts(com.baomidou.mybatisplus.core.metadata.IPage,
- * Boolean)},由其 XML({@code mapper/StakingProductMapper.xml} 的 {@code <select id="pageProducts">})在 SQL 层完成过滤:
- *
- * <pre>{@code
- * WHERE deleted = 0
- *   <if test="isRecommendation != null and isRecommendation == true">
- *       AND is_recommend = 1
- *   </if>
- * }</pre>
- *
- * <p>即:{@code isRecommendation=true} → 追加 {@code AND is_recommend = 1}(仅推荐);
- * {@code false}/{@code null} → 不追加过滤条件(返回全部未删除产品,含可质押 / 即将开启 / 已结束)。
- * 物理分页由 MyBatis-Plus 分页插件依据传入的 {@code Page} 自动施加 {@code LIMIT/OFFSET}。
- *
- * <h2>Mockito 建模 SQL 过滤语义(不依赖数据库 / MyBatis-Plus)</h2>
- *
- * <p>由于推荐过滤在 SQL 层完成,本属性测试用 Mockito mock {@link StakingProductMapper#pageProducts},对内存产品集合
- * <strong>忠实复刻上述 XML 的过滤 + 分页语义</strong>:
- *
- * <ul>
- *   <li>{@code isRecommendation == true} → 仅保留 {@code isRecommend == true} 的产品(对应 {@code AND is_recommend = 1});</li>
- *   <li>{@code isRecommendation == false / null} → 保留全部产品(对应不追加过滤条件);</li>
- *   <li>再按传入 {@code Page} 的 {@code current/size} 施加分页切片(对应分页插件的 {@code LIMIT/OFFSET}),
- *       并把切片结果与过滤后的 {@code total} 回填到 {@code Page} 对象({@code records + total})后返回。</li>
- * </ul>
- *
- * <p>{@link StakingProductConverter#toVo} 同样以 mock 实现:返回 {@code productId =
- * String.valueOf(product.getId())} 的 {@link StakingProductVO},从而保留「VO → 源产品({@code productId → isRecommend})」
- * 的反查映射——这是验证「true 场景返回的 VO 全部对应推荐产品」的关键({@link StakingProductVO} 本身不含 {@code isRecommend} 字段)。
- *
- * <h2>断言的两个分支(恰好对应需求 2.4 与 2.5)</h2>
- *
- * <ul>
- *   <li><b>{@code true}(需求 2.4)</b>:透传给 mapper 的过滤参数为 {@code true};返回的每个 VO 反查源产品均为
- *       {@code isRecommend=true};返回结果恰为「推荐子集」的分页切片;{@code total} 等于推荐产品数。</li>
- *   <li><b>{@code false}/{@code null}(需求 2.5)</b>:透传参数原样;返回结果恰为「全集」的分页切片(非推荐产品<b>不被剔除</b>);
- *       {@code total} 等于<b>全集</b>大小(而非推荐子集大小),由此证明未因推荐标记过滤。</li>
- * </ul>
- *
- * <p>同时验证 {@link ProductQueryServiceImpl} 正确地将 {@code isRecommendation} 透传给 mapper({@link ArgumentCaptor})
- * 并保留分页({@code current/size} 透传至结果 {@code Page})。
- *
- * <p>Validates: Requirements 2.4, 2.5
- */
-class RecommendationFilterPropertyTest {
-
-    /** 产品 ID 基准值;产品 id 取 {@code BASE_ID + 下标},保证集合内 id 唯一,可用于 VO 反查源产品。 */
-    private static final long BASE_ID = 100_000L;
-
-    // ==================== 生成器 ====================
-
-    /**
-     * 推荐标记列表生成器:长度 1~40,每个元素为随机布尔({@code is_recommend}),
-     * 覆盖「全推荐 / 全非推荐 / 混合」三类分布。集合大小即列表长度。
-     */
-    @Provide
-    Arbitrary<List<Boolean>> recommendFlags() {
-        return Arbitraries.of(Boolean.TRUE, Boolean.FALSE).list().ofMinSize(1).ofMaxSize(40);
-    }
-
-    // ==================== 测试夹具 ====================
-
-    /**
-     * 一次迭代的被测装配与断言所需数据。
-     *
-     * @param service 被测产品查询服务(mock 依赖、无 Spring / 数据库)
-     * @param mapper mock 的产品 Mapper(忠实复刻 XML 过滤 + 分页语义)
-     * @param products 输入产品集合(按生成顺序,id 唯一)
-     * @param recommendById 反查映射:产品 id → 是否推荐
-     */
-    private record Fixture(
-            ProductQueryServiceImpl service,
-            StakingProductMapper mapper,
-            List<StakingProduct> products,
-            Map<Long, Boolean> recommendById) {}
-
-    /**
-     * 依据推荐标记列表构造输入产品集合,并装配 mock 依赖的 {@link ProductQueryServiceImpl}。
-     *
-     * <p>mock 的 {@link StakingProductMapper#pageProducts} 按 {@code isRecommendation} 参数对内存集合应用
-     * 「true → 仅推荐;false/null → 全部」的过滤语义,再按传入 {@code Page} 的 {@code current/size} 分页切片,
-     * 回填 {@code records + total} 后返回(复刻 SQL WHERE + 分页插件 LIMIT/OFFSET)。
-     *
-     * @param flags 推荐标记列表,长度即集合大小
-     * @return 装配完成的测试夹具
-     */
-    private Fixture buildFixture(List<Boolean> flags) {
-        List<StakingProduct> products = new ArrayList<>(flags.size());
-        Map<Long, Boolean> recommendById = new HashMap<>();
-        for (int i = 0; i < flags.size(); i++) {
-            long id = BASE_ID + i;
-            boolean recommend = flags.get(i);
-            products.add(StakingProduct.builder()
-                    .id(id)
-                    .isRecommend(recommend)
-                    // 其余字段对推荐过滤不产生影响,仅填充最小必要值
-                    .minAmount(new BigDecimal("100"))
-                    .lockDays(90)
-                    .deleted(0)
-                    .build());
-            recommendById.put(id, recommend);
-        }
-
-        StakingProductMapper mapper = mock(StakingProductMapper.class);
-        StakingProductConverter converter = mock(StakingProductConverter.class);
-        StakingProductDetailConverter detailConverter = mock(StakingProductDetailConverter.class);
-
-        // 复刻 StakingProductMapper.xml#pageProducts 的过滤 + 分页语义:
-        //   true → 仅 isRecommend=true(AND is_recommend = 1);false/null → 全部(不追加过滤);
-        //   再按 Page 的 current/size 施加 LIMIT/OFFSET 切片,回填 records + total。
-        when(mapper.pageProducts(any(), any())).thenAnswer(inv -> {
-            Page<StakingProduct> pageParam = inv.getArgument(0);
-            Boolean isRecommendation = inv.getArgument(1);
-
-            List<StakingProduct> filtered = Boolean.TRUE.equals(isRecommendation)
-                    ? products.stream().filter(StakingProduct::getIsRecommend).toList()
-                    : products;
-
-            List<StakingProduct> slice = pageSlice(filtered, pageParam.getCurrent(), pageParam.getSize());
-            pageParam.setRecords(new ArrayList<>(slice));
-            pageParam.setTotal(filtered.size());
-            return pageParam;
-        });
-
-        // 装配层 mock:仅保留 productId = String.valueOf(id),以支撑「VO → 源产品」反查
-        when(converter.toVo(any(StakingProduct.class), anyLong())).thenAnswer(inv -> {
-            StakingProduct product = inv.getArgument(0);
-            StakingProductVO vo = new StakingProductVO();
-            vo.setProductId(String.valueOf(product.getId()));
-            return vo;
-        });
-
-        ProductQueryServiceImpl service = new ProductQueryServiceImpl(mapper, converter, detailConverter);
-        return new Fixture(service, mapper, products, recommendById);
-    }
-
-    /**
-     * 计算分页切片(1 基页码),复刻 MyBatis-Plus 分页插件的 {@code LIMIT/OFFSET} 行为。
-     *
-     * @param list 过滤后的产品列表
-     * @param current 当前页码(1 基)
-     * @param size 每页数量
-     * @return 当前页切片(越界时为空列表)
-     */
-    private static List<StakingProduct> pageSlice(List<StakingProduct> list, long current, long size) {
-        long offset = (current - 1) * size;
-        if (offset >= list.size()) {
-            return List.of();
-        }
-        int from = (int) offset;
-        int to = (int) Math.min(offset + size, list.size());
-        return list.subList(from, to);
-    }
-
-    /**
-     * 提取分页结果中各 VO 的产品 id({@code productId} 解析回 {@code Long}),保留顺序。
-     *
-     * @param page 服务返回的契约 VO 分页
-     * @return 产品 id 列表(按返回顺序)
-     */
-    private static List<Long> idsOf(Page<StakingProductVO> page) {
-        return page.getRecords().stream()
-                .map(vo -> Long.parseLong(vo.getProductId()))
-                .toList();
-    }
-
-    // ==================== Property 2:推荐过滤正确性 ====================
-
-    // Feature: staking-service, Property 2: 推荐过滤正确性
-    // isRecommendation=true(需求 2.4):返回的所有产品都满足 isRecommend=true。
-    //   (1) 透传给 mapper 的过滤参数恒为 true;
-    //   (2) 返回的每个 VO 反查源产品均为推荐(isRecommend=true);
-    //   (3) total 等于推荐产品数;返回记录恰为「推荐子集」的分页切片;
-    //   (4) 分页参数(current/size)被保留。
-    // Validates: Requirements 2.4, 2.5
-    @Property(tries = 100)
-    void recommendationTrueReturnsOnlyRecommendedProducts(
-            @ForAll("recommendFlags") List<Boolean> flags,
-            @ForAll @IntRange(min = 1, max = 8) int page,
-            @ForAll @IntRange(min = 1, max = 15) int size) {
-        Fixture f = buildFixture(flags);
-
-        Page<StakingProductVO> result = f.service().pageProducts(page, size, Boolean.TRUE);
-
-        // (1) isRecommendation=true 被原样透传给 mapper
-        ArgumentCaptor<Boolean> flagCaptor = ArgumentCaptor.forClass(Boolean.class);
-        verify(f.mapper()).pageProducts(any(), flagCaptor.capture());
-        assertThat(flagCaptor.getValue()).as("isRecommendation 应原样透传给 mapper").isTrue();
-
-        // (2) 返回的每个 VO 反查源产品均为推荐
-        assertThat(idsOf(result))
-                .as("isRecommendation=true 时返回的所有产品都应满足 isRecommend=true")
-                .allSatisfy(id -> assertThat(f.recommendById().get(id))
-                        .as("产品 %d 应为推荐产品", id)
-                        .isTrue());
-
-        // (3) total 等于推荐产品数;返回记录恰为「推荐子集」的分页切片
-        List<StakingProduct> recommended =
-                f.products().stream().filter(StakingProduct::getIsRecommend).toList();
-        List<Long> expectedIds = pageSlice(recommended, page, size).stream()
-                .map(StakingProduct::getId)
-                .toList();
-        assertThat(result.getTotal())
-                .as("total 应等于推荐产品总数")
-                .isEqualTo((long) recommended.size());
-        assertThat(idsOf(result))
-                .as("返回记录应恰为推荐子集的分页切片(顺序一致)")
-                .isEqualTo(expectedIds);
-
-        // (4) 分页参数保留
-        assertThat(result.getCurrent()).as("应保留请求页码").isEqualTo(page);
-        assertThat(result.getSize()).as("应保留每页数量").isEqualTo(size);
-    }
-
-    // Feature: staking-service, Property 2: 推荐过滤正确性
-    // isRecommendation=false(需求 2.5):返回结果为输入集合(按分页)的无过滤子集,不因推荐标记被剔除。
-    //   (1) 透传给 mapper 的过滤参数恒为 false;
-    //   (2) total 等于「全集」大小(而非推荐子集大小)—— 证明未因推荐标记过滤;
-    //   (3) 返回记录恰为「全集」的分页切片(非推荐产品保留在结果中);
-    //   (4) 返回的所有 id 均属于输入集合(子集性)。
-    // Validates: Requirements 2.4, 2.5
-    @Property(tries = 100)
-    void recommendationFalseReturnsUnfilteredSubset(
-            @ForAll("recommendFlags") List<Boolean> flags,
-            @ForAll @IntRange(min = 1, max = 8) int page,
-            @ForAll @IntRange(min = 1, max = 15) int size) {
-        Fixture f = buildFixture(flags);
-
-        Page<StakingProductVO> result = f.service().pageProducts(page, size, Boolean.FALSE);
-
-        // (1) isRecommendation=false 被原样透传
-        ArgumentCaptor<Boolean> flagCaptor = ArgumentCaptor.forClass(Boolean.class);
-        verify(f.mapper()).pageProducts(any(), flagCaptor.capture());
-        assertThat(flagCaptor.getValue()).as("isRecommendation 应原样透传给 mapper").isFalse();
-
-        // (2) total 等于全集大小(不因推荐标记过滤)
-        assertThat(result.getTotal())
-                .as("isRecommendation=false 时 total 应等于全集大小,不被推荐标记剔除")
-                .isEqualTo((long) f.products().size());
-
-        // (3) 返回记录恰为「全集」的分页切片(非推荐产品保留)
-        List<Long> expectedIds =
-                pageSlice(f.products(), page, size).stream().map(StakingProduct::getId).toList();
-        assertThat(idsOf(result))
-                .as("返回记录应恰为全集的分页切片(无过滤、顺序一致)")
-                .isEqualTo(expectedIds);
-
-        // (4) 子集性:返回的所有 id 均属于输入集合
-        assertThat(idsOf(result))
-                .as("返回结果应为输入产品集合的子集")
-                .isSubsetOf(f.recommendById().keySet());
-
-        // 分页参数保留
-        assertThat(result.getCurrent()).as("应保留请求页码").isEqualTo(page);
-        assertThat(result.getSize()).as("应保留每页数量").isEqualTo(size);
-    }
-
-    // Feature: staking-service, Property 2: 推荐过滤正确性
-    // isRecommendation=null(需求 2.5,与 false 等价的"不过滤"分支):返回全集的分页切片,total=全集大小。
-    // Validates: Requirements 2.4, 2.5
-    @Property(tries = 100)
-    void recommendationNullReturnsUnfilteredSubset(
-            @ForAll("recommendFlags") List<Boolean> flags,
-            @ForAll @IntRange(min = 1, max = 8) int page,
-            @ForAll @IntRange(min = 1, max = 15) int size) {
-        Fixture f = buildFixture(flags);
-
-        Page<StakingProductVO> result = f.service().pageProducts(page, size, null);
-
-        // 透传 null(不追加过滤条件)
-        ArgumentCaptor<Boolean> flagCaptor = ArgumentCaptor.forClass(Boolean.class);
-        verify(f.mapper()).pageProducts(any(), flagCaptor.capture());
-        assertThat(flagCaptor.getValue()).as("isRecommendation 应原样透传给 mapper(null)").isNull();
-
-        assertThat(result.getTotal())
-                .as("isRecommendation=null 时 total 应等于全集大小")
-                .isEqualTo((long) f.products().size());
-
-        List<Long> expectedIds =
-                pageSlice(f.products(), page, size).stream().map(StakingProduct::getId).toList();
-        assertThat(idsOf(result))
-                .as("返回记录应恰为全集的分页切片(无过滤)")
-                .isEqualTo(expectedIds);
-    }
-
-    // ==================== 边界锚定示例 ====================
-
-    // Feature: staking-service, Property 2: 推荐过滤正确性
-    // 边界锚定:3 推荐 + 2 非推荐,单页足以容纳全部。
-    //   true  → 仅返回 3 个推荐产品;
-    //   false → 返回全部 5 个产品(非推荐未被剔除)。
-    // Validates: Requirements 2.4, 2.5
-    @Example
-    void mixedSetTrueKeepsRecommendedFalseKeepsAll() {
-        // 产品集合:id 100000(R)、100001(N)、100002(R)、100003(N)、100004(R)
-        List<Boolean> flags = List.of(true, false, true, false, true);
-
-        // true:仅 3 个推荐产品
-        Fixture f1 = buildFixture(flags);
-        Page<StakingProductVO> recommendedPage = f1.service().pageProducts(1, 10, Boolean.TRUE);
-        assertThat(recommendedPage.getTotal()).as("推荐产品总数应为 3").isEqualTo(3L);
-        assertThat(idsOf(recommendedPage))
-                .as("true 仅返回推荐产品 id")
-                .containsExactly(BASE_ID, BASE_ID + 2, BASE_ID + 4);
-        assertThat(idsOf(recommendedPage))
-                .allSatisfy(id -> assertThat(f1.recommendById().get(id)).isTrue());
-
-        // false:返回全部 5 个产品,非推荐 id 仍在结果中
-        Fixture f2 = buildFixture(flags);
-        Page<StakingProductVO> allPage = f2.service().pageProducts(1, 10, Boolean.FALSE);
-        assertThat(allPage.getTotal()).as("全集大小应为 5").isEqualTo(5L);
-        assertThat(idsOf(allPage))
-                .as("false 返回全部产品,含非推荐产品(未被剔除)")
-                .containsExactly(BASE_ID, BASE_ID + 1, BASE_ID + 2, BASE_ID + 3, BASE_ID + 4);
-        assertThat(idsOf(allPage)).contains(BASE_ID + 1, BASE_ID + 3);
-    }
-}

+ 0 - 44
bex-cloud-staking-service/src/test/resources/application-test.yml

@@ -1,44 +0,0 @@
-# =============================================================================
-# 业务模块 RocketMQ + 本地消息表集成测试配置(H2 内存数据库,MySQL 兼容模式)
-#
-# 说明(任务 17.3):
-#   - 真实 RocketMQ broker / 资产 gRPC 无法在测试中启动,集成测试聚焦"本地消息表状态流转链路",
-#     用 H2 装配真实 StakingAssetEventMapper + 真实生产者/消费者/重试组件,mock RocketMQTemplate
-#     与 AssetGrpcClient 串联生产者与消费者;
-#   - 因运行环境无 Docker,采用 H2(MODE=MySQL)替代 Testcontainers(MySQL 8);
-#   - DATABASE_TO_LOWER=TRUE 使大小写不敏感,贴近 MySQL 默认行为;
-#   - 启动时执行 schema-h2.sql 建表(含 uk_event_no 唯一约束、version 乐观锁字段);
-#   - MyBatis-Plus 全局配置与生产保持一致:assign_id 主键、deleted 逻辑删除、mapper XML 位置。
-# =============================================================================
-spring:
-  cloud:
-    nacos:
-      server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
-      username: ${NACOS_USERNAME:nacos}
-      password: ${NACOS_PASSWORD:nacos}
-      config:
-        namespace: ${NACOS_NAMESPACE:public}
-        group: ${NACOS_GROUP:DEFAULT_GROUP}
-      discovery:
-        namespace: ${NACOS_NAMESPACE:public}
-        enabled: false
-  datasource:
-    url: jdbc:h2:mem:bex_staking_mq_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
-    driver-class-name: org.h2.Driver
-    username: sa
-    password: ""
-  sql:
-    init:
-      mode: always
-      schema-locations: classpath:schema-h2.sql
-
-mybatis-plus:
-  mapper-locations: classpath*:mapper/*.xml
-  global-config:
-    db-config:
-      id-type: assign_id
-      logic-delete-field: deleted
-      logic-delete-value: 1
-      logic-not-delete-value: 0
-  configuration:
-    map-underscore-to-camel-case: true

+ 0 - 120
bex-cloud-staking-service/src/test/resources/schema-h2.sql

@@ -1,120 +0,0 @@
--- =============================================================================
--- H2(MySQL 兼容模式)测试建表脚本 —— 用于 RocketMQ + 本地消息表集成测试(任务 17.3)
---
--- 设计说明:
---   1. 由生产脚本 bex_staking.sql 改写为 H2 可执行的等价 DDL:
---      - 去除 MySQL 专有的 BIGINT UNSIGNED / ENGINE=InnoDB / ON UPDATE CURRENT_TIMESTAMP;
---      - 仅保留主键与唯一约束(uk_event_no / uk_order_no 等),非唯一索引对链路测试无影响故省略。
---   2. 本任务聚焦本地消息表 staking_asset_event 的状态流转链路,并在 BURN 场景回填订单 burn_tx_hash,
---      故保留 staking_asset_event 与 staking_order 两张表;为兼容实体模块同一建表脚本约定,一并建出全部表。
---   3. deleted / version 字段保留,用于验证 MyBatis-Plus 逻辑删除与乐观锁(事件表流转依赖 @Version)。
---   4. 与实体模块 schema-h2.sql 保持等价,确保链路测试的库结构与 Mapper 集成测试一致。
--- =============================================================================
-
-DROP TABLE IF EXISTS `staking_product`;
-DROP TABLE IF EXISTS `staking_order`;
-DROP TABLE IF EXISTS `staking_profit_journal`;
-DROP TABLE IF EXISTS `staking_asset_event`;
-
--- 1. 产品定义主表
-CREATE TABLE `staking_product` (
-  `id`                    BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `product_sn`            VARCHAR(64)  NOT NULL COMMENT '产品唯一编号',
-  `title`                 VARCHAR(128) NOT NULL COMMENT '产品名称',
-  `product_type`          VARCHAR(32)  NOT NULL COMMENT '产品类型: PRIVATE_SALE, STAKING',
-  `status`                VARCHAR(32)  NOT NULL COMMENT '产品状态: ACTIVE, SOLD_OUT, DISABLED',
-  `display_status`        VARCHAR(32)  NOT NULL COMMENT '对外展示状态',
-  `is_recommend`          TINYINT      NOT NULL DEFAULT 0 COMMENT '是否推荐',
-  `is_show_twap`          TINYINT      NOT NULL DEFAULT 0 COMMENT '是否展示TWAP价格',
-  `sub_mode`              VARCHAR(32)  NOT NULL COMMENT '认购模式: FIXED, UNLIMITED',
-  `tier_config`           VARCHAR(512) DEFAULT NULL COMMENT '固定档位JSON',
-  `min_amount`            DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '最小认购限额',
-  `max_amount`            DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '最大认购限额',
-  `total_capacity`        DECIMAL(36,18) NOT NULL COMMENT '总募集上限',
-  `raised_amount`         DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '已募集金额',
-  `interest_type`         VARCHAR(32)  NOT NULL COMMENT '利息类型: PERCENTAGE, FIXED_AMOUNT',
-  `apr`                   DECIMAL(12,6) NOT NULL DEFAULT 0 COMMENT '年化收益率',
-  `period_rate`           DECIMAL(12,6) NOT NULL DEFAULT 0 COMMENT '周期收益率',
-  `fixed_interest_amount` DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '单位本金固定利息',
-  `lock_days`             INT NOT NULL COMMENT '锁仓天数',
-  `redeem_type`           VARCHAR(32)  NOT NULL COMMENT '赎回机制: NORMAL, DEFLATIONARY',
-  `deflation_rate`        DECIMAL(5,2) NOT NULL DEFAULT 80.00 COMMENT '通缩触发收益占比阈值(%)',
-  `display_scale`         INT NOT NULL DEFAULT 4 COMMENT '展示小数位',
-  `sale_start_time`       BIGINT NOT NULL COMMENT '募集开始时间戳(毫秒)',
-  `sale_end_time`         BIGINT NOT NULL COMMENT '募集结束时间戳(毫秒)',
-  `lock_start_time`       BIGINT DEFAULT NULL COMMENT '锁仓开始时间戳(毫秒)',
-  `lock_end_time`         BIGINT DEFAULT NULL COMMENT '锁仓到期时间戳(毫秒)',
-  `version`               INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `deleted`               TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0未删 1已删',
-  `created_at`            DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`            DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_product_sn` UNIQUE (`product_sn`)
-);
-
--- 2. 用户质押/私募订单表
-CREATE TABLE `staking_order` (
-  `id`                 BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `order_no`           VARCHAR(64) NOT NULL COMMENT '全局唯一订单编号',
-  `user_id`            BIGINT NOT NULL COMMENT '用户UID',
-  `product_id`         BIGINT NOT NULL COMMENT '产品ID',
-  `product_type`       VARCHAR(32) NOT NULL COMMENT '产品类型',
-  `idempotent_key`     VARCHAR(128) NOT NULL COMMENT '幂等键, 防重复下单',
-  `stake_amount`       DECIMAL(36,18) NOT NULL COMMENT '质押/认购本金(P)',
-  `entry_price`        DECIMAL(36,18) NOT NULL COMMENT '入场价格(Pt0)',
-  `initial_value_usdt` DECIMAL(36,18) NOT NULL COMMENT '初始USDT价值(V0)',
-  `cumulative_profit`  DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '累计已结算收益(R)',
-  `pending_profit`     DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '待领取收益余额',
-  `claimed_profit`     DECIMAL(36,18) NOT NULL DEFAULT 0 COMMENT '已领取收益累计',
-  `settled_days`       INT NOT NULL DEFAULT 0 COMMENT '已结算天数',
-  `status`             VARCHAR(32) NOT NULL COMMENT '订单状态',
-  `return_principal`   DECIMAL(36,18) DEFAULT NULL COMMENT '清算返还本金(R)',
-  `burn_amount`        DECIMAL(36,18) DEFAULT NULL COMMENT '销毁数量(P-R)',
-  `expire_price`       DECIMAL(36,18) DEFAULT NULL COMMENT '到期清算价格(Pt)',
-  `burn_tx_hash`       VARCHAR(128) DEFAULT NULL COMMENT '销毁划转交易哈希',
-  `lock_start_time`    BIGINT NOT NULL COMMENT '锁仓开始时间戳(毫秒)',
-  `expire_time`        BIGINT NOT NULL COMMENT '订单到期时间戳(毫秒)',
-  `version`            INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `deleted`            TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
-  `created_at`         DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`         DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_order_no` UNIQUE (`order_no`),
-  CONSTRAINT `uk_user_idem` UNIQUE (`user_id`, `idempotent_key`)
-);
-
--- 3. 收益派发流水日志表
-CREATE TABLE `staking_profit_journal` (
-  `id`            BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `journal_no`    VARCHAR(64) NOT NULL COMMENT '流水编号',
-  `order_no`      VARCHAR(64) NOT NULL COMMENT '订单编号',
-  `user_id`       BIGINT NOT NULL COMMENT '用户UID',
-  `profit_amount` DECIMAL(36,18) NOT NULL COMMENT '本次派发收益金额',
-  `settle_date`   INT NOT NULL COMMENT '结算业务日期 YYYYMMDD',
-  `status`        VARCHAR(32) NOT NULL COMMENT '状态: UNCLAIMED, CLAIMED',
-  `claim_time`    BIGINT DEFAULT NULL COMMENT '领取时间戳',
-  `version`       INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_journal_no` UNIQUE (`journal_no`),
-  CONSTRAINT `uk_order_date` UNIQUE (`order_no`, `settle_date`)
-);
-
--- 4. 资产账变本地消息表(本任务核心:PENDING → SENT → CONFIRMED / FAILED 状态流转)
-CREATE TABLE `staking_asset_event` (
-  `id`            BIGINT NOT NULL COMMENT '主键ID(雪花算法)',
-  `event_no`      VARCHAR(64) NOT NULL COMMENT '事件唯一编号(幂等 reference_id)',
-  `order_no`      VARCHAR(64) NOT NULL COMMENT '关联订单编号',
-  `user_id`       BIGINT NOT NULL COMMENT '用户UID',
-  `event_type`    VARCHAR(32) NOT NULL COMMENT '类型',
-  `amount`        DECIMAL(36,18) NOT NULL COMMENT '金额',
-  `status`        VARCHAR(32) NOT NULL COMMENT '状态',
-  `retry_count`   INT NOT NULL DEFAULT 0 COMMENT '重试次数',
-  `tx_hash`       VARCHAR(128) DEFAULT NULL COMMENT '账变交易哈希',
-  `version`       INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
-  `created_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `updated_at`    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  CONSTRAINT `uk_event_no` UNIQUE (`event_no`)
-);