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

640 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 全部通过。