# 部署指南（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
# .env（compose 变量插值用；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())"
# 或纯 python：python -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
# 宿主机装 uv：curl -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 key（DeepSeek/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. 方案 B：PaaS / 托管平台

容器化已就绪（两个 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 Service（Docker 运行时），分别指向两个 Dockerfile；web 的 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/web（pg 不动）

# 冒烟
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 已修复 5）：api 用 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.2（JSON 日志接入日志栈后按字段聚合）。

---

## 附：核心事实速查

- **服务/端口**：`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.5）：api 镜像含 `alembic.ini`；base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`、`web` 设 `API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build arg；api/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` 备份+轮换。
