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())) && !"USED".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) {
}
}