Skip to content

博客后台翻译模式

博客实体翻译如何通过 BullMQ 后台任务配合 Redis 状态追踪来处理。为新模块添加类似后台翻译时,请以本文档作为参考。


问题

直接在浏览器中翻译内容(通过 fetch + await)意味着如果用户在完成前离开页面,任务就会丢失。翻译必须在页面导航后仍能继续执行。

解决方案

将 BullMQ 任务加入 Redis 队列并立即返回。一个独立的 worker 进程接收任务,逐个语言进行翻译,并将进度写入 Redis。UI 端轮询获取状态更新。


架构概览

浏览器                      Next.js API              Redis + BullMQ           Worker 进程
──────                      ──────────               ──────────────           ──────────────
点击「翻译」       ──►  POST /entity-translate  ──►  入队任务         ──►  接收任务
                         写入 Redis: "queued"        + 初始化进度           逐语言翻译
                         返回 { jobId }                                     每步完成后更新 Redis
                                                                            全部完成后写入数据库
每 4 秒轮询        ──►  GET  /entity-translate/status  ◄── 读取 Redis
显示进度
  (排队中 → 3/11 → 已翻译)

两种队列类型

队列常量用途
blog-translationBLOG_TRANSLATION_QUEUE文章(title、excerpt、content、seoTitle、seoDescription)
blog-entity-translationBLOG_ENTITY_TRANSLATION_QUEUE标签、分类、作者(各仅一个字段)

文章字段较多且支持取消。实体更简单(每个实体仅一个字段)。


文件路径

队列定义

  • lib/queue.ts - 队列名称常量、任务数据类型、Queue 实例(通过 globalForQueue 实现单例)

API 路由

  • app/api/admin/blog/[postId]/translate/route.ts - POST:入队文章翻译
  • app/api/admin/blog/[postId]/translate/status/route.ts - GET:轮询文章翻译进度
  • app/api/admin/blog/[postId]/translate/cancel/route.ts - POST:取消文章翻译
  • app/api/admin/blog/entity-translate/route.ts - POST:入队实体翻译(标签/分类/作者)
  • app/api/admin/blog/entity-translate/status/route.ts - GET:轮询实体翻译进度

Worker

  • scripts/translation-worker/setup.ts - 共享配置:Redis 连接、AI 模型、常量、Prisma 客户端
  • scripts/translation-worker/index.ts - Worker 入口:任务处理器 + Worker 实例

管理后台页面(SSR 注水)

  • app/[locale]/admin/blog/page.tsx - 文章:通过 redis.mget() 获取初始状态
  • app/[locale]/admin/blog/tags/page.tsx - 标签:同上
  • app/[locale]/admin/blog/categories/page.tsx - 分类:同上
  • app/[locale]/admin/blog/authors/page.tsx - 作者:同上

表格组件(客户端轮询)

  • app/[locale]/admin/blog/_components/BlogPostsTable.tsx - 参考实现(最完整)
  • app/[locale]/admin/blog/tags/_components/BlogTagsTable.tsx
  • app/[locale]/admin/blog/categories/_components/BlogCategoriesTable.tsx
  • app/[locale]/admin/blog/authors/_components/BlogAuthorsTable.tsx

分步指南:为新模块添加后台翻译

1. 定义队列(如需要)

