package com.talentcard.server.service; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.entity.TicketPackage; import com.talentcard.server.entity.TicketPackageClaim; import com.talentcard.server.entity.TicketPackageCode; import com.talentcard.server.mapper.TicketPackageClaimMapper; import com.talentcard.server.mapper.TicketPackageCodeMapper; import com.talentcard.server.mapper.TicketPackageLevelRuleMapper; import com.talentcard.server.mapper.TicketPackageMapper; import com.talentcard.server.request.TicketPackageClaimRequest; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class TicketPackagePortalService { private final TicketPackageMapper packageMapper; private final TicketPackageCodeMapper codeMapper; private final TicketPackageClaimMapper claimMapper; private final TicketPackageLevelRuleMapper packageLevelRuleMapper; public TicketPackagePortalService( TicketPackageMapper packageMapper, TicketPackageCodeMapper codeMapper, TicketPackageClaimMapper claimMapper, TicketPackageLevelRuleMapper packageLevelRuleMapper ) { this.packageMapper = packageMapper; this.codeMapper = codeMapper; this.claimMapper = claimMapper; this.packageLevelRuleMapper = packageLevelRuleMapper; } @Transactional(readOnly = true) public List> packages(Long talentId, Long cardId, Long merchantId) { CardPair cards = cards(talentId, cardId, false); return portalPackageViews(merchantId, cards); } @Transactional(readOnly = true) public boolean hasPortalPackages(Long merchantId) { return merchantId != null && merchantId > 0 && !packageMapper.selectPortalPackages(merchantId).isEmpty(); } @Transactional(readOnly = true) public Map merchantQuota(Long talentId, Long cardId, Long merchantId) { return quotaSnapshot(talentId, cardId, merchantId, null); } @Transactional(readOnly = true) public Map packageQuota(Long talentId, Long cardId, Long merchantId, Long packageId) { return quotaSnapshot(talentId, cardId, merchantId, packageId); } private Map quotaSnapshot(Long talentId, Long cardId, Long merchantId, Long preferredPackageId) { Long id = requireId(merchantId, "影院商户ID不能为空"); CardPair cards = cards(talentId, cardId, false); List> packages = portalPackageViews(id, cards); if (packages.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "当前影院未配置可领取票包"); } Map selected = selectQuotaPackage(packages, preferredPackageId); int quotaTotal = intValue(value(selected, "packageQuotaTotal", "quotaTotal")); int quotaUsed = intValue(value(selected, "packageQuotaUsed", "quotaUsed")); int quotaRemaining = intValue(value(selected, "packageQuotaRemaining", "quotaRemaining")); Map result = new LinkedHashMap<>(); result.put("benefitType", "MOVIE_TICKET"); result.put("merchantId", id); result.put("available", booleanValue(selected.get("available")) && quotaRemaining > 0); result.put("quotaTotal", quotaTotal); result.put("quotaUsed", quotaUsed); result.put("quotaRemaining", quotaRemaining); result.put("quotaResetCycle", value(selected, "quotaResetCycle")); result.put("quotaWindowStart", value(selected, "quotaWindowStart")); result.put("packageId", value(selected, "packageId")); result.put("packageName", text(selected, "packageName")); result.put("merchantName", text(selected, "merchantName")); result.put("merchantNames", text(selected, "merchantNames")); result.put("appName", text(selected, "appName")); result.put("couponId", value(selected, "packageId")); result.put("couponName", text(selected, "packageName")); result.put("totalQuantity", value(selected, "totalQuantity")); result.put("remainingCount", quotaRemaining); result.put("availableCodes", value(selected, "availableCodes")); result.put("sourceType", "TICKET_PACKAGE"); result.put("sourceCardId", cards.sourceCardId()); result.put("sourceTalentId", cards.sourceTalentId()); result.put("sourceCardType", cards.sourceCardType()); result.put("familyCard", cards.familyCard()); result.put("primaryCardId", cards.primaryCardId()); result.put("primaryTalentId", cards.primaryTalentId()); result.put("primaryCardNo", cards.primaryCardNo()); result.put("cardLevel", cards.primaryCardLevel()); result.put("packages", packages.stream().map(row -> { Map packageQuota = new LinkedHashMap<>(); packageQuota.put("packageId", value(row, "packageId")); packageQuota.put("packageName", text(row, "packageName")); packageQuota.put("quotaTotal", value(row, "packageQuotaTotal", "quotaTotal")); packageQuota.put("quotaUsed", value(row, "packageQuotaUsed", "quotaUsed")); packageQuota.put("quotaRemaining", value(row, "packageQuotaRemaining", "quotaRemaining")); packageQuota.put("available", value(row, "available")); packageQuota.put("unavailableReason", text(row, "unavailableReason")); return packageQuota; }).toList()); return result; } @Transactional public Map claimByMerchant(Long merchantId, Long talentId, Long cardId, String idempotencyKey) { Long id = requireId(merchantId, "影院商户ID不能为空"); String key = clean(idempotencyKey); if (key.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "领取凭证不能为空"); } CardPair cards = cards(talentId, cardId, false); TicketPackageClaim existing = claimMapper.selectByIdempotencyKey(key); if (existing != null) { if (!cards.sourceTalentId().equals(existing.getTalentId()) || !cards.sourceCardId().equals(existing.getCardId())) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他用户使用"); } if (existing.getMerchantId() != null && !id.equals(existing.getMerchantId())) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他影院使用"); } return claimView(existing, false); } List> packages = portalPackageViews(id, cards); if (packages.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "当前影院未配置可领取票包"); } for (Map row : packages) { if (booleanValue(row.get("available"))) { Long packageId = longValue(row, "packageId"); return claim(packageId, new TicketPackageClaimRequest( cards.sourceTalentId(), cards.sourceCardId(), id, key), key); } } throw new BusinessException(ErrorCode.CONFLICT, text(packages.get(0), "unavailableReason").isBlank() ? "当前影院暂无可领取票包" : text(packages.get(0), "unavailableReason")); } @Transactional public Map claim(Long packageId, TicketPackageClaimRequest request, String headerIdempotencyKey) { Long id = requireId(packageId, "票包ID不能为空"); String idempotencyKey = clean(first(headerIdempotencyKey, request.idempotencyKey())); if (idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "领取凭证不能为空"); } TicketPackageClaim existing = claimMapper.selectByIdempotencyKey(idempotencyKey); if (existing != null) { if (!id.equals(existing.getPackageId())) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他票包使用"); } if (!request.talentId().equals(existing.getTalentId()) || (request.cardId() != null && !request.cardId().equals(existing.getCardId()))) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他用户使用"); } if (request.merchantId() != null && existing.getMerchantId() != null && !request.merchantId().equals(existing.getMerchantId())) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他影院使用"); } return claimView(existing, false); } CardPair cards = cards(request.talentId(), request.cardId(), true); TicketPackage ticketPackage = packageMapper.selectByIdForUpdate(id); validatePackage(ticketPackage, cards.primaryCardLevel()); Long actualMerchantId = claimMerchantId(ticketPackage, request.merchantId()); Quota quota = quota(ticketPackage.getId(), cards.primaryCardLevel()); int packageUsed = intValue(claimMapper.countPackageClaimedInWindow(ticketPackage.getId(), cards.primaryCardId(), quota.windowStart())); if (packageUsed >= quota.total()) { throw new BusinessException(ErrorCode.CONFLICT, "票包可兑换额度不足"); } TicketPackageCode code = codeMapper.selectAvailableCodeForUpdate(ticketPackage.getId()); if (code == null) { throw new BusinessException(ErrorCode.CONFLICT, "票包兑换码库存不足"); } LocalDateTime now = LocalDateTime.now(); int locked = codeMapper.update(null, new UpdateWrapper() .set("status", "LOCKED") .set("locked_by_talent_id", cards.sourceTalentId()) .set("locked_by_card_id", cards.sourceCardId()) .set("locked_at", now) .eq("id", code.getId()) .eq("status", "AVAILABLE") .eq("deleted", 0)); if (locked != 1) { throw new BusinessException(ErrorCode.CONFLICT, "票包兑换码已被领取,请重试"); } TicketPackageClaim claim = new TicketPackageClaim(); claim.setClaimNo(BusinessIdGenerator.next("TPC")); claim.setPackageId(ticketPackage.getId()); claim.setCodeId(code.getId()); claim.setMerchantId(actualMerchantId); claim.setTalentId(cards.sourceTalentId()); claim.setCardId(cards.sourceCardId()); claim.setPrimaryCardId(cards.primaryCardId()); claim.setCardNo(cards.sourceCardNo()); claim.setCardLevel(cards.primaryCardLevel()); claim.setTalentName(cards.sourceTalentName()); claim.setTalentMobile(cards.sourceTalentMobile()); claim.setExchangeCode(code.getExchangeCode()); claim.setStatus("ISSUED"); claim.setValidEnd(ticketPackage.getValidEnd()); claim.setIdempotencyKey(idempotencyKey); claim.setDeleted(0); try { claimMapper.insert(claim); } catch (DuplicateKeyException exception) { releaseLockedCode(code.getId()); TicketPackageClaim concurrent = claimMapper.selectByIdempotencyKey(idempotencyKey); if (concurrent != null) { if (!id.equals(concurrent.getPackageId())) { throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他票包使用"); } return claimView(concurrent, false); } throw new BusinessException(ErrorCode.CONFLICT, "领取请求已提交,请稍后查询领取结果"); } catch (RuntimeException exception) { releaseLockedCode(code.getId()); throw exception; } int issued = codeMapper.update(null, new UpdateWrapper() .set("status", "ISSUED") .set("issued_claim_id", claim.getId()) .set("issued_at", now) .eq("id", code.getId()) .eq("status", "LOCKED") .eq("deleted", 0)); if (issued != 1) { throw new BusinessException(ErrorCode.CONFLICT, "兑换码发放失败,请重试"); } return claimView(claim, true, quota, packageUsed + 1); } @Transactional(readOnly = true) public Map claimDetail(Long claimId, Long talentId) { Long id = requireId(claimId, "领取记录ID不能为空"); Long userId = requireId(talentId, "持卡人ID不能为空"); Map row = claimMapper.selectPortalDetail(id, userId); if (row == null || row.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "票包领取记录不存在或无权查看"); } Long rowPackageId = longValue(row, "packageId", "package_id"); Long rowPrimaryCardId = longValue(row, "primaryCardId", "primary_card_id"); if (rowPrimaryCardId == null || rowPrimaryCardId <= 0) { rowPrimaryCardId = longValue(row, "cardId", "card_id"); } String rowCardLevel = text(row, "cardLevel", "card_level"); if (rowCardLevel.isBlank() && rowPrimaryCardId != null && rowPrimaryCardId > 0) { rowCardLevel = text(claimMapper.selectCardLevelSnapshot(rowPrimaryCardId), "cardLevel", "card_level"); } Quota quota = quota(rowPackageId, rowCardLevel); int used = intValue(claimMapper.countPackageClaimedInWindow(rowPackageId, rowPrimaryCardId, quota.windowStart())); int totalQuantity = intValue(value(row, "totalQuantity", "total_quantity")); int issuedCodes = intValue(value(row, "issuedCodes", "issued_codes")); int usedCodes = intValue(value(row, "usedCodes", "used_codes")); int claimedCount = intValue(value(row, "claimedCount", "claimed_count")); String status = text(row, "status"); String codeStatus = "USED".equals(status) ? "USED" : "ACTIVE"; Map result = new LinkedHashMap<>(); result.put("claimId", value(row, "claimId")); result.put("claimNo", text(row, "claimNo")); result.put("packageId", value(row, "packageId")); result.put("couponName", text(row, "packageName")); result.put("packageName", text(row, "packageName")); result.put("merchantId", value(row, "merchantId")); result.put("merchantName", text(row, "merchantName")); result.put("merchantNames", text(row, "merchantNames")); result.put("appName", text(row, "appName")); result.put("claimStatus", status); result.put("exchangeCodeStatus", codeStatus); result.put("exchangeCode", "ACTIVE".equals(codeStatus) ? text(row, "exchangeCode") : ""); result.put("validEnd", value(row, "validEnd")); result.put("usedAt", value(row, "usedAt")); result.put("totalQuantity", totalQuantity); result.put("claimedCount", claimedCount); result.put("remainingCount", Math.max(totalQuantity - claimedCount, 0)); result.put("totalCodes", intValue(value(row, "totalCodes", "total_codes"))); result.put("availableCodes", intValue(value(row, "availableCodes", "available_codes"))); result.put("lockedCodes", intValue(value(row, "lockedCodes", "locked_codes"))); result.put("issuedCodes", issuedCodes); result.put("usedCodes", usedCodes); result.put("voidCodes", intValue(value(row, "voidCodes", "void_codes"))); result.put("quotaTotal", quota.total()); result.put("quotaUsed", used); result.put("quotaRemaining", Math.max(quota.total() - used, 0)); result.put("packageQuotaTotal", quota.total()); result.put("packageQuotaUsed", used); result.put("packageQuotaRemaining", Math.max(quota.total() - used, 0)); result.put("quotaResetCycle", quota.resetCycle()); result.put("annualTotal", quota.total()); result.put("annualClaimed", used); result.put("annualRemaining", Math.max(quota.total() - used, 0)); result.put("channel", text(row, "appName").isBlank() ? "指定商家APP" : text(row, "appName") + "APP"); result.put("verificationRule", text(row, "usageText").isBlank() ? "复制兑换码后前往指定商家APP兑换电影票" : text(row, "usageText")); result.put("invalidReason", "USED".equals(status) ? "兑换码已使用" : ""); return result; } private void releaseLockedCode(Long codeId) { codeMapper.update(null, new UpdateWrapper() .set("status", "AVAILABLE") .set("locked_by_talent_id", null) .set("locked_by_card_id", null) .set("locked_at", null) .eq("id", codeId) .eq("status", "LOCKED")); } private List> portalPackageViews(Long merchantId, CardPair cards) { return packageMapper.selectPortalPackages(merchantId).stream() .map(row -> portalPackageView(row, cards)) .toList(); } private Map selectQuotaPackage(List> packages, Long preferredPackageId) { if (preferredPackageId != null && preferredPackageId > 0) { for (Map row : packages) { Long packageId = longValue(row, "packageId"); if (preferredPackageId.equals(packageId)) { return row; } } } for (Map row : packages) { if (booleanValue(row.get("available"))) { return row; } } return packages.get(0); } private Map portalPackageView(Map row, CardPair cards) { Quota quota; try { quota = quota(longValue(row, "id"), cards.primaryCardLevel()); } catch (BusinessException exception) { quota = new Quota(0, "", LocalDate.of(1970, 1, 1).atStartOfDay()); } boolean supports = supportsCard(text(row, "talentLevels", "talent_levels"), cards.primaryCardLevel()); int totalQuantity = intValue(value(row, "totalQuantity", "total_quantity")); int issuedCodes = intValue(value(row, "issuedCodes", "issued_codes")); int usedCodes = intValue(value(row, "usedCodes", "used_codes")); int claimedCount = intValue(value(row, "claimedCount", "claimed_count")); int availableCodes = intValue(value(row, "availableCodes", "available_codes")); int packageUsed = intValue(claimMapper.countPackageClaimedInWindow(longValue(row, "id"), cards.primaryCardId(), quota.windowStart())); int remainingQuota = Math.max(quota.total() - packageUsed, 0); boolean available = supports && availableCodes > 0 && remainingQuota > 0; Map result = new LinkedHashMap<>(); result.put("packageId", value(row, "id")); result.put("packageNo", text(row, "packageNo", "package_no")); result.put("packageName", text(row, "packageName", "package_name")); result.put("merchantId", value(row, "merchantId", "merchant_id")); result.put("merchantName", text(row, "merchantName", "merchant_name")); result.put("merchantNames", text(row, "merchantNames", "merchant_names")); result.put("coverUrl", text(row, "coverUrl", "cover_url")); result.put("claimPrice", value(row, "claimPrice", "claim_price")); result.put("validStart", value(row, "validStart", "valid_start")); result.put("validEnd", value(row, "validEnd", "valid_end")); result.put("appName", text(row, "appName", "app_name")); result.put("appJumpUrl", text(row, "appJumpUrl", "app_jump_url")); result.put("usageText", text(row, "usageText", "usage_text")); result.put("totalQuantity", totalQuantity); result.put("claimedCount", claimedCount); result.put("remainingCount", Math.max(totalQuantity - claimedCount, 0)); result.put("totalCodes", intValue(value(row, "totalCodes", "total_codes"))); result.put("availableCodes", availableCodes); result.put("lockedCodes", intValue(value(row, "lockedCodes", "locked_codes"))); result.put("issuedCodes", issuedCodes); result.put("usedCodes", usedCodes); result.put("voidCodes", intValue(value(row, "voidCodes", "void_codes"))); result.put("quotaTotal", quota.total()); result.put("quotaUsed", packageUsed); result.put("quotaRemaining", remainingQuota); result.put("packageQuotaTotal", quota.total()); result.put("packageQuotaUsed", packageUsed); result.put("packageQuotaRemaining", remainingQuota); result.put("packageLimit", quota.total()); result.put("packageUsed", packageUsed); result.put("quotaResetCycle", quota.resetCycle()); result.put("quotaWindowStart", quota.windowStart()); result.put("available", available); result.put("unavailableReason", unavailableReason(supports, availableCodes, remainingQuota)); return result; } private Map claimView(TicketPackageClaim claim, boolean newlyIssued) { Quota quota = quota(claim.getPackageId(), claim.getCardLevel()); int used = intValue(claimMapper.countPackageClaimedInWindow( claim.getPackageId(), claim.getPrimaryCardId(), quota.windowStart())); return claimView(claim, newlyIssued, quota, used); } private Map claimView(TicketPackageClaim claim, boolean newlyIssued, Quota quota, int used) { Map result = new LinkedHashMap<>(); Map detail = packageMapper.selectAdminDetail(claim.getPackageId()); result.put("claimId", claim.getId()); result.put("claimNo", claim.getClaimNo()); result.put("packageId", claim.getPackageId()); result.put("packageName", text(detail, "packageName")); result.put("couponName", text(detail, "packageName")); result.put("merchantId", claim.getMerchantId()); result.put("merchantName", text(detail, "merchantName")); result.put("merchantNames", text(detail, "merchantNames")); result.put("appName", text(detail, "appName")); result.put("talentId", claim.getTalentId()); result.put("cardId", claim.getCardId()); result.put("primaryCardId", claim.getPrimaryCardId()); result.put("cardNo", claim.getCardNo()); result.put("cardLevel", claim.getCardLevel()); result.put("exchangeCode", claim.getExchangeCode()); result.put("status", claim.getStatus()); result.put("statusName", "USED".equals(claim.getStatus()) ? "已使用" : "已发放"); result.put("validEnd", claim.getValidEnd()); result.put("quotaTotal", quota.total()); result.put("quotaUsed", used); result.put("quotaRemaining", Math.max(quota.total() - used, 0)); result.put("packageQuotaTotal", quota.total()); result.put("packageQuotaUsed", used); result.put("packageQuotaRemaining", Math.max(quota.total() - used, 0)); result.put("quotaResetCycle", quota.resetCycle()); result.put("quotaWindowStart", quota.windowStart()); result.put("annualTotal", quota.total()); result.put("annualClaimed", used); result.put("annualRemaining", Math.max(quota.total() - used, 0)); result.put("newlyIssued", newlyIssued); return result; } private CardPair cards(Long talentId, Long cardId, boolean lock) { if ((talentId == null || talentId <= 0) && (cardId == null || cardId <= 0)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "持卡人ID或人才卡ID不能为空"); } Map source = lock ? claimMapper.selectActiveCardSnapshotForUpdate(talentId, cardId) : activeCardSnapshot(talentId, cardId); if (source == null || source.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "有效人才卡不存在"); } String cardType = text(source, "cardType", "card_type").toUpperCase(Locale.ROOT); if ("PERSONAL".equals(cardType) || cardType.isBlank()) { return new CardPair( longValue(source, "talentId", "talent_id"), longValue(source, "cardId", "card_id"), text(source, "cardNo", "card_no"), cardType, text(source, "cardLevel", "card_level"), text(source, "talentName", "talent_name"), text(source, "talentMobile", "talent_mobile"), longValue(source, "cardId", "card_id"), longValue(source, "talentId", "talent_id"), text(source, "cardNo", "card_no"), text(source, "cardLevel", "card_level"), false ); } Map primary = lock ? claimMapper.selectPrimaryCardByFamilyCardForUpdate(longValue(source, "cardId", "card_id")) : primaryCardSnapshot(longValue(source, "cardId", "card_id")); if (primary == null || primary.isEmpty()) { throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到有效主卡"); } return new CardPair( longValue(source, "talentId", "talent_id"), longValue(source, "cardId", "card_id"), text(source, "cardNo", "card_no"), cardType, text(primary, "cardLevel", "card_level"), text(source, "talentName", "talent_name"), text(source, "talentMobile", "talent_mobile"), longValue(primary, "cardId", "card_id"), longValue(primary, "talentId", "talent_id"), text(primary, "cardNo", "card_no"), text(primary, "cardLevel", "card_level"), true ); } private Map activeCardSnapshot(Long talentId, Long cardId) { return claimMapper.selectActiveCardSnapshot(talentId, cardId); } private Map primaryCardSnapshot(Long familyCardId) { return claimMapper.selectPrimaryCardByFamilyCard(familyCardId); } private void validatePackage(TicketPackage ticketPackage, String cardLevel) { if (ticketPackage == null || intValue(ticketPackage.getDeleted()) == 1) { throw new BusinessException(ErrorCode.NOT_FOUND, "票包不存在"); } if (intValue(ticketPackage.getStatus()) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "票包当前不可领取"); } LocalDate today = LocalDate.now(); if (ticketPackage.getValidStart() != null && ticketPackage.getValidStart().isAfter(today)) { throw new BusinessException(ErrorCode.CONFLICT, "票包尚未开始领取"); } if (ticketPackage.getValidEnd() != null && ticketPackage.getValidEnd().isBefore(today)) { throw new BusinessException(ErrorCode.CONFLICT, "票包已过期"); } if (!supportsCard(ticketPackage.getTalentLevels(), cardLevel)) { throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡不适用该票包"); } } private Long claimMerchantId(TicketPackage ticketPackage, Long requestMerchantId) { if (requestMerchantId == null || requestMerchantId <= 0) { return ticketPackage.getMerchantId(); } if (intValue(packageMapper.countPackageMerchant(ticketPackage.getId(), requestMerchantId)) < 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前影城不适用该票包"); } return requestMerchantId; } private Quota quota(Long packageId, String cardLevel) { String normalizedLevel = normalizeCardLevel(cardLevel); com.talentcard.server.entity.TicketPackageLevelRule rule = null; if (!normalizedLevel.isBlank()) { rule = packageLevelRuleMapper.selectOne(new QueryWrapper() .eq("package_id", packageId) .eq("deleted", 0) .eq("talent_level", normalizedLevel) .last("limit 1")); } List aliases = cardLevelAliases(cardLevel); if (rule == null && !aliases.isEmpty()) { rule = packageLevelRuleMapper.selectOne(new QueryWrapper() .eq("package_id", packageId) .eq("deleted", 0) .in("talent_level", aliases) .last("limit 1")); } if (rule == null) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未配置该票包可兑换额度"); } if (intValue(rule.getEnabled()) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未启用该票包权益"); } int total = intValue(rule.getClaimableCount()); if (total < 1) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未配置该票包可兑换额度"); } String resetCycle = clean(rule.getResetCycle()); return new Quota(total, resetCycle, windowStart(resetCycle)); } private String normalizeCardLevel(String cardLevel) { String raw = clean(cardLevel); String upper = raw.toUpperCase(Locale.ROOT); if (upper.contains("EXCELLENT") || raw.contains("卓越")) { return "卓越人才卡"; } if (upper.contains("OUTSTANDING") || raw.contains("杰出")) { return "杰出人才卡"; } if (upper.contains("ELITE") || raw.contains("菁英") || raw.contains("精英")) { return "菁英人才卡"; } return raw; } private List cardLevelAliases(String cardLevel) { String raw = clean(cardLevel); String upper = raw.toUpperCase(Locale.ROOT); if (upper.contains("EXCELLENT") || raw.contains("卓越")) { return List.of(raw, "卓越人才卡", "卓越", "EXCELLENT"); } if (upper.contains("OUTSTANDING") || raw.contains("杰出")) { return List.of(raw, "杰出人才卡", "杰出", "OUTSTANDING"); } if (upper.contains("ELITE") || raw.contains("菁英") || raw.contains("精英")) { return List.of(raw, "菁英人才卡", "菁英", "精英人才卡", "精英", "ELITE"); } return List.of(raw); } private LocalDateTime windowStart(String resetCycle) { String cycle = clean(resetCycle).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("天") || cycle.contains("日")) { return today.atStartOfDay(); } if (cycle.contains("WEEK") || cycle.contains("周") || cycle.contains("星期")) { return today.minusDays(today.getDayOfWeek().getValue() - 1L).atStartOfDay(); } if (cycle.contains("MONTH") || cycle.contains("月")) { return today.withDayOfMonth(1).atStartOfDay(); } if (cycle.contains("YEAR") || cycle.contains("ANNUAL") || cycle.contains("年")) { return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); } return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); } private boolean supportsCard(String configured, String cardLevel) { String value = clean(configured); String level = clean(cardLevel); if (value.isBlank()) { return true; } if (level.isBlank()) { return false; } return value.contains(level) || cardLevelAliases(level).stream() .filter(alias -> !alias.isBlank()) .anyMatch(value::contains); } private String unavailableReason(boolean supports, int availableCodes, int remainingQuota) { if (!supports) { return "当前人才卡不适用该票包"; } if (availableCodes < 1) { return "票包兑换码库存不足"; } if (remainingQuota < 1) { return "票包可兑换额度不足"; } return ""; } private Long requireId(Long id, String message) { if (id == null || id <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, message); } return id; } private String first(String... values) { for (String value : values) { String text = clean(value); if (!text.isBlank()) { return text; } } return ""; } private Object value(Map row, String... keys) { if (row == null) { return null; } for (String key : keys) { if (row.containsKey(key)) { return row.get(key); } } return null; } private String text(Map row, String... keys) { Object value = value(row, keys); return value == null ? "" : String.valueOf(value).trim(); } private Long longValue(Map row, String... keys) { Object value = value(row, keys); return value instanceof Number number ? number.longValue() : null; } private int intValue(Object value) { return value instanceof Number number ? number.intValue() : 0; } private boolean booleanValue(Object value) { if (value instanceof Boolean bool) { return bool; } if (value instanceof Number number) { return number.intValue() != 0; } String raw = value == null ? "" : String.valueOf(value).trim(); return "true".equalsIgnoreCase(raw) || "1".equals(raw); } private String clean(String value) { return value == null ? "" : value.trim(); } private record CardPair( Long sourceTalentId, Long sourceCardId, String sourceCardNo, String sourceCardType, String primaryCardLevel, String sourceTalentName, String sourceTalentMobile, Long primaryCardId, Long primaryTalentId, String primaryCardNo, String primaryCardLevelSnapshot, boolean familyCard ) { } private record Quota(int total, String resetCycle, LocalDateTime windowStart) { } }