feat(voucher): 支持券码重复使用

- 新增VoucherBatchCreateReqV2 请求对象,用于创建支持重复使用的券码批次
- 添加 VoucherUsageController 控制器,实现券码使用记录和统计功能
- 在VoucherInfo 对象中增加与重复使用相关的字段
- 修改 PriceVoucherBatchConfig 和 PriceVoucherCode 实体,支持重复使用相关属性
- 更新 VoucherBatchServiceImpl 和 VoucherServiceImpl,增加处理重复使用逻辑的方法
This commit is contained in:
2025-09-16 01:08:54 +08:00
parent 5531c576e0
commit ce3f7aae1e
17 changed files with 1167 additions and 21 deletions

View File

@@ -0,0 +1,67 @@
package com.ycwl.basic.pricing.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.pricing.dto.req.VoucherUsageHistoryReq;
import com.ycwl.basic.pricing.dto.resp.VoucherUsageRecordResp;
import com.ycwl.basic.pricing.dto.resp.VoucherUsageStatsResp;
import java.util.List;
/**
* 券码使用记录服务接口
*/
public interface IVoucherUsageService {
/**
* 分页查询券码使用记录
*
* @param req 查询请求
* @return 分页结果
*/
Page<VoucherUsageRecordResp> getUsageHistory(VoucherUsageHistoryReq req);
/**
* 获取指定券码的使用记录
*
* @param voucherCode 券码
* @return 使用记录列表
*/
List<VoucherUsageRecordResp> getUsageRecordsByCode(String voucherCode);
/**
* 获取用户在指定景区的券码使用记录
*
* @param faceId 用户faceId
* @param scenicId 景区ID
* @return 使用记录列表
*/
List<VoucherUsageRecordResp> getUserUsageRecords(Long faceId, Long scenicId);
/**
* 获取券码使用统计信息
*
* @param voucherCode 券码
* @return 统计信息
*/
VoucherUsageStatsResp getUsageStats(String voucherCode);
/**
* 获取批次券码使用统计信息
*
* @param batchId 批次ID
* @return 统计信息列表
*/
List<VoucherUsageStatsResp> getBatchUsageStats(Long batchId);
/**
* 记录券码使用(内部方法,由VoucherService调用)
*
* @param voucherCode 券码
* @param faceId 用户faceId
* @param orderId 订单ID
* @param discountAmount 优惠金额
* @param remark 备注
*/
void recordVoucherUsage(String voucherCode, Long faceId, String orderId,
java.math.BigDecimal discountAmount, String remark);
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.pricing.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReq;
import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReqV2;
import com.ycwl.basic.pricing.dto.req.VoucherBatchQueryReq;
import com.ycwl.basic.pricing.dto.resp.VoucherBatchResp;
import com.ycwl.basic.pricing.dto.resp.VoucherBatchStatsResp;
@@ -11,6 +12,11 @@ public interface VoucherBatchService {
Long createBatch(VoucherBatchCreateReq req);
/**
* 创建券码批次(支持可重复使用)
*/
Long createBatchV2(VoucherBatchCreateReqV2 req);
Page<VoucherBatchResp> queryBatchList(VoucherBatchQueryReq req);
VoucherBatchResp getBatchDetail(Long id);

View File

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.exception.BizException;
import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReq;
import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReqV2;
import com.ycwl.basic.pricing.dto.req.VoucherBatchQueryReq;
import com.ycwl.basic.pricing.dto.resp.VoucherBatchResp;
import com.ycwl.basic.pricing.dto.resp.VoucherBatchStatsResp;
@@ -78,6 +79,72 @@ public class VoucherBatchServiceImpl implements VoucherBatchService {
return batch.getId();
}
@Override
@Transactional
public Long createBatchV2(VoucherBatchCreateReqV2 req) {
if (req.getBatchName() == null || req.getBatchName().trim().isEmpty()) {
throw new BizException(400, "券码批次名称不能为空");
}
if (req.getScenicId() == null) {
throw new BizException(400, "景区ID不能为空");
}
if (req.getBrokerId() == null) {
throw new BizException(400, "推客ID不能为空");
}
if (req.getDiscountType() == null) {
throw new BizException(400, "优惠类型不能为空");
}
if (req.getTotalCount() == null || req.getTotalCount() < 1) {
throw new BizException(400, "券码数量必须大于0");
}
VoucherDiscountType discountType = VoucherDiscountType.getByCode(req.getDiscountType());
if (discountType == null) {
throw new BizException(400, "无效的优惠类型");
}
if (discountType != VoucherDiscountType.FREE_ALL && req.getDiscountValue() == null) {
throw new BizException(400, "优惠金额不能为空");
}
// 验证可重复使用参数
if (req.getMaxUseCount() != null && req.getMaxUseCount() < 1) {
throw new BizException(400, "最大使用次数必须大于0");
}
if (req.getMaxUsePerUser() != null && req.getMaxUsePerUser() < 1) {
throw new BizException(400, "每用户最大使用次数必须大于0");
}
if (req.getUseIntervalHours() != null && req.getUseIntervalHours() < 0) {
throw new BizException(400, "使用间隔小时数不能为负数");
}
PriceVoucherBatchConfig batch = new PriceVoucherBatchConfig();
BeanUtils.copyProperties(req, batch);
// 设置适用商品类型
if (req.getApplicableProducts() != null) {
batch.setApplicableProducts(req.getApplicableProducts());
}
batch.setUsedCount(0);
batch.setClaimedCount(0);
batch.setStatus(1);
batch.setCreateTime(new Date());
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr != null) {
batch.setCreateBy(Long.valueOf(userIdStr));
}
batch.setDeleted(0);
voucherBatchMapper.insert(batch);
// 生成券码,初始状态为UNCLAIMED,currentUseCount为0
voucherCodeService.generateVoucherCodes(batch.getId(), req.getScenicId(), req.getTotalCount());
return batch.getId();
}
@Override
public Page<VoucherBatchResp> queryBatchList(VoucherBatchQueryReq req) {
Page<PriceVoucherBatchConfig> page = new Page<>(req.getPageNum(), req.getPageSize());

View File

@@ -10,6 +10,8 @@ import com.ycwl.basic.pricing.enums.VoucherCodeStatus;
import com.ycwl.basic.pricing.enums.VoucherDiscountType;
import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper;
import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper;
import com.ycwl.basic.pricing.mapper.PriceVoucherUsageRecordMapper;
import com.ycwl.basic.pricing.entity.PriceVoucherUsageRecord;
import com.ycwl.basic.pricing.service.IVoucherService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -19,6 +21,8 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -34,6 +38,7 @@ public class VoucherServiceImpl implements IVoucherService {
private final PriceVoucherCodeMapper voucherCodeMapper;
private final PriceVoucherBatchConfigMapper voucherBatchConfigMapper;
private final PriceVoucherUsageRecordMapper usageRecordMapper;
@Override
public VoucherInfo validateAndGetVoucherInfo(String voucherCode, Long faceId, Long scenicId) {
@@ -63,24 +68,38 @@ public class VoucherServiceImpl implements IVoucherService {
// 检查券码状态和可用性
if (VoucherCodeStatus.UNCLAIMED.getCode().equals(voucherCodeEntity.getStatus())) {
// 未领取状态,检查是否可以领取
if (faceId != null) { // && canClaimVoucher(faceId, voucherCodeEntity.getScenicId())
if (faceId != null) {
voucherInfo.setAvailable(true);
} else {
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason("您已在该景区领取过券码");
}
} else if (VoucherCodeStatus.CLAIMED_UNUSED.getCode().equals(voucherCodeEntity.getStatus())) {
// 已领取使用,检查是否为当前用户
} else if (VoucherCodeStatus.canUse(voucherCodeEntity.getStatus())) {
// 已领取使用状态,检查是否为当前用户且未达到使用限制
if (faceId != null && faceId.equals(voucherCodeEntity.getFaceId())) {
voucherInfo.setAvailable(true);
String availabilityCheck = checkVoucherAvailability(voucherCodeEntity, batchConfig, faceId);
if (availabilityCheck == null) {
voucherInfo.setAvailable(true);
} else {
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason(availabilityCheck);
}
} else {
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason("券码已被其他用户领取");
}
} else {
// 已使用
} else if (VoucherCodeStatus.isExhausted(voucherCodeEntity.getStatus())) {
// 已用完或已使用
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason("券码已使");
voucherInfo.setUnavailableReason("券码已用");
} else if (VoucherCodeStatus.isExpired(voucherCodeEntity.getStatus())) {
// 已过期
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason("券码已过期");
} else {
// 其他状态
voucherInfo.setAvailable(false);
voucherInfo.setUnavailableReason("券码状态异常");
}
return voucherInfo;
@@ -109,19 +128,78 @@ public class VoucherServiceImpl implements IVoucherService {
@Override
public void markVoucherAsUsed(String voucherCode, String remark) {
markVoucherAsUsed(voucherCode, remark, null, null);
}
/**
* 标记券码为已使用(支持可重复使用)
*
* @param voucherCode 券码
* @param remark 使用备注
* @param orderId 订单ID
* @param discountAmount 优惠金额
*/
public void markVoucherAsUsed(String voucherCode, String remark, String orderId, BigDecimal discountAmount) {
if (!StringUtils.hasText(voucherCode)) {
return;
}
int result = voucherCodeMapper.useVoucher(voucherCode, LocalDateTime.now(), remark);
if (result > 0) {
// 更新批次统计
PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode);
if (voucherCodeEntity != null) {
voucherBatchConfigMapper.updateUsedCount(voucherCodeEntity.getBatchId(), 1);
}
log.info("券码已标记为使用: {}", voucherCode);
PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode);
if (voucherCodeEntity == null || voucherCodeEntity.getDeleted() == 1) {
log.warn("券码不存在或已删除: {}", voucherCode);
return;
}
PriceVoucherBatchConfig batchConfig = voucherBatchConfigMapper.selectById(voucherCodeEntity.getBatchId());
if (batchConfig == null || batchConfig.getDeleted() == 1) {
log.warn("券码批次不存在或已删除: batchId={}", voucherCodeEntity.getBatchId());
return;
}
Date now = new Date();
// 创建使用记录
PriceVoucherUsageRecord usageRecord = new PriceVoucherUsageRecord();
usageRecord.setVoucherCodeId(voucherCodeEntity.getId());
usageRecord.setVoucherCode(voucherCode);
usageRecord.setFaceId(voucherCodeEntity.getFaceId());
usageRecord.setScenicId(voucherCodeEntity.getScenicId());
usageRecord.setBatchId(voucherCodeEntity.getBatchId());
usageRecord.setUseTime(now);
usageRecord.setOrderId(orderId);
usageRecord.setDiscountAmount(discountAmount);
usageRecord.setRemark(remark);
usageRecord.setCreateTime(now);
usageRecord.setDeleted(0);
usageRecordMapper.insert(usageRecord);
// 更新券码使用次数和状态
Integer currentUseCount = voucherCodeEntity.getCurrentUseCount() != null ?
voucherCodeEntity.getCurrentUseCount() + 1 : 1;
voucherCodeEntity.setCurrentUseCount(currentUseCount);
voucherCodeEntity.setLastUsedTime(now);
// 检查是否达到最大使用次数
Integer maxUseCount = batchConfig.getMaxUseCount();
if (maxUseCount != null && currentUseCount >= maxUseCount) {
if (maxUseCount == 1) {
// 兼容原有逻辑,单次使用设为USED状态
voucherCodeEntity.setStatus(VoucherCodeStatus.USED.getCode());
voucherCodeEntity.setUsedTime(now);
} else {
// 多次使用达到上限设为CLAIMED_EXHAUSTED状态
voucherCodeEntity.setStatus(VoucherCodeStatus.CLAIMED_EXHAUSTED.getCode());
}
}
voucherCodeMapper.updateById(voucherCodeEntity);
// 更新批次统计(使用记录数,不是使用券码数)
voucherBatchConfigMapper.updateUsedCount(voucherCodeEntity.getBatchId(), 1);
log.info("券码使用记录已创建: code={}, useCount={}, maxUseCount={}",
voucherCode, currentUseCount, maxUseCount);
}
@Override
@@ -260,6 +338,48 @@ public class VoucherServiceImpl implements IVoucherService {
return bestVoucher;
}
/**
* 检查券码可用性
*
* @param voucherCode 券码实体
* @param batchConfig 批次配置
* @param faceId 用户faceId
* @return 不可用原因,null表示可用
*/
private String checkVoucherAvailability(PriceVoucherCode voucherCode, PriceVoucherBatchConfig batchConfig, Long faceId) {
// 1. 检查券码使用次数限制
Integer maxUseCount = batchConfig.getMaxUseCount();
Integer currentUseCount = voucherCode.getCurrentUseCount() != null ? voucherCode.getCurrentUseCount() : 0;
if (maxUseCount != null && currentUseCount >= maxUseCount) {
return "券码使用次数已达上限";
}
// 2. 检查用户使用次数限制
Integer maxUsePerUser = batchConfig.getMaxUsePerUser();
if (maxUsePerUser != null && faceId != null) {
Integer userUseCount = usageRecordMapper.countByFaceIdAndVoucherCodeId(faceId, voucherCode.getId());
if (userUseCount >= maxUsePerUser) {
return "您使用该券码的次数已达上限";
}
}
// 3. 检查使用间隔时间限制
Integer useIntervalHours = batchConfig.getUseIntervalHours();
if (useIntervalHours != null && faceId != null) {
Date lastUseTime = usageRecordMapper.getLastUseTimeByFaceIdAndVoucherCodeId(faceId, voucherCode.getId());
if (lastUseTime != null) {
long diffMillis = System.currentTimeMillis() - lastUseTime.getTime();
long diffHours = TimeUnit.MILLISECONDS.toHours(diffMillis);
if (diffHours < useIntervalHours) {
return String.format("请等待%d小时后再次使用该券码", useIntervalHours - diffHours);
}
}
}
return null; // 可用
}
/**
* 构建券码信息DTO
*/
@@ -278,6 +398,22 @@ public class VoucherServiceImpl implements IVoucherService {
voucherInfo.setUsedTime(voucherCode.getUsedTime());
voucherInfo.setApplicableProducts(batchConfig.getApplicableProducts());
// 设置可重复使用相关信息
Integer currentUseCount = voucherCode.getCurrentUseCount() != null ? voucherCode.getCurrentUseCount() : 0;
voucherInfo.setCurrentUseCount(currentUseCount);
voucherInfo.setMaxUseCount(batchConfig.getMaxUseCount());
voucherInfo.setMaxUsePerUser(batchConfig.getMaxUsePerUser());
voucherInfo.setUseIntervalHours(batchConfig.getUseIntervalHours());
voucherInfo.setLastUsedTime(voucherCode.getLastUsedTime());
// 计算剩余可使用次数
if (batchConfig.getMaxUseCount() != null) {
int remaining = batchConfig.getMaxUseCount() - currentUseCount;
voucherInfo.setRemainingUseCount(Math.max(0, remaining));
} else {
voucherInfo.setRemainingUseCount(null); // 无限次
}
return voucherInfo;
}

View File

@@ -0,0 +1,192 @@
package com.ycwl.basic.pricing.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.pricing.dto.req.VoucherUsageHistoryReq;
import com.ycwl.basic.pricing.dto.resp.VoucherUsageRecordResp;
import com.ycwl.basic.pricing.dto.resp.VoucherUsageStatsResp;
import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig;
import com.ycwl.basic.pricing.entity.PriceVoucherCode;
import com.ycwl.basic.pricing.entity.PriceVoucherUsageRecord;
import com.ycwl.basic.pricing.enums.VoucherCodeStatus;
import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper;
import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper;
import com.ycwl.basic.pricing.mapper.PriceVoucherUsageRecordMapper;
import com.ycwl.basic.pricing.service.IVoucherUsageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 券码使用记录服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VoucherUsageServiceImpl implements IVoucherUsageService {
private final PriceVoucherUsageRecordMapper usageRecordMapper;
private final PriceVoucherCodeMapper voucherCodeMapper;
private final PriceVoucherBatchConfigMapper batchConfigMapper;
@Override
public Page<VoucherUsageRecordResp> getUsageHistory(VoucherUsageHistoryReq req) {
Page<PriceVoucherUsageRecord> page = new Page<>(req.getPageNum(), req.getPageSize());
Page<PriceVoucherUsageRecord> entityPage = usageRecordMapper.selectPageWithConditions(
page, req.getBatchId(), req.getVoucherCode(), req.getFaceId(),
req.getScenicId(), req.getStartTime(), req.getEndTime());
Page<VoucherUsageRecordResp> respPage = new Page<>();
BeanUtils.copyProperties(entityPage, respPage);
List<VoucherUsageRecordResp> respList = new ArrayList<>();
for (PriceVoucherUsageRecord record : entityPage.getRecords()) {
VoucherUsageRecordResp resp = convertToResp(record);
respList.add(resp);
}
respPage.setRecords(respList);
return respPage;
}
@Override
public List<VoucherUsageRecordResp> getUsageRecordsByCode(String voucherCode) {
List<PriceVoucherUsageRecord> records = usageRecordMapper.selectByVoucherCode(voucherCode);
List<VoucherUsageRecordResp> respList = new ArrayList<>();
for (PriceVoucherUsageRecord record : records) {
respList.add(convertToResp(record));
}
return respList;
}
@Override
public List<VoucherUsageRecordResp> getUserUsageRecords(Long faceId, Long scenicId) {
List<PriceVoucherUsageRecord> records = usageRecordMapper.selectByFaceIdAndScenicId(faceId, scenicId);
List<VoucherUsageRecordResp> respList = new ArrayList<>();
for (PriceVoucherUsageRecord record : records) {
respList.add(convertToResp(record));
}
return respList;
}
@Override
public VoucherUsageStatsResp getUsageStats(String voucherCode) {
PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode);
if (voucherCodeEntity == null || voucherCodeEntity.getDeleted() == 1) {
return null;
}
PriceVoucherBatchConfig batchConfig = batchConfigMapper.selectById(voucherCodeEntity.getBatchId());
if (batchConfig == null || batchConfig.getDeleted() == 1) {
return null;
}
VoucherUsageStatsResp stats = new VoucherUsageStatsResp();
stats.setVoucherCodeId(voucherCodeEntity.getId());
stats.setVoucherCode(voucherCodeEntity.getCode());
stats.setBatchId(batchConfig.getId());
stats.setBatchName(batchConfig.getBatchName());
stats.setScenicId(voucherCodeEntity.getScenicId());
stats.setStatus(voucherCodeEntity.getStatus());
VoucherCodeStatus statusEnum = VoucherCodeStatus.getByCode(voucherCodeEntity.getStatus());
if (statusEnum != null) {
stats.setStatusName(statusEnum.getName());
}
Integer currentUseCount = voucherCodeEntity.getCurrentUseCount() != null ?
voucherCodeEntity.getCurrentUseCount() : 0;
stats.setCurrentUseCount(currentUseCount);
stats.setMaxUseCount(batchConfig.getMaxUseCount());
stats.setMaxUsePerUser(batchConfig.getMaxUsePerUser());
stats.setUseIntervalHours(batchConfig.getUseIntervalHours());
// 计算是否还能使用
boolean canUseMore = true;
if (batchConfig.getMaxUseCount() != null) {
canUseMore = currentUseCount < batchConfig.getMaxUseCount();
}
stats.setCanUseMore(canUseMore);
// 计算剩余使用次数
if (batchConfig.getMaxUseCount() != null) {
int remaining = batchConfig.getMaxUseCount() - currentUseCount;
stats.setRemainingUseCount(Math.max(0, remaining));
}
// 获取使用记录数
Integer totalUsageRecords = usageRecordMapper.selectByVoucherCodeId(voucherCodeEntity.getId()).size();
stats.setTotalUsageRecords(totalUsageRecords);
// 格式化最后使用时间
if (voucherCodeEntity.getLastUsedTime() != null) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
stats.setLastUsedTime(sdf.format(voucherCodeEntity.getLastUsedTime()));
}
return stats;
}
@Override
public List<VoucherUsageStatsResp> getBatchUsageStats(Long batchId) {
// 这里可以实现批次统计,暂时返回空列表
return new ArrayList<>();
}
@Override
public void recordVoucherUsage(String voucherCode, Long faceId, String orderId,
BigDecimal discountAmount, String remark) {
PriceVoucherCode voucherCodeEntity = voucherCodeMapper.selectByCode(voucherCode);
if (voucherCodeEntity == null || voucherCodeEntity.getDeleted() == 1) {
log.warn("券码不存在或已删除: {}", voucherCode);
return;
}
Date now = new Date();
PriceVoucherUsageRecord record = new PriceVoucherUsageRecord();
record.setVoucherCodeId(voucherCodeEntity.getId());
record.setVoucherCode(voucherCode);
record.setFaceId(faceId);
record.setScenicId(voucherCodeEntity.getScenicId());
record.setBatchId(voucherCodeEntity.getBatchId());
record.setUseTime(now);
record.setOrderId(orderId);
record.setDiscountAmount(discountAmount);
record.setRemark(remark);
record.setCreateTime(now);
record.setDeleted(0);
usageRecordMapper.insert(record);
log.info("券码使用记录已创建: voucherCode={}, faceId={}, orderId={}",
voucherCode, faceId, orderId);
}
/**
* 转换为响应DTO
*/
private VoucherUsageRecordResp convertToResp(PriceVoucherUsageRecord record) {
VoucherUsageRecordResp resp = new VoucherUsageRecordResp();
BeanUtils.copyProperties(record, resp);
// 查询批次名称
if (record.getBatchId() != null) {
PriceVoucherBatchConfig batchConfig = batchConfigMapper.selectById(record.getBatchId());
if (batchConfig != null) {
resp.setBatchName(batchConfig.getBatchName());
}
}
return resp;
}
}