package com.talentcard.server.service;
|
|
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.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 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 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;
|
|
public BenefitVerificationCodeService(
|
BenefitQuotaService quotaService,
|
BenefitMerchantMapper merchantMapper,
|
BenefitCinemaCouponMapper couponMapper,
|
BenefitCinemaCouponClaimMapper claimMapper,
|
BenefitFreeStayVoucherMapper voucherMapper,
|
BenefitFreeStayVerificationCodeMapper freeStayCodeMapper
|
) {
|
this.quotaService = quotaService;
|
this.merchantMapper = merchantMapper;
|
this.couponMapper = couponMapper;
|
this.claimMapper = claimMapper;
|
this.voucherMapper = voucherMapper;
|
this.freeStayCodeMapper = freeStayCodeMapper;
|
}
|
|
@Transactional
|
public Map<String, Object> 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<String, Object> generateMovieCode(BenefitVerificationCodeRequest request, String idempotencyKey) {
|
Long merchantId = requirePositive(request.merchantId(), "商户ID不能为空");
|
expireExpiredMovieCodes(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 token = MOVIE_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
|
String claimKey = claimIdempotencyKey(idempotencyKey, token);
|
BenefitCinemaCouponClaim idempotent = movieClaimByIdempotencyKey(claimKey);
|
if (idempotent != null) {
|
ensureSameMovieClaim(idempotent, couponId, merchantId, quota.cardId());
|
if (activeMovieCode(idempotent) && startsWithMoviePrefix(idempotent.getExchangeCode())) {
|
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())) {
|
return movieCodeResponse(existing, coupon, quota, dailyLimit, dailyUsed, false);
|
}
|
boolean stockAlreadyReserved = "ACTIVE".equals(status) && !startsWithMoviePrefix(existing.getExchangeCode());
|
if (!stockAlreadyReserved) {
|
decrementMovieStock(couponId, merchantId);
|
}
|
BenefitCinemaCouponClaim update = new BenefitCinemaCouponClaim();
|
update.setId(existing.getId());
|
update.setExchangeCode(token);
|
update.setCodeStatus("ACTIVE");
|
update.setStatus("RECEIVED");
|
update.setUsedAt(null);
|
update.setValidEnd(coupon.getValidEnd());
|
update.setIdempotencyKey(claimKey);
|
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(token);
|
claim.setCodeStatus("ACTIVE");
|
claim.setIdempotencyKey(claimKey);
|
claim.setDeleted(0);
|
claimMapper.insert(claim);
|
claimId = claim.getId();
|
}
|
BenefitCinemaCouponClaim claim = movieClaimById(claimId);
|
return movieCodeResponse(claim, couponMapper.selectById(couponId), quota, dailyLimit, dailyUsed + 1, true);
|
}
|
|
private Map<String, Object> 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.primaryTalentId(), merchantId, quota.primaryCardId(), quota.total());
|
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<BenefitFreeStayVerificationCode>()
|
.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(CODE_SECONDS));
|
code.setStatus(0);
|
freeStayCodeMapper.insert(code);
|
return hotelCodeResponse(code, voucher, quota);
|
}
|
|
private MovieCouponChoice movieCouponForUpdate(Long merchantId, String cardLevel, List<Long> sharedCardIds) {
|
ensureCinemaMerchant(merchantId);
|
List<BenefitCinemaCoupon> coupons = couponMapper.selectList(new QueryWrapper<BenefitCinemaCoupon>()
|
.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<String> 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 void decrementMovieStock(Long couponId, Long merchantId) {
|
int updated = couponMapper.update(null, new UpdateWrapper<BenefitCinemaCoupon>()
|
.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<Long> sharedCardIds, Long couponId) {
|
Long count = claimMapper.selectCount(new QueryWrapper<BenefitCinemaCouponClaim>()
|
.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 "
|
+ "and updated_at > date_sub(now(), interval 300 second)))"));
|
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)
|
.eq("idempotency_key", key)
|
.last("limit 1 for update"));
|
}
|
|
private BenefitCinemaCouponClaim movieClaimForUpdate(Long couponId, Long talentId) {
|
return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
|
.eq("deleted", 0)
|
.eq("coupon_id", couponId)
|
.eq("talent_id", talentId)
|
.last("limit 1 for update"));
|
}
|
|
private BenefitCinemaCouponClaim movieClaimById(Long claimId) {
|
BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
|
.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
|
|| !"cinema".equalsIgnoreCase(String.valueOf(merchant.getMerchantType()))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "未找到有效影院商户");
|
}
|
}
|
|
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()))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "未找到有效酒店商户");
|
}
|
}
|
|
private BenefitFreeStayVoucher voucherForUpdate(Long primaryTalentId, Long merchantId, Long primaryCardId, int totalCount) {
|
BenefitFreeStayVoucher voucher = voucherMapper.selectOne(new QueryWrapper<BenefitFreeStayVoucher>()
|
.eq("talent_id", 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.setMerchantId(merchantId);
|
voucher.setCardId(primaryCardId);
|
voucher.setTotalCount(totalCount);
|
voucher.setUsedCount(0);
|
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);
|
}
|
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);
|
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("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<String, Object> hotelCodeResponse(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher,
|
BenefitQuotaService.QuotaSnapshot quota) {
|
Map<String, Object> 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<String, Object> baseResponse(String benefitType, Object codeToken, LocalDateTime expiresAt,
|
BenefitQuotaService.QuotaSnapshot quota) {
|
Map<String, Object> 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", CODE_SECONDS);
|
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("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())) && !movieCodeExpired(claim.getUpdatedAt());
|
}
|
|
private boolean movieCodeExpired(LocalDateTime updatedAt) {
|
return updatedAt != null && !updatedAt.plusSeconds(CODE_SECONDS).isAfter(LocalDateTime.now());
|
}
|
|
private boolean startsWithMoviePrefix(String code) {
|
return code != null && code.startsWith(MOVIE_TOKEN_PREFIX);
|
}
|
|
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<String> 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 record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) {
|
}
|
}
|