"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。""" from __future__ import annotations import asyncio import hashlib import logging from datetime import datetime, timezone from typing import Protocol from app.config import settings from app.redis_client import get_redis from app.services.translation.base import BaseTranslator, TranslationResult from app.services.translation.local import LocalTranslator from app.services.translation.tencent import TencentTranslator logger = logging.getLogger("news.translate.service") # 缓存 key def _cache_key(text: str, src: str, tgt: str) -> str: h = hashlib.sha1(f"{src}|{tgt}|{text}".encode()).hexdigest() return f"translation:cache:{h}" def _month_key() -> str: now = datetime.now(timezone.utc) return f"translation:month:{now:%Y%m}" class TranslationService: def __init__(self): self._tencent: BaseTranslator | None = None self._local: BaseTranslator | None = None # 串行:1 个并发;避免触发腾讯 TMT 限速 self._sem = asyncio.Semaphore(1) def _primary(self) -> BaseTranslator: if self._tencent is None: self._tencent = TencentTranslator() return self._tencent def _fallback(self) -> BaseTranslator | None: if self._local is None and settings.local_translate_enabled: try: self._local = LocalTranslator() except Exception as e: logger.warning("local translator init failed: %s", e) self._local = None return self._local async def can_use_tencent(self, chars: int) -> bool: if not settings.tencentcloud_secret_id: return False r = get_redis() used = int(await r.get(_month_key()) or 0) buffered = int( settings.tencent_tmt_quota_month * (1 - settings.tencent_tmt_quota_buffer) ) return (used + chars) <= buffered async def add_usage(self, chars: int) -> None: r = get_redis() key = _month_key() now = datetime.now(timezone.utc) # 下个月第一天 if now.month == 12: next_month = now.replace(year=now.year + 1, month=1, day=1) else: next_month = now.replace(month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0) ttl = max(60, int((next_month - now).total_seconds()) + 86400) # +1 天 buffer async with r.pipeline(transaction=False) as pipe: pipe.incrby(key, chars) pipe.expire(key, ttl) await pipe.execute() async def translate( self, text: str, source: str = "auto", target: str = "zh" ) -> TranslationResult: if not text.strip(): return TranslationResult(text=text, engine="skip", chars=0) chars = len(text) # 1) 缓存 r = get_redis() ck = _cache_key(text, source, target) cached = await r.get(ck) if cached is not None: return TranslationResult(text=cached, engine="cache", chars=chars, cached=True) # 2) 选引擎 use_tencent = await self.can_use_tencent(chars) engine: BaseTranslator if use_tencent: engine = self._primary() else: fb = self._fallback() if fb is None: # 没本地:返回原文 + 标记 return TranslationResult( text=text + "\n\n[本条未翻译:配额耗尽且未启用本地翻译]", engine="skip", chars=chars, ) engine = fb logger.info("fallback to local translator for %d chars", chars) # 3) 调用 async with self._sem: res = None try: res = await engine.translate(text, source=source, target=target) except Exception as e: # 失败:降级 logger.exception("translate failed with %s: %s", engine.name, e) fb = self._fallback() if fb is not None and engine is not fb: try: res = await fb.translate(text, source=source, target=target) except Exception as e2: logger.exception("fallback %s also failed: %s", fb.name, e2) res = None if res is None: # 主 + fallback 都失败:抛异常,让上层标记 status=failed raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})") # 4) 写缓存 — 只缓存真实翻译结果;失败/降级文本不缓存(避免污染 30 天) if res.engine in ("tencent", "nllb", "cache") and not res.cached: # 二次保险:如果文本里仍含错误标记,也不缓存 if "[翻译失败" not in res.text and "[本条未翻译" not in res.text: try: await r.set(ck, res.text, ex=60 * 60 * 24 * 30) # 30 天 except Exception: pass # 5) 计数(只在 tencent 上计) if res.engine == "tencent": try: await self.add_usage(res.chars or chars) except Exception as e: logger.warning("add_usage failed: %s", e) return res # 全局单例 service = TranslationService() # 让后端 worker 直接调 class _Protocol(Protocol): async def translate(self, text: str, source: str = "auto", target: str = "zh") -> TranslationResult: ...