TranslationCacheService.java

package com.yumu.noveltranslator.adapter.out.redis;

import com.yumu.noveltranslator.adapter.out.persistence.entity.TranslationCache;
import com.yumu.noveltranslator.adapter.out.persistence.mapper.TranslationCacheMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.yumu.noveltranslator.port.out.TranslationCacheAdminPort;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 翻译缓存服务
 * 二级缓存架构:Caffeine (L1) → Redis (L2) → 翻译引擎
 *
 * <p>缓存策略:</p>
 * <ul>
 *   <li>L1 Caffeine:JVM 内存缓存,10 分钟过期,单实例最高性能</li>
 *   <li>L2 Redis:分布式缓存,30 分钟过期,支持多实例共享</li>
 * </ul>
 *
 * <p>MySQL 已从查询热路径移除,仅用于异步持久化(saveToDatabaseAsync)。</p>
 *
 * <p>防缓存问题:</p>
 * <ul>
 *   <li>缓存穿透:空值占位(5 分钟短暂过期)</li>
 *   <li>缓存击穿:双层锁(JVM 本地 synchronized + Redis SET NX 分布式锁)</li>
 *   <li>缓存雪崩:过期时间添加随机抖动</li>
 *   <li>缓存一致性:版本号前缀 + 延迟双删 + Redis pub/sub</li>
 * </ul>
 */
@Service
@Slf4j
public class TranslationCacheService implements TranslationCacheAdminPort {

    private final TranslationCacheMapper translationCacheMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private final CacheVersionService cacheVersionService;

    /** 专用调度线程池,替代 Thread.sleep 保证延迟任务可靠执行 */
    private final ScheduledExecutorService delayedCleanupExecutor = Executors.newScheduledThreadPool(
            2, r -> {
                Thread t = new Thread(r, "cache-cleanup");
                t.setDaemon(true);
                return t;
            });

    public TranslationCacheService(
            TranslationCacheMapper translationCacheMapper,
            StringRedisTemplate stringRedisTemplate,
            @Lazy CacheVersionService cacheVersionService) {
        this.translationCacheMapper = translationCacheMapper;
        this.stringRedisTemplate = stringRedisTemplate;
        this.cacheVersionService = cacheVersionService;
    }

    // ==================== L1: Caffeine 内存缓存 ====================

    /**
     * Caffeine 本地缓存,最大 10000 条,写入后 10 分钟过期
     */
    private final Cache<String, String> caffeineCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()
            .build();

    // ==================== L2: Redis 配置常量 ====================

    private static final String REDIS_KEY_PREFIX = "translator:cache:";
    private static final long REDIS_CACHE_SECONDS = 30 * 60;          // Redis 缓存 30 分钟
    private static final long REDIS_NULL_SECONDS = 5 * 60;            // 空值占位 5 分钟

    // ==================== 术语反向索引 ====================

    /** Redis Set key 前缀: glossary:cache_keys:{word} */
    private static final String GLOSSARY_CACHE_KEYS_PREFIX = "glossary:cache_keys:";

    /** 反向索引 Set 的 TTL(24 小时) */
    private static final long GLOSSARY_INDEX_TTL_SECONDS = 24 * 3600;

    /** 提取源文本中长度 >= 3 的单词,用于构建反向索引 */
    private static final Pattern WORD_EXTRACT_PATTERN = Pattern.compile("\\b[\\w\\p{L}]{3,}\\b");

    // ==================== L3: 数据库配置常量 ====================

    private static final long DATABASE_CACHE_EXPIRY_HOURS = 24;       // 数据库缓存 24 小时

    // ==================== 抖动配置(防雪崩) ====================

    private static final long REDIS_JITTER_SECONDS = 60;              // Redis 过期时间抖动 ±60 秒
    private static final long DB_JITTER_HOURS = 2;                    // 数据库过期时间抖动 ±2 小时

    // ==================== 模式缓存层级 ====================

    /**
     * 模式缓存层级:查询顺序
     */
    private static final Map<String, List<String>> MODE_CACHE_HIERARCHY = Map.of(
        "fast", List.of("team", "expert", "fast"),
        "expert", List.of("team", "expert"),
        "team", List.of("team")
    );

