feat(pricing): 实现优惠券打印功能

- 新增 AppVoucherController 控制器处理打印请求
- 实现 VoucherPrintService 接口和 VoucherPrintServiceImpl 实现类
- 添加 VoucherPrintReq 请求对象和 VoucherPrintResp 响应对象
- 创建 VoucherPrintRecord 实体和对应的 Mapper
- 更新 PriceVoucherCodeMapper 接口,添加随机获取未打印券码的方法
- 实现分布式锁机制防止重复打印- 生成流水号并记录打印状态
This commit is contained in:
2025-08-24 01:16:16 +08:00
parent 4c794cdda2
commit 0204b3bc23
12 changed files with 616 additions and 11 deletions

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.pricing.dto.req.VoucherPrintReq;
import com.ycwl.basic.pricing.dto.resp.VoucherPrintResp;
import com.ycwl.basic.pricing.service.VoucherPrintService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/mobile/voucher/v1")
public class AppVoucherController {
@Autowired
private VoucherPrintService voucherPrintService;
/**
* 打印小票
* @param request 打印请求
* @return 打印结果
*/
@PostMapping("/print")
public ApiResponse<VoucherPrintResp> printVoucherTicket(@RequestBody VoucherPrintReq request) {
log.info("收到打印小票请求: faceId={}, brokerId={}, scenicId={}",
request.getFaceId(), request.getBrokerId(), request.getScenicId());
VoucherPrintResp response = voucherPrintService.printVoucherTicket(request);
log.info("打印小票完成: code={}, voucherCode={}, status={}",
response.getCode(), response.getVoucherCode(), response.getPrintStatus());
return ApiResponse.success(response);
}
}

View File

