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 scenicId;
private Long brokerId; private Long brokerId;
private Long faceId; 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 可用券码 * @return 可用券码
*/ */
PriceVoucherCode findFirstAvailableByBatchId(@Param("batchId") Long batchId); 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) { if (req.getFaceId() == null) {
throw new BizException(400, "用户faceId不能为空"); 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())) { if (!canClaimVoucher(req.getFaceId(), req.getScenicId())) {
throw new BizException(400, "该用户在此景区已领取过券码"); throw new BizException(400, "该用户在此景区已领取过券码");
} }
PriceVoucherBatchConfig batch = voucherBatchService.getAvailableBatch(req.getScenicId(), req.getBrokerId()); // 获取券码所属批次
if (batch == null) { PriceVoucherBatchConfig batch = voucherBatchMapper.selectById(voucherCode.getBatchId());
throw new BizException(400, "暂无可用券码批次"); if (batch == null || batch.getDeleted() == 1) {
throw new BizException(400, "券码批次不存在");
} }
PriceVoucherCode availableCode = voucherCodeMapper.findFirstAvailableByBatchId(batch.getId()); // 验证批次是否可用于该推客
if (availableCode == null) { if (!Objects.equals(batch.getBrokerId(), req.getBrokerId())) {
throw new BizException(400, "券码已领完"); throw new BizException(400, "券码不属于该推客");
} }
availableCode.setFaceId(req.getFaceId()); // 更新券码状态
availableCode.setStatus(VoucherCodeStatus.CLAIMED_UNUSED.getCode()); voucherCode.setFaceId(req.getFaceId());
availableCode.setClaimedTime(new Date()); voucherCode.setStatus(VoucherCodeStatus.CLAIMED_UNUSED.getCode());
voucherCode.setClaimedTime(new Date());
voucherCodeMapper.updateById(availableCode); voucherCodeMapper.updateById(voucherCode);
voucherBatchService.updateBatchClaimedCount(batch.getId()); voucherBatchService.updateBatchClaimedCount(batch.getId());
return convertToResp(availableCode, batch); return convertToResp(voucherCode, batch);
} }
@Override @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());
}
}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper">
<resultMap id="BaseResultMap" type="com.ycwl.basic.pricing.entity.PriceVoucherCode">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="batch_id" property="batchId" jdbcType="BIGINT"/>
<result column="scenic_id" property="scenicId" jdbcType="BIGINT"/>
<result column="code" property="code" jdbcType="VARCHAR"/>
<result column="status" property="status" jdbcType="TINYINT"/>
<result column="face_id" property="faceId" jdbcType="BIGINT"/>
<result column="claimed_time" property="claimedTime" jdbcType="TIMESTAMP"/>
<result column="used_time" property="usedTime" jdbcType="TIMESTAMP"/>
<result column="remark" property="remark" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="TINYINT"/>
<result column="deleted_at" property="deletedAt" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List">
id, batch_id, scenic_id, code, status, face_id, claimed_time, used_time,
remark, create_time, update_time, deleted, deleted_at
</sql>
<select id="selectByCode" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE code = #{code}
AND deleted = 0
LIMIT 1
</select>
<select id="countByFaceIdAndScenicId" resultType="java.lang.Integer">
SELECT COUNT(1)
FROM price_voucher_code
WHERE face_id = #{faceId}
AND scenic_id = #{scenicId}
AND deleted = 0
</select>
<select id="selectAvailableVouchersByFaceIdAndScenicId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE face_id = #{faceId}
AND scenic_id = #{scenicId}
AND status = 1
AND deleted = 0
ORDER BY claimed_time DESC
</select>
<select id="selectUnclaimedVouchersByBatchId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE batch_id = #{batchId}
AND status = 0
AND deleted = 0
LIMIT #{limit}
</select>
<update id="claimVoucher">
UPDATE price_voucher_code
SET status = 1,
face_id = #{faceId},
claimed_time = #{claimedTime},
update_time = NOW()
WHERE id = #{id}
AND status = 0
AND deleted = 0
</update>
<update id="useVoucher">
UPDATE price_voucher_code
SET status = 2,
used_time = #{usedTime},
remark = #{remark},
update_time = NOW()
WHERE code = #{code}
AND status = 1
AND deleted = 0
</update>
<select id="selectByBatchId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE batch_id = #{batchId}
AND deleted = 0
ORDER BY create_time DESC
</select>
<select id="selectUserVouchers" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE face_id = #{faceId}
<if test="scenicId != null">
AND scenic_id = #{scenicId}
</if>
AND deleted = 0
ORDER BY claimed_time DESC
</select>
<select id="findFirstAvailableByBatchId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code
WHERE batch_id = #{batchId}
AND status = 0
AND deleted = 0
LIMIT 1
</select>
<select id="findRandomUnprintedVoucher" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM price_voucher_code pvc
WHERE pvc.scenic_id = #{scenicId}
AND pvc.status = 0
AND pvc.deleted = 0
AND NOT EXISTS (
SELECT 1 FROM voucher_print_record vpr
WHERE vpr.voucher_code_id = pvc.id
AND vpr.deleted = 0
)
ORDER BY RAND()
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.pricing.mapper.VoucherPrintRecordMapper">
<resultMap id="BaseResultMap" type="com.ycwl.basic.pricing.entity.VoucherPrintRecord">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="code" property="code" jdbcType="VARCHAR"/>
<result column="face_id" property="faceId" jdbcType="BIGINT"/>
<result column="broker_id" property="brokerId" jdbcType="BIGINT"/>
<result column="scenic_id" property="scenicId" jdbcType="BIGINT"/>
<result column="voucher_code_id" property="voucherCodeId" jdbcType="BIGINT"/>
<result column="voucher_code" property="voucherCode" jdbcType="VARCHAR"/>
<result column="print_status" property="printStatus" jdbcType="TINYINT"/>
<result column="error_message" property="errorMessage" jdbcType="VARCHAR"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
<result column="update_time" property="updateTime" jdbcType="TIMESTAMP"/>
<result column="deleted" property="deleted" jdbcType="TINYINT"/>
<result column="deleted_at" property="deletedAt" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List">
id, code, face_id, broker_id, scenic_id, voucher_code_id, voucher_code,
print_status, error_message, create_time, update_time, deleted, deleted_at
</sql>
<select id="selectByFaceBrokerScenic" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM voucher_print_record
WHERE face_id = #{faceId}
AND broker_id = #{brokerId}
AND scenic_id = #{scenicId}
AND deleted = 0
LIMIT 1
</select>
<select id="selectByVoucherCodeId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM voucher_print_record
WHERE voucher_code_id = #{voucherCodeId}
AND deleted = 0
LIMIT 1
</select>
<update id="updatePrintStatus">
UPDATE voucher_print_record
SET print_status = #{printStatus},
error_message = #{errorMessage},
update_time = NOW()
WHERE id = #{id}
</update>
</mapper>