一、完整实现方案:启动自动拉取最新翻译 + 环境区分加载

以下是可直接落地的代码,实现启动时自动拉取最新翻译JSON,且生产/测试环境优先远程API、不依赖本地JSON,开发环境可兜底本地JSON。

1. 环境配置文件(统一管理环境变量)

// toolkit/lib/localization/env.ts
import path from 'path';

// 环境枚举
export enum AppEnv {
  DEV = 'development',
  STAG = 'staging',
  PROD = 'production',
}

// 获取当前环境
export const getAppEnv = (): AppEnv => {
  const env = process.env.APP_ENV || process.env.NODE_ENV;
  switch (env) {
    case 'staging':
      return AppEnv.STAG;
    case 'production':
      return AppEnv.PROD;
    default:
      return AppEnv.DEV;
  }
};

// 基础配置
export const LOCALIZATION_CONFIG = {
  // API域名(区分环境)
  API_DOMAINS: {
    [AppEnv.DEV]: 'dev-localization-api.crypto.com',
    [AppEnv.STAG]: 'stag-localization-api.3ona.co',
    [AppEnv.PROD]: 'localization-api.crypto.com',
  },
  // 本地翻译文件目录
  LOCALES_DIR: path.join(process.cwd(), 'locales'),
  // 缓存TTL(生产/测试30分钟,开发5分钟)
  CACHE_TTL: getAppEnv() === AppEnv.DEV ? 5 * 60 : 30 * 60,
  // 是否启用本地JSON(仅开发环境)
  ENABLE_LOCAL_JSON: getAppEnv() === AppEnv.DEV,
};

// 获取API基础URL
export const getLocalizationApiBaseUrl = () => {
  const env = getAppEnv();
  return `https://${LOCALIZATION_CONFIG.API_DOMAINS[env]}`;
};

2. 启动时自动拉取翻译的脚本

// scripts/localization/fetch-locales.ts
import fs from 'fs/promises';
import path from 'path';
import { getAppEnv, LOCALIZATION_CONFIG, getLocalizationApiBaseUrl } from '../../toolkit/lib/localization/env';

// 类型定义
type LocaleOverview = {
  locales: Array<{
    code: string;
    file_urls: {
      'frontend-web__cdc_web': string;
    };
  }>;
  version: string;
};

type TranslationData = Record<string, string>;

// 确保locales目录存在
async function ensureLocalesDir() {
  try {
    await fs.access(LOCALIZATION_CONFIG.LOCALES_DIR);
  } catch {
    await fs.mkdir(LOCALIZATION_CONFIG.LOCALES_DIR, { recursive: true });
  }
}

// 拉取overview(语言列表+文件URL)
async function fetchLocaleOverview(): Promise<LocaleOverview> {
  const baseUrl = getLocalizationApiBaseUrl();
  const response = await fetch(`${baseUrl}/locales/overview.json`);
  
  if (!response.ok) {
    throw new Error(`Failed to fetch overview: ${response.statusText}`);
  }
  
  return response.json();
}

// 拉取单个语言的翻译并清理
async function fetchSingleLocale(localeCode: string, fileUrl: string): Promise<TranslationData> {
  const response = await fetch(fileUrl);
  
  if (!response.ok) {
    throw new Error(`Failed to fetch ${localeCode}: ${response.statusText}`);
  }
  
  const rawData = await response.json() as TranslationData;
  // 清理不兼容的key(next-intl不支持带点的key)
  const cleanedData: TranslationData = {};
  
  for (const [key, value] of Object.entries(rawData)) {
    if (!key.includes('.')) {
      cleanedData[key] = value.replaceAll('\\n', '\n');
    }
  }
  
  return cleanedData;
}

// 保存翻译到本地JSON
async function saveLocaleToFile(localeCode: string, data: TranslationData) {
  const filePath = path.join(LOCALIZATION_CONFIG.LOCALES_DIR, `${localeCode.replace('_', '-')}.json`);
  await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
  console.log(`✅ Saved ${localeCode} to ${filePath}`);
}