@@ -7,4 +7,5 @@ public class VoucherClaimReq {
private Long scenicId;
private Long brokerId;
private Long faceId;
private String code;
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.pricing.dto.req;
import lombok.Data;
/**
* 打印小票请求
*/
@Data
public class VoucherPrintReq {
private Long faceId;
private Long brokerId;
private Long scenicId;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.pricing.dto.resp;
import lombok.Data;
import java.util.Date;
/**
* 打印小票响应
*/
@Data
public class VoucherPrintResp {
/**
* 流水号
*/
private String code;
/**
* 券码
*/
private String voucherCode;
/**
* 打印状态:0=待打印,1=打印成功,2=打印失败
*/
private Integer printStatus;
/**
* 错误信息
*/
private String errorMessage;
/**
* 创建时间
*/
private Date createTime;
}

View File

@@ -0,0 +1,70 @@
package com.ycwl.basic.pricing.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 优惠券打印记录实体
*/
@Data
@TableName("voucher_print_record")
public class VoucherPrintRecord {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 流水号
*/
private String code;
/**
* 用户faceId
*/
private Long faceId;
/**
* 经纪人ID
*/
private Long brokerId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 券码ID
*/
private Long voucherCodeId;
/**
* 券码
*/
private String voucherCode;
/**
* 打印状态:0=待打印,1=打印成功,2=打印失败
*/
private Integer printStatus;
/**
* 错误信息
*/
private String errorMessage;
@TableField("create_time")
private Date createTime;
@TableField("update_time")
private Date updateTime;
private Integer deleted;
private Date deletedAt;
}

View File

@@ -91,4 +91,11 @@ public interface PriceVoucherCodeMapper extends BaseMapper<PriceVoucherCode> {
* @return 可用券码
*/
PriceVoucherCode findFirstAvailableByBatchId(@Param("batchId") Long batchId);
/**
* 随机获取一个未被打印过的可用券码
* @param scenicId 景区ID
* @return 可用券码
*/
PriceVoucherCode findRandomUnprintedVoucher(@Param("scenicId") Long scenicId);
}

View File

@@ -0,0 +1,42 @@
package com.ycwl.basic.pricing.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.pricing.entity.VoucherPrintRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 优惠券打印记录Mapper
*/
@Mapper
public interface VoucherPrintRecordMapper extends BaseMapper<VoucherPrintRecord> {
/**
* 根据faceId、brokerId、scenicId查询已存在的打印记录
* @param faceId 用户faceId
* @param brokerId 经纪人ID
* @param scenicId 景区ID
* @return 打印记录
*/
VoucherPrintRecord selectByFaceBrokerScenic(@Param("faceId") Long faceId,
@Param("brokerId") Long brokerId,
@Param("scenicId") Long scenicId);
/**
* 根据券码ID查询是否已被打印
* @param voucherCodeId 券码ID
* @return 打印记录
*/
VoucherPrintRecord selectByVoucherCodeId(@Param("voucherCodeId") Long voucherCodeId);
/**
* 更新打印状态
* @param id 记录ID
* @param printStatus 打印状态
* @param errorMessage 错误信息(可为null)
* @return 影响行数
*/
int updatePrintStatus(@Param("id") Long id,
@Param("printStatus") Integer printStatus,
@Param("errorMessage") String errorMessage);
}

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.pricing.service;
import com.ycwl.basic.pricing.dto.req.VoucherPrintReq;
import com.ycwl.basic.pricing.dto.resp.VoucherPrintResp;
/**
* 优惠券打印服务
*/
public interface VoucherPrintService {
/**
* 打印小票
* @param request 打印请求
* @return 打印响应
*/
VoucherPrintResp printVoucherTicket(VoucherPrintReq request);
}

View File

@@ -80,30 +80,50 @@ public class VoucherCodeServiceImpl implements VoucherCodeService {
if (req.getFaceId() == null) {
throw new BizException(400, "用户faceId不能为空");
}
if (!StringUtils.hasText(req.getCode())) {
throw new BizException(400, "券码不能为空");
}
// 验证券码是否存在且未被领取
LambdaQueryWrapper<PriceVoucherCode> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PriceVoucherCode::getCode, req.getCode())
.eq(PriceVoucherCode::getScenicId, req.getScenicId())
.eq(PriceVoucherCode::getDeleted, 0);
PriceVoucherCode voucherCode = voucherCodeMapper.selectOne(wrapper);
if (voucherCode == null) {
throw new BizException(400, "券码不存在或不属于该景区");
}
if (!Objects.equals(voucherCode.getStatus(), VoucherCodeStatus.UNCLAIMED.getCode())) {
throw new BizException(400, "券码已被领取或已使用");
}
if (!canClaimVoucher(req.getFaceId(), req.getScenicId())) {
throw new BizException(400, "该用户在此景区已领取过券码");
}
PriceVoucherBatchConfig batch = voucherBatchService.getAvailableBatch(req.getScenicId(), req.getBrokerId());
if (batch == null) {
throw new BizException(400, "暂无可用券码批次");
// 获取券码所属批次
PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(voucherCode.getBatchId());
if (batch == null || batch.getDeleted() == 1) {
throw new BizException(400, "券码批次不存在");
}
PriceVoucherCode availableCode = voucherCodeMapper.findFirstAvailableByBatchId(batch.getId());
if (availableCode == null) {
throw new BizException(400, "券码已领完");
// 验证批次是否可用于该推客
if (!Objects.equals(batch.getBrokerId(), req.getBrokerId())) {
throw new BizException(400, "券码不属于该推客");
}
availableCode.setFaceId(req.getFaceId());
availableCode.setStatus(VoucherCodeStatus.CLAIMED_UNUSED.getCode());
availableCode.setClaimedTime(new Date());
// 更新券码状态
voucherCode.setFaceId(req.getFaceId());
voucherCode.setStatus(VoucherCodeStatus.CLAIMED_UNUSED.getCode());
voucherCode.setClaimedTime(new Date());
voucherCodeMapper.updateById(availableCode);
voucherCodeMapper.updateById(voucherCode);
voucherBatchService.updateBatchClaimedCount(batch.getId());
return convertToResp(availableCode, batch);
return convertToResp(voucherCode, batch);
}
@Override

View File

@@ -0,0 +1,177 @@
package com.ycwl.basic.pricing.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.pricing.dto.req.VoucherPrintReq;
import com.ycwl.basic.pricing.dto.resp.VoucherPrintResp;
import com.ycwl.basic.pricing.entity.PriceVoucherCode;
import com.ycwl.basic.pricing.entity.VoucherPrintRecord;
import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper;
import com.ycwl.basic.pricing.mapper.VoucherPrintRecordMapper;
import com.ycwl.basic.pricing.service.VoucherPrintService;
import com.ycwl.basic.repository.FaceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 优惠券打印服务实现
*/
@Slf4j
@Service
public class VoucherPrintServiceImpl implements VoucherPrintService {
@Autowired
private VoucherPrintRecordMapper voucherPrintRecordMapper;
@Autowired
private PriceVoucherCodeMapper priceVoucherCodeMapper;
@Autowired
private FaceRepository faceRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String PRINT_LOCK_KEY = "voucher_print_lock:%s:%s:%s"; // faceId:brokerId:scenicId
private static final String CODE_PREFIX = "VT"; // Voucher Ticket
@Override
@Transactional(rollbackFor = Exception.class)
public VoucherPrintResp printVoucherTicket(VoucherPrintReq request) {
// 参数验证
if (request.getFaceId() == null) {
throw new BaseException("用户faceId不能为空");
}
if (request.getBrokerId() == null) {
throw new BaseException("经纪人ID不能为空");
}
if (request.getScenicId() == null) {
throw new BaseException("景区ID不能为空");
}
Long currentUserId = Long.valueOf(BaseContextHandler.getUserId());
// 验证faceId是否属于当前用户
validateFaceOwnership(request.getFaceId(), currentUserId);
// 使用Redis分布式锁防止重复打印
String lockKey = String.format(PRINT_LOCK_KEY, request.getFaceId(), request.getBrokerId(), request.getScenicId());
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁,超时时间30秒
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
if (!lockAcquired) {
throw new BaseException("请求处理中,请稍后再试");
}
// 检查是否已存在相同的打印记录
VoucherPrintRecord existingRecord = voucherPrintRecordMapper.selectByFaceBrokerScenic(
request.getFaceId(), request.getBrokerId(), request.getScenicId());
if (existingRecord != null) {
log.info("找到已存在的打印记录,返回该记录: {}", existingRecord.getId());
return buildResponse(existingRecord);
}
// 获取一个可用的券码(未被打印过的)
PriceVoucherCode availableVoucher = getAvailableUnprintedVoucher(request.getScenicId());
if (availableVoucher == null) {
throw new BaseException("暂无可用优惠券");
}
// 生成流水号
String code = generateCode();
// 创建打印记录
VoucherPrintRecord printRecord = new VoucherPrintRecord();
printRecord.setCode(code);
printRecord.setFaceId(request.getFaceId());
printRecord.setBrokerId(request.getBrokerId());
printRecord.setScenicId(request.getScenicId());
printRecord.setVoucherCodeId(availableVoucher.getId());
printRecord.setVoucherCode(availableVoucher.getCode());
printRecord.setPrintStatus(0); // 待打印
printRecord.setCreateTime(new Date());
printRecord.setUpdateTime(new Date());
printRecord.setDeleted(0);
voucherPrintRecordMapper.insert(printRecord);
// TODO: 调用打印机接口打印小票
// printTicket(printRecord);
// 暂时标记为打印成功状态(实际应该在打印成功回调中更新)
printRecord.setPrintStatus(1);
voucherPrintRecordMapper.updatePrintStatus(printRecord.getId(), 1, null);
log.info("成功创建打印记录: {}, 券码: {}", code, availableVoucher.getCode());
return buildResponse(printRecord);
} finally {
// 释放锁
if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
/**
* 验证faceId是否属于当前用户
*/
private void validateFaceOwnership(Long faceId, Long currentUserId) {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
throw new BaseException("用户人脸信息不存在");
}
if (!currentUserId.equals(face.getMemberId())) {
throw new BaseException("无权限操作该人脸信息");
}
}
/**
* 获取可用且未被打印过的券码
*/
private PriceVoucherCode getAvailableUnprintedVoucher(Long scenicId) {
return priceVoucherCodeMapper.findRandomUnprintedVoucher(scenicId);
}
/**
* 生成流水号
*/
private String generateCode() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timestamp = sdf.format(new Date());
String randomSuffix = String.valueOf((int)(Math.random() * 1000)).formatted("%03d");
return CODE_PREFIX + timestamp + randomSuffix;
}
/**
* 构建响应对象
*/
private VoucherPrintResp buildResponse(VoucherPrintRecord record) {
VoucherPrintResp response = new VoucherPrintResp();
BeanUtils.copyProperties(record, response);
return response;
}
/**
* 调用打印机接口(待实现)
*/
private void printTicket(VoucherPrintRecord record) {
// TODO: 实现打印机接口调用逻辑
log.info("TODO: 调用打印机打印小票,记录ID: {}, 券码: {}", record.getId(), record.getVoucherCode());
}
}