| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | |
| | | 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, |
| | |
| | | BenefitFreeStayVerificationCodeMapper freeStayCodeMapper, |
| | | BenefitFreeStayVoucherMapper voucherMapper, |
| | | BenefitMerchantMapper merchantMapper, |
| | | BenefitMerchantProductMapper productMapper |
| | | BenefitMerchantProductMapper productMapper, |
| | | TicketPackageClaimMapper ticketPackageClaimMapper, |
| | | TicketPackageCodeMapper ticketPackageCodeMapper, |
| | | TicketPackagePortalService ticketPackagePortalService, |
| | | OrderFundBoundaryService orderFundBoundary |
| | | ) { |
| | | this.quotaService = quotaService; |
| | | this.verifyRecordMapper = verifyRecordMapper; |
| | |
| | | this.voucherMapper = voucherMapper; |
| | | this.merchantMapper = merchantMapper; |
| | | this.productMapper = productMapper; |
| | | this.ticketPackageClaimMapper = ticketPackageClaimMapper; |
| | | this.ticketPackageCodeMapper = ticketPackageCodeMapper; |
| | | this.ticketPackagePortalService = ticketPackagePortalService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | 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); |
| | |
| | | .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, "电影票核销码已使用或已过期"); |
| | | } |
| | |
| | | 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) { |
| | |
| | | } |
| | | 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()) |
| | |
| | | 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) { |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | |
| | | 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(), |
| | |
| | | quota.used() + quantity, |
| | | Math.max(quota.remaining() - quantity, 0), |
| | | quota.resetCycle(), |
| | | quota.detail(), |
| | | quota.windowStart(), |
| | | quota.familyCard(), |
| | | quota.sharedCardIds() |
| | |
| | | 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) { |
| | |
| | | |
| | | private record FreeStayCodeContext(BenefitFreeStayVerificationCode code, BenefitFreeStayVoucher voucher) { |
| | | } |
| | | } |
| | | } |