docs(plan): add origin error page implementation plan

拆分为状态码解析、OpenResty 渲染、Option/快照、前端设置页与文档验收五步任务。
This commit is contained in:
ryan
2026-08-06 13:44:45 +08:00
parent fd62570431
commit 1f5bebd18a
3 changed files with 935 additions and 0 deletions
+467
View File
@@ -0,0 +1,467 @@
# 源站错误页 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。
**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。
**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService
**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md)
## Global Constraints
- 仅**反代**路由应用;**Pages** 路由不生成错误页指令
- HTTP **status 保持原错误码**,禁止统一改为 200
- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]`
- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB**
- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效
- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档
- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航
## File map
| 文件 | 职责 |
|------|------|
| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 |
| `pkg/render/openresty/status_codes_test.go` | 解析单测 |
| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 |
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server |
| `pkg/render/openresty/render_test.go` | 集成渲染断言 |
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed |
| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html |
| `internal/apps/openflare/option` 相关 test | 校验失败用例 |
| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 |
| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 |
| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) |
| `frontend/app/(main)/error-pages/page.tsx` | 设置页 |
| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 |
| `frontend/lib/utils/search-data.ts` | 搜索 |
| `docs/reference/configuration.md` | 配置键说明(中文) |
| `docs/changelog/index.md` | Unreleased |
| `docs/plan/index.md` | 进行中索引 |
---
### Task 1: 状态码标签解析(纯函数 TDD)
**Files:**
- Create: `pkg/render/openresty/status_codes.go`
- Create: `pkg/render/openresty/status_codes_test.go`
**Interfaces:**
- Produces:
- `func ExpandStatusCodeTags(tags []string) (codes []int, err error)`
- `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi`
- 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599`
- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝)
- [ ] **Step 1: 写失败单测**
```go
func TestExpandStatusCodeTags(t *testing.T) {
t.Parallel()
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
if err != nil {
t.Fatal(err)
}
// want sorted unique: 500,501,502,522
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
t.Fatalf("got %v", codes)
}
_, err = ExpandStatusCodeTags([]string{"399"})
if err == nil {
t.Fatal("expected error")
}
_, err = ExpandStatusCodeTags([]string{"503-500"})
if err == nil {
t.Fatal("expected reverse range error")
}
_, err = ExpandStatusCodeTags([]string{"5xx"})
if err == nil {
t.Fatal("expected syntax error")
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
Expected: FAIL(函数未定义)
- [ ] **Step 3: 实现 `status_codes.go`**
```go
package openresty
import (
"fmt"
"sort"
"strconv"
"strings"
)
const (
StatusCodeMin = 400
StatusCodeMax = 599
)
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return 0, 0, fmt.Errorf("状态码标签不能为空")
}
if i := strings.IndexByte(tag, '-'); i >= 0 {
lo, err = strconv.Atoi(tag[:i])
if err != nil {
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
}
hi, err = strconv.Atoi(tag[i+1:])
if err != nil {
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
}
} else {
lo, err = strconv.Atoi(tag)
if err != nil {
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
}
hi = lo
}
if lo > hi {
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
}
if lo < StatusCodeMin || hi > StatusCodeMax {
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
}
return lo, hi, nil
}
func ExpandStatusCodeTags(tags []string) ([]int, error) {
set := map[int]struct{}{}
for _, tag := range tags {
lo, hi, err := ParseStatusCodeTag(tag)
if err != nil {
return nil, err
}
for c := lo; c <= hi; c++ {
set[c] = struct{}{}
}
}
out := make([]int, 0, len(set))
for c := range set {
out = append(out, c)
}
sort.Ints(out)
return out, nil
}
```
- [ ] **Step 4: 运行确认通过**
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
git commit -m "feat(openresty): add status code tag expand helper"
```
---
### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
**Files:**
- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加:
- `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\``
- `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\``
- `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\``
- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile`
- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile
- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location
- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go`
**Interfaces:**
- Produces:
- `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"`
- `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}`
- `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认
- `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串
- SupportFile content = Effective HTML(保留占位符)
**Internal location 形状(必须 status 透传):**
```nginx
location = /__openflare_origin_error {
internal;
default_type text/html;
charset utf-8;
content_by_lua_block {
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
if not f then
ngx.status = ngx.status
ngx.say("Error ", ngx.status)
return
end
local body = f:read("*a")
f:close()
local status = tostring(ngx.status)
local host = ngx.var.host or ""
body = body:gsub("{{status}}", status, 1)
body = body:gsub("{{host}}", host, 1)
-- 全局替换剩余占位(若模板多处 status)
body = body:gsub("{{status}}", status)
body = body:gsub("{{host}}", host)
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.say(body)
}
}
```
占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。
在每个反代 `location /` 内(proxy 块):
```nginx
proxy_intercept_errors on;
```
在 server 块内(location 外):
```nginx
error_page 500 501 ... = /__openflare_origin_error;
```
+ internal location。
Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。
- [ ] **Step 1: 写失败单测**
```go
func TestRenderOriginErrorPageEnabled(t *testing.T) {
t.Parallel()
doc := Document{
Routes: []Route{{
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
OriginURL: "http://127.0.0.1:9", Enabled: true,
}},
OpenRestyConfig: ConfigSnapshot{
OriginErrorPageEnabled: true,
OriginErrorPageStatusCodes: []string{"500-599"},
},
}
out, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "proxy_intercept_errors on") {
t.Fatal("missing intercept")
}
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
t.Fatal("missing error_page")
}
if !strings.Contains(out, "{{status}}") == false {
// SupportFile 在 Render 全量结果中
}
res, err := Render(doc, nil)
if err != nil {
t.Fatal(err)
}
found := false
for _, f := range res.SupportFiles {
if f.Path == OriginErrorPageSupportPath {
found = true
if !strings.Contains(f.Content, "{{status}}") {
t.Fatal("template missing placeholder")
}
}
}
if !found {
t.Fatal("missing support file")
}
}
func TestRenderOriginErrorPageDisabled(t *testing.T) {
t.Parallel()
doc := Document{
Routes: []Route{{
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
OriginURL: "http://127.0.0.1:9", Enabled: true,
}},
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
}
out, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "proxy_intercept_errors") {
t.Fatal("should not intercept when disabled")
}
}
```
- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS
默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
- [ ] **Step 3: Agent placeholder 替换**
搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。
若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat(openresty): render origin error page directives"
```
---
### Task 3: Option keys、迁移、校验、快照
**Files:**
- Modify: `internal/model/system_configs.go` — 常量:
- `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"`
- `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"`
- `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"`
- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`:
```sql
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'origin_error_page_enabled',
'origin_error_page_status_codes',
'origin_error_page_html'
);
```
(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。)
- Modify: `openresty_validators.go`:
- enabled → `validateBooleanOption`
- status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024`
- html → `len(value) <= 256<<10`
- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`:
- Enabled: `getBoolConfig(..., true)`
- StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}`
- HTML: `getStringConfig(..., "")`
- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示)
- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]`
- [ ] **Step 2: 实现迁移与 snapshot 填充**
- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段**
- [ ] **Step 4: Commit**
```bash
git commit -m "feat(option): seed and validate origin error page options"
```
---
### Task 4: 前端 Tags Input + `/error-pages` 页
**Files:**
- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`)
- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行)
- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步
- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page
- Reuse: `OptionService.list` / `updateBatch`(同 performance 页)
**UI 行为:**
1. 管理员加载 options → 映射三字段
2. Switch 启用
3. TagsInput:默认展示解析后的 JSON 数组
4. Textarea HTML;按钮「加载默认模板」「恢复默认」
5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc`
6. 保存:`updateBatch` 三条;toast 提示去版本发布
7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误)
默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用**
- [ ] **Step 2: 实现 page.tsx**
- [ ] **Step 3: 导航与搜索**
- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示)
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(frontend): add origin error page settings under websites"
```
---
### Task 5: 文档、changelog、收尾门禁
**Files:**
- Modify: `docs/reference/configuration.md` — 三 key 说明
- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文
- Modify: `docs/plan/index.md` — 本计划列入进行中
- [ ] **Step 1: 写配置说明与 changelog**
示例 changelog:
```markdown
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
```
- [ ] **Step 2: `make code-check` 与前端 format**
- [ ] **Step 3: 手动验收清单(对照设计 §7.2)**
1. 默认启用 + 源站宕机 → 错误页 + 真实 status
2. 源站 503 → 替换
3. 仅 `522` → 其它 5xx 透传
4. 关闭并发布 → 透传
5. 自定义 `{{status}}`/`{{host}}`
6. Pages 不受影响
- [ ] **Step 4: Final commit**
```bash
git commit -m "docs: origin error page configuration and changelog"
```
---
## Spec coverage checklist
| Spec 要求 | Task |
|-----------|------|
| 全局开关 | 3, 4 |
| 默认 `500-599` / 单码+区间 | 1, 3, 4 |
| 状态码透传 + `{{status}}` | 2 |
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
| Option + 配置版本 | 3 |
| 仅反代 | 2 |
| 侧栏网站管理 | 4 |
| Tags Input | 4 |
| 256KB 限制 | 3 |
| 测试与 changelog | 1–5 |
## Placeholder scan
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。
+1
View File
@@ -13,6 +13,7 @@
当前进行中的开发计划:
* [源站错误页](./20260806-origin-error-page.md):全局可配置错误页(默认 500-599、CF 风格 HTML、状态码透传);Option + 配置版本 + OpenResty error_page。
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
@@ -0,0 +1,467 @@
# 源站错误页 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 全局可配置源站/网关错误页:默认标签 `500-599`、Cloudflare 风格 HTML、可在线自定义;HTTP 状态码保持原值并在页面展示 `{{status}}`/`{{host}}`;可关闭恢复透传。
**Architecture:** 三个 Option key 持久化 → 配置版本 `openresty_config` 快照 → `pkg/render/openresty` 对反代 server 生成 `proxy_intercept_errors` + `error_page` + internal location;模板 SupportFile + 轻量 `content_by_lua_block` 替换占位符。管理端 `/error-pages` 挂在侧栏「网站管理」。
**Tech Stack:** Go、goose、Option API、`pkg/render/openresty`、OpenResty/Lua、Next.js、Tags Input、OptionService
**Spec:** [docs/design/origin-error-page.md](../../design/origin-error-page.md) · [docs/superpowers/specs/2026-08-06-origin-error-page-design.md](../specs/2026-08-06-origin-error-page-design.md)
## Global Constraints
- 仅**反代**路由应用;**Pages** 路由不生成错误页指令
- HTTP **status 保持原错误码**,禁止统一改为 200
- 状态码标签:单码 `522` 或闭区间 `500-599`;范围 **400–599**;默认 `["500-599"]`
- 占位符:`{{status}}`、`{{host}}`;HTML 空 = 内置默认;最大 **256 KiB**
- 保存走 Option `update-batch`;**需配置版本发布**后边缘生效
- 完成后 `make code-check`;前端相关 `make format` / prettier;中文 changelog;不写英文文档
- 不新增独立业务路由注册(复用 `/api/v1/d/option`);侧栏仅前端导航
## File map
| 文件 | 职责 |
|------|------|
| `pkg/render/openresty/status_codes.go` | 标签解析/展开纯函数 |
| `pkg/render/openresty/status_codes_test.go` | 解析单测 |
| `pkg/render/openresty/origin_error_page.go` | 默认 HTML、渲染 error_page 片段、SupportFile 路径常量 |
| `pkg/render/openresty/origin_error_page_test.go` | 渲染片段单测 |
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
| `pkg/render/openresty/render.go` / `render_route.go` | 接入 error 块到反代 server |
| `pkg/render/openresty/render_test.go` | 集成渲染断言 |
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
| `internal/infra/persistence/migrator/goose/{postgres,sqlite}/202608060001_add_origin_error_page_options.sql` | seed |
| `internal/apps/openflare/option/openresty_validators.go` | 校验 enabled / codes / html |
| `internal/apps/openflare/option` 相关 test | 校验失败用例 |
| `internal/apps/openflare/config_version/snapshot.go` | 快照读写字段 |
| `internal/apps/openflare/config_version/logics.go` | `diffOpenRestyOptionDetails` 含新字段 |
| `frontend/components/ui/tags-input.tsx` | shadcn-extension Tags Input(若缺失则添加) |
| `frontend/app/(main)/error-pages/page.tsx` | 设置页 |
| `frontend/lib/navigation/openflare-nav.ts` | 网站管理菜单 |
| `frontend/lib/utils/search-data.ts` | 搜索 |
| `docs/reference/configuration.md` | 配置键说明(中文) |
| `docs/changelog/index.md` | Unreleased |
| `docs/plan/index.md` | 进行中索引 |
---
### Task 1: 状态码标签解析(纯函数 TDD)
**Files:**
- Create: `pkg/render/openresty/status_codes.go`
- Create: `pkg/render/openresty/status_codes_test.go`
**Interfaces:**
- Produces:
- `func ExpandStatusCodeTags(tags []string) (codes []int, err error)`
- `func ParseStatusCodeTag(tag string) (lo, hi int, err error)` — 单码时 `lo==hi`
- 常量:`StatusCodeMin = 400`, `StatusCodeMax = 599`
- 规则:trim;`^\d{3}$` 或 `^\d{3}-\d{3}$`;`lo<=hi`;均在 400–599;展开 inclusive;排序去重;空 tags → 空 slice + nil error(「启用且空」由校验层拒绝)
- [ ] **Step 1: 写失败单测**
```go
func TestExpandStatusCodeTags(t *testing.T) {
t.Parallel()
codes, err := ExpandStatusCodeTags([]string{"500-502", "522", "501"})
if err != nil {
t.Fatal(err)
}
// want sorted unique: 500,501,502,522
if len(codes) != 4 || codes[0] != 500 || codes[3] != 522 {
t.Fatalf("got %v", codes)
}
_, err = ExpandStatusCodeTags([]string{"399"})
if err == nil {
t.Fatal("expected error")
}
_, err = ExpandStatusCodeTags([]string{"503-500"})
if err == nil {
t.Fatal("expected reverse range error")
}
_, err = ExpandStatusCodeTags([]string{"5xx"})
if err == nil {
t.Fatal("expected syntax error")
}
}
```
- [ ] **Step 2: 运行确认失败**
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
Expected: FAIL(函数未定义)
- [ ] **Step 3: 实现 `status_codes.go`**
```go
package openresty
import (
"fmt"
"sort"
"strconv"
"strings"
)
const (
StatusCodeMin = 400
StatusCodeMax = 599
)
func ParseStatusCodeTag(tag string) (lo, hi int, err error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return 0, 0, fmt.Errorf("状态码标签不能为空")
}
if i := strings.IndexByte(tag, '-'); i >= 0 {
lo, err = strconv.Atoi(tag[:i])
if err != nil {
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
}
hi, err = strconv.Atoi(tag[i+1:])
if err != nil {
return 0, 0, fmt.Errorf("无效状态码区间: %s", tag)
}
} else {
lo, err = strconv.Atoi(tag)
if err != nil {
return 0, 0, fmt.Errorf("无效状态码: %s", tag)
}
hi = lo
}
if lo > hi {
return 0, 0, fmt.Errorf("状态码区间左右端点反序: %s", tag)
}
if lo < StatusCodeMin || hi > StatusCodeMax {
return 0, 0, fmt.Errorf("状态码须在 %d–%d: %s", StatusCodeMin, StatusCodeMax, tag)
}
return lo, hi, nil
}
func ExpandStatusCodeTags(tags []string) ([]int, error) {
set := map[int]struct{}{}
for _, tag := range tags {
lo, hi, err := ParseStatusCodeTag(tag)
if err != nil {
return nil, err
}
for c := lo; c <= hi; c++ {
set[c] = struct{}{}
}
}
out := make([]int, 0, len(set))
for c := range set {
out = append(out, c)
}
sort.Ints(out)
return out, nil
}
```
- [ ] **Step 4: 运行确认通过**
Run: `go test ./pkg/render/openresty/ -run TestExpandStatusCodeTags -count=1`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add pkg/render/openresty/status_codes.go pkg/render/openresty/status_codes_test.go
git commit -m "feat(openresty): add status code tag expand helper"
```
---
### Task 2: ConfigSnapshot 字段 + 默认 HTML + error_page 渲染(TDD)
**Files:**
- Modify: `pkg/render/openresty/types.go` — `ConfigSnapshot` 增加:
- `OriginErrorPageEnabled bool \`json:"origin_error_page_enabled"\``
- `OriginErrorPageStatusCodes []string \`json:"origin_error_page_status_codes,omitempty"\``
- `OriginErrorPageHTML string \`json:"origin_error_page_html,omitempty"\``
- Create: `pkg/render/openresty/origin_error_page.go` — 默认 HTML 常量、路径常量、`renderOriginErrorPageDirectives`、`originErrorPageSupportFile`
- Modify: `pkg/render/openresty/render.go` — `Render`/`RenderRouteConfig` 在 enabled 时 append SupportFile
- Modify: `pkg/render/openresty/render_route.go`(或 `render.go` 中 `renderHTTPProxyServer` / `renderHTTPSServer`)— 在 proxy location **与** server 级写入 intercept + error_page + internal location
- Test: `pkg/render/openresty/origin_error_page_test.go` + 扩展 `render_test.go`
**Interfaces:**
- Produces:
- `const OriginErrorPageSupportPath = "error_pages/origin_error.html.tmpl"`
- `const DefaultOriginErrorPageHTML = \`...\`` — CF 风格,含 `{{status}}` `{{host}}`
- `func EffectiveOriginErrorPageHTML(cfg ConfigSnapshot) string` — 空则默认
- `func renderOriginErrorPageServerBits(cfg ConfigSnapshot) string` — 若 disabled 或 expand 失败/空则 `""`;否则 `error_page ...` + internal location 字符串
- SupportFile content = Effective HTML(保留占位符)
**Internal location 形状(必须 status 透传):**
```nginx
location = /__openflare_origin_error {
internal;
default_type text/html;
charset utf-8;
content_by_lua_block {
local f = io.open("__OPENFLARE_ERROR_PAGE_TMPL__", "r")
if not f then
ngx.status = ngx.status
ngx.say("Error ", ngx.status)
return
end
local body = f:read("*a")
f:close()
local status = tostring(ngx.status)
local host = ngx.var.host or ""
body = body:gsub("{{status}}", status, 1)
body = body:gsub("{{host}}", host, 1)
-- 全局替换剩余占位(若模板多处 status)
body = body:gsub("{{status}}", status)
body = body:gsub("{{host}}", host)
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.say(body)
}
}
```
占位路径:渲染时用常量如 `ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"`,Agent 落盘时与 SupportFile 绝对路径替换(若现有 Agent 已有 support file root 替换模式则复用;否则在 `internal/apps/agent` 同步路径处增加对该 placeholder 的替换,与 `CertDirPlaceholder` 同类)。
在每个反代 `location /` 内(proxy 块):
```nginx
proxy_intercept_errors on;
```
在 server 块内(location 外):
```nginx
error_page 500 501 ... = /__openflare_origin_error;
```
+ internal location。
Pages 的 `renderHTTPSPagesServer` / pages HTTP **不**调用。
- [ ] **Step 1: 写失败单测**
```go
func TestRenderOriginErrorPageEnabled(t *testing.T) {
t.Parallel()
doc := Document{
Routes: []Route{{
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
OriginURL: "http://127.0.0.1:9", Enabled: true,
}},
OpenRestyConfig: ConfigSnapshot{
OriginErrorPageEnabled: true,
OriginErrorPageStatusCodes: []string{"500-599"},
},
}
out, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "proxy_intercept_errors on") {
t.Fatal("missing intercept")
}
if !strings.Contains(out, "error_page") || !strings.Contains(out, "/__openflare_origin_error") {
t.Fatal("missing error_page")
}
if !strings.Contains(out, "{{status}}") == false {
// SupportFile 在 Render 全量结果中
}
res, err := Render(doc, nil)
if err != nil {
t.Fatal(err)
}
found := false
for _, f := range res.SupportFiles {
if f.Path == OriginErrorPageSupportPath {
found = true
if !strings.Contains(f.Content, "{{status}}") {
t.Fatal("template missing placeholder")
}
}
}
if !found {
t.Fatal("missing support file")
}
}
func TestRenderOriginErrorPageDisabled(t *testing.T) {
t.Parallel()
doc := Document{
Routes: []Route{{
ID: 1, SiteName: "ex", Domains: []string{"ex.test"},
OriginURL: "http://127.0.0.1:9", Enabled: true,
}},
OpenRestyConfig: ConfigSnapshot{OriginErrorPageEnabled: false},
}
out, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "proxy_intercept_errors") {
t.Fatal("should not intercept when disabled")
}
}
```
- [ ] **Step 2: 运行确认失败** → 实现默认 HTML + 渲染接入 → 运行 PASS
默认 HTML 要求:大号 `{{status}}`、展示 `{{host}}`、中性「源站暂时无法提供服务」类文案、无 Cloudflare 商标、可单文件 inline CSS。
- [ ] **Step 3: Agent placeholder 替换**
搜索 Agent 写 conf 时如何替换 `__OPENFLARE_CERT_DIR__` 等,为 `__OPENFLARE_ERROR_PAGE_TMPL__` 增加指向 support 目录下 `error_pages/origin_error.html.tmpl` 的绝对路径。
若 conf 内 lua 无法可靠 `io.open` 绝对路径,可改为 `content_by_lua_file` + 小 lua 读固定相对路径;优先与现有 pow/waf 资源部署方式一致。
- [ ] **Step 4: Commit**
```bash
git commit -m "feat(openresty): render origin error page directives"
```
---
### Task 3: Option keys、迁移、校验、快照
**Files:**
- Modify: `internal/model/system_configs.go` — 常量:
- `ConfigKeyOriginErrorPageEnabled = "origin_error_page_enabled"`
- `ConfigKeyOriginErrorPageStatusCodes = "origin_error_page_status_codes"`
- `ConfigKeyOriginErrorPageHTML = "origin_error_page_html"`
- Create goose(postgres + sqlite 同名版本号)`202608060001_add_origin_error_page_options.sql`:
```sql
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('origin_error_page_enabled', 'true', 'business', 0, '是否启用源站错误页', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_status_codes', '["500-599"]', 'business', 0, '源站错误页触发状态码标签 JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('origin_error_page_html', '', 'business', 0, '源站错误页自定义 HTML,空则使用内置默认', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'origin_error_page_enabled',
'origin_error_page_status_codes',
'origin_error_page_html'
);
```
(SQLite 若无 `ON CONFLICT` 同现有迁移方言对齐。)
- Modify: `openresty_validators.go`:
- enabled → `validateBooleanOption`
- status_codes → JSON 数组 `[]string`,对每项 `ParseStatusCodeTag`;若 `enabled==true`(跨 key 时可用 state,或在 batch 校验后单独检查:若本 key 合法但 enabled 为 true 且 expand 为空则失败)。**实现建议**:`validateOriginErrorPageStatusCodes` 只校验标签可解析且 expand 非空(即使 disabled 也要求非空列表,避免脏数据);html 长度 `<= 256*1024`
- html → `len(value) <= 256<<10`
- Modify: `snapshot.go` 的 `openRestyConfigSnapshot` + `buildOpenRestyConfigSnapshot`:
- Enabled: `getBoolConfig(..., true)`
- StatusCodes: 解析 JSON 数组,失败则默认 `[]string{"500-599"}`
- HTML: `getStringConfig(..., "")`
- Modify: `logics.go` `diffOpenRestyOptionDetails` 比较新字段(否则发布 diff 不显示)
- [ ] **Step 1: 校验单测**(`option` 包)非法 `["abc"]`、超大 html、合法 `["522","500-502"]`
- [ ] **Step 2: 实现迁移与 snapshot 填充**
- [ ] **Step 3: 手动或单测确认 snapshot JSON 含字段**
- [ ] **Step 4: Commit**
```bash
git commit -m "feat(option): seed and validate origin error page options"
```
---
### Task 4: 前端 Tags Input + `/error-pages` 页
**Files:**
- Create/Modify: `frontend/components/ui/tags-input.tsx`(若无:用 shadcn skill / `npx shadcn@latest add` 社区 Tags Input;API:`value: string[]`, `onChange`, `placeholder`)
- Create: `frontend/app/(main)/error-pages/page.tsx`(及可选 `components/` 拆分若超 600 行)
- Modify: `frontend/lib/navigation/openflare-nav.ts` — `openflareWebsiteNavGroup.items` 增加 `{ title: '错误页', url: '/error-pages' }`;`openflareWebsiteSubNav` 同步
- Modify: `frontend/lib/utils/search-data.ts` — 关键词:错误页、源站、502、error page
- Reuse: `OptionService.list` / `updateBatch`(同 performance 页)
**UI 行为:**
1. 管理员加载 options → 映射三字段
2. Switch 启用
3. TagsInput:默认展示解析后的 JSON 数组
4. Textarea HTML;按钮「加载默认模板」「恢复默认」
5. 预览:客户端把 `{{status}}`→`502`、`{{host}}`→`example.com` 后 iframe `srcDoc`
6. 保存:`updateBatch` 三条;toast 提示去版本发布
7. 前端校验:标签用与后端相同规则(可抽 `lib/openflare/status-code-tags.ts` 轻量实现或仅保存时依赖后端错误)
默认 HTML:从前端常量复制与 Go `DefaultOriginErrorPageHTML` **内容一致**(计划实现时两处同一字符串;可在 PR 说明需人工对齐)。
- [ ] **Step 1: 添加 Tags Input 组件并在 demo 或本页使用**
- [ ] **Step 2: 实现 page.tsx**
- [ ] **Step 3: 导航与搜索**
- [ ] **Step 4: 本地 UI 走查**(开关、标签区间、预览、保存错误提示)
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(frontend): add origin error page settings under websites"
```
---
### Task 5: 文档、changelog、收尾门禁
**Files:**
- Modify: `docs/reference/configuration.md` — 三 key 说明
- Modify: `docs/changelog/index.md` `[unreleased]` 改进:用户可读中文
- Modify: `docs/plan/index.md` — 本计划列入进行中
- [ ] **Step 1: 写配置说明与 changelog**
示例 changelog:
```markdown
- 新增全局源站错误页:可在「网站管理 → 错误页」配置触发状态码(支持 500-599 区间与单码)与自定义 HTML;默认 Cloudflare 风格页面并保持真实 HTTP 状态码,关闭后恢复透传。
```
- [ ] **Step 2: `make code-check` 与前端 format**
- [ ] **Step 3: 手动验收清单(对照设计 §7.2)**
1. 默认启用 + 源站宕机 → 错误页 + 真实 status
2. 源站 503 → 替换
3. 仅 `522` → 其它 5xx 透传
4. 关闭并发布 → 透传
5. 自定义 `{{status}}`/`{{host}}`
6. Pages 不受影响
- [ ] **Step 4: Final commit**
```bash
git commit -m "docs: origin error page configuration and changelog"
```
---
## Spec coverage checklist
| Spec 要求 | Task |
|-----------|------|
| 全局开关 | 3, 4 |
| 默认 `500-599` / 单码+区间 | 1, 3, 4 |
| 状态码透传 + `{{status}}` | 2 |
| 默认 CF 风格 / 自定义 HTML | 2, 4 |
| Option + 配置版本 | 3 |
| 仅反代 | 2 |
| 侧栏网站管理 | 4 |
| Tags Input | 4 |
| 256KB 限制 | 3 |
| 测试与 changelog | 1–5 |
## Placeholder scan
无 TBD;Agent 路径替换若与现网 placeholder 机制不一致,在 Task 2 Step 3 内对齐现有 `CertDirPlaceholder` 模式,不另开悬空任务。