diff --git a/src/main/java/com/ycwl/basic/order/service/impl/OrderServiceImpl.java b/src/main/java/com/ycwl/basic/order/service/impl/OrderServiceImpl.java index fa14b0af..11f83a7f 100644 --- a/src/main/java/com/ycwl/basic/order/service/impl/OrderServiceImpl.java +++ b/src/main/java/com/ycwl/basic/order/service/impl/OrderServiceImpl.java @@ -193,7 +193,7 @@ public class OrderServiceImpl implements IOrderService { // 标记券码为已使用 try { String remark = "订单使用,订单号:" + orderNo; - voucherService.markVoucherAsUsed(voucherInfo.getVoucherCode(), remark); + voucherService.markVoucherAsUsed(voucherInfo.getVoucherCode(), remark, String.valueOf(orderId), order.getDiscountAmount(), order.getFaceId()); log.info("券码状态更新成功: voucherCode={}, orderId={}", voucherInfo.getVoucherCode(), orderNo); } catch (Exception e) { diff --git a/src/main/java/com/ycwl/basic/pricing/controller/PriceCalculationController.java b/src/main/java/com/ycwl/basic/pricing/controller/PriceCalculationController.java index 8b4cd2eb..279d6b62 100644 --- a/src/main/java/com/ycwl/basic/pricing/controller/PriceCalculationController.java +++ b/src/main/java/com/ycwl/basic/pricing/controller/PriceCalculationController.java @@ -28,7 +28,7 @@ public class PriceCalculationController { @PostMapping("/calculate") public ApiResponse calculatePrice(@RequestBody PriceCalculationRequest request) { log.info("价格计算请求: userId={}, products={}", request.getUserId(), request.getProducts().size()); - + request.setPreviewOnly(true); PriceCalculationResult result = priceCalculationService.calculatePrice(request); log.info("价格计算完成: originalAmount={}, finalAmount={}, usedCoupon={}", diff --git a/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java b/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java index 573870ec..4e135693 100644 --- a/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java +++ b/src/main/java/com/ycwl/basic/pricing/controller/VoucherManagementController.java @@ -2,6 +2,7 @@ package com.ycwl.basic.pricing.controller; 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.req.VoucherClaimReq; import com.ycwl.basic.pricing.dto.req.VoucherCodeQueryReq; @@ -34,6 +35,12 @@ public class VoucherManagementController { return ApiResponse.success(batchId); } + @PostMapping("/batch/create/v2") + public ApiResponse createBatchV2(@RequestBody VoucherBatchCreateReqV2 req) { + Long batchId = voucherBatchService.createBatchV2(req); + return ApiResponse.success(batchId); + } + @PostMapping("/batch/list") public ApiResponse> getBatchList(@RequestBody VoucherBatchQueryReq req) { Page page = voucherBatchService.queryBatchList(req); diff --git a/src/main/java/com/ycwl/basic/pricing/controller/VoucherUsageController.java b/src/main/java/com/ycwl/basic/pricing/controller/VoucherUsageController.java new file mode 100644 index 00000000..0c4f47cd --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/controller/VoucherUsageController.java @@ -0,0 +1,88 @@ +package com.ycwl.basic.pricing.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.utils.ApiResponse; +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.service.IVoucherUsageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 券码使用记录管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/pricing/voucher/usage") +@RequiredArgsConstructor +public class VoucherUsageController { + + private final IVoucherUsageService voucherUsageService; + + @PostMapping("/history") + public ApiResponse> getUsageHistory(@RequestBody VoucherUsageHistoryReq req) { + try { + Page result = voucherUsageService.getUsageHistory(req); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("查询券码使用记录失败", e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } + + @GetMapping("/{voucherCode}/records") + public ApiResponse> getRecordsByCode( + @PathVariable String voucherCode) { + try { + List records = voucherUsageService.getUsageRecordsByCode(voucherCode); + return ApiResponse.success(records); + } catch (Exception e) { + log.error("查询券码使用记录失败: {}", voucherCode, e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } + + @GetMapping("/{voucherCode}/stats") + public ApiResponse getUsageStats( + @PathVariable String voucherCode) { + try { + VoucherUsageStatsResp stats = voucherUsageService.getUsageStats(voucherCode); + if (stats == null) { + return ApiResponse.fail("券码不存在"); + } + return ApiResponse.success(stats); + } catch (Exception e) { + log.error("查询券码统计信息失败: {}", voucherCode, e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } + + @GetMapping("/user/{faceId}/scenic/{scenicId}") + public ApiResponse> getUserUsageRecords( + @PathVariable Long faceId, + @PathVariable Long scenicId) { + try { + List records = voucherUsageService.getUserUsageRecords(faceId, scenicId); + return ApiResponse.success(records); + } catch (Exception e) { + log.error("查询用户券码使用记录失败: faceId={}, scenicId={}", faceId, scenicId, e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } + + @GetMapping("/batch/{batchId}/stats") + public ApiResponse> getBatchUsageStats( + @PathVariable Long batchId) { + try { + List statsList = voucherUsageService.getBatchUsageStats(batchId); + return ApiResponse.success(statsList); + } catch (Exception e) { + log.error("查询批次券码统计信息失败: batchId={}", batchId, e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/MobilePriceCalculationRequest.java b/src/main/java/com/ycwl/basic/pricing/dto/MobilePriceCalculationRequest.java index d7e49dd7..7d8642ed 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/MobilePriceCalculationRequest.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/MobilePriceCalculationRequest.java @@ -56,7 +56,7 @@ public class MobilePriceCalculationRequest { request.setAutoUseCoupon(this.autoUseCoupon); request.setVoucherCode(this.voucherCode); request.setAutoUseVoucher(this.autoUseVoucher); - request.setPreviewOnly(this.previewOnly); + request.setPreviewOnly(true); return request; } } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java b/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java index 1492af2e..88f7ce89 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/VoucherInfo.java @@ -89,4 +89,34 @@ public class VoucherInfo { * null表示适用所有商品类型 */ private List applicableProducts; + + /** + * 当前使用次数 + */ + private Integer currentUseCount; + + /** + * 最大使用次数 + */ + private Integer maxUseCount; + + /** + * 每个用户最大使用次数 + */ + private Integer maxUsePerUser; + + /** + * 使用间隔小时数 + */ + private Integer useIntervalHours; + + /** + * 剩余可使用次数 + */ + private Integer remainingUseCount; + + /** + * 最后使用时间 + */ + private Date lastUsedTime; } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReqV2.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReqV2.java new file mode 100644 index 00000000..42a05fe6 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherBatchCreateReqV2.java @@ -0,0 +1,73 @@ +package com.ycwl.basic.pricing.dto.req; + +import com.ycwl.basic.pricing.enums.ProductType; +import lombok.Data; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.List; + +/** + * 券码批次创建请求(支持可重复使用) + */ +@Data +public class VoucherBatchCreateReqV2 { + + /** + * 券码批次名称 + */ + @NotBlank(message = "券码批次名称不能为空") + private String batchName; + + /** + * 景区ID + */ + @NotNull(message = "景区ID不能为空") + private Long scenicId; + + /** + * 推客ID + */ + @NotNull(message = "推客ID不能为空") + private Long brokerId; + + /** + * 优惠类型:0=全场免费,1=商品降价,2=商品打折 + */ + @NotNull(message = "优惠类型不能为空") + private Integer discountType; + + /** + * 优惠值(降价金额或折扣百分比) + */ + private BigDecimal discountValue; + + /** + * 适用商品类型列表 + */ + private List applicableProducts; + + /** + * 券码总数量 + */ + @NotNull(message = "券码数量不能为空") + @Min(value = 1, message = "券码数量必须大于0") + private Integer totalCount; + + /** + * 每个券码最大使用次数(NULL表示无限次,1表示单次使用) + */ + private Integer maxUseCount; + + /** + * 每个用户最大使用次数(NULL表示无限次) + */ + private Integer maxUsePerUser; + + /** + * 两次使用间隔小时数(NULL表示无间隔限制) + */ + private Integer useIntervalHours; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherUsageHistoryReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherUsageHistoryReq.java new file mode 100644 index 00000000..70c187e9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/VoucherUsageHistoryReq.java @@ -0,0 +1,55 @@ +package com.ycwl.basic.pricing.dto.req; + +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 券码使用历史查询请求 + */ +@Data +public class VoucherUsageHistoryReq { + + /** + * 券码 + */ + private String voucherCode; + + /** + * 用户faceId + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 开始时间 + */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** + * 结束时间 + */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + /** + * 页码 + */ + private Integer pageNum = 1; + + /** + * 页面大小 + */ + private Integer pageSize = 10; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageRecordResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageRecordResp.java new file mode 100644 index 00000000..59b75a90 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageRecordResp.java @@ -0,0 +1,76 @@ +package com.ycwl.basic.pricing.dto.resp; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 券码使用记录响应 + */ +@Data +public class VoucherUsageRecordResp { + + /** + * 记录ID + */ + private Long id; + + /** + * 券码ID + */ + private Long voucherCodeId; + + /** + * 券码 + */ + private String voucherCode; + + /** + * 使用用户faceId + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 批次名称 + */ + private String batchName; + + /** + * 使用时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date useTime; + + /** + * 关联订单ID + */ + private String orderId; + + /** + * 优惠金额 + */ + private BigDecimal discountAmount; + + /** + * 使用备注 + */ + private String remark; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageStatsResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageStatsResp.java new file mode 100644 index 00000000..4275449c --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/VoucherUsageStatsResp.java @@ -0,0 +1,85 @@ +package com.ycwl.basic.pricing.dto.resp; + +import lombok.Data; + +/** + * 券码使用统计响应 + */ +@Data +public class VoucherUsageStatsResp { + + /** + * 券码ID + */ + private Long voucherCodeId; + + /** + * 券码 + */ + private String voucherCode; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 批次名称 + */ + private String batchName; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 券码状态 + */ + private Integer status; + + /** + * 状态名称 + */ + private String statusName; + + /** + * 当前使用次数 + */ + private Integer currentUseCount; + + /** + * 最大使用次数 + */ + private Integer maxUseCount; + + /** + * 每个用户最大使用次数 + */ + private Integer maxUsePerUser; + + /** + * 使用间隔小时数 + */ + private Integer useIntervalHours; + + /** + * 是否还可以使用 + */ + private Boolean canUseMore; + + /** + * 剩余可使用次数 + */ + private Integer remainingUseCount; + + /** + * 总使用记录数 + */ + private Integer totalUsageRecords; + + /** + * 最后使用时间 + */ + private String lastUsedTime; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java index 2539e9b3..99c74f47 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherBatchConfig.java @@ -96,6 +96,21 @@ public class PriceVoucherBatchConfig { private Long updateBy; + /** + * 每个券码最大使用次数(NULL表示无限次,1表示单次使用兼容原有逻辑) + */ + private Integer maxUseCount; + + /** + * 每个用户最大使用次数(NULL表示无限次) + */ + private Integer maxUsePerUser; + + /** + * 两次使用间隔小时数(NULL表示无间隔限制) + */ + private Integer useIntervalHours; + private Integer deleted; private Date deletedAt; diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java index 1b59dc2e..f2288cae 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherCode.java @@ -58,6 +58,16 @@ public class PriceVoucherCode { */ private String remark; + /** + * 当前使用次数 + */ + private Integer currentUseCount; + + /** + * 最后使用时间 + */ + private Date lastUsedTime; + @TableField("create_time") private Date createTime; diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherUsageRecord.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherUsageRecord.java new file mode 100644 index 00000000..b8c8c937 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceVoucherUsageRecord.java @@ -0,0 +1,76 @@ +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.math.BigDecimal; +import java.util.Date; + +/** + * 券码使用记录实体 + */ +@Data +@TableName("price_voucher_usage_record") +public class PriceVoucherUsageRecord { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 券码ID + */ + private Long voucherCodeId; + + /** + * 券码 + */ + private String voucherCode; + + /** + * 使用用户faceId + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 批次ID + */ + private Long batchId; + + /** + * 使用时间 + */ + private Date useTime; + + /** + * 关联订单ID + */ + private String orderId; + + /** + * 优惠金额 + */ + private BigDecimal discountAmount; + + /** + * 使用备注 + */ + private String remark; + + @TableField("create_time") + private Date createTime; + + @TableField("update_time") + private Date updateTime; + + private Integer deleted; + + private Date deletedAt; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java b/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java index b4651bbf..73a0105f 100644 --- a/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java +++ b/src/main/java/com/ycwl/basic/pricing/enums/VoucherCodeStatus.java @@ -16,14 +16,29 @@ public enum VoucherCodeStatus { UNCLAIMED(0, "未领取"), /** - * 已领取未使用 + * 已领取可使用(可重复使用状态,替代原CLAIMED_UNUSED) + */ + CLAIMED_AVAILABLE(1, "已领取可使用"), + + /** + * 已领取未使用(兼容原有逻辑,等同于CLAIMED_AVAILABLE) */ CLAIMED_UNUSED(1, "已领取未使用"), /** - * 已使用 + * 已使用(兼容原有逻辑,当达到最大使用次数时为此状态) */ - USED(2, "已使用"); + USED(2, "已使用"), + + /** + * 已领取已用完(达到最大使用次数) + */ + CLAIMED_EXHAUSTED(3, "已领取已用完"), + + /** + * 已过期 + */ + EXPIRED(4, "已过期"); private final Integer code; private final String name; @@ -55,20 +70,38 @@ public enum VoucherCodeStatus { } /** - * 检查是否可以使用(已领取未使用状态) + * 检查是否可以使用(已领取可使用状态) * @param code 状态代码 * @return 是否可以使用 */ public static boolean canUse(Integer code) { - return CLAIMED_UNUSED.getCode().equals(code); + return CLAIMED_AVAILABLE.getCode().equals(code) || CLAIMED_UNUSED.getCode().equals(code); } /** - * 检查是否已使用 + * 检查是否已完全使用完(已使用或已用完状态) + * @param code 状态代码 + * @return 是否已完全使用完 + */ + public static boolean isExhausted(Integer code) { + return USED.getCode().equals(code) || CLAIMED_EXHAUSTED.getCode().equals(code); + } + + /** + * 检查是否为已使用状态(兼容原有逻辑) * @param code 状态代码 * @return 是否已使用 */ public static boolean isUsed(Integer code) { return USED.getCode().equals(code); } + + /** + * 检查是否已过期 + * @param code 状态代码 + * @return 是否已过期 + */ + public static boolean isExpired(Integer code) { + return EXPIRED.getCode().equals(code); + } } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherUsageRecordMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherUsageRecordMapper.java new file mode 100644 index 00000000..66dc111e --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceVoucherUsageRecordMapper.java @@ -0,0 +1,141 @@ +package com.ycwl.basic.pricing.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.pricing.entity.PriceVoucherUsageRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.Date; +import java.util.List; + +/** + * 券码使用记录Mapper + */ +@Mapper +public interface PriceVoucherUsageRecordMapper extends BaseMapper { + + /** + * 根据券码ID查询使用记录 + * + * @param voucherCodeId 券码ID + * @return 使用记录列表 + */ + @Select("SELECT * FROM price_voucher_usage_record WHERE voucher_code_id = #{voucherCodeId} AND deleted = 0 ORDER BY use_time DESC") + List selectByVoucherCodeId(@Param("voucherCodeId") Long voucherCodeId); + + /** + * 根据券码查询使用记录 + * + * @param voucherCode 券码 + * @return 使用记录列表 + */ + @Select("SELECT * FROM price_voucher_usage_record WHERE voucher_code = #{voucherCode} AND deleted = 0 ORDER BY use_time DESC") + List selectByVoucherCode(@Param("voucherCode") String voucherCode); + + /** + * 根据用户和景区查询使用记录 + * + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 使用记录列表 + */ + @Select("SELECT * FROM price_voucher_usage_record WHERE face_id = #{faceId} AND scenic_id = #{scenicId} AND deleted = 0 ORDER BY use_time DESC") + List selectByFaceIdAndScenicId(@Param("faceId") Long faceId, @Param("scenicId") Long scenicId); + + /** + * 统计用户在指定券码上的使用次数 + * + * @param faceId 用户faceId + * @param voucherCodeId 券码ID + * @return 使用次数 + */ + @Select("SELECT COUNT(*) FROM price_voucher_usage_record WHERE face_id = #{faceId} AND voucher_code_id = #{voucherCodeId} AND deleted = 0") + Integer countByFaceIdAndVoucherCodeId(@Param("faceId") Long faceId, @Param("voucherCodeId") Long voucherCodeId); + + /** + * 统计指定时间段内用户的使用次数 + * + * @param faceId 用户faceId + * @param voucherCodeId 券码ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 使用次数 + */ + @Select("SELECT COUNT(*) FROM price_voucher_usage_record WHERE face_id = #{faceId} AND voucher_code_id = #{voucherCodeId} " + + "AND use_time BETWEEN #{startTime} AND #{endTime} AND deleted = 0") + Integer countByFaceIdAndVoucherCodeIdAndTimeRange(@Param("faceId") Long faceId, + @Param("voucherCodeId") Long voucherCodeId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + + /** + * 获取用户最后一次使用该券码的时间 + * + * @param faceId 用户faceId + * @param voucherCodeId 券码ID + * @return 最后使用时间 + */ + @Select("SELECT MAX(use_time) FROM price_voucher_usage_record WHERE face_id = #{faceId} AND voucher_code_id = #{voucherCodeId} AND deleted = 0") + Date getLastUseTimeByFaceIdAndVoucherCodeId(@Param("faceId") Long faceId, @Param("voucherCodeId") Long voucherCodeId); + + /** + * 根据批次ID统计使用记录数量 + * + * @param batchId 批次ID + * @return 使用记录数量 + */ + @Select("SELECT COUNT(*) FROM price_voucher_usage_record WHERE batch_id = #{batchId} AND deleted = 0") + Integer countByBatchId(@Param("batchId") Long batchId); + + /** + * 统计用户在指定批次下的使用次数 + * + * @param faceId 用户faceId + * @param batchId 批次ID + * @return 使用次数 + */ + @Select("SELECT COUNT(*) FROM price_voucher_usage_record WHERE face_id = #{faceId} AND batch_id = #{batchId} AND deleted = 0") + Integer countByFaceIdAndBatchId(@Param("faceId") Long faceId, @Param("batchId") Long batchId); + + /** + * 获取用户在指定批次下最后一次使用时间 + * + * @param faceId 用户faceId + * @param batchId 批次ID + * @return 最后使用时间 + */ + @Select("SELECT MAX(use_time) FROM price_voucher_usage_record WHERE face_id = #{faceId} AND batch_id = #{batchId} AND deleted = 0") + Date getLastUseTimeByFaceIdAndBatchId(@Param("faceId") Long faceId, @Param("batchId") Long batchId); + + /** + * 分页查询券码使用记录 + * + * @param page 分页参数 + * @param batchId 批次ID(可选) + * @param voucherCode 券码(可选) + * @param faceId 用户faceId(可选) + * @param scenicId 景区ID(可选) + * @param startTime 开始时间(可选) + * @param endTime 结束时间(可选) + * @return 分页结果 + */ + @Select("") + Page selectPageWithConditions(Page page, + @Param("batchId") Long batchId, + @Param("voucherCode") String voucherCode, + @Param("faceId") Long faceId, + @Param("scenicId") Long scenicId, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java b/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java index ef0f4aa4..4f05658c 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java +++ b/src/main/java/com/ycwl/basic/pricing/service/IVoucherService.java @@ -27,14 +27,16 @@ public interface IVoucherService { * @return 可用券码列表 */ List getAvailableVouchers(Long faceId, Long scenicId); - /** * 标记券码为已使用 * @param voucherCode 券码 * @param remark 使用备注 + * @param faceId 人脸ID */ - void markVoucherAsUsed(String voucherCode, String remark); - + void markVoucherAsUsed(String voucherCode, String remark, Long faceId); + + void markVoucherAsUsed(String voucherCode, String remark, String orderId, BigDecimal discountAmount, Long faceId); + /** * 检查用户是否可以在指定景区领取券码 * @param faceId 用户faceId diff --git a/src/main/java/com/ycwl/basic/pricing/service/IVoucherUsageService.java b/src/main/java/com/ycwl/basic/pricing/service/IVoucherUsageService.java new file mode 100644 index 00000000..562dd3c9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/IVoucherUsageService.java @@ -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 getUsageHistory(VoucherUsageHistoryReq req); + + /** + * 获取指定券码的使用记录 + * + * @param voucherCode 券码 + * @return 使用记录列表 + */ + List getUsageRecordsByCode(String voucherCode); + + /** + * 获取用户在指定景区的券码使用记录 + * + * @param faceId 用户faceId + * @param scenicId 景区ID + * @return 使用记录列表 + */ + List getUserUsageRecords(Long faceId, Long scenicId); + + /** + * 获取券码使用统计信息 + * + * @param voucherCode 券码 + * @return 统计信息 + */ + VoucherUsageStatsResp getUsageStats(String voucherCode); + + /** + * 获取批次券码使用统计信息 + * + * @param batchId 批次ID + * @return 统计信息列表 + */ + List 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); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java b/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java index f54d9be0..a34aded2 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java +++ b/src/main/java/com/ycwl/basic/pricing/service/VoucherBatchService.java @@ -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 queryBatchList(VoucherBatchQueryReq req); VoucherBatchResp getBatchDetail(Long id); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java index 9f64f6e7..cbfee913 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java @@ -387,7 +387,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { // 标记券码为已使用 if (result.getUsedVoucher() != null && result.getUsedVoucher().getVoucherCode() != null) { String remark = String.format("价格计算使用 - 订单金额: %s", result.getFinalAmount()); - voucherService.markVoucherAsUsed(result.getUsedVoucher().getVoucherCode(), remark); + voucherService.markVoucherAsUsed(result.getUsedVoucher().getVoucherCode(), remark, request.getFaceId()); log.info("已标记券码为使用: {}", result.getUsedVoucher().getVoucherCode()); } diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java index 8ce020e0..383dc2c8 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherBatchServiceImpl.java @@ -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 queryBatchList(VoucherBatchQueryReq req) { Page page = new Page<>(req.getPageNum(), req.getPageSize()); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java index 250713ba..74b6d074 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherDiscountProvider.java @@ -112,21 +112,34 @@ public class VoucherDiscountProvider implements IDiscountProvider { return result; } - // 重新验证券码 + // 重新验证券码,包括重复使用权限验证 VoucherInfo voucherInfo = voucherService.validateAndGetVoucherInfo( voucherCode, context.getFaceId(), context.getScenicId() ); - if (voucherInfo == null || !Boolean.TRUE.equals(voucherInfo.getAvailable())) { + if (voucherInfo == null) { result.setSuccess(false); - result.setFailureReason("券码无效或不可用"); + result.setFailureReason("券码不存在或已失效"); + return result; + } + + if (!Boolean.TRUE.equals(voucherInfo.getAvailable())) { + result.setSuccess(false); + result.setFailureReason(voucherInfo.getUnavailableReason() != null ? + voucherInfo.getUnavailableReason() : "券码不可用"); return result; } // 计算实际优惠金额 BigDecimal actualDiscount = voucherService.calculateVoucherDiscount(voucherInfo, context); + if (actualDiscount.compareTo(BigDecimal.ZERO) <= 0) { + result.setSuccess(false); + result.setFailureReason("券码无法应用到当前商品"); + return result; + } + BigDecimal finalAmount; // 对于全场免费券码,最终金额为0 @@ -145,7 +158,11 @@ public class VoucherDiscountProvider implements IDiscountProvider { result.setFinalAmount(finalAmount); result.setSuccess(true); - log.info("成功应用券码: {}, 优惠金额: {}", voucherCode, actualDiscount); + // 显示剩余使用次数信息,处理无限次使用的情况 + String remainingInfo = voucherInfo.getRemainingUseCount() != null ? + voucherInfo.getRemainingUseCount().toString() : "无限次"; + log.info("成功应用券码: {}, 优惠金额: {}, 剩余使用次数: {}", + voucherCode, actualDiscount, remainingInfo); } catch (Exception e) { log.error("应用券码失败: " + discountInfo.getVoucherCode(), e); @@ -217,7 +234,7 @@ public class VoucherDiscountProvider implements IDiscountProvider { if (productConfig != null) { if (!Boolean.TRUE.equals(productConfig.getCanUseVoucher())) { - log.debug("商品配置不允许使用券码: productType={}, productId={}", + log.info("商品配置不允许使用券码: productType={}, productId={}", product.getProductType().getCode(), productId); return false; } diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java index abf043c0..c5612a5b 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherServiceImpl.java @@ -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; @@ -18,9 +20,9 @@ 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; import java.util.stream.Collectors; @@ -34,6 +36,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) { @@ -60,27 +63,63 @@ public class VoucherServiceImpl implements IVoucherService { VoucherInfo voucherInfo = buildVoucherInfo(voucherCodeEntity, batchConfig); - // 检查券码状态和可用性 + // 检查券码状态和可用性,包含完整的重复使用权限验证 if (VoucherCodeStatus.UNCLAIMED.getCode().equals(voucherCodeEntity.getStatus())) { - // 未领取状态,检查是否可以领取 - if (faceId != null) { // && canClaimVoucher(faceId, voucherCodeEntity.getScenicId()) - voucherInfo.setAvailable(true); - } else { + // 未领取状态,也需要检查用户的重复使用权限 + if (faceId == null) { voucherInfo.setAvailable(false); - voucherInfo.setUnavailableReason("您已在该景区领取过券码"); - } - } else if (VoucherCodeStatus.CLAIMED_UNUSED.getCode().equals(voucherCodeEntity.getStatus())) { - // 已领取未使用,检查是否为当前用户 - if (faceId != null && faceId.equals(voucherCodeEntity.getFaceId())) { - voucherInfo.setAvailable(true); + voucherInfo.setUnavailableReason("用户信息缺失,无法验证券码权限"); } else { + // 对于未领取的券码,检查用户在该批次下的使用权限 + String availabilityCheck = checkUserUsagePermission(voucherCodeEntity, batchConfig, faceId, false); + if (availabilityCheck == null) { + voucherInfo.setAvailable(true); + log.debug("未领取券码验证通过: code={}, faceId={}", voucherCode, faceId); + } else { + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason(availabilityCheck); + log.info("未领取券码使用受限: code={}, faceId={}, reason={}", + voucherCode, faceId, availabilityCheck); + } + } + } else if (VoucherCodeStatus.canUse(voucherCodeEntity.getStatus())) { + // 已领取可使用状态,进行完整的权限验证 + if (faceId == null) { + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("用户信息缺失,无法验证券码权限"); + } else if (!faceId.equals(voucherCodeEntity.getFaceId())) { voucherInfo.setAvailable(false); voucherInfo.setUnavailableReason("券码已被其他用户领取"); + } else { + // 执行详细的重复使用权限验证(已领取状态) + String availabilityCheck = checkUserUsagePermission(voucherCodeEntity, batchConfig, faceId, true); + if (availabilityCheck == null) { + voucherInfo.setAvailable(true); + log.debug("券码验证通过: code={}, faceId={}, currentUseCount={}, maxUseCount={}", + voucherCode, faceId, voucherCodeEntity.getCurrentUseCount(), batchConfig.getMaxUseCount()); + } else { + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason(availabilityCheck); + log.info("券码使用受限: code={}, faceId={}, reason={}", + voucherCode, faceId, availabilityCheck); + } } - } else { - // 已使用 + } else if (VoucherCodeStatus.isExhausted(voucherCodeEntity.getStatus())) { + // 已用完或已使用 voucherInfo.setAvailable(false); - voucherInfo.setUnavailableReason("券码已使用"); + if (batchConfig.getMaxUseCount() != null && batchConfig.getMaxUseCount() == 1) { + voucherInfo.setUnavailableReason("券码已使用"); + } else { + voucherInfo.setUnavailableReason("券码使用次数已达上限"); + } + } else if (VoucherCodeStatus.isExpired(voucherCodeEntity.getStatus())) { + // 已过期 + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("券码已过期"); + } else { + // 其他状态 + voucherInfo.setAvailable(false); + voucherInfo.setUnavailableReason("券码状态异常"); } return voucherInfo; @@ -106,22 +145,83 @@ public class VoucherServiceImpl implements IVoucherService { return voucherInfos; } - + @Override - public void markVoucherAsUsed(String voucherCode, String remark) { + public void markVoucherAsUsed(String voucherCode, String remark, Long faceId) { + markVoucherAsUsed(voucherCode, remark, null, null, faceId); + } + + /** + * 标记券码为已使用(支持可重复使用) + * + * @param voucherCode 券码 + * @param remark 使用备注 + * @param orderId 订单ID + * @param discountAmount 优惠金额 + * @param faceId 人脸ID + */ + @Override + public void markVoucherAsUsed(String voucherCode, String remark, String orderId, BigDecimal discountAmount, Long faceId) { 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(faceId != null ? faceId : 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={}, faceId={}", + voucherCode, currentUseCount, maxUseCount, faceId); } @Override @@ -260,6 +360,79 @@ public class VoucherServiceImpl implements IVoucherService { return bestVoucher; } + /** + * 统一检查用户券码使用权限 + * + * @param voucherCode 券码实体 + * @param batchConfig 批次配置 + * @param faceId 用户faceId + * @param isClaimed 是否为已领取状态(true=已领取,检查具体券码权限;false=未领取,检查批次权限) + * @return 不可用原因,null表示可用 + */ + private String checkUserUsagePermission(PriceVoucherCode voucherCode, PriceVoucherBatchConfig batchConfig, Long faceId, boolean isClaimed) { + if (faceId == null) { + return "用户信息缺失"; + } + + // 1. 检查券码使用次数限制(仅针对已领取的券码) + if (isClaimed) { + 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) { + Integer userUseCount; + if (isClaimed) { + // 已领取:检查用户对该具体券码的使用次数 + userUseCount = usageRecordMapper.countByFaceIdAndVoucherCodeId(faceId, voucherCode.getId()); + if (userUseCount >= maxUsePerUser) { + return "您使用该券码的次数已达上限"; + } + } else { + // 未领取:检查用户在该批次下的总使用次数 + userUseCount = usageRecordMapper.countByFaceIdAndBatchId(faceId, batchConfig.getId()); + if (userUseCount >= maxUsePerUser) { + return "您在该批次下使用券码的次数已达上限"; + } + } + } + + // 3. 检查使用间隔时间限制 + Integer useIntervalHours = batchConfig.getUseIntervalHours(); + if (useIntervalHours != null) { + Date lastUseTime; + if (isClaimed) { + // 已领取:检查该券码的最后使用时间 + 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); + } + } + } else { + // 未领取:检查该批次下的最后使用时间 + lastUseTime = usageRecordMapper.getLastUseTimeByFaceIdAndBatchId(faceId, batchConfig.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 +451,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; } diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherUsageServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherUsageServiceImpl.java new file mode 100644 index 00000000..cfeae1c3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/VoucherUsageServiceImpl.java @@ -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 getUsageHistory(VoucherUsageHistoryReq req) { + Page page = new Page<>(req.getPageNum(), req.getPageSize()); + + Page entityPage = usageRecordMapper.selectPageWithConditions( + page, req.getBatchId(), req.getVoucherCode(), req.getFaceId(), + req.getScenicId(), req.getStartTime(), req.getEndTime()); + + Page respPage = new Page<>(); + BeanUtils.copyProperties(entityPage, respPage); + + List respList = new ArrayList<>(); + for (PriceVoucherUsageRecord record : entityPage.getRecords()) { + VoucherUsageRecordResp resp = convertToResp(record); + respList.add(resp); + } + respPage.setRecords(respList); + + return respPage; + } + + @Override + public List getUsageRecordsByCode(String voucherCode) { + List records = usageRecordMapper.selectByVoucherCode(voucherCode); + List respList = new ArrayList<>(); + + for (PriceVoucherUsageRecord record : records) { + respList.add(convertToResp(record)); + } + + return respList; + } + + @Override + public List getUserUsageRecords(Long faceId, Long scenicId) { + List records = usageRecordMapper.selectByFaceIdAndScenicId(faceId, scenicId); + List 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 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/repository/PriceRepository.java b/src/main/java/com/ycwl/basic/repository/PriceRepository.java index 376dbad4..048a7508 100644 --- a/src/main/java/com/ycwl/basic/repository/PriceRepository.java +++ b/src/main/java/com/ycwl/basic/repository/PriceRepository.java @@ -2,19 +2,13 @@ package com.ycwl.basic.repository; import com.ycwl.basic.pricing.entity.PriceOnePriceConfig; import com.ycwl.basic.pricing.service.IOnePricePurchaseService; -import com.ycwl.basic.utils.JacksonUtil; import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; -import java.util.Set; - @Component public class PriceRepository { - @Autowired - private RedisTemplate redisTemplate; - public static final String PRICE_ID_CACHE = "price:%s"; @Autowired private IOnePricePurchaseService onePricePurchaseService; @@ -48,27 +42,7 @@ public class PriceRepository { priceConfig.setPrice(config.getOnePrice()); return priceConfig; } - String cacheKey = String.format(PRICE_ID_CACHE, id); - PriceConfigEntity priceConfigEntity = null; - if (redisTemplate.hasKey(cacheKey)) { - priceConfigEntity = JacksonUtil.parseObject(redisTemplate.opsForValue().get(cacheKey), PriceConfigEntity.class); - } - return priceConfigEntity; + return null; } - public void clearPriceCache(Integer id) { - if (redisTemplate.hasKey(String.format(PRICE_ID_CACHE, id))) { - PriceConfigEntity priceConfig = getPriceConfig(id); - if (priceConfig != null) { - clearPriceScenicCache(priceConfig.getScenicId()); - } - } - redisTemplate.delete(String.format(PRICE_ID_CACHE, id)); - } - - public void clearPriceScenicCache(Long scenicId) { - String cacheKey = String.format("price:s%s:*", scenicId); - Set keys = redisTemplate.keys(cacheKey); - redisTemplate.delete(keys); - } } diff --git a/src/main/java/com/ycwl/basic/repository/ScenicRepository.java b/src/main/java/com/ycwl/basic/repository/ScenicRepository.java index 436c6ca3..a2e5365b 100644 --- a/src/main/java/com/ycwl/basic/repository/ScenicRepository.java +++ b/src/main/java/com/ycwl/basic/repository/ScenicRepository.java @@ -41,23 +41,17 @@ public class ScenicRepository { @Autowired private ScenicConfigIntegrationService scenicConfigIntegrationService; - public static final String SCENIC_CACHE_KEY = "scenic:%s"; - public static final String SCENIC_BASIC_CACHE_KEY = "scenic:basic:%s"; - public static final String SCENIC_FULL_CACHE_KEY = "scenic:f%s"; - public static final String SCENIC_CONFIG_CACHE_KEY = "scenic:%s:config"; public static final String SCENIC_MP_CACHE_KEY = "scenic:%s:mp"; public static final String SCENIC_MP_NOTIFY_CACHE_KEY = "scenic:%s:mpNotify"; @Autowired private MpNotifyConfigMapper mpNotifyConfigMapper; public ScenicV2DTO getScenicBasic(Long id) { - String key = String.format(SCENIC_BASIC_CACHE_KEY, id); ScenicV2DTO scenicDTO = scenicIntegrationService.getScenic(id); return scenicDTO; } public ScenicEntity getScenic(Long id) { - String key = String.format(SCENIC_CACHE_KEY, id); ScenicV2WithConfigDTO scenicDTO = scenicIntegrationService.getScenicWithConfig(id); ScenicEntity scenicEntity = convertToScenicEntity(scenicDTO); return scenicEntity; @@ -226,27 +220,6 @@ public class ScenicRepository { } } - public void clearCache(Long scenicId) { - redisTemplate.delete(String.format(SCENIC_CACHE_KEY, scenicId)); - redisTemplate.delete(String.format(SCENIC_BASIC_CACHE_KEY, scenicId)); - redisTemplate.delete(String.format(SCENIC_FULL_CACHE_KEY, scenicId)); - redisTemplate.delete(String.format(SCENIC_CONFIG_CACHE_KEY, scenicId)); - redisTemplate.delete(String.format(SCENIC_MP_CACHE_KEY, scenicId)); - redisTemplate.delete(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId)); - } - - private ScenicEntity convertToScenicEntity(ScenicV2DTO dto) { - if (dto == null) { - return null; - } - ScenicEntity entity = new ScenicEntity(); - entity.setId(Long.parseLong(dto.getId())); - entity.setName(dto.getName()); - entity.setMpId(dto.getMpId()); - entity.setStatus(dto.getStatus().toString()); - return entity; - } - private ScenicEntity convertToScenicEntity(ScenicV2WithConfigDTO dto) { if (dto == null) { return null; @@ -272,61 +245,6 @@ public class ScenicRepository { return entity; } - private ScenicConfigEntity convertToScenicConfigEntity(ScenicV2WithConfigDTO dto, Long scenicId) { - if (dto == null || dto.getConfig() == null) { - return null; - } - - ScenicConfigEntity entity = new ScenicConfigEntity(); - entity.setScenicId(scenicId); - - java.util.Map config = dto.getConfig(); - - entity.setBookRoutine(ConfigValueUtil.getIntValue(config, "bookRoutine")); - entity.setForceFinishTime(ConfigValueUtil.getIntValue(config, "forceFinishTime")); - entity.setTourTime(ConfigValueUtil.getIntValue(config, "tourTime")); - entity.setSampleStoreDay(ConfigValueUtil.getIntValue(config, "sampleStoreDay")); - entity.setFaceStoreDay(ConfigValueUtil.getIntValue(config, "faceStoreDay")); - entity.setVideoStoreDay(ConfigValueUtil.getIntValue(config, "videoStoreDay")); - entity.setAllFree(ConfigValueUtil.getBooleanValue(config, "allFree")); - entity.setDisableSourceVideo(ConfigValueUtil.getBooleanValue(config, "disableSourceVideo")); - entity.setDisableSourceImage(ConfigValueUtil.getBooleanValue(config, "disableSourceImage")); - entity.setTemplateNewVideoType(ConfigValueUtil.getIntValue(config, "templateNewVideoType")); - entity.setAntiScreenRecordType(ConfigValueUtil.getIntValue(config, "antiScreenRecordType")); - entity.setVideoSourceStoreDay(ConfigValueUtil.getIntValue(config, "videoSourceStoreDay")); - entity.setImageSourceStoreDay(ConfigValueUtil.getIntValue(config, "imageSourceStoreDay")); - entity.setUserSourceExpireDay(ConfigValueUtil.getIntValue(config, "userSourceExpireDay")); - entity.setFaceDetectHelperThreshold(ConfigValueUtil.getIntValue(config, "faceDetectHelperThreshold")); - entity.setPhotoFreeNum(ConfigValueUtil.getIntValue(config, "photoFreeNum")); - entity.setVideoFreeNum(ConfigValueUtil.getIntValue(config, "videoFreeNum")); - entity.setVoucherEnable(ConfigValueUtil.getBooleanValue(config, "voucherEnable")); - - entity.setFaceScoreThreshold(ConfigValueUtil.getFloatValue(config, "faceScoreThreshold")); - entity.setBrokerDirectRate(ConfigValueUtil.getBigDecimalValue(config, "brokerDirectRate")); - - entity.setWatermarkType(ConfigValueUtil.getStringValue(config, "watermarkType")); - entity.setWatermarkScenicText(ConfigValueUtil.getStringValue(config, "watermarkScenicText")); - entity.setWatermarkDtFormat(ConfigValueUtil.getStringValue(config, "watermarkDtFormat")); - entity.setImageSourcePackHint(ConfigValueUtil.getStringValue(config, "imageSourcePackHint")); - entity.setVideoSourcePackHint(ConfigValueUtil.getStringValue(config, "videoSourcePackHint")); - entity.setExtraNotificationTime(ConfigValueUtil.getStringValue(config, "extraNotificationTime")); - - entity.setStoreType(ConfigValueUtil.getEnumValue(config, "storeType", StorageType.class)); - entity.setStoreConfigJson(ConfigValueUtil.getStringValue(config, "storeConfigJson")); - entity.setTmpStoreType(ConfigValueUtil.getEnumValue(config, "tmpStoreType", StorageType.class)); - entity.setTmpStoreConfigJson(ConfigValueUtil.getStringValue(config, "tmpStoreConfigJson")); - entity.setLocalStoreType(ConfigValueUtil.getEnumValue(config, "localStoreType", StorageType.class)); - entity.setLocalStoreConfigJson(ConfigValueUtil.getStringValue(config, "localStoreConfigJson")); - - entity.setFaceType(ConfigValueUtil.getEnumValue(config, "faceType", FaceBodyAdapterType.class)); - entity.setFaceConfigJson(ConfigValueUtil.getStringValue(config, "faceConfigJson")); - - entity.setPayType(ConfigValueUtil.getEnumValue(config, "payType", PayAdapterType.class)); - entity.setPayConfigJson(ConfigValueUtil.getStringValue(config, "payConfigJson")); - - return entity; - } - /** * 获取景区配置管理器 * @@ -341,24 +259,6 @@ public class ScenicRepository { return null; } } - - /** - * 获取景区配置管理器,带缓存支持 - * - * @param scenicId 景区ID - * @return ScenicConfigManager实例,如果获取失败返回null - */ - public ScenicConfigManager getScenicConfigManagerWithCache(Long scenicId) { - String key = String.format(SCENIC_CONFIG_CACHE_KEY + ":manager", scenicId); - - List configList = - scenicConfigIntegrationService.listConfigs(scenicId); - if (configList != null) { - ScenicConfigManager manager = new ScenicConfigManager(configList); - return manager; - } - return null; - } /** * 批量获取景区名称 diff --git a/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java b/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java index b42fb559..d280b018 100644 --- a/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java @@ -874,13 +874,12 @@ public class OrderServiceImpl implements OrderService { MemberRespVO member = memberMapper.getById(userId); order.setOpenId(member.getOpenId()); order.setScenicId(face.getScenicId()); - - order.setSlashPrice(cachedResult.getOriginalAmount()); - order.setPrice(cachedResult.getFinalAmount()); - // promo code - order.setPayPrice(cachedResult.getFinalAmount()); } - if (order.getPayPrice().equals(BigDecimal.ZERO)) { + order.setSlashPrice(cachedResult.getOriginalAmount()); + order.setPrice(cachedResult.getFinalAmount()); + // promo code + order.setPayPrice(cachedResult.getFinalAmount()); + if (order.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) { order.setStatus(OrderStateEnum.PAID.getState()); order.setPayAt(new Date()); } else { @@ -907,7 +906,7 @@ public class OrderServiceImpl implements OrderService { if (cachedResult.getUsedVoucher() != null) { order.setBrokerId(cachedResult.getUsedVoucher().getBrokerId()); order.setPromoCode(cachedResult.getUsedVoucher().getVoucherCode()); - iVoucherService.markVoucherAsUsed(cachedResult.getUsedVoucher().getVoucherCode(), order.getId().toString()); + iVoucherService.markVoucherAsUsed(cachedResult.getUsedVoucher().getVoucherCode(), "用户下单", order.getId().toString(), cachedResult.getUsedVoucher().getDiscountValue(), face.getId()); } List orderItems = new ArrayList<>(); OrderItemEntity orderItem = new OrderItemEntity(); @@ -930,7 +929,7 @@ public class OrderServiceImpl implements OrderService { OrderEntity order = orderMapper.get(orderId); // 检查订单金额是否为0 - if (order.getPayPrice() == null || order.getPayPrice().compareTo(BigDecimal.ZERO) == 0) { + if (order.getPayPrice() == null || order.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) { // 零金额订单:设置needPay为false,直接标记为已支付 order.setStatus(1); // 1表示已支付 order.setPayAt(new Date()); diff --git a/src/main/resources/mapper/OrderMapper.xml b/src/main/resources/mapper/OrderMapper.xml index a62111cd..2e7778de 100644 --- a/src/main/resources/mapper/OrderMapper.xml +++ b/src/main/resources/mapper/OrderMapper.xml @@ -224,6 +224,7 @@ coupon_price = #{couponPrice}, coupon_id = #{couponId}, coupon_record_id = #{couponRecordId}, + price = #{price}, pay_price = `price` - `coupon_price` where id = #{id}