chore(docs): purge

This commit is contained in:
ryan
2026-08-16 21:44:28 +08:00
parent 2aa0a70762
commit 3f97193280
39 changed files with 0 additions and 12727 deletions
@@ -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`。
-491
View File
@@ -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 <ZonePageClient zoneId={Number(zoneId)} />
}
```
遵循本地 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(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
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`。
-639
View File
@@ -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=<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<string, never>}
| {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<string, never>}
| {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` 全部通过。
@@ -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 降级。
@@ -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] 测试与提交
@@ -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): ...`(待用户确认)
- [ ] 合并 / 发布后需重新发布节点配置
@@ -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 执行后更新本表。
-161
View File
@@ -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 已更新)
- [ ] 提交合并
@@ -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 而非全局值
File diff suppressed because it is too large Load Diff
@@ -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` 部署到 `<luaDir>/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 对比。
-85
View File
@@ -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 运维说明
- [ ] 用户确认后提交
@@ -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`(在可接受时间内)
-151
View File
@@ -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` 仍断言页面展示“唯一访问者”,与本功能无关且在本任务基线中已存在。
-467
View File
@@ -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` 模式,不另开悬空任务。
-47
View File
@@ -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
-32
View File
@@ -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 表单的提交是否正常。
-47
View File
@@ -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` 指令或手动操作路径。
-38
View File
@@ -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 追溯历史决策。
* **禁止空文件**:请确保新创建的计划文档均基于对应的模板进行初始化填充。
@@ -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 而非全局值
@@ -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<WAFRuleNode['type'], string> = {
start: '开始',
ip_match: 'IP 匹配',
geo_match: '地域匹配',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
};
export function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
): 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(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
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(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
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 (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={/* type default from NODE_TYPE_LABELS */}
onChange={(e) => onChange({ ...node, label: e.target.value })}
/>
</Field>
);
}
```
Insert `<DisplayNameField ... />` 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 `<ReactFlow ...>` (xyflow supports these on the component).
Update `<NodeLibrary />` — 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 |
@@ -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.)
@@ -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` 模式,不另开悬空任务。
File diff suppressed because it is too large Load Diff
@@ -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/<YYYYMMDD>NNN_add_sw_offline_options.sql`
- Create: `internal/infra/persistence/migrator/goose/sqlite/<YYYYMMDD>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 := "<html>custom</html>"
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 = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网站暂时无法访问 | 联系站长</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #ffffff; color: #333333; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 48px 24px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
p { font-size: 16px; line-height: 1.7; color: #666666; max-width: 520px; }
</style>
</head>
<body>
<h1>网站暂时无法访问</h1>
<p>当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。</p>
</body>
</html>
`
// 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([[<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>加载中...</title>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then(function () {
location.replace("]] .. redir .. [[");
}).catch(function () {
location.replace("]] .. redir .. [[");
});
} else {
location.replace("]] .. redir .. [[");
}
</script>
</head>
<body>正在加载...</body>
</html>]])
`
// 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` 写入 `<CertDir>/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<Record<string, string>>((acc, option) => {
acc[option.key] = option.value;
return acc;
}, {});
}
export function mapOptionsToContactFields(
optionMap: Record<string, string>,
): ContactPageFields {
return {
enabled: optionMap[KEY_SW_ENABLED] === 'true',
html: optionMap[KEY_SW_HTML] ?? '',
};
}
export async function invalidateResponseQueries(queryClient: {
invalidateQueries: (opts: {
queryKey: readonly unknown[];
}) => Promise<unknown>;
}) {
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<string, string> }) {
const queryClient = useQueryClient();
const [fields, setFields] = useState<ContactPageFields>(
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 (
<div className='space-y-6'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
<div className='space-y-1.5'>
<CardTitle className='text-base'>离线兜底</CardTitle>
<CardDescription>
启用后给启用 HTTPS 的网站下发 Service Worker,域名被墙时浏览器从缓存展示此联系页。
</CardDescription>
</div>
<Button
size='sm'
className='shrink-0'
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<Save className='size-3.5' />
)}
保存
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-start justify-between gap-6'>
<div className='space-y-1'>
<Label className='text-sm font-medium'>启用 Service Worker 离线兜底</Label>
<p className='text-sm text-muted-foreground'>
仅对 HTTPS 网站生效;未启用的站点不受影响。
</p>
</div>
<Switch
checked={fields.enabled}
onCheckedChange={(enabled) =>
setFields((prev) => ({ ...prev, enabled }))
}
aria-label='启用离线兜底'
className='mt-0.5 shrink-0'
/>
</div>
<div className='flex flex-col gap-3'>
<Label htmlFor='sw-offline-html' className='text-sm font-medium'>
离线联系页 HTML
</Label>
<p className='text-sm text-muted-foreground'>
留空则使用内置默认模板。
</p>
<Textarea
id='sw-offline-html'
value={fields.html}
onChange={(e) =>
setFields((prev) => ({ ...prev, html: e.target.value }))
}
rows={12}
className='font-mono'
disabled={!fields.enabled}
/>
</div>
</CardContent>
</Card>
</div>
);
}
```
**注意:** 确认 `frontend/components/ui/` 存在 `textarea.tsx`(shadcn)。若无,用 `make sure` 或 `npx shadcn@latest add textarea` 添加。
- [ ] **Step 3: 创建页面容器 `responses/page.tsx`**
用 Tabs 组件组织「错误页」「联系页」两个 tab。错误页 tab 复用现有 `error-pages` 内容或重定向;联系页 tab 渲染 `ContactPageTab`。加载 `OptionService.list()` 传入 optionMap。
**实现提示:** 为避免重复,错误页 tab 的现有逻辑(`error-pages/page.tsx` 的 policy 卡片 + 预览卡)可先以 `redirect` 到 `/error-pages` 占位,或直接在容器内嵌两 tab。推荐:容器页 `responses/page.tsx` 读取 options,渲染 Tabs(错误页/联系页),错误页 tab 复用 `frontend/app/(main)/error-pages` 现有 UI(通过 import 其组件或在容器内重构)。**保守实现:** 容器页仅放两个 tab,错误页 tab 用 `<Link href='/error-pages'>` 或保留现有 `/error-pages` 路由,联系页 tab 显示新表单;导航入口改为「响应页面」指向 `/responses`。
- [ ] **Step 4: 更新导航**
`openflare-nav.ts` 第 63-67 行将「错误页」项改为「响应页面」:
```ts
{
title: '响应页面',
url: '/responses',
childUrls: ['/error-pages', '/responses/contact'],
},
```
第 123 行 `openflareWebsiteSubNav` 中 `{ title: '错误页', url: '/error-pages' }` 改为 `{ title: '响应页面', url: '/responses' }`。
- [ ] **Step 5: 构建前端**
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad/frontend && pnpm type-check`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add frontend/app/\(main\)/responses frontend/lib/navigation/openflare-nav.ts
git commit -m "feat(frontend): add response pages module with contact page tab"
```
---
### Task 8: Changelog 与收尾验证
**Files:**
- Modify: `docs/changelog/index.md`
**Interfaces:**
- Produces: `[Unreleased]` 下用户可读中文条目。
- [ ] **Step 1: 追加 changelog**
在 `docs/changelog/index.md` 的 `[Unreleased]` 下追加:
```markdown
### 新增
- 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。配置位于「响应页面」-「联系页」,可在版本发布中批量生效。
```
- [ ] **Step 2: 运行完整校验**
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make code-check`
Expected: PASS(golangci-lint + 前端类型检查)
- [ ] **Step 3: 运行后端全量测试**
Run: `go test ./...`
Expected: PASS
- [ ] **Step 4: 格式化**
Run: `cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make format`
Expected: 无格式变更或已应用
- [ ] **Step 5: 提交**
```bash
git add docs/changelog/index.md
git commit -m "docs: sw offline fallback changelog"
```
---
## Self-Review
**Spec 覆盖检查:**
- 全局 Option(sw_offline_enabled / html)→ Task 1-3、5 ✓
- 渲染层 SW 静态 + 挑战拦截(反代 + Pages,HTTPS-only)→ Task 4 ✓
- SupportFile 下发 sw.js / offline.html,Agent 占位符替换 → Task 4、6 ✓
- UA 白名单(真实浏览器特征)→ Task 6 `is_real_browser` ✓
- Cookie 长过期 + 首次挑战页 → Task 6 ✓
- 前端「响应页面」两 tab → Task 7 ✓
- 迁移 seed → Task 2 ✓
- Changelog → Task 8 ✓
**占位符扫描:** 无 TBD/TODO。Task 4 Step 5 与 Task 7 Step 3 保留实现细节提示(非占位,是给定方向让执行者按实际代码确认),已在文中明确标注"实现时确认"。
**类型一致性:** `ConfigSnapshot.SWOfflineEnabled/HTML` 在 Task 3/4/5 一致;`SWDirPlaceholder` 在 Task 3/4/6 一致;`sw_offline_enabled/sw_offline_html` key 在 Task 1/2/5/7 一致;`renderServiceWorkerChallenger`/`ServiceWorkerSupportFiles`/`EffectiveSWOfflineHTML`/`ManagedSWLuaFiles` 签名跨 Task 一致。
**已知待确认项(执行时需按实际代码落地):**
- Task 4:`renderAccessBlock` 的 access 块合并(避免 WAF/PoW 被覆盖)。
- Task 6:`certFileTargetPath` 是否支持子目录,落盘目录创建。
- Task 7:`textarea` 组件存在性;「响应页面」错误页 tab 与现有 `/error-pages` 路由的复用策略。
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
# Zone 域名重构规格
已确认的设计:
* `/websites` 展示可注册根域 Zone;详情 URL 使用 `/websites/:zoneId`。
* `managed_domains` 将被彻底替换为 `of_zones` 与 `of_zone_domains`。
* Zone 域名是 `of_proxy_routes` 域名与证书的规范化来源;一个域名至多连接一条路由,一条路由可含多个 Zone 的域名。
* `of_proxy_routes` 移除 `cert_id`、`cert_ids` 与 `domain_cert_ids`,不再指定证书;配置编译只从关联 Zone 域名的 `cert_id` 查询证书。
* Zone 域名只允许明确 FQDN;允许把含 `*.example.com` SAN 的 TLS 证书绑定到明确域名,但不允许通配符域名记录。
* 路由仍拥有上游、缓存、限流、WAF 与 Pages;Zone 只提供聚合管理和展示。
* 迁移先建新表、用 Public Suffix List 回填和验证,再在后续独立发布中移除旧表及冗余列。
完整设计、API、迁移与验证策略见 [Zone 与域名资源设计](../../design/zone-design.md)。
@@ -1,7 +0,0 @@
# Cloudflare DNS 指向 — Spec 指针
完整设计见项目设计基线:
**[docs/design/cloudflare-pointing.md](../../design/cloudflare-pointing.md)**
本文件仅作 brainstorming 工作流落点索引,避免与 `docs/design/` 双份正文漂移。
@@ -1,219 +0,0 @@
# 边缘限流全局默认设计
日期:2026-07-19
状态:已评审待实现
方案:渲染时按站点合并全局默认(方案 A)
## 背景
当前边缘限流仅挂在站点(Proxy Route)上,字段为:
- `limit_conn_per_server`:站点并发连接上限
- `limit_conn_per_ip`:单 IP 并发连接上限
- `limit_rate`:单请求带宽
OpenResty 渲染行为:
- `http {}` 始终声明共享 `limit_conn_zone`
- 各站点 `location` 在字段 `>0` / 非空时输出 `limit_conn` / `limit_rate`
- 站点值为 `0` 或空表示**关闭**,无全局默认
期望:在全局增加默认限流策略;站点未设置时继承默认,可覆盖或显式关闭。
## 目标
1. 提供三项全局默认限流配置,覆盖全部现有维度。
2. 站点 `0`/空 = 继承全局;`-1` = 显式关闭;`>0`/合法带宽串 = 站点自定义。
3. 合并发生在配置渲染路径,仍在各站点 `location` 输出生效指令(不在 `http {}` 写默认 `limit_conn`/`limit_rate`)。
4. 管理入口:侧栏「安全性」下新增子页「限流」。
5. 全局默认初始为 `0`/空,存量发布行为与现网一致。
## 非目标
- 引入 `limit_req`(按 RPS 限流)
- 在 `http {}` 上下文直接写默认 `limit_conn` / `limit_rate`
- 按路径 / URI 差异化限流
- 改变 `limit_conn` zone 键模型(仍为 `$server_name` 与 `$binary_remote_addr`)
## 语义
### 站点字段
| 值 | `limit_conn_*` | `limit_rate` |
|----|----------------|--------------|
| `0` / 空 | 继承全局默认 | 空或 `"0"` 规范化为空串后继承 |
| `-1` | 显式关闭该维度 | 字面 `"-1"` 表示显式关闭 |
| `>0` / 合法带宽 | 使用站点值 | 合法 `^\d+[kKmM]?$` 使用站点值 |
### 全局默认
| 值 | 含义 |
|----|------|
| `0` / 空 | 默认关闭;继承方亦不输出指令 |
| `>0` / 合法带宽串 | 作为未配置站点的生效值 |
全局默认**不允许** `-1`(无意义);仅 `>=0` 或合法 rate / 空。
### 合并规则(逐字段)
```
if route == -1: effective = off
else if route is set: effective = route // conn > 0 或 rate 合法非空
else: effective = global // route 为 0/空
// global 为 0/空 → off(不输出)
```
`limit_rate` 的「set」判定:规范化后非空且不等于 `"-1"`。
## 配置存储
沿用 `system_configs` + Option API + 发布快照,与其它 OpenResty 选项一致。
| Key | 类型语义 | 默认 |
|-----|----------|------|
| `openresty_default_limit_conn_per_server` | 非负整数 | `0` |
| `openresty_default_limit_conn_per_ip` | 非负整数 | `0` |
| `openresty_default_limit_rate` | 空或 `^\d+[kKmM]?$` | `""` |
实现要点:
- `internal/model/system_configs.go` 增加 `ConfigKeyOpenRestyDefaultLimit*` 常量
- goose seed/升级迁移写入默认值
- `internal/apps/openflare/option` 注册校验器(conn ≥ 0;rate 与站点同一套 pattern,允许空)
- `openRestyConfigSnapshot` / `buildOpenRestyConfigSnapshot` 增加三字段
- 变更进入 OpenResty option diff;**需重新发布配置版本后下发节点**
## 站点模型与 API
- DB 列类型不变(`INTEGER` / `VARCHAR(32)`),无 schema 变更
- `normalizeProxyRouteLimitConnValue`:允许 `>= -1`(原 `>= 0`)
- `normalizeProxyRouteLimitRate`:允许 `"-1"` 存为关闭标记;空/`0` → `""`(继承)
- View / Input / 前端类型同步暴露 `-1` 语义
- 错误文案更新(非法负数除 `-1` 外拒绝)
## 渲染路径
合并**唯一**发生在 `pkg/render/openresty.RenderRouteConfig`:该函数已接收完整 `Document`,可从 `doc.OpenRestyConfig` 读取全局默认,与各 `doc.Routes[i]` 的站点字段合并。Server 预览渲染与 Agent 落地渲染共用同一路径,禁止在 snapshot 构建或其它层再合一次。
步骤:
1. 对每个 route:用站点限流字段 + `doc.OpenRestyConfig` 中的默认三项 → `routeLimitConfig`
2. `renderRouteLimitBlock` 保持「有值才输出」
3. 应用范围不变:
- HTTP/HTTPS 反代 `location /`
- Pages 相关 location
- **不含** HTTP→HTTPS 重定向-only server
4. `http {}` 仍只输出现有 `limit_conn_zone` 两行
快照 JSON **保留站点原始值**(含 `0`/`-1`),不把合并结果写回 route;节点 conf 中只看到最终指令。
伪代码:
```go
func mergeRouteLimit(route routeLimits, def defaultLimits) routeLimitConfig {
return routeLimitConfig{
LimitConnPerServer: mergeConn(route.LimitConnPerServer, def.LimitConnPerServer),
LimitConnPerIP: mergeConn(route.LimitConnPerIP, def.LimitConnPerIP),
LimitRate: mergeRate(route.LimitRate, def.LimitRate),
}
}
func mergeConn(route, def int) int {
if route == -1 {
return 0 // off
}
if route > 0 {
return route
}
if def > 0 {
return def
}
return 0
}
func mergeRate(route, def string) string {
r := strings.TrimSpace(strings.ToLower(route))
if r == "-1" {
return ""
}
if r != "" && r != "0" {
return r
}
d := strings.TrimSpace(strings.ToLower(def))
if d != "" && d != "0" {
return d
}
return ""
}
```
## 前端
### 安全性 → 限流
- 导航:`openflareSecurityNavGroup` 增加 `{ title: '限流', url: '/rate-limits' }`
- 页面:`frontend/app/(main)/rate-limits/page.tsx`
- 通过 `OptionService.list` / `updateBatch` 读写上述 3 个 key
- UI 模式对齐性能页:标题规范、卡片分区、保存反馈
- 文案说明:`0`/空 = 默认关闭;`>0` = 未单独配置站点的默认生效值;修改后需发布配置版本
### 站点流量限制
- 更新 `limits-section.tsx` 与校验 helpers:
- `0`/空 = 继承全局默认
- `-1` = 关闭
- `>0` / 合法 rate = 自定义
- 可选:展示当前全局默认值作提示(只读)
- 创建站点默认仍为 `0`/空(即继承)
## 兼容性
| 场景 | 结果 |
|------|------|
| 升级后全局默认 0,站点全 0 | 与升级前一致:不限流 |
| 管理员设置全局默认后发布 | 所有 `0`/空站点自动生效默认 |
| 站点需保持关闭 | 将该项改为 `-1` 后保存并发布 |
| 旧 API 客户端只写 `0` | 合法;语义变为继承 |
| 旧快照无默认字段 | 按 0/空处理 |
## 边界说明
- `limit_conn_per_ip` zone 仍按 `$binary_remote_addr` 全局共享;各 location 的 N 可不同,计数空间共享(现网行为,本设计不改)。
- 多域名共享一条路由 → 共享合并后策略(产品边界不变)。
- 仅改全局默认不自动 reload 节点;走标准「选项变更 → 配置版本 diff → 发布」。
## 测试计划
1. **render 表驱动**:继承 / 显式关 / 覆盖 / 全局关 × 三字段
2. **normalize**:`-1`、`0`、`>0`、非法负值、rate `"-1"` / 空 / 合法 / 非法
3. **snapshot**:默认字段进入 `openresty_config`;option diff 可检测变更
4. **option 校验**:非法全局 rate / 负 conn 拒绝
5. 前端:限流页读写与站点文案(可选手测)
## 文档与变更记录
- 本设计文档:`docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md`
- 实现时更新中文 changelog `[Unreleased]`(用户可见语义与新设置页)
- 如有配置参考页,补充三个 key 的中文说明
- 不要求同步英文文档
## 实现落点(文件索引)
| 区域 | 路径 |
|------|------|
| 配置键 / seed | `internal/model/system_configs.go`,goose 迁移 |
| 校验 | `internal/apps/openflare/option/openresty_validators.go` |
| 快照 | `internal/apps/openflare/config_version/snapshot.go` |
| 站点规范化 | `internal/apps/openflare/proxy_route/helpers.go` |
| 渲染合并 | `pkg/render/openresty/render.go`(及调用处传参) |
| 导航 | `frontend/lib/navigation/openflare-nav.ts` |
| 限流设置页 | `frontend/app/(main)/rate-limits/` |
| 站点 UI | `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` |
## 验收标准
1. 全局默认可在「安全性 → 限流」读写,初始 0/空。
2. 全局设为有效值并发布后,站点限流为 0/空的 location 出现对应指令。
3. 站点 `-1` 在全局有默认时仍不输出该维度。
4. 站点 `>0` 覆盖全局。
5. 全局与站点均为 0/空时 conf 无 `limit_conn`/`limit_rate` 指令。
6. `make code-check` 通过;相关单测覆盖合并与规范化。
@@ -1,217 +0,0 @@
# 限流页请求压力分析设计
日期:2026-07-19
状态:已评审待实现
方案:Tabs(分析 / 配置)+ 专用 ECharts 双轴压力图(方案 A)
## 背景
`/rate-limits` 当前仅为管理员配置全局 OpenResty 默认限流(`limit_conn_*` / `limit_rate`),无请求压力可视化。
访问日志概览已提供:
- 过滤:时间预设 `24h | 7d | 15d | 30d` + 域名多选 `hosts[]`
- 数据:`GET /api/v1/d/access-logs/overview` → 小时桶 `trends.requests` / `trends.visits`,以及 `top_hosts` / `top_ips`(窗口总请求数)
- 图表:共享 `TrendChart` 为**单 Y 轴**;仓库内无 ECharts `dataZoom`、无双轴指标图
需求:在限流页展示当前请求压力(RPS),默认 24 小时,图表样式对齐「双轴时序面积折线 + 底部缩放条」描述,过滤复用访问日志概览组件,并增加域名/IP 平均 RPS 排行。
## 目标
1. `/rate-limits` 改为 Tabs:**分析**(默认)/ **配置**。
2. 分析 Tab:概览式过滤 + RPS/访客双轴主图 + 域名/IP 平均 RPS 排行。
3. 配置 Tab:迁入现有全局默认限流表单,行为不变。
4. 数据复用 `AccessLogService.getOverview`,不新增后端 API。
5. 主图为**专用** ECharts 组件,不扩展共享 `TrendChart`。
## 非目标
- 新 RPS 时序 API 或峰值桶 RPS 排行接口
- 给通用 `TrendChart` 增加双轴 / dataZoom
- 配置 Tab 限流语义变更
- 分析过滤支持 node_id / IP / path(概览亦无)
- 英文文档
## 页面信息架构
**路由:** `/rate-limits`(导航「安全性 → 限流」不变)
| Tab | 内容 |
|-----|------|
| **分析**(默认) | 过滤条 → `RatePressureChart` → 双排行榜 |
| **配置** | 现有三项全局默认限流表单 + 保存 + 链到版本发布 |
可选:`?tab=config` 直达配置;默认 `analysis`。
**权限:** 仅管理员(与现页一致)。
**分析 Tab 自上而下:**
1. **过滤条**(与访问日志概览一致)
- 时间:`24 | 168 | 360 | 720` 小时,默认 **24**
- 域名:Zone 树多选 → `hosts[]`
2. **主图卡片** `RatePressureChart`
3. **排行榜**(并排)
- 平均 RPS 最高域名
- 平均 RPS 最高 IP
## 数据与状态
### 查询
```ts
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
})
// queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts]
```
- 过滤变更 → 重新请求 overview
- 图表 `dataZoom` **仅**前端缩放已加载序列,**不**改 `hours`、**不**触发 refetch
### 指标定义
| 序列 | 源字段 | 换算 | 轴 |
|------|--------|------|-----|
| 请求速率 (RPS) | `trends.requests[].value` | `value / 3600`(概览固定 1h 桶) | 左 Y |
| 独立访客 | `trends.visits[].value` | 桶内 UV,不换算 | 右 Y |
- 时间点:`bucket_started_at`
- Tooltip:时间 + RPS(如 `12.3 req/s`)+ 访客数
- 空数据 / 加载 / 错误:对齐访问日志概览空态与 `ErrorInline` / loading
### 排行口径
窗口**平均** RPS(与 dashboard `estimated_qps` 一致):
```
avgRps = total_requests / (hours * 3600)
```
- 域名:`top_hosts[]` 的 `value` 为窗口总请求数 → 换算后展示
- IP:`top_ips[]` 同理
- 标题:「平均 RPS 最高域名」「平均 RPS 最高 IP」
- 副文案标明窗口(如「近 24 小时平均」)
- UI 组件:现有 `RankCard` / `RankChart`
**不是**峰值小时桶 RPS;避免新 API。
## 主图组件 `RatePressureChart`
### 布局(对齐产品描述)
1. **外部卡片**:圆角、边框/轻阴影,扁平矩形
2. **顶部控制栏**
- 左:主标题「请求压力」(字号加粗)
- 右:时钟图标 + 当前查询窗口起止(由 `hours` 与「现在」推算本地时间,`YYYY-MM-DD HH:mm:ss`)
3. **图例与轴标识**
- 左上:左轴属性「RPS」
- 右上:图例圆点 +「请求速率」「独立访客」
- 最右:右轴单位「访客 / 桶」
4. **主绘制区**
- 双 Y 轴:左 RPS 从 0 递增;右访客从 0 递增
- X 轴:时间,标签两行(月-日 / 时:分),可复用 `formatOverviewTrendLabel` 思路
- 水平等距虚线网格
- 面积 + 折线,半透明填充,两序列可重叠
5. **底部 dataZoom slider**
- ECharts `dataZoom: [{ type: 'slider', ... }]`
- 宽度对齐绘图区;左右手柄;内嵌缩略波动线
- 仅影响可见区间
### 实现约束
- 新建专用组件,**不要**给 `TrendChart` 加 dualY/dataZoom
- 库:`echarts` + `echarts-for-react`(与看板一致)
- 颜色使用主题/CSS 变量或与访问日志趋势相近的语义色,避免硬编码与 shadcn 变体冲突时可参考现有 `TrendChart` 系列色
## 过滤组件复用
优先从 `frontend/app/(main)/access-logs/components/overview-tab.tsx` **抽出**:
- `OverviewToolbar`(或等价)
- `OverviewHostFilter`
- 依赖的 `OVERVIEW_RANGE_OPTIONS` / `OverviewRangeHours` 已在 `access-log-utils.ts`
落点建议:
- 仍放在 `access-logs/components/` 并 export,限流分析 import;或
- 若跨模块更清晰,迁到 `frontend/components/common/`(仅当确实跨页面复用且避免循环依赖时)
**验收:** 访问日志概览过滤行为与抽出前一致。
## 文件结构
```
frontend/app/(main)/rate-limits/
page.tsx # Tabs、权限、分析/配置挂载
components/
analysis-tab.tsx # 过滤 + 图 + 排行 + overview query
rate-pressure-chart.tsx # 双轴 + dataZoom
config-tab.tsx # 现有 Option 表单逻辑迁入
```
可选抽出:
```
frontend/app/(main)/access-logs/components/
overview-toolbar.tsx # 从 overview-tab 抽出
overview-host-filter.tsx
```
后端:无变更。
## 边界与兼容
| 场景 | 行为 |
|------|------|
| 无日志 / ClickHouse 空 | 图与排行空态 |
| 仅选域名 | overview 带 `hosts[]` |
| dataZoom 拖动 | 不请求后端 |
| 非管理员 | 空态「权限不足」 |
| 书签 `/rate-limits` | 默认分析 Tab |
| 配置保存 | 仍 invalidate options / config-preview / config-versions |
## 测试与验收
### 自动化(按项目习惯)
- 若有 vitest:过滤 props 透传、`avgRps` 换算纯函数单测
- 图表以手工/视觉验收为主(ECharts 难做快照)
### 验收标准
1. 默认进入分析 Tab,24h,主图展示 RPS + 访客
2. 切换 7d / 域名后图与排行刷新
3. dataZoom 仅改变可见时间范围
4. 排行展示平均 RPS,不是原始请求总数(文案明确「平均」)
5. 配置 Tab 可读写三项默认限流并保存
6. 访问日志概览过滤不回归
7. `make prettier`;相关 typecheck/lint 通过
## 文档
- 本设计:`docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md`
- 实现时:`docs/changelog/index.md` `[Unreleased]` 补充用户可见条目
- 纯 UI/分析展示,无新 system config 键
## 实现落点索引
| 区域 | 路径 |
|------|------|
| 限流页 | `frontend/app/(main)/rate-limits/` |
| 概览过滤复用 | `access-logs/components/overview-tab.tsx` 等 |
| Overview API | `AccessLogService.getOverview` |
| 排行 UI | `components/data/rank-card.tsx` |
| 趋势参考 | `components/data/trend-chart.tsx`(只参考样式,不扩展) |
## 决策摘要
| 决策 | 选择 |
|------|------|
| 页面结构 | Tabs:分析 / 配置 |
| 双轴 | 左 RPS,右 独立访客/桶 |
| 过滤 | 概览过滤 + 默认 24h 预设 |
| 排行 | 窗口平均 RPS = 总请求 / 窗口秒数 |
| 图表实现 | 专用 ECharts 组件(方案 A) |
| 后端 | 无新 API |
@@ -1,122 +0,0 @@
# WAF 规则编辑器:节点命名与拖放添加
日期:2026-07-19
范围:`/waf/rules/editor` 前端交互与类型对齐
状态:已确认,待实现
## 背景
当前 WAF 规则流图编辑器有两处体验问题:
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
## 目标
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
## 非目标
- 节点备注 / note / remark
- 拖到边自动拆边插入
- 系统节点 `start` / `allow` 可改名
- 后端校验、编译或运行时语义变更
- 侧栏式节点库大改版
## 数据模型
### 后端(已有,不改)
```go
type RuleNode struct {
ID string `json:"id"`
Type RuleNodeType `json:"type"`
Label string `json:"label,omitempty"`
Position RulePosition `json:"position"`
Config json.RawMessage `json:"config"`
}
```
`label` 为空则 omit;现有大小限制与图校验保持不变。
### 前端
`WAFRuleNode` 各变体增加可选字段:
```ts
label?: string;
```
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
- 显示时:`label?.trim() || typeDefaultLabel`。
- 新建节点:不设 `label`(显示类型默认名)。
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
## UI 行为
### 画布节点(`rule-node.tsx`)
| 区域 | 行为 |
|------|------|
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
| 副标题 | 仍显示 `rule.id`(mono 小字) |
| 图标 / handle | 不变 |
### 属性栏(`node-properties.tsx`)
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
- 字段标签:`显示名称`
- 控件:`Input`,受控绑定 `node.label ?? ''`
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
系统节点保持现有文案。
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
3. React Flow 画布容器:
- `onDragOver`:`preventDefault`,允许 drop
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
4. 落点后选中新节点,清除边选中(与现有一致)。
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
## 实现落点(文件)
| 文件 | 变更 |
|------|------|
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
| `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-library.tsx` | 拖放源,去掉点击添加 |
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
## 错误与边界
- 未知 / 非法 drag type:忽略 drop。
- 落在画布外:不创建。
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
## 测试要点
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
5. 系统节点属性栏仍无「显示名称」。
## 验收标准
- [ ] 可编辑节点可命名,保存再打开名称仍在。
- [ ] 画布显示自定义名(空则类型名)。
- [ ] 仅拖放添加,松手位置为节点位置。
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
@@ -1,191 +0,0 @@
# WAF 规则节点:安全防护(security_check)
日期:2026-07-19
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
状态:已确认,待实现
## 背景
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
## 目标
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
## 非目标(v1)
- ModSecurity / OWASP CRS / libinjection 完整引擎
- 响应侧 XSS 检测、机器学习
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
- 无限制大 Body 全量扫描
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `security_check` |
| 句柄 | `true`, `false` |
| 可删除 / 可命名 / 可拖放 | 是 |
### Config
```json
{
"sql_injection": false,
"path_traversal": true,
"command_injection": false,
"xss": false,
"ssrf": false,
"file_inclusion": true,
"malicious_upload": false,
"xxe": false,
"crlf_injection": false
}
```
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|------|------|---------|--------------|
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
| `xss` | false | XSS | Query、Cookie、Referer、Body |
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
全部关闭时:节点恒 **true**(空操作),合法。
## 求值语义
```
inputs := collect_inspection_strings(request) // 见下
for each enabled rule:
if rule_matches(rule, inputs) → return false
return true
```
- **false** = 命中攻击特征(接阻止)
- **true** = 未命中(接通过或其它节点)
### 采集与限制
| 来源 | 方式 |
|------|------|
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
| Cookie | `ngx.var.http_cookie` |
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
### 规则特征方向(v1 模式包)
实现以可维护的模式表为准,下表为方向约束:
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
## 控制面
### `graph_types.go`
- `RuleNodeSecurityCheck = "security_check"`
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- 严格 JSON;仅允许已知布尔字段
### `graph_compile.go`
- 原样编译布尔字段进运行时配置
### 测试
- 合法全关 / 默认子集 / 全开
- 未知字段拒绝
- 编译保留默认
## 数据面
### `waf_runtime.lua`
```lua
elseif node.type == "security_check" then
handle = matches_security_check(node.config or {}) and "true" or "false"
```
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
### `waf_runtime_spec.lua`
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
## 前端
| 文件 | 变更 |
|------|------|
| `types.ts` | `security_check` + `SecurityCheckConfig` |
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
| `node-library.tsx` | 「安全防护」+ 图标 |
| `rule-node.tsx` | `true`/`false` handles |
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
| `graph-validation.ts` / `editor-behavior.ts` | handles |
### 属性栏草图
```
显示名称
── 安全防护 ──
命中任意已启用规则返回 False [?]
── 基础防护 ──
[Switch] 路径穿越防护 [?]
[Switch] 文件包含(LFI/RFI) [?]
[Switch] SQL 注入 [?]
...
```
Tooltip 文案包含检测面与简要说明(与产品表一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表
- `docs/changelog/index.md` `[Unreleased]`
## 验收标准
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
- [ ] 全关恒 true
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
## 风险
| 风险 | 缓解 |
|------|------|
| 误报 | 默认仅开低误报两项;模式偏保守 |
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
@@ -1,228 +0,0 @@
# WAF 规则节点:UA 检查(ua_check)
日期:2026-07-19
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
状态:已确认,待实现
## 背景
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
1. 要求请求必须携带 UA;
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
3. 优先屏蔽常见爬虫与非正常 UA。
## 目标
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
- 屏蔽逻辑优先级高于白名单匹配。
## 非目标
- 设备类型(Mobile/Tablet)维度。
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `ua_check` |
| 句柄 | `true`, `false` |
| 可删除 | 是 |
| 可命名 | 是(`label`) |
| 可拖放添加 | 是 |
### Config(JSON)
```json
{
"require_ua": false,
"browsers": [],
"operating_systems": [],
"match_mode": "or",
"block_common_bots": false,
"block_abnormal_ua": false,
"block_custom_ua": false,
"custom_ua_patterns": []
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
默认值:开关全 `false`,列表空,`match_mode: "or"`。
### 允许的标签(封闭枚举)
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
**browsers:**
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
**operating_systems:**
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
## 求值语义(边缘)
输入:`ua = http_user_agent`(trim 后判断空)。
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
**严格顺序:**
```
1) if require_ua and ua 为空 → false
2) browser, os := classify(ua)
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
5) if block_custom_ua and UA matches any custom_ua_patterns → false
6) has_browsers := browsers 非空; has_os := operating_systems 非空
7) if not has_browsers and not has_os → true
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
9) if has_browsers and not has_os → browser_hit
10) if has_os and not has_browsers → os_hit
11) if both lists set:
match_mode == "and" → browser_hit and os_hit
match_mode == "or" → browser_hit or os_hit
```
说明:
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
### 示例
| 配置摘要 | 请求 | 结果 |
|----------|------|------|
| 仅 `require_ua` | 无 UA | false |
| 仅 `require_ua` | 正常 Chrome | true |
| `block_common_bots` | Googlebot | false |
| `block_abnormal_ua` | 无法识别 UA | false |
| browsers=`[Chrome]`, mode=or | Safari | false |
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
| 列表皆空,无 block | 任意有 UA | true |
## 分类规则来源
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
实现要求:
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
## 控制面
### `graph_types.go`
- `RuleNodeUACheck RuleNodeType = "ua_check"`
- `UACheckConfig` 结构体对应上表 JSON 字段
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- `validateUACheckNodeConfig`:
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
- browsers / OS 标签 ∈ 封闭枚举
- 布尔字段默认 false
- `DisallowUnknownFields`
### `graph_compile.go`
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
- 规范化 `match_mode`(非法不得编译成功)
### 测试
- validate:合法配置、非法标签、非法 mode、缺句柄
- compile:列表排序去重、默认值
## 数据面(Agent)
### `waf_runtime.lua`
在 `execute_graph` 增加:
```lua
elseif node.type == "ua_check" then
handle = matches_ua_check(node.config) and "true" or "false"
```
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
### `waf_runtime_spec.lua`
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
## 前端编辑器
| 文件 | 变更 |
|------|------|
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
| `node-library.tsx` | 拖放项 |
| `rule-node.tsx` | 图标 + `true`/`false` handles |
| `node-properties.tsx` | 属性 UI(见下) |
| `graph-validation.ts` | handles + 标签/mode 校验 |
| `editor-behavior.ts` | connection handles |
### 属性栏布局
```
显示名称
── UA 检查 ──
[Switch] 开启 UA 检查
说明:开启后如果请求头不携带 UA 返回 False
── UA 匹配 ──
匹配模式 Select: 或(or) / 且(and)
浏览器 MultiSelect(封闭枚举)
操作系统 MultiSelect(封闭枚举)
── 屏蔽 ──
说明:命中返回 false,优先级高于匹配
[Switch] 屏蔽常见爬虫 UA
[Switch] 屏蔽非正常 UA
```
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
- 纯设计文档不写 changelog 以外的英文同步。
## 验收标准
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
## 风险与缓解
| 风险 | 缓解 |
|------|------|
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
@@ -1,159 +0,0 @@
# 站点级访问频率限制设计
日期:2026-07-20
状态:已评审待实现
方案:站点详情 Limits 暴露 `limit_req_per_ip`;渲染时按 effective rate 生成多 `limit_req_zone`,并用站点键隔离 IP 计数
## 背景
全局默认已有:
- `openresty_default_limit_conn_per_server`
- `openresty_default_limit_conn_per_ip`
- `openresty_default_limit_rate`
- `openresty_default_limit_req_per_ip`
站点级并发/带宽已在「反代站点详情 → 流量限制」中配置,语义为:空/`0` 继承、`-1` 关闭、自定义覆盖。
请求频率(`limit_req`)后端字段与 merge 已存在,但:
1. 前端站点详情未暴露 `limit_req_per_ip`
2. 渲染侧仅在全局默认非空时输出**单一** `limit_req_zone ... rate=全局值`,站点自定义 rate 无法真正独立生效(nginx 的 rate 写在 zone 上,不能仅靠 location 覆盖)
## 目标
1. 在**仅站点详情「流量限制」区块**配置单 IP 请求频率。
2. 语义与现有三项一致:空/`0` 继承全局;`-1` 关闭;合法 `Nr/s` / `Nr/m` 为站点自定义。
3. 站点自定义 rate **真正按该 rate 生效**(A 站 5r/s、B 站 10r/s 互不影响)。
4. 同 IP 在不同站点的频率配额**按站点隔离**。
5. 修改后仍需发布配置版本;Agent 使用与 Server 同源的 render 路径。
## 非目标
- 在「安全性 → 限流」页增加按站点列表编辑
- 新建站点表单中的频率字段
- 按路径 / URI 差异化频率限制
- 改变 `limit_conn_*` / `limit_rate` 的现有 zone 与合并模型
- 业务 Zone(顶级域 + 二级域名资源)模型变更
## 语义
### 站点字段 `limit_req_per_ip`(字符串)
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 继承全局 `openresty_default_limit_req_per_ip` |
| `"-1"` | 本站显式关闭频率限制 |
| `^\d+r/[sm]$`(大小写不敏感,存小写) | 本站自定义 rate |
### 全局默认
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 默认关闭;继承方亦不输出 `limit_req` |
| 合法 rate | 未配置站点的 effective rate |
### 合并(与现有 `mergeLimitRate` 一致)
```
if route == -1: effective = off
else if route is set: effective = route // 合法 rate
else: effective = global // route 空/0
// global 空/0 → off
```
## 渲染
### 问题
nginx `limit_req_zone` 的 `rate=` 在 zone 声明时固定;多个站点若 effective rate 不同,必须使用不同 zone。
### 步骤
1. 在 `RenderRouteConfig` / main 配置生成前,对全部 route 计算 effective `LimitReqPerIP`。
2. 收集非空 effective rate 的**去重集合**,在 `http {}`(`renderOpenRestyLimitZoneBlock` 扩展,需能访问 routes 或 precomputed rates)输出:
```nginx
# 变量键:站点名 + IP,保证跨站点计数隔离
# 实现可用 map 或在 server 内 set 后引用;zone key 采用组合键
limit_req_zone $openflare_req_key zone=openflare_req_<rate_token>:10m rate=<rate>;
```
`rate_token` 由 rate 规范化生成(如 `10r/s` → `10rs`,`100r/m` → `100rm`),仅作 zone 名片段,合法 nginx zone 名。
3. 每个业务 server 在 access 相关位置之前设置:
```nginx
set $openflare_req_key "$openflare_waf_site$binary_remote_addr";
```
(与现有 `set $openflare_waf_site "..."` 同源 site_name;若某 server 无 waf site 变量则用同一 displayName/site_name。)
4. `renderRouteLimitBlock` 在 effective rate 非空时输出:
```nginx
limit_req zone=openflare_req_<rate_token> burst=<calculateBurst> nodelay;
limit_req_status 429;
```
5. **无任何** effective rate 时:不输出任何 `limit_req_zone` / `limit_req`(避免引用不存在的 zone)。
6. 应用范围与现有 limit 块一致:HTTP/HTTPS 反代 `location /`、Pages 相关 location;不含 HTTP→HTTPS 重定向-only server。
### 与旧行为差异
| 项 | 旧 | 新 |
|----|----|----|
| zone 数量 | 全局最多 1 个 | 按不同 effective rate 多个 |
| zone key | `$binary_remote_addr` | `$openflare_req_key`(站点+IP) |
| 站点自定义 rate | 无法真正独立 | 引用对应 rate 的 zone |
快照 JSON **仍保留站点原始值**(含空/`-1`),不把 merge 结果写回 route。
## 数据与 API
- 列 `of_proxy_routes.limit_req_per_ip` 已存在;无新迁移(若环境已跑过既有迁移)。
- API `Input` / `View` 已有字段;normalize / 校验已存在。
- 前端类型与详情表单补齐即可。
## 前端
仅改站点详情 `limits-section.tsx`:
- 增加「单 IP 请求频率」输入
- 校验:空、`0`、`-1`、或 `^\d+r/[sm]$i`
- 规范化:trim + lower;`0` → `""`
- `ProxyRouteItem` / `ProxyRouteMutationPayload` 增加 `limit_req_per_ip`
- `buildPayloadFromRoute` 带上该字段,避免其它区块保存时丢失
文案:与并发/带宽一致(空或 0 继承;-1 关闭;例如 10r/s、100r/m 自定义)。
## Agent / 发布
- 配置保存后须**发布配置版本**
- Agent **本地** `RenderJSON`;必须部署含本设计 render 的 Agent,否则 source 有字段但 conf 无指令
- 若 Agent 已记录同 version/checksum,升级二进制后需触发重新 apply(重启或强制重同步)
## 测试
- `mergeRouteLimitConfig`:继承 / 覆盖 / `-1`(已有则补 rate 断言)
- 多站点不同 effective rate:main conf 含多个 `limit_req_zone`,各 location 引用正确 zone 名
- 全关闭:无 `limit_req` 相关指令
- 仅全局有值:一个 zone + 未自定义站点引用该 zone
- 前端类型与表单校验(手工或既有模式)
## 验收
1. 全局 `10r/s`,站点空 → 该站 location 有 limit_req,zone rate=10r/s
2. 站点改 `5r/s` 并发布 → 该站引用 5r/s zone
3. 站点 `-1` → 该站无 limit_req
4. 两站不同 rate,同 IP 压测互不抢同一配额
## 实现边界
| 层 | 工作量 |
|----|--------|
| 渲染 `pkg/render/openresty` | 多 zone + 站点键 + location 引用 |
| 前端详情 Limits + types + payload | 补字段 |
| 后端 API/DB | 已具备,仅回归 |
| 文档/changelog | 用户可见变更记中文 changelog |
@@ -1,253 +0,0 @@
# Frontend i18n Design
Date: 2026-07-24
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
Scope: Frontend UI only
## 1. Goals
Add bilingual UI support for Wavelet frontend:
- Languages: `zh-CN` and `en`
- Default locale: `zh-CN`
- Locale resolution: explicit user choice → browser language → default
- Phase 1: infrastructure + core paths only (layout / auth / settings)
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
### Non-goals (Phase 1)
- Backend API error / message localization
- Email / push notification localization
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
- Full translation of all admin business pages
## 2. Context
Current state:
- Root layout hardcodes `lang='zh-CN'`
- UI copy is mostly Chinese string literals across many TSX files
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
- No i18n library is installed
- Frontend supports both normal Next rewrites mode and static export embed mode
## 3. Approach
Use **next-intl in non-routing / provider mode**.
Why this approach:
- Mature App Router integration and clear `useTranslations` API
- ICU message format ready when needed
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
- Cookie + browser detection matches product preference without SEO path requirements
Rejected alternatives:
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
## 4. Architecture
```
RootLayout
html lang={locale}
ThemeProvider
CustomThemeProvider
AppQueryProvider
NextIntlClientProvider(locale, messages)
existing User / Notification / Bell providers
pages + components
```
### Key files
| Path | Responsibility |
| --- | --- |
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
| `frontend/messages/zh-CN.json` | Chinese messages |
| `frontend/messages/en.json` | English messages |
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
### Runtime flow
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
2. Load `messages/{locale}.json`
3. Provide locale + messages through `NextIntlClientProvider`
4. Components call `useTranslations('<namespace>')`
5. Language switcher writes cookie and refreshes locale/messages
6. Update `document.documentElement.lang`
## 5. Locale Resolution
Supported locales: `zh-CN`, `en`
Normalization:
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
- anything else → `zh-CN`
Priority:
1. User explicit choice stored in cookie `NEXT_LOCALE`
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
3. Default `zh-CN`
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
## 6. Static Export Compatibility
Constraints:
- No locale-segment routes
- No middleware-based locale rewriting required for correctness
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
Behavior:
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
## 7. Message Organization
Single catalog files with nested namespaces:
```json
{
"common": {
"save": "保存",
"cancel": "取消",
"loading": "加载中..."
},
"layout": {
"nav": {
"home": "首页",
"myFiles": "我的文件"
},
"userMenu": {
"settings": "设置",
"logout": "退出登录"
}
},
"auth": {
"login": {
"title": "登录",
"submit": "登录"
}
},
"settings": {
"appearance": {
"language": "语言",
"languageDesc": "选择界面显示语言"
}
}
}
```
Conventions:
- Keys use camelCase and hierarchical grouping
- Prefer complete phrases as values; avoid assembling sentences in components
- Use ICU only when needed (`{name}`, plural forms)
- Backend `error_msg` values are shown as-is in Phase 1
- Frontend-owned toast / validation copy is translated
Both locale files must keep the same key tree. A key-alignment check script is recommended.
## 8. Language Switcher UX
Placement:
- Header toolbar near theme controls
- Appearance settings page as an explicit preference row
UI labels for language options use native names and do not themselves translate:
- `中文`
- `English`
On change:
1. Persist `NEXT_LOCALE`
2. Apply new locale/messages (via refresh or controlled provider update)
3. Sync `document.documentElement.lang`
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
## 9. Phase 1 Migration Scope
### In scope
- Install and wire `next-intl`
- Message catalogs for core namespaces
- Locale resolution + persistence
- `LanguageSwitcher`
- Translate:
- layout shell: sidebar nav/user menu, header accessible labels / titles
- auth: login / register / OTP labels, buttons, validation messages
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
- Replace date/number hardcoding only where touched by the above paths
- Ensure `html lang` reflects active locale
### Out of scope
- Remaining admin pages and deep business modules
- Backend localization
- Route prefixing / SEO alternate links
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
## 10. Formatting Helpers
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
- `formatDateTime(value, locale)`
- `formatNumber(value, locale)`
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
## 11. Error Handling & Fallbacks
| Case | Behavior |
| --- | --- |
| Missing message key | Dev warning; do not crash; show key or fallback language value |
| Unsupported cookie locale | Normalize to supported locale / default |
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
| Backend error strings | Display raw `error_msg` |
## 12. Testing & Acceptance
Manual:
1. No cookie + browser Chinese → Chinese UI
2. No cookie + browser English → English UI
3. Manual switch to English survives refresh
4. Manual switch back to Chinese survives refresh
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
6. `pnpm build` and `pnpm build:embed` both succeed
7. Language switch does not break theme, session, or sidebar state
Automated (recommended):
- Unit tests for `normalizeLocale` / resolution priority
- Script or test asserting `zh-CN.json` and `en.json` key parity
## 13. Rollout Plan (high level)
1. Add i18n infrastructure and empty/core message files
2. Mount provider and language switcher
3. Migrate layout shell copy
4. Migrate auth copy
5. Migrate settings copy + appearance language control
6. Verify SSR and static-export builds
7. Document how later pages should adopt `useTranslations`
## 14. Open Implementation Notes
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
- Keep `internal/util` and backend packages untouched
- After implementation, follow repo frontend conventions and existing provider composition style
@@ -1,22 +0,0 @@
# 源站错误页设计(Spec)
> 权威正文与产品文档索引见:[docs/design/origin-error-page.md](../../design/origin-error-page.md)
> 本文为 brainstorming 流程落库副本,内容与上者保持一致。
---
## 摘要
源站/网关在用户配置的状态码(默认标签 `500-599`,支持 `522` 与 `500-599` 区间)上,返回全局可配置 HTML 错误页,替代当前透传行为。默认 Cloudflare 风格页;可在线自定义 HTML。HTTP **status 保持原错误码**,正文通过 `{{status}}` / `{{host}}` 展示。配置挂在 OpenFlare Option,随配置版本发布;侧栏「网站管理 → 错误页」。可关闭以恢复透传。
## 方案
**方案 A(已采纳)**:全局 Option → 配置快照 `ConfigSnapshot` → OpenResty 渲染 `proxy_intercept_errors` + `error_page` + internal location 模板替换。
非目标:按路由覆盖、上传文件、Pages 路由、改 WAF 自有页。
## 详细章节
完整章节(目标、状态码语法、配置模型、边缘渲染、前端、测试、实现清单、决策记录)见:
**[docs/design/origin-error-page.md](../../design/origin-error-page.md)**
@@ -1,177 +0,0 @@
# 日志数据库解耦设计(ClickHouse 可选化)
> 状态:已与用户逐段确认,待用户复核。
> 日期:2026-08-08
## 1. 背景与目标
当前系统日志/分析(访问日志、可观测时序)完全绑定 ClickHouse:`internal/repository/analytics` 直接操作 `db.ChConn`/`db.ChDB`,apps 层(`chwriter`、`risk_control`、`admin/logs`、`admin/status`)依赖 `config.ClickHouse.Enabled` 判断可用性。业务流量小、主机性能低时 ClickHouse 负担大。
目标:
1. **解耦**:ClickHouse 变为可选项;不启用时,主库(PostgreSQL;禁用时 SQLite)完整承接全部日志功能(写入、查询、聚合、清理)。
2. **代码级约束**:上层应用写日志不能直接调用底层库(`analyticsrepo` / `db.ChConn`),用接口 + import-lint 测试保证,而非 AGENTS.md 口头约束。
3. **可迁移**:提供用户触发的「切换日志数据库」任务,支持 PostgreSQL/SQLite ↔ ClickHouse 数据迁移。
4. **表结构**:CH 日志表迁入 PG/SQLite;CH 保持只有日志表的 SQL 脚本;PG/SQLite 包含全部表。
## 2. 现状要点
- 连接:`internal/infra/persistence/clickhouse.go`(`ChConn` 原生批量写 + `ChDB` GORM 查询),`init()` 依据 `clickhouse.enabled`。
- 分析域:`internal/repository/analytics/` 直接读写 CH;apps 通过 `batchwriter` 异步 flush(`chwriter`、`risk_control`)。
- 已有抽象雏形:`internal/repository/openflare_access_log_store.go` / `openflare_observability_store.go` 中的未导出 `accessLogStore` / `observabilityStore` 接口,默认 `clickhouseAccessLogStore{}`,测试可换 memory 实现——默认写死 CH、不可配置切换、接口未导出。
- 迁移:主库 goose(`goose/postgres` + `goose/sqlite` 双方言)与 CH 单方言(`goose/clickhouse`)分离。
- 历史:PG/SQLite 曾有过 `of_node_metric_snapshots`、`of_node_access_logs` 等观测表(`202606190010_create_of_observability_tables.sql`),后由 `202606200005_drop_of_node_observability_timeseries.sql` 删除(迁去 CH)。**旧 DDL 可复活改造**。
- 任务:Asynq + `task.RegisterHandler`/`RegisterTaskMeta`;`system_cleanup`(系统垃圾清理)每日任务已存在;`of_database_auto_cleanup`(可观测清理,schedule id=102)存在。
- 系统配置:`system_configs` 表(key/type/visibility),现有 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`(business)。
## 3. 已确认的核心决策
| # | 决策 |
|---|---|
| 1 | 范围:CH 不启用时,PG(或 SQLite)承担**全部**日志功能;聚合在 PG/SQLite 查询时实时计算,不物理建 MV 同构表。 |
| 2 | 实现:接口定义在 repository 层;PG 用 GORM 全新实现;CH 保留现有原生批量优化(`PrepareBatch`)包进同一接口。 |
| 3 | SQLite 是一等公民:`log_database` ∈ {`postgres`, `sqlite`, `clickhouse`};迁移方向 PG→CH、SQLite→CH、CH→PG、CH→SQLite。 |
| 4 | 日志库只有两种合法状态:**随主库**(`database.enabled` → postgres,否则 sqlite)或 **clickhouse**;不存在主库 PG + 日志 SQLite 的组合。 |
| 5 | 迁移任务「切换日志数据库」:纯复制、**源数据不删除**、可重试;迁移期间**冻结日志写入**(拒绝,不排队积压);全部成功才翻转主库标记。 |
| 6 | 清理统一到 `system_cleanup`(每日一次,日志过期无需实时);保留时间按**存储库**配置(`type=business`)。 |
## 4. 包结构与接口(方案一)
新增 `internal/repository/logstore/`,职责唯一:日志存储抽象。
```
internal/repository/logstore/
├── logstore.go # 导出接口:AccessLogStore / ObservabilityStore / UserAccessLogStore / CleanupStore / StatusStore
├── provider.go # Open(ctx) 按当前日志主库返回实现;ActiveDatabase() 供状态/UI;测试可注入
├── postgres_store.go # GORM 实现(PG 与 SQLite 共用一套,方言差异只在 goose DDL + dialect_* 小文件)
├── dialect_postgres.go # PG 方言 SQL 片段(date_trunc / FILTER / 分区清理)
├── dialect_sqlite.go # SQLite 方言 SQL 片段(strftime / unixepoch)
└── clickhouse_store.go # 把现有 analyticsrepo 原生批量 + GORM 查询包进接口(零性能损耗)
```
- **接口划分**(避免 40+ 方法巨型接口,合成 `logstore.Store` 结构体持有):
- `AccessLogStore`:节点访问日志的 InsertBatch / List / Count / RegionCounts / BucketAggregates / CountBuckets / BucketDimensions / IPAggregates / IPSummaries / CountIPSummaries / WAFIPAggregates / IPTrend / TrafficSummary / ValueCounts / NodeAggregates / DeleteAll / DeleteBefore / DeleteByNodeBefore。
- `ObservabilityStore`:4 表(metric snapshots / edge health / frps / frpc)的 Insert / List / Delete。
- `UserAccessLogStore`:`w_user_access_logs` 的 BatchInsert / Count / List / 统计(DailyTrend / BrowserDistribution / TopActiveUsers 等)。
- `CleanupStore`:按保留天数清理过期数据(PG=分区 DROP + 分批 DELETE;SQLite=分批 DELETE;CH=MODIFY TTL + materialize)。
- `StatusStore`:当前库状态、CH 运行指标(激活时)、GORM 写入器状态。
- **消费面**:`internal/repository` 现有公开函数(`ListOpenFlareAccessLogs`、`InsertOpenFlareAccessLogsBatch`、`InsertOpenFlareMetricSnapshot` 等)**保留签名、改为一行委托 `logstore`**,apps 调用面几乎不动;apps 里现有 `analyticsrepo` 直连(`risk_control`、`chwriter`、`tasks/database_cleanup.go`、`observability/access_log_logics.go`、`admin/logs`、`admin/status`)全部改走 repository/logstore。
- **import-lint 测试**:新增 `go test`,扫描 `internal/apps/**` 的 import,发现 `internal/repository/analytics` 或 `internal/infra/persistence`(`batchwriter` 白名单除外)即失败。这是「代码层面规避」的验收。
- `analyticsrepo` 保留,仅被 `logstore/clickhouse_store.go` 引用(CH 实现细节)。
### 主库标记与启动校验
- `system_configs` 新增内部 key:
- `log_database`(`postgres`/`sqlite`/`clickhouse`):当前日志主库,仅迁移任务写入。
- `log_db_migration`(`"migrating"`/空):迁移冻结标记,仅迁移任务写入。
- **首次 seed**(bootstrap Go 侧,因依赖运行时主库选择):key 缺失时,`clickhouse.enabled` → `clickhouse`(保持现状、不丢现有 CH 数据);否则 → 当前主库(`database.enabled` → `postgres`,否则 `sqlite`)。
- **启动校验**(bootstrap):
- `log_database=clickhouse` 但 `clickhouse.enabled=false` → 启动报错:「当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse」。
- `log_database=postgres` 但 `database.enabled=false`,或 `log_database=sqlite` 但 `database.enabled=true` → 启动报错(违反「随主库或随 CH」规则)。
- **key 保护**:`log_database`、`log_db_migration` 在配置更新接口(admin system-configs / option 校验)拒绝修改;仅迁移任务可写;启动校验兜底被篡改组合。
- **热切换**:`logstore` 通过系统配置缓存(Redis,更新即失效)读取 `log_database`;翻转后 API 进程自动切到新实现,无需自定义跨进程协议。
## 5. PG/SQLite 表结构与优化
**新建原始日志表(PG + SQLite 双方言 goose,同版本号)**——只建原始表,**不建** CH 物化视图/聚合表(`of_access_log_hourly`、`of_node_metric_capacity_hourly` 等),PG/SQLite 查询时实时聚合:
| 表 | 说明 |
|---|---|
| `w_user_access_logs` | 用户访问日志 |
| `of_node_access_logs` | 节点访问日志(含 user_agent/cache_status/bytes_sent/request_length/request_time_ms 现行列) |
| `of_node_metric_snapshots` | 资源指标 |
| `of_node_edge_health` | 边缘健康 |
| `of_node_obs_frps` | FRPS 观测 |
| `of_node_obs_frpc` | FRPC 观测 |
- **ID**:沿用 snowflake uint64(DDL 用 BIGINT,与 CH UInt64 对齐);不换自增,保证迁移 ID 原样保留、无冲突。
- **时间**:PG `TIMESTAMPTZ`;SQLite `DATETIME`。
- **复合主键**:分区表主键 `(id, 时间列)`(满足 PG 分区键进唯一索引要求)。
### PG 优化
1. **分区**:仅 `of_node_access_logs`、`w_user_access_logs` 两个高频表用 PG 原生 `PARTITION BY RANGE` **按月分区**;可观测 4 表数据量小,普通表 + 索引。SQLite 无原生分区 → 普通表 + 组合索引(方言差异只留在 goose DDL,运行时 GORM 代码共用)。
2. **批量写入**:PG/SQLite 统一 GORM `CreateInBatches`(批次 500–1000);CH 维持原生 `PrepareBatch`。
3. **索引**:
- `of_node_access_logs`:`(logged_at DESC)`、`(node_id, logged_at DESC)`、`(host, logged_at DESC)`;
- `w_user_access_logs`:`(created_at DESC)`、`(user_id, created_at DESC)`;
- 可观测表:`(node_id, captured_at DESC)`。
4. **聚合查询重写**:PG 用 `date_trunc` / `count(DISTINCT)` / `FILTER (WHERE ...)` 等价替换 CH 的 `toStartOfHour` / `uniqExact` / `countIf`;SQLite 用 `strftime` / `unixepoch`。时间分桶等少量方言 SQL 拆到 `dialect_postgres.go` / `dialect_sqlite.go`,store 主体方言中立。
### goose 迁移
- PG/SQLite 各新增一组建表迁移(复活并改造 `202606190010` 旧 DDL,按 database-migration 技能双方言、同版本号规则)。
- CH 目录不动(本来就只有日志表脚本,满足「CH 保持只有日志表 SQL」)。
## 6. 清理(并入 system_cleanup)
- 日志过期清理并入 `system_cleanup`(系统垃圾清理)每日任务;`of_database_auto_cleanup` 专用 schedule(id=102)与任务下线。
- 新增 `type=business` 配置(替换旧 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`):
- `log_retention_days_postgres`(默认 90)
- `log_retention_days_sqlite`(默认 90)
- `log_retention_days_clickhouse`(默认 90)
- `CleanupStore` 按当前生效库读取对应值执行:
- PG:分区 DROP(整月)+ 分批 DELETE(不满月);
- SQLite:分批 DELETE;
- CH:`ALTER TABLE ... MODIFY TTL toDateTime(...) + INTERVAL N DAY` + materialize(保留期由配置驱动,不再依赖 DDL 写死)。
- 旧 key `database_auto_cleanup_*` 由 goose 迁移删除,前端同步清理。
## 7. 迁移任务「切换日志数据库」
**元数据**:Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,名称「切换日志数据库」,参数 `target`(`postgres`/`sqlite`/`clickhouse`),`Retryable: true`。UI 按当前日志主库只展示合法目标(当前=CH → 「主库」;当前=主库 → 「ClickHouse」)。
**执行流程(worker 进程)**:
1. **校验**:`target == 当前主库` → 拒绝;`target=clickhouse` 但 CH 未启用 / `target=postgres` 但 `database.enabled=false` / `target=sqlite` 但 `database.enabled=true` → 拒绝。
2. **写冻结**:写 `log_db_migration = "migrating"`;先让 batchwriter 把在途批次 flush 完;此后 API 进程所有日志写入路径(`risk_control`、`chwriter` 队列、agent 上报落库)检查该 key → 返回明确错误(HTTP 503「日志数据库迁移中,暂不可写」),不排队积压。
3. **复制**:6 张原始日志表逐表、按 id 分批(每批 ~1000)读源 → 写目标(CH→主库用 GORM `CreateInBatches`;主库→CH 用原生 `PrepareBatch`);ID 原样保留;每表/每批 `task.AppendLog` 进度。
- **幂等前提**:开始复制前**清空目标库日志表**(任务参数「覆盖目标库已有日志」默认开启;目标库通常为空,仅「切回去」场景有旧数据)——保证失败重试可重跑不重复。
4. **翻转**:全部成功 → 更新 `log_database = target`、清除迁移标记 → `logstore` 缓存失效自动切到新实现 → 写入恢复(走新库)。
5. **失败**:返回错误触发 Asynq 重试;**失败时清除迁移标记**,写入继续走源库(不丢功能);重试时重新清空目标 + 复制。
**双进程一致性**:迁移标记与主库标记落在 `system_configs`(Redis 缓存,worker 更新后 API 进程自动失效重读)。
## 8. API 与前端
**后端**:
- `GET /api/v1/admin/status/log-database`(改造现有 `/clickhouse` 状态端点):返回当前日志主库、迁移状态(`idle`/`migrating`)、各库保留天数、当前合法迁移目标;CH 为主时附带现有 CH 运行指标,主库为主时附带 GORM 写入器状态。
- 任务「切换日志数据库」走现有任务管理通用派发 API(`RegisterTaskMeta` + Params),无需新派发接口;执行记录/进度复用任务框架。
- 系统配置:新增 3 个 `log_retention_days_*`(business)图形化 + 参数表可见;新增内部 `log_database`、`log_db_migration`(system、visibility=0、受保护);下线 `database_auto_cleanup_*`。
**前端**:
- 任务管理页:出现「切换日志数据库」,参数下拉只显示合法目标;页面展示当前日志主库与迁移状态。
- `/admin/settings` 业务配置:新增「日志保留时间」分组(PG/SQLite/CH 三个数字输入)。
- 状态/仪表盘:日志库状态卡片(当前库 + 迁移中提示)。
## 9. 测试与验证
- **import-lint 测试**:`internal/apps/**` 不得 import `internal/repository/analytics`、`internal/infra/persistence`(`batchwriter` 白名单除外),违规即失败。
- **logstore 单测**:GORM 实现用 SQLite 全量跑;PG 专属(分区 DROP 等)走既有集成测试路径;CH 实现复用现有 analyticsrepo 测试。
- **迁移任务测试**:目标/组合校验、批处理与 ID 保留、清空目标、翻转标记、失败清标记回退、冻结期写入拒绝——用 memory/sqlite 双端模拟,不依赖真实 CH。
- **清理测试**:`system_cleanup` 日志清理步骤(PG 分区 DROP / SQLite 分批 DELETE / CH TTL 修改)与保留配置读取。
- **迁移验证**:goose 空库 Up 全量(PG/SQLite/CH 三套)、`go test ./...`、`make swagger`(API 变更)、`make code-check`、`make format`。
## 10. 非目标(YAGNI)
- 不在 PG/SQLite 物理建聚合/物化视图表(查询实时聚合)。
- 不做 PG ↔ SQLite 日志互迁(非法组合,启动校验拒绝)。
- 迁移成功不自动删除源库数据(保留,后续提供手动清理入口)。
- 不引入 PG COPY 协议(GORM `CreateInBatches` 对低流量足够)。
- 不引入自定义跨进程迁移协议(`system_configs` + Redis 缓存即可)。
## 11. 里程碑建议(供实现计划分解)
1. **M1 抽象与改造**:`logstore` 接口 + PG/SQLite 实现 + `clickhouse_store` 包装 + import-lint 测试 + repository 委托改造 + apps 直连改造 + `log_database`/`log_db_migration` key 与启动校验。
2. **M2 表与清理**:goose 双方言建表迁移 + 保留配置 key + `system_cleanup` 日志清理步骤 + 下线 `of_database_auto_cleanup` 与旧配置。
3. **M3 迁移任务与展示**:迁移任务 Handler + 状态端点 + 任务管理页/业务配置前端 + 日志库状态卡片。
4. **M4 收尾**:全量验证(goose 三套、单测、`make code-check`/`swagger`/`format`)、文档同步(中文)、changelog `[Unreleased]`。
## 12. 实现归档说明(Task 18,2026-08-08)
- 设计稿第 4 节 provider 入口写作 `Open(ctx)`,实现命名为 `Active(ctx)`(按 `log_database` 解析并缓存,配置翻转后重建),另导出 `Build(ctx, database)` / `BuildForMigration(ctx, database)` 供迁移任务构造目标库 store;`ActiveDatabase(ctx)` 供状态端点。
- 设计稿第 4 节列出的 `CleanupStore` 接口未单独落地:清理实现为包级 `CleanupExpired(ctx)`(按当前激活库保留天数删除过期日志并预建 PG 分区),由 `system_cleanup` 每日任务调用。
- 设计稿第 4 节列举的 `tasks/database_cleanup.go` 已随 M2 下线(`of_database_auto_cleanup` 配置与前端 UI 一并移除),日志清理职责并入 `system_cleanup`。
- 迁移复制按 id 升序分页,`copyObservability` 以每批最后一条 id 作为下一批游标(修正计划中 `lastID += n` 的近似写法);失败回退由 `defer setMigrationFlag("")` 保证源库恢复可写,重试前先清空目标库保证幂等。
- 其余实现决策(`SetConfigReader` 注入、`ensureWritable` 统一冻结、解析 helper 迁至 `model/analytics` 等)见计划「自检记录」,与本文档一致。
@@ -1,117 +0,0 @@
# Service Worker 离线兜底设计(issue #23)
- 日期:2026-08-08
- 状态:设计已确认
- 范围:Proxy Route(反代)+ Pages 静态托管 全覆盖
## 1. 背景与目标
当 CDN 域名被墙、浏览器对所有网络请求失败时,用户会直接流失。本功能通过给网站下发 Service Worker,缓存一个"联系站长"离线页;域名被墙后,SW 从缓存吐出该页,保留用户并引导联系站长。
核心约束:
- 平台一键批量下发,避免逐个 Agent 配置。
- 不改源页代码,全部在 OpenResty 边缘层完成。
- 覆盖反代(Proxy Route)与 Pages 静态托管两种网站类型。
## 2. 机制总览
采用「首次挑战页 + Cookie 放行 + UA 白名单」模式,替代 `sub_filter` 响应体重写。
| 环节 | 行为 |
|---|---|
| 真实浏览器 UA(含特征版本,如 `Chrome/120`)首次访问首页 | 返回 SW 挑战页(内嵌 `register('/sw.js')` 与离线页预缓存),设置长过期 Cookie |
| 带 Cookie 的请求 | 直接放行到上游,正常返回真实页面 |
| 未知 UA(爬虫、curl,无真实浏览器特征) | 直接放过,交给 WAF 处理,拿到真实内容 |
### 为什么不用 sub_filter
`sub_filter` 需处理上游 gzip / Content-Type / 大响应扫描 / 流式缓冲等多处坑。本方案不改上游 body,整体替换首次响应,以上问题全部规避;且爬虫(不匹配真实浏览器 UA)天然绕过挑战页,不伤 SEO。
## 3. 分层职责
```
apps/proxy_route ─┐
apps/pages ─┼─ model → repository → 渲染(pkg/render/openresty) → Agent(OpenResty)
前端设置卡 ─┘ ↑ SW 挑战页 + sw.js/offline 落盘
```
### 后端数据(全局 Option,与 origin error page 同模式)
`sw_offline` 相关配置作为**全局 SystemConfig / OpenRestyConfig snapshot 字段**,对所有启用 HTTPS 的路由生效,实现"一键批量下发"。新增字段:
- `sw_offline_enabled`:是否启用 SW 离线兜底
- `sw_offline_html`:联系站长离线页 HTML 内容(默认提供内置模板)
### 渲染层(`pkg/render/openresty`)
新增 `renderServiceWorkerChallenger(cfg ConfigSnapshot)` 工具,为真实提供内容的 HTTPS server 块(`sw_offline_enabled` 且 `EnableHTTPS` 时)输出:
```nginx
# SW 脚本 + 离线页(作为 support file 落盘)
location = /sw.js { alias .../sw.js; add_header Service-Worker-Allowed /; }
location = /offline.html { alias .../offline.html; }
# 仅首页拦截:真实浏览器 UA 且无 cookie → 返回 SW 挑战页
# 否则(带 cookie / 未知 UA)→ 放行到上游
location = / {
if (真实浏览器UA && 无cookie) { content_by_lua 返回 SW 挑战页; }
放行到上游;
}
```
- SW 逻辑:`install` 阶段缓存 `/offline.html`;`fetch` 事件在网络失败时返回 `caches.match('/offline.html')`。
- 仅在 `EnableHTTPS` 时注入(SW 要求 HTTPS 安全上下文)。
- 多域名 server 块:`/sw.js`、`/offline.html`、挑战页在各 `server_name` 下同源可达。
- 仅对首页 `location = /` 触发;js/css/图片/API/子页面请求不拦,零额外开销。
## 4. 数据流
```
用户首次访问首页(真实UA, 无cookie)
→ OpenResty 判断:真实UA && 无cookie
→ 返回 SW 挑战页 (内嵌 register + 预缓存 offline.html)
→ 浏览器执行 → 注册 SW → 设置长过期 cookie
→ 用户再次请求(带cookie)
→ 放行到上游,正常返回真实页面
域名被墙后
→ 所有请求失败 → SW fetch 兜底 → 从缓存返回 /offline.html(联系页)
```
## 5. 边界与风险
| 项 | 处理 |
|---|---|
| 首次即被墙的用户 | SW 未注册,兜底无效(所有 SW 方案共性,接受) |
| HTTP-only 站点 | 跳过注入(SW 需 HTTPS) |
| 反代多域名 | 各域名同源提供 sw.js / offline.html / 挑战页 |
| Cookie 过期 | 设长过期(约 1 年),过期后重新走一次挑战页 |
| 未知 UA | 放过并交给 WAF 处理,不重复拦截 |
| 资源/API 请求 | 不拦,仅首页触发 |
## 6. 测试
- 渲染层单元测试:
- `sw_offline_enabled` 时输出 sw.js / offline.html / 挑战页 location
- 非 HTTPS 或未启用时不输出
- 仅首页触发,子路径/资源不触发
- UA 判定:真实浏览器 / 爬虫 / curl 三种 UA 的放行分支。
- Cookie 有无的放行分支。
- 现有 config snapshot checksum / rebind 测试不回归。
## 7. 前端命名与入口
离线联系页设置与现有 origin error page 设置合并为同一个功能模块,命名为**「响应页面」**(路由 `responses`),内含两个 tab:
- **错误页设置**:源站错误兜底页(现有 origin error page)
- **联系页设置**:SW 离线兜底联系页(本功能)
两者同属「边缘层兜底展示页」语义,统一管理与入口。
## 8. 待实现确认项(写 plan 时细化)
- SW 挑战页与 sw.js 的具体 Lua 实现与落盘路径(对齐现有 support file 机制)。
- `sw_offline_html` 默认内置模板样式(参考 origin error page 内置模板)。
- 「响应页面」前端模块下错误页/联系页两个 tab 的具体位置与交互。
- UA 白名单默认真实浏览器特征集合(Chrome / Firefox / Safari / Edge + 版本号正则)。
- SW 落盘路径:sw.js / offline.html 通过 SupportFile 下发,Agent 替换占位符(类似 ErrorPageTmplPlaceholder 机制)。
@@ -1,191 +0,0 @@
# SW 离线兜底生效范围(域名作用域)设计
- 日期:2026-08-08
- 状态:设计已确认
- 前置:issue #23 Service Worker 离线兜底(`docs/superpowers/specs/2026-08-08-service-worker-offline-design.md`)
- 范围:SW 注入从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」
## 1. 背景与目标
issue #23 实现后,`sw_offline_enabled` 为全局布尔开关:开启后对所有启用 HTTPS 的路由注入 Service Worker 离线兜底。本需求将其细化为可选的**生效域名范围**:
- 保留总开关(`sw_offline_enabled`)。
- 新增作用域:管理员选择需要生效的域名,仅作用域内域名注入 SW。
- 域名选择交互参考 `/cloudflare/groups/1` 的「添加域名成员」弹窗(搜索筛选、按 Zone 分组、批量勾选),但**与 Cloudflare 完全解耦**——仅复用交互模式,数据源为平台自身 zones/zone_domains,不涉及 A 记录同步。
核心约束:
- 语义为「总开关 && 域名 ∈ 作用域」交集:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
- 与 Cloudflare 指向分组(A 记录)无任何关联。
- 联系页 HTML(`sw_offline_html`)仍为全局单份,不分域名定制。
## 2. 机制总览
```
sw_offline_enabled (bool, 已有) 总开关
sw_offline_html (string, 已有) 联系页 HTML(全局一份)
sw_offline_domains (JSON 字符串数组, 新增) 生效域名作用域
渲染: routeSWEnabled(routeDomains, cfg)
= SWOfflineEnabled && routeDomains ∩ SWOfflineDomains ≠ ∅
命中 → HTTPS server 块注入 access 检查 + SW location
未命中 → 与 feature 前字节一致
```
Support files(`sw/sw.js`、`sw/offline.html`)仅在「总开关开 && 作用域非空」时下发,避免空作用域产生无用资源。
## 3. 数据层
### 3.1 配置 key
`model.ConfigKeySWOfflineDomains = "sw_offline_domains"`(business 类型,visibility 0),值存 JSON 域名字符串数组:
```json
["example.com", "api.example.com"]
```
### 3.2 goose 迁移(postgres + sqlite 各一份)
`INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING;`
Down 删除该 key。migrator 测试计数 92 → 93,并更新注释。
### 3.3 validator
`validateSWOfflineDomains(key, value string) error`,注册进 `openRestyOptionValidators`:
- JSON 解析为 `[]string`,失败报「必须为 JSON 字符串数组」
- 元素去重(重复报错)
- 元素非空、小写规范化校验(复用/对齐 zone `normalizeDomain` 的域名格式约束:无 `*`、无 `://` `/` `?` `#` `@`、`publicsuffix.EffectiveTLDPlusOne` 可解析)
- 数量上限 `maxSWOfflineDomains = 1000`(防滥用)
### 3.4 config_version snapshot
- `openRestyConfigSnapshot`(`snapshot.go`)新增 `SWOfflineDomains []string json:"sw_offline_domains,omitempty"`。
- `buildOpenRestyConfigSnapshot` 新增 `getStringSliceConfig(key string, defaultVal []string) []string`(解析 JSON 数组,失败回退默认),赋值 `SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil)`。
- `logics.go`:`diffOpenRestyOptionDetails` 追加 `appendIfChanged("SWOfflineDomains", ...)`;`openRestyOptionKeys()` 追加 `"SWOfflineDomains"`。
## 4. 渲染层(pkg/render/openresty)
### 4.1 ConfigSnapshot
`types.go` 的 `ConfigSnapshot` 新增:
```go
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
```
### 4.2 作用域判断
```go
// routeSWEnabled returns true when SW offline fallback applies to this route.
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
return false
}
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
for _, d := range cfg.SWOfflineDomains {
scope[d] = struct{}{}
}
for _, d := range routeDomains {
if _, ok := scope[d]; ok {
return true
}
}
return false
}
```
域名精确匹配(存储时已小写规范化)。
### 4.3 server 渲染签名扩展
- `RenderRouteConfig`:每 route 计算 `swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)`,传入 `renderProxyRoute` / `renderPagesRoute`(新增 `swEnabled bool` 参数)。
- 下传链路:`renderProxyRouteHTTPS` / `renderPagesRouteHTTPS` / `renderHTTPSServer` / `renderHTTPSPagesServer` 均新增 `swEnabled bool` 参数。
- `swEnabled=true` → `renderAccessBlockWithSW(siteName, powEnabled, cfg)` + 追加 `renderServiceWorkerChallenger(cfg)`(现行为,两函数内部不再判断 `SWOfflineEnabled`,条件已上移到 route 层)。
- `swEnabled=false` → 纯 `renderAccessBlock`,无 challenger(与 feature 前字节一致)。
- HTTP(80)server 块保持不注入(issue #23 已定 HTTPS-only)。
- `renderAccessBlockWithSW` / `renderServiceWorkerChallenger` 保留 `cfg` 参数(HTML 内容来自 `cfg.SWOfflineHTML`),仅移除其内部开关判断。
### 4.4 Support files
`Render` 中生成条件从 `if doc.OpenRestyConfig.SWOfflineEnabled` 改为:
```go
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
}
```
### 4.5 测试
- `routeSWEnabled`:开关关 / 作用域空 / 无交集 / 单域名交集 / 多域名部分交集。
- HTTPS server 渲染:命中 → 含 `require("sw.runtime").check()` + 三个 SW location;未命中 → 与旧输出字节一致。
- `Render`:空作用域不下发 `sw/*` support files。
- 现有 `TestRenderAccessBlockWithSWMergesSingleBlock` 等适配新签名(`cfg` 语义变化:禁用时不再由内部判断,改由上层传 `swEnabled`)。
## 5. 前端(frontend/app/(main)/responses)
### 5.1 联系页 tab 布局
联系页 tab 两张卡片:
**卡片 1:离线兜底(总开关)**
- 标题「离线兜底」+ 描述。
- 右上角「保存」按钮。
- 「启用 Service Worker 离线兜底」Switch(`sw_offline_enabled`)。
- 「生效范围」区块:当前已选域名 badge 列表(可移除)+「添加域名」按钮打开弹窗;开关关闭时整卡禁用/置灰。
- 保存时 `updateBatch` 一次性提交三个 key:
```ts
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
{ key: KEY_SW_HTML, value: fields.html },
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
```
- 保存成功后 `invalidateResponseQueries`(toast 提示「请前往版本发布使配置生效」不变)。
**卡片 2:联系页 HTML**
- 复用 `HtmlEditorWorkspace`(见 5.3),无占位符,实时预览原样 HTML。
### 5.2 域名选择弹窗(scope-domain-dialog.tsx)
- 交互复用 `member-add-dialog.tsx`:搜索框(域名/zone 模糊匹配)、按 Zone 分组折叠、组内勾选/取消、全选可见/清空、已选计数。
- 无橙云开关、无 Cloudflare 依赖。
- 数据源:`ZoneService.list()` + 每 zone `ZoneService.getOverview(id)` 并行拉取(`Promise.all`),zone 根域并入对应分组。**不新增后端 API**。
- 弹窗预勾选当前已生效域名;确认后返回选中的域名字符串数组(覆盖式替换本地 fields.domains)。
- 空态:无 zone 时提示「暂无可用域名,请先在 Zone 管理中注册」。
### 5.3 HtmlEditorWorkspace 复用(泛化)
`frontend/app/(main)/error-pages/components/html-editor-workspace.tsx` 泛化并移至 `frontend/components/common/html-editor-workspace.tsx`:
- Props 扩展:
- `maxBytes?: number`(默认 `ORIGIN_ERROR_PAGE_HTML_MAX_BYTES` = 256 KiB,SW 同为 256 KiB 常量可共用)
- `preview?: (html: string) => string`(默认 `previewOriginErrorPageHTML`;SW 传 `(html) => html` 原样预览)
- `footerHint?: React.ReactNode`(预览 footer 提示文案,默认错误页的「`{{status}}`→502 · `{{host}}`→example.com」;SW 传 `null`)
- 错误页 `edit/page.tsx` 改 import 路径,行为不变。
- `frontend/components/common/` 若不存在则创建目录。
### 5.4 shared.ts 与表单
- `KEY_SW_DOMAINS = 'sw_offline_domains'`。
- `ContactPageFields` 增加 `domains: string[]`;`defaultContactPageFields.domains = []`。
- `mapOptionsToContactFields` 解析 `sw_offline_domains` JSON(容错:非法 JSON → `[]`)。
## 6. 验证
- 后端:`go test ./pkg/render/openresty/... ./internal/apps/openflare/option/... ./internal/apps/openflare/config_version/... ./internal/infra/persistence/migrator/...`
- 前端:`pnpm tsc --noEmit` + `eslint`(联系页新字段/弹窗/多 zone 并行拉取)
- 全量:`go test ./...`、`make code-check`、`make format`
- `make swagger`:无新 API(验证无变更即可)
## 7. Changelog
`docs/changelog/index.md` `[Unreleased]` 更新 SW 条目:新增「可指定生效域名范围(仅对选中的 HTTPS 域名生效)」。
## 8. 已知边界
- 作用域存域名字符串数组:域名从 zone/zone_domain 改名后需手动同步作用域(与 `route.Domains` 精确匹配)。
- 联系页 HTML 全局单份,不分域名定制。
- 空作用域 + 总开关开 → 不注入(前端置灰提示先选域名)。
- 匹配为精确匹配,不跨子域通配(选 `example.com` 不自动覆盖 `api.example.com`,需显式加入)。