# Ops 服务接入规范 · AI 开发单页（opsstd v1）

- 标准：`opsstd`
- 标准版本：`1.2.6`（唯一事实源：`opsstd://v1/manifest.json` 的 `version`；`version.json` 为镜像元数据，两者不一致时启动即报错）
- 最低服务端版本：`2.1.0`（1.1.0 的 `DATA_DIR` 完整校验与 integrity 失败即备份失败依赖 2.1.0；当前**不强制校验**：没有接口暴露服务端版本，仅作规划与排障参考）。1.2.2 的备份目标语义（provider 覆盖、本地目标、远端 fail-closed）只在后端包含该改动的版本生效，发布前旧后端仍按「默认上传 OSS」行为运行
- 生成日期：`2026-11-14`
- 适用对象：部署在 47.110.36.244 上、由 PM2 托管并由 Caddy 反向代理的 Node.js / Python / Go / 静态服务
- 使用方式：将本文件整份作为起点，生成代码、目录、脚本、runbook 与项目登记；文中规则全部可离线自检，只有标注「平台校验」的条目需要 Ops 平台在线判定

> **鉴权先读（v1.0.0 起）**：公网入口 `https://mcp.soyooo.top/mcp` 需要两层凭据——客户端 Token（识别调用方与限流）+ 项目 Key（`X-Ops-Project-Key`，决定项目与能力）。不带项目 Key 时只能读取标准文档资源，**所有项目数据工具返回 `PROJECT_KEY_REQUIRED`**；带 Key 时只能访问 Key 绑定的项目。本地 stdio 模式行为不变。详见 1.1、1.2；项目端客户端配置与排查见第 17 章。

> 服务端返回的 `standardVersion` / `standardDigest` 若与本文件不一致，以服务端为准，并拒绝任何写操作（`STANDARD_VERSION_MISMATCH`，不做静默降级）。

---

## 0. 速览：12 条不可协商的硬规则

1. 代码目录固定 `deployPath`（登记 `backup.appDir`，未登记时按 `<OPS_DEPLOY_ROOT>/<id>` 兜底，安装默认 `/opt/apps/{id}`），数据目录独立（默认 `<deployPath>/data`），**数据不得位于 `releases/` 或 `current` 之内**。
2. PM2 进程名 = `pm2Name`，`cwd` = `deployPath`，两者与后台登记逐字一致。
3. 端口只从环境变量读取，服务只监听 `127.0.0.1`，**禁止 `0.0.0.0`**。
4. `autorestart: true`，并实现 `SIGTERM` 优雅退出。
5. 健康检查默认路径 `/healthz`（登记 `probePath`），**只有 2xx/3xx 通过，`>=400` 判失败**。
6. 日志只写 stdout/stderr，由 PM2 收集；应用**不自行写轮转文件**。
7. 启动参数按性质拆两处：非密钥运行参数（`PORT`/`HOST`/`NODE_ENV`/`WEB_DIST` 与数据路径键）写入 ecosystem `env`，作为启动唯一真源；密钥（`JWT_SECRET`、第三方 API Key 等）只放 `.env`（权限 `600`，不入 git、不进日志、不进备份包）。**数据目录不强制叫 `DATA_DIR`**：也可写应用真正消费的 `DATABASE_FILE`、`UPLOAD_ROOT` 等键，解析后必须落在登记 `dataDir` 内；无持久化状态在 projects.json 写 `stateless: true`。禁止为了凑字段写一个应用从不读取的假键。
8. 备份范围为**整个 `data/` 目录**；SQLite 必须用 `sqlite3 ... ".backup"`，禁止直接复制 `.db` 文件。
9. 运维脚本固定放 `/opt/ops-scripts/{id}/`，**不放项目 git 目录**（会被 `git pull` 覆盖）。
10. runbook 固定放 `/opt/runbooks/{id}.md`，8 章齐全，≤200KB；**不得写死具体 release 或版本号**（会天天过期），一律用 `readlink -f <deployPath>/current` 或读 `current/package.json` 动态取。
11. 发布产物 = `{id}-{version}.tar.gz` + 同名 `.sha256`，由项目内固定 build 脚本一条命令产出（含禁项扫描、软链检查、解包后随机端口 + 临时数据目录的实际启动自检），落到 `releases/{ts}` 并用 `current` 软链切换，不覆盖旧版本。
12. 不改动服务器上其他站点、不重启 Caddy、不使用 `pm2 delete/kill/save`、不对 `ops` 项目做任何操作。

---

## 1. 平台关系与边界

| 事项 | 约定 |
|------|------|
| 公网入口 | Caddy 终止 TLS 后 `reverse_proxy 127.0.0.1:{port}`；应用不直接暴露公网端口 |
| 证书 | 静态证书 `/etc/caddy/certs/fullchain.pem` 与 `privkey.pem`；新域名必须已被证书 SAN 覆盖，否则拒绝接入（**执行方：平台 / 运维**；项目方只需确认域名可正常 HTTPS 访问） |
| 平台角色 | 登记 projects.json、Caddy 站点块、备份目标（本地/OSS/COS）、健康探测、审计 |
| 项目角色 | 应用代码、`.env`、`data/`、本地备份触发、runbook 内容 |
| 预留资源 | 端口 80/443/2019（Caddy）、3100（ops-backend）、3200（ops-mcp）、8080（jingyu-server）、25884（scmusic）；项目端口建议 3000–3999 |
| 保留标识 | `projectId` 不得为 `ops`；`pm2Name` 不得为 `ops-backend`、`caddy`、`pm2-logrotate` |

### 1.1 两种使用方式（不要混为一谈）

| 方式 | 入口 | 凭据 | 可见范围 |
|------|------|------|----------|
| 公网入口 | `https://mcp.soyooo.top/mcp` | 客户端 Token + `X-Ops-Project-Key` | 仅 Key 绑定项目；无 Key 只能读标准文档资源 |
| 本地 stdio | 本机 `ops-mcp` 进程（管理员） | 本地配置；`OPS_PROJECT_KEY` 可选 | 本地管理员可读全部项目，行为与旧版一致 |

- 公网入口面向开发者与 CI，必须按 1.2 携带两层凭据；**不要把本地 stdio 的「可读全部项目」当成公网行为**。项目端如何配置客户端、自检与排错见第 17 章。
- stdio 模式适合管理员在本机排查；它不经过公网入口的 Key 校验，但同样不能绕过 ops-backend 的鉴权与硬约束。

### 1.2 公网入口凭据与错误语义

**第一层：客户端 Token**。放在 `Authorization: Bearer <token>`（兼容 `X-Ops-Token`），用于识别调用方与限流（默认 120 次/分钟，超出返回 `429`）。它**不授予任何项目数据权限**。

**第二层：项目 Key**。放在 `X-Ops-Project-Key`，格式 `ops_pt_<projectId>_<random>`，绑定唯一项目，决定可见范围与能力。Key 由 superadmin 通过 `POST /api/keys` 签发，服务端只存哈希，明文只在签发时展示一次。

两层凭据**发放入口不同、不可互相替代**：

- 项目 Key：在运维后台「项目 Key」签发，随「复制信息」一并下发（含 `X-Ops-Project-Key` 明文或掩码）。
- 客户端 Token：在运维后台「MCP 客户端」按**调用方**单独签发（`ops_ct_<clientId>_<random>`），不属于项目接入材料；后台「复制信息」里只有 `Authorization: Bearer <客户端 Token>` 占位，必须由管理员另行签发后填入。
- 只有项目 Key、没有客户端 Token 时，**第一个 `initialize` 就会返回 `401 CLIENT_TOKEN_REQUIRED`（`reason=missing`）**，不会进入 MCP 层，也拿不到任何 `ops_*` 工具；反之只有客户端 Token 时工具可见，但项目数据类工具一律 `PROJECT_KEY_REQUIRED`。

- 不带项目 Key：只可读取标准文档类资源（`opsstd://*`、`manifest.json`、规范正文、版本与 digest）；`opsproj://current` 只返回 `bound:false`。**所有项目数据类工具一律返回 `PROJECT_KEY_REQUIRED`，不会触达 ops-backend**。
- 带项目 Key：只能看到绑定项目——列表类接口只返回该项目，其余工具传入的项目必须与 Key 绑定项目一致；跨项目请求返回 `403`，错误信息不区分项目是否存在。
- 项目 Key 可读**自己项目**的只读信息：项目列表、部署状态、inventory（仅本项目域名/端口/进程）、备份列表、runbook、日志、调度状态、覆盖本项目域名的证书。跨项目访问与全部写操作仍返回 `403`；全局管理接口（`/api/keys`、`/api/caddy/*` 写接口等）一律拒绝。
- **冲突检测与新项目登记只能在管理员侧完成**：`ops_check_conflict` 依赖全量项目视图。项目 Key 下调用**不会给出“无冲突”**：返回 `ok:false`、`limitedView:true`、`requiresAdminReview:true`，并提示由管理员用全量视图复核。正确顺序是「先找管理员建项目并签 Key，再用 Key 部署」。

| 现象 | 返回 |
|------|------|
| 未携带 `Authorization` | `401`，`CLIENT_TOKEN_REQUIRED`，`reason=missing` |
| 客户端 Token 错误、被吊销、已过期 | `401`，`CLIENT_TOKEN_REQUIRED`，`reason=invalid` / `revoked` / `expired`（`code` 相同，**按 `reason` 区分**，不要只看 `code`） |
| 未带项目 Key 调用项目工具 | 工具错误 `PROJECT_KEY_REQUIRED`（不触达后端） |
| Key 头格式非法、重复数组、超过 128 字节 | `400`，`INVALID_PROJECT_KEY` |
| Key 伪造、过期、已吊销、一次性已使用、绑定项目不存在 | `401` |
| 访问非绑定项目 | `403`，提示不区分项目是否存在 |
| 使用 `ops` 项目的 Key | `403` |

**Key 类型**（均由 superadmin 签发）：

| 类型 | scope | 用途 | 默认有效期 |
|------|-------|------|------------|
| `deploy` | `project:read`、`project:deploy`、`project:rollback`、`project:backup:run`、`project:caddy:write`、`project:env:write` | 本项目发布、回滚、备份与改环境变量等写操作 | 24 小时（可设 1 小时–7 天） |
| `readonly` | `project:read`、`project:logs`、`project:backup:read` | 本项目只读、日志与备份查询 | 90 天（可设 1 小时–90 天） |

- `deploy` 类 Key 可设为一次性，成功使用一次后立即失效，再次使用返回 `401`。
- Key 等于生产写权限：**不要发到群聊、提交进仓库、写进日志或截图**；只放本机环境变量或密钥管理，泄露立即吊销。

安全前提：本模型只约束 MCP / 平台通道。如果开发者同时持有服务器 SSH/sudo，可直接绕过全部约束——「只发 Key、不发 shell」是合规成立的前提。

---

## 2. 当前能力边界（务必先读，避免误判）

### 2.1 已实现

- MCP 工具 14 个：11 个只读工具 `ops_list_projects`、`ops_get_health`、`ops_get_logs`、`ops_get_runbook`、`ops_list_backups`、`ops_get_scheduler_status`、`ops_get_inventory`、`ops_check_conflict`、`ops_plan_deployment`、`ops_check_conformance`、`ops_get_project_context`；3 个写工具 `ops_upload_artifact`、`ops_deploy_project`、`ops_rollback_project`（部署/回滚默认 `dryRun=true`，真正写入需 `dryRun=false` + `confirm=true`）。
- 公网入口两层凭据：客户端 Token 识别调用方与限流；项目 Key 绑定项目并决定能力。无 Key 仅标准文档资源可用，有 Key 仅绑定项目可见。
- 项目 Key 的签发/列表/吊销（`/api/keys`，仅 superadmin）、`/api/project/context`、`/api/project/deploy`、`/api/project/rollback`、`/api/project/env`（GET/PUT）。
- 受限执行器：部署/回滚/staging/`current` 切换/健康检查/失败回滚、`.env` 掩码读写，均在 ops-backend 内完成。
- 回滚语义：`ops_rollback_project` 支持可选 `targetRelease`——必须是该项目 `releases/` 中已存在的目录名，允许向前或向后；不传时保持“退一格”。目标等于当前会被拒绝；返回 `direction`（`forward`/`backward`）与 `targetSource`（`explicit`/`default`）。目标切换后重启、环境或健康校验失败时，统一切回原 release 并恢复 env 快照；切回失败会保留现场并写 error 级事件。oplog 成功事件的 `reason` 明确为 `null`，失败事件必须带 `reason`（`health`/`env`/`pm2`/`error`）。
- 线上 conformance（`ops_check_conformance`）：实时拉登记、查进程、探端口、读 Caddy 后现场比对，不读取发布包内任何文件；覆盖登记字段、runbook 路径与章节、备份配置与生效的备份目标、PM2 进程存在与 online、端口探测、Caddy 站点块与反代目标、OSS 资源前缀。
- 部署工具内置 conformance 门槛：存在 `fail` 时拒绝部署（`CONFORMANCE_FAILED`），项目 Key 无 override 权限。**首次部署**（项目从未有 `releases/current`）自动把 `process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target` 标为 `deferred`（不计 fail），部署成功后执行器补建站点块并立即复检、写入审计，MCP 在响应里返回 `postDeployConformance`；已部署项目仍按 `fail` 拦截。
- 备份调度、按生效目标（本地 / OSS / COS）落盘与按来源保留 7 份、审计日志、Dashboard、Caddy 配置备份与 reload 管理（人工在后台操作）。
- 规范站静态分发：`develop.soyooo.top` 提供 `/`、`/llms.txt`、`/index.md`，内容与 `standards/llms.txt` 同哈希（`/v1/` 版本化路径与 digest 在线校验尚未实现）。

### 2.2 部分实现（不得当作完整能力）

- 项目 Key 的只读范围已放宽到本项目：`GET /api/projects`、`/api/projects/deploy-status`、`/api/caddy/inventory`（按项目过滤）、`/api/scheduler/status`（按项目过滤）、`/api/certs`（按域名过滤）、`/api/projects/:id/{runbook,logs,backups,ecosystem-check}`；跨项目与写接口仍 `403`。`project:caddy:write` / `project:backup:run` 对应的项目级写接口尚未开放。
- conformance 门槛目前由 MCP 工具在部署前校验，后端未二次强制。
- `pathPrefixes` 对所有角色实际为 `['**']`，**前缀隔离未生效**；新账号需显式收窄，但不能把它当作唯一安全边界。
- `automation` 角色不存在（规划中），不得在代码或脚本中依赖。
- MCP 层 `plan:read` / `conformance:read` 只读工具已可用，但权限分层未在 MCP 侧强制。

### 2.3 尚未实现（不要调用）

- `ops init` 脚手架、`ops check --offline` 离线自检、CI 门槛。
- 项目级备份执行接口 `/api/project/backup/run`、项目级 Caddy 写入接口 `/api/project/caddy`。
- `ops_register_service`、`ops_preflight_onboard`、`ops_update_project`、`ops_publish_runbook`、`ops_run_backup`、`ops_adopt_service`、`ops_query_audit`。
- `backup.sh` / `monitor.sh` 的受控投递、runbook 自动发布、多版本兼容窗口。

> 现状补充：项目登记仍由管理员通过后台/API 写入 `projects.json`，MCP 侧没有 `ops_register_service`。启动配置（`ecosystem.config.cjs`）从 2.11.2 起已由部署执行器自动解析/渲染，**不再要求人工预先生成**；产物自带配置也可被采用。

