package com.talentcard.server.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.entity.MerchantCouponClaim; import com.talentcard.server.entity.MerchantCouponManagement; import com.talentcard.server.mapper.MerchantCouponClaimMapper; import com.talentcard.server.mapper.MerchantCouponManagementMapper; import com.talentcard.server.request.CouponClaimIssueRequest; import com.talentcard.server.request.CouponClaimStatusRequest; import com.talentcard.server.request.CouponManagementSaveRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; @Service public class MerchantCouponManagementService { private static final List COUPON_TYPES = List.of("CASH", "FULL_REDUCTION", "DISCOUNT", "EXCHANGE"); private final MerchantCouponManagementMapper couponMapper; private final MerchantCouponClaimMapper claimMapper; private final ObjectMapper objectMapper; public MerchantCouponManagementService(MerchantCouponManagementMapper couponMapper, MerchantCouponClaimMapper claimMapper, ObjectMapper objectMapper) { this.couponMapper = couponMapper; this.claimMapper = claimMapper; this.objectMapper = objectMapper; } @Transactional(readOnly = true) public PageResult> coupons(Long merchantId, String couponType, String batchNo, Integer status, String keyword, LocalDate validOn, int page, int size) { String type = normalizeTypeFilter(couponType); validateCouponStatus(status); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); List> records = DatabaseRows.normalize(couponMapper.selectManagementPage( merchantId, type, clean(batchNo), status, clean(keyword), validOn, safeSize, (safePage - 1) * safeSize)).stream().map(this::couponView).toList(); Long total = couponMapper.countManagementPage(merchantId, type, clean(batchNo), status, clean(keyword), validOn); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } @Transactional(readOnly = true) public Map coupon(Long id) { return couponView(toMap(requireCoupon(id))); } @Transactional public Map createCoupon(CouponManagementSaveRequest request) { validateSave(request, null); MerchantCouponManagement coupon = new MerchantCouponManagement(); apply(coupon, request); coupon.setBatchNo(clean(request.batchNo()).isBlank() ? nextBatchNo() : clean(request.batchNo())); coupon.setClaimedQuantity(0); coupon.setUseCount(0); coupon.setProductScope("ALL"); coupon.setStoreConsume(1); coupon.setDeleted(0); if (couponMapper.insert(coupon) != 1) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "优惠券配置保存失败"); } return coupon(coupon.getId()); } @Transactional public Map updateCoupon(Long id, CouponManagementSaveRequest request) { MerchantCouponManagement current = requireCoupon(id); validateSave(request, current); String requestedBatch = clean(request.batchNo()); if (current.getClaimedQuantity() != null && current.getClaimedQuantity() > 0 && !requestedBatch.isBlank() && !requestedBatch.equals(current.getBatchNo())) { throw new BusinessException(ErrorCode.CONFLICT, "已有领取记录时不能修改发放批次"); } apply(current, request); if (!requestedBatch.isBlank()) current.setBatchNo(requestedBatch); if (couponMapper.updateById(current) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券配置已变化,请刷新后重试"); } return coupon(id); } @Transactional public Map updateCouponStatus(Long id, Integer status) { validateCouponStatus(status); MerchantCouponManagement coupon = requireCoupon(id); coupon.setStatus(status); if (couponMapper.updateById(coupon) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券状态更新失败"); } return coupon(id); } @Transactional public Map issue(Long couponId, CouponClaimIssueRequest request) { MerchantCouponManagement coupon = requireCoupon(couponId); LocalDate today = LocalDate.now(); if (!Integer.valueOf(1).equals(coupon.getStatus())) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券已下架,不能发放"); } if (coupon.getValidStart() != null && coupon.getValidStart().isAfter(today)) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券尚未开始生效"); } if (coupon.getValidEnd() != null && coupon.getValidEnd().isBefore(today)) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券已过期"); } if (couponMapper.countTalent(request.talentId()) == 0) { throw new BusinessException(ErrorCode.NOT_FOUND, "领取人才不存在"); } if (request.cardId() != null && couponMapper.countTalentCard(request.cardId(), request.talentId()) == 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡不属于领取人"); } Long duplicate = claimMapper.selectCount(Wrappers.lambdaQuery() .eq(MerchantCouponClaim::getCouponId, couponId) .eq(MerchantCouponClaim::getTalentId, request.talentId()) .eq(MerchantCouponClaim::getBatchNo, coupon.getBatchNo())); if (duplicate != null && duplicate > 0) { throw new BusinessException(ErrorCode.CONFLICT, "该用户已领取当前批次优惠券"); } if (couponMapper.reserveOne(couponId) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券已下架或发放数量不足"); } MerchantCouponClaim claim = new MerchantCouponClaim(); claim.setClaimNo(BusinessIdGenerator.next("MCC")); claim.setCouponId(couponId); claim.setMerchantId(coupon.getMerchantId()); claim.setBatchNo(coupon.getBatchNo()); claim.setTalentId(request.talentId()); claim.setCardId(request.cardId()); claim.setClaimStatus(1); claim.setUseStatus(0); claim.setClaimedAt(LocalDateTime.now()); claim.setExpireTime(request.expireTime() != null ? request.expireTime() : coupon.getValidEnd() == null ? null : LocalDateTime.of(coupon.getValidEnd(), LocalTime.MAX)); claim.setRemark(trimToNull(request.remark())); claim.setDeleted(0); if (claimMapper.insert(claim) != 1) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "优惠券发放记录保存失败"); } return claim(claim.getId()); } @Transactional(readOnly = true) public PageResult> claims(Long couponId, Long merchantId, String batchNo, Integer claimStatus, Integer useStatus, String keyword, LocalDate startDate, LocalDate endDate, int page, int size) { validateClaimStatus(claimStatus); validateUseStatus(useStatus); if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期"); } int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); LocalDateTime start = startDate == null ? null : startDate.atStartOfDay(); LocalDateTime end = endDate == null ? null : endDate.plusDays(1).atStartOfDay(); List> records = DatabaseRows.normalize(claimMapper.selectClaimPage( couponId, merchantId, clean(batchNo), claimStatus, useStatus, clean(keyword), start, end, safeSize, (safePage - 1) * safeSize)).stream().map(this::claimView).toList(); Long total = claimMapper.countClaimPage(couponId, merchantId, clean(batchNo), claimStatus, useStatus, clean(keyword), start, end); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } @Transactional(readOnly = true) public Map claim(Long id) { MerchantCouponClaim claim = requireClaim(id); Map value = toMap(claim); MerchantCouponManagement coupon = couponMapper.selectById(claim.getCouponId()); if (coupon != null) { value.put("couponName", coupon.getCouponName()); value.put("couponType", coupon.getCouponType()); value.put("couponAmount", coupon.getCouponAmount()); } return claimView(value); } @Transactional public Map updateClaimStatus(Long id, CouponClaimStatusRequest request) { if (request.claimStatus() == null && request.useStatus() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST, "领取状态和使用状态至少填写一项"); } validateClaimStatus(request.claimStatus()); validateUseStatus(request.useStatus()); MerchantCouponClaim claim = requireClaim(id); Integer oldClaimStatus = claim.getClaimStatus(); Integer targetClaimStatus = request.claimStatus() == null ? oldClaimStatus : request.claimStatus(); Integer targetUseStatus = request.useStatus() == null ? claim.getUseStatus() : request.useStatus(); if (targetClaimStatus == 2 && targetUseStatus == 1) { throw new BusinessException(ErrorCode.BAD_REQUEST, "已取消领取的优惠券不能标记为已使用"); } if (oldClaimStatus != 2 && targetClaimStatus == 2) couponMapper.releaseOne(claim.getCouponId()); if (oldClaimStatus == 2 && targetClaimStatus != 2 && couponMapper.reserveOne(claim.getCouponId()) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "优惠券剩余发放数量不足,不能恢复领取"); } claim.setClaimStatus(targetClaimStatus); claim.setUseStatus(targetUseStatus); claim.setOrderId(request.orderId()); claim.setRemark(trimToNull(request.remark())); if (targetUseStatus == 1 && claim.getUsedAt() == null) claim.setUsedAt(LocalDateTime.now()); if (targetUseStatus != 1) claim.setUsedAt(null); if (claimMapper.updateById(claim) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "领取记录状态更新失败"); } return claim(id); } private void apply(MerchantCouponManagement coupon, CouponManagementSaveRequest request) { coupon.setMerchantId(request.merchantId()); coupon.setCouponName(request.couponName().trim()); coupon.setCouponType(normalizeType(request.couponType())); coupon.setCouponAmount(request.couponAmount()); coupon.setConditionType(clean(request.conditionType()).isBlank() ? "NONE" : clean(request.conditionType()).toUpperCase(Locale.ROOT)); coupon.setConditionAmount(request.conditionAmount()); coupon.setTalentLevels(writeLevels(request.talentLevels())); coupon.setValidStart(request.validStart()); coupon.setValidEnd(request.validEnd()); coupon.setTotalQuantity(request.totalQuantity()); coupon.setGovernmentSubsidy(Boolean.TRUE.equals(request.governmentSubsidy()) ? 1 : 0); coupon.setStatus(request.status() == null ? 1 : request.status()); } private void validateSave(CouponManagementSaveRequest request, MerchantCouponManagement current) { normalizeType(request.couponType()); validateCouponStatus(request.status()); if (couponMapper.countMerchant(request.merchantId()) == 0) { throw new BusinessException(ErrorCode.NOT_FOUND, "适用商户不存在"); } if (request.validStart() != null && request.validEnd() != null && request.validStart().isAfter(request.validEnd())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "有效开始日期不能晚于结束日期"); } int claimed = current == null || current.getClaimedQuantity() == null ? 0 : current.getClaimedQuantity(); if (request.totalQuantity() != -1 && request.totalQuantity() < claimed) { throw new BusinessException(ErrorCode.BAD_REQUEST, "发放总量不能小于已领取数量"); } } private MerchantCouponManagement requireCoupon(Long id) { if (id == null || id <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券ID不正确"); MerchantCouponManagement coupon = couponMapper.selectById(id); if (coupon == null) throw new BusinessException(ErrorCode.NOT_FOUND, "优惠券不存在"); return coupon; } private MerchantCouponClaim requireClaim(Long id) { if (id == null || id <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "领取记录ID不正确"); MerchantCouponClaim claim = claimMapper.selectById(id); if (claim == null) throw new BusinessException(ErrorCode.NOT_FOUND, "优惠券领取记录不存在"); return claim; } private void validateCouponStatus(Integer status) { if (status != null && status != 0 && status != 1) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券状态只能为0下架或1上架"); } private void validateClaimStatus(Integer status) { if (status != null && (status < 0 || status > 2)) throw new BusinessException(ErrorCode.BAD_REQUEST, "领取状态不正确"); } private void validateUseStatus(Integer status) { if (status != null && (status < 0 || status > 3)) throw new BusinessException(ErrorCode.BAD_REQUEST, "使用状态不正确"); } private String normalizeTypeFilter(String value) { return clean(value).isBlank() ? "" : normalizeType(value); } private String normalizeType(String value) { String type = clean(value).toUpperCase(Locale.ROOT).replace('-', '_'); if (!COUPON_TYPES.contains(type)) throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券类型不正确"); return type; } private String writeLevels(List levels) { if (levels == null || levels.isEmpty()) return null; try { return objectMapper.writeValueAsString(levels.stream().filter(v -> v != null && !v.isBlank()).map(String::trim).distinct().toList()); } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "适用人才卡等级保存失败"); } } private Object readLevels(Object value) { if (value == null || String.valueOf(value).isBlank()) return List.of(); try { return objectMapper.readValue(String.valueOf(value), new TypeReference>() {}); } catch (Exception exception) { return List.of(String.valueOf(value)); } } private Map couponView(Map source) { Map result = new LinkedHashMap<>(source); result.put("talentLevels", readLevels(source.get("talentLevels"))); Integer status = intValue(source.get("status")); result.put("statusText", status != null && status == 1 ? "上架" : "下架"); String type = String.valueOf(source.getOrDefault("couponType", "")); result.put("couponTypeText", couponTypeText(type)); int total = intValue(source.get("totalQuantity")) == null ? -1 : intValue(source.get("totalQuantity")); int claimed = intValue(source.get("claimedQuantity")) == null ? 0 : intValue(source.get("claimedQuantity")); result.put("remainingQuantity", total == -1 ? -1 : Math.max(total - claimed, 0)); return result; } private Map claimView(Map source) { Map result = new LinkedHashMap<>(source); result.put("claimStatusText", switch (intValue(source.get("claimStatus")) == null ? -1 : intValue(source.get("claimStatus"))) { case 0 -> "待领取"; case 1 -> "已领取"; case 2 -> "已取消"; default -> "未知"; }); result.put("useStatusText", switch (intValue(source.get("useStatus")) == null ? -1 : intValue(source.get("useStatus"))) { case 0 -> "未使用"; case 1 -> "已使用"; case 2 -> "已过期"; case 3 -> "已作废"; default -> "未知"; }); return result; } private String couponTypeText(String type) { return switch (type) { case "CASH" -> "代金券"; case "FULL_REDUCTION" -> "满减券"; case "DISCOUNT" -> "折扣券"; case "EXCHANGE" -> "兑换券"; default -> "未知"; }; } private Map toMap(Object bean) { return objectMapper.convertValue(bean, new TypeReference>() {}); } private String nextBatchNo() { return "CPB" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase(Locale.ROOT); } private String clean(String value) { return value == null ? "" : value.trim(); } private String trimToNull(String value) { return clean(value).isBlank() ? null : clean(value); } private Integer intValue(Object value) { return value instanceof Number number ? number.intValue() : null; } }