www_site/docs/phase-5-report.md
flym df6a0e46a6 docs: rework offsite backup into a real, restorable archive
Move backup to /volume2/NetBackup (DSM shared folder, kts_bak read/write) and
change the design from a --delete mirror to an additive, date-stamped archive:
per-run pg_dump snapshots for the DBs and per-run full tar.gz snapshots for
audio, each kept for 7 generations, transferred over encrypted SSH with
post-transfer SHA256 verification (success only when byte-identical). This
removes the --delete risk (source-side deletion no longer wipes the backup),
enables restore-to-a-date, and is verified by an actual restore from the NAS
into isolated DBs (0 errors, quota/ledger and audio associations consistent).
2026-09-10 17:48:51 +08:00

136 lines
12 KiB
Markdown
Raw 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.

# Phase 5 集成与上线准备报告
日期2026-09-10
基线代码:`9568622`Phase 4 验收通过)
本轮修复提交:`8657f44`(已部署为发布 `8657f44`
测试环境:`192.168.199.22`,隔离库 `kaotings_p5`,隔离音频目录 `/tmp/kaotings-p5-audio*`,本地伪造上游
原则:只把有证据的能力标为完成;故障注入全部在隔离环境进行,不触碰生产 `kaotings` 库与既有音频。
## 0. 结论摘要
| 项 | 结果 |
| --- | --- |
| 任务与额度异常矩阵Phase 3 遗留) | **通过**(修复后 13 个场景全部通过;见 §1 |
| 管理员并发调减额度不变式 | **发现并修复缺陷**,已验证(见 §1.1 |
| 自动化验收固化 | **通过**(测试工具、依赖版本、运行说明入仓库,秘密走环境变量) |
| 音频运维方案 + 隔离恢复演练 | **通过(带 1 项发现)**:库/音频可恢复、额度与流水一致;发现 4 条 `available` 音频行无对应文件(见 §3 |
| 上游安全改造 | **待外部条件**:公网 HTTP 仍为上线阻断,需上游提供 HTTPS/加密通道 + 来源限制(见 §4 |
| 剩余小项slug 500 / SMTP | **通过(记录在案)**:非法 ID 500 不泄露堆栈/内部信息SMTP 维持 V1 已声明限制 |
上线阻断项:**公网 HTTP 上游**§4未解除前不得判定“可正式上线”。
## 1. 任务与额度异常矩阵(隔离故障注入)
### 1.1 关键缺陷:管理员调减额度与任务并发(已修复)
- 现象(基线 `9568622``POST /admin/users/{id}/quota-adjustments` 计算投影余额时**未**对额度账户加行锁、也未持用户级咨询锁,只依据一次无锁读取判断。并发调减时多个请求读到陈旧的 `used/reserved`,全部通过校验后 `adjustment` 被相对累加,导致 `limit + adjustment - used - reserved` 出现负值(可用额度为负)。
- 隔离复现S3`limit=100` 的账户并发发起 4 次 `-30` 调减,重复 8 轮。**基线代码 7/8 轮不变式被破坏**available 出现负值)。
- 修复(`8657f44`
- `admin_quota`:先 `pg_advisory_xact_lock(hashtext(user_id))`,再 `SELECT ... FROM quota_accounts ... FOR UPDATE`,随后基于加锁后的行重算 `projected`。校验同时覆盖 `used``reserved`(冻结),不只是已用。
- `finish_task_success` / `finish_task_failure` / `recover_expired_tasks`:结算/回收前对额度账户 `FOR UPDATE`,使“冻结、结算、调减”都串行化在同一把行锁上。
-`create_tts_task`(已持咨询锁 + 额度行锁)保持同一加锁顺序,避免死锁。
- 修复后(`8657f44`S3 八轮**不变式全部保持**(最坏 available=10恒 ≥0S4冻结+调减并发压测)终态不变式成立、`reserved` 归零。
不变式定义(每个额度账户恒成立):`limit_snapshot + adjustment - used - reserved >= 0`。
### 1.2 场景矩阵(隔离环境,伪造上游)
被测代码:`8657f44`。每项记录:上游调用次数、任务终态、已用/冻结/可用(当前周期账户)、音频文件结果。
| 场景 | 上游调用 | 任务终态/错误码 | 已用 | 冻结 | 可用 | 音频文件 | 结论 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| S1 成功基线 | 1 | `succeeded` | 2 | 0 | 9998 | 存在,回放/下载 200 | 通过 |
| S2 最后额度并发争用 | 1 | `codes=[202,409]`(仅 1 个冻结成功) | 3 | 0 | 2 | n/a | 通过 |
| S3 管理员并发调减额度 | 0 | 8 轮不变式保持(最坏可用 10 | 0 | 0 | 10 | n/a | 通过(修复后) |
| S4 冻结与调减并发压测 | 3 | 终态不变式成立、冻结归零 | 24 | 0 | 16 | n/a | 通过 |
| S5 非音频 | 1 | `failed / UPSTREAM_NOT_AUDIO` | 0 | 0 | 1000 | 无 | 通过 |
| S5 损坏 WAV | 1 | `failed / UPSTREAM_AUDIO_CORRUPT` | 0 | 0 | 1000 | 无 | 通过 |
| S5 超大响应 | 1 | `failed / UPSTREAM_RESPONSE_TOO_LARGE` | 0 | 0 | 1000 | 无 | 通过 |
| S5 上游 5xx | 1 | `failed / UPSTREAM_HTTP_500` | 0 | 0 | 1000 | 无 | 通过 |
| S6 跨周期结算 | 1 | 结算落到任务创建时所属(上一)周期账户 | 5(上周期) | 0 | 100(本周期未变) | n/a | 通过 |
| S7 存储失败 | 1 | `failed`(音频目录不可写) | 0 | 0 | 10000 | 无 | 通过 |
| S8 上游超时 | 1 | `failed / UPSTREAM_TIMEOUT` | 0 | 0 | 10000 | 无 | 通过 |
| S9 重启恢复-queued | 2 | `queued→succeeded``running` 保持,租约未到期) | 3 | 3 | 9994 | queued 任务音频存在 | 通过 |
| S10 重启恢复-running 租约过期 | 0 | `failed / WORKER_LEASE_EXPIRED`,冻结释放 | 0 | 0 | 10000 | 无 | 通过 |
要点:
- 所有失败/异常路径均**释放冻结**、**不产生残留音频文件**、**不破坏额度不变式**。
- 重启恢复:`queued` 任务在进程重启后被重新领取并结算;`running` 任务在租约过期后被回收为 `WORKER_LEASE_EXPIRED` 并释放冻结。租约 5 分钟、单 Worker、数据库锁为跨进程边界与 Phase 3 一致)。
- S9 中 `running` 任务在重启后仍为 `running` 属预期(租约未到期,不会重复处理,待租约过期由回收逻辑兜底)。
对比基线:`9568622` 下 S3 失败7/8 轮破坏不变式),其余场景与上表一致。**修复后 `8657f44` 全部通过。**
复现方式(隔离,秘密走环境变量):`services/api/tests/phase5_exception_matrix.py`,见 `services/api/tests/README.md`
## 2. 自动化验收固化
- 测试与运行时依赖版本固化:`services/api/requirements.txt`、`services/api/requirements-test.txt`(已验证版本见 `services/api/tests/README.md` 表格)。
- 隔离库准备脚本 `services/api/tests/provision_isolated_env.sh``SUDO_PASSWORD`、`P5_DB_PASSWORD` 等**仅经环境变量注入**,不落仓库。
- 异常矩阵脚本 `services/api/tests/phase5_exception_matrix.py`:自启动隔离 API + 伪造上游,逐项断言并输出结果表;退出码 0 表示全部不变式通过,可重复执行、可作为验收门禁。
- 已在独立环境(隔离库 + 独立音频目录 + 伪造上游)验证可重复执行,不依赖测试机手工安装的工具;浏览器测试的 Playwright 浏览器二进制仍不入库(沿用 Phase 4 约定,`PLAYWRIGHT_BROWSERS_PATH` 指向本地缓存)。
## 3. 音频运维方案与隔离恢复演练
### 3.1 容量 / 保留 / 清理(需用户决定的参数集中列于 OPERATIONS.md §3
- 存储:本地私有目录 `/home/flym/kaotings-audio``{user_id}/{task_id}.wav`),非对象存储。
- 保留期:`AUDIO_RETENTION_SECONDS`(当前默认 7 天)——**待用户确认**。
- 清理:进程启动时 `cleanup_orphan_audio` 删除“无 `available` 记录且 mtime>1h”的孤儿文件保留期清理策略待确认后再启用**本轮不擅自删除任何既有音频**。
### 3.2 隔离恢复演练(业务库 + CMS 库 + 音频)
在隔离库 `p5_restore_biz` / `p5_restore_cms` 与隔离音频目录执行 `pg_dump`→`pg_restore` + 音频拷贝,结果:
- 业务库行数(源 vs 恢复全部一致users 35、quota_accounts 34、tts_tasks 28、audio_files 26、usage_records 61、membership_grants 10。
- 额度↔流水一致性恢复库34 个额度账户,`used` 与 `consume` 流水 0 不符、`reserved` 与 `reserve-consume-release` 流水 0 不符。
- CMS 库恢复 14 张表products=1、users=1、payload_migrations=1结构完整
- 音频:磁盘 22 个 `.wav` 全部可被 `available` 记录引用(无孤儿);**发现 4 条 `status=available` 且任务 `succeeded``audio_files` 行在磁盘无对应文件**(源目录与恢复目录均缺失)。
- 影响:这些历史任务在“音频可用”标记下回放/下载会返回 404优雅降级不崩溃属既有 DB/文件系统不一致,**非本轮引入**。
- 处置:作为待调查项记录,不阻断恢复;恢复流程本身(库 + 音频 + 额度/流水)验证通过。
结论业务库、CMS 库、音频均可在隔离环境完整恢复,历史↔音频关联与额度数据一致(除上述 4 条既有缺失)。
### 3.3 异地备份(本阶段后续配置,已落地并实际恢复验证)
- 用户提供异地 NASSynology`103.40.14.100`。按要求**不放 home**,落盘 **`/volume2/NetBackup/kaotings/`**DSM 共享文件夹,`db/`+`audio/``kts_bak` 已授读写)。
- 传输 **SSH `52200`(加密 + 密钥认证)**,不使用公网明文 rsync 守护端口 `50873`;密钥 `/root/.ssh/id_ed25519_ktsbak`
- 设计为“备份”而非“镜像”(针对评审意见修正):
- **追加、绝不用 `--delete`**:每次追加时间戳快照;源端删除/清理/损坏不会删除或清空 NAS 副本。
- **按日期可恢复**DB 每运行一份 `pg_dump` 快照;音频每运行一份整树 `tar.gz` 快照;保留最近 7 份/序列。
- **传输后 SHA256 校验**3 个产物逐一比对本地与 NAS缺失/不一致即 `exit 1`**只有完整且校验通过才记成功**。
- 脚本 `/usr/local/sbin/kaotings-backup.sh`rootsystemd `kaotings-backup.timer` 每日 03:00 触发。
- **已从 NAS 实际恢复验证**(非仅 `pg_restore --list``pg_restore` 到独立库 0 错误;行数与源一致;额度↔流水 0 不符34 账户);音频 22/26 与源一致。详见 `OPERATIONS.md` §2/§4。
- 仍待确认:保留份数(`KEEP`)、失败通知渠道、是否降权运行(`OPERATIONS.md` §5/§6
## 4. 上游安全改造(上线阻断,待外部条件)
- 现状TTS 上游为公网 **HTTP**`TTS_UPSTREAM_URL`Bearer Key 走明文传输。这是正式上线阻断项。
- 上线前必须满足(具体实施条件):
1. **传输加密**:上游提供 HTTPS受信任/可内部校验的证书),或受控加密通道(如内网 VPN/专线、mTLS
2. **来源限制**:上游侧限制仅本站服务器出口 IP/网段可访问(防火墙/安全组/WAF 白名单),并在业务侧保留 Bearer Key。
3. **外部网络复测**:加密与来源限制生效后,重新执行成功生成、音频校验、回放/下载、超时/失败矩阵与外部不可达性核查。
- 需要上游负责人提供:
- HTTPS 端点与证书(或 mTLS/专线接入方式、允许的来源网段)。
- 凭据注入名称、有效期、轮换方式(密钥仍只进 `/etc/kaotings/api.env`,不进 Git/前端/日志)。
- 允许的来源机器/IP 段与网络方式,便于双方配置来源限制。
- 边界:**不擅自修改上游服务**;本轮仅记录条件与所需输入,未改动任何上游配置。
## 5. 剩余小项
- **按 slug 的 REST 路由**:不新增。公网产品展示统一走 `where[status][equals]=published` 列表 + `where[slug][equals]=` 筛选(已验证 200
- **非法 ID 导致 500**`GET /cms/api/products/:slug`Payload 将 `:slug``id` 处理)传入非 UUID 返回 500响应体为**通用** `{"errors":[{"message":"Something went wrong."}]}`**未泄露堆栈、源码路径或内部信息**(已扫描确认)。作为 Payload 行为记录在案,公网展示闭环不受影响。
- **SMTP 未接入**:维持为 **V1 已声明限制**(验证渠道返回未启用)。管理员密码恢复依赖本地受控流程,见 `docs/cms-admin-recovery.md`;本轮不接 SMTP。
## 6. 部署与版本核对
- 修复提交 `8657f44` → 发布目录 `/home/flym/releases/8657f44`(自 `p4` 完整拷贝后更新 API
- 软链 `kaotings-api`→`…/8657f44/services/api`web/cms 同步指向 `8657f44`,仅重启了发生代码变更的 `kaotings-api`)。
- 版本核对:线上 `services/api/app/main.py` SHA256 = `571037f4…`,与本地 `8657f44` 完全一致;`systemctl is-active kaotings-api` = `active``GET /healthz` = `{"status":"ok","database":"ok","service":"api"}`
- 回滚:软链指回 `…/p4/services/api` 并重启 `kaotings-api` 即可(`p4` 保持完好)。
## 7. 未通过 / 待外部条件项(不得标为完成)
- **上线阻断**:公网 HTTP 上游未加密、无来源限制§4待上游提供条件并外部复测。
- **既有数据发现**4 条 `available` 音频行无对应文件§3.2),待调查根因;恢复流程本身通过。
- 旧 Big-TTS Web 退役前,仍需旧系统负责人提供源端备份证明与替代验收确认(沿用 Phase 3/4 边界)。