talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationService.java
@@ -9,6 +9,7 @@
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.business.order.OrderFundBoundaryService;
import com.talentcard.server.request.BenefitVerificationRequest;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.common.BusinessException;
@@ -22,6 +23,8 @@
import com.talentcard.server.entity.BenefitOrder;
import com.talentcard.server.entity.BenefitOrderDynamicVerificationCode;
import com.talentcard.server.entity.BenefitVerifyRecord;
import com.talentcard.server.entity.TicketPackageClaim;
import com.talentcard.server.entity.TicketPackageCode;
import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper;
import com.talentcard.server.mapper.BenefitCinemaCouponMapper;
import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper;
@@ -31,6 +34,8 @@
import com.talentcard.server.mapper.BenefitOrderDynamicVerificationCodeMapper;
import com.talentcard.server.mapper.BenefitOrderMapper;
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
import com.talentcard.server.mapper.TicketPackageClaimMapper;
import com.talentcard.server.mapper.TicketPackageCodeMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +51,10 @@
    private final BenefitFreeStayVoucherMapper voucherMapper;
    private final BenefitMerchantMapper merchantMapper;
    private final BenefitMerchantProductMapper productMapper;
    private final TicketPackageClaimMapper ticketPackageClaimMapper;
    private final TicketPackageCodeMapper ticketPackageCodeMapper;
    private final TicketPackagePortalService ticketPackagePortalService;
    private final OrderFundBoundaryService orderFundBoundary;
    public BenefitVerificationService(
            BenefitQuotaService quotaService,
@@ -57,7 +66,11 @@
            BenefitFreeStayVerificationCodeMapper freeStayCodeMapper,
            BenefitFreeStayVoucherMapper voucherMapper,
            BenefitMerchantMapper merchantMapper,
            BenefitMerchantProductMapper productMapper
            BenefitMerchantProductMapper productMapper,
            TicketPackageClaimMapper ticketPackageClaimMapper,
            TicketPackageCodeMapper ticketPackageCodeMapper,
            TicketPackagePortalService ticketPackagePortalService,
            OrderFundBoundaryService orderFundBoundary
    ) {
        this.quotaService = quotaService;
        this.verifyRecordMapper = verifyRecordMapper;
@@ -69,6 +82,10 @@
        this.voucherMapper = voucherMapper;
        this.merchantMapper = merchantMapper;
        this.productMapper = productMapper;
        this.ticketPackageClaimMapper = ticketPackageClaimMapper;
        this.ticketPackageCodeMapper = ticketPackageCodeMapper;
        this.ticketPackagePortalService = ticketPackagePortalService;
        this.orderFundBoundary = orderFundBoundary;
    }
    @Transactional
