返回文章列表
/ 功能设计

内容多语言翻译(商家自带 key)设计

UI 词表三语之外,让商品名、分类、文章与页面区块文案也能翻译并持久化。

i18n翻译OpenCC

状态:方案定稿,后端骨架已落地,前端与各实体接入未完成(详见 §11 实施状态) 适用范围:商品、商品分类、博客(文章/分类/标签)、店铺页面区块文案 语言集:zh-CN(源,店铺默认语言) / zh-TW / en

1. 背景与目标

店铺前台已支持 zh-CN / zh-TW / en 三语(见 src/common/shared/language.ts 的 SUPPORTED_LANGUAGES), 但只覆盖 UI 词表;商家录入的内容(商品名、分类名、文章、页面区块文案)仍只有一份原文。

目标:

  1. 商家在店铺设置里配置自己的翻译服务 key(平台不代付、不统一采购);
  2. 后台可对内容一键翻译(或配置成保存后自动翻译),译文落库缓存;
  3. 前台按访客语言(?lang= / store_lang cookie)返回译文,缺失时回退原文;
  4. 翻译失败绝不影响内容本身的保存与展示。

不做(本期):UI 词条(strings.*)的运行时翻译;访客侧在线翻译第三方内容(评论等)。

2. 关键决策(已确认)

决策点结论
Provider 首批openai(OpenAI 兼容协议,覆盖 DeepSeek / 通义 / GLM / 自建网关)+ opencc(本地简繁)
触发方式由商家在店铺设置里配置:manual(手动点翻译)或 onSave(保存内容后自动)
译文存储通用表 EntityTranslation(一行 = 实体某字段某语言)
翻译范围商品、分类、博客、页面区块
页面区块 entityId用 pagePath(文件系统页面没有稳定 id)
店铺语言列表Store.languages(新增 JSON 列)
人工修订需要(edited=true 后自动翻译不覆盖)

3. 繁中策略(重要)

zh-TW 不调三方接口,恒定走本地 OpenCC(cn → twp,含词汇级转换):

  • 简繁是同一语言的两种字形,机器翻译反而引入词义偏差(“软件/軟體”);
  • 零成本、无网络、不计入商家 key 配额 —— 商家只需为英文等外语付费。

实现:resolveProvider() 在目标语言为 zh-TW 时直接返回 OpenCcTranslationProvider, 不读商家配置。

4. 数据模型

4.1 StoreTranslationConfig(平台库 mall,一店一行)

字段类型说明
storeIdvarchar(50)所属店铺(唯一,一店一行)
providervarchar(30)openai / opencc
apiKeyCiphertextAES-256-GCM 密文(encryptSecret),明文不落库、不回显
endpointvarchar(255)自定义 baseUrl(代理 / 自建网关),openai 时必填
modelvarchar(100)模型名(deepseek-chat / qwen-plus …)
sourceLangvarchar(10)源语言,缺省取 store.language
targetLanguagesjson目标语言数组(SUPPORTED_LANGUAGES 子集)
glossaryjson术语表:不翻译的词 → 固定译法
triggervarchar(20)manual / onSave
enabledboolean是否启用
monthlyCharUsedinteger本月已用字符数(商家 key 成本可见性)
usageMonthvarchar(7)用量所属月份(yyyy-MM),跨月清零

放平台库的理由:key 属于店铺账号级配置,与 Store 同生命周期,不需要随租户库迁移。

4.2 EntityTranslation(租户库,StoreScopedEntity)

字段类型说明
storeIdvarchar(50)店铺隔离
entityTypevarchar(30)Product / ProductCategory / BlogArticle / BlogCategory / BlogTag / StorePage
entityIdvarchar(64)实体主键;页面用 pagePath
langvarchar(10)目标语言
fieldvarchar(200)字段名;页面区块用 JSON Pointer(/props/children/1/props/title)
valuetext译文
sourceHashvarchar(64)sha256(源文 + 术语表 + provider + model + 目标语言)
statusvarchar(20)pending / done / failed
errorMessagevarchar(500)失败原因(key 无效 / 配额超限等)
editedboolean人工修订过,自动重译不覆盖

注:alinq 的 mapping builder 不支持声明索引/唯一约束, (storeId, entityType, entityId, lang, field) 的唯一性由服务层「先查后写」保证 (src/common/translation/service.ts)。

