1371 lines
44 KiB
Markdown
1371 lines
44 KiB
Markdown
# 远端覆写完全替代本地语言包方案(融合版)
|
||
|
||
> 目标:让远端语言包成为唯一数据源,本地 `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 接口几十行代码即可跑起来。
|