From 6b08fbe123423554f365260f5290592546a7bddf Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 21:57:57 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20README=20+=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=83=A8=E7=BD=B2=E6=8C=87=E5=8D=97?= =?UTF-8?q?=EF=BC=88=E5=90=AB=20compose=20=E9=83=A8=E7=BD=B2=E9=9D=A2?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E4=B8=8E=E5=AE=89=E5=85=A8=E7=BA=A2=E7=BA=BF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOYMENT.md | 448 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 157 ++++++++++++++++++ 2 files changed, 605 insertions(+) create mode 100644 DEPLOYMENT.md create mode 100644 README.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..206143c --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,448 @@ +# 部署指南(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` | ❌ 无(见 §1.5 缺口) | +| `web` | Next.js 前端(standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ❌ 无(见 §1.5 缺口) | + +依赖关系(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——只在运行期(compose environment)设它**不会改**已构建产物(见 §1.5 缺口)。生产需在**构建期**注入前端可达的公网 API 地址(通常是反代后的 `https://writer.example.com/api` 之类)。 | +| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。容器内应设为 `http://api:8000`(compose 服务名)。**当前 compose 未设它**(见 §1.5 缺口),RSC 会回退到 `localhost:8000` 而打不到 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 时发现的、影响「照抄即部署」的点,本文各节给出规避办法: + +1. **api 镜像未拷 `alembic.ini`**:`apps/api/Dockerfile` 只 `COPY pyproject.toml / packages / apps/api`,**没有** `COPY alembic.ini`(它在仓库根)。因此 `docker compose run --rm api uv run alembic upgrade head` 会因找不到 `alembic.ini` 而失败。规避:从**宿主机 checkout** 跑迁移(§2.4 方式一,当前即可用),或给 Dockerfile 补一行 `COPY alembic.ini ./`(§2.4 方式二)。 +2. **compose 的 `api` 服务未提供 `CREDENTIAL_ENC_KEY`**(也没 `env_file`):base compose 里 `api.environment` 只有 `DATABASE_URL/DATABASE_URL_SYNC/APP_ENV=dev`。由于该密钥启动即校验,**base compose 直接 `up` 会让 api 启动失败**。生产必须补齐(§2.2 覆盖文件)。 +3. **compose 的 `web` 服务未设 `API_BASE_INTERNAL`**:RSC 服务端取数会回退到 `localhost:8000`,在容器里打不到 api。需在覆盖文件设 `API_BASE_INTERNAL=http://api:8000`(§2.2)。 +4. **`NEXT_PUBLIC_API_BASE` 是构建期内联**:web Dockerfile 未声明该 build ARG,浏览器 bundle 里的 API 基址会固定为 `http://localhost:8000`。生产要改,需在**构建阶段**注入(§3 反代方案里说明取舍)。 +5. **api/web 无 HEALTHCHECK**:两个 `Dockerfile` 和 compose 的 `api`/`web` 都没有健康检查(只有 `pg` 有)。编排/反代的存活探测需自行加(health 端点已就绪,见 §7.1)。 + +> 以上缺口不阻断部署,但决定了「不能照抄 base compose 一把梭」。生产用 §2.2 的覆盖文件补齐即可。 + +--- + +## 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` 是**开发导向**(api 用 `APP_ENV=dev`、缺 `CREDENTIAL_ENC_KEY`、 +web 缺 `API_BASE_INTERNAL`、pg 端口对外发布)。生产用一个**覆盖文件** `docker-compose.prod.yml` +补齐,不改 base 文件: + +```yaml +# docker-compose.prod.yml —— 与 base 叠加:docker compose -f docker-compose.yml -f docker-compose.prod.yml ... +services: + pg: + # 不把 5432 暴露到公网:仅绑本机回环(供宿主机跑迁移用),或整段删掉只走 compose 内网 + ports: + - "127.0.0.1:5432:5432" + + api: + environment: + DATABASE_URL: postgresql+asyncpg://writer:${PG_PASSWORD}@pg:5432/writer + DATABASE_URL_SYNC: postgresql+psycopg://writer:${PG_PASSWORD}@pg:5432/writer + APP_ENV: prod + LOG_JSON: "true" + CREDENTIAL_ENC_KEY: ${CREDENTIAL_ENC_KEY} # 见 §2.3 生成 + CORS_ORIGINS: "https://writer.example.com" # 前端真实来源,逗号分隔;不得含 '*' + # 可选:补健康检查(镜像里已装 uv/python,可用 python 探 /health) + # 若加了反代做存活探测,这里可省 + + web: + environment: + NEXT_PUBLIC_API_BASE: https://writer.example.com # 浏览器可达的公网 API(经反代) + API_BASE_INTERNAL: http://api:8000 # RSC 服务端内网直连 api 容器 +``` + +同时准备 pg 的强口令与密钥(**不要提交进仓库**),可放在同目录 `.env`(compose 会自动读取用于 `${...}` 插值): + +```bash +# .env(compose 变量插值用;chmod 600,勿入库) +PG_PASSWORD=<强随机口令> +CREDENTIAL_ENC_KEY=<§2.3 生成的 Fernet key> +``` + +> ⚠️ base compose 里 pg 的 `POSTGRES_PASSWORD: writer` 是硬编码弱口令。生产请在覆盖文件里 +> 用 `POSTGRES_PASSWORD: ${PG_PASSWORD}` 覆盖 pg,并让 `DATABASE_URL*` 用同一口令 +> (上例已用 `${PG_PASSWORD}`)。若你保持 base 的 `writer` 口令,**务必**确保 pg 不可从公网访问(§6)。 + +### 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),有两种可用方式: + +**方式一(当前即可用,无需改 Dockerfile)——从宿主机 checkout 跑迁移:** + +```bash +# 宿主机需装 uv(curl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH") +cd writer-work-flow +uv sync +# 指向 compose 里 pg 暴露到 127.0.0.1:5432 的端口(§2.2 覆盖文件已绑本机) +DATABASE_URL="postgresql+asyncpg://writer:${PG_PASSWORD}@127.0.0.1:5432/writer" \ +DATABASE_URL_SYNC="postgresql+psycopg://writer:${PG_PASSWORD}@127.0.0.1:5432/writer" \ +CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \ + uv run alembic upgrade head +uv run alembic check # 可选:确认无漂移 +``` + +**方式二(更干净,需给 Dockerfile 补一行)——在 api 容器里跑迁移:** +先在 `apps/api/Dockerfile` 加 `COPY alembic.ini ./`(放在 `COPY apps/api ./apps/api` 之后), +`$COMPOSE build api` 重建后: + +```bash +$COMPOSE run --rm api uv run alembic upgrade head +``` + +> 这一步需要 parent/维护者决定是否接受改 Dockerfile。改前方式二不可用。 + +**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` 缺失/非法(§1.5 缺口 2 / §2.3)—— +日志会显示 CredentialKeyError 相关堆栈。 + +部署完成后,**打开前端「设置」页录入 LLM provider 凭据**(§1.4),产品才能真正调用模型。 + +--- + +## 3. 反向代理 + HTTPS + +生产应在应用前放一层反代做 TLS 终止、单一入口、并(配合 §6)做访问控制。 +拓扑:浏览器 → 反代(443) → `web:3000`(页面/RSC)与 `api:8000`(`/api` 或独立子域)。 + +> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联(§1.5 缺口 4):浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。 +> 若走「同源 `/api` 前缀反代」,需让前端构建时 `NEXT_PUBLIC_API_BASE` 指向该前缀(当前 web Dockerfile 未暴露该 build ARG,需按缺口 4 处理); +> 若走「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=... PG_PASSWORD=...`。 +- 迁移:`fly ssh console` 进 api 机器跑 `alembic upgrade head`(同样受 §1.5 缺口 1 影响——需镜像含 `alembic.ini`,或用 `fly ssh` + release_command)。可用 Fly `[deploy] release_command` 在每次发布前自动跑迁移(需 alembic.ini 在镜像里)。 +- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入(Fly build args / Dockerfile ARG,缺口 4)。 +- 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)。 +- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL,`NEXT_PUBLIC_API_BASE`(构建期)指向 api 公网 URL。 + +> 两个平台的共同注意点:**全部继承 §1.5 的缺口**(尤其 alembic.ini 未随镜像、NEXT_PUBLIC 构建期内联)。 +> 上线前按缺口处理,或接受「迁移只能从含源码的环境跑」。 + +--- + +## 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 的方式一或方式二 +uv run alembic upgrade head # 方式一(宿主机 checkout,指向 pg) +$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 + +# 数据库回滚(谨慎):按需降级一版 +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 与 compose **未内置** HEALTHCHECK,§1.5 缺口 5——存活探测靠反代/平台配。) + +### 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`;compose `api` 缺 `CREDENTIAL_ENC_KEY`、`web` 缺 `API_BASE_INTERNAL`;`NEXT_PUBLIC_API_BASE` 构建期内联;api/web 无 HEALTHCHECK。 +- **红线**:单用户无认证 → 不裸暴露公网;pg 不对外;CORS 禁 `*`;`CREDENTIAL_ENC_KEY` 备份+轮换。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a641d8d --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# 网文创作工作流(Writer Work Flow) + +> AI 辅助的**中文网文写作工作流**,以 Web 应用形态交付。核心理念:**把写长篇当软件工程做**——先立架构(世界观 / 角色 / 大纲),对着规格逐章生成,每章写完即测(多维审查),全程维护单一真相源(数据库)。 + +作者不再让 AI「一口气瞎写十万字」,而是像写代码一样:需求(立项)→ 架构(设定库)→ 建模(角色卡)→ 接口(大纲)→ 实现(逐章生成)→ 测试(写完即审)→ 集成(验收更新全局状态)→ 回归(全书一致性)。AI 是「灵感增幅器 + 质检员」,作者始终掌控创意。 + +--- + +## 功能总览 + +- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角,AI 起草世界观、主角、金手指、总纲方案,一键建库。 +- **设定库(Codex)** — 结构化的单一真相源: + - 角色卡(外貌 / 动机 / 目标 / 口癖 / 性格弧光 / 人物关系图) + - 世界观(力量体系 / 势力 / 地理 / 词条) +- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。 +- **写章(SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。 +- **五审(写完即审)** — 一致性 / 伏笔 / 文风 / 节奏 四审 + 人物塑造(advisory 建议性)。审查器**只读**,不静默改稿。 +- **验收事务(HITL)** — 人在环中:作者裁决冲突后,唯一写入路径提交;未解决冲突阻断验收。章节摘要从**最终已验收文本**抽取,绝不污染真相源。 +- **创作工具箱(15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。 +- **模板库** — 复用与沉淀常用创作模板。 +- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关;Agent 只声明能力 tier,网关按配置映射到 provider+model;支持 Kimi Code 订阅 OAuth(device flow)。 + +--- + +## 技术栈(已锁定) + +| 层 | 选型 | +|---|---| +| 前端 | **Next.js + TypeScript**(仅 UI;经 OpenAPI 生成的 TS 客户端调后端,无手写共享类型) | +| 后端 | **Python + FastAPI**(async,SSE) | +| 编排 | **LangGraph**(写→审→验收 图,Postgres checkpointer 可恢复) | +| LLM 访问 | 自建**薄网关**,覆盖 `anthropic` + `openai`(baseURL 兼容 DeepSeek/Kimi/Qwen/GLM)+ `google-genai` | +| 结构化输出 | **Pydantic + instructor** | +| ORM / 迁移 | **SQLAlchemy 2.0(async)+ Alembic** | +| 存储 | **Postgres**(原型不启用 pgvector) | +| 长任务 | FastAPI BackgroundTasks + `jobs` 表(原型不引入独立队列) | + +工具链:**uv**(Python workspace,8 个成员)+ **pnpm**(前端)。Python 3.12+,Node 22。 + +--- + +## 快速开始 + +前置:Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker + +```bash +# 1. 克隆后,安装后端依赖(仓库根,uv workspace) +uv sync + +# 2. 安装前端依赖 +cd apps/web && pnpm install && cd ../.. + +# 3. 配置环境变量(CREDENTIAL_ENC_KEY 为必填的凭据加密 Fernet key,启动即校验) +cp .env.example .env +# 生产请重新生成: +# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +# 4. 起 Postgres 并跑迁移 +docker compose up -d pg +uv run alembic upgrade head + +# 5. 起后端 API(默认 http://localhost:8000,文档 /docs) +uv run uvicorn ww_api.main:app --reload + +# 6. 起前端(另开终端,默认 http://localhost:3000) +cd apps/web && pnpm dev +``` + +或一键起全套(pg + api + web): + +```bash +docker compose up +``` + +> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。 + +--- + +## 工具链 & 门禁 + +改动声明「完成」前须全绿(TDD 先红后绿,覆盖率 ≥ 80%)。 + +**后端**(仓库根): + +```bash +uv run ruff check . # lint +uv run ruff format . # format +uv run mypy packages apps # 类型 +uv run pytest -q # 单元 + 集成(需 pg:docker compose up -d pg) +uv run alembic check # 改过模型后校验无迁移漂移 +uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80 # 覆盖率门禁 +``` + +**前端**(`apps/web`): + +```bash +pnpm lint +pnpm typecheck +pnpm test # vitest(范围 lib/**:纯逻辑 + hooks) +pnpm build +pnpm test:coverage # 覆盖率门禁(阈值 80%) +``` + +**改过后端 schema** 后重生成 TS 客户端: + +```bash +cd apps/web && pnpm gen:api +``` + +CI 见 `.github/workflows/ci.yml`(backend job 带 pg service 跑 ruff/mypy/alembic/pytest;frontend job 跑 gen:api/lint/typecheck/test)。 + +--- + +## 项目结构 + +``` +apps/ + api/ FastAPI 应用:路由 / service / 请求响应 schema / SSE 端点 + web/ Next.js 前端:Server Components 读视图 + Client 编辑器/流式交互 +packages/ + shared/ 跨栈 Pydantic 契约 schema(前端经 OpenAPI 消费) + config/ 应用配置(Settings,环境变量边界) + db/ SQLAlchemy 2.0 模型 / Alembic 迁移 / Repository + llm_gateway/ 多 provider 网关:路由 / 回退 / 熔断 / adapter / 计费台账 + agents/ 声明式 AgentSpec + 外置 prompt(Agent 只声明能力 tier) + core/ domain 领域逻辑 · memory 记忆真相源(确定性选择注入)· orchestrator LangGraph 图 + skills/ 工具箱注册表 + skill 注册(声明驱动的生成器) +``` + +--- + +## 文档地图 + +四份 spec 分层,每层细化上一层;实现暴露的规格缺口须回写对应 spec。 + +| 文档 | 回答什么 | 何时读 | +|---|---|---| +| [`PRODUCT_SPEC.md`](./PRODUCT_SPEC.md) | 是什么 & 为什么 —— 问题、功能(含 P0/P1/P2 优先级)、数据模型、端点、Agent | 理解范围或某功能 | +| [`UX_SPEC.md`](./UX_SPEC.md) | 长什么样 —— 信息架构、用户流、页面线框、组件、视觉 token(纸感 / warm-cream 主题) | 做任何 UI | +| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 怎么实现 —— 完整 DDL、网关内部、编排引擎、API 契约、横切关注点 | 实现后端 / 数据 / 网关 | +| [`DEV_PLAN.md`](./DEV_PLAN.md) | 按什么顺序 —— Phase 0–5,每个任务标注学科 skill | 挑下一个任务 | +| [`PROGRESS.md`](./PROGRESS.md) | 交付到哪了 —— 权威状态台账 + 变更日志 | 开工前必读 | + +冲突裁决:更具体 / 更靠后的文档为准(`ARCHITECTURE` > `UX_SPEC`/`DEV_PLAN` > `PRODUCT_SPEC`)。协作与开发规范见 [`CLAUDE.md`](./CLAUDE.md)。 + +--- + +## 测试与原型边界 + +- **测试**:TDD 强制(先写失败测试);三层——单元(一个节点/函数/Repository,mock IO)→ 集成(FastAPI 端点 + DB + mock 网关)→ E2E(写→审→验收,mock 网关)。**所有测试一律 mock LLM,绝不命中真实 LLM API**。覆盖率门禁 ≥ 80%。 + +原型作用域,以下**明确不做**(勿误用于生产): + +- **单用户,无 auth / 多租户** —— `users` / `owner_id` 为占位桩。 +- **无向量检索 / pgvector** —— 记忆注入是确定性按需选择(显式点名 + 主要角色 + 最近 N 章),非向量搜索(向量检索列 P2)。 +- **无独立任务队列** —— 长任务走 FastAPI BackgroundTasks + `jobs` 表。 +- 不做模型微调、内容分发 / 发布平台对接、移动原生 App。