| | |
| | | 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 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; |
| | |
| | | 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, |
| | |
| | | ) { |
| | | 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 |
| | |
| | | |
| | | 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); |
| | |
| | | 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 { |
| | |
| | | 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) { |
| | |
| | | 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, "酒店权益券当前不可用"); |
| | | } |
| | |
| | | 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); |
| | |
| | | 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") |
| | |
| | | .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) |
| | |
| | | } |
| | | |
| | | 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")); |
| | | } |
| | |
| | | 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()); |
| | |
| | | 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()); |
| | |
| | | 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 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) { |
| | |
| | | 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) { |
| | | } |
| | | } |
| | | } |