|
|
@@ -1,352 +1,352 @@
|
|
|
-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);
|
|
|
- }
|
|
|
-}
|
|
|
+//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);
|
|
|
+// }
|
|
|
+//}
|