重构订单下单逻辑,引入策略模式和命令模式

- 为OrderTriggerSource枚举添加详细注释说明
- 将IOrderLifecycleService接口的initiateOrder方法重构为placeOrder
- 新增OrderPlacementCommand、OrderPlacementResult、OrderAmountBreakdown等DTO
- 实现订单下单策略模式,支持不同类型订单的差异化处理
- 优化金额计算逻辑,完善优惠券折扣计算
- 改进余额扣减逻辑,增强异常处理
- 更新相关控制器使用新的下单接口
- 完善单元测试覆盖,确保代码质量
This commit is contained in:
irving
2025-10-30 21:12:37 -04:00
parent 67692ff79f
commit e29c5db276
16 changed files with 1609 additions and 80 deletions

View File

@@ -418,14 +418,41 @@ public class OrderConstant {
@Getter @Getter
public enum OrderTriggerSource { public enum OrderTriggerSource {
/**
* 未标记来源的兜底,通常用于兼容历史数据
*/
UNKNOWN("unknown"), UNKNOWN("unknown"),
/**
* 运营或客服后台人工处理触发
*/
MANUAL("manual"), MANUAL("manual"),
/**
* 微信顾客端(小程序/公众号)下单触发
*/
WX_CUSTOMER("wx_customer"), WX_CUSTOMER("wx_customer"),
/**
* 微信店员端操作触发
*/
WX_CLERK("wx_clerk"), WX_CLERK("wx_clerk"),
/**
* 管理后台控制台界面发起
*/
ADMIN_CONSOLE("admin_console"), ADMIN_CONSOLE("admin_console"),
/**
* 管理后台开放接口调用
*/
ADMIN_API("admin_api"), ADMIN_API("admin_api"),
/**
* 打赏单自动生成的订单流程
*/
REWARD_ORDER("reward_order"), REWARD_ORDER("reward_order"),
/**
* 定时任务/调度器触发
*/
SCHEDULER("scheduler"), SCHEDULER("scheduler"),
/**
* 平台内部系统逻辑触发
*/
SYSTEM("system"); SYSTEM("system");
private final String code; private final String code;

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.order.module.dto;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 订单金额拆分结果:原价、优惠、实付。
*/
@Getter
@AllArgsConstructor(staticName = "of")
public class OrderAmountBreakdown {
private final BigDecimal grossAmount;
private final BigDecimal discountAmount;
private final BigDecimal netAmount;
}

View File

@@ -0,0 +1,48 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import lombok.Builder;
import lombok.Data;
import lombok.NonNull;
/**
* 下单指令,封装不同业务场景需要的参数。
*/
@Data
@Builder
public class OrderPlacementCommand {
@NonNull
private final OrderCreationContext orderContext;
private final String balanceOperationAction;
@Builder.Default
private final boolean deductBalance = true;
private final PricingInput pricingInput;
/**
* 计价所需的输入参数。
*/
@Data
@Builder
public static class PricingInput {
@NonNull
private final BigDecimal unitPrice;
private final int quantity;
@Builder.Default
private final List<String> couponIds = Collections.emptyList();
private final String commodityId;
@NonNull
private final OrderConstant.PlaceType placeType;
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 订单下单结果,包含订单实体和金额拆分。
*/
@Getter
@AllArgsConstructor(staticName = "of")
public class OrderPlacementResult {
private final PlayOrderInfoEntity order;
private final OrderAmountBreakdown amountBreakdown;
}

View File

@@ -2,12 +2,14 @@ package com.starry.admin.modules.order.service;
import com.starry.admin.modules.order.module.dto.OrderCompletionContext; import com.starry.admin.modules.order.module.dto.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
public interface IOrderLifecycleService { public interface IOrderLifecycleService {
PlayOrderInfoEntity initiateOrder(OrderCreationContext context); OrderPlacementResult placeOrder(OrderPlacementCommand command);
void completeOrder(String orderId, OrderCompletionContext context); void completeOrder(String orderId, OrderCompletionContext context);

View File

@@ -0,0 +1,45 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy {
protected final OrderLifecycleServiceImpl service;
AbstractOrderPlacementStrategy(OrderLifecycleServiceImpl service) {
this.service = service;
}
@Override
public OrderPlacementResult place(OrderPlacementCommand command) {
OrderCreationContext context = command.getOrderContext();
OrderAmountBreakdown breakdown = handlePricing(command, context);
PaymentInfo paymentInfo = context.getPaymentInfo();
if (paymentInfo == null) {
throw new CustomException("支付信息不能为空");
}
PlayOrderInfoEntity order = service.createOrderRecord(context);
if (command.isDeductBalance()) {
service.deductCustomerBalance(
context.getPurchaserBy(),
service.normalizeMoney(paymentInfo.getFinalAmount()),
command.getBalanceOperationAction(),
order.getId());
}
OrderAmountBreakdown amountBreakdown =
breakdown != null ? breakdown : service.fallbackBreakdown(paymentInfo);
return OrderPlacementResult.of(order, amountBreakdown);
}
protected abstract OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context);
}

View File

@@ -6,10 +6,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
@@ -25,8 +27,11 @@ 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;
import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCompletionContext; import com.starry.admin.modules.order.module.dto.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
@@ -37,15 +42,24 @@ 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.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.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponDiscountType;
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.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -56,6 +70,7 @@ import org.springframework.transaction.annotation.Transactional;
public class OrderLifecycleServiceImpl implements IOrderLifecycleService { public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken(); private static final LifecycleToken LIFECYCLE_TOKEN = new LifecycleToken();
private static final int MONEY_SCALE = 2;
private enum LifecycleOperation { private enum LifecycleOperation {
CREATE, CREATE,
@@ -81,21 +96,91 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private IPlayCouponDetailsService playCouponDetailsService; private IPlayCouponDetailsService playCouponDetailsService;
@Resource
private IPlayCouponInfoService playCouponInfoService;
@Resource @Resource
private ClerkRevenueCalculator clerkRevenueCalculator; private ClerkRevenueCalculator clerkRevenueCalculator;
@Resource @Resource
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
private Map<StrategyKey, OrderPlacementStrategy> placementStrategies;
@PostConstruct
void initPlacementStrategies() {
Map<StrategyKey, OrderPlacementStrategy> strategies = new HashMap<>();
OrderPlacementStrategy specifiedStrategy = new ServiceOrderPlacementStrategy(this, OrderConstant.PlaceType.SPECIFIED);
registerStrategy(strategies, OrderConstant.PlaceType.SPECIFIED, OrderConstant.RewardType.NOT_APPLICABLE, specifiedStrategy);
OrderPlacementStrategy randomStrategy = new ServiceOrderPlacementStrategy(this, OrderConstant.PlaceType.RANDOM);
registerStrategy(strategies, OrderConstant.PlaceType.RANDOM, OrderConstant.RewardType.NOT_APPLICABLE, randomStrategy);
OrderPlacementStrategy otherStrategy = new OtherOrderPlacementStrategy(this);
registerStrategy(strategies, OrderConstant.PlaceType.OTHER, OrderConstant.RewardType.NOT_APPLICABLE, otherStrategy);
OrderPlacementStrategy rewardGiftStrategy = new RewardGiftOrderPlacementStrategy(this);
registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.GIFT, rewardGiftStrategy);
OrderPlacementStrategy rewardTipStrategy = new RewardTipOrderPlacementStrategy(this);
registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.BALANCE, rewardTipStrategy);
registerStrategy(strategies, OrderConstant.PlaceType.REWARD, OrderConstant.RewardType.NOT_APPLICABLE, rewardTipStrategy);
this.placementStrategies = strategies;
}
private void registerStrategy(
Map<StrategyKey, OrderPlacementStrategy> strategies,
OrderConstant.PlaceType placeType,
OrderConstant.RewardType rewardType,
OrderPlacementStrategy strategy) {
StrategyKey key = StrategyKey.of(placeType, rewardType);
if (strategies.containsKey(key)) {
throw new IllegalStateException(String.format("Duplicate order placement strategy for %s/%s", placeType, rewardType));
}
strategies.put(key, strategy);
}
private boolean isRewardScenario(OrderCreationContext context) {
return context.getPlaceType() == OrderConstant.PlaceType.REWARD;
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public PlayOrderInfoEntity initiateOrder(OrderCreationContext context) { public OrderPlacementResult placeOrder(OrderPlacementCommand command) {
if (command == null || command.getOrderContext() == null) {
throw new CustomException("下单参数不能为空");
}
OrderCreationContext context = command.getOrderContext();
OrderConstant.PlaceType placeType = context.getPlaceType();
log.info("placeOrder placeType={} rewardFlag? {}", placeType, isRewardScenario(context));
if (placeType == null) {
throw new CustomException("下单类型不能为空");
}
OrderConstant.RewardType rewardType = context.getRewardType() != null
? context.getRewardType()
: OrderConstant.RewardType.NOT_APPLICABLE;
OrderPlacementStrategy strategy = placementStrategies.get(StrategyKey.of(placeType, rewardType));
if (strategy == null && rewardType != OrderConstant.RewardType.NOT_APPLICABLE) {
strategy = placementStrategies.get(StrategyKey.of(placeType, OrderConstant.RewardType.NOT_APPLICABLE));
}
if (strategy == null) {
throw new CustomException("暂不支持该类型的下单逻辑");
}
return strategy.place(command);
}
PlayOrderInfoEntity createOrderRecord(OrderCreationContext context) {
validateOrderCreationRequest(context); validateOrderCreationRequest(context);
PlayOrderInfoEntity entity = buildOrderEntity(context); PlayOrderInfoEntity entity = buildOrderEntity(context);
applyRandomOrderRequirements(entity, context.getRandomOrderRequirements()); applyRandomOrderRequirements(entity, context.getRandomOrderRequirements());
applyAcceptByInfo(entity, context); applyAcceptByInfo(entity, context);
if (context.isRewardOrder()) { boolean rewardScenario = isRewardScenario(context);
if (rewardScenario) {
applyRewardOrderDefaults(entity); applyRewardOrderDefaults(entity);
} }
@@ -111,7 +196,7 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
creationOperationType); creationOperationType);
updateCouponUsage(context.getPaymentInfo().getCouponIds()); updateCouponUsage(context.getPaymentInfo().getCouponIds());
if (context.isRewardOrder() && StrUtil.isNotBlank(context.getAcceptBy())) { if (rewardScenario && StrUtil.isNotBlank(context.getAcceptBy())) {
completeOrder( completeOrder(
entity.getId(), entity.getId(),
OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER) OrderCompletionContext.of(OrderActor.SYSTEM, null, OrderTriggerSource.REWARD_ORDER)
@@ -121,6 +206,162 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return entity; return entity;
} }
OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) {
int quantity = pricingInput.getQuantity();
if (quantity <= 0) {
throw new CustomException("数量不能为空");
}
BigDecimal normalizedUnitPrice = normalizeMoney(pricingInput.getUnitPrice());
BigDecimal grossAmount = normalizeMoney(normalizedUnitPrice.multiply(BigDecimal.valueOf(quantity)));
List<String> couponIds = pricingInput.getCouponIds();
if (CollectionUtil.isEmpty(couponIds)) {
return OrderAmountBreakdown.of(grossAmount, BigDecimal.ZERO, grossAmount);
}
BigDecimal discountTotal = BigDecimal.ZERO;
for (String couponId : couponIds) {
PlayCouponDetailsEntity couponDetails = playCouponDetailsService.getById(couponId);
if (couponDetails == null) {
throw new CustomException("优惠券不存在");
}
PlayCouponInfoEntity couponInfo = playCouponInfoService.selectPlayCouponInfoById(couponDetails.getCouponId());
String reason = playCouponInfoService.getCouponReasonForUnavailableUse(
couponInfo,
pricingInput.getPlaceType().getCode(),
pricingInput.getCommodityId(),
quantity,
normalizedUnitPrice);
if (StrUtil.isNotBlank(reason)) {
throw new CustomException("优惠券不可用 - " + reason);
}
CouponDiscountType discountType;
try {
discountType = CouponDiscountType.fromCode(couponInfo.getDiscountType());
} catch (CustomException ex) {
log.warn("Unsupported coupon discount type {}, couponId={}", couponInfo.getDiscountType(), couponInfo.getId());
continue;
}
switch (discountType) {
case FULL_REDUCTION:
discountTotal = discountTotal.add(normalizeMoney(couponInfo.getDiscountAmount()));
break;
case PERCENTAGE:
discountTotal = discountTotal.add(calculatePercentageDiscount(grossAmount, couponInfo.getDiscountAmount(), couponInfo.getId()));
break;
default:
break;
}
}
BigDecimal discountAmount = normalizeMoney(discountTotal.min(grossAmount));
BigDecimal netAmount = normalizeMoney(grossAmount.subtract(discountAmount));
return OrderAmountBreakdown.of(grossAmount, discountAmount, netAmount);
}
void deductCustomerBalance(
String customerId,
BigDecimal netAmount,
String operationAction,
String orderId) {
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(customerId);
if (customer == null) {
throw new CustomException("顾客不存在");
}
BigDecimal before = normalizeMoney(customer.getAccountBalance());
if (netAmount.compareTo(before) > 0) {
throw new ServiceException("余额不足", 998);
}
BigDecimal after = normalizeMoney(before.subtract(netAmount));
String action = StrUtil.isNotBlank(operationAction) ? operationAction : "下单";
customUserInfoService.updateAccountBalanceById(
customerId,
before,
after,
BalanceOperationType.CONSUME.getCode(),
action,
netAmount,
BigDecimal.ZERO,
orderId);
}
OrderAmountBreakdown fallbackBreakdown(PaymentInfo paymentInfo) {
return OrderAmountBreakdown.of(
normalizeMoney(paymentInfo.getOrderMoney()),
normalizeMoney(paymentInfo.getDiscountAmount()),
normalizeMoney(paymentInfo.getFinalAmount()));
}
private static final class StrategyKey {
private final OrderConstant.PlaceType placeType;
private final OrderConstant.RewardType rewardType;
private StrategyKey(OrderConstant.PlaceType placeType, OrderConstant.RewardType rewardType) {
this.placeType = placeType;
this.rewardType = rewardType;
}
static StrategyKey of(OrderConstant.PlaceType placeType, OrderConstant.RewardType rewardType) {
return new StrategyKey(placeType, rewardType);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof StrategyKey)) {
return false;
}
StrategyKey that = (StrategyKey) o;
return placeType == that.placeType && rewardType == that.rewardType;
}
@Override
public int hashCode() {
return Objects.hash(placeType, rewardType);
}
}
BigDecimal calculatePercentageDiscount(BigDecimal grossAmount, BigDecimal discountFold, String couponId) {
if (discountFold == null) {
log.warn("Percentage coupon has no discount amount configured, couponId={}", couponId);
return BigDecimal.ZERO;
}
BigDecimal normalizedFold = discountFold.abs();
// REVIEW THIS: WHY NORMALIZE this if < 10?
if (normalizedFold.compareTo(BigDecimal.TEN) > 0) {
return normalizeMoney(grossAmount);
}
BigDecimal rate;
if (normalizedFold.compareTo(BigDecimal.ONE) <= 0) {
rate = normalizedFold;
} else if (normalizedFold.compareTo(BigDecimal.TEN) <= 0) {
rate = normalizedFold.divide(BigDecimal.TEN, MONEY_SCALE + 2, RoundingMode.HALF_UP);
} else if (normalizedFold.compareTo(BigDecimal.valueOf(100)) <= 0) {
rate = normalizedFold.divide(BigDecimal.valueOf(100), MONEY_SCALE + 2, RoundingMode.HALF_UP);
} else {
log.warn("Percentage coupon rate out of range, couponId={}, value={}", couponId, discountFold);
return BigDecimal.ZERO;
}
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
log.warn("Percentage coupon rate <= 0, couponId={}", couponId);
return BigDecimal.ZERO;
}
if (rate.compareTo(BigDecimal.ONE) > 0) {
log.warn("Percentage coupon rate > 1 after normalisation, couponId={}", couponId);
rate = BigDecimal.ONE;
}
BigDecimal discount = grossAmount.multiply(BigDecimal.ONE.subtract(rate));
return normalizeMoney(discount.max(BigDecimal.ZERO));
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void completeOrder(String orderId, OrderCompletionContext context) { public void completeOrder(String orderId, OrderCompletionContext context) {
@@ -395,6 +636,13 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
playCouponDetailsService.updateCouponUseStateByIds(couponIds, CouponUseState.USED.getCode()); playCouponDetailsService.updateCouponUseStateByIds(couponIds, CouponUseState.USED.getCode());
} }
BigDecimal normalizeMoney(BigDecimal amount) {
if (amount == null) {
return BigDecimal.ZERO.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
return amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
private String resolvePayMethod(String payMethodCode) { private String resolvePayMethod(String payMethodCode) {
if (StrUtil.isBlank(payMethodCode)) { if (StrUtil.isBlank(payMethodCode)) {
return PayMethod.BALANCE.getCode(); return PayMethod.BALANCE.getCode();

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
interface OrderPlacementStrategy {
OrderPlacementResult place(OrderPlacementCommand command);
}

View File

@@ -0,0 +1,30 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
OtherOrderPlacementStrategy(OrderLifecycleServiceImpl service) {
super(service);
}
@Override
protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) {
PaymentInfo info = context.getPaymentInfo();
if (info == null) {
throw new CustomException("支付信息不能为空");
}
context.setPaymentInfo(PaymentInfo.builder()
.orderMoney(service.normalizeMoney(info.getOrderMoney()))
.discountAmount(service.normalizeMoney(info.getDiscountAmount()))
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds())
.payMethod(info.getPayMethod())
.build());
return null;
}
}

View File

@@ -0,0 +1,41 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
RewardGiftOrderPlacementStrategy(OrderLifecycleServiceImpl service) {
super(service);
}
@Override
protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) {
if (context.getRewardType() != OrderConstant.RewardType.GIFT) {
throw new CustomException("礼物订单类型不匹配");
}
PaymentInfo normalized = ensurePaymentInfoPresent(context);
return service.fallbackBreakdown(normalized);
}
private PaymentInfo ensurePaymentInfoPresent(OrderCreationContext context) {
PaymentInfo info = context.getPaymentInfo();
if (info == null) {
throw new CustomException("支付信息不能为空");
}
PaymentInfo normalized = PaymentInfo.builder()
.orderMoney(service.normalizeMoney(info.getOrderMoney()))
.discountAmount(service.normalizeMoney(info.getDiscountAmount()))
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds())
.payMethod(info.getPayMethod())
.build();
context.setPaymentInfo(normalized);
return normalized;
}
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
RewardTipOrderPlacementStrategy(OrderLifecycleServiceImpl service) {
super(service);
}
@Override
protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) {
if (context.getRewardType() == OrderConstant.RewardType.GIFT) {
throw new CustomException("礼物订单应使用礼物策略");
}
PaymentInfo normalized = ensurePaymentInfoPresent(context);
return service.fallbackBreakdown(normalized);
}
private PaymentInfo ensurePaymentInfoPresent(OrderCreationContext context) {
PaymentInfo info = context.getPaymentInfo();
if (info == null) {
throw new CustomException("支付信息不能为空");
}
PaymentInfo normalized = PaymentInfo.builder()
.orderMoney(service.normalizeMoney(info.getOrderMoney()))
.discountAmount(service.normalizeMoney(info.getDiscountAmount()))
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds())
.payMethod(info.getPayMethod())
.build();
context.setPaymentInfo(normalized);
return normalized;
}
}

View File

@@ -0,0 +1,57 @@
package com.starry.admin.modules.order.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.OrderAmountBreakdown;
import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
class ServiceOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
private final OrderConstant.PlaceType placeType;
ServiceOrderPlacementStrategy(OrderLifecycleServiceImpl service, OrderConstant.PlaceType placeType) {
super(service);
this.placeType = placeType;
}
@Override
protected OrderAmountBreakdown handlePricing(OrderPlacementCommand command, OrderCreationContext context) {
if (context.getPlaceType() != placeType) {
throw new CustomException("下单类型与服务计价策略不匹配");
}
OrderPlacementCommand.PricingInput pricingInput = command.getPricingInput();
if (pricingInput == null) {
PaymentInfo info = context.getPaymentInfo();
if (info == null) {
throw new CustomException("支付信息不能为空");
}
PaymentInfo normalized = PaymentInfo.builder()
.orderMoney(service.normalizeMoney(info.getOrderMoney()))
.discountAmount(service.normalizeMoney(info.getDiscountAmount()))
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds())
.payMethod(info.getPayMethod())
.build();
context.setPaymentInfo(normalized);
return service.fallbackBreakdown(normalized);
}
if (pricingInput.getPlaceType() != placeType) {
throw new CustomException("计价参数的下单类型不匹配");
}
OrderAmountBreakdown breakdown = service.calculateOrderAmounts(pricingInput);
PaymentInfo currentInfo = context.getPaymentInfo();
PaymentInfo updated = PaymentInfo.builder()
.orderMoney(breakdown.getGrossAmount())
.discountAmount(breakdown.getDiscountAmount())
.finalAmount(breakdown.getNetAmount())
.couponIds(pricingInput.getCouponIds())
.payMethod(currentInfo != null ? currentInfo.getPayMethod() : null)
.build();
context.setPaymentInfo(updated);
return breakdown;
}
}

View File

@@ -0,0 +1,36 @@
package com.starry.admin.modules.shop.module.enums;
import com.starry.admin.common.exception.CustomException;
import lombok.Getter;
/**
* 优惠券折扣类型枚举。
*/
@Getter
public enum CouponDiscountType {
/**
* 满减券(固定金额抵扣)。
*/
FULL_REDUCTION("0"),
/**
* 折扣券(按比例折扣)。暂未支持具体计算逻辑。
*/
PERCENTAGE("1");
private final String code;
CouponDiscountType(String code) {
this.code = code;
}
public static CouponDiscountType fromCode(String code) {
for (CouponDiscountType value : values()) {
if (value.code.equals(code)) {
return value;
}
}
throw new CustomException("不支持的优惠券折扣类型: " + code);
}
}

View File

@@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.aspect.CustomUserLogin; import com.starry.admin.common.aspect.CustomUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.common.task.OverdueOrderHandlerTask; import com.starry.admin.common.task.OverdueOrderHandlerTask;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
@@ -23,6 +22,8 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
@@ -32,8 +33,6 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService; import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService;
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
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.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.service.*; import com.starry.admin.modules.shop.service.*;
import com.starry.admin.modules.weichat.entity.*; import com.starry.admin.modules.weichat.entity.*;
@@ -63,12 +62,13 @@ import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -230,10 +230,6 @@ public class WxCustomController {
public R rewardToOrder(@ApiParam(value = "打赏信息", required = true) @Validated @RequestBody PlayOrderInfoRewardAdd vo) { public R rewardToOrder(@ApiParam(value = "打赏信息", required = true) @Validated @RequestBody PlayOrderInfoRewardAdd vo) {
MoneyUtils.verificationTypeIsNormal(vo.getMoney()); MoneyUtils.verificationTypeIsNormal(vo.getMoney());
String userId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); String userId = ThreadLocalRequestDetail.getCustomUserInfo().getId();
PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(userId);
if (new BigDecimal(vo.getMoney()).compareTo(customUserInfo.getAccountBalance()) > 0) {
throw new ServiceException("余额不足", 998);
}
String orderId = IdUtils.getUuid(); String orderId = IdUtils.getUuid();
// 记录订单信息 // 记录订单信息
OrderCreationContext orderRequest = OrderCreationContext.builder() OrderCreationContext orderRequest = OrderCreationContext.builder()
@@ -265,9 +261,10 @@ public class WxCustomController {
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark()) .remark(vo.getRemark())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
// 顾客减少余额 .orderContext(orderRequest)
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(new BigDecimal(vo.getMoney())), "1", "打赏", new BigDecimal(vo.getMoney()), BigDecimal.ZERO, orderId); .balanceOperationAction("打赏")
.build());
return R.ok("成功"); return R.ok("成功");
} }
@@ -301,34 +298,6 @@ public class WxCustomController {
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), clerkUserInfo.getLevelId()); PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), clerkUserInfo.getLevelId());
BigDecimal couponMoney = BigDecimal.ZERO;
for (String couponId : vo.getCouponIds()) {
PlayCouponDetailsEntity couponDetailsEntity = couponDetailsService.getById(couponId);
if (Objects.isNull(couponDetailsEntity)) {
throw new CustomException("优惠券不存在");
}
PlayCouponInfoEntity couponInfo = playCouponInfoService.selectPlayCouponInfoById(couponDetailsEntity.getCouponId());
String couponReasonForUnavailableUse = playCouponInfoService.getCouponReasonForUnavailableUse(couponInfo, "0", vo.getCommodityId(), vo.getCommodityQuantity(), commodityInfo.getCommodityPrice());
if (StrUtil.isNotBlank(couponReasonForUnavailableUse)) {
throw new CustomException("优惠券不可用");
}
if (couponInfo.getDiscountType().equals("0")) {
couponMoney = couponMoney.add(couponInfo.getDiscountAmount());
}
}
PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(customId);
BigDecimal money = commodityInfo.getCommodityPrice().multiply(new BigDecimal(vo.getCommodityQuantity()));
money = money.subtract(couponMoney);
if (money.compareTo(BigDecimal.ZERO) < 1) {
money = BigDecimal.ZERO;
}
if (money.compareTo(customUserInfo.getAccountBalance()) > 0) {
throw new ServiceException("余额不足", 998);
}
String orderId = IdUtils.getUuid(); String orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo(); String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息 // 记录订单信息
@@ -351,21 +320,39 @@ public class WxCustomController {
.commodityNumber(String.valueOf(vo.getCommodityQuantity())) .commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build()) .build())
.paymentInfo(PaymentInfo.builder() .paymentInfo(PaymentInfo.builder()
.orderMoney(money) .orderMoney(BigDecimal.ZERO)
.finalAmount(money) .finalAmount(BigDecimal.ZERO)
.discountAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds()) .couponIds(Collections.emptyList())
.build()) .build())
.purchaserBy(customId) .purchaserBy(customId)
.acceptBy(clerkUserInfo.getId()) .acceptBy(clerkUserInfo.getId())
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark()) .remark(vo.getRemark())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); List<String> couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds();
// 顾客减少余额 OrderPlacementCommand command = OrderPlacementCommand.builder()
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-指定单", money, BigDecimal.ZERO, orderId); .orderContext(orderRequest)
.balanceOperationAction("下单-指定单")
.pricingInput(OrderPlacementCommand.PricingInput.builder()
.unitPrice(commodityInfo.getCommodityPrice())
.quantity(vo.getCommodityQuantity())
.couponIds(couponIds)
.commodityId(commodityInfo.getCommodityId())
.placeType(OrderConstant.PlaceType.SPECIFIED)
.build())
.build();
OrderPlacementResult result = orderLifecycleService.placeOrder(command);
PlayOrderInfoEntity order = result.getOrder();
BigDecimal netAmount = result.getAmountBreakdown().getNetAmount();
// 发送通知给店员 // 发送通知给店员
wxCustomMpService.sendCreateOrderMessage(clerkUserInfo.getTenantId(), clerkUserInfo.getOpenid(), orderNo, money.toString(), commodityInfo.getCommodityName(),orderId ); wxCustomMpService.sendCreateOrderMessage(
clerkUserInfo.getTenantId(),
clerkUserInfo.getOpenid(),
orderNo,
netAmount.toString(),
commodityInfo.getCommodityName(),
order.getId());
return R.ok("成功"); return R.ok("成功");
} }
@@ -380,12 +367,7 @@ public class WxCustomController {
public R randomToOrdder(@Validated @RequestBody PlayOrderInfoRandomAdd vo) { public R randomToOrdder(@Validated @RequestBody PlayOrderInfoRandomAdd vo) {
// 验证当前顾客余额是否充足 // 验证当前顾客余额是否充足
String customId = ThreadLocalRequestDetail.getCustomUserInfo().getId(); String customId = ThreadLocalRequestDetail.getCustomUserInfo().getId();
PlayCustomUserInfoEntity customUserInfo = customUserInfoService.selectById(customId);
PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), vo.getLevelId()); PlayCommodityInfoVo commodityInfo = playCommodityInfoService.queryCommodityInfo(vo.getCommodityId(), vo.getLevelId());
BigDecimal money = commodityInfo.getCommodityPrice().multiply(new BigDecimal(vo.getCommodityQuantity()));
if (money.compareTo(customUserInfo.getAccountBalance()) > 0) {
throw new ServiceException("余额不足", 998);
}
String orderId = IdUtils.getUuid(); String orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo(); String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息 // 记录订单信息
@@ -408,10 +390,10 @@ public class WxCustomController {
.commodityNumber(String.valueOf(vo.getCommodityQuantity())) .commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build()) .build())
.paymentInfo(PaymentInfo.builder() .paymentInfo(PaymentInfo.builder()
.orderMoney(money) .orderMoney(BigDecimal.ZERO)
.finalAmount(money) .finalAmount(BigDecimal.ZERO)
.discountAmount(BigDecimal.ZERO) .discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds()) .couponIds(Collections.emptyList())
.build()) .build())
.purchaserBy(customId) .purchaserBy(customId)
.weiChatCode(vo.getWeiChatCode()) .weiChatCode(vo.getWeiChatCode())
@@ -422,13 +404,28 @@ public class WxCustomController {
.excludeHistory(vo.getExcludeHistory()) .excludeHistory(vo.getExcludeHistory())
.build()) .build())
.build(); .build();
orderLifecycleService.initiateOrder(orderRequest); List<String> couponIds = CollectionUtils.isEmpty(vo.getCouponIds()) ? Collections.emptyList() : vo.getCouponIds();
// 顾客减少余额 OrderPlacementCommand command = OrderPlacementCommand.builder()
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-随机单", money, BigDecimal.ZERO, orderId); .orderContext(orderRequest)
.balanceOperationAction("下单-随机单")
.pricingInput(OrderPlacementCommand.PricingInput.builder()
.unitPrice(commodityInfo.getCommodityPrice())
.quantity(vo.getCommodityQuantity())
.couponIds(couponIds)
.commodityId(commodityInfo.getCommodityId())
.placeType(OrderConstant.PlaceType.RANDOM)
.build())
.build();
OrderPlacementResult result = orderLifecycleService.placeOrder(command);
PlayOrderInfoEntity order = result.getOrder();
BigDecimal netAmount = result.getAmountBreakdown().getNetAmount();
// 给全部店员发送通知 // 给全部店员发送通知
List<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class).isNotNull(PlayClerkUserInfoEntity::getOpenid).eq(PlayClerkUserInfoEntity::getClerkState, "1") List<PlayClerkUserInfoEntity> clerkList = clerkUserInfoService.list(Wrappers.lambdaQuery(PlayClerkUserInfoEntity.class)
.eq(PlayClerkUserInfoEntity::getOnlineState, "1").eq(PlayClerkUserInfoEntity::getSex, vo.getSex())); .isNotNull(PlayClerkUserInfoEntity::getOpenid)
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, money.toString(), commodityInfo.getCommodityName(),orderId); .eq(PlayClerkUserInfoEntity::getClerkState, "1")
.eq(PlayClerkUserInfoEntity::getOnlineState, "1")
.eq(PlayClerkUserInfoEntity::getSex, vo.getSex()));
wxCustomMpService.sendCreateOrderMessageBatch(clerkList, orderNo, netAmount.toString(), commodityInfo.getCommodityName(),order.getId());
// 记录订单,指定指定未接单后,进行退款处理 // 记录订单,指定指定未接单后,进行退款处理
overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId()); overdueOrderHandlerTask.enqueue(orderId + "_" + SecurityUtils.getTenantId());
// 下单成功后,先根据用户条件进行随机分配 // 下单成功后,先根据用户条件进行随机分配

