博客后台翻译模式
博客实体翻译如何通过 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-translation | BLOG_TRANSLATION_QUEUE | 文章(title、excerpt、content、seoTitle、seoDescription) |
blog-entity-translation | BLOG_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.tsxapp/[locale]/admin/blog/categories/_components/BlogCategoriesTable.tsxapp/[locale]/admin/blog/authors/_components/BlogAuthorsTable.tsx
分步指南:为新模块添加后台翻译
1. 定义队列(如需要)
在 lib/queue.ts 中:
- 添加队列名称常量
- 添加任务数据类型(
type TMyJobData = { ... }) - 添加 Queue 实例,配置
removeOnComplete、removeOnFail、attempts: 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 条目dismissTimersref:终态 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