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

89 KiB
Raw Blame History

Pages 项目部署源与 GitHub Releases 自动更新 V2 实现方案

日期:2026-07-19 状态:代码实施完成(范围内自动化验证完成;生产环境验收见 §7) 方案版本:V2(设计修订版,不代表新增 /api/v2)

关联材料:

本文是完整、独立且可直接实施的技术方案,取代原方案成为该功能唯一实现基线。原方案与两份审核文档仅用于追溯设计演进;开发时不需要再将它们与本文拼接,也不得沿用其中与本文冲突的宽表、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 总体架构

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 关系总览

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:

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:

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 最小形状为:

{
  "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 只保存无密钥显示信息,例如:

{
  "provider": "remote_url",
  "display_name": "dist.zip"
}

2.4.5 状态机

状态固定为:

idle | checking | update_available | syncing | failed | attention
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。

派生规则:

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 生成:

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 索引与迁移

索引:

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 只包含:

{
  "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 错误。

最终提交的锁顺序固定为:

project -> source(存在时) -> runtime(存在时) -> upload(所有相关 ID 升序)

提交前只校验:

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 与 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

仓库地址只接受:

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 等安全上下文。

参考官方文档:

未认证公共请求存在严格额度,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 省略该键:

{
  "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 响应扩展为:

{
  "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:

{
  "source_type": "remote_url",
  "remote_url_set": true,
  "remote_url": "https://artifacts.example.com/dist.zip?token=secret",
  "remote_network_policy": "public"
}

GitHub latest:

{
  "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:

{
  "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 响应:

{
  "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:

{
  "source_type": "manual"
}

Remote view 只返回 Remote 有效字段:

{
  "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:

{
  "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 确认时提交:

{
  "confirmed_revision": "revision-hex-currently-shown"
}

action 成功入队返回:

{
  "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,再拆出高状态密度组件;禁止继续保留纯转发页面:

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 日志使用稳定阶段前缀:

[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。

建议:

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 对账拉取,无需发布新的主配置。

建议:

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 交互。

建议:

cd frontend
pnpm exec vitest run
pnpm exec tsc --noEmit
pnpm lint

4.7 最终项目门禁与手工验收

代码完成后:

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 最小矩阵。