Files
writer-work-flow/DEPLOYMENT.md
Yaojia Wang 5464539a91 fix(devops): 修复容器化部署面缺口,docker compose 可干净构建
消除 DEPLOYMENT.md §1.5 列出的 6 个「照抄即部署」缺口(仅动部署面文件,不改应用源码):

1. apps/api/Dockerfile 补 `COPY alembic.ini ./`,容器内可直接 alembic upgrade head。
2. base compose api 注入 CREDENTIAL_ENC_KEY(${VAR:-dev 占位},自动读根 .env 覆盖)+
   APP_ENV/LOG_JSON/CORS_ORIGINS,无 .env 也能起,不再因缺 Fernet key 启动失败。
3. base compose web 设 API_BASE_INTERNAL=http://api:8000,RSC 直连 api 容器。
4. web Dockerfile 加 ARG/ENV NEXT_PUBLIC_API_BASE(默认 localhost:8000),compose
   web.build.args 传入,构建期可注入公网 API 基址。
5. api/web Dockerfile 内置 HEALTHCHECK(api 用 urllib 打 /health、web 用 node 打 /)。
6. 新增 docker-compose.prod.yml 覆盖层做生产加固:pg 强口令 ${POSTGRES_PASSWORD}、
   pg 仅绑 127.0.0.1、APP_ENV=prod、LOG_JSON=true、密钥/连接串/CORS/公网 API 全从
   env 且必填(:? 缺失拒起)、restart: unless-stopped。base 保持 dev 便利不破坏。

附带修复:web Dockerfile 在 install 前补 COPY pnpm-workspace.yaml,使容器内 install
看到 minimumReleaseAgeExclude/onlyBuiltDependencies,避免误拒 @xyflow 等近期发布依赖;
新增 .dockerignore(根 + apps/web)排除宿主 node_modules/.next/.venv 等,避免平台原生
二进制经 COPY . . 覆盖容器版导致 next build 失败,并加速构建上下文、保证可复现。

验证:docker compose config(base)与 prod 合并 config 均通过;docker compose build
两镜像均成功(api 378MB / web 296MB);自定义 NEXT_PUBLIC_API_BASE 已确认内联进前端
bundle。DEPLOYMENT.md §1.5 回写为「已修复」并补 §2.6 生产 env 清单与正确部署命令。
2026-07-07 05:01:44 +02:00

