Files
diary-news/backend/app/services/translation/service.py
Mavis 639562593e fix: 翻译失败/降级文本不再写 cache(避免 30 天污染)
之前 service.translate 写 cache 无条件,导致:
- 第一次翻译失败时,'[翻译失败: ...]' 占位符被写进 cache
- 30 天内相同文本的请求(新文章 title 与老文章 title 相同时)全部返回占位符
- 触发 200+ 文章 title_zh 字段被永久污染

修法:仅在 engine ∈ {tencent, nllb, cache} 且文本不含错误标记时,才写 cache。
2026-06-08 00:48:36 +08:00

152 lines
5.5 KiB
Python

"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。"""
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: ...