lib/queue.ts 中:

  • 添加队列名称常量
  • 添加任务数据类型(type TMyJobData = { ... }
  • 添加 Queue 实例,配置 removeOnCompleteremoveOnFailattempts: 1
  • 添加到 globalForQueue 类型和开发模式赋值中

2. 创建 POST(入队)API 路由

参考:app/api/admin/blog/entity-translate/route.ts

ts
// 1. 权限检查(session?.user.role !== 'admin')
// 2. 验证输入(entityType、entityId)
// 3. 从数据库加载实体,确认源文本存在
// 4. 如存在则移除之前的任务
// 5. 在 Redis 中初始化 "queued" 进度
// 6. 入队 BullMQ 任务
// 7. 返回 { jobId }

Redis key 模式:blog-entity-translate:{entityType}:{entityId}

写入 Redis 的初始进度结构:

json
{
  "status": "queued",
  "totalSteps": 11,
  "completedSteps": 0,
  "failedSteps": 0,
  "steps": {}
}

3. 创建 GET(状态查询)API 路由

参考:app/api/admin/blog/entity-translate/status/route.ts

ts
// 1. 权限检查
// 2. 读取 Redis key
// 3. 返回解析后的 JSON 或 { status: 'idle' }

4. 添加 worker 处理器

scripts/translation-worker/index.ts 中:

ts
async function processMyJob(data: TMyJobData): Promise<void> {
  // 1. 从数据库加载实体
  // 2. 构建步骤列表(字段 × 语言)
  // 3. 将 Redis 进度设为 "running"
  // 4. 按顺序遍历步骤:
  //    a. 在 Redis 中将步骤设为 "running"
  //    b. 调用 translateText(sourceLocale, targetLocale, text, context)
  //    c. 将步骤设为 "done" 或 "failed"
  //    d. 更新 Redis 进度
  // 5. 将所有翻译写入数据库(使用原生 SQL 以避免 @updatedAt 自动更新)
  // 6. 在 Redis 中设置终态("done" 或 "failed")
}

重要:每个 Worker 需要独立的 Redis 连接(BullMQ 要求)。需创建新的 IORedis 实例。

Redis TTL:活跃任务 3600 秒,终态 300 秒。

5. 更新 page.tsx(SSR 注水)

从 Redis 获取初始状态,使页面加载时 UI 显示正确状态:

ts
const keys = entities.map((e) => `blog-entity-translate:mytype:${e.id}`);
const values = await redis.mget(...keys);
// 解析每个值,筛选 queued/running/done/failed 状态
// 作为 initialTranslationStatuses prop 传递给表格

6. 更新表格组件(客户端轮询)

关键部分:

  • statusMap 状态:Map<string, TStatusEntry>,从 SSR 数据初始化
  • pollActive 回调:每 4 秒轮询 GET 状态接口,针对 queued/running 条目
  • dismissTimers ref:终态 15 秒后自动消失
  • handleTranslate 函数:POST 入队,乐观更新 statusMap 为 "queued"
  • TranslationCell 组件包含 5 种状态:
    • queued - 时钟图标,"排队中"
    • running - 加载动画 + "3/11" 计数
    • failed - "重试" 按钮(再次调用 handleTranslate)
    • done - 对勾图标,"已翻译"(临时状态,自动消失后回退到数据库标记)
    • 默认 - "翻译" 按钮(检查 entity.isTranslated 获取持久状态)

7. 语言标签绿色对勾模式(表单)

当某个语言已填写内容时,在表单编辑器中显示带对勾的绿色标签:

tsx
const hasContent = !!formData.fieldName[locale]?.trim();
// ...
className={cn(
  'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
  activeLocale === locale
    ? 'border-primary bg-primary text-primary-foreground'
    : hasContent
      ? 'border-emerald-200 bg-emerald-50/50 text-emerald-700'
      : 'border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground',
)}
// 对非当前激活的已填写标签显示对勾:
{hasContent && activeLocale !== locale ? (
  <Check className="ml-1 inline size-3 flex-none" />
) : null}

关键设计决策

  • 并发数:1 — 一次只翻译一个,以遵守 Vertex AI 速率限制
  • 重试:在处理器内部处理(每步 5 次重试 + 指数退避),而非由 BullMQ 处理(attempts: 1
  • 原生 SQL 写入数据库prisma.$executeRawUnsafe 以避免翻译时 @updatedAt 自动更新
  • 12 种语言,11 个目标 — en 为源语言,翻译至:zh_CN、zh_TW、ja、ko、pt、es、fr、de、ar、id、nl
  • Worker 独立运行 通过 PM2:npx tsx scripts/translation-worker/index.ts

Internal documentation for MysticX team