diff --git a/docs/plan/20260719-http-default-rate-limit.md b/docs/plan/20260719-http-default-rate-limit.md new file mode 100644 index 00000000..9140ef87 --- /dev/null +++ b/docs/plan/20260719-http-default-rate-limit.md @@ -0,0 +1,709 @@ +# 边缘限流全局默认 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/db/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/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql` +- Create: `internal/db/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/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \ + internal/db/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \ + internal/apps/openflare/option/validate.go \ + internal/apps/openflare/option/openresty_validators.go \ + internal/apps/openflare/config_version/snapshot.go \ + internal/apps/openflare/config_version/logics.go +git commit -m "feat(config): add openresty default rate limit system options" +``` + +--- + +### Task 3: 站点 normalize 允许 -1 + +**Files:** +- Modify: `internal/apps/openflare/proxy_route/helpers.go` +- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案) +- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go` + +**Interfaces:** +- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"` + +- [ ] **Step 1: 失败单测** + +```go +func TestNormalizeProxyRouteLimitConnValue(t *testing.T) { + t.Parallel() + got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server") + if err != nil || got != -1 { + t.Fatalf("want -1, got %d err %v", got, err) + } + if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil { + t.Fatal("expected error for -2") + } +} + +func TestNormalizeProxyRouteLimitRate(t *testing.T) { + t.Parallel() + got, err := normalizeProxyRouteLimitRate("-1") + if err != nil || got != "-1" { + t.Fatalf("want -1, got %q err %v", got, err) + } + got, err = normalizeProxyRouteLimitRate("0") + if err != nil || got != "" { + t.Fatalf("want empty inherit, got %q err %v", got, err) + } +} +``` + +- [ ] **Step 2: 实现** + +```go +func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) { + if value < -1 { + return 0, fmt.Errorf("%s must be greater than or equal to -1", field) + } + return value, nil +} + +func normalizeProxyRouteLimitRate(raw string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" || normalized == "0" { + return "", nil + } + if normalized == "-1" { + return "-1", nil + } + if !proxyRouteLimitRatePattern.MatchString(normalized) { + return "", errors.New(errProxyRouteLimitRate) + } + if strings.TrimRight(normalized, "km") == "" { + return "", nil + } + return normalized, nil +} +``` + +可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。 + +- [ ] **Step 3: 测试** + +```bash +go test ./internal/apps/openflare/proxy_route/ -count=1 +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/apps/openflare/proxy_route/ +git commit -m "feat(proxy-route): allow -1 to disable rate limits" +``` + +--- + +### Task 4: 前端 — 安全性「限流」页 + 站点文案 + +**Files:** +- Modify: `frontend/lib/navigation/openflare-nav.ts` +- Create: `frontend/app/(main)/rate-limits/page.tsx` +- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` +- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts` +- Modify: `frontend/lib/utils/search-data.ts` + +**Interfaces:** +- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等 +- Produces: `/rate-limits` 管理页;站点表单接受 `-1` + +- [ ] **Step 1: 导航** + +`openflareSecurityNavGroup.items`: + +```ts +{ title: 'WAF', url: '/waf' }, +{ title: 'IP 组', url: '/ip-groups' }, +{ title: '限流', url: '/rate-limits' }, +``` + +- [ ] **Step 2: 搜索** + +`search-data.ts` 在 IP 组后增加: + +```ts +{ + id: 'console-rate-limits', + title: '限流', + description: '配置边缘站点默认并发与带宽限流策略', + url: '/rate-limits', + category: 'page', + keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'], +}, +``` + +- [ ] **Step 3: 限流设置页** + +新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`: + +- `useAuth` 管理员校验 +- `OptionService.list` / `updateBatch` +- 三字段表单 + 单卡片保存 +- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」 +- 描述:空/0 表示默认关闭;修改后需在版本发布中生效 +- keys: + - `openresty_default_limit_conn_per_server` + - `openresty_default_limit_conn_per_ip` + - `openresty_default_limit_rate` +- conn:非负整数;rate:空或 `^\d+[kKmM]?$` +- 保存成功 toast + invalidate options / config-preview / config-versions +- 链到 `/config-versions` + +页面骨架要点(完整实现时展开为完整组件,勿留半成品): + +```tsx +// 字段 state、OptionService.list map、updateBatch([{key,value},...]) +// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」 +``` + +- [ ] **Step 4: 站点 limits-section** + +1. schema:conn 允许空、`0`、`-1`、正整数: + +```ts +if (!rawValue) continue; +if (!/^-1$|^\d+$/.test(rawValue)) { + context.addIssue({ ..., message: '请输入 -1、0 或正整数' }); +} +``` + +2. `validateLimitRate` / `normalizeLimitRate`: + +```ts +export function validateLimitRate(value: string) { + const normalized = value.trim(); + if (!normalized || normalized === '0' || normalized === '-1') { + return null; + } + if (!limitRatePattern.test(normalized)) { + return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭'; + } + return null; +} + +export function normalizeLimitRate(value: string) { + const normalized = value.trim().toLowerCase(); + if (normalized === '0') return ''; + return normalized; // 保留 -1 +} +``` + +3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空) + +4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字 + +5. 文案: + +``` +description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。' +FormDescription 同步说明 +``` + +6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。` + +- [ ] **Step 5: prettier + 类型检查(按项目习惯)** + +```bash +make prettier +# 若有前端 typecheck: +# cd frontend && pnpm exec tsc --noEmit +``` + +- [ ] **Step 6: Commit** + +```bash +git add frontend/lib/navigation/openflare-nav.ts \ + frontend/app/\(main\)/rate-limits/ \ + frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \ + frontend/app/\(main\)/proxy-routes/components/helpers.ts \ + frontend/lib/utils/search-data.ts +git commit -m "feat(frontend): add security rate-limits page and inherit UI" +``` + +--- + +### Task 5: 文档、索引、门禁 + +**Files:** +- Modify: `docs/reference/configuration.md`(OpenResty 配置表) +- Modify: `docs/changelog/index.md` `[unreleased]` +- Modify: `docs/plan/index.md` + +- [ ] **Step 1: configuration.md** + +在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入: + +```md +| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` | +| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` | +| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 | +``` + +- [ ] **Step 2: changelog** + +`[unreleased]` 下: + +```md +### 新增 + +- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。 + +### 改进 + +- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。 +``` + +- [ ] **Step 3: plan index** + +`docs/plan/index.md` 进行中列表增加: + +```md +* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。 +``` + +- [ ] **Step 4: 全量门禁** + +```bash +make code-check +make prettier +``` + +Expected: 通过;修复任何报错后再提交。 + +- [ ] **Step 5: Commit** + +```bash +git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md +git commit -m "docs: document default edge rate limits" +``` + +--- + +## Spec coverage checklist + +| Spec 要求 | Task | +|-----------|------| +| 三项全局默认 | 2, 4 | +| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 | +| 仅 `RenderRouteConfig` 合并 | 1 | +| 快照保留原始站点值 | 2(不写回 route) | +| 安全性子页「限流」 | 4 | +| 初始 0/空兼容 | 2 seed | +| option diff / 发布 | 2 | +| 测试合并/normalize | 1, 3 | +| 中文文档/changelog | 5 | +| 非目标 limit_req / http 级指令 | 未做 | + +## 手动验收 + +1. 迁移后三键存在且为 `0`/空 +2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存 +3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate` +4. 站点将该项改为 `-1` 保存并发布:该维度指令消失 +5. 站点改为 `50`:输出 50 而非全局值 diff --git a/docs/plan/index.md b/docs/plan/index.md index 974203a5..37138a4c 100644 --- a/docs/plan/index.md +++ b/docs/plan/index.md @@ -19,6 +19,7 @@ * [访问日志 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 合并。 ## 使用建议 diff --git a/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md b/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md new file mode 100644 index 00000000..9140ef87 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-http-default-rate-limit.md @@ -0,0 +1,709 @@ +# 边缘限流全局默认 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/db/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/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql` +- Create: `internal/db/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/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \ + internal/db/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 而非全局值