feat: 优化优惠券发放与库存校验流程

This commit is contained in:
irving
2025-10-31 00:10:24 -04:00
parent 48bdc9af33
commit db6132d7e3
9 changed files with 220 additions and 14 deletions

View File

@@ -5,8 +5,8 @@ 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.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.vo.*;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.common.annotation.Log;
import com.starry.common.enums.BusinessType;
@@ -38,9 +38,6 @@ public class PlayCouponInfoController {
@Resource
private IPlayCouponInfoService playCouponInfoService;
@Resource
private IPlayCouponDetailsService playCouponDetailsService;
@Resource
private IPlayCustomUserInfoService playCustomUserInfoService;
@@ -85,9 +82,9 @@ public class PlayCouponInfoController {
@PostMapping("/sendCoupon")
public R sendCoupon(@ApiParam(value = "优惠券发放信息", required = true) @Validated @RequestBody PlayCouponInfoSendVo vo) {
PlayCustomUserInfoEntity customUserInfo = playCustomUserInfoService.selectById(vo.getCustomId());
for (Integer i = 0; i < vo.getSendNumber(); i++) {
playCouponDetailsService.create(customUserInfo.getId(), customUserInfo.getNickname(),
customUserInfo.getLevelId(), vo.getId(), "2", "1");
for (int i = 0; i < vo.getSendNumber(); i++) {
playCouponInfoService.claimCouponForCustom(
vo.getId(), customUserInfo, CouponObtainChannel.BACKEND_GRANT);
}
return R.ok();
}
@@ -131,6 +128,31 @@ public class PlayCouponInfoController {
if ("2".equals(vo.getValidityPeriodType()) && vo.getEffectiveDay() <= 0) {
throw new CustomException("用券时间输入错误有效天数不能为0");
}
boolean unlimitedInventory = "1".equals(vo.getCouponQuantityType());
if (unlimitedInventory) {
entity.setCouponQuantity(null);
entity.setRemainingQuantity(null);
} else {
Integer totalQuantity = vo.getCouponQuantity();
if (totalQuantity == null || totalQuantity <= 0) {
throw new CustomException("优惠券总数必须大于0");
}
entity.setCouponQuantity(totalQuantity);
entity.setRemainingQuantity(totalQuantity);
}
boolean unlimitedPerUser = "1".equals(vo.getClerkObtainedMaxQuantityType());
if (unlimitedPerUser) {
entity.setClerkObtainedMaxQuantity(null);
} else {
Integer perUserLimit = vo.getClerkObtainedMaxQuantity();
if (perUserLimit == null || perUserLimit <= 0) {
throw new CustomException("每人限领数量必须大于0");
}
entity.setClerkObtainedMaxQuantity(perUserLimit);
}
String discountContent = "";
if (BigDecimal.ZERO.compareTo(vo.getUseMinAmount()) == 0) {
discountContent = "无门槛";

View File

@@ -2,13 +2,33 @@ package com.starry.admin.modules.shop.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 优惠券信息Mapper接口
*
* @author admin
* @since 2024-07-04
*/
@Mapper
public interface PlayCouponInfoMapper extends MPJBaseMapper<PlayCouponInfoEntity> {
@Update({
"UPDATE play_coupon_info",
"SET issued_quantity = IFNULL(issued_quantity, 0) + 1,",
" remaining_quantity = CASE",
" WHEN remaining_quantity IS NULL THEN NULL",
" WHEN remaining_quantity < 0 THEN remaining_quantity",
" ELSE remaining_quantity - 1",
" END",
"WHERE id = #{id}",
" AND (remaining_quantity IS NULL OR remaining_quantity <> 0)"
})
int increaseIssuedAndConsumeRemaining(String id);
@Select({
"SELECT * FROM play_coupon_info",
"WHERE id = #{id}",
"FOR UPDATE"
})
PlayCouponInfoEntity selectByIdForUpdate(String id);
}

View File

@@ -0,0 +1,27 @@
package com.starry.admin.modules.shop.module.enums;
import java.util.Arrays;
import java.util.Optional;
import lombok.Getter;
/**
* 优惠券领取渠道
*/
@Getter
public enum CouponObtainChannel {
SELF_SERVICE("1"),
BACKEND_GRANT("2"),
REFUND_RETURN("3"),
SHARE("4"),
LOTTERY("5");
private final String code;
CouponObtainChannel(String code) {
this.code = code;
}
public static Optional<CouponObtainChannel> fromCode(String code) {
return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst();
}
}

View File

@@ -0,0 +1,38 @@
package com.starry.admin.modules.shop.module.support;
/**
* Encapsulates how coupon inventory should behave (finite vs. unlimited).
*/
public enum CouponInventoryPolicy {
UNLIMITED,
FINITE;
public boolean isUnlimited() {
return this == UNLIMITED;
}
public boolean consumesStock() {
return this == FINITE;
}
public boolean hasStock(Integer remainingQuantity) {
if (isUnlimited()) {
return true;
}
return remainingQuantity == null || remainingQuantity > 0;
}
public static CouponInventoryPolicy resolve(Integer totalQuantity, Integer remainingQuantity) {
if (isUnlimitedSentinel(totalQuantity) || isUnlimitedSentinel(remainingQuantity)) {
return UNLIMITED;
}
if (totalQuantity == null && remainingQuantity == null) {
return UNLIMITED;
}
return FINITE;
}
private static boolean isUnlimitedSentinel(Integer value) {
return value != null && value < 0;
}
}

View File

@@ -82,6 +82,11 @@ public interface IPlayCouponDetailsService extends IService<PlayCouponDetailsEnt
*/
boolean create(PlayCouponDetailsEntity playCouponDetails);
/**
* 统计指定用户的有效优惠券数量(排除已回收)。
*/
long countActiveByCustomAndCoupon(String couponId, String customId);
/**
* 修改优惠券详情
*

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import java.math.BigDecimal;
@@ -53,6 +54,11 @@ public interface IPlayCouponInfoService extends IService<PlayCouponInfoEntity> {
String useState,
LocalDateTime obtainingTime);
/**
* 顾客领取优惠券,校验库存与限领并生成明细。
*/
void claimCouponForCustom(String couponId, PlayCustomUserInfoEntity customUserInfo, CouponObtainChannel obtainChannel);
/**
* 获取优惠券不可领取的原因
*

View File

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.starry.admin.modules.shop.mapper.PlayCouponDetailsMapper;
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.vo.PlayCouponDetailsQueryVo;
@@ -123,6 +124,15 @@ public class PlayCouponDetailsServiceImpl extends ServiceImpl<PlayCouponDetailsM
return save(playCouponDetails);
}
@Override
public long countActiveByCustomAndCoupon(String couponId, String customId) {
return lambdaQuery()
.eq(PlayCouponDetailsEntity::getCouponId, couponId)
.eq(PlayCouponDetailsEntity::getCustomId, customId)
.eq(PlayCouponDetailsEntity::getUseState, CouponUseState.UNUSED.getCode())
.count();
}
@Override
public List<PlayCouponDetailsReturnVo> selectByCustomId(String customId) {
MPJLambdaWrapper<PlayCouponDetailsEntity> lambdaWrapper = new MPJLambdaWrapper<>();

View File

@@ -12,10 +12,13 @@ import com.starry.admin.modules.shop.mapper.PlayCouponInfoMapper;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.support.CouponInventoryPolicy;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoQueryVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponInfoReturnVo;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.shop.service.IPlayCouponInfoService;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
@@ -25,6 +28,7 @@ import java.util.List;
import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 优惠券信息Service业务层处理
@@ -39,6 +43,9 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
@Resource
private PlayCouponInfoMapper playCouponInfoMapper;
@Resource
private IPlayCouponDetailsService playCouponDetailsService;
/**
* 获取优惠券不可用的原因
*
@@ -130,6 +137,54 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
return Optional.empty();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void claimCouponForCustom(
String couponId,
PlayCustomUserInfoEntity customUserInfo,
CouponObtainChannel obtainChannel) {
if (customUserInfo == null) {
throw new CustomException("顾客信息不存在");
}
CouponObtainChannel channel = obtainChannel != null ? obtainChannel : CouponObtainChannel.SELF_SERVICE;
PlayCouponInfoEntity couponInfo = playCouponInfoMapper.selectByIdForUpdate(couponId);
if (couponInfo == null) {
throw new CustomException("优惠券不存在");
}
Optional<CouponOnlineState> onlineState = CouponOnlineState.fromCode(couponInfo.getCouponOnLineState());
if (onlineState.isPresent() && onlineState.get() != CouponOnlineState.ONLINE) {
throw new CustomException("优惠券已下架");
}
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(couponInfo.getCouponQuantity(), couponInfo.getRemainingQuantity());
if (!inventoryPolicy.hasStock(couponInfo.getRemainingQuantity())) {
throw new CustomException("优惠券已领完");
}
Integer maxQuantityPerUser = couponInfo.getClerkObtainedMaxQuantity();
if (maxQuantityPerUser != null && maxQuantityPerUser > 0) {
long claimed = playCouponDetailsService.countActiveByCustomAndCoupon(couponId, customUserInfo.getId());
if (claimed >= maxQuantityPerUser) {
throw new CustomException("优惠券已达到领取上限");
}
}
int affected = playCouponInfoMapper.increaseIssuedAndConsumeRemaining(couponId);
if (inventoryPolicy.consumesStock() && affected == 0) {
throw new CustomException("优惠券已领完");
}
playCouponDetailsService.create(
customUserInfo.getId(),
customUserInfo.getNickname(),
customUserInfo.getLevelId(),
couponId,
channel.getCode(),
CouponUseState.UNUSED.getCode());
}
/**
* 获取优惠券不可领取的原因
*
@@ -144,6 +199,22 @@ public class PlayCouponInfoServiceImpl extends ServiceImpl<PlayCouponInfoMapper,
@Override
public String getReasonForNotObtainingCoupons(PlayCouponInfoEntity entity,
PlayCustomUserInfoEntity customUserInfo) {
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(entity.getCouponQuantity(), entity.getRemainingQuantity());
if (!inventoryPolicy.hasStock(entity.getRemainingQuantity())) {
return "优惠券已领完";
}
if (customUserInfo != null && playCouponDetailsService != null) {
Integer perUserLimit = entity.getClerkObtainedMaxQuantity();
if (perUserLimit != null && perUserLimit > 0) {
long claimed = playCouponDetailsService.countActiveByCustomAndCoupon(
entity.getId(),
customUserInfo.getId());
if (claimed >= perUserLimit) {
return "优惠券已达到领取上限";
}
}
}
CouponClaimConditionType type = CouponClaimConditionType.of(entity.getClaimConditionType());
switch (type) {
case ALL:

View File

@@ -10,8 +10,10 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType;
import com.starry.admin.modules.shop.module.enums.CouponObtainChannel;
import com.starry.admin.modules.shop.module.enums.CouponOnlineState;
import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType;
import com.starry.admin.modules.shop.module.support.CouponInventoryPolicy;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
@@ -75,10 +77,13 @@ public class WxCouponController {
PlayCouponInfoEntity entity = couponInfoService.selectPlayCouponInfoById(id);
String msg = couponInfoService.getReasonForNotObtainingCoupons(entity, customUserInfo);
boolean success = StrUtil.isBlank(msg);
// 优惠券领取验证通过,发放优惠券
if (success) {
couponDetailsService.create(customUserInfo.getId(), customUserInfo.getNickname(),
customUserInfo.getLevelId(), entity.getId(), "1", "1");
try {
couponInfoService.claimCouponForCustom(entity.getId(), customUserInfo, CouponObtainChannel.SELF_SERVICE);
} catch (CustomException ex) {
success = false;
msg = ex.getMessage();
}
}
Map<String, Object> result = new HashMap<>();
result.put("success", success);
@@ -112,7 +117,9 @@ public class WxCouponController {
if (!online) {
continue;
}
if (couponInfoEntity.getRemainingQuantity() != null && couponInfoEntity.getRemainingQuantity() <= 0) {
CouponInventoryPolicy inventoryPolicy = CouponInventoryPolicy
.resolve(couponInfoEntity.getCouponQuantity(), couponInfoEntity.getRemainingQuantity());
if (!inventoryPolicy.hasStock(couponInfoEntity.getRemainingQuantity())) {
continue;
}
CouponValidityPeriodType validityType = CouponValidityPeriodType