yeg
2026-09-28 85bfb9351803d97a33329d507f30a2f78bf1fc75
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
@@ -1,5 +1,11 @@
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;
@@ -20,18 +26,23 @@
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 CODE_SECONDS = 300;
    public static final String MOVIE_TOKEN_PREFIX = "MV2";
    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;
@@ -40,10 +51,16 @@
    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,
@@ -51,10 +68,90 @@
    ) {
        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(), "电影票二维码生成失败");
    }
    /**
     * 观影权益(票包兑换码)的二维码:内容是后台批量生成、绑定影城的那串兑换码。
     *
     * <p>与电影券的二维码分成两个入口,因为两者的领取记录不在同一张表——同一个 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<String, Object> 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<String, Object> 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
@@ -68,7 +165,10 @@
    private Map<String, Object> generateMovieCode(BenefitVerificationCodeRequest request, String idempotencyKey) {
        Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
        expireExpiredMovieCodes(merchantId);
        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);
@@ -78,39 +178,40 @@
        int dailyLimit = choice.dailyLimit();
        int dailyUsed = choice.dailyUsed();
        String token = MOVIE_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
        String claimKey = claimIdempotencyKey(idempotencyKey, token);
        String claimKey = claimIdempotencyKey(idempotencyKey, "MOVIE");
        BenefitCinemaCouponClaim idempotent = movieClaimByIdempotencyKey(claimKey);
        if (idempotent != null) {
            ensureSameMovieClaim(idempotent, couponId, merchantId, quota.cardId());
            if (activeMovieCode(idempotent) && startsWithMoviePrefix(idempotent.getExchangeCode())) {
            if (activeMovieCode(idempotent)) {
                return movieCodeResponse(idempotent, coupon, quota, dailyLimit, dailyUsed, false);
            }
            throw new BusinessException(ErrorCode.CONFLICT, "幂等键对应的电影票核销码已过期或已使用");
        }
        BenefitCinemaCouponClaim existing = movieClaimForUpdate(couponId, quota.talentId());
        Long claimId;
        if (existing != null) {
            String status = upper(existing.getCodeStatus());
            if ("USED".equals(status)) {
                throw new BusinessException(ErrorCode.CONFLICT, "电影票已核销");
            }
            if (activeMovieCode(existing) && startsWithMoviePrefix(existing.getExchangeCode())) {
            if (activeMovieCode(existing)) {
                return movieCodeResponse(existing, coupon, quota, dailyLimit, dailyUsed, false);
            }
            boolean stockAlreadyReserved = "ACTIVE".equals(status) && !startsWithMoviePrefix(existing.getExchangeCode());
            if (!stockAlreadyReserved) {
                decrementMovieStock(couponId, merchantId);
            }
        }
        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(token);
            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 {
@@ -124,15 +225,71 @@
            claim.setCardNo(quota.cardNo());
            claim.setValidEnd(coupon.getValidEnd());
            claim.setStatus("RECEIVED");
            claim.setExchangeCode(token);
            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<String, Object> generateMovieTicketPackageCode(BenefitVerificationCodeRequest request,
                                                               String idempotencyKey,
                                                               Long merchantId) {
        String claimKey = claimIdempotencyKey(idempotencyKey, "MOVIE");
        Map<String, Object> claim = ticketPackageService.claimByMerchant(
                merchantId, request.talentId(), request.cardId(), claimKey);
        Long packageId = longValue(claim.get("packageId"));
        Map<String, Object> 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<String, Object> 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<String, Object> generateHotelFreeStayCode(BenefitVerificationCodeRequest request) {
@@ -140,7 +297,7 @@
        ensureHotelMerchant(merchantId);
        BenefitQuotaService.QuotaSnapshot quota = quotaService.requireAvailable(
                BenefitQuotaService.HOTEL, request.cardId(), request.talentId(), merchantId, null, 1);
        BenefitFreeStayVoucher voucher = voucherForUpdate(quota.primaryTalentId(), merchantId, quota.primaryCardId(), quota.total());
        BenefitFreeStayVoucher voucher = voucherForUpdate(quota, merchantId);
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店权益券当前不可用");
        }
@@ -155,7 +312,7 @@
        BenefitFreeStayVerificationCode code = new BenefitFreeStayVerificationCode();
        code.setCodeToken(hotelFreeStayToken(quota.cardId()));
        code.setVoucherId(voucher.getId());
        code.setExpiresAt(LocalDateTime.now().plusSeconds(CODE_SECONDS));
        code.setExpiresAt(LocalDateTime.now().plusSeconds(HOTEL_CODE_SECONDS));
        code.setStatus(0);
        freeStayCodeMapper.insert(code);
        return hotelCodeResponse(code, voucher, quota);
@@ -193,6 +350,27 @@
        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<TicketPackageCode>()
                .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<BenefitCinemaCoupon>()
                .setSql("stock = stock - 1")
@@ -212,36 +390,9 @@
                .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 "
                        + "and updated_at > date_sub(now(), interval 300 second)))"));
                        + "(upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE' and updated_at >= current_date))"));
        return intValue(count);
    }
    private void expireExpiredMovieCodes(Long merchantId) {
        List<BenefitCinemaCouponClaim> expired = claimMapper.selectList(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("merchant_id", merchantId)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .likeRight("exchange_code", MOVIE_TOKEN_PREFIX)
                .isNull("used_at")
                .apply("updated_at <= date_sub(now(), interval 300 second)")
                .last("for update"));
        for (BenefitCinemaCouponClaim claim : expired) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claim.getId())
                    .eq("deleted", 0)
                    .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
            if (updated == 1) {
                couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
                        .setSql("stock = stock + 1")
                        .eq("id", claim.getCouponId())
                        .eq("deleted", 0));
            }
        }
    }
    private BenefitCinemaCouponClaim movieClaimByIdempotencyKey(String key) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
@@ -250,9 +401,7 @@
    }
    private BenefitCinemaCouponClaim movieClaimForUpdate(Long couponId, Long talentId) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
                .eq("coupon_id", couponId)
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>().eq("coupon_id", couponId)
                .eq("talent_id", talentId)
                .last("limit 1 for update"));
    }
@@ -277,56 +426,63 @@
    private void ensureCinemaMerchant(Long merchantId) {
        BenefitMerchant merchant = merchantMapper.selectById(merchantId);
        if (merchant == null || intValue(merchant.getDeleted()) != 0 || intValue(merchant.getStatus()) != 1
                || !"cinema".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
                || !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
                || !"hotel".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
                || !isHotelMerchantType(merchant.getMerchantType())) {
            throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户");
        }
    }
    private BenefitFreeStayVoucher voucherForUpdate(Long primaryTalentId, Long merchantId, Long primaryCardId, int totalCount) {
    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<BenefitFreeStayVoucher>()
                .eq("talent_id", primaryTalentId)
                .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(primaryTalentId);
            voucher.setTalentId(quota.primaryTalentId());
            voucher.setMerchantId(merchantId);
            voucher.setCardId(primaryCardId);
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setCardId(quota.primaryCardId());
            voucher.setTotalCount(quota.total());
            voucher.setUsedCount(quota.used());
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.insert(voucher);
            return voucher;
        }
        if (intValue(voucher.getStatus()) != 1 || expiredDate(voucher.getValidEnd())) {
            voucher.setTotalCount(totalCount);
            voucher.setUsedCount(0);
            voucher.setValidEnd(validEnd);
            voucher.setStatus(1);
            voucherMapper.updateById(voucher);
        } else if (intValue(voucher.getTotalCount()) < totalCount) {
            voucher.setTotalCount(totalCount);
            voucherMapper.updateById(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<String, Object> movieCodeResponse(BenefitCinemaCouponClaim claim, BenefitCinemaCoupon coupon,
                                                  BenefitQuotaService.QuotaSnapshot quota,
                                                  int dailyLimit, int dailyUsed, boolean newlyReserved) {
        LocalDateTime updatedAt = claim.getUpdatedAt() == null ? LocalDateTime.now() : claim.getUpdatedAt();
        LocalDateTime expiresAt = updatedAt.plusSeconds(CODE_SECONDS);
        LocalDateTime expiresAt = coupon != null && coupon.getValidEnd() != null
                ? coupon.getValidEnd().plusDays(1).atStartOfDay()
                : LocalDateTime.now().plusYears(1);
        BenefitMerchant merchant = merchantMapper.selectById(claim.getMerchantId());
        Map<String, Object> result = baseResponse(BenefitQuotaService.MOVIE_TICKET, claim.getExchangeCode(), expiresAt, quota);
        result.put("displayCode", claim.getExchangeCode());
@@ -367,7 +523,8 @@
        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", CODE_SECONDS);
        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());
@@ -381,6 +538,7 @@
        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;
    }
@@ -391,15 +549,29 @@
    }
    private boolean activeMovieCode(BenefitCinemaCouponClaim claim) {
        return "ACTIVE".equals(upper(claim.getCodeStatus())) && !movieCodeExpired(claim.getUpdatedAt());
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(CODE_SECONDS).isAfter(LocalDateTime.now());
        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) {
@@ -452,6 +624,30 @@
        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) {
    }
}
}