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