修复订单下单错误和余额扣款校验问题
Some checks failed
Build and Push Backend / docker (push) Failing after 5s

This commit is contained in:
irving
2025-11-03 10:02:03 -05:00
parent fe36332ef3
commit 83112b406a
14 changed files with 456 additions and 23 deletions

View File

@@ -134,7 +134,16 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId, lambdaWrapper.leftJoin(PlayClerkLevelInfoEntity.class, PlayClerkLevelInfoEntity::getId,
PlayClerkUserInfoEntity::getLevelId); PlayClerkUserInfoEntity::getLevelId);
lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId); lambdaWrapper.eq(PlayClerkUserInfoEntity::getId, clerkId);
return this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper); PlayClerkLevelInfoEntity levelInfo = this.baseMapper.selectJoinOne(PlayClerkLevelInfoEntity.class, lambdaWrapper);
if (levelInfo != null) {
return levelInfo;
}
PlayClerkUserInfoEntity clerk = this.baseMapper.selectById(clerkId);
if (clerk == null || StringUtils.isBlank(clerk.getLevelId())) {
return null;
}
return playClerkLevelInfoService.getById(clerk.getLevelId());
} }

View File

@@ -23,6 +23,7 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState; import com.starry.admin.modules.order.module.constant.OrderConstant.OrdersExpiredState;
import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod; import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod;
import com.starry.admin.modules.order.module.constant.OrderConstant.PaymentSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag; import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
@@ -41,6 +42,7 @@ import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
@@ -105,6 +107,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
@Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies; private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct @PostConstruct
@@ -515,6 +520,10 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("每个订单只能退款一次~"); throw new CustomException("每个订单只能退款一次~");
} }
if (isBalancePaidOrder(order) && !playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), order.getId())) {
throw new CustomException("订单未发生余额扣款,无法退款");
}
UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>(); UpdateWrapper<PlayOrderInfoEntity> refundUpdate = new UpdateWrapper<>();
refundUpdate.eq("id", order.getId()) refundUpdate.eq("id", order.getId())
.eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode()) .eq("refund_type", OrderRefundFlag.NOT_REFUNDED.getCode())
@@ -581,6 +590,19 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
refundOperationType); refundOperationType);
} }
private boolean isBalancePaidOrder(PlayOrderInfoEntity order) {
String sourceCode = order.getPaymentSource();
if (StrUtil.isBlank(sourceCode)) {
return true;
}
try {
return PaymentSource.fromCode(sourceCode) == PaymentSource.BALANCE;
} catch (IllegalArgumentException ex) {
log.warn("Unknown payment source {}, defaulting to balance for refund guard", sourceCode);
return true;
}
}
private void validateOrderCreationRequest(OrderCreationContext context) { private void validateOrderCreationRequest(OrderCreationContext context) {
if (context == null) { if (context == null) {
throw new CustomException("订单创建请求不能为空"); throw new CustomException("订单创建请求不能为空");

View File

@@ -37,6 +37,13 @@ public class ClerkRevenueCalculator {
BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount; BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo(); ClerkEstimatedRevenueVo estimatedRevenueVo = new ClerkEstimatedRevenueVo();
if (levelInfo == null) {
log.warn("店员{}缺少等级提成配置预计收益按0处理", clerkId);
estimatedRevenueVo.setRevenueRatio(0);
estimatedRevenueVo.setRevenueAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
return estimatedRevenueVo;
}
boolean fallbackToOther = false; boolean fallbackToOther = false;
OrderConstant.PlaceType placeTypeEnum; OrderConstant.PlaceType placeTypeEnum;
try { try {
@@ -49,13 +56,13 @@ public class ClerkRevenueCalculator {
switch (placeTypeEnum) { switch (placeTypeEnum) {
case SPECIFIED: // 指定单 case SPECIFIED: // 指定单
fillRegularOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRegularOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case RANDOM: // 随机单 case RANDOM: // 随机单
fillRandomOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRandomOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case REWARD: // 打赏单 case REWARD: // 打赏单
fillRewardOrderRevenue(firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRewardOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case OTHER: case OTHER:
default: default:
@@ -71,42 +78,56 @@ public class ClerkRevenueCalculator {
return estimatedRevenueVo; return estimatedRevenueVo;
} }
private void fillRegularOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRegularOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRegularRatio()); int ratio = safeRatio(levelInfo.getFirstRegularRatio(), "firstRegularRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRegularRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRegularRatio()); int ratio = safeRatio(levelInfo.getNotFirstRegularRatio(), "notFirstRegularRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRegularRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private void fillRandomOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRandomOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRandomRadio()); int ratio = safeRatio(levelInfo.getFirstRandomRadio(), "firstRandomRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRandomRadio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRandomRadio()); int ratio = safeRatio(levelInfo.getNotFirstRandomRadio(), "notFirstRandomRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRandomRadio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private void fillRewardOrderRevenue(String firstOrder, BigDecimal orderAmount, private void fillRewardOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { if ("1".equals(firstOrder)) {
vo.setRevenueRatio(levelInfo.getFirstRewardRatio()); int ratio = safeRatio(levelInfo.getFirstRewardRatio(), "firstRewardRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getFirstRewardRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else { } else {
vo.setRevenueRatio(levelInfo.getNotFirstRewardRatio()); int ratio = safeRatio(levelInfo.getNotFirstRewardRatio(), "notFirstRewardRatio", clerkId);
vo.setRevenueAmount(scaleAmount(orderAmount, levelInfo.getNotFirstRewardRatio())); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} }
} }
private BigDecimal scaleAmount(BigDecimal baseAmount, Integer ratio) { private int safeRatio(Integer ratio, String ratioField, String clerkId) {
if (ratio == null) {
log.warn("店员{}的等级配置字段{}缺失已按0%处理", clerkId, ratioField);
return 0;
}
return ratio;
}
private BigDecimal scaleAmount(BigDecimal baseAmount, int ratio) {
return baseAmount return baseAmount
.multiply(new BigDecimal(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) .multiply(BigDecimal.valueOf(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP))
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
} }

View File

@@ -0,0 +1,18 @@
package com.starry.admin.modules.personnel.module.enums;
import lombok.Getter;
/**
* 用户类型枚举0:陪聊;1:顾客)
*/
@Getter
public enum BalanceDetailsUserType {
CLERK("0"),
CUSTOMER("1");
private final String code;
BalanceDetailsUserType(String code) {
this.code = code;
}
}

View File

@@ -69,6 +69,17 @@ public interface IPlayBalanceDetailsInfoService extends IService<PlayBalanceDeta
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
BigDecimal giftAmount, String orderId); BigDecimal giftAmount, String orderId);
/**
* 判断顾客是否对指定订单发生过余额扣款。
*
* @param userId
* 顾客ID
* @param orderId
* 订单ID
* @return 存在消费流水返回true
*/
boolean existsCustomerConsumeRecord(String userId, String orderId);
/** /**
* 新增余额明细 * 新增余额明细
* *

View File

@@ -6,11 +6,14 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.mapper.PlayBalanceDetailsInfoMapper; import com.starry.admin.modules.personnel.mapper.PlayBalanceDetailsInfoMapper;
import com.starry.admin.modules.personnel.module.entity.PlayBalanceDetailsInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayBalanceDetailsInfoEntity;
import com.starry.admin.modules.personnel.module.enums.BalanceDetailsUserType;
import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsQueryVo; import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsQueryVo;
import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsReturnVo; import com.starry.admin.modules.personnel.module.vo.PlayBalanceDetailsReturnVo;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService; import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
@@ -114,7 +117,12 @@ public class PlayBalanceDetailsInfoServiceImpl
public void insertBalanceDetailsInfo(String userType, String userId, BigDecimal balanceBeforeOperation, public void insertBalanceDetailsInfo(String userType, String userId, BigDecimal balanceBeforeOperation,
BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney, BigDecimal balanceAfterOperation, String operationType, String operationAction, BigDecimal balanceMoney,
BigDecimal giftAmount, String orderId) { BigDecimal giftAmount, String orderId) {
PlayOrderInfoEntity orderInfo = playOrderInfoService.selectOrderInfoById(orderId); PlayOrderInfoEntity orderInfo = null;
try {
orderInfo = playOrderInfoService.selectOrderInfoById(orderId);
} catch (CustomException ex) {
orderInfo = null;
}
PlayBalanceDetailsInfoEntity entity = new PlayBalanceDetailsInfoEntity(); PlayBalanceDetailsInfoEntity entity = new PlayBalanceDetailsInfoEntity();
entity.setId(IdUtils.getUuid()); entity.setId(IdUtils.getUuid());
entity.setUserType(userType); entity.setUserType(userType);
@@ -180,4 +188,15 @@ public class PlayBalanceDetailsInfoServiceImpl
public int deletePlayBalanceDetailsInfoById(String id) { public int deletePlayBalanceDetailsInfoById(String id) {
return playBalanceDetailsInfoMapper.deleteById(id); return playBalanceDetailsInfoMapper.deleteById(id);
} }
@Override
public boolean existsCustomerConsumeRecord(String userId, String orderId) {
return lambdaQuery()
.eq(PlayBalanceDetailsInfoEntity::getUserType, BalanceDetailsUserType.CUSTOMER.getCode())
.eq(PlayBalanceDetailsInfoEntity::getUserId, userId)
.eq(PlayBalanceDetailsInfoEntity::getOrderId, orderId)
.eq(PlayBalanceDetailsInfoEntity::getOperationType, BalanceOperationType.CONSUME.getCode())
.last("limit 1")
.one() != null;
}
} }

View File

@@ -4,11 +4,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("apitest") @ActiveProfiles("apitest")
@TestPropertySource(properties = "spring.task.scheduling.enabled=false")
public abstract class AbstractApiTest { public abstract class AbstractApiTest {
protected static final String TENANT_HEADER = "X-Tenant"; protected static final String TENANT_HEADER = "X-Tenant";

View File

@@ -0,0 +1,76 @@
package com.starry.admin.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
@Autowired
private BlindBoxConfigService blindBoxConfigService;
@Test
void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String configId = "blind-" + IdUtils.getUuid();
try {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId(configId);
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
config.setName("API盲盒-" + IdUtils.getUuid().substring(0, 6));
config.setPrice(new BigDecimal("66.00"));
config.setStatus(1);
blindBoxConfigService.save(config);
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
ensureTenantContext();
long beforeCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode())
.count();
String payload = "{" +
"\"blindBoxId\":\"" + configId + "\"," +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"weiChatCode\":\"apitest-customer-wx\"" +
"}";
mockMvc.perform(post("/wx/blind-box/order/purchase")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long afterCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getOrderType, OrderConstant.OrderType.BLIND_BOX_PURCHASE.getCode())
.count();
Assertions.assertThat(afterCount).isEqualTo(beforeCount);
} finally {
blindBoxConfigService.removeById(configId);
CustomSecurityContextHolder.remove();
}
}
}

View File

@@ -39,6 +39,43 @@ class WxCustomGiftOrderApiTest extends WxCustomOrderApiTestSupport {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void giftOrderFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String remark = "API gift insufficient " + IdUtils.getUuid();
try {
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"giftId\":\"" + ApiTestDataSeeder.DEFAULT_GIFT_ID + "\"," +
"\"giftQuantity\":1," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/gift")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long count = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getRemark, remark)
.count();
Assertions.assertThat(count).isZero();
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test @Test
// 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物, // 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物,
// 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。 // 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
@@ -12,12 +13,14 @@ import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType; import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
import com.starry.admin.modules.shop.module.enums.CouponDiscountType; import com.starry.admin.modules.shop.module.enums.CouponDiscountType;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState; import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType; import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService; import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.admin.modules.weichat.service.WxTokenService; import com.starry.admin.modules.weichat.service.WxTokenService;
@@ -66,7 +69,11 @@ abstract class WxCustomOrderApiTestSupport extends AbstractApiTest {
@Autowired @Autowired
protected IPlayCouponDetailsService couponDetailsService; protected IPlayCouponDetailsService couponDetailsService;
@Autowired
protected IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
protected void resetCustomerBalance() { protected void resetCustomerBalance() {
ensureDefaultClerkLevelBinding();
BigDecimal balance = new BigDecimal("200.00"); BigDecimal balance = new BigDecimal("200.00");
customUserInfoService.updateAccountBalanceById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, balance); customUserInfoService.updateAccountBalanceById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, balance);
customUserInfoService.lambdaUpdate() customUserInfoService.lambdaUpdate()
@@ -80,6 +87,11 @@ abstract class WxCustomOrderApiTestSupport extends AbstractApiTest {
.update(); .update();
} }
protected void setCustomerBalance(BigDecimal balance) {
ensureDefaultClerkLevelBinding();
customUserInfoService.updateAccountBalanceById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, balance);
}
protected void ensureTenantContext() { protected void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
} }
@@ -199,4 +211,43 @@ abstract class WxCustomOrderApiTestSupport extends AbstractApiTest {
throw new AssertionError("优惠券未标记为已使用"); throw new AssertionError("优惠券未标记为已使用");
} }
} }
private void ensureDefaultClerkLevelBinding() {
ensureTenantContext();
PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
if (!StringUtils.hasText(clerk.getLevelId())
|| !ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID.equals(clerk.getLevelId())) {
boolean updated = clerkUserInfoService.lambdaUpdate()
.set(PlayClerkUserInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.eq(PlayClerkUserInfoEntity::getId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
.eq(PlayClerkUserInfoEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.update();
if (!updated) {
throw new IllegalStateException("ApiTest fixtures failed to bind default level to test clerk");
}
clerk = clerkUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
}
long pricingCount = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.count();
if (pricingCount == 0) {
PlayCommodityAndLevelInfoEntity mapping = new PlayCommodityAndLevelInfoEntity();
mapping.setId(com.starry.common.utils.IdUtils.getUuid());
mapping.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
mapping.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
mapping.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
mapping.setPrice(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
mapping.setSort(1L);
commodityAndLevelInfoService.save(mapping);
pricingCount = commodityAndLevelInfoService.lambdaQuery()
.eq(PlayCommodityAndLevelInfoEntity::getCommodityId, ApiTestDataSeeder.DEFAULT_COMMODITY_ID)
.eq(PlayCommodityAndLevelInfoEntity::getLevelId, ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID)
.count();
}
if (pricingCount == 0) {
throw new IllegalStateException("ApiTest fixtures missing commodity pricing for default clerk level");
}
}
} }

View File

@@ -51,6 +51,52 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService; private com.starry.admin.modules.order.service.IPlayOrderInfoService orderInfoService;
@Test
void randomOrderFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String remark = "API random insufficient " + IdUtils.getUuid();
try {
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
ensureTenantContext();
long beforeCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode())
.count();
String payload = "{" +
"\"sex\":\"2\"," +
"\"levelId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID + "\"," +
"\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
"\"commodityQuantity\":1," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"excludeHistory\":\"0\"," +
"\"couponIds\":[]," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/random")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long afterCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.RANDOM.getCode())
.count();
Assertions.assertThat(afterCount).isEqualTo(beforeCount);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test @Test
// 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案, // 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案,
// 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。 // 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。

View File

@@ -19,6 +19,48 @@ import org.springframework.http.MediaType;
class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport { class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport {
@Test
void rewardOrderFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String remark = "API reward insufficient " + IdUtils.getUuid();
try {
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
ensureTenantContext();
long beforeCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.REWARD.getCode())
.count();
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"money\":\"18.00\"," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/reward")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long afterCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.eq(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.REWARD.getCode())
.count();
Assertions.assertThat(afterCount).isEqualTo(beforeCount);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test @Test
// 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录, // 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录,
// 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。 // 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。

View File

@@ -39,6 +39,55 @@ class WxCustomSpecifiedOrderApiTest extends WxCustomOrderApiTestSupport {
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
private com.starry.admin.modules.weichat.service.WxTokenService clerkWxTokenService; private com.starry.admin.modules.weichat.service.WxTokenService clerkWxTokenService;
@Test
void specifiedOrderFailsWhenBalanceInsufficient() throws Exception {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
String remark = "API specified insufficient " + IdUtils.getUuid();
try {
setCustomerBalance(BigDecimal.ZERO);
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
ensureTenantContext();
com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity clerk =
clerkUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
Assertions.assertThat(clerk.getLevelId())
.as("默认店员应绑定基础等级用于价格校验")
.isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
ensureTenantContext();
long beforeCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.count();
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"commodityId\":\"" + ApiTestDataSeeder.DEFAULT_COMMODITY_ID + "\"," +
"\"commodityQuantity\":1," +
"\"weiChatCode\":\"apitest-customer-wx\"," +
"\"couponIds\":[]," +
"\"remark\":\"" + remark + "\"" +
"}";
mockMvc.perform(post("/wx/custom/order/commodity")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(998));
ensureTenantContext();
long afterCount = playOrderInfoService.lambdaQuery()
.eq(PlayOrderInfoEntity::getPurchaserBy, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
.count();
Assertions.assertThat(afterCount).isEqualTo(beforeCount);
} finally {
CustomSecurityContextHolder.remove();
}
}
@Test @Test
// 测试用例:指定单取消后优惠券应恢复为未使用 // 测试用例:指定单取消后优惠券应恢复为未使用
void specifiedOrderCancellationReleasesCoupon() throws Exception { void specifiedOrderCancellationReleasesCoupon() throws Exception {

View File

@@ -29,6 +29,7 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderType; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderType;
import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod; import com.starry.admin.modules.order.module.constant.OrderConstant.PayMethod;
import com.starry.admin.modules.order.module.constant.OrderConstant.PaymentSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType; import com.starry.admin.modules.order.module.constant.OrderConstant.PlaceType;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
@@ -45,6 +46,7 @@ import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo; import com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
@@ -101,6 +103,9 @@ class OrderLifecycleServiceImplTest {
@Mock @Mock
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
@Mock
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@BeforeEach @BeforeEach
void initStrategies() { void initStrategies() {
lifecycleService.initPlacementStrategies(); lifecycleService.initPlacementStrategies();
@@ -1101,6 +1106,7 @@ private PlayOrderLogInfoMapper orderLogInfoMapper;
customer.setId(order.getPurchaserBy()); customer.setId(order.getPurchaserBy());
customer.setAccountBalance(BigDecimal.valueOf(10)); customer.setAccountBalance(BigDecimal.valueOf(10));
when(customUserInfoService.getById(order.getPurchaserBy())).thenReturn(customer); when(customUserInfoService.getById(order.getPurchaserBy())).thenReturn(customer);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(true);
OrderRefundContext context = new OrderRefundContext(); OrderRefundContext context = new OrderRefundContext();
context.setOrderId(orderId); context.setOrderId(orderId);
@@ -1176,6 +1182,8 @@ private PlayOrderLogInfoMapper orderLogInfoMapper;
context.setRefundAmount(BigDecimal.ONE); context.setRefundAmount(BigDecimal.ONE);
context.withTriggerSource(OrderTriggerSource.ADMIN_API); context.withTriggerSource(OrderTriggerSource.ADMIN_API);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(true);
lifecycleService.refundOrder(context); lifecycleService.refundOrder(context);
verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any()); verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any());
@@ -1183,6 +1191,28 @@ private PlayOrderLogInfoMapper orderLogInfoMapper;
verify(orderLogInfoMapper, never()).insert(any()); verify(orderLogInfoMapper, never()).insert(any());
} }
@Test
void refundOrder_balanceWithoutConsumption_throws() {
String orderId = UUID.randomUUID().toString();
PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode());
order.setFinalAmount(BigDecimal.TEN);
order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
order.setPaymentSource(PaymentSource.BALANCE.getCode());
when(orderInfoMapper.selectById(orderId)).thenReturn(order);
when(playBalanceDetailsInfoService.existsCustomerConsumeRecord(order.getPurchaserBy(), orderId)).thenReturn(false);
OrderRefundContext context = new OrderRefundContext();
context.setOrderId(orderId);
context.setRefundAmount(BigDecimal.ONE);
context.withTriggerSource(OrderTriggerSource.ADMIN_API);
assertThrows(CustomException.class, () -> lifecycleService.refundOrder(context));
verify(orderInfoMapper, never()).update(isNull(), any());
verify(customUserInfoService, never()).updateAccountBalanceById(any(), any(), any(), any(), any(), any(), any(), any());
verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString());
}
private PlayOrderInfoEntity buildOrder(String orderId, String status) { private PlayOrderInfoEntity buildOrder(String orderId, String status) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(orderId); entity.setId(orderId);