Files
OpenFlare/docs/superpowers/plans/2026-07-19-http-default-rate-limit.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

23 KiB
Raw Blame History

边缘限流全局默认 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

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 末尾追加:

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: 跑测确认失败
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1

Expected: FAIL(mergeRouteLimitConfig undefined 或行为不符)

  • Step 3: 实现 types + merge + 调用

ConfigSnapshot 增加:

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 将:

limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}

改为:

limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)

并新增:

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: 跑测通过
go test ./pkg/render/openresty/ -count=1

Expected: PASS

  • Step 5: Commit
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 前或后)加入:

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 同内容)
-- +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 增加:

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 注册:

model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitConnPerIP:     validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate:          validateOpenRestyDefaultLimitRate,
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 时偶然正确,但语义不清)。改为:

getNonNegIntConfig := func(key string, defaultVal int) int {
	val, err := repository.GetIntByKey(ctx, key)
	if err != nil || val < 0 {
		return defaultVal
	}
	return val
}
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 对齐:

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 末尾:

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: 编译/相关测试
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1

Expected: PASS

  • Step 7: Commit
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: 失败单测

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: 实现
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: 测试
go test ./internal/apps/openflare/proxy_route/ -count=1
  • Step 4: Commit
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:

{ title: 'WAF', url: '/waf' },
{ title: 'IP 组', url: '/ip-groups' },
{ title: '限流', url: '/rate-limits' },
  • Step 2: 搜索

search-data.ts 在 IP 组后增加:

{
  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

页面骨架要点(完整实现时展开为完整组件,勿留半成品):

// 字段 state、OptionService.list map、updateBatch([{key,value},...])
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
  • Step 4: 站点 limits-section
  1. schema:conn 允许空、0、-1、正整数:
if (!rawValue) continue;
if (!/^-1$|^\d+$/.test(rawValue)) {
  context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
}
  1. validateLimitRate / normalizeLimitRate:
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
}
  1. 表单展示:-1 需显示为 '-1'(注意 route.limit_conn_per_server ? String : '' 对 -1 已为 truthy;对 0 仍为空)

  2. 提交:空 → 0;-1 → -1;正数 → 数字

  3. 文案:

description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
FormDescription 同步说明
  1. 侧栏「流量限制」section description 可改为:设置连接数和限速(可继承全局默认)。
  • Step 5: prettier + 类型检查(按项目习惯)
make prettier
# 若有前端 typecheck:
# cd frontend && pnpm exec tsc --noEmit
  • Step 6: Commit
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 之间插入:

| `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] 下:

### 新增

- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。

### 改进

- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
  • Step 3: plan index

docs/plan/index.md 进行中列表增加:

* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
  • Step 4: 全量门禁
make code-check
make prettier

Expected: 通过;修复任何报错后再提交。

  • Step 5: Commit
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 而非全局值