// 主函数:拉取所有语言翻译
async function fetchAllLocales() {
  console.log(`🔍 Current environment: ${getAppEnv()}`);
  console.log(`📥 Starting to fetch latest locales...`);

  try {
    // 确保目录存在
    await ensureLocalesDir();
    
    // 拉取overview
    const overview = await fetchLocaleOverview();
    console.log(`📋 Found ${overview.locales.length} locales (version: ${overview.version})`);
    
    // 并行拉取+保存所有语言
    await Promise.all(
      overview.locales.map(async (locale) => {
        const fileUrl = locale.file_urls['frontend-web__cdc_web'];
        const translation = await fetchSingleLocale(locale.code, fileUrl);
        // 仅开发环境保存到本地(生产/测试不需要本地文件)
        if (LOCALIZATION_CONFIG.ENABLE_LOCAL_JSON) {
          await saveLocaleToFile(locale.code, translation);
        }
      })
    );

    console.log(`🎉 All locales fetched successfully!`);
  } catch (error) {
    console.error(`❌ Failed to fetch locales:`, error);
    // 开发环境允许失败(兜底本地文件),生产/测试直接退出
    if (getAppEnv() !== AppEnv.DEV) {
      process.exit(1);
    }
  }
}

// 执行主函数
fetchAllLocales();

3. 运行时翻译加载逻辑(区分环境)

// src/server/services/localization/localeLoader.ts
import { getAppEnv, LOCALIZATION_CONFIG, getLocalizationApiBaseUrl } from '../../../toolkit/lib/localization/env';
import fs from 'fs/promises';
import path from 'path';

// 类型定义
export type LocaleMessages = Record<string, string>;
type LocaleOverview = {
  locales: Array<{
    code: string;
    file_urls: {
      'frontend-web__cdc_web': string;
    };
  }>;
  version: string;
};

// ------------------------------
// 1. 远程加载(生产/测试环境优先)
// ------------------------------
async function fetchLocaleOverview(): Promise<LocaleOverview> {
  const baseUrl = getLocalizationApiBaseUrl();
  const response = await fetch(`${baseUrl}/locales/overview.json`);
  
  if (!response.ok) {
    throw new Error(`Failed to fetch overview: ${response.status}`);
  }
  
  return response.json();
}

async function loadLocaleFromRemote(localeCode: string): Promise<LocaleMessages> {
  const overview = await fetchLocaleOverview();
  const locale = overview.locales.find(item => item.code.replace('_', '-') === localeCode);
  
  if (!locale) {
    throw new Error(`Locale ${localeCode} not found in remote`);
  }
  
  const response = await fetch(locale.file_urls['frontend-web__cdc_web']);
  
  if (!response.ok) {
    throw new Error(`Failed to load ${localeCode} from remote: ${response.status}`);
  }
  
  const rawData = await response.json() as LocaleMessages;
  // 清理key
  const cleanedData: LocaleMessages = {};
  
  for (const [key, value] of Object.entries(rawData)) {
    if (!key.includes('.')) {
      cleanedData[key] = value.replaceAll('\\n', '\n');
    }
  }
  
  return cleanedData;
}

// ------------------------------
// 2. 本地加载(仅开发环境兜底)
// ------------------------------
async function loadLocaleFromLocal(localeCode: string): Promise<LocaleMessages | null> {
  if (!LOCALIZATION_CONFIG.ENABLE_LOCAL_JSON) {
    return null;
  }
  
  try {
    const filePath = path.join(LOCALIZATION_CONFIG.LOCALES_DIR, `${localeCode}.json`);
    const fileContent = await fs.readFile(filePath, 'utf-8');
    return JSON.parse(fileContent) as LocaleMessages;
  } catch {
    return null;
  }
}

