From 3f9719328075262f854d18fa2fa1c30ace0e3247 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 21:44:28 +0800 Subject: [PATCH] chore(docs): purge --- docs/plan/20260710-clickhouse-p0-p3-fix.md | 22 - docs/plan/20260712-zone-domain-refactor.md | 491 ---- docs/plan/20260713-waf-orchestration.md | 639 ----- docs/plan/20260717-observability-redesign.md | 99 - docs/plan/20260718-access-log-cache-status.md | 63 - .../20260718-edge-cache-static-default.md | 38 - ...260718-observability-ch-migrate-runbook.md | 71 - docs/plan/20260719-access-log-ip-tab.md | 161 -- docs/plan/20260719-http-default-rate-limit.md | 709 ----- docs/plan/20260719-pages-source-sync-v2.md | 1505 ---------- docs/plan/20260719-waf-ip-matcher-radix.md | 38 - docs/plan/20260723-edge-cache-cf-align.md | 85 - .../20260724-model-repository-layering.md | 44 - docs/plan/20260804-cloudflare-pointing.md | 151 - docs/plan/20260806-origin-error-page.md | 467 --- docs/plan/clickhouse-cpu-optimization.md | 47 - docs/plan/handover-plan-template.md | 32 - docs/plan/implementation-plan-template.md | 47 - docs/plan/index.md | 38 - .../2026-07-19-http-default-rate-limit.md | 709 ----- .../2026-07-19-waf-editor-node-label-drag.md | 388 --- .../plans/2026-07-19-waf-ua-check-node.md | 42 - .../plans/2026-08-06-origin-error-page.md | 467 --- .../2026-08-08-log-database-decoupling.md | 2556 ----------------- .../2026-08-08-service-worker-offline.md | 885 ------ .../plans/2026-08-08-sw-offline-scope.md | 1017 ------- .../specs/2026-07-12-zone-domain-design.md | 13 - .../2026-07-19-cloudflare-pointing-design.md | 7 - ...26-07-19-http-default-rate-limit-design.md | 219 -- .../2026-07-19-rate-limit-analytics-design.md | 217 -- ...07-19-waf-editor-node-label-drag-design.md | 122 - ...26-07-19-waf-security-check-node-design.md | 191 -- .../2026-07-19-waf-ua-check-node-design.md | 228 -- .../specs/2026-07-20-site-limit-req-design.md | 159 - .../specs/2026-07-24-frontend-i18n-design.md | 253 -- .../2026-08-06-origin-error-page-design.md | 22 - ...26-08-08-log-database-decoupling-design.md | 177 -- ...026-08-08-service-worker-offline-design.md | 117 - .../2026-08-08-sw-offline-scope-design.md | 191 -- 39 files changed, 12727 deletions(-) delete mode 100644 docs/plan/20260710-clickhouse-p0-p3-fix.md delete mode 100644 docs/plan/20260712-zone-domain-refactor.md delete mode 100644 docs/plan/20260713-waf-orchestration.md delete mode 100644 docs/plan/20260717-observability-redesign.md delete mode 100644 docs/plan/20260718-access-log-cache-status.md delete mode 100644 docs/plan/20260718-edge-cache-static-default.md delete mode 100644 docs/plan/20260718-observability-ch-migrate-runbook.md delete mode 100644 docs/plan/20260719-access-log-ip-tab.md delete mode 100644 docs/plan/20260719-http-default-rate-limit.md delete mode 100644 docs/plan/20260719-pages-source-sync-v2.md delete mode 100644 docs/plan/20260719-waf-ip-matcher-radix.md delete mode 100644 docs/plan/20260723-edge-cache-cf-align.md delete mode 100644 docs/plan/20260724-model-repository-layering.md delete mode 100644 docs/plan/20260804-cloudflare-pointing.md delete mode 100644 docs/plan/20260806-origin-error-page.md delete mode 100644 docs/plan/clickhouse-cpu-optimization.md delete mode 100644 docs/plan/handover-plan-template.md delete mode 100644 docs/plan/implementation-plan-template.md delete mode 100644 docs/plan/index.md delete mode 100644 docs/superpowers/plans/2026-07-19-http-default-rate-limit.md delete mode 100644 docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md delete mode 100644 docs/superpowers/plans/2026-07-19-waf-ua-check-node.md delete mode 100644 docs/superpowers/plans/2026-08-06-origin-error-page.md delete mode 100644 docs/superpowers/plans/2026-08-08-log-database-decoupling.md delete mode 100644 docs/superpowers/plans/2026-08-08-service-worker-offline.md delete mode 100644 docs/superpowers/plans/2026-08-08-sw-offline-scope.md delete mode 100644 docs/superpowers/specs/2026-07-12-zone-domain-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-cloudflare-pointing-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-waf-security-check-node-design.md delete mode 100644 docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md delete mode 100644 docs/superpowers/specs/2026-07-20-site-limit-req-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-frontend-i18n-design.md delete mode 100644 docs/superpowers/specs/2026-08-06-origin-error-page-design.md delete mode 100644 docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md delete mode 100644 docs/superpowers/specs/2026-08-08-service-worker-offline-design.md delete mode 100644 docs/superpowers/specs/2026-08-08-sw-offline-scope-design.md diff --git a/docs/plan/20260710-clickhouse-p0-p3-fix.md b/docs/plan/20260710-clickhouse-p0-p3-fix.md deleted file mode 100644 index 5bd7d932..00000000 --- a/docs/plan/20260710-clickhouse-p0-p3-fix.md +++ /dev/null @@ -1,22 +0,0 @@ -# ClickHouse P0–P3 修复计划 - -> 状态: 已完成(已合并主工作区,`make code-check` 通过) -> 策略: 4 个互不干扰 worktree 并行,最后由主代理合并 - -## 任务拆分 - -| ID | Worktree 主题 | 范围 | 禁止改动 | -|----|---------------|------|----------| -| WT1 | P0 清理语义 C1 | cleanup maintenance / delete / tasks | chwriter、dashboard、DDL 新 MV | -| WT2 | 写路径 C2+H1+H2+H3 | chwriter、batchwriter、risk_control、model store 分层、status 指标 | goose 迁移、dashboard 读逻辑 | -| WT3 | 读路径 H4+H5 | 最新快照查询、metric/openresty 小时 MV + 读路径 | chwriter、cleanup | -| WT4 | P3 打磨 | 连接池/async_insert、traffic hourly TTL、UV 语义 | model store 分层、cleanup | - -## 合并顺序 - -1. WT1 → 2. WT2 → 3. WT3 → 4. WT4 -(迁移文件时间戳已错开,changelog 由主代理统一写) - -## 验收 - -各 worktree: 相关 `go test` + 可运行部分;合并后 `make code-check`。 diff --git a/docs/plan/20260712-zone-domain-refactor.md b/docs/plan/20260712-zone-domain-refactor.md deleted file mode 100644 index 3e5a6368..00000000 --- a/docs/plan/20260712-zone-domain-refactor.md +++ /dev/null @@ -1,491 +0,0 @@ -# Zone 与域名资源重构 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 以稳定 ID 的 Zone 管理入口和正规化 Zone 域名替代 `managed_domains` 及反代路由中的域名/证书冗余字段,同时保持配置发布后的 OpenResty 行为不变。 - -**Architecture:** `of_zones` 管理可注册根域;`of_zone_domains` 是明确 FQDN、证书和反代路由之间的唯一关联来源。反代路由保留路由策略,配置快照在控制面联查 Zone 域名与证书后生成现有 OpenResty 配置格式。第一发布阶段保留旧列供可重复执行的历史数据导入读取;生产快照对比通过后才执行第二阶段清理。 - -**Tech Stack:** Go 1.25、Gin、GORM、goose(PostgreSQL/SQLite)、`golang.org/x/net/publicsuffix`、Next.js App Router、TypeScript、TanStack Query、shadcn/ui。 - -## Global Constraints - -* Zone URL 必须为 `/websites/:zoneId`,不得使用域名作为路由参数。 -* Zone 根域由 `publicsuffix.EffectiveTLDPlusOne` 解析;Zone 域名只接受明确 FQDN,拒绝 `*.`。 -* TLS 证书可含通配符 SAN;证书只能由 `of_zone_domains.cert_id` 指定,`of_proxy_routes` 不再保存证书字段。 -* `of_zone_domains.domain` 全局唯一;同一 Zone 域名至多绑定一条反代路由,路由可关联多个 Zone 的域名。 -* 不建立物理数据库外键;所有关联列必须建立显式索引。 -* 所有 HTTP 路由仅通过 `internal/router/v1/openflare/` 的管理端注册器委派;Handler 使用 `response.Abort*` 报错并补全 Swagger。 -* 不新增 DNS 记录管理、边缘函数、预览子域或多租户能力。 -* 每次 API 变更运行 `make swagger`;每个实现任务结束运行对应测试;完成前必须运行 `make code-check`。 - ---- - -## File Structure - -| 路径 | 职责 | -| --- | --- | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607120001_create_zone_domain_tables.sql` | 第一阶段 Zone/ZoneDomain DDL 与索引。 | -| `internal/model/openflare_zone.go` | Zone、ZoneDomain 模型及数据访问。 | -| `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go}` | Zone CRUD、概览、输入验证和历史导入。 | -| `internal/cmd/migrate_zones.go` | 显式、可重复运行的历史 Zone 数据导入命令。 | -| `internal/router/v1/openflare/register_zone.go` | `/api/v1/d/zones` 路由注册。 | -| `internal/apps/openflare/proxy_route/*` | 以 `zone_domain_ids` 取代域名与证书输入。 | -| `internal/apps/openflare/config_version/*`、`pkg/render/openresty/*` | 快照与 OpenResty 渲染改为使用 Zone 域名。 | -| `frontend/lib/services/openflare/{zone.service.ts,types.ts,index.ts}` | Zone API 类型和服务。 | -| `frontend/vitest.config.ts`、`frontend/tests/zone/*.test.tsx` | Zone 页面与域名选择器的最小前端测试运行环境。 | -| `frontend/app/(main)/websites/*` | Zone 列表、`[zoneId]` 动态详情页和局部组件。 | -| `frontend/app/(main)/proxy-routes/*` | Zone 域名选择器替换旧域名/证书编辑器。 | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607130001_drop_legacy_route_domain_columns.sql` | 第二阶段删除旧表、列与索引。 | - -### Task 1: 第一阶段 Schema、模型与迁移测试 - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql` -- Create: `internal/model/openflare_zone.go` -- Create: `internal/model/openflare_zone_test.go` -- Modify: `internal/model/openflare_proxy_route.go` -- Test: `internal/infra/persistence/migrator/migrator_test.go` - -**Interfaces:** -- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`. -- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK. - -- [x] **Step 1: 写失败的模型与迁移测试** - -```go -func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) { - // Create zones and domains, then assert a domain cannot be bound twice. -} -``` - -Run: `go test ./internal/model ./internal/infra/persistence/migrator -run 'Zone|Migrat' -count=1` - -Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。 - -- [x] **Step 2: 新建双方言 DDL** - -```sql -CREATE TABLE IF NOT EXISTS of_zones ( - id BIGSERIAL PRIMARY KEY, - domain VARCHAR(255) NOT NULL, - remark VARCHAR(255) NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain); - -CREATE TABLE IF NOT EXISTS of_zone_domains ( - id BIGSERIAL PRIMARY KEY, - zone_id BIGINT NOT NULL, - proxy_route_id BIGINT, - domain VARCHAR(255) NOT NULL, - cert_id BIGINT, - remark VARCHAR(255) NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain); -CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); -CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); -CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); -``` - -SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。 - -- [x] **Step 3: 实现模型和受事务保护的绑定替换** - -```go -type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time } -type ZoneDomain struct { ID uint; ZoneID uint; ProxyRouteID *uint; Domain string; CertID *uint; Remark string; CreatedAt time.Time; UpdatedAt time.Time } - -func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error -``` - -实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。 - -- [x] **Step 4: 运行模型与迁移测试** - -Run: `go test ./internal/model ./internal/infra/persistence/migrator -run 'Zone|Migrat' -count=1` - -Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。 - -- [x] **Step 5: Commit** - -```bash -git add internal/infra/persistence/migrator/goose internal/model -git commit -m "feat(zone): add normalized zone domain schema" -``` - -### Task 2: Zone 领域逻辑、历史导入命令与管理 API - -**Files:** -- Create: `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go,logics_test.go}` -- Create: `internal/cmd/migrate_zones.go` -- Create: `internal/router/v1/openflare/register_zone.go` -- Modify: `internal/cmd/root.go` -- Modify: `internal/router/v1/openflare/register_tls.go` -- Test: `internal/apps/openflare/integration/security_test.go` - -**Interfaces:** -- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers. -- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`. - -- [x] **Step 1: 写失败的逻辑与 API 测试** - -```go -func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) } -func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) { - root, err := zoneRoot("api.example.co.uk") - require.NoError(t, err) - require.Equal(t, "example.co.uk", root) -} -``` - -集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。 - -- [x] **Step 2: 实现精确域名和 Zone 归属验证** - -```go -func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) } -func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) -``` - -拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。 - -- [x] **Step 3: 实现显式导入命令** - -```go -var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error { - report, err := zone.ImportLegacy(context.Background()) - return report.LogAndReturn(err) -}} -``` - -导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。 - -- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由** - -```go -zoneGroup := apiGroup.Group("/zones") -zoneGroup.Use(apiutil.AdminMiddlewares()...) -zoneGroup.GET("/", zone.ListHandler) -zoneGroup.POST("/", zone.CreateHandler) -zoneGroup.GET("/:id/overview", zone.GetOverviewHandler) -``` - -把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。 - -- [x] **Step 5: 验证并 Commit** - -Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger` - -Expected: PASS,Swagger 不再含 `/managed-domains` 且包含 `/zones`。 - -```bash -git add internal/apps/openflare/zone internal/cmd internal/router/v1/openflare docs -git commit -m "feat(zone): add zone management api and legacy importer" -``` - -### Task 3: 反代路由改用 ZoneDomain 关联 - -**Files:** -- Modify: `internal/apps/openflare/proxy_route/{logics.go,helpers.go,build_helpers.go,routers.go,errs.go,logics_test.go}` -- Modify: `internal/model/openflare_proxy_route.go` -- Modify: `internal/apps/openflare/tls/logics.go` -- Modify: `internal/apps/openflare/origin/logics.go` - -**Interfaces:** -- Consumes: `zone_domain_ids []uint` and Task 1 binding API. -- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`. - -- [x] **Step 1: 写失败的路由逻辑测试** - -```go -input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true} -view, err := CreateProxyRoute(ctx, input) -require.NoError(t, err) -require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs) -``` - -同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。 - -- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段** - -```go -type ZoneDomainBindingInput struct { - ZoneDomainIDs []uint `json:"zone_domain_ids"` -} -type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`; Domain string `json:"domain"`; CertID *uint `json:"cert_id"` } -``` - -移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。 - -- [x] **Step 3: 用关联记录验证并构建路由** - -在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。 - -- [x] **Step 4: 运行路由和 TLS 回归测试** - -Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1` - -Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。 - -- [x] **Step 5: Commit** - -```bash -git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model -git commit -m "refactor(proxy): bind routes through zone domains" -``` - -### Task 4: 配置快照、渲染与关联消费者 - -**Files:** -- Modify: `internal/apps/openflare/config_version/{snapshot.go,helpers.go,logics.go,logics_test.go,certificate_snapshot_test.go,pages_snapshot.go}` -- Modify: `pkg/render/openresty/{types.go,render.go,render_route.go,render_test.go}` -- Modify: `internal/apps/openflare/{flared/logics.go,uptimekuma/sync.go}` -- Modify: `internal/apps/openflare/routeidentity/identity.go` - -**Interfaces:** -- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields. - -- [x] **Step 1: 写快照等价性失败测试** - -```go -func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) { - // Two explicit ZoneDomains with different certs must render two TLS server blocks. -} -``` - -加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。 - -- [x] **Step 2: 在快照边界联查并生成临时渲染字段** - -```go -domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID) -snapshotRoute.Domains = maps.Values(domainNames) -snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains) -``` - -`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。 - -- [x] **Step 3: 移除旧字段回退路径** - -删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。 - -- [x] **Step 4: 运行数据面测试** - -Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1` - -Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。 - -- [x] **Step 5: Commit** - -```bash -git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty -git commit -m "refactor(config): render routes from zone domains" -``` - -### Task 5: Zone 前端服务与 ID 动态页面 - -**Files:** -- Create: `frontend/lib/services/openflare/zone.service.ts` -- Create: `frontend/vitest.config.ts` -- Create: `frontend/tests/zone/{websites-page.test.tsx,zone-page.test.tsx}` -- Modify: `frontend/lib/services/openflare/{types.ts,index.ts}` -- Modify: `frontend/lib/services/index.ts` -- Modify: `frontend/app/(main)/websites/page.tsx` -- Create: `frontend/app/(main)/websites/[zoneId]/page.tsx` -- Create: `frontend/app/(main)/websites/[zoneId]/components/{zone-overview.tsx,zone-domains-table.tsx,zone-route-summary.tsx,zone-editor-dialog.tsx,zone-domain-dialog.tsx}` -- Delete: `frontend/app/(main)/websites/detail/page.tsx` -- Delete: `frontend/app/(main)/websites/detail/page-client.tsx` -- Delete: legacy Website/managed-domain-only components after imports are removed. - -**Interfaces:** -- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types. - -- [x] **Step 1: 写服务与页面行为测试** - -先安装仅用于本次页面测试的开发依赖: - -```bash -cd frontend && pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom -``` - -```ts -expect(ZoneService.getOverview).toHaveBeenCalledWith(42) -expect(screen.getByRole('heading', {name: 'example.com'})).toBeVisible() -``` - -覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。 - -- [x] **Step 2: 实现类型化服务与查询键** - -```ts -export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string } -export class ZoneService extends OpenFlareBaseService { protected static override basePath = '/api/v1/d/zones' } -export const zoneQueryKey = ['openflare', 'zones'] as const -``` - -所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。 - -- [x] **Step 3: 用 Next 动态段实现 Zone 详情** - -```tsx -export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) { - const {zoneId} = await params - return -} -``` - -遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。 - -- [x] **Step 4: 实现列表和详情交互** - -列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。 - -- [x] **Step 5: 验证并 Commit** - -Run: `cd frontend && pnpm exec vitest run && pnpm lint` - -Expected: PASS。 - -```bash -git add frontend/lib/services frontend/app/'(main)'/websites -git commit -m "feat(web): add zone-based website management" -``` - -### Task 6: 反代路由前端切换到 Zone 域名选择器 - -**Files:** -- Create: `frontend/app/(main)/proxy-routes/components/zone-domain-selector.tsx` -- Create: `frontend/tests/zone/zone-domain-selector.test.tsx` -- Modify: `frontend/app/(main)/proxy-routes/{components/proxy-route-create-sheet.tsx,components/helpers.ts,page-client.tsx}` -- Modify: `frontend/app/(main)/proxy-routes/detail/{helpers.ts,page-client.tsx,components/domain-section.tsx}` -- Delete: `frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx` -- Modify: `frontend/lib/services/openflare/types.ts` - -**Interfaces:** -- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`. -- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。 - -- [x] **Step 1: 写失败的选择器测试** - -```tsx -render() -expect(screen.getByText('api.example.com')).toBeVisible() -expect(onChange).toHaveBeenCalledWith([7]) -``` - -覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。 - -- [x] **Step 2: 移除旧前端负载与自动匹配** - -从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。 - -- [x] **Step 3: 实现 Zone 域名选择和保存负载** - -```ts -mutationFn: (payload) => ProxyRouteService.update(route.id, { - ...payload, - zone_domain_ids: selectedDomainIDs, -}) -``` - -展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。 - -- [x] **Step 4: 运行前端类型和交互测试** - -Run: `cd frontend && pnpm exec tsc --noEmit` - -Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。 - -- [x] **Step 5: Commit** - -```bash -git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts -git commit -m "refactor(web): select route domains from zones" -``` - -### Task 7: 第一发布阶段验证、文档与发布前数据检查 - -**Files:** -- Modify: `docs/design/zone-design.md` -- Modify: `docs/changelog/index.md` -- Modify: generated `docs/{docs.go,swagger.json,swagger.yaml}` -- Create: `docs/guide/zone-domain-migration.md` - -- [x] **Step 1: 为导入命令写可操作迁移指南** - -文档写明备份、执行 `wavelet migrate-zones`、读取导入报告、发布预览、比较 `server_name`/证书支持文件、发布激活和回滚步骤;不允许在报告有冲突时继续。 - -- [x] **Step 2: 生成 Swagger 和更新未发布变更** - -Run: `make swagger` - -在 `[Unreleased]` 记录 Zone 管理、反代路由域名正规化和移除 managed-domain API。 - -- [x] **Step 3: 运行全量质量门禁** - -Run: `go test ./... && make code-check` - -Expected: PASS。 - -- [x] **Step 4: 做快照等价性验收** - -在升级前导出活动版本,在导入后生成预览;逐个比较所有路由的明确 `server_name` 集合、证书路径、WAF RouteID 绑定与 Pages 部署引用。只允许旧快照的域名/证书冗余 JSON 消失,不允许数据面语义变化。 - -- [x] **Step 5: Commit** - -```bash -git add docs -git commit -m "docs(zone): add migration and release verification guide" -``` - -### Task 8: 第二发布阶段——删除旧表和冗余列 - -**Precondition:** 已在生产环境完成 Task 7 的导入、预览对比和至少一次发布/回滚验证;`migrate-zones` 报告无冲突。 - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql` -- Delete: `internal/model/openflare_managed_domain.go` -- Delete: `internal/apps/openflare/tls/managed_domain.go` -- Delete: `internal/apps/openflare/tls/helpers.go` 中仅用于旧路由证书数组的函数 -- Modify: legacy迁移相关测试、模型测试与 `docs/design/zone-design.md` - -- [x] **Step 1: 写空库与升级库清理失败测试** - -```go -func TestLegacyRouteColumnsAreAbsentAfterCleanup(t *testing.T) { - require.False(t, db.DB(ctx).Migrator().HasColumn(&model.ProxyRoute{}, "domain")) -} -``` - -- [x] **Step 2: 编写双方言清理 DDL** - -PostgreSQL 删除旧唯一索引和 `domain`、`domains`、`cert_id`、`cert_ids`、`domain_cert_ids`,再删除 `of_managed_domains`;SQLite 使用重建 `of_proxy_routes` 表的迁移方式保留所有非旧字段与索引。Down 仅在开发数据库恢复旧结构,不回填历史数据。 - -- [x] **Step 3: 删除旧读取代码与测试 fixture** - -删除所有 `route.Domain`、`route.Domains`、`route.CertID`、`route.CertIDs`、`route.DomainCertIDs` 的持久化引用;让编译器、Uptime Kuma、Flared、来源摘要及 API 只使用 ZoneDomain 查询结果。 - -- [x] **Step 4: 验证升级和完整回归** - -Run: `go test ./internal/infra/persistence/migrator ./internal/model ./internal/apps/openflare/... ./pkg/render/openresty -count=1 && make code-check` - -Expected: PASS;全仓搜索不再发现旧 `ManagedDomain` 业务代码、`ProxyRoute` 持久化字段或管理端 API;渲染快照中的临时 `DomainCertIDs` 类型允许保留。 - -- [x] **Step 5: Commit** - -```bash -git add internal/infra/persistence/migrator internal/model internal/apps frontend docs -git commit -m "refactor(zone): remove legacy route domain storage" -``` - -## Plan Self-Review - -* Spec coverage: Tasks 1–4 交付正规化数据模型、API、迁移与数据面;Tasks 5–6 交付 ID 路由和 Zone 交互;Tasks 7–8 覆盖质量门禁与旧表清理。 -* Placeholder scan: 无待定标记或未定义的实现步骤;所有删除动作在明确的生产验证前置条件后执行。 -* Type consistency: 路由写入统一使用 `zone_domain_ids`,持久化关系统一为 `ZoneDomain.ProxyRouteID`,渲染边界仅使用临时 `Domains`/`DomainCertIDs`。 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md deleted file mode 100644 index 5b832759..00000000 --- a/docs/plan/20260713-waf-orchestration.md +++ /dev/null @@ -1,639 +0,0 @@ -# WAF 可编排规则实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 将固定顺序的 WAF 规则组重构为可用 React Flow 编辑、发布时编译、OpenResty 内存执行的有序 DAG 规则系统。 - -**Architecture:** 控制面以带修订号的版本化 JSON 保存整张编辑态图,Server 保存与发布时执行同一套强校验并编译为精简运行态 DAG。规则仅随配置发布和 OpenResty reload 加载一次;动态 IP 组由协调 Worker 每 5 秒检查 checksum,变化时更新共享快照和各 Worker 本地内存对象。 - -**Tech Stack:** Go 1.25、Gin、GORM、goose、PostgreSQL/SQLite、OpenResty Lua、Next.js 16 App Router、React 19、TypeScript、`@xyflow/react`、TanStack Query、shadcn/ui、Vitest。 - -## 实现状态(2026-07-13) - -Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。地域属性栏使用完整国家与 ISO 3166-2 一级行政区数据,国家同时展示中文名称与代码,行政区支持按名称或代码搜索。发布器保证空规则绑定编码为 `[]`,Lua 运行时兼容旧快照中的 `null` 数组,避免未启用或空绑定规则导致请求 500。PoW 节点通过共享内存暂存配置,并以 `ngx.exec` 显式参数把配置键传入内部挑战处理器,避免内部重定向丢失请求上下文后误报节点未执行。 - -当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 - -## Global Constraints - -- 每张图恰好一个 `start` 和一个 `allow`;`block` 可多个;图必须无环、无悬空、无不可达节点,所有路径必须抵达 `allow` 或 `block`。 -- `ip_match` 与 `geo_match` 只输出 `true`/`false`,分别表示匹配与未匹配;`pow` 只输出 `next`。 -- 全局规则固定前置;路由自定义规则按绑定 `sequence` 升序执行;当前规则 `allow` 后继续下一条,`block` 立即终止。 -- 规则运行态 JSON 仅在 OpenResty reload 后由 Worker 加载一次;请求路径禁止文件 I/O、checksum 和 JSON 解码。 -- IP 组请求路径始终读取 Worker 本地对象;整个实例每 5 秒最多一个 Worker 检查 checksum。 -- 迁移只保留规则名称、全局标记、启用状态和绑定关系,旧策略统一重置为 `开始 → 通过`。 -- 所有 HTTP 路由仅在 `internal/router/router.go` 的既有分发体系中通过 `internal/router/v1/openflare/register_waf.go` 注册;API 失败使用 `response.Abort*`。 -- API Handler 变化后运行 `make swagger`;代码完成后运行 `make code-check`;代码变更写入 `docs/changelog/index.md` 的 `[Unreleased]`。 -- 前端不得删除 `frontend/node_modules`;使用 shadcn variant 与全局 CSS 变量,页面根容器保持 `w-full py-6 px-1`。 -- 实现前阅读 `.agent/skills/database-migration/SKILL.md`、`.agent/skills/new-api/SKILL.md`、`.agent/skills/shadcn/SKILL.md` 以及 `frontend/node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/page.md` 等匹配的 Next.js 本地文档。 - ---- - -## 1. 目标与背景 - -当前 `OpenFlareWAFRuleGroup` 把 IP/地域黑白名单、PoW 与阻止响应平铺为固定字段,Lua 按硬编码顺序判断,无法表达用户自定义分支。本次交付包含图模型、强校验、API、迁移、发布编译、Lua DAG 执行、有序绑定、IP 组五秒内存刷新和 React Flow 编辑器;不包含循环、脚本节点、表达式节点、子图和跨规则跳转。 - -## 2. 数据与控制流 - -```mermaid -flowchart LR - UI[React Flow 编辑态图] -->|revision + graph| API[Server 校验与保存] - API --> DB[(规则 graph JSON)] - DB --> PUB[发布编译器] - PUB --> SNAP[运行态 DAG 快照] - SNAP --> AGENT[Agent 原子落盘] - AGENT -->|reload| LUA[OpenResty Worker 内存] - IPS[IP 组异步更新] -->|JSON 后 checksum| TIMER[5 秒协调定时器] - TIMER --> SHM[ngx.shared 原始快照] - SHM --> LUA -``` - -## 3. 文件结构与职责 - -- `internal/apps/openflare/waf/graph_types.go`:编辑态图、节点配置、运行态图和默认图类型。 -- `internal/apps/openflare/waf/graph_validate.go`:结构、端口、配置、引用、可达性和终止性校验。 -- `internal/apps/openflare/waf/graph_compile.go`:删除 UI 字段、编译索引化 DAG、收集 IP 组引用。 -- `internal/apps/openflare/waf/rule_logics.go`:规则元数据、修订保存和有序绑定逻辑;从现有过大的 `logics.go` 中抽离规则职责。 -- `internal/apps/openflare/waf/rule_routers.go`:规则 API Handler 与 Swagger;IP 组 Handler 留在现有文件或后续独立拆分。 -- `internal/apps/agent/nginx/waf_assets.go`:只负责嵌入 Lua 文件;实际 Lua 拆到 `internal/apps/agent/nginx/waf_runtime.lua` 与 `waf_ip_groups.lua` 并使用 `go:embed`,避免继续膨胀 Go 字符串。 -- `frontend/app/(main)/waf/page.tsx`:规则/IP 组列表和仅名称创建流程。 -- `frontend/app/(main)/waf/rules/editor/page.tsx`:静态可导出的编辑器路由入口,通过查询参数读取规则 ID,避免 Next 静态导出的动态参数限制。 -- `frontend/app/(main)/waf/rules/editor/components/`:画布、节点、节点库、属性栏和校验提示,单文件保持低于 600 行。 - ---- - -### Task 1: 数据库迁移与持久化模型 - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql` -- Create: `internal/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql` -- Modify: `internal/model/openflare_waf.go` -- Create: `internal/model/openflare_waf_graph_test.go` - -**Interfaces:** -- Produces: `Graph string`, `Revision uint64`, `Sequence int`;`UpdateOpenFlareWAFRuleGraph(ctx, id, revision, graph) (uint64, error)`;绑定查询按 `sequence, id` 排序。 -- Consumes: 现有 `OpenFlareWAFRuleGroup` 与 `OpenFlareWAFRuleGroupBinding`。 - -- [ ] **Step 1: 阅读数据库迁移 Skill 并写迁移失败测试** - -测试建立旧 Schema、插入两个规则和无序绑定、执行迁移后断言图统一为默认图、`revision = 1`、绑定顺序稳定。测试核心断言: - -```go -require.JSONEq(t, `{"schema_version":1,"nodes":[{"id":"start","type":"start","position":{"x":0,"y":0},"config":{}},{"id":"allow","type":"allow","position":{"x":320,"y":0},"config":{}}],"edges":[{"id":"start-allow","source":"start","source_handle":"next","target":"allow"}]}`, group.Graph) -assert.Equal(t, uint64(1), group.Revision) -assert.Equal(t, []int{0, 1}, []int{bindings[0].Sequence, bindings[1].Sequence}) -``` - -- [ ] **Step 2: 运行模型测试确认失败** - -Run: `go test ./internal/model -run 'TestOpenFlareWAFGraph|TestReplaceOpenFlareWAFRuleGroupBindings' -count=1` - -Expected: FAIL,缺少新字段或迁移列。 - -- [ ] **Step 3: 编写 PostgreSQL 与 SQLite goose 迁移** - -`202607150001` 只执行 DDL:两端都增加 `graph TEXT NOT NULL`、`revision BIGINT/INTEGER NOT NULL DEFAULT 1`、`sequence INTEGER NOT NULL DEFAULT 0`。`202607150002` 只执行 DML:用确定性的 `id` 顺序为每个 `proxy_route_id` 回填 sequence,并将所有 graph 重置为同一默认 JSON。Down 分别恢复数据语义与旧列结构;不要创建物理外键,禁止把 DDL 与 DML 放入同一个迁移文件。 - -- [ ] **Step 4: 实现乐观锁与有序绑定模型方法** - -```go -var ErrWAFRuleRevisionConflict = errors.New("waf rule revision conflict") - -func UpdateOpenFlareWAFRuleGraph(ctx context.Context, id uint, revision uint64, graph string) (uint64, error) { - result := db.DB(ctx).Model(&OpenFlareWAFRuleGroup{}). - Where("id = ? AND revision = ?", id, revision). - Updates(map[string]any{"graph": graph, "revision": gorm.Expr("revision + 1")}) - if result.Error != nil { return 0, result.Error } - if result.RowsAffected != 1 { return 0, ErrWAFRuleRevisionConflict } - return revision + 1, nil -} -``` - -绑定替换在事务中按输入下标写 `Sequence: index`;查询显式 `Order("sequence asc").Order("id asc")`。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `go test ./internal/model ./internal/infra/persistence/migrator/... -count=1` - -Expected: PASS。 - -Commit: `feat(waf): add graph persistence and binding order` - ---- - -### Task 2: 图类型、默认图和强校验器 - -**Files:** -- Create: `internal/apps/openflare/waf/graph_types.go` -- Create: `internal/apps/openflare/waf/graph_validate.go` -- Create: `internal/apps/openflare/waf/graph_validate_test.go` -- Modify: `internal/apps/openflare/waf/errs.go` - -**Interfaces:** -- Produces: `RuleGraph`, `RuleNode`, `RuleEdge`, `DefaultRuleGraph() RuleGraph`, `ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(context.Context, uint) (bool, error)) error`。 -- Consumes: Task 1 的 JSON 持久化字段。 - -- [ ] **Step 1: 写表驱动失败测试** - -覆盖合法默认图、重复 start/allow、环、不可达节点、悬空端口、错误 handle、同 handle 多目标、无终止路径、未知类型、无效 IP/CIDR、缺失 IP 组、非法国家/地区、PoW 范围和超限图。 - -```go -tests := []struct{name string; mutate func(*RuleGraph); want string}{ - {"cycle", addCycle, "规则图不能包含循环"}, - {"missing false edge", removeFalseEdge, "节点 match-1 的 false 出口未连接"}, - {"unreachable", addUnreachableNode, "节点 orphan 无法从开始节点到达"}, -} -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/openflare/waf -run TestValidateRuleGraph -count=1` - -Expected: FAIL,类型和校验函数不存在。 - -- [ ] **Step 3: 定义带判别联合的图类型** - -```go -type RuleNodeType string -const ( - RuleNodeStart RuleNodeType = "start" - RuleNodeAllow RuleNodeType = "allow" - RuleNodeBlock RuleNodeType = "block" - RuleNodeIPMatch RuleNodeType = "ip_match" - RuleNodeGeoMatch RuleNodeType = "geo_match" - RuleNodePoW RuleNodeType = "pow" -) -type RuleGraph struct { SchemaVersion int `json:"schema_version"`; Nodes []RuleNode `json:"nodes"`; Edges []RuleEdge `json:"edges"` } -type RuleEdge struct { ID, Source, SourceHandle, Target string } -``` - -`RuleNode.Config` 先用 `json.RawMessage` 解码到明确的 `IPMatchConfig`、`GeoMatchConfig`、`PoWNodeConfig`、`BlockNodeConfig`,禁止透传未知字段。 - -- [ ] **Step 4: 实现结构和 DFS/Kahn 校验** - -先检查大小、ID、类型、端口和配置,再用 Kahn 检测环,用从 start 的 DFS 检测可达性,用反向图从所有终止节点遍历检测终止性。错误文案携带节点/边 ID,供前端定位。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `go test ./internal/apps/openflare/waf -run 'Test(DefaultRuleGraph|ValidateRuleGraph)' -count=1` - -Expected: PASS。 - -Commit: `feat(waf): validate composable rule graphs` - ---- - -### Task 3: 运行态图编译器 - -**Files:** -- Create: `internal/apps/openflare/waf/graph_compile.go` -- Create: `internal/apps/openflare/waf/graph_compile_test.go` - -**Interfaces:** -- Produces: `CompileRuleGraph(graph RuleGraph) (RuntimeRuleGraph, error)`、`ReferencedIPGroupIDs(graph RuleGraph) []uint`。 -- Consumes: Task 2 的已校验图类型。 - -- [ ] **Step 1: 写编译快照测试** - -断言位置和显示名不进入 JSON、节点通过 ID map O(1) 查找、出口按 handle 编译、IP 组 ID 去重排序。 - -```go -compiled, err := CompileRuleGraph(graph) -require.NoError(t, err) -assert.Equal(t, "start", compiled.Entry) -assert.Equal(t, "allow", compiled.Nodes["match"].Next["true"]) -assert.Equal(t, []uint{2, 7}, ReferencedIPGroupIDs(graph)) -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/openflare/waf -run 'TestCompileRuleGraph|TestReferencedIPGroupIDs' -count=1` - -Expected: FAIL,编译接口不存在。 - -- [ ] **Step 3: 实现确定性编译** - -输出结构只保留 `entry`、按节点 ID 索引的类型化运行配置和 handle→target 映射;序列化前对可排序切片排序,确保相同图生成相同快照和 checksum。 - -- [ ] **Step 4: 运行测试并提交** - -Run: `go test ./internal/apps/openflare/waf -run 'TestCompileRuleGraph|TestReferencedIPGroupIDs' -count=1` - -Expected: PASS。 - -Commit: `feat(waf): compile rule graphs for runtime` - ---- - -### Task 4: 规则 API、修订冲突与有序绑定 - -**Files:** -- Create: `internal/apps/openflare/waf/rule_logics.go` -- Create: `internal/apps/openflare/waf/rule_routers.go` -- Create: `internal/apps/openflare/waf/rule_logics_test.go` -- Modify: `internal/apps/openflare/waf/logics.go` -- Modify: `internal/apps/openflare/waf/routers.go` -- Modify: `internal/router/v1/openflare/register_waf.go` - -**Interfaces:** -- Produces: `CreateRuleInput{Name string}`、`SaveRuleGraphInput{Revision uint64; Graph RuleGraph}`、`UpdateRuleMetaInput{Name string; Enabled bool}`;创建、详情、元数据、图保存、删除和有序绑定 API。 -- Consumes: Tasks 1–3 的模型、默认图和校验器。 - -- [ ] **Step 1: 阅读 new-api Skill,写逻辑与 Handler 失败测试** - -覆盖只传名称创建默认图、空名 400、图非法 400、revision 冲突 409、绑定顺序往返不变、全局规则不能被路由绑定排序覆盖。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/openflare/waf ./internal/router/v1/openflare -run 'Test(CreateRule|SaveRuleGraph|ReplaceSiteRuleGroups)' -count=1` - -Expected: FAIL,API 输入与路由尚未实现。 - -- [ ] **Step 3: 实现 logic 与安全错误映射** - -```go -func SaveRuleGraph(ctx context.Context, id uint, input SaveRuleGraphInput) (*RuleView, error) { - if err := ValidateRuleGraph(ctx, input.Graph, ipGroupExists); err != nil { return nil, err } - raw, err := json.Marshal(input.Graph) - if err != nil { return nil, err } - if _, err = model.UpdateOpenFlareWAFRuleGraph(ctx, id, input.Revision, string(raw)); err != nil { return nil, err } - return GetRule(ctx, id) -} -``` - -数据库/编码错误用 `pkg/logger` 记录后映射 `AbortInternal`;校验错误用 `AbortBadRequest`;revision 冲突用 `AbortConflict`。 - -- [ ] **Step 4: 注册路由并补全 Swagger** - -保留 `/rule-groups` 路径以减少前端与兼容面变化,但将创建 payload 改为仅名称,新增 `POST /rule-groups/:id/graph` 与 `POST /rule-groups/:id/meta`。所有 `@Failure 400/404/409/500` 与统一 response envelope 完整声明。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `go test ./internal/apps/openflare/waf ./internal/router/v1/openflare -count=1` - -Expected: PASS。 - -Commit: `feat(api): expose orchestrated waf rules` - ---- - -### Task 5: 发布快照与规则顺序 - -**Files:** -- Modify: `internal/apps/openflare/config_version/snapshot.go` -- Modify: `internal/apps/openflare/config_version/logics_test.go` -- Create: `internal/apps/openflare/config_version/waf_graph_snapshot_test.go` - -**Interfaces:** -- Produces: 发布快照中的 `rule_groups[].graph` 运行态 DAG、按 sequence 排序的 `bindings[].rule_group_ids`、图引用 IP 组集合。 -- Consumes: Task 3 编译器与 Task 1 有序绑定。 - -- [ ] **Step 1: 写失败测试** - -建立全局规则和两个自定义图,绑定顺序 `[customB, customA]`,断言快照保持该顺序、运行图无 position、只包含图引用的 IP 组;非法启用图阻止预览/发布。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/openflare/config_version -run 'TestWAFGraphSnapshot|TestBuildSnapshotRejectsInvalidWAFGraph' -count=1` - -Expected: FAIL,快照仍输出旧固定字段并按 ID 排序。 - -- [ ] **Step 3: 替换固定字段快照编译** - -删除 `snapshotWAFRuleGroup` 的旧黑白名单/PoW 字段,加入 `Graph waf.RuntimeRuleGraph`;`buildSnapshotWAFIPGroups` 从所有编辑图的 `ReferencedIPGroupIDs` 聚合;绑定不再按 group ID 排序。 - -- [ ] **Step 4: 运行测试并提交** - -Run: `go test ./internal/apps/openflare/config_version ./internal/apps/openflare/integration -count=1` - -Expected: PASS。 - -Commit: `feat(waf): publish ordered runtime graphs` - ---- - -### Task 6: OpenResty 内存 DAG 执行器 - -**Files:** -- Create: `internal/apps/agent/nginx/waf_runtime.lua` -- Create: `internal/apps/agent/nginx/waf_runtime_spec.lua` -- Modify: `internal/apps/agent/nginx/waf_assets.go` -- Modify: `internal/apps/agent/nginx/waf_assets_test.go` -- Modify: `internal/apps/agent/nginx/manager.go` -- Modify: `internal/apps/agent/nginx/manager_test.go` -- Modify: `internal/apps/agent/nginx/pow_assets.go` - -**Interfaces:** -- Produces: `require("waf.runtime").check()`,模块加载时读取一次规则配置,请求时执行内存 DAG。 -- Consumes: Task 5 的运行态快照;现有 PoW challenge/session 代码。 - -- [ ] **Step 1: 写 Lua 执行器失败测试** - -用 stub `ngx` 覆盖 IP true/false、地域 true/false、PoW 接管/完成、多个 block 响应、全局前置、自定义顺序、未知节点 fail-closed、请求期间 `io.open` 调用次数为 0。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/agent/nginx -run 'TestWAFRuntime' -count=1` - -Expected: FAIL,运行时仍为固定链且每次请求读取配置。 - -- [ ] **Step 3: 将规则加载移到 Lua 模块初始化** - -```lua -local rules_config = assert(load_json_once(runtime_dir .. "/waf_config.json")) - -function _M.check() - local rules = active_rules_for_site(rules_config, ngx.var.openflare_waf_site or "") - for _, rule in ipairs(rules) do - local decision = execute_graph(rule.graph) - if decision.kind == "block" then return render_block(decision.config) end - end -end -``` - -在 `manager.go` 生成的 `http` 块中显式加入 `init_worker_by_lua_block { require("waf.runtime").init() }`,使新 Worker 在 reload 启动阶段完成读取与解析,而不是推迟到首个请求。模块缓存使每个 Worker 只解析一次;执行器设置最大步数为节点数,任何损坏图都记录限频错误并返回阻止响应。 - -- [ ] **Step 4: 将 PoW 变为节点执行接口** - -抽取现有 PoW runtime 为 `pow.evaluate(config)`:完成返回 `true`,未完成直接输出/重定向挑战并返回接管标记。移除“按站点选择第一个 pow_enabled 规则”的旧扫描逻辑。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `go test ./internal/apps/agent/nginx ./internal/apps/agent/sync -count=1` - -Expected: PASS。 - -Commit: `feat(agent): execute waf graphs from worker memory` - ---- - -### Task 7: IP 组 checksum 与五秒内存刷新 - -**Files:** -- Create: `internal/apps/agent/nginx/waf_ip_groups.lua` -- Create: `internal/apps/agent/nginx/waf_ip_groups_spec.lua` -- Modify: `internal/apps/agent/sync/service.go` -- Modify: `internal/apps/agent/sync/service_test.go` -- Modify: `internal/apps/agent/nginx/waf_assets.go` - -**Interfaces:** -- Produces: `waf_ip_groups.json.checksum`;Lua `ip_groups.current()` 返回 Worker 本地对象;协调刷新间隔固定 5 秒。 -- Consumes: 现有 Agent IP 组同步 payload 与独立的 `ngx.shared.openflare_waf_ip_groups`(64 MiB);完整运行时快照上限为 20 MiB。 - -- [ ] **Step 1: 写失败测试** - -断言 Agent 先原子替换 JSON、最后原子替换 checksum;Lua 稳定状态 15 秒只读取 checksum 3 次且不读 JSON;变化时全实例只读一次 JSON;非法新 JSON 保留旧对象。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/agent/sync ./internal/apps/agent/nginx -run 'TestWAFIPGroup(Checksum|Refresh)' -count=1` - -Expected: FAIL,checksum sidecar 和定时器不存在。 - -- [ ] **Step 3: Agent 写 checksum sidecar** - -checksum 使用 Agent 已有快照 checksum;写入采用同目录临时文件、fsync/close、rename 的现有原子文件工具。严格顺序为 JSON rename 成功后 checksum rename。 - -- [ ] **Step 4: 实现协调 Worker 刷新** - -```lua -local function tick(premature) - if premature then return end - local ok = shared:add("ip_refresh_lock", true, 4) - if ok then refresh_from_checksum() end - adopt_shared_snapshot_if_changed() -end -ngx.timer.every(5, tick) -``` - -协调 Worker 变化时把 raw JSON 和 checksum 写共享字典;每个 Worker 只在 shared version 变化时 decode 到模块局部 `current_groups`。请求只调用 `ip_groups.current()`。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `go test ./internal/apps/agent/nginx ./internal/apps/agent/sync -count=1` - -Expected: PASS。 - -Commit: `perf(waf): refresh ip groups by checksum timer` - ---- - -### Task 8: 前端类型、Service 与创建流程 - -**Files:** -- Modify: `frontend/package.json` -- Modify: `frontend/pnpm-lock.yaml` -- Modify: `frontend/lib/services/openflare/types.ts` -- Modify: `frontend/lib/services/openflare/waf.service.ts` -- Modify: `frontend/app/(main)/waf/page.tsx` -- Create: `frontend/app/(main)/waf/components/create-rule-dialog.tsx` -- Modify: `frontend/app/(main)/waf/components/rule-groups-table.tsx` -- Delete after replacement: `frontend/app/(main)/waf/components/rule-group-sheet.tsx` -- Test: `frontend/tests/unit/waf-rule-service.test.ts` - -**Interfaces:** -- Produces: TypeScript 判别联合 `WAFRuleNode`、`WAFRuleGraph`、`WAFRule`;`WafService.createRule({name})`、`saveRuleGraph(id, {revision, graph})`。 -- Consumes: Task 4 API。 - -- [ ] **Step 1: 阅读 shadcn 与 Next 本地文档,安装 React Flow** - -Run: `cd frontend && pnpm add @xyflow/react` - -Expected: `package.json` 与 lockfile 增加同一版本的 `@xyflow/react`。 - -- [ ] **Step 2: 写 Service 与创建流程失败测试** - -断言创建 payload 只有 `{name}`,保存包含 revision,创建成功导航到 `/waf/rules/editor?id=`,不再打开旧规则大表单。 - -- [ ] **Step 3: 运行测试确认失败** - -Run: `cd frontend && pnpm vitest run tests/unit/waf-rule-service.test.ts` - -Expected: FAIL,旧 payload 仍要求固定字段。 - -- [ ] **Step 4: 实现类型、Service 和名称对话框** - -```ts -export type WAFRuleNode = - | {id: string; type: 'start'; position: XYPosition; config: Record} - | {id: string; type: 'ip_match'; position: XYPosition; config: IPMatchConfig} - | {id: string; type: 'geo_match'; position: XYPosition; config: GeoMatchConfig} - | {id: string; type: 'pow'; position: XYPosition; config: PoWNodeConfig} - | {id: string; type: 'allow'; position: XYPosition; config: Record} - | {id: string; type: 'block'; position: XYPosition; config: BlockNodeConfig}; -``` - -静态方法作为 React Query 回调时继续用箭头函数包裹。列表创建成功后 `router.push('/waf/rules/editor?id=' + rule.id)`。 - -- [ ] **Step 5: 运行测试并提交** - -Run: `cd frontend && pnpm vitest run tests/unit/waf-rule-service.test.ts && pnpm lint` - -Expected: PASS。 - -Commit: `feat(frontend): create orchestrated waf rules` - ---- - -### Task 9: React Flow 编排器与固定属性栏 - -**Files:** -- Create: `frontend/app/(main)/waf/rules/editor/page.tsx` -- Create: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` -- Create: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` -- Create: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` -- Create: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` -- Create: `frontend/app/(main)/waf/rules/editor/components/graph-validation.ts` -- Create: `frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts` -- Create: `frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx` - -**Interfaces:** -- Produces: 全宽 React Flow 编辑器;前端 `validateGraph(graph): GraphIssue[]`;Server 错误节点定位。 -- Consumes: Task 8 类型与 Service。 - -- [ ] **Step 1: 写前端图校验失败测试** - -覆盖唯一 start/allow、必需 handle、禁止环、不可达、终止性以及删除节点同步删边。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/graph-validation.test.ts'` - -Expected: FAIL,校验器不存在。 - -- [ ] **Step 3: 实现页面骨架和数据状态** - -`page.tsx` 直接维护 query、mutation、React Flow nodes/edges、dirty、selection 和右侧栏状态,不创建同名中转容器。根节点使用 `w-full py-6 px-1`,标题严格使用既定图标和 `h1` 规范。 - -- [ ] **Step 4: 实现节点、handle 和连线约束** - -`start/pow` 只显示 `next` source handle,`ip_match/geo_match` 显示 `true`、`false`,`allow/block` 只显示 target handle。`isValidConnection` 阻止错误端口、同端口重复连接和形成环;start/allow 禁止删除。 - -- [ ] **Step 5: 实现固定右侧属性栏** - -属性栏按节点判别联合渲染 IP/CIDR 与 IP 组多选、国家/地区多选、PoW 配置、阻止状态码与 HTML。颜色和阴影通过节点组件 variant/CSS 变量集中定义,不在业务调用点硬编码。 - -- [ ] **Step 6: 实现保存、冲突和未保存提示** - -仅图合法时启用保存;409 显示“规则已在其他页面更新,请重新加载”;Server 返回节点/边 ID 时选中并聚焦;浏览器离开和应用内返回均提示未保存变更。 - -- [ ] **Step 7: 运行测试、构建并提交** - -Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build` - -Expected: PASS;静态导出包含 `/waf/rules/editor`。 - -Commit: `feat(frontend): add visual waf rule composer` - ---- - -### Task 10: 路由绑定排序 UI 与旧界面清理 - -**Files:** -- Modify: `frontend/app/(main)/waf/components/site-binding-sheet.tsx` -- Modify: `frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx` -- Modify: `frontend/app/(main)/waf/components/helpers.ts`(删除仅旧规则表单使用的导出;若清空则删除文件) -- Delete: `frontend/app/(main)/waf/components/pow-config-panel.tsx` -- Delete: `frontend/app/(main)/waf/components/rule-entry-dialog.tsx` -- Delete: `frontend/app/(main)/waf/components/rule-list-section.tsx` -- Test: `frontend/tests/unit/waf-binding-order.test.tsx` - -**Interfaces:** -- Produces: 拖拽或上下移动的有序绑定列表,提交 ID 顺序不被排序。 -- Consumes: Task 4 有序绑定 API 和 Task 8 Service。 - -- [ ] **Step 1: 写绑定顺序失败测试** - -选择规则 A/B/C,移动为 C/A/B,断言 API payload 为 `{ids:[C,A,B]}`;全局规则单独展示为固定前置且不可拖动。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd frontend && pnpm vitest run tests/unit/waf-binding-order.test.tsx` - -Expected: FAIL,当前 UI 只表达集合。 - -- [ ] **Step 3: 实现排序并删除旧固定表单组件** - -复用项目现有 `@dnd-kit/sortable`;为键盘用户提供上移/下移操作。清理旧字段、旧 PoW 面板和不再引用的 helper,保留 IP 组管理组件。 - -- [ ] **Step 4: 运行测试并提交** - -Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build` - -Expected: PASS。 - -Commit: `refactor(frontend): order waf bindings and remove legacy editor` - ---- - -### Task 11: 旧后端字段清理、Swagger、中文文档与端到端验证 - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql` -- Modify: `internal/model/openflare_waf.go` -- Modify: `internal/apps/openflare/waf/logics_test.go` -- Modify: `docs/design/waf-design.md` -- Modify: `docs/guide/waf-usage.md` -- Modify: `docs/changelog/index.md` -- Generated: `docs/docs.go`, `docs/swagger.json`, `docs/swagger.yaml` - -**Interfaces:** -- Produces: 无旧固定策略字段的最终 Schema 与中文使用文档。 -- Consumes: Tasks 1–10 的完整替代实现。 - -- [ ] **Step 1: 写迁移与集成失败测试** - -断言最终表不再包含 `block_status_code`、`ip_whitelist`、`ip_blacklist`、地域名单、`pow_enabled`、`pow_config`;端到端图分别产生 allow、block、PoW 接管,IP 组变化在 5–10 秒内生效。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `go test ./internal/apps/openflare/integration ./internal/model -run 'TestOrchestratedWAF|TestLegacyWAFColumnsRemoved' -count=1` - -Expected: FAIL,旧列仍存在。 - -- [ ] **Step 3: 删除旧列和旧代码路径** - -PostgreSQL 直接 `DROP COLUMN`;SQLite 使用项目支持版本的 `DROP COLUMN` 或重建表迁移并复制 `id/name/enabled/is_global/graph/revision/timestamps`。删除 Go model/view/input 中的旧字段和固定链 helper,确保仓库中业务代码不再引用它们。 - -- [ ] **Step 4: 更新中文文档与 changelog** - -`waf-design.md` 删除固定链作为现行设计的表述,链接可编排设计;`waf-usage.md` 写创建、节点语义、绑定顺序、发布生效和迁移警告;`[Unreleased]` 增加 WAF 可视编排、发布加载和 IP 组刷新条目。 - -- [ ] **Step 5: 生成 Swagger 并运行全量验证** - -Run: `make swagger` - -Expected: PASS,生成文件包含新 graph/meta API 与 409 response。 - -Run: `go test ./... -count=1` - -Expected: PASS。 - -Run: `cd frontend && pnpm vitest run && pnpm lint && pnpm build` - -Expected: PASS。 - -Run: `make code-check` - -Expected: PASS,无格式、lint、测试或生成文件差异。 - -- [ ] **Step 6: 最终人工数据面验收** - -创建规则并编排 `开始 → IP 匹配 → true:通过 / false:地域匹配 → true:阻止A / false:PoW → 通过`,绑定到测试路由并发布。用命中/未命中 IP、不同 GeoIP 和无 PoW cookie 请求验证三个分支;更新引用 IP 组后不发布,确认 5–10 秒内结果变化且 OpenResty 未 reload。 - -- [ ] **Step 7: 提交** - -Commit: `feat(waf): complete composable rule orchestration` - -## 4. 最终验收标准 - -- 用户新增规则时只输入名称并立即进入 React Flow 编排器。 -- 默认规则为 `开始 → 通过`;特殊节点与处理节点满足设计约束。 -- Server 和前端均拒绝非法图,发布再次校验,revision 冲突返回 409。 -- 全局规则固定前置,自定义规则严格按绑定顺序执行。 -- OpenResty 请求路径对规则与 IP 组均为纯内存读取。 -- 规则只在发布 reload 时加载;IP 组每 5 秒 checksum 检查且仅变化时读取完整 JSON。 -- PostgreSQL、SQLite、Go、Lua、前端、Swagger、构建与 `make code-check` 全部通过。 diff --git a/docs/plan/20260717-observability-redesign.md b/docs/plan/20260717-observability-redesign.md deleted file mode 100644 index 8104ea54..00000000 --- a/docs/plan/20260717-observability-redesign.md +++ /dev/null @@ -1,99 +0,0 @@ -# 边缘可观测与业务流量统计重构 — 实现计划 - -说明:本计划对应设计文档 [observability-design.md](../design/observability-design.md)。重大架构重构,按阶段交付,避免一次大爆炸。 - ---- - -## 0. 落地进度(2026-07-18) - -* [x] M1 看板业务趋势改读 access log;网络图文案改为已提供/接收 + 宿主机网卡 -* [x] M2 协议 v2 字段(host_metrics/edge_health/request_length);CH 列 `request_length`/`request_time_ms` -* [x] M3 Agent:观测口仅健康连接;payload 不再发 TrafficReport;access_logs 带 request_length -* [x] M4 Server:停写 TrafficReport;openresty 仅存 connections;明细入库带 request_length -* [x] 分布图 status/top domains + 节点行请求/UV 改 access log;24h UV 用 uniqExact;API bytes_provided/received -* [x] M5:`of_node_edge_health`、`of_access_log_hourly`(+MV);删除 request_reports/traffic_hourly/openresty_hourly/obs_openresty;写入/查询改道 -* [x] 收尾:清 openresty hourly / request_report 死路径;edge_health 写全 status;cleanup 命名 `node_edge_health`;hourly 回填 SQL + UV 策略文档 -* [x] 协议/API 去兼容层(Agent 销毁重建):删除 TrafficReport / openresty_observation / snapshot 别名 / request_reports API 字段 / openresty_rx|tx -* [x] 前端 UV 文案:24h/查询窗口独立访客;趋势图不绘分时 UV -* [ ] 真实环境 ClickHouse 迁移 + `202607180003` 回填(本机 Docker 未起时需运维执行) - -## 1. 目标与背景 (Goal & Context) - -* **需求背景**:看板「OpenResty 入/出站」与 Zone「已提供数据」不一致;Agent 预聚合与访问日志双轨;`openresty_tx` 与 `bytes_sent` 业务语义重复。 -* **开发范围 (Scope)**: - * **必做**:业务趋势统一为访问日志聚合;UI 字段与文案收敛;协议补齐 `request_length`;停用预聚合作为权威源;Agent 瘦身。 - * **后续**:废弃 CH 表清理、hourly rollup 性能优化、Relay 指标对齐。 -* **Out of Scope**:通用日志平台、替换 ClickHouse、APM。 - ---- - -## 2. 设计与决策 (Design & Decisions) - -* **核心对象**:以 `of_node_access_logs` 为 L1 权威;主机 snapshot 为 L3;OpenResty 仅健康/连接为 L2。 -* **传输模型(示例与频率)**:见 [observability-transport-model.md](../design/observability-transport-model.md)。 -* **协议与表结构**:见 [observability-data-model.md](../design/observability-data-model.md)(NodePayload v2、落库流水线、DDL、废弃表)。 -* **API**:看板与 Zone 共用聚合语义;`bytes_provided` / `bytes_received`(兼容 `bytes_sent` 别名)。 -* **数据流**:见 [observability-design.md](../design/observability-design.md) §5。 -* **权衡**:性能用 Server 侧 rollup,不恢复 Agent 预聚合。 - ---- - -## 3. 阶段与修改清单 (Proposed Changes) - -### 阶段 M1 — 读路径切换(优先对账) - -* #### [MODIFY] `internal/apps/openflare/dashboard/*`、`observability/analytics.go` - * 业务 24h 趋势改为 access log 聚合(全局)。 - * 网络趋势中业务曲线与主机网卡分离。 -* #### [MODIFY] 前端 dashboard 组件与文案 - * 「OpenResty 出站/入站」→「已提供数据/接收数据」或拆卡片。 -* #### [MODIFY] Zone stats 字段对齐(如需别名) -* **验收**:单 Zone 流量时看板已提供 ≈ Zone 已提供。 - -### 阶段 M2 — 协议与入库补齐 - -* #### [MODIFY] `pkg/protocol/agent.go` — `NodeAccessLog.request_length` -* #### [MODIFY] Agent 解析与 CH 写入列 -* #### [MODIFY] goose ClickHouse migration(如缺列) - -### 阶段 M3 — 停写预聚合权威路径 - -* #### [MODIFY] Server persist:TrafficReport / openresty rx/tx 不再驱动看板 -* 可选:直接停写以减 CH 压力 - -### 阶段 M4 — Agent 瘦身 - -* #### [MODIFY] 移除 TrafficReport 构建主路径、Lua 业务 dict 计数、state 内业务累计 -* #### [MODIFY] 心跳仅明细 + snapshot + 连接/健康 - -### 阶段 M5 — 清理 - -* 删除废弃 API 字段、前端类型、CH 表/MV、相关测试夹具 -* 更新 agent-design / changelog(代码变更时) - ---- - -## 4. 验证计划 (Verification Plan) - -### 自动化 - -* `go test`:zone stats、dashboard 聚合、agent access log 解析 -* 前端:zone / dashboard 文案与字段测试 - -### 手动 - -* 制造已知大小响应,对比 Zone 与看板 24h 已提供数据 -* 确认宿主机网卡曲线与业务已提供数据分区展示、数值可不一致且文案不诱导对账 - -### 质量门禁 - -* `make swagger`(若 API 变更) -* `make code-check` -* `make prettier` - ---- - -## 5. 依赖与风险 - -* 明细量大时 M1 需同步评估 hourly rollup(仍 Server 侧)。 -* 旧 Agent 无 `request_length` 时接收数据为空,需 UI 降级。 diff --git a/docs/plan/20260718-access-log-cache-status.md b/docs/plan/20260718-access-log-cache-status.md deleted file mode 100644 index e08e5ff4..00000000 --- a/docs/plan/20260718-access-log-cache-status.md +++ /dev/null @@ -1,63 +0,0 @@ -# 访问日志 cache_status 明细可见 — 实现计划 - -说明:对应设计 [observability-data-model.md §3.5.1](../design/observability-data-model.md)。第一期只做明细可见,不上报 upstream 地址。 - ---- - -## 1. 目标与背景 - -* **需求背景**:访问日志无法判断请求是否命中边缘缓存、是否回源。 -* **开发范围 (Scope)**: - * **必做**:OpenResty 日志输出 `$upstream_cache_status`;Agent 上报;CH 入库;列表/详情展示三态标签。 - * **Out of Scope**:命中率看板、hourly 维度、`upstream_addr`。 - ---- - -## 2. 设计与决策 - -* **唯一字段**:`cache_status` string(原始值)。 -* **UI 三态(不落库)**: - * 命中:`HIT` / `STALE` / `REVALIDATED` / `UPDATING` - * 回源:`MISS` / `EXPIRED` - * 未缓存:`BYPASS` / `-` / 空 -* **数据流**:log_format → Agent parse → protocol → Server model → CH → API → 前端明细。 - ---- - -## 3. 修改清单 - -### 边缘 / 协议 - -* `pkg/render/openresty/types.go`、`internal/model/openflare_option.go`:`log_format` 增加 `cache_status` -* `internal/apps/agent/observability/traffic.go`:解析与映射 -* `pkg/protocol/agent.go`:`NodeAccessLog.CacheStatus` - -### Server / CH - -* goose:`202607180005_access_log_cache_status.sql` -* `internal/model/analytics/node_access_log.go`、writer、list/scan、store 映射 -* `internal/model/openflare_observability.go`、agent build records -* API `AccessLogView` + list 响应带 `cache_status` - -### 前端 - -* types / 明细列表标签 / 详情字段 -* 三态 helper:`resolveCacheOutcome(cache_status)` - ---- - -## 4. 验证 - -* `go test ./internal/apps/agent/observability/ ./internal/repository/analytics/ ./internal/apps/openflare/agent/` -* `make swagger`(若 Handler 响应结构变更) -* `make code-check` / `make prettier` - ---- - -## 5. 落地进度 - -* [x] log_format + protocol + agent parse -* [x] CH migration + 写入/读取 -* [x] API + 前端明细展示 -* [x] 缓冲去重 key 含 cache_status;保留 `-` 原始值 -* [x] 测试与提交 diff --git a/docs/plan/20260718-edge-cache-static-default.md b/docs/plan/20260718-edge-cache-static-default.md deleted file mode 100644 index e10c598f..00000000 --- a/docs/plan/20260718-edge-cache-static-default.md +++ /dev/null @@ -1,38 +0,0 @@ -# 边缘缓存默认 static 策略 — 实现计划 - -对应设计:[edge-cache-design.md](../design/edge-cache-design.md) - -## 目标 - -路由开启缓存后,**新建推荐**仅缓存标准静态扩展名(`static`);存量 `url`/空策略映射为 `all`,不收窄缓存范围。 - -## 兼容规则(评审后定稿) - -| 场景 | 行为 | -| --- | --- | -| 已启用 + `''` / `url` | 读 API / 快照 / 渲染 → **`all`** | -| 写入时 enabled 且 policy 为空 | 规范为 **`all`**(旧客户端兼容) | -| UI 新建/推荐默认 | **显式提交** `static` | -| 关闭缓存 | policy 存 `''`,rules 清空 | - -## 修改清单 - -1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;空/`url`/`all` 无路径限制 -2. **校验/展示** `proxy_route/helpers.go`:`normalizeCachePolicy` + `displayCachePolicy` -3. **快照** `config_version/logics.go`:`normalizeSnapshotCachePolicy` -4. **前端** `cache-section.tsx` + helpers:存量 empty/url→`all`;关闭时提交 `''`;新建默认 `static` -5. **测试** render + proxy_route -6. **设计/changelog** 同步兼容说明 - -## 验证 - -```bash -go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/ ./internal/apps/openflare/config_version/ -# 已通过(2026-07-18) -``` - -## 状态 - -- [x] 功能实现 + 评审修复(empty→all,禁止静默收窄) -- [ ] 提交 `fix(cache): ...`(待用户确认) -- [ ] 合并 / 发布后需重新发布节点配置 diff --git a/docs/plan/20260718-observability-ch-migrate-runbook.md b/docs/plan/20260718-observability-ch-migrate-runbook.md deleted file mode 100644 index 7ee38f22..00000000 --- a/docs/plan/20260718-observability-ch-migrate-runbook.md +++ /dev/null @@ -1,71 +0,0 @@ -# ClickHouse 观测表迁移与小时汇总回填(运维手册) - -适用:M5 观测存储(`of_node_edge_health`、`of_access_log_hourly`、删旧表)及历史小时回填。 - -## 前提 - -* 控制面 `config.yaml` / 环境变量中 ClickHouse 已启用,账号可写 `openflare` 库。 -* 备份策略已就绪(可选:对 `of_node_access_logs` 做快照)。 -* **Agent 升级策略为销毁重建**;勿混跑旧 Agent(旧协议字段已从 Server 删除)。 - -## 1. 自动迁移(推荐) - -进程启动时 `migrator.MigrateClickHouse()` 会按 goose 顺序执行: - -| 版本 | 作用 | -| --- | --- | -| `202607180001` | access log 增加 `request_length` / `request_time_ms` | -| `202607180002` | 建 `of_node_edge_health`、`of_access_log_hourly`(+MV);删 request_reports / openresty 吞吐表 | -| `202607180003` | 从明细 ANTI JOIN 回填近 90 天 `of_access_log_hourly` | - -启动 API / all 模式一次即可: - -```bash -# 示例:本地 -./bin/openflare api -# 或 -make run # 以项目实际入口为准 -``` - -查看 goose 版本表(ClickHouse)确认三版本均已应用。 - -## 2. 仅回填(迁移已执行、MV 创建前缺历史) - -若只需重跑回填 SQL: - -```bash -clickhouse-client --host 127.0.0.1 --port 9000 \ - --user default --password "$CLICKHOUSE_PASSWORD" \ - --database openflare \ - --multiquery < internal/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql -``` - -(goose 文件含 `+goose Up` 注释,若 client 报错可去掉注释行后执行 INSERT 主体。) - -回填可重复:`ANTI JOIN` 跳过已有 `(node_id, hour, host)`。 - -## 3. 验收 - -```sql --- 新表存在 -SHOW TABLES FROM openflare LIKE 'of_node_edge_health'; -SHOW TABLES FROM openflare LIKE 'of_access_log_hourly'; - --- 旧表应不存在 -SHOW TABLES FROM openflare LIKE 'of_node_request_reports'; -SHOW TABLES FROM openflare LIKE 'of_node_obs_openresty'; - --- 小时汇总有数据(有历史访问时) -SELECT count() FROM of_access_log_hourly; -SELECT min(hour), max(hour), sum(request_count) FROM of_access_log_hourly; -``` - -看板 24h 请求趋势应优先走 hourly;UV 卡片为整窗独立访客,**不等于**小时 UV 之和。 - -## 4. 本机执行记录 - -| 日期 | 环境 | 结果 | -| --- | --- | --- | -| 2026-07-18 | 开发机 | Docker daemon 未启动,未能 live 迁移;SQL 与 goose 文件已入库 | - -运维在目标环境按 §1–§3 执行后更新本表。 diff --git a/docs/plan/20260719-access-log-ip-tab.md b/docs/plan/20260719-access-log-ip-tab.md deleted file mode 100644 index 8a3354ce..00000000 --- a/docs/plan/20260719-access-log-ip-tab.md +++ /dev/null @@ -1,161 +0,0 @@ -# 访问日志 IP 明细 Tab — 实现计划 - -## 1. 目标与背景 (Goal & Context) - -* **需求背景**:运维需要按 IP 维度快速查看时间窗内的访问量与流量,并下钻单 IP 情报;原先 IP 分析嵌在「单条访问日志详情」中,入口弱、列表能力缺失。 -* **开发范围 (Scope) V1**: - * 访问日志页新增第三 Tab **「IP 明细」**(`?tab=ips`)。 - * IP 列表:时间筛选(快捷 24h/7d/15d/30d + 自定义 since/until)、分页、按请求数 / 入站 / 出站 / 最后访问 / 2xx 比例排序。 - * 列表列:IP、地区、请求数、2xx 比例(2xx 数 / 总请求)、入站流量、出站流量、最后访问。 - * 行详情:弹窗展示完整 **IP 情报**(分析 + 趋势 + Top 分布 + 加入 WAF IP 组)。 - * **日志明细详情弹窗仅展示单条请求字段**,不再内嵌 IP 情报;如需分析请到 IP 明细。 -* **Out of Scope(V1 不做)**: - * 独立 `/access-logs/ip` 子路由全页。 - * IP 列表 UI 暴露节点 / host 筛选(后端可保留兼容参数,前端首版不放)。 - * 入/出站带宽时间序列(趋势图仍为请求数)。 - * 实时 GeoIP 二次查询(沿用入库 `region`)。 - -## 2. 设计与决策 (Design & Decisions) - -### 2.1 页面与交互 - -| Tab | URL | 内容 | -| --- | --- | --- | -| 概览 | `/access-logs` | 不变 | -| IP 明细 | `/access-logs?tab=ips` | 新 | -| 日志明细 | `/access-logs?tab=list` | 不变;详情弹窗瘦身 | - -* **时间筛选(IP 明细)**: - * 快捷:`hours` ∈ {24, 168, 360, 720},默认 168(7d)。 - * 自定义:`since` + `until`(RFC3339);**同时提供时覆盖 hours**。 -* **详情形态**:留在列表页的 Dialog(非独立子页)。 -* **日志详情瘦身**:`access-log-detail-dialog` 只渲染请求字段(时间、节点、IP、地区、host、path、UA、cache、status 等)及必要操作;删除 analysis/trend/WAF 组内嵌区块。WAF「按 IP 加入组」仅保留在 IP 详情弹窗。 - -### 2.2 API 设计(扩展现有端点,不新建) - -**`GET /api/v1/d/access-logs/ip-summary`** - -| 参数 | 说明 | -| --- | --- | -| `hours` | 1–720;默认 168;在无 since/until 时生效 | -| `since` / `until` | 可选 RFC3339;同时有效时优先于 hours | -| `sort_by` | `total_requests`(默认)\| `request_length`(入站)\| `bytes_sent`(出站)\| `last_seen_at` \| `success_ratio` | -| `sort_order` | `asc` \| `desc` | -| `p` / `page_size` | 分页,page_size 上限 200 | -| `remote_addr` / `node_id` / `host` | 兼容保留;V1 UI 可不暴露 | - -**响应行字段(扩展)** - -```text -remote_addr string -region string // 窗内 argMax(region, logged_at) 或等价 -total_requests uint64 -success_2xx_count uint64 // status_code 200–299 -success_ratio float64 // success_2xx_count / total_requests;total=0 时为 0 -bytes_received uint64 // sum(request_length) 入站 -bytes_sent uint64 // sum(bytes_sent) 出站 -last_seen_at time -``` - -* `recent_requests`:可停止计算或固定返回 0;**UI 不展示**。避免与可配置时间窗语义冲突。 -* 详情下钻仍用现有: - * `GET .../ip-summary/analysis?remote_addr=&hours=`(或 since/until,若后续扩展;V1 将列表当前窗映射为 hours 或 since/until 与后端约定一致) - * `GET .../ip-summary/trend?remote_addr=&hours=&bucket_minutes=` - -**分析/趋势时间窗对齐**:打开 IP 详情时,将列表当前时间窗传入 analysis/trend(优先 since/until;仅有 hours 则传 hours)。 - -### 2.3 数据层(ClickHouse) - -* 表:`of_node_access_logs`(已有 `bytes_sent`、`request_length`、`status_code`、`region`)。 -* 聚合:`GROUP BY remote_addr`,在 `NodeAccessLogFilter.Since/Until` 上过滤。 -* 2xx:`countIf(status_code >= 200 AND status_code < 300)`。 -* region:`argMax(region, logged_at)`。 -* 排序:服务端 ORDER BY 对应表达式;`success_ratio` 注意除零(`if(total=0,0,ratio)`)。 - -### 2.4 设计决策权衡 - -| 选项 | 结论 | -| --- | --- | -| 扩展 `/ip-summary` vs 新 `/ip-list` | **扩展现有**,前端 service 已有 `listIPSummaries` | -| 详情弹窗 vs 子页 | **弹窗**,与现有明细交互一致 | -| IP 情报放日志详情 vs 独立 IP 详情 | **仅 IP 明细详情**;日志详情只展示请求信息 | -| 时间:仅快捷 vs 仅自定义 | **两者都要**,自定义优先 | - -### 2.5 数据流(示意) - -```mermaid -flowchart LR - UI_IP[IP 明细 Tab] --> API_List[GET /ip-summary] - API_List --> CH[(of_node_access_logs)] - UI_IP --> UI_Dlg[IP 详情 Dialog] - UI_Dlg --> API_A[GET /ip-summary/analysis] - UI_Dlg --> API_T[GET /ip-summary/trend] - API_A --> CH - API_T --> CH - UI_List[日志明细 Tab] --> API_Logs[GET /access-logs] - UI_List --> UI_LogDlg[日志详情 Dialog] - UI_LogDlg -.->|不请求 IP 分析| X[仅请求字段] -``` - -## 3. 具体修改文件清单 (Proposed Changes) - -### 后端 Server - -* #### [MODIFY] `internal/repository/analytics/node_access_log_stats.go`(及 filter 如有) - * `IPSummariesNodeAccessLogs`:时间窗、sum 入/出、2xx count、ratio、region、扩展 sort。 -* #### [MODIFY] `internal/apps/openflare/observability/access_log_logics.go` - * Query/View 类型扩展;解析 hours/since/until;去掉或忽略 recent 3h 硬编码。 -* #### [MODIFY] `internal/apps/openflare/observability/routers.go` / handler - * 绑定新 query;Swagger 注释。 -* #### [MODIFY] 相关单元测试(logics / repository 若有) - -### 前端 Web - -* #### [MODIFY] `frontend/app/(main)/access-logs/page.tsx` - * 第三 Tab `ips`;`resolveTab` / `handleTabChange`。 -* #### [NEW] `frontend/app/(main)/access-logs/components/ip-tab.tsx` - * 列表、时间筛选、排序、分页、打开详情。 -* #### [NEW] `frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx` - * IP 入口详情壳。 -* #### [NEW] `frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx` - * 从现有 `access-log-detail-dialog` **迁出** 分析/趋势/排行/WAF IP 组逻辑。 -* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx` - * **删除** IP 情报相关 UI 与 `getIPAnalysis` / `getIPTrend` 请求;仅请求日志字段展示。 -* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-utils.ts` - * tab 类型、IP 排序选项、时间筛选辅助。 -* #### [MODIFY] `frontend/lib/services/openflare/access-log.service.ts` + `types.ts` - * `listIPSummaries` 参数与 `AccessLogIPSummaryItem` 字段同步。 - -### 文档 - -* #### [MODIFY] `docs/changelog/index.md` — `[Unreleased]` 用户可见说明 -* #### [MODIFY] `docs/design/observability-design.md` 或 data-model(如有访问日志 UI 约定)— 补充 IP 明细 Tab 与 API 字段(中文) -* #### [MODIFY] `docs/plan/index.md` — 挂上本计划链接 - -## 4. 验证计划 (Verification Plan) - -### 自动化 - -```bash -go test ./internal/apps/openflare/observability/ ./internal/repository/analytics/ -# 前端:相关 tsc / 页面无类型错误 -make code-check # 完成后按项目门禁 -make prettier -make swagger # API 注释变更后 -``` - -### 手动 - -1. `/access-logs?tab=ips` 默认 7d 列表有数据;切换 24h/自定义区间结果变化。 -2. 分别按请求数、入站、出站、2xx 比例、最后访问排序正确。 -3. 2xx 比例 = 2xx/总数;0 请求不出现 NaN/Infinity。 -4. 点 IP 打开详情:指标/趋势/Top/WAF 组可用;时间窗与列表一致。 -5. 日志明细 → 详情:仅请求信息,**无** IP 分析/趋势区块。 -6. 概览 Tab 行为无回归。 - -## 5. 状态 - -- [x] 需求澄清与方案确认 -- [x] 实现(后端 ip-summary 扩展 + 前端 IP 明细 Tab + 日志详情瘦身) -- [x] 测试与 changelog(`go test` 相关包通过;changelog 已更新) -- [ ] 提交合并 diff --git a/docs/plan/20260719-http-default-rate-limit.md b/docs/plan/20260719-http-default-rate-limit.md deleted file mode 100644 index c2966122..00000000 --- a/docs/plan/20260719-http-default-rate-limit.md +++ /dev/null @@ -1,709 +0,0 @@ -# 边缘限流全局默认 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。 - -**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。 - -**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService - -**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md) - -## Global Constraints - -- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`) -- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate` -- 全局默认初始 `0`/空 → 存量行为不变 -- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档 -- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由) - -## File map - -| 文件 | 职责 | -|------|------| -| `internal/model/system_configs.go` | 三个 ConfigKey 常量 | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 | -| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 | -| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 | -| `internal/apps/openflare/config_version/logics.go` | option diff keys | -| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 | -| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 | -| `pkg/render/openresty/render_test.go` | 合并渲染单测 | -| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 | -| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 | -| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 | -| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI | -| `frontend/lib/utils/search-data.ts` | 搜索入口 | -| `docs/reference/configuration.md` | 配置键说明 | -| `docs/changelog/index.md` | Unreleased | -| `docs/plan/index.md` | 进行中计划索引 | - ---- - -### Task 1: Render 合并(TDD 核心) - -**Files:** -- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`) -- Modify: `pkg/render/openresty/render.go` -- Test: `pkg/render/openresty/render_test.go` - -**Interfaces:** -- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等) -- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig` -- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string` - -- [ ] **Step 1: 写失败单测** - -在 `render_test.go` 末尾追加: - -```go -func TestMergeRouteLimitConfig(t *testing.T) { - t.Parallel() - cases := []struct { - name string - route Route - cfg ConfigSnapshot - want routeLimitConfig - }{ - { - name: "both zero off", - route: Route{}, - cfg: ConfigSnapshot{}, - want: routeLimitConfig{}, - }, - { - name: "inherit all defaults", - route: Route{}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"}, - }, - { - name: "explicit off ignores default", - route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{}, - }, - { - name: "route overrides default", - route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"}, - }, - { - name: "partial inherit", - route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "256k", - }, - want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := mergeRouteLimitConfig(tc.route, tc.cfg) - if got != tc.want { - t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want) - } - }) - } -} - -func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) { - doc := Document{ - Routes: []Route{{ - SiteName: "example.com", - Domains: []string{"example.com"}, - Enabled: true, - OriginURL: "http://127.0.0.1:8080", - Upstreams: []string{"http://127.0.0.1:8080"}, - }}, - OpenRestyConfig: ConfigSnapshot{ - DefaultLimitConnPerServer: 120, - DefaultLimitConnPerIP: 12, - DefaultLimitRate: "512k", - }, - } - rendered, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatalf("RenderRouteConfig() error = %v", err) - } - for _, want := range []string{ - "limit_conn openflare_conn_per_server 120;", - "limit_conn openflare_conn_per_ip 12;", - "limit_rate 512k;", - } { - if !strings.Contains(rendered, want) { - t.Fatalf("expected %q in route config, got:\n%s", want, rendered) - } - } -} - -func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) { - doc := Document{ - Routes: []Route{{ - SiteName: "example.com", - Domains: []string{"example.com"}, - Enabled: true, - OriginURL: "http://127.0.0.1:8080", - Upstreams: []string{"http://127.0.0.1:8080"}, - LimitConnPerServer: -1, - LimitConnPerIP: -1, - LimitRate: "-1", - }}, - OpenRestyConfig: ConfigSnapshot{ - DefaultLimitConnPerServer: 120, - DefaultLimitConnPerIP: 12, - DefaultLimitRate: "512k", - }, - } - rendered, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatalf("RenderRouteConfig() error = %v", err) - } - if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") { - t.Fatalf("expected no limit directives, got:\n%s", rendered) - } -} -``` - -- [ ] **Step 2: 跑测确认失败** - -```bash -go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1 -``` - -Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符) - -- [ ] **Step 3: 实现 types + merge + 调用** - -`ConfigSnapshot` 增加: - -```go -DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` -DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` -DefaultLimitRate string `json:"default_limit_rate,omitempty"` -``` - -`render.go` 中 `RenderRouteConfig` 将: - -```go -limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate} -``` - -改为: - -```go -limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig) -``` - -并新增: - -```go -func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig { - return routeLimitConfig{ - LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer), - LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP), - LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate), - } -} - -func mergeLimitConn(route, def int) int { - if route == -1 { - return 0 - } - if route > 0 { - return route - } - if def > 0 { - return def - } - return 0 -} - -func mergeLimitRate(route, def string) string { - r := strings.ToLower(strings.TrimSpace(route)) - if r == "-1" { - return "" - } - if r != "" && r != "0" { - return r - } - d := strings.ToLower(strings.TrimSpace(def)) - if d != "" && d != "0" { - return d - } - return "" -} -``` - -- [ ] **Step 4: 跑测通过** - -```bash -go test ./pkg/render/openresty/ -count=1 -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go -git commit -m "feat(openresty): merge global default limits at route render" -``` - ---- - -### Task 2: 配置键、迁移、校验、快照 - -**Files:** -- Modify: `internal/model/system_configs.go` -- Create: `internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql` -- Modify: `internal/apps/openflare/option/validate.go` -- Modify: `internal/apps/openflare/option/openresty_validators.go` -- Modify: `internal/apps/openflare/config_version/snapshot.go` -- Modify: `internal/apps/openflare/config_version/logics.go` - -**Interfaces:** -- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名 -- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见 - -- [ ] **Step 1: 常量** - -在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入: - -```go -ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接 -ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接 -ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽 -``` - -- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)** - -```sql --- +goose Up -INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - --- +goose Down -DELETE FROM w_system_configs WHERE key IN ( - 'openresty_default_limit_conn_per_server', - 'openresty_default_limit_conn_per_ip', - 'openresty_default_limit_rate' -); -``` - -SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。 - -- [ ] **Step 3: 校验器** - -`validate.go` 增加: - -```go -func validateNonNegativeIntegerOption(key, value string) error { - intValue, err := strconv.Atoi(value) - if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数", key) - } - return nil -} -``` - -`openresty_validators.go` 注册: - -```go -model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption, -model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, -model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, -``` - -```go -var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) - -func validateOpenRestyDefaultLimitRate(key, trimmed string) error { - if trimmed == "" || trimmed == "0" { - return nil - } - if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) { - return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key) - } - return nil -} -``` - -- [ ] **Step 4: 快照读取(注意 0 合法)** - -`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。 - -**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为: - -```go -getNonNegIntConfig := func(key string, defaultVal int) int { - val, err := repository.GetIntByKey(ctx, key) - if err != nil || val < 0 { - return defaultVal - } - return val -} -``` - -```go -DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0), -DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), -DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), -``` - -若 `DefaultLimitRate == "0"`,规范化为 `""`。 - -确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐: - -```go -DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` -DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` -DefaultLimitRate string `json:"default_limit_rate,omitempty"` -``` - -- [ ] **Step 5: option diff** - -在 `diffOpenRestyOptionDetails` 末尾: - -```go -appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer)) -appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP)) -appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate) -``` - -`openRestyOptionKeys()` 同步追加这三 key 字符串。 - -- [ ] **Step 6: 编译/相关测试** - -```bash -go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1 -``` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add internal/model/system_configs.go \ - internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \ - internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \ - internal/apps/openflare/option/validate.go \ - internal/apps/openflare/option/openresty_validators.go \ - internal/apps/openflare/config_version/snapshot.go \ - internal/apps/openflare/config_version/logics.go -git commit -m "feat(config): add openresty default rate limit system options" -``` - ---- - -### Task 3: 站点 normalize 允许 -1 - -**Files:** -- Modify: `internal/apps/openflare/proxy_route/helpers.go` -- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案) -- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go` - -**Interfaces:** -- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"` - -- [ ] **Step 1: 失败单测** - -```go -func TestNormalizeProxyRouteLimitConnValue(t *testing.T) { - t.Parallel() - got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server") - if err != nil || got != -1 { - t.Fatalf("want -1, got %d err %v", got, err) - } - if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil { - t.Fatal("expected error for -2") - } -} - -func TestNormalizeProxyRouteLimitRate(t *testing.T) { - t.Parallel() - got, err := normalizeProxyRouteLimitRate("-1") - if err != nil || got != "-1" { - t.Fatalf("want -1, got %q err %v", got, err) - } - got, err = normalizeProxyRouteLimitRate("0") - if err != nil || got != "" { - t.Fatalf("want empty inherit, got %q err %v", got, err) - } -} -``` - -- [ ] **Step 2: 实现** - -```go -func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) { - if value < -1 { - return 0, fmt.Errorf("%s must be greater than or equal to -1", field) - } - return value, nil -} - -func normalizeProxyRouteLimitRate(raw string) (string, error) { - normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" || normalized == "0" { - return "", nil - } - if normalized == "-1" { - return "-1", nil - } - if !proxyRouteLimitRatePattern.MatchString(normalized) { - return "", errors.New(errProxyRouteLimitRate) - } - if strings.TrimRight(normalized, "km") == "" { - return "", nil - } - return normalized, nil -} -``` - -可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。 - -- [ ] **Step 3: 测试** - -```bash -go test ./internal/apps/openflare/proxy_route/ -count=1 -``` - -- [ ] **Step 4: Commit** - -```bash -git add internal/apps/openflare/proxy_route/ -git commit -m "feat(proxy-route): allow -1 to disable rate limits" -``` - ---- - -### Task 4: 前端 — 安全性「限流」页 + 站点文案 - -**Files:** -- Modify: `frontend/lib/navigation/openflare-nav.ts` -- Create: `frontend/app/(main)/rate-limits/page.tsx` -- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` -- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts` -- Modify: `frontend/lib/utils/search-data.ts` - -**Interfaces:** -- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等 -- Produces: `/rate-limits` 管理页;站点表单接受 `-1` - -- [ ] **Step 1: 导航** - -`openflareSecurityNavGroup.items`: - -```ts -{ title: 'WAF', url: '/waf' }, -{ title: 'IP 组', url: '/ip-groups' }, -{ title: '限流', url: '/rate-limits' }, -``` - -- [ ] **Step 2: 搜索** - -`search-data.ts` 在 IP 组后增加: - -```ts -{ - id: 'console-rate-limits', - title: '限流', - description: '配置边缘站点默认并发与带宽限流策略', - url: '/rate-limits', - category: 'page', - keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'], -}, -``` - -- [ ] **Step 3: 限流设置页** - -新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`: - -- `useAuth` 管理员校验 -- `OptionService.list` / `updateBatch` -- 三字段表单 + 单卡片保存 -- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」 -- 描述:空/0 表示默认关闭;修改后需在版本发布中生效 -- keys: - - `openresty_default_limit_conn_per_server` - - `openresty_default_limit_conn_per_ip` - - `openresty_default_limit_rate` -- conn:非负整数;rate:空或 `^\d+[kKmM]?$` -- 保存成功 toast + invalidate options / config-preview / config-versions -- 链到 `/config-versions` - -页面骨架要点(完整实现时展开为完整组件,勿留半成品): - -```tsx -// 字段 state、OptionService.list map、updateBatch([{key,value},...]) -// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」 -``` - -- [ ] **Step 4: 站点 limits-section** - -1. schema:conn 允许空、`0`、`-1`、正整数: - -```ts -if (!rawValue) continue; -if (!/^-1$|^\d+$/.test(rawValue)) { - context.addIssue({ ..., message: '请输入 -1、0 或正整数' }); -} -``` - -2. `validateLimitRate` / `normalizeLimitRate`: - -```ts -export function validateLimitRate(value: string) { - const normalized = value.trim(); - if (!normalized || normalized === '0' || normalized === '-1') { - return null; - } - if (!limitRatePattern.test(normalized)) { - return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭'; - } - return null; -} - -export function normalizeLimitRate(value: string) { - const normalized = value.trim().toLowerCase(); - if (normalized === '0') return ''; - return normalized; // 保留 -1 -} -``` - -3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空) - -4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字 - -5. 文案: - -``` -description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。' -FormDescription 同步说明 -``` - -6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。` - -- [ ] **Step 5: prettier + 类型检查(按项目习惯)** - -```bash -make prettier -# 若有前端 typecheck: -# cd frontend && pnpm exec tsc --noEmit -``` - -- [ ] **Step 6: Commit** - -```bash -git add frontend/lib/navigation/openflare-nav.ts \ - frontend/app/\(main\)/rate-limits/ \ - frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \ - frontend/app/\(main\)/proxy-routes/components/helpers.ts \ - frontend/lib/utils/search-data.ts -git commit -m "feat(frontend): add security rate-limits page and inherit UI" -``` - ---- - -### Task 5: 文档、索引、门禁 - -**Files:** -- Modify: `docs/reference/configuration.md`(OpenResty 配置表) -- Modify: `docs/changelog/index.md` `[unreleased]` -- Modify: `docs/plan/index.md` - -- [ ] **Step 1: configuration.md** - -在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入: - -```md -| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` | -| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` | -| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 | -``` - -- [ ] **Step 2: changelog** - -`[unreleased]` 下: - -```md -### 新增 - -- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。 - -### 改进 - -- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。 -``` - -- [ ] **Step 3: plan index** - -`docs/plan/index.md` 进行中列表增加: - -```md -* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。 -``` - -- [ ] **Step 4: 全量门禁** - -```bash -make code-check -make prettier -``` - -Expected: 通过;修复任何报错后再提交。 - -- [ ] **Step 5: Commit** - -```bash -git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md -git commit -m "docs: document default edge rate limits" -``` - ---- - -## Spec coverage checklist - -| Spec 要求 | Task | -|-----------|------| -| 三项全局默认 | 2, 4 | -| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 | -| 仅 `RenderRouteConfig` 合并 | 1 | -| 快照保留原始站点值 | 2(不写回 route) | -| 安全性子页「限流」 | 4 | -| 初始 0/空兼容 | 2 seed | -| option diff / 发布 | 2 | -| 测试合并/normalize | 1, 3 | -| 中文文档/changelog | 5 | -| 非目标 limit_req / http 级指令 | 未做 | - -## 手动验收 - -1. 迁移后三键存在且为 `0`/空 -2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存 -3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate` -4. 站点将该项改为 `-1` 保存并发布:该维度指令消失 -5. 站点改为 `50`:输出 50 而非全局值 diff --git a/docs/plan/20260719-pages-source-sync-v2.md b/docs/plan/20260719-pages-source-sync-v2.md deleted file mode 100644 index 09fe265f..00000000 --- a/docs/plan/20260719-pages-source-sync-v2.md +++ /dev/null @@ -1,1505 +0,0 @@ -# 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:::: -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:`,自动任务为 `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:` | `manual_upload` | -| 一次性 URL | `user:` | `manual_url` | -| 持久来源手动 sync | `user:` | `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 最小矩阵。 diff --git a/docs/plan/20260719-waf-ip-matcher-radix.md b/docs/plan/20260719-waf-ip-matcher-radix.md deleted file mode 100644 index 5c26d776..00000000 --- a/docs/plan/20260719-waf-ip-matcher-radix.md +++ /dev/null @@ -1,38 +0,0 @@ -# WAF IP 匹配:Radix / lua-resty-ipmatcher - -## 1. 目标与背景 (Goal & Context) - -* **需求背景**:`ip_match` 对 IP 组 `ip_list` 做线性扫描,且每行强制 `ipv6_equal` + `ip_in_cidr`,大名单(订阅/自动规则可达万~十万级)时压测 RPS 约 65、OpenResty CPU 打满。 -* **开发范围 (Scope)**: - * **必做**:边缘热路径改为预处理索引 + O(W) 查询;IP 组快照加载时编译;节点内联 `ips`/`cidrs` 同样编译;Agent 镜像安装 `lua-resty-ipmatcher`;规格与 changelog。 - * **Out of Scope**:控制面协议变更、改 IP 组存储格式、Geo 匹配优化。 - -## 2. 设计与决策 (Design & Decisions) - -* **选型**:OpenResty 使用 `resty.ipmatcher`(底层 Radix,支持 IP 与 CIDR 统一;可用 `match_bin(binary_remote_addr)`)。 -* **编译时机**: - * IP 组:`waf.ip_groups` 采纳新快照时为每组 `ip_list` 建 matcher,挂到 `group._matcher`。 - * 节点 `ips`/`cidrs`:首次匹配时合并列表建 matcher,用 weak 缓存或按 config 引用缓存。 -* **回退**:`require("resty.ipmatcher")` 失败时用纯 Lua「exact set + 预解析 CIDR」回退(测试 / 未装 opm 的本地 OpenResty),避免回归到每行 IPv6 全解析。 -* **不引入**:手写纯 Lua 十万节点 table 树作为生产主路径(内存与 GC 差)。 - -## 3. 具体修改文件清单 (Proposed Changes) - -### 边缘 Agent 与 OpenResty - -* #### [MODIFY] `docker/Dockerfile.agent` - * **不**通过 OPM 安装 ipmatcher(`api7` 账号在 OPM 不存在)。 -* #### [NEW] `internal/apps/agent/nginx/resty/ipmatcher.lua`(vendor api7 v0.6.1) - * 随 `ManagedWAFLuaFiles` 部署到 `/resty/ipmatcher.lua`,由 `lua_package_path` 加载。 -* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime.lua` - * 编译/查询 helper;重写 `matches_ip_values`。 -* #### [MODIFY] `internal/apps/agent/nginx/waf_ip_groups.lua` - * 无需在刷新模块内编译;快照采纳后由 `waf.runtime` 惰性编译 `group._matcher`。 -* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime_spec.lua` / `waf_ip_groups_spec.lua` - * 覆盖 exact/CIDR/IPv6/组 miss;大名单语义 smoke。 -* #### [MODIFY] `docs/changelog/index.md`、相关设计/plan 备注 - -## 4. 验证计划 (Verification Plan) - -* `go test ./internal/apps/agent/nginx/ -count=1` -* 重建 Agent 镜像后压测:三组大名单 miss 路径 CPU/RPS 对比。 diff --git a/docs/plan/20260723-edge-cache-cf-align.md b/docs/plan/20260723-edge-cache-cf-align.md deleted file mode 100644 index bf8e8e23..00000000 --- a/docs/plan/20260723-edge-cache-cf-align.md +++ /dev/null @@ -1,85 +0,0 @@ -# 边缘缓存对齐 Cloudflare 默认模型 — 实现计划 - -对应设计:[edge-cache-design.md](../design/edge-cache-design.md) - -## 1. 目标与背景 - -* **需求背景**:现网对会话 Cookie / Authorization / 请求 Cache-Control 一律旁路,登录用户静态资源几乎全是「未缓存」,命中率远低于 Cloudflare 默认。需对齐 CF 两段闭环:请求 eligible × 响应可共享缓存。 -* **Scope(必做)** - 1. 删除请求侧 Cookie、Authorization、请求 Cache-Control 旁路 - 2. 响应侧:`proxy_no_cache` 绑定 `$upstream_http_set_cookie` - 3. 默认 `proxy_cache_valid`(200/206/301→120m,302/303→20m,404/410→3m) - 4. 默认静态扩展名移除 `json`;保留 `map`/`mjs`/`wasm` - 5. 渲染单测 + UI 文案 + 设计/changelog -* **Out of Scope**:Purge、Cache Rules、强制忽略源站 CC、Auth RFC 条件缓存、HEAD→GET - -## 2. 设计决策摘要 - -| 决策 | 选择 | -| --- | --- | -| 请求 Cookie | 不旁路(对齐 CF) | -| Set-Cookie | 不入库 | -| 无源站 CC | 状态码默认 Edge TTL | -| json | 默认表移除 | -| 兼容 | `url`→`all` 不变;行为变更需重新发布配置 | - -## 3. 修改清单 - -### 边缘渲染 - -* #### [MODIFY] `pkg/render/openresty/render.go` - * `renderRouteCacheBlock`:仅保留非 GET 旁路;`proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie`;追加三行 `proxy_cache_valid` -* #### [MODIFY] `pkg/render/openresty/types.go` - * `DefaultStaticCacheExtensions`:去掉 `json` -* #### [MODIFY] `pkg/render/openresty/render_test.go` - * 断言:无 cookie/auth/cache_control 旁路;含 set_cookie 与 proxy_cache_valid;表不含 json、含 map - -### 前端 - -* #### [MODIFY] `frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx` - * 去掉「绕过登录 Cookie / Authorization」类文案 - * 改为 CF 对齐说明:源站 private/no-store、Set-Cookie 不入库、默认静态不含 HTML/JSON - -### 文档 - -* #### [MODIFY] `docs/design/edge-cache-design.md`(已更新) -* #### [MODIFY] `docs/changelog/index.md` `[Unreleased]` -* #### [MODIFY] `docs/plan/index.md` 登记本计划 - -### 不改 - -* 无 DB 迁移 -* 无 API 字段变更(策略枚举不变) - -## 4. 验证计划 - -### 自动化 - -```bash -go test ./pkg/render/openresty/ -make format -make code-check -``` - -### 数据面(配置发布后) - -1. 站点 `cache_enabled` + `static`,全局缓存开 -2. 带 session Cookie:`GET /static/app.js` 第二次应 HIT -3. `GET /index.html` 应为未缓存 -4. 源站返回 `Set-Cookie` 的静态 URL 不应出现稳定 HIT -5. 访问日志 `cache_status` 与三态一致 - -## 5. 发布注意 - -* 节点需 **重新发布/拉取配置版本** 后旁路变更才生效 -* 若站点依赖边缘缓存 `*.json`,改为 `suffix` 含 json 或 `all` - -## 6. 状态 - -- [x] 设计定稿(用户确认:全量对齐 CF) -- [x] 渲染与单测 -- [x] UI 文案 -- [x] changelog / plan index -- [x] `make format` + `make code-check` -- [x] 复查补强:`all` 策略 UI 警告;proxy-config / troubleshooting 运维说明 -- [ ] 用户确认后提交 diff --git a/docs/plan/20260724-model-repository-layering.md b/docs/plan/20260724-model-repository-layering.md deleted file mode 100644 index 00c25d30..00000000 --- a/docs/plan/20260724-model-repository-layering.md +++ /dev/null @@ -1,44 +0,0 @@ -# model / repository 分层治理 - -说明:将 `internal/model` 收敛为无 IO 实体层,`internal/repository` 作为唯一持久化入口。 - ---- - -## 1. 目标与背景 (Goal & Context) - -* **需求背景**:已提交的 AGENTS 曾允许 model 直接使用 GORM,导致 OpenFlare 业务 CRUD 与平台 repository 双轨并存。工作区目标分层与代码不一致,接手成本高。 -* **开发范围 (Scope)**: - * 固化规范:`model` 仅实体 / DTO / 无 IO 规则;`repository` 唯一持久化入口。 - * 将 `internal/model` 中现有 `db.DB` / Redis / ClickHouse store 适配迁入 `internal/repository`。 - * 全量更新 call site:`model.Get/List/Create…` → `repository.…`。 - * 编译通过 + `make format` + 相关单测。 -* **Out of Scope**:不改表结构、不改 API 契约、不做业务行为变更;不强制一次重写所有测试风格。 - -## 2. 设计与决策 - -* **分层**: - * `apps → repository → model` - * `repository → infra/persistence`(及 `repository/analytics`) - * **禁止** `model → repository`、**禁止** model 内 `db.DB` / Redis / ClickHouse -* **迁移策略**:按文件拆分 package-level IO 函数至同名 `repository` 文件;类型与纯函数留在 model;store 适配整文件迁入 repository。 -* **命名**:repository 函数保持原导出名,降低 call site 改动面。 - -## 3. 具体修改文件清单 - -### 规范 - -* #### [MODIFY] `AGENTS.md` -* #### [MODIFY] `docs/design/index.md` -* #### [MODIFY] `docs/plan/index.md`(登记本计划) - -### 后端 - -* #### [MODIFY] `internal/model/*.go`(剥离 IO) -* #### [NEW/MODIFY] `internal/repository/*.go`(承接 CRUD / store) -* #### [MODIFY] `internal/apps/**`、`internal/infra/task/**` 等 call site - -## 4. 验证计划 - -* `go test ./internal/model/... ./internal/repository/...` -* 关键包 `go build ./...` -* `make format` / `make code-check`(在可接受时间内) diff --git a/docs/plan/20260804-cloudflare-pointing.md b/docs/plan/20260804-cloudflare-pointing.md deleted file mode 100644 index 33cd5ac7..00000000 --- a/docs/plan/20260804-cloudflare-pointing.md +++ /dev/null @@ -1,151 +0,0 @@ -# Cloudflare DNS 指向实现计划 - -> 状态:代码实施完成(2026-08-04;范围内自动化验证完成;全量前端仅保留任务开始前已存在的 Zone 文案断言失败) - -> **执行方式**:使用 `superpowers:executing-plans` 在当前会话按任务逐项实施;每项遵循测试先行(RED → GREEN → REFACTOR)。 - -## 1. 目标与背景 (Goal & Context) - -* **需求背景**:落实提交 `21fb303e` 中的 Cloudflare DNS 指向设计,让管理员以 ZoneDomain 为粒度,将明确 FQDN 的单条 A 记录幂等指向 OpenFlare 边缘节点 IPv4,避免在 Cloudflare 控制台重复手工操作。 -* **开发范围 (Scope)**: - * 全局一份 Cloudflare 连接,支持从现有 Cloudflare DNS 账号导入或独立录入 API Token。 - * 指向分组、成员、主/备/生效节点、成员橙云、同步状态与错误信息。 - * Cloudflare Zone/DNS Record HTTP 客户端与单成员幂等 reconcile。 - * 手动同步、成员/分组变更同步、节点 IP 变化 best-effort 入队。 - * 管理 API、Swagger、前端总览/设置/分组列表/分组详情和侧边栏入口。 -* **Out of Scope**:自动故障切换/回切、AAAA、多 A 负载、CNAME、定时全量对账、非 Cloudflare DNS 厂商、多 Cloudflare 账号并行。 - -## 2. 设计与决策 (Design & Decisions) - -### 核心对象/数据模型 - -* `of_cf_connections`:全局连接;`source` 为 `dns_account` 或 `standalone`,独立 Token 使用现有 `enc:v1:` 密文格式,响应永不暴露凭据。 -* `of_cf_pointing_groups`:分组名、主节点、可选备用节点、生效节点、默认橙云和启用状态;一期 `active_node_id = primary_node_id`。 -* `of_cf_pointing_members`:分组、全局唯一 `zone_domain_id`、成员橙云、Cloudflare Zone/Record ID 缓存、期望 IP 与同步状态。 -* PostgreSQL/SQLite 使用同版本 Goose DDL,不建立物理外键,关系字段显式索引,数据库默认值与 Go 零值一致。 - -### API 与鉴权设计 - -* 前缀 `/api/v1/d/cloudflare`,统一使用 `apiutil.AdminMiddlewares()`。 -* 连接:`GET/PUT /connection`、`POST /connection/verify`、`POST /connection/clear`。 -* 总览:`GET /overview`。 -* 分组:`GET/POST /groups`、`GET /groups/:id`、`POST /groups/:id/update|delete|sync`。 -* 成员:`GET/POST /groups/:id/members`、`POST /groups/:id/members/:memberId/update|remove|sync`。 -* 可用域名:`GET /domains/available`。 -* 成功统一 HTTP 200 + `response.OK`;失败通过 `response.Abort*` 交由全局中间件写出。 - -### 数据流与架构图 - -```mermaid -flowchart LR - UI[Cloudflare 管理页面] --> API[/api/v1/d/cloudflare] - API --> Logic[cloudflare 业务逻辑] - Logic --> Repo[repository] - Repo --> DB[(PG / SQLite)] - Logic --> Queue[Asynq] - Queue --> Worker[Cloudflare 同步 Handler] - Worker --> Reconcile[成员 Reconcile] - Reconcile --> CF[Cloudflare Zone / DNS API] - Reconcile --> Repo - Node[节点手动更新或心跳] --> Queue -``` - -### 设计决策权衡 - -* 使用标准库 `net/http` 自建最小 Cloudflare 客户端,避免引入覆盖面过大的 SDK;接口只暴露 verify、Zone 查找和 A 记录 CRUD,便于 mock。 -* 单成员同步采用进程内 keyed mutex 防止同一 Worker 进程并发双写;数据库状态在调用远端前标记 `syncing`,完成后写回 `ok/error`。 -* 整组、按节点和常规变更统一通过 Asynq 投递单成员任务;请求路径只做校验和状态变更,避免管理 API 被远端网络延迟阻塞。连接测试是唯一同步调用 Cloudflare 的管理操作。 -* 删除成员/分组默认先删除已缓存或唯一同名 A 记录,再删除本地记录;远端删除失败时保留本地成员并返回可读错误,避免失去重试依据。 -* 将 TLS 包内的敏感字段加解密提取为 `internal/apps/openflare/credential`,保持既有密文兼容并让 Cloudflare 复用,避免业务包重复实现凭据存储。 - -## 3. 具体修改文件清单 (Proposed Changes) - -### Task 1:凭据共享与数据库模型 - -**测试先行**:验证旧明文、`enc:v1:` 密文、无 SessionSecret 和缺少密钥时的兼容行为;验证迁移能创建三张表和唯一索引。 - -* **[NEW]** `internal/apps/openflare/credential/sensitive.go`、`sensitive_test.go`:提供 `Seal` / `Open`。 -* **[MODIFY]** `internal/apps/openflare/tls/sensitive.go` 及调用点:委派到共享凭据包,保留 TLS 对外行为。 -* **[NEW]** `internal/model/openflare_cloudflare.go`:连接、分组、成员实体和同步状态常量。 -* **[NEW]** `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608040001_create_cloudflare_pointing.sql`。 -* **[MODIFY]** `internal/infra/persistence/migrator/migrator_test.go`:检查表、索引和唯一约束。 - -### Task 2:Repository 与 Cloudflare HTTP 客户端 - -**测试先行**:覆盖连接 upsert/clear、分组/成员 CRUD、可用域名、按 active node 查询成员;使用 `httptest.Server` 覆盖 Token verify、Zone 查找、A 记录 list/create/update/delete、API 错误和 429 `Retry-After`。 - -* **[NEW]** `internal/repository/openflare_cloudflare.go`、`openflare_cloudflare_test.go`:唯一持久化入口和必要事务。 -* **[NEW]** `internal/apps/openflare/cloudflare/client.go`、`client_test.go`:最小 Cloudflare API 接口与 HTTP 实现。 -* **[NEW]** `internal/apps/openflare/cloudflare/types.go`、`errs.go`:输入/输出 DTO、内部状态和用户可见错误常量。 - -### Task 3:Reconcile、业务逻辑与异步任务 - -**测试先行**:覆盖 Token 来源解析、0/1/多条同名 A、缓存 Record ID 失效回退、非法 IPv4、成员默认橙云初始化、成员更新、移出删除远端、分组变更入队、按节点 IP 变更入队和同成员串行执行。 - -* **[NEW]** `internal/apps/openflare/cloudflare/reconcile.go`、`reconcile_test.go`:单成员期望状态计算与幂等同步。 -* **[NEW]** `internal/apps/openflare/cloudflare/logics.go`、`logics_test.go`:连接、总览、分组、成员业务编排。 -* **[NEW]** `internal/apps/openflare/cloudflare/tasks.go`、`tasks_test.go`:`cloudflare:sync_member`、`sync_group`、`sync_by_node` Handler、Meta、payload 校验与投递函数。 -* **[MODIFY]** `internal/infra/task/handlers/register.go`:集中注册 Cloudflare 任务。 -* **[MODIFY]** `internal/apps/openflare/node/logics.go`、`internal/apps/openflare/agent/logics.go`:节点 IP 真正变化后 best-effort 投递,不阻断原流程;失败记录日志。 - -### Task 4:管理 API 与 Swagger - -**测试先行**:使用 Gin 测试覆盖管理员路由、参数绑定、404/409/未就绪映射、Token 响应脱敏和主要成功响应。 - -* **[NEW]** `internal/apps/openflare/cloudflare/routers.go`、`routers_test.go`:Handlers 与 Swagger 注释。 -* **[NEW]** `internal/router/v1/openflare/register_cloudflare.go`。 -* **[MODIFY]** `internal/router/v1/openflare/v1.go`:注册 Cloudflare 路由委派。 -* **[GENERATED]** `docs/docs.go`、`docs/swagger.json`、`docs/swagger.yaml`:运行 `make swagger` 生成。 - -### Task 5:前端服务、导航与页面 - -**测试先行**:覆盖 service 路径/载荷、未就绪引导、连接配置不回显 Token、分组创建、成员添加/橙云更新、同步与删除确认。 - -* **[NEW]** `frontend/lib/services/openflare/cloudflare.service.ts`。 -* **[MODIFY]** `frontend/lib/services/openflare/types.ts`、`index.ts`:类型、导出和 `openflareServices.cloudflare`。 -* **[MODIFY]** `frontend/lib/navigation/openflare-nav.ts`:网站管理组增加 Cloudflare 入口与子路由高亮。 -* **[NEW]** `frontend/app/(main)/cloudflare/page.tsx`:总览和就绪门禁。 -* **[NEW]** `frontend/app/(main)/cloudflare/settings/page.tsx`:DNS 账号导入/独立 Token 配置与连接测试。 -* **[NEW]** `frontend/app/(main)/cloudflare/groups/page.tsx`:分组列表、创建、同步、删除确认。 -* **[NEW]** `frontend/app/(main)/cloudflare/groups/[id]/page.tsx` 及邻近 `components/`:分组配置、成员列表、添加/更新/同步/移除。 -* **[NEW]** `frontend/tests/cloudflare/*.test.ts(x)`:服务和关键交互测试。 - -### Task 6:设计边界、变更日志与收尾 - -* **[MODIFY]** `docs/design/architecture.md`、`docs/design/index.md`:补充 Cloudflare 可选控制面能力与阅读入口。 -* **[MODIFY]** `docs/changelog/index.md`:在 `[Unreleased]` 添加中文用户可见条目。 -* **[MODIFY]** `docs/plan/index.md`:登记本计划;完成时保留计划并标记状态。 - -## 4. 验证计划 (Verification Plan) - -### 自动化单元测试 - -* `go test ./internal/apps/openflare/credential ./internal/apps/openflare/cloudflare ./internal/repository ./internal/infra/persistence/migrator ./internal/apps/openflare/node ./internal/apps/openflare/agent` -* `pnpm --dir frontend test -- --run frontend/tests/cloudflare` -* `go test ./...` - -### 生成与质量门禁 - -* `make license` -* `make swagger` -* `make format` -* `make code-check` - -### 手动验收路径 - -1. 在 `/cloudflare/settings` 选择现有 Cloudflare DNS 账号或录入独立 Token,测试连接成功。 -2. 在 `/cloudflare/groups` 新建分组,选择具有合法 IPv4 的 edge 节点。 -3. 在详情页加入 ZoneDomain,观察状态从 `pending/syncing` 变为 `ok`,Cloudflare 上出现单条 A 记录。 -4. 修改成员橙云并同步,确认远端 `proxied` 与期望一致。 -5. 构造同名多 A,确认同步失败并提示先在 Cloudflare 清理。 -6. 移出成员,确认默认删除本模块管理的远端 A;修改节点 IP 后确认相关成员重新入队。 - -## 5. 实施结果与验证记录 - -* 已完成共享凭据加密、双数据库迁移、repository、Cloudflare HTTP 客户端、成员 reconcile、三类异步任务、管理 API、节点 IP 变化联动、前端服务与四级管理页面。 -* 删除远端记录时,缓存 Record ID 失效会回退到唯一同名 A;停用分组内修改成员只标记 `pending`,不投递必然失败的同步任务。 -* 已运行 `make license`、`make swagger`、`make format`;Swagger 已生成 Cloudflare 管理接口。 -* `go test ./...` 通过。 -* `make code-check` 通过,包含架构守卫、golangci-lint、TypeScript 与 ESLint。 -* `pnpm exec vitest run tests/cloudflare` 通过(2 个测试文件、2 个测试)。 -* 前端全量 Vitest 为 20/21 个测试文件、106/107 个测试通过;唯一失败为既存 `tests/zone/zone-page.test.tsx` 仍断言页面展示“唯一访问者”,与本功能无关且在本任务基线中已存在。 diff --git a/docs/plan/20260806-origin-error-page.md b/docs/plan/20260806-origin-error-page.md deleted file mode 100644 index 633df5e2..00000000 --- a/docs/plan/20260806-origin-error-page.md +++ /dev/null @@ -1,467 +0,0 @@ -# 源站错误页 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。 - -**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。 - -**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService - -**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md) - -## Global Constraints - -- 仅**反代**路由应用;**Pages** 路由不生成错误页指令 -- HTTP **status 保持原错误码**,禁止统一改为 200 -- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]` -- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB** -- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效 -- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档 -- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航 - -## File map - -| 文件 | 职责 | -|------|------| -| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 | -| `pkg/render/openresty/status_codes_test.go` | 解析单测 | -| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 | -| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 | -| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 | -| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server | -| `pkg/render/openresty/render_test.go` | 集成渲染断言 | -| `internal/model/system_configs.go` | 三个 ConfigKey 常量 | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed | -| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html | -| `internal/apps/openflare/option` 相关 test | 校验失败用例 | -| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 | -| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 | -| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) | -| `frontend/app/(main)/error-pages/page.tsx` | 设置页 | -| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 | -| `frontend/lib/utils/search-data.ts` | 搜索 | -| `docs/reference/configuration.md` | 配置键说明(中文) | -| `docs/changelog/index.md` | Unreleased | -| `docs/plan/index.md` | 进行中索引 | - ---- - -### Task 1: 状态码标签解析(纯函数 TDD) - -**Files:** -- Create: `pkg/render/openresty/status_codes.go` -- Create: `pkg/render/openresty/status_codes_test.go` - -**Interfaces:** -- Produces: - - `func ExpandStatusCodeTags(tags []string) (codes []int, err error)` - - `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi` - - 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599` -- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝) - -- [ ] **Step 1: 写失败单测** - -```go -func TestExpandStatusCodeTags(t *testing.T) { - t.Parallel() - codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"}) - if err != nil { - t.Fatal(err) - } - // want sorted unique: 500,501,502,522 - if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 { - t.Fatalf("got %v", codes) - } - _, err = ExpandStatusCodeTags([]string{"399"}) - if err == nil { - t.Fatal("expected error") - } - _, err = ExpandStatusCodeTags([]string{"503-500"}) - if err == nil { - t.Fatal("expected reverse range error") - } - _, err = ExpandStatusCodeTags([]string{"5xx"}) - if err == nil { - t.Fatal("expected syntax error") - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1` -Expected: FAIL(函数未定义) - -- [ ] **Step 3: 实现 `status_codes.go`** - -```go -package openresty - -import ( - "fmt" - "sort" - "strconv" - "strings" -) - -const ( - StatusCodeMin = 400 - StatusCodeMax = 599 -) - -func ParseStatusCodeTag(tag string) (lo, hi int, err error) { - tag = strings.TrimSpace(tag) - if tag == "" { - return 0, 0, fmt.Errorf("状态码标签不能为空") - } - if i := strings.IndexByte(tag, '-'); i >= 0 { - lo, err = strconv.Atoi(tag[:i]) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) - } - hi, err = strconv.Atoi(tag[i+1:]) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) - } - } else { - lo, err = strconv.Atoi(tag) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码: %s", tag) - } - hi = lo - } - if lo > hi { - return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag) - } - if lo < StatusCodeMin || hi > StatusCodeMax { - return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag) - } - return lo, hi, nil -} - -func ExpandStatusCodeTags(tags []string) ([]int, error) { - set := map[int]struct{}{} - for _, tag := range tags { - lo, hi, err := ParseStatusCodeTag(tag) - if err != nil { - return nil, err - } - for c := lo; c <= hi; c++ { - set[c] = struct{}{} - } - } - out := make([]int, 0, len(set)) - for c := range set { - out = append(out, c) - } - sort.Ints(out) - return out, nil -} -``` - -- [ ] **Step 4: 运行确认通过** - -Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go -git commit -m "feat(openresty): add status code tag expand helper" -``` - ---- - -### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD) - -**Files:** -- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加: - - `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\`` - - `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\`` - - `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\`` -- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile` -- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile -- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location -- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go` - -**Interfaces:** -- Produces: - - `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"` - - `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}` - - `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认 - - `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串 - - SupportFile content = Effective HTML(保留占位符) - -**Internal location 形状(必须 status 透传):** - -```nginx - location = /__openflare_origin_error { - internal; - default_type text/html; - charset utf-8; - content_by_lua_block { - local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r") - if not f then - ngx.status = ngx.status - ngx.say("Error ", ngx.status) - return - end - local body = f:read("*a") - f:close() - local status = tostring(ngx.status) - local host = ngx.var.host or "" - body = body:gsub("{{status}}", status, 1) - body = body:gsub("{{host}}", host, 1) - -- 全局替换剩余占位(若模板多处 status) - body = body:gsub("{{status}}", status) - body = body:gsub("{{host}}", host) - ngx.header["Content-Type"] = "text/html; charset=utf-8" - ngx.say(body) - } - } -``` - -占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。 - -在每个反代 `location /` 内(proxy 块): - -```nginx - proxy_intercept_errors on; -``` - -在 server 块内(location 外): - -```nginx - error_page 500 501 ... = /__openflare_origin_error; -``` - -+ internal location。 - -Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。 - -- [ ] **Step 1: 写失败单测** - -```go -func TestRenderOriginErrorPageEnabled(t *testing.T) { - t.Parallel() - doc := Document{ - Routes: []Route{{ - ID: 1, SiteName: "ex", Domains: []string{"ex.test"}, - OriginURL: "http://127.0.0.1:9", Enabled: true, - }}, - OpenRestyConfig: ConfigSnapshot{ - OriginErrorPageEnabled: true, - OriginErrorPageStatusCodes: []string{"500-599"}, - }, - } - out, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "proxy_intercept_errors on") { - t.Fatal("missing intercept") - } - if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") { - t.Fatal("missing error_page") - } - if !strings.Contains(out, "{{status}}") == false { - // SupportFile 在 Render 全量结果中 - } - res, err := Render(doc, nil) - if err != nil { - t.Fatal(err) - } - found := false - for _, f := range res.SupportFiles { - if f.Path == OriginErrorPageSupportPath { - found = true - if !strings.Contains(f.Content, "{{status}}") { - t.Fatal("template missing placeholder") - } - } - } - if !found { - t.Fatal("missing support file") - } -} - -func TestRenderOriginErrorPageDisabled(t *testing.T) { - t.Parallel() - doc := Document{ - Routes: []Route{{ - ID: 1, SiteName: "ex", Domains: []string{"ex.test"}, - OriginURL: "http://127.0.0.1:9", Enabled: true, - }}, - OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false}, - } - out, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "proxy_intercept_errors") { - t.Fatal("should not intercept when disabled") - } -} -``` - -- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS - -默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。 - -- [ ] **Step 3: Agent placeholder 替换** - -搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。 -若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。 - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat(openresty): render origin error page directives" -``` - ---- - -### Task 3: Option keys、迁移、校验、快照 - -**Files:** -- Modify: `internal/model/system_configs.go` — 常量: - - `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"` - - `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"` - - `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"` -- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`: - -```sql --- +goose Up -INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - --- +goose Down -DELETE FROM w_system_configs WHERE key IN ( - 'origin_error_page_enabled', - 'origin_error_page_status_codes', - 'origin_error_page_html' -); -``` - -(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。) - -- Modify: `openresty_validators.go`: - - enabled → `validateBooleanOption` - - status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024` - - html → `len(value) <= 256<<10` - -- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`: - - Enabled: `getBoolConfig(..., true)` - - StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}` - - HTML: `getStringConfig(..., "")` - -- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示) - -- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]` - -- [ ] **Step 2: 实现迁移与 snapshot 填充** - -- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段** - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat(option): seed and validate origin error page options" -``` - ---- - -### Task 4: 前端 Tags Input + `/error-pages` 页 - -**Files:** -- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`) -- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行) -- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步 -- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page -- Reuse: `OptionService.list` / `updateBatch`(同 performance 页) - -**UI 行为:** -1. 管理员加载 options → 映射三字段 -2. Switch 启用 -3. TagsInput:默认展示解析后的 JSON 数组 -4. Textarea HTML;按钮「加载默认模板」「恢复默认」 -5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc` -6. 保存:`updateBatch` 三条;toast 提示去版本发布 -7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误) - -默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。 - -- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用** - -- [ ] **Step 2: 实现 page.tsx** - -- [ ] **Step 3: 导航与搜索** - -- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示) - -- [ ] **Step 5: Commit** - -```bash -git commit -m "feat(frontend): add origin error page settings under websites" -``` - ---- - -### Task 5: 文档、changelog、收尾门禁 - -**Files:** -- Modify: `docs/reference/configuration.md` — 三 key 说明 -- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文 -- Modify: `docs/plan/index.md` — 本计划列入进行中 - -- [ ] **Step 1: 写配置说明与 changelog** - -示例 changelog: - -```markdown -- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。 -``` - -- [ ] **Step 2: `make code-check` 与前端 format** - -- [ ] **Step 3: 手动验收清单(对照设计 §7.2)** - -1. 默认启用 + 源站宕机 → 错误页 + 真实 status -2. 源站 503 → 替换 -3. 仅 `522` → 其它 5xx 透传 -4. 关闭并发布 → 透传 -5. 自定义 `{{status}}`/`{{host}}` -6. Pages 不受影响 - -- [ ] **Step 4: Final commit** - -```bash -git commit -m "docs: origin error page configuration and changelog" -``` - ---- - -## Spec coverage checklist - -| Spec 要求 | Task | -|-----------|------| -| 全局开关 | 3, 4 | -| 默认 `500-599` / 单码+区间 | 1, 3, 4 | -| 状态码透传 + `{{status}}` | 2 | -| 默认 CF 风格 / 自定义 HTML | 2, 4 | -| Option + 配置版本 | 3 | -| 仅反代 | 2 | -| 侧栏网站管理 | 4 | -| Tags Input | 4 | -| 256KB 限制 | 3 | -| 测试与 changelog | 1–5 | - -## Placeholder scan - -无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。 diff --git a/docs/plan/clickhouse-cpu-optimization.md b/docs/plan/clickhouse-cpu-optimization.md deleted file mode 100644 index ba828bcb..00000000 --- a/docs/plan/clickhouse-cpu-optimization.md +++ /dev/null @@ -1,47 +0,0 @@ -# ClickHouse CPU 性能优化计划 - -> PLAN_ID: `63ba981b` -> 状态: 已完成(含 Phase 2 遗留治理) -> 目标: 完成 P0–P2 优化,降低 ClickHouse CPU 占用 - -## 背景 - -ClickHouse CPU 偏高由写入侧(小 part 频繁 flush、心跳同步 DELETE mutation)与查询侧(无 LIMIT 全表扫、高频轮询、WAF 全量拉日志)叠加导致。 - -## PR Plan - -### PR 1: 写入路径 P0 优化 - -- **Description:** 移除心跳路径同步 `ALTER DELETE`;为 `batchwriter` 增加 `MinBatchSize`;调大可观测 writer 批次与 flush 间隔;为 openresty/frps/frpc 补全去重。 -- **Files/components affected:** `internal/apps/openflare/agent/observability.go`, `internal/infra/persistence/batchwriter/`, `internal/apps/openflare/chwriter/`, `internal/infra/persistence/batchwriter/*_test.go` -- **Dependencies:** None - -### PR 2: ClickHouse 客户端与配置 P1 - -- **Description:** 启用 `async_insert` 等写入优化 settings;提高 `block_buffer_size` 默认值;更新 `config.example.yaml` 与配置模型注释。 -- **Files/components affected:** `internal/infra/persistence/clickhouse.go`, `internal/infra/config/model.go`, `internal/infra/config/config.go`, `config.example.yaml` -- **Dependencies:** None - -### PR 3: Dashboard 与可观测查询 P0 - -- **Description:** 消除 `limit=0` 无界查询;复用已有限制数据构建趋势;增加服务端短 TTL 缓存;降低前端轮询频率。 -- **Files/components affected:** `internal/apps/openflare/dashboard/logics.go`, `internal/apps/openflare/observability/node_logics.go`, `frontend/app/(main)/page.tsx`, `frontend/app/(main)/nodes/components/node-observability.tsx` -- **Dependencies:** None - -### PR 4: 访问日志与 WAF 查询 P0/P1 - -- **Description:** WAF IP 同步改为 ClickHouse 侧聚合;IP 汇总与折叠日志 SQL 分页;消除 count 重复全量扫描;列表 API 强制默认时间窗口。 -- **Files/components affected:** `internal/apps/openflare/waf/ip_group_sync.go`, `internal/repository/analytics/node_access_log_stats.go`, `internal/model/openflare_access_log.go`, `internal/apps/openflare/observability/access_log_logics.go`, `internal/repository/analytics/access_log_stats.go` -- **Dependencies:** None - -### PR 5: ClickHouse DDL 与数据规范化 P1 - -- **Description:** 为 7 张分析表添加 TTL;收窄 `of_node_access_logs` ORDER BY;插入时规范化 `remote_addr`(去 trim 查询);将可观测 obs 三表纳入自动清理。 -- **Files/components affected:** `internal/infra/persistence/migrator/goose/clickhouse/`, `internal/repository/analytics/node_access_log_writer.go`, `internal/apps/openflare/tasks/database_cleanup.go`, `internal/model/analytics/` -- **Dependencies:** PR 1 - -### PR 6: 基础设施与审计减负 P2 - -- **Description:** Docker ClickHouse 服务端基础调优;审计日志 headers 截断/精简;更新 changelog。 -- **Files/components affected:** `docker-compose.yaml`, `docker/clickhouse/` (if needed), `internal/apps/risk_control/middleware.go`, `docs/changelog/index.md` -- **Dependencies:** None \ No newline at end of file diff --git a/docs/plan/handover-plan-template.md b/docs/plan/handover-plan-template.md deleted file mode 100644 index bd34ca33..00000000 --- a/docs/plan/handover-plan-template.md +++ /dev/null @@ -1,32 +0,0 @@ -# AI 接手计划模板 - -说明:本模板用于在 AI 代理上下文发生截断、压缩(Compaction)或将任务转移给另一个 AI 代理时使用,帮助新接手的 AI 快速恢复 100% 的工作状态。 - ---- - -## 1. 当前任务状态 (Current Status) -* **主线任务描述**:用一句话说清楚当前正在解决的核心问题。 -* **开发分支/提交**:记录当前的工作目录、修改的未暂存文件、或 Git 临时分支名。 -* **已完成内容 (Completed)**: - - [x] 功能 A 后端接口及单测 - - [x] 前端面板表单组件 -- **进行中内容 (In Progress)**: - - [/] 配置文件渲染与重写模块 -- **待处理内容 (To Do)**: - - [ ] 边缘节点同步下载与校验落地 - - [ ] 发布功能整体连通性验证 - -## 2. 核心文件与上下文 (Key Files & Context) -列出与当前开发高度相关的核心文件以及需要注意的特殊背景: -* `file:///path/to/core_file.go#L100-L150`:此处负责...,修改时需要注意... -* `file:///path/to/frontend_component.tsx`:用于展现... - -## 3. 待决策与遗留问题 (Outstanding Decisions & Issues) -* [ ] **疑问/阻塞点**:是否需要支持某某场景?目前是如何兜底处理的? -* [ ] **异常与缺陷**:单测 `./controller/...` 运行时目前有 1 个 Fail,失败原因为... - -## 4. 下一步行动指南 (Next Steps) -新接手 AI 进来后应当立即执行的前 3 步命令或编辑操作: -1. **第一步**:执行 `go test ./controller/...` 确认环境并复现 Fail 异常。 -2. **第二步**:修改 `openflare-server/internal/controller/xxx.go` 中的逻辑以修复该 Fail。 -3. **第三步**:在管理端前端页面调试 xxx 表单的提交是否正常。 diff --git a/docs/plan/implementation-plan-template.md b/docs/plan/implementation-plan-template.md deleted file mode 100644 index 555c0151..00000000 --- a/docs/plan/implementation-plan-template.md +++ /dev/null @@ -1,47 +0,0 @@ -# 功能开发实现计划模板 - -说明:本模板用于指导新特性或重大模块开发前的技术规划,明确需求、范围与设计决策。 - ---- - -## 1. 目标与背景 (Goal & Context) -* **需求背景**:说明为什么要开发这个特性,解决什么业务痛点或安全隐患。 -* **开发范围 (Scope)**:明确 V1 阶段的核心交付指标。哪些是本次必做的,哪些是留到后续迭代的(Out of Scope)。 - -## 2. 设计与决策决策 (Design & Decisions) -* **核心对象/数据模型**: - * 说明是否需要修改或新增数据库表(Gorm 结构体、Migration SQL,包括新增字段与关联)。 -* **API 与鉴权设计**: - * 详细定义新增的 REST API 路由、请求载荷(Payload JSON)与响应格式。 -* **数据流与架构图**: - * 使用 Mermaid 绘制数据或控制流的流向。 -* **设计决策权衡**: - * 记录为何选用方案 A 而非方案 B。 - -## 3. 具体修改文件清单 (Proposed Changes) -按模块或组件列出需要修改的物理文件路径及修改点: - -### 后端 Server -* #### [NEW] `openflare-server/internal/model/entity.go` - * 职责:... -* #### [MODIFY] `openflare-server/internal/service/feature.go` - * 职责:... - -### 边缘 Agent 与 OpenResty -* #### [MODIFY] `openflare-agent/sync/sync.go` - * 职责:... - -### 前端 Web -* #### [NEW] `openflare-server/web/features/feature-view.tsx` - * 职责:... - ---- - -## 4. 验证计划 (Verification Plan) - -### 自动化单元测试 -* 运行的单测命令,如:`go test -v ./service/...` - -### 数据面重载与生效验证 -* 说明如何验证新配置在数据面落地。 -* 提供验证测试的 `curl` 指令或手动操作路径。 diff --git a/docs/plan/index.md b/docs/plan/index.md deleted file mode 100644 index 7b3d121e..00000000 --- a/docs/plan/index.md +++ /dev/null @@ -1,38 +0,0 @@ -# 开发计划与 AI 接手 - -本分区用于存放正在进行的开发计划(Plan)以及 AI 代理之间的工作接手计划(Handover)。这能帮助不同的 AI 代理快速掌握当前项目状态、历史上下文与后续开发步骤。 - -## 计划模板 - -在创建具体的开发计划或接手文档时,请使用以下标准模板进行初始化: - -1. **[实现计划模板](./implementation-plan-template.md)**:用于新功能开发或重大重构前的技术方案规划。 -2. **[AI 接手计划模板](./handover-plan-template.md)**:用于在上下文截断、压缩或更换 AI 代理时,记录当前任务状态、已完成内容与下一步执行计划。 - -## 正在进行的计划 - -当前进行中的开发计划: - -* [源站错误页](./20260806-origin-error-page.md):全局可配置错误页(默认 500-599、CF 风格 HTML、状态码透传);Option + 配置版本 + OpenResty error_page。 -* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。 -* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。 -* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。 -* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。 -* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。 -* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。 -* [边缘限流全局默认](./20260719-http-default-rate-limit.md):全局默认并发/带宽;站点 0 继承、-1 关闭;RenderRouteConfig 合并。 -* [边缘缓存对齐 Cloudflare 默认模型](./20260723-edge-cache-cf-align.md):删除过严请求旁路;Set-Cookie 不入库;默认 Edge TTL;扩展名去 json。 - -## 已完成的计划 - -* [Cloudflare DNS 指向](./20260804-cloudflare-pointing.md):已完成连接配置、分组与成员管理、单 A 记录幂等同步、节点 IP 变化联动、异步任务和管理页面。 - -* [model / repository 分层治理](./20260724-model-repository-layering.md):model 无 IO;repository 唯一持久化;已完成 OpenFlare/平台 CRUD 迁入 repository。 - -* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。 - -## 使用建议 - -* **命名规范**:正在进行的开发计划建议命名为 `docs/plan/YYYYMMDD-[feature-name].md`,接手计划建议命名为 `docs/plan/handover-[task-name].md`。 -* **物理隔离**:本目录下的计划文件只在开发周期内进行更新。当对应功能开发完毕并上线后,相应的计划文档应予以保留或归档,以供日后维护与新 AI 追溯历史决策。 -* **禁止空文件**:请确保新创建的计划文档均基于对应的模板进行初始化填充。 diff --git a/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md b/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md deleted file mode 100644 index c2966122..00000000 --- a/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md +++ /dev/null @@ -1,709 +0,0 @@ -# 边缘限流全局默认 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。 - -**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。 - -**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService - -**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md) - -## Global Constraints - -- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`) -- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate` -- 全局默认初始 `0`/空 → 存量行为不变 -- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档 -- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由) - -## File map - -| 文件 | 职责 | -|------|------| -| `internal/model/system_configs.go` | 三个 ConfigKey 常量 | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 | -| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 | -| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 | -| `internal/apps/openflare/config_version/logics.go` | option diff keys | -| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 | -| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 | -| `pkg/render/openresty/render_test.go` | 合并渲染单测 | -| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 | -| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 | -| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 | -| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI | -| `frontend/lib/utils/search-data.ts` | 搜索入口 | -| `docs/reference/configuration.md` | 配置键说明 | -| `docs/changelog/index.md` | Unreleased | -| `docs/plan/index.md` | 进行中计划索引 | - ---- - -### Task 1: Render 合并(TDD 核心) - -**Files:** -- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`) -- Modify: `pkg/render/openresty/render.go` -- Test: `pkg/render/openresty/render_test.go` - -**Interfaces:** -- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等) -- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig` -- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string` - -- [ ] **Step 1: 写失败单测** - -在 `render_test.go` 末尾追加: - -```go -func TestMergeRouteLimitConfig(t *testing.T) { - t.Parallel() - cases := []struct { - name string - route Route - cfg ConfigSnapshot - want routeLimitConfig - }{ - { - name: "both zero off", - route: Route{}, - cfg: ConfigSnapshot{}, - want: routeLimitConfig{}, - }, - { - name: "inherit all defaults", - route: Route{}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"}, - }, - { - name: "explicit off ignores default", - route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{}, - }, - { - name: "route overrides default", - route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "512k", - }, - want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"}, - }, - { - name: "partial inherit", - route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""}, - cfg: ConfigSnapshot{ - DefaultLimitConnPerServer: 100, - DefaultLimitConnPerIP: 10, - DefaultLimitRate: "256k", - }, - want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := mergeRouteLimitConfig(tc.route, tc.cfg) - if got != tc.want { - t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want) - } - }) - } -} - -func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) { - doc := Document{ - Routes: []Route{{ - SiteName: "example.com", - Domains: []string{"example.com"}, - Enabled: true, - OriginURL: "http://127.0.0.1:8080", - Upstreams: []string{"http://127.0.0.1:8080"}, - }}, - OpenRestyConfig: ConfigSnapshot{ - DefaultLimitConnPerServer: 120, - DefaultLimitConnPerIP: 12, - DefaultLimitRate: "512k", - }, - } - rendered, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatalf("RenderRouteConfig() error = %v", err) - } - for _, want := range []string{ - "limit_conn openflare_conn_per_server 120;", - "limit_conn openflare_conn_per_ip 12;", - "limit_rate 512k;", - } { - if !strings.Contains(rendered, want) { - t.Fatalf("expected %q in route config, got:\n%s", want, rendered) - } - } -} - -func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) { - doc := Document{ - Routes: []Route{{ - SiteName: "example.com", - Domains: []string{"example.com"}, - Enabled: true, - OriginURL: "http://127.0.0.1:8080", - Upstreams: []string{"http://127.0.0.1:8080"}, - LimitConnPerServer: -1, - LimitConnPerIP: -1, - LimitRate: "-1", - }}, - OpenRestyConfig: ConfigSnapshot{ - DefaultLimitConnPerServer: 120, - DefaultLimitConnPerIP: 12, - DefaultLimitRate: "512k", - }, - } - rendered, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatalf("RenderRouteConfig() error = %v", err) - } - if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") { - t.Fatalf("expected no limit directives, got:\n%s", rendered) - } -} -``` - -- [ ] **Step 2: 跑测确认失败** - -```bash -go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1 -``` - -Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符) - -- [ ] **Step 3: 实现 types + merge + 调用** - -`ConfigSnapshot` 增加: - -```go -DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` -DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` -DefaultLimitRate string `json:"default_limit_rate,omitempty"` -``` - -`render.go` 中 `RenderRouteConfig` 将: - -```go -limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate} -``` - -改为: - -```go -limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig) -``` - -并新增: - -```go -func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig { - return routeLimitConfig{ - LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer), - LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP), - LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate), - } -} - -func mergeLimitConn(route, def int) int { - if route == -1 { - return 0 - } - if route > 0 { - return route - } - if def > 0 { - return def - } - return 0 -} - -func mergeLimitRate(route, def string) string { - r := strings.ToLower(strings.TrimSpace(route)) - if r == "-1" { - return "" - } - if r != "" && r != "0" { - return r - } - d := strings.ToLower(strings.TrimSpace(def)) - if d != "" && d != "0" { - return d - } - return "" -} -``` - -- [ ] **Step 4: 跑测通过** - -```bash -go test ./pkg/render/openresty/ -count=1 -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go -git commit -m "feat(openresty): merge global default limits at route render" -``` - ---- - -### Task 2: 配置键、迁移、校验、快照 - -**Files:** -- Modify: `internal/model/system_configs.go` -- Create: `internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql` -- Modify: `internal/apps/openflare/option/validate.go` -- Modify: `internal/apps/openflare/option/openresty_validators.go` -- Modify: `internal/apps/openflare/config_version/snapshot.go` -- Modify: `internal/apps/openflare/config_version/logics.go` - -**Interfaces:** -- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名 -- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见 - -- [ ] **Step 1: 常量** - -在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入: - -```go -ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接 -ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接 -ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽 -``` - -- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)** - -```sql --- +goose Up -INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - --- +goose Down -DELETE FROM w_system_configs WHERE key IN ( - 'openresty_default_limit_conn_per_server', - 'openresty_default_limit_conn_per_ip', - 'openresty_default_limit_rate' -); -``` - -SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。 - -- [ ] **Step 3: 校验器** - -`validate.go` 增加: - -```go -func validateNonNegativeIntegerOption(key, value string) error { - intValue, err := strconv.Atoi(value) - if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数", key) - } - return nil -} -``` - -`openresty_validators.go` 注册: - -```go -model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption, -model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption, -model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate, -``` - -```go -var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) - -func validateOpenRestyDefaultLimitRate(key, trimmed string) error { - if trimmed == "" || trimmed == "0" { - return nil - } - if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) { - return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key) - } - return nil -} -``` - -- [ ] **Step 4: 快照读取(注意 0 合法)** - -`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。 - -**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为: - -```go -getNonNegIntConfig := func(key string, defaultVal int) int { - val, err := repository.GetIntByKey(ctx, key) - if err != nil || val < 0 { - return defaultVal - } - return val -} -``` - -```go -DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0), -DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0), -DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))), -``` - -若 `DefaultLimitRate == "0"`,规范化为 `""`。 - -确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐: - -```go -DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"` -DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"` -DefaultLimitRate string `json:"default_limit_rate,omitempty"` -``` - -- [ ] **Step 5: option diff** - -在 `diffOpenRestyOptionDetails` 末尾: - -```go -appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer)) -appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP)) -appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate) -``` - -`openRestyOptionKeys()` 同步追加这三 key 字符串。 - -- [ ] **Step 6: 编译/相关测试** - -```bash -go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1 -``` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add internal/model/system_configs.go \ - internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \ - internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \ - internal/apps/openflare/option/validate.go \ - internal/apps/openflare/option/openresty_validators.go \ - internal/apps/openflare/config_version/snapshot.go \ - internal/apps/openflare/config_version/logics.go -git commit -m "feat(config): add openresty default rate limit system options" -``` - ---- - -### Task 3: 站点 normalize 允许 -1 - -**Files:** -- Modify: `internal/apps/openflare/proxy_route/helpers.go` -- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案) -- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go` - -**Interfaces:** -- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"` - -- [ ] **Step 1: 失败单测** - -```go -func TestNormalizeProxyRouteLimitConnValue(t *testing.T) { - t.Parallel() - got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server") - if err != nil || got != -1 { - t.Fatalf("want -1, got %d err %v", got, err) - } - if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil { - t.Fatal("expected error for -2") - } -} - -func TestNormalizeProxyRouteLimitRate(t *testing.T) { - t.Parallel() - got, err := normalizeProxyRouteLimitRate("-1") - if err != nil || got != "-1" { - t.Fatalf("want -1, got %q err %v", got, err) - } - got, err = normalizeProxyRouteLimitRate("0") - if err != nil || got != "" { - t.Fatalf("want empty inherit, got %q err %v", got, err) - } -} -``` - -- [ ] **Step 2: 实现** - -```go -func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) { - if value < -1 { - return 0, fmt.Errorf("%s must be greater than or equal to -1", field) - } - return value, nil -} - -func normalizeProxyRouteLimitRate(raw string) (string, error) { - normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" || normalized == "0" { - return "", nil - } - if normalized == "-1" { - return "-1", nil - } - if !proxyRouteLimitRatePattern.MatchString(normalized) { - return "", errors.New(errProxyRouteLimitRate) - } - if strings.TrimRight(normalized, "km") == "" { - return "", nil - } - return normalized, nil -} -``` - -可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。 - -- [ ] **Step 3: 测试** - -```bash -go test ./internal/apps/openflare/proxy_route/ -count=1 -``` - -- [ ] **Step 4: Commit** - -```bash -git add internal/apps/openflare/proxy_route/ -git commit -m "feat(proxy-route): allow -1 to disable rate limits" -``` - ---- - -### Task 4: 前端 — 安全性「限流」页 + 站点文案 - -**Files:** -- Modify: `frontend/lib/navigation/openflare-nav.ts` -- Create: `frontend/app/(main)/rate-limits/page.tsx` -- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` -- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts` -- Modify: `frontend/lib/utils/search-data.ts` - -**Interfaces:** -- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等 -- Produces: `/rate-limits` 管理页;站点表单接受 `-1` - -- [ ] **Step 1: 导航** - -`openflareSecurityNavGroup.items`: - -```ts -{ title: 'WAF', url: '/waf' }, -{ title: 'IP 组', url: '/ip-groups' }, -{ title: '限流', url: '/rate-limits' }, -``` - -- [ ] **Step 2: 搜索** - -`search-data.ts` 在 IP 组后增加: - -```ts -{ - id: 'console-rate-limits', - title: '限流', - description: '配置边缘站点默认并发与带宽限流策略', - url: '/rate-limits', - category: 'page', - keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'], -}, -``` - -- [ ] **Step 3: 限流设置页** - -新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`: - -- `useAuth` 管理员校验 -- `OptionService.list` / `updateBatch` -- 三字段表单 + 单卡片保存 -- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」 -- 描述:空/0 表示默认关闭;修改后需在版本发布中生效 -- keys: - - `openresty_default_limit_conn_per_server` - - `openresty_default_limit_conn_per_ip` - - `openresty_default_limit_rate` -- conn:非负整数;rate:空或 `^\d+[kKmM]?$` -- 保存成功 toast + invalidate options / config-preview / config-versions -- 链到 `/config-versions` - -页面骨架要点(完整实现时展开为完整组件,勿留半成品): - -```tsx -// 字段 state、OptionService.list map、updateBatch([{key,value},...]) -// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」 -``` - -- [ ] **Step 4: 站点 limits-section** - -1. schema:conn 允许空、`0`、`-1`、正整数: - -```ts -if (!rawValue) continue; -if (!/^-1$|^\d+$/.test(rawValue)) { - context.addIssue({ ..., message: '请输入 -1、0 或正整数' }); -} -``` - -2. `validateLimitRate` / `normalizeLimitRate`: - -```ts -export function validateLimitRate(value: string) { - const normalized = value.trim(); - if (!normalized || normalized === '0' || normalized === '-1') { - return null; - } - if (!limitRatePattern.test(normalized)) { - return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭'; - } - return null; -} - -export function normalizeLimitRate(value: string) { - const normalized = value.trim().toLowerCase(); - if (normalized === '0') return ''; - return normalized; // 保留 -1 -} -``` - -3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空) - -4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字 - -5. 文案: - -``` -description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。' -FormDescription 同步说明 -``` - -6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。` - -- [ ] **Step 5: prettier + 类型检查(按项目习惯)** - -```bash -make prettier -# 若有前端 typecheck: -# cd frontend && pnpm exec tsc --noEmit -``` - -- [ ] **Step 6: Commit** - -```bash -git add frontend/lib/navigation/openflare-nav.ts \ - frontend/app/\(main\)/rate-limits/ \ - frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \ - frontend/app/\(main\)/proxy-routes/components/helpers.ts \ - frontend/lib/utils/search-data.ts -git commit -m "feat(frontend): add security rate-limits page and inherit UI" -``` - ---- - -### Task 5: 文档、索引、门禁 - -**Files:** -- Modify: `docs/reference/configuration.md`(OpenResty 配置表) -- Modify: `docs/changelog/index.md` `[unreleased]` -- Modify: `docs/plan/index.md` - -- [ ] **Step 1: configuration.md** - -在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入: - -```md -| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` | -| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` | -| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 | -``` - -- [ ] **Step 2: changelog** - -`[unreleased]` 下: - -```md -### 新增 - -- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。 - -### 改进 - -- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。 -``` - -- [ ] **Step 3: plan index** - -`docs/plan/index.md` 进行中列表增加: - -```md -* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。 -``` - -- [ ] **Step 4: 全量门禁** - -```bash -make code-check -make prettier -``` - -Expected: 通过;修复任何报错后再提交。 - -- [ ] **Step 5: Commit** - -```bash -git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md -git commit -m "docs: document default edge rate limits" -``` - ---- - -## Spec coverage checklist - -| Spec 要求 | Task | -|-----------|------| -| 三项全局默认 | 2, 4 | -| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 | -| 仅 `RenderRouteConfig` 合并 | 1 | -| 快照保留原始站点值 | 2(不写回 route) | -| 安全性子页「限流」 | 4 | -| 初始 0/空兼容 | 2 seed | -| option diff / 发布 | 2 | -| 测试合并/normalize | 1, 3 | -| 中文文档/changelog | 5 | -| 非目标 limit_req / http 级指令 | 未做 | - -## 手动验收 - -1. 迁移后三键存在且为 `0`/空 -2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存 -3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate` -4. 站点将该项改为 `-1` 保存并发布:该维度指令消失 -5. 站点改为 `50`:输出 50 而非全局值 diff --git a/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md b/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md deleted file mode 100644 index 23082a98..00000000 --- a/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md +++ /dev/null @@ -1,388 +0,0 @@ -# WAF Editor Node Label + Drag-Add Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset). - -**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes. - -**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui. - -**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md` - -## Global Constraints - -- No backend / schema_version / note field changes. -- System nodes `start` / `allow`: no rename UI. -- New nodes: no default `label` (type name shown). -- Drag-only add; remove click-add. -- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates. - -## File Map - -| File | Role | -|------|------| -| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants | -| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant | -| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory | -| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` | -| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input | -| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node | -| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick | -| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create | - ---- - -### Task 1: Types + pure helpers - -**Files:** -- Modify: `frontend/lib/services/openflare/types.ts` -- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` -- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` - -**Interfaces:** -- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)` - -- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`. - -- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`: - -```ts -import { describe, expect, it } from 'vitest'; -import { - createRuleNode, - displayNodeTitle, - NODE_TYPE_LABELS, -} from './node-factory'; - -describe('displayNodeTitle', () => { - it('uses trimmed label when present', () => { - expect( - displayNodeTitle({ - id: 'x', - type: 'ip_match', - label: ' 办公室 ', - position: { x: 0, y: 0 }, - config: { ips: [], cidrs: [], ip_group_ids: [] }, - }), - ).toBe('办公室'); - }); - - it('falls back to type default when label empty', () => { - expect( - displayNodeTitle({ - id: 'x', - type: 'block', - label: ' ', - position: { x: 0, y: 0 }, - config: { status_code: 403, response_body: '' }, - }), - ).toBe(NODE_TYPE_LABELS.block); - }); -}); - -describe('createRuleNode', () => { - it('creates typed node at position without label', () => { - const node = createRuleNode('pow', { x: 12, y: 34 }); - expect(node.type).toBe('pow'); - expect(node.position).toEqual({ x: 12, y: 34 }); - expect(node.label).toBeUndefined(); - expect(node.id.startsWith('pow-')).toBe(true); - if (node.type === 'pow') { - expect(node.config).toEqual({ - algorithm: 'fast', - difficulty: 4, - session_ttl: 3600, - challenge_ttl: 300, - }); - } - }); -}); -``` - -- [ ] **Step 3: Implement `node-factory.ts`** - -```ts -import type { WAFRuleNode } from '@/lib/services/openflare'; - -export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node'; - -export type AddableNodeType = Extract< - WAFRuleNode['type'], - 'ip_match' | 'geo_match' | 'pow' | 'block' ->; - -export const NODE_TYPE_LABELS: Record = { - start: '开始', - ip_match: 'IP 匹配', - geo_match: '地域匹配', - pow: 'PoW 挑战', - allow: '通过', - block: '阻止', -}; - -export function displayNodeTitle( - node: Pick, -): string { - const custom = node.label?.trim(); - return custom || NODE_TYPE_LABELS[node.type]; -} - -export function createRuleNode( - type: AddableNodeType, - position: { x: number; y: number }, -): WAFRuleNode { - const id = `${type}-${crypto.randomUUID().slice(0, 8)}`; - if (type === 'ip_match') - return { - id, - type, - position, - config: { ips: [], cidrs: [], ip_group_ids: [] }, - }; - if (type === 'geo_match') - return { id, type, position, config: { countries: [], regions: [] } }; - if (type === 'pow') - return { - id, - type, - position, - config: { - algorithm: 'fast', - difficulty: 4, - session_ttl: 3600, - challenge_ttl: 300, - }, - }; - return { - id, - type: 'block', - position, - config: { status_code: 403, response_body: '' }, - }; -} - -export function parseAddableNodeType(value: string): AddableNodeType | null { - if ( - value === 'ip_match' || - value === 'geo_match' || - value === 'pow' || - value === 'block' - ) - return value; - return null; -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts' -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add frontend/lib/services/openflare/types.ts \ - frontend/app/(main)/waf/rules/editor/components/node-factory.ts \ - frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts -git commit -m "feat(waf): add node label type and factory helpers" -``` - ---- - -### Task 2: Canvas title + properties label field - -**Files:** -- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` -- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` -- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` - -- [ ] **Step 1: Tests for properties** - -Add to `node-properties.test.tsx`: - -```ts -it('edits display name for configurable nodes', () => { - const node: WAFRuleNode = { - id: 'match', - type: 'ip_match', - position: { x: 0, y: 0 }, - config: { ips: [], cidrs: [], ip_group_ids: [] }, - }; - const onChange = vi.fn(); - render(); - fireEvent.change(screen.getByLabelText('显示名称'), { - target: { value: '内网放行' }, - }); - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ label: '内网放行' }), - ); -}); - -it('hides display name for system nodes', () => { - const node: WAFRuleNode = { - id: 'start', - type: 'start', - position: { x: 0, y: 0 }, - config: {}, - }; - render(); - expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument(); - expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument(); -}); -``` - -- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system): - -Prefer extract: - -```tsx -function DisplayNameField({ - node, - onChange, -}: { - node: WAFRuleNode; - onChange: (node: WAFRuleNode) => void; -}) { - return ( - - 显示名称 - onChange({ ...node, label: e.target.value })} - /> - - ); -} -``` - -Insert `` as first child inside each non-system `FieldGroup`. - -- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle. - -- [ ] **Step 4: Run tests** - -```bash -cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts' -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \ - frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \ - frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx -git commit -m "feat(waf): show and edit rule node display names" -``` - ---- - -### Task 3: Drag library + canvas drop - -**Files:** -- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` -- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` -- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType` - -- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with: - -```tsx -onDragStart={(e) => { - e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type); - e.dataTransfer.setData('text/plain', type); - e.dataTransfer.effectAllowed = 'copy'; -}} -``` - -Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes. - -- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with: - -```ts -const addNodeAt = useCallback( - (type: AddableNodeType, position: { x: number; y: number }) => { - const node = createRuleNode(type, position); - onGraphChange({ ...graph, nodes: [...graph.nodes, node] }); - onSelectEdge(undefined); - onSelect(node.id); - }, - [graph, onGraphChange, onSelect, onSelectEdge], -); - -const onDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; -}, []); - -const onDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const raw = - e.dataTransfer.getData(WAF_NODE_DRAG_MIME) || - e.dataTransfer.getData('text/plain'); - const type = parseAddableNodeType(raw); - if (!type || !instance.current) return; - const position = instance.current.screenToFlowPosition({ - x: e.clientX, - y: e.clientY, - }); - addNodeAt(type, position); - }, - [addNodeAt], -); -``` - -Pass `onDragOver` / `onDrop` to `` (xyflow supports these on the component). - -Update `` — no `onAdd`. - -- [ ] **Step 3: Run editor-related tests** - -```bash -cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor' -``` - -Expected: PASS (update any tests that assumed click-add) - -- [ ] **Step 4: Format + commit** - -```bash -make prettier -git add frontend/app/(main)/waf/rules/editor -git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor" -``` - -- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`: - -```md -### 改进 -- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 -``` - -```bash -git add docs/changelog/index.md -git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加" -``` - ---- - -## Spec coverage - -| Spec item | Task | -|-----------|------| -| `label?` on TS types | 1 | -| Display title fallback | 1–2 | -| Properties 显示名称 | 2 | -| System nodes no rename | 2 | -| Drag-only library | 3 | -| Drop at cursor | 3 | -| No note / backend | N/A (omitted) | -| Tests | 1–3 | -| Changelog | 3 | diff --git a/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md b/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md deleted file mode 100644 index 2a4b7be4..00000000 --- a/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md +++ /dev/null @@ -1,42 +0,0 @@ -# WAF UA Check Node Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add WAF graph node `ua_check` (require UA, browser/OS whitelist with and/or, bot/abnormal blocks) end-to-end: validate/compile, Lua runtime, editor UI. - -**Architecture:** Match-node pattern like `geo_match`. Control plane stores `UACheckConfig`; edge classifies `http_user_agent` with analytics-equivalent token rules; evaluation order: require → block bots → block abnormal → whitelist. - -**Tech Stack:** Go (waf package), Lua (OpenResty waf_runtime), React/TS editor, Vitest, Go tests. - -**Spec:** `docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md` - -## Global Constraints - -- Type `ua_check`; handles `true`/`false`. -- Config fields: `require_ua`, `browsers`, `operating_systems`, `match_mode` (`and`|`or`, default `or`), `block_common_bots`, `block_abnormal_ua`. -- Closed enums for browser/OS labels matching analytics. -- Block before whitelist; empty lists = no whitelist constraint. -- No schema_version bump; no new HTTP API. -- Changelog + Chinese design doc update. - -## File Map - -| File | Role | -|------|------| -| `internal/apps/openflare/waf/graph_types.go` | Type + config | -| `internal/apps/openflare/waf/graph_validate.go` | Validate + handles | -| `internal/apps/openflare/waf/graph_compile.go` | Compile normalize | -| `internal/apps/openflare/waf/*_test.go` | Go tests | -| `internal/apps/agent/nginx/waf_runtime.lua` | Runtime eval | -| `internal/apps/agent/nginx/waf_runtime_spec.lua` | Lua specs | -| `internal/apps/agent/nginx/manager_test.go` | Embed smoke if needed | -| Frontend editor components + types | UI | -| `docs/design/waf-orchestration-design.md` | Node table | -| `docs/changelog/index.md` | Unreleased | - -### Task 1: Backend types/validate/compile -### Task 2: Lua runtime + specs -### Task 3: Frontend editor -### Task 4: Docs + gates - -(Detailed code follows during implementation; execute TDD per layer.) diff --git a/docs/superpowers/plans/2026-08-06-origin-error-page.md b/docs/superpowers/plans/2026-08-06-origin-error-page.md deleted file mode 100644 index 633df5e2..00000000 --- a/docs/superpowers/plans/2026-08-06-origin-error-page.md +++ /dev/null @@ -1,467 +0,0 @@ -# 源站错误页 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。 - -**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。 - -**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService - -**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md) - -## Global Constraints - -- 仅**反代**路由应用;**Pages** 路由不生成错误页指令 -- HTTP **status 保持原错误码**,禁止统一改为 200 -- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]` -- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB** -- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效 -- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档 -- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航 - -## File map - -| 文件 | 职责 | -|------|------| -| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 | -| `pkg/render/openresty/status_codes_test.go` | 解析单测 | -| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 | -| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 | -| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 | -| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server | -| `pkg/render/openresty/render_test.go` | 集成渲染断言 | -| `internal/model/system_configs.go` | 三个 ConfigKey 常量 | -| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed | -| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html | -| `internal/apps/openflare/option` 相关 test | 校验失败用例 | -| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 | -| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 | -| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) | -| `frontend/app/(main)/error-pages/page.tsx` | 设置页 | -| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 | -| `frontend/lib/utils/search-data.ts` | 搜索 | -| `docs/reference/configuration.md` | 配置键说明(中文) | -| `docs/changelog/index.md` | Unreleased | -| `docs/plan/index.md` | 进行中索引 | - ---- - -### Task 1: 状态码标签解析(纯函数 TDD) - -**Files:** -- Create: `pkg/render/openresty/status_codes.go` -- Create: `pkg/render/openresty/status_codes_test.go` - -**Interfaces:** -- Produces: - - `func ExpandStatusCodeTags(tags []string) (codes []int, err error)` - - `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi` - - 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599` -- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝) - -- [ ] **Step 1: 写失败单测** - -```go -func TestExpandStatusCodeTags(t *testing.T) { - t.Parallel() - codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"}) - if err != nil { - t.Fatal(err) - } - // want sorted unique: 500,501,502,522 - if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 { - t.Fatalf("got %v", codes) - } - _, err = ExpandStatusCodeTags([]string{"399"}) - if err == nil { - t.Fatal("expected error") - } - _, err = ExpandStatusCodeTags([]string{"503-500"}) - if err == nil { - t.Fatal("expected reverse range error") - } - _, err = ExpandStatusCodeTags([]string{"5xx"}) - if err == nil { - t.Fatal("expected syntax error") - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1` -Expected: FAIL(函数未定义) - -- [ ] **Step 3: 实现 `status_codes.go`** - -```go -package openresty - -import ( - "fmt" - "sort" - "strconv" - "strings" -) - -const ( - StatusCodeMin = 400 - StatusCodeMax = 599 -) - -func ParseStatusCodeTag(tag string) (lo, hi int, err error) { - tag = strings.TrimSpace(tag) - if tag == "" { - return 0, 0, fmt.Errorf("状态码标签不能为空") - } - if i := strings.IndexByte(tag, '-'); i >= 0 { - lo, err = strconv.Atoi(tag[:i]) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) - } - hi, err = strconv.Atoi(tag[i+1:]) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码区间: %s", tag) - } - } else { - lo, err = strconv.Atoi(tag) - if err != nil { - return 0, 0, fmt.Errorf("无效状态码: %s", tag) - } - hi = lo - } - if lo > hi { - return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag) - } - if lo < StatusCodeMin || hi > StatusCodeMax { - return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag) - } - return lo, hi, nil -} - -func ExpandStatusCodeTags(tags []string) ([]int, error) { - set := map[int]struct{}{} - for _, tag := range tags { - lo, hi, err := ParseStatusCodeTag(tag) - if err != nil { - return nil, err - } - for c := lo; c <= hi; c++ { - set[c] = struct{}{} - } - } - out := make([]int, 0, len(set)) - for c := range set { - out = append(out, c) - } - sort.Ints(out) - return out, nil -} -``` - -- [ ] **Step 4: 运行确认通过** - -Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go -git commit -m "feat(openresty): add status code tag expand helper" -``` - ---- - -### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD) - -**Files:** -- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加: - - `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\`` - - `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\`` - - `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\`` -- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile` -- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile -- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location -- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go` - -**Interfaces:** -- Produces: - - `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"` - - `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}` - - `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认 - - `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串 - - SupportFile content = Effective HTML(保留占位符) - -**Internal location 形状(必须 status 透传):** - -```nginx - location = /__openflare_origin_error { - internal; - default_type text/html; - charset utf-8; - content_by_lua_block { - local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r") - if not f then - ngx.status = ngx.status - ngx.say("Error ", ngx.status) - return - end - local body = f:read("*a") - f:close() - local status = tostring(ngx.status) - local host = ngx.var.host or "" - body = body:gsub("{{status}}", status, 1) - body = body:gsub("{{host}}", host, 1) - -- 全局替换剩余占位(若模板多处 status) - body = body:gsub("{{status}}", status) - body = body:gsub("{{host}}", host) - ngx.header["Content-Type"] = "text/html; charset=utf-8" - ngx.say(body) - } - } -``` - -占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。 - -在每个反代 `location /` 内(proxy 块): - -```nginx - proxy_intercept_errors on; -``` - -在 server 块内(location 外): - -```nginx - error_page 500 501 ... = /__openflare_origin_error; -``` - -+ internal location。 - -Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。 - -- [ ] **Step 1: 写失败单测** - -```go -func TestRenderOriginErrorPageEnabled(t *testing.T) { - t.Parallel() - doc := Document{ - Routes: []Route{{ - ID: 1, SiteName: "ex", Domains: []string{"ex.test"}, - OriginURL: "http://127.0.0.1:9", Enabled: true, - }}, - OpenRestyConfig: ConfigSnapshot{ - OriginErrorPageEnabled: true, - OriginErrorPageStatusCodes: []string{"500-599"}, - }, - } - out, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "proxy_intercept_errors on") { - t.Fatal("missing intercept") - } - if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") { - t.Fatal("missing error_page") - } - if !strings.Contains(out, "{{status}}") == false { - // SupportFile 在 Render 全量结果中 - } - res, err := Render(doc, nil) - if err != nil { - t.Fatal(err) - } - found := false - for _, f := range res.SupportFiles { - if f.Path == OriginErrorPageSupportPath { - found = true - if !strings.Contains(f.Content, "{{status}}") { - t.Fatal("template missing placeholder") - } - } - } - if !found { - t.Fatal("missing support file") - } -} - -func TestRenderOriginErrorPageDisabled(t *testing.T) { - t.Parallel() - doc := Document{ - Routes: []Route{{ - ID: 1, SiteName: "ex", Domains: []string{"ex.test"}, - OriginURL: "http://127.0.0.1:9", Enabled: true, - }}, - OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false}, - } - out, err := RenderRouteConfig(doc, nil) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "proxy_intercept_errors") { - t.Fatal("should not intercept when disabled") - } -} -``` - -- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS - -默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。 - -- [ ] **Step 3: Agent placeholder 替换** - -搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。 -若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。 - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat(openresty): render origin error page directives" -``` - ---- - -### Task 3: Option keys、迁移、校验、快照 - -**Files:** -- Modify: `internal/model/system_configs.go` — 常量: - - `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"` - - `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"` - - `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"` -- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`: - -```sql --- +goose Up -INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - --- +goose Down -DELETE FROM w_system_configs WHERE key IN ( - 'origin_error_page_enabled', - 'origin_error_page_status_codes', - 'origin_error_page_html' -); -``` - -(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。) - -- Modify: `openresty_validators.go`: - - enabled → `validateBooleanOption` - - status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024` - - html → `len(value) <= 256<<10` - -- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`: - - Enabled: `getBoolConfig(..., true)` - - StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}` - - HTML: `getStringConfig(..., "")` - -- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示) - -- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]` - -- [ ] **Step 2: 实现迁移与 snapshot 填充** - -- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段** - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat(option): seed and validate origin error page options" -``` - ---- - -### Task 4: 前端 Tags Input + `/error-pages` 页 - -**Files:** -- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`) -- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行) -- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步 -- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page -- Reuse: `OptionService.list` / `updateBatch`(同 performance 页) - -**UI 行为:** -1. 管理员加载 options → 映射三字段 -2. Switch 启用 -3. TagsInput:默认展示解析后的 JSON 数组 -4. Textarea HTML;按钮「加载默认模板」「恢复默认」 -5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc` -6. 保存:`updateBatch` 三条;toast 提示去版本发布 -7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误) - -默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。 - -- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用** - -- [ ] **Step 2: 实现 page.tsx** - -- [ ] **Step 3: 导航与搜索** - -- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示) - -- [ ] **Step 5: Commit** - -```bash -git commit -m "feat(frontend): add origin error page settings under websites" -``` - ---- - -### Task 5: 文档、changelog、收尾门禁 - -**Files:** -- Modify: `docs/reference/configuration.md` — 三 key 说明 -- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文 -- Modify: `docs/plan/index.md` — 本计划列入进行中 - -- [ ] **Step 1: 写配置说明与 changelog** - -示例 changelog: - -```markdown -- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。 -``` - -- [ ] **Step 2: `make code-check` 与前端 format** - -- [ ] **Step 3: 手动验收清单(对照设计 §7.2)** - -1. 默认启用 + 源站宕机 → 错误页 + 真实 status -2. 源站 503 → 替换 -3. 仅 `522` → 其它 5xx 透传 -4. 关闭并发布 → 透传 -5. 自定义 `{{status}}`/`{{host}}` -6. Pages 不受影响 - -- [ ] **Step 4: Final commit** - -```bash -git commit -m "docs: origin error page configuration and changelog" -``` - ---- - -## Spec coverage checklist - -| Spec 要求 | Task | -|-----------|------| -| 全局开关 | 3, 4 | -| 默认 `500-599` / 单码+区间 | 1, 3, 4 | -| 状态码透传 + `{{status}}` | 2 | -| 默认 CF 风格 / 自定义 HTML | 2, 4 | -| Option + 配置版本 | 3 | -| 仅反代 | 2 | -| 侧栏网站管理 | 4 | -| Tags Input | 4 | -| 256KB 限制 | 3 | -| 测试与 changelog | 1–5 | - -## Placeholder scan - -无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。 diff --git a/docs/superpowers/plans/2026-08-08-log-database-decoupling.md b/docs/superpowers/plans/2026-08-08-log-database-decoupling.md deleted file mode 100644 index 2524f000..00000000 --- a/docs/superpowers/plans/2026-08-08-log-database-decoupling.md +++ /dev/null @@ -1,2556 +0,0 @@ -# 日志数据库解耦(ClickHouse 可选化)实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 让日志/分析存储从 ClickHouse 解耦——新增 `internal/repository/logstore` 抽象(PG/SQLite 用 GORM、CH 用现有原生优化),ClickHouse 变为可选;提供「切换日志数据库」迁移任务与按库保留时间配置。 - -**Architecture:** repository 层导出接口 + 配置驱动 provider(`log_database` 系统配置决定激活实现);apps 只面向 `logstore`/`repository` 公开函数,import-lint 测试强制约束;CH 实现包住现有 `analyticsrepo`(零性能损耗);PG/SQLite 共用一套 GORM 实现(方言 SQL 拆 `dialect_*` 小文件)。 - -**Tech Stack:** Go 1.25+、GORM、PostgreSQL/SQLite(主库 goose 双方言)、ClickHouse(原生 driver + 单方言 goose)、Asynq 任务框架、Next.js/TypeScript/shadcn。 - -## Global Constraints - -- 模块:`github.com/Rain-kl/Wavelet`;Go 1.25.7。 -- 分层:`apps → repository → model`;`model` 禁止 import `repository`/`db`;`pkg/util/` 禁止 Gin/GORM/sessions。 -- 路由仅注册于 `internal/router/router.go`;`Serve()` 禁止进程级初始化。 -- 迁移:PG/SQLite 双方言同版本号 goose SQL(`internal/infra/persistence/migrator/goose/{postgres,sqlite}`);CH 单方言(`goose/clickhouse`);禁止 GORM AutoMigrate(**生产**;单测可用 sqlite AutoMigrate 建测试表)。 -- 任务/推送注册:`bootstrap.RegisterTasks()` 等显式装配,禁止 `init()` 注册跨模块集成。 -- API 错误:`response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))`。 -- 系统配置:key 常量在 `internal/model/system_configs.go`;值存字符串;`type` ∈ {`system`,`business`};`visibility` 0/1;goose 双方言 seed。 -- 前端:shadcn `variant` + CSS 变量;页面根 `w-full`;标题 `h1 text-2xl font-semibold tracking-tight`;service 继承 `BaseService`,回调用箭头函数。 -- 日志库合法状态:`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且 `postgres` 仅当 `database.enabled`、`sqlite` 仅当 `!database.enabled`、`clickhouse` 仅当 `clickhouse.enabled`。 -- 完成标准:`go test ./...`、`make swagger`(API 变更时)、`make code-check`、`make format`;goose 三套空库 Up 全量通过。 - ---- - -## 里程碑与文件总览 - -| 文件 | 职责 | -|---|---| -| `internal/model/analytics/filter.go`(新) | 从 analyticsrepo 迁入的过滤/结果 DTO(纯数据) | -| `internal/model/system_configs.go` | 新增 `ConfigKeyLogDatabase`、`ConfigKeyLogDBMigration`、`ConfigKeyLogRetentionDaysPostgres/SQLite/ClickHouse` | -| `internal/repository/logstore/logstore.go`(新) | 导出接口 + `Store` 结构体 + `ErrMigrating` | -| `internal/repository/logstore/provider.go`(新) | `Init(ctx)`/`Active(ctx)`/`Migrating(ctx)`/`Reload`/测试注入 | -| `internal/repository/logstore/postgres_store.go`(新) | GORM 实现(PG/SQLite 共用) | -| `internal/repository/logstore/dialect_postgres.go`、`dialect_sqlite.go`(新) | 方言 SQL 片段 | -| `internal/repository/logstore/clickhouse_store.go`(新) | CH 实现(委托 analyticsrepo) | -| `internal/repository/logstore/hooks.go`(新) | `AccessLogInsertHooks`/`ObservabilityInsertHooks` 注册表(从 repository 迁入) | -| `internal/repository/logstore/imports_test.go`(新) | import-lint 测试 | -| `internal/repository/openflare_access_log_store.go`、`openflare_observability_store.go` | 删除(被 logstore 吸收) | -| `internal/repository/openflare_access_log.go`、`openflare_observability.go` | 改为一行委托 logstore | -| `internal/apps/risk_control/logics.go`、`internal/apps/openflare/chwriter/writer.go` | flush func 与入口改为 logstore;冻结检查 | -| `internal/apps/openflare/tasks/database_cleanup.go` | 清理逻辑迁入 `system_cleanup`;任务下线 | -| `internal/apps/admin/logs/routers.go`、`internal/apps/admin/status/clickhouse.go` | 改走 logstore;状态端点改造 | -| `internal/apps/upload/task/cleanup.go` | 新增日志清理步骤 | -| `internal/apps/openflare/async_tasks.go`、`internal/infra/task/handlers/register.go` | 注册「切换日志数据库」任务;下线清理任务 | -| `internal/apps/openflare/tasks/log_db_switch.go`(新) | 迁移任务 Handler | -| `internal/platform/bootstrap/bootstrap.go` | 启动校验 + logstore 初始化 | -| `internal/infra/config/model.go` | (无新启动配置;校验仅用现有字段) | -| goose:`postgres/20260808NNNN_create_log_tables.sql`、`sqlite/20260808NNNN_create_log_tables.sql` | 6 张原始日志表(PG 分区) | -| goose:`postgres/20260808NNNN_log_retention_configs.sql`、`sqlite/...` | 保留配置 + 旧 key 下线 | -| goose:`postgres/20260808NNNN_drop_database_cleanup_schedule.sql`、`sqlite/...` | 下线 `of_database_auto_cleanup` schedule | -| `internal/apps/admin/system_config/routers.go`、`internal/apps/openflare/option/validate.go` | `log_database`/`log_db_migration` key 保护 | -| `frontend/...` | 任务管理页日志库状态、业务配置「日志保留时间」分组 | -| `docs/changelog/index.md` | `[Unreleased]` 中文条目 | - ---- - -## M1:抽象层与主库日志读写 - -### Task 1: DTO 类型迁入 model/analytics - -**Files:** -- Create: `internal/model/analytics/filter.go` -- Modify: `internal/repository/analytics/access_log.go`、`node_access_log.go`、`node_observability.go`、`access_log_stats.go`、`node_access_log_stats.go`、`node_observability_delete.go` 等(删除本地类型定义,改 import model/analytics) -- Test: `internal/model/analytics/filter_test.go` - -**Interfaces:** -- Consumes: 现有 analyticsrepo 包内类型定义位置。 -- Produces: `analyticsmodel.AccessLogFilter`、`analyticsmodel.NodeAccessLogFilter`、`analyticsmodel.NodeObservabilityFilter`、`analyticsmodel.DailyTrend`、`analyticsmodel.BrowserShare`、`analyticsmodel.TopUser`、`analyticsmodel.NodeAccessLogRegionCount`、`analyticsmodel.NodeAccessLogTrafficSummary`、`analyticsmodel.NodeAccessLogValueCount`、`analyticsmodel.NodeAccessLogNodeAggregate`(字段逐一从 analyticsrepo 原定义复制)。 - -- [ ] **Step 1: 在 `internal/model/analytics/filter.go` 定义迁移类型** - -```go -// Package analytics 定义分析域模型与查询 DTO(纯数据,无 IO)。 -package analytics - -import "time" - -// AccessLogFilter 用户访问日志查询条件。 -type AccessLogFilter struct { - UserID uint64 - Path string - Method string - IP string - Status int32 - Since time.Time - Until time.Time - Page int - PageSize int -} - -// NodeAccessLogFilter 节点访问日志查询条件。 -type NodeAccessLogFilter struct { - NodeID string - RemoteAddr string - Host string - Hosts []string - Path string - Since time.Time - Until time.Time - Page int - PageSize int - SortBy string - SortOrder string -} - -// NodeObservabilityFilter 可观测查询条件。 -type NodeObservabilityFilter struct { - NodeID string - Since time.Time - Limit int -} - -// DailyTrend 每日访问趋势。 -type DailyTrend struct { - Date string - Cnt uint64 -} - -// BrowserShare 浏览器占比。 -type BrowserShare struct { - Browser string - Cnt uint64 -} - -// TopUser 活跃用户排行。 -type TopUser struct { - UserID uint64 - Cnt uint64 -} - -// NodeAccessLogRegionCount 地区访问计数。 -type NodeAccessLogRegionCount struct { - Region string - Count uint64 -} - -// NodeAccessLogTrafficSummary 流量汇总。 -type NodeAccessLogTrafficSummary struct { - RequestCount uint64 - ErrorCount uint64 - UniqueIPCount uint64 - BytesSent uint64 - RequestLength uint64 - NodeCount uint64 -} - -// NodeAccessLogValueCount 维度值计数。 -type NodeAccessLogValueCount struct { - Value string - Count uint64 -} - -// NodeAccessLogNodeAggregate 按节点聚合。 -type NodeAccessLogNodeAggregate struct { - NodeID string - RequestCount uint64 - ErrorCount uint64 - UniqueIPCount uint64 -} -``` - -> 注意:以上字段必须与 `internal/repository/analytics/` 中同名类型**逐字段一致**(比对 `access_log.go`、`node_access_log.go`、`node_access_log_stats.go`、`access_log_stats.go`)。若原类型字段与这里不同,以原类型为准修改本文件,保持语义不变。 - -- [ ] **Step 2: 让 analyticsrepo 使用新类型**——在每个原类型定义处删除定义,替换为类型别名,保证包内调用点零改动: - -```go -// internal/repository/analytics/access_log.go 顶部 -import analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - -type AccessLogFilter = analyticsmodel.AccessLogFilter -``` - -对 `NodeAccessLogFilter`、`NodeObservabilityFilter`、`DailyTrend`、`BrowserShare`、`TopUser`、`NodeAccessLogRegionCount`、`NodeAccessLogTrafficSummary`、`NodeAccessLogValueCount`、`NodeAccessLogNodeAggregate`、`ClickHouseOperationalStats`(及 `ClickHouseOperationalStats` 的字段结构体,含 `BatchWriters []batchwriter.Stats`)同样处理(原类型定义删除,替换为别名)。`ClickHouseOperationalStats` 迁入 `model/analytics` 后,logstore 状态接口可直接引用,CH 实现仍由 analyticsrepo 填充。 - -- [ ] **Step 3: 编译验证** 运行 `go build ./internal/...`,确认无重定义/未使用错误。 -- [ ] **Step 4: 提交** `git add internal/model/analytics/filter.go internal/repository/analytics/ && git commit -m "refactor(analytics): move filter/result DTOs to model/analytics"` - -### Task 2: logstore 接口与 provider 骨架 - -**Files:** -- Create: `internal/repository/logstore/logstore.go` -- Create: `internal/repository/logstore/provider.go` -- Create: `internal/repository/logstore/provider_test.go` - -**Interfaces:** -- Consumes: `analyticsmodel.*` DTO(Task 1)、`model.ConfigKeyLogDatabase`/`ConfigKeyLogDBMigration`(Task 8 定义,本任务先用字符串常量占位并加注释)、`db.DB(ctx)`(`internal/infra/persistence` 的 GORM 句柄)、`repository.GetSystemConfigByKey`。 -- Produces: 接口 `AccessLogStore`/`ObservabilityStore`/`UserAccessLogStore`、结构体 `Store`、`ErrMigrating`、`Init(ctx)`/`Active(ctx)`/`Migrating(ctx)`/`ResetForTest`。 - -- [ ] **Step 1: 写接口与 `Store` 结构体(logstore.go)** - -```go -// Package logstore 提供日志/分析存储抽象:上层只面向本包接口, -// 禁止直接 import internal/repository/analytics 或触碰 db.ChConn/db.ChDB。 -package logstore - -import ( - "context" - "errors" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" -) - -// ErrMigrating 表示日志数据库正在迁移,当前禁止写入。 -var ErrMigrating = errors.New("log database is migrating, writes are disabled") - -// AccessLogStore 节点访问日志(of_node_access_logs)。 -type AccessLogStore interface { - // InsertBatch 为写入入口:冻结检查 + 经 hook 入队(异步),不直接落库。 - InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error - // BatchInsertNodeAccessLogs 为 batchwriter flush 目标:直接批量写入当前存储。 - BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error - - List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) - Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) - RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) - BucketAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) - CountBuckets(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) (int64, error) - BucketDimensions(ctx context.Context, filter model.OpenFlareAccessLogQuery, column string, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketDimension, error) - IPAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery, exactRemoteAddr bool) ([]analyticsmodel.NodeAccessLogIPAggregate, error) - IPSummaries(ctx context.Context, filter model.OpenFlareAccessLogQuery, recentSince time.Time) ([]analyticsmodel.NodeAccessLogIPSummary, error) - CountIPSummaries(ctx context.Context, filter model.OpenFlareAccessLogQuery) (int64, error) - WAFIPAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery) ([]analyticsmodel.NodeAccessLogWAFIPAggregate, error) - IPTrend(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogIPTrend, error) - TrafficSummary(ctx context.Context, filter model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) - ValueCounts(ctx context.Context, filter model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) - NodeAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) - DeleteAll(ctx context.Context) (int64, error) - DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) - DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) - // ListForMigration 按 id 升序分页读取(迁移复制用)。 - ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeAccessLog, error) -} - -// ObservabilityStore 可观测 4 表(metric snapshots / edge health / frps / frpc)。 -type ObservabilityStore interface { - InsertMetricSnapshot(ctx context.Context, record *model.OpenFlareMetricSnapshot) error - ListMetricSnapshots(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareMetricSnapshot, error) - DeleteAllMetricSnapshots(ctx context.Context) (int64, error) - DeleteMetricSnapshotsBefore(ctx context.Context, cutoff time.Time) (int64, error) - BatchInsertNodeMetricSnapshots(ctx context.Context, rows []analyticsmodel.NodeMetricSnapshot) error - - InsertEdgeHealth(ctx context.Context, record *model.OpenFlareEdgeHealth) error - ListEdgeHealth(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareEdgeHealth, error) - DeleteAllEdgeHealth(ctx context.Context) (int64, error) - DeleteEdgeHealthBefore(ctx context.Context, cutoff time.Time) (int64, error) - BatchInsertNodeEdgeHealth(ctx context.Context, rows []analyticsmodel.NodeEdgeHealth) error - - InsertNodeObservationFrps(ctx context.Context, record *model.OpenFlareNodeObservationFrps) error - ListNodeObservationFrps(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareNodeObservationFrps, error) - DeleteAllNodeObservationFrps(ctx context.Context) (int64, error) - DeleteNodeObservationFrpsBefore(ctx context.Context, cutoff time.Time) (int64, error) - BatchInsertNodeObsFrps(ctx context.Context, rows []analyticsmodel.NodeObsFrps) error - - InsertNodeObservationFrpc(ctx context.Context, record *model.OpenFlareNodeObservationFrpc) error - ListNodeObservationFrpc(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareNodeObservationFrpc, error) - DeleteAllNodeObservationFrpc(ctx context.Context) (int64, error) - DeleteNodeObservationFrpcBefore(ctx context.Context, cutoff time.Time) (int64, error) - BatchInsertNodeObsFrpc(ctx context.Context, rows []analyticsmodel.NodeObsFrpc) error - - // 迁移复制用:按 id 升序分页读取。 - ListMetricSnapshotsForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeMetricSnapshot, error) - ListEdgeHealthForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeEdgeHealth, error) - ListNodeObsFrpsForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeObsFrps, error) - ListNodeObsFrpcForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeObsFrpc, error) -} - -// UserAccessLogStore 用户访问日志(w_user_access_logs)。 -type UserAccessLogStore interface { - BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error - Count(ctx context.Context, filter analyticsmodel.AccessLogFilter) (uint64, error) - List(ctx context.Context, filter analyticsmodel.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) - GetDailyTrend(ctx context.Context, days int) ([]analyticsmodel.DailyTrend, error) - GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsmodel.BrowserShare, error) - GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsmodel.TopUser, error) -} - -// StatusStore 日志库状态(供管理端状态端点)。 -type StatusStore interface { - ActiveDatabase(ctx context.Context) (string, error) - ClickHouseOperationalStats(ctx context.Context) (*analyticsmodel.ClickHouseOperationalStats, error) // 仅 CH 激活时非 nil -} - -// Store 聚合当前生效日志库的全部域存储。 -type Store struct { - AccessLogs AccessLogStore - Observability ObservabilityStore - UserAccessLogs UserAccessLogStore - Status StatusStore -} -``` - -- [ ] **Step 2: 写 provider(provider.go)** - -```go -package logstore - -import ( - "context" - "errors" - "fmt" - "sync" - - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" -) - -// logDatabaseKey / logMigrationKey 暂用字符串,Task 8 换为 model.ConfigKey*。 -const ( - logDatabaseKey = "log_database" - logMigrationKey = "log_db_migration" -) - -// ConfigReader 读取系统配置字符串值,由 bootstrap 注入(避免 logstore ↔ repository 循环依赖)。 -type ConfigReader func(ctx context.Context, key string) (string, error) - -var ( - configReader ConfigReader - - storeMu sync.RWMutex - active *Store - activeDB string -) - -// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。 -func SetConfigReader(fn ConfigReader) { configReader = fn } - -func getConfig(ctx context.Context, key string) (string, error) { - if configReader == nil { - return "", errors.New("logstore: config reader not wired") - } - return configReader(ctx, key) -} - -// Active 返回当前生效的日志库 Store。按 log_database 系统配置惰性解析并缓存, -// 配置更新(含迁移任务翻转)后自动重建。 -func Active(ctx context.Context) (*Store, error) { - current, err := resolveDatabase(ctx) - if err != nil { - return nil, err - } - storeMu.RLock() - if active != nil && activeDB == current { - s := active - storeMu.RUnlock() - return s, nil - } - storeMu.RUnlock() - - storeMu.Lock() - defer storeMu.Unlock() - if active != nil && activeDB == current { - return active, nil - } - s, err := buildStore(ctx, current) - if err != nil { - return nil, err - } - active = s - activeDB = current - return s, nil -} - -// Migrating 返回日志库是否处于迁移冻结状态。 -func Migrating(ctx context.Context) bool { - v, err := getConfig(ctx, logMigrationKey) - if err != nil { - return false - } - return v == "migrating" -} - -// Init 在 bootstrap 阶段预热一次激活 store(幂等,失败不致命——首次使用时再解析)。 -func Init(ctx context.Context) { - _, _ = Active(ctx) -} - -// ResetForTest 清空缓存的激活 store 与 reader,便于测试注入。 -func ResetForTest() { - storeMu.Lock() - active = nil - activeDB = "" - storeMu.Unlock() -} - -// Build 直接按目标构造 store(迁移任务复制到目标库时使用,不经 Active 缓存)。 -func Build(ctx context.Context, database string) (*Store, error) { - return buildStore(ctx, database) -} - -// ActiveDatabase 返回当前日志主库名(postgres|sqlite|clickhouse)。 -func ActiveDatabase(ctx context.Context) (string, error) { - return resolveDatabase(ctx) -} - -// resolveDatabase 读取 log_database,缺失时按启动规则 seed 并返回。 -func resolveDatabase(ctx context.Context) (string, error) { - v, err := getConfig(ctx, logDatabaseKey) - if err == nil && v != "" { - return v, nil - } - // 首次启动 seed:CH 启用 → clickhouse;否则随主库。 - defaultDB := "sqlite" - if config.Config.Database.Enabled { - defaultDB = "postgres" - } - if config.Config.ClickHouse.Enabled { - defaultDB = "clickhouse" - } - return defaultDB, nil -} - -// buildStore 按目标构造实现(Task 3-5 提供构造函数)。 -func buildStore(ctx context.Context, database string) (*Store, error) { - switch database { - case "clickhouse": - ch := newClickHouseStore() - return &Store{AccessLogs: ch, Observability: ch, UserAccessLogs: ch, Status: ch}, nil - case "postgres", "sqlite": - g := newGormStore(db.DB(ctx)) - return &Store{AccessLogs: g, Observability: g, UserAccessLogs: g, Status: g}, nil - default: - return nil, fmt.Errorf("unsupported log database: %s", database) - } -} -``` - -(`db.DB(ctx)` 返回 `*gorm.DB`,见 `internal/infra/persistence/postgres.go`;`newGormStore`/`newClickHouseStore` 在 Task 3-5 实现。) - -- [ ] **Step 3: 写 provider 单测(provider_test.go)**——用 `SetStoreForTest` 注入 fake 验证 `Active` 缓存与切换: - -```go -package logstore - -import ( - "context" - "testing" -) - -func TestMigratingReadsConfig(t *testing.T) { - ResetForTest() - SetConfigReader(func(_ context.Context, key string) (string, error) { - if key == logMigrationKey { - return "migrating", nil - } - return "", nil - }) - if !Migrating(context.Background()) { - t.Fatal("Migrating() = false, want true when key=migrating") - } - SetConfigReader(func(_ context.Context, key string) (string, error) { - return "", nil - }) - if Migrating(context.Background()) { - t.Fatal("Migrating() = true, want false when key empty") - } -} - -func TestResolveDatabaseDefaults(t *testing.T) { - ResetForTest() - // 配置缺失时按主库规则 seed(config.Config 默认值由既有测试基建决定)。 - got, err := resolveDatabase(context.Background()) - if err != nil { - t.Fatalf("resolveDatabase: %v", err) - } - if got != "postgres" && got != "sqlite" && got != "clickhouse" { - t.Fatalf("unexpected default log database: %s", got) - } -} -``` - -- [ ] **Step 4: 运行测试** `go test ./internal/repository/logstore/` 期望 PASS。 -- [ ] **Step 5: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): add log store interfaces and provider skeleton"` - -### Task 3: GORM 实现——节点访问日志(AccessLogStore) - -**Files:** -- Create: `internal/repository/logstore/postgres_store.go` -- Create: `internal/repository/logstore/dialect_postgres.go` -- Create: `internal/repository/logstore/dialect_sqlite.go` -- Create: `internal/repository/logstore/postgres_store_test.go` - -**Interfaces:** -- Consumes: `db.DB(ctx)`、`analyticsmodel.*`、`model.OpenFlareAccessLog*`、`hooks` 注册表(Task 5 提供 `QueueNodeAccessLogs`)。 -- Produces: `newGormStore(db *gorm.DB) *gormLogStore`(实现 `AccessLogStore`/`ObservabilityStore`/`UserAccessLogStore`)。 - -- [ ] **Step 1: 写 dialect 小文件** - -`dialect_postgres.go`: -```go -package logstore - -import "gorm.io/gorm" - -// timeBucketSQL 返回 PG 时间分桶表达式(epoch 秒 -> 分桶起点)。 -func timeBucketSQL(column string, bucketSeconds int64) string { - return "to_timestamp(floor(extract(epoch from " + column + ")/" + itoa(bucketSeconds) + ")*" + itoa(bucketSeconds) + ")" -} - -// gormDBForWrite 返回写句柄(PG/SQLite 相同)。 -func gormDBForWrite(db *gorm.DB) *gorm.DB { return db } -``` - -`dialect_sqlite.go`: -```go -package logstore - -import ( - "strconv" - - "gorm.io/gorm" -) - -func timeBucketSQL(column string, bucketSeconds int64) string { - return "(floor(unixepoch(" + column + ")/" + strconv.FormatInt(bucketSeconds, 10) + ")*" + strconv.FormatInt(bucketSeconds, 10) + ")" -} - -func gormDBForWrite(db *gorm.DB) *gorm.DB { return db } -``` - -> 若需要精确到毫秒的分桶(现有 CH 用秒级分桶即可),以现有 `node_access_log_stats.go` 的 bucket 语义为准,两种方言输出同一语义。 - -- [ ] **Step 2: 写 `postgres_store.go`(节点访问日志部分)** - -```go -package logstore - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "gorm.io/gorm" -) - -// gormLogStore 是 PG/SQLite 共用的 GORM 日志存储实现。 -type gormLogStore struct { - db *gorm.DB -} - -func newGormStore(db *gorm.DB) *gormLogStore { return &gormLogStore{db: db} } - -// ensureWritable 冻结期拒绝写入。 -func (s *gormLogStore) ensureWritable(ctx context.Context) error { - if Migrating(ctx) { - return ErrMigrating - } - return nil -} - -// InsertBatch 节点访问日志写入入口:冻结检查后经 hook 入队(异步),与现状一致。 -func (s *gormLogStore) InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error { - if err := s.ensureWritable(ctx); err != nil { - return err - } - rows := make([]analyticsmodel.NodeAccessLog, 0, len(records)) - for _, r := range records { - if r == nil { - continue - } - rows = append(rows, toAnalyticsNodeAccessLog(r)) - } - if h := currentAccessLogHooks().QueueNodeAccessLogs; h != nil { - h(rows) - } - return nil -} - -// BatchInsertNodeAccessLogs 是 batchwriter flush 目标:GORM 分批落库。 -func (s *gormLogStore) BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { - if len(rows) == 0 { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error -} - -func (s *gormLogStore) List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { - f := toNodeAccessLogFilter(query) - var rows []analyticsmodel.NodeAccessLog - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) - if f.Since.IsZero() == false { - q = q.Where("logged_at >= ?", f.Since) - } - if f.Until.IsZero() == false { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.RemoteAddr != "" { - q = q.Where("remote_addr = ?", f.RemoteAddr) - } - if len(f.Hosts) > 0 { - q = q.Where("host IN ?", f.Hosts) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - if f.Path != "" { - q = q.Where("path = ?", f.Path) - } - order := "logged_at DESC, id DESC" - if f.SortOrder == "asc" { - order = "logged_at ASC, id ASC" - } - if err := q.Order(order).Limit(limitOr(f.PageSize, 100)).Offset(offsetOf(f.Page, f.PageSize)).Find(&rows).Error; err != nil { - return nil, err - } - return fromAnalyticsNodeAccessLogs(rows), nil -} - -func (s *gormLogStore) Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) { - f := toNodeAccessLogFilter(query) - var total, uniqIP, bytesSent int64 - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) - if !f.Since.IsZero() { - q = q.Where("logged_at >= ?", f.Since) - } - if !f.Until.IsZero() { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.RemoteAddr != "" { - q = q.Where("remote_addr = ?", f.RemoteAddr) - } - if len(f.Hosts) > 0 { - q = q.Where("host IN ?", f.Hosts) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - if f.Path != "" { - q = q.Where("path = ?", f.Path) - } - if err := q.Count(&total).Error; err != nil { - return 0, 0, 0, err - } - if err := q.Distinct("remote_addr").Count(&uniqIP).Error; err != nil { - return 0, 0, 0, err - } - if err := q.Select("COALESCE(SUM(bytes_sent),0)").Scan(&bytesSent).Error; err != nil { - return 0, 0, 0, err - } - return total, uniqIP, bytesSent, nil -} - -func (s *gormLogStore) TrafficSummary(ctx context.Context, query model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) { - f := toNodeAccessLogFilter(query) - var out struct { - RequestCount int64 - ErrorCount int64 - UniqueIPCount int64 - BytesSent int64 - RequestLength int64 - NodeCount int64 - } - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) - if !f.Since.IsZero() { - q = q.Where("logged_at >= ?", f.Since) - } - if !f.Until.IsZero() { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - err := q.Select(` - COUNT(*) AS request_count, - COUNT(*) FILTER (WHERE status_code >= 500) AS error_count, - COUNT(DISTINCT remote_addr) AS unique_ip_count, - COALESCE(SUM(bytes_sent),0) AS bytes_sent, - COALESCE(SUM(request_length),0) AS request_length, - COUNT(DISTINCT node_id) AS node_count`).Scan(&out).Error - if err != nil { - return model.OpenFlareAccessLogTrafficSummary{}, err - } - return model.OpenFlareAccessLogTrafficSummary{ - RequestCount: out.RequestCount, - ErrorCount: out.ErrorCount, - UniqueIPCount: out.UniqueIPCount, - BytesSent: out.BytesSent, - RequestLength: out.RequestLength, - NodeCount: out.NodeCount, - }, nil -} - -func (s *gormLogStore) ValueCounts(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) { - col, ok := nodeAccessLogValueColumn(column) - if !ok { - return nil, fmt.Errorf("unsupported value count column: %s", column) - } - f := toNodeAccessLogFilter(query) - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). - Select(col+" AS value, COUNT(*) AS count") - if !f.Since.IsZero() { - q = q.Where("logged_at >= ?", f.Since) - } - if !f.Until.IsZero() { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - type row struct { - Value string - Count int64 - } - var rows []row - if err := q.Group(col).Order("count DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error; err != nil { - return nil, err - } - out := make([]model.OpenFlareAccessLogValueCount, len(rows)) - for i, r := range rows { - out[i] = model.OpenFlareAccessLogValueCount{Value: r.Value, Count: r.Count} - } - return out, nil -} - -func (s *gormLogStore) NodeAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) { - f := toNodeAccessLogFilter(query) - type row struct { - NodeID string - RequestCount int64 - ErrorCount int64 - UniqueIPCount int64 - } - var rows []row - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). - Select("node_id, COUNT(*) AS request_count, COUNT(*) FILTER (WHERE status_code >= 500) AS error_count, COUNT(DISTINCT remote_addr) AS unique_ip_count") - if !f.Since.IsZero() { - q = q.Where("logged_at >= ?", f.Since) - } - if !f.Until.IsZero() { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - if err := q.Group("node_id").Order("request_count DESC").Scan(&rows).Error; err != nil { - return nil, err - } - out := make([]model.OpenFlareAccessLogNodeAggregate, len(rows)) - for i, r := range rows { - out[i] = model.OpenFlareAccessLogNodeAggregate{NodeID: r.NodeID, RequestCount: r.RequestCount, ErrorCount: r.ErrorCount, UniqueIPCount: r.UniqueIPCount} - } - return out, nil -} - -func (s *gormLogStore) RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) { - type row struct { - Region string - Count int64 - } - var rows []row - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). - Select("region, COUNT(*) AS count"). - Where("node_id = ? AND region <> '' AND logged_at >= ?", nodeID, since) - if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error; err != nil { - return nil, err - } - out := make([]*model.OpenFlareAccessLogRegionCount, len(rows)) - for i, r := range rows { - out[i] = &model.OpenFlareAccessLogRegionCount{Region: r.Region, Count: r.Count} - } - return out, nil -} - -func (s *gormLogStore) BucketAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) { - f := toNodeAccessLogFilter(query) - expr := timeBucketSQL("logged_at", bucketSeconds) - type row struct { - Bucket int64 - RequestCount int64 - ErrorCount int64 - } - var rows []row - q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). - Select(expr+" AS bucket, COUNT(*) AS request_count, COUNT(*) FILTER (WHERE status_code >= 500) AS error_count") - if !f.Since.IsZero() { - q = q.Where("logged_at >= ?", f.Since) - } - if !f.Until.IsZero() { - q = q.Where("logged_at <= ?", f.Until) - } - if f.NodeID != "" { - q = q.Where("node_id = ?", f.NodeID) - } - if f.Host != "" { - q = q.Where("host = ?", f.Host) - } - if err := q.Group(expr).Order("bucket ASC").Scan(&rows).Error; err != nil { - return nil, err - } - out := make([]analyticsmodel.NodeAccessLogBucketAggregate, len(rows)) - for i, r := range rows { - out[i] = analyticsmodel.NodeAccessLogBucketAggregate{Bucket: r.Bucket, RequestCount: r.RequestCount, ErrorCount: r.ErrorCount} - } - return out, nil -} - -func (s *gormLogStore) DeleteAll(ctx context.Context) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeAccessLog{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("logged_at < ?", cutoff).Delete(&analyticsmodel.NodeAccessLog{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("node_id = ? AND logged_at < ?", nodeID, before).Delete(&analyticsmodel.NodeAccessLog{}) - return res.RowsAffected, res.Error -} -``` - -- [ ] **Step 2b: 补齐 AccessLogStore 剩余聚合方法(必须全部实现 + 编译期断言)** - -`gormLogStore` 必须实现 `AccessLogStore` 的**全部 20 个方法**(当前 Step 1 只含 13 个)。补齐:`CountBuckets`、`BucketDimensions`、`IPAggregates`、`IPSummaries`、`CountIPSummaries`、`WAFIPAggregates`、`IPTrend`。语义以 `internal/repository/analytics/node_access_log_stats.go`(及 `node_access_log.go` 中对应函数)为准,用 GORM/方言 SQL 等价实现: - -- 时间分桶统一返回 epoch 秒整型:PG `(floor(extract(epoch from )/)*)::bigint`;SQLite `(floor(unixepoch()/)*)`(修正 `timeBucketSQL`,保证 PG/SQLite 输出同为 int64 epoch,与 `BucketEpoch` 扫描类型一致)。 -- `CountBuckets`:`SELECT COUNT(*) FROM (SELECT 1 FROM t WHERE ... GROUP BY bucket) x`。 -- `BucketDimensions`:`GROUP BY bucket, ` 返回维度计数。 -- `IPAggregates`:按 remote_addr(或精确 remote_addr)聚合 request_count / error_count / unique host 等,字段对照 `NodeAccessLogIPAggregate`。 -- `IPSummaries` / `CountIPSummaries`:按 IP 汇总近窗口(含最近活跃时间),字段对照 `NodeAccessLogIPSummary`。 -- `WAFIPAggregates`:按 IP 聚合状态码分布,字段对照 `NodeAccessLogWAFIPAggregate`。 -- `IPTrend`:按 IP × 时间桶聚合,字段对照 `NodeAccessLogIPTrend`。 -- **过滤语义对齐 CH**(`node_access_log_filter.go`):remote_addr/host/path 用 `LIKE trim(value)+'%'` 前缀匹配;hosts 用 `lower(trim(host)) IN (...)`;until 用开区间 `<`;node_id 先 trim。 -- 文件底部加编译期断言:`var _ AccessLogStore = (*gormLogStore)(nil)`。 -- 测试:`postgres_store_test.go` 至少覆盖 `CountBuckets`/`IPTrend`(sqlite 内存库写入若干行后断言分桶数量与趋势),其余方法以编译期断言 + 既有语义测试兜底。 - -- [ ] **Step 3: 写 helper(postgres_store.go 同文件底部)** - -```go -func limitOr(v, def int) int { - if v <= 0 { - return def - } - return v -} - -func offsetOf(page, pageSize int) int { - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - return (page - 1) * pageSize -} - -func nodeAccessLogValueColumn(column string) (string, bool) { - switch column { - case "remote_addr": - return "remote_addr", true - case "host": - return "host", true - case "path": - return "path", true - case "region": - return "region", true - case "status_code": - return "status_code", true - case "user_agent": - return "user_agent", true - case "cache_status": - return "cache_status", true - } - return "", false -} -``` - -> `toAnalyticsNodeAccessLog`/`fromAnalyticsNodeAccessLogs`/`toNodeAccessLogFilter` 从 `internal/repository/openflare_access_log_store.go` 复制(含 math 边界保护逻辑);Task 6 删除旧文件后这些 helper 不再冲突。 - -- [ ] **Step 4: 写单测(postgres_store_test.go,sqlite 内存库 + AutoMigrate)** - -```go -package logstore - -import ( - "context" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" -) - -func newTestGormStore(t *testing.T) *gormLogStore { - t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&analyticsmodel.NodeAccessLog{}); err != nil { - t.Fatalf("automigrate: %v", err) - } - return newGormStore(db) -} - -func TestGormBatchInsertAndCount(t *testing.T) { - ResetForTest() - SetConfigReader(func(_ context.Context, _ string) (string, error) { return "", nil }) - s := newTestGormStore(t) - now := time.Now() - rows := []analyticsmodel.NodeAccessLog{ - {ID: 1, NodeID: "n1", LoggedAt: now, RemoteAddr: "1.1.1.1", StatusCode: 200, BytesSent: 100}, - {ID: 2, NodeID: "n1", LoggedAt: now, RemoteAddr: "2.2.2.2", StatusCode: 500, BytesSent: 200}, - } - if err := s.BatchInsertNodeAccessLogs(context.Background(), rows); err != nil { - t.Fatalf("insert: %v", err) - } - total, uniqIP, bytesSent, err := s.Count(context.Background(), model.OpenFlareAccessLogQuery{NodeID: "n1"}) - if err != nil { - t.Fatalf("count: %v", err) - } - if total != 2 || uniqIP != 2 || bytesSent != 300 { - t.Fatalf("count got total=%d uniq=%d bytes=%d", total, uniqIP, bytesSent) - } -} -``` - -(`nodeQuery` 返回 `model.OpenFlareAccessLogQuery{NodeID: "n1"}`;`InsertBatch` 冻结与 hook 测试放 Task 6。) - -- [ ] **Step 5: 运行测试** `go test ./internal/repository/logstore/` 期望 PASS。 -- [ ] **Step 6: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): GORM node access log store"` - -### Task 4: GORM 实现——可观测 4 表 + 用户访问日志 - -**Files:** -- Modify: `internal/repository/logstore/postgres_store.go`(追加方法) -- Modify: `internal/repository/logstore/postgres_store_test.go` - -**Interfaces:** -- Consumes: `model.OpenFlareMetricSnapshot`/`OpenFlareEdgeHealth`/`OpenFlareNodeObservationFrps`/`OpenFlareNodeObservationFrpc`、`analyticsmodel.NodeMetricSnapshot` 等、`currentObservabilityHooks()`(Task 5)。 -- Produces: `gormLogStore` 完整实现 `ObservabilityStore` 与 `UserAccessLogStore`。 - -- [ ] **Step 1: 可观测写入入口 + flush + 查询(追加到 postgres_store.go)** - -```go -// ---- ObservabilityStore ---- - -func (s *gormLogStore) InsertMetricSnapshot(ctx context.Context, record *model.OpenFlareMetricSnapshot) error { - if record == nil { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - if h := currentObservabilityHooks().QueueMetricSnapshot; h != nil { - h(toAnalyticsNodeMetricSnapshot(record)) - } - return nil -} - -func (s *gormLogStore) ListMetricSnapshots(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareMetricSnapshot, error) { - var rows []analyticsmodel.NodeMetricSnapshot - q := s.db.WithContext(ctx).Where("node_id = ? AND captured_at >= ?", nodeID, since).Order("captured_at DESC, id DESC") - if err := q.Limit(limitOr(limit, 100)).Find(&rows).Error; err != nil { - return nil, err - } - return fromAnalyticsNodeMetricSnapshots(rows), nil -} - -func (s *gormLogStore) DeleteAllMetricSnapshots(ctx context.Context) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeMetricSnapshot{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) DeleteMetricSnapshotsBefore(ctx context.Context, cutoff time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("captured_at < ?", cutoff).Delete(&analyticsmodel.NodeMetricSnapshot{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) BatchInsertNodeMetricSnapshots(ctx context.Context, rows []analyticsmodel.NodeMetricSnapshot) error { - if len(rows) == 0 { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error -} - -// InsertEdgeHealth 等 8 个 entry/list/delete + 3 个 flush 全部与 metric snapshots 同构。 -// 完整模板(以 edge health 为例): - -func (s *gormLogStore) InsertEdgeHealth(ctx context.Context, record *model.OpenFlareEdgeHealth) error { - if record == nil { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - if h := currentObservabilityHooks().QueueEdgeHealth; h != nil { - h(toAnalyticsNodeEdgeHealth(record)) - } - return nil -} - -func (s *gormLogStore) ListEdgeHealth(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareEdgeHealth, error) { - var rows []analyticsmodel.NodeEdgeHealth - if err := s.db.WithContext(ctx).Where("node_id = ? AND captured_at >= ?", nodeID, since). - Order("captured_at DESC, id DESC").Limit(limitOr(limit, 100)).Find(&rows).Error; err != nil { - return nil, err - } - return fromAnalyticsNodeEdgeHealths(rows), nil -} - -func (s *gormLogStore) DeleteAllEdgeHealth(ctx context.Context) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeEdgeHealth{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) DeleteEdgeHealthBefore(ctx context.Context, cutoff time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - res := s.db.WithContext(ctx).Where("captured_at < ?", cutoff).Delete(&analyticsmodel.NodeEdgeHealth{}) - return res.RowsAffected, res.Error -} - -func (s *gormLogStore) BatchInsertNodeEdgeHealth(ctx context.Context, rows []analyticsmodel.NodeEdgeHealth) error { - if len(rows) == 0 { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error -} - -// FRPS/FRPC 两组按同一模板,替换映射如下: -// FRPS: model.OpenFlareNodeObservationFrps ↔ analyticsmodel.NodeObsFrps;hook=QueueNodeObsFrps;转换 toAnalyticsNodeObsFrps -// FRPC: model.OpenFlareNodeObservationFrpc ↔ analyticsmodel.NodeObsFrpc;hook=QueueNodeObsFrpc;转换 toAnalyticsNodeObsFrpc -// list 列名统一 captured_at;delete 统一 captured_at < cutoff。 -// 转换函数(toAnalyticsNodeEdgeHealth/fromAnalyticsNodeEdgeHealths/toAnalyticsNodeObsFrps/toAnalyticsNodeObsFrpc) -// 从旧 openflare_observability_store.go 复制。 -``` - -> 逐方法补齐(8 个 entry/list/delete + 3 个 flush),表名/模型:`analyticsmodel.NodeEdgeHealth`、`analyticsmodel.NodeObsFrps`、`analyticsmodel.NodeObsFrpc`;model 侧 `OpenFlareEdgeHealth`、`OpenFlareNodeObservationFrps`、`OpenFlareNodeObservationFrpc`。`toAnalyticsNodeEdgeHealth` 等转换函数从旧 `openflare_observability_store.go` 复制。 - -- [ ] **Step 2: 用户访问日志(追加)** - -```go -// ---- UserAccessLogStore ---- - -func (s *gormLogStore) BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error { - if len(logs) == 0 { - return nil - } - if err := s.ensureWritable(ctx); err != nil { - return err - } - return s.db.WithContext(ctx).CreateInBatches(logs, 500).Error -} - -func (s *gormLogStore) Count(ctx context.Context, filter analyticsmodel.AccessLogFilter) (uint64, error) { - var total int64 - q := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}) - if filter.UserID != 0 { - q = q.Where("user_id = ?", filter.UserID) - } - if filter.Path != "" { - q = q.Where("path = ?", filter.Path) - } - if filter.Method != "" { - q = q.Where("method = ?", filter.Method) - } - if filter.IP != "" { - q = q.Where("ip = ?", filter.IP) - } - if filter.Status != 0 { - q = q.Where("status = ?", filter.Status) - } - if !filter.Since.IsZero() { - q = q.Where("created_at >= ?", filter.Since) - } - if !filter.Until.IsZero() { - q = q.Where("created_at <= ?", filter.Until) - } - if err := q.Count(&total).Error; err != nil { - return 0, err - } - return uint64(total), nil -} - -func (s *gormLogStore) List(ctx context.Context, filter analyticsmodel.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) { - total, err := s.Count(ctx, filter) - if err != nil { - return nil, 0, err - } - if total == 0 { - return []analyticsmodel.UserAccessLog{}, 0, nil - } - var rows []analyticsmodel.UserAccessLog - q := s.db.WithContext(ctx).Where(buildUserAccessLogWhere(filter)).Order("created_at DESC, id DESC") - if err := q.Limit(pageSize).Offset(offsetOf(page, pageSize)).Find(&rows).Error; err != nil { - return nil, 0, err - } - return rows, total, nil -} - -func (s *gormLogStore) GetDailyTrend(ctx context.Context, days int) ([]analyticsmodel.DailyTrend, error) { - if days <= 0 { - days = 7 - } - // 镜像 CH access_log_stats.go:起点 = (days-1) 天前当日零点;必须返回恰好 days 个日历日并补零。 - start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour) - type row struct { - Date string - Cnt uint64 - } - var rows []row - err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). - Select(dailyTrendDateSQL()+" AS date, COUNT(*) AS cnt"). - Where("created_at >= ?", start). - Group("date").Order("date ASC").Scan(&rows).Error - if err != nil { - return nil, err - } - counts := make(map[string]uint64, len(rows)) - for _, r := range rows { - counts[r.Date] = r.Cnt - } - out := make([]analyticsmodel.DailyTrend, 0, days) - for i := 0; i < days; i++ { - d := start.AddDate(0, 0, i).Format("2006-01-02") - out = append(out, analyticsmodel.DailyTrend{Date: d, Cnt: counts[d]}) - } - return out, nil -} - -func (s *gormLogStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsmodel.BrowserShare, error) { - return s.userAgentGroupCount(ctx, startTime, "browser") -} - -func (s *gormLogStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsmodel.TopUser, error) { - type row struct { - UserID uint64 - Cnt uint64 - } - var rows []row - err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). - Select("user_id, COUNT(*) AS cnt"). - Where("user_id <> 0 AND created_at >= ?", startTime). - Group("user_id").Order("cnt DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error - if err != nil { - return nil, err - } - out := make([]analyticsmodel.TopUser, len(rows)) - for i, r := range rows { - out[i] = analyticsmodel.TopUser{UserID: r.UserID, Cnt: r.Cnt} - } - return out, nil -} -``` - -> `buildUserAccessLogWhere` 与 `Count` 内联条件一致。**AccessLogFilter 使用单一权威字段集(Task 1 迁入的 CH 原字段)**:`UserIDs []uint64`、`Path`、`StartTime`/`EndTime *time.Time`。GORM 的 Count/List 必须用该字段集并镜像 CH 过滤语义(`user_id IN ?`、`path LIKE '%..%'`、`StartTime >=`、`EndTime <`)——**禁止在 AccessLogFilter 上追加仅 GORM 使用的字段**(会造成双字段集静默分叉)。`GetDailyTrend` 的日期格式化拆到 dialect 文件:`dailyTrendDateSQL()` 返回 PG `to_char(created_at,'YYYY-MM-DD')` / SQLite `strftime('%Y-%m-%d', created_at)`。`userAgentGroupCount` 用现有 `analyticsrepo.ParseBrowserName` 语义改为 SQL 侧 `CASE` 或复用 helper——实现时对照 `access_log_stats.go` 的浏览器判定逻辑,保持统计口径一致。 - -- [ ] **Step 3: 单测追加**——`TestGormUserAccessLogCountList`、`TestGormObservabilityInsertList`(sqlite AutoMigrate 对应模型,断言写入/查询/删除)。 -- [ ] **Step 4: 运行** `go test ./internal/repository/logstore/` PASS。 -- [ ] **Step 5: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): GORM observability and user access log store"` - -### Task 5: CH 包装实现 + hooks 注册表迁入 logstore - -**Files:** -- Create: `internal/repository/logstore/clickhouse_store.go` -- Create: `internal/repository/logstore/hooks.go` -- Modify: `internal/repository/openflare_access_log_store.go`、`internal/repository/openflare_observability_store.go`(删除,被吸收) - -**Interfaces:** -- Consumes: `analyticsrepo.*` 全部现成函数、`db.ChConn`/`db.ChDB`。 -- Produces: `newClickHouseStore() *clickhouseLogStore`;`SetAccessLogHooks`/`SetObservabilityHooks`/`currentAccessLogHooks`/`currentObservabilityHooks`。 - -- [ ] **Step 1: hooks.go** - -```go -package logstore - -import ( - "sync" - - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" -) - -// AccessLogHooks 节点访问日志异步入队回调(由 chwriter 装配)。 -type AccessLogHooks struct { - QueueNodeAccessLogs func(logs []analyticsmodel.NodeAccessLog) -} - -// ObservabilityHooks 可观测异步入队回调(由 chwriter 装配)。 -type ObservabilityHooks struct { - QueueMetricSnapshot func(record analyticsmodel.NodeMetricSnapshot) - QueueEdgeHealth func(record analyticsmodel.NodeEdgeHealth) - QueueNodeObsFrps func(record analyticsmodel.NodeObsFrps) - QueueNodeObsFrpc func(record analyticsmodel.NodeObsFrpc) -} - -var ( - hooksMu sync.RWMutex - accessLogHooks AccessLogHooks - observabilityHooks ObservabilityHooks -) - -func SetAccessLogHooks(h AccessLogHooks) { - hooksMu.Lock() - accessLogHooks = h - hooksMu.Unlock() -} - -func SetObservabilityHooks(h ObservabilityHooks) { - hooksMu.Lock() - observabilityHooks = h - hooksMu.Unlock() -} - -func currentAccessLogHooks() AccessLogHooks { - hooksMu.RLock() - defer hooksMu.RUnlock() - return accessLogHooks -} - -func currentObservabilityHooks() ObservabilityHooks { - hooksMu.RLock() - defer hooksMu.RUnlock() - return observabilityHooks -} -``` - -> 旧 `AccessLogInsertHooks`/`ObservabilityInsertHooks` 及 `SetAccessLogInsertHooks` 等在 repository 包删除,chwriter 改为调用 `logstore.SetAccessLogHooks`(Task 9)。 - -- [ ] **Step 2: clickhouse_store.go——逐方法委托 analyticsrepo(仅列代表,全部方法照此)** - -```go -package logstore - -import ( - "context" - "errors" - "time" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" -) - -type clickhouseLogStore struct{} - -func newClickHouseStore() *clickhouseLogStore { return &clickhouseLogStore{} } - -func chConnErr() error { - if !db.ChConnReady() { - return errors.New("clickhouse connection is not initialized") - } - return nil -} - -// ---- AccessLogStore ---- - -func (s *clickhouseLogStore) InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error { - if err := s.ensureWritable(ctx); err != nil { - return err - } - rows := make([]analyticsmodel.NodeAccessLog, 0, len(records)) - for _, r := range records { - if r == nil { - continue - } - rows = append(rows, toAnalyticsNodeAccessLog(r)) - } - if h := currentAccessLogHooks().QueueNodeAccessLogs; h != nil { - h(rows) - } - return nil -} - -func (s *clickhouseLogStore) ensureWritable(ctx context.Context) error { - if Migrating(ctx) { - return ErrMigrating - } - return nil -} - -func (s *clickhouseLogStore) BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { - if err := s.ensureWritable(ctx); err != nil { - return err - } - return analyticsrepo.BatchInsertNodeAccessLogs(ctx, rows) -} - -func (s *clickhouseLogStore) List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { - rows, err := analyticsrepo.ListNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) - if err != nil { - return nil, err - } - return fromAnalyticsNodeAccessLogs(rows), nil -} - -func (s *clickhouseLogStore) Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) { - return analyticsrepo.CountNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) -} - -func (s *clickhouseLogStore) RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) { - rows, err := analyticsrepo.RegionCountsNodeAccessLogs(ctx, nodeID, since, limit) - if err != nil { - return nil, err - } - out := make([]*model.OpenFlareAccessLogRegionCount, len(rows)) - for i, r := range rows { - out[i] = &model.OpenFlareAccessLogRegionCount{Region: r.Region, Count: r.Count} - } - return out, nil -} - -func (s *clickhouseLogStore) BucketAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) { - return analyticsrepo.BucketAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) -} - -func (s *clickhouseLogStore) CountBuckets(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) (int64, error) { - return analyticsrepo.CountBucketAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) -} - -func (s *clickhouseLogStore) BucketDimensions(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketDimension, error) { - return analyticsrepo.BucketDimensionsNodeAccessLogs(ctx, toNodeAccessLogFilter(query), column, bucketSeconds) -} - -func (s *clickhouseLogStore) IPAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, exactRemoteAddr bool) ([]analyticsmodel.NodeAccessLogIPAggregate, error) { - return analyticsrepo.IPAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), exactRemoteAddr) -} - -func (s *clickhouseLogStore) IPSummaries(ctx context.Context, query model.OpenFlareAccessLogQuery, recentSince time.Time) ([]analyticsmodel.NodeAccessLogIPSummary, error) { - return analyticsrepo.IPSummariesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), recentSince) -} - -func (s *clickhouseLogStore) CountIPSummaries(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, error) { - return analyticsrepo.CountIPSummaryNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) -} - -func (s *clickhouseLogStore) WAFIPAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]analyticsmodel.NodeAccessLogWAFIPAggregate, error) { - return analyticsrepo.IPAggregatesForWAFNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) -} - -func (s *clickhouseLogStore) IPTrend(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogIPTrend, error) { - return analyticsrepo.IPTrendNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) -} - -func (s *clickhouseLogStore) TrafficSummary(ctx context.Context, query model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) { - row, err := analyticsrepo.TrafficSummaryNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) - if err != nil { - return model.OpenFlareAccessLogTrafficSummary{}, err - } - return model.OpenFlareAccessLogTrafficSummary{ - RequestCount: int64(row.RequestCount), - ErrorCount: int64(row.ErrorCount), - UniqueIPCount: int64(row.UniqueIPCount), - BytesSent: int64(row.BytesSent), - RequestLength: int64(row.RequestLength), - NodeCount: int64(row.NodeCount), - }, nil -} - -func (s *clickhouseLogStore) ValueCounts(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) { - rows, err := analyticsrepo.ValueCountsNodeAccessLogs(ctx, toNodeAccessLogFilter(query), column, limit) - if err != nil { - return nil, err - } - out := make([]model.OpenFlareAccessLogValueCount, len(rows)) - for i, r := range rows { - out[i] = model.OpenFlareAccessLogValueCount{Value: r.Value, Count: int64(r.Count)} - } - return out, nil -} - -func (s *clickhouseLogStore) NodeAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) { - rows, err := analyticsrepo.NodeAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) - if err != nil { - return nil, err - } - out := make([]model.OpenFlareAccessLogNodeAggregate, len(rows)) - for i, r := range rows { - out[i] = model.OpenFlareAccessLogNodeAggregate{NodeID: r.NodeID, RequestCount: int64(r.RequestCount), ErrorCount: int64(r.ErrorCount), UniqueIPCount: int64(r.UniqueIPCount)} - } - return out, nil -} - -func (s *clickhouseLogStore) DeleteAll(ctx context.Context) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - return analyticsrepo.DeleteAllNodeAccessLogs(ctx) -} - -func (s *clickhouseLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - return analyticsrepo.DeleteNodeAccessLogsBefore(ctx, cutoff) -} - -func (s *clickhouseLogStore) DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) { - if err := s.ensureWritable(ctx); err != nil { - return 0, err - } - return analyticsrepo.DeleteNodeAccessLogsByNodeBefore(ctx, nodeID, before) -} - -// ---- ObservabilityStore(entry=ensureWritable+hook;flush/query/delete 委托 analyticsrepo) -// InsertMetricSnapshot / ListMetricSnapshots / DeleteAllMetricSnapshots / DeleteMetricSnapshotsBefore / BatchInsertNodeMetricSnapshots -// ...(同构,参照旧 clickhouseObservabilityStore 委托) -// ---- UserAccessLogStore -// BatchInsert -> analyticsrepo.BatchInsert -// Count/List -> analyticsrepo.CountAccessLogs / ListAccessLogs -// GetDailyTrend / GetBrowserDistribution / GetTopActiveUsers -> analyticsrepo.GetDailyTrend / GetBrowserDistribution / GetTopActiveUsers -``` - -> 转换函数 `toAnalyticsNodeAccessLog`/`fromAnalyticsNodeAccessLogs`/`toNodeAccessLogFilter`/`toAnalyticsNodeMetricSnapshot` 等集中放 `postgres_store.go` 或本文件共享区域(两个实现共用)。 - -- [ ] **Step 3: 删除旧 store 文件**——删 `internal/repository/openflare_access_log_store.go`、`internal/repository/openflare_observability_store.go`;其中的 memory store 测试替身迁到 `logstore/memory_store_test.go`(保留 `NewMemoryAccessLogStore` 等价物供 repository 测试)。 -- [ ] **Step 4: 编译 + 测试** `go build ./internal/...`;`go test ./internal/repository/...` 修复引用。 -- [ ] **Step 5: 提交** `git add internal/repository/logstore/ internal/repository/ && git commit -m "refactor(logstore): wrap ClickHouse analytics repo behind interface"` - -### Task 6: repository 公开函数改委托 logstore - -**Files:** -- Modify: `internal/repository/openflare_access_log.go`(函数体改为 `logstore.Active(ctx)` 委托) -- Modify: `internal/repository/openflare_observability.go`(同上) - -**Interfaces:** -- Consumes: `logstore.Active`、`logstore.Store` 字段。 -- Produces: 保留原公开函数签名,行为不变(CH 激活时与现状一致)。 - -- [ ] **Step 1: 改写 `openflare_access_log.go` 各函数** - -```go -package repository - -import ( - "context" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/model/analytics" // 若类型别名仍需要 - "github.com/Rain-kl/Wavelet/internal/repository/logstore" -) - -// ListOpenFlareAccessLogs lists access logs matching the query. -func ListOpenFlareAccessLogs(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { - s, err := logstore.Active(ctx) - if err != nil { - return nil, err - } - return s.AccessLogs.List(ctx, query) -} -``` - -对同文件其余函数(`ListOpenFlareAccessLogWAFIPAggregates`、`InsertOpenFlareAccessLogsBatch`、`CountOpenFlareAccessLogs`、`TrafficSummaryOpenFlareAccessLogs`、`RegionCountsOpenFlareAccessLogs`、`BucketAggregates*`、`CountBuckets*`、`BucketDimensions*`、`IPAggregates*`、`IPSummaries*`、`CountIPSummaries*`、`IPTrend*`、`ValueCounts*`、`NodeAggregates*`、`Delete*`)逐一委托到 `s.AccessLogs` 对应方法;`InsertOpenFlareAccessLogsBatch` → `s.AccessLogs.InsertBatch`。**保留行类型别名**(`openFlareAccessLogBucketAggregateRow` 等)供调用方编译。 - -- [ ] **Step 2: 改写 `openflare_observability.go`**——`InsertOpenFlareMetricSnapshot` → `s.Observability.InsertMetricSnapshot`;`ListMetricSnapshots*`/`Delete*` 同理;健康事件(`ReconcileOpenFlareHealthEvents` 等**主库表**逻辑)保持原实现不动。 -- [ ] **Step 3: 编译 + 测试** `go build ./internal/...`、`go test ./internal/repository/...`(旧测试若引用 memory store 替换为 logstore 测试替身)。 -- [ ] **Step 4: 提交** `git add internal/repository/ && git commit -m "refactor(repository): delegate log CRUD to logstore"` - -### Task 7: import-lint 测试(代码级约束验收) - -**Files:** -- Create: `internal/repository/logstore/imports_test.go` - -- [ ] **Step 1: 写测试** - -```go -package logstore - -import ( - "os/exec" - "strings" - "testing" -) - -// forbiddenImports 上层应用禁止直接触碰的底层日志实现。 -var forbiddenImports = []string{ - "github.com/Rain-kl/Wavelet/internal/repository/analytics", -} - -// allowedInfraPersistence 允许 apps 引入的 infra/persistence 子包。 -// batchwriter=批量写入框架;idgen=snowflake ID 生成工具(apps 合法使用,非日志后端访问)。 -var allowedInfraPersistence = []string{ - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter", - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen", -} - -func TestAppsMustNotImportLogBackendDirectly(t *testing.T) { - t.Chdir("../../..") - out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./internal/apps/...").Output() - if err != nil { - t.Fatalf("go list: %v", err) - } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - pkg := fields[0] - if !strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/internal/apps") { - continue - } - for _, imp := range fields[1:] { - for _, forbidden := range forbiddenImports { - if imp == forbidden && !allowedAnalyticsDelegation[pkg] { - t.Errorf("%s must not import forbidden log backend %s", pkg, forbidden) - } - } - if strings.HasPrefix(imp, "github.com/Rain-kl/Wavelet/internal/infra/persistence/") { - allowed := false - for _, a := range allowedInfraPersistence { - if imp == a || strings.HasPrefix(imp, a+"/") { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s must not import infra/persistence subpackage directly: %s", pkg, imp) - } - } - } - } -} -``` - -> 说明:`go list -deps` 在测试工作目录执行,先 `t.Chdir` 到仓库根(`../../..`)再运行,避免依赖 `go test` 的临时目录。若 `internal/apps/admin/logs` 等仍 import analyticsrepo,本测试失败——正好驱动 Task 9。 - -- [ ] **Step 2: 运行** `go test ./internal/repository/logstore/ -run TestAppsMustNotImportLogBackendDirectly -v`——预期当前**失败**(列出违规包)。 -- [ ] **Step 3: 暂不提交**——本测试在 apps 改造完成前保持 RED(预期失败列出违规包)。Task 9 完成 apps 改造、本测试转绿后,随 Task 9 一并提交(提交信息:`test(logstore): enforce apps must not import log backend directly`)。 - -### Task 8: 系统配置 key + 启动校验 + key 保护 - -**Files:** -- Modify: `internal/model/system_configs.go`(新增 key 常量) -- Modify: `internal/platform/bootstrap/bootstrap.go`(`Init` 加校验与 seed) -- Modify: `internal/apps/admin/system_config/routers.go`(受保护 key 拒绝修改) -- Modify: `internal/apps/openflare/option/validate.go`(同) -- Create: `internal/platform/bootstrap/bootstrap_test.go`(追加校验测试) - -**Interfaces:** -- Consumes: `config.Config.Database.Enabled`、`config.Config.ClickHouse.Enabled`、`repository.GetSystemConfigByKey`、`repository.UpdateSystemConfigFields`。 -- Produces: `model.ConfigKeyLogDatabase = "log_database"`、`model.ConfigKeyLogDBMigration = "log_db_migration"`、`model.ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres"`、`model.ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite"`、`model.ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse"`。 - -- [ ] **Step 1: 新增 key 常量(system_configs.go)** - -```go -// 日志数据库解耦 -ConfigKeyLogDatabase = "log_database" // 当前日志主库:postgres|sqlite|clickhouse(仅迁移任务写入) -ConfigKeyLogDBMigration = "log_db_migration" // 迁移冻结标记:"migrating" 或空 -ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres" // PostgreSQL 日志保留天数 -ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite" // SQLite 日志保留天数 -ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse" // ClickHouse 日志保留天数 -``` - -- [ ] **Step 2: bootstrap 校验 + seed(bootstrap.go `Init` 内,`initRuntimeOnce.Do` 开头)** - -```go -// validateAndSeedLogDatabase 校验日志主库标记与运行配置的一致性,首次启动 seed。 -func validateAndSeedLogDatabase(ctx context.Context) error { - cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) - if err != nil { - return fmt.Errorf("读取日志主库配置失败: %w", err) - } - current := cfg.Value - if current == "" { - // 首次启动 seed:CH 启用 → clickhouse;否则随主库。 - current = "sqlite" - if config.Config.Database.Enabled { - current = "postgres" - } - if config.Config.ClickHouse.Enabled { - current = "clickhouse" - } - if err := repository.UpdateSystemConfigFields(ctx, &model.SystemConfig{Key: model.ConfigKeyLogDatabase}, map[string]any{"value": current}); err != nil { - return fmt.Errorf("初始化日志主库配置失败: %w", err) - } - return nil - } - switch current { - case "clickhouse": - if !config.Config.ClickHouse.Enabled { - return errors.New("当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse") - } - case "postgres": - if !config.Config.Database.Enabled { - return errors.New("当前日志主库为 PostgreSQL 但 PostgreSQL 未启用(当前为 SQLite 主库)。请运行『切换日志数据库』迁回 SQLite 或启用 PostgreSQL") - } - case "sqlite": - if config.Config.Database.Enabled { - return errors.New("当前日志主库为 SQLite 但当前主库为 PostgreSQL。请运行『切换日志数据库』迁移到 PostgreSQL") - } - default: - return fmt.Errorf("未知的日志主库配置: %s", current) - } - return nil -} -``` - -在 `Init` 的 `initRuntimeOnce.Do` 内最先调用:`if err := validateAndSeedLogDatabase(ctx); err != nil { logger.ErrorF(...); log.Fatalf(...) }`(或按项目既有致命启动错误处理方式)。 - -- [ ] **Step 3: key 保护(admin system-config 更新路径)** - -`internal/apps/admin/system_config/routers.go` 的 `UpdateSystemConfig` 与 `internal/apps/openflare/option/validate.go` 增加: - -```go -// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。 -var protectedConfigKeys = map[string]bool{ - model.ConfigKeyLogDatabase: true, - model.ConfigKeyLogDBMigration: true, -} - -func isProtectedConfigKey(key string) bool { return protectedConfigKeys[key] } -``` - -更新处理:命中保护 key 时返回业务错误(`response.AbortBadRequest(c, "该配置项由系统任务管理,禁止手动修改")`),且不写库。 - -- [ ] **Step 4: 单测**——`bootstrap_test.go` 三态校验(clickhouse 未启用 / postgres 但 sqlite 主库 / sqlite 但 postgres 主库)各自返回明确错误;seed 缺失时写入正确默认值。 -- [ ] **Step 5: 运行** `go test ./internal/platform/bootstrap/ ./internal/model/ ./internal/apps/admin/system_config/` PASS。 -- [ ] **Step 6: 提交** `git add internal/model/system_configs.go internal/platform/bootstrap/ internal/apps/admin/system_config/ internal/apps/openflare/option/ && git commit -m "feat(config): log database marker, boot validation, and protected keys"` - -### Task 9: apps 层改走 logstore(消除 import-lint 违规) - -**Files:** -- Modify: `internal/apps/risk_control/logics.go`、`internal/apps/openflare/chwriter/writer.go` -- Modify: `internal/apps/openflare/tasks/database_cleanup.go`(本任务只改 import;清理合并到 M2) -- Modify: `internal/apps/openflare/observability/access_log_logics.go`(仅解析 helper 保留 analyticsrepo 合法引用则不动;若违规则把 `ParseDeviceType`/`ParseBrowserName`/`ParseOSName` 迁到 `model/analytics` 或 `internal/util`) -- Modify: `internal/apps/admin/logs/routers.go`、`internal/apps/admin/status/clickhouse.go` -- Test: `internal/repository/logstore/imports_test.go`(回归) - -**Interfaces:** -- Consumes: `logstore.Active`、`logstore.Migrating`、`logstore.ErrMigrating`、`logstore.SetAccessLogHooks`/`SetObservabilityHooks`。 - -- [ ] **Step 1: chwriter flush func 改为 logstore** - -`writer.go` 中 5 处 `analyticsrepo.BatchInsertNode*` → `logstore.Active(ctx).Observability/AccessLogs` 对应 flush 方法(或包级 helper): - -```go -func flushNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { - s, err := logstore.Active(ctx) - if err != nil { - return err - } - return s.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows) -} -``` - -`Init` 内 `if !config.Config.ClickHouse.Enabled { return }` 改为 `if logstore.Active(ctx) == nil ...` 或直接始终初始化 writer(writer flush 走 logstore,激活库由 logstore 决定);`wireModelInsertHooks` 改为调用 `logstore.SetAccessLogHooks`/`logstore.SetObservabilityHooks`。 - -- [ ] **Step 2: risk_control flush 与冻结** - -`logics.go`:flush func 中 `analyticsrepo.BatchInsert` → `logstore.Active(ctx).UserAccessLogs.BatchInsert`;`InitLogWriter` 的 CH 开关条件移除,改为由 logstore 激活库决定(PG/SQLite 也启用该 writer);middleware 入队前: - -```go -if logstore.Migrating(c.Request.Context()) { - logger.WarnF(c.Request.Context(), "[RiskControl] log DB migrating, skip audit log") - return // 不阻断业务请求 -} -``` - -- [ ] **Step 3: admin/logs 改走 logstore** - -`routers.go` 中 `analyticsrepo.ListAccessLogs/CountAccessLogs/GetDailyTrend/GetBrowserDistribution/GetTopActiveUsers` → `logstore.Active(ctx).UserAccessLogs.*`;`config.Config.ClickHouse.Enabled || !db.ChConnReady()` 的守卫改为按激活库判断(`logstore.Active(ctx)` 成功即可用),错误文案从「ClickHouse 存储服务未启用」改为「日志存储未启用」。 - -- [ ] **Step 4: admin/status 端点骨架** - -`clickhouse.go` 改为读取 `logstore.Active` 与激活库名,返回统一结构(M3 Task 16 完成前端与完整字段): - -```go -type LogDatabaseStatus struct { - ActiveDatabase string `json:"active_database"` - Migration string `json:"migration"` // idle | migrating - RetentionDays map[string]int `json:"retention_days"` - AvailableTargets []string `json:"available_targets"` -} -``` - -CH 激活时保留 `GetClickHouseOperationalStats` 与 `collectBatchWriterStats`。 - -- [ ] **Step 5: database_cleanup.go 临时保留 import 但标记 TODO(M2 Task 13 迁移)**——若 import-lint 在 Task 7 已注册,本任务先让 `database_cleanup.go` 改为经 repository 公开函数(其逻辑已走 logstore),并同步 `access_log_logics.go` 解析 helper(迁 `ParseBrowserName` 等为 `model/analytics` 纯函数,analyticsrepo 内部复用)。 -- [ ] **Step 6: 运行 import-lint 回归** `go test ./internal/repository/logstore/ -run TestAppsMustNotImportLogBackendDirectly -v` 期望 **PASS**。 -- [ ] **Step 7: 全量编译** `go build ./internal/...`、`go test ./internal/apps/...` 修复。 -- [ ] **Step 8: 提交** `git add internal/apps/ && git commit -m "refactor(apps): route log reads/writes through logstore"` - -### Task 10: bootstrap 装配 logstore - -**Files:** -- Modify: `internal/platform/bootstrap/bootstrap.go` -- Modify: `internal/cmd/all.go`、`api.go`、`worker.go`、`root.go`(如有必要) - -**Interfaces:** -- Consumes: `logstore.SetConfigReader`、`logstore.Init`。 -- Produces: 运行期 `logstore` 激活 store 可解析。 - -- [ ] **Step 1: 装配 config reader + Init** - -`bootstrap.Init` 的 `initRuntimeOnce.Do` 内、校验之后: - -```go -logstore.SetConfigReader(func(ctx context.Context, key string) (string, error) { - cfg, err := repository.GetSystemConfigByKey(ctx, key) - if err != nil { - return "", err - } - return cfg.Value, nil -}) -logstore.Init(ctx) -``` - -- [ ] **Step 2: worker 进程也需要 Init**——确认 `cmd/worker.go` 与 `cmd/all.go` 都调用 `bootstrap.Init`(现 API 分支启动 writer;worker 迁移任务需能读配置与激活 store,`logstore.Init` 必须在两种进程都执行)。 -- [ ] **Step 3: 测试** `go test ./internal/platform/bootstrap/`;`go build ./cmd/...`。 -- [ ] **Step 4: 提交** `git add internal/platform/bootstrap/ internal/cmd/ && git commit -m "feat(bootstrap): wire logstore config reader and init"` - ---- - -## M2:建表与清理 - -### Task 10b: 小时级聚合读经 logstore(PG 实时计算 / CH 读 rollup 表) - -**Files:** -- Modify: `internal/repository/logstore/logstore.go`(`ObservabilityStore` 增 3 个方法) -- Modify: `internal/repository/logstore/postgres_store.go`(PG 按小时从原始表实时聚合) -- Modify: `internal/repository/logstore/clickhouse_store.go`(委托 analyticsrepo rollup 读 + 现有 raw 兜底逻辑) -- Modify: `internal/repository/openflare_observability.go`(3 个 `ListOpenFlare*HourlySince` 改委托 logstore) -- Modify: `internal/repository/logstore/imports_test.go`(若 `internal/repository` 不再直接 import analyticsrepo,可移除其对 `allowedAnalyticsDelegation` 的豁免) - -**Interfaces:** -- Consumes: Task 3/4 GORM store、Task 5 CH store、`analyticsrepo.ListNodeTrafficHourly`/`ListAccessLogHourly`/`ListNodeMetricHourly` 及 `mergeNodeMetricHourlyPreferRollup`/`listNodeMetricHourlyFromRaw` 语义。 -- Produces: `ObservabilityStore.ListTrafficHourly(ctx, nodeID, since) ([]analyticsmodel.NodeTrafficHourly, error)`、`ListAccessLogHourly(...)`、`ListMetricHourly(...)`。 - -- [ ] **Step 1: 接口加方法**(logstore.go) -- [ ] **Step 2: CH 实现委托 analyticsrepo**(rollup 表 + raw 兜底,逐行复制现有逻辑) -- [ ] **Step 3: PG 实现按小时实时聚合**——`date_trunc('hour', logged_at/captured_at)` 分组(方言 `timeBucketSQL(col, 3600)` 复用),请求/错误/字节数与 CH rollup 同字段;`ListMetricHourly` 用 `avg(cpu)/max-min 计数器` 近似同 CH `mergeNodeMetricHourlyPreferRollup` 口径。 -- [ ] **Step 4: repository 门面 3 个函数改委托 logstore**;若门面不再 import analyticsrepo,收紧 lint 豁免。 -- [ ] **Step 5: 测试**——PG/SQLite 实时聚合与 CH rollup 口径一致性(sqlite 写原始行断言小时桶输出);CH 委托回归。 -- [ ] **Step 6: 提交** `git add internal/repository/ && git commit -m "feat(logstore): hourly rollup reads with PG real-time aggregation"` - ---- -### Task 11: goose 双方言建表迁移(6 张原始日志表) - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql` - -**Interfaces:** -- Consumes: database-migration 技能规则(双方言同版本号、无物理外键、默认值与 Go 零值一致)。 -- Produces: PG/SQLite 各 6 张日志表(`w_user_access_logs`、`of_node_access_logs`、`of_node_metric_snapshots`、`of_node_edge_health`、`of_node_obs_frps`、`of_node_obs_frpc`)。 - -- [ ] **Step 1: PG 建表(含分区)** - -```sql --- +goose Up --- 节点访问日志:按月 RANGE 分区,复合主键 (id, logged_at) 满足分区键进唯一索引要求。 -CREATE TABLE of_node_access_logs ( - id BIGINT NOT NULL, - node_id VARCHAR(64) NOT NULL, - logged_at TIMESTAMPTZ NOT NULL, - remote_addr VARCHAR(128) NOT NULL DEFAULT '', - region VARCHAR(128) NOT NULL DEFAULT '', - host VARCHAR(255) NOT NULL DEFAULT '', - path VARCHAR(2048) NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - cache_status VARCHAR(64) NOT NULL DEFAULT '', - status_code INTEGER NOT NULL DEFAULT 0, - bytes_sent BIGINT NOT NULL DEFAULT 0, - request_length BIGINT NOT NULL DEFAULT 0, - request_time_ms INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, logged_at) -) PARTITION BY RANGE (logged_at); - -CREATE INDEX idx_of_node_access_logs_node_id ON of_node_access_logs (node_id, logged_at DESC); -CREATE INDEX idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); -CREATE INDEX idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); -CREATE INDEX idx_of_node_access_logs_status_code ON of_node_access_logs (status_code, logged_at DESC); - --- 用户访问日志:按月分区。 -CREATE TABLE w_user_access_logs ( - id BIGINT NOT NULL, - user_id BIGINT NOT NULL DEFAULT 0, - path VARCHAR(2048) NOT NULL DEFAULT '', - method VARCHAR(16) NOT NULL DEFAULT '', - ip VARCHAR(128) NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - headers TEXT NOT NULL DEFAULT '', - status INTEGER NOT NULL DEFAULT 0, - latency BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, created_at) -) PARTITION BY RANGE (created_at); - -CREATE INDEX idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC); - --- 可观测 4 表:普通表 + 索引。 -CREATE TABLE of_node_metric_snapshots ( - id BIGINT NOT NULL PRIMARY KEY, - node_id VARCHAR(64) NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - cpu_usage_percent DOUBLE PRECISION NOT NULL DEFAULT 0, - memory_used_bytes BIGINT NOT NULL DEFAULT 0, - memory_total_bytes BIGINT NOT NULL DEFAULT 0, - storage_used_bytes BIGINT NOT NULL DEFAULT 0, - storage_total_bytes BIGINT NOT NULL DEFAULT 0, - disk_read_bytes BIGINT NOT NULL DEFAULT 0, - disk_write_bytes BIGINT NOT NULL DEFAULT 0, - network_rx_bytes BIGINT NOT NULL DEFAULT 0, - network_tx_bytes BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX idx_of_node_metric_snapshots_node ON of_node_metric_snapshots (node_id, captured_at DESC); - -CREATE TABLE of_node_edge_health ( - id BIGINT NOT NULL PRIMARY KEY, - node_id VARCHAR(64) NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - status VARCHAR(64) NOT NULL DEFAULT '', - connections BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX idx_of_node_edge_health_node ON of_node_edge_health (node_id, captured_at DESC); - -CREATE TABLE of_node_obs_frps ( - id BIGINT NOT NULL PRIMARY KEY, - node_id VARCHAR(64) NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - frps_connections INTEGER NOT NULL DEFAULT 0, - frps_proxy_count INTEGER NOT NULL DEFAULT 0, - frps_client_count INTEGER NOT NULL DEFAULT 0, - frps_proxies TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX idx_of_node_obs_frps_node ON of_node_obs_frps (node_id, captured_at DESC); - -CREATE TABLE of_node_obs_frpc ( - id BIGINT NOT NULL PRIMARY KEY, - node_id VARCHAR(64) NOT NULL, - captured_at TIMESTAMPTZ NOT NULL, - tunnel_status VARCHAR(16) NOT NULL DEFAULT '', - connected_relays_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX idx_of_node_obs_frpc_node ON of_node_obs_frpc (node_id, captured_at DESC); - --- 分区预建:创建未来 3 个月与当前月分区(当月及下两个月)。 -DO $$ -DECLARE - d date; -BEGIN - FOR d IN SELECT generate_series(date_trunc('month', now())::date, (date_trunc('month', now()) + interval '2 months')::date, interval '1 month')::date - LOOP - EXECUTE format('CREATE TABLE IF NOT EXISTS of_node_access_logs_%s PARTITION OF of_node_access_logs FOR VALUES FROM (%L) TO (%L)', - to_char(d, 'YYYYMM'), d, d + interval '1 month'); - EXECUTE format('CREATE TABLE IF NOT EXISTS w_user_access_logs_%s PARTITION OF w_user_access_logs FOR VALUES FROM (%L) TO (%L)', - to_char(d, 'YYYYMM'), d, d + interval '1 month'); - END LOOP; -END $$; - --- +goose Down -DROP TABLE IF EXISTS w_user_access_logs; -DROP TABLE IF EXISTS of_node_access_logs; -DROP TABLE IF EXISTS of_node_metric_snapshots; -DROP TABLE IF EXISTS of_node_edge_health; -DROP TABLE IF EXISTS of_node_obs_frps; -DROP TABLE IF EXISTS of_node_obs_frpc; -``` - -- [ ] **Step 2: SQLite 建表(普通表,同语义)** - -```sql --- +goose Up -CREATE TABLE IF NOT EXISTS of_node_access_logs ( - id INTEGER PRIMARY KEY, - node_id TEXT NOT NULL DEFAULT '', - logged_at DATETIME NOT NULL, - remote_addr TEXT NOT NULL DEFAULT '', - region TEXT NOT NULL DEFAULT '', - host TEXT NOT NULL DEFAULT '', - path TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - cache_status TEXT NOT NULL DEFAULT '', - status_code INTEGER NOT NULL DEFAULT 0, - bytes_sent INTEGER NOT NULL DEFAULT 0, - request_length INTEGER NOT NULL DEFAULT 0, - request_time_ms INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_node ON of_node_access_logs (node_id, logged_at DESC); -CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); -CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); --- 其余 5 表同构(w_user_access_logs 主键 id;可观测表 id INTEGER PRIMARY KEY + (node_id, captured_at DESC) 索引) - --- +goose Down -DROP TABLE IF EXISTS of_node_access_logs; -DROP TABLE IF EXISTS w_user_access_logs; -DROP TABLE IF EXISTS of_node_metric_snapshots; -DROP TABLE IF EXISTS of_node_edge_health; -DROP TABLE IF EXISTS of_node_obs_frps; -DROP TABLE IF EXISTS of_node_obs_frpc; -``` - -- [ ] **Step 3: 验证 goose** `go test ./internal/infra/persistence/migrator`(空库 Up 全量)。 -- [ ] **Step 4: 提交** `git add internal/infra/persistence/migrator/goose/ && git commit -m "feat(migrate): create log tables in postgres and sqlite"` - -### Task 12: 保留时间配置 + 旧 key 下线 - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql` -- Modify: `internal/model/system_configs.go`(删除旧 key 常量或标记废弃) -- Modify: `internal/testhelper/test_helper.go`(seed 同步) - -**Interfaces:** -- Produces: 3 个 business 配置(默认 90);旧 `database_auto_cleanup_enabled`/`database_auto_cleanup_retention_days` 从 `system_configs` 删除。 - -- [ ] **Step 1: PG 迁移** - -```sql --- +goose Up -INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('log_retention_days_postgres', '90', 'business', 0, 'PostgreSQL 日志保留天数(访问日志与可观测统一)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('log_retention_days_sqlite', '90', 'business', 0, 'SQLite 日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('log_retention_days_clickhouse','90', 'business', 0, 'ClickHouse 日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - -DELETE FROM system_configs WHERE key IN ('database_auto_cleanup_enabled', 'database_auto_cleanup_retention_days'); - --- +goose Down -INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('database_auto_cleanup_enabled', 'true', 'business', 0, '数据库自动清理开关', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('database_auto_cleanup_retention_days', '30', 'business', 0, '数据库保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; -DELETE FROM system_configs WHERE key IN ('log_retention_days_postgres', 'log_retention_days_sqlite', 'log_retention_days_clickhouse'); -``` - -- [ ] **Step 2: SQLite 同版本号镜像**(`INSERT OR IGNORE` / `DELETE`,语义一致)。 -- [ ] **Step 3: model 常量更新**——旧 key 常量删除;`validate.go` 中 `validateDatabaseCleanupOption` 替换为 `validateLogRetentionOption`(3 个新 key,值 ≥1 整数)。 -- [ ] **Step 4: testhelper seed 同步**——`seedDefaultConfigs` 增 3 个新 key、删旧 key(含公共 key 列表如有)。 -- [ ] **Step 5: 验证** `go test ./internal/infra/persistence/migrator ./internal/apps/config ./internal/apps/admin/system_config ./internal/testhelper`。 -- [ ] **Step 6: 提交** `git add internal/ && git commit -m "feat(config): per-store log retention settings, drop legacy cleanup config"` - -### Task 13: CleanupStore + system_cleanup 日志清理步骤 + PG 分区预建 - -> 含 Task 11 审查跟进:PG 分区表仅在建表迁移时预建当前+2 月;`CleanupExpired` 每次运行时必须先确保「当前月 + 未来 2 个月」的分区存在(幂等 `CREATE TABLE IF NOT EXISTS ... PARTITION OF`),否则 3 个月后新写入会报 "no partition of relation found"。在 `CleanupStore`(或 logstore 包内 `EnsurePartitions(ctx)`)实现,PG 方言执行、SQLite/CH 为 no-op;`system_cleanup` 每日调用保证分区持续存在。 - -**Files:** -- Create: `internal/repository/logstore/cleanup.go` -- Modify: `internal/apps/upload/task/cleanup.go`(追加日志清理步骤) -- Create: `internal/repository/logstore/cleanup_test.go` - -**Interfaces:** -- Consumes: `model.ConfigKeyLogRetentionDays*`、`logstore.Active`。 -- Produces: `CleanupExpired(ctx) (*CleanupSummary, error)`(repository 层入口,`system_cleanup` 调用)。 - -- [ ] **Step 1: cleanup.go** - -```go -package logstore - -import ( - "context" - "fmt" - "strconv" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" -) - -// CleanupSummary 汇总本次清理结果。 -type CleanupSummary struct { - ActiveDatabase string `json:"active_database"` - RetentionDays int `json:"retention_days"` - Deleted int64 `json:"deleted"` - Tables []string `json:"tables"` -} - -// retentionDaysForActive 按当前激活库读取保留天数(默认 90)。 -func retentionDaysForActive(ctx context.Context) int { - key := model.ConfigKeyLogRetentionDaysPostgres - if dbName, _ := resolveDatabase(ctx); dbName == "sqlite" { - key = model.ConfigKeyLogRetentionDaysSQLite - } else if dbName == "clickhouse" { - key = model.ConfigKeyLogRetentionDaysClickHouse - } - v, err := getConfig(ctx, key) - if err != nil { - return 90 - } - days, perr := strconv.Atoi(v) - if perr != nil || days <= 0 { - return 90 - } - return days -} - -// CleanupExpired 按当前激活库保留天数清理过期日志(每日由 system_cleanup 调用)。 -func CleanupExpired(ctx context.Context) (*CleanupSummary, error) { - s, err := Active(ctx) - if err != nil { - return nil, err - } - days := retentionDaysForActive(ctx) - cutoff := time.Now().AddDate(0, 0, -days) - summary := &CleanupSummary{RetentionDays: days, Tables: []string{}} - summary.ActiveDatabase, _ = resolveDatabase(ctx) - - if err := cleanupTable(ctx, s, "node_access_logs", func() (int64, error) { - return s.AccessLogs.DeleteBefore(ctx, cutoff) - }, summary); err != nil { - return nil, err - } - if err := cleanupTable(ctx, s, "metric_snapshots", func() (int64, error) { - return s.Observability.DeleteMetricSnapshotsBefore(ctx, cutoff) - }, summary); err != nil { - return nil, err - } - // edge_health / obs_frps / obs_frpc 同构 - return summary, nil -} - -func cleanupTable(ctx context.Context, s *Store, name string, fn func() (int64, error), summary *CleanupSummary) error { - n, err := fn() - if err != nil { - return fmt.Errorf("cleanup %s: %w", name, err) - } - summary.Deleted += n - summary.Tables = append(summary.Tables, name) - return nil -} -``` - -> PG 实现优化(可选,首版用 DeleteBefore 即可):`DeleteBefore` 在 PG 分区表上命中 `logged_at` 分区键,按月 DROP 整分区后再 DELETE 不满月——M1 Task 3 的 `DeleteBefore` 已按 `logged_at < cutoff` 实现,满足正确性;后续再优化为 DROP PARTITION。CH 实现:`DeleteNodeAccessLogsBefore` 已做 TTL materialize;保留天数变化时 `clickhouseLogStore.DeleteBefore` 增加 `ALTER TABLE ... MODIFY TTL`(见 M4 优化项,可延后)。 - -- [ ] **Step 2: system_cleanup 追加步骤(upload/task/cleanup.go)** - -在现有清理步骤之后追加: - -```go -task.AppendLog(ctx, "开始清理过期日志(按当前日志库保留天数)...") -summary, err := logstore.CleanupExpired(ctx) -if err != nil { - task.AppendLog(ctx, "清理过期日志失败: %v", err) -} else if summary.Deleted == 0 { - task.AppendLog(ctx, "没有需要清理的过期日志 (保留 %d 天)", summary.RetentionDays) -} else { - task.AppendLog(ctx, "日志清理完成:保留 %d 天,删除 %d 条", summary.RetentionDays, summary.Deleted) -} -``` - -(`internal/apps/upload/task/cleanup.go` import `internal/repository/logstore`——upload/task 属 apps 层,import logstore 合法。) - -- [ ] **Step 3: 单测(cleanup_test.go)**——sqlite store 写入 40 天前/昨天各 1 条,`CleanupExpired` 用 `SetConfigReader` 注入 `log_retention_days_sqlite=30`,断言 40 天前的被删、昨天的保留。 -- [ ] **Step 4: 运行** `go test ./internal/repository/logstore/ ./internal/apps/upload/task/`。 -- [ ] **Step 5: 提交** `git add internal/repository/logstore/ internal/apps/upload/task/ && git commit -m "feat(cleanup): log retention cleanup in system_cleanup task"` - -### Task 14: 下线 of_database_auto_cleanup - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql`、`sqlite/202608080003_...` -- Modify: `internal/apps/openflare/async_tasks.go`(删除 `DatabaseAutoCleanupTask`/`DatabaseAutoCleanupMeta`/`DatabaseAutoCleanupHandler`) -- Modify: `internal/infra/task/handlers/register.go`(注销) -- Modify: `internal/apps/openflare/tasks/database_cleanup.go`(删除;清理能力已并入 system_cleanup) - -**Interfaces:** -- Consumes: Task 13 完成。 -- Produces: `of_database_auto_cleanup` 从 schedule 与任务注册中消失。 - -- [ ] **Step 1: goose 删 schedule** - -```sql --- +goose Up -DELETE FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'; --- +goose Down -INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) -VALUES (102, 'OpenFlare 可观测数据自动清理', 'of_database_auto_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; -``` - -- [ ] **Step 2: 注销任务与删除文件**——`register.go` 移除对应两行;`async_tasks.go` 删除常量/元数据/Handler;删除 `tasks/database_cleanup.go`。 -- [ ] **Step 3: 前端清理**——搜索前端对 `of_database_auto_cleanup` / `database_auto_cleanup_*` 引用并删除(任务页硬编码列表如有)。 -- [ ] **Step 4: 验证** `go build ./internal/...`、`go test ./internal/infra/persistence/migrator ./internal/infra/task/`。 -- [ ] **Step 5: 提交** `git add internal/ frontend/ && git commit -m "chore(cleanup): decommission of_database_auto_cleanup task and schedule"` - ---- - -## M3:迁移任务与展示 - -### Task 15: 「切换日志数据库」任务 Handler - -**Files:** -- Create: `internal/apps/openflare/tasks/log_db_switch.go` -- Create: `internal/apps/openflare/tasks/log_db_switch_test.go` -- Modify: `internal/apps/openflare/async_tasks.go`(注册元数据) -- Modify: `internal/infra/task/handlers/register.go`(注册 Handler) - -**Interfaces:** -- Consumes: `logstore.Active`/`logstore.Migrating`、`repository.UpdateSystemConfigFields`、`model.ConfigKeyLogDatabase`/`ConfigKeyLogDBMigration`、`analyticsmodel.*`、`config.Config`。 -- Produces: Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,参数 `target`。 - -- [ ] **Step 1: 元数据(async_tasks.go)** - -```go -// LogDBSwitchTask 切换日志数据库任务标识。 -const ( - LogDBSwitchTask = "openflare:log_db_switch" - TaskTypeLogDBSwitch = "of_log_db_switch" -) - -var LogDBSwitchMeta = task.TaskMeta{ - Type: TaskTypeLogDBSwitch, - AsynqTask: LogDBSwitchTask, - Name: "切换日志数据库", - Description: "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)", - SupportsTime: false, - MaxRetry: task.DefaultMaxRetry, - Queue: task.QueueDefault, - Retryable: true, - Params: []task.TaskParam{ - {Name: "target", Label: "目标日志库", Type: "string", Required: true, - Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"}, - }, -} -``` - -- [ ] **Step 2: Handler(log_db_switch.go)** - -```go -package tasks - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" -) - -const copyBatchSize = 1000 - -type logDBSwitchPayload struct { - Target string `json:"target"` -} - -// LogDBSwitchHandler 切换日志数据库任务处理器。 -type LogDBSwitchHandler struct{} - -// ValidatePayload 校验并规范化参数。 -func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) { - var p logDBSwitchPayload - if err := json.Unmarshal(payload, &p); err != nil { - return nil, fmt.Errorf("参数解析失败: %w", err) - } - p.Target = normalizeTarget(p.Target) - if !validTarget(p.Target) { - return nil, fmt.Errorf("目标日志库不合法: %s", p.Target) - } - out, err := json.Marshal(p) - if err != nil { - return nil, err - } - return out, nil -} - -func normalizeTarget(v string) string { - switch v { - case "postgres", "postgresql": - return "postgres" - case "sqlite", "sqlite3": - return "sqlite" - case "clickhouse", "ch": - return "clickhouse" - } - return v -} - -func validTarget(v string) bool { - return v == "postgres" || v == "sqlite" || v == "clickhouse" -} - -// Execute 执行迁移。 -func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { - var p logDBSwitchPayload - if err := json.Unmarshal(payload, &p); err != nil { - return nil, fmt.Errorf("参数解析失败: %w", err) - } - p.Target = normalizeTarget(p.Target) - if err := validateSwitch(ctx, p.Target); err != nil { - return nil, err - } - - source, _ := currentLogDatabase(ctx) - task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target) - if err := setMigrationFlag(ctx, "migrating"); err != nil { - return nil, err - } - defer func() { _ = setMigrationFlag(ctx, "") }() // 失败也清除,保持源库可写 - - if err := drainLogWriters(ctx); err != nil { - return nil, fmt.Errorf("排空日志写入队列失败: %w", err) - } - - src, err := logstore.Active(ctx) - if err != nil { - return nil, err - } - dst, err := buildTargetStore(ctx, p.Target) - if err != nil { - return nil, err - } - - // 清空目标库日志表(幂等重试前提)。 - if err := clearTargetLogTables(ctx, dst, p.Target); err != nil { - return nil, err - } - - // 逐表复制。 - if err := copyAccessLogs(ctx, src, dst); err != nil { - return nil, err - } - if err := copyUserAccessLogs(ctx, src, dst); err != nil { - return nil, err - } - if err := copyObservability(ctx, src, dst); err != nil { - return nil, err - } - - // 翻转主库标记。 - if err := flipLogDatabase(ctx, p.Target); err != nil { - return nil, err - } - task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target) - return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil -} -``` - -- [ ] **Step 3: 辅助函数(同文件)** - -```go -func validateSwitch(ctx context.Context, target string) error { - source, err := currentLogDatabase(ctx) - if err != nil { - return err - } - if source == target { - return errors.New("目标日志库与当前日志库相同,无需迁移") - } - switch target { - case "clickhouse": - if !config.Config.ClickHouse.Enabled { - return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse") - } - case "postgres": - if !config.Config.Database.Enabled { - return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL") - } - case "sqlite": - if config.Config.Database.Enabled { - return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite") - } - } - return nil -} - -func currentLogDatabase(ctx context.Context) (string, error) { - cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) - if err != nil { - return "", fmt.Errorf("读取日志主库失败: %w", err) - } - if cfg.Value == "" { - return "", errors.New("日志主库配置为空") - } - return cfg.Value, nil -} - -func setMigrationFlag(ctx context.Context, v string) error { - // 必须用 SaveOrUpdateSystemConfig:UpdateSystemConfigFields 缺行时静默 no-op, - // 且不失效 RAM 配置缓存(TTL=-1 永不过期),会导致冻结/翻转不生效、进程间脑裂。 - return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDBMigration, v) -} - -func flipLogDatabase(ctx context.Context, target string) error { - return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, target) -} - -// buildTargetStore 构造目标库 Store(不经过 Active 缓存,直接 Build)。 -func buildTargetStore(ctx context.Context, database string) (*logstore.Store, error) { - return logstore.Build(ctx, database) -} - -func clearTargetLogTables(ctx context.Context, dst *logstore.Store, target string) error { - // 依次清空 6 张表:AccessLogs.DeleteAll、UserAccessLogs.DeleteAll、Observability.DeleteAll*(SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。 - if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil { - return fmt.Errorf("清空目标访问日志失败: %w", err) - } - if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil { - return fmt.Errorf("清空目标用户访问日志失败: %w", err) - } - for _, fn := range []func(context.Context) (int64, error){ - dst.Observability.DeleteAllMetricSnapshots, - dst.Observability.DeleteAllEdgeHealth, - dst.Observability.DeleteAllNodeObservationFrps, - dst.Observability.DeleteAllNodeObservationFrpc, - } { - if _, err := fn(ctx); err != nil { - return err - } - } - return nil -} - -// copyAccessLogs 从 src 复制节点访问日志到 dst。 -func copyAccessLogs(ctx context.Context, src, dst *logstore.Store) error { - // 注意:迁移期间 src 已冻结,但复制读取不受冻结影响;每批按 id 升序扫描。 - var lastID uint64 - for { - rows, err := listNodeAccessLogsByID(ctx, src, lastID, copyBatchSize) - if err != nil { - return err - } - if len(rows) == 0 { - break - } - if err := dst.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows); err != nil { - return fmt.Errorf("写入目标访问日志失败(批 %d): %w", lastID, err) - } - task.AppendLog(ctx, "已复制访问日志 %d 条(截至 id=%d)", len(rows), rows[len(rows)-1].ID) - lastID = rows[len(rows)-1].ID - if len(rows) < copyBatchSize { - break - } - } - return nil -} -``` - -> `ListForMigration` 已在 Task 2 接口定义:GORM 实现 `Where("id > ?", afterID).Order("id ASC").Limit(limit)`;CH 实现原生 SQL `SELECT ... FROM of_node_access_logs WHERE id > ? ORDER BY id LIMIT ?`。可观测 4 表的 `*ForMigration` 同理(按各自表名/模型)。 - -```go -// copyObservability 复制 4 张可观测表。 -func copyObservability(ctx context.Context, src, dst *logstore.Store) error { - for _, c := range []struct { - name string - read func(ctx context.Context, afterID uint64, limit int) (int, error) - }{ - {"metric_snapshots", func(ctx context.Context, afterID uint64, limit int) (int, error) { - rows, err := src.Observability.ListMetricSnapshotsForMigration(ctx, afterID, limit) - if err != nil || len(rows) == 0 { - return len(rows), err - } - return len(rows), dst.Observability.BatchInsertNodeMetricSnapshots(ctx, rows) - }}, - // edge_health / obs_frps / obs_frpc 同构,调用各自 ForMigration/BatchInsert 对。 - } { - var lastID uint64 - for { - n, err := c.read(ctx, lastID, copyBatchSize) - if err != nil { - return fmt.Errorf("复制 %s 失败: %w", c.name, err) - } - if n == 0 { - break - } - task.AppendLog(ctx, "已复制 %s %d 条", c.name, n) - if n < copyBatchSize { - break - } - lastID += uint64(n) // 近似游标;实现时改为每批最后一条 id 更精确 - } - } - return nil -} -``` - -- [ ] **Step 4: 注册**——`register.go` 加 `task.RegisterHandler(openflare.LogDBSwitchTask, &openflare.LogDBSwitchHandler{})` + `task.RegisterTaskMeta(openflare.LogDBSwitchMeta)`。 -- [ ] **Step 5: 单测(log_db_switch_test.go)**——sqlite↔sqlite 模拟(源 store 写入 3 条,目标 store 空库),执行 `copyAccessLogs` 断言 ID 保留、数量一致;`validateSwitch` 各非法组合报错;`ValidatePayload` 归一化。 -- [ ] **Step 6: 运行** `go test ./internal/apps/openflare/tasks/ ./internal/infra/task/`。 -- [ ] **Step 7: 提交** `git add internal/apps/openflare/ internal/infra/task/ && git commit -m "feat(task): add switch log database migration task"` - -### Task 16: 日志库状态端点 - -**Files:** -- Modify: `internal/apps/admin/status/clickhouse.go`(改造为 `log-database` 状态端点,保留旧路径兼容或重命名 + 路由更新) -- Modify: `internal/router/v1/admin.go`(路由注册) -- Modify: `internal/apps/admin/status/swagger` 注释 - -**Interfaces:** -- Consumes: `logstore.Active`、`logstore.Migrating`、`repository.GetIntByKey`(3 个保留配置)、`config.Config`。 -- Produces: `GET /api/v1/admin/status/log-database` 返回 `LogDatabaseStatus`。 - -- [ ] **Step 1: 实现状态结构(改造 clickhouse.go)** - -```go -// GetLogDatabaseStatus 返回当前日志库状态。 -// @Summary 获取日志数据库状态 -// @Description 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 -// @Tags admin -// @Produce json -// @Security SessionCookie -// @Success 200 {object} response.Any{data=status.LogDatabaseStatus} "获取成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" -// @Router /api/v1/admin/status/log-database [get] -func GetLogDatabaseStatus(c *gin.Context) { - ctx := c.Request.Context() - s, err := logstore.Active(ctx) - if err != nil { - response.AbortInternal(c, "日志存储初始化失败") - return - } - activeDB, _ := logstore.ActiveDatabase(ctx) // provider 增加 ActiveDatabase(ctx) 返回当前库名 - migration := "idle" - if logstore.Migrating(ctx) { - migration = "migrating" - } - out := LogDatabaseStatus{ - ActiveDatabase: activeDB, - Migration: migration, - RetentionDays: map[string]int{ - "postgres": retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres), - "sqlite": retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite), - "clickhouse": retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse), - }, - AvailableTargets: availableTargets(ctx), - } - if activeDB == "clickhouse" { - stats, err := analyticsrepo.GetClickHouseOperationalStats(ctx) // 经 logstore StatusStore 暴露 - if err == nil { - stats.BatchWriters = collectBatchWriterStats() - out.ClickHouse = stats - } - } - c.JSON(http.StatusOK, response.OK(out)) -} -``` - -> `logstore.ActiveDatabase(ctx)` 与 `logstore.Build(ctx, database)`(Task 15 用到)需在 provider 增加并实现;`analyticsrepo.GetClickHouseOperationalStats` 改为经 `logstore.StatusStore` 暴露,避免 admin/status import analyticsrepo(违反 import-lint)。 - -- [ ] **Step 2: 路由**——`internal/router/v1/admin.go` 将 `/status/clickhouse` 替换/新增为 `/status/log-database`;旧路径保留 301 或删除(实现时选删除并同步前端)。 -- [ ] **Step 3: 单测**——`logstore.ActiveDatabase`/`Build` 分支测试;`availableTargets`(当前=clickhouse → 主库;当前=主库 → clickhouse)。 -- [ ] **Step 4: swagger** `make swagger`。 -- [ ] **Step 5: 验证** `go test ./internal/apps/admin/status/`、`go build ./internal/...`。 -- [ ] **Step 6: 提交** `git add internal/apps/admin/ internal/router/ && git commit -m "feat(status): log database status endpoint"` - -### Task 17: 前端——任务参数、业务配置、状态展示 - -**Files:** -- Modify: `frontend/lib/services/admin/*`(任务/状态类型,若需) -- Modify: `frontend/components/common/settings/operation-tab.tsx` 或业务配置分组(「日志保留时间」) -- Modify: 任务管理页组件(`frontend/.../tasks.tsx` 或等价文件)——展示当前日志主库 + 迁移状态 + 「切换日志数据库」参数下拉 -- Modify: 状态页/仪表盘(日志库状态卡片) - -**Interfaces:** -- Consumes: 现有 Admin 任务派发 API、`/api/v1/admin/status/log-database`、`AdminService.updateSystemConfig`。 - -- [ ] **Step 1: 业务配置分组**——在 `/admin/settings` 业务配置 Tab 新增「日志保留时间」:3 个 `Input type="number"`(PG/SQLite/CH),保存调 `AdminService.updateSystemConfig`,成功后 invalidate `["admin","system-configs"]`,Sonner toast。 -- [ ] **Step 2: 任务管理页**——「切换日志数据库」出现在任务列表;参数 `target` 下拉按状态端点 `available_targets` 渲染(显示「PostgreSQL(主库)」/「SQLite(主库)」/「ClickHouse」);任务卡片显示 `active_database` 与迁移状态徽标。 -- [ ] **Step 3: 状态卡片**——仪表盘或任务页展示当前日志主库、保留天数、迁移中提示。 -- [ ] **Step 4: 验证** `cd frontend && pnpm build`(或 `pnpm lint`)。 -- [ ] **Step 5: 提交** `git add frontend/ && git commit -m "feat(frontend): log database status, retention settings, and switch task UI"` - ---- - -## M4:收尾与全量验证 - -### Task 18: 全量验证、文档与 changelog - -**Files:** -- Modify: `docs/changelog/index.md`(`[Unreleased]` 中文条目) -- Modify: `docs/design/`(如需要,日志数据库解耦设计说明) -- 全局验证 - -- [ ] **Step 1: 全量检查** 运行: - - `go build ./...` - - `go test ./...` - - `make code-check` - - `make swagger`(若 API 有变) - - `make format` - - goose 三套空库 Up 验证(`go test ./internal/infra/persistence/migrator`) -- [ ] **Step 2: changelog**——在 `docs/changelog/index.md` 的 `[Unreleased]` 增加合并条目: - -```markdown -- 日志存储解耦:新增日志存储抽象(`internal/repository/logstore`),ClickHouse 变为可选项,不启用时由 PostgreSQL/SQLite 承担全部日志功能;新增「切换日志数据库」任务支持 PostgreSQL/SQLite 与 ClickHouse 间数据迁移(迁移期间冻结日志写入,成功后自动切换主库并保留源数据);日志保留时间改为按存储库在业务配置中设置(`log_retention_days_*`),过期清理并入系统垃圾清理每日任务。 -``` - -- [ ] **Step 3: 设计文档归档**——确认 `docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md` 与计划一致;实现偏差在 spec 或 changelog 标注。 -- [ ] **Step 4: 提交** `git add docs/ && git commit -m "docs: log database decoupling changelog and design notes"` - ---- - -## 自检记录(writing-plans self-review) - -- **规格覆盖**:M1 Task 1-10 覆盖规格第 4 节(包结构/接口/约束/标记校验);M2 Task 11-14 覆盖第 5、6 节(表/优化/清理);M3 Task 15-17 覆盖第 7、8 节(迁移任务/API/前端);M4 Task 18 覆盖第 9 节(测试验证)与文档。 -- **已知实现决策(由实现者按此执行,避免歧义)**: - 1. `logstore` 不 import `internal/repository`(防循环);配置读取经 bootstrap 注入 `SetConfigReader`。 - 2. 迁移复制按 id 升序扫描:`AccessLogStore.ListForMigration` + 可观测 4 个 `*ForMigration`(Task 2 已定义),CH 与 GORM 各自实现;`copyObservability` 用每批最后一条 id 作为下一批游标(实现时修正计划里 `lastID += n` 的近似写法)。 - 3. `logstore.Build(ctx, database)` 导出供迁移任务构造目标 store;`ActiveDatabase(ctx)` 供状态端点。 - 4. admin/status 不直接 import analyticsrepo——CH 运行指标经 `logstore.StatusStore` 暴露。 - 5. 解析 helper(`ParseBrowserName` 等)迁至 `model/analytics` 纯函数,apps 不再依赖 analyticsrepo。 - 6. 迁移期间源库冻结由 logstore 各实现 `ensureWritable` 统一保证;risk_control 审计中间件在冻结期跳过写日志但不阻断请求。 - 7. 失败回退:`defer setMigrationFlag("")` 保证失败后源库恢复可写;重试时先清空目标再复制(幂等)。 diff --git a/docs/superpowers/plans/2026-08-08-service-worker-offline.md b/docs/superpowers/plans/2026-08-08-service-worker-offline.md deleted file mode 100644 index 8ed98656..00000000 --- a/docs/superpowers/plans/2026-08-08-service-worker-offline.md +++ /dev/null @@ -1,885 +0,0 @@ -# Service Worker 离线兜底 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 给平台所有启用 HTTPS 的网站(反代 + Pages)下发 Service Worker 离线兜底:域名被墙后浏览器从缓存吐出"联系站长"页,避免用户流失。全平台一键批量下发。 - -**Architecture:** 全局 Option(SystemConfig / OpenRestyConfig snapshot)驱动,与现有 origin error page 完全同模式。渲染层在 HTTPS server 块注入 SW 静态 location + 首页挑战拦截(真实浏览器 UA 且无 cookie 时返回含 `register('/sw.js')` 的挑战页),通过 SupportFile 下发 sw.js / offline.html,Agent 替换占位符落盘。前端「响应页面」模块两个 tab:错误页 / 联系页。 - -**Tech Stack:** Go 1.25+、Gin、GORM、PostgreSQL/SQLite goose 迁移、OpenResty/Lua、Next.js、TypeScript、shadcn/ui、TanStack Query。 - -## Global Constraints - -- 遵循 AGENTS.md 分层:`apps → repository → model`,禁止 `model → repository`。 -- API 错误用 `response.Abort*`;Handler 不直接 `c.JSON`。 -- 渲染改动后:`make swagger`(本功能无新 API,跳过);开发完成:`make code-check`;提交前:`make format`。 -- 代码/配置变更写入 `docs/changelog/index.md` 的 `[Unreleased]`(中文,用户可读)。 -- 配置 key 命名:小写 snake_case。测试临时目录只用 `t.TempDir()`。 -- 前端:`variant` + CSS 变量,业务 `className` 不硬编码颜色;根容器 `w-full`,外层 `py-6 px-1`;标题行 `flex items-center gap-2`。 -- 配置文件路径占位符统一追加到 `pkg/render/openresty/types.go` 的 const 块。 -- 迁移:PostgreSQL 与 SQLite 各一份 goose SQL(`goose/postgres/`、`goose/sqlite/`),见 `database-migration` skill。 - ---- - -### Task 1: 后端配置 key 与 Option 校验 - -**Files:** -- Modify: `internal/model/system_configs.go:114-117` -- Modify: `internal/apps/openflare/option/openresty_validators.go:19-65` -- Modify: `internal/apps/openflare/option/openresty_validators.go:69-79` - -**Interfaces:** -- Produces: 常量 `model.ConfigKeySWOfflineEnabled`, `model.ConfigKeySWOfflineHTML`; 校验函数 `validateSWOfflineHTML`。 - -- [ ] **Step 1: 在 `system_configs.go` 追加 key 常量** - -在 `ConfigKeyOriginErrorPageGetOnly`(第 117 行)后追加: - -```go - ConfigKeySWOfflineEnabled = "sw_offline_enabled" // 是否启用 Service Worker 离线兜底 - ConfigKeySWOfflineHTML = "sw_offline_html" // 离线联系页自定义 HTML(空则内置默认) -``` - -- [ ] **Step 2: 注册 validator** - -在 `openRestyOptionValidators` map(`openresty_validators.go` 第 61-64 行)后追加: - -```go - model.ConfigKeySWOfflineEnabled: validateBooleanOption, - model.ConfigKeySWOfflineHTML: validateSWOfflineHTML, -``` - -- [ ] **Step 3: 在 `validateOpenRestyOption` 增加 HTML 字节数特殊处理** - -在第 69-79 行函数内,`if key == model.ConfigKeyOriginErrorPageHTML` 分支改为同时覆盖 SW HTML: - -```go - if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML { - return validateOriginErrorPageHTML(key, value) - } -``` - -`validateOriginErrorPageHTML` 逻辑(非空、≤256 KiB)对两个 HTML 复用,无需新函数。 - -- [ ] **Step 4: 运行测试** - -Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && go build ./... && go test ./internal/apps/openflare/option/...` -Expected: PASS - -- [ ] **Step 5: 提交** - -```bash -git add internal/model/system_configs.go internal/apps/openflare/option/openresty_validators.go -git commit -m "feat(option): add sw offline config keys and validation" -``` - ---- - -### Task 2: goose 迁移(PostgreSQL + SQLite)Seed 全局 Option - -**Files:** -- Create: `internal/infra/persistence/migrator/goose/postgres/NNN_add_sw_offline_options.sql` -- Create: `internal/infra/persistence/migrator/goose/sqlite/NNN_add_sw_offline_options.sql` - -**Interfaces:** -- Consumes: Task 1 key 常量。 -- Produces: 数据库 seed 的 `sw_offline_enabled` / `sw_offline_html` 两行 `w_system_configs`。 - -- [ ] **Step 1: 确认迁移序号** - -Run: `ls /Users/ryan/conductor/workspaces/OpenFlare/islamabad/internal/infra/persistence/migrator/goose/postgres/ | tail -3` -取最新序号 +1(如 `202608080001`)。 - -- [ ] **Step 2: 创建 postgres 迁移** - -创建 `goose/postgres/202608080001_add_sw_offline_options.sql`: - -```sql --- +goose Up -INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) -VALUES - ('sw_offline_enabled', 'false', 'business', 0, '是否启用 Service Worker 离线兜底', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('sw_offline_html', '', 'business', 0, '离线联系页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (key) DO NOTHING; - --- +goose Down -DELETE FROM w_system_configs WHERE key IN ( - 'sw_offline_enabled', - 'sw_offline_html' -); -``` - -- [ ] **Step 3: 创建 sqlite 迁移** - -创建 `goose/sqlite/202608080001_add_sw_offline_options.sql`(内容与 postgres 相同)。 - -- [ ] **Step 4: 运行迁移测试** - -Run: `go test ./internal/infra/persistence/migrator/...` -Expected: PASS(数据库迁移冒烟通过) - -- [ ] **Step 5: 提交** - -```bash -git add internal/infra/persistence/migrator/goose/postgres/202608080001_add_sw_offline_options.sql internal/infra/persistence/migrator/goose/sqlite/202608080001_add_sw_offline_options.sql -git commit -m "feat(db): seed sw offline options" -``` - ---- - -### Task 3: ConfigSnapshot 渲染类型字段 - -**Files:** -- Modify: `pkg/render/openresty/types.go:20-26` -- Modify: `pkg/render/openresty/types.go:318-323`(`ConfigSnapshot` 结构体) - -**Interfaces:** -- Produces: `ConfigSnapshot.SWOfflineEnabled bool`、`ConfigSnapshot.SWOfflineHTML string`;常量 `SWDirPlaceholder`。 - -- [ ] **Step 1: 追加占位符常量** - -在 `types.go` 占位符 const 块(第 23 行 `ErrorPageTmplPlaceholder` 后)追加: - -```go - SWDirPlaceholder = "__OPENFLARE_SW_DIR__" -``` - -- [ ] **Step 2: 追加 ConfigSnapshot 字段** - -在 `ConfigSnapshot` 末尾(`OriginErrorPageGetOnly` 后)追加: - -```go - // SWOfflineEnabled enables the Service Worker offline fallback for HTTPS routes. - SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"` - // SWOfflineHTML is the contact-page HTML served offline; empty uses the built-in default. - SWOfflineHTML string `json:"sw_offline_html,omitempty"` -``` - -- [ ] **Step 3: 提交** - -```bash -git add pkg/render/openresty/types.go -git commit -m "feat(openresty): add sw offline ConfigSnapshot fields and placeholder" -``` - ---- - -### Task 4: 渲染层 SW 资源与挑战拦截 - -**Files:** -- Create: `pkg/render/openresty/service_worker.go` -- Create: `pkg/render/openresty/service_worker_test.go` -- Modify: `pkg/render/openresty/render.go:37-59`(`Render` 追加 support files) -- Modify: `pkg/render/openresty/render.go:90-115`(`RenderRouteConfig` 注入挑战) - -**Interfaces:** -- Consumes: `ConfigSnapshot.SWOfflineEnabled` / `.SWOfflineHTML`;`SWDirPlaceholder`。 -- Produces: `DefaultSWOfflineHTML string`、`EffectiveSWOfflineHTML(cfg ConfigSnapshot) string`、`ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile`、`renderServiceWorkerChallenger(cfg ConfigSnapshot) string`。 - -- [ ] **Step 1: 写失败测试** - -创建 `service_worker_test.go`,断言: -1. `EffectiveSWOfflineHTML`:HTML 为空返回内置默认;非空返回自定义。 -2. `ServiceWorkerSupportFiles`:仅当 `SWOfflineEnabled` 时返回 `sw/sw.js` 与 `sw/offline.html` 两个文件;未启用返回 nil。 -3. `renderServiceWorkerChallenger`:启用且含 sw.js location、offline location、挑战 location;未启用返回空串。 - -```go -package openresty - -import ( - "strings" - "testing" -) - -func TestEffectiveSWOfflineHTML(t *testing.T) { - if got := EffectiveSWOfflineHTML(ConfigSnapshot{}); got != DefaultSWOfflineHTML { - t.Fatalf("default mismatch") - } - custom := "custom" - if got := EffectiveSWOfflineHTML(ConfigSnapshot{SWOfflineHTML: custom}); got != custom { - t.Fatalf("custom mismatch") - } -} - -func TestServiceWorkerSupportFiles(t *testing.T) { - disabled := ServiceWorkerSupportFiles(ConfigSnapshot{}) - if disabled != nil { - t.Fatalf("expected nil when disabled, got %v", disabled) - } - enabled := ServiceWorkerSupportFiles(ConfigSnapshot{SWOfflineEnabled: true}) - if len(enabled) != 2 { - t.Fatalf("expected 2 support files, got %d", len(enabled)) - } - paths := map[string]string{} - for _, f := range enabled { - paths[f.Path] = f.Content - } - if _, ok := paths["sw/sw.js"]; !ok { - t.Fatalf("missing sw/sw.js") - } - if _, ok := paths["sw/offline.html"]; !ok { - t.Fatalf("missing sw/offline.html") - } -} - -func TestRenderServiceWorkerChallenger(t *testing.T) { - if got := renderServiceWorkerChallenger(ConfigSnapshot{}); got != "" { - t.Fatalf("expected empty when disabled") - } - got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true}) - for _, want := range []string{"location = /sw.js", "location = /offline.html", "sw.runtime", "content_by_lua"} { - if !strings.Contains(got, want) { - t.Fatalf("challenger missing %q", want) - } - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `go test ./pkg/render/openresty/ -run 'TestEffectiveSWOfflineHTML|TestServiceWorkerSupportFiles|TestRenderServiceWorkerChallenger'` -Expected: FAIL(函数未定义) - -- [ ] **Step 3: 实现 `service_worker.go`** - -```go -package openresty - -import ( - "strings" -) - -const ( - SWJSLocation = "location = /sw.js" - SWOfflineLocation = "location = /offline.html" - SWChallengeLua = "sw/challenge.lua" - SWRuntimeLua = "sw/runtime.lua" - swDirPrefix = "sw/" -) - -// DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked. -const DefaultSWOfflineHTML = ` - - - - -网站暂时无法访问 | 联系站长 - - - -

