From 32db276e68a8a458254b809ec83c01f33a16a97b Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Mon, 29 Jun 2026 17:47:24 +0800 Subject: [PATCH] docs: add remote-only i18n solution docs and reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增远端完全替代本地语言包的国际化方案文档,并在原指南中添加对应引用,补充新文档到文档列表中。 --- docs/i18n-guidelines.md | 3 + docs/i18n-remote-only-solution.md | 1370 +++++++++++++++++++++++++++++ 2 files changed, 1373 insertions(+) create mode 100644 docs/i18n-remote-only-solution.md diff --git a/docs/i18n-guidelines.md b/docs/i18n-guidelines.md index 4d3d42a..4696990 100644 --- a/docs/i18n-guidelines.md +++ b/docs/i18n-guidelines.md @@ -297,6 +297,8 @@ $lang('hello', { name: 'Tom' }); // 你好,{name} → 你好,Tom - 拉取失败时不抛错,静默使用本地兜底,并触发 `lang:fallback` 事件。 - 切换语言/租户时自动清理对应缓存并重新拉取。 +> 扩展方案:若希望远端语言包完全替代本地语言包(即删除 `lang/` 目录后仍能正常运行),参见 `docs/i18n-remote-only-solution.md`。 + ### 6.3 覆写数据格式 后端返回的 `overrides` 是一个扁平 map,key 为点分路径,value 为字符串: @@ -391,6 +393,7 @@ npm run i18n:check | `scripts/verify-p2-keys.js` | 动态 key 枚举验证 | | `docs/i18n-audit-report.md` | 全量审计报告 | | `docs/i18n-p2-solution-plan.md` | P2 动态 key 处理方案 | +| `docs/i18n-remote-only-solution.md` | 远端完全替代本地语言包方案 | | `docs/i18n-guidelines.md` | 本说明文档 | --- diff --git a/docs/i18n-remote-only-solution.md b/docs/i18n-remote-only-solution.md new file mode 100644 index 0000000..babc9d2 --- /dev/null +++ b/docs/i18n-remote-only-solution.md @@ -0,0 +1,1370 @@ +# 远端覆写完全替代本地语言包方案(融合版) + +> 目标:让远端语言包成为唯一数据源,本地 `lang/` 目录可以删除,且运行效果与现在一致。 +> 核心思想:**前端 `lang/` 文件作为远端基础数据源,数据库覆写作为增量层,后端合并后返回统一全量 bundle;客户端只认一个接口、一种格式。** + +--- + +## 一、为什么需要远端优先 + +当前方案是 **本地语言包 + 远端覆写** 的混合模式: + +- 本地 `lang/zh-cn/`、`lang/en-us/` 是主数据源; +- 远端只返回 `overrides` map,用于运营 Hotfix 或多租户定制; +- 若删除本地语言包,所有 `import('lang/xx/xxx.js')` 会失败,页面直接显示空串或 raw key。 + +要满足“删除本地语言包后效果一致”,必须让远端承担主数据源角色,本地只做**启动首屏兜底缓存**。 + +--- + +## 二、融合架构设计 + +不再区分“方案 A”和“方案 B”,而是把它们合并成一条统一的远端链路: + +```text +┌─────────────────────────────────────────────────────────────┐ +│ 前端 lang/ 目录(基础数据源) │ +│ 部署到服务端,作为默认、完整、可版本化的语言包基线 │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ Admin 后台 / 数据库(覆写层) │ +│ 仅存储需要覆盖或新增的 key │ +│ 支持 locale / platform / tenant 维度 │ +└─────────────────────────┬───────────────────────────────────┘ + │ 合并 +┌─────────────────────────▼───────────────────────────────────┐ +│ PHP 后端 API │ +│ /api/lang/bundle 读取 lang 文件 + 合并数据库覆写 │ +│ /api/lang/diff 返回 base 版本到当前版本的差异 │ +└─────────────────────────┬───────────────────────────────────┘ + │ HTTPS / CDN +┌─────────────────────────▼───────────────────────────────────┐ +│ 客户端 │ +│ 1. 启动时优先读本地 storage 缓存 │ +│ 2. 异步拉取远端 bundle / diff │ +│ 3. 更新缓存并刷新全局语言上下文 │ +│ 4. $lang() 只读内存中的合并语言树 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.1 两层数据源 + +| 层级 | 来源 | 作用 | 是否必须 | +|---|---|---|---| +| Base | 服务端部署的前端 `lang/` 文件 | 提供完整默认语言包 | 必须 | +| Override | 数据库/配置文件 | 覆盖或补充特定 key | 可选,按需启用 | + +### 2.2 合并规则 + +后端每次响应 `/api/lang/bundle` 时: + +1. 读取 `lang//` 下所有 `.js` 文件,归一化并合并成 **base tree**; +2. 从数据库读取当前 `locale + platform + tenant` 的覆写记录,展开成 **override tree**; +3. 用深度合并把 `override tree` 覆盖到 `base tree` 上; +4. 返回合并后的全量 tree。 + +这样: + +- 没有覆写时,返回的就是前端语言包本身; +- 有覆写时,只改变被覆写的 key,其余保持前端文件内容; +- 客户端无需关心某个 key 来自文件还是数据库。 + +--- + +## 三、远端数据格式 + +统一返回层级树,客户端直接使用,无需展开: + +```json +{ + "code": 0, + "data": { + "version": "1751181600", + "locale": "zh-cn", + "platform": "h5", + "tenant": "default", + "source": "merged", + "tree": { + "common": { + "submit": "提交", + "cancel": "取消" + }, + "components": { + "payment": { + "alipay": "支付宝支付", + "wechatpay": "微信支付" + } + }, + "componentsDiy": { + "diyArticle": { + "viewMore": "查看更多" + } + }, + "order": { + "list": { + "title": "我的订单", + "status": { + "waitpay": "待付款" + } + } + } + } + } +} +``` + +--- + +## 四、后端 API 设计 + +### 4.1 全量 bundle + +```http +GET /api/lang/bundle?locale=zh-cn&platform=h5&tenant=default +``` + +响应字段: + +- `version`:bundle 版本,建议用 base 文件最后修改时间或自定义版本号; +- `locale` / `platform` / `tenant`:请求维度; +- `tree`:合并后的完整语言树。 + +### 4.2 增量 diff + +```http +GET /api/lang/diff?locale=zh-cn&platform=h5&tenant=default&from_version=120 +``` + +返回自 `from_version` 以来的变更: + +```json +{ + "code": 0, + "data": { + "version": "1751181600", + "changes": { + "common.submit": "提交", + "order.list.title": null + } + } +} +``` + +> diff 实现可简可繁:初期可以直接返回全量 `tree` 让客户端覆盖;后期再实现 key 级 diff。 + +### 4.3 CDN 与缓存策略 + +bundle 接口应支持 CDN 缓存和客户端缓存: + +- **服务端响应头**: + - `Cache-Control: public, max-age=300`(5 分钟短缓存,保证更新及时性); + - `ETag: `; + - `Content-Encoding: gzip/br`(bundle 通常 50~200KB,压缩后显著减小)。 +- **CDN 策略**:按 URL(含 locale / platform / tenant / version)缓存,版本变化自动失效; +- **客户端缓存**:storage 缓存 bundle,key 带 version; +- **缓存优先级**:内存 > storage > CDN > 源站。 + +### 4.4 安全与访问控制 + +- `/api/lang/bundle` 可以设为**公开接口**(语言包不涉及敏感业务数据,且 App 启动前用户可能未登录); +- 若业务需要保护 tenant 级文案,可在请求头中携带匿名 token 或签名; +- 必须对 `locale`、`platform`、`tenant` 做白名单校验,防止路径遍历或非法查询; +- 后端永远不要信任客户端传来的 `version`,diff 接口只做幂等返回。 + +### 4.5 签名与完整性校验 + +`/api/lang/bundle` 虽可公开访问,但仍需防止 CDN 污染或中间人篡改。推荐在响应中加入签名字段: + +```json +{ + "code": 0, + "data": { + "version": "1751181600", + "tree": { ... }, + "sign": "sha256=xxxxxxxx" + } +} +``` + +PHP 生成签名: + +```php +private function sign($tree, $version, $secret) +{ + $payload = json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return hash_hmac('sha256', $version . $payload, $secret); +} +``` + +客户端在收到 bundle 后校验签名,校验失败则视为异常并使用缓存或内嵌兜底。签名密钥从配置中心下发或打包时内嵌,避免硬编码在仓库。 + +### 4.6 版本控制策略 + +不建议用文件 `mtime` 作为生产版本号,原因: + +- 不同服务器部署时间不一致会导致版本号不一致; +- CI/CD 并行部署时难以保证原子性。 + +推荐方案: + +| 方案 | 实现 | 适用场景 | +|---|---|---| +| 构建生成 version 文件 | CI 打包时写入 `lang/.version`,PHP 读取该文件 | 推荐,稳定可控 | +| Git commit hash | 用当前 commit 短 hash 作为版本 | 适合 DevOps 成熟团队 | +| 内容 hash | 对合并后的 tree 做 MD5/SHA1 | 最精确,但计算成本稍高 | + +示例:CI 生成 `lang/.version` + +```bash +# deploy.sh +echo "$(date +%s)-$(git rev-parse --short HEAD)" > public/static/lang/.version +``` + +PHP 读取: + +```php +private function getBundleVersion($locale) +{ + $versionFile = $this->basePath . '/' . $locale . '/.version'; + if (file_exists($versionFile)) { + return trim(file_get_contents($versionFile)); + } + return 'unknown'; +} +``` + +--- + +## 五、构建与部署流程 + +### 5.1 前端构建时同步 lang 文件 + +在 `package.json` 中增加构建脚本: + +```json +{ + "scripts": { + "build:h5": "npm run sync:lang && uni build", + "sync:lang": "node scripts/sync-lang-to-server.js" + } +} +``` + +`scripts/sync-lang-to-server.js` 示例: + +```js +const fs = require('fs'); +const path = require('path'); + +const SRC = path.resolve(__dirname, '../lang'); +const DEST = path.resolve(__dirname, '../../server/public/static/lang'); + +function copyDir(src, dest) { + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +copyDir(SRC, DEST); + +// 生成 version 文件 +const version = `${Date.now()}-${process.env.GIT_COMMIT || 'dev'}`; +for (const locale of ['zh-cn', 'en-us']) { + fs.writeFileSync(path.join(DEST, locale, '.version'), version); +} + +console.log('lang files synced to server'); +``` + +### 5.2 部署原子性 + +语言包文件必须**全量替换**,不能部分更新: + +- 使用软链/目录切换:先部署到新目录 `lang.new`,再原子切换软链 `lang -> lang.new`; +- 或让 PHP 读取带版本号的目录 `lang/1751181600/`,版本变化时切换配置。 + +### 5.3 多环境管理 + +| 环境 | lang 来源 | 是否启用覆写 | +|---|---|---| +| 本地开发 | 本地 `lang/` 文件(`source: 'local'`) | 否 | +| 测试环境 | 远端 bundle(`source: 'remote-only'`) | 可启用 | +| 预发环境 | 远端 bundle | 仅内部租户覆写 | +| 生产环境 | 远端 bundle + CDN | 启用 | + +--- + +## 六、PHP 实现示例 + +### 6.1 数据表设计(覆写层) + +```sql +CREATE TABLE lang_overrides ( + id INT AUTO_INCREMENT PRIMARY KEY, + locale VARCHAR(10) NOT NULL DEFAULT 'zh-cn', + platform VARCHAR(20) NOT NULL DEFAULT '', + tenant VARCHAR(50) NOT NULL DEFAULT '', + namespace VARCHAR(255) NOT NULL, -- 点分 key,如 common.submit + value TEXT, -- NULL 表示删除 + is_enabled TINYINT NOT NULL DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_key (locale, platform, tenant, namespace) +); +``` + +```sql +CREATE TABLE lang_bundle_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + locale VARCHAR(10) NOT NULL DEFAULT 'zh-cn', + platform VARCHAR(20) NOT NULL DEFAULT '', + tenant VARCHAR(50) NOT NULL DEFAULT '', + version VARCHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_version (locale, platform, tenant) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 6.2 Bundle 控制器 + +```php +basePath = __DIR__ . '/../../public/static/lang'; + } + + /** + * GET /api/lang/bundle?locale=zh-cn&platform=h5&tenant=default + */ + public function bundle() + { + $locale = $_GET['locale'] ?? 'zh-cn'; + $platform = $_GET['platform'] ?? ''; + $tenant = $_GET['tenant'] ?? ''; + + if (!in_array($locale, ['zh-cn', 'en-us'])) { + return $this->json(['code' => 400, 'message' => 'invalid locale']); + } + + $localePath = $this->basePath . '/' . $locale; + if (!is_dir($localePath)) { + return $this->json(['code' => 404, 'message' => 'locale not found']); + } + + // 1. 读取前端 lang 文件作为 base + $baseTree = $this->loadLangDir($localePath); + + // 2. 读取数据库覆写层 + $overrideTree = $this->loadOverrides($locale, $platform, $tenant); + + // 3. 深度合并:override 覆盖 base + $mergedTree = $this->deepMerge($baseTree, $overrideTree); + + // 4. 按平台裁剪(可选) + $mergedTree = $this->filterByPlatform($mergedTree, $platform); + + $this->setCacheHeaders($locale); + + return $this->json([ + 'code' => 0, + 'data' => [ + 'version' => $this->getBundleVersion($locale), + 'locale' => $locale, + 'platform' => $platform, + 'tenant' => $tenant, + 'source' => 'merged', + 'tree' => $mergedTree, + ] + ]); + } + + private function setCacheHeaders($locale) + { + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: public, max-age=300'); + header('ETag: "' . $this->getBundleVersion($locale) . '"'); + } + + /** + * 递归读取 lang 目录,归一化命名空间 + */ + private function loadLangDir($dir) + { + $tree = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir) + ); + + foreach ($iterator as $file) { + if ($file->isDir() || $file->getExtension() !== 'js') { + continue; + } + + $relativePath = str_replace($dir . DIRECTORY_SEPARATOR, '', $file->getPathname()); + $relativePath = str_replace('\\', '/', $relativePath); + $relativePath = preg_replace('/\.js$/', '', $relativePath); + + // 跳过 version 文件 + if ($relativePath === '.version') continue; + + $content = file_get_contents($file->getPathname()); + $lang = $this->parseLangExport($content); + if ($lang === null) continue; + + $namespace = $this->toNamespace($relativePath); + $this->setNestedValue($tree, $namespace, $lang); + } + + return $tree; + } + + /** + * 解析 export const lang = { ... } + */ + private function parseLangExport($content) + { + if (!preg_match('/export\s+const\s+lang\s*=\s*([\s\S]*?);?\s*$/m', $content, $matches)) { + return null; + } + $json = $matches[1]; + + // 简单清理 JS 语法为合法 JSON + $json = preg_replace('/([\{\,])\s*\/\/[^\n]*/', '$1', $json); + $json = preg_replace('/\/\*[\s\S]*?\*\//', '', $json); + $json = str_replace("'", '"', $json); + $json = preg_replace('/,\s*([\}\]])/', '$1', $json); + + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : null; + } + + private function toNamespace($relativePath) + { + $segments = explode('/', $relativePath); + return array_map([$this, 'toCamelCase'], $segments); + } + + private function toCamelCase($name) + { + return preg_replace_callback('/[-_]([a-z])/', function ($m) { + return strtoupper($m[1]); + }, $name); + } + + private function setNestedValue(&$tree, $path, $value) + { + $cursor = &$tree; + foreach ($path as $key) { + if (!isset($cursor[$key]) || !is_array($cursor[$key])) { + $cursor[$key] = []; + } + $cursor = &$cursor[$key]; + } + $cursor = $this->deepMerge($cursor, $value); + } + + /** + * 从数据库读取覆写层,并展开为层级树 + */ + private function loadOverrides($locale, $platform, $tenant) + { + $tree = []; + + // 按优先级升序查询,后覆盖先 + $rows = DB::table('lang_overrides') + ->where('locale', $locale) + ->where('is_enabled', 1) + ->where(function ($q) use ($platform, $tenant) { + $q->where(function ($q2) { // global + $q2->where('tenant', '')->where('platform', ''); + }) + ->orWhere(function ($q2) use ($platform) { // platform only + $q2->where('tenant', '')->where('platform', $platform); + }) + ->orWhere(function ($q2) use ($tenant) { // tenant only + $q2->where('tenant', $tenant)->where('platform', ''); + }) + ->orWhere(function ($q2) use ($platform, $tenant) { // tenant + platform + $q2->where('tenant', $tenant)->where('platform', $platform); + }); + }) + ->orderByRaw(" + CASE + WHEN tenant = '' AND platform = '' THEN 1 + WHEN tenant = '' AND platform != '' THEN 2 + WHEN tenant != '' AND platform = '' THEN 3 + ELSE 4 + END ASC + ") + ->get(); + + foreach ($rows as $row) { + $path = explode('.', $row->namespace); + if ($row->value === null) { + $this->deleteNestedValue($tree, $path); + } else { + $this->setNestedValue($tree, $path, $row->value); + } + } + + return $tree; + } + + /** + * 可选:按平台裁剪不需要的 key + */ + private function filterByPlatform($tree, $platform) + { + // 示例:若某 key 仅在 mp-weixin 使用,可在此过滤 + // 当前项目暂不需要,留作扩展 + return $tree; + } + + private function deepMerge($target, $source) + { + if (!is_array($target)) $target = []; + if (!is_array($source)) return $source; + + foreach ($source as $key => $value) { + if (isset($target[$key]) && is_array($target[$key]) && is_array($value)) { + $target[$key] = $this->deepMerge($target[$key], $value); + } else { + $target[$key] = $value; + } + } + return $target; + } + + private function deleteNestedValue(&$tree, $path) + { + $cursor = &$tree; + foreach ($path as $i => $key) { + if (!isset($cursor[$key])) return; + if ($i === count($path) - 1) { + unset($cursor[$key]); + } else { + $cursor = &$cursor[$key]; + } + } + } + + private function getBundleVersion($locale) + { + $versionFile = $this->basePath . '/' . $locale . '/.version'; + if (file_exists($versionFile)) { + return trim(file_get_contents($versionFile)); + } + return 'unknown'; + } + + private function json($data) + { + echo json_encode($data); + } +} +``` + +### 6.3 无数据库时的最小实现 + +如果暂时不需要 Admin 覆写,可以把 `loadOverrides` 直接返回空数组,整个系统仍然可用。等需要运营 Hotfix 时再启用数据库表。 + +### 6.4 Admin 覆写接口设计 + +后台对覆写层的增删改查接口: + +```php +class LangOverrideController +{ + /** + * POST /api/admin/lang/overrides + */ + public function store() + { + $locale = $_POST['locale'] ?? 'zh-cn'; + $platform = $_POST['platform'] ?? ''; + $tenant = $_POST['tenant'] ?? ''; + $namespace = $_POST['namespace'] ?? ''; + $value = $_POST['value'] ?? null; + + // 白名单校验 + if (!in_array($locale, ['zh-cn', 'en-us'])) { + return $this->json(['code' => 400, 'message' => 'invalid locale']); + } + if (!$this->isValidNamespace($namespace)) { + return $this->json(['code' => 400, 'message' => 'invalid namespace']); + } + + DB::table('lang_overrides') + ->updateOrInsert( + ['locale' => $locale, 'platform' => $platform, 'tenant' => $tenant, 'namespace' => $namespace], + ['value' => $value, 'is_enabled' => 1, 'updated_at' => date('Y-m-d H:i:s')] + ); + + // 更新版本号,触发客户端更新 + $this->bumpVersion($locale, $platform, $tenant); + + return $this->json(['code' => 0, 'message' => 'ok']); + } + + /** + * DELETE /api/admin/lang/overrides + */ + public function destroy() + { + $id = $_GET['id'] ?? 0; + DB::table('lang_overrides')->where('id', $id)->delete(); + return $this->json(['code' => 0, 'message' => 'ok']); + } + + /** + * GET /api/admin/lang/overrides + */ + public function index() + { + $locale = $_GET['locale'] ?? 'zh-cn'; + $platform = $_GET['platform'] ?? ''; + $tenant = $_GET['tenant'] ?? ''; + + $rows = DB::table('lang_overrides') + ->where('locale', $locale) + ->where(function ($q) use ($platform) { + if ($platform) $q->where('platform', $platform); + }) + ->where(function ($q) use ($tenant) { + if ($tenant) $q->where('tenant', $tenant); + }) + ->orderBy('updated_at', 'desc') + ->get(); + + return $this->json(['code' => 0, 'data' => $rows]); + } + + private function bumpVersion($locale, $platform, $tenant) + { + DB::table('lang_bundle_versions') + ->updateOrInsert( + ['locale' => $locale, 'platform' => $platform, 'tenant' => $tenant], + ['version' => time(), 'updated_at' => date('Y-m-d H:i:s')] + ); + } +} +``` + +### 6.5 diff 算法实现 + +`/api/lang/diff` 用于增量更新。初期可直接返回全量 tree,后期实现 key 级 diff: + +```php +public function diff() +{ + $locale = $_GET['locale'] ?? 'zh-cn'; + $platform = $_GET['platform'] ?? ''; + $tenant = $_GET['tenant'] ?? ''; + $fromVersion = $_GET['from_version'] ?? ''; + + // 获取当前版本 + $currentVersion = $this->getCurrentVersion($locale, $platform, $tenant); + + // 如果版本相同,返回空 diff + if ($fromVersion === $currentVersion) { + return $this->json(['code' => 0, 'data' => ['version' => $currentVersion, 'changes' => []]]); + } + + // 初期:直接返回全量 tree 作为 changes + $bundle = $this->buildBundle($locale, $platform, $tenant); + $changes = $this->flattenTree($bundle['tree']); + + return $this->json([ + 'code' => 0, + 'data' => [ + 'version' => $currentVersion, + 'changes' => $changes, + ] + ]); +} + +/** + * 把层级树展开为点分 key map + */ +private function flattenTree($tree, $prefix = '') +{ + $result = []; + foreach ($tree as $key => $value) { + $path = $prefix ? $prefix . '.' . $key : $key; + if (is_array($value)) { + $result = array_merge($result, $this->flattenTree($value, $path)); + } else { + $result[$path] = $value; + } + } + return $result; +} + +/** + * 后期:基于版本快照实现真 diff + */ +private function diffAgainstSnapshot($locale, $platform, $tenant, $fromVersion) +{ + $oldTree = $this->loadSnapshot($locale, $platform, $tenant, $fromVersion); + $newTree = $this->buildBundle($locale, $platform, $tenant)['tree']; + + $oldFlat = $this->flattenTree($oldTree); + $newFlat = $this->flattenTree($newTree); + + $changes = []; + foreach ($newFlat as $key => $value) { + if (!isset($oldFlat[$key]) || $oldFlat[$key] !== $value) { + $changes[$key] = $value; + } + } + foreach ($oldFlat as $key => $value) { + if (!isset($newFlat[$key])) { + $changes[$key] = null; + } + } + + return $changes; +} +``` + +--- + +## 七、客户端改造方案 + +### 7.1 配置项扩展 + +```js +{ + source: 'remote', // 'local' | 'remote' | 'hybrid' | 'remote-only' + defaultLang: 'zh-cn', + langList: ['zh-cn', 'en-us'], + + remote: { + bundleUrl: '/api/lang/bundle', + diffUrl: '/api/lang/diff', // 可选,无 diff 时走全量 bundle + + storagePrefix: 'lang_', + bundleStorageKey: 'lang_bundle', + versionStorageKey: 'lang_version', + + requestTimeout: 8000, + bootTimeout: 3000, + allowStaleCache: true, + cacheMaxAge: 7 * 24 * 3600 * 1000, + } +} +``` + +### 7.2 新增 `common/js/lang-loader.js` + +```js +// common/js/lang-loader.js +import config from './config-external.js'; + +const STORAGE_KEYS = { + bundle: `${config.remote.storagePrefix}bundle`, + version: `${config.remote.storagePrefix}version`, +}; + +function readCache() { + try { + const bundleStr = uni.getStorageSync(STORAGE_KEYS.bundle); + const version = uni.getStorageSync(STORAGE_KEYS.version); + if (!bundleStr) return null; + + const parsed = JSON.parse(bundleStr); + if (config.remote.cacheMaxAge && parsed.timestamp) { + const age = Date.now() - parsed.timestamp; + if (age > config.remote.cacheMaxAge && !config.remote.allowStaleCache) { + return null; + } + } + return { version, data: parsed.data }; + } catch (e) { + return null; + } +} + +function writeCache(version, data) { + try { + uni.setStorageSync(STORAGE_KEYS.bundle, JSON.stringify({ + data, + timestamp: Date.now(), + })); + uni.setStorageSync(STORAGE_KEYS.version, version); + } catch (e) { + console.warn('[i18n] cache write failed', e); + } +} + +async function fetchBundle(locale, platform, tenant) { + const url = `${config.remote.bundleUrl}?locale=${locale}&platform=${platform}&tenant=${tenant}`; + return new Promise((resolve, reject) => { + uni.request({ + url, + method: 'GET', + timeout: config.remote.requestTimeout, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 0) { + const payload = res.data.data; + resolve({ + version: payload.version, + data: payload.tree, + }); + } else { + reject(new Error('bundle fetch failed')); + } + }, + fail: reject, + }); + }); +} + +async function fetchDiff(locale, platform, tenant, fromVersion) { + if (!config.remote.diffUrl) return null; + const url = `${config.remote.diffUrl}?locale=${locale}&platform=${platform}&tenant=${tenant}&from_version=${fromVersion}`; + return new Promise((resolve, reject) => { + uni.request({ + url, + method: 'GET', + timeout: config.remote.requestTimeout, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 0) { + resolve(res.data.data); + } else { + reject(new Error('diff fetch failed')); + } + }, + fail: reject, + }); + }); +} + +function applyDiff(tree, diff) { + const result = JSON.parse(JSON.stringify(tree)); + for (const [key, value] of Object.entries(diff)) { + const parts = key.split('.'); + let cursor = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!cursor[part] || typeof cursor[part] !== 'object') { + cursor[part] = {}; + } + cursor = cursor[part]; + } + if (value === null) { + delete cursor[parts[parts.length - 1]]; + } else { + cursor[parts[parts.length - 1]] = value; + } + } + return result; +} + +export async function loadLanguageTree(locale, platform, tenant) { + const cache = readCache(); + + if (!cache) { + const bundle = await fetchBundle(locale, platform, tenant); + writeCache(bundle.version, bundle.data); + return bundle.data; + } + + // 有缓存:先返回缓存,后台拉取更新 + fetchDiff(locale, platform, tenant, cache.version) + .then(async (diff) => { + if (!diff) { + // 无 diff 接口或 diff 为空,直接拉全量 bundle 对比 version + const bundle = await fetchBundle(locale, platform, tenant); + if (bundle.version !== cache.version) { + writeCache(bundle.version, bundle.data); + uni.$emit('lang:updated', { locale, version: bundle.version }); + } + return; + } + + if (diff.changes) { + const newTree = applyDiff(cache.data, diff.changes); + writeCache(diff.version, newTree); + uni.$emit('lang:updated', { locale, version: diff.version }); + } + }) + .catch(() => { + // 静默失败,继续使用缓存 + }); + + return cache.data; +} +``` + +### 7.3 启动时序与首屏优化 + +remote-only 模式下,启动流程必须保证 `$lang()` 在首屏渲染前可用。推荐在 `App.vue` 的 `onLaunch` 中阻塞式加载 bundle: + +```js +// App.vue +import { loadLanguageTree } from '@/common/js/lang-loader.js'; + +export default { + onLaunch() { + const locale = uni.getStorageSync('lang') || 'zh-cn'; + const platform = this.getPlatform(); + const tenant = this.getTenant(); + + // 阻塞启动,直到 bundle 就绪 + this.$langReady = loadLanguageTree(locale, platform, tenant) + .then(tree => { + uni.$emit('lang:ready', { locale, tree }); + }) + .catch(err => { + console.error('[i18n] launch bundle failed', err); + }); + }, + + onShow() { + // 等待 bundle 就绪后再放行首屏 + return this.$langReady; + } +}; +``` + +页面级入口同步等待: + +```js +// pages/index/index.vue +export default { + async onLoad() { + await getApp().$langReady; + // 此时 $lang() 已可用 + } +}; +``` + +**首屏时间优化**: + +| 策略 | 说明 | +|---|---| +| 缓存优先启动 | 先读本地 storage 缓存并渲染,后台异步更新 | +| bundle 预加载 | 在启动页或 splash 时并行请求 bundle | +| 最小化首屏 key | 内嵌默认 bundle 仅含 TabBar / 启动页 / 错误提示,避免阻塞 | +| 请求超时控制 | `bootTimeout` 默认 3s,超时后使用缓存或兜底 | +| CDN 边缘缓存 | 5 分钟短缓存 + 版本号变化自动失效 | + +### 7.4 修改 `common/js/lang.js` + +在 `lang(field)` 的合并逻辑前增加 remote-only 分支: + +```js +async function getMergedLang(locale, route) { + if (langConfig.source === 'remote-only') { + const tree = await loadLanguageTree(locale, currentPlatform, currentTenant); + return tree; + } + + // 原有 local / hybrid 逻辑 + const common = await import(`@/lang/${locale}/common.js`); + const shared = await loadSharedPacks(locale); + const page = await loadPagePack(locale, route); + return deepMerge(common, shared, page, remoteOverrides); +} +``` + +### 7.5 离线降级策略 + +完全删除本地语言包后,必须处理以下异常场景: + +| 场景 | 策略 | +|---|---| +| 有缓存且远端失败 | 继续使用缓存,记录 warning | +| 无缓存且远端失败 | 显示错误页/重试按钮,或fallback到内嵌默认 bundle | +| 远端返回非 200 | 使用缓存;无缓存则显示 loading 失败提示 | +| 远端返回空 tree | 视为异常,使用缓存或内嵌 bundle | +| 首次安装弱网 | 启动页等待 bundle,超时时提示用户 | + +**内嵌默认 bundle**: + +在 `common/js/lang-defaults.js` 中内嵌一份最小化的默认语言包(至少包含 TabBar、启动页、错误提示文案): + +```js +// common/js/lang-defaults.js +export const defaultBundle = { + 'zh-cn': { + common: { loading: '加载中...', retry: '重试', networkError: '网络异常' }, + tabBar: { home: '首页', member: '我的' }, + }, + 'en-us': { + common: { loading: 'Loading...', retry: 'Retry', networkError: 'Network error' }, + tabBar: { home: 'Home', member: 'Me' }, + }, +}; +``` + +在 `loadLanguageTree` 中作为最后兜底: + +```js +if (!cache) { + try { + const bundle = await fetchBundle(locale, platform, tenant); + writeCache(bundle.version, bundle.data); + return bundle.data; + } catch (e) { + console.error('[i18n] bundle fetch failed, fallback to embedded defaults'); + return defaultBundle[locale] || defaultBundle['zh-cn']; + } +} +``` + +### 7.6 热更新与页面刷新机制 + +运营在 Admin 修改文案后,客户端不需要重启 App 即可生效。 + +**后台发布时生成版本**: + +```sql +-- 发布覆写时更新 lang_bundle_versions 表 +INSERT INTO lang_bundle_versions (locale, platform, tenant, version, created_at) +VALUES ('zh-cn', 'h5', 'default', UNIX_TIMESTAMP(), NOW()) +ON DUPLICATE KEY UPDATE version = VALUES(version), created_at = VALUES(created_at); +``` + +**客户端轮询/推送更新**: + +1. **定时轮询**:App 回到前台时,调用 `/api/lang/bundle` 对比 `version`; +2. **WebSocket / 长连接推送**:Admin 发布后,服务端向在线客户端推送 `lang:updated` 事件; +3. **页面刷新**:收到更新后,客户端写入缓存并触发全局事件: + +```js +uni.$on('lang:updated', ({ locale, version }) => { + // 通知当前页面刷新 + const pages = getCurrentPages(); + const current = pages[pages.length - 1]; + if (current && current.$vm && current.$vm.$forceUpdate) { + current.$vm.$forceUpdate(); + } + + // 或全局提示用户 + uni.showToast({ title: '语言包已更新', icon: 'none' }); +}); +``` + +**强制刷新策略**: + +- 对于 TabBar、导航标题等全局文案,更新后立即 `$forceUpdate`; +- 对于长列表中的静态文案,允许下一页加载时自然生效; +- 对于支付结果页等一次性页面,不强制刷新,避免用户困惑。 + +### 7.7 灰度发布 + +上线 remote-only 时,不要一次性全量切换: + +1. **白名单灰度**:根据 userId / deviceId / 地区,仅对部分用户启用 `source: 'remote-only'`; +2. **版本灰度**:App 新版本先 5% 启用,观察 24 小时后再扩大; +3. **回滚开关**:后端/配置中心保留 `source` 切换能力,出问题立即切回 `local`; +4. **双写校验**:remote-only 用户同时走本地和远端,对比关键 key 是否一致并上报差异。 + +### 7.8 平台差异适配 + +不同平台的请求方式、缓存能力、包体积限制差异较大,需要分别处理。 + +**微信小程序** + +| 差异点 | 处理方案 | +|---|---| +| 包体积限制 | bundle 不打包进小程序,启动时从远端拉取;内嵌默认 bundle 控制在 5KB 以内 | +| `uni.request` 域名 | bundle 接口域名必须加入小程序 request 合法域名 | +| `uni.getStorageSync` 容量 | 单用户 10MB 足够缓存 bundle;超出时清理旧缓存 | +| 并发限制 | `uni.request` 并发 10 条,bundle 请求优先级最高 | +| 后台切前台 | `onShow` 中检测版本,静默更新 | +| 弱网/无网 | 优先使用缓存,缓存失效时使用内嵌默认 bundle | + +**H5** + +| 差异点 | 处理方案 | +|---|---| +| localStorage | 同源策略下可长期缓存;注意隐身模式不可用 | +| Service Worker | 可缓存 bundle,实现离线访问 | +| CDN 跨域 | bundle 接口需配置 `Access-Control-Allow-Origin` | +| SSR/SEO | SSR 时服务端直接请求 `/api/lang/bundle` 注入 HTML,避免客户端闪烁 | +| 刷新页面 | 每次刷新重新请求 bundle,依赖 304/ETag 减少流量 | + +**App** + +| 差异点 | 处理方案 | +|---|---| +| 原生存储 | 优先使用 `plus.storage`,容量大且稳定 | +| 推送更新 | 可利用 uni-push 推送语言包更新通知 | +| 离线包 | 打包时内置最近一次 bundle,无网时直接使用 | + +--- + +## 八、后台管理系统设计 + +后台只需管理**覆写层**,不需要管理所有 key: + +### 8.1 核心功能 + +1. **Key 搜索与覆写** + - 输入点分 key(如 `order.list.title`); + - 填写覆盖后的 value; + - 选择生效维度(locale / platform / tenant)。 + +2. **从 base 导入 key** + - 定期或手动把前端 `lang/` 文件同步到后台; + - 后台展示所有 base key,方便运营人员选择要覆写的 key。 + +3. **版本与发布** + - 每次发布生成新版本号; + - 支持回滚到上一版本; + - diff 接口根据版本号计算变更。 + +4. **多维度隔离** + - 优先级:`tenant + platform` > `tenant` > `platform` > `global`。 + +### 8.2 为什么只管理覆写层 + +- 默认文案仍由前端 `lang/` 文件维护,开发流程不变; +- 运营只干预需要修改的 key,降低管理复杂度; +- 没有覆写时,系统零性能损耗(override tree 为空)。 + +--- + +## 九、测试与校验 + +### 9.1 一致性校验脚本 + +新增 `scripts/verify-remote-bundle.js`,对比远端 bundle 与本地合并结果: + +```js +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +const BASE_URL = process.env.LANG_BUNDLE_URL || 'http://localhost/api/lang/bundle'; +const LOCAL_LANG_DIR = path.resolve(__dirname, '../lang'); + +async function main() { + const locales = ['zh-cn', 'en-us']; + let hasError = false; + + for (const locale of locales) { + const remoteTree = await fetchRemote(locale); + const localTree = loadLocal(locale); + + const diff = compare(remoteTree, localTree); + if (diff.length) { + console.error(`[${locale}] 不一致 key:`, diff); + hasError = true; + } else { + console.log(`[${locale}] 远端 bundle 与本地一致`); + } + } + + process.exit(hasError ? 1 : 0); +} + +async function fetchRemote(locale) { + return new Promise((resolve, reject) => { + http.get(`${BASE_URL}?locale=${locale}&platform=h5`, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const json = JSON.parse(data); + resolve(json.data.tree); + }); + }).on('error', reject); + }); +} + +function loadLocal(locale) { + // 复用现有 i18n-check.js 中的本地合并逻辑 + // ... +} + +function compare(a, b, path = '') { + const diffs = []; + // 递归比较... + return diffs; +} + +main(); +``` + +### 9.2 CI 集成 + +在 CI 中增加校验步骤: + +```yaml +# .github/workflows/i18n.yml +- name: Sync lang files to test server + run: npm run sync:lang --env=test + +- name: Verify remote bundle + run: LANG_BUNDLE_URL=http://test.example.com/api/lang/bundle node scripts/verify-remote-bundle.js + +- name: Run i18n check + run: npm run i18n:check +``` + +### 9.3 线上巡检 + +定时脚本调用 `/api/lang/bundle`,检查: + +- 返回码是否为 0; +- tree 是否非空; +- 关键 key(如 `common.submit`、`tabBar.home`)是否存在; +- 响应时间是否超过阈值。 + +### 9.4 与现有 i18n-check 的整合 + +项目已有 `npm run i18n:check` 脚本,用于校验 `zh-cn` 与 `en-us` 的 key 一致性。remote-only 模式下,该校验仍然有效,且可以扩展为: + +1. **本地校验**:继续校验 `lang/zh-cn/` 与 `lang/en-us/` 的一致性; +2. **远端校验**:同步到服务端后,拉取 `/api/lang/bundle` 校验远端合并结果是否仍然一致; +3. **覆写层校验**:校验 `lang_overrides` 表中是否存在只覆盖了一个语言版本的 key。 + +扩展脚本 `scripts/verify-remote-bundle.js` 的核心逻辑: + +```js +async function verifyBundleConsistency(localeA, localeB) { + const treeA = await fetchRemote(localeA); + const treeB = await fetchRemote(localeB); + + const flatA = flatten(treeA); + const flatB = flatten(treeB); + + const onlyInA = Object.keys(flatA).filter(k => !(k in flatB)); + const onlyInB = Object.keys(flatB).filter(k => !(k in flatA)); + + if (onlyInA.length || onlyInB.length) { + console.error('远端 bundle 语言不一致:', { onlyInA, onlyInB }); + process.exit(1); + } +} +``` + +在 CI 中增加: + +```yaml +- name: Run local i18n check + run: npm run i18n:check + +- name: Verify remote bundle consistency + run: node scripts/verify-remote-bundle.js +``` + +--- + +## 十、监控与告警 + +### 10.1 客户端埋点 + +| 事件 | 含义 | 告警条件 | +|---|---|---| +| `lang_bundle_success` | 成功拉取 bundle | — | +| `lang_bundle_fail` | 拉取失败 | 连续 N 次失败 | +| `lang_bundle_cache_hit` | 使用缓存启动 | 占比过高可能表示更新不及时 | +| `lang_bundle_duration` | bundle 请求耗时 | P99 > 2s | +| `lang_key_missing` | $lang() 返回空串 | 命中 raw key/空串 | + +### 10.2 服务端监控 + +- `/api/lang/bundle` QPS、错误率、P99 延迟; +- CDN 命中率; +- bundle 文件大小趋势; +- override 表变更频率。 + +--- + +## 十一、迁移步骤 + +### 阶段 1:PHP 后端接入 + +1. 把前端 `lang/zh-cn/`、`lang/en-us/` 目录复制到服务端; +2. 实现 `/api/lang/bundle`,先让 `loadOverrides` 返回空数组; +3. 配置 CDN 和 gzip/br; +4. 验证返回的 tree 与本地合并结果一致。 + +### 阶段 2:客户端改造 + +1. 新增 `common/js/lang-loader.js`; +2. 修改 `common/js/lang.js`,支持 `source: 'remote-only'`; +3. App 启动流程接入 loader; +4. 内嵌默认语言兜底 bundle。 + +### 阶段 3:灰度验证 + +1. 保留本地 `lang/` 目录; +2. 配置 `source: 'remote'` 或 `hybrid`,对比 remote bundle 与本地结果; +3. 运行 `npm run i18n:check` 和 `node scripts/verify-p2-keys.js`; +4. 对白名单用户启用 `source: 'remote-only'`,观察一周; +5. 在测试环境删除本地语言包,验证功能正常。 + +### 阶段 4:正式上线 + +1. 逐步扩大 remote-only 用户比例; +2. 配置 `source: 'remote-only'` 为默认; +3. 构建脚本中移除 `lang/` 目录打包(或保留但不使用); +4. 监控 bundle 接口成功率、首屏加载时间、缓存命中率。 + +### 阶段 5(可选):启用 Admin 覆写 + +1. 创建 `lang_overrides` 表; +2. 实现 Admin 后台,允许运营人员覆写指定 key; +3. 验证覆写后客户端能正确展示。 + +--- + +## 十二、风险与回退 + +| 风险 | 影响 | 缓解措施 | +|---|---|---| +| bundle 接口挂掉 | 新用户无法进入应用 | 内嵌默认语言兜底;启用 CDN;多活部署 | +| bundle 体积过大 | 首屏慢、流量高 | 按平台裁剪;gzip/br;diff 更新 | +| 后台误改 key | 线上文案错误 | 发布前预览;版本回滚;灰度发布 | +| 缓存过期无网 | 老用户打开空白 | `allowStaleCache: true` | +| 多租户 key 冲突 | 文案串租户 | 查询时按 tenant / platform 优先级过滤 | +| 版本号不一致 | CDN/多机返回不同 bundle | 构建生成 `.version` 文件 | +| 本地开发困难 | 后端未启动时无法开发 | 本地环境 `source: 'local'` | +| CDN 缓存污染 | 用户看到旧版或被篡改文案 | 签名校验;URL 带版本号;HTTPS | +| 首屏 bundle 未就绪 | 页面先渲染后闪变 | App.vue 阻塞加载;内嵌首屏兜底 | +| 灰度配置错误 | 全量用户提前进入 remote-only | 配置中心双人复核;白名单逐步放量 | +| 覆写只改单一语言 | zh/en 文案不一致 | CI 远端一致性校验;Admin 强制双语言填写 | +| 小程序域名未配置 | 请求失败 | 上线前检查 request 合法域名 | +| storage 写满 | 缓存失败 | 捕获异常;清理旧缓存;降级内存缓存 | +| diff 接口异常 | 增量更新失败 | 回退全量 bundle;客户端自动重试 | +| 跨平台文案差异 | 某平台显示 raw key | 平台维度覆写;平台白名单校验 | + +回退方案: + +- 紧急情况下,客户端配置切回 `source: 'local'`,恢复使用本地 `lang/` 目录; +- 建议上线初期保留本地语言包,待 remote-only 稳定后再删除。 + +--- + +## 十三、关键结论 + +融合后的方案满足“删除本地语言包后效果一致”的目标: + +1. **前端 `lang/` 文件作为 base**:部署到服务端,提供完整默认语言包; +2. **数据库/Admin 作为 override 层**:只管理需要覆写的 key; +3. **后端合并后返回统一 tree**:客户端只认一个接口、一种格式; +4. **构建/部署/版本/缓存/安全/监控全覆盖**:从开发到上线形成闭环; +5. **启动时序与首屏优化**:App.vue 阻塞加载 + 缓存优先,避免页面闪变; +6. **热更新与页面刷新**:Admin 发布后客户端无需重启即可生效; +7. **平台差异适配**:针对微信小程序、H5、App 分别处理缓存、域名、存储; +8. **签名与完整性校验**:防止 CDN 污染和中间人篡改; +9. **与 i18n-check 整合**:本地 + 远端双重校验,保证 zh/en 一致性; +10. **分阶段上线 + 灰度 + 回退**:降低切换风险; +11. **PHP 后端最小改动**:核心 bundle 接口几十行代码即可跑起来。