### 2.4 平台自动判定 / 只能人工

| 判定方式 | 条目 |
|----------|------|
| 平台可自动判定 | `registry.*`（字段格式与必填）、`runbook.path_standard` / `runbook.readable` / `runbook.sections`、`backup.app_dir` / `backup.schedule` / `backup.oss_prefix`（按生效目标可列出）、`process.exists` / `process.online`、`network.port_registered` / `network.port_probe`、`caddy.site_block` / `caddy.proxy_target` / `caddy.domain_unique`、`oss.asset_prefix` |
| 当前一律返回 `unknown`（需人工补证） | `backup.script_referenced`、`monitor.script_referenced`（crontab）、`network.listen_loopback`（`ss`）、`process.start_params`、`caddy.hardening`、`security.env_permissions`、`security.public_bind` |

`unknown` **不等于** `fail`，也不代表通过；出现 `unknown` 必须人工核对并在报告中列出。标注「平台自动判定」的条目在只读数据源不可用时（OSS 列取失败、runbook 读取异常、pm2 不可达）同样会返回 `unknown`。

结论：当前接入必须人工执行。conformance 只校验「登记与可观测项」，不校验代码质量；本文件的运行时契约（R1–R7，定义见 2.5）目前依赖开发者自检与后续离线校验，平台尚未逐条校验。

### 2.5 运行时契约 R1–R7

本节是 R1–R7 的唯一定义；其他章节引用编号时一律指回本节，不再重复展开。

| # | 契约 | 规范要求 | 判定方式 | 当前强度 |
|---|------|----------|----------|----------|
| R1 | 进程标识与工作目录 | PM2 `name` 必须等于登记 `pm2Name`；`cwd` 必须等于 `deployPath`（取登记 `backup.appDir`；未登记时 `<OPS_DEPLOY_ROOT>/<id>`，安装默认 `/opt/apps/{id}`） | 预检解析 ecosystem 的 `name`/`cwd`；`pm2 describe {pm2Name}` 的 `pm_cwd` 复核 | **fail**（已实现） |
| R2 | 监听地址 | 端口只来自 `PORT`；只监听 `127.0.0.1`，**禁止 `0.0.0.0`** | 预检 `PORT` 键；健康探测命中 `127.0.0.1:{localPort}`；项目方确认代码绑定 `127.0.0.1` 且与登记 `port` 一致（`ss -tlnp` 需服务器权限，属管理员核对手段） | 预检部分 **fail**；`0.0.0.0` 检测恒 `unknown`（无 `ss` 只读数据源） |
| R3 | 健康检查 | 路径以登记 `probePath` 为准，默认 `/healthz`；仅 `2xx/3xx` 通过，`>=400` 判失败 | 部署后轮询 `probePath`；conformance `network.port_probe` | **fail**（健康失败触发回滚） |
| R4 | 日志 | 只写 stdout/stderr；不得自建文件轮转 | 人工检查 `createWriteStream`/日志框架 file transport；`pm2 logs` 可读 | warn（无自动判定） |
| R5 | 优雅退出 | 处理 `SIGTERM`：停止接收新请求 → 等在途请求 → 释放 DB/定时器/队列 → `exit 0` | 人工 `kill -TERM` 验证；`kill_timeout` 留足时间 | warn（无自动判定） |
| R6 | 环境分层 | 非密钥运行参数（`PORT`/`HOST`/`NODE_ENV`/`WEB_DIST`/数据路径键）必须写入 ecosystem `env`；密钥只放 `deployPath/.env`（600），由应用自行加载 | 预检 `ecosystem.env` 缺键即 fail；`/proc/<pid>/environ` 比对 `PORT`、`WEB_DIST`、ecosystem 声明的数据键；密钥是否误入 `env` 仅人工检查 | 缺键 **fail**；密钥误入 `env` warn（无自动扫描） |
| R7 | 数据目录独立 | `dataDir` 及其解析后的数据键不得位于 `releases/` 或 `current` 内；切换版本后数据必须仍在 | 数据键路径校验（fail）；`dataDir` 本身的 `realpath` 比对依赖后端 `2.1.0` | 数据键 **fail**；`DATA_DIR`/`dataDir` 自身校验在 `2.1.0` 前不生效 |

判定约定：

- 表中「当前强度」描述的是 `1.1.0` 已实现的行为；与设计文档早期版本不一致的，以本节为准。
- R6 已按现行环境模型修正：旧表述「`.env` 由应用自身加载、不依赖 PM2 注入」只适用于密钥；非密钥参数必须由 ecosystem `env` 注入，否则 `/proc` 不可校验。
- R2/R4/R5 属于「规范硬性、平台只能人工或 `unknown`」的条目，不得因为 conformance 返回 `unknown` 就视为通过。

---

## 3. 目录约定

```text
<OPS_DEPLOY_ROOT>/{id}/         deployPath，PM2 的 cwd（安装参数 --deploy-root，默认 /opt/apps/{id}）
├── releases/{YYYYMMDD-HHMMSS}/ 不可变发布目录，每次部署新建
├── current -> releases/{ts}    当前版本软链，回滚即重指
├── data/                       dataDir，持久数据，禁止放进 release
├── .env                        运行时配置，权限 600，不入库
├── .env.example                键名 + 说明 + 安全默认值，可入库
├── src/                        应用代码（随 release 发布）
└── package.json                依赖与启动脚本

/opt/ops-scripts/{id}/backup.sh   备份脚本（运维归属，不进项目 git）
/opt/ops-scripts/{id}/monitor.sh  监控脚本（同上）
/opt/runbooks/{id}.md             运维手册，≤200KB
```

> 图中 `<OPS_DEPLOY_ROOT>/{id}` 是 `deployPath` 的标准写法：实际值优先取项目登记的 `backup.appDir`；未登记时取部署根（`--deploy-root`，默认 `/opt/apps`，即 `/opt/apps/{id}`）。`deployPath` **不是 `projects.json` 的独立字段**，不要自行改写或写入登记条目；任何 `/opt/{id}` 都是旧口径残留。

硬要求：

- `dataDir` 的 `realpath` 不得以 `releases/` 或 `current` 为前缀——切换版本后数据必须仍在。
- `current` 是软链，不要把它当真实目录写入；应用不应依赖自身位于哪个 release。
- 运维脚本与 runbook 都不要放项目仓库：历史上 `git pull` 覆盖脚本导致备份静默失败。
- 目录与文件建议属主为非 root 运行用户；`.env` 权限恒为 `600`。
- 无端口服务：`localPort=0`，不参与端口探测、不生成 Caddy 站点块，其余契约不变。

---

## 4. PM2 运行契约

| 项 | 规范 |
|----|------|
| 进程名 | `name` 必须等于登记 `pm2Name`，全局唯一 |
| 工作目录 | `cwd` 必须等于 `deployPath`（取登记 `backup.appDir`；未登记时 `<OPS_DEPLOY_ROOT>/<id>`，安装默认 `/opt/apps/{id}`） |
| 入口 | `script` 指向 `current/` 下的入口文件，切换版本后重启即生效 |
| 实例 | 默认 `instances: 1`、`exec_mode: 'fork'`；需要 cluster 时必须先与平台确认共享状态 |
| 重启 | `autorestart: true`、`max_restarts` 有限值、`max_memory_restart` 明确上限 |
| 退出 | `kill_timeout` 建议 10000ms，配合应用侧 `SIGTERM` 处理 |
| 环境 | `env` 必须显式写全非密钥运行参数：`PORT`（= `localPort`；无端口服务除外）、数据路径键（`DATA_DIR` 或应用真正消费的 `DATABASE_FILE`/`UPLOAD_ROOT` 等，见第 7 节）、`HOST`、`NODE_ENV`，有前端产物时加 `WEB_DIST`；**密钥只走 `.env`，禁止写进 `env`** |
| 超时 | 应用层 `requestTimeout` 不得早于反代 `read_timeout`/`write_timeout`（否则反代还在等、应用先断）；它只约束**接收请求体**，不约束响应流，不能当流式响应超时用 |
| 禁止 | `pm2 delete`、`pm2 kill`、`pm2 save`、改监听端口、启动 `ops-backend` 替身进程 |

`ecosystem.config.js` 参考骨架（平台从 2.11.2 起会在首次部署时按登记自动渲染；本地自检可参照同一结构）：

```js
module.exports = {
  apps: [{
    name: 'demo-api',              // 必须等于 projects.json 的 pm2Name
    cwd: '/opt/apps/demo-api',     // 必须等于 deployPath（<OPS_DEPLOY_ROOT>/{id} 或登记 backup.appDir）
    script: 'current/server.js',   // 通过 current 软链指向当前 release
    instances: 1,
    exec_mode: 'fork',
    autorestart: true,
    max_restarts: 10,
    max_memory_restart: '300M',
    kill_timeout: 10000,
    env: {                          // 非密钥运行参数：启动唯一真源，与登记值逐字一致
      NODE_ENV: 'production',
      PORT: '3000',                 // 必须等于登记 localPort
      HOST: '127.0.0.1',
      DATA_DIR: '/opt/apps/demo-api/data', // 数据路径键之一；应用实际读 DATABASE_FILE/UPLOAD_ROOT 时改写对应键
      WEB_DIST: 'current/dist'      // 仅当产物含前端静态资源时必填
    }                               // 密钥（JWT_SECRET、第三方 API Key）只放 .env，不进 env
  }]
};
```

`SIGTERM` 语义（R5）：收到信号后停止接受新连接 → 等待在途请求结束 → 关闭 DB / 定时器 / 队列 → 以 `0` 退出；超过 `kill_timeout` 允许被强杀，但不得留下锁文件或半写数据。`SIGINT` 同样处理。

**超时口径**（实测教训）：

- 应用层 `requestTimeout` 必须**不早于**反代侧 `read_timeout` / `write_timeout`，否则会出现「反代还在等、应用先断开」的假 502/504。
- `requestTimeout` 只约束**接收请求体**的时长，**不约束响应流**；把它当成流式响应超时会导致长任务被误判。
- 真正的超时要按**业务最长耗时**单独设计（下游 API 调用、批处理、导出等），并在 runbook 写明依据。first 的经历是：瓶颈来自上游 API 的 120s 中止，而不是本服务或反代，调小本服务超时只会更快失败。

---

## 5. 健康检查契约

- 默认路径 `/healthz`；实际探测路径以登记 `probePath` 为准（默认值 `/healthz`，必填）。
- 成功：HTTP `200`，`Content-Type: application/json`，响应体 `{"status":"ok"}`。
- 失败：依赖不可用时返回 `503`（不要用 200 掩盖 DB/依赖故障）。
- 判定：只有 `2xx/3xx` 视为通过；**`4xx/5xx` 一律判失败**，404 不再视为存活。
- 要求：健康检查不得触发写操作或长耗时查询；接口必须在进程启动后尽快可用。
- 无端口服务：`localPort=0`，不做端口探测。

---

## 6. 日志约定

- 应用把结构化日志写到 stdout / stderr，由 PM2 收集与 `pm2-logrotate` 轮转。
- 应用**不得自行实现文件轮转**（`createWriteStream` + 自建切割、日志框架 file transport 均属不合规，记为 warn 级）。
- 单行 JSON 优先：`{"t":"ISO8601","level":"info","msg":"..."}`，便于检索。
- 禁止打印：`.env` 值、AccessKey/Secret、Token、完整请求头、用户隐私字段。
- 日志量需可控：健康检查等高频路径不逐次打 info；错误日志必须包含可定位的上下文。

---

## 7. 环境变量约定

启动环境按**性质**拆成两个来源，职责不重叠：

| 类别 | 内容 | 位置 | 作用 |
|------|------|------|------|
| 非密钥运行参数 | `PORT`、`HOST`、`NODE_ENV`、`WEB_DIST`，以及**数据路径键**（`DATA_DIR` 或应用真正消费的键） | `ecosystem.config.js` 的 `env` | 启动唯一真源；平台预检与启动后校验都以它为准 |
| 密钥与业务凭据 | `JWT_SECRET`、第三方 API Key、数据库口令等 | `deployPath/.env`（权限 `600`） | 只在进程内使用；不入库、不入日志、不进发布产物 |

### 7.1 端口

- `PORT` 必须显式写入 ecosystem `env`，值等于登记 `localPort`。
- 无端口服务（`localPort=0`）不声明 `PORT`，其余键照常。

### 7.2 数据目录（三种表达，任选其一）

| 方式 | 写法 | 校验 |
|------|------|------|
| A. 通用键 | `DATA_DIR=<deployPath>/data` | 解析后必须落在登记 `dataDir` 内（含相等与子目录）；相对路径以部署根解析 |
| B. 应用真实消费的键 | 如 `DATABASE_FILE=<deployPath>/data/app.db`、`UPLOAD_ROOT=<deployPath>/data/uploads` | 解析后**必须落在登记 `dataDir` 内**（含相等与子目录） |
| C. 无持久化状态 | projects.json 写 `stateless: true` | 不要求任何数据键，也不校验 `dataDir`；备份可关闭 |

**判定顺序（与实现一致，命中即停）**：

1. `projects.json` 的 `stateless === true` → 视为无持久化状态，通过；
2. ecosystem `env` 中 `DATA_DIR` 非空 → 采用该键，并按与具体数据键相同的规则解析、校验落在 `dataDir` 内；
3. ecosystem `env` 中符合 `_FILE` / `_DIR` / `_PATH` / `_ROOT` / `_DB` 后缀的数据类键（排除 `NODE_PATH`、`WEB_DIST`、`HEALTH_PATH`、`PATH`）→ 逐个解析并校验落在 `dataDir` 内；
4. 以上都没有时，读取项目根 `.env`（`deployPath/.env`）**仅补充发现**数据类键，再做同样的路径校验；
5. 仍没有 → 判 fail：`ecosystem.env 缺少 DATA_DIR，也未声明具体数据键（*_FILE/_DIR/_PATH/_ROOT/_DB）或 stateless=true：无法确认持久化数据目录`。

- **`.env` 只是补充发现来源**：从 `.env` 读到的键只参与「发现 + 路径校验」，**不参与** `/proc/<pid>/environ` 运行时比对——`.env` 由应用在进程内注入，不进入 `execve` 初始环境，看不到这些键。只有 ecosystem `env` 声明的键才纳入运行时校验。
- **`DATA_DIR` 校验的生效版本**：`DATA_DIR` 的完整路径校验自后端 **`2.1.0`** 起生效；`2.0.0` 及更早版本的实际行为是「`DATA_DIR` 存在即通过，不做路径比对」。在低于 `2.1.0` 的后端上，接入方仍应主动把 `DATA_DIR` 指向登记 `dataDir`，不要依赖宽松行为；平台在该版本前不会因此判 fail。
- **相对路径以部署根为基准**：相对路径按 PM2 `cwd`（即 `deployPath`）解析，绝对路径原样使用。校验失败的错误信息会写明基准目录与登记目录，例如 `DATABASE_FILE=data/app.db（相对 cwd=/opt/apps/demo-api 解析 → /opt/apps/demo-api/data/app.db）不在登记 dataDir=/opt/apps/demo-api/data 内`。
- **为什么允许 B**：jingyu 这类应用读的是 `DATABASE_FILE` 与 `UPLOAD_ROOT`，从不读 `DATA_DIR`。硬填 `DATA_DIR` 等于在登记表里造一条永远无人读取的假数据——这比缺字段更糟，因为它会让预检「通过」却什么都没验证。用真实键才能既表达清楚，又能被实际校验。
- 一个项目可以同时有多个数据键，但解析后都必须落在同一个 `dataDir` 内；需要多个独立目录时，先与管理员确认再调整登记。
- `stateless` 与数据键/`dataDir` 互斥使用：声明 `stateless: true` 后，不应再指望平台校验数据目录；若之后产生持久化数据，必须去掉该字段并补数据键。

