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.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.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.BenefitMovieTicketRuleMapper;
|
import com.talentcard.server.mapper.BenefitOrderMapper;
|
import com.talentcard.server.mapper.BenefitTalentCardMapper;
|
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
|
import org.springframework.stereotype.Service;
|
|
@Service
|
public class BenefitQuotaService {
|
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<String> HOTEL_VERIFY_TYPES = List.of(
|
"HOTEL", "HOTEL_FREE", "FREE_STAY", "HOTEL_ORDER", "HOTEL_RESERVATION"
|
);
|
|
private final BenefitTalentCardMapper cardMapper;
|
private final BenefitFamilyCardApplicationMapper familyMapper;
|
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,
|
BenefitMovieTicketRuleMapper movieRuleMapper,
|
BenefitCinemaCouponClaimMapper claimMapper,
|
BenefitFreeStayVoucherMapper voucherMapper,
|
BenefitMerchantBenefitRuleMapper merchantBenefitRuleMapper,
|
BenefitVerifyRecordMapper verifyRecordMapper,
|
BenefitOrderMapper orderMapper
|
) {
|
this.cardMapper = cardMapper;
|
this.familyMapper = familyMapper;
|
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(), 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<String> cardLevelAliases(String cardLevel) {
|
Set<String> 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")) {
|
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<BenefitFamilyCardApplication>()
|
.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<BenefitTalentCard>()
|
.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<Long> sharedCardIds(Long primaryCardId, Long primaryTalentId) {
|
Set<Long> ids = new LinkedHashSet<>();
|
ids.add(primaryCardId);
|
List<BenefitFamilyCardApplication> applications = familyMapper.selectList(new QueryWrapper<BenefitFamilyCardApplication>()
|
.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<BenefitTalentCard> activeCards = cardMapper.selectList(new QueryWrapper<BenefitTalentCard>()
|
.select("id")
|
.eq("deleted", 0)
|
.eq("status", 1)
|
.in("id", ids));
|
Set<Long> 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<BenefitTalentCard>()
|
.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<BenefitTalentCard>()
|
.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<BenefitTalentCard>()
|
.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<String> aliases = cardLevelAliases(primary.cardLevel());
|
BenefitMovieTicketRule rule = movieRuleMapper.selectOne(new QueryWrapper<BenefitMovieTicketRule>()
|
.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) {
|
List<String> aliases = cardLevelAliases(primary.cardLevel());
|
QueryWrapper<BenefitMerchantBenefitRule> query = new QueryWrapper<BenefitMerchantBenefitRule>()
|
.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<BenefitFreeStayVoucher> voucherQuery = new QueryWrapper<BenefitFreeStayVoucher>()
|
.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 int usedQuota(String benefitType, List<Long> sharedCardIds, LocalDateTime windowStart) {
|
if (MOVIE_TICKET.equals(benefitType)) {
|
return usedMovieQuota(sharedCardIds, windowStart);
|
}
|
return usedHotelQuota(sharedCardIds, windowStart);
|
}
|
|
private int usedMovieQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
|
Long used = claimMapper.selectCount(new QueryWrapper<BenefitCinemaCouponClaim>()
|
.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} "
|
+ "and updated_at > date_sub(now(), interval 300 second)))", windowStart, windowStart));
|
return intValue(used);
|
}
|
|
private int usedHotelQuota(List<Long> sharedCardIds, LocalDateTime windowStart) {
|
Long records = verifyRecordMapper.selectCount(new QueryWrapper<BenefitVerifyRecord>()
|
.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 = 'hotel' "
|
+ "where o.id = verify_record.order_id and o.deleted = 0)")));
|
|
Long ordersWithoutRecord = orderMapper.selectCount(new QueryWrapper<BenefitOrder>()
|
.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 = 'hotel')")
|
.apply("not exists (select 1 from verify_record r where r.order_id = benefit_order.id and r.status = 1)"));
|
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) {
|
return value instanceof Number number ? number.intValue() : 0;
|
}
|
|
private record PrimaryCard(
|
Long sourceTalentId,
|
Long sourceCardId,
|
String sourceCardNo,
|
String sourceCardType,
|
Long primaryTalentId,
|
Long primaryCardId,
|
String primaryCardNo,
|
String cardLevel,
|
boolean familyCard,
|
List<Long> 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<Long> sharedCardIds
|
) {
|
}
|
}
|