状态:方案定稿,后端骨架已落地,前端与各实体接入未完成(详见 §11 实施状态) 适用范围:商品、商品分类、博客(文章/分类/标签)、店铺页面区块文案 语言集:
zh-CN(源,店铺默认语言) /zh-TW/en
1. 背景与目标
店铺前台已支持 zh-CN / zh-TW / en 三语(见 src/common/shared/language.ts 的 SUPPORTED_LANGUAGES),
但只覆盖 UI 词表;商家录入的内容(商品名、分类名、文章、页面区块文案)仍只有一份原文。
目标:
- 商家在店铺设置里配置自己的翻译服务 key(平台不代付、不统一采购);
- 后台可对内容一键翻译(或配置成保存后自动翻译),译文落库缓存;
- 前台按访客语言(
?lang=/store_langcookie)返回译文,缺失时回退原文; - 翻译失败绝不影响内容本身的保存与展示。
不做(本期):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,一店一行)
| 字段 | 类型 | 说明 |
|---|---|---|
storeId | varchar(50) | 所属店铺(唯一,一店一行) |
provider | varchar(30) | openai / opencc |
apiKeyCipher | text | AES-256-GCM 密文(encryptSecret),明文不落库、不回显 |
endpoint | varchar(255) | 自定义 baseUrl(代理 / 自建网关),openai 时必填 |
model | varchar(100) | 模型名(deepseek-chat / qwen-plus …) |
sourceLang | varchar(10) | 源语言,缺省取 store.language |
targetLanguages | json | 目标语言数组(SUPPORTED_LANGUAGES 子集) |
glossary | json | 术语表:不翻译的词 → 固定译法 |
trigger | varchar(20) | manual / onSave |
enabled | boolean | 是否启用 |
monthlyCharUsed | integer | 本月已用字符数(商家 key 成本可见性) |
usageMonth | varchar(7) | 用量所属月份(yyyy-MM),跨月清零 |
放平台库的理由:key 属于店铺账号级配置,与 Store 同生命周期,不需要随租户库迁移。
4.2 EntityTranslation(租户库,StoreScopedEntity)
| 字段 | 类型 | 说明 |
|---|---|---|
storeId | varchar(50) | 店铺隔离 |
entityType | varchar(30) | Product / ProductCategory / BlogArticle / BlogCategory / BlogTag / StorePage |
entityId | varchar(64) | 实体主键;页面用 pagePath |
lang | varchar(10) | 目标语言 |
field | varchar(200) | 字段名;页面区块用 JSON Pointer(/props/children/1/props/title) |
value | text | 译文 |
sourceHash | varchar(64) | sha256(源文 + 术语表 + provider + model + 目标语言) |
status | varchar(20) | pending / done / failed |
errorMessage | varchar(500) | 失败原因(key 无效 / 配额超限等) |
edited | boolean | 人工修订过,自动重译不覆盖 |
注: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. 翻译流程与幂等
- 读
StoreTranslationConfig;未启用 → 直接报错(不写任何译文行); - 按
entityType分组取源文(TRANSLATABLE_FIELDS定义每类实体参与翻译的字段); - 逐条算
sourceHash:- 已有
status=done且 hash 相同 → 跳过(省钱核心); - 已有
edited=true→ 跳过(人工修订优先); - 其余进入待翻队列;
- 已有
- 按
format(text / html)分组,按语言串行调用 provider; - 逐条写入:成功
done,失败failed+errorMessage(不抛错中断整批); - 仅
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
- 店铺设置 → 翻译服务(
pagePaths.store.translation→public/pages/store/translation.tsx)- provider、endpoint、apiKey(密码框,已配置时只显示占位)、model
- 源语言、目标语言多选、触发方式(手动 / 保存自动)、术语表(键值对)
- 「测试连接」+ 本月已用字符数
- 列表页:
DataList批量操作加「翻译」,行内显示「已翻译 / 部分 / 失败」状态标签 - 编辑页:语言 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.ts | Store.languages、StoreTranslationConfig、EntityTranslation |
src/common/data/mall/mappings.ts | 新列 + 两张新表(运行时映射) |
src/backoffice/database/mappings.ts | 同上(backoffice 侧映射副本) |
src/common/data/mall/mall-data-context.ts | storeTranslationConfigs |
src/common/data/mall/store-data-context.ts | entityTranslations |
src/common/translation/types.ts | provider 接口 + TranslationError |
src/common/translation/providers/openai.ts | OpenAI 兼容 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.ts | apiPaths.translation.*、pagePaths.store.translation |
package.json | 新增依赖 opencc-js(用 --legacy-peer-deps 安装,项目存在 antd v6 与 pro-components 的既有 peer 冲突) |
尚未验证:服务未重启、未执行表结构同步(ensureSchemaSynced 会在启动/开通流程里增量建表),
接口与页面均未联调。
未完成
- 前端
services/translation-service.ts、pages/store/translation.tsx、菜单入口、strings.translation.*三语文案; - 商品 / 分类列表的「翻译」按钮 +
trigger=onSave保存后自动翻译; - 前台
data-loaders.ts按?lang=覆盖译文; - 博客(文章 / 分类 / 标签)接入;
- 页面区块(§10)渲染期替换 + 设计器「翻译本页」按钮;
- 译文人工修订 UI。
接入下一实体时需要改的地方
TRANSLATABLE_FIELDS(service.ts)加字段表;loadEntities()(同文件)加一个case;- 列表页加翻译按钮、前台取数处覆盖字段;
- 页面区块另走 §10 的 JSON Pointer 方案。
12. 风险与注意事项
KEY_MATERIAL:src/common/security/api-key.ts目前是常量 + 环境变量兜底 (loadConfig().apiSecretKey,由环境变量apiSecretKey注入,缺省内置占位串)。生产必须注入环境变量并固定下来, 否则换机器/多环境会解不开已存密文。- 数据出境:内容会发送给商家选择的三方服务,配置页需明确提示。
- 商家 key 保护:用量统计仅用于可见性;如需硬性额度,可加
monthlyCharLimit并在超限时拒绝翻译。 - HTML 保护:
format=html依赖模型遵守 prompt;对富文本建议上线前抽样检查标签完整性。 onSave放大成本:频繁编辑会反复触发翻译(hash 未变则跳过,但仍有一次查库), 默认建议manual。- 并发:当前为请求内同步执行(≤50 条)。若要支持大批量,需要引入后台任务队列
(参考
src/backoffice/jobs/member-stat-job.ts的启动方式)。