### 7.3 其余运行参数

- 非密钥参数必须**显式写入 ecosystem `env`**：`PORT` 等于登记 `localPort`，数据路径键按 7.2，产物含前端静态资源时还必须写 `WEB_DIST`。缺失即预检失败（`ecosystem.env` 判 fail），不依赖应用代码或 `.env` 兜底。
- 进程启动后，平台读取 `/proc/<pid>/environ`，与 ecosystem `env` 逐键比对 `PORT`、`WEB_DIST`（如有）与 **ecosystem `env` 中声明的**数据路径键（仅在 `.env` 中发现、未写入 ecosystem `env` 的键不参与运行时比对）；不一致即 `ENV_MISMATCH` 并回滚。这是「配置的值」与「进程实际值」之间的可校验通道。
- **为什么不能把运行参数只放 `.env`**：`node --env-file=.env`（以及 `dotenv`）是在进程内部注入的，不进入进程的 `execve` 初始环境，`/proc/<pid>/environ` 里看不到这些键；而 PM2 会把调用方父进程的环境合并进应用，且 `--env-file` 不覆盖已存在的变量。结果是「配置里写的值」与「进程实际拿到的值」可能不一致，却没有可观测通道能发现。写进 `env` 后，参数在 PM2 spawn 时即确定、`/proc` 可验证，预检与运行期校验才能闭环。
- 运行参数变更流程：改 ecosystem `env` → `pm2 restart <ecosystem> --only <pm2Name> --update-env`（若 `/proc` 仍未生效则 `pm2 delete` + `pm2 start`）→ 用 `/proc/<pid>/environ` 确认 `PORT` 与数据路径键。
- 密钥变更流程：改 `.env` → 重启进程 → 验证；**不要**通过修改 PM2 `env` 下发密钥（会被进程快照固化，改 `.env` 不生效）。
- `.env` 放 `deployPath/.env`，权限 `600`，不提交 git，不打进发布产物，不写入 runbook 明文。
- `.env.example` 入库，只写键名、说明与安全默认值。
- 端口键固定为 `PORT`；数据路径键优先用应用真实消费的键（`DATA_DIR`、`DATABASE_FILE`、`UPLOAD_ROOT` 等），并在 runbook 第 7 章登记键名与解析后的绝对路径（值脱敏）。
- 密钥不入库、不入日志、不作为命令行参数、不返回给调用方。

---

## 8. 数据与备份契约

### 8.1 数据

- `dataDir` 顶层定义，默认 `<deployPath>/data`；`backup.dataDir` 仅作历史兼容，缺省取顶层值。
- 默认 SQLite，库文件位于 `dataDir`，可开 WAL。

### 8.2 备份范围与一致性

备份**整个 `data/` 目录**，`app.db` 只是其中一部分：

```bash
# 1) 先复制 data/ 全量，跳过 SQLite 主库与 WAL/SHM
tar -C "$DATA_DIR" --exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' \
  -cf - . | tar -C "$stage" -xf -

# 2) 每个 SQLite 库用在线备份生成一致性副本，并自检
sqlite3 "$DATA_DIR/app.db" ".backup '$stage/app.db'"
sqlite3 "$stage/app.db" "PRAGMA integrity_check;"   # 必须返回 ok

# 3) 打包并原子落盘
tar -C "$stage" -czf "$BACKUP_DIR/{id}-$ts.tar.gz.tmp" .
mv -f "$BACKUP_DIR/{id}-$ts.tar.gz.tmp" "$BACKUP_DIR/{id}-$ts.tar.gz"
```

禁止：直接 `cp` / `tar` 正在写入的 `.db`（WAL 模式下会得到损坏或落后的副本）。

**完整性失败的语义**：`PRAGMA integrity_check` 不为 `ok` 时，本次备份必须判为失败，不得上传或标记成功。模板 `backup.sh` 已按 `exit 1` 实现；平台 `doBackup` 自后端 **`2.1.0`** 起也按 fail 处理（`2.0.0` 及更早版本的实际行为是仅打印 `warn` 并继续上传，运维必须人工核对告警日志）。

### 8.2.1 非 SQLite 数据库（MySQL / PostgreSQL）

8.2 的 `data/` 打包只覆盖文件型数据。使用 MySQL/Postgres 的项目，数据库不在 `dataDir` 内，**平台备份包不包含业务数据**，必须按本节补导出。

**显式前置条件**：本节依赖「平台在 tar 打包 `dataDir` 之前先执行 `backup.triggerCmd`」这一现有执行顺序（见 §10 与备份调度器实现）。如果后端改为并行执行、跳过 `triggerCmd`，或在 `triggerCmd` 失败后仍继续打包上传，本节失效，必须重新评审。`triggerCmd` 非零退出时平台当前只告警、不中断打包，因此导出成功与否必须在脚本内部自行保证并落盘可验证标记。

1. **导出责任**：项目自己的 `/opt/ops-scripts/{id}/backup.sh` 负责导出数据库；平台只打包 `dataDir`，不直连数据库。
2. **导出落点**：导出文件必须写入 `dataDir` 下的专用子目录（建议 `$DATA_DIR/db-dump/`），先写 `.tmp` 再原子 `mv`，避免平台 tar 到半个文件。
3. **一致性参数**：
   - MySQL：`mysqldump --single-transaction --quick --routines --triggers`（InnoDB）；需要强一致快照时从只读副本导出。
   - PostgreSQL：`pg_dump --format=custom --no-owner`；全实例级备份（`pg_basebackup`）单独由运维评估。
4. **凭据**：数据库口令只从 `deployPath/.env` 读取，不得出现在命令行参数、脚本明文或日志中（避免 `ps` 泄露）。
5. **校验**：导出后检查退出码、文件非空、格式可识别（如 `pg_restore --list`）；任何一项失败即 `exit 1`，并在 `dataDir` 写入失败标记供监控识别。
6. **保留**：`db-dump/` 只保留最新一份工作文件，历史由平台备份包承载；本地与远端目标仍按 8.3 各自保留 7 份，避免 `data/` 无限增长。
7. **恢复**：runbook 第 4 章必须写明取哪份包、解出 dump、恢复到哪个实例、恢复后如何校验关键表行数；每季度至少演练一次。
8. **未覆盖即视为无备份**：未按本节导出的 MySQL/Postgres 项目，不具备可恢复备份；登记时不得把「备份已开启」当作数据保护已完成的依据。

### 8.3 备份目标与保留

备份目标是三层解析：**项目级 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → 本地**，默认 **本地**。

- **本地目标**：落 `OPS_BACKUP_LOCAL_DIR`（建议指向独立数据盘，如 `/data/ops-backups`，避免与系统盘/发布目录争空间）；未配置时由平台按自身数据目录落盘。保留最近 **7** 份，按时间倒序清理。
- 打包时只排除目录级 `logs/`，不要全局排除 `*.log`——否则 `data/` 内的日志类数据文件会被漏掉。
- **远端目标**（`oss` / `cos`）：必须先在后台「存储设置」完成配置并**通过连接测试**才可用。未配置、测试失败或凭据失效时，备份请求 **fail-closed**：返回 `409 BACKUP_TARGET_UNAVAILABLE`，**不会静默退回本地**。升级到新备份模型后必须点一次连接测试，否则远端目标一律视为不可用。
- **项目级覆盖**：`backup.provider` 取 `""`（跟随全局）/ `local` / `oss` / `cos`；只有显式填远端时才强制走远端，填 `local` 可让单个项目绕开全局远端。
- **按来源保留**：本地与各远端目标各自计数、各自清理（前缀 `backups/{id}/`），切换目标不会删除另一来源的历史包；每个来源保留最新 7 份。
- **按来源查看/下载/恢复**：`GET /api/projects/{id}/backups?provider=<local|oss|cos>` 列出该来源的包，`GET /api/oss/download/{key}?provider=...` 下载，`POST /api/projects/{id}/backups/restore` 指定来源与对象恢复；远端下载优先使用短期预签名 URL，不经 ops 公网带宽。
- 项目侧只产出本地一致性快照，**不要自建第二条上传链路**（双跑会产生重复备份与保留错乱）。
- 静态资源前缀 `assets/{id}/`，可选。

### 8.4 定时任务

- 备份与健康检查的 crontab 由平台/运维登记，项目侧不重复注册同类任务。
- `monitor.sh` 建议 `*/5 * * * *`；异常只写日志并 `exit 1`，**不做任何重启动作**。

---

## 9. runbook 必备章节（8 章，缺一章即 warn）

```markdown
# ${SERVICE_NAME} 运维手册
## 1. 服务概览（PM2 名、端口、目录、运行用户、依赖）
## 2. 域名与反代（域名、Caddy 站点块、证书 SAN）
## 3. 启停与部署（命令、部署脚本、回滚步骤）
## 4. 数据与备份（DB 路径、backup.sh 位置、备份目标与保留策略）
## 5. 健康检查与监控（${PROBE_PATH}、monitor.sh、crontab）
## 6. 日志位置与排查（pm2 logs、应用日志、审计）
## 7. 关键环境变量（脱敏）
## 8. 已知风险与注意事项
```

- 路径 `/opt/runbooks/{id}.md`，UTF-8，≤200KB。
- 第 7 章只列键名与用途，值一律脱敏。
- 第 3 章必须能按步骤复现「部署 + 回滚」，不能只写「见 CI」。
- **第 1、3、4 章禁止写死具体 release 或版本号**（如 `releases/20261109-030000`、`v1.4.2`），它们会随每次部署过期。统一用 `readlink -f <deployPath>/current` 或读 `current/package.json` 的 `version` 动态获取；示意写法：`readlink -f /opt/apps/{id}/current`。
- 占位符统一为 `${VAR}` 一种写法（如 `${SERVICE_NAME}`、`${PM2_NAME}`、`${PORT}`、`${PROBE_PATH}`），不再使用 `{service}` 等变体。

---

## 10. projects.json 字段

必填：`id`、`name`、`pm2Name`、`localPort`、`runbookPath`、`probePath`。schema 已设 `additionalProperties=false`，未列出的字段（含拼写错误）一律报错。`probePath` 必填是为了强制显式确认，登记表单与迁移脚本自动填入默认值 `/healthz`。

| 字段 | 必填 | 默认 | 含义与校验 |
|------|------|------|------------|
| `id` | 是 | — | `^[a-z][a-z0-9-]{1,31}$`，全局唯一，不得为 `ops` |
| `name` | 是 | — | 展示名，1–40 字符 |
| `pm2Name` | 是 | — | 必须存在于 `pm2 jlist`，全局唯一，不得为保留名 |
| `localPort` | 是 | — | `0`–65535，`0` 表示无端口服务；其余值不得与预留端口冲突。3000–3999 **只是建议段**：登记不因超出该区间失败，部署规划仅产生一条 warning。真正参与硬校验的是登记范围 `0`–`65535` 与规划接口要求的 `1024`–`65535`；与登记值不一致时才报错 |
| `runbookPath` | 是 | — | 绝对路径，标准位置 `<OPS_RUNBOOKS_DIR>/{id}.md`（默认 `/opt/runbooks/{id}.md`），存在且 ≤200KB。项目 Key 可读自己项目的 runbook；文件不存在返回 `404 RUNBOOK_NOT_FOUND`，路径越界返回 `403 RUNBOOK_PATH_NOT_ALLOWED` |
| `probePath` | 是 | `/healthz` | 探测路径，必须返回 2xx/3xx |
| `dataDir` | 否 | `<deployPath>/data` | 登记的数据目录（顶层唯一定义）。运行时由 `DATA_DIR` 或应用真实消费的键（`DATABASE_FILE`、`UPLOAD_ROOT` 等）表达；**两者解析后都必须落在该目录内**（`DATA_DIR` 的完整校验自后端 `2.1.0` 起生效）。不得位于 `releases/`、`current` 中；`stateless: true` 的项目不要求该目录存在 |
| `stateless` | 否 | `false` | `true` = 显式声明无持久化状态：不要求数据键、不校验 `dataDir`，判定优先级最高。**声明后平台会忽略数据键，不会因「互斥」报错**；若项目仍有本地持久化文件，必须去掉该字段并补真实数据键 |
| `standardVersion` | 否 | `1.1.0` | 项目**登记**的标准版本，只由显式登记/adopt 变更；与平台当前版本不是同一个概念。`ops_list_projects` 额外返回 `registeredStandardVersion`（登记值）与 `platformStandardVersion`（平台值），旧字段保留为登记值以兼容 |
| `healthCheck.enabled` | 否 | `true` | 是否纳入 `healthy` 判定 |
| `environment` | 否 | `production` | 环境标识，避免测试服务混入健康判定 |
| `owner` | 否 | `""` | 负责人，用于审计与告警 |
| `managed` | 否 | `true` | `false` 时只展示，禁止任何控制动作 |
| `downloadSync` | 否 | `false` | 是否把上传同步到 `/var/www/apk/`；仅安装包类项目置 `true` |
| `deployPath`（派生，非 schema 字段） | — | 取 `backup.appDir` | **不写入 `projects.json`**，`additionalProperties=false` 下写入会被判非法字段。实际值取登记的 `backup.appDir`；未登记时由 `ops_get_project_context` 与规划器统一返回 `<OPS_DEPLOY_ROOT>/<id>`（默认 `/opt/apps/{id}`，`deployPathSource:"default"`），不写回登记。与 PM2 `cwd` 必须逐字一致；规划器与 conformance 使用同一口径，不再用 `/opt/{id}` 对照 |
| `backup.appDir` | 开启备份时必填 | — | **历史兼容别名，实际对应 `deployPath`**，不作为备份内容根（备份范围以顶层 `dataDir` 为准）。绝对路径且存在，不得为 `/`、`/opt`、`/etc`、`/var/www`；新项目填 `<OPS_DEPLOY_ROOT>/<id>`（默认 `/opt/apps/{id}`）。计划在下一个 minor 版本改为可选并废弃 |
| `backup.dataDir` | 否 | 取顶层 `dataDir` | 历史兼容覆盖字段 |
| `backup.provider` | 否 | `""` | 项目级备份目标覆盖：`""` 跟随全局 `OPS_BACKUP_PROVIDER`，或 `local` / `oss` / `cos`。远端目标必须已配置且连接测试通过，否则备份 fail-closed（`409 BACKUP_TARGET_UNAVAILABLE`），不退回本地 |
| `backup.triggerCmd` | 否 | — | 绝对路径可执行文件，不含 <code>; &#124; &amp; $ \ &#96; &gt; &lt;</code> 等 shell 元字符；标准值 `/opt/ops-scripts/{id}/backup.sh` |
| `backup.schedule.enabled` | 否 | `false` | 是否启用定时备份 |
| `backup.schedule.cron` | 启用时必填 | `0 3 * * *` | 5 段标准 cron，需通过 `cron.validate` |
| `backup.schedule.timezone` | 否 | `Asia/Shanghai` | 时区 |
| `oss.assetPrefix` | 否 | `assets/{id}/` | 该服务的 OSS 资源前缀 |
| `webUrl` / `adminUrl` | 否 | — | 对外访问入口；有域名站点块时 `webUrl` 必填 |
| `description` / `icon` | 否 | — | 展示用 |
| `onboardedAt` / `onboardedBy` | 否 | — | 接入审计元数据 |

