QuotaService.java
package com.yumu.noveltranslator.domain.service;
import com.yumu.noveltranslator.port.out.BillingRepositoryPort;
import com.yumu.noveltranslator.port.out.QuotaPort;
import com.yumu.noveltranslator.properties.TranslationLimitProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;
/**
* 字符配额服务
* <p>
* 纯业务逻辑,Redis 操作通过 QuotaPort 抽象。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QuotaService {
private final BillingRepositoryPort billingPort;
private final TranslationLimitProperties limitProperties;
private final QuotaPort quotaPort;
/**
* 获取用户月度配额(原始值)
*/
public long getMonthlyQuota(String userLevel) {
if (userLevel == null) return limitProperties.getFreeMonthlyChars();
return switch (userLevel.toLowerCase()) {
case "max" -> limitProperties.getMaxMonthlyChars();
case "pro" -> limitProperties.getProMonthlyChars();
default -> limitProperties.getFreeMonthlyChars();
};
}
/**
* 获取模式系数
*/
public double getModeMultiplier(String mode) {
if (mode == null) return limitProperties.getExpertModeMultiplier();
return switch (mode.toLowerCase()) {
case "fast" -> limitProperties.getFastModeMultiplier();
case "team" -> limitProperties.getTeamModeMultiplier();
default -> limitProperties.getExpertModeMultiplier();
};
}
/**
* 获取剩余可用字符数(月度配额 - 已用)
*/
public long getRemainingChars(Long userId, String userLevel) {
long quota = getMonthlyQuota(userLevel);
long used = getUsedThisMonth(userId);
return Math.max(0, quota - used);
}
/**
* 查询本月已用字符数(从 MySQL,用于用户面板展示,最终一致性可接受)
*/
public long getUsedThisMonth(Long userId) {
LocalDate monthStart = LocalDate.now().withDayOfMonth(1);
return billingPort.getMonthlyQuotaUsage(userId, monthStart);
}
/**
* 构建 Redis 配额 key: quota:chars:{userId}:{yearMonth}
*/
private String quotaKey(Long userId) {
return "quota:chars:" + userId + ":" + YearMonth.now();
}
/** 当配额超过此阈值时视为无限,跳过 Redis 检查 */
private static final long UNLIMITED_QUOTA_THRESHOLD = 10_000_000L;
/**
* 尝试消耗字符(Lua 脚本原子检查 + INCR,无需分布式锁)
* @return 是否成功扣减
*/
public boolean tryConsumeChars(Long userId, String userLevel, long translatedCharCount, String mode) {
double multiplier = getModeMultiplier(mode);
long cost = (long) Math.ceil(translatedCharCount * multiplier);
long quota = getMonthlyQuota(userLevel);
// 如果配额超过阈值,视为无限,跳过 Redis 调用
if (quota >= UNLIMITED_QUOTA_THRESHOLD) {
return true;
}
String key = quotaKey(userId);
// 本月剩余天数 + 10 天缓冲作为 Redis key 过期时间
int ttl = (int) (ChronoUnit.DAYS.between(LocalDate.now(), YearMonth.now().atEndOfMonth()) + 10);
if (quotaPort.tryConsumeChars(key, quota, cost, ttl)) {
quotaPort.incrementDailyUsage(userId, LocalDate.now(), cost);
return true;
}
log.warn("字符配额不足: userId={}, cost={}", userId, cost);
// Redis 故障时回退到 MySQL 查询,避免 DoS
return fallbackConsumeChars(userId, userLevel, cost);
}
/**
* Redis 不可用时的降级方案
*/
private boolean fallbackConsumeChars(Long userId, String userLevel, long cost) {
long quota = getMonthlyQuota(userLevel);
long used = billingPort.getMonthlyQuotaUsage(userId, LocalDate.now().withDayOfMonth(1));
if (quota - used < cost) {
return false;
}
quotaPort.incrementDailyUsage(userId, LocalDate.now(), cost);
return true;
}
/**
* 退款:返还字符配额(翻译失败时调用)
*/
public void refundChars(Long userId, long chars, String mode) {
if (chars <= 0) return;
double multiplier = getModeMultiplier(mode);
long refundAmount = (long) Math.ceil(chars * multiplier);
String key = quotaKey(userId);
quotaPort.refundChars(key, refundAmount);
quotaPort.decrementDailyUsage(userId, LocalDate.now(), refundAmount);
}
}