|
|
@@ -6,7 +6,6 @@ import com.etotem.cfc.entity.BasicQuotaUsage;
|
|
|
import com.etotem.cfc.entity.User;
|
|
|
import com.etotem.cfc.mapper.BasicQuotaUsageMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
|
-import com.etotem.cfc.mapper.UserMapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
@@ -14,10 +13,44 @@ import org.springframework.stereotype.Service;
|
|
|
import javax.annotation.Resource;
|
|
|
import java.util.*;
|
|
|
|
|
|
+/**
|
|
|
+ * BASIC 体验会员配额服务。
|
|
|
+ *
|
|
|
+ * <p>配额周期(2026-10-23 新口径,用户定稿):
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code AI_CHAT}(AI 对话):BASIC 每天 3 次;FAMILY 及以上不限次</li>
|
|
|
+ * <li>{@code FOOD_RECOGNIZE}(饮食拍照识别):BASIC 每天 3 次;FAMILY 及以上不限次</li>
|
|
|
+ * <li>{@code REPORT_READING}(报告解读):BASIC 每月 1 次;FAMILY 及以上不限次</li>
|
|
|
+ * </ul>
|
|
|
+ * 注:{@code ASSESSMENT_QUOTA} 已移除——测评预约不是特殊功能,不设会员配额(迁移 382)。
|
|
|
+ *
|
|
|
+ * <p>周期粒度由 {@link #periodOf(String)} 决定:日配额按自然日(00:00-23:59:59)滚动,
|
|
|
+ * 月配额按自然月。到期自动换周期(懒重置:查不到当前周期记录即按 used=0 新建),
|
|
|
+ * 无需定时任务干预;{@link #refreshQuotas()} 仅做当前周期记录的兜底初始化。
|
|
|
+ */
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
public class BasicQuotaService {
|
|
|
|
|
|
+ /** 配额周期粒度 */
|
|
|
+ public enum QuotaPeriod {
|
|
|
+ DAILY, MONTHLY
|
|
|
+ }
|
|
|
+
|
|
|
+ // BASIC 会员配额上限(数值=周期内次数)
|
|
|
+ public static final Map<String, Integer> QUOTA_LIMITS = new HashMap<>();
|
|
|
+ // 配额周期:AI_CHAT / FOOD_RECOGNIZE 为日配额;REPORT_READING 为月配额
|
|
|
+ public static final Map<String, QuotaPeriod> QUOTA_PERIODS = new HashMap<>();
|
|
|
+
|
|
|
+ static {
|
|
|
+ QUOTA_LIMITS.put("AI_CHAT", 3);
|
|
|
+ QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
|
|
|
+ QUOTA_LIMITS.put("REPORT_READING", 1);
|
|
|
+ QUOTA_PERIODS.put("AI_CHAT", QuotaPeriod.DAILY);
|
|
|
+ QUOTA_PERIODS.put("FOOD_RECOGNIZE", QuotaPeriod.DAILY);
|
|
|
+ QUOTA_PERIODS.put("REPORT_READING", QuotaPeriod.MONTHLY);
|
|
|
+ }
|
|
|
+
|
|
|
@Resource
|
|
|
private BasicQuotaUsageMapper basicQuotaUsageMapper;
|
|
|
|
|
|
@@ -27,32 +60,30 @@ public class BasicQuotaService {
|
|
|
@Resource
|
|
|
private MembershipService membershipService;
|
|
|
|
|
|
- // BASIC 会员月度配额上限
|
|
|
- public static final Map<String, Integer> QUOTA_LIMITS = new HashMap<>();
|
|
|
- static {
|
|
|
- QUOTA_LIMITS.put("AI_CHAT", 3);
|
|
|
- QUOTA_LIMITS.put("REPORT_READING", 1);
|
|
|
- QUOTA_LIMITS.put("ASSESSMENT_QUOTA", 1);
|
|
|
- QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
- * 按配额类型返回月度上限(0 表示无配额限制)
|
|
|
+ * 按配额类型返回周期内上限(0 表示无配额限制/未知类型)
|
|
|
*/
|
|
|
public static int getQuotaLimit(String quotaType) {
|
|
|
return QUOTA_LIMITS.getOrDefault(quotaType, 0);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 查询指定用户指定月份某配额的剩余次数
|
|
|
+ * 按配额类型返回周期粒度(未知类型按月处理)
|
|
|
+ */
|
|
|
+ public static QuotaPeriod periodOf(String quotaType) {
|
|
|
+ return QUOTA_PERIODS.getOrDefault(quotaType, QuotaPeriod.MONTHLY);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询指定用户当前周期某配额的剩余次数(日配额看今天,月配额看本月)
|
|
|
*/
|
|
|
- public QuotaStatusDTO getMonthlyQuota(Long userId, String quotaType) {
|
|
|
+ public QuotaStatusDTO getQuotaStatus(Long userId, String quotaType) {
|
|
|
if (userId == null || quotaType == null) {
|
|
|
return null;
|
|
|
}
|
|
|
Date now = new Date();
|
|
|
- Date periodStart = getMonthStart(now);
|
|
|
- Date periodEnd = getMonthEnd(now);
|
|
|
+ Date periodStart = periodStart(quotaType, now);
|
|
|
+ Date periodEnd = periodEnd(quotaType, now);
|
|
|
|
|
|
BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
|
|
|
new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
@@ -73,18 +104,23 @@ public class BasicQuotaService {
|
|
|
dto.setRemaining(Math.max(0, total - used));
|
|
|
dto.setPeriodStart(periodStart);
|
|
|
dto.setPeriodEnd(periodEnd);
|
|
|
+ // FAMILY 及以上不限次:下发 unlimited 标记,前端不展示进度条、不做剩余次数拦截
|
|
|
+ int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
|
|
|
+ if (userOrder > membershipService.getLevelOrder("BASIC")) {
|
|
|
+ dto.setUnlimited(true);
|
|
|
+ }
|
|
|
return dto;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 尝试扣减配额,返回是否成功
|
|
|
+ * 尝试扣减配额,返回是否成功(按配额类型的周期粒度定位当前周期记录,查不到即新建)
|
|
|
*/
|
|
|
public QuotaConsumeResult consumeQuota(Long userId, String quotaType) {
|
|
|
QuotaConsumeResult result = new QuotaConsumeResult();
|
|
|
|
|
|
Date now = new Date();
|
|
|
- Date periodStart = getMonthStart(now);
|
|
|
- Date periodEnd = getMonthEnd(now);
|
|
|
+ Date periodStart = periodStart(quotaType, now);
|
|
|
+ Date periodEnd = periodEnd(quotaType, now);
|
|
|
|
|
|
BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
|
|
|
new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
@@ -129,49 +165,88 @@ public class BasicQuotaService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 批量刷新所有有效 BASIC 用户当月配额(每月1号调用)
|
|
|
+ * 会员权益配额扣减(AI 对话 / 报告解读等 BASIC 权益):
|
|
|
+ * <ul>
|
|
|
+ * <li>FAMILY 及以上(level_order > BASIC):不限次,直接放行,不落配额记录;</li>
|
|
|
+ * <li>BASIC:走对应配额(日/月周期);</li>
|
|
|
+ * <li>FREE 及以下:{@code rejectFree=true} 时拒绝并提示升级(AI 对话、饮食识别——
|
|
|
+ * 前端入口本就仅会员可见,后端做一致硬门禁);{@code rejectFree=false} 时按现状扣减
|
|
|
+ * (报告解读:上传已对全员开放,FREE 维持既有 1 次/月口径,不静默放开)。</li>
|
|
|
+ * </ul>
|
|
|
+ * userId 为 null/0(系统级后台任务,如文章自动分类)时直接放行。
|
|
|
*/
|
|
|
- public void refreshMonthlyQuota() {
|
|
|
- Date now = new Date();
|
|
|
- Date monthStart = getMonthStart(now);
|
|
|
- Date monthEnd = getMonthEnd(now);
|
|
|
+ public QuotaConsumeResult consumeMemberQuota(Long userId, String quotaType, boolean rejectFree) {
|
|
|
+ QuotaConsumeResult result = new QuotaConsumeResult();
|
|
|
+ if (userId == null || userId == 0L) {
|
|
|
+ result.setSuccess(true);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
|
|
|
+ int basicOrder = membershipService.getLevelOrder("BASIC");
|
|
|
+ if (userOrder > basicOrder) {
|
|
|
+ // FAMILY/PREMIUM/LIFETIME:不限次
|
|
|
+ result.setSuccess(true);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ if (userOrder < basicOrder && rejectFree) {
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setRemaining(0);
|
|
|
+ result.setToastMsg(buildFreeRejectToast(quotaType));
|
|
|
+ result.setActionUrl("/pages/membership/upgrade");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ return consumeQuota(userId, quotaType);
|
|
|
+ }
|
|
|
|
|
|
- List<BasicQuotaUsage> lastMonthRecords = basicQuotaUsageMapper.selectList(
|
|
|
+ /**
|
|
|
+ * 图片解析(食材识别)配额校验:
|
|
|
+ * - FAMILY 及以上:不限次放行;
|
|
|
+ * - BASIC:走 FOOD_RECOGNIZE 日配额(3 次/天);
|
|
|
+ * - FREE:拒绝并提示升级。
|
|
|
+ */
|
|
|
+ public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
|
|
|
+ return consumeMemberQuota(userId, "FOOD_RECOGNIZE", true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 兜底初始化:为所有有效 BASIC 用户创建当前周期的配额记录(used=0)。
|
|
|
+ * 不重置已在周期内的记录(日配额按天、月配额按月自然滚动,懒重置已覆盖)。
|
|
|
+ */
|
|
|
+ public void refreshQuotas() {
|
|
|
+ Date now = new Date();
|
|
|
+ List<BasicQuotaUsage> allRecords = basicQuotaUsageMapper.selectList(
|
|
|
new LambdaQueryWrapper<BasicQuotaUsage>());
|
|
|
|
|
|
Set<Long> userIds = new HashSet<>();
|
|
|
- for (BasicQuotaUsage rec : lastMonthRecords) {
|
|
|
+ for (BasicQuotaUsage rec : allRecords) {
|
|
|
if (isActiveBasicMember(rec.getUserId())) {
|
|
|
userIds.add(rec.getUserId());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
for (Long userId : userIds) {
|
|
|
- for (String quotaType : QUOTA_LIMITS.keySet()) {
|
|
|
+ for (Map.Entry<String, Integer> entry : QUOTA_LIMITS.entrySet()) {
|
|
|
+ String quotaType = entry.getKey();
|
|
|
try {
|
|
|
+ Date periodStart = periodStart(quotaType, now);
|
|
|
+ Date periodEnd = periodEnd(quotaType, now);
|
|
|
BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
|
|
|
new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
.eq(BasicQuotaUsage::getUserId, userId)
|
|
|
.eq(BasicQuotaUsage::getQuotaType, quotaType)
|
|
|
- .eq(BasicQuotaUsage::getPeriodStart, monthStart));
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, periodStart));
|
|
|
if (existing == null) {
|
|
|
BasicQuotaUsage newUsage = new BasicQuotaUsage();
|
|
|
newUsage.setUserId(userId);
|
|
|
newUsage.setQuotaType(quotaType);
|
|
|
newUsage.setUsedCount(0);
|
|
|
- newUsage.setTotalCount(QUOTA_LIMITS.get(quotaType));
|
|
|
- newUsage.setPeriodStart(monthStart);
|
|
|
- newUsage.setPeriodEnd(monthEnd);
|
|
|
+ newUsage.setTotalCount(entry.getValue());
|
|
|
+ newUsage.setPeriodStart(periodStart);
|
|
|
+ newUsage.setPeriodEnd(periodEnd);
|
|
|
newUsage.setCreatedAt(now);
|
|
|
newUsage.setUpdatedAt(now);
|
|
|
basicQuotaUsageMapper.insert(newUsage);
|
|
|
- log.info("BASIC配额刷新初始化: userId={}, quotaType={}", userId, quotaType);
|
|
|
- } else {
|
|
|
- existing.setUsedCount(0);
|
|
|
- existing.setPeriodStart(monthStart);
|
|
|
- existing.setPeriodEnd(monthEnd);
|
|
|
- existing.setUpdatedAt(now);
|
|
|
- basicQuotaUsageMapper.updateById(existing);
|
|
|
+ log.info("BASIC配额初始化: userId={}, quotaType={}, total={}", userId, quotaType, entry.getValue());
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
log.warn("BASIC配额刷新失败: userId={}, quotaType={}", userId, quotaType, e);
|
|
|
@@ -181,6 +256,18 @@ public class BasicQuotaService {
|
|
|
log.info("BASIC配额刷新完成, 处理用户数={}", userIds.size());
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 兼容旧名的配额刷新入口({@link AdminBasicQuotaController} 应急调用,不在本计划改动范围)。
|
|
|
+ * 2026-10-23 起语义与 {@link #refreshQuotas()} 一致:只兜底创建当前周期缺失记录,
|
|
|
+ * 不再重置周期内已有用量(旧"每月重置"行为已废除,防止日配额被清零重发)。
|
|
|
+ *
|
|
|
+ * @deprecated 请改调 {@link #refreshQuotas()}
|
|
|
+ */
|
|
|
+ @Deprecated
|
|
|
+ public void refreshMonthlyQuota() {
|
|
|
+ refreshQuotas();
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 检查用户当前是否为有效的 BASIC 会员
|
|
|
*/
|
|
|
@@ -210,32 +297,71 @@ public class BasicQuotaService {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
+ // ==================== 周期计算 ====================
|
|
|
+
|
|
|
+ public Date periodStart(String quotaType, Date date) {
|
|
|
+ return periodOf(quotaType) == QuotaPeriod.DAILY ? getDayStart(date) : getMonthStart(date);
|
|
|
+ }
|
|
|
+
|
|
|
+ public Date periodEnd(String quotaType, Date date) {
|
|
|
+ return periodOf(quotaType) == QuotaPeriod.DAILY ? getDayEnd(date) : getMonthEnd(date);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * 图片解析(食材识别)配额校验:
|
|
|
- * - FAMILY 及以上(level_order > BASIC):不限次放行;
|
|
|
- * - BASIC:走 FOOD_RECOGNIZE 月度配额;
|
|
|
- * - FREE:拒绝并提示升级。
|
|
|
+ * 为指定用户初始化所有配额类型的当前周期记录(used=0,已存在则跳过)。
|
|
|
+ * 用于支付成功开通 BASIC 时的配额初始化(ActivationQuotaService 委托)。
|
|
|
*/
|
|
|
- public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
|
|
|
- QuotaConsumeResult result = new QuotaConsumeResult();
|
|
|
- if (userId == null || userId == 0L) {
|
|
|
- result.setSuccess(true);
|
|
|
- return result;
|
|
|
- }
|
|
|
- int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
|
|
|
- int basicOrder = membershipService.getLevelOrder("BASIC");
|
|
|
- if (userOrder < basicOrder) {
|
|
|
- result.setSuccess(false);
|
|
|
- result.setRemaining(0);
|
|
|
- result.setToastMsg("图片解析为体验会员及以上专属权益,升级后即可使用");
|
|
|
- result.setActionUrl("/pages/membership/upgrade");
|
|
|
- return result;
|
|
|
+ public void initUserQuotaRecords(Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return;
|
|
|
}
|
|
|
- if (userOrder > basicOrder) {
|
|
|
- result.setSuccess(true);
|
|
|
- return result;
|
|
|
+ Date now = new Date();
|
|
|
+ for (Map.Entry<String, Integer> entry : QUOTA_LIMITS.entrySet()) {
|
|
|
+ String quotaType = entry.getKey();
|
|
|
+ try {
|
|
|
+ Date periodStart = periodStart(quotaType, now);
|
|
|
+ BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
+ .eq(BasicQuotaUsage::getUserId, userId)
|
|
|
+ .eq(BasicQuotaUsage::getQuotaType, quotaType)
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, periodStart));
|
|
|
+ if (existing == null) {
|
|
|
+ BasicQuotaUsage usage = new BasicQuotaUsage();
|
|
|
+ usage.setUserId(userId);
|
|
|
+ usage.setQuotaType(quotaType);
|
|
|
+ usage.setUsedCount(0);
|
|
|
+ usage.setTotalCount(entry.getValue());
|
|
|
+ usage.setPeriodStart(periodStart);
|
|
|
+ usage.setPeriodEnd(periodEnd(quotaType, now));
|
|
|
+ usage.setCreatedAt(now);
|
|
|
+ usage.setUpdatedAt(now);
|
|
|
+ basicQuotaUsageMapper.insert(usage);
|
|
|
+ log.info("BASIC配额初始化: userId={}, quotaType={}, total={}", userId, quotaType, entry.getValue());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("BASIC配额初始化失败: userId={}, quotaType={}", userId, quotaType, e);
|
|
|
+ }
|
|
|
}
|
|
|
- return consumeQuota(userId, "FOOD_RECOGNIZE");
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date getDayStart(Date date) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(date);
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
+ cal.set(Calendar.MINUTE, 0);
|
|
|
+ cal.set(Calendar.SECOND, 0);
|
|
|
+ cal.set(Calendar.MILLISECOND, 0);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date getDayEnd(Date date) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(date);
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 23);
|
|
|
+ cal.set(Calendar.MINUTE, 59);
|
|
|
+ cal.set(Calendar.SECOND, 59);
|
|
|
+ cal.set(Calendar.MILLISECOND, 999);
|
|
|
+ return cal.getTime();
|
|
|
}
|
|
|
|
|
|
private Date getMonthStart(Date date) {
|
|
|
@@ -260,16 +386,29 @@ public class BasicQuotaService {
|
|
|
return cal.getTime();
|
|
|
}
|
|
|
|
|
|
+ // ==================== 文案 ====================
|
|
|
+
|
|
|
private String buildExhaustedToast(String quotaType, int total) {
|
|
|
switch (quotaType) {
|
|
|
case "AI_CHAT":
|
|
|
- return "您本月 AI 对话已用完(" + total + "/" + total + "),升级 FAMILY 享不限次畅聊";
|
|
|
+ return "您今日 AI 对话已用完(" + total + "/" + total + "),升级 FAMILY 享不限次畅聊";
|
|
|
+ case "FOOD_RECOGNIZE":
|
|
|
+ return "今日饮食识别额度已用完(" + total + "/" + total + "),升级 FAMILY 享不限次识别";
|
|
|
case "REPORT_READING":
|
|
|
return "本月报告解读额度已用完(" + total + "/" + total + "),升级 FAMILY 解锁不限次深度解读";
|
|
|
- case "ASSESSMENT_QUOTA":
|
|
|
- return "测评预约为 FAMILY 会员专属权益,升级后即可预约";
|
|
|
default:
|
|
|
return "本月额度已用完,升级 FAMILY 解锁更多";
|
|
|
}
|
|
|
}
|
|
|
-}
|
|
|
+
|
|
|
+ private String buildFreeRejectToast(String quotaType) {
|
|
|
+ switch (quotaType) {
|
|
|
+ case "AI_CHAT":
|
|
|
+ return "AI 对话为体验会员及以上专属权益,升级后即可使用";
|
|
|
+ case "FOOD_RECOGNIZE":
|
|
|
+ return "图片解析为体验会员及以上专属权益,升级后即可使用";
|
|
|
+ default:
|
|
|
+ return "该功能为体验会员及以上专属权益,升级后即可使用";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|