存量迁移提醒：`probePath` 已成为必填，上线 schema 收紧前必须由 `ensureProjectDefaults()` 为老条目补齐，否则旧数据直接判 fail。

---

## 11. 发布产物形态（v1 定稿）

```text
dist/
├── demo-api-1.0.0.tar.gz          应用与依赖（不含 data/、.env、backups/）
└── demo-api-1.0.0.tar.gz.sha256   内容：<64位小写sha256>  demo-api-1.0.0.tar.gz
```

| 项 | 规范 |
|----|------|
| 包名 | `{id}-{version}.tar.gz`；`version` 匹配 `^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$` |
| 校验文件 | 同名 `{id}-{version}.tar.gz.sha256`，一行，`<64位小写hex>  <文件名>`（两空格，兼容 `sha256sum -c`） |
| 包内结构 | 解包后根目录即应用根：`package.json`、入口文件（`server.js` 或 `dist/`）、`node_modules/` 或依赖锁文件 + 离线缓存 |
| 包内禁止 | `.env`、`data/`、`backups/`、`logs/`、`.git/`、`releases/`、`current` |
| 启动配置 | 推荐不打包，由平台按登记渲染。可选自带 `ecosystem.config.{js,cjs,mjs,json}`：部署目录没有配置时平台会复制并采用；部署目录已有配置时以部署目录为准 |
| 落盘 | 解包到 `{deployPath}/releases/{YYYYMMDD-HHMMSS}/`，`current` 软链指向它，保留最近 3 个 release |

- **发布产物只有两个文件**：压缩包与同名 `.sha256`。除此之外不要求、也不读取任何附加文件。
- **打包必须用项目内固定 build 脚本**，一条命令产出 `{id}-{version}.tar.gz` 与 `.sha256`。脚本至少做三件事：
    1. **禁项扫描**：确认包内无 `.env`、`data/`、`backups/`、`logs/`、`.git/`、`releases/`、`current`，命中即失败退出（`ecosystem.config.*` 可选，不作禁项）。
    2. **软链检查**：拒绝指向包外的符号链接与绝对路径条目，避免解包逃逸。
    3. **解包启动自检**：在临时目录解包，用**随机空闲端口 + 临时数据目录**真实启动，轮询 `/healthz`（或登记 `probePath`）返回 2xx/3xx 后再清理退出；启动失败即不产出产物。
- **反例：靠人记 tar 参数打包**。手工 `tar czf` 容易漏排除项、把 `.env` 或 `data/` 打进去、忘记生成 `.sha256`，且打包前从不验证能否启动——问题全部推迟到生产部署时才暴露。build 脚本应入库，并把上述检查作为脚本的一部分，而不是写在文档里靠人执行。
- **conformance 是实时计算，不是交付物**：`ops_check_conformance` 每次都重新拉项目登记、查进程、探端口、读 Caddy 现场比对；`conformance.json` 不是发布产物，也不是部署前置条件——缺失它不会阻止部署。静态快照会立即过期，反而误导判断，因此不再要求，也不再归档。需要体检结论时直接调用该工具。
- 默认**不联网**：优先内置 `node_modules`，或在服务器保留离线缓存后执行 `npm ci --omit=dev --offline`。
- 部署流程（2.11.2 起已实现）：上传 → 校验 `sha256` 与大小 → staging 解包（拒绝绝对路径、`..`、符号链接） → `releases/{ts}` → **入口探测 / 启动配置解析或渲染** → 切换 `current` → 启动/重启 PM2 → 轮询 `probePath` → 失败自动回滚上一 release。
- **保留数量**：`releases/` 仅保留最近 3 个（`OPS_DEPLOY_KEEP_RELEASES`，默认 3）；更早的 release 由平台清理，显式回滚只能指向仍存在的目录。清理**由平台执行**，项目方只需在回滚前确认目标 release 仍在。
- **首次部署失败（`keep-current`）**：当项目只有一个 release、没有可切回的上一版本时，部署失败**不会删除入口**：`current` 仍指向新 release，平台标记本次失败并记录 `strategy=keep-current`。此时没有「旧版本」可回退，必须人工处置：修复后重新发布，或确认可接受后保留现状。该策略只保证入口不消失，不代表新版本可用。
- **带迁移的发布**：部署前必须按第 19 章判定迁移兼容性；`current` 的自动切换不等于数据库可回退，回滚语义见第 19 章。
- 部署前必须 conformance `fail === 0`；`warn` 允许但写审计。

### 11.2 启动配置渲染与入口探测（1.2.6 / 服务端 2.11.2）

平台按以下**优先级**决定本次部署使用哪份启动配置，结论写入审计事件 `project.ecosystem.render`，并在部署响应/`dryRun` 的 `ecosystem` 字段回显：

| 优先级 | 来源 | 行为 |
|--------|------|------|
| 1 | 部署目录已有 `ecosystem.config.{js,cjs,mjs,json}` | 直接使用；`projects.json` 的 `ecosystemMode="render"` 才强制重渲染，覆盖前备份为 `*.bak-<YYYYMMDDHHMMSS>` |
| 2 | 产物根目录自带上述配置 | 部署目录没有配置时复制并采用（部署目录已有则仍用部署目录的） |
| 3 | 按登记渲染 | 写入 `{deployPath}/ecosystem.config.cjs`，`name=pm2Name`、`cwd=deployPath`、`script=current/<入口>`、`env` 含 `PORT`（= `localPort`，0 则省略）与 `DATA_DIR`（`stateless=true` 时省略），并 `autorestart:true` / `instances:1` / `exec_mode:"fork"` / `max_memory_restart:"256M"` |

**入口探测优先级**（按顺序，命中即停）：

1. `projects.json` 的 `entry`（相对产物根；显式覆盖）
2. `package.json` → `scripts.start`（仅接受可直接交给 node 的命令：`node [--flags] <file>` 或纯文件路径；`npm/pnpm/pm2/next` 等包装命令不猜）
3. `package.json` → `main`
4. 常见入口：`server.js`、`index.js`、`app.js`、`main.js`、`dist/server.js`、`dist/index.js`、`src/server.js`、`src/index.js`、`build/server.js`、`server/index.js`、`app/server.js`
5. 静态站点：`dist/index.html`、`build/index.html`、`public/index.html`、`out/index.html`、`web/dist/index.html`，以及根目录 `index.html`（平台会写入 `current/.ops/static-server.cjs` 并提供 `/healthz`）

**探测不到直接失败**（`ENTRY_DETECT_FAILED`），返回 `detail` 含已尝试的候选路径与原因，`suggestion` 给出修复方式；不会猜一个「可能能跑」的入口。项目侧可补：
- `projects.json` 显式 `entry`（如 `server.js`、`dist/server.js`）；
- 或 `package.json` 的 `scripts.start` / `main` 指向真实存在的文件；
- 或提供上述静态站点目录之一。

`dryRun` 只在临时解包目录生成 `.ops/ecosystem.preview.cjs` 预览，不写部署目录；返回 `ecosystem.contentPreview` 可直接查看渲染结果。

---

## 12. 最小 Node.js 骨架（30 行内，含健康检查、env 端口、SIGTERM）

```js
'use strict';
const http = require('node:http');
const path = require('node:path');
const PORT = Number(process.env.PORT || 3000);                    // 端口只来自环境变量
const HOST = '127.0.0.1';                                         // 禁止 0.0.0.0
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); // cwd=deployPath

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/healthz') {           // 路径需与 probePath 一致
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ status: 'ok' }));
  }
  res.writeHead(404, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ error: 'not_found' }));
});

server.listen(PORT, HOST, () => {
  console.log(JSON.stringify({ t: new Date().toISOString(), msg: 'listening', host: HOST, port: PORT, dataDir: DATA_DIR }));
});

let closing = false;
const shutdown = (signal) => {
  if (closing) return;
  closing = true;
  console.log(JSON.stringify({ t: new Date().toISOString(), msg: 'shutdown', signal }));
  server.close(() => process.exit(0));            // 停止新请求，等待在途请求结束
  setTimeout(() => process.exit(0), 10000).unref(); // 兜底强制退出
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
```

配套：`PORT`/`HOST` 与数据路径键由 ecosystem `env` 注入，应用只读 `process.env`，不得硬编码；`.env` 只放密钥，入口仍可用 `node --env-file=.env` 或首行 `require('dotenv').config()` 加载（`--env-file` 不覆盖 `env` 已注入的变量）。示例用的是 `DATA_DIR`；若应用实际读 `DATABASE_FILE` / `UPLOAD_ROOT`，改成对应键，只要解析后落在登记 `dataDir` 内（见 7.2）。数据文件一律写在 `dataDir` 内。

---

## 13. 验收清单（逐条自检）

清单分两组：**13.1 由项目方自己勾**（项目仓库内、项目进程内能完成的事）；**13.2 由平台执行，项目方只做确认**（不要试图自己配 DNS、改证书或写 Caddy）。技术口径与编号引用保持不变。

> **优先级**：每条开头标注档位。**必检** = 不满足会导致部署失败、被平台拒绝或线上故障；**建议检** = 不满足不阻断部署，但会削弱可观测性、可维护性或排障效率。13.1 共 31 条：必检 23 条，建议检 8 条；时间紧时至少把「必检」全部走一遍。
>
> **权限前提**：项目方通常只持有项目 Key，不能执行 `pm2`、`ss`、`ls releases/`、读 `/proc` 等需要服务器 shell 的操作。这类核对一律改为「确认平台接口返回值」；文中出现的命令保留给管理员核对用。

### 13.1 项目方必须完成

代码与运行

- [ ] **[必检]** ecosystem `env` 显式写全 `PORT`（无端口服务除外）、`HOST`、数据路径键（`DATA_DIR` 或应用真实消费的 `DATABASE_FILE`/`UPLOAD_ROOT` 等；无持久化状态在 projects.json 写 `stateless: true`），有前端产物含 `WEB_DIST`；与登记值一致
- [ ] **[必检]** 由 `ops_check_conformance` 判定 `env.*`：`PORT`、数据路径键与 ecosystem `env` 一致，且数据键与 `DATA_DIR` 解析后落在登记 `dataDir` 内（`DATA_DIR` 完整校验依赖后端 ≥ `2.1.0`）。项目方确认返回的 `env.*` 结果与预期一致；`/proc/<pid>/environ` 比对需服务器权限，仅供管理员核对
- [ ] **[建议检]** `requestTimeout` 不早于反代 `read_timeout`/`write_timeout`，且未把 `requestTimeout` 当作响应流超时
- [ ] **[必检]** `.env` 只放密钥（权限 `600`），PM2 `env` 中无任何密钥
- [ ] **[必检]** 端口读自 `PORT`，host 固定 `127.0.0.1`，代码中无 `0.0.0.0`
- [ ] **[必检]** `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:{port}{probePath}` 返回 2xx/3xx（本地或部署后由平台探测结果确认）
- [ ] **[建议检]** 健康检查在依赖故障时返回 `503`，不以 200 掩盖
- [ ] **[必检]** 实现 `SIGTERM`：收到信号后正常退出、无锁文件残留、退出码 0
- [ ] **[建议检]** 日志只写 stdout/stderr，无自建轮转文件
- [ ] **[必检]** 若自带 `ecosystem.config.*`：`name` = `pm2Name`、`cwd` = `deployPath`、`autorestart: true`；未自带则由平台渲染，部署响应 `ecosystem` 字段会回显来源与入口
- [ ] **[建议检]** 项目方确认登记字段与平台 conformance 返回一致（`pm2` 状态、`pm_cwd`）。`pm2 describe {pm2Name}` 需服务器权限，保留给管理员核对
- [ ] **[必检]** `engines` 声明与 ecosystem `interpreter` 指向的 Node 大版本一致（见 20.3）

数据与备份

- [ ] **[必检]** 数据路径不在 `releases/` 或 `current/` 下：确认 `ops_check_conformance` 的 `config.*` 无 `fail`。`realpath` 比对需服务器权限，供管理员核对
- [ ] **[必检]** 备份包覆盖整个 `data/`，SQLite 副本 `PRAGMA integrity_check` 返回 `ok`；integrity 失败即视为备份失败（平台自后端 `2.1.0` 起按 fail 处理），MySQL/Postgres 包内包含数据库导出与校验结果（见 8.2.1）
- [ ] **[建议检]** runbook 第 4 章写明迁移方式、迁移失败处置与数据库恢复步骤
- [ ] **[必检]** 发布说明标注本次迁移为「向后兼容」或「破坏性」；破坏性迁移不依赖自动回滚（见第 19 章）
- [ ] **[必检]** `backup.sh`、`monitor.sh` 的内容由项目方提供，并保证脚本语法正确、具备可执行权限（本地 `bash -n` 与 `chmod +x` 检查）；落位到 `/opt/ops-scripts/{id}/` 由平台执行，项目方确认平台接口可见
- [ ] **[必检]** `.env` 权限 `600`，未出现在 git 与发布包中

登记与文档

- [ ] **[必检]** `id` 合规且非 `ops`，`pm2Name` 唯一且非保留名
- [ ] **[必检]** 项目 Key 未进群聊、仓库、日志或截图；只存于本机安全位置
- [ ] **[必检]** `probePath` 与实际健康检查路径一致
- [ ] **[建议检]** `runbookPath` 指向 `/opt/runbooks/{id}.md`，8 章齐全、≤200KB
- [ ] **[建议检]** runbook 未写死 release 名或版本号，均用 `readlink -f <deployPath>/current` 或 `current/package.json` 动态获取
- [ ] **[必检]** `dataDir` 为绝对路径且存在（`stateless: true` 的项目除外）；`backup.appDir` 填 `<OPS_DEPLOY_ROOT>/{id}`（默认 `/opt/apps/{id}`），按 `deployPath` 别名理解
- [ ] **[必检]** 登记的 `standardVersion` 与平台当前声明一致（以 `manifest.json` 或接口返回为准，不要写死具体版本号）

交付与接入

- [ ] **[必检]** 公网入口同时携带客户端 Token 与 `X-Ops-Project-Key`；无 Key 时只读标准资源
- [ ] **[建议检]** 项目 Key 下 `ops_list_projects` 只返回绑定项目，跨项目调用返回 `403`
- [ ] **[必检]** `ops_check_conformance` 无 `fail`；`warn` 已记录；`unknown` 已人工核对
- [ ] **[必检]** 发布产物为 `{id}-{version}.tar.gz`，同名 `.sha256` 可用 `sha256sum -c` 校验
- [ ] **[必检]** 产物由项目内固定 build 脚本一条命令产出；该脚本含禁项扫描、软链检查、解包后随机端口 + 临时数据目录启动自检
- [ ] **[必检]** 包内无 `.env` / `data/` / `backups/` / `logs/` / `.git/` / `releases/` / `current`（`ecosystem.config.*` 可选）

### 13.2 平台方完成，项目方仅需确认

以下条目由平台执行，项目方**不要自己动手**；确认方式写的是“你如何验证它已生效”，不是“你如何去配置”。

