From 6b2a1c2ba77c210780369f2f71bdd71c0e6a18f7 Mon Sep 17 00:00:00 2001 From: irving Date: Mon, 27 Oct 2025 00:12:07 -0400 Subject: [PATCH] fix: correct order lifecycle refunds and add coverage --- .../controller/PlayOrderInfoController.java | 51 ++--- .../admin/modules/order/job/OrderJob.java | 15 +- .../order/module/constant/OrderConstant.java | 149 +++++++++++- .../module/dto/OrderCompletionContext.java | 69 ++++++ .../order/module/dto/OrderRefundContext.java | 44 ++++ .../module/entity/PlayOrderInfoEntity.java | 16 ++ .../order/service/IOrderLifecycleService.java | 11 + .../impl/OrderLifecycleServiceImpl.java | 195 ++++++++++++++++ .../impl/PlayOrderInfoServiceImpl.java | 157 +++++++------ .../service/PlayOrderInfoServiceTest.java | 23 +- .../impl/OrderLifecycleServiceImplTest.java | 216 ++++++++++++++++++ 11 files changed, 816 insertions(+), 130 deletions(-) create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRefundContext.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java index ab088fa..fc8dbfb 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/controller/PlayOrderInfoController.java @@ -4,12 +4,13 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; -import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; -import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant.OperatorType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +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.vo.*; +import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; -import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.weichat.service.WxCustomMpService; @@ -53,9 +54,7 @@ public class PlayOrderInfoController { private IPlayCommodityInfoService playCommodityInfoService; @Resource - private IPlayOrderRefundInfoService playOrderRefundInfoService; - @Resource - private IPlayCustomUserInfoService customUserInfoService; + private IOrderLifecycleService orderLifecycleService; @Resource private IPlayClerkUserInfoService clerkUserInfoService; @@ -89,38 +88,14 @@ public class PlayOrderInfoController { // @PreAuthorize("@customSs.hasPermission('order:order:update')") @PostMapping("/orderRefund") public R orderRefund(@ApiParam(value = "退款信息", required = true) @Validated @RequestBody PlayOrderRefundAddVo vo) { - PlayOrderInfoEntity orderInfo = orderInfoService.selectOrderInfoById(vo.getOrderId()); - if (orderInfo.getFinalAmount().compareTo(vo.getRefundAmount()) < 0) { - throw new CustomException("退款金额不能大于支付金额"); - } - if ("3".equals(orderInfo.getOrderStatus())) { - throw new CustomException("【已完成】的订单无法操作退款"); - } - if ("4".equals(orderInfo.getOrderStatus())) { - throw new CustomException("【已取消】的订单无法操作退款"); - } - if ("1".equals(orderInfo.getRefundType())) { - throw new CustomException("每个订单只能退款一次~"); - } - PlayOrderInfoEntity updateOrderInfo = new PlayOrderInfoEntity(); - updateOrderInfo.setId(orderInfo.getId()); - updateOrderInfo.setRefundType("1"); - // 订单退款,订单状态变为已取消 - updateOrderInfo.setOrderStatus("4"); - updateOrderInfo.setRefundAmount(vo.getRefundAmount()); - // 修改订单状态 - orderInfoService.update(updateOrderInfo); - // 记录退款信息 - String refundType = orderInfo.getFinalAmount().compareTo(vo.getRefundAmount()) == 0 ? "0" : "1"; - - PlayCustomUserInfoEntity customUserInfo = customUserInfoService.getById(orderInfo.getPurchaserBy()); - customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), - customUserInfo.getAccountBalance().add(orderInfo.getOrderMoney()), "3", "订单退款", - orderInfo.getOrderMoney(), BigDecimal.ZERO, vo.getOrderId()); - - playOrderRefundInfoService.add(orderInfo.getId(), orderInfo.getPurchaserBy(), orderInfo.getAcceptBy(), - orderInfo.getPayMethod(), refundType, vo.getRefundAmount(), vo.getRefundReason(), "2", - SecurityUtils.getUserId(), "0", "0"); + OrderRefundContext context = new OrderRefundContext(); + context.setOrderId(vo.getOrderId()); + context.setRefundAmount(vo.getRefundAmount()); + context.setRefundReason(vo.getRefundReason()); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setOperatorId(SecurityUtils.getUserId()); + context.withTriggerSource(OrderTriggerSource.ADMIN_API); + orderLifecycleService.refundOrder(context); return R.ok("退款成功"); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/job/OrderJob.java b/play-admin/src/main/java/com/starry/admin/modules/order/job/OrderJob.java index d4694fa..9765753 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/job/OrderJob.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/job/OrderJob.java @@ -8,9 +8,10 @@ import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +import com.starry.admin.modules.order.module.dto.OrderCompletionContext; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.weichat.service.WxCustomMpService; -import com.starry.common.redis.RedisCache; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Date; @@ -18,7 +19,6 @@ import java.util.List; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -33,6 +33,8 @@ public class OrderJob { @Resource private IPlayClerkUserInfoService clerkUserInfoService; @Resource + private IOrderLifecycleService orderLifecycleService; + @Resource public RedisTemplate redisTemplate; @@ -60,11 +62,10 @@ public class OrderJob { // 判断与开始时间相比较,如果大于服务时长,则修改订单状态为已完成 if (ca.getOrderStartTime().plusMinutes(serviceDuration).isBefore(LocalDateTime.now())) { - PlayOrderInfoEntity entity2 = new PlayOrderInfoEntity(ca.getId(), "3"); - entity2.setOrderEndTime(LocalDateTime.now()); - this.orderInfoMapper.updateById(entity2); - // 发送消息 - wxCustomMpService.sendOrderFinishMessage(ca); + orderLifecycleService.completeOrder( + ca.getId(), + OrderCompletionContext.scheduler("auto finish by duration") + .withForceNotify(true)); } } catch (Exception e) { diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java index c2a9bb9..971eb32 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java @@ -211,15 +211,142 @@ public class OrderConstant { public static final String EXCLUDE_HISTORY_NO = "0"; public static final String EXCLUDE_HISTORY_YES = "1"; - // Legacy constants for backward compatibility - consider deprecating - @Deprecated - public final static String ORDER_STATUS_0 = "0"; - @Deprecated - public final static String ORDER_STATUS_1 = "1"; - @Deprecated - public final static String ORDER_STATUS_2 = "2"; - @Deprecated - public final static String ORDER_STATUS_3 = "3"; - @Deprecated - public final static String ORDER_STATUS_4 = "4"; + @Getter + public enum OrderRefundFlag { + NOT_REFUNDED("0"), + REFUNDED("1"); + + private final String code; + + OrderRefundFlag(String code) { + this.code = code; + } + + public static OrderRefundFlag fromCode(String code) { + for (OrderRefundFlag flag : values()) { + if (flag.code.equals(code)) { + return flag; + } + } + throw new IllegalArgumentException("Unknown order refund flag code: " + code); + } + } + + @Getter + public enum OrderRefundRecordType { + FULL("0"), + PARTIAL("1"); + + private final String code; + + OrderRefundRecordType(String code) { + this.code = code; + } + + public static OrderRefundRecordType fromCode(String code) { + for (OrderRefundRecordType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + throw new IllegalArgumentException("Unknown refund record type code: " + code); + } + } + + @Getter + public enum OrderRefundState { + PROCESSING("0"), + SUCCESS("1"), + CLOSED("2"), + ABNORMAL("-1"); + + private final String code; + + OrderRefundState(String code) { + this.code = code; + } + + public static OrderRefundState fromCode(String code) { + for (OrderRefundState state : values()) { + if (state.code.equals(code)) { + return state; + } + } + throw new IllegalArgumentException("Unknown refund state code: " + code); + } + } + + @Getter + public enum ReviewRequirement { + NOT_REQUIRED("0"), + REQUIRED("1"); + + private final String code; + + ReviewRequirement(String code) { + this.code = code; + } + + public static ReviewRequirement fromCode(String code) { + for (ReviewRequirement requirement : values()) { + if (requirement.code.equals(code)) { + return requirement; + } + } + throw new IllegalArgumentException("Unknown review requirement code: " + code); + } + } + + @Getter + public enum BalanceOperationType { + RECHARGE("0"), + CONSUME("1"), + SERVICE("2"), + REFUND("3"); + + private final String code; + + BalanceOperationType(String code) { + this.code = code; + } + + public static BalanceOperationType fromCode(String code) { + for (BalanceOperationType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + throw new IllegalArgumentException("Unknown balance operation type code: " + code); + } + } + + + @Getter + public enum OrderTriggerSource { + UNKNOWN("unknown"), + MANUAL("manual"), + WX_CUSTOMER("wx_customer"), + WX_CLERK("wx_clerk"), + ADMIN_CONSOLE("admin_console"), + ADMIN_API("admin_api"), + REWARD_ORDER("reward_order"), + SCHEDULER("scheduler"), + SYSTEM("system"); + + private final String code; + + OrderTriggerSource(String code) { + this.code = code; + } + + public static OrderTriggerSource fromCode(String code) { + for (OrderTriggerSource source : values()) { + if (source.code.equals(code)) { + return source; + } + } + throw new IllegalArgumentException("Unknown order trigger source code: " + code); + } + } + } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java new file mode 100644 index 0000000..1ffa67f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCompletionContext.java @@ -0,0 +1,69 @@ +package com.starry.admin.modules.order.module.dto; + +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +import java.util.Objects; +import lombok.Data; +import org.springframework.lang.Nullable; + +/** + * 订单完成上下文信息,用于记录触发来源与操作人。 + */ +@Data +public class OrderCompletionContext { + /** + * 操作人类型(0:顾客;1:店员;2:管理员),可为空用于系统任务。 + */ + @Nullable + private String operatorType; + + /** 操作人ID,可为空用于系统任务。 */ + @Nullable + private String operatorId; + + /** 触发来源描述,例如 wx_customer、scheduler、admin_panel。 */ + private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN; + + /** 额外备注信息,可为空。 */ + @Nullable + private String comment; + + /** 是否强制发送完成通知。 */ + private boolean forceNotify; + + public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource) { + Objects.requireNonNull(triggerSource, "triggerSource cannot be null"); + OrderCompletionContext context = new OrderCompletionContext(); + context.setOperatorType(operatorType); + context.setOperatorId(operatorId); + context.setTriggerSource(triggerSource); + return context; + } + + public static OrderCompletionContext of(@Nullable String operatorType, @Nullable String operatorId, OrderTriggerSource triggerSource, @Nullable String comment) { + OrderCompletionContext context = of(operatorType, operatorId, triggerSource); + return context.withComment(comment); + } + + public static OrderCompletionContext scheduler(@Nullable String comment) { + return of(null, null, OrderTriggerSource.SCHEDULER, comment); + } + + public static OrderCompletionContext system(OrderTriggerSource triggerSource, @Nullable String comment) { + return of(null, null, triggerSource, comment); + } + + public OrderCompletionContext withForceNotify(boolean forceNotify) { + this.forceNotify = forceNotify; + return this; + } + + public OrderCompletionContext withComment(@Nullable String comment) { + this.comment = comment; + return this; + } + + public OrderCompletionContext withTriggerSource(OrderTriggerSource triggerSource) { + this.triggerSource = Objects.requireNonNull(triggerSource, "triggerSource"); + return this; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRefundContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRefundContext.java new file mode 100644 index 0000000..e6e5e62 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderRefundContext.java @@ -0,0 +1,44 @@ +package com.starry.admin.modules.order.module.dto; + +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; +import java.math.BigDecimal; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.lang.Nullable; + +@Data +public class OrderRefundContext { + + @NotBlank + private String orderId; + + @NotNull + private BigDecimal refundAmount; + + @Nullable + private String refundReason; + + @Nullable + private String operatorType; + + @Nullable + private String operatorId; + + private OrderTriggerSource triggerSource = OrderTriggerSource.UNKNOWN; + + private boolean fullRefund; + + @Nullable + private String comment; + + public OrderRefundContext withTriggerSource(OrderTriggerSource triggerSource) { + this.triggerSource = triggerSource; + return this; + } + + public OrderRefundContext withComment(@Nullable String comment) { + this.comment = comment; + return this; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java index 1b72979..d006a15 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java @@ -313,4 +313,20 @@ public class PlayOrderInfoEntity extends BaseEntity { this.orderType = orderType; this.placeType = placeType; } + + /** + * 请使用 OrderCompletionService 统一处理订单完成逻辑,直接设置状态仅限初始化或遗留代码。 + */ + @Deprecated + public void setOrderStatus(String orderStatus) { + this.orderStatus = orderStatus; + } + + /** + * 请使用 OrderCompletionService 统一处理订单结束时间写入。 + */ + @Deprecated + public void setOrderEndTime(LocalDateTime orderEndTime) { + this.orderEndTime = orderEndTime; + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java new file mode 100644 index 0000000..e399f55 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/IOrderLifecycleService.java @@ -0,0 +1,11 @@ +package com.starry.admin.modules.order.service; + +import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderRefundContext; + +public interface IOrderLifecycleService { + + void completeOrder(String orderId, OrderCompletionContext context); + + void refundOrder(OrderRefundContext context); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java new file mode 100644 index 0000000..3416c3b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java @@ -0,0 +1,195 @@ +package com.starry.admin.modules.order.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +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.OrderRefundFlag; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; +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.ReviewRequirement; +import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IOrderLifecycleService; +import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; +import com.starry.admin.modules.weichat.service.WxCustomMpService; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.utils.SecurityUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import javax.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +public class OrderLifecycleServiceImpl implements IOrderLifecycleService { + + @Resource + private PlayOrderInfoMapper orderInfoMapper; + + @Resource + private IEarningsService earningsService; + + @Resource + private WxCustomMpService wxCustomMpService; + + @Resource + private IPlayOrderRefundInfoService orderRefundInfoService; + + @Resource + private IPlayCustomUserInfoService customUserInfoService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void completeOrder(String orderId, OrderCompletionContext context) { + if (StrUtil.isBlank(orderId)) { + throw new CustomException("订单ID不能为空"); + } + PlayOrderInfoEntity order = orderInfoMapper.selectById(orderId); + if (order == null) { + throw new CustomException("订单不存在"); + } + + OrderTriggerSource source = context != null && context.getTriggerSource() != null + ? context.getTriggerSource() + : OrderTriggerSource.UNKNOWN; + boolean alreadyCompleted = OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus()); + if (!alreadyCompleted && !OrderStatus.IN_PROGRESS.getCode().equals(order.getOrderStatus())) { + log.warn("Skip completing order {}, unexpected status {}, source={}", orderId, order.getOrderStatus(), source.getCode()); + throw new CustomException("订单状态异常,无法完成"); + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endTime = order.getOrderEndTime() != null ? order.getOrderEndTime() : now; + + boolean statusUpdated = false; + if (!alreadyCompleted) { + PlayOrderInfoEntity update = new PlayOrderInfoEntity(orderId, OrderStatus.COMPLETED.getCode()); + update.setOrderEndTime(endTime); + orderInfoMapper.updateById(update); + statusUpdated = true; + } else if (order.getOrderEndTime() == null) { + PlayOrderInfoEntity update = new PlayOrderInfoEntity(orderId, OrderStatus.COMPLETED.getCode()); + update.setOrderEndTime(endTime); + orderInfoMapper.updateById(update); + statusUpdated = true; + } + + PlayOrderInfoEntity latest = orderInfoMapper.selectById(orderId); + if (latest == null) { + throw new CustomException("订单不存在"); + } + + boolean earningsCreated = ensureEarnings(latest, source); + boolean forceNotify = context != null && context.isForceNotify(); + boolean shouldNotify = statusUpdated || (forceNotify && earningsCreated); + if (shouldNotify) { + wxCustomMpService.sendOrderFinishMessageAsync(latest); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void refundOrder(OrderRefundContext context) { + if (context == null || StrUtil.isBlank(context.getOrderId())) { + throw new CustomException("订单ID不能为空"); + } + PlayOrderInfoEntity order = orderInfoMapper.selectById(context.getOrderId()); + if (order == null) { + throw new CustomException("订单不存在"); + } + BigDecimal refundAmount = context.getRefundAmount(); + if (refundAmount == null) { + throw new CustomException("退款金额不能为空"); + } + if (refundAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new CustomException("退款金额不能小于0"); + } + BigDecimal finalAmount = order.getFinalAmount() == null ? BigDecimal.ZERO : order.getFinalAmount(); + if (finalAmount.compareTo(refundAmount) < 0) { + throw new CustomException("退款金额不能大于支付金额"); + } + if (OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus())) { + throw new CustomException("【已完成】的订单无法操作退款"); + } + if (OrderStatus.CANCELLED.getCode().equals(order.getOrderStatus())) { + throw new CustomException("【已取消】的订单无法操作退款"); + } + if (OrderRefundFlag.REFUNDED.getCode().equals(order.getRefundType())) { + throw new CustomException("每个订单只能退款一次~"); + } + + PlayOrderInfoEntity update = new PlayOrderInfoEntity(); + update.setId(order.getId()); + update.setRefundType(OrderRefundFlag.REFUNDED.getCode()); + update.setOrderStatus(OrderStatus.CANCELLED.getCode()); + update.setRefundAmount(refundAmount); + orderInfoMapper.updateById(update); + + PlayCustomUserInfoEntity customUser = customUserInfoService.getById(order.getPurchaserBy()); + if (customUser == null) { + throw new CustomException("顾客信息不存在"); + } + BigDecimal currentBalance = customUser.getAccountBalance() == null ? BigDecimal.ZERO : customUser.getAccountBalance(); + BigDecimal refundSourceAmount = refundAmount; + customUserInfoService.updateAccountBalanceById( + customUser.getId(), + currentBalance, + currentBalance.add(refundSourceAmount), + BalanceOperationType.REFUND.getCode(), + "订单退款", + refundSourceAmount, + BigDecimal.ZERO, + order.getId()); + + OrderRefundRecordType refundRecordType = finalAmount.compareTo(refundAmount) == 0 + ? OrderRefundRecordType.FULL + : OrderRefundRecordType.PARTIAL; + String refundByType = StrUtil.isNotBlank(context.getOperatorType()) + ? context.getOperatorType() + : OperatorType.ADMIN.getCode(); + String refundById = StrUtil.isNotBlank(context.getOperatorId()) ? context.getOperatorId() : SecurityUtils.getUserId(); + + orderRefundInfoService.add( + order.getId(), + order.getPurchaserBy(), + order.getAcceptBy(), + order.getPayMethod(), + refundRecordType.getCode(), + refundAmount, + context.getRefundReason(), + refundByType, + refundById, + OrderRefundState.PROCESSING.getCode(), + ReviewRequirement.NOT_REQUIRED.getCode()); + } + + private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) { + Long existing = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getTenantId, order.getTenantId()) + .eq(EarningsLineEntity::getOrderId, order.getId()) + .count(); + if (existing != null && existing > 0) { + return false; + } + earningsService.createFromOrder(order); + Long after = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getTenantId, order.getTenantId()) + .eq(EarningsLineEntity::getOrderId, order.getId()) + .count(); + if (after == null || after == 0) { + log.warn("Failed to create earnings line for order {}, source={}", order.getId(), source.getCode()); + return false; + } + return true; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java index 0e80396..d73201f 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/PlayOrderInfoServiceImpl.java @@ -1,8 +1,5 @@ package com.starry.admin.modules.order.service.impl; -import static com.starry.admin.modules.order.module.constant.OrderConstant.ORDER_STATUS_2; -import static com.starry.admin.modules.order.module.constant.OrderConstant.ORDER_STATUS_3; - import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; @@ -20,12 +17,21 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; 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.OperatorType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundFlag; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; +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.ReviewRequirement; import com.starry.admin.modules.order.module.dto.*; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity; import com.starry.admin.modules.order.module.vo.*; +import com.starry.admin.modules.order.service.IOrderLifecycleService; import com.starry.admin.modules.order.service.IPlayOrderComplaintInfoService; import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService; @@ -93,7 +99,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl getTotalOrderInfo(String tenantId) { @@ -117,7 +123,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl listByEndTime(String clerkId, LocalDateTime endTime) { MPJLambdaWrapper lambdaQueryWrapper = new MPJLambdaWrapper<>(); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId); - lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, "3"); + lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); lambdaQueryWrapper.lt(PlayOrderInfoEntity::getOrderEndTime, endTime); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderSettlementState, "0"); return this.baseMapper.selectList(lambdaQueryWrapper); @@ -604,7 +614,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl lambdaQueryWrapper = new LambdaQueryWrapper<>(); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getPlaceType, "1"); - lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, "0"); + lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.PENDING.getCode()); // lambdaQueryWrapper.eq(PlayOrderInfoEntity::getLevelId, entity.getLevelId()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getSex, entity.getSex()); // lambdaQueryWrapper.eq(PlayOrderInfoEntity::getExcludeHistory, "0") @@ -617,7 +627,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl selectRewardByPage(PlayRewardOrderQueryVo vo) { MPJLambdaWrapper lambdaQueryWrapper = new MPJLambdaWrapper<>(); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getPlaceType, "2"); - lambdaQueryWrapper.eq(PlayOrderInfoEntity::getRefundType, "0"); + lambdaQueryWrapper.eq(PlayOrderInfoEntity::getRefundType, OrderRefundFlag.NOT_REFUNDED.getCode()); lambdaQueryWrapper.selectAll(PlayOrderInfoEntity.class); lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getPurchaserTime); // 查询陪聊表 @@ -636,7 +646,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class).eq(PlayOrderInfoEntity::getPurchaserBy, customId).eq(PlayOrderInfoEntity::getAcceptBy, clerkId).eq(PlayOrderInfoEntity::getOrderStatus, "3"); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class).eq(PlayOrderInfoEntity::getPurchaserBy, customId).eq(PlayOrderInfoEntity::getAcceptBy, clerkId).eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); return this.baseMapper.selectCount(wrapper) > 0; } @@ -677,7 +687,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl orderService.forceCancelOngoingOrder("2", "admin-1", orderId, BigDecimal.valueOf(80), "管理员取消测试", Collections.emptyList())); verify(orderInfoMapper, times(1)).updateById(any(PlayOrderInfoEntity.class)); verify(customUserInfoService, times(1)).updateAccountBalanceById(eq("customer-1"), any(BigDecimal.class), - any(BigDecimal.class), anyString(), anyString(), eq(BigDecimal.valueOf(80)), eq(BigDecimal.ZERO), - eq(orderId)); - verify(playOrderRefundInfoService, times(1)).add(eq(orderId), eq("customer-1"), eq("clerk-1"), anyString(), - eq("0"), eq(BigDecimal.valueOf(80)), eq("管理员取消测试"), eq("2"), eq("admin-1"), eq("0"), eq("0")); - verify(wxCustomMpService, times(1)).sendOrderCancelMessage(any(PlayOrderInfoEntity.class), + any(BigDecimal.class), eq(OrderConstant.BalanceOperationType.REFUND.getCode()), eq("订单取消退款"), + eq(BigDecimal.valueOf(80)), eq(BigDecimal.ZERO), eq(orderId)); + verify(playOrderRefundInfoService, times(1)).add(eq(orderId), eq("customer-1"), eq("clerk-1"), + eq(inProgressOrder.getPayMethod()), + eq(OrderConstant.OrderRefundRecordType.PARTIAL.getCode()), + eq(BigDecimal.valueOf(80)), eq("管理员取消测试"), eq("2"), eq("admin-1"), + eq(OrderConstant.OrderRefundState.PROCESSING.getCode()), + eq(OrderConstant.ReviewRequirement.NOT_REQUIRED.getCode())); + verify(wxCustomMpService, times(1)).sendOrderCancelMessageAsync(any(PlayOrderInfoEntity.class), eq("管理员取消测试")); } @@ -493,7 +498,7 @@ class PlayOrderInfoServiceTest { String orderId = "order_invalid_force_cancel"; PlayOrderInfoEntity pendingOrder = new PlayOrderInfoEntity(); pendingOrder.setId(orderId); - pendingOrder.setOrderStatus(OrderConstant.ORDER_STATUS_0); + pendingOrder.setOrderStatus(OrderStatus.PENDING.getCode()); pendingOrder.setAcceptBy("clerk-1"); pendingOrder.setPurchaserBy("customer-1"); pendingOrder.setFinalAmount(BigDecimal.valueOf(50)); diff --git a/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java new file mode 100644 index 0000000..1eb6c07 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImplTest.java @@ -0,0 +1,216 @@ +package com.starry.admin.modules.order.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +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.OrderRefundFlag; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundRecordType; +import com.starry.admin.modules.order.module.constant.OrderConstant.OrderRefundState; +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.ReviewRequirement; +import com.starry.admin.modules.order.module.dto.OrderCompletionContext; +import com.starry.admin.modules.order.module.dto.OrderRefundContext; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; +import com.starry.admin.modules.weichat.service.WxCustomMpService; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OrderLifecycleServiceImplTest { + + @InjectMocks + private OrderLifecycleServiceImpl lifecycleService; + + @Mock + private PlayOrderInfoMapper orderInfoMapper; + + @Mock + private IEarningsService earningsService; + + @Mock + private WxCustomMpService wxCustomMpService; + + @Mock + private IPlayOrderRefundInfoService orderRefundInfoService; + + @Mock + private IPlayCustomUserInfoService customUserInfoService; + + @Test + void completeOrder_inProgress_createsEarningsAndNotifies() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity inProgress = buildOrder(orderId, OrderStatus.IN_PROGRESS.getCode()); + inProgress.setOrderEndTime(null); + + PlayOrderInfoEntity completed = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completed.setOrderEndTime(LocalDateTime.now()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(inProgress, completed); + when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); + mockEarningsCounts(0L, 1L); + + OrderCompletionContext context = OrderCompletionContext.of( + OperatorType.CLERK.getCode(), + inProgress.getAcceptBy(), + OrderTriggerSource.WX_CLERK); + + lifecycleService.completeOrder(orderId, context); + + verify(orderInfoMapper).updateById(argThat(entity -> + orderId.equals(entity.getId()) + && OrderStatus.COMPLETED.getCode().equals(entity.getOrderStatus()) + && entity.getOrderEndTime() != null)); + verify(earningsService).createFromOrder(completed); + verify(wxCustomMpService).sendOrderFinishMessageAsync(completed); + } + + @Test + void completeOrder_alreadyCompleted_skipsEarningsCreation() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity alreadyCompleted = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + alreadyCompleted.setOrderEndTime(null); + + PlayOrderInfoEntity completedWithEnd = buildOrder(orderId, OrderStatus.COMPLETED.getCode()); + completedWithEnd.setOrderEndTime(LocalDateTime.now()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(alreadyCompleted, completedWithEnd); + when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); + mockEarningsCounts(1L); + + lifecycleService.completeOrder(orderId, OrderCompletionContext.of( + OperatorType.ADMIN.getCode(), + "admin-1", + OrderTriggerSource.ADMIN_CONSOLE)); + + verify(earningsService, never()).createFromOrder(any()); + verify(wxCustomMpService).sendOrderFinishMessageAsync(completedWithEnd); + } + + @Test + void refundOrder_partialAmount_updatesLedgerAndRecords() { + String orderId = UUID.randomUUID().toString(); + BigDecimal finalAmount = BigDecimal.valueOf(100); + BigDecimal refundAmount = BigDecimal.valueOf(40); + + PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode()); + order.setFinalAmount(finalAmount); + order.setOrderMoney(finalAmount); + order.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); + order.setPayMethod("1"); + + when(orderInfoMapper.selectById(orderId)).thenReturn(order); + when(orderInfoMapper.updateById(any(PlayOrderInfoEntity.class))).thenReturn(1); + + PlayCustomUserInfoEntity customer = new PlayCustomUserInfoEntity(); + customer.setId(order.getPurchaserBy()); + customer.setAccountBalance(BigDecimal.valueOf(10)); + when(customUserInfoService.getById(order.getPurchaserBy())).thenReturn(customer); + + OrderRefundContext context = new OrderRefundContext(); + context.setOrderId(orderId); + context.setRefundAmount(refundAmount); + context.setRefundReason("部分退款测试"); + context.setOperatorType(OperatorType.ADMIN.getCode()); + context.setOperatorId("admin-1"); + context.withTriggerSource(OrderTriggerSource.ADMIN_API); + + lifecycleService.refundOrder(context); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(PlayOrderInfoEntity.class); + verify(orderInfoMapper).updateById(updateCaptor.capture()); + PlayOrderInfoEntity updated = updateCaptor.getValue(); + assertEquals(OrderStatus.CANCELLED.getCode(), updated.getOrderStatus()); + assertEquals(OrderRefundFlag.REFUNDED.getCode(), updated.getRefundType()); + assertEquals(refundAmount, updated.getRefundAmount()); + + verify(customUserInfoService).updateAccountBalanceById( + eq(order.getPurchaserBy()), + eq(customer.getAccountBalance()), + eq(customer.getAccountBalance().add(refundAmount)), + eq(BalanceOperationType.REFUND.getCode()), + eq("订单退款"), + eq(refundAmount), + eq(BigDecimal.ZERO), + eq(orderId)); + + verify(orderRefundInfoService).add( + eq(orderId), + eq(order.getPurchaserBy()), + eq(order.getAcceptBy()), + eq(order.getPayMethod()), + eq(OrderRefundRecordType.PARTIAL.getCode()), + eq(refundAmount), + eq("部分退款测试"), + eq(OperatorType.ADMIN.getCode()), + eq("admin-1"), + eq(OrderRefundState.PROCESSING.getCode()), + eq(ReviewRequirement.NOT_REQUIRED.getCode())); + } + + @Test + void refundOrder_throwsWhenAlreadyRefunded() { + String orderId = UUID.randomUUID().toString(); + PlayOrderInfoEntity order = buildOrder(orderId, OrderStatus.ACCEPTED.getCode()); + order.setFinalAmount(BigDecimal.TEN); + order.setOrderMoney(BigDecimal.TEN); + order.setRefundType(OrderRefundFlag.REFUNDED.getCode()); + + when(orderInfoMapper.selectById(orderId)).thenReturn(order); + + 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()).updateById(any()); + verify(orderRefundInfoService, never()).add(anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyString(), anyString(), anyString(), anyString(), anyString()); + } + + private PlayOrderInfoEntity buildOrder(String orderId, String status) { + PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); + entity.setId(orderId); + entity.setOrderStatus(status); + entity.setAcceptBy("clerk-1"); + entity.setPurchaserBy("customer-1"); + entity.setTenantId("tenant-1"); + return entity; + } + + private void mockEarningsCounts(long... counts) { + LambdaQueryChainWrapper chain = Mockito.mock(LambdaQueryChainWrapper.class); + when(chain.eq(any(), any())).thenReturn(chain); + if (counts.length == 0) { + when(chain.count()).thenReturn(0L); + } else { + org.mockito.stubbing.OngoingStubbing stubbing = when(chain.count()).thenReturn(counts[0]); + for (int i = 1; i < counts.length; i++) { + stubbing = stubbing.thenReturn(counts[i]); + } + } + when(earningsService.lambdaQuery()).thenReturn(chain); + } +}