@@ -92,17 +109,13 @@
    private Map<String, Object> verifyMovieTicket(MerchantPrincipal principal, String verifyCode, String remark, String idempotencyKey) {
        BenefitCinemaCouponClaim probe = movieClaimByCode(verifyCode, false);
        if (probe == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        if (!principal.merchantId().equals(probe.getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票不属于当前商户");
            return verifyTicketPackageClaim(principal, verifyCode, remark, idempotencyKey);
        }
        if (!verifyCode.startsWith(BenefitVerificationCodeService.MOVIE_TOKEN_PREFIX)) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码必须由二期生成核销码接口生成");
        }
        if (movieCodeExpired(probe.getUpdatedAt())) {
            expireMovieClaim(probe.getId());
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        if (!principal.merchantId().equals(probe.getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票不属于当前商户");
        }
        quotaService.current(BenefitQuotaService.MOVIE_TICKET, probe.getCardId(), probe.getTalentId(), probe.getMerchantId(), null);
@@ -117,8 +130,7 @@
                .eq("id", claim.getId())
                .eq("deleted", 0)
                .eq("exchange_code", verifyCode)
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'")
                .apply("updated_at > date_sub(now(), interval 300 second)"));
                .apply("upper(coalesce(code_status, 'ACTIVE')) = 'ACTIVE'"));
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已使用或已过期");
        }
@@ -128,6 +140,41 @@
        BenefitQuotaService.QuotaSnapshot quotaAfter = quotaService.current(BenefitQuotaService.MOVIE_TICKET,
                claim.getCardId(), claim.getTalentId(), claim.getMerchantId(), null);
        return result(record, "CINEMA_COUPON_CLAIM", claim.getId(), quotaAfter, 1);
    }
    private Map<String, Object> verifyTicketPackageClaim(MerchantPrincipal principal, String verifyCode, String remark, String idempotencyKey) {
        TicketPackageClaim probe = ticketPackageClaimMapper.selectByExchangeCodeForUpdate(verifyCode);
        if (probe == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        if (!principal.merchantId().equals(probe.getMerchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票不属于当前商户");
        }
        validateTicketPackageClaim(probe);
        int claimUpdated = ticketPackageClaimMapper.update(null, new UpdateWrapper<TicketPackageClaim>()
                .set("status", "USED")
                .setSql("used_at = now()")
                .setSql("updated_at = now()")
                .eq("id", probe.getId())
                .eq("deleted", 0)
                .eq("exchange_code", verifyCode)
                .eq("status", "ISSUED"));
        int codeUpdated = ticketPackageCodeMapper.update(null, new UpdateWrapper<TicketPackageCode>()
                .set("status", "USED")
                .setSql("used_at = now()")
                .setSql("updated_at = now()")
                .eq("id", probe.getCodeId())
                .eq("deleted", 0)
                .eq("status", "ISSUED"));
        if (claimUpdated != 1 || codeUpdated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已使用或已过期");
        }
        BenefitVerifyRecord record = insertRecord(principal, null, probe.getTalentId(), probe.getCardId(), null,
                BenefitQuotaService.MOVIE_TICKET, 0L, 0L,
                sourceRemark("ticketPackageClaimId=" + probe.getId(), remark), idempotencyKey);
        Map<String, Object> quotaAfter = ticketPackagePortalService.packageQuota(
                probe.getTalentId(), probe.getCardId(), probe.getMerchantId(), probe.getPackageId());
        return ticketPackageResult(record, probe.getId(), quotaAfter);
    }
    private Map<String, Object> verifyHotel(MerchantPrincipal principal, String verifyCode, String remark, String idempotencyKey) {
@@ -155,10 +202,11 @@
        }
        BenefitOrderDynamicVerificationCode code = context.code();
        BenefitOrder order = context.order();
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())
                || intValue(order.getOrderStatus()) != 1 || intValue(order.getVerifyStatus()) != 0) {
        if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())) {
            throw new BusinessException(ErrorCode.CONFLICT, "酒店订单当前不可核销");
        }
        // 与商户端核销、退款共用同一把订单行锁:未支付、已退款或已进入提现记录的订单都不能核销。
        orderFundBoundary.lock(order.getId()).requireVerifiable();
        int codeUpdated = orderCodeMapper.update(null, new UpdateWrapper<BenefitOrderDynamicVerificationCode>()
                .set("status", 1)
                .eq("id", code.getId())
@@ -231,7 +279,7 @@
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        BenefitCinemaCoupon coupon = couponMapper.selectById(claim.getCouponId());
        if (!"ACTIVE".equals(upper(claim.getCodeStatus())) || movieCodeExpired(claim.getUpdatedAt())) {
        if (!"ACTIVE".equals(upper(claim.getCodeStatus()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
        if (coupon == null || expiredDate(claim.getValidEnd()) || intValue(coupon.getStatus()) != 1) {
@@ -239,32 +287,17 @@
        }
    }
    private void expireMovieClaim(Long claimId) {
        BenefitCinemaCouponClaim claim = claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("id", claimId)
                .eq("deleted", 0)
                .last("limit 1 for update"));
    private void validateTicketPackageClaim(TicketPackageClaim claim) {
        if (claim == null) {
            return;
            throw new BusinessException(ErrorCode.NOT_FOUND, "电影票核销码不存在");
        }
        if ("ACTIVE".equals(upper(claim.getCodeStatus()))
                && startsWithMoviePrefix(claim.getExchangeCode())
                && movieCodeExpired(claim.getUpdatedAt())) {
            int updated = claimMapper.update(null, new UpdateWrapper<BenefitCinemaCouponClaim>()
                    .set("code_status", "EXPIRED")
                    .set("status", "EXPIRED")
                    .eq("id", claimId)
                    .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));
            }
        if (!"ISSUED".equals(upper(claim.getStatus()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已使用或已失效");
        }
        if (expiredDate(claim.getValidEnd())) {
            throw new BusinessException(ErrorCode.CONFLICT, "电影票核销码已过期");
        }
    }
    private BenefitCinemaCouponClaim movieClaimByCode(String code, boolean lock) {
        return claimMapper.selectOne(new QueryWrapper<BenefitCinemaCouponClaim>()
                .eq("deleted", 0)
@@ -391,6 +424,34 @@
        return result;
    }
    private Map<String, Object> ticketPackageResult(BenefitVerifyRecord record, Object sourceId, Map<String, Object> quota) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verified", true);
        result.put("verifyRecordId", record.getId());
        result.put("verifyNo", record.getVerifyNo());
        result.put("benefitType", record.getVerifyType());
        result.put("sourceType", "TICKET_PACKAGE_CLAIM");
        result.put("sourceId", sourceId);
        result.put("quantity", 1);
        result.put("verifiedAt", record.getVerifyTime());
        result.put("sharedQuotaMode", "AUTO_DEDUCT");
        result.put("sourceCardId", quota.get("sourceCardId"));
        result.put("sourceTalentId", quota.get("sourceTalentId"));
        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("quotaTotal", quota.get("quotaTotal"));
        result.put("quotaUsed", quota.get("quotaUsed"));
        result.put("quotaRemaining", quota.get("quotaRemaining"));
        result.put("quotaResetCycle", quota.get("quotaResetCycle"));
        result.put("quotaWindowStart", quota.get("quotaWindowStart"));
        result.put("packageId", quota.get("packageId"));
        result.put("packageName", quota.get("packageName"));
        return result;
    }
    private BenefitQuotaService.QuotaSnapshot afterDeduct(BenefitQuotaService.QuotaSnapshot quota, int quantity) {
        return new BenefitQuotaService.QuotaSnapshot(
                quota.benefitType(),
@@ -406,6 +467,7 @@
                quota.used() + quantity,
                Math.max(quota.remaining() - quantity, 0),
                quota.resetCycle(),
                quota.detail(),
                quota.windowStart(),
                quota.familyCard(),
                quota.sharedCardIds()
@@ -439,10 +501,6 @@
    private Long parsePositiveLong(String value) {
        long parsed = Long.parseLong(value);
        return parsed > 0 ? parsed : null;
    }
    private boolean movieCodeExpired(LocalDateTime updatedAt) {
        return updatedAt != null && !updatedAt.plusSeconds(BenefitVerificationCodeService.CODE_SECONDS).isAfter(LocalDateTime.now());
    }
    private boolean startsWithMoviePrefix(String code) {
@@ -505,4 +563,4 @@
    private record FreeStayCodeContext(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher) {
    }
}
}