From 8898f00c2150048a03ca561b7ed98dbf9c80732b Mon Sep 17 00:00:00 2001
From: wenminjie <1036699586@qq.com>
Date: 星期四, 06 八月 2026 16:18:51 +0800
Subject: [PATCH] flyway、对接小程序

---
 talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java |  206 +++++++++++++++++++++++++++++++++------------------
 1 files changed, 132 insertions(+), 74 deletions(-)

diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
index a229c44..ee19fa8 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
@@ -1,5 +1,11 @@
 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;
@@ -20,18 +26,21 @@
 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.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 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;
@@ -40,10 +49,12 @@
     private final BenefitCinemaCouponClaimMapper claimMapper;
     private final BenefitFreeStayVoucherMapper voucherMapper;
     private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper;
+    private final TicketPackageCodeMapper codeMapper;
 
     public BenefitVerificationCodeService(
             BenefitQuotaService quotaService,
             BenefitMerchantMapper merchantMapper,
+            TicketPackageCodeMapper codeMapper,
             BenefitCinemaCouponMapper couponMapper,
             BenefitCinemaCouponClaimMapper claimMapper,
             BenefitFreeStayVoucherMapper voucherMapper,
@@ -51,10 +62,59 @@
     ) {
         this.quotaService = quotaService;
         this.merchantMapper = merchantMapper;
+        this.codeMapper = codeMapper;
         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, "电影票兑换码不可用");
+        }
+        try {
+            var matrix = new QRCodeWriter().encode(claim.getExchangeCode(), 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, "电影票二维码生成失败");
+        }
+    }
+
+    @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);
+        } 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("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
@@ -68,7 +128,6 @@
 
     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);
@@ -78,39 +137,40 @@
         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 {
@@ -124,13 +184,14 @@
             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);
     }
@@ -140,7 +201,7 @@
         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, "酒店权益券当前不可用");
         }
@@ -155,7 +216,7 @@
         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);
@@ -193,6 +254,27 @@
         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")
@@ -213,35 +295,9 @@
                 .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)
@@ -250,9 +306,7 @@
     }
 
     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"));
     }
@@ -277,56 +331,63 @@
     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);
     }
 
     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());
@@ -367,7 +428,8 @@
         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());
@@ -391,11 +453,7 @@
     }
 
     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) {
@@ -454,4 +512,4 @@
 
     private record MovieCouponChoice(BenefitCinemaCoupon coupon, int dailyLimit, int dailyUsed) {
     }
-}
\ No newline at end of file
+}

--
Gitblit v1.7.1