// ------------------------------
// 3. 统一加载入口(区分环境)
// ------------------------------
export async function loadLocaleMessages(localeCode: string): Promise<LocaleMessages> {
  const env = getAppEnv();
  
  // 生产/测试环境:仅远程加载(不依赖本地JSON)
  if (env === AppEnv.PROD || env === AppEnv.STAG) {
    return loadLocaleFromRemote(localeCode);
  }
  
  // 开发环境:先远程,失败则兜底本地
  try {
    return await loadLocaleFromRemote(localeCode);
  } catch (remoteError) {
    console.warn(`⚠️ Remote load failed (${localeCode}), fallback to local:`, remoteError.message);
    const localData = await loadLocaleFromLocal(localeCode);
    
    if (!localData) {
      throw new Error(`Both remote and local load failed for ${localeCode}`);
    }
    
    return localData;
  }
}

4. 缓存层整合(Stale-While-Revalidate)

// src/server/services/localization/localeCache.ts
import { Cacheable } from 'cacheable';
import { loadLocaleMessages, LocaleMessages } from './localeLoader';
import { LOCALIZATION_CONFIG, getAppEnv } from '../../../toolkit/lib/localization/env';

// 缓存实例
const cache = new Cacheable({
  ttl: LOCALIZATION_CONFIG.CACHE_TTL * 1000, // 转毫秒
  namespace: 'locale-cache',
});

// 带缓存的加载逻辑
export async function getCachedLocaleMessages(localeCode: string): Promise<LocaleMessages> {
  const cacheKey = `locale:${localeCode}`;
  
  // 使用Stale-While-Revalidate策略
  return cache.cacheable(
    () => loadLocaleMessages(localeCode), // 实际加载函数
    cacheKey,
    {
      cachePolicy: 'stale-while-revalidate',
      maxAge: LOCALIZATION_CONFIG.CACHE_TTL * 1000,
    }
  );
}

// 清空缓存(可选:手动触发更新)
export function clearLocaleCache(localeCode?: string) {
  if (localeCode) {
    cache.delete(`locale:${localeCode}`);
  } else {
    cache.clear();
  }
}

5. package.json 脚本配置(启动自动拉取)

{
  "scripts": {
    "predev": "tsx scripts/localization/fetch-locales.ts", // 开发启动前拉取
    "dev": "next dev",
    "prebuild": "tsx scripts/localization/fetch-locales.ts", // 构建前拉取(开发环境)
    "build": "next build",
    "start": "next start",
    "fetch:locales": "tsx scripts/localization/fetch-locales.ts" // 手动拉取命令
  },
  "dependencies": {
    "cacheable": "^4.0.0",
    "next": "^15.0.0",
    "next-intl": "^3.0.0",
    "tsx": "^4.0.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

二、核心逻辑说明

1. 启动自动拉取

  • 通过 predev/prebuild 钩子,启动/构建前自动执行 fetch-locales.ts
  • 开发环境:拉取后保存到本地 locales/ 目录,作为兜底
  • 生产/测试环境:仅拉取验证,不保存本地文件(完全依赖远程API)

2. 环境区分加载

环境加载策略是否依赖本地JSON
开发 (DEV)优先远程 → 失败则兜底本地✅ 是(兜底)
测试 (STAG)仅远程加载(失败直接报错)❌ 否
生产 (PROD)仅远程加载(失败直接报错)❌ 否

3. 缓存策略

  • 生产/测试环境:缓存30分钟,过期后先返回旧数据 + 后台刷新,无感知更新
  • 开发环境:缓存5分钟,方便调试

三、使用方式

  1. 安装依赖:npm install cacheable next-intl tsx
  2. 配置环境变量:
    • 开发:默认无需配置(APP_ENV=development)
    • 测试:APP_ENV=staging npm run build && npm run start
    • 生产:APP_ENV=production npm run build && npm run start
  3. 启动项目:npm run dev(自动拉取最新翻译后启动)

四、扩展建议

  1. 错误监控:生产环境可接入Sentry,监控远程翻译加载失败
  2. 手动更新:提供接口触发 clearLocaleCache(),实现翻译热更新
  3. CI/CD集成:生产构建时校验翻译完整性,避免缺失key
  4. 本地调试:开发环境可通过 ENABLE_LOCAL_JSON=true 强制使用本地文件