    // ==================== 核心查询方法 ====================

    /**
     * 空值占位标记
     */
    private static final String NULL_PLACEHOLDER = "__NULL__";

    // ==================== 缓存统计 ====================

    private final AtomicLong l1HitCount = new AtomicLong(0);
    private final AtomicLong l2HitCount = new AtomicLong(0);
    private final AtomicLong cacheMissCount = new AtomicLong(0);
    private final AtomicLong nullHitCount = new AtomicLong(0);       // 空值命中(穿透防护)

    // ==================== 核心查询方法 ====================

    /**
     * 获取翻译缓存(L1 Caffeine → L2 Redis)
     */
    public String getCache(String cacheKey) {
        // 1. L1: 尝试 Caffeine 缓存
        String l1Value = caffeineCache.getIfPresent(cacheKey);
        if (l1Value != null) {
            if (NULL_PLACEHOLDER.equals(l1Value)) {
                nullHitCount.incrementAndGet();
                return null;
            }
            l1HitCount.incrementAndGet();
            return l1Value;
        }

        // 2. L2: 直接查 Redis(无分布式锁,高并发下由翻译引擎自然防击穿)
        String redisKey = REDIS_KEY_PREFIX + cacheKey;
        String value = stringRedisTemplate.opsForValue().get(redisKey);
        if (value != null) {
            if (NULL_PLACEHOLDER.equals(value)) {
                caffeineCache.put(cacheKey, NULL_PLACEHOLDER);
                return null;
            }
            caffeineCache.put(cacheKey, value);
            l2HitCount.incrementAndGet();
            return value;
        }

        cacheMissCount.incrementAndGet();
        return null;
    }

    /**
     * 根据翻译模式获取缓存,使用 MGET 批量获取多个模式 key
     * 按模式层级搜索:fast→[team, expert, fast], expert→[team, expert], team→[team]
     */
    public String getCacheByMode(String cacheKey, String currentMode) {
        List<String> modesToSearch = MODE_CACHE_HIERARCHY.getOrDefault(
            currentMode != null ? currentMode : "fast", List.of("fast"));

        // 构建待查 Redis key 列表
        List<String> redisKeys = new ArrayList<>(modesToSearch.size());
        for (String mode : modesToSearch) {
            String modeKey = cacheKey + "_" + mode;
            // 先查 L1
            String l1 = caffeineCache.getIfPresent(modeKey);
            if (l1 != null) {
                if (NULL_PLACEHOLDER.equals(l1)) return null;
                l1HitCount.incrementAndGet();
                log.debug("L1 模式缓存命中: mode={}", mode);
                return l1;
            }
            redisKeys.add(REDIS_KEY_PREFIX + modeKey);
        }

        // L1 全未命中 → MGET 批量查 L2
        if (!redisKeys.isEmpty()) {
            List<String> values = stringRedisTemplate.opsForValue().multiGet(redisKeys);
            if (values != null && !values.isEmpty()) {
                for (int i = 0; i < values.size() && i < modesToSearch.size(); i++) {
                    String modeKey = cacheKey + "_" + modesToSearch.get(i);
                    String v = values.get(i);
                    if (v != null) {
                        if (NULL_PLACEHOLDER.equals(v)) {
                            caffeineCache.put(modeKey, NULL_PLACEHOLDER);
                            return null;
                        }
                        caffeineCache.put(modeKey, v);
                        l2HitCount.incrementAndGet();
                        log.debug("L2 模式缓存命中: mode={}", modesToSearch.get(i));
                        return v;
                    }
                }
            }
        }

        cacheMissCount.incrementAndGet();
        return null;
    }

    // ==================== 核心写入方法 ====================

    /**
     * 从数据库查询缓存(高并发降级模式)
     */
    private TranslationCache queryDatabaseCache(String cacheKey) {
        try {
            TranslationCache cache = translationCacheMapper.selectById(cacheKey);
            if (cache == null) {
                cache = translationCacheMapper.selectOne(
                        new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
                                .eq("cache_key", cacheKey)
                                .gt("expire_time", LocalDateTime.now())
                );
            }
            return cache;
        } catch (Exception e) {
            log.debug("L3 数据库查询跳过(连接池繁忙或异常): {}", e.getMessage());
            return null;
        }
    }

