From 085909df98d3f21ab6013f3ea1b0240b19d7dca8 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 25 May 2026 21:12:14 +0800 Subject: [PATCH 1/5] =?UTF-8?q?docs(deployment):=20fill=20phase=202=20batc?= =?UTF-8?q?h=20=E2=80=94=20github-actions=20+=20database=20+=20https-proxy?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐部署指南第二批前 3 篇正文: - github-actions:覆盖 release.yml / deploy-personal.yml / verify.yml / docs.yml / dependabot-fix.yml 的触发方式、镜像 tag 规则、远端 smoke 与 secrets 清单,并给出首次配置的 6 步流程。 - database:澄清 PG/SQLite 不可互替(PERCENTILE_CONT 等 SQL 在 SQLite 上直接报错),列出 DB_TYPE 自动推断与生产 fail-fast 行为,区分 db:generate 与 db:push 的取舍,并对照 CI 的 migration job。 - https-proxy:给出 Nginx / Caddy / 1Panel 三种形态的最小反代配置, 覆盖 SSE 流式响应所需的 proxy_buffering off 与 flush_interval -1, 并显式声明当前代码不输出任何 CORS 响应头,跨域必须在反代层处理。 完成 issue #167 Phase 2 部署指南 3/6 篇。 --- docs/guide/deployment/database.md | 182 ++++++++++++++++- docs/guide/deployment/github-actions.md | 197 +++++++++++++++++- docs/guide/deployment/https-proxy.md | 260 +++++++++++++++++++++++- 3 files changed, 609 insertions(+), 30 deletions(-) diff --git a/docs/guide/deployment/database.md b/docs/guide/deployment/database.md index 5e21a639..624b0115 100644 --- a/docs/guide/deployment/database.md +++ b/docs/guide/deployment/database.md @@ -5,19 +5,181 @@ outline: deep # 数据库选型与初始化 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 同时维护 PostgreSQL 与 SQLite 两份 Drizzle schema,但二者并不是平替:PostgreSQL 是生产唯一推荐项,SQLite 仅服务本地开发沙箱。本页说明两种选型的差别、首次初始化命令、`db:push` 与 `db:migrate` 的取舍、迁移文件结构、以及 CI 上如何验证迁移幂等。 + +不在本页范围内的内容:表清单与字段含义见架构介绍中的 [数据库 schema](../architecture/database-schema);备份与恢复见 [数据持久化与备份](./persistence-backup);环境变量与默认值见 [环境变量参考](./env-reference)。 + +## 选型对照 + +| 维度 | PostgreSQL | SQLite | +| ----------------------------- | ------------------------------------------------------- | -------------------------------------------- | +| 适用场景 | 生产部署、所有公开发行版本 | 本地开发、单机演示、E2E 测试 | +| Drizzle dialect | `postgresql` | `sqlite` | +| Schema 入口 | `src/lib/db/schema-pg.ts` | `src/lib/db/schema-sqlite.ts` | +| Migration 目录 | `drizzle/` | `drizzle-sqlite/` | +| Drizzle config | `drizzle.config.ts` | `drizzle-sqlite.config.ts` | +| 连接来源 | `DATABASE_URL` | `SQLITE_DB_PATH`(默认 `./data/dev.sqlite`) | +| Generate 命令 | `pnpm db:generate` | `pnpm db:generate:sqlite` | +| Migrate 命令 | `pnpm db:migrate` | `pnpm db:migrate:sqlite` | +| 统计聚合(`PERCENTILE_CONT`) | 完整支持 | 部分查询直接报错 | +| 并发与连接池 | 多连接、ACID | 单文件,多连接需要打开 WAL | +| 部署形态 | `docker-compose.yml` 默认启动 `postgres:16-alpine` 容器 | 应用进程直接读写本地文件 | + +::: warning SQLite 不是平替 +`src/lib/db/index.ts:14` 的注释明确指出:SQLite 在结构上对常规 CRUD 兼容,但 `PERCENTILE_CONT` 等 PG 专用 SQL 在 SQLite 上不可用,统计聚合(`/api/admin/stats/*`)会有部分查询直接报错。任何生产部署都必须使用 PostgreSQL。SQLite 仅服务本地开发,避免在没有 Docker 的环境下也能跑 E2E。 +::: + +## DB_TYPE 自动推断与 fail-fast + +`src/lib/utils/config.ts:13` 把 `dbType` 默认为「有 `DATABASE_URL` 时取 `postgres`,否则取 `sqlite`」。也就是说: + +- 设置了 `DATABASE_URL` 而未显式声明 `DB_TYPE`:按 PostgreSQL 处理。 +- 未设置 `DATABASE_URL` 也未显式声明 `DB_TYPE`:按 SQLite 处理。 +- 显式 `DB_TYPE=postgres`:必须再提供 `DATABASE_URL`,否则启动期校验失败。 +- 显式 `DB_TYPE=sqlite`:使用 `SQLITE_DB_PATH`(默认 `./data/dev.sqlite`)。 + +`src/lib/utils/config.ts:99-105` 还有一道生产环境的 fail-fast 守卫:当 `NODE_ENV=production` 且既没有 `DB_TYPE` 又没有 `DATABASE_URL` 时,启动期直接抛出错误,避免静默回退到 SQLite 然后埋下 `PERCENTILE_CONT` 等运行期失败。 + +## PostgreSQL 初始化 + +Docker Compose 部署是 PostgreSQL 的默认形态。`docker-compose.yml` 中已经预置 `db` 服务(`postgres:16-alpine`),与 `autorouter` 服务挂在同一 `autorouter-net` 网络。最小可启动 `.env` 仅需: + +```env +POSTGRES_USER=autorouter +POSTGRES_PASSWORD= +POSTGRES_DB=autorouter +DATABASE_URL=postgresql://autorouter:@db:5432/autorouter +``` + +`DATABASE_URL` 中的密码必须与 `POSTGRES_PASSWORD` 字面一致——`docker-compose.yml` 把这两个值分别透传给 `db` 与 `autorouter` 容器,二者不会自动同步。任何一侧改动都需要同步另一侧并重启栈,否则应用容器会持续报 `password authentication failed`。 + +启动命令: + +```bash +docker compose up -d +``` + +`autorouter` 容器对 `db.condition: service_healthy` 有 `depends_on` 约束,会等 `pg_isready` 通过后才启动。容器内 AutoRouter 启动时不会自动跑迁移,需要按下文「迁移流程」执行 `pnpm db:migrate`,或在 CI 中由 `deploy-personal.yml` 之外的渠道触发。 + +### 本地 PostgreSQL(非 Docker) + +直接连本机 PostgreSQL 时,把 `DATABASE_URL` 改为 host 是 `localhost`: + +```env +DATABASE_URL=postgresql://autorouter:password@localhost:5432/autorouter +``` + +在容器内填 `localhost` 会指向应用容器自身而不是数据库——只有非容器场景才用 `localhost`。在容器里跑应用、外部跑 PG 的混合形态,需要用宿主机 IP 或 `host.docker.internal`。 + +## SQLite 初始化 + +SQLite 部署不需要任何编排,应用启动时按 `SQLITE_DB_PATH`(默认 `./data/dev.sqlite`)创建或打开数据库文件。最简单的本地开发: + +```bash +pnpm db:migrate:sqlite # 用 scripts/db/migrate-sqlite.mjs 对齐 drizzle-sqlite/ 下迁移 +pnpm dev # 启动 Next.js dev server +``` + +如需切换 SQLite 文件位置: + +```env +DB_TYPE=sqlite +SQLITE_DB_PATH=./data/scratch.sqlite +``` + +Playwright E2E(`playwright.e2e.config.ts:19`)也走 SQLite 路径:webServer 命令为 `pnpm db:migrate:sqlite && pnpm dev --port ${port}`,确保每次 E2E 跑前数据库 schema 都是最新。 + +## 迁移流程 + +Drizzle 把 schema 变更通过 SQL 迁移文件管理。常用命令对照: + +| 命令 | 作用 | +| --------------------------- | ----------------------------------------------------------------------------- | +| `pnpm db:generate` | 比对 `schema-pg.ts` 与 `drizzle/` 现状,生成新的 PG 迁移文件 | +| `pnpm db:generate:sqlite` | 比对 `schema-sqlite.ts` 与 `drizzle-sqlite/` 现状,生成新的 SQLite 迁移文件 | +| `pnpm db:migrate` | 把 `drizzle/` 下未 apply 的迁移按序施加到 `DATABASE_URL` 指向的 PG | +| `pnpm db:migrate:sqlite` | 把 `drizzle-sqlite/` 下未 apply 的迁移施加到 `SQLITE_DB_PATH` 文件 | +| `pnpm db:check:consistency` | 校验「`drizzle/` 与 schema-pg」「`drizzle-sqlite/` 与 schema-sqlite」是否一致 | +| `pnpm db:push` | 跳过迁移文件,直接把 schema 推到数据库;仅供本地快速迭代用 | +| `pnpm db:studio` | 启动 Drizzle Studio 可视化查看数据 | + +### `db:generate` 与 `db:push` 的取舍 + +`db:push` 直接把 schema diff 应用到数据库,不生成迁移文件。它的速度优势仅在本地开发循环——「改一行 schema、看一眼 Studio」。**任何要写入 git 的 schema 变更都必须走 `db:generate`**: + +1. `db:push` 不留任何审计 / 回滚痕迹,迁移历史里缺这一步,后续在其他环境的迁移就对不齐。 +2. `db:push` 不会同时维护 `drizzle-sqlite/` 那一份,会让两份 schema 漂移。 +3. CI 的 `migration` job 通过 `db:check:consistency` 校验 schema 与迁移目录一致性,`db:push` 留下的差异会被 CI 直接拒绝。 + +### 标准 schema 变更流程 + +修改 `src/lib/db/schema-pg.ts`(与 `schema-sqlite.ts` 中对应字段)后: + +```bash +pnpm db:generate +pnpm db:generate:sqlite + +# 两次 generate 都产出文件后再统一 apply 验证 +pnpm db:migrate +pnpm db:migrate:sqlite +``` + +把新生成的 `drizzle/_*.sql`、`drizzle/meta/_snapshot.json`、`drizzle-sqlite/...` 一并 commit。`schema-pg.ts` 与 `schema-sqlite.ts` 不允许只改一边——否则 `db:check:consistency` 直接报错。 + +### 迁移目录结构 + +``` +drizzle/ +├── 0000_fresh_blizzard.sql +├── 0001_thankful_sinister_six.sql +├── ... +└── meta/ + ├── _journal.json + ├── 0000_snapshot.json + └── ... + +drizzle-sqlite/ +├── 0000_broken_post.sql +├── ... +└── meta/ + ├── _journal.json + └── ... +``` + +`meta/_journal.json` 是 Drizzle 维护的迁移登记表,每次 `db:generate` 会追加一条目;`meta/_snapshot.json` 是当时的 schema 快照,下次 `db:generate` 用它跟当前 schema 比对得出 diff。 + +::: tip 不要手工编辑迁移 SQL +迁移文件由 `drizzle-kit` 生成。手工修改 SQL 会让下一次 `db:generate` 的 diff 计算偏离实际数据库状态,最终在 `db:check:consistency` 上失败。若需要补充非默认行为(例如在 PG 上加 `CONCURRENTLY` 索引),通常做法是:先 `db:generate` 拿到基线迁移,再在该 SQL 文件末尾「追加」一行而非「重写」前面的内容,并保留 meta snapshot 不变。 ::: -## 计划覆盖的内容 +## CI 上的迁移校验 + +`.github/workflows/verify.yml` 中的 `migration` job 拉起一个真实 `postgres:16-alpine` 服务容器并依次执行: + +1. `pnpm db:check:consistency`:脚本 `scripts/ci/check-drizzle-consistency.mjs` 内部对每个 dialect(`postgres` + `sqlite`)都重新跑一次 `db:generate*`,若生成结果与已 commit 的 SQL / snapshot 不同则失败。 +2. `pnpm db:migrate`:把 `drizzle/` 全量 apply 到空 PG。 +3. `pnpm db:migrate`:第二次 apply,验证幂等性。重复 apply 不能产生任何 diff 或副作用。 + +`migration` 失败的两类常见情形: + +- `db:check:consistency` 不通过:通常是只改了 `schema-pg.ts` 没改 `schema-sqlite.ts`,或者反过来。修复方式是把两份 schema 改齐并重新 `db:generate*`。 +- `db:migrate` 第二次失败:迁移文件中存在非幂等操作(例如 `CREATE TABLE` 没加 `IF NOT EXISTS`、`INSERT` 未做去重)。Drizzle 默认生成的 SQL 是幂等的,遇到这种情况通常是手工编辑过迁移 SQL。 + +## 与升级 / 回滚的关系 + +`deploy-personal.yml` 当前不会在远端自动跑迁移。这是个隐含约束——镜像内的应用代码与服务器上 PG 的 schema 应当事先对齐: -PostgreSQL(默认)与 SQLite(轻量场景)的取舍、`db:push` 与 `db:migrate` 的选用、首次初始化命令。 +- **前向兼容的迁移**:新版本的 schema 与上一版本完全兼容(仅新增列、新增可空字段),可以先把镜像升上去,再事后跑 `pnpm db:migrate`(或在容器内执行 `node node_modules/drizzle-kit/bin.cjs migrate`)。 +- **破坏性迁移**:删列、改类型、重命名等。生产升级前必须先把数据库迁好、再切镜像;回滚同理,需要先回滚 schema 再切镜像。 -## 在正文就绪前的临时建议 +回滚到旧 tag 时,如果旧版本 schema 不兼容当前数据库(例如新增了 NOT NULL 列),应用启动会立刻失败。详细策略见 [升级与回滚](./upgrade-rollback)。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `drizzle.config.ts`、`drizzle-sqlite.config.ts`:dialect 与连接来源 +- `src/lib/db/index.ts`、`src/lib/db/schema.ts`:barrel 与运行期 schema 选择 +- `src/lib/utils/config.ts`:`DB_TYPE` 自动推断与生产 fail-fast 守卫 +- `package.json` scripts 段:`db:generate` / `db:migrate` / `db:check:consistency` / `db:push` 命令定义 +- `scripts/ci/check-drizzle-consistency.mjs`、`scripts/db/migrate-sqlite.mjs`:迁移一致性与 SQLite 迁移实现 +- `.github/workflows/verify.yml` 的 `migration` job:CI 层迁移校验 +- `playwright.e2e.config.ts`:E2E webServer 命令中如何对齐 SQLite schema diff --git a/docs/guide/deployment/github-actions.md b/docs/guide/deployment/github-actions.md index f665c1e3..afc3d142 100644 --- a/docs/guide/deployment/github-actions.md +++ b/docs/guide/deployment/github-actions.md @@ -5,19 +5,196 @@ outline: deep # GitHub Actions CI 部署 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +仓库内置三条 GitHub Actions 工作流共同支撑「打 tag → 构建并推送镜像 → 在远端服务器上完成部署」的全自动链路:`release.yml` 负责构建与发布镜像,`deploy-personal.yml` 负责把已发布镜像通过 SSH 推送到目标服务器,`verify.yml` 在每个 PR 与 master push 上跑质量门禁。本页按这条主链路顺序展开,覆盖触发方式、Secrets 清单、首次配置步骤、与 `docker compose` 主部署路径的衔接位置。 + +不在本页范围内的内容:每个环境变量字段的语义见 [环境变量参考](./env-reference);CLIProxyAPI sidecar 的补齐流程见 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar);版本号与镜像 tag 的语义化规则见架构介绍中的 [版本与发布](../architecture/release)。 + +## 工作流总览 + +| 工作流文件 | 触发方式 | 职责 | +| --------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `.github/workflows/release.yml` | 向 `master` 推送形如 `v*` 的 tag | 校验 tag 形式、生产构建、推送镜像到 `ghcr.io/g1331/autorouter`、生成 release | +| `.github/workflows/deploy-personal.yml` | 在 GitHub Actions 页面手工触发 `workflow_dispatch` | 拉取目标 release tag 对应的 `docker-compose.yml`,通过 SSH 在远端启动并 smoke | +| `.github/workflows/verify.yml` | 向 `master` 推送相关源码 / 配置 / 工作流变更,或对 `master` 开 PR 时 | ESLint、Prettier、`tsc`、Vitest、迁移一致性、代理稳定性、Playwright E2E | +| `.github/workflows/docs.yml` | `docs/**` / `README*` / `docs.yml` 自身 / `package.json` / `pnpm-lock.yaml` 变更时 | 构建 VitePress 站点,master 推送时部署到 GitHub Pages | +| `.github/workflows/dependabot-fix.yml` | Dependabot 在 `package.json` 上开 PR 时 | 重新生成 `pnpm-lock.yaml` 并回推到 PR 分支 | + +`release.yml` 与 `deploy-personal.yml` 是一对:前者把镜像发布出去,后者把镜像部署上线。`verify.yml` 与 `docs.yml` 在主干上做质量保障。`dependabot-fix.yml` 解决 Dependabot 不能正确处理 pnpm workspace 时 lockfile 不同步的问题。 + +## `release.yml`:tag 触发的发布流水线 + +### 触发条件 + +工作流在收到 `tags: ["v*"]` push 时执行(`.github/workflows/release.yml:3-5`)。tag 必须满足下列正则才能被接受: + +```text +^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ +``` + +也就是 `vMAJOR.MINOR.PATCH`、`vMAJOR.MINOR.PATCH-alpha.N` 或 `vMAJOR.MINOR.PATCH-beta.N` 三种形态之一。带 `-alpha.N` / `-beta.N` 后缀的 tag 会被标记为 prerelease;不带后缀的稳定 tag 会同时被发布为 `latest`。 + +除此之外还有第二条硬约束:tag 指向的 commit 必须在 `origin/master` 路径上。工作流通过 `git merge-base --is-ancestor` 校验该约束(`.github/workflows/release.yml:48-51`),不满足时直接失败,避免在 feature 分支上误打 tag 后发出脏镜像。 + +### 构建与推送 + +通过 tag 校验后流水线依次执行: + +1. `pnpm install --frozen-lockfile` 安装依赖。 +2. `pnpm build` 完成 Next.js 生产构建。`DB_TYPE=postgres` 与 `NEXT_TELEMETRY_DISABLED=1` 在构建期注入,应用版本号通过 `NEXT_PUBLIC_APP_VERSION` 注入(取自 tag 去掉前缀 `v` 后的部分)。 +3. `actionlint` 校验所有工作流文件本身。 +4. `docker/setup-buildx-action` 准备 Buildx,`docker/login-action` 用 `GITHUB_TOKEN` 登录 `ghcr.io`。 +5. `docker/metadata-action` 生成镜像 tag 集合。 +6. `docker/build-push-action` 推送镜像,平台限定 `linux/amd64`,构建缓存通过 `type=gha` 复用。 + +镜像 tag 的具体生成规则按 `docker/metadata-action` 的 `tags:` 段(`.github/workflows/release.yml:96-100`): + +| 规则 | 何时生效 | +| ----------------------------------------- | --------------------- | +| `type=raw,value=${{ github.ref_name }}` | 始终:原始 tag 字符串 | +| `type=semver,pattern={{version}}` | 始终:完整 semver | +| `type=semver,pattern={{major}}.{{minor}}` | 仅稳定 tag(无 `-`) | +| `type=raw,value=latest` | 仅稳定 tag(无 `-`) | + +带 alpha/beta 后缀的 tag 只会更新与 tag 本身同名的镜像,不会污染 `latest` 与 `MAJOR.MINOR`,避免预览版本被默认拉取到生产环境。 + +### Release notes 与基线计算 + +镜像推送完成后流水线再生成 release notes: + +- `notes_baseline` 步骤(`.github/workflows/release.yml:116-159`)决定 `git-cliff` 的对比起点: + - 稳定 tag:取出当前 commit 可达的最近一个稳定 tag(即不带 `-alpha`/`-beta` 后缀的 `vN.N.N`)。 + - alpha/beta tag:取出同一基线版本下、同一渠道的上一颗预发布 tag。如果该基线下没有更早的同渠道 tag,则回退到最近一个稳定 tag。 +- `git-cliff` 用 `cliff.toml` 中的规则把 commits 分组成 `New Features` / `Bug Fixes` / `Security` / `Performance` / `Documentation` / `Tests` / `Maintenance` / `Other Changes` 等段(详见 [版本与发布](../architecture/release))。 +- 预发布 tag 渲染 changelog 时会带上 `--ignore-tags '.*-(alpha|beta)\\.[0-9]+$'`,避免预发布版本被当成稳定版本写入对比。 + +最后 `softprops/action-gh-release` 创建 GitHub Release,body 内嵌前述 metadata 与生成的 changelog。释出的 `release-body.md` 与 `release-metadata.json` 同时作为 artifact 上传,便于事后审计。 + +### 所需权限与 Environment + +工作流声明的最小权限: + +```yaml +permissions: + contents: write # 创建 GitHub Release 时需要 + packages: write # 推送镜像到 ghcr.io 时需要 +``` + +`environment: release` 用来给 release job 绑定 GitHub Environment 上的二次保护:若仓库给 `release` environment 配置了 reviewer 审批策略,则流水线会在 release job 启动前等待人工审批。该机制对个人项目非必要,但对多人协作仓库推荐启用。 + +## `deploy-personal.yml`:远端 SSH 部署 + +### 触发方式 + +只能通过 GitHub Actions 页面手工触发 `workflow_dispatch`。三个输入字段(`.github/workflows/deploy-personal.yml:4-18`): + +| 输入 | 含义 | 形式 | +| -------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `image_ref` | 要部署的镜像引用 | `v0.1.0`(自动补全 `ghcr.io/g1331/autorouter:` 前缀);或完整 `ghcr.io/...` 引用;或 `sha256:...` digest | +| `environment_name` | GitHub Environment 名称 | 默认 `personal-production`;作业会绑定到该 environment 的 secrets 与审批策略 | +| `confirm_release_id` | 用于二次确认的 release tag | 例如 `v0.1.0`。流水线会校验该 tag 存在、tag 指向的 commit 在 `origin/master` 路径上、对应 GitHub Release 也存在 | + +`confirm_release_id` 是一道防呆——必须填写当前要部署的 release tag,且与 `image_ref` 配套。误输入会让流水线在 `validate` 阶段直接拒绝,避免把错版本推上服务器。 + +### 必须配置的 Secrets + +`deploy-personal.yml` 把 `appleboy/ssh-action` 作为运行手段,需要在目标 GitHub Environment(默认 `personal-production`)的 secrets 中配置下列项: + +| Secret | 必填 | 默认值 | 用途 | +| ----------------- | ---- | ----------------- | ------------------------------------------------------------------------------- | +| `SERVER_HOST` | 是 | 无 | 目标服务器主机名或 IP | +| `SERVER_USER` | 是 | 无 | SSH 登录用户 | +| `SSH_PRIVATE_KEY` | 是 | 无 | SSH 私钥 | +| `SERVER_PORT` | 否 | `22` | SSH 端口 | +| `DEPLOY_DIR` | 否 | `/opt/autorouter` | 部署目录,主 `docker-compose.yml` 与 `.env` 都在该目录下 | +| `ADMIN_TOKEN` | 是 | 无 | 管理 API token。首次部署写入 `.env`;每次部署都会用该值覆盖 `.env` 中已有的字段 | + +`SSH_PRIVATE_KEY` 推荐使用专为该工作流生成的最小权限密钥,并把对应公钥 `authorized_keys` 中的 `command=` 限定为「只允许 `docker compose` 子命令」之类的策略,进一步收紧风险面(可选)。 + +### 远端执行流程 + +工作流登录服务器后逐步执行(`.github/workflows/deploy-personal.yml:78-130`): + +1. `mkdir -p ${DEPLOY_DIR}`,进入该目录。 +2. `curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com///docker-compose.yml`,拉取与 `confirm_release_id` 完全对齐的主 compose 文件。 +3. 首次部署时 `.env` 不存在,自动生成:`POSTGRES_PASSWORD` 取自 `openssl rand -base64 24` 去掉 `/+=` 后截前 32 字节,`ENCRYPTION_KEY` 取自 `openssl rand -base64 32`,`ADMIN_TOKEN` 来自 GitHub secret,`PORT` 写死为 `3331`。 +4. 已有 `.env` 时只覆盖 `AUTOROUTER_IMAGE` 与 `ADMIN_TOKEN` 两行,其余字段保持不变。这是升级 / 回滚的关键路径——切换 release tag 不会重置加密密钥与数据库密码,原数据继续可读。 +5. `docker pull "${IMAGE}"` 拉取目标镜像。 +6. `docker compose up -d --remove-orphans` 启动整套栈。 + +第三步生成的 `ENCRYPTION_KEY` 是一次性事件:首次部署成功后该密钥就固化在服务器 `.env` 中,后续工作流不再生成、也不会覆盖。这意味着丢失该 `.env` 等同于丢失整个加密体系(详见 [环境变量参考](./env-reference))。生产环境强烈建议在首次部署后立刻把该文件备份到密码管理器或离线介质。 + +::: warning .env 不会自动维护 CLIPROXY* 段 +`deploy-personal.yml` 只 `curl` 主 `docker-compose.yml`,不会拉取 `docker-compose.cliproxy.yml`、`cliproxy/` 目录,也不会向 `.env` 写入任何 `CLIPROXY*\*` 字段。需要 OAuth 类上游时,必须按 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 手工补齐。 +::: + +### Verify 阶段 + +部署完成后流水线立即进入 `Verify deployment` 步骤,这是 `deploy-personal.yml` 与朴素 `docker compose up -d` 最大的差别——CI 会在远端执行一次完整 smoke: + +1. 轮询 `docker ps` 直到 `autorouter` 容器进入 `healthy`,最多等 60 秒。 +2. `curl http://localhost:${PORT}/api/health`,比对返回 JSON 中的 `version` 字段与 `confirm_release_id`(去掉前缀 `v`)。版本不一致则报错,证明镜像没有正确切换。 +3. `curl -H "Authorization: Bearer ${ADMIN_TOKEN}" /api/admin/health?active_only=true`,验证管理 API 鉴权正常。 +4. 在容器内启动一个 Node.js 子进程,在 127.0.0.1 上拉起 mock 上游(监听 `3101`),通过 Admin API 创建测试上游与测试 Key,分别发一笔非流式与流式请求经过 `/api/proxy/v1/chat/completions`,验证转发链路与 SSE 流均工作,最后删除测试资源。 + +第四步的 mock smoke 会把请求经过整条「鉴权 → 选路 → 转发 → 日志 → 计费」链路。只有这一步通过才会写入 `GITHUB_STEP_SUMMARY`,相当于「部署成功」的硬性证明。 + +## `verify.yml`:PR 与 master 的质量门禁 + +`verify.yml` 是部署链路的前置:进入 release 流程之前的每一个 PR 都必须先通过这条工作流。一共 6 个 job,并行运行,最后由 `verify-status` 聚合判定: + +| Job | 关键步骤 | 失败时含义 | +| ----------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `quality` | `pnpm lint` / `pnpm format:check` / `pnpm exec tsc --noEmit` / `pnpm test:run --coverage` | 代码格式 / 类型 / 单元测试任一不通过 | +| `build` | `pnpm build` | Next.js 生产构建失败 | +| `migration` | `pnpm db:check:consistency`、`pnpm db:migrate`(两次,验证幂等性) | drizzle 迁移目录与 schema 不一致,或迁移在 PG 上无法运行 | +| `proxy-stability` | `pnpm test:proxy-stability` | 代理转发链路在新构建下不稳定 | +| `e2e` | `pnpm exec playwright install --with-deps chromium` + `pnpm e2e` | Playwright E2E 用例失败 | +| `actionlint` | `raven-actions/actionlint@v2` | 工作流语法或常见错误 | + +`migration` 与 `proxy-stability` 两个 job 在 ubuntu runner 上拉起 `postgres:16-alpine` 服务容器跑真实 PG,避免迁移在内存数据库上「能跑但生产 PG 上失败」的盲区。`migration` 还会连续 `db:migrate` 两次,验证幂等:第二次 apply 应当无变化,否则迁移本身有副作用。 + +`verify-status` 是 `needs: [...]` 收尾 job,对所有上游 job 的 `result` 做并集判断。GitHub 分支保护规则中把 `verify-status` 设为必需,就一次绑定了全部门禁,无需在保护策略里逐个勾选。 + +::: tip Dependabot PR 例外处理 +所有 install 步骤都按 `github.actor == 'dependabot[bot]'` 选择 `--no-frozen-lockfile`,避免 Dependabot 单独改 `package.json` 时 lockfile 不同步触发 install 失败。配套的 `dependabot-fix.yml` 会在 PR 上自动重生 lockfile 并 push 回 PR 分支。 ::: -## 计划覆盖的内容 +## 首次配置步骤 + +按下列顺序完成一次「从 fork 仓库到能用 `deploy-personal.yml` 部署」的配置: + +1. **打开 GHCR 写入权限**:仓库 `Settings → Actions → General → Workflow permissions` 选 `Read and write permissions`。`release.yml` 需要 `packages: write` 才能推送镜像。 +2. **创建 GitHub Environment**:仓库 `Settings → Environments → New environment`,名称建议沿用默认值 `personal-production`。可选给该 environment 设 reviewer 审批策略,给 deploy 加一道人工确认门。 +3. **配置 Secrets**:按上面「必须配置的 Secrets」表,把 `SERVER_HOST` / `SERVER_USER` / `SSH_PRIVATE_KEY` / `ADMIN_TOKEN` 等添加到该 environment。 +4. **首发**:本地准备好版本号(修改 `package.json` 的 `version` 字段,新版本应当符合 [版本与发布](../architecture/release) 中描述的命名规则),merge 到 `master`,给该 commit 打 `v0.0.1`(或对应版本)的 tag 并 push: + + ```bash + git tag v0.0.1 + git push origin v0.0.1 + ``` + + `release.yml` 自动触发,几分钟后 `ghcr.io/g1331/autorouter:v0.0.1` 可用。 + +5. **首次部署**:到 GitHub Actions 页面手工触发 `Personal Deploy`,`image_ref` 填 `v0.0.1`,`confirm_release_id` 填同一个 `v0.0.1`。流水线会通过 SSH 在目标服务器上完成首次部署并自动 smoke。 +6. **可选:补 sidecar**:若需要 Codex / Claude / Gemini OAuth 上游,按 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 手工补齐。 + +## 后续升级与回滚 + +后续每次只需: + +- **升级**:在 `master` 上 push 新 tag → 等 `release.yml` 完成 → 手工触发 `deploy-personal.yml`,`image_ref` 与 `confirm_release_id` 都填新 tag。 +- **回滚**:手工触发 `deploy-personal.yml`,`image_ref` 与 `confirm_release_id` 填到目标旧 tag 即可。流水线会用旧 tag 对应的 `docker-compose.yml` 与镜像覆盖运行版本,`.env` 中除 `AUTOROUTER_IMAGE` 与 `ADMIN_TOKEN` 外的字段保持原样,数据卷不动,加密密钥不变。 -`release.yml`(打 tag → 构建镜像 → ghcr)与 `deploy-personal.yml`(workflow_dispatch SSH 部署)两个流程的触发方式、Secrets 清单、首次配置步骤。 +完整的升级与回滚流程见 [升级与回滚](./upgrade-rollback)。 -## 在正文就绪前的临时建议 +## 来源对照 -在该文档正文上线之前,可以参考以下材料获取等价信息: +本页所有事实均来自仓库当前 master 上的下列文件: -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `.github/workflows/release.yml`:tag 校验、镜像 tag 生成规则、release notes 基线计算 +- `.github/workflows/deploy-personal.yml`:远端 SSH 流程、首次 `.env` 生成规则、smoke 步骤 +- `.github/workflows/verify.yml`:质量门禁 job 拓扑 +- `.github/workflows/docs.yml`:VitePress 站点构建与 Pages 部署 +- `.github/workflows/dependabot-fix.yml`:lockfile 自动修复 +- `cliff.toml`:release notes 模板与分组规则 +- `docker-compose.yml`、`docker-compose.cliproxy.yml`:部署编排默认值 diff --git a/docs/guide/deployment/https-proxy.md b/docs/guide/deployment/https-proxy.md index bc6a1b40..b160781d 100644 --- a/docs/guide/deployment/https-proxy.md +++ b/docs/guide/deployment/https-proxy.md @@ -5,19 +5,259 @@ outline: deep # HTTPS 与反向代理 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 的应用进程只在容器内监听明文 HTTP `3000` 端口,宿主机上默认映射到 `${PORT:-3331}`,本身不承担 TLS 终止。要把服务暴露到公网,需要在前面放一层反向代理负责证书与 TLS 握手。本页给出 Nginx、Caddy、1Panel 三种主流形态的最小可用配置,覆盖 SSE 长连接、流式响应、上传体大小、CORS 配置的衔接关系。 + +不在本页范围内的内容:环境变量字段说明见 [环境变量参考](./env-reference);常见网络层问题见 [常见部署问题排查](./troubleshooting);CLIProxyAPI 出站代理是另外一回事,见 [CLIProxyAPI 出站代理配置](../usage/cliproxy-egress-proxy)。 + +## 反向代理需要关注的四类行为 + +把 AutoRouter 摆到代理后面之前,先理清四个会影响反向代理配置的关键行为: + +1. **明文 HTTP 上游**:容器内监听 `3000`,宿主机映射到 `${PORT:-3331}`。反向代理与 AutoRouter 之间用明文 HTTP 即可,没必要再做一层 TLS。 +2. **SSE / 流式响应**:`/api/proxy/v1/*` 当请求体携带 `stream: true` 时按 `text/event-stream` 返回长连接流。反向代理必须关闭对该路径的缓冲(`proxy_buffering off`),并把读超时调到分钟级,否则首字延迟会被代理缓冲、流式片段丢失或连接被提前关。 +3. **长上传 / 大上下文请求体**:聊天接口的请求体在多轮长对话或携带 `tool_calls` 时可能突破默认上限(Nginx 默认 `client_max_body_size 1m`、Caddy 默认 32 MiB),需要按业务上调。 +4. **CORS**:AutoRouter 自身代码当前**不会**输出 `Access-Control-Allow-*` 响应头。`.env` 中的 `CORS_ORIGINS` 在 `src/lib/utils/config.ts:40-45` 里只是被解析了一次,没有运行期效果。需要跨域时一律由反向代理层注入响应头。 + +::: warning CORS_ORIGINS 当前无运行期效果 +当前代码里 `corsOrigins` 字段只在 config 解析阶段被读取,没有任何 route handler 或 middleware 据此输出 `Access-Control-Allow-Origin` / `Access-Control-Allow-Methods` 等响应头。若需要从浏览器跨域调用 `/api/proxy/v1/*` 或 `/api/admin/*`,必须在反向代理层(Nginx / Caddy)显式 `add_header Access-Control-Allow-Origin ""`。这是部署里最容易踩到的环节之一。 ::: -## 计划覆盖的内容 +## 入站拓扑 + +最常见的入站拓扑: + +``` + 公网 HTTPS (443) + │ + ▼ + ┌──────────────────────────┐ + │ Reverse proxy (TLS 终止) │ + │ Nginx / Caddy / 1Panel │ + └──────────────────────────┘ + │ 明文 HTTP 127.0.0.1:3331 + ▼ + ┌──────────────────────────┐ + │ AutoRouter (容器内 3000)│ + └──────────────────────────┘ +``` + +反向代理与 AutoRouter 既可以同机也可以跨机。同机部署时,让 `docker-compose.yml` 中的 `ports:` 只 bind `127.0.0.1`(默认是 `"3331:3000"`,意味着监听所有网卡),减少端口被外网直接探测的攻击面: + +```yaml +# docker-compose.override.yml +services: + autorouter: + ports: + - "127.0.0.1:3331:3000" +``` + +跨机部署时,反向代理通过内网或 VPN 访问 AutoRouter,宿主机防火墙只放行反向代理来源 IP,不让 `3331` 直接对公网开放。 + +## Nginx 最小配置 + +下列配置假设: + +- 域名 `autorouter.example.com` 已通过 ACME(如 `certbot`)拿到证书,路径 `/etc/letsencrypt/live/autorouter.example.com/{fullchain.pem,privkey.pem}`。 +- AutoRouter 容器在同机,明文 `127.0.0.1:3331`。 + +```nginx +# /etc/nginx/conf.d/autorouter.conf +upstream autorouter_app { + server 127.0.0.1:3331; + keepalive 32; +} + +server { + listen 80; + server_name autorouter.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name autorouter.example.com; + + ssl_certificate /etc/letsencrypt/live/autorouter.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/autorouter.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 50m; # 给长上下文聊天请求体留充足上限 + + # 通用反代设置 + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 默认 location:管理后台与管理 API + location / { + proxy_pass http://autorouter_app; + + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + # SSE / 流式:/api/proxy/v1/* 单独关闭缓冲并放宽超时 + location /api/proxy/ { + proxy_pass http://autorouter_app; + + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + + proxy_read_timeout 600s; # 长对话流式响应可能持续数分钟 + proxy_send_timeout 600s; + } +} +``` + +关键点: + +| 配置项 | 作用 | +| ----------------------------------------- | ------------------------------------------------ | +| `proxy_buffering off` + `proxy_cache off` | 关闭缓冲,让 SSE 分片实时下推给客户端 | +| `proxy_http_version 1.1` + keepalive | 复用与 AutoRouter 之间的 HTTP 连接,减少握手开销 | +| `proxy_read_timeout 600s` | 长流响应需要更长的读超时,否则 Nginx 主动断流 | +| `client_max_body_size 50m` | 容许较大请求体;按需调整 | + +`proxy_pass` 必须使用 `http://`(明文),不要加 `;` 之外的额外路径,避免破坏 Next.js 路由处理。 + +### 加 CORS 时 + +需要从浏览器跨域调 `/api/proxy/v1/*` 或 `/api/admin/*` 时,在对应 `location` 内加: + +```nginx +location /api/ { + proxy_pass http://autorouter_app; + + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "$http_origin" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Goog-Api-Key, X-Api-Key" always; + add_header Access-Control-Allow-Credentials "true" always; + add_header Access-Control-Max-Age "600" always; + return 204; + } + + add_header Access-Control-Allow-Origin "$http_origin" always; + add_header Access-Control-Allow-Credentials "true" always; + + proxy_buffering off; + proxy_read_timeout 600s; +} +``` + +把 `$http_origin` 改为白名单回显或具体域名,避免无条件回显任意来源。生产推荐使用 `map` 块做白名单: + +```nginx +map $http_origin $cors_allow { + default ""; + ~^https://app\.example\.com$ $http_origin; + ~^https://staging\.example\.com$ $http_origin; +} +``` + +随后把 `add_header Access-Control-Allow-Origin "$cors_allow" always;` 替换原有写法。 + +## Caddy 最小配置 + +Caddy 自带 ACME,证书申请与续签全自动。最小 `Caddyfile`: + +```caddyfile +autorouter.example.com { + encode zstd gzip + + # 主体反代到 127.0.0.1:3331 + reverse_proxy 127.0.0.1:3331 { + flush_interval -1 # 关键:关闭响应缓冲,等价于 nginx proxy_buffering off + transport http { + read_timeout 600s + write_timeout 600s + keepalive 30s + } + } + + request_body { + max_size 50MB # 长上下文请求体上限 + } +} +``` + +`flush_interval -1` 让 Caddy 在每次有数据时立刻 flush 到客户端,是 SSE 流式响应必须的配置。其余默认值已经足够。 + +需要 CORS 时: + +```caddyfile +@cors_origin header_regexp Origin ^https://(app|staging)\.example\.com$ + +handle /api/* { + header @cors_origin Access-Control-Allow-Origin "{header.Origin}" + header @cors_origin Access-Control-Allow-Credentials "true" + header @cors_origin Access-Control-Allow-Headers "Authorization, Content-Type, X-Goog-Api-Key, X-Api-Key" + header @cors_origin Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" + + @preflight method OPTIONS + respond @preflight 204 + + reverse_proxy 127.0.0.1:3331 { + flush_interval -1 + } +} +``` + +## 1Panel 与同类面板 + +1Panel、宝塔、aaPanel 等面板提供基于 Nginx 的可视化反代。共同套路: + +1. **建站时选用站点类型 → 反向代理**:目标地址填 `http://127.0.0.1:3331`。 +2. **绑定证书**:上传 ACME 证书或让面板自动签发 Let's Encrypt。 +3. **进入站点的「反向代理 → 高级」/「自定义配置」面板**,把下列片段贴入站点配置: + + ```nginx + client_max_body_size 50m; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + location /api/proxy/ { + proxy_pass http://127.0.0.1:3331; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + ``` + + 面板的默认 `location /` 通常无需改动,只需为 `/api/proxy/` 单独加一段。 + +4. **重启 Nginx**:通过面板触发或 `systemctl reload nginx`。 + +面板自带的 WAF / 速率限制对 SSE 请求可能误判,初次部署若发现流式响应被截断,先把 WAF 对 `/api/proxy/` 路径放白名单再做下一步排障。 + +## SSE / 流式:常见破口 + +流式响应被破坏的典型现象:客户端收到首段 chunk 后等了一段时间才收到剩余内容,或者直接收到 HTTP 502 / 504。逐项排查: + +| 现象 | 根因 | 修复 | +| ------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------- | +| 全部内容一次到达,没有 chunk | 反向代理开了 `proxy_buffering`(Nginx 默认开) | 在 `/api/proxy/` 的 location 内 `proxy_buffering off` | +| 流到一半 502 / 504 | `proxy_read_timeout` 默认 60s 不够长 | 提到 `600s`(视模型而定) | +| 客户端立即断流但本地直连容器正常 | Caddy 默认会等响应完整才发送 | `reverse_proxy` 块加 `flush_interval -1` | +| CDN 把 `Content-Type: text/event-stream` 当静态资源缓存 | CDN 默认会缓存 200 响应 | 给 `/api/proxy/` 在 CDN 上配 `Cache-Control: no-store` 或 bypass | + +## 端口与 CSRF / 跨站 -在 Nginx、Caddy、1Panel 等场景下把 AutoRouter 暴露到公网的方式与 CORS 配置。 +`/api/admin/*` 用 `Authorization: Bearer ` 鉴权,没有基于 cookie 的会话,因此天然不受 CSRF 影响。`/api/proxy/v1/*` 同理用客户端 API Key 走 Header 鉴权。这意味着反向代理层除非有特殊需要,不必引入 CSRF 防护中间件。 -## 在正文就绪前的临时建议 +管理后台 UI 把 token 保存在浏览器 sessionStorage,离开标签页即丢失。反向代理层不需要为此加任何配套。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `docker-compose.yml`:端口映射默认值 `${PORT:-3331}:3000` +- `src/lib/utils/config.ts`:`corsOrigins` 解析逻辑,确认当前没有运行期 CORS 注入 +- `src/app/api/proxy/v1/[...path]/route.ts`:`/api/proxy/v1/*` 在 `stream: true` 下走 SSE 路径 +- `src/app/api/admin/*` 与 `src/lib/utils/api-auth.ts`:管理 API 用 Bearer Token 鉴权而非 cookie From 409eb719b9f649b70150cecf462d49c08b56f1f9 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 25 May 2026 21:18:01 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(deployment):=20fill=20phase=202=20fina?= =?UTF-8?q?l=20batch=20=E2=80=94=20persistence-backup=20+=20upgrade-rollba?= =?UTF-8?q?ck=20+=20troubleshooting=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐部署指南最后 3 篇正文: - persistence-backup:把运行状态拆为 PG / autorouter-data / cliproxy-auth / cliproxy-logs / 录制目录 / .env 六处,逐项给出在线热备与恢复样例; 明确「单独备份 PG 不足以恢复全部状态」,.env 必须独立纳入备份。 - upgrade-rollback:围绕 AUTOROUTER_IMAGE 切换给出 A/B 两条路径的命令, 按 schema 兼容性区分前向迁移与破坏性迁移的升级顺序,以及破坏性回滚 必须依赖 pg_dump 离线备份。 - troubleshooting:按部署阶段从前向后排查容器启动、healthcheck、 localhost vs 服务名、ENCRYPTION_KEY 丢失四类常见故障,每项给出 诊断与修复路径。 完成 issue #167 Phase 2 部署指南剩余 6/6 篇。 --- docs/guide/deployment/persistence-backup.md | 261 ++++++++++++++++++- docs/guide/deployment/troubleshooting.md | 266 +++++++++++++++++++- docs/guide/deployment/upgrade-rollback.md | 205 ++++++++++++++- 3 files changed, 702 insertions(+), 30 deletions(-) diff --git a/docs/guide/deployment/persistence-backup.md b/docs/guide/deployment/persistence-backup.md index efc528c4..8c6a28a6 100644 --- a/docs/guide/deployment/persistence-backup.md +++ b/docs/guide/deployment/persistence-backup.md @@ -5,19 +5,260 @@ outline: deep # 数据持久化与备份 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 的运行状态分布在四个位置:PostgreSQL 数据库、`autorouter-data` 容器卷、CLIProxyAPI 的 `cliproxy-auth` 与 `cliproxy-logs` 卷(仅启用 sidecar 时存在)、磁盘上的流量录制目录。备份策略需要按位置分别处理,单独备份数据库无法恢复全部状态。本页给出每类持久化位置的备份与恢复样例,附带 named volume 与 bind mount 两种存储形态的变体。 + +不在本页范围内的内容:删除 sidecar 卷后 OAuth 凭据如何重建见 [CLIProxyAPI 首次使用指南](../usage/cliproxy-first-time);升级 / 回滚的整体流程见 [升级与回滚](./upgrade-rollback);流量录制本身的运行期配置见 [请求录制](../usage/request-recording)。 + +## 持久化位置清单 + +| 位置 | 形态 | 内容 | 丢失后果 | +| ---------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| PostgreSQL 数据库(默认在 `postgres-data` 卷) | docker compose 命名卷 | 上游配置、客户端 Key、熔断状态、请求日志、计费快照、CLIProxy 实例与账号注册 | 系统状态归零,需要重新登记上游与 Key | +| `autorouter-data` 卷 | docker compose 命名卷 | 容器内 `/app/data`;当前主要承载 SQLite 模式的 `dev.sqlite`,PG 部署下该卷基本为空 | 仅 SQLite 模式有影响;PG 部署可忽略 | +| `cliproxy-auth` 卷 | docker compose 命名卷(sidecar) | Codex / Claude / Gemini 的 OAuth token 明文 | 所有账号需要在 CLIProxyAPI 管理端重新 OAuth 登录 | +| `cliproxy-logs` 卷 | docker compose 命名卷(sidecar) | CLIProxyAPI 的运行日志 | 仅丢历史日志,不影响运行 | +| 流量录制目录(`RECORDER_FIXTURES_DIR`) | 容器内目录或绑定挂载 | 已录制的请求 / 响应 fixture(JSON 文件);数据库 `traffic_recordings` 表仅存索引 | 索引仍在,但 `fixture_path` 指向的文件已丢失,回放与详情查看失效 | +| `ENCRYPTION_KEY`(不在卷里,但同等关键) | `.env` 文件 | Fernet 加密密钥,用于解密上游 API Key、CLIProxy 凭据等敏感字段 | 数据库行还在,但所有加密字段都无法解密;上游配置必须逐条手工重填 | + +::: danger 备份策略必须覆盖 .env +`.env` 中的 `ENCRYPTION_KEY` 不存在于任何 named volume 中,标准的 `docker volume` 备份命令不会带上它。一旦 `.env` 丢失且没有离线副本,即使 PG 数据库完整恢复,所有上游凭据仍然不可读。`.env` 必须作为独立项纳入备份计划,建议在密码管理器或离线介质中保留至少一份。 ::: -## 计划覆盖的内容 +## docker named volume 与 bind mount 对照 + +`docker-compose.yml`(仓库内默认)使用 named volume: + +```yaml +volumes: + autorouter-data: + postgres-data: + +services: + autorouter: + volumes: + - autorouter-data:/app/data + db: + volumes: + - postgres-data:/var/lib/postgresql/data +``` + +named volume 的实际路径由 Docker 管理,宿主机上通常位于 `/var/lib/docker/volumes//_data`。Compose 启动时会自动在卷名前加 project 前缀,宿主机上看到的实际名为 `_`。`/opt/autorouter` 部署目录对应的 project 名通常是 `autorouter`,因此实际卷名形如 `autorouter_postgres-data`。 + +若希望把卷数据直接放到宿主机指定目录(便于现有备份策略复用),改用 bind mount: + +```yaml +# docker-compose.override.yml +services: + autorouter: + volumes: + - /var/lib/autorouter/data:/app/data + db: + volumes: + - /var/lib/autorouter/postgres:/var/lib/postgresql/data +``` + +bind mount 的目录由运维方自行准备,权限由宿主机文件系统决定。PG 数据目录在大多数 Linux 发行版上需要属主 UID `999`(容器内 postgres 用户的 UID),否则 `db` 容器会在启动期报权限错误: + +```bash +sudo mkdir -p /var/lib/autorouter/postgres +sudo chown -R 999:999 /var/lib/autorouter/postgres +``` + +`docker-compose.override.yml` 与主 `docker-compose.yml` 在 `docker compose up` 时按文件名顺序合并,无需再带 `-f`。 + +## PostgreSQL 备份 + +数据库是状态最丰富的位置,备份选 `pg_dump` 即可。下面给出三种典型场景。 + +### 方案 A:在主机上调 `docker exec` 执行 `pg_dump` + +适用于「应用容器与 DB 容器都在同一台主机」的常见场景。 + +```bash +# 1. 用容器内的 pg_dump 把整个数据库 dump 到主机 +docker exec autorouter-db \ + pg_dump --clean --if-exists -U autorouter autorouter \ + > /backup/autorouter-$(date +%Y%m%d-%H%M%S).sql + +# 2. 压缩 +gzip /backup/autorouter-*.sql +``` + +`--clean --if-exists` 让 dump 在 restore 时先 `DROP` 旧对象,避免恢复到非空库时冲突。`autorouter` 是用户名与数据库名,按 `.env` 实际值调整。 + +### 方案 B:定时备份(cron) + +```bash +# /etc/cron.d/autorouter-backup +0 3 * * * root /usr/local/bin/autorouter-backup.sh +``` + +```bash +#!/bin/bash +# /usr/local/bin/autorouter-backup.sh +set -euo pipefail + +BACKUP_DIR=/backup/autorouter +RETENTION_DAYS=14 +DATE_TAG=$(date +%Y%m%d-%H%M%S) + +mkdir -p "${BACKUP_DIR}" + +docker exec autorouter-db \ + pg_dump --clean --if-exists -U autorouter autorouter \ + | gzip > "${BACKUP_DIR}/db-${DATE_TAG}.sql.gz" + +# 同步备份 .env(因为 ENCRYPTION_KEY 在这里) +cp /opt/autorouter/.env "${BACKUP_DIR}/env-${DATE_TAG}.env" + +# 清理超过保留期的旧备份 +find "${BACKUP_DIR}" -name "db-*.sql.gz" -mtime +${RETENTION_DAYS} -delete +find "${BACKUP_DIR}" -name "env-*.env" -mtime +${RETENTION_DAYS} -delete +``` + +`.env` 必须随 dump 一起备份,否则 dump 恢复后所有加密字段无法解密。 + +### 方案 C:物理备份(停机) + +只在「主机维护期、明确停机」时使用。直接 `tar` 整个 PG 数据目录: + +```bash +docker compose stop db +sudo tar czf /backup/autorouter-pgdata-$(date +%Y%m%d-%H%M%S).tar.gz \ + -C /var/lib/docker/volumes/autorouter_postgres-data _data +docker compose start db +``` + +物理备份的 restore 路径是「停机 → 解压回 `_data` → 启动」,跨 PG 主版本时不通用,平常不推荐。 + +## PostgreSQL 恢复 + +```bash +# 1. 准备一个空数据库(如果是全新机器,按 .env 先 docker compose up -d db 即可) +docker compose up -d db +docker exec -i autorouter-db \ + dropdb -U autorouter --if-exists autorouter +docker exec -i autorouter-db \ + createdb -U autorouter autorouter + +# 2. 灌入备份 +gunzip < /backup/autorouter-db-20260524-030001.sql.gz \ + | docker exec -i autorouter-db psql -U autorouter -d autorouter + +# 3. 若 .env 也丢失,先把备份的 .env 还原 +cp /backup/autorouter/env-20260524-030001.env /opt/autorouter/.env + +# 4. 启动应用 +docker compose up -d +``` + +`docker exec -i` 的 `-i` 是必须的,否则 stdin 不会传入容器内的 `psql`。 + +::: warning 跨主版本 dump / restore +如果备份来源是 `postgres:16`、目标主机用了 `postgres:17`,建议先在目标主机用同版本的 `pg_dump` 再 dump 一遍(或直接迁数据库版本前先做 dump)。否则 dump 中包含的 `pg_dump` 版本声明与目标版本不一致时偶发警告。 +::: + +## CLIProxyAPI `cliproxy-auth` 备份 + +`cliproxy-auth` 存的是 OAuth token 明文,丢失等于「所有账号需要 CLIProxyAPI 管理端重新 OAuth 登录」。如果接入的账号比较多,备份它能省下大量重做登录的时间。 + +### 在线热备(推荐) + +借助一次性容器把 named volume 的内容打包出来: + +```bash +docker run --rm \ + -v autorouter_cliproxy-auth:/source:ro \ + -v /backup:/backup \ + alpine \ + sh -c 'cd /source && tar czf /backup/cliproxy-auth-$(date +%Y%m%d-%H%M%S).tar.gz .' +``` + +参数解释: + +| 参数 | 作用 | +| ---------------------------------------- | ------------------------------------------------------------ | +| `-v autorouter_cliproxy-auth:/source:ro` | 以只读方式挂入实际的卷(注意:实际卷名带 `_` 前缀) | +| `-v /backup:/backup` | 挂入主机的备份目录 | +| `alpine` + `sh -c '...tar czf...'` | 用临时容器打包;用 alpine 避免镜像膨胀 | + +实际项目前缀按 `docker volume ls --filter name=cliproxy` 的输出取。 + +### 恢复 + +```bash +# 1. 创建(或清空)目标卷 +docker volume create autorouter_cliproxy-auth + +# 2. 把备份回灌 +docker run --rm \ + -v autorouter_cliproxy-auth:/target \ + -v /backup:/backup:ro \ + alpine \ + sh -c 'cd /target && tar xzf /backup/cliproxy-auth-20260524-030001.tar.gz' + +# 3. 启动 sidecar +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d cliproxyapi +``` + +## 流量录制目录备份 + +`recordTrafficFixture`(`src/lib/services/traffic-recorder.ts:517`)把录制内容以 JSON 写到 `RECORDER_FIXTURES_DIR`(默认 `tests/fixtures`,仓库内默认;deploy 工作流通常落到 `/app/data/...` 之类挂入卷的位置)。数据库 `trafficRecordings` 表只存元数据与 `fixture_path` 路径。这意味着: + +- 单独备份 PG 不足以恢复录制;恢复后详情页打开会找不到文件。 +- 单独备份录制目录也不够;查询索引、过滤、统计都依赖 PG。 + +完整的录制备份必须 PG 与录制目录一起做。`RECORDER_FIXTURES_DIR` 通常会挂入名为 `autorouter-data` 的 named volume(如默认编排),或挂入 bind mount。备份方式与 `cliproxy-auth` 同套路:用一次性容器 + `tar`。 + +```bash +docker run --rm \ + -v autorouter_autorouter-data:/source:ro \ + -v /backup:/backup \ + alpine \ + sh -c 'cd /source && tar czf /backup/autorouter-data-$(date +%Y%m%d-%H%M%S).tar.gz .' +``` + +不需要长期保留录制时,可在管理后台「系统 → 请求录制」面板配置 `retention_days` 让后台清理任务自动处理。 + +## bind mount 形态下的备份 + +把所有 named volume 都改成 bind mount 后,备份命令变得跟普通文件备份没区别: + +```bash +# PG 数据 + 应用数据 + sidecar +sudo tar czf /backup/autorouter-$(date +%Y%m%d-%H%M%S).tar.gz \ + -C / \ + var/lib/autorouter/postgres \ + var/lib/autorouter/data \ + var/lib/autorouter/cliproxy-auth \ + opt/autorouter/.env +``` + +bind mount 的好处:与现有备份系统(borg / restic / 普通 rsync)无缝衔接、`.env` 可以放在同一棵子树内一起备份。代价:宿主机权限策略需要自己维护,PG 数据目录的 UID 要对齐。 + +::: tip 不要在备份中包含 cliproxy-logs +`cliproxy-logs` 仅是日志,丢了不影响业务,长期备份反而浪费空间。备份脚本中可以明确排除: + +```bash +tar --exclude='*/cliproxy-logs/*' ... +``` + +::: + +## 验证备份 + +备份能成功生成不等于能成功恢复。建议每月做一次完整恢复演练: -命名卷 `cliproxy-auth`、`cliproxy-logs` 与 PostgreSQL 卷的备份恢复样例,以及 bind mount 变体。 +1. 在备用机或同主机的另一个 project(用 `COMPOSE_PROJECT_NAME=autorouter-restore`)启动一套空栈。 +2. 按上述步骤恢复 PG dump、`.env`、`cliproxy-auth`、录制目录。 +3. 启动栈,登录管理后台,验证:上游列表、客户端 Key 列表、CLIProxyAPI 实例「连通性检测」、最近一笔请求日志详情都能正常打开。 -## 在正文就绪前的临时建议 +只跑命令不验证恢复结果,遇到真实故障时常会发现备份缺一项。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `docker-compose.yml`:`autorouter-data` 与 `postgres-data` 卷声明 +- `docker-compose.cliproxy.yml`:`cliproxy-auth` 与 `cliproxy-logs` 卷声明(带 sidecar 时) +- `src/lib/services/traffic-recorder.ts`、`src/lib/services/traffic-recording-service.ts`:录制目录的实际写入位置 +- `src/lib/db/schema-pg.ts` 中 `traffic_recordings` 表的 `fixture_path` 字段:解释为何数据库与目录必须同步备份 +- `.github/workflows/deploy-personal.yml`:远端 `.env` 由 CI 首次生成并维护,备份必须独立纳入 diff --git a/docs/guide/deployment/troubleshooting.md b/docs/guide/deployment/troubleshooting.md index 89225fcb..5ad49952 100644 --- a/docs/guide/deployment/troubleshooting.md +++ b/docs/guide/deployment/troubleshooting.md @@ -5,19 +5,265 @@ outline: deep # 常见部署问题排查 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +本页按部署阶段从前向后梳理常见故障:容器无法启动 → healthcheck 反复失败 → 应用内部请求异常 → 排查 `localhost` 与服务名陷阱 → `ENCRYPTION_KEY` 丢失的连锁影响。每个故障都给出诊断路径与修复路径,避免凭日志关键字盲猜。 + +不在本页范围内的内容:运行期请求层面的故障(具体上游 5xx、SSE 中断、计费异常等)见使用指南中的 [故障排查手册](../usage/troubleshooting);HTTPS / 反向代理层面的问题见 [HTTPS 与反向代理](./https-proxy)。 + +## 诊断起点:三条状态命令 + +不管什么现象,先跑这三条命令拿到统一视图: + +```bash +# 1. 容器是否在跑、是否 healthy +docker compose ps + +# 2. 关键容器最近日志 +docker compose logs --tail=200 autorouter +docker compose logs --tail=200 db + +# 3. 端到端探针(不需要 token) +curl -fsS http://localhost:${PORT:-3331}/api/health +``` + +带 sidecar 的部署需要把 compose 命令换成双 `-f` 形态: + +```bash +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml ps +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml logs --tail=200 cliproxyapi +``` + +`docker compose ps` 的 `STATUS` 列形如 `Up 3 minutes (healthy)`、`Up 30 seconds (health: starting)`、`Restarting (1) Less than a second ago` 等,是定位故障类型的第一信号。 + +## 容器无法启动 + +容器在 `docker compose up -d` 后立即退出,反复重启,或者根本启动不起来。按容器分别处理。 + +### `autorouter` 反复重启 + +`docker compose logs autorouter` 找到第一段错误。最常见的几类: + +#### `ENCRYPTION_KEY is required` 或长度校验失败 + +`src/lib/utils/config.ts:23` 强制 `encryptionKey` 长度 44(base64 编码的 32 字节)。诊断: + +```bash +grep "^ENCRYPTION_KEY=" .env +``` + +修复: + +- 字段不存在:补上一行 `ENCRYPTION_KEY=<新生成的 base64 32 字节>`。生成命令 `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`。 +- 字段存在但长度不对:常见错误是把 hex 当 base64 填了。重新生成并替换。 +- `.env` 字段全在但容器还是读不到:`docker compose config` 看实际注入到容器的环境变量。如果 `.env` 不在 `docker-compose.yml` 同目录下,Compose 不会加载它,可以用 `docker compose --env-file=/path/to/.env up -d` 显式指定。 + +::: danger 不要为了通过校验随便填一个 ENCRYPTION_KEY +首次部署时 `ENCRYPTION_KEY` 是一次性事件——它锁定数据库中所有已加密字段的解密能力。如果当前数据库已经有上游配置,换一个新密钥意味着所有上游 API Key 都无法再解密。补救路径只剩下「逐条手工重填」。这种情况下优先从备份恢复原 `.env`,详见 [数据持久化与备份](./persistence-backup)。 ::: -## 计划覆盖的内容 +#### `ADMIN_TOKEN is required` + +`src/lib/utils/config.ts:25` 强制 `adminToken` 至少 1 个字符。修复方式同上,缺失就补。 + +#### `DATABASE_URL is required in production` + +`src/lib/utils/config.ts:99-105` 在 `NODE_ENV=production` 时强制要求 `DATABASE_URL`。`docker-compose.yml` 默认会注入 `NODE_ENV=production`,因此即便本地的「开发用」`.env` 没有 `DATABASE_URL`,应用容器也会 fail-fast。 + +修复:在 `.env` 中显式提供 `DATABASE_URL=postgresql://...@db:5432/...`。 + +#### `password authentication failed` + +应用容器能跑起来但持续报错。诊断: + +```bash +grep "^POSTGRES_PASSWORD=" .env +grep "^DATABASE_URL=" .env +``` + +`POSTGRES_PASSWORD` 与 `DATABASE_URL` 中嵌入的密码必须字面一致。`docker-compose.yml` 把这两个值分别透传给 `db` 容器(用作 PG 初始化密码)与 `autorouter` 容器(用作连接密码),二者不会自动同步。任何一侧改动后必须同步另一侧。 + +::: warning 改密码不会改库内现有用户 +PG 容器只在「初次创建 `postgres-data` 卷时」读取 `POSTGRES_PASSWORD` 初始化超级用户。卷已经有数据时改 `POSTGRES_PASSWORD` 不会改库里实际的用户密码。要变更现有部署的密码: + +1. `docker compose exec db psql -U autorouter -d autorouter -c "ALTER USER autorouter PASSWORD '<新密码>';"` +2. 同步改 `.env` 中 `POSTGRES_PASSWORD` 与 `DATABASE_URL`。 +3. `docker compose up -d` 让应用容器读到新连接串。 + ::: + +#### `next start` 报 `Could not find a production build` + +镜像里的 standalone build 没就绪。通常发生在本地直接 `docker build .` 但 Dockerfile 阶段被截断,或者镜像引用的不是 `release.yml` 发布的 tag。修复:换成官方 ghcr.io 镜像即可。 + +```env +AUTOROUTER_IMAGE=ghcr.io/g1331/autorouter:v0.1.0 +``` + +### `db` 容器无法启动 + +`docker compose logs db` 找日志。最常见两类: + +#### `PANIC: could not write to file ...` + +PG 数据目录权限不对。多见于 bind mount 形态——宿主机目录所有者不是 UID 999(容器内 postgres 用户)。修复: + +```bash +sudo chown -R 999:999 /var/lib/autorouter/postgres +docker compose restart db +``` + +#### `database files are incompatible with server` + +升级了 PG 主版本但 `postgres-data` 卷里还是旧版本的数据目录。修复有两条路径: + +- 推荐路径:在旧 PG 镜像下 `pg_dump` 出来、清空卷、新版本下 `psql` 灌回去。 +- 临时路径:把 `image: postgres:16-alpine` 回退到旧版本。 + +升级 PG 主版本是计划内动作,正常运营周期不会触发这条错误。 + +### `cliproxyapi` 容器无法启动(仅带 sidecar 时) + +`docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml logs cliproxyapi`。最常见的错误: + +#### `unable to load config.yaml` + +`cliproxy/docker-entrypoint.sh` 在启动期读取 `CLIPROXY_*` 环境变量、渲染 `config.yaml.template` 为 `config.yaml`。任一字段缺失会让渲染失败。检查 `.env` 是否完整包含 `CLIPROXY_CLIENT_API_KEY` 与 `CLIPROXY_MANAGEMENT_KEY` 两个必填字段,缺失参考 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 补齐。 + +#### `cliproxy/config.yaml.template: no such file` + +叠加文件挂入的 `./cliproxy/` 路径在主机上不存在。CI 路径部署时常见——`deploy-personal.yml` 只拉主 compose,不会拉 sidecar 资料。修复方式同上:按 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 把 sidecar 资料补齐到 `${DEPLOY_DIR}/cliproxy/`。 + +## Healthcheck 反复失败 + +容器在跑但 `STATUS` 长期停在 `(health: starting)` 或 `(unhealthy)`。 + +### `autorouter` 健康检查不通过 + +`docker-compose.yml` 内对 `autorouter` 的 healthcheck 是 `wget -q --spider http://localhost:3000/api/health`,每 30s 一次、超时 10s、失败 3 次判 unhealthy、启动期 40s。失败时按下面顺序排查: + +1. 进入容器手动测一遍:`docker compose exec autorouter wget -qO- http://localhost:3000/api/health`。返回 JSON 即应用就绪。 +2. 应用未就绪:看 `docker compose logs autorouter`。常见情形: + - 启动期间数据库还在做 `pg_isready` 但不健康(再等 40 秒,启动期内属于正常)。 + - 启动期反复连数据库失败:见上文「`password authentication failed`」与「`DATABASE_URL is required`」。 +3. 应用就绪但 healthcheck 仍报失败:通常是宿主机或代理把容器内部 `localhost` 端口拦截了——不应该发生,因为 `wget` 在容器内跑。如果出现,重启 Docker daemon 或者排查是否有 LSM(AppArmor / SELinux)规则。 + +### `db` 健康检查不通过 + +`db` 的 healthcheck 是 `pg_isready -U autorouter -d autorouter`。失败时: + +```bash +docker compose exec db pg_isready -U autorouter -d autorouter +docker compose exec db psql -U autorouter -d autorouter -c "SELECT 1;" +``` + +`pg_isready` 通过但 `psql` 失败:超级用户密码或库名与 `.env` 不一致。 + +`pg_isready` 始终失败:数据目录损坏;检查 `docker compose logs db`,按上文「`PANIC: could not write to file`」或「`database files are incompatible`」处理。 + +### `cliproxyapi` 健康检查不通过 + +healthcheck 是 `wget -q --spider http://localhost:${CLIPROXY_PORT:-8317}/healthz`。失败时: + +```bash +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml exec cliproxyapi \ + wget -qO- "http://localhost:${CLIPROXY_PORT:-8317}/healthz" +``` + +返回非 200:通常是 `CLIPROXY_*` 配置不全或 OAuth 卷损坏。看 `docker compose logs cliproxyapi`。 + +## `localhost` 与服务名陷阱 + +部署里最常见的请求层错误。 + +### 现象 + +- AutoRouter 报「上游地址不可达」,但用 host shell `curl http://localhost:8317/...` 正常。 +- AutoRouter 管理后台「CLIProxyAPI 实例连通性检测」失败,提示「地址不可达」。 +- 调用方报「上游 connection refused」,但上游服务在容器外能 ping 通。 + +### 根因 + +`autorouter` 容器内的 `localhost` 指向 **AutoRouter 容器自身**,不是宿主机、也不是其他容器。 + +- AutoRouter → CLIProxyAPI(同 Compose 网络):必须用容器服务名 `cliproxyapi`。 +- AutoRouter → 宿主机上跑的服务:用宿主机 IP,或在 `docker-compose.yml` 中给该服务声明 `extra_hosts: host.docker.internal:host-gateway` 后用 `host.docker.internal`。 +- AutoRouter → 外部公网服务:直接用公网 DNS / IP。 + +### 修复 + +把 AutoRouter 管理后台中所有「上游 base URL」「CLIProxyAPI 代理基础地址」字段中的 `localhost` 都改成对应的容器服务名或公网地址。最常见的两条对照: + +| 错误填写 | 正确填写 | +| ----------------------- | --------------------------------------------------------------------------- | +| `http://localhost:8317` | `http://cliproxyapi:8317`(受管 sidecar)或 `http://<公网/内网 IP>:8317` | +| `http://localhost:8080` | `http://host.docker.internal:8080`(同机宿主机服务,需要 extra_hosts 配置) | + +详细说明见 [部署形态总览](./overview) 的「容器服务名 vs `localhost`」段,以及 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 的「在 AutoRouter 管理端登记实例」一节。 + +## `ENCRYPTION_KEY` 丢失的影响 + +`ENCRYPTION_KEY` 用 Fernet 算法加密下面这些字段,落地到 PG: + +- `upstreams.api_key`:上游 provider 的 API Key +- `apiKeys.key_value`:客户端 API Key 的明文备份 +- `cliproxyInstances.client_api_key`、`cliproxyInstances.management_key`:CLIProxyAPI 凭据 +- 其他敏感字段(按 schema 演进可能新增) + +### 现象 + +切到新密钥(或丢失 `.env` 后用新密钥重启)后: + +- 任何「调用上游」的请求立即 500,错误日志含 `decryption failed` / `invalid token`。 +- 管理后台打开上游详情,密钥栏显示无法解密。 +- CLIProxyAPI 实例连通性检测一律失败。 + +### 排查 + +```bash +grep "^ENCRYPTION_KEY=" .env | md5sum +``` + +把当前 `.env` 中的密钥与「上次正常工作时的备份」做哈希对比(不要在日志里输出密钥本身)。两者不一致即密钥已变更。 + +### 修复 + +**优先**:从备份恢复原 `.env`。`.env` 不在数据库 dump 里,必须有离线副本。恢复后立即 `docker compose up -d` 重启容器,所有加密字段立即可解。 + +**没有备份**:只能逐条手工重填。在管理后台: + +1. 上游:删除每个上游、重新创建并填入原始 API Key(API Key 必须有外部来源)。 +2. 客户端 Key:删除并重新生成。新 Key 与旧 Key 不同,所有客户端需要更新。 +3. CLIProxyAPI 实例:删除并重新登记。OAuth 账号本身保留在 `cliproxy-auth` 卷里,登记好实例后无需重新登录。 + +这条路径要花数小时到数天,取决于上游数量与客户端配合度。这也是为什么 [数据持久化与备份](./persistence-backup) 里强调「`.env` 必须纳入备份策略」。 + +## 网络与端口 + +部署后无法从宿主机或公网访问 AutoRouter。 + +### 宿主机 `curl http://localhost:3331/...` 拒绝连接 + +| 检查 | 处理 | +| ----------------------------------------------------------- | -------------------------------------------------------- | +| `docker compose ps` 显示 `autorouter` 已 `Up (healthy)`? | 否:先按前几节修复启动问题 | +| `.env` 中 `PORT=...` 与实际 `curl` 端口是否一致? | 默认 `3331`;改过的话 `curl http://localhost:<改后端口>` | +| 宿主机其他进程是否占了 `3331`?`ss -lntp \| grep 3331` | 是:换 `PORT` 或停掉占用进程 | +| 是否在容器侧绑了 `127.0.0.1:3331`,但用宿主机外部 IP 访问? | 改 ports bind 形态,或换成 `127.0.0.1` 访问 | + +### 外部访问报 502 / 504(反向代理后端不可达) + +反向代理与 AutoRouter 之间通讯有问题。按下面顺序排: + +1. 反代主机 `curl http://127.0.0.1:3331/api/health` 通吗? +2. 反向代理的 `upstream` 或 `reverse_proxy` 指向正确? +3. SSE 路径上 `proxy_buffering off` 与 `proxy_read_timeout 600s` 都配了?(见 [HTTPS 与反向代理](./https-proxy)) -容器无法启动、healthcheck 失败、`localhost` 与服务名陷阱、`ENCRYPTION_KEY` 丢失的影响。 +### 「上游 5xx」但应用本身没问题 -## 在正文就绪前的临时建议 +属于运行期上游故障,不是部署问题。见使用指南中的 [故障排查手册](../usage/troubleshooting)。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `docker-compose.yml`、`docker-compose.cliproxy.yml`:healthcheck 定义与卷结构 +- `src/lib/utils/config.ts`:启动期校验逻辑(`ENCRYPTION_KEY` 长度、`DATABASE_URL` 必填守卫等) +- `src/lib/utils/encryption.ts`:Fernet 加密实现,决定了 `ENCRYPTION_KEY` 丢失的不可恢复性 +- `.github/workflows/deploy-personal.yml`:远端部署期 smoke 步骤,定义了「最低限度可用」的标准 +- `cliproxy/docker-entrypoint.sh`:sidecar 启动期配置渲染逻辑 diff --git a/docs/guide/deployment/upgrade-rollback.md b/docs/guide/deployment/upgrade-rollback.md index 7542d192..dbf01197 100644 --- a/docs/guide/deployment/upgrade-rollback.md +++ b/docs/guide/deployment/upgrade-rollback.md @@ -5,19 +5,204 @@ outline: deep # 升级与回滚 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 的版本切换围绕「替换 `AUTOROUTER_IMAGE` 指向的镜像 tag」展开。镜像本身由 `release.yml` 在 ghcr.io 上发布,部署侧只需把 `.env` 中的 tag 改到目标版本再 `docker compose up -d` 即可生效。`.env` 的其他字段、数据库内容、加密密钥、CLIProxy OAuth 凭据等都保持原样。本页给出源码 + docker compose、CI + 远端 SSH 两条路径下的升级与回滚步骤,覆盖 schema 兼容、数据卷复用、sidecar 同步几个关键约束。 + +不在本页范围内的内容:CI 工作流本身的触发与配置见 [GitHub Actions CI 部署](./github-actions);备份策略见 [数据持久化与备份](./persistence-backup);schema 兼容性见 [数据库选型与初始化](./database)。 + +## 镜像 tag 与版本号 + +`release.yml`(`.github/workflows/release.yml:96-100`)发布的镜像 tag 形态如下: + +| tag 形态 | 何时存在 | 含义 | +| ---------------------------- | ---------------- | --------------------------------- | +| `vMAJOR.MINOR.PATCH` | 每个稳定 release | 完整版本号,长期固定 | +| `MAJOR.MINOR.PATCH` | 每个稳定 release | 同上,semver 标准形式 | +| `MAJOR.MINOR` | 仅稳定 release | minor 滚动 tag | +| `latest` | 仅稳定 release | 始终指向最新稳定版 | +| `vMAJOR.MINOR.PATCH-alpha.N` | 预发布 | 不会触碰 `latest` / `MAJOR.MINOR` | +| `vMAJOR.MINOR.PATCH-beta.N` | 预发布 | 不会触碰 `latest` / `MAJOR.MINOR` | + +升级与回滚都通过把 `AUTOROUTER_IMAGE` 改成 `ghcr.io/g1331/autorouter:` 来完成。生产部署强烈建议显式 pin 到具体 `v*` tag 或 `@sha256:`,避免 `latest` 在某次 push 后悄悄漂移。版本号与 release notes 规则见架构介绍中的 [版本与发布](../architecture/release)。 + +## 升级前的兼容性确认 + +每次升级前先看 release notes,确认两件事: + +1. **数据库迁移是否包含破坏性变更**:删列、改类型、重命名等。release notes 中标记为 `BREAKING` 的迁移需要走「先迁后切」的流程(详见下文)。 +2. **环境变量是否变化**:新增的必填字段、被移除的字段、默认值变更。`.env` 缺失字段会导致启动期校验失败。 + +当前 release 在 GitHub Releases 页面的 `## Generated Notes` 段会按 `New Features` / `Bug Fixes` / `Security` / `Performance` / `Documentation` / `Tests` / `Maintenance` / `Other Changes` 分组(详见 [版本与发布](../architecture/release))。`Bug Fixes` 中含「迁移」字样的条目要特别留意。 + +## 路径 A:源码 + docker compose 升级 + +```bash +cd /opt/autorouter # 或本地仓库目录 + +# 1. 拉一次目标 tag 的最新 docker-compose.yml(不同版本之间可能有调整) +RELEASE_TAG=v0.2.0 +curl -fsSL -o docker-compose.yml \ + "https://raw.githubusercontent.com/g1331/AutoRouter/${RELEASE_TAG}/docker-compose.yml" + +# 2. 在 .env 中切换 AUTOROUTER_IMAGE +sed -i "s|^AUTOROUTER_IMAGE=.*|AUTOROUTER_IMAGE=ghcr.io/g1331/autorouter:${RELEASE_TAG}|" .env + +# 3. 拉镜像 +docker compose pull autorouter + +# 4. 启动 +docker compose up -d +``` + +带 sidecar 的部署多两步: + +```bash +# 1b. 同步 sidecar 叠加文件(升级新 release 的 cliproxy 模板/脚本变化) +curl -fsSL -o docker-compose.cliproxy.yml \ + "https://raw.githubusercontent.com/g1331/AutoRouter/${RELEASE_TAG}/docker-compose.cliproxy.yml" +curl -fsSL -o cliproxy/config.yaml.template \ + "https://raw.githubusercontent.com/g1331/AutoRouter/${RELEASE_TAG}/cliproxy/config.yaml.template" +curl -fsSL -o cliproxy/docker-entrypoint.sh \ + "https://raw.githubusercontent.com/g1331/AutoRouter/${RELEASE_TAG}/cliproxy/docker-entrypoint.sh" +chmod +x cliproxy/docker-entrypoint.sh + +# 4b. 双 -f 启动 +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d +``` + +`docker compose up -d` 看到镜像变化会重建对应容器;`postgres-data`、`autorouter-data`、`cliproxy-auth`、`cliproxy-logs` 等 named volume 是持久的,重建容器不会清空。 + +### 升级后 smoke + +```bash +curl http://localhost:3331/api/health +``` + +预期返回中 `version` 字段为目标版本号(不带 `v` 前缀)。再带上 admin token 验证管理 API: + +```bash +curl -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + http://localhost:3331/api/admin/health?active_only=true +``` + +返回 `200` + 健康上游列表 = 应用、数据库、管理鉴权三道关都通了。 + +## 路径 B:CI + 远端 SSH 升级 + +`deploy-personal.yml` 把上述步骤自动化。每次升级: + +1. 等 `release.yml` 把新 tag 推到 ghcr.io(GitHub Actions 页面看到对应 release 即可)。 +2. 在 GitHub Actions 页面手工触发 `Personal Deploy`,`image_ref` 与 `confirm_release_id` 都填新 tag。 +3. 等待流水线完成,`Verify deployment` 步骤会自动 smoke `/api/health`、`/api/admin/health` 与一笔完整代理转发。 + +工作流远端执行时只会覆写 `.env` 中的 `AUTOROUTER_IMAGE` 与 `ADMIN_TOKEN` 两行(`.github/workflows/deploy-personal.yml:112-127`),其他字段保留。这保证升级 / 回滚不会重置数据库密码与 `ENCRYPTION_KEY`,原数据继续可读。 + +::: warning sidecar 不会被 CI 同步 +`deploy-personal.yml` 只 `curl` 主 `docker-compose.yml`。升级新 release 时如果 `docker-compose.cliproxy.yml` 或 `cliproxy/` 目录有变化(例如新增 CLIPROXY 变量、调整 entrypoint),CI 不会自动同步。需要手工按 [CI 部署后追加 CLIProxyAPI sidecar](./cliproxy-sidecar) 的「升级与回滚」段更新 sidecar 资料。 ::: -## 计划覆盖的内容 +## 数据库迁移与升级顺序 + +`deploy-personal.yml` 当前不会在远端自动跑数据库迁移,迁移由部署人手工触发。这导致升级时必须按 schema 兼容性区分顺序: + +### 前向兼容的迁移 + +新版本仅新增列 / 新增可空字段 / 新增表 / 索引变化,旧版本应用代码不读新字段。这类升级可以「先切镜像、再跑迁移」或「先迁移、再切镜像」皆可,操作风险低: + +```bash +# 切镜像 +docker compose up -d +# 跑迁移(容器内) +docker compose exec autorouter node node_modules/drizzle-kit/bin.cjs migrate +``` + +### 破坏性迁移 + +新版本删列 / 改类型 / 重命名表 / 修改约束。此时旧版本应用如果还在跑、又遇到新 schema,会立刻失败。必须按下面顺序: + +1. 短暂停业务:`docker compose stop autorouter`(PG 容器保持运行)。 +2. 跑迁移:`docker compose exec db psql -U autorouter -d autorouter -f /tmp/migrate.sql`,或者临时启动一个新版本镜像在 entrypoint 加 `--migrate-only` 等价物(项目当前没有该选项,手工跑 `node drizzle-kit migrate` 是标准做法)。 +3. 切镜像:修改 `.env` 中 `AUTOROUTER_IMAGE`,`docker compose up -d autorouter`。 + +破坏性迁移不能回滚——回滚意味着把已经 `DROP` 掉的列变回来,等价于「换库」。因此破坏性升级**必须**在升级前完成 `pg_dump` 离线备份。 + +## 回滚到上一个版本 + +回滚的路径与升级镜像(镜像方向相反)相同。 + +### 路径 A:源码 + docker compose + +```bash +# 1. 拉对应旧 tag 的 docker-compose.yml(保证编排与旧版本对齐) +PREVIOUS_TAG=v0.1.0 +curl -fsSL -o docker-compose.yml \ + "https://raw.githubusercontent.com/g1331/AutoRouter/${PREVIOUS_TAG}/docker-compose.yml" + +# 2. 切回旧镜像 +sed -i "s|^AUTOROUTER_IMAGE=.*|AUTOROUTER_IMAGE=ghcr.io/g1331/autorouter:${PREVIOUS_TAG}|" .env + +# 3. 拉镜像 +docker compose pull autorouter + +# 4. 启动 +docker compose up -d +``` + +### 路径 B:CI + 远端 SSH + +直接触发 `deploy-personal.yml`,`image_ref` 与 `confirm_release_id` 填到目标旧 tag。流水线会自动用旧 tag 对应的 `docker-compose.yml` 与镜像覆盖运行版本。 + +### 回滚的 schema 限制 + +回滚的前提是:旧版本 schema 与当前数据库 schema **完全兼容**。三种情形分别处理: + +| 场景 | 处置 | +| --------------------------------------------------- | ----------------------------------------------------------------------- | +| 升级时没跑过新 release 的迁移 | 直接切回镜像即可 | +| 升级跑了新 release 的迁移,但都是前向兼容(仅新增) | 直接切回镜像;旧版本看不到新字段但能继续工作 | +| 升级跑了破坏性迁移 | 必须先用 `pg_dump` 备份回灌到迁移前状态,再切回旧镜像;无备份则无法回滚 | + +::: danger 没有备份就没有破坏性回滚 +破坏性迁移意味着旧版本应用代码与新 schema 不兼容、新版本代码也不再认识旧字段。回滚前如果没有迁移前的 dump,回滚会让应用容器在启动期立刻报字段缺失错误。强烈建议每次涉及 BREAKING 迁移的升级前都先做一份完整的 `pg_dump`(备份方式见 [数据持久化与备份](./persistence-backup))。 +::: + +## `.env` 在升级 / 回滚时的最小变更 + +正常的升级 / 回滚操作只动 `AUTOROUTER_IMAGE` 一行。其余字段保持原样: + +| 字段 | 升级 / 回滚时是否需要变更 | +| ---------------------------------- | --------------------------------------------------------------------- | +| `AUTOROUTER_IMAGE` | 是。切到目标 tag 或 digest | +| `POSTGRES_*` / `DATABASE_URL` | 否。改这些会让新容器连不上现有数据库 | +| `ENCRYPTION_KEY` | 否。改这些会让原本加密的字段全部不可解 | +| `ADMIN_TOKEN` | 否。除非主动轮换;CI 部署模式下会被 secret 覆盖 | +| `PORT` | 否。除非有端口冲突需要换 | +| `CLIPROXY_*` | 否。除非随版本调整凭据 | +| `RECORDER_*`(已废弃为运行时配置) | 否。这些已经不再影响运行期行为,运行期开关在管理后台 Runtime Settings | + +任何「需要顺手改一下密码 / 密钥」的需求与升级 / 回滚解耦:先单独完成密钥轮换并验证可用,再做版本切换。混在一起做出问题时难以定位是版本还是密钥的问题。 + +## 升级失败时的快速回滚清单 + +按下面三步快速回到上一个工作版本: + +1. **找回上一个 tag**:从 `docker compose ps --format json` 或 GitHub Actions 「最近一次成功的 deploy-personal.yml run」摘要里取出上一次部署的 tag。 +2. **回滚镜像**:按上节「路径 A / 路径 B」之一切回去。 +3. **smoke**:`curl /api/health` 看 `version` 字段、再带 admin token 看 `/api/admin/health`。两项都通过则回滚成功。 + +如果 `/api/health` 立即报 500、容器反复重启: -版本切换流程、`AUTOROUTER_IMAGE` 替换、出问题时回退到上一个 tag。 +| 现象 | 大概率原因 | +| -------------------------------------------- | ---------------------------------------------------------- | +| 日志中含 `ENCRYPTION_KEY` 校验失败 | `.env` 被误改或丢字段。先从备份恢复 `.env` | +| 日志中含 `column "x" does not exist` | 上次升级跑过破坏性迁移、当前 schema 已经不兼容回滚目标 | +| 日志中含 `password authentication failed` | `.env` 与 `db` 容器内 PG 密码不一致,常见于动手改过 `.env` | +| 容器启动后 30s 内被 healthcheck 判 unhealthy | 数据库还未 ready 或迁移阻塞了启动;查看 `db` 日志 | -## 在正文就绪前的临时建议 +更完整的排查路径见 [常见部署问题排查](./troubleshooting)。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `.github/workflows/release.yml`:镜像 tag 生成规则与 release notes 模板 +- `.github/workflows/deploy-personal.yml`:远端升级 / 回滚操作的实现(只覆写 `AUTOROUTER_IMAGE` 与 `ADMIN_TOKEN`) +- `docker-compose.yml`、`docker-compose.cliproxy.yml`:编排定义;版本之间的差异需要手工对齐 +- `src/lib/utils/config.ts`:启动期对 `ENCRYPTION_KEY` 与 `ADMIN_TOKEN` 的强制校验,决定了 `.env` 在升级时的不可变字段 From 503f3fc53b9361813c39b23c757b0bd975ec9d56 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 25 May 2026 21:24:34 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(architecture):=20fill=20phase=202=20ba?= =?UTF-8?q?tch=206C=20=E2=80=94=20testing=20+=20contributing=20+=20release?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐架构介绍最后 3 篇正文(协作面): - testing:把 tests/ 目录按 Vitest(unit + components)与 Playwright (e2e / a11y / visual)两条轴划分,澄清「tests/integration/ 当前 不存在」,并把 verify.yml 的 6 个 job 拓扑与本地复现 CI 流程串起来。 - contributing:从分支命名 / Conventional Commits / Prettier+ESLint +TS 的 strict / pre-commit Python 框架四段切入,给出 PR 流程与 OpenSpec 提案的适用边界,并把 CLAUDE.md 的提交边界规则落到具体 情形。 - release:用 SemVer + alpha/beta 渠道、release.yml 的 tag 正则与 ancestor 校验、git-cliff 基线计算、docker/metadata-action 的镜像 tag 矩阵、package.json version 与 tag 的关系串成完整发布链路。 完成 issue #167 Phase 2 架构介绍剩余 3/3 篇。 --- docs/guide/architecture/contributing.md | 226 ++++++++++++++++++++- docs/guide/architecture/release.md | 248 +++++++++++++++++++++++- docs/guide/architecture/testing.md | 228 +++++++++++++++++++++- 3 files changed, 672 insertions(+), 30 deletions(-) diff --git a/docs/guide/architecture/contributing.md b/docs/guide/architecture/contributing.md index 4cd8eb7a..2abc0634 100644 --- a/docs/guide/architecture/contributing.md +++ b/docs/guide/architecture/contributing.md @@ -5,19 +5,225 @@ outline: deep # 贡献指南与代码规范 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +本页面向第一次给 AutoRouter 提交 PR 的人:从在哪里讨论需求、用什么分支、代码风格如何统一、pre-commit 钩子做什么、PR 合入路径、OpenSpec 提案的使用场景。所有规则的事实来源是仓库中的 `.pre-commit-config.yaml`、`eslint.config.mjs`、`.prettierrc`、`.github/workflows/verify.yml`、`openspec/` 目录。本文只是把这些零散事实串成可执行的协作路径。 + +不在本页范围内的内容:测试如何组织见 [测试策略](./testing);版本号与 release 流程见 [版本与发布](./release);CI 工作流细节见 [GitHub Actions CI 部署](../deployment/github-actions)。 + +## 协作前先看哪几样 + +建议在动手写代码之前先看: + +| 资料 | 看的目的 | +| ------------------------------ | ------------------------------------------------------ | +| 仓库 `master` 分支 `README.md` | 当前能力、技术栈、运行方式 | +| `CLAUDE.md` | 项目对协作者的约定(含代码风格、目录布局、常用命令) | +| GitHub Issues | 是否已有相关讨论或正在做的变更 | +| `openspec/` 目录 | 大型变更的提案、规格、任务拆解(详见下文 OpenSpec 段) | +| `.github/workflows/verify.yml` | PR 要通过哪些 CI 门禁 | + +避免重复造轮子的最好办法是先在 issue 区或现有 OpenSpec 变更里找一遍,再决定要不要新开。 + +## 分支与提交 + +### 分支 + +- 主分支:`master`。所有 PR 合入 `master`,再由 `release.yml` 在打 tag 时构建镜像。 +- 功能分支:从 `master` 拉取,命名建议带前缀,例如 `feat/xxx`、`fix/xxx`、`docs/xxx`、`chore/xxx`、`refactor/xxx`。 +- 实验分支:长期未合的实验性分支不建议留在 origin 上,本地用即可。 + +### Commit 信息 + +仓库使用 Conventional Commits 风格,`cliff.toml` 中的 `commit_parsers`(`cliff.toml:44-56`)把以下前缀映射到 release notes 分组: + +| 前缀 | 进入 release notes 哪一组 | +| ------------------------ | ------------------------- | +| `feat` | New Features | +| `fix` | Bug Fixes | +| `security` | Security | +| `perf` | Performance | +| `docs` / `doc` | Documentation | +| `test` | Tests | +| `refactor` | Maintenance | +| `ci` / `build` / `chore` | Maintenance | +| 任何其他 | Other Changes | + +带 scope 也可以(例如 `feat(billing): ...`),release notes 渲染时会自动去掉 `(scope)!:` 这一段。`Merge pull request` 与 `Merge branch` 在 release notes 中跳过。 + +把每个 commit 的第一行写成「能直接进 release notes 的描述」是一个低成本的协作习惯——这样 release notes 不需要后期手工润色。 + +### 不要做的 + +- `--no-verify` 跳 pre-commit。CLAUDE.md 已经显式禁止。 +- 一次 PR 把无关的多个主题混在一起。多主题 PR 评审困难、回滚困难、release notes 也会脏。 +- 大段的「顺手清理 / 重排顺序 / 改命名」夹在主题改动里。这类纯格式 / 命名变更最好单独 PR。 + +## 代码风格 + +| 工具 | 配置文件 | 触发时机 | +| ---------- | -------------------------------- | ----------------------------------------------- | +| Prettier | `.prettierrc` | pre-commit 钩子 + `verify.yml` 的 `quality` job | +| ESLint | `eslint.config.mjs` | pre-commit 钩子 + `verify.yml` | +| TypeScript | `tsconfig.json` + `tsc --noEmit` | pre-commit 钩子 + `verify.yml` | + +### Prettier + +仓库的 `.prettierrc`: + +```json +{ + "singleQuote": false, + "trailingComma": "es5", + "semi": true, + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} +``` + +要点:双引号;结尾分号;行宽 100;箭头函数始终带括号;行尾 LF。Windows 上 git 默认会把 LF 转 CRLF,建议在仓库目录下: + +```bash +git config core.autocrlf false +``` + +避免在 commit 时把 LF 误转成 CRLF 导致 Prettier 整文件 reformat。 + +### ESLint + +`eslint.config.mjs` 基于 `eslint-config-next` 的 `core-web-vitals` 与 `typescript` 预设,附加几条本地规则: + +| 规则 | 取值 | 用途 | +| ----------------------------------- | -------------------------------------------------------- | ------------------------------------------ | +| `no-console` | `warn`,允许 `console.warn` / `console.error` | 防止误把临时 `console.log` 留到生产代码 | +| `no-restricted-imports` | 禁 `../*../*` | 阻止三层及以上相对路径,强制走 `@/` 别名 | +| `@typescript-eslint/no-unused-vars` | `warn`,允许 `^_` 前缀 | 未用变量降为告警,便于在工作过程中保留占位 | +| `tsdoc/syntax` | `warn`(针对 `src/**/*.ts`,跳过 components/hooks) | 公共 API 的 TSDoc 语法校验 | +| `jsdoc/*` | `warn`(针对 `src/lib/services/**` 与 `src/app/api/**`) | service 与 API 入口要求最低限度的文档覆盖 | + +`pnpm lint` 即跑全套;本地修复用 `pnpm exec eslint --fix <文件>`。 + +### TypeScript + +整个仓库走 strict 模式(`tsconfig.json` 内 `"strict": true`)。CI 与 pre-commit 钩子都跑 `pnpm exec tsc --noEmit`。本地写代码时若 IDE 与 CLI 报错不一致,先在 IDE 里 reload TS server,再看是否有 stale 缓存。 + +CLAUDE.md 显式说明:**不写无依据的防御性编程 / 埋雷式保护逻辑**。这条不被 eslint 强制,但属于评审时会反复提的口径。 + +## pre-commit 钩子 + +仓库使用 Python `pre-commit` 框架(不是 husky)。配置在 `.pre-commit-config.yaml`,本地安装: + +```bash +pip install pre-commit # 或 pipx install pre-commit +pre-commit install # 写入 .git/hooks/pre-commit +``` + +`.pre-commit-config.yaml` 中的钩子分两段: + +### 通用文件检查 + +来自 `pre-commit/pre-commit-hooks` 的标准 hooks: + +| 钩子 | 触发条件 | +| ------------------------- | -------------------------------------------------------------------- | +| `check-added-large-files` | 拒绝大于 500 KB 的文件(`docs/images/` 例外) | +| `check-yaml` | 校验 YAML 语法(`.claude` / `.codex` / `.gemini` / `openspec` 排除) | +| `check-toml` | 校验 TOML 语法 | +| `check-json` | 校验 JSON 语法 | +| `end-of-file-fixer` | 文件结尾必须有换行 | +| `trailing-whitespace` | 行尾无空白 | + +### 本地 hooks + +| 钩子 | 命令 | 范围 | +| ---------- | ---------------------------- | -------------------------------------------- | +| `prettier` | `pnpm exec prettier --check` | `*.(js,jsx,ts,tsx,css,json,md,yml,yaml)` | +| `eslint` | `pnpm exec eslint --fix` | `src/**/*.(js,jsx,ts,tsx)` | +| `tsc` | `pnpm exec tsc --noEmit` | 仓库内有 `*.ts` / `*.tsx` 改动时全量类型检查 | + +`tsc` 钩子设置了 `pass_filenames: false`:单文件改动也会触发全量类型检查,因为 TypeScript 的依赖图意味着小改动可能让其他文件报错。 + +::: warning 不要用 --no-verify 跳过失败 +任何 pre-commit 失败都应该先修复再 commit,不要用 `git commit --no-verify` 跳过。CI 的 `verify.yml` 会跑同样的检查;本地跳过只是把失败推到 PR 审查阶段,浪费协作者时间。 +::: + +如果钩子误报或确实需要跳过,按下面顺序处理: + +1. 大文件超过 500KB:先确认是不是该提交(是不是构建产物 / 依赖 / 临时数据)。是就放在 `.gitignore` 里;确实需要的资源在 `.pre-commit-config.yaml` 中 explicit allowlist。 +2. Prettier 误报:通常是行尾或换行问题。`pnpm exec prettier --write <文件>` 让 Prettier 自动修复。 +3. ESLint 报某条规则太严:先看是不是项目层面的口径问题;若确实需要例外,按 ESLint 的 `// eslint-disable-next-line ` 单行 disable,并在 commit 里说明原因。 + +## PR 流程 + +1. **提 issue 或留言**:若改动属于「方案有不同选择」「会改公共接口」「跨多个模块」,先在 issue / OpenSpec 提案里说明意图,避免方向走错。 +2. **拉分支、写实现、跑测试**:本地至少跑 `pnpm test:run` 与 `pnpm exec tsc --noEmit`,必要时跑 `pnpm e2e`。 +3. **提 PR**:标题与首条 commit 风格一致(Conventional Commits);PR body 简要写:动机、改了什么、是否引入破坏性变更、是否需要数据库迁移。模板可参考 `cliff.toml` 各分组的实际产出。 +4. **等 CI**:`verify.yml` 的 `verify-status` 必须通过;docs 改动会触发 `docs.yml`。CI 失败先看日志而不是反复重试。 +5. **响应 review**:fix 类响应直接 push 新 commit;不强行 rebase / squash,PR 合入时由 reviewer 选 squash / merge 策略。 +6. **合入**:默认由 reviewer 操作 merge。涉及多 commit 的功能 PR 推荐用 squash merge 让 master 历史保持线性,docs 类多文件 PR 也走相同方式。 + +## OpenSpec 提案 + +仓库内置 OpenSpec 工作流(目录 `openspec/`)。当一项变更同时具备以下任一特征时,建议先开 OpenSpec 提案再动手写代码: + +- 影响多个模块的设计选择,例如新增一类上游 / 改写鉴权流程。 +- 引入新的运行期组件(后台任务、缓存层、外部依赖)。 +- 涉及公开 API / 数据库 schema 变更,且不止 1~2 张表。 +- 需要在 PR 之间共享语境,例如「先合 A 再合 B」。 + +OpenSpec 把变更拆成几类 artifact: + +| Artifact | 作用 | +| ------------- | -------------------------------------------- | +| `proposal.md` | 问题动机、目标、不在范围内的内容 | +| `design.md` | 设计决策、考虑过但否决的方案、关键 trade-off | +| `tasks.md` | 落实拆解(phase + 任务) | +| `specs/...` | 规格 spec(新增或 delta) | + +新建变更的方式:通过 `openspec` 系列命令(详见仓库 `openspec/config.yaml` 与命令使用说明)或直接在 `openspec/changes//` 下手动创建。完成后通过 `openspec archive` 把变更归档到 `openspec/changes/archive/`。 + +::: tip OpenSpec 不是必选门槛 +小范围 bugfix、文档补齐、依赖升级、零散重构等并不需要走 OpenSpec。判断依据:「半小时内能讲清楚的改动」一般不需要。开 OpenSpec 反而拖慢。 ::: -## 计划覆盖的内容 +## 文档变更约定 + +仓库的 `docs/` 目录采用 VitePress 站点(详见 [GitHub Actions CI 部署](../deployment/github-actions) 中 `docs.yml` 的部分)。新增 / 修改文档要点: + +- 文件使用 `.md` 扩展名,frontmatter 至少包含 `title` 与 `outline: deep`。 +- 链接到其他文档使用相对路径(例如 `[环境变量参考](./env-reference)`),不要用绝对路径。 +- 新增页面同时更新 `docs/.vitepress/config.ts` 中对应 sidebar,否则页面只能通过直链访问。 +- 不要在 commit 里添加自动生成的 `docs/.vitepress/dist/` 产物。 +- 涉及多语言时同步 `docs/en/`;目前 `docs/en/` 仅有 placeholder,新增中文文档时不强制要求同步英文版本。 + +文档类 PR 与代码类 PR 走相同的 CI 门禁;`docs.yml` 会校验 VitePress 构建本身是否通过。 + +## 提交内容的边界 + +CLAUDE.md 写得很清楚: + +- 只修改与任务相关的文件。 +- 避免引入无关的结构调整、命名变动和样式漂移。 +- 保留与当前任务无关的已有改动。 +- 未经明确授权,不执行破坏性操作。 + +实际操作上的几个判定: -提交流程、pre-commit 钩子、ESLint 与 Prettier、OpenSpec 提案的使用方式。 +| 情形 | 处理 | +| ------------------------------------------ | ------------------------------------------------------------------------------- | +| 顺手发现旁边一段代码不规范 | 单独开 PR 修;本 PR 不混入 | +| 升级一个依赖时发现 lockfile 大幅变化 | 用 `pnpm install --frozen-lockfile` 验证;diff 中确认确实是该依赖的传递依赖变化 | +| 改 schema 想顺手清理一张「看起来没用的表」 | 不要。这种表往往在某个角落仍被引用;先确认引用情况再单独 PR | +| 改 UI 顺手把一个组件目录重命名 | 把重命名与功能改动分两个 PR | -## 在正文就绪前的临时建议 +每个 PR 越窄,被合入的概率越高,回滚的成本越低。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `.pre-commit-config.yaml`:钩子定义与排除规则 +- `eslint.config.mjs`:ESLint 规则集与文件作用域 +- `.prettierrc`:格式化口径 +- `tsconfig.json`:TypeScript 严格模式约束 +- `.github/workflows/verify.yml`:CI 门禁的实际命令 +- `cliff.toml`:commit 前缀到 release notes 分组的映射 +- `openspec/config.yaml`、`openspec/changes/`、`openspec/specs/`:OpenSpec 工作流的事实来源 +- `CLAUDE.md`:项目协作口径 diff --git a/docs/guide/architecture/release.md b/docs/guide/architecture/release.md index 4f993759..9e550e53 100644 --- a/docs/guide/architecture/release.md +++ b/docs/guide/architecture/release.md @@ -5,19 +5,247 @@ outline: deep # 版本与发布 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 按 SemVer 风格做版本管理,所有正式发布都通过「打 tag → `release.yml` 构建镜像 → 写 GitHub Release」这条链路落地,没有手工写 release notes 的环节。`git-cliff` 按 `cliff.toml` 把 commits 分组渲染成 changelog,镜像 tag 由 `docker/metadata-action` 按规则生成。本页梳理整套流程的版本号规则、tag 形态、release notes 生成、镜像 tag 策略、与升级 / 回滚的衔接。 + +不在本页范围内的内容:CI 工作流本身见 [GitHub Actions CI 部署](../deployment/github-actions);部署 / 升级 / 回滚的具体命令见 [升级与回滚](../deployment/upgrade-rollback);Conventional Commits 在贡献流程里的扮演见 [贡献指南与代码规范](./contributing)。 + +## 版本号规则 + +仓库使用 SemVer 风格的 `MAJOR.MINOR.PATCH`,可选追加 `-alpha.N` 或 `-beta.N` 后缀: + +| 形态 | 例子 | 含义 | +| ---------------------------- | ---------------- | ----------------------------------------------------------- | +| `vMAJOR.MINOR.PATCH` | `v0.2.0` | 稳定 release,会同时刷新 `latest` 与 `MAJOR.MINOR` 镜像 tag | +| `vMAJOR.MINOR.PATCH-alpha.N` | `v0.3.0-alpha.1` | 公开预览。预发布渠道;不会触碰 `latest` / `MAJOR.MINOR` | +| `vMAJOR.MINOR.PATCH-beta.N` | `v0.3.0-beta.2` | 公开预览。预发布渠道;不会触碰 `latest` / `MAJOR.MINOR` | + +`release.yml` 通过下列正则强制 tag 形态(`.github/workflows/release.yml:39`): + +```text +^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ +``` + +不在该正则范围内的 tag(例如 `v0.1.0-rc.1` 或 `v0.1`)都会被流水线直接拒绝。 + +### 何时升 PATCH / MINOR / MAJOR + +| 升哪一位 | 触发条件 | +| -------- | ----------------------------------------------------------------------------------------------------- | +| PATCH | 仅含 bugfix / 文档 / 内部重构;不改公开 API、不引入破坏性 schema 迁移 | +| MINOR | 新增功能;可能新增 schema 列或表,但保持向后兼容;保留旧字段 | +| MAJOR | 不向后兼容的变更:删表 / 删字段 / 改公开 API 行为 / 默认值的不兼容调整 / 强制要求新增的必填环境变量等 | + +`0.x` 阶段对 SemVer 的承诺较弱:MINOR 之间可能存在小幅破坏性变更,但仍建议尽量保留向后兼容、并在 release notes 与升级文档里显式列出。 + +### 何时用 alpha / beta + +- **alpha**:内部 / 早期试用。预期会有调整空间,不应当用于生产。 +- **beta**:API 表层稳定,邀请较多人试用。可以用于生产,但建议显式 pin 到具体 `-beta.N`。 + +`alpha` 与 `beta` 的发布频率没有硬性规定。常见路径是「先发若干个 `-alpha.N` 收集反馈,再发若干个 `-beta.N` 稳定一波,最后发对应 `vMAJOR.MINOR.PATCH` 正式版」。 + +## tag 与提交的关系 + +`release.yml` 对 tag 指向的 commit 还有一道硬约束(`.github/workflows/release.yml:47-51`): + +```bash +git fetch origin master +if ! git merge-base --is-ancestor "${RELEASE_COMMIT}" origin/master; then + echo "::error::Release tag must point to a commit contained in origin/master" + exit 1 +fi +``` + +意思是:tag 必须指向 `origin/master` 路径上的 commit。这条约束防止在 feature 分支上误打 tag 后发出脏镜像,也意味着「先合 PR 到 master、再打 tag」是唯一允许的顺序。 + +完整发布流程: + +1. 在 `master` 上确认要发布的 commit。 +2. 在该 commit 上打 tag: + + ```bash + git tag v0.2.0 + git push origin v0.2.0 + ``` + +3. `release.yml` 自动触发,跑校验 / 构建 / 推送 / 生成 release。 +4. 之后由部署侧通过 [升级与回滚](../deployment/upgrade-rollback) 决定何时切到新镜像。 + +### 误打 tag 怎么办 + +如果 tag 已经 push 但 release 不应当发出: + +```bash +# 1. 等 release.yml 跑完(被流水线拒绝最干净;若已成功则继续) +git push --delete origin v0.2.0 +git tag -d v0.2.0 + +# 2. 已经创建了 GitHub Release:在 Releases 页面 → 该 release → Delete +# 3. 已经推到 ghcr.io:尝试在 ghcr 控制台删除该 tag 对应的版本 +``` + +仍残留的镜像 tag 会让 `deploy-personal.yml` 仍能拉到错版本,因此误发后的清理必须完整覆盖「git tag、GitHub Release、ghcr.io 镜像」三处。 + +## 镜像 tag 策略 + +`docker/metadata-action` 根据 release.yml 中的 `tags:` 段生成镜像 tag 集合(`.github/workflows/release.yml:96-100`): + +```yaml +tags: | + type=raw,value=${{ github.ref_name }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(steps.validate.outputs.release_version, '-') }} + type=raw,value=latest,enable=${{ !contains(steps.validate.outputs.release_version, '-') }} +``` + +对应实际生成的 tag: + +| 来源规则 | 稳定 release(`v0.2.0`) | 预发布(`v0.3.0-alpha.1`) | +| ----------------------------------------- | ------------------------ | -------------------------- | +| `type=raw,value=${{ github.ref_name }}` | `v0.2.0` | `v0.3.0-alpha.1` | +| `type=semver,pattern={{version}}` | `0.2.0` | `0.3.0-alpha.1` | +| `type=semver,pattern={{major}}.{{minor}}` | `0.2` | —(不生成) | +| `type=raw,value=latest` | `latest` | —(不生成) | + +设计目的: + +- `latest` 与 `MAJOR.MINOR` 滚动 tag 始终指向最新稳定版,方便快速试用。 +- 预发布 tag 只创建「与 tag 自身同名」的镜像,避免预览版本被默认拉取到生产。 +- `v0.2.0` 与 `0.2.0` 同时存在,是为了兼容部分客户端只识别带 / 不带 `v` 前缀两种风格。 + +::: warning 生产部署不要用 latest +`latest` 的好处是「不需要查最新版本号」,代价是每次 release 后语义在悄悄漂移。生产部署应当显式 pin 到具体 `vMAJOR.MINOR.PATCH` 或 `@sha256:`,详见 [升级与回滚](../deployment/upgrade-rollback)。 ::: -## 计划覆盖的内容 +## 镜像平台与构建缓存 + +`docker/build-push-action` 在 release.yml 中固定 `platforms: linux/amd64`。当前不构建 arm64 镜像;arm 平台的部署需要自行 build。 + +构建缓存通过 `type=gha` 复用 GitHub Actions 缓存,跨同一仓库的不同 release 共享,减少重复构建成本。缓存的 invalidation 由 buildx 自身管理,通常不需要人工干预。 + +## release notes 自动生成 + +release notes 不写手稿,由 `git-cliff` + `cliff.toml` 自动渲染。 + +### 基线计算 + +每次发布要确定一个「对比起点」(previous tag),release notes 的内容是「上次到本次之间的所有 commits」。基线计算逻辑见 `release.yml:116-159`: + +| 当前 tag 类型 | 基线选择 | +| ---------------- | ---------------------------------------------------------------------- | +| 稳定 `vN.N.N` | 取当前 commit 可达的最近一个稳定 tag | +| `vN.N.N-alpha.N` | 取「同一基线下同渠道的上一颗预发布 tag」,没有则回退到最近一个稳定 tag | +| `vN.N.N-beta.N` | 取「同一基线下同渠道的上一颗预发布 tag」,没有则回退到最近一个稳定 tag | + +举例: + +- `v0.2.0` 之前的稳定 tag 是 `v0.1.0`,基线就是 `v0.1.0`。 +- `v0.3.0-alpha.1` 是某基线下首颗 alpha,没有同渠道前任,基线退化到最近的稳定 `v0.2.0`。 +- `v0.3.0-alpha.2`:基线是 `v0.3.0-alpha.1`。 +- `v0.3.0-beta.1`:基线是「v0.3.0 基线下同渠道(beta)」的上一颗 beta;没有则退化到最近稳定。 + +预发布 tag 渲染 changelog 时会带上 `--ignore-tags '.*-(alpha|beta)\\.[0-9]+$'`,避免预发布 tag 被当成稳定版本写入对比关系。 + +### commit 分组 + +`cliff.toml:44-56` 定义了 commit 前缀到分组的映射: + +| 前缀 | 分组 | +| ------------------------ | ------------- | +| `feat` | New Features | +| `fix` | Bug Fixes | +| `security` | Security | +| `perf` | Performance | +| `docs` / `doc` | Documentation | +| `test` | Tests | +| `refactor` | Maintenance | +| `ci` / `build` / `chore` | Maintenance | +| 其他 | Other Changes | + +`cliff.toml:13` 把分组顺序固定为: + +``` +New Features → Bug Fixes → Security → Performance → Documentation → Tests → Maintenance → Other Changes +``` + +每条 commit 渲染时按 commit subject(或者关联 PR 的 title)展示,自动追加 PR 链接: + +```text +- Some commit subject ([#42](https://github.com/g1331/AutoRouter/pull/42)) +``` + +`cliff.toml:33-35` 的 postprocessor 会把 commit subject 中开头的 `feat(scope)!:` / `fix:` 等前缀去掉,避免 release notes 中重复出现「fix: fix bug」之类的累赘。 + +`Merge pull request` 与 `Merge branch` 会被显式 skip。 + +### release body 结构 + +release.yml 把 release body 拼接为: + +``` +## Release Metadata + +- Tag: ... +- Release version: ... +- Package version: ... +- Commit: ... +- Previous tag: ... +- Compare range: ... +- Image: ghcr.io/g1331/autorouter:vN.N.N +- Image digest: sha256:... + +## Generated Notes + + + +## Changelog + +Full Changelog: https://github.com/g1331/AutoRouter/compare/... +``` + +每次 release 都会同时上传 `release-body.md` 与 `release-metadata.json` 作为 artifact,便于事后审计「这个 release 的镜像 digest 是多少」「对比基线是哪一颗 tag」等问题。 + +## `package.json` version 与 tag 的关系 + +`release.yml:36-37` 在校验阶段读取 `package.json` 中的 `version`,与 tag(去掉 `v` 前缀)做对比并双写进 release metadata: + +```bash +PACKAGE_VERSION=$(node -p "require('./package.json').version") +ACTUAL_TAG="${GITHUB_REF_NAME}" +``` + +当前流水线**不强制**两者一致——`release-metadata.json` 中分别记录 `releaseVersion`(来自 tag)与 `packageVersion`(来自 `package.json`)。推荐每次打 tag 前都在 PR 中同步更新 `package.json` 的 `version` 字段,让两者保持一致,避免 `npm version` 与 git tag 漂移。 + +实际应用版本号通过 `NEXT_PUBLIC_APP_VERSION` 注入到镜像构建产物里(`release.yml:73`),并由 `/api/health` 端点返回。`deploy-personal.yml` 的 verify 阶段会比对 `/api/health` 返回的 `version` 与 `confirm_release_id`,二者必须一致。 + +## 发布前检查清单 + +打 tag 之前确认: + +| 检查 | 处理 | +| ---------------------------------------------------------------- | ---------------------------------- | +| 当前 commit 在 `origin/master` 上 | 否则 `release.yml` 直接拒绝 | +| `package.json` 的 `version` 已更新到目标版本号 | PR 中同步改 | +| `verify.yml` 在该 commit 上已经 `verify-status` 通过 | 否则发出去的镜像可能有未发现的回归 | +| 涉及 schema 变更:迁移已生成、`db:check:consistency` 通过 | PR 阶段就要确认 | +| 涉及破坏性变更:commit / PR title 已 `feat!:` / `fix!:` 显式标注 | 影响后续 changelog 解读 | +| `docker-compose.yml` 与 `docker-compose.cliproxy.yml` 改动 | 升级文档中要交代清楚 | + +预发布渠道(alpha/beta)的检查清单可以略松,但破坏性变更与 schema 迁移仍然必须显式标注。 -语义化版本、alpha / beta 标签、`cliff.toml` 自动生成 release notes、镜像 tag 策略。 +## 与升级 / 回滚的衔接 -## 在正文就绪前的临时建议 +| 文档 | 衔接点 | +| ------------------------------------------------------ | ---------------------------------------------------------- | +| [GitHub Actions CI 部署](../deployment/github-actions) | 镜像构建、远端 SSH 部署、smoke 步骤的细节 | +| [升级与回滚](../deployment/upgrade-rollback) | `AUTOROUTER_IMAGE` 切换的实际操作、schema 兼容性的处理顺序 | +| [数据持久化与备份](../deployment/persistence-backup) | 破坏性升级前的 `pg_dump` 必要性 | +| [贡献指南与代码规范](./contributing) | Conventional Commits 前缀如何影响 release notes 分组 | -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `.github/workflows/release.yml`:tag 校验、镜像 tag 规则、基线计算、release body 拼装 +- `.github/workflows/deploy-personal.yml`:verify 阶段如何比对 `/api/health.version` 与 `confirm_release_id` +- `cliff.toml`:commit 分组规则与 postprocessor +- `package.json`:`version` 字段与 `release.yml` 的版本比对 +- `docker-compose.yml`:`AUTOROUTER_IMAGE` 默认 `ghcr.io/g1331/autorouter:latest` 的来源 diff --git a/docs/guide/architecture/testing.md b/docs/guide/architecture/testing.md index 28ec025e..69cef22f 100644 --- a/docs/guide/architecture/testing.md +++ b/docs/guide/architecture/testing.md @@ -5,19 +5,227 @@ outline: deep # 测试策略 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 的自动化测试沿两条轴展开:按运行环境分为 Vitest(单元 + 组件)与 Playwright(E2E)两套工具;按目的分为单元行为、组件渲染、a11y、视觉回归、E2E 流程、代理稳定性、迁移幂等。所有测试入口都在 `package.json` scripts 段声明,CI 通过 `verify.yml` 串成 6 个并行 job。本页说明这套布局的目的、`tests/` 目录的边界划分、如何在本地复现 CI 流程。 + +不在本页范围内的内容:CI 工作流本身见 [GitHub Actions CI 部署](../deployment/github-actions);迁移一致性的具体校验逻辑见 [数据库选型与初始化](../deployment/database);贡献流程与 pre-commit 配置见 [贡献指南与代码规范](./contributing)。 + +## 测试工具与命令对照 + +| 工具 | 命令 | 覆盖范围 | +| ------------------- | --------------------------- | ---------------------------------------------------------- | +| Vitest(监听模式) | `pnpm test` | 本地开发实时反馈 | +| Vitest(一次运行) | `pnpm test:run` | CI 与 pre-push 校验 | +| Vitest(覆盖率) | `pnpm test:run --coverage` | 同上,加 v8 coverage | +| Playwright E2E | `pnpm e2e` | 真实浏览器端到端走查 | +| Playwright(带 UI) | `pnpm e2e:headed` | 本地排查 E2E 用 | +| 代理稳定性 smoke | `pnpm test:proxy-stability` | 把 mock 上游接到真实代理路径,验证 SSE / 非流式 / 故障转移 | +| 迁移一致性 | `pnpm db:check:consistency` | `drizzle/` 与 `drizzle-sqlite/` 是否与 schema 对齐 | + +`tests/` 下的目录结构按上述命令选择性 include。Vitest 配置(`vitest.config.ts:20`)显式声明: + +```ts +include: ["tests/components/**/*.test.{ts,tsx}", "tests/unit/**/*.test.{ts,tsx}"]; +``` + +即 `tests/components/` 与 `tests/unit/` 由 Vitest 跑;其他目录由 Playwright 等工具各自承接。 + +## `tests/` 目录划分 + +``` +tests/ +├── components/ # Vitest 组件测试(jsdom + React Testing Library) +│ ├── admin/ +│ ├── dashboard/ +│ └── ui/ +├── unit/ # Vitest 纯函数 / hook / route handler 单元测试 +│ ├── api/ +│ ├── hooks/ +│ ├── i18n/ +│ ├── lib/ +│ ├── scripts/ +│ ├── services/ +│ └── utils/ +├── e2e/ # Playwright E2E(真实 Chromium + SQLite dev server) +├── a11y/ # Playwright + axe-core 可访问性扫描 +├── visual/ # Playwright 截图视觉回归 +├── fixtures/ # 流量录制 fixture(openai / anthropic / google) +└── setup.ts # Vitest 全局 setup +``` + +各目录的边界判定如下: + +| 目录 | 何时放在这里 | 何时不放在这里 | +| ------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `tests/unit/` | 测试单个函数、route handler、hook 的输入输出;通过 mock 切断外部副作用 | 涉及多组件协作的渲染(去 `tests/components/`);需要真实浏览器(去 `e2e/`) | +| `tests/components/` | 单个 React 组件在 jsdom 下的渲染、交互、可访问性快速校验 | 完整页面跨路由跳转、Server Component 行为(去 `e2e/`) | +| `tests/e2e/` | 完整用户路径在真实浏览器中的可用性,例如「登录 → 创建上游 → 发请求 → 看日志」 | 单个函数行为 | +| `tests/a11y/` | 用 axe-core 扫页面级 a11y 缺陷 | 单组件 a11y(应在 `tests/components/` 中用 jest-dom 断言) | +| `tests/visual/` | 视觉回归,固定 viewport 截图对比 | 内容快速变化的页面(截图会频繁飘) | +| `tests/fixtures/` | 由流量录制写入的真实上游响应样本,供 `/api/mock/*` 回放或 fixture 驱动的单元测试 | 任何 `*.test.ts` / `*.spec.ts` 文件 | + +`tests/integration/` 这个目录当前**不存在**——历史上有过这个概念,目前已经被分散到 `tests/unit/api/` 与 `tests/e2e/` 两个目录。文档以仓库现状为准。 + +## Vitest 配置要点 + +`vitest.config.ts` 内的几个关键决策: + +| 配置 | 含义 | +| ---------------------------------- | ---------------------------------------------------------------------- | +| `environment: "jsdom"` | 所有测试都跑在 jsdom 里,React Testing Library 能直接渲染组件 | +| `globals: true` | 不需要在每个文件 `import { describe, it } from "vitest"` | +| `setupFiles: ["./tests/setup.ts"]` | jest-dom 断言、全局 mock、polyfill 都在这里挂 | +| `coverage.provider: "v8"` | v8 native coverage,性能优于 istanbul | +| `coverage.include` | 只测 `src/components/**`、`src/lib/**`、`src/hooks/**`,避开页面 / API | +| 别名 `@` → `./src` | 与 Next.js `tsconfig.json` 中的路径别名保持一致 | + +`coverage.include` 把覆盖率统计范围限定在「可单元化的纯逻辑」上。`src/app/` 下的 route handler 通常通过 `tests/unit/api/` 间接测试,但路径本身不计入覆盖率分母——避免被「Next.js 自动生成的 SSR 入口」拉低覆盖率指标。 + +## 单元测试的常见形态 + +### Route handler 单元测试 + +测试 `src/app/api/admin/*` 下的 route handler:构造 `NextRequest`,调用 handler,断言返回值。位于 `tests/unit/api/`: + +```ts +// tests/unit/api/admin/circuit-breakers/route.test.ts +const request = new NextRequest("http://localhost/api/admin/circuit-breakers", { + headers: { authorization: `Bearer ${adminToken}` }, +}); +const response = await GET(request); +expect(response.status).toBe(200); +``` + +通过 mock `@/lib/db` 与 `@/lib/services/*` 切断真实数据库。 + +### Service 单元测试 + +测试 `src/lib/services/*` 下的业务函数:`failover-config.test.ts`、`circuit-breaker.test.ts` 等。多数 service 都设计为「函数式 + 显式依赖注入」,所以测试时直接调用函数即可。 + +### Hook 单元测试 + +测试 `src/hooks/*`:包一层 `QueryClientProvider`,用 `renderHook` 触发,断言 hook 状态。`tests/unit/hooks/use-request-logs.test.ts` 是参考样例。 + +## Playwright E2E + +`playwright.e2e.config.ts:18-23` 的 `webServer` 段定义了 E2E 启动方式: + +```ts +webServer: { + command: `pnpm db:migrate:sqlite && pnpm dev --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, +} +``` + +每次 `pnpm e2e` 运行前都会: + +1. 先跑 `db:migrate:sqlite` 把 SQLite schema 对齐到最新。 +2. 启 dev server。 +3. 等 baseURL 200 后再开始跑测试。 +4. CI 环境强制重新启 dev server;本地复用已有进程,方便单测调试。 + +E2E 用 SQLite 而不是 PG 的原因:CI 不希望为 E2E 拉一个 PG 服务容器、本地环境也不希望强制要求 Docker。SQLite 在 E2E 路径上不会触及 `PERCENTILE_CONT` 等不兼容查询,安全。 + +::: tip E2E 验收的是路径而非数据 +当前 E2E 用例集中在两个场景:`billing-tier-flow.spec.ts` 校验阶梯计费下单后日志与计费快照的展示;`logs-routing-decision.spec.ts` 校验路由决策可视化在端到端是否正确。新增 E2E 之前先确认场景是否「单元测试 + 组件测试」就足以覆盖——E2E 跑得慢且不稳定,应当只作为关键路径的回归网。 ::: -## 计划覆盖的内容 +## 代理稳定性 smoke + +`pnpm test:proxy-stability` 调用 `scripts/ci/proxy-stability-check.mjs`。该脚本: + +1. 占用一个随机空闲端口启动 AutoRouter(连真实 PG)。 +2. 在 127.0.0.1 上启一个 mock 上游。 +3. 通过 admin API 创建测试上游与测试 Key。 +4. 串行发若干笔请求(非流式 / 流式 / 故障转移),断言每一笔的响应符合预期。 +5. 清理资源。 + +这条 smoke 覆盖了 Vitest 单元测试覆盖不到的部分:「真实 HTTP 跨进程通讯」「SSE 双工管道」「failover 完整链路」。CI 上由 `verify.yml` 的 `proxy-stability` job 跑,连真实 `postgres:16-alpine` 服务容器。 + +## 迁移一致性 + +`pnpm db:check:consistency` 调用 `scripts/ci/check-drizzle-consistency.mjs`,把当前 `schema-pg.ts` 与 `schema-sqlite.ts` 重新走一遍 `db:generate*` 流程,若生成结果与 `drizzle/`、`drizzle-sqlite/` 已 commit 的 SQL 与 snapshot 不一致则失败。详细机制见 [数据库选型与初始化](../deployment/database) 的「CI 上的迁移校验」。 + +## CI 工作流的测试 job 拓扑 + +`.github/workflows/verify.yml` 把上述工具串成 6 个并行 job + 1 个 status job: + +| Job | 跑的命令 | 关键依赖 | +| ----------------- | ---------------------------------------------------------------- | -------------------------------- | +| `quality` | lint / format / tsc / `test:run --coverage` | jsdom,无外部服务 | +| `build` | `pnpm build` | 仅 Node 22 | +| `migration` | `db:check:consistency`、`db:migrate`、再 `db:migrate`(幂等性) | `postgres:16-alpine` 服务容器 | +| `proxy-stability` | `pnpm test:proxy-stability` | `postgres:16-alpine` 服务容器 | +| `e2e` | `pnpm exec playwright install --with-deps chromium` + `pnpm e2e` | 在 GitHub runner 上安装 chromium | +| `actionlint` | `raven-actions/actionlint@v2` | 校验所有 workflow yml | +| `verify-status` | 等所有 job 完成,对每个 `needs..result` 判定 | 分支保护规则只需勾这一个 | + +`migration` 与 `proxy-stability` 各自单独拉一个 PG 容器、不与其他 job 共享数据库,避免互相污染。 + +`e2e` 在 GitHub Actions runner 上 `playwright install --with-deps chromium` 大约耗时 30s 左右;首跑会略慢,后续靠 GitHub 的镜像缓存复用。 + +## 本地复现 CI + +CI 失败时按下面顺序在本地复现: + +```bash +# 1. 与 CI 同款的「锁文件强一致」安装 +pnpm install --frozen-lockfile + +# 2. 静态检查全套 +pnpm lint +pnpm format:check +pnpm exec tsc --noEmit + +# 3. 单元 + 组件测试(含覆盖率) +pnpm test:run --coverage + +# 4. 生产构建 +DB_TYPE=postgres pnpm build + +# 5. 需要 PG 时单独起容器再跑 +docker run --rm -d --name pg-ci \ + -e POSTGRES_USER=autorouter -e POSTGRES_PASSWORD=autorouter -e POSTGRES_DB=autorouter \ + -p 5432:5432 postgres:16-alpine + +DATABASE_URL=postgresql://autorouter:autorouter@127.0.0.1:5432/autorouter \ + pnpm db:check:consistency + +DATABASE_URL=postgresql://autorouter:autorouter@127.0.0.1:5432/autorouter \ + pnpm db:migrate + +AUTOROUTER_DATABASE_URL=postgresql://autorouter:autorouter@127.0.0.1:5432/autorouter \ + pnpm test:proxy-stability + +# 6. E2E +pnpm e2e +``` + +每一步对应一个 CI job,按这个顺序排错可以快速定位故障来源。 + +## 新增测试的实践 + +写新测试时先确认它属于哪一类: -单元测试(Vitest)、`tests/unit/` 与 `tests/integration/` 的边界、CI 工作流。 +| 想测的对象 | 放哪里 | 命名约定 | +| --------------------------------- | ----------------------------------------- | -------------------- | +| `src/lib/utils/` 下的纯函数 | `tests/unit/utils/` | `.test.ts` | +| `src/lib/services/` 下的 service | `tests/unit/services/` | `.test.ts` | +| `src/hooks/` 下的 hook | `tests/unit/hooks/` | `.test.ts` | +| `src/app/api/` 下的 route handler | `tests/unit/api/<同源路径>/route.test.ts` | mirror 源码路径 | +| 组件交互 / 渲染 | `tests/components/<对应子目录>/` | `.test.tsx` | +| 完整用户路径 | `tests/e2e/` | `.spec.ts` | +| a11y 扫描 | `tests/a11y/` | `.spec.ts` | +| 视觉回归 | `tests/visual/` | `.spec.ts` | -## 在正文就绪前的临时建议 +新增测试时同步看 `tests/setup.ts` 中的全局 mock 是否需要扩充。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 来源对照 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- `vitest.config.ts`:include 模式、环境、coverage 范围 +- `playwright.e2e.config.ts`:E2E webServer 与 reuseExistingServer 策略 +- `tests/setup.ts`、`tests/unit/` / `tests/components/` 等目录:实际的测试组织 +- `scripts/ci/check-drizzle-consistency.mjs`、`scripts/ci/proxy-stability-check.mjs`:CI 上自定义 smoke 的实现 +- `.github/workflows/verify.yml`:完整 CI 拓扑与每个 job 的依赖 +- `package.json` scripts 段:所有测试命令的定义 From 241d6b895dfaead430b1aacc922ed5e079c2ca40 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 25 May 2026 21:29:21 +0800 Subject: [PATCH 4/5] docs: avoid VitePress Vue interpolation of GitHub Actions context (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VitePress 把 markdown 中的 `\$\{\{ ... \}\}` 当成 Vue 模板插值解析, 即便位于 inline code 内也会进入编译期 SSR;命中后 release.yml 的 `\$\{\{ github.ref_name \}\}` 与 docker/metadata-action 的 `\{\{version\}\}` / `\{\{major\}\}.\{\{minor\}\}` 都会在 docs:build 渲染期抛 `Cannot read properties of undefined (reading 'ref_name')`。 把 github-actions.md 与 release.md 中受影响的表格替换为不含双花括号 的等价描述(用 `` / `` / `.` 占位 符表达,并显式回引仓库内的 yaml 源文件位置)。yaml fenced code block 中的原始写法保持不变,那里 VitePress 不会解析双花括号。 --- docs/guide/architecture/release.md | 25 +++++++++++++++++-------- docs/guide/deployment/github-actions.md | 16 ++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/guide/architecture/release.md b/docs/guide/architecture/release.md index 9e550e53..39b14dab 100644 --- a/docs/guide/architecture/release.md +++ b/docs/guide/architecture/release.md @@ -98,14 +98,23 @@ tags: | type=raw,value=latest,enable=${{ !contains(steps.validate.outputs.release_version, '-') }} ``` -对应实际生成的 tag: - -| 来源规则 | 稳定 release(`v0.2.0`) | 预发布(`v0.3.0-alpha.1`) | -| ----------------------------------------- | ------------------------ | -------------------------- | -| `type=raw,value=${{ github.ref_name }}` | `v0.2.0` | `v0.3.0-alpha.1` | -| `type=semver,pattern={{version}}` | `0.2.0` | `0.3.0-alpha.1` | -| `type=semver,pattern={{major}}.{{minor}}` | `0.2` | —(不生成) | -| `type=raw,value=latest` | `latest` | —(不生成) | +四条规则对应实际生成的 tag(以 `v0.2.0` 与 `v0.3.0-alpha.1` 为例): + +```text +稳定 release (v0.2.0): + type=raw,value= → v0.2.0 + type=semver,pattern= → 0.2.0 + type=semver,pattern=. → 0.2 + type=raw,value=latest → latest + +预发布 (v0.3.0-alpha.1): + type=raw,value= → v0.3.0-alpha.1 + type=semver,pattern= → 0.3.0-alpha.1 + type=semver,pattern=. → —(不生成) + type=raw,value=latest → —(不生成) +``` + +上面示意里 `` / `` / `.` 在 release.yml 的原始 yaml 中分别对应 GitHub Actions 上下文表达式与 docker/metadata-action 的占位符(实际写法见上一个 yaml 代码块)。 设计目的: diff --git a/docs/guide/deployment/github-actions.md b/docs/guide/deployment/github-actions.md index afc3d142..d799e64c 100644 --- a/docs/guide/deployment/github-actions.md +++ b/docs/guide/deployment/github-actions.md @@ -46,14 +46,14 @@ outline: deep 5. `docker/metadata-action` 生成镜像 tag 集合。 6. `docker/build-push-action` 推送镜像,平台限定 `linux/amd64`,构建缓存通过 `type=gha` 复用。 -镜像 tag 的具体生成规则按 `docker/metadata-action` 的 `tags:` 段(`.github/workflows/release.yml:96-100`): - -| 规则 | 何时生效 | -| ----------------------------------------- | --------------------- | -| `type=raw,value=${{ github.ref_name }}` | 始终:原始 tag 字符串 | -| `type=semver,pattern={{version}}` | 始终:完整 semver | -| `type=semver,pattern={{major}}.{{minor}}` | 仅稳定 tag(无 `-`) | -| `type=raw,value=latest` | 仅稳定 tag(无 `-`) | +镜像 tag 的具体生成规则按 `docker/metadata-action` 的 `tags:` 段(`.github/workflows/release.yml:96-100`),合计 4 条: + +1. `type=raw,value=`:始终生成,使用 push 进来的原始 tag 字符串。 +2. `type=semver,pattern=`:始终生成,完整 semver。 +3. `type=semver,pattern=.`:仅稳定 tag(不含 `-` 后缀)。 +4. `type=raw,value=latest`:仅稳定 tag。 + +`` 与 `` / `.` 在 release.yml 的 yaml 中分别对应 GitHub Actions 上下文表达式与 docker/metadata-action 的内置占位符;上述说明用尖括号包住,避免与 VitePress 的 Vue 模板语法冲突,原文里它们仍是带双花括号的标准写法(直接看仓库内 `.github/workflows/release.yml:96-100` 即可)。 带 alpha/beta 后缀的 tag 只会更新与 tag 本身同名的镜像,不会污染 `latest` 与 `MAJOR.MINOR`,避免预览版本被默认拉取到生产环境。 From e2627233f9051da122b98b07108262f8ee115355 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 25 May 2026 22:28:21 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20align=20deployment/architecture=20d?= =?UTF-8?q?ocs=20with=20entrypoint=20migration=20&=20=E5=8D=B7=E6=8C=82?= =?UTF-8?q?=E8=BD=BD=E7=8E=B0=E7=8A=B6=20(#167,=20#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review 抽读时发现四处与仓库现状不符的事实陈述,本次一并修正: 1. database.md / upgrade-rollback.md 关于「容器不会自动跑迁移」「需要部署人手工触发 pnpm db:migrate」的描述与 scripts/docker-entrypoint.sh 现状矛盾。 该 entrypoint 在应用启动前会自动跑一遍内嵌的 migration runner(不依赖 drizzle-kit,按文件名顺序 apply drizzle/*.sql、用 __drizzle_migrations 表去重)。改为说明自动 apply 行为,并把破坏性迁移段重写为「entrypoint 仍 forward apply,回滚必须靠 pg_dump」。 2. database.md / upgrade-rollback.md 建议的 `docker compose exec autorouter node node_modules/drizzle-kit/bin.cjs migrate` 在生产镜像内无法执行。 Dockerfile standalone runner stage 只 copy postgres 这一个 node_modules 子包,drizzle-kit 是 devDependency 不进镜像。改为推荐「重启 autorouter 让 entrypoint 重跑」或「docker run --rm --entrypoint /app/docker-entrypoint.sh ghcr.io/g1331/autorouter:vN.N.N true」这种把 entrypoint 与 server.js 解耦的临时容器写法。 3. persistence-backup.md 关于「RECORDER_FIXTURES_DIR 通常会挂入 autorouter-data named volume(如默认编排)」的描述错误。 docker-compose.yml 中 RECORDER_FIXTURES_DIR 默认值是 `tests/fixtures`,相对容器内 /app/,实际写到 /app/tests/fixtures,不在任何 named volume 上——容器重建即丢。补 ::: danger ::: 容器警告,并显式给出「显式把 RECORDER_FIXTURES_DIR 指到 /app/data/...」的修复路径。 4. contributing.md 关于「推荐用 squash merge」与仓库实际 merge commit 历史(PR #184/#185/#186 都是 Merge pull request 形态)冲突。改为陈述「近期实际历史以 merge commit 为主,cliff.toml 显式 skip 这类 commit」,把策略选择留给 reviewer。 来源对照段同步补 scripts/docker-entrypoint.sh 与 Dockerfile 两项依据。 --- docs/guide/architecture/contributing.md | 4 +- docs/guide/deployment/database.md | 25 ++++++++--- docs/guide/deployment/persistence-backup.md | 19 +++++++- docs/guide/deployment/upgrade-rollback.md | 50 ++++++++++++++++----- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/docs/guide/architecture/contributing.md b/docs/guide/architecture/contributing.md index 2abc0634..f6155f0c 100644 --- a/docs/guide/architecture/contributing.md +++ b/docs/guide/architecture/contributing.md @@ -158,8 +158,8 @@ pre-commit install # 写入 .git/hooks/pre-commit 2. **拉分支、写实现、跑测试**:本地至少跑 `pnpm test:run` 与 `pnpm exec tsc --noEmit`,必要时跑 `pnpm e2e`。 3. **提 PR**:标题与首条 commit 风格一致(Conventional Commits);PR body 简要写:动机、改了什么、是否引入破坏性变更、是否需要数据库迁移。模板可参考 `cliff.toml` 各分组的实际产出。 4. **等 CI**:`verify.yml` 的 `verify-status` 必须通过;docs 改动会触发 `docs.yml`。CI 失败先看日志而不是反复重试。 -5. **响应 review**:fix 类响应直接 push 新 commit;不强行 rebase / squash,PR 合入时由 reviewer 选 squash / merge 策略。 -6. **合入**:默认由 reviewer 操作 merge。涉及多 commit 的功能 PR 推荐用 squash merge 让 master 历史保持线性,docs 类多文件 PR 也走相同方式。 +5. **响应 review**:fix 类响应直接 push 新 commit;不强行 rebase / squash,PR 合入时由 reviewer 选合并策略。 +6. **合入**:默认由 reviewer 操作 merge。仓库近期实际历史以 merge commit(`Merge pull request #N from ...`)为主,配合 `cliff.toml:45` 中显式 `skip` 这类 commit 的设定,保证 release notes 渲染时只看到主题 commit。需要 squash 把多个 fixup 合并的话由 reviewer 决定。 ## OpenSpec 提案 diff --git a/docs/guide/deployment/database.md b/docs/guide/deployment/database.md index 624b0115..154a4b49 100644 --- a/docs/guide/deployment/database.md +++ b/docs/guide/deployment/database.md @@ -59,7 +59,16 @@ DATABASE_URL=postgresql://autorouter:@db:5432/autorouter docker compose up -d ``` -`autorouter` 容器对 `db.condition: service_healthy` 有 `depends_on` 约束,会等 `pg_isready` 通过后才启动。容器内 AutoRouter 启动时不会自动跑迁移,需要按下文「迁移流程」执行 `pnpm db:migrate`,或在 CI 中由 `deploy-personal.yml` 之外的渠道触发。 +`autorouter` 容器对 `db.condition: service_healthy` 有 `depends_on` 约束,会等 `pg_isready` 通过后才启动。容器 entrypoint(`scripts/docker-entrypoint.sh`)在应用启动**之前**会自动跑一遍迁移——脚本内嵌一段不依赖 `drizzle-kit` 的自实现 migration runner,按文件名顺序 apply `drizzle/*.sql`,并把已 apply 的迁移哈希记到 `__drizzle_migrations` 表里。这意味着每次 `docker compose up -d`、`docker compose restart autorouter` 或新版本镜像首启都会增量 apply 新迁移,不需要部署人手工触发 `pnpm db:migrate`。 + +::: tip 手工跑迁移的几种场景 +绝大多数运行期场景由 entrypoint 自动处理。只有在下列特殊情况下才需要手工干预: + +- 开发期对本地 SQLite 操作:`pnpm db:migrate:sqlite`。 +- 在容器外、对 PG 单独 apply 某条 SQL:`docker compose exec db psql -U autorouter -d autorouter -f /path/to/migration.sql`。 +- 跑 `pnpm db:migrate` / `drizzle-kit migrate` 需要 dev 依赖,**生产容器内不可用**——`Dockerfile` 的 standalone runner stage 只 copy `postgres` 这一个 node_modules 子包,`drizzle-kit` 是 devDependency 不进镜像。需要在本地或 CI runner 上跑。 + +::: ### 本地 PostgreSQL(非 Docker) @@ -167,12 +176,16 @@ drizzle-sqlite/ ## 与升级 / 回滚的关系 -`deploy-personal.yml` 当前不会在远端自动跑迁移。这是个隐含约束——镜像内的应用代码与服务器上 PG 的 schema 应当事先对齐: +升级到新镜像 tag 时,迁移由 autorouter 容器 entrypoint 在启动期自动 apply(见上文)。这条自动路径对**前向兼容**的迁移完全够用: + +- **前向兼容的迁移**:新版本仅新增列 / 新增可空字段 / 新增表 / 索引变化。`docker compose up -d` 切镜像 → entrypoint 自动 apply 新迁移 → 应用启动。中间无需手工干预。 + +但**破坏性迁移**(删列、改类型、重命名)需要额外注意: -- **前向兼容的迁移**:新版本的 schema 与上一版本完全兼容(仅新增列、新增可空字段),可以先把镜像升上去,再事后跑 `pnpm db:migrate`(或在容器内执行 `node node_modules/drizzle-kit/bin.cjs migrate`)。 -- **破坏性迁移**:删列、改类型、重命名等。生产升级前必须先把数据库迁好、再切镜像;回滚同理,需要先回滚 schema 再切镜像。 +- 旧版本应用代码仍指向旧字段,新镜像 entrypoint 一旦 apply 破坏性迁移,旧版本副本(例如蓝绿部署中尚未切流量的旧实例)会立刻看到字段缺失而崩溃。 +- 回滚到旧 tag 时,旧版本应用启动**不会自动反向迁移**——entrypoint 只 forward apply,不 rollback;旧版本会直接尝试读不存在的字段或写已经被改类型的列。 -回滚到旧 tag 时,如果旧版本 schema 不兼容当前数据库(例如新增了 NOT NULL 列),应用启动会立刻失败。详细策略见 [升级与回滚](./upgrade-rollback)。 +因此涉及破坏性迁移的版本切换在升级前必须先做 `pg_dump`(备份方式见 [数据持久化与备份](./persistence-backup))。回滚路径只能依靠把 dump 回灌到迁移前状态,再切回旧镜像;没有备份就没有破坏性回滚。详细策略见 [升级与回滚](./upgrade-rollback)。 ## 来源对照 @@ -181,5 +194,7 @@ drizzle-sqlite/ - `src/lib/utils/config.ts`:`DB_TYPE` 自动推断与生产 fail-fast 守卫 - `package.json` scripts 段:`db:generate` / `db:migrate` / `db:check:consistency` / `db:push` 命令定义 - `scripts/ci/check-drizzle-consistency.mjs`、`scripts/db/migrate-sqlite.mjs`:迁移一致性与 SQLite 迁移实现 +- `scripts/docker-entrypoint.sh`:容器启动期自动 apply `drizzle/*.sql` 的内嵌 migration runner(不依赖 `drizzle-kit`) +- `Dockerfile`:standalone runner stage 只 copy `postgres` 子包,确认 `drizzle-kit` 不在生产镜像内 - `.github/workflows/verify.yml` 的 `migration` job:CI 层迁移校验 - `playwright.e2e.config.ts`:E2E webServer 命令中如何对齐 SQLite schema diff --git a/docs/guide/deployment/persistence-backup.md b/docs/guide/deployment/persistence-backup.md index 8c6a28a6..59569a57 100644 --- a/docs/guide/deployment/persistence-backup.md +++ b/docs/guide/deployment/persistence-backup.md @@ -203,12 +203,27 @@ docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d clipro ## 流量录制目录备份 -`recordTrafficFixture`(`src/lib/services/traffic-recorder.ts:517`)把录制内容以 JSON 写到 `RECORDER_FIXTURES_DIR`(默认 `tests/fixtures`,仓库内默认;deploy 工作流通常落到 `/app/data/...` 之类挂入卷的位置)。数据库 `trafficRecordings` 表只存元数据与 `fixture_path` 路径。这意味着: +`recordTrafficFixture`(`src/lib/services/traffic-recorder.ts:517`)把录制内容以 JSON 写到 `RECORDER_FIXTURES_DIR` 指向的目录。数据库 `trafficRecordings` 表只存元数据与 `fixture_path` 路径。这意味着: - 单独备份 PG 不足以恢复录制;恢复后详情页打开会找不到文件。 - 单独备份录制目录也不够;查询索引、过滤、统计都依赖 PG。 -完整的录制备份必须 PG 与录制目录一起做。`RECORDER_FIXTURES_DIR` 通常会挂入名为 `autorouter-data` 的 named volume(如默认编排),或挂入 bind mount。备份方式与 `cliproxy-auth` 同套路:用一次性容器 + `tar`。 +完整的录制备份必须 PG 与录制目录一起做。 + +::: danger 默认 RECORDER_FIXTURES_DIR 不是持久路径 +`docker-compose.yml` 中 `RECORDER_FIXTURES_DIR` 的默认值是 `tests/fixtures`(相对于容器内 `/app/`),实际写到 `/app/tests/fixtures`——这个目录在容器内部、**不在任何 named volume 上**。容器一旦重建(`docker compose up -d` 拉新镜像、`docker compose down && up -d` 等)所有录制文件即丢失。 + +要在生产环境保留录制,必须显式把 `RECORDER_FIXTURES_DIR` 指到挂在持久卷上的子目录。最少改动是把它指到 `autorouter-data` 卷下的子目录: + +```env +# .env +RECORDER_FIXTURES_DIR=/app/data/traffic-recordings +``` + +`docker compose up -d` 让 autorouter 容器读到新值后,录制就会落到 `autorouter-data` 卷里,下面的备份命令才有意义。如果当前部署是默认值,需要在改 `.env` 之前接受「现存的容器内录制将随重建丢失」这一前提。 +::: + +把 `RECORDER_FIXTURES_DIR` 指到 `/app/data/...` 之后,录制目录就并入了 `autorouter-data` 卷,备份方式与 `cliproxy-auth` 同套路:用一次性容器 + `tar`。 ```bash docker run --rm \ diff --git a/docs/guide/deployment/upgrade-rollback.md b/docs/guide/deployment/upgrade-rollback.md index dbf01197..669b9404 100644 --- a/docs/guide/deployment/upgrade-rollback.md +++ b/docs/guide/deployment/upgrade-rollback.md @@ -102,28 +102,58 @@ curl -H "Authorization: Bearer ${ADMIN_TOKEN}" \ ## 数据库迁移与升级顺序 -`deploy-personal.yml` 当前不会在远端自动跑数据库迁移,迁移由部署人手工触发。这导致升级时必须按 schema 兼容性区分顺序: +迁移由 autorouter 容器 entrypoint(`scripts/docker-entrypoint.sh`)在每次启动期自动 apply——脚本内嵌一段不依赖 `drizzle-kit` 的 migration runner,按文件名顺序把 `drizzle/*.sql` 中尚未 apply 的项写入数据库,并把哈希记到 `__drizzle_migrations` 表。这意味着切镜像后**第一次启动**就会增量 apply 新迁移,部署人不需要、也无法在容器内手工跑 `drizzle-kit migrate`(dev 依赖不在 standalone 镜像内)。 + +具体行为按迁移类型分两种情形处理。 ### 前向兼容的迁移 -新版本仅新增列 / 新增可空字段 / 新增表 / 索引变化,旧版本应用代码不读新字段。这类升级可以「先切镜像、再跑迁移」或「先迁移、再切镜像」皆可,操作风险低: +新版本仅新增列 / 新增可空字段 / 新增表 / 索引变化,旧版本应用代码不读新字段。这类升级直接 `docker compose up -d` 即可: ```bash -# 切镜像 +# .env 切到新 AUTOROUTER_IMAGE 后 docker compose up -d -# 跑迁移(容器内) -docker compose exec autorouter node node_modules/drizzle-kit/bin.cjs migrate +# entrypoint 自动 apply 新迁移 → 应用启动 +docker compose logs autorouter | grep -E '\[AutoRouter\] (Applying|Migrations completed)' ``` +日志中会看到 `Applying migration: _*.sql` 与 `Migrations completed` 两类行,确认迁移已经走到末尾即可。 + ### 破坏性迁移 -新版本删列 / 改类型 / 重命名表 / 修改约束。此时旧版本应用如果还在跑、又遇到新 schema,会立刻失败。必须按下面顺序: +新版本删列 / 改类型 / 重命名表 / 修改约束。entrypoint 仍会按 forward 路径自动 apply 迁移,但有两个风险需要在升级前规避: + +- **旧版本副本崩溃**:蓝绿 / 多副本部署里,若旧版本应用还指向旧字段就开始读写,新镜像 apply 破坏性迁移后旧副本会立刻报字段缺失。生产升级前需要先把旧副本全部下线(或确认只有单副本)。 +- **不能自动回滚**:entrypoint 只 forward apply,不做 rollback。一旦新迁移在生产 PG 上 apply 成功,旧版本镜像就无法在不修复 schema 的前提下重新启动。 + +破坏性升级前必须先 `pg_dump` 出离线备份;回滚路径只能依靠把 dump 回灌到迁移前状态,再切回旧镜像。备份方式见 [数据持久化与备份](./persistence-backup)。 + +```bash +# 0. 升级前:在迁移 apply 之前做完整 dump +docker exec autorouter-db \ + pg_dump --clean --if-exists -U autorouter autorouter \ + | gzip > /backup/before-vN.N.N.sql.gz + +# 1. 单副本可以直接切镜像;多副本需先下线旧副本 +sed -i "s|^AUTOROUTER_IMAGE=.*|AUTOROUTER_IMAGE=ghcr.io/g1331/autorouter:vN.N.N|" .env +docker compose up -d autorouter + +# 2. 看 entrypoint 是否正常 apply 完 +docker compose logs --tail=200 autorouter +``` + +如果只有数据库迁移本身想单独 apply(不切应用镜像),可以临时拉一个**新版本镜像**并只让它跑 entrypoint 的迁移段、跑完即退: -1. 短暂停业务:`docker compose stop autorouter`(PG 容器保持运行)。 -2. 跑迁移:`docker compose exec db psql -U autorouter -d autorouter -f /tmp/migrate.sql`,或者临时启动一个新版本镜像在 entrypoint 加 `--migrate-only` 等价物(项目当前没有该选项,手工跑 `node drizzle-kit migrate` 是标准做法)。 -3. 切镜像:修改 `.env` 中 `AUTOROUTER_IMAGE`,`docker compose up -d autorouter`。 +```bash +docker run --rm \ + --network autorouter-net \ + -e DATABASE_URL="${DATABASE_URL}" \ + --entrypoint /bin/sh \ + ghcr.io/g1331/autorouter:vN.N.N \ + /app/docker-entrypoint.sh true +``` -破坏性迁移不能回滚——回滚意味着把已经 `DROP` 掉的列变回来,等价于「换库」。因此破坏性升级**必须**在升级前完成 `pg_dump` 离线备份。 +`true` 命令把 entrypoint 末尾的 `exec "$@"` 替换为空操作,让脚本跑完迁移后直接退出,不启动应用。这是少数需要「迁移与应用启动解耦」时的实用手段。 ## 回滚到上一个版本