458 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 部署指南DEPLOYMENT
> 本文是把「AI 辅助中文网文写作工作流」部署到服务器的实操指南。
> 所有命令/端口/服务名均以仓库当前 `docker-compose.yml`、两份 `Dockerfile`、
> `.env.example`、`packages/config/ww_config/settings.py`、`apps/api/ww_api/main.py`
> 为准。**遇到与你手上 compose 不一致处,一律以实际 `docker-compose.yml` 为准。**
>
> ⚠️ **重要前提:本产品当前是「单用户原型」,没有任何认证 / 多租户**`users`/`owner_id`
> 是桩)。这直接决定了安全部署的红线——见 [§6 安全红线](#6-安全红线单用户原型的硬约束)。
> **不要在没有网络层保护的情况下把它裸暴露到公网。**
---
## 1. 架构与前置条件
### 1.1 三个组件
部署单元由三个容器组成(服务名即 `docker-compose.yml` 中的 key
| 服务名 | 角色 | 镜像来源 | 容器端口 | compose 发布端口 | 健康检查 |
|---|---|---|---|---|---|
| `pg` | Postgres 16 数据库(唯一真源) | `postgres:16-alpine`(拉取) | 5432 | `5432:5432` | ✅ `pg_isready -U writer`interval 5s / timeout 3s / retries 10 |
| `api` | FastAPI 后端async + SSE + LangGraph 编排) | 由 `apps/api/Dockerfile` 构建,构建上下文 = **仓库根 `.`** | 8000 | `8000:8000` | ✅ Dockerfile `HEALTHCHECK``python urllib``/health`interval 30s / timeout 5s / start-period 20s / retries 3 |
| `web` | Next.js 前端standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ✅ Dockerfile `HEALTHCHECK``node http``/`interval 30s / timeout 5s / start-period 20s / retries 3 |
依赖关系compose `depends_on`
- `api` 依赖 `pg`,且**等 pg 健康**后才起(`condition: service_healthy`)。
- `web` 依赖 `api`,但**只是启动顺序**(无 `condition`——web 可能先于 api 就绪,属可容忍的运行期竞态。
数据持久化:命名卷 `pgdata` 挂到 `pg``/var/lib/postgresql/data`。**备份/迁移数据 = 备份这个卷**(见 §7
### 1.2 镜像怎么来
两个应用镜像都从源码 `Dockerfile` **本地构建**(仓库未发布预构建镜像):
- **`apps/api/Dockerfile`**`python:3.12-slim` + 从 `ghcr.io/astral-sh/uv` 拷入 `uv``COPY pyproject.toml uv.lock* / packages / apps/api``uv sync --no-dev --frozen`(失败回退非 frozen`CMD uv run uvicorn ww_api.main:app --host 0.0.0.0 --port 8000`
- **`apps/web/Dockerfile`**多阶段。build 阶段 `node:22-slim` + corepack(pnpm)`pnpm install --frozen-lockfile``pnpm build`Next `output: "standalone"`,见 `apps/web/next.config.mjs`run 阶段只拷 `.next/standalone` + `.next/static` + `public``CMD node server.js`
> web 镜像在构建期消费**已提交的** OpenAPI TS 客户端 `apps/web/lib/api/schema.d.ts`(该文件在 git 里,仅 `openapi.json` 被 gitignore。因此 web 构建**不需要**联网跑 `pnpm gen:api`。**但**:后端 schema 改动后,必须先 `cd apps/web && pnpm gen:api` 重生成并提交 `schema.d.ts`,再构建 web 镜像,否则前后端契约漂移。
### 1.3 需要的环境变量(逐项)
变量来源 = `packages/config/ww_config/settings.py`(后端)+ `apps/web/lib/api/config.ts`(前端)。
**后端api 容器):**
| 变量 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|
| `DATABASE_URL` | 建议显式设 | `postgresql+asyncpg://writer:writer@localhost:5432/writer` | **异步**连接串asyncpg。运行时 + `alembic upgrade head` 都读它(`packages/db/migrations/env.py` 用它建 async engine 跑迁移)。容器内主机名用 compose 服务名 `pg`。 |
| `DATABASE_URL_SYNC` | 建议显式设 | `postgresql+psycopg://writer:writer@localhost:5432/writer` | **同步**连接串psycopg`.env.example` 与 compose 均定义;供同步工具链使用。 |
| `APP_ENV` | 否 | `dev` | 环境名。生产设 `prod`。 |
| `LOG_JSON` | 否 | `false` | 结构化日志是否输出 JSON。**生产设 `true`**structlog JSON便于采集。 |
| `CREDENTIAL_ENC_KEY` | **✅ 必填** | 空(`SecretStr("")` | provider 凭据对称加密密钥Fernet, urlsafe-base64 32B。**api 启动即校验**`main._lifespan``_fernet(...)`):缺失/非法 → **进程直接启动失败**,不带病接流量。生产**必须**用新密钥,见 §2.3。 |
| `CORS_ORIGINS` | 生产强烈建议 | `["http://localhost:3000","http://127.0.0.1:3000"]` | 浏览器跨源白名单,逗号分隔覆盖。**启动断言不含通配 `*`**(与 `allow_credentials=True` 不兼容,`main.create_app``assert "*" not in cors_origins`)。生产填前端真实来源,如 `https://writer.example.com`。 |
**前端web 容器):**
| 变量 | 是否必填 | 默认值(`config.ts` 回退) | 说明 |
|---|---|---|---|
| `NEXT_PUBLIC_API_BASE` | 生产建议 | `http://localhost:8000` | **浏览器端**访问 API 的基址。⚠️ Next.js 的 `NEXT_PUBLIC_*` 在**构建期内联**进客户端 bundle——只在运行期设它**不会改**已构建产物。web `Dockerfile` 现声明 `ARG NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8000`base/prod compose 的 `web.build.args` 均传入它;生产在**构建期**用 `docker compose build` 注入前端可达的公网 API 地址(通常反代后的 `https://writer.example.com` 之类)。 |
| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。base/prod compose 的 `web.environment` 已设为 `http://api:8000`compose 服务名RSC 直连 api 容器。 |
### 1.4 LLM provider key 不进环境变量
**不要**把任何 LLM 供应商密钥DeepSeek/Kimi/OpenAI/Anthropic/Qwen/GLM/Gemini 等)写进 env 或 compose。
本产品的 provider 凭据是**部署后在应用「设置」页录入**,由后端用 `CREDENTIAL_ENC_KEY`Fernet
**加密后存进 Postgres**`provider_credentials` 表 / `settings_providers` 路由)。
Kimi Code 走 OAuth 订阅授权(`kimi_oauth` 路由),同样落库。
推论:`CREDENTIAL_ENC_KEY` 是解开所有已存凭据的唯一钥匙——**丢了它 = 库里所有 provider 凭据不可解密**,只能重新录入。务必备份保管(见 §2.3 / §6
### 1.5 部署面缺口——已修复(现可照抄部署)
此前审阅 Dockerfile/compose 发现的 6 个「照抄即部署」缺口**均已在部署面文件消除**(不改应用源码)。现状如下:
1. **api 镜像已拷 `alembic.ini`** ✅:`apps/api/Dockerfile``COPY apps/api` 之后新增 `COPY alembic.ini ./`(迁移目录 `packages/db/migrations``COPY packages` 进镜像)。因此可直接在 api 容器内跑 `docker compose run --rm api uv run alembic upgrade head`§2.4)。
2. **compose `api` 已提供 `CREDENTIAL_ENC_KEY`**base `docker-compose.yml``api.environment` 现注入 `CREDENTIAL_ENC_KEY``${CREDENTIAL_ENC_KEY:-<dev 占位>}`compose 自动读仓库根 `.env` 覆盖该默认)、`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`。**base compose 现可直接 `up` 起 api**(无 `.env` 也走 dev 占位 key 起来;生产用 §2.2 覆盖文件强制真值)。
3. **compose `web` 已设 `API_BASE_INTERNAL`**base 的 `web.environment``API_BASE_INTERNAL=http://api:8000`RSC 服务端取数直连 api 容器。
4. **`NEXT_PUBLIC_API_BASE` 支持构建期注入** ✅web `Dockerfile``pnpm build` 前声明 `ARG NEXT_PUBLIC_API_BASE=http://localhost:8000` + `ENV`base/prod compose 的 `web.build.args` 传入它。生产 `docker compose -f ... -f docker-compose.prod.yml build` 即把公网 API 基址内联进 bundle。
5. **api/web 已内置 HEALTHCHECK** ✅:`apps/api/Dockerfile` 用 stdlib `urllib``/health``apps/web/Dockerfile``node http``/`(均 interval 30s / timeout 5s / start-period 20s / retries 3镜像无 curl 故用运行时自带解释器)。编排/反代可直接读容器健康态。
6. **base 弱口令/裸暴露——生产用覆盖层加固**base `docker-compose.yml` 保持 dev 便利pg `writer/writer``5432:5432``APP_ENV=dev`);新增 **`docker-compose.prod.yml`** 覆盖层做生产加固——pg 强口令 `${POSTGRES_PASSWORD}``:?` 缺失即拒起、pg 仅绑 `127.0.0.1:5432``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`DATABASE_URL*`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 全从 env 且必填、`restart: unless-stopped`
> 构建面同时补了两个 `.dockerignore`(仓库根 + `apps/web`):排除宿主 `node_modules`/`.next`/`.venv`/`.git`/缓存,避免宿主平台原生二进制(如 macOS 的 next-swc经 `COPY . .` 覆盖容器内 Linux 版导致 `pnpm build` 失败,并加速构建上下文传输、保证可复现。
>
> 校验:`docker compose config`base与 `docker compose -f docker-compose.yml -f docker-compose.prod.yml config`prod 合并)均通过;`docker compose build` 两镜像均成功构建。
---
## 2. 方案 A单机 docker compose推荐起步
适合:单台服务器 / VPS起步验证。前提服务器已装 Docker含 Docker Compose v2 插件)。
### 2.1 装 Docker + 拉代码
```bash
# 服务器上(以 Ubuntu 为例)安装 Docker Engine + compose 插件
curl -fsSL https://get.docker.com | sh
docker --version && docker compose version # 确认 compose v2 可用
# 拉代码
git clone <你的仓库地址> writer-work-flow
cd writer-work-flow
git checkout main # 或你的发布分支
```
### 2.2 准备生产配置(覆盖 base compose 的 dev 缺省)
base `docker-compose.yml` 是**开发导向**pg 弱口令 `writer/writer``5432` 对外发布、`APP_ENV=dev`
`CREDENTIAL_ENC_KEY` 用 dev 占位默认)。生产加固的**覆盖文件 `docker-compose.prod.yml` 已随仓库提供**(无需手写),
与 base 叠加使用即可。它把安全项全部改为**从宿主 env 强制注入**(缺失即拒绝启动):
- `pg``POSTGRES_PASSWORD=${POSTGRES_PASSWORD}` 强口令、端口仅绑 `127.0.0.1:5432``restart: unless-stopped`
- `api``APP_ENV=prod``LOG_JSON=true``CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`DATABASE_URL*`(用同一强口令)全从 env。
- `web``NEXT_PUBLIC_API_BASE`(构建期 build arg + 运行期 env指向公网 API、`API_BASE_INTERNAL=http://api:8000`
你只需在**仓库根**准备一个 `.env`compose 自动读取做 `${...}` 插值;**chmod 600、勿入库**,已被 `.gitignore` 忽略):
```bash
# .envcompose 变量插值用chmod 600勿入库
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<§2.3 生成的 Fernet key>
CORS_ORIGINS=https://writer.example.com # 前端真实来源,逗号分隔;禁通配 *
NEXT_PUBLIC_API_BASE=https://writer.example.com # 浏览器可达的公网 API经反代
```
> prod 覆盖层对 `POSTGRES_PASSWORD`/`CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 用
> `${VAR:?...}`(必填)语义——**任一缺失,`docker compose ... config`/`up` 直接报错拒起**,从设计上杜绝
> 「带 base 弱口令/占位 key 上生产」。完整清单见 [§2.6 生产 env 清单](#26-生产-env-清单)。
### 2.3 生成并妥善保管 CREDENTIAL_ENC_KEY
⚠️ **这是最关键的一步**。用 Fernet 生成一把新密钥(`.env.example` 里那把是开发占位,**生产禁用**
```bash
# 任一装了 cryptography 的环境(也可用仓库 uv 环境)
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 或纯 pythonpython -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
把输出的一行密钥填进 §2.2 的 `.env``CREDENTIAL_ENC_KEY`,并**离线备份**(密码管理器/密钥库)。
> **丢失 = 灾难**:所有已录入的 provider 凭据(存在 Postgres 里、用它加密)将**永久不可解密**
> 只能清空后重新录入。轮换密钥需先用旧钥解密全部凭据、再用新钥重加密(当前无自动轮换脚本,
> 见 §6。**切勿**复用 `.env.example` 里的占位 key那等于凭据未加密。
### 2.4 构建镜像 + 跑迁移 + 起服务
```bash
# 在仓库根,带上覆盖文件
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
# 1) 构建两个应用镜像
$COMPOSE build
# 2) 先起数据库
$COMPOSE up -d pg
# 等 pg 健康compose 已配 healthcheck
$COMPOSE ps # 看 pg 是否 healthy
# 3) 跑数据库迁移(含 LangGraph checkpointer 建表)—— 见下方两种方式
```
**迁移的关键事实**`alembic upgrade head` 一条命令会做**两件事**——
(a) 建/升级 16 张业务表;(b) 迁移 `d3e4f5a6b7c8_langgraph_checkpoint_setup` 会调用 LangGraph 的
`checkpointer.setup()``checkpoints/checkpoint_blobs/checkpoint_writes/checkpoint_migrations`
四张检查点表。**checkpointer 的建表只在迁移里发生,绝不在应用运行时**(符合 CLAUDE.md「LangGraph」纪律
所以**部署时必须先把迁移升到 head应用才能正常写章/审稿/续跑**。
api 镜像现已随包 `alembic.ini`§1.5 已修复 1**推荐直接在 api 容器里跑迁移**(无需宿主装 uv
**方式一(推荐)——在 api 容器里跑迁移:**
```bash
$COMPOSE run --rm api uv run alembic upgrade head
$COMPOSE run --rm api uv run alembic check # 可选:确认无漂移
```
> 该临时容器与 api 服务共享同一 env含 `DATABASE_URL*`/`CREDENTIAL_ENC_KEY`),直连 compose 内网的 `pg`,无需暴露端口。
**方式二(备选)——从宿主机 checkout 跑迁移**(宿主已装 uv 时可用prod 覆盖层把 pg 绑到 `127.0.0.1:5432`
```bash
# 宿主机装 uvcurl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"
cd writer-work-flow && uv sync
DATABASE_URL="postgresql+asyncpg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
DATABASE_URL_SYNC="postgresql+psycopg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \
uv run alembic upgrade head
```
**4) 起全部服务:**
```bash
$COMPOSE up -d
$COMPOSE ps # api/web 应为 running注意它们无 healthcheck看不到 healthy
```
### 2.5 健康检查
```bash
# API根 + health 端点(均返回 200 JSON
curl -f http://127.0.0.1:8000/ # {"service":"ww-api","status":"ok"}
curl -f http://127.0.0.1:8000/health # {"status":"ok"}
# Web首页
curl -fI http://127.0.0.1:3000/
# 看日志api 启动会校验 CREDENTIAL_ENC_KEY、seed 单用户桩、reap 僵尸 job
$COMPOSE logs -f api
```
若 api 启动即退出,最常见原因是 `CREDENTIAL_ENC_KEY` 非法base compose 已注入 dev 默认;
生产覆盖层要求经 env 提供合法 Fernet key见 §2.3)——日志会显示 CredentialKeyError 相关堆栈。
部署完成后,**打开前端「设置」页录入 LLM provider 凭据**§1.4),产品才能真正调用模型。
### 2.6 生产 env 清单
生产用 `docker-compose.prod.yml` 覆盖层,以下变量必须在**仓库根 `.env`**compose 自动读取做 `${...}` 插值;
`chmod 600`、勿入库)或宿主环境变量提供。带 `:?` 者缺失即 `config`/`up` **报错拒起**
| 变量 | 必填 | 消费方 | 说明 |
|---|:---:|---|---|
| `POSTGRES_PASSWORD` | ✅ | pg + api(`DATABASE_URL*`) | pg 强随机口令,替换 base 弱口令 `writer/writer`。api 连接串复用它。 |
| `CREDENTIAL_ENC_KEY` | ✅ | api | Fernet key§2.3 新生成,**勿用 `.env.example` 占位值**。api 启动即校验;解开全部已存 provider 凭据的唯一钥匙,须离线备份。 |
| `CORS_ORIGINS` | ✅ | api | 浏览器跨源白名单,逗号分隔,**禁通配 `*`**(如 `https://writer.example.com`)。 |
| `NEXT_PUBLIC_API_BASE` | ✅ | web**构建期**内联 + 运行期) | 浏览器可达的公网 API 基址(经反代)。改它必须**重新 `docker compose ... build web`**(构建期内联进 bundle。 |
| `APP_ENV` | 覆盖层已固定 `prod` | api | 无需手设prod 层写死)。 |
| `LOG_JSON` | 覆盖层已固定 `true` | api | 无需手设prod 层写死)。 |
| `API_BASE_INTERNAL` | 覆盖层已固定 `http://api:8000` | web(RSC) | 无需手设prod 层写死)。 |
> LLM provider keyDeepSeek/Kimi/OpenAI…**永不进 env**——部署后在应用「设置」页录入、加密落库§1.4)。
生成 `.env` 骨架(示例):
```bash
cat > .env <<'EOF'
POSTGRES_PASSWORD=<强随机口令>
CREDENTIAL_ENC_KEY=<uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 的输出>
CORS_ORIGINS=https://writer.example.com
NEXT_PUBLIC_API_BASE=https://writer.example.com
EOF
chmod 600 .env
```
---
## 3. 反向代理 + HTTPS
生产应在应用前放一层反代做 TLS 终止、单一入口、并(配合 §6做访问控制。
拓扑:浏览器 → 反代(443) → `web:3000`(页面/RSC`api:8000``/api` 或独立子域)。
> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联:浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。
> web `Dockerfile` 已暴露 `ARG NEXT_PUBLIC_API_BASE`、prod compose 的 `web.build.args` 会传入它——改这个基址后**必须重新 `docker compose ... build web`** 才生效(重启不改已构建 bundle
> 若走「同源 `/api` 前缀反代」,构建时把 `NEXT_PUBLIC_API_BASE` 设为该前缀若走「api 独立子域」(如 `api.example.com`),同理构建期注入。
### 3.1 Caddy推荐自动证书
```caddy
# Caddyfile —— 自动申请/续签 Let's Encrypt 证书
writer.example.com {
# 后端 API同源 /api/* 反代到 api 容器(去掉 /api 前缀按你的前端约定调整)
handle_path /api/* {
reverse_proxy api:8000
}
# 其余流量到 Next 前端
reverse_proxy web:3000
# SSE 长连接:关掉缓冲,避免审稿/写章流被缓存卡住
reverse_proxy api:8000 {
flush_interval -1
}
}
```
将 Caddy 也放进 compose与 api/web 同网络,用服务名访问),暴露 80/443。
### 3.2 Nginx手动 / certbot 证书)
```nginx
server {
listen 443 ssl;
server_name writer.example.com;
ssl_certificate /etc/letsencrypt/live/writer.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/writer.example.com/privkey.pem;
# 前端
location / {
proxy_pass http://web:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 后端 API含 SSE写章/四审流)
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection ""; # SSE保持长连接
proxy_buffering off; # SSE关缓冲事件即时下发
proxy_read_timeout 3600s; # 长任务/流式容忍
}
}
```
> 无论 Caddy/Nginx**SSE 必须关代理缓冲**(写章、四审都是事件流),否则前端进度条会卡住。
---
## 4. 方案 BPaaS / 托管平台
容器化已就绪(两个 Dockerfile + standalone 前端),可托管到 PaaS。共性**用托管 Postgres**
在平台面板设 env尤其 `CREDENTIAL_ENC_KEY``CORS_ORIGINS``DATABASE_URL*`
部署后照 §2.4 跑一次 `alembic upgrade head`。provider key 仍在应用设置页录入(不进平台 env
### 4.1 Fly.io
- 两个 app`fly launch` 分别为 api`apps/api/Dockerfile`,构建上下文=仓库根)与 web`apps/web/Dockerfile`,上下文=`apps/web`)。
- Postgres 用 `fly postgres create`(或外部托管 PG`DATABASE_URL`/`DATABASE_URL_SYNC` 设成其连接串(注意驱动前缀 `+asyncpg`/`+psycopg`Fly 给的是原生 `postgres://`,需自行加前缀)。
- `fly secrets set CREDENTIAL_ENC_KEY=... CORS_ORIGINS=... POSTGRES_PASSWORD=...`
- 迁移:镜像已含 `alembic.ini`§1.5 已修复 1可用 Fly `[deploy] release_command = "uv run alembic upgrade head"` 在每次发布前自动跑迁移,或 `fly ssh console` 手动跑。
- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入Fly build args → Dockerfile `ARG NEXT_PUBLIC_API_BASE`,已暴露)。
- api 内部端口 8000、web 内部端口 3000`fly.toml``internal_port` 对应。
### 4.2 Render
- 两个 Web ServiceDocker 运行时),分别指向两个 Dockerfileweb 的 Docker build context 设 `apps/web`api 设仓库根。
- Postgres 用 Render Managed Postgres把内部连接串填进 api 的 `DATABASE_URL*`(加 `+asyncpg`/`+psycopg` 前缀)。
- 在 api service 的 Environment 填 `CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`APP_ENV=prod`/`LOG_JSON=true`
- 迁移:用 Render 的 **Pre-Deploy Command**`uv run alembic upgrade head`(镜像已含 `alembic.ini`§1.5 已修复 1
- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL`NEXT_PUBLIC_API_BASE`(构建期 build arg指向 api 公网 URL。
> 两个平台的共同注意点:`NEXT_PUBLIC_API_BASE` 仍是**构建期内联**(改它须重构 web 镜像);`alembic.ini` 已随镜像,迁移可在容器/release 命令里跑。
> HEALTHCHECK 已内建于镜像,平台存活探测可直接复用或按平台惯例另配 `/health`api/`/`web
---
## 5. 迁移与升级流程
### 5.1 常规升级
```bash
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
git pull # 拉新代码(含新迁移 / schema.d.ts
$COMPOSE build # 重建 api/web 镜像
# 先升迁移到 head含新增业务表 + 可能的 checkpointer 变更),再滚动重启(见 §2.4
$COMPOSE run --rm api uv run alembic upgrade head # 在 api 容器内跑迁移(镜像已含 alembic.ini
$COMPOSE up -d # 用新镜像重启 api/webpg 不动)
# 冒烟
curl -f http://127.0.0.1:8000/health && curl -fI http://127.0.0.1:3000/
```
顺序要点:**先迁移、后换应用镜像**——新代码通常假设 schema 已到 head。若后端 schema 有变,
记得升级前已在开发侧 `pnpm gen:api` 重生成并提交了 `apps/web/lib/api/schema.d.ts`§1.2
否则 web 构建/类型会漂移。
### 5.2 回滚
```bash
# 应用回滚:切回上一个 tag/commit 重建重启
git checkout <上一个可用 tag>
$COMPOSE build && $COMPOSE up -d
# 数据库回滚(谨慎):按需降级一版
$COMPOSE run --rm api uv run alembic downgrade -1
```
> ⚠️ DB 降级可能丢列/丢表数据,且 `d3e4f5a6b7c8` 的 `downgrade()` 会 `DROP` 四张 checkpoint 表
> (在跑的写章/链任务会丢控制流状态)。**回滚前先备份**§7.3)。业务表回滚同理不可逆,优先「向前修复」。
---
## 6. 安全红线(单用户原型的硬约束)
⚠️ **本产品没有认证、没有多租户、没有登录**`users`/`owner_id` 是桩,见 CLAUDE.md「Prototype scope」
任何拿到 URL 的人都能读写全部小说数据、录入/触发用花你钱的 LLM 凭据。因此:
1. **绝不裸暴露到公网。** 至少满足其一:
- 只在 **VPN / 内网 / SSH 隧道**内访问;
- 反代前加 **IP 白名单****HTTP Basic Auth**Caddy `basic_auth` / Nginx `auth_basic`
- 或**先补一层认证**再上公网(当前无,属产品级改造)。
2. **Postgres 不对公网开放。** base compose 把 `pg` 发布到 `5432:5432`(全网卡)。生产**必须**改成
仅回环 `127.0.0.1:5432:5432`§2.2)或删掉端口映射只走 compose 内网。**改掉 base 里的弱口令 `writer/writer`**。
3. **CORS 显式白名单,禁通配。** `CORS_ORIGINS` 必须是精确来源;后端已在启动断言不含 `*`
`allow_credentials=True``*` 会被浏览器拒且语义危险)。别为图省事放宽。
4. **`CREDENTIAL_ENC_KEY` 的密钥管理与轮换:**
- 用 §2.3 生成的**新** Fernet key绝不用 `.env.example` 占位值;
- 存进密钥库 / 平台 secrets`.env` `chmod 600` 且**不入库**
- **备份它**(丢了 = 所有 provider 凭据不可解密);
- 轮换需「旧钥解密全部凭据 → 新钥重加密」(当前无自动脚本,轮换前规划停机 + 备份)。
5. **provider key 只走应用设置页**(加密落库),永不写进 env/compose/日志§1.4)。
6. **日志脱敏已内建**structlog 结构化日志按 CLAUDE.md「Logging」纪律**不记全 prompt/正文**(记长度/哈希)、
**不记 API key**。保持这一约束,别在自建反代/中间件里回退成明文全量日志。
7. **HTTPS 强制**§3别让凭据/正文走明文。
---
## 7. 可观测性与运维
### 7.1 健康端点
- `GET /``{"service":"ww-api","status":"ok"}`
- `GET /health``{"status":"ok"}`
两者均无鉴权、返回 200可作反代/编排的存活探测目标。
api/web 的 Dockerfile **已内置** `HEALTHCHECK`§1.5 已修复 5api 用 stdlib `urllib``/health`、web 用 `node http``/`——`docker compose ps` 直接显示 `healthy`,反代/平台亦可复用。
### 7.2 结构化日志
- 后端 structlog**生产设 `LOG_JSON=true`** 输出 JSON便于日志系统采集/检索。
- 每个请求带 `request_id`(贯穿 assemble→write→四审→accept 一条链),也进错误响应信封——
用户报错时可用它端到端 grep。
- 采集:`docker compose logs`,或把容器 stdout 接到你的日志栈Loki/ELK/云日志)。
### 7.3 备份 Postgres含凭据库
数据全在 `pgdata` 卷 + Postgres 里,**包含加密后的 provider 凭据**。定期逻辑备份:
```bash
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
# 逻辑备份(推荐)
$COMPOSE exec pg pg_dump -U writer -d writer -Fc > backup_$(date +%F).dump
# 恢复
$COMPOSE exec -T pg pg_restore -U writer -d writer --clean < backup_YYYY-MM-DD.dump
```
> **备份数据库还不够**`CREDENTIAL_ENC_KEY` 要**单独异地备份**——DB 里的凭据是密文,
> 没有这把钥匙备份也解不出§2.3 / §6。两者分开保管、都要备。
### 7.4 成本账本LLM 调用)
每次 LLM 调用都按 `ARCHITECTURE §4.8` 的字段集记结构化日志provider/model/tier/token 数/耗时/`request_id` 等),
喂成本账本。运维时用这些日志:
- 核对每章/每次生成的 token 与花费;
- 排查慢/失败调用(网关的路由/回退/熔断也在此可见);
-`request_id` 关联「一次写章」的整条链。
采集方式同 §7.2JSON 日志接入日志栈后按字段聚合)。
---
## 附:核心事实速查
- **服务/端口**`pg`(5432, 唯一健康检查 `pg_isready -U writer`) · `api`(8000) · `web`(3000)。
- **镜像**均本地构建api 上下文=仓库根web 上下文=`apps/web`standalone
- **必填 env**`CREDENTIAL_ENC_KEY`(启动即校验,缺失/非法则 api 起不来)。
- **迁移**`alembic upgrade head` 同时建业务表 + LangGraph checkpointer 表checkpointer 只在迁移建表,不在运行时。
- **provider key**:不进 env部署后在设置页录入、加密落库。
- **部署面缺口已全部修复**§1.5api 镜像含 `alembic.ini`base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS``web``API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build argapi/web 内置 HEALTHCHECK生产加固走 `docker-compose.prod.yml` 覆盖层(强口令/回环 pg/prod env/`restart`)。
- **部署命令**`docker compose build`dev`docker compose -f docker-compose.yml -f docker-compose.prod.yml build`prod`... run --rm api uv run alembic upgrade head``... up -d`
- **红线**:单用户无认证 → 不裸暴露公网pg 不对外CORS 禁 `*``CREDENTIAL_ENC_KEY` 备份+轮换。