Prechádzať zdrojové kódy

fix plus代充年付、独立年付领取API50额度

zoujiajian 1 mesiac pred
rodič
commit
2fa20329fb

+ 5 - 3
netflix-dao/src/main/java/com/cyksj/model/excel/ExcelInvoiceData.java

@@ -21,12 +21,14 @@ import java.util.Date;
 @Setter
 @SearchBean(tables = "user_invoice_apply_record uar inner join user_invoice_order_relate uor on uor.record_id = uar.id" +
 		" left join order_don o on o.id = uor.order_id and uor.type = 1" +
-		" left join upgrade_order_don uod on uod.id = uor.order_id and uor.type = 2"
-		, groupBy = "uar.id,uar.user_id,uar.money,uar.zfb_payment,uar.head,uar.tax_no,uar.bank,uar.bank_no,uar.email,uar.status,uar.invoice_img,uar.created_time,uar.update_time")
+		" left join upgrade_order_don uod on uod.id = uor.order_id and uor.type = 2" +
+		" left join vip_order_don vod on vod.id = uor.order_id and uor.type = 3"
+		, groupBy = "uar.id,uar.user_id,uar.money,uar.zfb_payment,uar.head,uar.tax_no,uar.bank,uar.bank_no,uar.email,uar.file_name,uar.status,uar.invoice_img,uar.created_time,uar.update_time")
 @ExcelIgnoreUnannotated
 public class ExcelInvoiceData {
 
-	@DbField("group_concat(o.order_no)")
+	// 与发票列表一致,普通、增值、会员订单都参与搜索和导出。
+	@DbField("group_concat(convert(ifnull(o.order_no,ifnull(uod.order_no,vod.order_no)) using utf8mb4) collate utf8mb4_general_ci)")
 	@ExcelProperty("开票订单")
 	private String orderNoStr;
 

+ 3 - 2
netflix-dao/src/main/java/com/cyksj/model/views/UserInvoiceApplyRecordView.java

@@ -20,7 +20,7 @@ import java.util.Date;
 		" left join order_don o on o.id = uor.order_id and uor.type = 1" +
 		" left join upgrade_order_don uod on uod.id = uor.order_id and uor.type = 2" +
 		" left join vip_order_don vod on vod.id = uor.order_id and uor.type = 3"
-		, groupBy = "uar.id,uar.user_id,uar.money,uar.zfb_payment,uar.head,uar.tax_no,uar.bank,uar.bank_no,uar.email,uar.status,uar.invoice_img,uar.created_time,uar.update_time")
+		, groupBy = "uar.id,uar.user_id,uar.money,uar.zfb_payment,uar.head,uar.tax_no,uar.bank,uar.bank_no,uar.email,uar.file_name,uar.status,uar.invoice_img,uar.created_time,uar.update_time")
 public class UserInvoiceApplyRecordView {
 
 	@DbField("uar.id")
@@ -80,7 +80,8 @@ public class UserInvoiceApplyRecordView {
 	@DbField("uar.invoice_img")
 	private String invoiceImg;
 
-	@DbField("group_concat(ifnull(o.order_no,ifnull(uod.order_no,vod.order_no)))")
+	// 三类订单表的排序规则可能不同,统一后再聚合,避免订单号 LIKE 筛选报错。
+	@DbField("group_concat(convert(ifnull(o.order_no,ifnull(uod.order_no,vod.order_no)) using utf8mb4) collate utf8mb4_general_ci)")
 	private String orderNoStr;
 
     /**

+ 144 - 32
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java

@@ -13,6 +13,7 @@ import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpResponse;
 import cn.hutool.http.HttpUtil;
 import cn.hutool.json.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.cyksj.common.constant.Constant;
@@ -81,7 +82,10 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
 
 import javax.servlet.http.HttpServletRequest;
 import java.math.BigDecimal;
@@ -245,6 +249,8 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	private final TicketRedemptionCodeRecordMapper ticketRedemptionCodeRecordMapper;
 
+    private final PlatformTransactionManager transactionManager;
+
 	private static final Date GPT_ACTIVITY_PAY_TIME_BEGIN = DateUtil.parse("2026-05-01 00:00:00");
 
 	private static final String GPT_ACTIVITY_REDEEM_NAME = "GPT代充/独立赠送";
@@ -253,6 +259,8 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	private static final Integer DEFAULT_API_QUOTA = 2500000;
 
+	private static final Integer YEAR_RECHARGE_API_QUOTA = 25000000;
+
 	@Override
 	public SearchResult<RenewalView> getMyTicket(long userId, Boolean isLoginPopularize, String customId, String account, String orderNo, Integer cmt) {
 		User u = userMapper.selectById(userId);
@@ -336,6 +344,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 					.gt(VipUser::getExpiryTime, now));
 			List<RenewalView> removeElements = null;
 			for (RenewalView ticket : list.getDataList()) {
+				ticket.setCanReceiveGptApiQuota(false);
 				if (ticket.getSkuId() == null || ticket.getGoodsId() == null) {
 					continue;
 				}
@@ -437,9 +446,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 						ticket.setIsComplete(orderDon.getStatus() == OrderDon.Status.complete ? true : false);
 					}
 				}
-                if (isGptApiQuotaSourceTicket(orderDon, ticket.getGoodsId(), ticket.getSkuNum())) {
-                    markGptApiQuotaReceiveStatus(userIds, ticket);
-                }
+                markGptApiQuotaReceiveStatus(userIds, ticket, orderDon);
 				if (lang != null && !StrUtil.equals(UserPageLanguage.Language.zh_CN.name(), lang)) {
 					String languageSpecVal = backInfoTranslationsFrontService.getLanguageSpecVal(zhSkuId, lang);
 					if (StrUtil.isNotBlank(languageSpecVal)) {
@@ -592,15 +599,92 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 		return true;
 	}
 
-	private void markGptApiQuotaReceiveStatus(List<Long> userIds, RenewalView ticket) {
-        Integer successCount = ticketRedemptionCodeRecordMapper.selectCount(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
+	private void markGptApiQuotaReceiveStatus(List<Long> userIds, RenewalView ticket, OrderDon sourceOrder) {
+        boolean yearQuota = isYearRechargeSku(ticket.getGoodsId(), ticket.getMonths(), ticket.getSkuNum());
+        // PLUS 仅 skuNum == 1 的独立规格可领;与实际领取接口共用资格校验。
+        ticket.setCanReceiveGptApiQuota(isGptApiQuotaSourceTicket(sourceOrder, ticket.getGoodsId(), ticket.getSkuNum())
+                && !hasReceivedGptApiQuota(userIds, ticket.getGoodsId(), yearQuota)
+                && !hasPendingGptApiQuota(userIds, ticket.getGoodsId())
+                && (!yearQuota || hasNewGptApiQuotaPurchase(userIds, ticket.getGoodsId(), sourceOrder)));
+	}
+
+    private boolean hasReceivedGptApiQuota(List<Long> userIds, Long sourceGoodsId, boolean yearQuota) {
+        LambdaQueryWrapper<TicketRedemptionCodeRecord> query = Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
                 .in(TicketRedemptionCodeRecord::getUserId, userIds)
-                .eq(TicketRedemptionCodeRecord::getSourceGoodsId, ticket.getGoodsId())
                 .eq(TicketRedemptionCodeRecord::getSkuId, Constant.GPT_ACTIVITY_GIFT_SKU_ID)
                 .eq(TicketRedemptionCodeRecord::getSourceType, RedisKey.GPT_API_QUOTA_KEY)
-                .eq(TicketRedemptionCodeRecord::getStatus, "success"));
-        ticket.setCanReceiveGptApiQuota(successCount == 0);
-	}
+                .eq(TicketRedemptionCodeRecord::getSourceGoodsId, sourceGoodsId)
+                .and(wrapper -> wrapper.eq(TicketRedemptionCodeRecord::getStatus, "success")
+                        .or(issued -> issued.isNotNull(TicketRedemptionCodeRecord::getRedeemCode)
+                                .ne(TicketRedemptionCodeRecord::getRedeemCode, "")));
+        if (yearQuota) {
+            // 独立年付、代充年付按商品各限领一次50元;原5元不占用50元资格。
+            query.ge(TicketRedemptionCodeRecord::getQuota, YEAR_RECHARGE_API_QUOTA);
+        }
+        // 普通规格仍按当前商品的任意已发额度限制一次。
+        return ticketRedemptionCodeRecordMapper.selectCount(query) > 0;
+    }
+
+    private boolean hasPendingGptApiQuota(List<Long> userIds, Long sourceGoodsId) {
+        // 同一商品发放中或结果不确定时禁止重发,不占用另一商品的领取资格。
+        return ticketRedemptionCodeRecordMapper.selectCount(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
+                .in(TicketRedemptionCodeRecord::getUserId, userIds)
+                .eq(TicketRedemptionCodeRecord::getSkuId, Constant.GPT_ACTIVITY_GIFT_SKU_ID)
+                .eq(TicketRedemptionCodeRecord::getSourceType, RedisKey.GPT_API_QUOTA_KEY)
+                .eq(TicketRedemptionCodeRecord::getSourceGoodsId, sourceGoodsId)
+                .eq(TicketRedemptionCodeRecord::getStatus, "processing")) > 0;
+    }
+
+    private boolean hasNewGptApiQuotaPurchase(List<Long> userIds, Long sourceGoodsId, OrderDon sourceOrder) {
+        Date purchaseTime = sourceOrder == null ? null
+                : Optional.ofNullable(sourceOrder.getPayTime()).orElse(sourceOrder.getCreatedTime());
+        if (purchaseTime == null) {
+            return false;
+        }
+        // 历史记录的relationId指向赠品车票;用领取时间排除已领5元的旧订单。
+        Integer previousClaims = ticketRedemptionCodeRecordMapper.selectCount(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
+                .in(TicketRedemptionCodeRecord::getUserId, userIds)
+                .eq(TicketRedemptionCodeRecord::getSkuId, Constant.GPT_ACTIVITY_GIFT_SKU_ID)
+                .eq(TicketRedemptionCodeRecord::getSourceType, RedisKey.GPT_API_QUOTA_KEY)
+                .eq(TicketRedemptionCodeRecord::getSourceGoodsId, sourceGoodsId)
+                .and(wrapper -> wrapper.eq(TicketRedemptionCodeRecord::getStatus, "success")
+                        .or(issued -> issued.isNotNull(TicketRedemptionCodeRecord::getRedeemCode)
+                                .ne(TicketRedemptionCodeRecord::getRedeemCode, "")))
+                .and(wrapper -> wrapper.lt(TicketRedemptionCodeRecord::getQuota, YEAR_RECHARGE_API_QUOTA)
+                        .or().isNull(TicketRedemptionCodeRecord::getQuota))
+                .and(wrapper -> wrapper.ge(TicketRedemptionCodeRecord::getCreatedTime, purchaseTime)
+                        .or().isNull(TicketRedemptionCodeRecord::getCreatedTime)));
+        return previousClaims == 0;
+    }
+
+    private void reserveGptApiQuota(List<Long> userIds, OrderDon sourceOrder,
+                                   TicketRedemptionCodeRecord record, boolean yearQuota) {
+        TransactionTemplate transaction = new TransactionTemplate(transactionManager);
+        transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+        transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
+        transaction.executeWithoutResult(status -> {
+            // 先按固定顺序锁关联用户,再检查并占位;即使 Redis 锁超时也不能并发发放。
+            for (Long relatedUserId : new TreeSet<>(userIds)) {
+                User user = userMapper.selectOne(Wrappers.lambdaQuery(User.class)
+                        .select(User::getId).eq(User::getId, relatedUserId).last("for update"));
+                if (user == null) {
+                    throw BusinessRuntimeException.getInstance("用户不存在,无法领取");
+                }
+            }
+            if (hasReceivedGptApiQuota(userIds, record.getSourceGoodsId(), yearQuota)) {
+                throw BusinessRuntimeException.getInstance(yearQuota ? "您已领取50元API额度" : "您已领取");
+            }
+            if (hasPendingGptApiQuota(userIds, record.getSourceGoodsId())) {
+                throw BusinessRuntimeException.getInstance("额度正在发放或结果待确认,请勿重复领取;长时间未到账请联系客服");
+            }
+            if (yearQuota && !hasNewGptApiQuotaPurchase(userIds, record.getSourceGoodsId(), sourceOrder)) {
+                throw BusinessRuntimeException.getInstance("请再次购买年付后领取50元API额度");
+            }
+            if (ticketRedemptionCodeRecordMapper.insert(record) != 1) {
+                throw BusinessRuntimeException.getInstance("领取记录保存失败,请稍后再试");
+            }
+        });
+    }
 
 	@Override
 	public GroupsRelation getSimilarExpiredGroupRelation(GoodsDonSku sku, List<Long> errorsRelationIds, String ip) {
@@ -2830,7 +2914,8 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 			userIds = List.of(userId);
 		}
 		String lockKey = String.format("%s:%s", RedisKey.GPT_API_QUOTA_KEY, Collections.min(userIds));
-		if (!redisService.setNx(lockKey, userId, 60L)) {
+        String lockToken = UUID.randomUUID().toString();
+		if (!redisService.setNx(lockKey, lockToken, 60L)) {
 			throw BusinessRuntimeException.getInstance("领取中,请稍后再试");
 		}
 		try {
@@ -2859,20 +2944,12 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 			if (sourceOrder == null) {
 				throw BusinessRuntimeException.getInstance("不符合领取要求");
 			}
-            Long sourceGoodsId = sourceRelation.getGoodsId();
+			Long sourceGoodsId = sourceRelation.getGoodsId();
             if (!isGptApiQuotaSourceTicket(sourceOrder, sourceGoodsId, sourceSku.getNum())) {
                 throw BusinessRuntimeException.getInstance("当前车票不符合领取条件");
             }
-
-            Integer receivedCount = ticketRedemptionCodeRecordMapper.selectCount(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
-                    .in(TicketRedemptionCodeRecord::getUserId, userIds)
-                    .eq(TicketRedemptionCodeRecord::getSkuId, Constant.GPT_ACTIVITY_GIFT_SKU_ID)
-                    .eq(TicketRedemptionCodeRecord::getSourceType, RedisKey.GPT_API_QUOTA_KEY)
-                    .eq(TicketRedemptionCodeRecord::getSourceGoodsId, sourceGoodsId)
-                    .eq(TicketRedemptionCodeRecord::getStatus, "success"));
-			if (receivedCount > 0) {
-				throw BusinessRuntimeException.getInstance("您已领取");
-			}
+			boolean yearQuota = isYearRechargeSku(sourceGoodsId, sourceSku.getMonths(), sourceSku.getNum());
+			Integer quota = yearQuota ? YEAR_RECHARGE_API_QUOTA : DEFAULT_API_QUOTA;
 
 			TicketRedemptionCodeRecord record = new TicketRedemptionCodeRecord();
 			record.setUserId(userId);
@@ -2882,13 +2959,19 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 			record.setSkuId(Constant.GPT_ACTIVITY_GIFT_SKU_ID);
 			record.setSourceType(RedisKey.GPT_API_QUOTA_KEY);
 			record.setRedeemName(GPT_ACTIVITY_REDEEM_NAME);
+			record.setQuota(quota);
 			record.setStatus("processing");
 			record.setRelationExpiryTime(sourceRelation.getExpiryTime());
-			ticketRedemptionCodeRecordMapper.insert(record);
+            reserveGptApiQuota(userIds, sourceOrder, record, yearQuota);
 
-			GoodsDonSku giftSku = goodsDonSkuMapper.selectById(Constant.GPT_ACTIVITY_GIFT_SKU_ID);
+			GoodsDonSku giftSku = null;
 			GroupsRelation giftRelation = null;
+            boolean redeemRequested = false;
 			try {
+                giftSku = goodsDonSkuMapper.selectById(Constant.GPT_ACTIVITY_GIFT_SKU_ID);
+                if (giftSku == null) {
+                    throw BusinessRuntimeException.getInstance("赠品规格不存在,请联系客服");
+                }
 				giftRelation = getReusableGptActivityGiftRelation(userIds, giftSku.getId(), sourceGoodsId);
 				if (giftRelation == null) {
 					Long giftRelationId = getTicket(userId, giftSku.getId(), Boolean.FALSE);
@@ -2900,7 +2983,8 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 						throw BusinessRuntimeException.getInstance("系统繁忙,请重新领取");
 					}
 				}
-				GptActivityRedeemResult redeemResult = requestGptActivityRedeemCode(Constant.AI_API_SVC_DOMAIN, GPT_ACTIVITY_REDEEM_NAME, DEFAULT_API_QUOTA);
+                redeemRequested = true;
+				GptActivityRedeemResult redeemResult = requestGptActivityRedeemCode(Constant.AI_API_SVC_DOMAIN, GPT_ACTIVITY_REDEEM_NAME, quota);
 				record.setRelationId(giftRelation.getId());
 				record.setGoodsId(giftSku.getGoodsId());
                 record.setSourceGoodsId(sourceGoodsId);
@@ -2911,26 +2995,41 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 				record.setStatus("success");
 				record.setRelationExpiryTime(giftRelation.getExpiryTime());
 				record.setApiResponse(redeemResult.getApiResponse());
-				ticketRedemptionCodeRecordMapper.updateById(record);
+                if (ticketRedemptionCodeRecordMapper.updateById(record) != 1) {
+                    throw BusinessRuntimeException.getInstance("额度发放结果待确认,请联系客服,勿重复领取");
+                }
 
 				ReceiveGptActivityTicketResp resp = new ReceiveGptActivityTicketResp();
 				resp.setRelationId(giftRelation.getId());
 				resp.setRedeemCode(redeemResult.getRedeemCode());
 				return resp;
 			} catch (Exception e) {
-				record.setGoodsId(giftSku.getGoodsId());
+                if (giftSku != null && giftRelation != null) {
+                    record.setGoodsId(giftSku.getGoodsId());
+                }
                 record.setSourceGoodsId(sourceGoodsId);
-                record.setStatus("error");
+                // 已调用远端后,即使超时/落库失败也可能已发码;保留占位,禁止自动重发。
+                record.setStatus(redeemRequested ? "processing" : "error");
 				record.setErrorMsg(StringUtil.getErrorText(e));
 				if (giftRelation != null) {
 					record.setRelationId(giftRelation.getId());
 					record.setRelationExpiryTime(giftRelation.getExpiryTime());
 				}
-				ticketRedemptionCodeRecordMapper.updateById(record);
+                try {
+                    if (ticketRedemptionCodeRecordMapper.updateById(record) != 1) {
+                        log.error("GPT API quota claim record update failed, recordId={}", record.getId());
+                    }
+                } catch (Exception persistenceError) {
+                    // 初始 processing 已独立提交;保存失败时仍保留防重占位。
+                    if (persistenceError != e) {
+                        e.addSuppressed(persistenceError);
+                    }
+                    log.error("GPT API quota claim result persistence failed, recordId={}", record.getId(), persistenceError);
+                }
 				throw e;
 			}
 		} finally {
-			redisService.del(lockKey);
+			redisService.deleteIfValueEquals(lockKey, lockToken);
 		}
 	}
 
@@ -3025,6 +3124,12 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
                 || (Constant.GPT_PLUS_GID.equals(goodsId) && Objects.equals(skuNum, 1))) && orderDon.getCreatedTime().after(DateUtil.parse("2026-05-01"));
     }
 
+    private boolean isYearRechargeSku(Long goodsId, Integer skuMonths, Integer skuNum) {
+        return (Constant.GPT_RECHARGE_GOODS_ID.equals(goodsId)
+                || (Constant.GPT_PLUS_GID.equals(goodsId) && Objects.equals(skuNum, 1)))
+                && skuMonths != null && skuMonths >= 12;
+    }
+
     private GroupsRelation getReusableGptActivityGiftRelation(List<Long> userIds, Long giftSkuId, Long sourceGoodsId) {
         TicketRedemptionCodeRecord retryRecord = ticketRedemptionCodeRecordMapper.selectOne(Wrappers.lambdaQuery(TicketRedemptionCodeRecord.class)
                 .in(TicketRedemptionCodeRecord::getUserId, userIds)
@@ -3036,7 +3141,14 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
                 .orderByDesc(TicketRedemptionCodeRecord::getId)
                 .last("limit 1"));
         if (retryRecord != null) {
-            return groupsRelationMapper.selectById(retryRecord.getRelationId());
+            GroupsRelation giftRelation = groupsRelationMapper.selectById(retryRecord.getRelationId());
+            // 发券前失败的旧记录可能仍指向付费源票,只能复用当前用户的赠品票。
+            if (giftRelation != null && userIds.contains(giftRelation.getUserId()) && giftRelation.getGroupsId() != null) {
+                GroupsTrips giftGroup = groupsMapper.selectById(giftRelation.getGroupsId());
+                if (giftGroup != null && Objects.equals(giftGroup.getSkuId(), giftSkuId)) {
+                    return giftRelation;
+                }
+            }
         }
         return null;
     }
@@ -3044,7 +3156,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 		return quota == null || quota <= 0 ? DEFAULT_API_QUOTA : quota;
 	}
 
-	private GptActivityRedeemResult requestGptActivityRedeemCode(String apiDomain, String redeemName, Integer quota) throws Exception {
+	GptActivityRedeemResult requestGptActivityRedeemCode(String apiDomain, String redeemName, Integer quota) throws Exception {
 		Map<String, Object> params = new HashMap<>();
 		params.put("name", redeemName);
 		params.put("count", 1);
@@ -3079,7 +3191,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	@lombok.Getter
 	@lombok.Setter
-	private static class GptActivityRedeemResult {
+	static class GptActivityRedeemResult {
 		private String redeemCode;
 		private String apiResponse;
 		private Integer quota;