    /**
     * 保存翻译缓存到 L1 + L2 + L3
     * 写入时使用当前版本号作为 key 前缀
     *
     * @param cacheKey   缓存键(不含版本号,由内部拼接)
     * @param sourceText 源文本
     * @param targetText 目标文本
     * @param sourceLang 源语言
     * @param targetLang 目标语言
     * @param engine     翻译引擎
     */
    public void putCache(String cacheKey, String sourceText, String targetText,
                         String sourceLang, String targetLang, String engine) {
        putCache(cacheKey, sourceText, targetText, sourceLang, targetLang, engine, null);
    }

    /**
     * 保存翻译缓存到 L1 + L2 + L3(带模式标签)
     * 写入时使用当前版本号作为 key 前缀
     *
     * @param cacheKey   基础缓存键(不含版本号)
     * @param sourceText 源文本
     * @param targetText 目标文本
     * @param sourceLang 源语言
     * @param targetLang 目标语言
     * @param engine     翻译引擎
     * @param mode       翻译模式(fast/expert/team),为空则不附加模式标签
     */
    public void putCache(String cacheKey, String sourceText, String targetText,
                         String sourceLang, String targetLang, String engine, String mode) {
        // 获取当前版本号
        String version = cacheVersionService.getVersion(sourceLang, targetLang);
        // cacheKey 可能已经包含 v{N}: 前缀(来自 CacheKeyUtil),先剥离再重新拼接
        String strippedKey = cacheKey.replaceFirst("^v\\d+:", "");
        String baseKey = "v" + version + ":" + strippedKey;
        String finalKey = (mode != null && !mode.isBlank()) ? baseKey + "_" + mode : baseKey;

        String redisKey = REDIS_KEY_PREFIX + finalKey;

        // 1. 写入 L1 Caffeine
        caffeineCache.put(finalKey, targetText);

        // 2. 写入 L2 Redis(带随机抖动防雪崩)
        long redisTtl = REDIS_CACHE_SECONDS + jitter(REDIS_JITTER_SECONDS);
        stringRedisTemplate.opsForValue().set(redisKey, targetText, Duration.ofSeconds(redisTtl));

        // 3. 异步维护术语反向索引(非关键路径,不阻塞响应)
        Thread.startVirtualThread(() -> buildReverseIndex(finalKey, sourceText));

        // 4. 异步写入 L3 数据库(带版本号)
        saveToDatabaseAsync(finalKey, sourceText, targetText, sourceLang, targetLang, engine, version);

        log.debug("L1+L2+L3 写入成功: key={}, version={}, mode={}", finalKey, version, mode);
    }

    /**
     * 保存空值占位(防缓存穿透)
     *
     * @param cacheKey 缓存键
     */
    public void putNullCache(String cacheKey) {
        String redisKey = REDIS_KEY_PREFIX + cacheKey;

        // L1 写入空值占位
        caffeineCache.put(cacheKey, NULL_PLACEHOLDER);

        // L2 Redis 写入空值占位(短过期)
        stringRedisTemplate.opsForValue().set(redisKey, NULL_PLACEHOLDER, Duration.ofSeconds(REDIS_NULL_SECONDS));

        log.debug("空值占位写入:{}", cacheKey);
    }

    /**
     * 仅保存到 L1 缓存(用于数据库缓存已存在的场景)
     */
    public void putToMemoryCache(String cacheKey, String targetText) {
        caffeineCache.put(cacheKey, targetText);
    }

    // ==================== 术语反向索引 ====================