| 事项 | 执行方 | 技术口径（不变） | 项目方确认方式 |
|------|--------|------------------|----------------|
| DNS 解析 | 平台 / 运维 | 域名 DNS 指向 `47.110.36.244` | `dig +short <domain>` 返回该 IP |
| 证书 | 平台 / 运维 | 静态证书 `/etc/caddy/certs/fullchain.pem` 的 SAN 覆盖该域名 | `curl -fsS https://<domain>{probePath}` 无证书告警；`openssl s_client -connect <domain>:443` 可验证 SAN |
| Caddy 站点块 | 平台 / 运维 | 仅包含本项目域名，`reverse_proxy 127.0.0.1:{port}` | 从公网访问 `https://<domain>{probePath}` 返回 2xx/3xx，且未串到其他站点 |
| PM2 托管 | 平台 | 进程以 `pm2Name` 常驻，`autorestart: true` | 确认 `ops_check_conformance` 返回的进程状态为 `online`；`pm2 describe {pm2Name}` 为管理员核对手段 |
| 项目登记与 Key 签发 | 平台 / superadmin | 管理员全量冲突预检后登记 `projects.json`，通过 `POST /api/keys` 签发 `deploy`/`readonly` Key | `ops_get_project_context` 返回本项目；`ops_list_projects` 仅返回本项目 |
| 端口与域名冲突预检 | 平台 / 管理员 | 用全量视图检查端口、域名、`pm2Name`、项目 `id` | 部署前由管理员给出无冲突结论（项目 Key 下 `ops_check_conflict` 会漏报） |
| 备份落盘与保留 | 平台 | 按生效目标（项目 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → `local`）落盘，每个来源保留最新 7 份；远端目标需连接测试通过，否则 409 `BACKUP_TARGET_UNAVAILABLE` | `ops_list_backups` 能看到当前来源的新对象；数量为 7 |
| 备份 cron 登记 | 平台 | `schedule.enabled` / `cron` / `timezone`（`Asia/Shanghai`） | 后台或 `ops_get_project_context` 确认 schedule 已启用且时区正确 |
| release 清理 | 平台 | `releases/` 仅保留最近 3 个（`OPS_DEPLOY_KEEP_RELEASES`，默认 3） | 连续部署后确认平台报告的保留数量为 3，且回滚目标仍在 |
| 审计日志 | 平台 | 记录登记、备份、部署（或接入）动作 | 后台审计视图能查到对应记录 |

---

## 14. 常见失败原因与反例

| 现象 | 根因 | 正确做法 |
|------|------|----------|
| 端口从公网可直连，绕过 TLS 与限流 | 监听 `0.0.0.0` | 固定 `127.0.0.1`；对外只经 Caddy |
| 部署后数据丢失 | `data/` 放在 `releases/` 或 `current` 内 | 数据目录独立于发布目录（R7，见 2.5，fail 级） |
| Caddy 502，但进程 online | 端口硬编码与登记 `localPort` 不一致 | 端口只读 `PORT`，登记值与实际一致 |
| 健康检查 404 却显示健康 | 探测路径错误或旧版把 404 当存活 | `probePath` 必须返回 2xx/3xx，否则判 fail |
| 改 `.env` 不生效 | 密钥通过 PM2 `env` 注入并被进程快照固化 | 密钥只放 `.env`，改后重启进程 |
| 进程实际端口/数据目录与配置不一致 | 运行参数只写在 `.env`，被 PM2 合并的父进程环境（如 `PORT=3100`）覆盖，且 `/proc` 看不到、无法校验 | `PORT`/`HOST` 与**应用实际消费的数据路径键**写入 ecosystem `env` 作为唯一真源，再用 `/proc/<pid>/environ` 复核 |
| 预检显示数据目录已配置，实际却没人读 | 为了满足「必须有 `DATA_DIR`」硬填一个应用从不读的键 | 按 7.2 写应用真实消费的键（`DATABASE_FILE`、`UPLOAD_ROOT` 等），并校验解析后落在 `dataDir` 内 |
| 无持久化服务被要求提供数据目录 | 未声明无状态，或误以为必须填 `DATA_DIR` | projects.json 写 `stateless: true`，并移除数据键与 `dataDir` |
| 在 `.env` 里写数据键，却发现运行时校验看不到 | `.env` 是进程内注入，不进 `execve` 环境，`/proc` 不可见 | 数据键写进 ecosystem `env`；`.env` 仅作补充发现 |
| 502/504，或长任务被提前切断 | `requestTimeout` 短于反代 `read_timeout`/`write_timeout`；或把 `requestTimeout` 当作响应流超时 | 应用超时不早于反代；流式/长任务按业务最长耗时单独设计 |
| 每次部署都要手工改 runbook | runbook 写死了 `current` 指向的 release 或版本号 | 用 `readlink -f <deployPath>/current` 或读 `current/package.json` |
| 产物夹带 `.env`/`data/`，或打包后才发现在生产起不来 | 靠人记 `tar` 参数手工打包，无禁项扫描与启动自检 | 用项目内固定 build 脚本一条命令出包，内置扫描与解包启动验证 |
| 重启时请求失败、SQLite 锁残留 | 未处理 `SIGTERM` | 实现优雅退出，`kill_timeout` 留足时间 |
| 备份损坏或丢数据 | 直接 `cp`/`tar` 正在写的 `.db` | 使用 `sqlite3 ".backup"` |
| 备份包缺少 `data/` 内非 DB 文件 | 只备份了 `app.db` | 备份整个 `data/`，DB 单独用 `.backup` 覆盖 |
| 备份包缺少 `data/` 内日志 | 打包时全局排除 `*.log` | 只排除目录级 `logs/` |
| 备份突然停止且无告警 | `backup.sh` 放在项目 git 目录被覆盖 | 脚本放 `/opt/ops-scripts/{id}/` |
| 磁盘被日志写满 | 应用自建轮转或无限追加文件 | 只写 stdout，交给 PM2 日志轮转 |
| 部署后进程起不来 | 产物不含依赖，且默认不联网 | 内置 `node_modules` 或使用离线缓存 |
| 产物被拒收 | 缺少 `.sha256` 或文件名不合规 | 按 `{id}-{version}.tar.gz` + 同名校验文件出包 |
| 修改 Caddy 后全站异常 | 直接改整份 Caddyfile 且未 validate | 只增改本项目站点块，validate → 备份 → reload，失败回滚 |
| 登记被拒 | `id` 使用 `ops`，或 `pm2Name` 使用保留名 | 更换合规标识 |
| 字段拼写错误却未报错 | 旧版 schema 允许额外字段 | 当前 `additionalProperties=false`，按字段表登记 |
| 引入密钥泄露 | 密钥进 git / 日志 / runbook / 命令行参数 | 只放 `600` 的 `.env`，日志与响应脱敏 |
| 公网入口所有项目工具都报 `PROJECT_KEY_REQUIRED` | 只带了客户端 Token，没带 `X-Ops-Project-Key` | 先找管理员签发项目 Key，再放入请求头 |
| 项目 Key 调用直接 `400 INVALID_PROJECT_KEY` | 头格式错误、重复数组或超过 128 字节 | 只放单个 `ops_pt_<projectId>_<random>` 值 |
| 项目 Key 突然 `401` | 过期、被吊销、一次性 Key 已用过，或绑定项目已删除 | 重新向管理员申请，不要复用旧 Key |
| 冲突预检显示无冲突，接入后却撞端口/域名 | 用项目 Key 跑 `ops_check_conflict`，只看到本项目 | 冲突检测必须由管理员用全量视图完成 |
| 启动报 `duplicate column` / 迁移重复执行 | 迁移无记录表或记录表与文件不对齐，失败后盲目重跑 | 按第 18 章建立 `schema_migrations`，迁移语句可重复执行，失败即中止启动 |
| 回滚到旧代码后接口 500 / 字段不存在 | 数据库已前进，旧代码不认识新 schema，仍自动切回上一 release | 破坏性迁移禁止自动回滚，只能前进修复；见第 19 章 |
| 迁移失败但服务仍在跑 | 迁移在 `listen()` 之后执行，或失败被吞掉 | 启动前执行，失败 `exit` 非零，绝不带着半套 schema 提供服务 |

---

## 15. 版本与变更

- 当前标准版本 `1.2.5`；资源索引、`minServerVersion`（`2.1.0`）与 digest 覆盖范围见 `standards/manifest.json` 与 15.1。
- **1.0.0（2026-11-09）权限模型变更**：公网入口改为「客户端 Token + 项目 Key」两层凭据；无 Key 只能读标准文档资源，所有项目工具返回 `PROJECT_KEY_REQUIRED`；冲突检测与新项目登记只能由管理员完成。旧版权限描述作废，以 1.1、1.2 为准。
- **1.0.0（2026-11-09）产物要求收敛**：`conformance.json` 不再是发布产物，也不参与门禁判定；`ops_check_conformance` 实时计算，静态快照会过期。发布产物只有压缩包与同名 `.sha256`。
- **1.0.0（2026-11-11）数据目录表达放宽**：数据路径不再强制 `DATA_DIR`；可用应用真实消费的键（`DATABASE_FILE`、`UPLOAD_ROOT` 等），解析后落在登记 `dataDir` 内即可；校验跟着实际消费的键走。禁止为满足字段硬填无人读取的假键。
- **1.0.0（2026-11-11）实战经验补充**：新增超时口径（`requestTimeout` 与反代 `read_timeout`/`write_timeout` 的关系、不约束响应流）、项目内固定 build 脚本要求（禁项扫描、软链检查、解包启动自检）、runbook 禁止写死 release/版本号。
- **1.0.0（2026-11-11）新增 `stateless` 字段**：projects.json 与 `ops_plan_deployment` 输入均支持可选 `stateless: boolean`（默认 `false`，不进 required）。数据目录判定顺序为 `stateless` → `DATA_DIR` → ecosystem 中 `_FILE`/`_DIR`/`_PATH`/`_ROOT`/`_DB` 类键 → 项目根 `.env` 补充发现 → 都没有则拒绝；`.env` 来源的键不参与 `/proc` 比对。
- **1.1.0（2026-11-12）运行时契约补全**：新增 2.5，正式定义 R1–R7；修正 2.4、14 的悬空引用；R6 按现行环境模型改为「非密钥参数走 ecosystem `env`，密钥走 `.env`」。
- **1.1.0（2026-11-12）数据目录校验收紧**：`DATA_DIR` 与具体数据键一样，解析后必须落在登记 `dataDir` 内；自后端 `2.1.0` 起生效（`2.0.0` 为「存在即通过」）。该收紧会使部分存量项目由通过变失败，故按 minor 发布。
- **1.1.0（2026-11-12）迁移规则**：新增第 18、19 章，覆盖迁移文件位置与命名、记录表、幂等、失败中止、禁止人工补记录、带迁移回滚与 forward fix。
- **1.1.0（2026-11-12）一致性修正**：`integrity_check` 统一 fail 语义（后端 ≥ `2.1.0`，`2.0.0` 为 warn 后继续上传）；`backup.appDir` 明确为 `deployPath` 历史别名；`stateless` 不再表述为「强制互斥」；13 章与 7.2 C 对齐。
- **1.1.0（2026-11-12）回滚风险明示**：第 19 章明确执行器当前无条件 `switch-back`、完全不感知迁移；破坏性迁移必须人工闸门，迁移元数据自动判定仅作为未来建议，不构成现行要求。
- **1.2.0（2026-11-12）新增运行时与依赖基线**：新增第 20 章，按「平台自身依赖 / 项目必须满足 / 平台保证项 / 建议项」四类划分约束力。B 层只保留项目真正能决定的两项——Node.js 大版本、原生模块工具链；磁盘、内存、Caddy、PM2、SQLite CLI、glibc 运行时、OS/systemd 归入平台保证项，保留失败表现仅作排障线索。不写死补丁号，Node 推荐值表述为「22.x LTS，以平台现网为准」。补充项目侧 `engines` 声明、构建脚本 Node 大版本自检与原生模块重编前置检查。纯新增说明，不改变既有判定行为，`minServerVersion` 保持 `2.1.0`。
- **1.2.0（2026-11-12）规范站构建可复现**：`develop-site/requirements.txt` 固定 `markdown==3.9`；`build.py --check` 校验运行版本与钉版本一致；发布记录需同时包含构建依赖版本与四个文件的 sha256。
- **1.2.0（2026-11-12）验收清单归属与优先级修正**：第 13 章拆为「13.1 项目方必须完成」与「13.2 平台方完成，项目方仅需确认」。DNS、证书 SAN、Caddy 站点块、PM2 托管、项目登记与 Key 签发、端口冲突预检、备份上传与保留、备份 cron、release 清理、审计日志移入平台组，并给出「项目方确认方式」；`/proc` 比对、`pm2 describe`、脚本落位、`realpath`、`ss`、`ls releases/` 等需服务器权限的核对统一改为「确认平台返回值」，命令保留给管理员。13.1 共 31 条按「必检 23 / 建议检 8」标注档位（判据：不满足是否导致部署失败或线上故障）。技术口径与编号引用不变；`standardVersion` 验收项改为「与平台当前声明一致（以 `manifest.json` 或接口返回为准）」，不再写死版本号。第 1 节证书行与第 11 章 release 保留同步补执行方说明。
- **1.2.1（2026-11-12）digest 校验盲区修正**：此前 digest 只覆盖 `manifest.resources` 的 14 个文件，规范正文 `llms.txt`、`version.json`、`manifest.json` 都不在集合内，导致 1.1.0 与 1.2.0 正文已变而 digest 仍是 `sha256:6b4b4350…`。现把 `llms.txt` 与 `version.json` 纳入 resources，正文或版本元数据的任何改动都会改变 digest；`manifest.json` 因自引用仍不参与，其版本字段由 `version.json` 镜像并在启动时校验一致。`minServerVersion` 保持 `2.1.0`，判定行为不变。
- **1.2.2（2026-11-14）备份目标模型**：备份目标改为「项目级 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → 本地」，默认从 `oss` 改为 `local`；新增本地目标实现（`OPS_BACKUP_LOCAL_DIR`）；远端（`oss`/`cos`）必须已配置且连接测试通过才可用，否则 fail-closed（`409 BACKUP_TARGET_UNAVAILABLE`，不退回本地）；`projects.schema.json` 的 `backup` 新增可选 `provider`（`""`/`local`/`oss`/`cos`）；保留份数按来源各自计数；新增按来源列出、下载、恢复接口。`minServerVersion` 保持 `2.1.0`（不强制校验），但新语义仅在后端包含本次改动后生效，升级后需在「存储设置」点一次连接测试。
- **1.2.6（2026-11-14）首次部署自动启动配置**：部署执行器新增入口探测与 `ecosystem.config.cjs` 渲染（部署目录已有 > 产物自带 > 按登记渲染，覆盖前备份），`dryRun` 返回 `ecosystem.contentPreview`；探测优先级为登记 `entry` → `package.json scripts.start` → `main` → 常见入口 → 静态站点，探测不到即 `ENTRY_DETECT_FAILED`（附候选与建议）；渲染/探测写审计 `project.ecosystem.render`。`projects.json` 新增可选 `entry` / `ecosystemMode`（`auto`|`render`）；产物可自带 `ecosystem.config.*`（不再列为禁项）。规范 §11.2 与 §2.3 已同步。需服务端 `2.11.2`；`minServerVersion` 保持 `2.1.0`（不强制校验）。
- **1.2.5（2026-11-14）冷启动口径修正**：① 规范站正文头部版本/生成日期改由构建脚本从 `manifest.json` 渲染，发布前断言首页、`llms.txt`、`version.json`、`manifest.json` 四份版本一致，不一致即构建失败；② 正文点名的 `/standards/version.json`、`/standards/manifest.json`、`/v1/manifest.json` 现在真实可访问，`opsstd://v1/...` 给出到 HTTPS 路径的映射；③ `ops_check_conflict` 对已登记项目自身的命中标 `self:true` 并从 `ok` 判定中排除，更新部署可直接放行；④ `ops_get_project_context` 内嵌 conformance 复用 `ops_check_conformance` 同一实现（含首次纳管 deferred），并返回 `snapshotAt` / `rulesVersion`；⑤ `caddy.site_block` 的 `actual` 区分「无站点块 / 有块无路由 / 有路由但目标不符」，`caddy.domain_unique` 与它同源；⑥ 首次部署把 `caddy.site_block`、`caddy.proxy_target` 一并 `deferred`，平台在部署成功后写入站点块并立即复检，已部署项目仍按 `fail` 拦截；⑦ 项目 Key 可读自己项目的 runbook，runbook 目录由 `OPS_RUNBOOKS_DIR` 配置（默认 `/opt/runbooks`），文件不存在返回 404 而非 403；⑧ `ops_list_projects` 与 context 增加 `registeredStandardVersion` / `platformStandardVersion` 显式字段（旧 `standardVersion` 保留兼容）；⑨ `ops_plan_deployment` 对 `stateless` 或显式空密钥的项目把 `.env` 步骤标 `applicable:false`。需服务端 `2.11.1`；`minServerVersion` 保持 `2.1.0`（不强制校验）。
- **1.2.4（2026-11-14）产物上传与部署闭环**：新增项目产物上传通道 `POST /artifacts`（客户端 Token + `X-Ops-Project-Key` 两层鉴权）转发到后端 `POST /api/project/artifacts`，按项目落盘到 `<OPS_ARTIFACTS_ROOT>/<projectId>/`，文件名白名单清洗、仅接受 `.tar.gz`/`.tgz`、默认 200 MiB、每项目保留最近 10 份，返回 `artifactPath/size/sha256/uploadedAt`；新增 `ops_upload_artifact`。首次部署（从未有 `releases/current`）自动把 `process.ecosystem_loadable` 与 `network.port_probe` 标为 `deferred`，部署后立即复检；已部署项目仍按 `fail` 拦截。项目 Key 只读范围放开到本项目（项目列表、部署状态、inventory、调度、证书、runbook、日志、备份），跨项目与写接口仍 `403`。路径口径统一为 `<OPS_DEPLOY_ROOT>/<id>`，规划器/conformance/context 清理 `/opt/{id}` 残留。`ops_plan_deployment` 返回 `planType=create|update`，仅真实冲突 `blocked`。需服务端 `2.11.0`；`minServerVersion` 保持 `2.1.0`（不强制校验）。
- **1.2.3（2026-11-14）鉴权与路径口径修订**：1.2 补充两层凭据发放入口差异与 401 的 `reason` 语义（`missing`/`invalid`/`revoked`/`expired`，`code` 相同）；17.1–17.3 明确客户端 Token 需在「MCP 客户端」单独签发、不属于项目接入材料，新增不依赖客户端的 `curl initialize` 自检，并说明客户端不展开 `${VAR}` 时表现为 `reason=invalid`；`ops_check_conflict` 在项目 Key 下的描述改为与实现一致（`ok:false` + `limitedView:true` + `requiresAdminReview:true`）；10 补 `deployPath` 为派生字段（取 `backup.appDir`，不写入 `projects.json`），并明确 3000–3999 只是建议段、硬校验为登记 `0`–`65535` 与规划 `1024`–`65535`；默认部署目录统一为 `<OPS_DEPLOY_ROOT>/<id>`（默认 `/opt/apps/{id}`，与 `install.sh`、`executor` 实现一致）；`ops_get_project_context` 未登记 `backup.appDir` 时返回空 `deployPath`，不再猜 `/opt/{id}`。无后端行为变更，`minServerVersion` 保持 `2.1.0`。
- 版本语义：`patch` 文案与提示；`minor` 新增可选字段/模板或行为收紧，向后兼容；`major` 新增必填项或路径变更，升 `v2` 且 `v1` 保留至少两个发行周期。
- 项目侧只通过显式登记/adopt 变更 `standardVersion`，不随平台升级自动追随。
- 任何写入工具在 `standardVersion` / `standardDigest` 不一致时必须拒绝（`STANDARD_VERSION_MISMATCH`），不允许静默降级，也不解析 `latest` 别名。
- `minServerVersion` 当前不强制校验；`manifest.json` 中 `minServerVersionEnforced=false` 已注明。
- 标准内容变更流程：修改 `standards/` → CI 校验 schema/模板占位符/示例 → 生成版本与 digest → 随发行包发布；禁止手工改写版本与 digest。发布记录必须包含构建依赖版本（`develop-site/requirements.txt`）与站点四个文件的 sha256。

