package com.talentcard.server.service; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import javax.imageio.ImageIO; import com.google.zxing.BarcodeFormat; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.qrcode.QRCodeWriter; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; 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.request.BenefitVerificationCodeRequest; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.entity.BenefitCinemaCoupon; import com.talentcard.server.entity.BenefitCinemaCouponClaim; import com.talentcard.server.entity.BenefitFreeStayVerificationCode; import com.talentcard.server.entity.BenefitFreeStayVoucher; import com.talentcard.server.entity.BenefitMerchant; import com.talentcard.server.entity.TicketPackageCode; import com.talentcard.server.entity.TicketPackageClaim; import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper; import com.talentcard.server.mapper.BenefitCinemaCouponMapper; import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper; import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper; import com.talentcard.server.mapper.BenefitMerchantMapper; import com.talentcard.server.mapper.TicketPackageCodeMapper; import com.talentcard.server.mapper.TicketPackageClaimMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class BenefitVerificationCodeService { public static final int MOVIE_CODE_SECONDS = 0; public static final int HOTEL_CODE_SECONDS = 60; public static final String MOVIE_TOKEN_PREFIX = "WD-"; public static final String HOTEL_TOKEN_PREFIX = "HV2"; private final BenefitQuotaService quotaService; private final BenefitMerchantMapper merchantMapper; private final BenefitCinemaCouponMapper couponMapper; private final BenefitCinemaCouponClaimMapper claimMapper; private final BenefitFreeStayVoucherMapper voucherMapper; private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper; private final TicketPackageCodeMapper codeMapper; private final TicketPackageClaimMapper ticketPackageClaimMapper; private final TicketPackagePortalService ticketPackageService; public BenefitVerificationCodeService( BenefitQuotaService quotaService, BenefitMerchantMapper merchantMapper, TicketPackageCodeMapper codeMapper, TicketPackageClaimMapper ticketPackageClaimMapper, TicketPackagePortalService ticketPackageService, BenefitCinemaCouponMapper couponMapper, BenefitCinemaCouponClaimMapper claimMapper, BenefitFreeStayVoucherMapper voucherMapper, BenefitFreeStayVerificationCodeMapper freeStayCodeMapper ) { this.quotaService = quotaService; this.merchantMapper = merchantMapper; this.codeMapper = codeMapper; this.ticketPackageClaimMapper = ticketPackageClaimMapper; this.ticketPackageService = ticketPackageService; this.couponMapper = couponMapper; this.claimMapper = claimMapper; this.voucherMapper = voucherMapper; this.freeStayCodeMapper = freeStayCodeMapper; } @Transactional(readOnly = true) public byte[] movieCodeImage(Long claimId) { BenefitCinemaCouponClaim claim = movieClaimById(claimId); if (!activeMovieCode(claim)) { throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码不可用"); } return qrPng(claim.getExchangeCode(), "电影票二维码生成失败"); } /** * 观影权益(票包兑换码)的二维码:内容是后台批量生成、绑定影城的那串兑换码。 * *

与电影券的二维码分成两个入口,因为两者的领取记录不在同一张表——同一个 id 可能两边都存在, * 合并成一个接口会张冠李戴。 */ @Transactional(readOnly = true) public byte[] ticketPackageCodeImage(Long claimId) { TicketPackageClaim claim = ticketPackageClaimMapper.selectById(claimId); if (claim == null || (claim.getDeleted() != null && claim.getDeleted() != 0)) { throw new BusinessException(ErrorCode.NOT_FOUND, "观影权益领取记录不存在"); } if (!"ISSUED".equals(upper(claim.getStatus()))) { throw new BusinessException(ErrorCode.CONFLICT, "观影权益已核销或已失效"); } if (expiredDate(claim.getValidEnd())) { throw new BusinessException(ErrorCode.CONFLICT, "观影权益已过期"); } return qrPng(claim.getExchangeCode(), "观影权益二维码生成失败"); } private byte[] qrPng(String content, String failureMessage) { try { var matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 480, 480); BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix); ByteArrayOutputStream output = new ByteArrayOutputStream(); ImageIO.write(image, "PNG", output); return output.toByteArray(); } catch (Exception ex) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, failureMessage); } } @Transactional public Map quota(BenefitVerificationCodeRequest request) { Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空"); String benefitType = quotaService.normalizeBenefitType(request.benefitType()); if (BenefitQuotaService.MOVIE_TICKET.equals(benefitType)) { ensureCinemaMerchant(merchantId); if (ticketPackageService.hasPortalPackages(merchantId)) { return ticketPackageService.merchantQuota(request.talentId(), request.cardId(), merchantId); } } else { ensureHotelMerchant(merchantId); } BenefitQuotaService.QuotaSnapshot quota = quotaService.current( benefitType, request.cardId(), request.talentId(), merchantId, null); Map result = new LinkedHashMap<>(); result.put("benefitType", benefitType); result.put("merchantId", merchantId); result.put("available", quota.remaining() > 0); result.put("quotaTotal", quota.total()); result.put("quotaUsed", quota.used()); result.put("quotaRemaining", quota.remaining()); result.put("quotaResetCycle", quota.resetCycle()); result.put("quotaDetail", quota.detail()); result.put("quotaWindowStart", quota.windowStart()); result.put("sourceCardId", quota.cardId()); result.put("sourceTalentId", quota.talentId()); result.put("sourceCardType", quota.cardType()); result.put("familyCard", quota.familyCard()); result.put("primaryCardId", quota.primaryCardId()); result.put("primaryTalentId", quota.primaryTalentId()); result.put("primaryCardNo", quota.primaryCardNo()); result.put("cardLevel", quota.cardLevel()); return result; } @Transactional public Map generate(BenefitVerificationCodeRequest request, String idempotencyKey) { String benefitType = quotaService.normalizeBenefitType(request.benefitType()); if (BenefitQuotaService.MOVIE_TICKET.equals(benefitType)) { return generateMovieCode(request, idempotencyKey); } return generateHotelFreeStayCode(request); } private Map generateMovieCode(BenefitVerificationCodeRequest request, String idempotencyKey) { Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空"); ensureCinemaMerchant(merchantId); if (ticketPackageService.hasPortalPackages(merchantId)) { return generateMovieTicketPackageCode(request, idempotencyKey, merchantId); } BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable( BenefitQuotaService.MOVIE_TICKET, request.cardId(), request.talentId(), merchantId, null, 1); MovieCouponChoice choice = movieCouponForUpdate(merchantId, quota.cardLevel(), quota.sharedCardIds()); BenefitCinemaCoupon coupon = choice.coupon(); Long couponId = coupon.getId(); int dailyLimit = choice.dailyLimit(); int dailyUsed = choice.dailyUsed(); String claimKey = claimIdempotencyKey(idempotencyKey, "MOVIE"); BenefitCinemaCouponClaim idempotent = movieClaimByIdempotencyKey(claimKey); if (idempotent != null) { ensureSameMovieClaim(idempotent, couponId, merchantId, quota.cardId()); if (activeMovieCode(idempotent)) { return movieCodeResponse(idempotent, coupon, quota, dailyLimit, dailyUsed, false); } throw new BusinessException(ErrorCode.CONFLICT, "幂等键对应的电影票核销码已过期或已使用"); } BenefitCinemaCouponClaim existing = movieClaimForUpdate(couponId, quota.talentId()); if (existing != null) { String status = upper(existing.getCodeStatus()); if ("USED".equals(status)) { throw new BusinessException(ErrorCode.CONFLICT, "电影票已核销"); } if (activeMovieCode(existing)) { return movieCodeResponse(existing, coupon, quota, dailyLimit, dailyUsed, false); } } TicketPackageCode poolCode = reserveMoviePoolCode(merchantId, quota.talentId(), quota.cardId()); Long claimId; if (existing != null) { decrementMovieStock(couponId, merchantId); BenefitCinemaCouponClaim update = new BenefitCinemaCouponClaim(); update.setId(existing.getId()); update.setExchangeCode(poolCode.getExchangeCode()); update.setCodeStatus("ACTIVE"); update.setStatus("RECEIVED"); update.setUsedAt(null); update.setValidEnd(coupon.getValidEnd()); update.setIdempotencyKey(claimKey); update.setDeleted(0); claimMapper.updateById(update); claimId = existing.getId(); } else { decrementMovieStock(couponId, merchantId); BenefitCinemaCouponClaim claim = new BenefitCinemaCouponClaim(); claim.setClaimNo(BusinessIdGenerator.next("MC")); claim.setCouponId(couponId); claim.setMerchantId(merchantId); claim.setTalentId(quota.talentId()); claim.setCardId(quota.cardId()); claim.setCardNo(quota.cardNo()); claim.setValidEnd(coupon.getValidEnd()); claim.setStatus("RECEIVED"); claim.setExchangeCode(poolCode.getExchangeCode()); claim.setCodeStatus("ACTIVE"); claim.setIdempotencyKey(claimKey); claim.setDeleted(0); claimMapper.insert(claim); claimId = claim.getId(); } issueMoviePoolCode(poolCode, claimId); BenefitCinemaCouponClaim claim = movieClaimById(claimId); return movieCodeResponse(claim, couponMapper.selectById(couponId), quota, dailyLimit, dailyUsed + 1, true); } private Map generateMovieTicketPackageCode(BenefitVerificationCodeRequest request, String idempotencyKey, Long merchantId) { String claimKey = claimIdempotencyKey(idempotencyKey, "MOVIE"); Map claim = ticketPackageService.claimByMerchant( merchantId, request.talentId(), request.cardId(), claimKey); Long packageId = longValue(claim.get("packageId")); Map quota = ticketPackageService.packageQuota( request.talentId(), request.cardId(), merchantId, packageId); LocalDateTime expiresAt = ticketPackageExpiresAt(claim.get("validEnd")); int quotaTotal = intValue(quota.get("quotaTotal")); int quotaUsed = intValue(quota.get("quotaUsed")); int quotaRemaining = intValue(quota.get("quotaRemaining")); boolean newlyIssued = booleanValue(claim.get("newlyIssued")); Map result = new LinkedHashMap<>(); result.put("benefitType", BenefitQuotaService.MOVIE_TICKET); result.put("verifyCode", claim.get("exchangeCode")); result.put("displayCode", claim.get("exchangeCode")); result.put("exchangeCode", claim.get("exchangeCode")); result.put("expiresAt", expiresAt); result.put("expiresAtEpochMillis", expiresAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); result.put("expiresInSeconds", Math.max(0, java.time.Duration.between(LocalDateTime.now(), expiresAt).toSeconds())); result.put("refreshAfterSeconds", MOVIE_CODE_SECONDS); result.put("sharedQuotaMode", "AUTO_DEDUCT"); result.put("sourceType", "TICKET_PACKAGE_CLAIM"); result.put("sourceId", claim.get("claimId")); result.put("claimId", claim.get("claimId")); result.put("merchantId", merchantId); result.put("merchantName", quota.get("merchantName")); result.put("packageId", claim.get("packageId")); result.put("couponId", claim.get("packageId")); result.put("couponName", first(claim.get("packageName"), quota.get("packageName"))); result.put("packageName", first(claim.get("packageName"), quota.get("packageName"))); result.put("quotaTotal", quotaTotal); result.put("quotaUsed", quotaUsed); result.put("quotaRemainingBefore", newlyIssued ? quotaRemaining + 1 : quotaRemaining); result.put("quotaRemaining", quotaRemaining); result.put("quotaResetCycle", quota.get("quotaResetCycle")); result.put("quotaWindowStart", quota.get("quotaWindowStart")); result.put("sourceCardId", quota.get("sourceCardId")); result.put("sourceTalentId", quota.get("sourceTalentId")); result.put("sourceCardType", quota.get("sourceCardType")); result.put("familyCard", quota.get("familyCard")); result.put("primaryCardId", quota.get("primaryCardId")); result.put("primaryTalentId", quota.get("primaryTalentId")); result.put("primaryCardNo", quota.get("primaryCardNo")); result.put("cardLevel", quota.get("cardLevel")); result.put("dailyLimit", quotaTotal); result.put("dailyUsed", quotaUsed); result.put("dailyRemaining", quotaRemaining); result.put("quotaEffect", newlyIssued ? "RESERVED_ON_CODE_GENERATION" : "EXISTING_ACTIVE_CODE"); return result; } private Map generateHotelFreeStayCode(BenefitVerificationCodeRequest request) { Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空"); ensureHotelMerchant(merchantId); BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable( BenefitQuotaService.HOTEL, request.cardId(), request.talentId(), merchantId, null, 1); BenefitFreeStayVoucher voucher = voucherForUpdate(quota, merchantId); if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) { throw new BusinessException(ErrorCode.CONFLICT, "酒店权益券当前不可用"); } if (intValue(voucher.getUsedCount()) >= Math.min(intValue(voucher.getTotalCount()), quota.total())) { throw new BusinessException(ErrorCode.CONFLICT, "酒店权益次数已用完"); } freeStayCodeMapper.update(null, new UpdateWrapper() .set("status", 2) .eq("voucher_id", voucher.getId()) .eq("status", 0)); BenefitFreeStayVerificationCode code = new BenefitFreeStayVerificationCode(); code.setCodeToken(hotelFreeStayToken(quota.cardId())); code.setVoucherId(voucher.getId()); code.setExpiresAt(LocalDateTime.now().plusSeconds(HOTEL_CODE_SECONDS)); code.setStatus(0); freeStayCodeMapper.insert(code); return hotelCodeResponse(code, voucher, quota); } private MovieCouponChoice movieCouponForUpdate(Long merchantId, String cardLevel, List sharedCardIds) { ensureCinemaMerchant(merchantId); List coupons = couponMapper.selectList(new QueryWrapper() .eq("merchant_id", merchantId) .eq("deleted", 0) .eq("status", 1) .gt("stock", 0) .and(wrapper -> wrapper.isNull("valid_end").or().ge("valid_end", LocalDate.now())) .orderByAsc("sort_no", "id") .last("for update")); if (coupons.isEmpty()) { throw new BusinessException(ErrorCode.CONFLICT, "未找到可用电影票权益券"); } List aliases = quotaService.cardLevelAliases(cardLevel); boolean cardLevelSupported = false; for (BenefitCinemaCoupon coupon : coupons) { if (!supportsCard(coupon.getTalentLevels(), aliases)) { continue; } cardLevelSupported = true; int dailyLimit = Math.max(1, intValue(coupon.getPerUserLimit())); int dailyUsed = dailyMovieClaims(sharedCardIds, coupon.getId()); if (dailyUsed < dailyLimit) { return new MovieCouponChoice(coupon, dailyLimit, dailyUsed); } } if (!cardLevelSupported) { throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型不适用该电影票权益券"); } throw new BusinessException(ErrorCode.CONFLICT, "当天电影票领取次数已达上限"); } private TicketPackageCode reserveMoviePoolCode(Long merchantId, Long talentId, Long cardId) { TicketPackageCode code = codeMapper.selectAvailableByMerchantForUpdate(merchantId); if (code == null) { throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码码池库存不足,请联系后台导入兑换码"); } int locked = codeMapper.lockForIssue(code.getId(), talentId, cardId); if (locked != 1) { throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码已被领取,请重试"); } return code; } private void issueMoviePoolCode(TicketPackageCode code, Long claimId) { codeMapper.update(null, new UpdateWrapper() .set("status", "ISSUED") .set("issued_claim_id", claimId) .set("issued_at", LocalDateTime.now()) .eq("id", code.getId()) .eq("status", "LOCKED") .eq("deleted", 0)); } private void decrementMovieStock(Long couponId, Long merchantId) { int updated = couponMapper.update(null, new UpdateWrapper() .setSql("stock = stock - 1") .eq("id", couponId) .eq("merchant_id", merchantId) .eq("deleted", 0) .eq("status", 1) .gt("stock", 0)); if (updated != 1) { throw new BusinessException(ErrorCode.CONFLICT, "电影票权益券库存不足"); } } private int dailyMovieClaims(List sharedCardIds, Long couponId) { Long count = claimMapper.selectCount(new QueryWrapper() .eq("deleted", 0) .in("card_id", sharedCardIds) .eq("coupon_id", couponId) .apply("((upper(coalesce(code_status, 'ACTIVE')) = 'USED' and used_at >= current_date) or " + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= current_date))")); return intValue(count); } private BenefitCinemaCouponClaim movieClaimByIdempotencyKey(String key) { return claimMapper.selectOne(new QueryWrapper() .eq("deleted", 0) .eq("idempotency_key", key) .last("limit 1 for update")); } private BenefitCinemaCouponClaim movieClaimForUpdate(Long couponId, Long talentId) { return claimMapper.selectOne(new QueryWrapper().eq("coupon_id", couponId) .eq("talent_id", talentId) .last("limit 1 for update")); } private BenefitCinemaCouponClaim movieClaimById(Long claimId) { BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper() .eq("deleted", 0) .eq("id", claimId) .last("limit 1")); if (claim == null) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "电影票领取记录保存失败"); } return claim; } private void ensureSameMovieClaim(BenefitCinemaCouponClaim claim, Long couponId, Long merchantId, Long cardId) { if (!couponId.equals(claim.getCouponId()) || !merchantId.equals(claim.getMerchantId()) || !cardId.equals(claim.getCardId())) { throw new BusinessException(ErrorCode.CONFLICT, "幂等键已被其他电影票核销码请求使用"); } } private void ensureCinemaMerchant(Long merchantId) { BenefitMerchant merchant = merchantMapper.selectById(merchantId); if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1 || !isCinemaMerchantType(merchant.getMerchantType())) { throw new BusinessException(ErrorCode.CONFLICT, "未找到有效影院商户"); } } private boolean isCinemaMerchantType(String merchantType) { String type = merchantType == null ? "" : merchantType.trim(); return "cinema".equalsIgnoreCase(type) || "theatre".equalsIgnoreCase(type) || "观影服务".equals(type) || "电影院".equals(type) || "影院".equals(type) || "影城".equals(type) || "剧院".equals(type); } private void ensureHotelMerchant(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, "未找到有效酒店商户"); } } private boolean isHotelMerchantType(String merchantType) { String type = merchantType == null ? "" : merchantType.trim(); return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type); } private BenefitFreeStayVoucher voucherForUpdate(BenefitQuotaService.QuotaSnapshot quota, Long merchantId) { BenefitFreeStayVoucher voucher = voucherMapper.selectOne(new QueryWrapper() .eq("talent_id", quota.primaryTalentId()) .eq("merchant_id", merchantId) .last("limit 1 for update")); LocalDate validEnd = LocalDate.now().plusYears(1); if (voucher == null) { voucher = new BenefitFreeStayVoucher(); voucher.setVoucherNo(BusinessIdGenerator.next("FSV")); voucher.setTalentId(quota.primaryTalentId()); voucher.setMerchantId(merchantId); voucher.setCardId(quota.primaryCardId()); voucher.setTotalCount(quota.total()); voucher.setUsedCount(quota.used()); voucher.setValidEnd(validEnd); voucher.setStatus(1); voucherMapper.insert(voucher); return voucher; } voucher.setCardId(quota.primaryCardId()); voucher.setTotalCount(quota.total()); voucher.setUsedCount(quota.used()); voucher.setValidEnd(validEnd); voucher.setStatus(1); voucherMapper.updateById(voucher); return voucher; } private Map movieCodeResponse(BenefitCinemaCouponClaim claim, BenefitCinemaCoupon coupon, BenefitQuotaService.QuotaSnapshot quota, int dailyLimit, int dailyUsed, boolean newlyReserved) { LocalDateTime expiresAt = coupon != null && coupon.getValidEnd() != null ? coupon.getValidEnd().plusDays(1).atStartOfDay() : LocalDateTime.now().plusYears(1); BenefitMerchant merchant = merchantMapper.selectById(claim.getMerchantId()); Map result = baseResponse(BenefitQuotaService.MOVIE_TICKET, claim.getExchangeCode(), expiresAt, quota); result.put("displayCode", claim.getExchangeCode()); result.put("sourceType", "CINEMA_COUPON_CLAIM"); result.put("sourceId", claim.getId()); result.put("merchantId", claim.getMerchantId()); result.put("merchantName", merchant == null ? "" : merchant.getMerchantName()); result.put("couponId", claim.getCouponId()); result.put("couponName", coupon == null ? "" : coupon.getCouponName()); result.put("stockRemaining", coupon == null ? null : coupon.getStock()); result.put("dailyLimit", dailyLimit); result.put("dailyUsed", dailyUsed); result.put("dailyRemaining", Math.max(dailyLimit - dailyUsed, 0)); result.put("quotaEffect", newlyReserved ? "RESERVED_ON_CODE_GENERATION" : "EXISTING_ACTIVE_CODE"); return result; } private Map hotelCodeResponse(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher, BenefitQuotaService.QuotaSnapshot quota) { Map result = baseResponse(BenefitQuotaService.HOTEL, code.getCodeToken(), code.getExpiresAt(), quota); result.put("displayCode", shortDisplayCode(code.getCodeToken())); result.put("sourceType", "FREE_STAY_VERIFICATION_CODE"); result.put("sourceId", code.getId()); result.put("merchantId", voucher.getMerchantId()); result.put("voucherId", voucher.getId()); result.put("voucherNo", voucher.getVoucherNo()); result.put("voucherTotal", voucher.getTotalCount()); result.put("voucherUsed", voucher.getUsedCount()); result.put("quotaEffect", "DEDUCT_ON_VERIFICATION"); return result; } private Map baseResponse(String benefitType, Object codeToken, LocalDateTime expiresAt, BenefitQuotaService.QuotaSnapshot quota) { Map result = new LinkedHashMap<>(); result.put("benefitType", benefitType); result.put("verifyCode", codeToken); result.put("expiresAt", expiresAt); result.put("expiresAtEpochMillis", expiresAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); result.put("expiresInSeconds", Math.max(0, java.time.Duration.between(LocalDateTime.now(), expiresAt).toSeconds())); result.put("refreshAfterSeconds", BenefitQuotaService.HOTEL.equals(benefitType) ? HOTEL_CODE_SECONDS : 0); result.put("sharedQuotaMode", "AUTO_DEDUCT"); result.put("sourceCardId", quota.cardId()); result.put("sourceTalentId", quota.talentId()); result.put("sourceCardType", quota.cardType()); result.put("familyCard", quota.familyCard()); result.put("primaryCardId", quota.primaryCardId()); result.put("primaryTalentId", quota.primaryTalentId()); result.put("primaryCardNo", quota.primaryCardNo()); result.put("cardLevel", quota.cardLevel()); result.put("quotaTotal", quota.total()); result.put("quotaUsed", quota.used()); result.put("quotaRemainingBefore", quota.remaining()); result.put("quotaResetCycle", quota.resetCycle()); result.put("quotaDetail", quota.detail()); result.put("quotaWindowStart", quota.windowStart()); return result; } private String hotelFreeStayToken(Long sourceCardId) { long card = sourceCardId == null ? 0 : sourceCardId; return HOTEL_TOKEN_PREFIX + "C" + card + "P0_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT); } private boolean activeMovieCode(BenefitCinemaCouponClaim claim) { return "ACTIVE".equals(upper(claim.getCodeStatus())) && !expiredDate(claim.getValidEnd()); } private boolean startsWithMoviePrefix(String code) { return code != null && code.startsWith(MOVIE_TOKEN_PREFIX); } private LocalDateTime ticketPackageExpiresAt(Object validEnd) { if (validEnd instanceof LocalDate date) { return date.plusDays(1).atStartOfDay(); } if (validEnd instanceof java.sql.Date date) { return date.toLocalDate().plusDays(1).atStartOfDay(); } String raw = validEnd == null ? "" : String.valueOf(validEnd).trim(); if (raw.length() >= 10) { try { return LocalDate.parse(raw.substring(0, 10)).plusDays(1).atStartOfDay(); } catch (RuntimeException ignored) { return LocalDateTime.now().plusYears(1); } } return LocalDateTime.now().plusYears(1); } private String claimIdempotencyKey(String raw, String token) { if (raw == null || raw.isBlank()) { return "VCODE-" + token; } String key = raw.trim(); if (key.length() > 100) { throw new BusinessException(ErrorCode.BAD_REQUEST, "幂等键长度不能超过限制"); } return "VCODE-" + key; } private boolean supportsCard(String levels, List aliases) { String configured = levels == null ? "" : levels.trim(); if (configured.isBlank()) { return true; } for (String alias : aliases) { if (alias != null && !alias.isBlank() && configured.contains(alias)) { return true; } } return false; } private String shortDisplayCode(String token) { if (token == null || token.length() <= 8) { return token; } return token.substring(token.length() - 8); } private Long requirePositive(Long value, String message) { if (value == null || value <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, message); } return value; } private boolean expiredDate(LocalDate value) { return value != null && value.isBefore(LocalDate.now()); } private String upper(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); } private int intValue(Object value) { return value instanceof Number number ? number.intValue() : 0; } private Long longValue(Object value) { return value instanceof Number number ? number.longValue() : null; } private Object first(Object... values) { for (Object value : values) { if (value != null && !String.valueOf(value).isBlank()) { return value; } } return ""; } 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 record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) { } }