网站暂时无法访问

-

当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。

- - -` - -// EffectiveSWOfflineHTML returns custom HTML when set, otherwise the built-in default. -func EffectiveSWOfflineHTML(cfg ConfigSnapshot) string { - if strings.TrimSpace(cfg.SWOfflineHTML) == "" { - return DefaultSWOfflineHTML - } - return cfg.SWOfflineHTML -} - -// ServiceWorkerSupportFiles returns the sw.js script and offline contact page. -func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile { - if !cfg.SWOfflineEnabled { - return nil - } - return []SupportFile{ - {Path: swDirPrefix + "sw.js", Content: defaultSWJS()}, - {Path: swDirPrefix + "offline.html", Content: EffectiveSWOfflineHTML(cfg)}, - } -} - -func defaultSWJS() string { - return `var CACHE = "openflare-offline-v1"; -var OFFLINE = "/offline.html"; -self.addEventListener("install", function (e) { - e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll([OFFLINE]); })); - self.skipWaiting(); -}); -self.addEventListener("activate", function (e) { - e.waitUntil(caches.keys().then(function (keys) { - return Promise.all(keys.filter(function (k) { return k !== CACHE; }).map(function (k) { return caches.delete(k); })); - })); - self.clients.claim(); -}); -self.addEventListener("fetch", function (e) { - if (e.request.method !== "GET") { return; } - e.respondWith( - fetch(e.request).catch(function () { - return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); }); - }) - ); -}); -` -} - -// renderServiceWorkerChallenger emits SW static locations and the homepage -// challenge intercept for HTTPS server blocks. -func renderServiceWorkerChallenger(cfg ConfigSnapshot) string { - if !cfg.SWOfflineEnabled { - return "" - } - var builder strings.Builder - builder.WriteString("\n location = /sw.js {\n") - builder.WriteString(" alias " + SWDirPlaceholder + "/sw.js;\n") - builder.WriteString(" default_type application/javascript;\n") - builder.WriteString(" add_header Service-Worker-Allowed /;\n") - builder.WriteString(" add_header Cache-Control \"no-cache\";\n") - builder.WriteString(" }\n\n") - builder.WriteString(" location = /offline.html {\n") - builder.WriteString(" alias " + SWDirPlaceholder + "/offline.html;\n") - builder.WriteString(" default_type text/html;\n") - builder.WriteString(" add_header Cache-Control \"no-cache\";\n") - builder.WriteString(" }\n\n") - builder.WriteString(" location = /__openflare_sw_challenge {\n") - builder.WriteString(" internal;\n") - builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n") - builder.WriteString(" }\n") - return builder.String() -} -``` - -- [ ] **Step 4: 接入 `Render` 追加 support files** - -在 `render.go` `Render` 函数内、`originErrorPageSupportFile` 追加之后追加: - -```go - if doc.OpenRestyConfig.SWOfflineEnabled { - files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...) - } -``` - -- [ ] **Step 5: 接入 `RenderRouteConfig` 注入挑战** - -在 `RenderRouteConfig` 内 `renderProxyRoute` / `renderPagesRoute` 调用之前,将 SW 拦截接入 server 块。将 `renderAccessBlock(siteName, powEnabled)` 调用处扩展:新建 `renderServerAccess(siteName, powEnabled, cfg)` 封装,并在其中追加 SW 运行时检查。具体为在 `renderAccessBlock` 生成的 access 块内,追加对 `sw.runtime` 的调用。 - -简化实现:新增 `renderAccessBlockWithSW(siteName string, powEnabled bool, cfg ConfigSnapshot) string`,返回 `renderAccessBlock(siteName, powEnabled)` 与(当 `SWOfflineEnabled` 时)追加: - -``` - access_by_lua_block { - if not string.find(package.path, "__OPENFLARE_LUA_DIR__/?.lua", 1, true) then - package.path = "__OPENFLARE_LUA_DIR__/?.lua;__OPENFLARE_LUA_DIR__/?/init.lua;" .. package.path - end - require("sw.runtime").check() - } -``` - -然后将 `renderHTTPProxyServer`、`renderHTTPSServer`、`renderHTTPPagesServer`、`renderHTTPSPagesServer` 中 `renderAccessBlock(...)` 替换为 `renderAccessBlockWithSW(..., cfg)`,并在各自 server 块内追加 `renderServiceWorkerChallenger(cfg)` 输出。 - -**注意:** `renderAccessBlock` 在既有 powEnabled 分支已含 `access_by_lua_block`。为兼容,`renderAccessBlockWithSW` 在 powEnabled 分支内合并 SW check 到同一块;非 pow 分支额外追加一个块。本步以**仅新增 server 级 SW location + 独立 `access_by_lua_block`** 为最小实现;若 nginx 同 server 存在两个 `access_by_lua_block`,运行时只执行最后一个——**故实现必须合并**。请在实现时确认 `renderAccessBlock` 各分支,将 SW check 合并进唯一 access 块内,避免覆盖 WAF/PoW。 - -- [ ] **Step 6: 运行测试** - -Run: `go test ./pkg/render/openresty/...` -Expected: PASS - -- [ ] **Step 7: 提交** - -```bash -git add pkg/render/openresty/service_worker.go pkg/render/openresty/service_worker_test.go pkg/render/openresty/render.go -git commit -m "feat(openresty): render sw offline assets and challenge intercept" -``` - ---- - -### Task 5: config_version snapshot 接入全局 Option - -**Files:** -- Modify: `internal/apps/openflare/config_version/snapshot.go:143-147`(`openRestyConfigSnapshot` 字段) -- Modify: `internal/apps/openflare/config_version/snapshot.go:559-563`(`buildOpenRestyConfigSnapshot` 读取) -- Modify: `internal/apps/openflare/config_version/logics.go:537-541`(diff 追加) -- Modify: `internal/apps/openflare/config_version/logics.go:604-608`(option keys 追加) - -**Interfaces:** -- Consumes: `model.ConfigKeySWOfflineEnabled` / `.SWOfflineHTML`。 -- Produces: snapshot JSON 内 `sw_offline_enabled` / `sw_offline_html` 字段,触发 checksum 变化。 - -- [ ] **Step 1: snapshot 结构体追加字段** - -在 `openRestyConfigSnapshot`(`snapshot.go:143-147`,`OriginErrorPageGetOnly` 后)追加: - -```go - SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"` - SWOfflineHTML string `json:"sw_offline_html,omitempty"` -``` - -- [ ] **Step 2: build 读取配置** - -在 `buildOpenRestyConfigSnapshot`(`snapshot.go:559-563`,`OriginErrorPageGetOnly` 赋值后)追加: - -```go - SWOfflineEnabled: getBoolConfig(model.ConfigKeySWOfflineEnabled, false), - SWOfflineHTML: getStringConfig(model.ConfigKeySWOfflineHTML, ""), -``` - -- [ ] **Step 3: diff 追加** - -在 `diffOpenRestyOptionDetails`(`logics.go:540` 后)追加: - -```go - appendIfChanged("SWOfflineEnabled", fmt.Sprintf("%t", left.SWOfflineEnabled), fmt.Sprintf("%t", right.SWOfflineEnabled)) - appendIfChanged("SWOfflineHTML", left.SWOfflineHTML, right.SWOfflineHTML) -``` - -- [ ] **Step 4: option keys 追加** - -在 `openRestyOptionKeys()`(`logics.go:607` 后)追加: - -```go - "SWOfflineEnabled", - "SWOfflineHTML", -``` - -- [ ] **Step 5: 运行测试** - -Run: `go test ./internal/apps/openflare/config_version/...` -Expected: PASS - -- [ ] **Step 6: 提交** - -```bash -git add internal/apps/openflare/config_version/snapshot.go internal/apps/openflare/config_version/logics.go -git commit -m "feat(config): wire sw offline options into config snapshot" -``` - ---- - -### Task 6: Agent 侧 SW Lua 资源与占位符替换 - -**Files:** -- Create: `internal/apps/agent/nginx/sw_assets.go` -- Modify: `internal/apps/agent/nginx/manager.go:393-410`(`EnsureLuaAssets` 追加 SW Lua) -- Modify: `internal/apps/agent/nginx/manager.go:526-528`(checksum 归一化 SW 路径) -- Modify: `internal/apps/agent/nginx/manager.go:1381-1383`(renderRouteConfig 替换 SW 占位符) - -**Interfaces:** -- Consumes: `openrestyrender.SWDirPlaceholder`、`openrestyrender.SWChallengeLua`、`openrestyrender.SWRuntimeLua`。 -- Produces: `ManagedSWLuaFiles() []protocol.SupportFile`(`sw/runtime.lua`、`sw/challenge.lua`)。 - -- [ ] **Step 1: 创建 `sw_assets.go`** - -```go -package nginx - -import ( - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" -) - -const openRestySWRuntimeLua = `local source = debug.getinfo(1, "S").source or "" -if string.sub(source, 1, 1) == "@" then - local script_path = string.sub(source, 2) - local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$") - if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then - package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path - end -end - -local function is_real_browser(ua) - if not ua or ua == "" then return false end - -- Chrome/Edge/CentOS-style: "Chrome/120" - if string.find(ua, "Chrome/%d", 1, true) then return true end - -- Firefox: "Firefox/120" - if string.find(ua, "Firefox/%d", 1, true) then return true end - -- Safari (non-Chrome, e.g. "Version/17.0 Safari") - if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end - return false -end - -local function pass_through() - return true -end - -function _M_check() - local ua = ngx.var.http_user_agent or "" - if not is_real_browser(ua) then return pass_through() end - - local uri = ngx.var.uri or "" - if uri ~= "/" then return pass_through() end - - local cookie = ngx.var["cookie___openflare_sw"] - if cookie and cookie ~= "" then return pass_through() end - - -- intercept: internal redirect to challenge page, which registers SW + sets cookie - local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "") - ngx.req.set_uri_args({ redir = redir }) - return ngx.exec("/__openflare_sw_challenge") -end -` - -const openRestySWChallengeLua = `local args = ngx.req.get_uri_args() -local redir = args["redir"] or "/" -ngx.header["Set-Cookie"] = "__openflare_sw=1; Path=/; Max-Age=31536000" -ngx.header.content_type = "text/html; charset=utf-8" -ngx.say([[ - - - - - -加载中... - - -正在加载... -]]) -` - -// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge. -func ManagedSWLuaFiles() []protocol.SupportFile { - return []protocol.SupportFile{ - {Path: "sw/runtime.lua", Content: openRestySWRuntimeLua}, - {Path: "sw/challenge.lua", Content: openRestySWChallengeLua}, - } -} -``` - -- [ ] **Step 2: `EnsureLuaAssets` 追加 SW Lua** - -在 `manager.go:403`(`allSupportFiles` 组装处)追加: - -```go - allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...) -``` - -- [ ] **Step 3: checksum 归一化 SW 路径** - -在 `manager.go:526-528`(error page 路径归一化后)追加: - -```go - swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) - normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder) -``` - -- [ ] **Step 4: `renderRouteConfig` 替换 SW 占位符** - -在 `manager.go:1381-1383`(error page 替换后)追加: - -```go - swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw")) - rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir) -``` - -- [ ] **Step 5: 确认 SW 文件落盘** - -`renderServiceWorkerChallenger` 中 `alias __OPENFLARE_SW_DIR__/sw.js` 与 `/offline.html` 引用 support files `sw/sw.js`、`sw/offline.html`。这些文件经 Task 4 作为普通 support file 由 `writeManagedCertFiles` 写入 `/sw/`(路径含子目录)。验证 `certFileTargetPath` 支持子目录路径(读 `manager.go` 确认)。若不支持,需在 `writeManagedCertFiles` 中 `os.MkdirAll(filepath.Dir(targetPath))`。**实现时确认并补全目录创建。** - -- [ ] **Step 6: 运行测试** - -Run: `go build ./... && go test ./internal/apps/agent/nginx/...` -Expected: PASS - -- [ ] **Step 7: 提交** - -```bash -git add internal/apps/agent/nginx/sw_assets.go internal/apps/agent/nginx/manager.go -git commit -m "feat(agent): ship sw offline lua assets and placeholder substitution" -``` - ---- - -### Task 7: 前端「响应页面」模块(两个 tab) - -**Files:** -- Create: `frontend/app/(main)/responses/page.tsx` -- Create: `frontend/app/(main)/responses/components/contact-page-tab.tsx` -- Create: `frontend/app/(main)/responses/components/shared.ts` -- Modify: `frontend/lib/navigation/openflare-nav.ts:63-67` -- Modify: `frontend/lib/navigation/openflare-nav.ts:123` - -**Interfaces:** -- Consumes: `OptionService.list()` / `OptionService.updateBatch()`(已存在)。 -- Produces: 联系页 tab 编辑 `sw_offline_enabled` / `sw_offline_html` 两个 option。 - -- [ ] **Step 1: 创建共享 helper `shared.ts`** - -```ts -export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const; - -export const KEY_SW_ENABLED = 'sw_offline_enabled'; -export const KEY_SW_HTML = 'sw_offline_html'; - -export type ContactPageFields = { - enabled: boolean; - html: string; -}; - -export const defaultContactPageFields: ContactPageFields = { - enabled: false, - html: '', -}; - -export function optionsToMap(options: Array<{ key: string; value: string }>) { - return options.reduce>((acc, option) => { - acc[option.key] = option.value; - return acc; - }, {}); -} - -export function mapOptionsToContactFields( - optionMap: Record, -): ContactPageFields { - return { - enabled: optionMap[KEY_SW_ENABLED] === 'true', - html: optionMap[KEY_SW_HTML] ?? '', - }; -} - -export async function invalidateResponseQueries(queryClient: { - invalidateQueries: (opts: { - queryKey: readonly unknown[]; - }) => Promise; -}) { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-preview'], - }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-versions'], - }), - ]); -} -``` - -- [ ] **Step 2: 创建联系页 tab `contact-page-tab.tsx`** - -参考 `error-pages/page.tsx` 交互:一个「启用」开关 + 一个 HTML 文本域 + 保存按钮。保存 `updateBatch([{key: KEY_SW_ENABLED,...},{key: KEY_SW_HTML,...}])`,成功后 `invalidateResponseQueries`。 - -```tsx -'use client'; - -import { useEffect, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Save } from 'lucide-react'; -import { toast } from 'sonner'; - -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { Textarea } from '@/components/ui/textarea'; -import { OptionService } from '@/lib/services/openflare'; - -import { - defaultContactPageFields, - invalidateResponseQueries, - KEY_SW_ENABLED, - KEY_SW_HTML, - mapOptionsToContactFields, - optionsToMap, - type ContactPageFields, -} from './shared'; - -export function ContactPageTab({ optionMap }: { optionMap: Record }) { - const queryClient = useQueryClient(); - const [fields, setFields] = useState( - defaultContactPageFields, - ); - - useEffect(() => { - setFields(mapOptionsToContactFields(optionMap)); - }, [optionMap]); - - const saveMutation = useMutation({ - mutationFn: async () => { - await OptionService.updateBatch([ - { key: KEY_SW_ENABLED, value: String(fields.enabled) }, - { key: KEY_SW_HTML, value: fields.html }, - ]); - }, - onSuccess: async () => { - toast.success('联系页已保存,请前往版本发布使配置生效'); - await invalidateResponseQueries(queryClient); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : '保存失败'); - }, - }); - - return ( -
- - -
- 离线兜底 - - 启用后给启用 HTTPS 的网站下发 Service Worker,域名被墙时浏览器从缓存展示此联系页。 - -
- -
- -
-
- -

- 仅对 HTTPS 网站生效;未启用的站点不受影响。 -

-
- - setFields((prev) => ({ ...prev, enabled })) - } - aria-label='启用离线兜底' - className='mt-0.5 shrink-0' - /> -
-
- -

- 留空则使用内置默认模板。 -

-