View File

@@ -2,7 +2,6 @@ package com.starry.admin.modules.weichat.service;
import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService; import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
@@ -11,7 +10,10 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType; import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
@@ -79,27 +81,21 @@ public class WxGiftOrderService {
throw new CustomException("礼物金额必须大于0"); throw new CustomException("礼物金额必须大于0");
} }
BigDecimal currentBalance = customUserInfo.getAccountBalance() == null
? BigDecimal.ZERO : customUserInfo.getAccountBalance();
if (totalAmount.compareTo(currentBalance) > 0) {
throw new ServiceException("余额不足", 998);
}
String orderId = IdUtils.getUuid(); String orderId = IdUtils.getUuid();
OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(), OrderCreationContext orderRequest = buildOrderCreationContext(orderId, request, sessionUser.getId(),
giftInfo, totalAmount); giftInfo, totalAmount);
orderLifecycleService.initiateOrder(orderRequest); OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
.orderContext(orderRequest)
BigDecimal newBalance = currentBalance.subtract(totalAmount); .balanceOperationAction("赠送礼物")
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), currentBalance, newBalance, "1", .build());
"赠送礼物", totalAmount, BigDecimal.ZERO, orderId); PlayOrderInfoEntity order = result.getOrder();
String tenantId = customUserInfo.getTenantId(); String tenantId = customUserInfo.getTenantId();
long delta = giftQuantity; long delta = giftQuantity;
playCustomGiftInfoService.incrementGiftCount(customUserInfo.getId(), request.getGiftId(), tenantId, delta); playCustomGiftInfoService.incrementGiftCount(customUserInfo.getId(), request.getGiftId(), tenantId, delta);
playClerkGiftInfoService.incrementGiftCount(request.getClerkId(), request.getGiftId(), tenantId, delta); playClerkGiftInfoService.incrementGiftCount(request.getClerkId(), request.getGiftId(), tenantId, delta);
return orderId; return order.getId();
} }
private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request, private OrderCreationContext buildOrderCreationContext(String orderId, PlayOrderInfoGiftAdd request,

View File

@@ -3,6 +3,8 @@ package com.starry.admin.modules.order.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
@@ -10,6 +12,7 @@ import static org.mockito.Mockito.*;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
@@ -33,6 +36,8 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.YesNoFlag;
import com.starry.admin.modules.order.module.dto.CommodityInfo; import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCompletionContext; import com.starry.admin.modules.order.module.dto.OrderCompletionContext;
import com.starry.admin.modules.order.module.dto.OrderCreationContext; import com.starry.admin.modules.order.module.dto.OrderCreationContext;
import com.starry.admin.modules.order.module.dto.OrderPlacementCommand;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import com.starry.admin.modules.order.module.dto.OrderRefundContext; import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
@@ -41,7 +46,10 @@ 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.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.PlayCouponInfoEntity;
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.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -49,7 +57,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -81,14 +91,65 @@ class OrderLifecycleServiceImplTest {
@Mock @Mock
private IPlayCouponDetailsService playCouponDetailsService; private IPlayCouponDetailsService playCouponDetailsService;
@Mock
private IPlayCouponInfoService playCouponInfoService;
@Mock @Mock
private ClerkRevenueCalculator clerkRevenueCalculator; private ClerkRevenueCalculator clerkRevenueCalculator;
@Mock @Mock
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
@BeforeEach
void initStrategies() {
lifecycleService.initPlacementStrategies();
}
@Test @Test
void initiateOrder_specifiedOrder_persistsAndUpdatesCoupon() { void placeOrder_throwsWhenCommandNull() {
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(null));
}
@Test
void placeOrder_throwsWhenContextNull() {
OrderPlacementCommand command = mock(OrderPlacementCommand.class);
when(command.getOrderContext()).thenReturn(null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_throwsWhenPaymentInfoMissing() {
OrderCreationContext context = OrderCreationContext.builder()
.orderId("ctx-001")
.orderNo("NO-001")
.orderStatus(OrderStatus.PENDING)
.orderType(OrderType.NORMAL)
.placeType(PlaceType.OTHER)
.rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity")
.commodityType(CommodityType.SERVICE)
.commodityPrice(BigDecimal.TEN)
.serviceDuration("60")
.commodityName("服务")
.commodityNumber("1")
.build())
.paymentInfo(null)
.purchaserBy("customer-1")
.build();
OrderPlacementCommand command = OrderPlacementCommand.builder()
.orderContext(context)
.deductBalance(false)
.build();
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedOrder_persistsAndUpdatesCoupon() {
OrderCreationContext request = OrderCreationContext.builder() OrderCreationContext request = OrderCreationContext.builder()
.orderId("order-init-001") .orderId("order-init-001")
.orderNo("NO20241001") .orderNo("NO20241001")
@@ -118,6 +179,18 @@ class OrderLifecycleServiceImplTest {
.remark("备注") .remark("备注")
.build(); .build();
OrderPlacementCommand command = OrderPlacementCommand.builder()
.orderContext(request)
.deductBalance(false)
.pricingInput(OrderPlacementCommand.PricingInput.builder()
.unitPrice(BigDecimal.valueOf(199))
.quantity(1)
.couponIds(request.getPaymentInfo().getCouponIds())
.commodityId("commodity-01")
.placeType(PlaceType.SPECIFIED)
.build())
.build();
ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo(); ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
revenueVo.setRevenueAmount(BigDecimal.valueOf(89.5)); revenueVo.setRevenueAmount(BigDecimal.valueOf(89.5));
revenueVo.setRevenueRatio(50); revenueVo.setRevenueRatio(50);
@@ -129,7 +202,23 @@ class OrderLifecycleServiceImplTest {
doNothing().when(customUserInfoService).saveOrderInfo(any()); doNothing().when(customUserInfoService).saveOrderInfo(any());
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString()); doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());
PlayOrderInfoEntity created = lifecycleService.initiateOrder(request); PlayCouponDetailsEntity couponDetails = new PlayCouponDetailsEntity();
couponDetails.setCouponId("coupon-master");
when(playCouponDetailsService.getById("coupon-1")).thenReturn(couponDetails);
PlayCouponInfoEntity couponInfo = new PlayCouponInfoEntity();
couponInfo.setDiscountType("0");
couponInfo.setDiscountAmount(BigDecimal.valueOf(20));
when(playCouponInfoService.selectPlayCouponInfoById("coupon-master")).thenReturn(couponInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(
eq(couponInfo),
eq(PlaceType.SPECIFIED.getCode()),
eq("commodity-01"),
eq(1),
any())).thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command);
PlayOrderInfoEntity created = result.getOrder();
verify(customUserInfoService).saveOrderInfo(created); verify(customUserInfoService).saveOrderInfo(created);
verify(orderInfoMapper).insert(created); verify(orderInfoMapper).insert(created);
@@ -148,6 +237,740 @@ class OrderLifecycleServiceImplTest {
assertEquals(PayMethod.WECHAT.getCode(), created.getPayMethod()); assertEquals(PayMethod.WECHAT.getCode(), created.getPayMethod());
} }
@Test
void placeOrder_otherType_skipsPricingAndUsesExistingAmounts() {
PaymentInfo payment = payment(BigDecimal.valueOf(50), BigDecimal.valueOf(50), BigDecimal.ZERO, Collections.emptyList());
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));
assertMoneyEquals("50.00", result.getAmountBreakdown().getGrossAmount());
assertMoneyEquals("0.00", result.getAmountBreakdown().getDiscountAmount());
assertMoneyEquals("50.00", result.getAmountBreakdown().getNetAmount());
verify(playCouponDetailsService, never()).updateCouponUseStateByIds(anyList(), anyString());
}
@Test
void placeOrder_otherType_withCoupons_marksUsageButKeepsAmounts() {
List<String> coupons = Collections.singletonList("coupon-1");
PaymentInfo payment = payment(BigDecimal.valueOf(60), BigDecimal.valueOf(60), BigDecimal.ZERO, coupons);
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
}
@Test
void placeOrder_specifiedWithoutCoupons_calculatesTotallyFromPricing() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-001");
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(20), 2, Collections.emptyList(), "commodity-x", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("40.00", context.getPaymentInfo().getOrderMoney());
assertMoneyEquals("0.00", context.getPaymentInfo().getDiscountAmount());
assertMoneyEquals("40.00", context.getPaymentInfo().getFinalAmount());
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithFullReductionCouponReducesNetAmount() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "coupon-master", "0", BigDecimal.valueOf(15), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-123", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("15.00", result.getAmountBreakdown().getDiscountAmount());
assertMoneyEquals("15.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithMultipleCouponsCapsNetAtZero() {
List<String> coupons = Arrays.asList("coupon-1", "coupon-2");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
PlayCouponDetailsEntity firstDetail = new PlayCouponDetailsEntity();
firstDetail.setCouponId("master-1");
PlayCouponDetailsEntity secondDetail = new PlayCouponDetailsEntity();
secondDetail.setCouponId("master-2");
when(playCouponDetailsService.getById("coupon-1")).thenReturn(firstDetail);
when(playCouponDetailsService.getById("coupon-2")).thenReturn(secondDetail);
PlayCouponInfoEntity firstInfo = new PlayCouponInfoEntity();
firstInfo.setDiscountType("0");
firstInfo.setDiscountAmount(BigDecimal.valueOf(40));
PlayCouponInfoEntity secondInfo = new PlayCouponInfoEntity();
secondInfo.setDiscountType("0");
secondInfo.setDiscountAmount(BigDecimal.valueOf(30));
when(playCouponInfoService.selectPlayCouponInfoById("master-1")).thenReturn(firstInfo);
when(playCouponInfoService.selectPlayCouponInfoById("master-2")).thenReturn(secondInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(60), 1, coupons, "commodity-max", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithPercentageFold_eightyPercentApplied() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(8), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(50), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithPercentageFoldGreaterThanTenTreatsAsFree() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.valueOf(20), "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(40), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedWithNonPositivePercentageIgnoresDiscount() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "1", BigDecimal.ZERO, "");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-perc", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_specifiedThrowsWhenCouponDetailMissing() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
when(playCouponDetailsService.getById("coupon-1")).thenReturn(null);
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-missing", PlaceType.SPECIFIED),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedThrowsWhenCouponIneligible() {
List<String> coupons = Collections.singletonList("coupon-1");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
stubCoupon("coupon-1", "master-1", "0", BigDecimal.valueOf(10), "订单类型不符合");
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, coupons, "commodity-invalid", PlaceType.SPECIFIED),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_specifiedSkipsUnsupportedCouponTypeButSucceeds() {
List<String> coupons = Collections.singletonList("coupon-unsupported");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-001");
stubDefaultPersistence();
PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
detail.setCouponId("master-unsupported");
when(playCouponDetailsService.getById("coupon-unsupported")).thenReturn(detail);
PlayCouponInfoEntity info = new PlayCouponInfoEntity();
info.setDiscountType("X");
info.setDiscountAmount(BigDecimal.valueOf(999));
when(playCouponInfoService.selectPlayCouponInfoById("master-unsupported")).thenReturn(info);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(45), 1, coupons, "commodity-unsupported", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("45.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_randomWithValidRequirementsSucceeds() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.MALE)
.clerkLevelId("level-1")
.excludeHistory("0")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(35), 2, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
false,
null,
null));
assertMoneyEquals("70.00", result.getAmountBreakdown().getGrossAmount());
}
@Test
void placeOrder_randomWithoutRequirementsThrows() {
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
false,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_rewardGiftWithoutAcceptByDoesNotAutoComplete() {
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.GIFT,
payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, never()).selectById(anyString());
verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any());
verify(earningsService, never()).createFromOrder(any());
}
@Test
void placeOrder_rewardGiftWithAcceptByTriggersAutoComplete() {
PlayOrderInfoEntity inProgress = buildOrder("reward-order", OrderStatus.IN_PROGRESS.getCode());
inProgress.setAcceptBy("clerk-1");
PlayOrderInfoEntity completed = buildOrder("reward-order", OrderStatus.COMPLETED.getCode());
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.GIFT,
payment(BigDecimal.valueOf(30), BigDecimal.valueOf(30), BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-1");
stubDefaultPersistence();
when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
mockEarningsCounts(0L, 1L);
doNothing().when(customUserInfoService).handleOrderCompletion(any());
doNothing().when(earningsService).createFromOrder(any());
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
verify(customUserInfoService).handleOrderCompletion(any());
verify(earningsService).createFromOrder(completed);
verify(wxCustomMpService).sendOrderFinishMessageAsync(completed);
}
@Test
void placeOrder_rewardTipWithoutAcceptByDoesNotAutoComplete() {
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.BALANCE,
payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
null,
null);
stubDefaultPersistence();
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, never()).selectById(anyString());
verify(wxCustomMpService, never()).sendOrderFinishMessageAsync(any());
verify(earningsService, never()).createFromOrder(any());
}
@Test
void placeOrder_rewardTipWithAcceptByTriggersAutoComplete() {
PlayOrderInfoEntity inProgress = buildOrder("tip-reward", OrderStatus.IN_PROGRESS.getCode());
inProgress.setAcceptBy("clerk-3");
PlayOrderInfoEntity completed = buildOrder("tip-reward", OrderStatus.COMPLETED.getCode());
OrderCreationContext context = baseContext(
PlaceType.REWARD,
RewardType.BALANCE,
payment(BigDecimal.valueOf(40), BigDecimal.valueOf(40), BigDecimal.ZERO, Collections.emptyList()),
null,
"clerk-3");
stubDefaultPersistence();
when(orderInfoMapper.selectById(anyString())).thenReturn(inProgress, completed);
when(orderInfoMapper.update(isNull(), any())).thenReturn(1);
mockEarningsCounts(0L, 1L);
doNothing().when(customUserInfoService).handleOrderCompletion(any());
doNothing().when(earningsService).createFromOrder(any());
lifecycleService.placeOrder(command(context, null, false, null, null));
verify(orderInfoMapper, atLeastOnce()).selectById(anyString());
verify(customUserInfoService).handleOrderCompletion(any());
verify(earningsService).createFromOrder(completed);
verify(wxCustomMpService).sendOrderFinishMessageAsync(completed);
}
@Test
void placeOrder_withBalanceDeductionAndSufficientFundsUpdatesBalance() {
List<String> coupons = Collections.emptyList();
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-balance");
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(100));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(20), 2, coupons, "commodity-balance", PlaceType.SPECIFIED),
true,
null,
null));
assertMoneyEquals("40.00", result.getAmountBreakdown().getNetAmount());
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance().subtract(new BigDecimal("40.00"))),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
eq(new BigDecimal("40.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withBalanceDeductionUsesCustomAction() {
List<String> coupons = Collections.emptyList();
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(50));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(10), 2, coupons, "commodity-act", PlaceType.SPECIFIED),
true,
"测试扣款",
null));
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
any(),
any(),
eq(BalanceOperationType.CONSUME.getCode()),
eq("测试扣款"),
eq(new BigDecimal("20.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withInsufficientBalanceThrows() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(20), 1, Collections.emptyList(), "commodity-insufficient", PlaceType.SPECIFIED),
true,
null,
null);
assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
verify(customUserInfoService, never()).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
}
@Test
void placeOrder_withMissingCustomerThrows() {
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(null);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(10), 1, Collections.emptyList(), "commodity-missing", PlaceType.SPECIFIED),
true,
null,
null);
assertThrows(CustomException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_noDeductionHandlesNullFinalAmount() {
PaymentInfo payment = payment(BigDecimal.valueOf(15), null, BigDecimal.ZERO, Collections.emptyList());
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment,
null,
null);
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(context, null, false, null, null));
assertMoneyEquals("0.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_deductFalseStillMarksCouponUsage() {
List<String> coupons = Collections.singletonList("coupon-flag");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
null);
stubDefaultPersistence();
stubCoupon("coupon-flag", "master-flag", "0", BigDecimal.valueOf(5), "");
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(25), 1, coupons, "commodity-flag", PlaceType.SPECIFIED),
false,
null,
null));
verify(playCouponDetailsService).updateCouponUseStateByIds(eq(coupons), eq(CouponUseState.USED.getCode()));
}
@Test
void placeOrder_randomWithBalanceDeductionSucceeds() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.FEMALE)
.clerkLevelId("level-9")
.excludeHistory("1")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(300));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(60), 2, Collections.emptyList(), "commodity-random", PlaceType.RANDOM),
true,
"随机扣款",
null));
assertMoneyEquals("120.00", result.getAmountBreakdown().getNetAmount());
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
any(),
any(),
eq(BalanceOperationType.CONSUME.getCode()),
eq("随机扣款"),
eq(new BigDecimal("120.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_randomWithDeductionThrowsWhenInsufficientBalance() {
RandomOrderRequirements requirements = RandomOrderRequirements.builder()
.clerkGender(Gender.UNKNOWN)
.clerkLevelId("level-2")
.excludeHistory("0")
.build();
OrderCreationContext context = baseContext(
PlaceType.RANDOM,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
requirements,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
stubDefaultPersistence();
OrderPlacementCommand command = command(
context,
pricing(BigDecimal.valueOf(30), 1, Collections.emptyList(), "commodity-rand", PlaceType.RANDOM),
true,
null,
null);
assertThrows(ServiceException.class, () -> lifecycleService.placeOrder(command));
}
@Test
void placeOrder_withMixedCouponTypesAppliesValidOnes() {
List<String> coupons = Arrays.asList("coupon-invalid", "coupon-valid");
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, coupons),
null,
"clerk-1");
stubDefaultPersistence();
PlayCouponDetailsEntity invalidDetail = new PlayCouponDetailsEntity();
invalidDetail.setCouponId("master-invalid");
PlayCouponDetailsEntity validDetail = new PlayCouponDetailsEntity();
validDetail.setCouponId("master-valid");
when(playCouponDetailsService.getById("coupon-invalid")).thenReturn(invalidDetail);
when(playCouponDetailsService.getById("coupon-valid")).thenReturn(validDetail);
PlayCouponInfoEntity invalidInfo = new PlayCouponInfoEntity();
invalidInfo.setDiscountType("X");
invalidInfo.setDiscountAmount(BigDecimal.valueOf(100));
PlayCouponInfoEntity validInfo = new PlayCouponInfoEntity();
validInfo.setDiscountType("0");
validInfo.setDiscountAmount(BigDecimal.valueOf(10));
when(playCouponInfoService.selectPlayCouponInfoById("master-invalid")).thenReturn(invalidInfo);
when(playCouponInfoService.selectPlayCouponInfoById("master-valid")).thenReturn(validInfo);
when(playCouponInfoService.getCouponReasonForUnavailableUse(any(), anyString(), anyString(), anyInt(), any()))
.thenReturn("");
OrderPlacementResult result = lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(35), 1, coupons, "commodity-mixed", PlaceType.SPECIFIED),
false,
null,
null));
assertMoneyEquals("25.00", result.getAmountBreakdown().getNetAmount());
}
@Test
void placeOrder_withDeductionFetchesCustomerBalance() {
OrderCreationContext context = baseContext(
PlaceType.SPECIFIED,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(70));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
pricing(BigDecimal.valueOf(15), 2, Collections.emptyList(), "commodity-snap", PlaceType.SPECIFIED),
true,
null,
null));
verify(customUserInfoService).selectById(context.getPurchaserBy());
verify(customUserInfoService).updateAccountBalanceById(
eq(customer.getId()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance().subtract(new BigDecimal("30.00"))),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
eq(new BigDecimal("30.00")),
eq(BigDecimal.ZERO),
anyString());
}
@Test
void placeOrder_withZeroFinalAmountStillUpdatesLedger() {
OrderCreationContext context = baseContext(
PlaceType.OTHER,
RewardType.NOT_APPLICABLE,
payment(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList()),
null,
null);
PlayCustomUserInfoEntity customer = customer(context.getPurchaserBy(), BigDecimal.valueOf(10));
when(customUserInfoService.selectById(context.getPurchaserBy())).thenReturn(customer);
doNothing().when(customUserInfoService).updateAccountBalanceById(anyString(), any(), any(), anyString(), anyString(), any(), any(), anyString());
stubDefaultPersistence();
lifecycleService.placeOrder(command(
context,
null,
true,
null,
null));
verify(customUserInfoService).updateAccountBalanceById(
eq(context.getPurchaserBy()),
eq(customer.getAccountBalance()),
eq(customer.getAccountBalance()),
eq(BalanceOperationType.CONSUME.getCode()),
eq("下单"),
argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
argThat(amount -> amount.compareTo(BigDecimal.ZERO) == 0),
anyString());
}
@Test @Test
void completeOrder_inProgress_createsEarningsAndNotifies() { void completeOrder_inProgress_createsEarningsAndNotifies() {
String orderId = UUID.randomUUID().toString(); String orderId = UUID.randomUUID().toString();
@@ -329,6 +1152,103 @@ class OrderLifecycleServiceImplTest {
return entity; return entity;
} }
private void stubDefaultPersistence() {
lenient().when(orderInfoMapper.selectCount(any())).thenReturn(0L);
lenient().when(orderInfoMapper.insert(any())).thenReturn(1);
lenient().doNothing().when(customUserInfoService).saveOrderInfo(any());
lenient().doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), anyString());
ClerkEstimatedRevenueVo revenueVo = new ClerkEstimatedRevenueVo();
revenueVo.setRevenueAmount(BigDecimal.ZERO);
revenueVo.setRevenueRatio(0);
lenient().when(clerkRevenueCalculator.calculateEstimatedRevenue(
anyString(), anyList(), anyString(), anyString(), any())).thenReturn(revenueVo);
}
private PaymentInfo payment(BigDecimal gross, BigDecimal net, BigDecimal discount, List<String> couponIds) {
return PaymentInfo.builder()
.orderMoney(gross)
.finalAmount(net)
.discountAmount(discount)
.couponIds(couponIds)
.build();
}
private OrderCreationContext baseContext(PlaceType placeType, RewardType rewardType, PaymentInfo paymentInfo,
RandomOrderRequirements randomRequirements, String acceptBy) {
return OrderCreationContext.builder()
.orderId(UUID.randomUUID().toString())
.orderNo("NO-" + System.nanoTime())
.orderStatus(OrderStatus.PENDING)
.orderType(OrderType.NORMAL)
.placeType(placeType)
.rewardType(rewardType)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity-" + placeType.getCode())
.commodityType(CommodityType.SERVICE)
.commodityPrice(BigDecimal.TEN)
.serviceDuration("60")
.commodityName("服务")
.commodityNumber("1")
.build())
.paymentInfo(paymentInfo)
.purchaserBy("customer-" + placeType.getCode())
.acceptBy(acceptBy)
.randomOrderRequirements(randomRequirements)
.build();
}
private OrderPlacementCommand.PricingInput pricing(BigDecimal unitPrice, int quantity, List<String> couponIds,
String commodityId, PlaceType placeType) {
return OrderPlacementCommand.PricingInput.builder()
.unitPrice(unitPrice)
.quantity(quantity)
.couponIds(couponIds)
.commodityId(commodityId)
.placeType(placeType)
.build();
}
private OrderPlacementCommand command(OrderCreationContext context,
OrderPlacementCommand.PricingInput pricing,
boolean deduct,
String action,
PlayCustomUserInfoEntity snapshot) {
OrderPlacementCommand.OrderPlacementCommandBuilder builder = OrderPlacementCommand.builder()
.orderContext(context)
.deductBalance(deduct)
.balanceOperationAction(action);
if (pricing != null) {
builder.pricingInput(pricing);
}
return builder.build();
}
private void stubCoupon(String detailId, String masterId, String discountType, BigDecimal discountAmount,
String reason) {
PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity();
detail.setCouponId(masterId);
when(playCouponDetailsService.getById(detailId)).thenReturn(detail);
PlayCouponInfoEntity info = new PlayCouponInfoEntity();
info.setDiscountType(discountType);
info.setDiscountAmount(discountAmount);
when(playCouponInfoService.selectPlayCouponInfoById(masterId)).thenReturn(info);
when(playCouponInfoService.getCouponReasonForUnavailableUse(
eq(info), anyString(), anyString(), anyInt(), any())).thenReturn(reason);
}
private PlayCustomUserInfoEntity customer(String id, BigDecimal balance) {
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(id);
entity.setAccountBalance(balance.setScale(2));
return entity;
}
private void assertMoneyEquals(String expected, BigDecimal actual) {
assertEquals(0, actual.compareTo(new BigDecimal(expected)));
}
private void mockEarningsCounts(long... counts) { private void mockEarningsCounts(long... counts) {
LambdaQueryChainWrapper<EarningsLineEntity> chain = Mockito.mock(LambdaQueryChainWrapper.class); LambdaQueryChainWrapper<EarningsLineEntity> chain = Mockito.mock(LambdaQueryChainWrapper.class);
when(chain.eq(any(), any())).thenReturn(chain); when(chain.eq(any(), any())).thenReturn(chain);