# 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);
        }
    }