5. Provider 层

src/common/translation/

export interface TranslationProvider {
    name: string;
    requireKey: boolean;              // opencc 为 false
    translate(req: {
        texts: string[];
        from: string;
        to: string;
        glossary?: Record<string, string>;
        format?: "text" | "html";
    }): Promise<string[]>;
    test(): Promise<{ ok: boolean; message?: string }>;
}

5.1 openai(OpenAI 兼容)

  • endpoint 三种形态归一:https://x.com → .../v1/chat/completions;.../v1 → 补 /chat/completions;已完整则原样;
  • prompt 固定输出「JSON 字符串数组」,长度与顺序必须一致;含 HTML 时明确「只译标签间文本」;
  • 解析容忍 ```json 代码块与前后解释文字,条目数不符即报 bad-response;
  • 错误码:401/403 → auth,429 → quota,网络异常 → network,其它非 2xx → bad-response;
  • 单次超时 30s(AbortSignal.timeout)。

5.2 opencc(本地简繁)

opencc-js 的 Converter({ from: "cn", to: "twp" }),模块级缓存 converter 实例; 仅支持 to === "zh-TW",其它目标语言报 unsupported。

5.3 批量与容错(translation/index.ts)

项取值
单批上限50 条 / 8000 字符
并发2(避免商家 key 被打爆)
重试网络 / 配额 / 响应异常,指数退避 3 次;auth/no-key/unsupported 不重试
术语表翻译前替换成 ⟦G0⟧ 占位符,译后还原,避免模型改写品牌名/型号

6. 翻译流程与幂等

  1. 读 StoreTranslationConfig;未启用 → 直接报错(不写任何译文行);
  2. 按 entityType 分组取源文(TRANSLATABLE_FIELDS 定义每类实体参与翻译的字段);
  3. 逐条算 sourceHash:
    • 已有 status=done 且 hash 相同 → 跳过(省钱核心);
    • 已有 edited=true → 跳过(人工修订优先);
    • 其余进入待翻队列;
  4. 按 format(text / html)分组,按语言串行调用 provider;
  5. 逐条写入:成功 done,失败 failed + errorMessage(不抛错中断整批);
  6. 仅 requireKey 的 provider 累计字符数到 monthlyCharUsed(跨月自动清零)。

触发方式:

  • manual:列表页批量「翻译」/ 编辑页「翻译本页」;
  • onSave:内容保存成功后 fire-and-forget 调用(必须 try/catch,失败只记日志)。

7. API(backoffice)

方法路径说明
GET/translation/config读配置;apiKey 只回 hasApiKey 布尔值
POST/translation/config保存;apiKey 不传=不变,__clear__=清空
POST/translation/config/test测试连接(可用未保存的临时配置)
POST/translation/translate{ entityType, entityIds[], langs? },单批 ≤ 50
GET/translation/status?entityType=&entityIds=a,b → 每实体每语言的状态
GET/translation/items/:entityType/:entityId某实体全部译文(含失败原因)
POST/translation/items/update/:id人工修订(置 edited=true)
POST/translation/items/delete/:id删除译文(下次强制重译)

路径定义在 src/backoffice/shared/paths.ts 的 apiPaths.translation.*,前端不得硬编码。

8. 后台 UI

  1. 店铺设置 → 翻译服务(pagePaths.store.translation → public/pages/store/translation.tsx)
    • provider、endpoint、apiKey(密码框,已配置时只显示占位)、model
    • 源语言、目标语言多选、触发方式(手动 / 保存自动)、术语表(键值对)
    • 「测试连接」+ 本月已用字符数
  2. 列表页:DataList 批量操作加「翻译」,行内显示「已翻译 / 部分 / 失败」状态标签
  3. 编辑页:语言 tab 展示译文,可人工修订(走 items/update)

9. 前台消费与回退

内容注入点
商品 / 分类data-loaders.ts 的 productDataSet / categoryDataSet 返回前批量取译文,覆盖 name / description / shortDescription
博客前台文章列表 / 详情取数处覆盖 title / summary / content / keywords / seoDescription
页面区块见 §10

读取用 getEntityTranslations(ctx, entityType, lang, ids) → Map<entityId, Record<field, value>>,只取 status=done。

回退链:当前语言译文 → 店铺默认语言 → 原文。任何翻译链路异常只降级为原文,不能 500。

10. 页面区块文案(待实现)

页面组件树存在 page-files/store/**.yaml(已发布快照),StorePage / PageRecord 只存元数据与编辑态内容。

推荐:渲染期替换(不改 yaml)

  • entityType = "StorePage",entityId = pagePath,field = JSON Pointer(/props/children/1/props/title);
  • 在页面数据加载完成、渲染前遍历组件树,把 props 下的文案字符串按 pointer 替换;
  • 需要翻译的键:props 下值为字符串且不在白名单(className / href / src / url / id / key / type / 颜色 / 尺寸)中的节点;
  • 优点:不污染已发布快照,区块改版重发布不会冲掉译文;
  • 代价:渲染期多一次按 pagePath + lang 的批量查询(可缓存)。

备选(不推荐):发布期生成 page-files/store-en/** 多语言副本 —— 文件翻倍、发布链路改两处、文案更新需全量重翻。

11. 实施状态

已完成(后端骨架,未联调)

文件内容
src/common/data/mall/entities.tsStore.languages、StoreTranslationConfig、EntityTranslation
src/common/data/mall/mappings.ts新列 + 两张新表(运行时映射)
src/backoffice/database/mappings.ts同上(backoffice 侧映射副本)
src/common/data/mall/mall-data-context.tsstoreTranslationConfigs
src/common/data/mall/store-data-context.tsentityTranslations
src/common/translation/types.tsprovider 接口 + TranslationError
src/common/translation/providers/openai.tsOpenAI 兼容 provider
src/common/translation/providers/opencc.ts本地简繁 provider
src/common/translation/index.ts工厂、分批、并发、重试、术语表保护
src/common/translation/service.ts源文采集、hash 幂等、写入、前台读取
src/backoffice/controllers/translation.controller.ts配置 / 测试 / 翻译 / 状态 / 修订 接口
src/backoffice/shared/paths.tsapiPaths.translation.*、pagePaths.store.translation
package.json新增依赖 opencc-js(用 --legacy-peer-deps 安装,项目存在 antd v6 与 pro-components 的既有 peer 冲突)

尚未验证:服务未重启、未执行表结构同步(ensureSchemaSynced 会在启动/开通流程里增量建表), 接口与页面均未联调。

未完成

  1. 前端 services/translation-service.ts、pages/store/translation.tsx、菜单入口、strings.translation.* 三语文案;
  2. 商品 / 分类列表的「翻译」按钮 + trigger=onSave 保存后自动翻译;
  3. 前台 data-loaders.ts 按 ?lang= 覆盖译文;
  4. 博客(文章 / 分类 / 标签)接入;
  5. 页面区块(§10)渲染期替换 + 设计器「翻译本页」按钮;
  6. 译文人工修订 UI。

接入下一实体时需要改的地方

  1. TRANSLATABLE_FIELDS(service.ts)加字段表;
  2. loadEntities()(同文件)加一个 case;
  3. 列表页加翻译按钮、前台取数处覆盖字段;
  4. 页面区块另走 §10 的 JSON Pointer 方案。

12. 风险与注意事项

  1. KEY_MATERIAL:src/common/security/api-key.ts 目前是常量 + 环境变量兜底 (loadConfig().apiSecretKey,由环境变量 apiSecretKey 注入,缺省内置占位串)。生产必须注入环境变量并固定下来, 否则换机器/多环境会解不开已存密文。
  2. 数据出境:内容会发送给商家选择的三方服务,配置页需明确提示。
  3. 商家 key 保护:用量统计仅用于可见性;如需硬性额度,可加 monthlyCharLimit 并在超限时拒绝翻译。
  4. HTML 保护:format=html 依赖模型遵守 prompt;对富文本建议上线前抽样检查标签完整性。
  5. onSave 放大成本:频繁编辑会反复触发翻译(hash 未变则跳过,但仍有一次查库), 默认建议 manual。
  6. 并发:当前为请求内同步执行(≤50 条)。若要支持大批量,需要引入后台任务队列 (参考 src/backoffice/jobs/member-stat-job.ts 的启动方式)。