package com.talentcard.server.service;
|
|
import java.time.LocalDate;
|
import java.time.LocalDateTime;
|
import java.time.temporal.TemporalAdjusters;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
|
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.common.BusinessException;
|
import com.talentcard.server.common.ErrorCode;
|
import com.talentcard.server.entity.BenefitMovieTicketRule;
|
import com.talentcard.server.entity.TicketPackage;
|
import com.talentcard.server.entity.TicketPackageClaim;
|
import com.talentcard.server.entity.TicketPackageCode;
|
import com.talentcard.server.mapper.BenefitMovieTicketRuleMapper;
|
import com.talentcard.server.mapper.TicketPackageClaimMapper;
|
import com.talentcard.server.mapper.TicketPackageCodeMapper;
|
import com.talentcard.server.mapper.TicketPackageMapper;
|
import com.talentcard.server.request.TicketPackageClaimRequest;
|
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
@Service
|
public class TicketPackagePortalService {
|
private final TicketPackageMapper packageMapper;
|
private final TicketPackageCodeMapper codeMapper;
|
private final TicketPackageClaimMapper claimMapper;
|
private final BenefitMovieTicketRuleMapper ruleMapper;
|
|
public TicketPackagePortalService(
|
TicketPackageMapper packageMapper,
|
TicketPackageCodeMapper codeMapper,
|
TicketPackageClaimMapper claimMapper,
|
BenefitMovieTicketRuleMapper ruleMapper
|
) {
|
this.packageMapper = packageMapper;
|
this.codeMapper = codeMapper;
|
this.claimMapper = claimMapper;
|
this.ruleMapper = ruleMapper;
|
}
|
|
@Transactional(readOnly = true)
|
public List<Map<String, Object>> packages(Long talentId, Long cardId, Long merchantId) {
|
CardPair cards = cards(talentId, cardId, false);
|
Quota quota = quota(cards.primaryCardLevel(), cards.primaryCardId());
|
int totalUsed = intValue(claimMapper.countTotalClaimedInWindow(cards.primaryCardId(), quota.windowStart()));
|
return packageMapper.selectPortalPackages(merchantId).stream()
|
.map(row -> portalPackageView(row, cards, quota, totalUsed))
|
.toList();
|
}
|
|
@Transactional
|
public Map<String, Object> claim(Long packageId, TicketPackageClaimRequest request, String headerIdempotencyKey) {
|
Long id = requireId(packageId, "票包ID不能为空");
|
String idempotencyKey = clean(first(headerIdempotencyKey, request.idempotencyKey()));
|
if (idempotencyKey.isBlank()) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "领取凭证不能为空");
|
}
|
TicketPackageClaim existing = claimMapper.selectByIdempotencyKey(idempotencyKey);
|
if (existing != null) {
|
if (!id.equals(existing.getPackageId())) {
|
throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他票包使用");
|
}
|
if (!request.talentId().equals(existing.getTalentId())
|
|| (request.cardId() != null && !request.cardId().equals(existing.getCardId()))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他用户使用");
|
}
|
return claimView(existing, false);
|
}
|
|
CardPair cards = cards(request.talentId(), request.cardId(), true);
|
TicketPackage ticketPackage = packageMapper.selectByIdForUpdate(id);
|
validatePackage(ticketPackage, cards.primaryCardLevel());
|
Quota quota = quota(cards.primaryCardLevel(), cards.primaryCardId());
|
int totalUsed = intValue(claimMapper.countTotalClaimedInWindow(cards.primaryCardId(), quota.windowStart()));
|
if (totalUsed >= quota.total()) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包可兑换额度不足");
|
}
|
int packageLimit = ticketPackage.getPerUserLimit() == null || ticketPackage.getPerUserLimit() < 1 ? 1 : ticketPackage.getPerUserLimit();
|
int packageUsed = intValue(claimMapper.countClaimedInWindow(ticketPackage.getId(), cards.primaryCardId(), quota.windowStart()));
|
if (packageUsed >= packageLimit) {
|
throw new BusinessException(ErrorCode.CONFLICT, "该票包领取次数已达上限");
|
}
|
|
TicketPackageCode code = codeMapper.selectAvailableCodeForUpdate(ticketPackage.getId());
|
if (code == null) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包兑换码库存不足");
|
}
|
LocalDateTime now = LocalDateTime.now();
|
int locked = codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
|
.set("status", "LOCKED")
|
.set("locked_by_talent_id", cards.sourceTalentId())
|
.set("locked_by_card_id", cards.sourceCardId())
|
.set("locked_at", now)
|
.eq("id", code.getId())
|
.eq("status", "AVAILABLE")
|
.eq("deleted", 0));
|
if (locked != 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包兑换码已被领取,请重试");
|
}
|
|
TicketPackageClaim claim = new TicketPackageClaim();
|
claim.setClaimNo(BusinessIdGenerator.next("TPC"));
|
claim.setPackageId(ticketPackage.getId());
|
claim.setCodeId(code.getId());
|
claim.setMerchantId(ticketPackage.getMerchantId());
|
claim.setTalentId(cards.sourceTalentId());
|
claim.setCardId(cards.sourceCardId());
|
claim.setPrimaryCardId(cards.primaryCardId());
|
claim.setCardNo(cards.sourceCardNo());
|
claim.setCardLevel(cards.primaryCardLevel());
|
claim.setTalentName(cards.sourceTalentName());
|
claim.setTalentMobile(cards.sourceTalentMobile());
|
claim.setExchangeCode(code.getExchangeCode());
|
claim.setStatus("ISSUED");
|
claim.setValidEnd(ticketPackage.getValidEnd());
|
claim.setIdempotencyKey(idempotencyKey);
|
claim.setDeleted(0);
|
try {
|
claimMapper.insert(claim);
|
} catch (DuplicateKeyException exception) {
|
releaseLockedCode(code.getId());
|
TicketPackageClaim concurrent = claimMapper.selectByIdempotencyKey(idempotencyKey);
|
if (concurrent != null) {
|
if (!id.equals(concurrent.getPackageId())) {
|
throw new BusinessException(ErrorCode.CONFLICT, "领取凭证已被其他票包使用");
|
}
|
return claimView(concurrent, false);
|
}
|
throw new BusinessException(ErrorCode.CONFLICT, "领取请求已提交,请稍后查询领取结果");
|
} catch (RuntimeException exception) {
|
releaseLockedCode(code.getId());
|
throw exception;
|
}
|
int issued = codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
|
.set("status", "ISSUED")
|
.set("issued_claim_id", claim.getId())
|
.set("issued_at", now)
|
.eq("id", code.getId())
|
.eq("status", "LOCKED")
|
.eq("deleted", 0));
|
if (issued != 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "兑换码发放失败,请重试");
|
}
|
return claimView(claim, true);
|
}
|
|
private void releaseLockedCode(Long codeId) {
|
codeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
|
.set("status", "AVAILABLE")
|
.set("locked_by_talent_id", null)
|
.set("locked_by_card_id", null)
|
.set("locked_at", null)
|
.eq("id", codeId)
|
.eq("status", "LOCKED"));
|
}
|
|
private Map<String, Object> portalPackageView(Map<String, Object> row, CardPair cards, Quota quota, int totalUsed) {
|
boolean supports = supportsCard(text(row, "talentLevels", "talent_levels"), cards.primaryCardLevel());
|
int availableCodes = intValue(value(row, "availableCodes", "available_codes"));
|
int packageUsed = intValue(claimMapper.countClaimedInWindow(longValue(row, "id"), cards.primaryCardId(), quota.windowStart()));
|
int packageLimit = intValue(value(row, "perUserLimit", "per_user_limit"));
|
if (packageLimit < 1) {
|
packageLimit = 1;
|
}
|
int remainingQuota = Math.max(quota.total() - totalUsed, 0);
|
boolean available = supports && availableCodes > 0 && remainingQuota > 0 && packageUsed < packageLimit;
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("packageId", value(row, "id"));
|
result.put("packageNo", text(row, "packageNo", "package_no"));
|
result.put("packageName", text(row, "packageName", "package_name"));
|
result.put("merchantId", value(row, "merchantId", "merchant_id"));
|
result.put("merchantName", text(row, "merchantName", "merchant_name"));
|
result.put("coverUrl", text(row, "coverUrl", "cover_url"));
|
result.put("claimPrice", value(row, "claimPrice", "claim_price"));
|
result.put("validStart", value(row, "validStart", "valid_start"));
|
result.put("validEnd", value(row, "validEnd", "valid_end"));
|
result.put("appName", text(row, "appName", "app_name"));
|
result.put("appJumpUrl", text(row, "appJumpUrl", "app_jump_url"));
|
result.put("usageText", text(row, "usageText", "usage_text"));
|
result.put("availableCodes", availableCodes);
|
result.put("quotaTotal", quota.total());
|
result.put("quotaUsed", totalUsed);
|
result.put("quotaRemaining", remainingQuota);
|
result.put("packageLimit", packageLimit);
|
result.put("packageUsed", packageUsed);
|
result.put("available", available);
|
result.put("unavailableReason", unavailableReason(supports, availableCodes, remainingQuota, packageUsed, packageLimit));
|
return result;
|
}
|
|
private Map<String, Object> claimView(TicketPackageClaim claim, boolean newlyIssued) {
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("claimId", claim.getId());
|
result.put("claimNo", claim.getClaimNo());
|
result.put("packageId", claim.getPackageId());
|
result.put("merchantId", claim.getMerchantId());
|
result.put("talentId", claim.getTalentId());
|
result.put("cardId", claim.getCardId());
|
result.put("primaryCardId", claim.getPrimaryCardId());
|
result.put("cardNo", claim.getCardNo());
|
result.put("cardLevel", claim.getCardLevel());
|
result.put("exchangeCode", claim.getExchangeCode());
|
result.put("status", claim.getStatus());
|
result.put("statusName", "USED".equals(claim.getStatus()) ? "已使用" : "已发放");
|
result.put("validEnd", claim.getValidEnd());
|
result.put("newlyIssued", newlyIssued);
|
return result;
|
}
|
|
private CardPair cards(Long talentId, Long cardId, boolean lock) {
|
if ((talentId == null || talentId <= 0) && (cardId == null || cardId <= 0)) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "持卡人ID或人才卡ID不能为空");
|
}
|
Map<String, Object> source = lock ? claimMapper.selectActiveCardSnapshotForUpdate(talentId, cardId)
|
: activeCardSnapshot(talentId, cardId);
|
if (source == null || source.isEmpty()) {
|
throw new BusinessException(ErrorCode.NOT_FOUND, "有效人才卡不存在");
|
}
|
String cardType = text(source, "cardType", "card_type").toUpperCase(Locale.ROOT);
|
if ("PERSONAL".equals(cardType) || cardType.isBlank()) {
|
return new CardPair(
|
longValue(source, "talentId", "talent_id"),
|
longValue(source, "cardId", "card_id"),
|
text(source, "cardNo", "card_no"),
|
text(source, "cardLevel", "card_level"),
|
text(source, "talentName", "talent_name"),
|
text(source, "talentMobile", "talent_mobile"),
|
longValue(source, "cardId", "card_id"),
|
text(source, "cardLevel", "card_level")
|
);
|
}
|
Map<String, Object> primary = lock ? claimMapper.selectPrimaryCardByFamilyCardForUpdate(longValue(source, "cardId", "card_id"))
|
: primaryCardSnapshot(longValue(source, "cardId", "card_id"));
|
if (primary == null || primary.isEmpty()) {
|
throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到有效主卡");
|
}
|
return new CardPair(
|
longValue(source, "talentId", "talent_id"),
|
longValue(source, "cardId", "card_id"),
|
text(source, "cardNo", "card_no"),
|
text(primary, "cardLevel", "card_level"),
|
text(source, "talentName", "talent_name"),
|
text(source, "talentMobile", "talent_mobile"),
|
longValue(primary, "cardId", "card_id"),
|
text(primary, "cardLevel", "card_level")
|
);
|
}
|
|
private Map<String, Object> activeCardSnapshot(Long talentId, Long cardId) {
|
return claimMapper.selectActiveCardSnapshot(talentId, cardId);
|
}
|
|
private Map<String, Object> primaryCardSnapshot(Long familyCardId) {
|
return claimMapper.selectPrimaryCardByFamilyCard(familyCardId);
|
}
|
|
private void validatePackage(TicketPackage ticketPackage, String cardLevel) {
|
if (ticketPackage == null || intValue(ticketPackage.getDeleted()) == 1) {
|
throw new BusinessException(ErrorCode.NOT_FOUND, "票包不存在");
|
}
|
if (intValue(ticketPackage.getStatus()) != 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包当前不可领取");
|
}
|
LocalDate today = LocalDate.now();
|
if (ticketPackage.getValidStart() != null && ticketPackage.getValidStart().isAfter(today)) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包尚未开始领取");
|
}
|
if (ticketPackage.getValidEnd() != null && ticketPackage.getValidEnd().isBefore(today)) {
|
throw new BusinessException(ErrorCode.CONFLICT, "票包已过期");
|
}
|
if (!supportsCard(ticketPackage.getTalentLevels(), cardLevel)) {
|
throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡不适用该票包");
|
}
|
}
|
|
private Quota quota(String cardLevel, Long primaryCardId) {
|
BenefitMovieTicketRule rule = rule(cardLevel);
|
int total = intValue(rule.getClaimableCount());
|
if (total < 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未配置票包可兑换额度");
|
}
|
String resetCycle = clean(rule.getResetCycle());
|
return new Quota(total, resetCycle, windowStart(resetCycle));
|
}
|
|
private BenefitMovieTicketRule rule(String cardLevel) {
|
List<String> aliases = cardLevelAliases(cardLevel);
|
BenefitMovieTicketRule rule = ruleMapper.selectOne(new QueryWrapper<BenefitMovieTicketRule>()
|
.in("talent_level", aliases)
|
.last("limit 1"));
|
if (rule == null || intValue(rule.getEnabled()) != 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "当前卡类型未启用票包权益");
|
}
|
return rule;
|
}
|
|
private List<String> cardLevelAliases(String cardLevel) {
|
String raw = clean(cardLevel);
|
String upper = raw.toUpperCase(Locale.ROOT);
|
if (upper.contains("EXCELLENT") || raw.contains("卓越")) {
|
return List.of(raw, "卓越人才卡", "卓越", "EXCELLENT");
|
}
|
if (upper.contains("OUTSTANDING") || raw.contains("杰出")) {
|
return List.of(raw, "杰出人才卡", "杰出", "OUTSTANDING");
|
}
|
if (upper.contains("ELITE") || raw.contains("菁英")) {
|
return List.of(raw, "菁英人才卡", "菁英", "ELITE");
|
}
|
return List.of(raw);
|
}
|
|
private LocalDateTime windowStart(String resetCycle) {
|
String cycle = clean(resetCycle).toUpperCase(Locale.ROOT);
|
LocalDate today = LocalDate.now();
|
if (cycle.isBlank() || "NONE".equals(cycle)) {
|
return LocalDate.of(1970, 1, 1).atStartOfDay();
|
}
|
if (cycle.contains("DAY") || cycle.contains("天") || cycle.contains("日")) {
|
return today.atStartOfDay();
|
}
|
if (cycle.contains("WEEK") || cycle.contains("周") || cycle.contains("星期")) {
|
return today.minusDays(today.getDayOfWeek().getValue() - 1L).atStartOfDay();
|
}
|
if (cycle.contains("MONTH") || cycle.contains("月")) {
|
return today.withDayOfMonth(1).atStartOfDay();
|
}
|
if (cycle.contains("YEAR") || cycle.contains("ANNUAL") || cycle.contains("年")) {
|
return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
|
}
|
return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
|
}
|
|
private boolean supportsCard(String configured, String cardLevel) {
|
String value = clean(configured);
|
String level = clean(cardLevel);
|
if (value.isBlank()) {
|
return true;
|
}
|
if (level.isBlank()) {
|
return false;
|
}
|
return value.contains(level) || cardLevelAliases(level).stream()
|
.filter(alias -> !alias.isBlank())
|
.anyMatch(value::contains);
|
}
|
|
private String unavailableReason(boolean supports, int availableCodes, int remainingQuota, int packageUsed, int packageLimit) {
|
if (!supports) {
|
return "当前人才卡不适用该票包";
|
}
|
if (availableCodes < 1) {
|
return "票包兑换码库存不足";
|
}
|
if (remainingQuota < 1) {
|
return "票包可兑换额度不足";
|
}
|
if (packageUsed >= packageLimit) {
|
return "该票包领取次数已达上限";
|
}
|
return "";
|
}
|
|
private Long requireId(Long id, String message) {
|
if (id == null || id <= 0) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, message);
|
}
|
return id;
|
}
|
|
private String first(String... values) {
|
for (String value : values) {
|
String text = clean(value);
|
if (!text.isBlank()) {
|
return text;
|
}
|
}
|
return "";
|
}
|
|
private Object value(Map<String, Object> row, String... keys) {
|
for (String key : keys) {
|
if (row.containsKey(key)) {
|
return row.get(key);
|
}
|
}
|
return null;
|
}
|
|
private String text(Map<String, Object> row, String... keys) {
|
Object value = value(row, keys);
|
return value == null ? "" : String.valueOf(value).trim();
|
}
|
|
private Long longValue(Map<String, Object> row, String... keys) {
|
Object value = value(row, keys);
|
return value instanceof Number number ? number.longValue() : null;
|
}
|
|
private int intValue(Object value) {
|
return value instanceof Number number ? number.intValue() : 0;
|
}
|
|
private String clean(String value) {
|
return value == null ? "" : value.trim();
|
}
|
|
private record CardPair(
|
Long sourceTalentId,
|
Long sourceCardId,
|
String sourceCardNo,
|
String primaryCardLevel,
|
String sourceTalentName,
|
String sourceTalentMobile,
|
Long primaryCardId,
|
String primaryCardLevelSnapshot
|
) {
|
}
|
|
private record Quota(int total, String resetCycle, LocalDateTime windowStart) {
|
}
|
}
|