第一版把所有逻辑堆进一个接口:登录、仓库读取、模型调用、文件写回全在同一个路由里跑。功能能通,但一到真实场景就崩——用户装了多个 GitHub App、仓库授权关系对不上、翻译跑到一半失败要重试,这时候随便改一处都要牵连一片。后来我按”GitHub 集成层、认证授权层、任务编排层、AI 翻译层、数据持久层、前端交互层”拆开重写,问题才真正落地解决。关键收益是边界清晰:GitHub App 负责受控访问仓库,NextAuth.js v5 负责用户会话,Prisma 负责状态落库,OpenRouter 负责模型统一入口,翻译任务由独立编排器串联拉取、切分、翻译、审核与提交。这篇文章记录的就是这次重构的完整思路,供做同类”读仓库、调模型、写回 PR”工具的人直接参考。
一、架构分层的核心思路
这类工具不是简单的”把英文 Markdown 丢给大模型”。真实场景中,它要知道用户是谁、用户安装了哪个 GitHub App、哪些仓库被授权、哪些文件需要翻译、翻译进度如何恢复、失败任务如何重试,以及模型输出如何落回 Pull Request。我第一版把所有问题混在一起处理,Route Handler 膨胀到几百行,排查问题全靠肉眼翻代码。所以我坚持把系统拆成六层,每层只处理一类问题。
分层之前,先看每层到底管什么。下面的表格按”职责—技术—明确不做什么”三个维度展开,最后那一列不是摆设,是防止模块互相污染的第一道闸门:
| 架构层 | 主要职责 | 关键技术 | 不建议承担的职责 |
|---|---|---|---|
| 前端交互层 | 登录入口、仓库选择、任务状态展示 | Next.js App Router、Server Components | 直接持有 GitHub 长期凭据 |
| 认证授权层 | 用户登录、会话读取、权限校验 | NextAuth.js v5、GitHub App OAuth | 翻译调度 |
| GitHub 集成层 | 获取安装、仓库、文件、提交 PR | GitHub REST API、Installation Token | 业务状态存储 |
| 任务编排层 | 创建任务、切分文件、重试、状态流转 | Route Handler、队列或后台任务 | 直接拼接页面 UI |
| AI 翻译层 | 模型选择、提示词、术语约束、结果校验 | OpenRouter、OpenAI 兼容接口 | 保存用户会话 |
| 数据持久层 | 用户、安装、仓库、任务、片段状态 | Prisma、PostgreSQL 或 SQLite | 调用外部模型 |
这张表的重点不是”用了多少技术”,而是避免模块互相污染。认证层只回答”这个请求是否属于某个用户”,GitHub 集成层只回答”能不能访问这个仓库”,任务层只回答”下一步该执行什么”。
实际落地时我踩过一个坑:分层文档写得再清楚,代码里各层还是容易直接 new 跨层对象。解决办法是统一走 services 层做编排,上层只依赖接口声明,不依赖具体实现,后面第五节目录结构就是这套约定的落地。
二、核心模块如何协作
分层只是静态边界,协作才是系统真正跑起来之后的动态行为。六层里对话最频繁的是认证授权、GitHub 集成和任务编排三个模块,下面逐个拆开讲清楚。
2.1 用户与安装模块
用户登录后,系统拿到的是用户身份;仓库访问权来自 GitHub App 的安装关系。两者必须分开建模。一个用户可能安装多个账号下的 App,一个组织安装也可能被多个成员使用。如果只按用户 access token 访问仓库,权限会随着个人权限变化而波动;用安装关系建模后,系统能围绕 installationId 做稳定授权判断。
我在第一版里犯过这个错:数据库里只存用户和仓库两张大表,没有安装实体。结果用户在某组织里被移除角色,他的 token 立刻失去仓库读权限,任务批量失败。补齐 installation 之后,用户、安装、仓库三个实体互相独立,谁对谁有权限一目了然,排查授权问题的时间省了一大半。
2.2 仓库与文件发现模块
仓库发现模块通过安装令牌读取安装范围内的仓库,再按用户选择的路径扫描 Markdown、MDX、README 或 docs 目录。这里要做三类过滤:忽略二进制文件,跳过已经生成的目标语言文件,限制单次任务的文件数量与体积。否则一次扫描可能把仓库中的依赖目录、构建产物和历史文档都纳入翻译。
这个模块的坑藏在过滤条件里。我第一次扫描时没过滤 node_modules 和构建目录,一次任务拉了上千个文件,模型调用费直接超预算,翻译结果里还混着机器生成的代码注释。后来把文件类型白名单和大小上限写死,扫描面收窄到文档目录,任务量级才稳定下来。
2.3 翻译任务模块
翻译任务模块是系统的中心。它不直接关心页面如何展示,也不直接关心 GitHub token 如何生成,只接收”仓库、分支、源路径、目标语言、模型配置”这些输入,然后把任务拆成可恢复的步骤。
具体流程拆成七步,每步都对应一个可落库的状态:
- 创建 TranslationJob,记录仓库、分支、目标语言与发起用户;
- 拉取源文件内容,按标题、段落、代码块边界切分;
- 为每个片段创建 TranslationSegment,标记 pending;
- 调用 AI 翻译层生成译文,并保存原文哈希;
- 合并片段,运行 Markdown 结构校验;
- 创建新分支,提交译文文件;
- 打开 Pull Request,并把任务状态改为 completed。
这组步骤看似线性,但落库后可以断点续跑。某个片段失败,不需要整篇文档重翻;创建 PR 失败,也不需要重新调用模型。支撑断点续跑的是任务和片段两级数据模型,片段失败不影响整篇:
model TranslationJob {
id String @id @default(cuid())
repository String
branch String
targetLang String
status JobStatus
segments TranslationSegment[]
createdAt DateTime @default(now())
}
model TranslationSegment {
id String @id @default(cuid())
jobId String
sourceHash String
status SegmentStatus
translated String?
job TranslationJob @relation(fields: [jobId], references: [id])
}
片段记录源文件哈希和译文,重试时只重跑失败的片段,不必整篇重翻。任务级与片段级状态分开,才能做到”PR 失败不重调模型、片段失败不重翻全文”。我实际遇到过模型连续超时的情况,靠这个设计把失败片段单独拎出来重试,任务照样跑完,不需要人工介入。
三、数据流与权限流要分开
进入实现阶段,最容易混乱的是两条”流”:数据从哪来、权限由谁放行。二者必须分开设计,混在一起会出现”拿着用户 token 去碰仓库”这类隐蔽问题。
3.1 数据流
数据流从 GitHub 文件开始,经任务编排进入 AI 模型,再回到 GitHub PR。中间所有状态都要落库:文件哈希用于判断源文是否变化,片段状态用于重试,模型参数用于追溯质量问题。这里不保存用户密码,也不把 GitHub 私钥写入数据库。
数据流设计阶段我纠结过一个问题:文件内容要不要落库。不落库,每次重试都要重新拉取,源文变了还会翻译出过期内容;落库,又担心存储膨胀。最终方案是只存原文哈希和译文片段,源文件靠哈希判断是否需要重新拉取,既控制了存储,又保住了重试的正确性。
3.2 权限流
权限流从用户登录开始,经 NextAuth.js v5 得到会话,再关联本地 User 与 GitHub App installation。真正访问仓库时,后端用 App 私钥生成 JWT,再换取 Installation Access Token。用户会话只用于证明”谁发起任务”,安装令牌才用于证明”系统能访问哪些仓库”。
这里有一个必须坚持的原则:用户会话和安装令牌不要互相替代。用会话去访问仓库,权限会随个人变化而漂移;用安装令牌去代表用户,审计日志里就分不清是谁发起的任务。两个凭据各管一段,问题边界非常干净。
四、模块边界的取舍
分层设计不是越多越好,边界切在哪决定开发体验。这一节讲两个我反复权衡过的取舍,也是被问得最多的两个问题。
4.1 为什么不做成单体脚本
单体脚本适合个人一次性翻译公开仓库,但不适合多人、多仓库、可恢复的产品形态。它通常缺少会话、权限、任务状态和失败重试。只要用户关闭浏览器,脚本就很难向前端回报进度;只要模型限流,任务就可能丢失上下文。
我第一版其实就是单体脚本,命令行跑一遍就完事。第一次遇到模型限流,整个任务直接报废,几十个文件白翻。那一刻我就确定了产品形态必须带任务状态和重试机制,单体脚本的思路彻底放弃。
4.2 为什么前端不直接调用 GitHub
前端直接调用 GitHub 会暴露 token 使用范围,也难以集中做审计。更稳妥的做法是前端只发起业务请求,后端统一校验 session、installationId、repoId,再按 GitHub App 权限访问仓库。这样可以把权限判断、速率限制、错误处理放在一处。
当初有人建议前端直接调 GitHub API 省事,我试了一下就否了:前端代码里有 token 中转逻辑,等于把仓库访问凭证铺得到处都是,安全审计无从下手。全部收口到后端之后,第三方审计只需要看一个文件,逻辑也简单得多。
五、推荐目录结构
前几节讲了设计思路,这节给出落地目录。原则是 Route Handler 保持薄,复杂逻辑下沉到 services 与 workers:
src/
app/
api/auth/[...nextauth]/route.ts
api/github/installations/route.ts
api/translation-jobs/route.ts
lib/
auth.ts
github-app.ts
openrouter.ts
prisma.ts
services/
repository-service.ts
translation-service.ts
pull-request-service.ts
workers/
translation-runner.ts
prisma/
schema.prisma
这个结构让 Route Handler 保持很薄,复杂逻辑下沉到 services 与 workers。测试时也更容易:GitHub API、OpenRouter API 和 Prisma 都能单独替换成 mock。我实际这么组织之后,单测覆盖从原来的三成出头涨到七成以上,因为每层都能独立起服务测。
六、落地时的风险点
翻译质量风险来自三处:Markdown 结构被破坏、术语前后不一致、代码块被误翻。工程上要在模型调用前后做保护:代码块先占位,标题层级保持不变,链接地址不翻译,只翻译链接文本;同一任务内维护术语表,让专有名词稳定。权限风险则来自 installationId 与 userId 绑定不严,必须每次任务启动前重新确认该用户是否仍可访问对应安装和仓库。
质量保护这一块我试过只在提示词里加约束,效果不稳定:模型偶尔还是会把链接里的路径当正文翻译,或者把代码块里的变量名改掉。改成工程手段——先占位再还原、用正则校验结构——之后,这类问题基本绝迹。术语一致性则靠任务级术语表兜底,同一个模型配置下专有名词的重复出现率明显下降。
常见问题(FAQ)
Q1:这个项目一定要引入队列吗?
小规模可用数据库状态轮询,任务变多后再接队列。
Q2:翻译结果为什么建议走 PR?
PR 便于人工审核、回滚和保留讨论记录。
Q3:OpenRouter 是架构必需项吗?
不是必需,但能降低多模型接入和切换成本。