    /**
     * 从 sourceText 中提取长度 >= 3 的单词,维护反向索引 Set。
     * Redis key: glossary:cache_keys:{word_lowercase} -> Set of cacheKeys
     * TTL: 24 小时
     */
    void buildReverseIndex(String cacheKey, String sourceText) {
        if (sourceText == null || sourceText.isBlank()) {
            return;
        }

        Set<String> uniqueWords = new HashSet<>();
        Matcher matcher = WORD_EXTRACT_PATTERN.matcher(sourceText);
        while (matcher.find()) {
            uniqueWords.add(matcher.group().toLowerCase());
        }

        for (String word : uniqueWords) {
            String indexKey = GLOSSARY_CACHE_KEYS_PREFIX + word;
            stringRedisTemplate.opsForSet().add(indexKey, cacheKey);
            stringRedisTemplate.expire(indexKey, Duration.ofSeconds(GLOSSARY_INDEX_TTL_SECONDS));
        }

        if (!uniqueWords.isEmpty()) {
            log.debug("反向索引已更新: cacheKey={}, 词条数={}", cacheKey, uniqueWords.size());
        }
    }

    /**
     * 当术语发生变化时,使所有包含该词的缓存失效。
     * 用于 glossary term 变更时的细粒度缓存失效。
     *
     * @param sourceWord 发生变化的原词(如 "Apple")
     */
    public void invalidateKeysForTerm(String sourceWord) {
        if (sourceWord == null || sourceWord.isBlank()) {
            return;
        }

        String lowerWord = sourceWord.toLowerCase();
        String indexKey = GLOSSARY_CACHE_KEYS_PREFIX + lowerWord;

        // 1. 获取所有受影响的 cache keys
        Set<String> cacheKeys = stringRedisTemplate.opsForSet().members(indexKey);
        if (cacheKeys == null || cacheKeys.isEmpty()) {
            log.debug("术语反向索引无匹配: word={}", lowerWord);
            return;
        }

        log.info("开始术语细粒度失效: word={}, 影响 {} 个缓存键", lowerWord, cacheKeys.size());

        // 2. 清空本地 Caffeine L1 缓存中对应的 key
        for (String key : cacheKeys) {
            caffeineCache.invalidate(key);
        }

        // 3. 删除 L2 Redis 中的缓存 key
        try {
            String[] redisKeys = cacheKeys.stream()
                    .map(k -> REDIS_KEY_PREFIX + k)
                    .toArray(String[]::new);
            if (redisKeys.length > 0) {
                long deleted = stringRedisTemplate.delete(java.util.List.of(redisKeys));
                log.info("L2 Redis 删除 {} 个术语相关缓存键", deleted);
            }
        } catch (Exception e) {
            log.warn("L2 Redis 术语缓存删除失败: word={}, error={}", lowerWord, e.getMessage());
        }

        // 4. 删除 L3 数据库中的缓存记录
        try {
            for (String key : cacheKeys) {
                translationCacheMapper.delete(
                        new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
                                .eq("cache_key", key)
                );
            }
            log.info("L3 数据库删除 {} 个术语相关记录", cacheKeys.size());
        } catch (Exception e) {
            log.warn("L3 数据库术语缓存删除失败: word={}, error={}", lowerWord, e.getMessage());
        }

        // 5. 清理反向索引 Set 条目
        stringRedisTemplate.delete(indexKey);

        log.info("术语细粒度失效完成: word={}", lowerWord);
    }

    // ==================== 异步数据库写入 ====================

    /**
     * 异步保存缓存到数据库(使用虚拟线程,带重试机制)
     */
    private void saveToDatabaseAsync(String cacheKey, String sourceText, String targetText,
                                      String sourceLang, String targetLang, String engine, String version) {
        Thread.startVirtualThread(() -> {
            int maxRetries = 2;
            for (int attempt = 0; attempt <= maxRetries; attempt++) {
                try {
                    TranslationCache cache = new TranslationCache();
                    cache.setCacheKey(cacheKey);
                    cache.setSourceText(sourceText);
                    cache.setTargetText(targetText);
                    cache.setSourceLang(sourceLang);
                    cache.setTargetLang(targetLang);
                    cache.setEngine(engine);
                    cache.setVersion(Integer.parseInt(version));
                    // 24 小时 + 随机抖动(0~4 小时),防雪崩
                    long jitterHours = jitter(DB_JITTER_HOURS);
                    cache.setExpireTime(LocalDateTime.now().plusHours(DATABASE_CACHE_EXPIRY_HOURS + jitterHours));

                    translationCacheMapper.insertCache(cache);
                    log.debug("L3 数据库缓存保存成功:{}", cacheKey);
                    break;
                } catch (Exception e) {
                    if (attempt == maxRetries) {
                        log.warn("L3 数据库缓存保存失败(重试 {} 次后放弃):{} - {}", maxRetries, cacheKey, e.getMessage());
                    } else {
                        try {
                            Thread.sleep(500L * (attempt + 1));
                        } catch (InterruptedException ie) {
                            Thread.currentThread().interrupt();
                            break;
                        }
                    }
                }
            }
        });
    }

