package com.talentcard.server.service; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.entity.BenefitCinemaCouponClaim; import com.talentcard.server.entity.BenefitFamilyCardApplication; import com.talentcard.server.entity.BenefitFreeStayVoucher; import com.talentcard.server.entity.BenefitMerchant; import com.talentcard.server.entity.BenefitMerchantBenefitRule; import com.talentcard.server.entity.BenefitMovieTicketRule; import com.talentcard.server.entity.BenefitOrder; import com.talentcard.server.entity.BenefitTalentCard; import com.talentcard.server.entity.BenefitVerifyRecord; import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper; import com.talentcard.server.mapper.BenefitFamilyCardApplicationMapper; import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper; import com.talentcard.server.mapper.BenefitMerchantBenefitRuleMapper; import com.talentcard.server.mapper.BenefitMerchantMapper; import com.talentcard.server.mapper.BenefitMovieTicketRuleMapper; import com.talentcard.server.mapper.BenefitOrderMapper; import com.talentcard.server.mapper.BenefitTalentCardMapper; import com.talentcard.server.mapper.BenefitVerifyRecordMapper; import org.springframework.stereotype.Service; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @Service public class BenefitQuotaService { private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); private static final TypeReference>> RIGHTS_TYPE = new TypeReference<>() {}; public static final String MOVIE_TICKET = "MOVIE_TICKET"; public static final String HOTEL = "HOTEL"; private static final int DEFAULT_HOTEL_TOTAL = 5; private static final List HOTEL_VERIFY_TYPES = List.of( "HOTEL", "HOTEL_FREE", "FREE_STAY", "HOTEL_ORDER", "HOTEL_RESERVATION" ); private final BenefitTalentCardMapper cardMapper; private final BenefitFamilyCardApplicationMapper familyMapper; private final BenefitMerchantMapper merchantMapper; private final BenefitMovieTicketRuleMapper movieRuleMapper; private final BenefitCinemaCouponClaimMapper claimMapper; private final BenefitFreeStayVoucherMapper voucherMapper; private final BenefitMerchantBenefitRuleMapper merchantBenefitRuleMapper; private final BenefitVerifyRecordMapper verifyRecordMapper; private final BenefitOrderMapper orderMapper; public BenefitQuotaService( BenefitTalentCardMapper cardMapper, BenefitFamilyCardApplicationMapper familyMapper, BenefitMerchantMapper merchantMapper, BenefitMovieTicketRuleMapper movieRuleMapper, BenefitCinemaCouponClaimMapper claimMapper, BenefitFreeStayVoucherMapper voucherMapper, BenefitMerchantBenefitRuleMapper merchantBenefitRuleMapper, BenefitVerifyRecordMapper verifyRecordMapper, BenefitOrderMapper orderMapper ) { this.cardMapper = cardMapper; this.familyMapper = familyMapper; this.merchantMapper = merchantMapper; this.movieRuleMapper = movieRuleMapper; this.claimMapper = claimMapper; this.voucherMapper = voucherMapper; this.merchantBenefitRuleMapper = merchantBenefitRuleMapper; this.verifyRecordMapper = verifyRecordMapper; this.orderMapper = orderMapper; } public QuotaSnapshot requireAvailable(String rawBenefitType, Long cardId, Long talentId) { return requireAvailable(rawBenefitType, cardId, talentId, null, null, 1); } public QuotaSnapshot requireAvailable(String rawBenefitType, Long cardId, Long talentId, Long merchantId, Long productId, int quantity) { if (quantity < 1) { throw new BusinessException(ErrorCode.BAD_REQUEST, "核销数量必须大于0"); } QuotaSnapshot snapshot = current(rawBenefitType, cardId, talentId, merchantId, productId); if (snapshot.remaining() < quantity) { throw new BusinessException(ErrorCode.CONFLICT, "主卡权益剩余次数不足"); } return snapshot; } public QuotaSnapshot current(String rawBenefitType, Long cardId, Long talentId) { return current(rawBenefitType, cardId, talentId, null, null); } public QuotaSnapshot current(String rawBenefitType, Long cardId, Long talentId, Long merchantId, Long productId) { String benefitType = normalizeBenefitType(rawBenefitType); PrimaryCard primary = lockPrimaryCard(cardId, talentId); QuotaPolicy policy = policy(benefitType, primary, merchantId, productId); int used = usedQuota(benefitType, primary.sharedCardIds(), merchantId, policy.windowStart()); return new QuotaSnapshot( benefitType, primary.sourceTalentId(), primary.sourceCardId(), primary.sourceCardNo(), primary.sourceCardType(), primary.primaryTalentId(), primary.primaryCardId(), primary.primaryCardNo(), primary.cardLevel(), policy.total(), used, Math.max(policy.total() - used, 0), policy.resetCycle(), policy.windowStart(), primary.familyCard(), primary.sharedCardIds() ); } public String normalizeBenefitType(String value) { String type = value == null ? "" : value.trim().toUpperCase(Locale.ROOT).replace('-', '_'); return switch (type) { case "MOVIE", "MOVIE_TICKET", "CINEMA", "CINEMA_TICKET" -> MOVIE_TICKET; case "HOTEL", "FREE_STAY", "HOTEL_RESERVATION", "STAY" -> HOTEL; default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "权益类型仅支持MOVIE_TICKET电影票或HOTEL酒店"); }; } public List cardLevelAliases(String cardLevel) { Set aliases = new LinkedHashSet<>(); String raw = cardLevel == null ? "" : cardLevel.trim(); if (!raw.isBlank()) { aliases.add(raw); } String upper = raw.toUpperCase(Locale.ROOT); if (upper.contains("EXCELLENT") || raw.contains("\u5353\u8d8a")) { aliases.add("EXCELLENT"); aliases.add("\u5353\u8d8a\u4eba\u624d\u5361"); aliases.add("\u5353\u8d8a"); } if (upper.contains("OUTSTANDING") || raw.contains("\u6770\u51fa")) { aliases.add("OUTSTANDING"); aliases.add("\u6770\u51fa\u4eba\u624d\u5361"); aliases.add("\u6770\u51fa"); } if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1") || raw.contains("\u7cbe\u82f1")) { aliases.add("ELITE"); aliases.add("\u83c1\u82f1\u4eba\u624d\u5361"); aliases.add("\u83c1\u82f1"); } return aliases.isEmpty() ? List.of(raw) : List.copyOf(aliases); } private PrimaryCard lockPrimaryCard(Long cardId, Long talentId) { if (cardId == null && talentId == null) { throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡ID或人才用户ID不能为空"); } BenefitTalentCard sourceCard = cardId == null ? activeCardForTalent(talentId) : card(cardId); Long sourceCardId = sourceCard.getId(); Long sourceTalentId = sourceCard.getTalentId(); String sourceCardType = text(sourceCard.getCardType()).toUpperCase(Locale.ROOT); if ("PERSONAL".equals(sourceCardType) || sourceCardType.isBlank()) { return primaryCard(sourceCardId, sourceTalentId, sourceCard.getCardNo(), sourceCardType, sourceCardId, sourceTalentId); } BenefitFamilyCardApplication owner = familyMapper.selectOne(new QueryWrapper() .eq("deleted", 0) .eq("family_card_id", sourceCardId) .isNotNull("talent_id") .orderByDesc("id") .last("limit 1")); if (owner == null) { throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到绑定的主卡"); } BenefitTalentCard primary = activePersonalCard(owner.getTalentId()); return primaryCard(sourceCardId, sourceTalentId, sourceCard.getCardNo(), sourceCardType, primary.getId(), primary.getTalentId()); } private PrimaryCard primaryCard(Long sourceCardId, Long sourceTalentId, String sourceCardNo, String sourceCardType, Long primaryCardId, Long primaryTalentId) { BenefitTalentCard primary = cardMapper.selectOne(new QueryWrapper() .eq("id", primaryCardId) .eq("talent_id", primaryTalentId) .eq("deleted", 0) .last("limit 1 for update")); if (primary == null) { throw new BusinessException(ErrorCode.CONFLICT, "主卡不存在"); } if (intValue(primary.getStatus()) != 1 || notStarted(primary.getValidStart()) || expiredDate(primary.getValidEnd()) || expiredDateTime(primary.getExpireTime())) { throw new BusinessException(ErrorCode.CONFLICT, "主卡当前不可用"); } return new PrimaryCard( sourceTalentId, sourceCardId, sourceCardNo, sourceCardType, primaryTalentId, primaryCardId, primary.getCardNo(), text(primary.getCardLevel()), !"PERSONAL".equals(sourceCardType), sharedCardIds(primaryCardId, primaryTalentId) ); } private List sharedCardIds(Long primaryCardId, Long primaryTalentId) { Set ids = new LinkedHashSet<>(); ids.add(primaryCardId); List applications = familyMapper.selectList(new QueryWrapper() .eq("deleted", 0) .eq("talent_id", primaryTalentId) .isNotNull("family_card_id")); for (BenefitFamilyCardApplication application : applications) { if (application.getFamilyCardId() != null) { ids.add(application.getFamilyCardId()); } } if (ids.size() == 1) { return List.copyOf(ids); } List activeCards = cardMapper.selectList(new QueryWrapper() .select("id") .eq("deleted", 0) .eq("status", 1) .in("id", ids)); Set activeIds = new LinkedHashSet<>(); activeIds.add(primaryCardId); for (BenefitTalentCard card : activeCards) { activeIds.add(card.getId()); } return List.copyOf(activeIds); } private BenefitTalentCard card(Long cardId) { BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper() .eq("id", cardId) .eq("deleted", 0) .last("limit 1")); if (card == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "人才卡不存在"); } if (intValue(card.getStatus()) != 1 || notStarted(card.getValidStart()) || expiredDate(card.getValidEnd()) || expiredDateTime(card.getExpireTime())) { throw new BusinessException(ErrorCode.CONFLICT, "人才卡当前不可用"); } return card; } private BenefitTalentCard activeCardForTalent(Long talentId) { BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper() .eq("talent_id", talentId) .eq("deleted", 0) .eq("status", 1) .and(wrapper -> wrapper.isNull("valid_start").or().le("valid_start", LocalDate.now())) .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now())) .and(wrapper -> wrapper.isNull("expire_time").or().gt("expire_time", LocalDateTime.now())) .last("order by case when card_type = 'PERSONAL' then 0 else 1 end, id desc limit 1")); if (card == null) { throw new BusinessException(ErrorCode.CONFLICT, "未找到有效人才卡"); } return card; } private BenefitTalentCard activePersonalCard(Long talentId) { BenefitTalentCard card = cardMapper.selectOne(new QueryWrapper() .eq("talent_id", talentId) .eq("card_type", "PERSONAL") .eq("deleted", 0) .eq("status", 1) .and(wrapper -> wrapper.isNull("valid_start").or().le("valid_start", LocalDate.now())) .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now())) .and(wrapper -> wrapper.isNull("expire_time").or().gt("expire_time", LocalDateTime.now())) .orderByDesc("id") .last("limit 1")); if (card == null) { throw new BusinessException(ErrorCode.CONFLICT, "未找到有效主卡"); } return card; } private QuotaPolicy policy(String benefitType, PrimaryCard primary, Long merchantId, Long productId) { if (MOVIE_TICKET.equals(benefitType)) { return moviePolicy(primary); } return hotelPolicy(primary, merchantId, productId); } private QuotaPolicy moviePolicy(PrimaryCard primary) { List aliases = cardLevelAliases(primary.cardLevel()); BenefitMovieTicketRule rule = movieRuleMapper.selectOne(new QueryWrapper() .in("talent_level", aliases) .last("limit 1")); if (rule == null || intValue(rule.getEnabled()) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未启用电影票权益"); } int total = intValue(rule.getClaimableCount()); if (total < 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未配置电影票权益次数"); } String resetCycle = text(rule.getResetCycle()); return new QuotaPolicy(total, resetCycle, windowStart(resetCycle, true)); } private QuotaPolicy hotelPolicy(PrimaryCard primary, Long merchantId, Long productId) { if (merchantId != null) { QuotaPolicy merchantRightsPolicy = merchantRightsPolicy(primary, merchantId); if (merchantRightsPolicy != null) { return merchantRightsPolicy; } List aliases = cardLevelAliases(primary.cardLevel()); QueryWrapper query = new QueryWrapper() .eq("deleted", 0) .eq("status", 1) .eq("merchant_id", merchantId) .in("card_level", aliases) .isNotNull("free_count") .gt("free_count", 0); if (productId == null) { query.isNull("product_id").orderByDesc("id").last("limit 1"); } else { query.and(wrapper -> wrapper.eq("product_id", productId).or().isNull("product_id")) .last("order by case when product_id = " + productId + " then 0 else 1 end, id desc limit 1"); } BenefitMerchantBenefitRule rule = merchantBenefitRuleMapper.selectOne(query); if (rule != null) { if (primary.familyCard() && intValue(rule.getCanFamilyUse()) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可使用该酒店权益"); } String resetCycle = text(rule.getResetCycle()); return new QuotaPolicy(intValue(rule.getFreeCount()), resetCycle, windowStart(resetCycle, false)); } } QueryWrapper voucherQuery = new QueryWrapper() .select("total_count") .eq("talent_id", primary.primaryTalentId()) .eq("status", 1) .ge("valid_end", LocalDate.now()) .orderByDesc("total_count") .last("limit 1"); if (merchantId != null) { voucherQuery.eq("merchant_id", merchantId); } BenefitFreeStayVoucher voucher = voucherMapper.selectOne(voucherQuery); int configured = voucher == null || intValue(voucher.getTotalCount()) < 1 ? DEFAULT_HOTEL_TOTAL : voucher.getTotalCount(); return new QuotaPolicy(configured, "YEARLY", windowStart("YEARLY", false)); } private QuotaPolicy merchantRightsPolicy(PrimaryCard primary, Long merchantId) { BenefitMerchant merchant = merchantMapper.selectById(merchantId); if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1 || !isHotelMerchantType(merchant.getMerchantType())) { throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户"); } String config = text(merchant.getMerchantRightsConfig()); if (config.isBlank()) { return null; } Map matched = null; String cardLevel = normalizeCardLevel(primary.cardLevel()); try { for (Map right : JSON_MAPPER.readValue(config, RIGHTS_TYPE)) { if (cardLevel.equals(normalizeCardLevel(right.get("level")))) { matched = right; break; } } } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "酒店商户权益配置格式不正确"); } if (matched == null) { throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店权益"); } if (!booleanValue(matched.get("enabled"))) { throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未启用酒店权益"); } Object countValue = firstValue(matched, "freeCount", "freeLimitCount", "limitCount"); Object freeValue = firstValue(matched, "freeEnabled", "free"); if (freeValue != null && !booleanValue(freeValue)) { throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未启用酒店免费券"); } int total = intValue(countValue); if (total < 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店免费券次数"); } Object familyShared = firstValue(matched, "familyCardShared", "canFamilyUse", "familyUseEnabled"); if (primary.familyCard() && familyShared != null && !booleanValue(familyShared)) { throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可共享该酒店权益"); } String resetCycle = text(firstValue(matched, "freeResetCycle", "resetCycle")); if (resetCycle.isBlank()) { throw new BusinessException(ErrorCode.CONFLICT, "当前主卡类型未配置酒店免费券重置周期"); } return new QuotaPolicy(total, resetCycle, windowStart(resetCycle, false)); } private Object firstValue(Map values, String... keys) { for (String key : keys) { Object value = values.get(key); if (value != null && !text(value).isBlank()) { return value; } } return null; } private String normalizeCardLevel(Object value) { String raw = text(value); String upper = raw.toUpperCase(Locale.ROOT); if (upper.contains("EXCELLENT") || raw.contains("\u5353\u8d8a")) { return "EXCELLENT"; } if (upper.contains("OUTSTANDING") || raw.contains("\u6770\u51fa")) { return "OUTSTANDING"; } if (upper.contains("ELITE") || raw.contains("\u83c1\u82f1") || raw.contains("\u7cbe\u82f1")) { return "ELITE"; } return upper; } private boolean booleanValue(Object value) { if (value instanceof Boolean bool) { return bool; } if (value instanceof Number number) { return number.intValue() != 0; } String raw = text(value); return "true".equalsIgnoreCase(raw) || "1".equals(raw) || "yes".equalsIgnoreCase(raw); } private boolean isHotelMerchantType(String merchantType) { String type = text(merchantType); return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type); } private int usedQuota(String benefitType, List sharedCardIds, Long merchantId, LocalDateTime windowStart) { if (MOVIE_TICKET.equals(benefitType)) { return usedMovieQuota(sharedCardIds, windowStart); } return usedHotelQuota(sharedCardIds, merchantId, windowStart); } private int usedMovieQuota(List sharedCardIds, LocalDateTime windowStart) { Long used = claimMapper.selectCount(new QueryWrapper() .eq("deleted", 0) .in("card_id", sharedCardIds) .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= {0}) or " + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= {1}))", windowStart, windowStart)); return intValue(used); } private int usedHotelQuota(List sharedCardIds, Long merchantId, LocalDateTime windowStart) { QueryWrapper recordQuery = new QueryWrapper() .in("card_id", sharedCardIds) .eq("status", 1) .ge("verify_time", windowStart) .and(wrapper -> wrapper.in("verify_type", HOTEL_VERIFY_TYPES) .or() .apply("exists (select 1 from benefit_order o " + "join merchant m on m.id = o.merchant_id and m.deleted = 0 " + "and m.merchant_type in ('hotel', '酒店服务') " + "where o.id = verify_record.order_id and o.deleted = 0)")); if (merchantId != null) { recordQuery.and(wrapper -> wrapper.eq("merchant_id", merchantId) .or() .apply("exists (select 1 from benefit_order o where o.id = verify_record.order_id " + "and o.deleted = 0 and o.merchant_id = {0})", merchantId)); } Long records = verifyRecordMapper.selectCount(recordQuery); QueryWrapper orderQuery = new QueryWrapper() .eq("deleted", 0) .in("card_id", sharedCardIds) .eq("verify_status", 1) .ge("updated_at", windowStart) .apply("exists (select 1 from merchant m where m.id = benefit_order.merchant_id " + "and m.deleted = 0 and m.merchant_type in ('hotel', '酒店服务'))") .apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)"); if (merchantId != null) { orderQuery.eq("merchant_id", merchantId); } Long ordersWithoutRecord = orderMapper.selectCount(orderQuery); return intValue(records) + intValue(ordersWithoutRecord); } private LocalDateTime windowStart(String resetCycle, boolean monthlyDefault) { String cycle = resetCycle == null ? "" : resetCycle.trim().toUpperCase(Locale.ROOT); LocalDate today = LocalDate.now(); if (cycle.isBlank() || "NONE".equals(cycle)) { return LocalDate.of(1970, 1, 1).atStartOfDay(); } if (cycle.contains("DAY") || cycle.contains("DAILY") || cycle.contains("\u5929") || cycle.contains("\u65e5")) { return today.atStartOfDay(); } if (cycle.contains("WEEK") || cycle.contains("\u5468") || cycle.contains("\u661f\u671f")) { return today.minusDays(today.getDayOfWeek().getValue() - 1L).atStartOfDay(); } if (cycle.contains("YEAR") || cycle.contains("ANNUAL") || cycle.contains("\u5e74")) { return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); } if (cycle.contains("MONTH") || cycle.contains("\u6708") || monthlyDefault) { return today.withDayOfMonth(1).atStartOfDay(); } return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); } private boolean notStarted(LocalDate value) { return value != null && value.isAfter(LocalDate.now()); } private boolean expiredDate(LocalDate value) { return value != null && value.isBefore(LocalDate.now()); } private boolean expiredDateTime(LocalDateTime value) { return value != null && !value.isAfter(LocalDateTime.now()); } private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); } private int intValue(Object value) { if (value instanceof Number number) { return number.intValue(); } try { String raw = text(value); return raw.isBlank() ? 0 : Integer.parseInt(raw); } catch (NumberFormatException ignored) { return 0; } } private record PrimaryCard( Long sourceTalentId, Long sourceCardId, String sourceCardNo, String sourceCardType, Long primaryTalentId, Long primaryCardId, String primaryCardNo, String cardLevel, boolean familyCard, List sharedCardIds ) { } private record QuotaPolicy(int total, String resetCycle, LocalDateTime windowStart) { } public record QuotaSnapshot( String benefitType, Long talentId, Long cardId, String cardNo, String cardType, Long primaryTalentId, Long primaryCardId, String primaryCardNo, String cardLevel, int total, int used, int remaining, String resetCycle, LocalDateTime windowStart, boolean familyCard, List sharedCardIds ) { } }