### 15.1 digest 的覆盖范围与用途

**算法**：对 `manifest.resources` 中每个文件取内容 sha256，按 `uri` 字典序拼成 `uri + \0 + sha256 + \n`，再整体取 sha256，形如 `sha256:<hex>`。仓库内 `manifest.json` 的 `digest` 字段恒为 `null`，运行时按同一算法重算，禁止手工填写。

**覆盖范围**：

| 是否参与 | 对象 | 说明 |
|----------|------|------|
| 参与 | `resources` 列出的全部文件 | 含规范正文 `llms.txt`、`version.json`、两份 schema、模板、清单等；任一内容变化都会改变 digest |
| 不参与 | `manifest.json` 自身 | digest 写在自身内部，自引用无法收敛；其 `version` / `major` 由 `version.json` 镜像，`StandardRegistry` 启动时校验两者一致，不一致直接抛 `VALIDATION_ERROR` |
| 不参与 | `resources[].title` / `description` / `mime` | 只改元数据不会改变 digest，因此改元数据也必须升版本号 |
| 不参与 | `standards/` 下未登记为资源的文件 | 新增受控文件必须同时登记进 `resources`，否则等于没有校验 |

**用途**：证明「你读到的正文与平台登记的版本是同一份」，用于工具响应里的 `standardDigest`、写入前的版本比对与审计留痕。它**不是**发布包签名，也**不是**防篡改证明——同一路径下的文件被替换后再重算 digest，摘要同样会变，但摘要本身无法证明来源可信。

**使用方式**：写入类工具在不一致时返回 `STANDARD_VERSION_MISMATCH`；人工核对时以 `opsstd://v1/manifest.json` 返回的 digest 为准，不要缓存旧值。

**路径映射（`opsstd://` → HTTPS）**：`opsstd://v1/manifest.json` ↔ `https://develop.soyooo.top/v1/manifest.json`（与 `/manifest.json`、`/standards/manifest.json` 同内容）；`opsstd://v1/version.json` ↔ `/v1/version.json`（与 `/version.json`、`/standards/version.json` 同内容）；其余 `opsstd://v1/<path>` ↔ `https://develop.soyooo.top/<path>`。正文提到的元数据路径均已真实可访问，不需要按虚拟标识去猜目录。

---

## 16. 产物投放与部署闭环（顺序不要颠倒）

标准闭环是 **投放 → 校验 → 干跑 → 收敛 conformance → 部署 → 复检**。任何一步失败都停在该步，不要靠跳过换取“部署成功”。

1. **自检**：按第 13 章逐条自检；体检以 `ops_check_conformance` 的实时结果为准，不需要生成静态文件。
2. **找管理员建项目并签 Key**：由管理员用全量视图完成 `id` / `pm2Name` / 端口 / 域名冲突预检，登记 `projects.json`，并签发 `deploy` 或 `readonly` 项目 Key。项目 Key 下跑 `ops_check_conflict` 仍返回 `limitedView:true`（不能证明全局无冲突），但用于**已登记项目**时自身命中会标 `self:true` 且不计入 `ok`，不会再把本项目报成冲突。
3. **投放产物**：两种通道，任选其一（都返回同一个 `artifactPath`）：
   - HTTP（推荐，不依赖 SSH）：`POST https://mcp.soyooo.top/artifacts`
   - stdio 本地：`ops_upload_artifact` 传 `sourcePath`（MCP 读取本机文件后转发）
   - 已上传确认：`ops_upload_artifact` 传 `fileName`（+ `sha256`），只做存在性与校验和确认，不重复传输
4. **校验**：把响应里的 `sha256` 与本地 `sha256sum app.tar.gz` 比对；服务端已强制 `.tar.gz`/`.tgz` 与体积上限，文件名会被清洗，`artifactPath` 一定落在本项目目录。
5. **干跑**：先 `ops_plan_deployment` 确认 `planType` 与路径口径（`deployPath = <OPS_DEPLOY_ROOT>/{id}`，更新计划取已登记 `backup.appDir`），再 `ops_deploy_project` 传 `artifactPath` + `dryRun=true`。
6. **收敛 conformance**：`ops_check_conformance` 直到 `fail=0`。**首次部署**（项目从未有 `releases/current`）会自动把 `process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target` 标为 `deferred`，不需要在部署前“修好”它们；已部署项目仍是 `fail`，必须修完。Caddy 站点块由**平台在执行器切换 current 前创建**（`caddyEnsureSite`），项目侧不需要也不应该手工改 Caddyfile。
7. **部署**：`dryRun=false` + `confirm=true`，Key 需具备 `project:deploy`。执行器自动解析启动配置：部署目录已有 → 产物自带 → 按登记探测渲染（见 11.2）；`dryRun` 返回 `ecosystem` 预览，不必先手工准备 `ecosystem.config.*`。
8. **复检**：首次部署成功后立即复检上述四项；执行器把结果写入 `deploy.conformance` 审计/oplog（含 `caddyOk` / `caddyAction`），MCP 在响应里返回 `postDeployConformance`。随后再跑一次 `ops_check_conformance`，确认无 `fail`、`unknown` 已人工核对。

### 16.1 产物上传契约（HTTP）

| 项 | 约定 |
|----|------|
| 路由 | 公网 `POST https://mcp.soyooo.top/artifacts`；后端实现 `POST /api/project/artifacts` |
| 鉴权 | 两层：`Authorization: Bearer <客户端 Token>` + `X-Ops-Project-Key: ops_pt_<projectId>_<random>`；缺任一层都失败 |
| 请求体 | `multipart/form-data`；文件字段名固定 `artifact`；可选字段 `sha256`（64 位小写十六进制，用于服务端提前比对） |
| 文件名 | 原文件名只取 basename 并清洗为 `[A-Za-z0-9._-]`，必须以 `.tar.gz` 或 `.tgz` 结尾；落盘名 `<YYYYMMDD-HHMMSS>-<sha256前8位>-<name>` |
| 落盘位置 | `<OPS_ARTIFACTS_ROOT>/<projectId>/`，默认 `/opt/ops/shared/artifacts/<projectId>/`；项目 Key 只能写自己项目目录 |
| 体积上限 | 200 MiB（`OPS_ARTIFACT_MAX_BYTES` / `OPS_DEPLOY_MAX_ARTIFACT_BYTES`） |
| 保留策略 | 每项目保留最近 10 份（`OPS_ARTIFACT_KEEP`），按 mtime 清理旧文件 |
| 成功响应 | `{ ok, projectId, artifactPath, fileName, size, sha256, uploadedAt, retention:{keep,pruned} }`；`artifactPath` 可直接传给 `ops_deploy_project` |
| 查询/确认 | `GET /api/project/artifacts?name=<fileName>` 返回 `file:{artifactPath,size,sha256,uploadedAt}`；不带 `name` 时列本项目产物 |
| 错误码 | `401` 客户端 Token/项目 Key 无效；`403` 跨项目或 scope 不足；`400` `ARTIFACT_REQUIRED`/`INVALID_FILENAME`/`CHECKSUM_MISMATCH`；`413` `ARTIFACT_TOO_LARGE`；`415` `UNSUPPORTED_ARTIFACT_TYPE`；`500` `ARTIFACT_WRITE_FAILED` |

MCP 在转发前会做文件系统边界校验：来自暂存区的 `artifactPath` 必须属于本项目目录，跨项目产物直接 `403 ARTIFACT_PROJECT_MISMATCH`（这是路径隔离，不替代后端的 Key 绑定与 scope 校验）。

```bash
# 投放
curl -sS -X POST https://mcp.soyooo.top/artifacts \
  -H "Authorization: Bearer $OPS_CLIENT_TOKEN" \
  -H "X-Ops-Project-Key: $OPS_PROJECT_KEY" \
  -F "artifact=@demo-api-1.0.0.tar.gz" \
  -F "sha256=$(sha256sum demo-api-1.0.0.tar.gz | awk '{print $1}')"
# → { "artifactPath": "/opt/ops/shared/artifacts/demo-api/20261114-101500-ab12cd34-demo-api-1.0.0.tar.gz", ... }


### 16.2 冷启动读数与字段口径（1.2.5）

| 场景 | 约定 |
|------|------|
| 冲突检测 | 已登记项目自身的域名/端口/PM2 名/ID 命中标 `self:true`；`ok` 只由非 self 冲突决定，`selfConflicts` 给出数量，`effectiveConflicts` 只含真实冲突 |
| context 与 conformance | `ops_get_project_context.conformance` 复用 `ops_check_conformance` 同一实现（含首次纳管 deferred）；两者都返回 `snapshotAt` 与 `rulesVersion`（`opsstd@<version>+<digest8>`），同一次调用的读数应一致 |
| Caddy 判定 | `caddy.site_block.actual` ∈ `无站点块` / `有站点块但无路由` / `存在（N 条路由）`；`caddy.proxy_target.actual` 区分无块、有块无路由、目标不符；`caddy.domain_unique` 与 `site_block` 同源（`inventory.sites`），不再出现「缺失」与「1 个站点块」并存 |
| 首次部署 | 四项 `deferred`：`process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target`；执行器在切换 current 前创建站点块，部署后复检并写审计（`deploy.conformance` 含 `caddyOk` / `caddyAction`） |
| 版本字段 | `registeredStandardVersion`（项目登记值）与 `platformStandardVersion`（平台当前值）显式区分；旧 `standardVersion` 在 context 中保持平台值、在 `ops_list_projects` 中保持登记值 |
| .env 步骤 | `ops_plan_deployment.manualSteps` 每项带 `applicable`；`stateless:true` 或显式 `envKeys: []` 时 `.env` 步骤为 `applicable:false` 并给出 `reason`，无需人工判断 |

---

## 17. 项目端如何接入 MCP（客户端配置、自检与排查）

项目侧的 AI 环境默认没有任何 `ops_*` 工具：既查不到平台，也验证不了连通性。接入是**外部平台配置**，项目目录里不需要、也不应该添加任何实现（不加 SDK、不写代理、不放凭据文件）。

### 17.1 客户端配置

MCP 服务地址固定为 `https://mcp.soyooo.top/mcp`，传输方式为 **Streamable HTTP**（远程 MCP，非 stdio）。两个请求头缺一不可：

| 请求头 | 值 | 谁发放 | 能否写进仓库 |
|--------|----|--------|--------------|
| `Authorization` | `Bearer <客户端 Token>` | 平台管理员在运维后台「MCP 客户端」按调用方签发，识别调用方与限流；**不随项目接入材料下发** | **不能**。只放本机环境变量或客户端密钥管理 |
| `X-Ops-Project-Key` | `ops_pt_<projectId>_<random>` | 管理员通过 `POST /api/keys` 为你的项目签发 | **不能**。等同生产写权限，泄露立即吊销 |