    // ==================== 延迟双删 ====================

    /**
     * 延迟双删:在数据变更时保证缓存一致性
     *
     * 流程:
     * 1. 前置删除:清空 L1 + L2 对应语言对的缓存
     * 2. 版本号 bump:Redis INCR + 发布 pub/sub 事件
     * 3. 延迟 2 秒后通过 ScheduledExecutorService 调度后置删除
     * 4. 后置删除:再次清空 L2 Redis(版本前缀匹配)+ L3 过期版本记录
     *
     * 注:使用 ScheduledExecutorService 替代 Thread.sleep,避免容器重启/调度导致后置删除丢失。
     * 即使后置删除未执行,版本号机制已保证旧 key 不可达,仅占用少量内存直到 TTL 自然淘汰。
     *
     * @param sourceLang 源语言
     * @param targetLang 目标语言
     */
    public void delayedDoubleDelete(String sourceLang, String targetLang) {
        try {
            // Step 1: 前置删除 — 清空本地 L1 缓存
            log.info("延迟双删 [前置删除]: sourceLang={}, targetLang={}", sourceLang, targetLang);
            clearLocalCache();

            // Step 2: 版本号 bump + 发布 pub/sub 事件(其他实例收到后会清空 L1)
            String newVersion = cacheVersionService.bumpVersionAndPublish(sourceLang, targetLang);

            // Step 3-5: 通过 ScheduledExecutorService 调度后置删除,避免 Thread.sleep 不可靠
            delayedCleanupExecutor.schedule(() -> {
                try {
                    // 后置删除 — 清空 L2 Redis 中旧版本的所有 key
                    String oldVersion = String.valueOf(Integer.parseInt(newVersion) - 1);
                    deleteRedisByOldVersion(oldVersion);

                    // 删除 L3 数据库中旧版本的记录
                    deleteDbCacheByOldVersion(Integer.parseInt(newVersion));

                    log.info("延迟双删完成: sourceLang={}, targetLang={}, newVersion={}", sourceLang, targetLang, newVersion);
                } catch (Exception e) {
                    log.warn("延迟双删 [后置删除] 失败: {}", e.getMessage());
                }
            }, 2, TimeUnit.SECONDS);
        } catch (Exception e) {
            log.error("延迟双删异常: sourceLang={}, targetLang={}, error={}", sourceLang, targetLang, e.getMessage(), e);
        }
    }

