Files
lucky_shop/docs/i18n-remote-only-solution.md
ZF sun 32db276e68 docs: add remote-only i18n solution docs and reference
新增远端完全替代本地语言包的国际化方案文档,并在原指南中添加对应引用,补充新文档到文档列表中。
2026-06-29 17:47:24 +08:00

1371 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 远端覆写完全替代本地语言包方案(融合版)
> 目标:让远端语言包成为唯一数据源,本地 `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/<locale>/` 下所有 `.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: <version>`;
- `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
<?php
class LangBundleController
{
private $basePath;
public function __construct()
{
$this->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 接口几十行代码即可跑起来。