配置片段（占位符替换为本机环境变量，不要把明文写进仓库）：

```json
{
  "mcpServers": {
    "ops": {
      "type": "http",
      "url": "https://mcp.soyooo.top/mcp",
      "headers": {
        "Authorization": "Bearer ${OPS_CLIENT_TOKEN}",
        "X-Ops-Project-Key": "${OPS_PROJECT_KEY}"
      }
    }
  }
}
```

不同客户端字段名可能是 `transport` / `type` / `streamableHttp`，语义相同：URL + 两个 Header。`codex` / `claude` / `cursor` 等只要支持远程 HTTP MCP 均可按上表配置。

`${OPS_CLIENT_TOKEN}` 这类写法**只在客户端自身支持环境变量展开时生效**。若客户端不展开（把字面量原样发出），请求会带上无效 Token，返回 `401 CLIENT_TOKEN_REQUIRED`（`reason=invalid`）——这与“没配 Token”的 `reason=missing` 是两种不同问题。不支持展开时，改用客户端自带的密钥管理，或在仅本机可读的配置中直接填明文；任何情况下都不要把明文提交进仓库。客户端配置改动后需要**重启客户端**才会重新连接并刷新工具列表。

### 17.2 连通性自检

不依赖客户端，先用一条 `curl` 确认两层凭据都生效（`initialize` 不需要项目 Key 也能判定第一层）：

```bash
curl -sS -X POST https://mcp.soyooo.top/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $OPS_CLIENT_TOKEN" \
  -H "X-Ops-Project-Key: $OPS_PROJECT_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"selfcheck","version":"0.0.0"}}}'
```

判定：`200` 且返回 `serverInfo.name = ops-mcp` 表示两层凭据都成立；`401 CLIENT_TOKEN_REQUIRED` 是第一层失败（看 `reason`）；`400 INVALID_PROJECT_KEY` 是项目 Key 头格式问题。**只带项目 Key、不带客户端 Token 时，`initialize` 一定 `401`，这是预期行为，不是服务故障。**

配置生效后，工具列表应能看到 `ops_*` 前缀的工具：

- 无项目 Key：只应看到标准资源类读取能力（`opsstd://*`、manifest、规范正文、版本与 digest），看不到项目数据。
- 有项目 Key：只读工具应返回**且仅返回**绑定项目，例如 `ops_get_project_context` 返回 `ok:true`，且 `data.projectId` 等于你的 `projectId`；`ops_list_projects` 只有这一条。`deployPath` 取登记 `backup.appDir`；未登记时返回 `<OPS_DEPLOY_ROOT>/{id}` 并标记 `deployPathSource:"default"`，不要把默认值写回登记。
- 推荐自检顺序：`ops_get_project_context` → `ops_list_projects` → `ops_check_conformance` → `ops_get_health`。

只读工具（无需写权限）：`ops_list_projects`、`ops_get_health`、`ops_get_logs`、`ops_get_runbook`、`ops_list_backups`、`ops_get_scheduler_status`、`ops_get_inventory`、`ops_check_conflict`、`ops_plan_deployment`、`ops_check_conformance`、`ops_get_project_context`。

写工具（`ops_upload_artifact`、`ops_deploy_project`、`ops_rollback_project`）需要三件事同时成立：`deploy` 类型项目 Key（`ops_upload_artifact` 与部署需要 `project:deploy` scope）、`dryRun=false` 且 `confirm=true`（上传不需要）、conformance 无 `fail`（首次部署的 `deferred` 不计）。只读 Key 调用会被拒绝。

### 17.3 无工具或报错时的排查顺序

按顺序排除，不要跳步：

| 顺序 | 检查 | 症状 | 处理 |
|------|------|------|------|
| 1 | 客户端是否配置了 MCP 服务、是否重启生效 | 工具列表里没有 `ops_*` | 检查 URL 与传输方式；重启客户端 |
| 2 | 是否带客户端 Token | `401 CLIENT_TOKEN_REQUIRED`，`reason=missing` | 向平台管理员申请客户端 Token（后台「MCP 客户端」），放进 `Authorization`；它不属于项目接入材料，不会随项目 Key 一起下发 |
| 2.1 | 客户端是否把 `${...}` 原样发出 | `401 CLIENT_TOKEN_REQUIRED`，`reason=invalid` | 客户端不支持环境变量展开；改用明文或客户端密钥管理，然后重启客户端 |
| 3 | 是否带项目 Key | 所有项目工具返回 `PROJECT_KEY_REQUIRED` | 找管理员为你的项目签发 Key，放进 `X-Ops-Project-Key` |
| 4 | Key 头格式是否正确 | `400 INVALID_PROJECT_KEY` | 只放单个 `ops_pt_<projectId>_<random>`，不要数组、不要多余空格、不超过 128 字节 |
| 5 | Key 是否绑定当前项目 | `403`，提示不区分项目是否存在 | Key 绑定了别的项目；本项目需单独签发，不要复用他人 Key |
| 6 | Key 是否过期/吊销/一次性已用 | `401` | 重新申请，不要复用旧 Key |
| 7 | 权限是否够 | 写工具 403 或 scope 不足 | 换 `deploy` 类型 Key；`readonly` 只能读 |

- 排查只看错误层级，不需要猜项目是否存在：`401` 是凭据问题（按 `reason` 再分 missing / invalid / revoked / expired），`400` 是格式问题，`403` 是项目/权限问题，`PROJECT_KEY_REQUIRED` 是漏了第二层凭据。
- 所有项目数据都经由平台返回，**不要在项目仓库里读取 `projects.json` 或直连 ops-backend**；也不要把 `OPS_PROJECT_KEY` 写进 `.env` 后随备份包上传。

### 17.4 边界

- 冲突检测与新项目登记必须在管理员侧完成。项目 Key 下 `ops_check_conflict` 只看到本项目，仍返回 `limitedView:true` + `requiresAdminReview:true`；但已登记项目的自身命中会标 `self:true` 并排除在 `ok` 之外（`ok` 可直接用于更新部署放行），不会再出现「自己撞自己」的 4 条冲突。
- 本地 stdio 模式是管理员在本机排查用的，与公网入口凭据模型不同，不要把两者混用。
- 项目目录只需按本规范写出应用；MCP 接入是外部配置，不产生任何代码或文件交付物。

---

## 18. 数据迁移规则

迁移失败的典型事故是 `duplicate column`：没有记录表、失败后盲目重跑，DDL 已生效却再次执行。本章给出可执行的统一规则。

### 18.1 迁移文件的位置

- 目录固定为 release 根下的 `migrations/`，即运行时路径 `current/migrations/`。
- 迁移文件是**代码的一部分**，随 `{id}-{version}.tar.gz` 发布，落在 `releases/{ts}/migrations/`；不得放在 `data/`，也不得在启动时从网络下载。
- `migrations/` 不在第 11 章禁项列表内，但必须出现在 build 脚本的包清单中；发布后该目录只读，不再修改。

### 18.2 命名与排序

- 文件名格式：`NNNN_snake_case.sql`，`NNNN` 为至少 4 位、左补零的十进制序号（如 `0001_create_user.sql`、`0002_add_user_email.sql`）。
- 排序：按序号数值升序；序号相同时按文件名字典序，但**禁止序号重复**。
- 序号只增不减，**不得复用已发布过的序号**；允许留空号（如 0003 缺失），不允许插入到已有序号之前。
- 不要求提供 `down`/回滚脚本；数据库回退不作为标准能力（见第 19 章）。

### 18.3 随 release 发布与执行时机

- 迁移在**进程启动阶段、`listen()` 之前**执行；执行完成前不得接受任何请求。
- 执行入口必须是确定性的：启动命令 → 加载配置 → 获取迁移锁 → 校验记录表 → 按序执行未应用迁移 → 释放锁 → 启动 HTTP 服务。
- 多实例或 `exec_mode: 'cluster'` 时，必须使用数据库级咨询锁（PostgreSQL `pg_advisory_lock`、MySQL `GET_LOCK`、SQLite `BEGIN IMMEDIATE`）保证同一时刻只有一个执行者；未实现锁的项目不得开多实例。
- 迁移执行必须有超时与日志：记录开始/结束、文件名、耗时、失败原因；日志不得输出数据库口令。

### 18.4 记录表结构与对齐要求

固定表名 `schema_migrations`（如项目已有同名表，按本节字段改造，不得另起别名）。记录表由迁移运行器在启动时以 `CREATE TABLE IF NOT EXISTS` 创建，**不占用迁移序号**，也不得依赖某条业务迁移来建表：

| 列 | 类型 | 约束 | 含义 |
|----|------|------|------|
| `version` | VARCHAR(32) | PRIMARY KEY | 迁移序号（与文件名前缀一致，如 `0002`） |
| `name` | VARCHAR(255) | NOT NULL | 文件名（不含目录） |
| `checksum` | CHAR(64) | NOT NULL | 迁移文件内容的 SHA-256（小写 hex） |
| `applied_at` | VARCHAR(32) | NOT NULL | ISO8601 UTC 时间 |
| `duration_ms` | INTEGER | NULL | 执行耗时 |
| `release` | VARCHAR(64) | NULL | 产生该记录的 release 目录名，便于追溯 |

对齐规则（启动时校验，不满足即按 18.5 处理）：

1. **文件 → 记录**：release 内每个迁移文件在表中至多一条记录，且 `version` 唯一。
2. **记录 → 文件**：表中存在记录、但当前 release 找不到对应文件 → **fail**（说明库比代码新，或发布包缺文件）。
3. **校验和**：已应用文件的 `checksum` 必须与当前文件内容一致；不一致 → **fail**（已发布迁移被改写，禁止继续）。
4. **连续性**：若存在序号更大的记录，则所有更小序号的迁移都必须有记录；出现「跳号未应用」→ **fail**。
5. **未应用**：文件中没有记录的，按顺序执行；执行成功写一条记录，失败不写。

### 18.5 幂等与失败处理

- **运行器幂等**：重复启动时，已记录且校验和一致的迁移必须跳过；重复启动不得重复执行。
- **语句幂等**：迁移本身应尽量可重复执行（如 `CREATE TABLE IF NOT EXISTS`、先查 `information_schema` 再 `ALTER`）。原因：MySQL 等引擎的 DDL 无法回滚，可能出现「DDL 已生效、记录尚未写入」的窗口；若语句不可重复执行，下一次启动就会报 `duplicate column`。
- **失败必须中止启动**：任何迁移失败 → 记录已成功的前序迁移 → 打印失败文件与原因 → 以非零码退出；**禁止** `listen()`、禁止降级为「跳过失败继续跑」、禁止只写日志不退出。
- PM2 会按 `autorestart` 重试；连续失败达到 `max_restarts` 后进入 `errored`，由监控告警。修复方式只能是：修正迁移文件（未发布时）、补一个向前修复的迁移（已发布时），或从备份恢复；不允许靠重试碰运气。
- 破坏性迁移（删列、改类型、加非空约束等）在执行前必须先完成一次可验证备份，并在 runbook/发布说明中标注；平台目前没有「部署前自动备份」钩子，因此这是人工闸门。

### 18.6 禁止人工补记录行（事故处置 ≠ 标准做法）

- **严禁**在正常发布流程中手工 `INSERT`/`UPDATE`/`DELETE` `schema_migrations`，也严禁手工修改已应用迁移文件来「对齐」记录。
- jingyu 的 `duplicate column` 事故中，曾通过人工补 tracking 行让服务继续启动。这属于**事故处置**，不是标准做法：它掩盖了「记录表与文件不一致」的真实原因，下一次发布仍会复发。
- 只有以下情况允许人工介入，且必须同时满足：已停止写入、已做完整备份、由管理员执行、写入审计、事后补一条修正迁移或在演练环境复现验证：
  1. 记录表损坏且无法从备份恢复；
  2. DDL 已生效但记录缺失，且该迁移语句可证明可重复执行；
  3. 数据库比代码新，需临时回退到匹配的 release。
- 人工介入后必须在 runbook 第 8 章记录时间、原因、执行人、SQL、影响范围与后续修复计划。

---

## 19. 带迁移发布的回滚语义

回滚只切 `current`、不回写数据；但代码回退后数据库 schema 可能已经前进。本章规定什么情况下允许回滚、什么情况下必须前进修复。

### 19.1 两类迁移

| 类型 | 定义 | 示例 |
|------|------|------|
| 向后兼容（expand） | 旧代码在新 schema 上仍可正常读写 | 新增可空列、新增表、新增索引、扩大字段长度 |
| 破坏性（contract） | 旧代码在新 schema 上会报错或写坏数据 | 删列/删表、重命名、改类型、加非空且无默认值、收紧约束 |

### 19.2 允许回滚的条件

同时满足才允许把 `current` 切回上一 release：

1. 本次发布只包含**向后兼容**迁移；
2. 发布说明明确标注「可回滚」，且回滚目标 release 仍在 `releases/` 中（保留 3 个）；
3. 回滚后按 19.4 验证旧代码能读写新 schema。

回滚仍只切 `current`、不回写数据；数据库保持前进状态。

### 19.3 必须前进修复（forward fix）的情况

出现以下任一情况，**禁止回滚到旧代码**，只能前进修复：

1. 本次包含破坏性迁移（已删列/改类型/重命名/收紧约束）；
2. 迁移已写入数据（如回填、拆分表），旧代码无法解释新数据；
3. 记录表显示数据库版本高于目标 release 的迁移集合；
4. 回滚后旧代码启动即报 schema 相关错误。

处置顺序：停止继续发布 → 保留现场与日志 → 评估能否用一个向前迁移修复 → 修复并发布新 release；无法修复时才从备份恢复（会丢失备份点之后的数据，必须走事故流程）。

### 19.4 向后兼容的最低要求

- 新 schema 必须至少兼容**上一个 release（N-1）**的代码；跨两个版本的破坏性变更必须拆成「扩展 → 双写/回填 → 收缩」多次发布。
- 重命名列/表：先新增、双写、回填、切读、最后在后续 release 删除旧列；禁止在同一次发布内改名并删除。
- 新增非空列必须带默认值或先允许为空，回填后再收紧。
- 回滚验证至少覆盖：健康检查、登录/鉴权、核心写路径、迁移记录表读取。

### 19.5 现状风险：自动回滚不感知迁移（强制人工闸门）

> **风险结论**：当前执行器在部署失败（健康检查、环境校验、PM2 启动等）时会**无条件**切回上一 release（`switch-back`）。它不读取迁移文件、不读取 `schema_migrations`，也没有任何「本次发布是否包含破坏性迁移」的判断。因此，一次包含破坏性迁移的发布如果失败，自动回滚会把**旧代码指向已经前进的数据库**，可能出现启动即报错、字段不存在、写坏数据等二次故障，且回滚动作本身会掩盖首次失败的原因。

- **标准要求（现状下必须执行）**：包含破坏性迁移的发布**不得使用自动部署工具直接发布**，必须：
  1. 走人工发布流程，管理员在场；发布说明显式标注「破坏性 / 禁止回滚」，并写入发布记录；
  2. 发布前完成一次可验证备份，并确认恢复步骤可用；
  3. 发布失败时**不执行自动回滚**，按 19.3 前进修复；若必须回到旧代码，先确认数据库已回退到与旧代码匹配的版本（只能从备份恢复，属事故流程）。