    /**
     * 删除 Redis 中旧版本的所有缓存 key
     */
    private void deleteRedisByOldVersion(String oldVersion) {
        String pattern = REDIS_KEY_PREFIX + "v" + oldVersion + ":*";
        var keys = stringRedisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            long deleted = stringRedisTemplate.delete(keys);
            log.info("延迟双删 [L2] 删除旧版本 Redis key: version={}, 删除 {} 条", oldVersion, deleted);
        }
    }

    /**
     * 删除数据库中旧版本的所有缓存记录
     */
    private void deleteDbCacheByOldVersion(int currentVersion) {
        int deleted = translationCacheMapper.delete(
                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
                        .lt("version", currentVersion)
        );
        log.info("延迟双删 [L3] 删除旧版本 DB 记录: currentVersion={}, 删除 {} 条", currentVersion, deleted);
    }

    // ==================== 本地缓存清空(供 CacheVersionService 调用) ====================

    /**
     * 清空本地 Caffeine 缓存,供 pub/sub 事件处理使用
     */
    public void clearLocalCache() {
        long size = caffeineCache.estimatedSize();
        caffeineCache.invalidateAll();
        log.debug("本地 Caffeine 缓存已清空,原条目数: {}", size);
    }

    // ==================== 辅助方法 ====================

    /**
     * 生成随机抖动值,范围为 [0, max)
     * 用于防止缓存雪崩
     */
    private long jitter(long maxSeconds) {
        return ThreadLocalRandom.current().nextLong(maxSeconds);
    }

    /**
     * 检查缓存是否存在(不返回值)
     */
    public boolean hasCache(String cacheKey) {
        return getCache(cacheKey) != null;
    }

    /**
     * 获取缓存命中率统计
     */
    public Map<String, Object> getCacheStats() {
        long total = l1HitCount.get() + l2HitCount.get() + cacheMissCount.get();
        double hitRate = total > 0 ? (double) (l1HitCount.get() + l2HitCount.get()) / total * 100 : 0;

        return Map.of(
                "l1Hits", l1HitCount.get(),
                "l2Hits", l2HitCount.get(),
                "nullHits", nullHitCount.get(),
                "misses", cacheMissCount.get(),
                "hitRate", String.format("%.2f%%", hitRate),
                "totalRequests", total,
                "caffeineStats", caffeineCache.stats().toString()
        );
    }

    /**
     * 清理所有缓存(调试用)
     */
    public void clearAllCache() {
        caffeineCache.invalidateAll();
        log.info("L1 Caffeine 缓存已清空");

        try {
            stringRedisTemplate.delete(
                    stringRedisTemplate.keys(REDIS_KEY_PREFIX + "*")
            );
            log.info("L2 Redis 缓存已清空(前缀:{})", REDIS_KEY_PREFIX);
        } catch (Exception e) {
            log.warn("L2 Redis 缓存清空失败:{}", e.getMessage());
        }

        try {
            int deleted = translationCacheMapper.delete(
                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
            );
            log.info("L3 数据库缓存已清空,删除 {} 条记录", deleted);
        } catch (Exception e) {
            log.warn("L3 数据库缓存清空失败:{}", e.getMessage());
        }

        // 同时清空版本号
        try {
            var versionKeys = stringRedisTemplate.keys("translator:cache_version:*");
            if (versionKeys != null && !versionKeys.isEmpty()) {
                stringRedisTemplate.delete(versionKeys);
                log.info("缓存版本号已清空");
            }
        } catch (Exception e) {
            log.warn("缓存版本号清空失败: {}", e.getMessage());
        }
    }

    /**
     * 清理过期缓存
     * 定期调用此方法清理数据库中的过期缓存
     */
    public void cleanupExpiredCache() {
        try {
            int deleted = translationCacheMapper.delete(
                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TranslationCache>()
                            .lt("expire_time", LocalDateTime.now())
            );
            log.info("清理过期数据库缓存记录:{} 条", deleted);
        } catch (Exception e) {
            log.error("清理过期数据库缓存失败:{}", e.getMessage());
        }
    }

    /**
     * 预热缓存 - 批量加载热门翻译到 L1
     */
    public void warmupCache(Iterable<String> cacheKeys) {
        int count = 0;
        for (String cacheKey : cacheKeys) {
            TranslationCache dbCache = queryDatabaseCache(cacheKey);
            if (dbCache != null) {
                String value = dbCache.getTargetText();
                caffeineCache.put(cacheKey, value);
                // 同时回写 L2
                String redisKey = REDIS_KEY_PREFIX + cacheKey;
                stringRedisTemplate.opsForValue().setIfAbsent(
                        redisKey,
                        value,
                        Duration.ofSeconds(REDIS_CACHE_SECONDS + jitter(REDIS_JITTER_SECONDS))
                );
                count++;
            }
        }
        log.info("缓存预热完成:加载 {} 条记录到 L1+L2", count);
    }

    /**
     * 应用关闭时清理缓存资源
     */
    @PreDestroy
    public void shutdown() {
        log.info("翻译缓存服务关闭,清理 Caffeine 缓存...");
        caffeineCache.invalidateAll();
        log.info("翻译缓存服务关闭,关闭调度线程池...");
        delayedCleanupExecutor.shutdown();
        try {
            if (!delayedCleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
                delayedCleanupExecutor.shutdownNow();
            }
        } catch (InterruptedException e) {
            delayedCleanupExecutor.shutdownNow();
            Thread.currentThread().interrupt();
        }
        log.info("翻译缓存服务已关闭");
    }
}