Files
OpenFlare/docs/plan/20260719-pages-source-sync-v2.md
T
ryan 33a1c32cf8 refactor(structure): group platform, infra, and shared packages
Reorganize internal packages into platform/infra/shared layers and update
imports, docs, and seed-count tests to match current system configs.
2026-07-24 15:41:59 +08:00

1506 lines
89 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.
# Pages 项目部署源与 GitHub Releases 自动更新 V2 实现方案
日期:2026-07-19
状态:代码实施完成(范围内自动化验证完成;生产环境验收见 §7)
方案版本:V2(设计修订版,不代表新增 `/api/v2`)
关联材料:
* 原方案:[`20260718-pages-source-sync.md`](./20260718-pages-source-sync.md)
* 设计审核:[`20260719-pages-source-sync-design-review.md`](./20260719-pages-source-sync-design-review.md)
* 表结构审核:[`20260719-pages-source-sync-schema-revision.md`](./20260719-pages-source-sync-schema-revision.md)
> 本文是完整、独立且可直接实施的技术方案,取代原方案成为该功能唯一实现基线。原方案与两份审核文档仅用于追溯设计演进;开发时不需要再将它们与本文拼接,也不得沿用其中与本文冲突的宽表、11 态状态机、`activate=false` 或四重 fence 设计。
---
## 0. 结论摘要
V2 保留原方案正确的主链路:外部来源只由 Server 控制面访问,所有包都进入统一的不可变 deployment 管线,Agent 仍只从 Server 拉取当前 active package。审核意见中的高优先级问题按以下规则一次性收敛:
1. source 配置与运行态拆为 `of_pages_project_sources`、`of_pages_project_source_runtime` 两张表;runtime 不再冗余 `project_id`。
2. 来源同步固定为“解析/下载 → 校验 → 创建或复用 deployment → 原子激活”,API 不提供 `activate` 开关。
3. `sync_status` 只保留 `idle | checking | update_available | syncing | failed | attention` 六态;排队状态使用现有 `TaskExecution`,不在 source runtime 重复保存。
4. 互斥只由 runtime lease 负责;过期结果只在最终事务校验 source `config_version`、project `content_config_version`、`lease_token` 与 lease 未过期时间,不再传递通用 `expected_revision`。
5. 人工激活不同 deployment 时,只要项目存在 source,就在同一事务中 fence 在途任务;若自动更新已开启,同时强制关闭,避免人工回滚被下一轮 latest 静默覆盖。
6. `history_count=1` 时,手动上传允许临时保留 active 与最新 candidate 两条;激活后恢复严格上限。source sync 因创建与激活同事务完成,不产生未激活候选。
7. Remote URL 只支持手动“同步并发布”;只有 GitHub `latest` 支持定时检查和可选自动更新,GitHub `tag` 只支持手动检查/同步。
8. Remote URL 使用显式 `remote_url_set` 控制“保留或替换”密文 URL;API、日志、任务 payload 和 deployment provenance 均不得泄漏 query token。
9. 阶段 0 先修复 `RootDir`、归档真实展开限制、Agent 全量内存下载和历史裁剪问题,再接入远端来源。
---
## 1. 目标与背景 (Goal & Context)
### 1.1 方案制定时的实现与问题
方案制定时,Pages 已支持:
* 管理员本地上传压缩包;
* 同步调用 `POST /api/v1/d/pages/:id/deployments/upload-from-url` 完成一次性 URL 导入;
* 使用 `RootDir + EntryFile` 检查归档并通过 `upload.Ingest` 保存;
* 创建不可变 deployment、手动激活、保留历史版本;
* Agent 通过 latest hash/package 接口拉取 active deployment,并原子切换本地 `current`。
现状的主要问题不是缺少下载函数,而是缺少项目级、可持续管理的来源模型:URL 每次都要重新输入,GitHub Release 无版本游标和自动检查,任务与 deployment 也没有可审计的来源快照。同时,现有代码还存在必须在自动化前修复的边界:
* `RootDir` 已用于 Server 校验,但 OpenResty `LocalRoot` 未稳定追加该目录;
* Agent 将整个 package 读入 `[]byte`,解压时关闭实际限制;
* tar family 的部分检查/解压路径会按声明大小物化成员内容;
* `history_count=1` 时,刚上传的未激活 candidate 会被旧 active 挤掉;
* `PolicyDedupNewRecord` 当前复制既有 record metadata,且新 record 持久化失败时可能误删复用的共享 object;
* 一次性 URL client 允许私网与不安全 TLS,不能作为新持久来源的默认网络策略;
* 前端上传 payload、deployment 类型、固定入口文件提示和请求超时与后端契约存在漂移。
### 1.2 功能目标
本方案交付以下能力:
* Pages 项目可保持手动模式,或配置一个持久 Remote URL / GitHub Release 来源;
* Remote URL 可重复手动同步,每次按下载内容 SHA-256 幂等创建或复用 deployment 并激活;
* GitHub 支持 `latest`(默认)与固定 `tag`,asset 名称默认精确匹配 `dist.zip`;
* GitHub `latest` 可按 5~1440 分钟间隔定时检查,默认 60 分钟;自动更新默认关闭;
* 检查发现新版本但自动更新关闭时,只展示更新,不下载;
* 同一 Release 下 asset 被替换时进入 `attention`,必须由管理员确认指定 revision 后才允许同步;
* `RootDir` / `EntryFile` 继续作为项目级内容配置统一作用于本地、Remote 与 GitHub 包,不在 source 中复制一套入口字段;
* 所有来源统一使用现有 Pages 归档检查、上传、deployment、激活、历史裁剪和 Agent 分发链路;
* 自动化执行可互斥、可 fence、可恢复,且失败不会改变旧 active deployment。
### 1.3 来源能力矩阵
| 模式 | source 行 | 触发方式 | 检查更新 | 自动更新 | revision | 成功结果 |
| --- | --- | --- | --- | --- | --- | --- |
| 手动本地上传 | 无 | 管理员上传 | 无 | 无 | 不用于幂等 | 创建 candidate,管理员再激活 |
| 一次性 URL(兼容) | 无 | 旧同步 API | 无 | 无 | 不用于幂等 | 每次创建 candidate,管理员再激活 |
| 持久 Remote URL | 有 | 管理员“同步并发布” | 不提供 | 不提供 | 下载内容 SHA-256 | 创建或复用并强制激活 |
| GitHub Release tag | 有 | 管理员检查/同步 | 仅手动 | 不提供 | Release/asset 元数据哈希 | 创建或复用并强制激活 |
| GitHub Release latest | 有 | 手动或 scanner | 定时 | 可选,默认关闭 | Release/asset 元数据哈希 | 创建或复用并强制激活 |
无 source 行即手动模式。切换或删除 source 不删除 deployment,也不改变当前 active deployment。
### 1.4 默认值
| 配置 | 默认值 | 边界 |
| --- | --- | --- |
| GitHub selector | `latest` | `latest` / `tag` |
| Release asset | `dist.zip` | basename,精确且区分大小写 |
| 自动更新 | `false` | 仅 GitHub latest 可开启 |
| 检查间隔 | 60 分钟 | 5~1440 分钟 |
| scanner cron | `*/5 * * * *` | 固定,无新增系统设置 |
| scanner 单批 | 20 个 source | 按 `next_check_at, source_id` 排序 |
| check lease | 2 分钟 | 到期可恢复 |
| sync lease | 15 分钟 | 长下载期间按需续租 |
| Remote 网络策略 | `public` | `public` / `trusted_internal` |
### 1.5 非目标
本次不实现:
* GitHub 私有仓库、GitHub Token、GitHub App 或其它代码托管平台;
* source archive、`zipball_url` / `tarball_url` 回退;
* asset glob、正则、优先级列表或 semver 自行排序;
* Remote URL 的定时轮询或自动更新;
* 多 source、分支构建、Webhook、CI 构建、预览环境;
* `remote_url` 数据库加密列;V2 先保证最小暴露和全链路脱敏;
* 额外同步历史表、租约表、Provider 分表或全局 GitHub 响应缓存;
* Agent 直接访问 GitHub 或 Remote URL。
上述“仓库构建”属于明确的后续能力,不在 V2 偷跑实现;但 V2 的 Provider、部署来源视图和导入管线必须保留可扩展边界,避免未来只能把 Git clone/build 逻辑塞入 `github_release` 分支或重写 deployment 主链路。
---
## 2. 设计与决策 (Design & Decisions)
### 2.1 核心原则
1. **Server 单一信任边界**:第三方网络访问、digest 校验与归档检查都在 Server 完成。
2. **source 可变,deployment 不可变**:source 表示当前配置;deployment 保存创建时的最小来源快照,不随 source 编辑。
3. **检查不等于部署**:GitHub check 只更新远端游标;只有 sync 才下载、创建并激活。
4. **成功才切换**:网络和归档工作在事务外;active pointer、deployment 与 applied cursor 在最终事务原子提交。
5. **状态面最小化**:source runtime 只保存控制面稳定状态,队列细节和阶段日志复用现有 TaskExecution。
6. **人工操作优先**:人工激活或回滚必须 fence 自动任务,且不能被自动更新静默覆盖。
7. **平台能力复用**:文件摄取继续通过 `upload.Ingest`;普通文件删除使用 `upload.Remove` / `RemoveOwned`,Pages 保留类型在复检业务引用后使用同包的 `RemoveLockedTx`;任务继续使用现有 task/Asynq 框架。
### 2.2 总体架构
```mermaid
flowchart LR
Admin["管理员 / Pages 详情页"] --> SourceAPI["Pages Source API"]
SourceAPI --> ConfigDB[("Source Config")]
SourceAPI --> RuntimeDB[("Source Runtime")]
SourceAPI -->|"手动 check / sync"| ActionTask["Pages Source Action Task"]
Scheduler["Scheduler"] --> ScanTask["Pages Source Scan Task"]
ScanTask -->|"串行检查到期 latest"| GitHubAPI["GitHub Releases API"]
ScanTask --> RuntimeDB
ScanTask -->|"限量 orphan record 补偿"| Upload
ScanTask -->|"发现更新且 auto=true"| ActionTask
ActionTask --> Remote["Remote URL / GitHub Asset"]
ActionTask --> Pipeline["统一导入管线"]
Pipeline --> Inspect["真实展开与入口校验"]
Inspect --> Upload["upload.Ingest"]
Upload --> DeploymentDB[("Pages Deployments")]
DeploymentDB --> Activate["原子激活 + applied cursor"]
Activate --> RuntimeDB
Agent["Agent latest hash/package 对账"] --> DeploymentDB
Agent --> Current["projects/{id}/current"]
Current --> OpenResty["OpenResty 静态服务"]
```
scanner 本身是一个正式 TaskHandler,并非绕过任务框架。它在单次执行中先扫描并精确 CAS 恢复全部过期 lease,再限量补偿最多 100 条 orphan record,最后串行检查最多 20 个到期的 GitHub latest source,避免一次 cron 批量投递并行 GitHub 请求。手动操作和自动下载使用统一 action task;scanner 不执行长时间 package 下载。
### 2.3 领域对象与不变量
| 对象 | 生命周期 | 不变量 |
| --- | --- | --- |
| PagesProject | 可变 | `RootDir` / `EntryFile` 的实质变化递增 `content_config_version` |
| PagesProjectSource | 可变配置 | 每项目最多一条;不保存状态、游标或 lease |
| PagesProjectSourceRuntime | 可变运行态 | 与 source 1:1;状态、游标、lease 只写本表 |
| PagesDeployment | 不可变事实 | 持久来源 revision 幂等;provenance 创建后不回写 |
| PagesDeploymentFile | 不可变清单 | 只属于一个 deployment |
核心不变量:
* source 与 runtime 必须同事务创建、同事务删除;无 source 就无 runtime。
* deployment 的 `source_identity` 与 `source_revision` 必须同时为非空值或同时为 SQL `NULL`。
* 持久来源 sync 成功时,deployment、files、active pointer 和 runtime applied cursor 在同一事务提交。
* source/project 配置变化或人工激活可以使任务过期;过期任务不得改变 active、runtime 或其它 deployment。
* source API 永不返回完整 Remote URL,任务 payload 永不携带 URL。
### 2.4 数据模型
#### 2.4.1 关系总览
```text
of_pages_projects
└── 0..1 of_pages_project_sources
└── 1..1 of_pages_project_source_runtime
of_pages_projects
└── 0..N of_pages_deployments
└── 0..N of_pages_deployment_files
```
不建立物理外键;删除顺序由 Pages service 事务显式保证。
#### 2.4.2 `of_pages_project_sources`:纯配置
| 字段 | 类型 / DB 默认 | 说明 |
| --- | --- | --- |
| `id` | PK | source ID |
| `project_id` | bigint/integer | 项目 ID,唯一索引 |
| `source_type` | varchar(32), `''` | 服务层写 `remote_url` / `github_release` |
| `remote_url` | text, `''` | 仅 Remote;可含 query secret,禁止回显 |
| `remote_network_policy` | varchar(32), `''` | 服务层写 `public` / `trusted_internal` |
| `github_repository` | varchar(255), `''` | 规范化为 `{owner}/{repo}` |
| `release_selector` | varchar(16), `''` | `latest` / `tag` |
| `release_tag` | varchar(255), `''` | tag 模式必填,latest 必须空 |
| `asset_name` | varchar(255), `''` | 服务层默认写 `dist.zip` |
| `auto_update_enabled` | bool, `false` | 仅 GitHub latest 可为 true |
| `check_interval_minutes` | int, `0` | GitHub latest 服务层默认写 60 |
| `config_version` | int, `0` | 创建显式写 1;实质配置变化或人工 fence 时递增 |
| `source_identity` | char(64), `''` | 无凭据的稳定身份 SHA-256 |
| `created_at` / `updated_at` | datetime | 审计时间 |
配置表禁止加入 `sync_status`、`etag`、`last_seen_*`、`last_applied_*` 或 `lease_*`。
#### 2.4.3 `source_identity`
GitHub:
```text
LP(value) = uint64be(byte_length(UTF8(value))) || UTF8(value)
SHA-256(
"openflare:pages:github-release:v2" ||
LP(owner_repo) || LP(selector) || LP(tag) || LP(asset_name)
)
```
GitHub identity 对每个 UTF-8 字段使用无歧义的长度前缀编码,不能使用分隔符直接拼接;自动更新开关和检查间隔不参与 identity。
Remote:
```text
SHA-256("remote_url|" + canonical_scheme_host_port_path)
```
Remote canonical identity 使用小写 scheme/host、移除默认端口并保留规范化 path;明确排除 query、fragment 和 userinfo。下载 URL 仍保存管理员输入的完整值,但 URL userinfo 和 fragment 本身不允许保存。
identity 变化时,同事务重置 runtime 的 ETag、seen/applied cursor、detail、错误、检查时间和 lease;当前 active deployment 不变。仅 query token、自动更新、检查间隔或网络策略变化时 identity 不变,保留 cursor,但仍递增 `config_version`、清 lease 并按现有 cursor 重算稳定状态。
#### 2.4.4 `of_pages_project_source_runtime`:纯运行态
| 字段 | 类型 / DB 默认 | 说明 |
| --- | --- | --- |
| `source_id` | PK | 与 source 1:1 的逻辑关联 |
| `etag` | varchar(512), `''` | GitHub 条件请求 |
| `last_seen_revision` | char(64), `''` | 最近解析到的 revision |
| `last_seen_detail` | text, `''` | 已校验的安全 JSON 对象字符串 |
| `last_applied_revision` | char(64), `''` | 当前 source 视角下已激活 revision |
| `last_applied_detail` | text, `''` | 与 applied revision 配套的安全 JSON |
| `sync_status` | varchar(32), `''` | 创建时服务层显式写 `idle` |
| `last_error` | text, `''` | 脱敏后的最近错误 |
| `last_checked_at` | nullable datetime | GitHub 最近完成检查时间 |
| `last_synced_at` | nullable datetime | 最近成功同步并激活时间 |
| `next_check_at` | nullable datetime | 仅 GitHub latest 非空;普通索引 |
| `lease_expires_at` | nullable datetime | 当前租约截止时间 |
| `lease_token` | varchar(64), `''` | 每次获取租约生成的新 token |
| `updated_at` | datetime | 运行态更新时间 |
runtime 刻意不保存 `project_id`:scanner 本来就必须 join source 读取 `source_type`、selector 和 config version;重复保存 project ID 只会引入漂移和额外索引。scanner 查询以 `next_check_at` 索引定位 runtime,再 join source。
detail 使用跨 PostgreSQL/SQLite 一致的 text,并由 Go typed struct 统一 marshal/unmarshal;比较和幂等只读取 revision 列,禁止解析 JSON 做 CAS。GitHub detail 最小形状为:
```json
{
"provider": "github",
"release_id": "123456",
"asset_id": "789",
"tag": "v1.2.3",
"asset_name": "dist.zip",
"asset_updated_at": "2026-07-18T12:00:00Z",
"digest": "sha256:..."
}
```
Remote detail 只保存无密钥显示信息,例如:
```json
{
"provider": "remote_url",
"display_name": "dist.zip"
}
```
#### 2.4.5 状态机
状态固定为:
```text
idle | checking | update_available | syncing | failed | attention
```
```mermaid
stateDiagram-v2
[*] --> idle
idle --> checking: GitHub check
update_available --> checking: 再次 check
failed --> checking: 重试 check
attention --> checking: 再次 check
checking --> idle: 无更新
checking --> update_available: 有更新且不自动同步
checking --> attention: 同 Release asset 被替换
checking --> failed: 检查失败
idle --> syncing: 手动 sync
update_available --> syncing: 手动或自动 sync
failed --> syncing: 手动重试
attention --> syncing: 确认指定 revision
syncing --> idle: 同步并激活成功
syncing --> failed: 下载/校验/提交失败
syncing --> attention: 替换风险未确认
```
约定:
* `syncing` 覆盖下载、校验、Ingest、创建和激活;详细阶段只写 task 日志。
* `failed` 可以与“已有待更新 revision”同时存在;API 的 `update_available` 始终由 revision 派生,而非由状态字符串判断。
* `attention` 是 GitHub 供应链确认状态,不等同于普通失败。
* `queued` / `succeeded` 属于 `w_task_executions`,不进入 runtime。
派生规则:
```text
update_available =
last_seen_revision != '' AND
last_seen_revision != last_applied_revision
```
#### 2.4.6 `of_pages_projects` 增量
新增:
| 字段 | 类型 / 默认 | 说明 |
| --- | --- | --- |
| `content_config_version` | int, `0` | 仅 `RootDir` / `EntryFile` 实质变化时 +1 |
SPA Fallback、API Proxy、名称、描述、启停等变化不影响归档内容校验,不递增该版本。
#### 2.4.7 `of_pages_deployments` 精简 provenance
| 字段 | 类型 / DB 默认 | 说明 |
| --- | --- | --- |
| `source_type` | varchar(32), `''` | `manual_upload` / `manual_url` / `remote_url` / `github_release` |
| `source_identity` | nullable char(64) | 持久 source 快照;手动/一次性 URL 必须为 SQL `NULL` |
| `source_revision` | nullable char(64) | 持久 source 幂等键;手动/一次性 URL 必须为 SQL `NULL` |
| `source_label` | varchar(255), `''` | tag 或安全文件名,不含 query |
| `source_meta` | text, `''` | 安全 JSON 审计快照,不含 URL/token |
| `trigger_type` | varchar(32), `''` | `manual_upload` / `manual_url` / `manual_sync` / `scheduled_auto_update` |
不再增加独立的 release/asset/digest 宽列;这些只在 `source_meta` 保留审计快照。deployment 列表 API 只返回安全的 `source_type`、`source_label`、`trigger_type`,不直接输出原始 meta JSON。
revision 生成:
```text
github_raw = github:<release_id>:<asset_id>:<asset_updated_at>:<declared_digest>
github_revision = SHA-256(github_raw)
remote_revision = SHA-256(downloaded_package_bytes)
```
GitHub 未提供 digest 时,`declared_digest` 为空;同步仍必须计算 package SHA-256 作为 deployment checksum。若 GitHub 提供 `sha256:` digest,则下载后必须严格校验。
#### 2.4.8 索引与迁移
索引:
```text
UNIQUE of_pages_project_sources(project_id)
INDEX of_pages_project_source_runtime(next_check_at)
UNIQUE of_pages_deployments(project_id, deployment_number)
UNIQUE of_pages_deployments(project_id, source_identity, source_revision)
WHERE source_identity IS NOT NULL AND source_revision IS NOT NULL
```
PostgreSQL 与 SQLite 均创建同语义的部分唯一索引。禁止用空字符串代替 deployment 的 NULL provenance,否则手动重复上传会被误判为同一来源版本。
新增双方言 migration:
1. `202607190002_add_pages_source_runtime.sql`:两张 source 表、project content version、deployment provenance、索引和存量回填。
2. `202607190003_seed_pages_source_scan.sql`:幂等插入 `of_pages_source_scan` 的 5 分钟 schedule。
存量 deployment 只能可靠回填为 `source_type=manual_upload`、`trigger_type=manual_upload`,identity/revision 保持 NULL;现有记录无法反推出是否来自旧一次性 URL。schedule seed 不写死 ID,使用 `WHERE NOT EXISTS (task_type = 'of_pages_source_scan')`,Down 仅按该 task type 删除。
#### 2.4.9 写入矩阵
| 操作 | source config | runtime | deployment |
| --- | --- | --- | --- |
| 创建 source | 新建 | 同事务新建 idle | 不变 |
| 编辑 source | 实质变化时 version +1 | identity 变则 reset,否则保留 cursor、清 lease | 不变 |
| 删除 source | 删除 | 同事务删除 | 全部保留 |
| GitHub check / 304 | 不变 | seen、时间、状态、下次检查 | 不变 |
| source sync | 不变 | syncing → applied/idle | 创建或复用并激活 |
| 人工激活/回滚 | 必要时关闭 auto、version +1 | 清 lease,按目标 provenance 更新 applied | 切 active |
| project 删除 | 删除 | 先删除 | 按现有流程删除 |
### 2.5 任务、租约与并发
#### 2.5.1 任务类型
只新增两个任务:
| Meta Type | Asynq Type | 职责 |
| --- | --- | --- |
| `of_pages_source_scan` | `openflare:pages_source_scan` | 恢复过期 lease/orphan record;串行检查一批到期 GitHub latest source;必要时投递 sync action |
| `of_pages_source_action` | `openflare:pages_source_action` | 执行管理员 check/sync 或 scanner 触发的 sync |
两者都在 `internal/infra/task/handlers/register.go` 显式注册 Handler 与 TaskMeta。现有 `bootstrap.RegisterTasks()` 已覆盖 API、worker、scheduler 和 all 入口,不新增 `init()`,也不修改 `internal/router/router.go`、`internal/platform/bootstrap/bootstrap.go` 或 `internal/cmd` 的装配职责。
两类任务都标记为 `TaskMeta.InternalOnly=true`。通用 Admin Task 类型列表、手工 dispatch 与 schedule 创建/更新必须隐藏或拒绝 internal-only meta;scheduler 与 Pages 内部 dispatch 仍使用完整 registry。这样客户端不能绕过 Pages Handler 自行伪造 `source_id`、`config_version` 或 `actor`。
action payload 只包含:
```json
{
"source_id": 42,
"config_version": 3,
"action": "check",
"actor": "user:1234567890",
"target_revision": "",
"confirmed_revision": ""
}
```
规则:
* `action` 仅为 `check` / `sync`;
* `target_revision` 只由 scanner 在自动 sync 时写入,用于锁定本次 check 发现的 revision;手动 sync 为空;
* `confirmed_revision` 只在确认 `attention` 时携带 UI 当前看到的精确 revision;不用单纯 boolean 确认未知的未来版本;
* payload 不携带 Remote URL、GitHub 下载 URL、ETag、`content_config_version` 或通用 `expected_revision`;`target_revision` 是自动检查结果约束,不参与配置 fencing;
* `actor` 手动操作为 `user:<id>`,自动任务为 `system:pages-source-sync`,禁止空字符串表示系统。
调用 `task.DispatchTask` 时,框架级 `triggeredBy` 继续使用 `manual` / `system`;具体操作者只放在已校验且无密钥的 action payload 中,供 deployment `created_by` 与审计日志使用。
该 payload 是 Server 内部契约:HTTP Handler 只接受 action 所需业务字段,再从路由项目、当前 source 和 OAuth context 组装 `source_id/config_version/actor`,禁止客户端直接指定或冒充这些值。
`content_config_version` 在 sync Worker 获取 lease 后读取并形成执行快照,最终事务再次检查。这样既能阻止旧入口配置被激活,又不把每次项目变更传播进队列 payload。
#### 2.5.2 lease 规则
lease 只解决“同一 source 同时只能有一个执行者”:
* check 获取 2 分钟短 lease,并将状态切为 `checking`;
* sync 获取 15 分钟长 lease,并将状态切为 `syncing`;
* 获取使用 `source_id + config_version + lease 已过期` 的 CAS;
* 续租、状态写入、终态和释放必须同时满足 `lease_token` 匹配且 `lease_expires_at > now`;续租不再 join project/source 版本;
* 最终事务前强制续租一次;最终提交仍必须再次检查 token 与未过期时间,不能让“尚未被新 Worker 改写 token 的过期 lease”通过;
* source 配置变化、RootDir/EntryFile 变化或人工激活统一调用 `fenceAndNormalizeRuntime`:清 token/expiry;若当前 seen/applied 仍构成同 Release 替换则为 `attention`,否则 seen≠applied 为 `update_available`,其余为 `idle`;source 删除则同事务直接删除 runtime/source,行不存在即 fence;
* 未拿到 lease 的重复任务写一条 no-op task 日志并成功结束,不制造 runtime 错误。
最终提交的锁顺序固定为:
```text
project -> source(存在时) -> runtime(存在时) -> upload(所有相关 ID 升序)
```
提交前只校验:
```text
source.config_version == captured_source_version
project.content_config_version == captured_content_version
runtime.lease_token == worker_token
runtime.lease_expires_at > transaction_now
target_upload.status == used
```
source/runtime 条件只适用于持久 source;本地上传和一次性 URL 仍必须先锁 project、最后锁目标 upload。create-or-load 选中的既有 deployment 与本次新建但最终未使用的 upload 不同时,两个 upload ID 在最后一层按升序加锁,避免多行反序。上述任一条件不满足,任务按“配置、执行权或上传记录已变化”结束,不覆盖新 runtime 状态;若本次创建了 upload record,则进入补偿。revision 幂等由 deployment 部分唯一索引负责,不再增加第四套通用 revision fence。
#### 2.5.3 scanner 流程
每 5 分钟执行:
1. 扫描所有 runtime 中 lease 已过期且状态为 `checking/syncing` 的行;恢复 UPDATE 必须再次 CAS 原 token 且 `lease_expires_at <= now`,避免覆盖刚续租的 Worker。成功后清 lease、状态设为 `failed`,记录“上次任务租约已过期”,GitHub latest 的 `next_check_at` 调整为近期重试。
2. 执行 2.7.2 的限量 orphan record reconciliation;单条失败只告警并保留候选,不中断 source 检查。
3. join source 查询 `github_release + latest + next_check_at <= now`,按 `next_check_at, source_id` 排序,最多取 20 条。
4. 对每条 source 尝试获取短 lease;失败说明另一个 scanner/action 已处理,直接跳过。
5. 在当前 scanner TaskHandler 内串行调用 GitHub check,共享同一 check service;单个 source 失败只落该 runtime,不中断其它 source。
6. `304` 仍更新 `last_checked_at/next_check_at`,并根据已保存的 seen/applied revision 重新判断是否待同步。
7. 发现更新后无论 auto 开关,都先原子写 seen cursor、将状态落为 `update_available` 并释放短 lease;auto 开启时再投递带本次 `target_revision` 的 `action=sync`,sync Worker 获取长 lease 后才切为 `syncing`。
8. sync 入队失败:保持 `update_available`,记录安全错误,并把 `next_check_at` 调整为短退避,后续 scanner 可再次尝试。
9. 下次检查时间使用 interval 加 source-ID 派生的小幅 jitter,避免整点集中请求。
scanner 直接串行 check 而不是先批量投递 check action,目的是减少 GitHub 并发和一层“派发预占”状态。重叠的 scanner 实例仍通过每个 source 的 lease 互斥;不增加全局 scanner 锁。
#### 2.5.4 action 流程
手动 check:
1. Handler 校验 source 为 GitHub;Remote 直接返回稳定 400,不入队。
2. action Worker 校验 payload `config_version`,获取短 lease。
3. 解析 Release/asset,更新 seen、ETag、检查时间与状态后释放 lease。
4. 手动 check 永远不隐式下载;即使 auto 已开启,也只由 scanner 检查路径触发自动 sync,避免“点击检查”产生意外发布。
手动或自动 sync:
1. 校验 source/config version,获取长 lease并读取 project content version。
2. GitHub 在 lease 内重新解析目标 Release/asset;Remote 直接下载。这样刚保存 source 时无需等待一次 check 才能同步。
3. scanner 自动 sync 若携带 `target_revision`,本次新解析 target 必须与其相等;不相等说明 latest 在 check 与执行间变化,任务将新 target 安全写为 seen,按本次 target 归一为 `attention/update_available`,释放 lease 并把 `next_check_at` 提前,禁止直接部署未经原 check 锁定的新 revision。
4. 非空 `confirmed_revision` 必须先与本次 target 完全相等,否则要求刷新后重试;再以本次 target 与 applied detail 判断同 Release 替换,构成替换且未确认当前 target 时写 `attention` 并停止。
5. 流式下载、digest/checksum 校验、归档检查与 Ingest。
6. 进入最终事务完成 create-or-load、激活与 applied cursor;提交后严格裁剪历史。
永久业务错误(非法配置、asset 不存在、未确认 attention)通过 task 框架的 `PermanentError` 包装为 `asynq.SkipRetry`,不进行 Asynq 快速重试;瞬时网络/存储错误按 TaskMeta 的有限次数退避重试。包装后的 `Error()` 只暴露脱敏 domain message。重复任务、旧 config version 和丢失 lease 作为成功 no-op 结束,避免无意义重试。Provider/Action Handler 在把 error 返回 task executor 前必须转换为不含 URL/query/header/body 的安全 domain error;原始错误也只能经统一 URL 脱敏后写内部日志,防止 TaskExecution `error_message/log/result` 持久化密钥。
### 2.6 Provider 设计
#### 2.6.0 Provider 扩展边界与未来仓库构建
V2 Provider 只负责把某个外部来源解析为一个经过约束的不可变归档候选,不负责直接写 deployment、切 active 或操作 Agent。Pages service 继续统一承担归档检查、`upload.Ingest`、deployment create-or-load、激活、历史裁剪与补偿。当前 Remote URL 与 GitHub Release 都实现这一窄边界。
为后续“从仓库拉代码自动构建”预留以下设计约束,但本期不增加数据库列、API 或空实现:
* 后续新增独立 `git_repository` source/provider,禁止复用或扩展 `github_release` 语义;Release asset 是预构建产物来源,repository source 是源码与构建来源,两者凭据、revision、失败阶段和 UI 配置完全不同。
* repository provider 的输出仍必须是临时目录中的受限归档/构建产物描述,再进入现有统一导入管线;build checkout、依赖安装、命令执行和日志隔离属于未来独立 build executor,不进入 Agent,也不绕过 `upload.Ingest`。
* source view 与前端表单继续使用 discriminated union;未来可以新增 repository variant,而无需给 Remote/GitHub Release 视图加入无关的 branch、build command、output directory 或 environment 字段。
* deployment provenance 保留 `source_type/source_identity/source_revision/source_label/source_meta/trigger_type` 的通用事实边界;未来 repository revision 可使用 commit SHA,安全 `source_meta` 可保存 branch/build 输出摘要,但不得保存凭据或完整环境变量。
* TaskExecution 继续承载阶段日志。未来构建可增加 resolve/checkout/build/package 阶段,但 source runtime 不因此扩展为构建步骤状态机。
该边界参考 Cloudflare Pages 当前将 [Git integration](https://developers.cloudflare.com/pages/configuration/git-integration/) 与 [Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/) 分成不同来源体验、同时把生产部署与历史部署统一呈现的产品结构;OpenFlare 保留自己的“来源可切换且历史部署不删除”决策,不照搬 Cloudflare 创建后不可切换来源的限制。
#### 2.6.1 持久 Remote URL
Remote 来源只提供“同步并发布”,不提供 check、定时检查或自动更新。每次同步:
1. 按 source 保存的 network policy 构建下载 client;
2. 流式写入 Server 临时文件,同时计算 SHA-256 和实际压缩包大小;
3. 以内容 SHA-256 生成 revision;若同 identity/revision deployment 已存在,跳过 Ingest,直接进入安全激活;
4. 新 revision 使用统一归档/上传/激活管线;
5. 成功后 seen 与 applied 同时更新为该 revision,状态回到 `idle`。
归档格式优先使用配置 URL path 的安全 basename;名称缺失或无可识别扩展名时,使用 `pagesarchive.DetectFormat` 对临时文件至少前 512 字节做 magic sniff,覆盖 tar 在偏移位置的签名,不能沿用当前仅 16 字节的探测。redirect 最终 URL 和 `Content-Disposition` 不进入 provenance,避免签名地址或不可信文件名泄漏。
Remote URL 的 query 可用于签名 token。API 返回:
* `has_remote_url=true`;
* `display_url=https://example.com/dist.zip?***`;
* 永不返回原始 URL。
编辑时使用显式 `remote_url_set`:
* 新建 Remote、从 GitHub 切换到 Remote:必须为 `true` 且 URL 非空;
* 编辑现有 Remote 但只改 network policy:必须为 `false`,同时省略 `remote_url`;
* 替换地址:为 `true` 并提交新 URL;
* `false` 却携带 URL,或 `true` 但 URL 为空,均返回 400;
* 前端绝不能把 `display_url` 当作可保存值。
#### 2.6.2 GitHub Releases
仓库地址只接受:
```text
https://github.com/{owner}/{repo}
https://github.com/{owner}/{repo}.git
```
保存时规范化为 `{owner}/{repo}`;拒绝非 `https`、非 `github.com`、userinfo、query、fragment、额外 path 及空 owner/repo。V2 只访问公开仓库。
Release 解析:
* latest:`GET /repos/{owner}/{repo}/releases/latest`;采用 GitHub 的 latest 语义,不拉列表、不自行比较 semver;
* tag:`GET /repos/{owner}/{repo}/releases/tags/{url.PathEscape(tag)}`;固定 tag 不进入 scanner;
* asset:只接受 `state=uploaded` 且 `name == asset_name` 的精确、区分大小写匹配;
* asset 不存在时,安全错误最多列出该 Release 前 10 个 asset 名,单项与总错误长度均截断;
* 不回退到源码 archive。
API client 使用新的窄包 `internal/integration/githubrelease`,集中 Release/asset HTTP 契约、redirect、ETag 与限流解析;Pages 模块只负责 source 规则、revision 和状态映射。当前 node/edge/admin updater 的旧实现不在本功能中强制迁移,但后续新增调用方必须复用该包,避免继续增加 feature-local GitHub client。
* 发送 `Accept: application/vnd.github+json`、固定 `User-Agent`;实现基线固定 `X-GitHub-Api-Version: 2026-03-10`,收敛为一个常量;
* 保存 ETag 并发送 `If-None-Match`;
* 处理 `Retry-After`、`X-RateLimit-Remaining`、`X-RateLimit-Reset`,按服务端指示设置 `next_check_at`,禁止紧循环;
* asset 下载使用 `/repos/{owner}/{repo}/releases/assets/{asset_id}` 与 `Accept: application/octet-stream`,兼容 `200` 内容和 `302` 跳转;
* GitHub 始终使用严格 TLS;asset redirect 仅允许 HTTPS、最多 5 次,每跳解析并校验公网 IP;跨 host 删除 `Authorization`、`Cookie`、`Referer` 和条件请求 header;
* 元数据与下载错误只保留 status、request id、repo、tag、asset 等安全上下文。
参考官方文档:
* [GitHub Releases REST API](https://docs.github.com/en/rest/releases/releases)
* [GitHub Release Assets REST API](https://docs.github.com/en/rest/releases/assets)
* [GitHub REST API 最佳实践](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api)
* [GitHub REST API Rate Limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api)
未认证公共请求存在严格额度,V2 通过 ETag、串行 scanner、jitter 与服务端退避降低消耗,不承诺大规模仓库轮询。多项目共享仓库缓存留到出现真实规模瓶颈后再设计。
#### 2.6.3 `attention` 与 digest 失败边界
* 每次 check/sync 都以本次新解析的 target 判断:`target.release_id == applied.release_id` 且 revision 变化时进入 `attention`;禁止用过期的 runtime seen 代替本次 target;
* 管理员同步时必须提交与当前 seen 完全相等的 `confirmed_revision`;状态变化后旧确认自动失效;
* declared digest 与实际 package checksum 不一致:`failed`,不能用 attention 确认绕过;
* 同一 revision 重复点击由部分唯一索引和 lease 双重保证只产生一条 deployment;
* 后续 latest 已推进到不同 release ID 时,不再满足同 Release 替换条件,应转为普通 `update_available` 并按 auto 策略继续;attention 不设计成永久 hold。
### 2.7 统一导入、激活与回滚
#### 2.7.1 source sync 原子提交
source sync 不创建长期 candidate,固定执行以下顺序:
1. 获取 source lease,快照 source config version、project content version、`RootDir`、`EntryFile`。
2. 事务外解析并流式下载到临时文件,计算 checksum;临时文件在所有退出路径删除。
3. 使用快照的 `RootDir + EntryFile` 做真实展开限制、路径与入口校验,得到 manifest。
4. 先查询相同 project/source identity/revision 的 deployment;存在则不调用 Ingest。
5. 不存在时调用 `upload.Ingest`,使用现有 Pages upload type 与 `PolicyDedupNewRecord`;upload metadata 的 `Extra` 写入固定 marker 版本、十进制字符串形式的 `pages_project_id` 及可选 `pages_source_id`,供孤儿补偿判断,绝不写 URL 或 token。平台需先修正 dedup 新记录语义:新 record 采用本次请求的业务 metadata,仅从既有 object 继承存储归属 `Bucket`,不能继续复制既有 record 的业务 `Extra`;dedup record 写库失败时也绝不能删除并非本次 Ingest 创建的共享 object。
6. 最终事务先按 `project -> source -> runtime` 加锁并校验双 version、lease token/expiry;project 锁同时串行化本项目所有 V2 deployment 创建、激活、裁剪与 orphan 判定。
7. create-or-load 必须使用 GORM `clause.OnConflict{DoNothing: true}`(或等价 `INSERT ... ON CONFLICT DO NOTHING`),再按 `(project_id, source_identity, source_revision)` 查询 winner,禁止依赖普通唯一冲突后继续查询已 aborted 的 PostgreSQL 事务。若冲突仅来自 deployment number 且 revision winner 不存在,则在 project 锁内重新分配编号并有限重试。
8. 确定目标 deployment 后,将目标 upload 与本次 Ingest upload(若不同)按 ID 升序锁定;目标 upload 必须仍为 `used`。唯一竞争产生的多余 upload 只记录为事务后的补偿目标,禁止在 Pages 事务内调用另起事务的 `upload.Remove`。
9. 取消旧 active、激活目标 deployment、更新 project active pointer,并更新 runtime applied/seen/status/时间。
10. 提交后立即补偿未被采用的 upload,再执行严格历史裁剪;事务回滚则补偿本次 Ingest upload。裁剪失败不回滚已成功激活,但必须告警并由下一次裁剪自愈。
任何最终事务前的失败都保持旧 active。`created_by` / `trigger_type` 约定:
| 触发 | `created_by` | `trigger_type` |
| --- | --- | --- |
| 本地上传 | `user:<id>` | `manual_upload` |
| 一次性 URL | `user:<id>` | `manual_url` |
| 持久来源手动 sync | `user:<id>` | `manual_sync` |
| scanner 自动更新 | `system:pages-source-sync` | `scheduled_auto_update` |
#### 2.7.2 Ingest 补偿与延迟记录恢复
`upload.Remove` 当前只会软删除 upload record、调整统计并失效缓存,不会删除底层 object;因此实现与验收不得宣称 defer 调用后物理文件已回收。
新创建的 Pages upload record 统一使用以下无密钥 marker;`pages_source_id` 只在持久 source sync 时存在,手动上传与一次性 URL 省略该键:
```json
{
"pages_ingest_marker": "pages_deployment_v2",
"pages_project_id": "123",
"pages_source_id": "456"
}
```
ID 使用十进制字符串,cleanup 必须严格解析并校验关联归属;marker 不作为权限凭证,只作为“允许进入 Pages 孤儿判定”的一个条件。`project_slug`、归档格式等可由正式模型/Upload 列获得且当前无读取方,不再复制进新 record 的 `Extra`。
`openflare_pages_deployment` 由 upload 平台集中定义并导出为保留 type,Pages 与通用 Handler 复用同一常量:通用 `POST /api/v1/upload` 必须拒绝客户端提交该值,通用管理员/用户删除入口及 `upload.Remove` / `RemoveOwned` 也必须拒绝删除该类型;cleanup 候选还必须满足 `user_id == repository.GetSystemUser(ctx).ID`。marker、保留 type、system owner 三项缺一不可,避免普通用户伪造 metadata 后被后台任务误删。
V2 采用两层处理:
1. 立即补偿:只要 Ingest 创建了新 upload record 而最终事务未引用它,就调用 Pages 内部 `removePagesUploadIfUnreferenced`;该函数锁 project(存在时)与 upload、再次确认没有任何 deployment 引用,再调用 `upload.RemoveLockedTx`。补偿错误必须写可告警日志,不能 `_ =` 静默忽略。
2. 延迟记录补偿:Pages scanner 每轮最多选择 100 条超过 2 小时、状态仍为 `used`、system owner、type 为 `openflare_pages_deployment`、无 deployment 引用且带 V2 Pages marker 的 upload。这覆盖“立即补偿调用本身失败”的恢复路径;PostgreSQL 使用 JSONB 路径、SQLite 使用 `json_extract` 将 marker 纳入 SQL 候选条件,避免存量合法记录长期占满批次。任一条件不满足的记录一律跳过,禁止仅凭 type/时间推断孤儿。
`upload.Remove`、`RemoveOwned` 与 Pages 内部删除路径必须共用同一幂等删除原语:事务内锁定包含 deleted 状态的 record,再由 `RemoveLockedTx` 以 `id + status IN (pending, used)` 做 CAS;只有 `RowsAffected == 1` 才递减统计,已 deleted 视为成功 no-op。事务成功后无论本次是否发生状态迁移都失效该 record 的 metadata cache,以便顺带修复前次提交后 cache invalidation 中断;这样立即补偿、延迟补偿、历史裁剪和管理员删除并发时不会重复扣减统计。`Remove` / `RemoveOwned` 在锁内发现保留 type 时返回稳定 domain error,不得调用 `RemoveLockedTx`。
cleanup 最终 recheck 与软删除必须在同一数据库临界区完成:
1. 事务外读取 candidate 快照,严格解析 system owner、marker、`pages_project_id/pages_source_id`;格式错误直接跳过并告警;
2. 事务内统一按 `project -> source(存在时) -> runtime(存在时) -> upload` 加锁;项目或 marker 指向的 source 已不存在属于合法 orphan 场景,应继续检查;只有 source ID 仍存在但其 `project_id` 与 marker 不同才跳过并告警。禁止先锁 upload 再反向读取 runtime;
3. source 存在时若 runtime 有未过期 lease,回滚并跳过;随后锁 upload,再次确认 ID、marker、归属、`used` 状态和 2 小时阈值;
4. 在持有 project/upload 锁的情况下确认不存在任何 deployment 引用该 upload;所有 V2 deployment 创建路径也必须遵循同一锁顺序,避免检查后又插入引用;
5. 通过 upload 平台提供的事务内幂等 `RemoveLockedTx` 完成软删除与统计更新,提交后统一失效 upload metadata cache;业务模块不得直接调用 repository 或改 `w_uploads`;
6. Pages 最终提交若后获得 upload 行锁,必须因 status 已 deleted 而终止;若 deployment 提交先完成,cleanup 在引用检查时跳过。两者竞争时只能有一方成功,绝不允许 deployment 指向 deleted upload。
V2 不物理删除 object,也不硬删除 upload record:`PolicyDedupNewRecord` 可能共享 `file_path`,当前平台没有能与并发 dedup 创建原子协调的引用锁/引用计数,先检查再删除仍有竞态。软删除后的 object 和记录保持可识别,待 upload 平台提供安全的统一 blob GC 后回收;Pages 业务包不得直接调用 storage backend。清理失败保留 active 候选供下次重试,并输出数量与错误上下文。
同一安全边界也适用于现有 `system:cleanup`:阶段 0 将 pending upload 清理收敛为 `RemoveLockedTx` 的记录级软删除、统计与 cache 失效,停止直接 `backend.Delete(file_path)`。仅增加“是否还有 active record”检查仍无法闭合“检查后并发 dedup 新 record”的竞态,不能作为物理删除依据;所有 upload blob 的物理回收统一留给未来具备引用协调能力的平台 GC。
#### 2.7.3 人工激活/回滚硬约束
通过现有 activation API 人工激活不同于当前 active ID 的 deployment 时:
1. 按全局顺序锁 project、当前 source(如有)、runtime 与目标 deployment 的 upload;目标 upload 非 `used` 时拒绝激活;
2. 若存在 source,始终 `config_version + 1` 并清 lease,fence 已排队和正在执行的 source task;
3. 若 `auto_update_enabled=true`,同事务强制改为 false;
4. 目标 deployment identity 等于当前 source identity 时,将 runtime applied 更新为目标 revision/detail;否则清空 applied;若归一后的 seen/applied 仍构成同 Release 替换则保持 `attention`,否则状态为 `idle` 或 `update_available`;
5. 切换 active deployment 后提交;
6. 输出结构化审计日志:actor、project、旧/新 deployment、是否关闭 auto、目标 source type/identity(不含 URL)。
重复激活当前 active 视为 no-op,不关闭自动更新。该规则刻意比“只在 identity 不同才关闭”更严格:即使回滚到同一 GitHub source 的旧 revision,下一轮 latest 也可能覆盖人工选择。
前端确认框必须明确提示:“激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。”成功后同时刷新 project、source 与 deployment queries。
#### 2.7.4 `history_count=1`
手动上传仍保留“先上传 candidate、再人工激活”的现有交互,但裁剪增加 `preserveCandidateID`:
* 上传完成后保留当前 active 与本次新 candidate;即使 history limit 为 1,也允许临时最多 2 条;
* 再次上传时只保护 active 与最新 candidate,旧 candidate 可被裁剪;
* candidate 激活后执行 strict prune,不再传 preserve ID,恢复总数 `<= history_count`;
* source sync 在同一事务内创建并激活,提交后直接 strict prune;
* prune 删除 deployment/files 后,artifact record 也统一交给 `removePagesUploadIfUnreferenced`;通用文件管理永远不直接删除 Pages 保留类型;
* `history_count<=0` 继续表示不限制。
手动 candidate 创建与 source 最终提交都先锁 project 再分配 deployment number,并由 `(project_id, deployment_number)` 唯一索引兜底。这是一项明确的产品例外,不新增 candidate 状态或额外保留配置。
preserve/strict prune 每次都在事务内先锁 project,再重新读取 active 与候选集合后决定删除项;禁止沿用事务外快照做删除判断。deployment/files 提交后,待删除 artifact 再交给无引用复检路径软删除。
### 2.8 安全与数据面前置修复
#### 2.8.1 `RootDir` / `EntryFile`
统一使用一个严格的逻辑路径规范化函数:
* `RootDir` 允许空字符串表示归档根目录;非空 RootDir 与 EntryFile 只接受 UTF-8 相对 POSIX 路径,空 EntryFile 由服务层归一为 `index.html`;
* 拒绝绝对路径、`.` / `..` segment、反斜线、Windows drive、NUL/控制字符、引号、分号及超长值;
* 逻辑归档路径用 `path` 处理,不用平台相关 `filepath`;落盘路径仍用 `filepath` 并再次执行目录逃逸检查;
* project 已有 active deployment 时,更新 RootDir/EntryFile 前用现有 deployment file manifest 验证新入口存在;失败保持原配置;
* 阶段 0 先完成严格校验、manifest 验证与 LocalRoot 一致性;阶段 1 随 source DDL 增加 `content_config_version` 后,实质变化再递增该版本并清当前 source lease;
* snapshot 与 rebind 构建 `LocalRoot` 时安全追加规范化 RootDir,确保 Server 检查路径与 OpenResty 实际服务路径一致。
归档继续保留现有 common-root 语义:若所有文件共享唯一首层目录,检查与解压都会先剥离该目录,随后再解释 `RootDir`。阶段 0 以测试固化该规则,未来仓库构建产物也必须输出符合相同 artifact contract 的目录结构。
#### 2.8.2 Remote SSRF 与 TLS
`public` 策略:
* 仅允许 `http` / `https`,最多 5 次 redirect,不使用环境代理;
* 每次连接前解析 host,拒绝 loopback、private、link-local、multicast、unspecified 及其它非公网地址;
* 自定义 `DialContext` 直接连接已校验 IP,不能在校验后再次按 host 解析,防止 DNS rebinding;
* 每次 redirect 重新执行 scheme、host 与 IP 检查;
* HTTPS 严格证书验证,禁止 `InsecureSkipVerify`;
* 设置连接、响应头、整体下载超时,并以实际流量强制 package size 上限。
专用 client 通过 `pkg/httppool` 新增的可配置 transport factory 复用连接池参数与 OTel instrumentation,同时显式注入 no-proxy、受控 DialContext 和 TLS policy;不能直接使用当前会读取环境代理的 `DefaultTransport()`。
`trusted_internal` 策略是管理员显式选择的信任边界:允许私网目标与自签 TLS,但仍执行 http(s)、redirect、超时、真实大小和归档限制;UI 必须展示醒目风险提示。新 source 默认永远是 `public`。
旧 `upload-from-url` 为兼容现有行为,内部映射到共享 downloader 的 trusted-internal 兼容策略,不再保留第二套 HTTP client;新 UI 不再暴露该入口。
#### 2.8.3 归档真实限制
Server 与 Agent 都必须按实际读取字节执行:
* 压缩包字节数、单文件展开字节数、总展开字节数、文件数;
* Content-Length/asset size 只用于提前拒绝,不能代替流式上限;
* 拒绝 Zip-Slip、绝对路径、Windows drive、symlink、hardlink、device/特殊条目;
* tar/tar.gz/tar.xz/tar.bz2 检查与解压不得把所有成员 body 物化到内存;
* zip/7z 声明大小必须在实际复制时再次验证;
* Server manifest 中的 `file_count/total_size` 来自实际检查结果。
继续复用现有 Pages 设置:压缩包默认 100 MiB、硬上限 2048 MiB、文件数 1000、展开总量按现有规则计算;不新增一组 source 专用大小设置。
#### 2.8.4 Agent 流式下载与本地硬上限
latest hash 响应扩展为:
```json
{
"project_id": 1,
"deployment_id": 2,
"hash": "sha256-hex",
"package_size": 1048576,
"file_count": 128,
"total_size": 8388608
}
```
Agent:
1. 先读取 metadata,并拒绝超过 Agent 编译期绝对上限的值;绝对上限不得被 Server 响应放大。
2. 将 package response 流式写入 release 临时文件,使用 `io.LimitedReader` 约束实际压缩字节,并在写入同时计算 SHA-256。
3. 再次读取 latest metadata;hash/deployment 发生变化时删除临时文件并按现有有限次数重试。
4. 使用 `pagesarchive.ExtractFile` 的流式实现解压到 `.tmp`,开启文件数、单文件和总量限制;Server metadata 只作为更小的预期上限,仍受本地绝对 cap 约束。
5. 完整校验、写 marker 后才原子切换 current;失败保留旧 current。
编译期绝对上限固定为压缩包 2 GiB、文件数 1000、单文件 8 GiB、总展开 8 GiB,与 Server 当前硬边界一致;Server metadata 只能收紧这些值。`file_count>0 && total_size=0` 是全部零字节文件的合法情况,不能被 limits 的默认值逻辑放大。
Agent 继续只访问 Server,不解析 source provenance,也不访问第三方 URL。
### 2.9 API 与鉴权
沿用当前 Pages/admin action-style 路由;所有接口使用 `apiutil.AdminMiddlewares()`,成功 HTTP 200,错误通过 `response.Abort*` 交给全局 ErrorHandler。
| 方法 | 路由 | 语义 |
| --- | --- | --- |
| GET | `/api/v1/d/pages/:id/source` | 返回 discriminated source view;无 source 返回 manual |
| POST | `/api/v1/d/pages/:id/source/update` | 创建或更新 source |
| POST | `/api/v1/d/pages/:id/source/delete` | 幂等切回 manual;deployment/active 保留 |
| POST | `/api/v1/d/pages/:id/source/check` | GitHub 手动检查;Remote 返回稳定 400 |
| POST | `/api/v1/d/pages/:id/source/sync` | Remote/GitHub 同步并强制激活 |
#### 2.9.1 Source update payload
Remote:
```json
{
"source_type": "remote_url",
"remote_url_set": true,
"remote_url": "https://artifacts.example.com/dist.zip?token=secret",
"remote_network_policy": "public"
}
```
GitHub latest:
```json
{
"source_type": "github_release",
"repository_url": "https://github.com/owner/repo",
"release_selector": "latest",
"asset_name": "dist.zip",
"auto_update_enabled": false,
"check_interval_minutes": 60
}
```
GitHub tag:
```json
{
"source_type": "github_release",
"repository_url": "https://github.com/owner/repo",
"release_selector": "tag",
"release_tag": "v1.2.3",
"asset_name": "dist.zip"
}
```
使用 discriminated validation:Remote 不接受 GitHub/auto 字段;tag 不接受开启 auto 或非零 interval;latest 必须没有 tag;模式外已知字段非零即 400。数据库产品默认由 service 归一并显式写入,不依赖 GORM/DB 默认推断。
source type 切换时必须在同一事务清空另一 Provider 的全部列:Remote → GitHub 清除完整 `remote_url/network_policy`,GitHub → Remote 清除 repository/selector/tag/asset/auto/interval。禁止只改 `source_type` 而让 query token 或失效配置继续滞留数据库。
GitHub source 新建或实质更新成功后,在数据库事务提交后异步投递首次 check;无实质变化不重复投递。队列入队不是数据库事务的一部分,因此入队失败时 source 仍保存成功:响应中的 `check_task=null`、`warning` 给出可重试提示,同时 runtime 标为 failed;用户可点击检查,latest scanner 也会在近期重试。
创建或更新 latest source 时先将 `next_check_at` 设为 `now + interval + jitter`;tag 始终为 NULL。首次 check 入队失败时将 latest 的 `next_check_at` 提前到下一轮 scanner,成功 check 则按 interval 重算。首次 check 本身只负责发现版本,不因保存动作隐式发布;若管理员同时开启 auto,后续 scanner 或显式 sync 再执行发布。
update 响应:
```json
{
"error_msg": "",
"data": {
"source": {},
"check_task": {
"task_id": "manual_of_pages_source_action_...",
"execution_id": "1234567890",
"action": "check"
},
"warning": ""
}
}
```
#### 2.9.2 Source view
manual:
```json
{
"source_type": "manual"
}
```
Remote view 只返回 Remote 有效字段:
```json
{
"source_type": "remote_url",
"has_remote_url": true,
"display_url": "https://artifacts.example.com/dist.zip?***",
"remote_network_policy": "public",
"sync_status": "idle",
"last_applied": {
"revision": "sha256-hex",
"label": "dist.zip"
},
"last_synced_at": "2026-07-19T10:00:00Z",
"last_error": ""
}
```
GitHub view:
```json
{
"source_type": "github_release",
"github_repository": "owner/repo",
"release_selector": "latest",
"release_tag": "",
"asset_name": "dist.zip",
"auto_update_enabled": false,
"check_interval_minutes": 60,
"sync_status": "update_available",
"update_available": true,
"last_seen": {
"revision": "revision-hex",
"label": "v1.2.3",
"asset_name": "dist.zip"
},
"last_applied": {
"revision": "revision-hex",
"label": "v1.2.2",
"asset_name": "dist.zip"
},
"last_checked_at": "2026-07-19T10:00:00Z",
"last_synced_at": "2026-07-18T10:00:00Z",
"next_check_at": "2026-07-19T11:00:00Z",
"last_error": ""
}
```
API 不返回 config/content version、lease、ETag、raw detail JSON、GitHub asset URL 或完整 Remote URL。detail 先反序列化为内部 typed struct,再映射为上述安全 view。
#### 2.9.3 Action request/receipt
check 无请求体。普通 sync 的规范请求体为 `{}`;Handler 同时把空 body 的 `io.EOF` 视为默认空请求,避免 `BaseService.post(..., undefined)` 稳定返回 400。只在 attention 确认时提交:
```json
{
"confirmed_revision": "revision-hex-currently-shown"
}
```
action 成功入队返回:
```json
{
"task_id": "manual_of_pages_source_action_...",
"execution_id": "1234567890",
"action": "sync"
}
```
Handler 在 `task.DispatchTask` 返回后,按 task ID 读取已先创建的 TaskExecution,并返回 numeric execution ID 的字符串形式。前端复用现有 task execution detail API 轮询 `pending/running/succeeded/failed`,source runtime 不增加 queued 状态。
典型错误:
| 条件 | HTTP | 文案语义 |
| --- | --- | --- |
| payload/模式字段非法 | 400 | 指出当前来源允许的配置 |
| Remote 调用 check | 400 | `远程地址来源不支持检查更新,请使用立即同步` |
| attention 未确认或确认已过期 | 400 | 要求刷新并确认当前 revision |
| 项目/source 不存在 | 404 | 安全的资源不存在提示 |
| source 有有效 lease | 409 | 来源任务正在执行 |
| 入队/数据库内部失败 | 500 | 通用安全提示,底层错误写日志 |
上述 attention/lease 检查是 Handler 的 best-effort preflight;preflight 与 Worker 获取 lease 之间仍可能发生竞态。竞态中的权威结果由 Worker 的 target revision、lease 与最终事务校验决定,并通过脱敏的 TaskExecution 成功 no-op 或失败结果反馈,API 不承诺把所有异步竞态同步映射成 400/409。
#### 2.9.4 旧一次性 URL
`POST /api/v1/d/pages/:id/deployments/upload-from-url` 在 V2 保留:
* Swagger description 标记 Deprecated;
* 不创建 source,不写 identity/revision,每次仍创建新的 manual URL candidate;
* 内部复用新的流式 downloader、归档校验和 candidate 裁剪规则;
* 为保持兼容,映射到 trusted-internal 网络策略;
* 新前端移除入口,最早在下一个 major version 才考虑删除。
### 2.10 前端方案
#### 2.10.1 页面结构
当前 `detail/page.tsx` 仅转发 `page-client.tsx`,且 `page-client.tsx` 已接近复杂度阈值。V2 将路由骨架、标题、外层布局与 Suspense 直接移回物理入口 `page.tsx`,再拆出高状态密度组件;禁止继续保留纯转发页面:
```text
detail/page.tsx
├── pages-source-card.tsx
├── pages-source-dialog.tsx
├── deployment-history.tsx
└── deployment-files-panel.tsx
```
六个 source status 的 badge/文案映射直接放在 `pages-source-card.tsx`,不再创建薄的 `pages-source-status.tsx`。现有 `page-client.tsx` 的剩余 query/交互逻辑在拆分后移入对应业务组件,不再作为同名页面容器保留。
信息层级参考 Cloudflare Pages 当前项目页,但使用 OpenFlare 现有设计系统实现,不复制品牌视觉:
1. 顶部项目摘要优先显示当前生产部署、入口路径与关键动作;
2. “部署源”卡片单独表达当前 source、远端游标与同步动作,来源设置不与 deployment 行内操作混杂;
3. “部署历史”展示不可变部署事实与来源快照,当前 active 置顶突出,历史回滚保持显式确认;
4. source dialog 以 manual / Remote URL / GitHub Release 的分步选择呈现;未来新增 repository source 时只增加新的 discriminated step,不改写现有三类表单字段。
#### 2.10.2 能力分离
Remote 卡片只显示:
* 脱敏 URL、network policy、最近同步、已应用 revision、最近错误;
* “编辑来源”“同步并发布”“切换回手动”;
* 不显示检查、自动更新、检查间隔或 next check。
编辑 Remote 默认 `remote_url_set=false` 并展示只读 masked URL;用户点击“更换地址”后才出现空输入框。`trusted_internal` 需要二次风险提示。
GitHub latest 卡片显示检查、同步、自动更新、间隔、远端/已应用版本和 next check。GitHub tag 显示手动检查/同步,隐藏自动更新与周期字段。`attention` 使用 Alert + 确认弹窗,提交卡片当前 revision。
source 历史信息与 deployment 历史分工:
* source 卡片显示当前远端状态;
* deployment 行只显示创建时快照,例如 `GitHub · v1.2.3 · 定时更新`;
* 历史区域明确标注“部署时来源快照”,不重复展示远端最新状态。
#### 2.10.3 上传与契约修复
* `DeploymentUploadDialog` 移除 URL tab,只保留本地上传;
* 显示项目实际 `root_dir + entry_file`,不再硬编码 `index.html`;
* multipart 只发送 `package`,删除后端未消费的 root/entry 字段;
* `PagesDeployment` 类型删除后端不返回的 `root_dir/entry_file`,增加安全 provenance 字段;
* 兼容 URL service 使用与后端 10 分钟相容的 timeout,直到 UI/API 最终移除;
* deployment query 的 `isError` 单独渲染错误组件,不能降级成“暂无部署”。
#### 2.10.4 轮询
* 用户 action 拿到 `execution_id` 后轮询现有 TaskExecution;pending/running 继续,succeeded/failed 停止;
* 终态统一 invalid project/source/deployments/files queries;失败展示 TaskExecution 安全文案并重新读取 source `last_error`;
* source status 为 checking/syncing 时,以约 2 秒频率刷新 source;
* GitHub latest 空闲时以低频刷新或在 `next_check_at` 附近刷新,确保 scanner 发现更新后页面无需手动刷新;Remote/tag 空闲时不持续轮询;
* 所有轮询设置前端最长等待时间,超时停止自动请求并提供手动刷新;
* 操作按钮在本地 mutation、TaskExecution pending/running 或 source lease busy 任一条件成立时禁用。
在实现任何 Next.js 改动前,先读取 `frontend/node_modules/next/dist/docs/` 中与 App Router、Client Component、数据获取相关的当前版本文档,并遵循项目 shadcn 与页面拆分规范。
### 2.11 日志、可观测性与敏感信息
source status 不承担详细执行日志。TaskExecution 日志使用稳定阶段前缀:
```text
[check] [resolve] [download] [verify] [ingest] [activate] [cleanup]
```
日志可以记录 source/project ID、repo、tag、asset name、revision 前缀、HTTP status、GitHub request ID、字节数和耗时;不得记录 Remote 原始 URL/query、asset 临时下载 URL、Cookie/Authorization 或响应 body。
scanner TaskResult 和结构化日志至少记录:
* 到期总数、选取数、成功/失败/跳过数;
* 检查 backlog;
* GitHub 403/429 与退避截止时间;
* 自动 sync 投递成功/失败数;
* lease 过期恢复数。
* orphan 候选、已补偿、仍被引用、lease busy、非法 marker 与失败数。
当前仓库没有统一业务 metrics abstraction,V2 不为该功能单独引入一套指标框架;后续接入全局 OTel metrics 时再把上述计数提升为 metrics。
### 2.12 关键取舍
| 决策 | 采用方案 | 未采用方案与原因 |
| --- | --- | --- |
| runtime project ID | 不冗余,scanner join source | 冗余列需额外一致性维护,且无法消除读取 config 的 join |
| sync 语义 | 固定创建/复用并激活 | `activate=false` 与 history=1 冲突,并扩大 UI/状态机 |
| 状态 | 六态 | 11 态与 TaskExecution 重复,容易卡在中间态 |
| scanner | TaskHandler 内串行 check,自动更新再投 sync | 批量投递 check 会放大 GitHub 并发并需要派发预占状态 |
| fencing | lease + source/project 双 version 最终校验 | 通用 expected revision 是第四套重复 fence;仅 attention 使用精确确认 revision |
| 回滚 | 人工激活其它部署即 fence;auto 强制关闭 | 只靠 UI 提示无法阻止下一轮 latest 覆盖回滚 |
| Remote URL 编辑 | `remote_url_set` 显式保留/替换 | masked URL 回填、空串或省略语义容易误清密钥 |
| GitHub client | 新建窄 `internal/integration/githubrelease` 包,Pages 复用 | 仓库已有多套 feature-local Release 访问,再新增 Pages 私有 client 会继续扩大重复;本阶段不强制迁移旧调用方 |
| orphan upload | Pages 无引用复检软删除 + scanner 延迟记录补偿;物理 blob GC 后续统一建设 | Pages 直接删 storage 违反平台边界且可能误删 dedup 共享对象;通用 upload cleanup 反向依赖 Pages 状态也会破坏模块边界 |
| scanner 批量 | V2 固定 20,并记录 backlog | 现阶段新增系统设置只扩大配置面;出现真实容量瓶颈后再配置化或改延迟任务 |
---
## 3. 具体修改文件清单 (Proposed Changes)
以下为实施边界;同一阶段可在不改变职责的前提下合并测试文件,不应再拆出只有常量转发的薄文件。
### 3.1 后端 Server
#### [NEW] `internal/model/openflare_pages_source.go`
* `PagesProjectSource`、`PagesProjectSourceRuntime` 模型与表名。
#### [MODIFY] `internal/model/openflare_pages.go`
* project content version;deployment nullable provenance。
#### [NEW] `internal/apps/openflare/pages/source.go`
* discriminated input/view、默认值、identity、脱敏、source CRUD。
#### [NEW] `internal/apps/openflare/pages/source_provider.go`
* Provider 内部接口、Remote public/trusted downloader、共享流式下载结果。
#### [NEW] `internal/integration/githubrelease/client.go`
* 可复用的 GitHub latest/tag/asset client、ETag、rate limit、受控 redirect 与安全错误。
#### [MODIFY] `pkg/httppool/httppool.go`
* 增加保留现有池参数/OTel 的可配置 transport factory,供 SSRF-safe DialContext、no-proxy 与 TLS policy 使用;默认 client 行为不变。
#### [NEW] `internal/apps/openflare/pages/source_sync.go`
* Remote/GitHub 统一 ingest、deployment create-or-load、原子激活与失败补偿。
#### [NEW] `internal/apps/openflare/pages/source_runtime.go`
* source execution snapshot、短/长 lease、heartbeat、失败终态、过期 lease 精确 CAS 恢复。
#### [NEW] `internal/apps/openflare/pages/github_source.go`、`github_source_action.go`
* GitHub 配置归一化,以及 latest/tag check、ETag/304、精确 revision sync、attention 与 provider 退避。
#### [NEW] `internal/apps/openflare/pages/source_tasks.go`
* action TaskMeta、旧/新 payload normalization、actor/trigger 边界与 Handler。
#### [NEW] `internal/apps/openflare/pages/source_scanner.go`
* internal-only scanner、过期 lease 恢复、20 条稳定批次、403/429 退避、backlog 与精确 revision 自动派发。
#### [NEW] `internal/apps/openflare/pages/source_orphan_cleanup.go`、`internal/model/openflare_pages_cleanup.go`
* 100 条/2 小时隔离的 orphan upload 候选查询、统一锁序复检、幂等软删除与缓存修复。
#### [MODIFY] `internal/apps/openflare/pages/logics.go`
* 统一 deployment 创建/激活;created_by/provenance;人工回滚硬约束;candidate/strict prune;Pages artifact 无引用复检删除。
#### [MODIFY] `internal/apps/openflare/pages/helpers.go`
* 严格 RootDir/EntryFile、真实归档限制、manifest 与 Agent metadata;移除对 Pages 保留 type 的通用 `upload.Remove` 调用。
#### [MODIFY] `internal/apps/openflare/pages/download_url.go`
* 旧 URL 导入改用共享 downloader,删除独立不安全 client 分叉;无扩展名归档使用至少 512 字节 format sniff。
#### [MODIFY] `internal/apps/openflare/pages/routers.go`
* 5 个 source Handler;从 OAuth context 获取真实 user ID;Swagger;旧 URL deprecated。
#### [MODIFY] `internal/apps/openflare/pages/errs.go`
* source、Provider、lease 与 attention 的稳定安全错误文案。
#### [MODIFY] `internal/router/v1/openflare/register_pages.go`
* 注册 5 条 source 路由;不在顶层 router 直接挂业务 Handler。
#### [MODIFY] `internal/infra/task/handlers/register.go`
* 显式注册 scanner/action Handler 与 TaskMeta。
#### [MODIFY] `internal/apps/upload/ingest/helpers.go`
* `PolicyDedupNewRecord` 的新 record 使用本次请求业务 metadata,并只继承既有 object 的 `Bucket`,保证每条业务记录的归属信息独立。
* 将“本次是否真实写入 object”作为持久化失败补偿的显式条件;dedup record 创建/统计失败不得删除复用的既有 `file_path`。
#### [MODIFY] `internal/apps/upload/ingest/remove.go`、`internal/apps/upload/exports.go`
* 增加仅供已持有 upload 行锁的事务编排使用的 `RemoveLockedTx`,统一 CAS 软删除与统计更新;`Remove` / `RemoveOwned` 也改用该原语并将已删除视为 no-op,但对 Pages 保留 type 返回稳定拒绝错误。
* 提供提交后调用的 cache invalidation 出口,禁止调用方直接依赖 upload cache 子包。
#### [MODIFY] `internal/repository/upload.go`
* upload 软删除更新增加 active status 条件并返回 `RowsAffected`,保证只有一次真实状态迁移会触发统计扣减。
#### [MODIFY] `internal/apps/upload/task/cleanup.go`
* pending upload 清理改用幂等软删除原语并停止直接删除可能被 dedup record 共享的 object;物理 blob GC 不在 Pages V2 内伪实现。
#### [MODIFY] `internal/apps/upload/handler/routers.go`、`file_management.go`、`logics.go`
* 通用上传 API 拒绝创建 Pages 保留 type,通用管理员/用户文件删除拒绝移除该 type,并同步更新 Swagger 错误说明。
#### [MODIFY] `internal/apps/upload/shared/constants.go`、`errs.go`
* 在 upload 平台集中定义保留 type `openflare_pages_deployment` 与安全错误,由 `exports.go` 导出并供 Pages/Handler 共用。
#### [NEW/MODIFY TEST] Pages、upload 与 model tests
* `source_test.go`、`source_provider_test.go`、`source_sync_test.go`、`internal/integration/githubrelease/client_test.go` 与 `pkg/httppool/httppool_test.go`;
* `logics_test.go`、`routers_test.go`、`internal/apps/upload/ingest/helpers_test.go`、`internal/apps/upload/ingest/remove_test.go`、`internal/apps/upload/handler/routers_test.go`、`internal/apps/upload/task/tasks_test.go`;
* model/迁移测试覆盖 NULL 部分索引与双版本。
### 3.2 数据库迁移
#### [NEW] PostgreSQL
* `internal/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql`
* `internal/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql`
#### [NEW] SQLite
* `internal/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql`
* `internal/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql`
版本号若已被其它分支占用,实施时只顺延编号,不改变 DDL/DML 拆分。
SQLite `0001` 的 Down 必须通过重建受影响表完整移除新增列、约束与索引,不接受只删除 source/runtime 表却遗留 project/deployment 列的伪回滚;PostgreSQL 与 SQLite 都需要真实 Up/Down/Up 验证。
### 3.3 Agent、协议与归档库
#### [MODIFY] `pkg/pagesarchive/entry.go`、`path.go`、`inspect.go`、`list.go`、`extract.go`、`limits.go`
* tar family 流式检查/解压、实际字节限制、特殊条目拒绝与 ExtractFile。
#### [MODIFY] `pkg/protocol/agent.go`
* latest hash response 增加 package/file/total size。
#### [MODIFY] `internal/apps/openflare/agent/routers.go`
* 返回 Agent 限额 metadata,保持 package 流式响应。
#### [MODIFY] `internal/apps/agent/httpclient/client.go`
* package 下载从 `[]byte` 改为受限流式写入。
#### [MODIFY] `internal/apps/agent/sync/service.go`、`pages.go`
* client interface、临时文件、hash race 复核、ExtractFile 与本地绝对 cap。
#### [MODIFY] `internal/apps/openflare/config_version/pages_snapshot.go`、`internal/apps/openflare/pages/rebind.go`
* `LocalRoot` 安全追加规范化 RootDir。
### 3.4 前端 Web
#### [MODIFY] `frontend/lib/services/openflare/types.ts`
* source union、action receipt、safe provenance;清理 deployment/upload 漂移字段。
#### [MODIFY] `frontend/lib/services/openflare/pages.service.ts`、`index.ts`
* 5 个 source API;本地 upload 只发 package;兼容 URL timeout。
#### [NEW] `frontend/app/(main)/pages/detail/components/pages-source-card.tsx`
* source query、能力分离、状态与 TaskExecution 轮询。
#### [NEW] `frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx`
* Remote/GitHub discriminated form、URL replacement 与 trusted warning。
#### [NEW] `frontend/app/(main)/pages/detail/components/deployment-history.tsx`
* deployment query、激活/删除、历史 provenance 和回滚提示。
#### [NEW] `frontend/app/(main)/pages/detail/components/deployment-files-panel.tsx`
* deployment files query 与错误态。
#### [MODIFY] `frontend/app/(main)/pages/detail/page.tsx`
* 直接承载路由骨架、标题、布局与 Suspense,并组合上述组件。
#### [DELETE] `frontend/app/(main)/pages/detail/page-client.tsx`
* 拆分完成后移除纯转发容器;业务逻辑归入 page 与就近子组件。
#### [MODIFY] `frontend/app/(main)/pages/components/deployment-upload-dialog.tsx`、`pages-utils.ts`
* 本地上传单模式、真实入口显示与 source query key。
#### [MODIFY/NEW TEST] 前端测试
* `frontend/tests/openflare/pages-service.test.ts`
* `frontend/tests/openflare/pages-source-ui.test.tsx`
* `frontend/tests/openflare/pages-source-auto-update.test.tsx`
### 3.5 文档与生成物(代码实施时)
#### [MODIFY]
* `docs/design/pages-design.md`
* `docs/design/index.md`
* `docs/design/architecture.md`
* `docs/guide/pages-usage.md`
* `README.md`
* `docs/changelog/index.md` 的 `[Unreleased]`(仅实际代码变更后)
API 实现后运行 `make swagger` 更新 `docs/docs.go`、`docs/swagger.json`、`docs/swagger.yaml`,禁止手工编辑生成物。本计划文档不加入 `docs/config.ts` 的用户文档导航。
---
## 4. 验证计划 (Verification Plan)
### 4.1 数据库与模型
* PostgreSQL/SQLite 空库 Up、现有库升级和 Down;
* source/runtime 同事务 1:1,无 source 项目保持 manual;
* config/runtime 无审核中已删除的宽表冗余列;
* identity 变化 reset runtime,query token/策略变化保留 cursor;
* 持久 source 同 revision 并发只产生一条 deployment;
* PostgreSQL/SQLite 的 create-or-load 使用 conflict-do-nothing 后可在同一事务读取 winner;deployment number 独立冲突能有限重试;
* 手动/旧 URL identity/revision 为 NULL,可重复导入相同包;
* `(project_id, deployment_number)` 并发唯一;
* schedule seed 无固定 ID、可重复 Up,Down 不影响其它 schedule。
建议:
```bash
go test ./internal/infra/persistence/migrator ./internal/model
```
### 4.2 source、Provider 与 API
* Remote/GitHub discriminated validation、默认值与非法模式字段;
* Remote `remote_url_set` 新建/保留/替换全部分支;
* Remote/GitHub 双向切换会清空非当前 Provider 列,旧 query token 不残留;
* Remote 无扩展名 tar 与常见合法扩展名归档均能识别,redirect/Content-Disposition 不污染安全 label;
* URL/repository 规范化、identity 不包含凭据;
* source view、错误、task payload、deployment meta、日志均无 query token;
* GitHub latest/tag endpoint、exact asset、asset 缺失的有限候选列表;
* ETag/304、200/302 asset、403/404/429、Retry-After/reset;
* asset redirect 仅 HTTPS、最多 5 次、逐跳公网 IP 校验,并移除敏感 header/Referer;
* digest 正确、不匹配、缺失;
* Remote check 稳定 400;source delete 幂等且保留 active/history;
* 普通上传 API 提交保留 type `openflare_pages_deployment` 时稳定拒绝;管理员/用户通用删除 API 对该类型同样返回稳定冲突,已被 deployment 引用与暂时无引用两种情况都不能绕过;
* source save 后 check 入队成功与“配置已保存但入队失败”的部分成功语义;
* Handler 全部使用 Abort*,Swagger 声明实际 Failure 状态。
* Provider/TaskResult/TaskExecution 的 `error_message/log/result` 不含 Remote query token、临时下载 URL 或敏感 header。
GitHub/Remote 使用 `httptest.Server` 或可注入 RoundTripper,不在普通单测访问真实外网。
### 4.3 并发、状态与回滚
* 六态转换,无 queued/succeeded runtime 残留;
* 重复 action 只有一个 lease owner,其余 no-op;
* lease 到期但 token 尚未被接管时,旧 Worker 仍无法续租/写终态/激活;scanner 随后恢复 failed;
* source 编辑/删除期间的旧任务不能提交;
* 下载期间修改 RootDir/EntryFile,旧 content version 不能激活;
* scanner 单个 source 失败不阻塞后续 source;304 且已有待更新 revision 时仍能自动投递;
* scanner 看到 revision A、sync 执行时 latest 已变为 B:`target_revision` 不匹配,A/B 均不被该任务激活,并提前下一次检查;
* auto=false 只更新 cursor,不下载;tag 不进入 scanner;
* 人工激活其它 deployment 时 fence 在途任务、关闭 auto,并正确更新/清空 applied;
* 同 identity 旧 revision 回滚同样关闭 auto;重复激活当前 active 不关闭;
* same-release replacement 进入 attention,错误 confirmed revision 不能绕过;digest mismatch 进入 failed;
* attention 后 latest 推进到不同 release ID 时恢复普通 update_available/auto 路径,不形成永久 hold;
* source sync 任何失败均保留旧 active。
* 复用已有 deployment 或人工激活时,目标 upload 已 deleted 会被拒绝,不产生失效 active pointer。
### 4.4 历史与上传补偿
* history=1 手动上传后保留 active + 最新 candidate;再次上传替换旧 candidate;
* candidate 激活后严格恢复 1 条;source sync 激活后只保留新 active;
* Ingest 成功但最终事务失败时 upload record 被软删除并记录补偿结果;
* `PolicyDedupNewRecord` 复用 object 时,新 record 保留本次请求的 Pages marker/项目/source metadata,仅继承既有 object 的 `Bucket`;
* 故障注入 dedup record 创建或统计失败,既有 upload/object 仍可读取,只有本次真实新写 object 才允许在持久化失败时删除;
* 立即 `removePagesUploadIfUnreferenced` 失败时 record 保持 `used`;scanner 隔离期后只处理带 V2 marker 且无 deployment 引用的 Pages upload;
* 普通用户即使伪造保留 type、完整 marker 和真实项目/source ID,也因 HTTP 保留 type 校验与 system owner 双重条件不会进入 cleanup;
* 对普通 upload,`Remove`、`RemoveOwned` 并发删除同一 record 时只有一次 `RowsAffected=1`,上传统计只扣减一次;Pages 补偿/cleanup/历史裁剪共享同一断言;
* source lease 未过期时 cleanup 跳过;过期 Worker 不能续租,后续最终提交因 lease/upload 状态失败;
* cleanup 与最终部署事务按同一 `project -> source -> runtime -> upload` 顺序竞争,分别覆盖 cleanup 先提交、deployment 先提交两种结果,断言不存在指向 deleted upload 的 deployment;
* 管理员通用删除与人工激活并发时删除请求被保留 type 策略拒绝,激活只可能看到 `used` target;
* marker 缺失/损坏或仍存在的 source 归属不一致时只告警并跳过;项目/source 已删除时按合法 orphan 继续补偿;
* PostgreSQL JSONB 与 SQLite `json_extract` 候选查询只选 V2 marker,并以 100 条为批次上限;
* cleanup 不物理删除 object、不硬删记录,dedup 共享 file path 不受影响;
* `system:cleanup` 对过期 pending record 只做一次软删除/统计扣减,不再调用 storage backend 删除共享 object;
* record 补偿失败保留重试候选并产生可告警日志。
### 4.5 归档、网络与 Agent
* chunked 实际 body 超限、伪造 Content-Length、单文件/总量/文件数超限;
* zip/tar/tar.gz/tar.xz/tar.bz2/7z 的合法包与压缩炸弹;
* Zip-Slip、绝对路径、Windows drive、symlink/hardlink/special entry;
* public 拒绝 loopback/private/link-local、redirect 到私网和 DNS rebinding;
* public 拒绝自签 TLS,trusted_internal 仅显式选择后允许;
* Agent 大包不进入 `[]byte`,实际下载超过 metadata/absolute cap 即失败;
* metadata 被伪造为超大值时本地 cap 仍生效;
* hash race 不切换 current;解压失败保留旧 current;
* RootDir + EntryFile 从上传检查到 OpenResty root/index 端到端一致;
* 自动激活后 Agent 通过现有周期 latest 对账拉取,无需发布新的主配置。
建议:
```bash
go test ./pkg/pagesarchive \
./internal/apps/openflare/pages \
./internal/apps/openflare/agent \
./internal/apps/agent/httpclient \
./internal/apps/agent/sync \
./internal/apps/upload/ingest \
./internal/apps/upload/handler \
./internal/apps/upload/task
```
### 4.6 前端
* 三类 source view 与 latest/tag/Remote 能力分离;
* Remote masked URL 不会被保存回后端,replace 开关 payload 正确;
* attention revision 确认、trusted warning、回滚关闭 auto 提示;
* TaskExecution pending/running/terminal 轮询与超时;
* scanner 自动更新后的低频 source 刷新;
* deployment query 错误不显示为空列表;
* 本地上传只发 package,显示真实入口;
* 用户输入完整 URL 时只存在于受控输入框与本地 form state;保存后不重新渲染原值,也不进入 masked view、toast、console、日志或测试 snapshot。输入框使用 password/reveal 交互。
建议:
```bash
cd frontend
pnpm exec vitest run
pnpm exec tsc --noEmit
pnpm lint
```
### 4.7 最终项目门禁与手工验收
代码完成后:
```bash
make swagger
make prettier
make code-check
```
手工最小矩阵:
1. 本地上传 → candidate → 激活 → Agent current 更新;
2. public Remote 同步相同/不同内容,验证复用与新 deployment;
3. trusted internal Remote 的私网/自签场景与风险提示;
4. GitHub tag 检查、同步;
5. GitHub latest 检查到更新,auto off 只提示;auto on 自动激活;
6. 自动更新后人工回滚,验证 auto 被关闭且下次 scanner 不打回 latest;
7. asset 同 Release 替换,验证 attention 与精确 revision 确认;
8. Server/Worker 在下载、Ingest、最终提交不同阶段中断,验证旧 active、lease 恢复与 orphan upload 记录补偿。
---
## 5. 分阶段实施
### 阶段 0:安全与一致性前置(已完成:`4e8ec232`)
* RootDir/EntryFile 严格路径与 LocalRoot 端到端一致;
* Server 真实归档限制和 tar 流式实现;
* Agent 流式下载、ExtractFile、metadata 与绝对 cap;
* history=1 candidate preserve/strict prune;
* created_by 真实 actor;
* upload dedup 新记录 metadata/对象所有权语义、幂等 CAS 软删除、HTTP 保留 type 创建/删除边界;现有 Pages prune/补偿切换到无引用复检删除并记录错误,`system:cleanup` 停止不安全的物理 object 删除;物理 blob GC 保持独立平台后续项。
验收:不引入 source 表/API 的情况下,现有本地上传与旧 URL 路径全部回归;大包内存与路径安全测试通过。
### 阶段 1:数据模型与 Remote 手动同步(已完成:`38b05169`)
* `0001` DDL migration、model、source CRUD/view;
* runtime 六态、lease、config/content fence;
* Remote public/trusted downloader;
* action sync、原子 create-or-load/activate;
* Remote source card/dialog;旧 URL UI 移除但 API 保留。
验收:Remote 只能手动同步并发布;相同内容幂等;失败保持旧 active;立即补偿失败可观测;URL 全链路脱敏。
本阶段 API/DTO 变化完成后立即运行 `make swagger` 并将生成物纳入阶段验证,不把 Swagger 漂移累积到阶段 4。
### 阶段 2:GitHub 手动检查与同步(已完成:`c39a3edc`)
* GitHub client、latest/tag、ETag、asset/digest、rate limit;
* action check、首次异步 check、update_available;
* attention + exact confirmed revision;
* latest 的 auto 开关在本阶段不出现在 UI,服务层拒绝 `auto_update_enabled=true`;
* GitHub source UI 和 deployment provenance。
验收:tag/latest 均可手动检查/同步;auto 仍保持 false;asset 替换不能未经确认激活。
本阶段 API/DTO 变化后再次运行 `make swagger`,保证阶段 2 可独立合并发布。
### 阶段 3:latest scanner 与自动更新(已完成:`848884d8`、`999428cf`、`67b051c2`)
* scanner task、`0002` schedule seed、过期 lease 恢复;
* serial batch、jitter、退避、自动 sync dispatch;
* marker 白名单 orphan record 延迟补偿与统一锁顺序竞态测试;
* 自动更新开关与 interval;
* 人工回滚关闭 auto 的完整前后端交互;
* TaskExecution 与后台状态轮询。
验收:auto 默认 false;开启后只对 latest 生效;回滚不会被自动打回;多 source 失败隔离和 backlog 日志可用。
本阶段 API/DTO 变化后再次运行 `make swagger`,阶段 4 只做最终一致性复检。
### 阶段 4:文档、生成物与验证记录(代码收口已完成)
* 同步 Pages design/architecture/guide/README 与中文 changelog;
* 生成 Swagger;
* 运行前后端测试、`make prettier`、`make code-check`,并如实记录全仓失败与未执行边界;
* 整理 §4.7 手工矩阵,并记录本地环境未覆盖的真实外部场景。
每个阶段只提交本阶段明确路径并独立验证;阶段 0 不与后续 source 功能捆绑成一个大提交。
---
## 6. 生产验收完成定义
只有同时满足以下条件,V2 才视为生产验收完成:
* 三类来源能力边界与 UI/API 完全一致;
* 数据模型为 config/runtime 分离的瘦表,状态不超过六态;
* source sync 无 candidate/activate 分支,revision 幂等且原子激活;
* 人工回滚可以硬性终止自动覆盖;
* Remote/GitHub/日志/task/deployment 均无密钥泄漏;
* Server 与 Agent 都执行真实字节上限,Agent 不再全量内存下载;
* history=1、本地 candidate、source sync 和清理补偿均有自动化覆盖;
* PostgreSQL、SQLite、后端、Agent、前端及项目门禁全部通过;
* 实际代码、Swagger、中文设计/使用文档和 changelog 同步。
本轮状态中的“代码实施完成”表示功能、迁移、前后端交互、生成物和文档均已落地,范围内自动化门禁已经通过。真实 PostgreSQL、真实外部来源和多 Agent 故障矩阵仍是生产验收条件;未执行项及全仓存量失败不会在本文中伪报为通过,统一记录如下。
---
## 7. 实施结果与验证记录
### 7.1 已交付
* 完成部署包安全与一致性前置:项目 RootDir 生效、归档实际字节限制、Agent 流式下载与复核、历史裁剪、上传记录补偿及共享对象安全边界。
* 完成 Remote URL 与公开 GitHub Release source/runtime 模型、CRUD、手动 check/sync、revision 幂等、不可变 deployment 与原子激活。
* 完成 GitHub latest scanner、稳定批次、lease 恢复、ETag/304、403/429 退避、精确 revision 自动派发和 orphan upload 延迟补偿。
* 完成 Cloudflare Pages 风格的“当前生产部署 → 部署源 → 部署历史”前端交互,并保留独立 `git_repository` Provider、Server build executor 和统一 artifact pipeline 的后续边界。
* 完成 Swagger、中文 changelog、Pages 设计、总体架构、Agent 设计、使用指南与 README 同步。
### 7.2 已通过的自动化验证
* `make swagger`:通过,生成物已随实现提交。
* `make prettier`:通过;格式化产生的三个无关存量文件变化已恢复,未混入提交。
* `make code-check`:通过;`golangci-lint`、前端 TypeScript 与全量 ESLint 均无错误。
* Pages、Agent、GitHub Release integration、归档、上传和迁移相关 Go 定向测试通过;关键并发路径的 race 测试通过。
* SQLite Pages source 与 scanner schedule migration 的 Up/Down/Up 通过。
* 前端 Pages 四个测试文件共 34 条用例全部通过,相关 TypeScript 与 ESLint 检查通过。
* 全仓 Go 测试中 `internal/apps/openflare/pages`、`internal/apps/openflare/agent`、`internal/integration/githubrelease`、`internal/infra/persistence/migrator`、`internal/apps/upload/*` 与 `pkg/pagesarchive` 均通过。
### 7.3 全仓测试中仍存在的范围外失败
* `go test ./... -count=1` 未全绿:`internal/apps/admin/system_config` 仍按旧快照断言 32 条默认配置和 3 条 business 配置,实际为 34 和 5;本分支未修改该模块。
* `internal/apps/flared/frpc` 的 `TestUnexpectedExit0CPUProtection`、`TestBackoffReset` 及 `internal/apps/relay/frps` 的 `TestUnexpectedExitAndAutorestart` 存在进程状态时序失败。
* `internal/apps/openflare/tasks` 的 `TestRunSSLRenewJobTriggersDueCertificates` 连接本机 Redis 时收到 `NOAUTH Authentication required`,证书状态因此未进入 `applying`。
* 前端全仓 Vitest 共 101 条通过、1 条失败:`frontend/tests/zone/zone-page.test.tsx:116` 仍查找旧文案“唯一访问者”;Pages 的 34 条测试不受影响。
这些失败点不属于本次 Pages V2 功能路径,因此未通过扩大范围修改存量模块来掩盖;它们仍应在各自模块后续收敛。
### 7.4 本地环境未执行的生产验收
* `OPENFLARE_TEST_POSTGRES_DSN` 未设置,因此 PostgreSQL migration Up/Down/Up、JSONB orphan 候选矩阵和真实行锁竞争未执行;SQLite 对应路径已通过。
* 未访问真实公开 GitHub Release,也未以真实服务验证 GitHub rate limit、asset redirect、Remote public DNS rebinding 或 `trusted_internal` 自签 TLS;普通测试均使用可注入 client/`httptest.Server` 覆盖协议分支。
* 未执行多 Agent 实机收敛,以及 Server/Worker 在下载、Ingest、最终提交阶段的进程级中断矩阵。
* 前端交互由 React Testing Library 覆盖,未执行真实浏览器 E2E。
### 7.5 剩余验证债务
当前 orphan/deployment 竞态测试能验证锁序与事务结果,但 SQLite 串行执行不能替代 PostgreSQL 下两个独立事务的真实行锁竞争。生产发布前应在可用 PostgreSQL 测试实例上补跑 migration、JSONB 候选和双事务交错测试,并按 §4.7 完成真实网络与多 Agent 最小矩阵。