- 使用自动部署工具发布**向后兼容**迁移时，仍可能触发自动回滚；这类回滚理论上安全，但必须满足 19.4 的 N-1 兼容要求，并在回滚后验证健康检查、登录/鉴权与核心写路径。
- 首次部署（只有一个 release）失败时保留 `current`（`keep-current`），不会切回旧代码；但新代码/新 schema 可能仍不可用，同样需要人工处置。

> **未来能力（建议，不是现行标准要求，不得依赖）**：执行器在回滚前读取 release 内的迁移元数据（例如发布说明中的 `rollback: allowed|forbidden`、迁移清单哈希或迁移文件是否存在），发现破坏性迁移时拒绝自动回滚并返回明确错误。该能力目前**完全不存在**；在实现并发布之前，填写任何标记都不会生效，也不能作为通过依据或免责理由。

### 19.6 发布前判定清单

- [ ] 列出本次新增迁移文件与序号，确认无重复、无改写已发布文件
- [ ] 逐条标注「向后兼容/破坏性」，并写入发布说明
- [ ] 破坏性迁移：已做可验证备份，已确认走人工发布且不会被自动回滚
- [ ] 发布说明已标注「破坏性 / 禁止回滚」，并知会执行发布的管理员
- [ ] 回滚目标 release 由平台保证仍存在：确认平台报告的回滚目标版本可用，且其迁移集合不高于当前数据库版本；不要用 `ls releases/` 自行判断
- [ ] runbook 第 3、4 章已更新迁移与恢复步骤

---

## 20. 运行时与依赖基线

本章回答一个问题：**新建项目要能被 ops 部署，哪些运行时条件需要项目自己保证、哪些由平台保证**。事实来源是 2026-11-12 的只读盘点（现网实测 + 官方发布记录）；标准只把项目能够选择的项写成义务，宿主条件一律归平台。

### 20.1 四类约束力（先分清哪层管你）

| 层级 | 内容 | 对项目方的约束力 |
|------|------|------------------|
| A. 平台自身依赖 | ops-backend、ops-mcp、规范站构建所用的包及其版本 | **无约束**。仅作说明与排障参考，项目方不需要关心，也不得据此要求平台升级 |
| B. 项目必须满足 | Node.js 大版本、原生模块工具链（python3 / make / g++ / glibc） | **规范**。这些是项目自己的代码与依赖能否装上、能否跑起来的前提，不满足会在安装或启动阶段直接失败 |
| 平台保证项 | PM2、Caddy、SQLite CLI、glibc 运行时、OS / systemd、磁盘、内存 | **由平台负责，项目无需处理，也未提供降级路径**。列出仅为排障定位：出现对应症状时找管理员，不要在项目内绕过 |
| C. 建议项 | 满足更顺，不满足不阻断部署 | **建议**。只影响可维护性与一致性 |

> 四类不得混用：不要把 A 层或平台保证项的版本写成对项目的要求，也不要把 C 层的建议写成 `fail` 条件。宿主条件（磁盘、内存、Caddy、PM2、SQLite CLI、OS）不是项目能选择的，写进项目义务只会把平台自身的运维责任转嫁出去。

### 20.2 A 层：平台自身依赖（仅说明）

平台组件（项目方无需关心，仅供参考）：

| 组件 | 现网版本 | 说明 |
|------|----------|------|
| Express | 4.22.3 | ops-backend 的 HTTP 框架 |
| @modelcontextprotocol/sdk | 1.32.2 | MCP 工具与传输 |
| zod | 3.25.76 | MCP 工具入参校验，必须与 SDK 支持的 zod 主版本一致 |
| better-sqlite3 | 11.10.0 | first / jingyu 的数据库驱动，按各自 Node ABI 编译 |
| node-cron / ali-oss / jsonwebtoken / bcryptjs / dotenv / multer | 4.6.0 / 6.23.0 / 9.0.3 / 2.4.3 / 16.6.1 / 1.4.5-lts.2 | 调度、对象存储、鉴权、密码、配置、上传 |
| PM2 | 7.0.x | 进程托管，执行器读取 `pm2 jlist` |
| Caddy | 2.11.x | 反向代理，执行器调用 `validate --config` / `reload` |
| markdown | 3.9 | 规范站构建，见 20.8 |

上表是平台事实，**不构成对项目的要求**，项目侧下限只在 20.3 声明。

### 20.3 B 层：项目必须满足（仅 2 项）

| 项 | 最低可用 | 推荐 | 为什么需要 | 不满足时的具体表现 |
|----|----------|------|------------|--------------------|
| Node.js 大版本 | **≥ 22** | **22.x LTS，以平台现网为准**（Node 24 需平台白名单） | 平台侧 `engines` 基线、`--env-file`、现代 ESM 行为；执行器用 `node --check` 校验入口 | Node 20 及以下：`npm install` 报 `EBADENGINE`（`engine-strict` 时直接失败）；应用或脚本使用 `--env-file` 时 `node` 报 `bad option`，进程起不来；ESM/CJS 互操作行为差异，表现为 `ERR_REQUIRE_ESM` 或导入失败 |
| 原生模块工具链（仅当含原生模块） | `python3` + `make` + `g++`（gcc ≥ 10）且 glibc ≥ 2.28 | 与平台现网一致，以管理员告知为准 | `better-sqlite3` 等原生模块必须按目标机 Node ABI 重编；跨机部署不是「装一次到处用」 | `npm install` 或 `npm rebuild` 报 `gyp ERR! stack Error: not found: make` / `python3` / `g++`，构建中断；只拷贝 `node_modules` 不自检时，进程启动报 `NODE_MODULE_VERSION` 不匹配；glibc 过低时直接执行 `node` 报 `GLIBC_2.28 not found` |

**Node 大版本怎么选（22 与 24 的差异）**

- 默认使用 **22**：系统 `/usr/local/bin/node`，也是 PM2 daemon 与多数量级项目的运行时。
- **24 仅限平台白名单**（如 first）。使用方法是在 ecosystem 中显式指定解释器：

  ```js
  module.exports = [{
    name: '${PM2_NAME}',
    cwd: '${DEPLOY_PATH}',
    script: '${ENTRY}',
    interpreter: '/opt/node24/bin/node',
  }];
  ```

  不写 `interpreter` 时使用系统 Node（22）。**不得在项目内自行安装或切换 Node 版本**。
- `engines` 必须与 `interpreter` 实际指向的大版本一致：用 22 写 `">=22 <23"`，获准用 24 写 `">=24 <25"`。两者不一致时，本地安装与线上运行会表现出不同的模块解析行为。
- **22 与 24 的 ABI 不同**：同一项目的原生模块必须与它实际运行的 Node 版本一起编译。`first` 用 Node 24、`jingyu` 用 Node 22，两者的 `better-sqlite3` 必须分别重编。

补充约束：

- **跨机迁移不是升级**：新机按平台现网版本复制，只重编 native、重装平台相关包（如 `tsx`/`esbuild` 的 linux-x64 包）。
- **无端口服务**（`localPort=0`）同样要满足 Node 与工具链下限；端口不是本层的判断依据。

### 20.4 平台保证项（由平台负责，项目无需处理）

以下条件由平台保证。项目方不需要安装、升级或校验，平台也**未提供降级路径**（例如不能在项目内自建 Caddy 或替换 SQLite CLI）。「不满足时的表现」是排障线索：若线上出现这些症状，说明平台侧条件异常，应直接找管理员，不要在项目里绕过。

| 项 | 平台保证 | 为什么需要 | 不满足时的表现（排障线索） |
|----|----------|------------|---------------------------|
| PM2 | 版本支持 `pm2 jlist` 的 `pm_cwd`、`pm_exec_path`、`restart_time`，以及 `--only`、`kill_timeout`、`treekill` | 执行器据此判断进程工作目录与重启行为 | `jlist` 缺字段，R1（`cwd` 必须等于 `deployPath`）判定拿不到实际值而失败；启动参数含 `--only` 时 `pm2 start` 报未知选项；`treekill` 不生效导致重载残留旧进程 |
| Caddy | 支持 `caddy validate --config` 与 `caddy reload`，站点块按 v2 语法 | 接入校验与 TLS 终止 | 不支持 `validate` 的参数形式，接入校验停在 validate 一步，配置不落盘；`reload` 行为差异导致新站点块未生效但返回成功 |
| SQLite CLI | 存在，且支持 `.backup` 与 `PRAGMA integrity_check` | 备份脚本的核心命令 | `command not found`，或旧版不支持 `.backup`：`backup.sh` 非零退出，首次备份失败；后端 `2.1.0` 起未通过 integrity 的备份会被判为失败并拒绝上传 |
| glibc 运行时 | ≥ 2.28 | Node 22/24 官方二进制与 native 模块的运行前提 | 直接执行 `node` 报 `GLIBC_2.28 not found`，PM2 拉起后立即 `errored` 并反复重启 |
| OS / 架构 | Linux x86_64 + systemd | PM2 与 Caddy 需要服务化 | `pm2 startup` 报无 systemd / 不支持的 init，开机不自启；非 x86_64 时 native 预编译包缺失，回退到源码编译 |
| 磁盘 | 满足至少 3 个 release、本地备份（保留 7 份）与依赖共存 | 部署与备份的落盘空间 | `ENOSPC`：`npm install` 失败、`tar czf` 中途失败、备份写 `.tmp` 时失败，表现为部署在校验或解包阶段中断 |
| 内存 | 满足 native 构建与多个 PM2 进程并行 | 构建与运行稳定性 | 构建被 OOM kill（`Killed`，无明确错误）、PM2 反复 `restart`、健康检查超时被判定部署失败 |

平台当前实装（**记录时现网值，可能已变化**，仅作排障参照）：

| 项 | 记录时版本 |
|----|------------|
| Node（系统） | 22.12.0（Node 22 LTS） |
| Node（`/opt/node24`） | 24.22.2（白名单项目使用） |
| npm | 10.9.0（Node 22）/ 11.19.1（Node 24） |
| SQLite CLI | 3.42.0 |
| gcc / make / python3 | 12.3.0 / 4.4.1 / 3.11.6 |
| glibc | 2.38 |
| 内核 / 架构 | 6.6.102 / x86_64（Alibaba Cloud Linux 4） |

这些数字会随平台升级变化，**标准不对项目方声明补丁号**；需要精确值时向管理员确认。

### 20.5 C 层：建议项（不阻断）

| 建议 | 原因 | 不满足的后果 |
|------|------|--------------|
| `package.json` 声明 `engines` 且与 `interpreter` 一致 | 本地与 CI 能提前暴露版本不符 | 只在部署时才失败，排查成本更高 |
| 本地开发环境与平台 Node 大版本对齐 | 避免本地能跑、线上装不上 | native 模块与 ESM 行为在本地与线上不一致 |
| npm ≥ 10 | Node 22 自带，无需额外动作 | 旧 npm 的 lockfile 行为差异，可能装出不同依赖树 |
| 默认 Node 22，Node 24 仅白名单 | 与平台基线一致 | 混用会放大 ABI 问题 |
| 项目内固定构建依赖版本 | 构建可复现，产物哈希稳定 | 产物哈希漂移，无法判断是否真的改了代码 |

### 20.6 两条原则

1. **标准声明的是「已验证的大版本与工具链」，不是「最新版本」，也不是补丁号。** 具体补丁（Node 22.x 的哪个小版本、Caddy 的哪个 patch）由平台维护，会随升级变化；标准不把这类数字写成项目义务。**不要为了追新而升级运行时**，也不要因为平台补丁前进了就认为标准要求也变了。
2. **升级节奏由平台方决定。** 项目方不得在发布窗口内自行升级 Node / PM2 / Caddy / glibc / OS 大版本，也不得改 `package.json` 的 `engines` 与依赖区间来「顺便升级」。需要升级时由平台统一安排，并与执行器、备份、迁移一起回归。
3. **新机器安装取当时的稳定版本。** 默认 Node 22 LTS、Caddy 稳定版，不把补丁号写死；需要复现某次发布时，用 `manifest.json` 的 `minServerVersion`、`develop-site/requirements.txt` 的项目 lockfile 锁定基线，而不是回退到旧补丁包。新机器只保证「大版本与工具链满足」，补丁由平台在安装后按稳定版推进。
4. **升级边界：运行时不做热升级，应用依赖可升但必须重启。** Node / Caddy / PM2 的升级要停机切换并跑部署、回滚、证书与健康检查回归，不能在业务进程运行中替换；项目自身的 npm 依赖可以在发布窗口内升级，但必须重新安装、重启 PM2 进程并完成回归。标准版本号变化只表示正文口径变化，不代表平台运行时已经升级。

### 20.7 项目侧自检（可直接复制）

**(1) `package.json` 声明运行时下限**

```json
{
  "engines": {
    "node": ">=22 <23",
    "npm": ">=10"
  }
}
```

注意：`engines` 默认只是告警，只有开启 `engine-strict=true` 或在 CI 中显式检查才会阻断；PM2 完全不读 `engines`。因此还必须做 (2)。

**(2) 构建脚本里的 Node 大版本硬校验**（放在 build 脚本最前面，先于打包）

```bash
#!/usr/bin/env bash
set -euo pipefail

# 与本项目 ecosystem 的 interpreter 保持一致：系统 Node 写 22，白名单 Node 24 写 24
need_node_major=22
node_major="$(node -p 'process.versions.node.split(".")[0]')"
if [ "$node_major" -ne "$need_node_major" ]; then
  echo "FAIL runtime: Node $(node -v) 与目标大版本 ${need_node_major}.x 不一致" >&2
  exit 1
fi

echo "runtime ok: node=$(node -v)"
```

PM2、Caddy、SQLite CLI 属于平台保证项，项目侧不必自检；若在本地开发时临时用到 `sqlite3`，自行安装即可，不要把它写进部署前置条件。

**(3) 原生模块重编前置检查**（只有含原生模块时需要）

```bash
if grep -q '"better-sqlite3"' package.json 2>/dev/null; then
  for bin in python3 make g++; do
    command -v "$bin" >/dev/null || {
      echo "FAIL: 缺少 $bin，原生模块无法编译（gyp ERR! not found）" >&2; exit 1; }
  done
  node -e "require('better-sqlite3')" 2>/dev/null || {
    echo "FAIL: better-sqlite3 与当前 Node ABI 不匹配，执行 npm rebuild better-sqlite3" >&2
    exit 1; }
fi
```

**(4) 自检结论写进交付说明**：Node 主版本（是否与 `interpreter` 一致）、是否含原生模块及是否已按目标 Node 重编。这两项是部署失败时最先被问到的信息。

### 20.8 规范站（本文件）的构建可复现

规范站由 `ops-mcp/standards/llms.txt` 生成，构建依赖只有一个 Python 包，但历史上未钉版本（本机 3.9，最新 3.11.3），导致 `index.html` 的哈希对无关升级敏感。

- `develop-site/requirements.txt` 固定 `markdown==3.9`；
- 构建前执行 `pip install -r requirements.txt`，不要用全局已装的任意版本；
- `build.py --check` 会比对运行版本与 `requirements.txt` 的钉版本，不一致直接失败；
- **标准发布清单增加一条**：发布记录必须包含构建依赖版本（`requirements.txt` 内容）与四个文件（`llms.txt`、`index.md`、`index.html` 及标准源副本）的 sha256，便于判断哈希变化是内容变化还是构建环境变化。

升级 `markdown` 属于 A 层动作：由平台决定，升级后必须重新基线化站点哈希并重新发布，不得在内容发布中夹带。
