chore(docs): purge

This commit is contained in:
ryan
2026-08-16 21:44:28 +08:00
parent 2aa0a70762
commit 3f97193280
39 changed files with 0 additions and 12727 deletions
@@ -1,13 +0,0 @@
# Zone 域名重构规格
已确认的设计:
* `/websites` 展示可注册根域 Zone;详情 URL 使用 `/websites/:zoneId`。
* `managed_domains` 将被彻底替换为 `of_zones` 与 `of_zone_domains`。
* Zone 域名是 `of_proxy_routes` 域名与证书的规范化来源;一个域名至多连接一条路由,一条路由可含多个 Zone 的域名。
* `of_proxy_routes` 移除 `cert_id`、`cert_ids` 与 `domain_cert_ids`,不再指定证书;配置编译只从关联 Zone 域名的 `cert_id` 查询证书。
* Zone 域名只允许明确 FQDN;允许把含 `*.example.com` SAN 的 TLS 证书绑定到明确域名,但不允许通配符域名记录。
* 路由仍拥有上游、缓存、限流、WAF 与 Pages;Zone 只提供聚合管理和展示。
* 迁移先建新表、用 Public Suffix List 回填和验证,再在后续独立发布中移除旧表及冗余列。
完整设计、API、迁移与验证策略见 [Zone 与域名资源设计](../../design/zone-design.md)。
@@ -1,7 +0,0 @@
# Cloudflare DNS 指向 — Spec 指针
完整设计见项目设计基线:
**[docs/design/cloudflare-pointing.md](../../design/cloudflare-pointing.md)**
本文件仅作 brainstorming 工作流落点索引,避免与 `docs/design/` 双份正文漂移。
@@ -1,219 +0,0 @@
# 边缘限流全局默认设计
日期:2026-07-19
状态:已评审待实现
方案:渲染时按站点合并全局默认(方案 A)
## 背景
当前边缘限流仅挂在站点(Proxy Route)上,字段为:
- `limit_conn_per_server`:站点并发连接上限
- `limit_conn_per_ip`:单 IP 并发连接上限
- `limit_rate`:单请求带宽
OpenResty 渲染行为:
- `http {}` 始终声明共享 `limit_conn_zone`
- 各站点 `location` 在字段 `>0` / 非空时输出 `limit_conn` / `limit_rate`
- 站点值为 `0` 或空表示**关闭**,无全局默认
期望:在全局增加默认限流策略;站点未设置时继承默认,可覆盖或显式关闭。
## 目标
1. 提供三项全局默认限流配置,覆盖全部现有维度。
2. 站点 `0`/空 = 继承全局;`-1` = 显式关闭;`>0`/合法带宽串 = 站点自定义。
3. 合并发生在配置渲染路径,仍在各站点 `location` 输出生效指令(不在 `http {}` 写默认 `limit_conn`/`limit_rate`)。
4. 管理入口:侧栏「安全性」下新增子页「限流」。
5. 全局默认初始为 `0`/空,存量发布行为与现网一致。
## 非目标
- 引入 `limit_req`(按 RPS 限流)
- 在 `http {}` 上下文直接写默认 `limit_conn` / `limit_rate`
- 按路径 / URI 差异化限流
- 改变 `limit_conn` zone 键模型(仍为 `$server_name` 与 `$binary_remote_addr`)
## 语义
### 站点字段
| 值 | `limit_conn_*` | `limit_rate` |
|----|----------------|--------------|
| `0` / 空 | 继承全局默认 | 空或 `"0"` 规范化为空串后继承 |
| `-1` | 显式关闭该维度 | 字面 `"-1"` 表示显式关闭 |
| `>0` / 合法带宽 | 使用站点值 | 合法 `^\d+[kKmM]?$` 使用站点值 |
### 全局默认
| 值 | 含义 |
|----|------|
| `0` / 空 | 默认关闭;继承方亦不输出指令 |
| `>0` / 合法带宽串 | 作为未配置站点的生效值 |
全局默认**不允许** `-1`(无意义);仅 `>=0` 或合法 rate / 空。
### 合并规则(逐字段)
```
if route == -1: effective = off
else if route is set: effective = route // conn > 0 或 rate 合法非空
else: effective = global // route 为 0/空
// global 为 0/空 → off(不输出)
```
`limit_rate` 的「set」判定:规范化后非空且不等于 `"-1"`。
## 配置存储
沿用 `system_configs` + Option API + 发布快照,与其它 OpenResty 选项一致。
| Key | 类型语义 | 默认 |
|-----|----------|------|
| `openresty_default_limit_conn_per_server` | 非负整数 | `0` |
| `openresty_default_limit_conn_per_ip` | 非负整数 | `0` |
| `openresty_default_limit_rate` | 空或 `^\d+[kKmM]?$` | `""` |
实现要点:
- `internal/model/system_configs.go` 增加 `ConfigKeyOpenRestyDefaultLimit*` 常量
- goose seed/升级迁移写入默认值
- `internal/apps/openflare/option` 注册校验器(conn ≥ 0;rate 与站点同一套 pattern,允许空)
- `openRestyConfigSnapshot` / `buildOpenRestyConfigSnapshot` 增加三字段
- 变更进入 OpenResty option diff;**需重新发布配置版本后下发节点**
## 站点模型与 API
- DB 列类型不变(`INTEGER` / `VARCHAR(32)`),无 schema 变更
- `normalizeProxyRouteLimitConnValue`:允许 `>= -1`(原 `>= 0`)
- `normalizeProxyRouteLimitRate`:允许 `"-1"` 存为关闭标记;空/`0` → `""`(继承)
- View / Input / 前端类型同步暴露 `-1` 语义
- 错误文案更新(非法负数除 `-1` 外拒绝)
## 渲染路径
合并**唯一**发生在 `pkg/render/openresty.RenderRouteConfig`:该函数已接收完整 `Document`,可从 `doc.OpenRestyConfig` 读取全局默认,与各 `doc.Routes[i]` 的站点字段合并。Server 预览渲染与 Agent 落地渲染共用同一路径,禁止在 snapshot 构建或其它层再合一次。
步骤:
1. 对每个 route:用站点限流字段 + `doc.OpenRestyConfig` 中的默认三项 → `routeLimitConfig`
2. `renderRouteLimitBlock` 保持「有值才输出」
3. 应用范围不变:
- HTTP/HTTPS 反代 `location /`
- Pages 相关 location
- **不含** HTTP→HTTPS 重定向-only server
4. `http {}` 仍只输出现有 `limit_conn_zone` 两行
快照 JSON **保留站点原始值**(含 `0`/`-1`),不把合并结果写回 route;节点 conf 中只看到最终指令。
伪代码:
```go
func mergeRouteLimit(route routeLimits, def defaultLimits) routeLimitConfig {
return routeLimitConfig{
LimitConnPerServer: mergeConn(route.LimitConnPerServer, def.LimitConnPerServer),
LimitConnPerIP: mergeConn(route.LimitConnPerIP, def.LimitConnPerIP),
LimitRate: mergeRate(route.LimitRate, def.LimitRate),
}
}
func mergeConn(route, def int) int {
if route == -1 {
return 0 // off
}
if route > 0 {
return route
}
if def > 0 {
return def
}
return 0
}
func mergeRate(route, def string) string {
r := strings.TrimSpace(strings.ToLower(route))
if r == "-1" {
return ""
}
if r != "" && r != "0" {
return r
}
d := strings.TrimSpace(strings.ToLower(def))
if d != "" && d != "0" {
return d
}
return ""
}
```
## 前端
### 安全性 → 限流
- 导航:`openflareSecurityNavGroup` 增加 `{ title: '限流', url: '/rate-limits' }`
- 页面:`frontend/app/(main)/rate-limits/page.tsx`
- 通过 `OptionService.list` / `updateBatch` 读写上述 3 个 key
- UI 模式对齐性能页:标题规范、卡片分区、保存反馈
- 文案说明:`0`/空 = 默认关闭;`>0` = 未单独配置站点的默认生效值;修改后需发布配置版本
### 站点流量限制
- 更新 `limits-section.tsx` 与校验 helpers:
- `0`/空 = 继承全局默认
- `-1` = 关闭
- `>0` / 合法 rate = 自定义
- 可选:展示当前全局默认值作提示(只读)
- 创建站点默认仍为 `0`/空(即继承)
## 兼容性
| 场景 | 结果 |
|------|------|
| 升级后全局默认 0,站点全 0 | 与升级前一致:不限流 |
| 管理员设置全局默认后发布 | 所有 `0`/空站点自动生效默认 |
| 站点需保持关闭 | 将该项改为 `-1` 后保存并发布 |
| 旧 API 客户端只写 `0` | 合法;语义变为继承 |
| 旧快照无默认字段 | 按 0/空处理 |
## 边界说明
- `limit_conn_per_ip` zone 仍按 `$binary_remote_addr` 全局共享;各 location 的 N 可不同,计数空间共享(现网行为,本设计不改)。
- 多域名共享一条路由 → 共享合并后策略(产品边界不变)。
- 仅改全局默认不自动 reload 节点;走标准「选项变更 → 配置版本 diff → 发布」。
## 测试计划
1. **render 表驱动**:继承 / 显式关 / 覆盖 / 全局关 × 三字段
2. **normalize**:`-1`、`0`、`>0`、非法负值、rate `"-1"` / 空 / 合法 / 非法
3. **snapshot**:默认字段进入 `openresty_config`;option diff 可检测变更
4. **option 校验**:非法全局 rate / 负 conn 拒绝
5. 前端:限流页读写与站点文案(可选手测)
## 文档与变更记录
- 本设计文档:`docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md`
- 实现时更新中文 changelog `[Unreleased]`(用户可见语义与新设置页)
- 如有配置参考页,补充三个 key 的中文说明
- 不要求同步英文文档
## 实现落点(文件索引)
| 区域 | 路径 |
|------|------|
| 配置键 / seed | `internal/model/system_configs.go`,goose 迁移 |
| 校验 | `internal/apps/openflare/option/openresty_validators.go` |
| 快照 | `internal/apps/openflare/config_version/snapshot.go` |
| 站点规范化 | `internal/apps/openflare/proxy_route/helpers.go` |
| 渲染合并 | `pkg/render/openresty/render.go`(及调用处传参) |
| 导航 | `frontend/lib/navigation/openflare-nav.ts` |
| 限流设置页 | `frontend/app/(main)/rate-limits/` |
| 站点 UI | `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` |
## 验收标准
1. 全局默认可在「安全性 → 限流」读写,初始 0/空。
2. 全局设为有效值并发布后,站点限流为 0/空的 location 出现对应指令。
3. 站点 `-1` 在全局有默认时仍不输出该维度。
4. 站点 `>0` 覆盖全局。
5. 全局与站点均为 0/空时 conf 无 `limit_conn`/`limit_rate` 指令。
6. `make code-check` 通过;相关单测覆盖合并与规范化。
@@ -1,217 +0,0 @@
# 限流页请求压力分析设计
日期:2026-07-19
状态:已评审待实现
方案:Tabs(分析 / 配置)+ 专用 ECharts 双轴压力图(方案 A)
## 背景
`/rate-limits` 当前仅为管理员配置全局 OpenResty 默认限流(`limit_conn_*` / `limit_rate`),无请求压力可视化。
访问日志概览已提供:
- 过滤:时间预设 `24h | 7d | 15d | 30d` + 域名多选 `hosts[]`
- 数据:`GET /api/v1/d/access-logs/overview` → 小时桶 `trends.requests` / `trends.visits`,以及 `top_hosts` / `top_ips`(窗口总请求数)
- 图表:共享 `TrendChart` 为**单 Y 轴**;仓库内无 ECharts `dataZoom`、无双轴指标图
需求:在限流页展示当前请求压力(RPS),默认 24 小时,图表样式对齐「双轴时序面积折线 + 底部缩放条」描述,过滤复用访问日志概览组件,并增加域名/IP 平均 RPS 排行。
## 目标
1. `/rate-limits` 改为 Tabs:**分析**(默认)/ **配置**。
2. 分析 Tab:概览式过滤 + RPS/访客双轴主图 + 域名/IP 平均 RPS 排行。
3. 配置 Tab:迁入现有全局默认限流表单,行为不变。
4. 数据复用 `AccessLogService.getOverview`,不新增后端 API。
5. 主图为**专用** ECharts 组件,不扩展共享 `TrendChart`。
## 非目标
- 新 RPS 时序 API 或峰值桶 RPS 排行接口
- 给通用 `TrendChart` 增加双轴 / dataZoom
- 配置 Tab 限流语义变更
- 分析过滤支持 node_id / IP / path(概览亦无)
- 英文文档
## 页面信息架构
**路由:** `/rate-limits`(导航「安全性 → 限流」不变)
| Tab | 内容 |
|-----|------|
| **分析**(默认) | 过滤条 → `RatePressureChart` → 双排行榜 |
| **配置** | 现有三项全局默认限流表单 + 保存 + 链到版本发布 |
可选:`?tab=config` 直达配置;默认 `analysis`。
**权限:** 仅管理员(与现页一致)。
**分析 Tab 自上而下:**
1. **过滤条**(与访问日志概览一致)
- 时间:`24 | 168 | 360 | 720` 小时,默认 **24**
- 域名:Zone 树多选 → `hosts[]`
2. **主图卡片** `RatePressureChart`
3. **排行榜**(并排)
- 平均 RPS 最高域名
- 平均 RPS 最高 IP
## 数据与状态
### 查询
```ts
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
})
// queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts]
```
- 过滤变更 → 重新请求 overview
- 图表 `dataZoom` **仅**前端缩放已加载序列,**不**改 `hours`、**不**触发 refetch
### 指标定义
| 序列 | 源字段 | 换算 | 轴 |
|------|--------|------|-----|
| 请求速率 (RPS) | `trends.requests[].value` | `value / 3600`(概览固定 1h 桶) | 左 Y |
| 独立访客 | `trends.visits[].value` | 桶内 UV,不换算 | 右 Y |
- 时间点:`bucket_started_at`
- Tooltip:时间 + RPS(如 `12.3 req/s`)+ 访客数
- 空数据 / 加载 / 错误:对齐访问日志概览空态与 `ErrorInline` / loading
### 排行口径
窗口**平均** RPS(与 dashboard `estimated_qps` 一致):
```
avgRps = total_requests / (hours * 3600)
```
- 域名:`top_hosts[]` 的 `value` 为窗口总请求数 → 换算后展示
- IP:`top_ips[]` 同理
- 标题:「平均 RPS 最高域名」「平均 RPS 最高 IP」
- 副文案标明窗口(如「近 24 小时平均」)
- UI 组件:现有 `RankCard` / `RankChart`
**不是**峰值小时桶 RPS;避免新 API。
## 主图组件 `RatePressureChart`
### 布局(对齐产品描述)
1. **外部卡片**:圆角、边框/轻阴影,扁平矩形
2. **顶部控制栏**
- 左:主标题「请求压力」(字号加粗)
- 右:时钟图标 + 当前查询窗口起止(由 `hours` 与「现在」推算本地时间,`YYYY-MM-DD HH:mm:ss`)
3. **图例与轴标识**
- 左上:左轴属性「RPS」
- 右上:图例圆点 +「请求速率」「独立访客」
- 最右:右轴单位「访客 / 桶」
4. **主绘制区**
- 双 Y 轴:左 RPS 从 0 递增;右访客从 0 递增
- X 轴:时间,标签两行(月-日 / 时:分),可复用 `formatOverviewTrendLabel` 思路
- 水平等距虚线网格
- 面积 + 折线,半透明填充,两序列可重叠
5. **底部 dataZoom slider**
- ECharts `dataZoom: [{ type: 'slider', ... }]`
- 宽度对齐绘图区;左右手柄;内嵌缩略波动线
- 仅影响可见区间
### 实现约束
- 新建专用组件,**不要**给 `TrendChart` 加 dualY/dataZoom
- 库:`echarts` + `echarts-for-react`(与看板一致)
- 颜色使用主题/CSS 变量或与访问日志趋势相近的语义色,避免硬编码与 shadcn 变体冲突时可参考现有 `TrendChart` 系列色
## 过滤组件复用
优先从 `frontend/app/(main)/access-logs/components/overview-tab.tsx` **抽出**:
- `OverviewToolbar`(或等价)
- `OverviewHostFilter`
- 依赖的 `OVERVIEW_RANGE_OPTIONS` / `OverviewRangeHours` 已在 `access-log-utils.ts`
落点建议:
- 仍放在 `access-logs/components/` 并 export,限流分析 import;或
- 若跨模块更清晰,迁到 `frontend/components/common/`(仅当确实跨页面复用且避免循环依赖时)
**验收:** 访问日志概览过滤行为与抽出前一致。
## 文件结构
```
frontend/app/(main)/rate-limits/
page.tsx # Tabs、权限、分析/配置挂载
components/
analysis-tab.tsx # 过滤 + 图 + 排行 + overview query
rate-pressure-chart.tsx # 双轴 + dataZoom
config-tab.tsx # 现有 Option 表单逻辑迁入
```
可选抽出:
```
frontend/app/(main)/access-logs/components/
overview-toolbar.tsx # 从 overview-tab 抽出
overview-host-filter.tsx
```
后端:无变更。
## 边界与兼容
| 场景 | 行为 |
|------|------|
| 无日志 / ClickHouse 空 | 图与排行空态 |
| 仅选域名 | overview 带 `hosts[]` |
| dataZoom 拖动 | 不请求后端 |
| 非管理员 | 空态「权限不足」 |
| 书签 `/rate-limits` | 默认分析 Tab |
| 配置保存 | 仍 invalidate options / config-preview / config-versions |
## 测试与验收
### 自动化(按项目习惯)
- 若有 vitest:过滤 props 透传、`avgRps` 换算纯函数单测
- 图表以手工/视觉验收为主(ECharts 难做快照)
### 验收标准
1. 默认进入分析 Tab,24h,主图展示 RPS + 访客
2. 切换 7d / 域名后图与排行刷新
3. dataZoom 仅改变可见时间范围
4. 排行展示平均 RPS,不是原始请求总数(文案明确「平均」)
5. 配置 Tab 可读写三项默认限流并保存
6. 访问日志概览过滤不回归
7. `make prettier`;相关 typecheck/lint 通过
## 文档
- 本设计:`docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md`
- 实现时:`docs/changelog/index.md` `[Unreleased]` 补充用户可见条目
- 纯 UI/分析展示,无新 system config 键
## 实现落点索引
| 区域 | 路径 |
|------|------|
| 限流页 | `frontend/app/(main)/rate-limits/` |
| 概览过滤复用 | `access-logs/components/overview-tab.tsx` 等 |
| Overview API | `AccessLogService.getOverview` |
| 排行 UI | `components/data/rank-card.tsx` |
| 趋势参考 | `components/data/trend-chart.tsx`(只参考样式,不扩展) |
## 决策摘要
| 决策 | 选择 |
|------|------|
| 页面结构 | Tabs:分析 / 配置 |
| 双轴 | 左 RPS,右 独立访客/桶 |
| 过滤 | 概览过滤 + 默认 24h 预设 |
| 排行 | 窗口平均 RPS = 总请求 / 窗口秒数 |
| 图表实现 | 专用 ECharts 组件(方案 A) |
| 后端 | 无新 API |
@@ -1,122 +0,0 @@
# WAF 规则编辑器:节点命名与拖放添加
日期:2026-07-19
范围:`/waf/rules/editor` 前端交互与类型对齐
状态:已确认,待实现
## 背景
当前 WAF 规则流图编辑器有两处体验问题:
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
## 目标
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
## 非目标
- 节点备注 / note / remark
- 拖到边自动拆边插入
- 系统节点 `start` / `allow` 可改名
- 后端校验、编译或运行时语义变更
- 侧栏式节点库大改版
## 数据模型
### 后端(已有,不改)
```go
type RuleNode struct {
ID string `json:"id"`
Type RuleNodeType `json:"type"`
Label string `json:"label,omitempty"`
Position RulePosition `json:"position"`
Config json.RawMessage `json:"config"`
}
```
`label` 为空则 omit;现有大小限制与图校验保持不变。
### 前端
`WAFRuleNode` 各变体增加可选字段:
```ts
label?: string;
```
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
- 显示时:`label?.trim() || typeDefaultLabel`。
- 新建节点:不设 `label`(显示类型默认名)。
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
## UI 行为
### 画布节点(`rule-node.tsx`)
| 区域 | 行为 |
|------|------|
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
| 副标题 | 仍显示 `rule.id`(mono 小字) |
| 图标 / handle | 不变 |
### 属性栏(`node-properties.tsx`)
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
- 字段标签:`显示名称`
- 控件:`Input`,受控绑定 `node.label ?? ''`
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
系统节点保持现有文案。
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
3. React Flow 画布容器:
- `onDragOver`:`preventDefault`,允许 drop
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
4. 落点后选中新节点,清除边选中(与现有一致)。
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
## 实现落点(文件)
| 文件 | 变更 |
|------|------|
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | 标题显示逻辑 |
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」字段 |
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | 拖放源,去掉点击添加 |
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
## 错误与边界
- 未知 / 非法 drag type:忽略 drop。
- 落在画布外:不创建。
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
## 测试要点
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
5. 系统节点属性栏仍无「显示名称」。
## 验收标准
- [ ] 可编辑节点可命名,保存再打开名称仍在。
- [ ] 画布显示自定义名(空则类型名)。
- [ ] 仅拖放添加,松手位置为节点位置。
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
@@ -1,191 +0,0 @@
# WAF 规则节点:安全防护(security_check)
日期:2026-07-19
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
状态:已确认,待实现
## 背景
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
## 目标
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
## 非目标(v1)
- ModSecurity / OWASP CRS / libinjection 完整引擎
- 响应侧 XSS 检测、机器学习
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
- 无限制大 Body 全量扫描
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `security_check` |
| 句柄 | `true`, `false` |
| 可删除 / 可命名 / 可拖放 | 是 |
### Config
```json
{
"sql_injection": false,
"path_traversal": true,
"command_injection": false,
"xss": false,
"ssrf": false,
"file_inclusion": true,
"malicious_upload": false,
"xxe": false,
"crlf_injection": false
}
```
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|------|------|---------|--------------|
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
| `xss` | false | XSS | Query、Cookie、Referer、Body |
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
全部关闭时:节点恒 **true**(空操作),合法。
## 求值语义
```
inputs := collect_inspection_strings(request) // 见下
for each enabled rule:
if rule_matches(rule, inputs) → return false
return true
```
- **false** = 命中攻击特征(接阻止)
- **true** = 未命中(接通过或其它节点)
### 采集与限制
| 来源 | 方式 |
|------|------|
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
| Cookie | `ngx.var.http_cookie` |
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
### 规则特征方向(v1 模式包)
实现以可维护的模式表为准,下表为方向约束:
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
## 控制面
### `graph_types.go`
- `RuleNodeSecurityCheck = "security_check"`
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- 严格 JSON;仅允许已知布尔字段
### `graph_compile.go`
- 原样编译布尔字段进运行时配置
### 测试
- 合法全关 / 默认子集 / 全开
- 未知字段拒绝
- 编译保留默认
## 数据面
### `waf_runtime.lua`
```lua
elseif node.type == "security_check" then
handle = matches_security_check(node.config or {}) and "true" or "false"
```
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
### `waf_runtime_spec.lua`
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
## 前端
| 文件 | 变更 |
|------|------|
| `types.ts` | `security_check` + `SecurityCheckConfig` |
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
| `node-library.tsx` | 「安全防护」+ 图标 |
| `rule-node.tsx` | `true`/`false` handles |
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
| `graph-validation.ts` / `editor-behavior.ts` | handles |
### 属性栏草图
```
显示名称
── 安全防护 ──
命中任意已启用规则返回 False [?]
── 基础防护 ──
[Switch] 路径穿越防护 [?]
[Switch] 文件包含(LFI/RFI) [?]
[Switch] SQL 注入 [?]
...
```
Tooltip 文案包含检测面与简要说明(与产品表一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表
- `docs/changelog/index.md` `[Unreleased]`
## 验收标准
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
- [ ] 全关恒 true
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
## 风险
| 风险 | 缓解 |
|------|------|
| 误报 | 默认仅开低误报两项;模式偏保守 |
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
@@ -1,228 +0,0 @@
# WAF 规则节点:UA 检查(ua_check)
日期:2026-07-19
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
状态:已确认,待实现
## 背景
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
1. 要求请求必须携带 UA;
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
3. 优先屏蔽常见爬虫与非正常 UA。
## 目标
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
- 屏蔽逻辑优先级高于白名单匹配。
## 非目标
- 设备类型(Mobile/Tablet)维度。
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `ua_check` |
| 句柄 | `true`, `false` |
| 可删除 | 是 |
| 可命名 | 是(`label`) |
| 可拖放添加 | 是 |
### Config(JSON)
```json
{
"require_ua": false,
"browsers": [],
"operating_systems": [],
"match_mode": "or",
"block_common_bots": false,
"block_abnormal_ua": false,
"block_custom_ua": false,
"custom_ua_patterns": []
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
默认值:开关全 `false`,列表空,`match_mode: "or"`。
### 允许的标签(封闭枚举)
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
**browsers:**
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
**operating_systems:**
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
## 求值语义(边缘)
输入:`ua = http_user_agent`(trim 后判断空)。
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
**严格顺序:**
```
1) if require_ua and ua 为空 → false
2) browser, os := classify(ua)
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
5) if block_custom_ua and UA matches any custom_ua_patterns → false
6) has_browsers := browsers 非空; has_os := operating_systems 非空
7) if not has_browsers and not has_os → true
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
9) if has_browsers and not has_os → browser_hit
10) if has_os and not has_browsers → os_hit
11) if both lists set:
match_mode == "and" → browser_hit and os_hit
match_mode == "or" → browser_hit or os_hit
```
说明:
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
### 示例
| 配置摘要 | 请求 | 结果 |
|----------|------|------|
| 仅 `require_ua` | 无 UA | false |
| 仅 `require_ua` | 正常 Chrome | true |
| `block_common_bots` | Googlebot | false |
| `block_abnormal_ua` | 无法识别 UA | false |
| browsers=`[Chrome]`, mode=or | Safari | false |
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
| 列表皆空,无 block | 任意有 UA | true |
## 分类规则来源
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
实现要求:
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
## 控制面
### `graph_types.go`
- `RuleNodeUACheck RuleNodeType = "ua_check"`
- `UACheckConfig` 结构体对应上表 JSON 字段
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- `validateUACheckNodeConfig`:
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
- browsers / OS 标签 ∈ 封闭枚举
- 布尔字段默认 false
- `DisallowUnknownFields`
### `graph_compile.go`
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
- 规范化 `match_mode`(非法不得编译成功)
### 测试
- validate:合法配置、非法标签、非法 mode、缺句柄
- compile:列表排序去重、默认值
## 数据面(Agent)
### `waf_runtime.lua`
在 `execute_graph` 增加:
```lua
elseif node.type == "ua_check" then
handle = matches_ua_check(node.config) and "true" or "false"
```
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
### `waf_runtime_spec.lua`
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
## 前端编辑器
| 文件 | 变更 |
|------|------|
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
| `node-library.tsx` | 拖放项 |
| `rule-node.tsx` | 图标 + `true`/`false` handles |
| `node-properties.tsx` | 属性 UI(见下) |
| `graph-validation.ts` | handles + 标签/mode 校验 |
| `editor-behavior.ts` | connection handles |
### 属性栏布局
```
显示名称
── UA 检查 ──
[Switch] 开启 UA 检查
说明:开启后如果请求头不携带 UA 返回 False
── UA 匹配 ──
匹配模式 Select: 或(or) / 且(and)
浏览器 MultiSelect(封闭枚举)
操作系统 MultiSelect(封闭枚举)
── 屏蔽 ──
说明:命中返回 false,优先级高于匹配
[Switch] 屏蔽常见爬虫 UA
[Switch] 屏蔽非正常 UA
```
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
- 纯设计文档不写 changelog 以外的英文同步。
## 验收标准
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
## 风险与缓解
| 风险 | 缓解 |
|------|------|
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
@@ -1,159 +0,0 @@
# 站点级访问频率限制设计
日期:2026-07-20
状态:已评审待实现
方案:站点详情 Limits 暴露 `limit_req_per_ip`;渲染时按 effective rate 生成多 `limit_req_zone`,并用站点键隔离 IP 计数
## 背景
全局默认已有:
- `openresty_default_limit_conn_per_server`
- `openresty_default_limit_conn_per_ip`
- `openresty_default_limit_rate`
- `openresty_default_limit_req_per_ip`
站点级并发/带宽已在「反代站点详情 → 流量限制」中配置,语义为:空/`0` 继承、`-1` 关闭、自定义覆盖。
请求频率(`limit_req`)后端字段与 merge 已存在,但:
1. 前端站点详情未暴露 `limit_req_per_ip`
2. 渲染侧仅在全局默认非空时输出**单一** `limit_req_zone ... rate=全局值`,站点自定义 rate 无法真正独立生效(nginx 的 rate 写在 zone 上,不能仅靠 location 覆盖)
## 目标
1. 在**仅站点详情「流量限制」区块**配置单 IP 请求频率。
2. 语义与现有三项一致:空/`0` 继承全局;`-1` 关闭;合法 `Nr/s` / `Nr/m` 为站点自定义。
3. 站点自定义 rate **真正按该 rate 生效**(A 站 5r/s、B 站 10r/s 互不影响)。
4. 同 IP 在不同站点的频率配额**按站点隔离**。
5. 修改后仍需发布配置版本;Agent 使用与 Server 同源的 render 路径。
## 非目标
- 在「安全性 → 限流」页增加按站点列表编辑
- 新建站点表单中的频率字段
- 按路径 / URI 差异化频率限制
- 改变 `limit_conn_*` / `limit_rate` 的现有 zone 与合并模型
- 业务 Zone(顶级域 + 二级域名资源)模型变更
## 语义
### 站点字段 `limit_req_per_ip`(字符串)
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 继承全局 `openresty_default_limit_req_per_ip` |
| `"-1"` | 本站显式关闭频率限制 |
| `^\d+r/[sm]$`(大小写不敏感,存小写) | 本站自定义 rate |
### 全局默认
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 默认关闭;继承方亦不输出 `limit_req` |
| 合法 rate | 未配置站点的 effective rate |
### 合并(与现有 `mergeLimitRate` 一致)
```
if route == -1: effective = off
else if route is set: effective = route // 合法 rate
else: effective = global // route 空/0
// global 空/0 → off
```
## 渲染
### 问题
nginx `limit_req_zone` 的 `rate=` 在 zone 声明时固定;多个站点若 effective rate 不同,必须使用不同 zone。
### 步骤
1. 在 `RenderRouteConfig` / main 配置生成前,对全部 route 计算 effective `LimitReqPerIP`。
2. 收集非空 effective rate 的**去重集合**,在 `http {}`(`renderOpenRestyLimitZoneBlock` 扩展,需能访问 routes 或 precomputed rates)输出:
```nginx
# 变量键:站点名 + IP,保证跨站点计数隔离
# 实现可用 map 或在 server 内 set 后引用;zone key 采用组合键
limit_req_zone $openflare_req_key zone=openflare_req_<rate_token>:10m rate=<rate>;
```
`rate_token` 由 rate 规范化生成(如 `10r/s` → `10rs`,`100r/m` → `100rm`),仅作 zone 名片段,合法 nginx zone 名。
3. 每个业务 server 在 access 相关位置之前设置:
```nginx
set $openflare_req_key "$openflare_waf_site$binary_remote_addr";
```
(与现有 `set $openflare_waf_site "..."` 同源 site_name;若某 server 无 waf site 变量则用同一 displayName/site_name。)
4. `renderRouteLimitBlock` 在 effective rate 非空时输出:
```nginx
limit_req zone=openflare_req_<rate_token> burst=<calculateBurst> nodelay;
limit_req_status 429;
```
5. **无任何** effective rate 时:不输出任何 `limit_req_zone` / `limit_req`(避免引用不存在的 zone)。
6. 应用范围与现有 limit 块一致:HTTP/HTTPS 反代 `location /`、Pages 相关 location;不含 HTTP→HTTPS 重定向-only server。
### 与旧行为差异
| 项 | 旧 | 新 |
|----|----|----|
| zone 数量 | 全局最多 1 个 | 按不同 effective rate 多个 |
| zone key | `$binary_remote_addr` | `$openflare_req_key`(站点+IP) |
| 站点自定义 rate | 无法真正独立 | 引用对应 rate 的 zone |
快照 JSON **仍保留站点原始值**(含空/`-1`),不把 merge 结果写回 route。
## 数据与 API
- 列 `of_proxy_routes.limit_req_per_ip` 已存在;无新迁移(若环境已跑过既有迁移)。
- API `Input` / `View` 已有字段;normalize / 校验已存在。
- 前端类型与详情表单补齐即可。
## 前端
仅改站点详情 `limits-section.tsx`:
- 增加「单 IP 请求频率」输入
- 校验:空、`0`、`-1`、或 `^\d+r/[sm]$i`
- 规范化:trim + lower;`0` → `""`
- `ProxyRouteItem` / `ProxyRouteMutationPayload` 增加 `limit_req_per_ip`
- `buildPayloadFromRoute` 带上该字段,避免其它区块保存时丢失
文案:与并发/带宽一致(空或 0 继承;-1 关闭;例如 10r/s、100r/m 自定义)。
## Agent / 发布
- 配置保存后须**发布配置版本**
- Agent **本地** `RenderJSON`;必须部署含本设计 render 的 Agent,否则 source 有字段但 conf 无指令
- 若 Agent 已记录同 version/checksum,升级二进制后需触发重新 apply(重启或强制重同步)
## 测试
- `mergeRouteLimitConfig`:继承 / 覆盖 / `-1`(已有则补 rate 断言)
- 多站点不同 effective rate:main conf 含多个 `limit_req_zone`,各 location 引用正确 zone 名
- 全关闭:无 `limit_req` 相关指令
- 仅全局有值:一个 zone + 未自定义站点引用该 zone
- 前端类型与表单校验(手工或既有模式)
## 验收
1. 全局 `10r/s`,站点空 → 该站 location 有 limit_req,zone rate=10r/s
2. 站点改 `5r/s` 并发布 → 该站引用 5r/s zone
3. 站点 `-1` → 该站无 limit_req
4. 两站不同 rate,同 IP 压测互不抢同一配额
## 实现边界
| 层 | 工作量 |
|----|--------|
| 渲染 `pkg/render/openresty` | 多 zone + 站点键 + location 引用 |
| 前端详情 Limits + types + payload | 补字段 |
| 后端 API/DB | 已具备,仅回归 |
| 文档/changelog | 用户可见变更记中文 changelog |
@@ -1,253 +0,0 @@
# Frontend i18n Design
Date: 2026-07-24
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
Scope: Frontend UI only
## 1. Goals
Add bilingual UI support for Wavelet frontend:
- Languages: `zh-CN` and `en`
- Default locale: `zh-CN`
- Locale resolution: explicit user choice → browser language → default
- Phase 1: infrastructure + core paths only (layout / auth / settings)
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
### Non-goals (Phase 1)
- Backend API error / message localization
- Email / push notification localization
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
- Full translation of all admin business pages
## 2. Context
Current state:
- Root layout hardcodes `lang='zh-CN'`
- UI copy is mostly Chinese string literals across many TSX files
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
- No i18n library is installed
- Frontend supports both normal Next rewrites mode and static export embed mode
## 3. Approach
Use **next-intl in non-routing / provider mode**.
Why this approach:
- Mature App Router integration and clear `useTranslations` API
- ICU message format ready when needed
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
- Cookie + browser detection matches product preference without SEO path requirements
Rejected alternatives:
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
## 4. Architecture
```
RootLayout
html lang={locale}
ThemeProvider
CustomThemeProvider
AppQueryProvider
NextIntlClientProvider(locale, messages)
existing User / Notification / Bell providers
pages + components
```
### Key files
| Path | Responsibility |
| --- | --- |
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
| `frontend/messages/zh-CN.json` | Chinese messages |
| `frontend/messages/en.json` | English messages |
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
### Runtime flow
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
2. Load `messages/{locale}.json`
3. Provide locale + messages through `NextIntlClientProvider`
4. Components call `useTranslations('<namespace>')`
5. Language switcher writes cookie and refreshes locale/messages
6. Update `document.documentElement.lang`
## 5. Locale Resolution
Supported locales: `zh-CN`, `en`
Normalization:
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
- anything else → `zh-CN`
Priority:
1. User explicit choice stored in cookie `NEXT_LOCALE`
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
3. Default `zh-CN`
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
## 6. Static Export Compatibility
Constraints:
- No locale-segment routes
- No middleware-based locale rewriting required for correctness
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
Behavior:
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
## 7. Message Organization
Single catalog files with nested namespaces:
```json
{
"common": {
"save": "保存",
"cancel": "取消",
"loading": "加载中..."
},
"layout": {
"nav": {
"home": "首页",
"myFiles": "我的文件"
},
"userMenu": {
"settings": "设置",
"logout": "退出登录"
}
},
"auth": {
"login": {
"title": "登录",
"submit": "登录"
}
},
"settings": {
"appearance": {
"language": "语言",
"languageDesc": "选择界面显示语言"
}
}
}
```
Conventions:
- Keys use camelCase and hierarchical grouping
- Prefer complete phrases as values; avoid assembling sentences in components
- Use ICU only when needed (`{name}`, plural forms)
- Backend `error_msg` values are shown as-is in Phase 1
- Frontend-owned toast / validation copy is translated
Both locale files must keep the same key tree. A key-alignment check script is recommended.
## 8. Language Switcher UX
Placement:
- Header toolbar near theme controls
- Appearance settings page as an explicit preference row
UI labels for language options use native names and do not themselves translate:
- `中文`
- `English`
On change:
1. Persist `NEXT_LOCALE`
2. Apply new locale/messages (via refresh or controlled provider update)
3. Sync `document.documentElement.lang`
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
## 9. Phase 1 Migration Scope
### In scope
- Install and wire `next-intl`
- Message catalogs for core namespaces
- Locale resolution + persistence
- `LanguageSwitcher`
- Translate:
- layout shell: sidebar nav/user menu, header accessible labels / titles
- auth: login / register / OTP labels, buttons, validation messages
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
- Replace date/number hardcoding only where touched by the above paths
- Ensure `html lang` reflects active locale
### Out of scope
- Remaining admin pages and deep business modules
- Backend localization
- Route prefixing / SEO alternate links
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
## 10. Formatting Helpers
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
- `formatDateTime(value, locale)`
- `formatNumber(value, locale)`
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
## 11. Error Handling & Fallbacks
| Case | Behavior |
| --- | --- |
| Missing message key | Dev warning; do not crash; show key or fallback language value |
| Unsupported cookie locale | Normalize to supported locale / default |
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
| Backend error strings | Display raw `error_msg` |
## 12. Testing & Acceptance
Manual:
1. No cookie + browser Chinese → Chinese UI
2. No cookie + browser English → English UI
3. Manual switch to English survives refresh
4. Manual switch back to Chinese survives refresh
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
6. `pnpm build` and `pnpm build:embed` both succeed
7. Language switch does not break theme, session, or sidebar state
Automated (recommended):
- Unit tests for `normalizeLocale` / resolution priority
- Script or test asserting `zh-CN.json` and `en.json` key parity
## 13. Rollout Plan (high level)
1. Add i18n infrastructure and empty/core message files
2. Mount provider and language switcher
3. Migrate layout shell copy
4. Migrate auth copy
5. Migrate settings copy + appearance language control
6. Verify SSR and static-export builds
7. Document how later pages should adopt `useTranslations`
## 14. Open Implementation Notes
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
- Keep `internal/util` and backend packages untouched
- After implementation, follow repo frontend conventions and existing provider composition style
@@ -1,22 +0,0 @@
# 源站错误页设计(Spec)
> 权威正文与产品文档索引见:[docs/design/origin-error-page.md](../../design/origin-error-page.md)
> 本文为 brainstorming 流程落库副本,内容与上者保持一致。
---
## 摘要
源站/网关在用户配置的状态码(默认标签 `500-599`,支持 `522` 与 `500-599` 区间)上,返回全局可配置 HTML 错误页,替代当前透传行为。默认 Cloudflare 风格页;可在线自定义 HTML。HTTP **status 保持原错误码**,正文通过 `{{status}}` / `{{host}}` 展示。配置挂在 OpenFlare Option,随配置版本发布;侧栏「网站管理 → 错误页」。可关闭以恢复透传。
## 方案
**方案 A(已采纳)**:全局 Option → 配置快照 `ConfigSnapshot` → OpenResty 渲染 `proxy_intercept_errors` + `error_page` + internal location 模板替换。
非目标:按路由覆盖、上传文件、Pages 路由、改 WAF 自有页。
## 详细章节
完整章节(目标、状态码语法、配置模型、边缘渲染、前端、测试、实现清单、决策记录)见:
**[docs/design/origin-error-page.md](../../design/origin-error-page.md)**
@@ -1,177 +0,0 @@
# 日志数据库解耦设计(ClickHouse 可选化)
> 状态:已与用户逐段确认,待用户复核。
> 日期:2026-08-08
## 1. 背景与目标
当前系统日志/分析(访问日志、可观测时序)完全绑定 ClickHouse:`internal/repository/analytics` 直接操作 `db.ChConn`/`db.ChDB`,apps 层(`chwriter`、`risk_control`、`admin/logs`、`admin/status`)依赖 `config.ClickHouse.Enabled` 判断可用性。业务流量小、主机性能低时 ClickHouse 负担大。
目标:
1. **解耦**:ClickHouse 变为可选项;不启用时,主库(PostgreSQL;禁用时 SQLite)完整承接全部日志功能(写入、查询、聚合、清理)。
2. **代码级约束**:上层应用写日志不能直接调用底层库(`analyticsrepo` / `db.ChConn`),用接口 + import-lint 测试保证,而非 AGENTS.md 口头约束。
3. **可迁移**:提供用户触发的「切换日志数据库」任务,支持 PostgreSQL/SQLite ↔ ClickHouse 数据迁移。
4. **表结构**:CH 日志表迁入 PG/SQLite;CH 保持只有日志表的 SQL 脚本;PG/SQLite 包含全部表。
## 2. 现状要点
- 连接:`internal/infra/persistence/clickhouse.go`(`ChConn` 原生批量写 + `ChDB` GORM 查询),`init()` 依据 `clickhouse.enabled`。
- 分析域:`internal/repository/analytics/` 直接读写 CH;apps 通过 `batchwriter` 异步 flush(`chwriter`、`risk_control`)。
- 已有抽象雏形:`internal/repository/openflare_access_log_store.go` / `openflare_observability_store.go` 中的未导出 `accessLogStore` / `observabilityStore` 接口,默认 `clickhouseAccessLogStore{}`,测试可换 memory 实现——默认写死 CH、不可配置切换、接口未导出。
- 迁移:主库 goose(`goose/postgres` + `goose/sqlite` 双方言)与 CH 单方言(`goose/clickhouse`)分离。
- 历史:PG/SQLite 曾有过 `of_node_metric_snapshots`、`of_node_access_logs` 等观测表(`202606190010_create_of_observability_tables.sql`),后由 `202606200005_drop_of_node_observability_timeseries.sql` 删除(迁去 CH)。**旧 DDL 可复活改造**。
- 任务:Asynq + `task.RegisterHandler`/`RegisterTaskMeta`;`system_cleanup`(系统垃圾清理)每日任务已存在;`of_database_auto_cleanup`(可观测清理,schedule id=102)存在。
- 系统配置:`system_configs` 表(key/type/visibility),现有 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`(business)。
## 3. 已确认的核心决策
| # | 决策 |
|---|---|
| 1 | 范围:CH 不启用时,PG(或 SQLite)承担**全部**日志功能;聚合在 PG/SQLite 查询时实时计算,不物理建 MV 同构表。 |
| 2 | 实现:接口定义在 repository 层;PG 用 GORM 全新实现;CH 保留现有原生批量优化(`PrepareBatch`)包进同一接口。 |
| 3 | SQLite 是一等公民:`log_database` ∈ {`postgres`, `sqlite`, `clickhouse`};迁移方向 PG→CH、SQLite→CH、CH→PG、CH→SQLite。 |
| 4 | 日志库只有两种合法状态:**随主库**(`database.enabled` → postgres,否则 sqlite)或 **clickhouse**;不存在主库 PG + 日志 SQLite 的组合。 |
| 5 | 迁移任务「切换日志数据库」:纯复制、**源数据不删除**、可重试;迁移期间**冻结日志写入**(拒绝,不排队积压);全部成功才翻转主库标记。 |
| 6 | 清理统一到 `system_cleanup`(每日一次,日志过期无需实时);保留时间按**存储库**配置(`type=business`)。 |
## 4. 包结构与接口(方案一)
新增 `internal/repository/logstore/`,职责唯一:日志存储抽象。
```
internal/repository/logstore/
├── logstore.go # 导出接口:AccessLogStore / ObservabilityStore / UserAccessLogStore / CleanupStore / StatusStore
├── provider.go # Open(ctx) 按当前日志主库返回实现;ActiveDatabase() 供状态/UI;测试可注入
├── postgres_store.go # GORM 实现(PG 与 SQLite 共用一套,方言差异只在 goose DDL + dialect_* 小文件)
├── dialect_postgres.go # PG 方言 SQL 片段(date_trunc / FILTER / 分区清理)
├── dialect_sqlite.go # SQLite 方言 SQL 片段(strftime / unixepoch)
└── clickhouse_store.go # 把现有 analyticsrepo 原生批量 + GORM 查询包进接口(零性能损耗)
```
- **接口划分**(避免 40+ 方法巨型接口,合成 `logstore.Store` 结构体持有):
- `AccessLogStore`:节点访问日志的 InsertBatch / List / Count / RegionCounts / BucketAggregates / CountBuckets / BucketDimensions / IPAggregates / IPSummaries / CountIPSummaries / WAFIPAggregates / IPTrend / TrafficSummary / ValueCounts / NodeAggregates / DeleteAll / DeleteBefore / DeleteByNodeBefore。
- `ObservabilityStore`:4 表(metric snapshots / edge health / frps / frpc)的 Insert / List / Delete。
- `UserAccessLogStore`:`w_user_access_logs` 的 BatchInsert / Count / List / 统计(DailyTrend / BrowserDistribution / TopActiveUsers 等)。
- `CleanupStore`:按保留天数清理过期数据(PG=分区 DROP + 分批 DELETE;SQLite=分批 DELETE;CH=MODIFY TTL + materialize)。
- `StatusStore`:当前库状态、CH 运行指标(激活时)、GORM 写入器状态。
- **消费面**:`internal/repository` 现有公开函数(`ListOpenFlareAccessLogs`、`InsertOpenFlareAccessLogsBatch`、`InsertOpenFlareMetricSnapshot` 等)**保留签名、改为一行委托 `logstore`**,apps 调用面几乎不动;apps 里现有 `analyticsrepo` 直连(`risk_control`、`chwriter`、`tasks/database_cleanup.go`、`observability/access_log_logics.go`、`admin/logs`、`admin/status`)全部改走 repository/logstore。
- **import-lint 测试**:新增 `go test`,扫描 `internal/apps/**` 的 import,发现 `internal/repository/analytics` 或 `internal/infra/persistence`(`batchwriter` 白名单除外)即失败。这是「代码层面规避」的验收。
- `analyticsrepo` 保留,仅被 `logstore/clickhouse_store.go` 引用(CH 实现细节)。
### 主库标记与启动校验
- `system_configs` 新增内部 key:
- `log_database`(`postgres`/`sqlite`/`clickhouse`):当前日志主库,仅迁移任务写入。
- `log_db_migration`(`"migrating"`/空):迁移冻结标记,仅迁移任务写入。
- **首次 seed**(bootstrap Go 侧,因依赖运行时主库选择):key 缺失时,`clickhouse.enabled` → `clickhouse`(保持现状、不丢现有 CH 数据);否则 → 当前主库(`database.enabled` → `postgres`,否则 `sqlite`)。
- **启动校验**(bootstrap):
- `log_database=clickhouse` 但 `clickhouse.enabled=false` → 启动报错:「当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse」。
- `log_database=postgres` 但 `database.enabled=false`,或 `log_database=sqlite` 但 `database.enabled=true` → 启动报错(违反「随主库或随 CH」规则)。
- **key 保护**:`log_database`、`log_db_migration` 在配置更新接口(admin system-configs / option 校验)拒绝修改;仅迁移任务可写;启动校验兜底被篡改组合。
- **热切换**:`logstore` 通过系统配置缓存(Redis,更新即失效)读取 `log_database`;翻转后 API 进程自动切到新实现,无需自定义跨进程协议。
## 5. PG/SQLite 表结构与优化
**新建原始日志表(PG + SQLite 双方言 goose,同版本号)**——只建原始表,**不建** CH 物化视图/聚合表(`of_access_log_hourly`、`of_node_metric_capacity_hourly` 等),PG/SQLite 查询时实时聚合:
| 表 | 说明 |
|---|---|
| `w_user_access_logs` | 用户访问日志 |
| `of_node_access_logs` | 节点访问日志(含 user_agent/cache_status/bytes_sent/request_length/request_time_ms 现行列) |
| `of_node_metric_snapshots` | 资源指标 |
| `of_node_edge_health` | 边缘健康 |
| `of_node_obs_frps` | FRPS 观测 |
| `of_node_obs_frpc` | FRPC 观测 |
- **ID**:沿用 snowflake uint64(DDL 用 BIGINT,与 CH UInt64 对齐);不换自增,保证迁移 ID 原样保留、无冲突。
- **时间**:PG `TIMESTAMPTZ`;SQLite `DATETIME`。
- **复合主键**:分区表主键 `(id, 时间列)`(满足 PG 分区键进唯一索引要求)。
### PG 优化
1. **分区**:仅 `of_node_access_logs`、`w_user_access_logs` 两个高频表用 PG 原生 `PARTITION BY RANGE` **按月分区**;可观测 4 表数据量小,普通表 + 索引。SQLite 无原生分区 → 普通表 + 组合索引(方言差异只留在 goose DDL,运行时 GORM 代码共用)。
2. **批量写入**:PG/SQLite 统一 GORM `CreateInBatches`(批次 500–1000);CH 维持原生 `PrepareBatch`。
3. **索引**:
- `of_node_access_logs`:`(logged_at DESC)`、`(node_id, logged_at DESC)`、`(host, logged_at DESC)`;
- `w_user_access_logs`:`(created_at DESC)`、`(user_id, created_at DESC)`;
- 可观测表:`(node_id, captured_at DESC)`。
4. **聚合查询重写**:PG 用 `date_trunc` / `count(DISTINCT)` / `FILTER (WHERE ...)` 等价替换 CH 的 `toStartOfHour` / `uniqExact` / `countIf`;SQLite 用 `strftime` / `unixepoch`。时间分桶等少量方言 SQL 拆到 `dialect_postgres.go` / `dialect_sqlite.go`,store 主体方言中立。
### goose 迁移
- PG/SQLite 各新增一组建表迁移(复活并改造 `202606190010` 旧 DDL,按 database-migration 技能双方言、同版本号规则)。
- CH 目录不动(本来就只有日志表脚本,满足「CH 保持只有日志表 SQL」)。
## 6. 清理(并入 system_cleanup)
- 日志过期清理并入 `system_cleanup`(系统垃圾清理)每日任务;`of_database_auto_cleanup` 专用 schedule(id=102)与任务下线。
- 新增 `type=business` 配置(替换旧 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`):
- `log_retention_days_postgres`(默认 90)
- `log_retention_days_sqlite`(默认 90)
- `log_retention_days_clickhouse`(默认 90)
- `CleanupStore` 按当前生效库读取对应值执行:
- PG:分区 DROP(整月)+ 分批 DELETE(不满月);
- SQLite:分批 DELETE;
- CH:`ALTER TABLE ... MODIFY TTL toDateTime(...) + INTERVAL N DAY` + materialize(保留期由配置驱动,不再依赖 DDL 写死)。
- 旧 key `database_auto_cleanup_*` 由 goose 迁移删除,前端同步清理。
## 7. 迁移任务「切换日志数据库」
**元数据**:Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,名称「切换日志数据库」,参数 `target`(`postgres`/`sqlite`/`clickhouse`),`Retryable: true`。UI 按当前日志主库只展示合法目标(当前=CH → 「主库」;当前=主库 → 「ClickHouse」)。
**执行流程(worker 进程)**:
1. **校验**:`target == 当前主库` → 拒绝;`target=clickhouse` 但 CH 未启用 / `target=postgres` 但 `database.enabled=false` / `target=sqlite` 但 `database.enabled=true` → 拒绝。
2. **写冻结**:写 `log_db_migration = "migrating"`;先让 batchwriter 把在途批次 flush 完;此后 API 进程所有日志写入路径(`risk_control`、`chwriter` 队列、agent 上报落库)检查该 key → 返回明确错误(HTTP 503「日志数据库迁移中,暂不可写」),不排队积压。
3. **复制**:6 张原始日志表逐表、按 id 分批(每批 ~1000)读源 → 写目标(CH→主库用 GORM `CreateInBatches`;主库→CH 用原生 `PrepareBatch`);ID 原样保留;每表/每批 `task.AppendLog` 进度。
- **幂等前提**:开始复制前**清空目标库日志表**(任务参数「覆盖目标库已有日志」默认开启;目标库通常为空,仅「切回去」场景有旧数据)——保证失败重试可重跑不重复。
4. **翻转**:全部成功 → 更新 `log_database = target`、清除迁移标记 → `logstore` 缓存失效自动切到新实现 → 写入恢复(走新库)。
5. **失败**:返回错误触发 Asynq 重试;**失败时清除迁移标记**,写入继续走源库(不丢功能);重试时重新清空目标 + 复制。
**双进程一致性**:迁移标记与主库标记落在 `system_configs`(Redis 缓存,worker 更新后 API 进程自动失效重读)。
## 8. API 与前端
**后端**:
- `GET /api/v1/admin/status/log-database`(改造现有 `/clickhouse` 状态端点):返回当前日志主库、迁移状态(`idle`/`migrating`)、各库保留天数、当前合法迁移目标;CH 为主时附带现有 CH 运行指标,主库为主时附带 GORM 写入器状态。
- 任务「切换日志数据库」走现有任务管理通用派发 API(`RegisterTaskMeta` + Params),无需新派发接口;执行记录/进度复用任务框架。
- 系统配置:新增 3 个 `log_retention_days_*`(business)图形化 + 参数表可见;新增内部 `log_database`、`log_db_migration`(system、visibility=0、受保护);下线 `database_auto_cleanup_*`。
**前端**:
- 任务管理页:出现「切换日志数据库」,参数下拉只显示合法目标;页面展示当前日志主库与迁移状态。
- `/admin/settings` 业务配置:新增「日志保留时间」分组(PG/SQLite/CH 三个数字输入)。
- 状态/仪表盘:日志库状态卡片(当前库 + 迁移中提示)。
## 9. 测试与验证
- **import-lint 测试**:`internal/apps/**` 不得 import `internal/repository/analytics`、`internal/infra/persistence`(`batchwriter` 白名单除外),违规即失败。
- **logstore 单测**:GORM 实现用 SQLite 全量跑;PG 专属(分区 DROP 等)走既有集成测试路径;CH 实现复用现有 analyticsrepo 测试。
- **迁移任务测试**:目标/组合校验、批处理与 ID 保留、清空目标、翻转标记、失败清标记回退、冻结期写入拒绝——用 memory/sqlite 双端模拟,不依赖真实 CH。
- **清理测试**:`system_cleanup` 日志清理步骤(PG 分区 DROP / SQLite 分批 DELETE / CH TTL 修改)与保留配置读取。
- **迁移验证**:goose 空库 Up 全量(PG/SQLite/CH 三套)、`go test ./...`、`make swagger`(API 变更)、`make code-check`、`make format`。
## 10. 非目标(YAGNI)
- 不在 PG/SQLite 物理建聚合/物化视图表(查询实时聚合)。
- 不做 PG ↔ SQLite 日志互迁(非法组合,启动校验拒绝)。
- 迁移成功不自动删除源库数据(保留,后续提供手动清理入口)。
- 不引入 PG COPY 协议(GORM `CreateInBatches` 对低流量足够)。
- 不引入自定义跨进程迁移协议(`system_configs` + Redis 缓存即可)。
## 11. 里程碑建议(供实现计划分解)
1. **M1 抽象与改造**:`logstore` 接口 + PG/SQLite 实现 + `clickhouse_store` 包装 + import-lint 测试 + repository 委托改造 + apps 直连改造 + `log_database`/`log_db_migration` key 与启动校验。
2. **M2 表与清理**:goose 双方言建表迁移 + 保留配置 key + `system_cleanup` 日志清理步骤 + 下线 `of_database_auto_cleanup` 与旧配置。
3. **M3 迁移任务与展示**:迁移任务 Handler + 状态端点 + 任务管理页/业务配置前端 + 日志库状态卡片。
4. **M4 收尾**:全量验证(goose 三套、单测、`make code-check`/`swagger`/`format`)、文档同步(中文)、changelog `[Unreleased]`。
## 12. 实现归档说明(Task 18,2026-08-08)
- 设计稿第 4 节 provider 入口写作 `Open(ctx)`,实现命名为 `Active(ctx)`(按 `log_database` 解析并缓存,配置翻转后重建),另导出 `Build(ctx, database)` / `BuildForMigration(ctx, database)` 供迁移任务构造目标库 store;`ActiveDatabase(ctx)` 供状态端点。
- 设计稿第 4 节列出的 `CleanupStore` 接口未单独落地:清理实现为包级 `CleanupExpired(ctx)`(按当前激活库保留天数删除过期日志并预建 PG 分区),由 `system_cleanup` 每日任务调用。
- 设计稿第 4 节列举的 `tasks/database_cleanup.go` 已随 M2 下线(`of_database_auto_cleanup` 配置与前端 UI 一并移除),日志清理职责并入 `system_cleanup`。
- 迁移复制按 id 升序分页,`copyObservability` 以每批最后一条 id 作为下一批游标(修正计划中 `lastID += n` 的近似写法);失败回退由 `defer setMigrationFlag("")` 保证源库恢复可写,重试前先清空目标库保证幂等。
- 其余实现决策(`SetConfigReader` 注入、`ensureWritable` 统一冻结、解析 helper 迁至 `model/analytics` 等)见计划「自检记录」,与本文档一致。
@@ -1,117 +0,0 @@
# Service Worker 离线兜底设计(issue #23)
- 日期:2026-08-08
- 状态:设计已确认
- 范围:Proxy Route(反代)+ Pages 静态托管 全覆盖
## 1. 背景与目标
当 CDN 域名被墙、浏览器对所有网络请求失败时,用户会直接流失。本功能通过给网站下发 Service Worker,缓存一个"联系站长"离线页;域名被墙后,SW 从缓存吐出该页,保留用户并引导联系站长。
核心约束:
- 平台一键批量下发,避免逐个 Agent 配置。
- 不改源页代码,全部在 OpenResty 边缘层完成。
- 覆盖反代(Proxy Route)与 Pages 静态托管两种网站类型。
## 2. 机制总览
采用「首次挑战页 + Cookie 放行 + UA 白名单」模式,替代 `sub_filter` 响应体重写。
| 环节 | 行为 |
|---|---|
| 真实浏览器 UA(含特征版本,如 `Chrome/120`)首次访问首页 | 返回 SW 挑战页(内嵌 `register('/sw.js')` 与离线页预缓存),设置长过期 Cookie |
| 带 Cookie 的请求 | 直接放行到上游,正常返回真实页面 |
| 未知 UA(爬虫、curl,无真实浏览器特征) | 直接放过,交给 WAF 处理,拿到真实内容 |
### 为什么不用 sub_filter
`sub_filter` 需处理上游 gzip / Content-Type / 大响应扫描 / 流式缓冲等多处坑。本方案不改上游 body,整体替换首次响应,以上问题全部规避;且爬虫(不匹配真实浏览器 UA)天然绕过挑战页,不伤 SEO。
## 3. 分层职责
```
apps/proxy_route ─┐
apps/pages ─┼─ model → repository → 渲染(pkg/render/openresty) → Agent(OpenResty)
前端设置卡 ─┘ ↑ SW 挑战页 + sw.js/offline 落盘
```
### 后端数据(全局 Option,与 origin error page 同模式)
`sw_offline` 相关配置作为**全局 SystemConfig / OpenRestyConfig snapshot 字段**,对所有启用 HTTPS 的路由生效,实现"一键批量下发"。新增字段:
- `sw_offline_enabled`:是否启用 SW 离线兜底
- `sw_offline_html`:联系站长离线页 HTML 内容(默认提供内置模板)
### 渲染层(`pkg/render/openresty`)
新增 `renderServiceWorkerChallenger(cfg ConfigSnapshot)` 工具,为真实提供内容的 HTTPS server 块(`sw_offline_enabled` 且 `EnableHTTPS` 时)输出:
```nginx
# SW 脚本 + 离线页(作为 support file 落盘)
location = /sw.js { alias .../sw.js; add_header Service-Worker-Allowed /; }
location = /offline.html { alias .../offline.html; }
# 仅首页拦截:真实浏览器 UA 且无 cookie → 返回 SW 挑战页
# 否则(带 cookie / 未知 UA)→ 放行到上游
location = / {
if (真实浏览器UA && 无cookie) { content_by_lua 返回 SW 挑战页; }
放行到上游;
}
```
- SW 逻辑:`install` 阶段缓存 `/offline.html`;`fetch` 事件在网络失败时返回 `caches.match('/offline.html')`。
- 仅在 `EnableHTTPS` 时注入(SW 要求 HTTPS 安全上下文)。
- 多域名 server 块:`/sw.js`、`/offline.html`、挑战页在各 `server_name` 下同源可达。
- 仅对首页 `location = /` 触发;js/css/图片/API/子页面请求不拦,零额外开销。
## 4. 数据流
```
用户首次访问首页(真实UA, 无cookie)
→ OpenResty 判断:真实UA && 无cookie
→ 返回 SW 挑战页 (内嵌 register + 预缓存 offline.html)
→ 浏览器执行 → 注册 SW → 设置长过期 cookie
→ 用户再次请求(带cookie)
→ 放行到上游,正常返回真实页面
域名被墙后
→ 所有请求失败 → SW fetch 兜底 → 从缓存返回 /offline.html(联系页)
```
## 5. 边界与风险
| 项 | 处理 |
|---|---|
| 首次即被墙的用户 | SW 未注册,兜底无效(所有 SW 方案共性,接受) |
| HTTP-only 站点 | 跳过注入(SW 需 HTTPS) |
| 反代多域名 | 各域名同源提供 sw.js / offline.html / 挑战页 |
| Cookie 过期 | 设长过期(约 1 年),过期后重新走一次挑战页 |
| 未知 UA | 放过并交给 WAF 处理,不重复拦截 |
| 资源/API 请求 | 不拦,仅首页触发 |
## 6. 测试
- 渲染层单元测试:
- `sw_offline_enabled` 时输出 sw.js / offline.html / 挑战页 location
- 非 HTTPS 或未启用时不输出
- 仅首页触发,子路径/资源不触发
- UA 判定:真实浏览器 / 爬虫 / curl 三种 UA 的放行分支。
- Cookie 有无的放行分支。
- 现有 config snapshot checksum / rebind 测试不回归。
## 7. 前端命名与入口
离线联系页设置与现有 origin error page 设置合并为同一个功能模块,命名为**「响应页面」**(路由 `responses`),内含两个 tab:
- **错误页设置**:源站错误兜底页(现有 origin error page)
- **联系页设置**:SW 离线兜底联系页(本功能)
两者同属「边缘层兜底展示页」语义,统一管理与入口。
## 8. 待实现确认项(写 plan 时细化)
- SW 挑战页与 sw.js 的具体 Lua 实现与落盘路径(对齐现有 support file 机制)。
- `sw_offline_html` 默认内置模板样式(参考 origin error page 内置模板)。
- 「响应页面」前端模块下错误页/联系页两个 tab 的具体位置与交互。
- UA 白名单默认真实浏览器特征集合(Chrome / Firefox / Safari / Edge + 版本号正则)。
- SW 落盘路径:sw.js / offline.html 通过 SupportFile 下发,Agent 替换占位符(类似 ErrorPageTmplPlaceholder 机制)。
@@ -1,191 +0,0 @@
# SW 离线兜底生效范围(域名作用域)设计
- 日期:2026-08-08
- 状态:设计已确认
- 前置:issue #23 Service Worker 离线兜底(`docs/superpowers/specs/2026-08-08-service-worker-offline-design.md`)
- 范围:SW 注入从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」
## 1. 背景与目标
issue #23 实现后,`sw_offline_enabled` 为全局布尔开关:开启后对所有启用 HTTPS 的路由注入 Service Worker 离线兜底。本需求将其细化为可选的**生效域名范围**:
- 保留总开关(`sw_offline_enabled`)。
- 新增作用域:管理员选择需要生效的域名,仅作用域内域名注入 SW。
- 域名选择交互参考 `/cloudflare/groups/1` 的「添加域名成员」弹窗(搜索筛选、按 Zone 分组、批量勾选),但**与 Cloudflare 完全解耦**——仅复用交互模式,数据源为平台自身 zones/zone_domains,不涉及 A 记录同步。
核心约束:
- 语义为「总开关 && 域名 ∈ 作用域」交集:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
- 与 Cloudflare 指向分组(A 记录)无任何关联。
- 联系页 HTML(`sw_offline_html`)仍为全局单份,不分域名定制。
## 2. 机制总览
```
sw_offline_enabled (bool, 已有) 总开关
sw_offline_html (string, 已有) 联系页 HTML(全局一份)
sw_offline_domains (JSON 字符串数组, 新增) 生效域名作用域
渲染: routeSWEnabled(routeDomains, cfg)
= SWOfflineEnabled && routeDomains ∩ SWOfflineDomains ≠ ∅
命中 → HTTPS server 块注入 access 检查 + SW location
未命中 → 与 feature 前字节一致
```
Support files(`sw/sw.js`、`sw/offline.html`)仅在「总开关开 && 作用域非空」时下发,避免空作用域产生无用资源。
## 3. 数据层
### 3.1 配置 key
`model.ConfigKeySWOfflineDomains = "sw_offline_domains"`(business 类型,visibility 0),值存 JSON 域名字符串数组:
```json
["example.com", "api.example.com"]
```
### 3.2 goose 迁移(postgres + sqlite 各一份)
`INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING;`
Down 删除该 key。migrator 测试计数 92 → 93,并更新注释。
### 3.3 validator
`validateSWOfflineDomains(key, value string) error`,注册进 `openRestyOptionValidators`:
- JSON 解析为 `[]string`,失败报「必须为 JSON 字符串数组」
- 元素去重(重复报错)
- 元素非空、小写规范化校验(复用/对齐 zone `normalizeDomain` 的域名格式约束:无 `*`、无 `://` `/` `?` `#` `@`、`publicsuffix.EffectiveTLDPlusOne` 可解析)
- 数量上限 `maxSWOfflineDomains = 1000`(防滥用)
### 3.4 config_version snapshot
- `openRestyConfigSnapshot`(`snapshot.go`)新增 `SWOfflineDomains []string json:"sw_offline_domains,omitempty"`。
- `buildOpenRestyConfigSnapshot` 新增 `getStringSliceConfig(key string, defaultVal []string) []string`(解析 JSON 数组,失败回退默认),赋值 `SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil)`。
- `logics.go`:`diffOpenRestyOptionDetails` 追加 `appendIfChanged("SWOfflineDomains", ...)`;`openRestyOptionKeys()` 追加 `"SWOfflineDomains"`。
## 4. 渲染层(pkg/render/openresty)
### 4.1 ConfigSnapshot
`types.go` 的 `ConfigSnapshot` 新增:
```go
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
```
### 4.2 作用域判断
```go
// routeSWEnabled returns true when SW offline fallback applies to this route.
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
return false
}
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
for _, d := range cfg.SWOfflineDomains {
scope[d] = struct{}{}
}
for _, d := range routeDomains {
if _, ok := scope[d]; ok {
return true
}
}
return false
}
```
域名精确匹配(存储时已小写规范化)。
### 4.3 server 渲染签名扩展
- `RenderRouteConfig`:每 route 计算 `swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)`,传入 `renderProxyRoute` / `renderPagesRoute`(新增 `swEnabled bool` 参数)。
- 下传链路:`renderProxyRouteHTTPS` / `renderPagesRouteHTTPS` / `renderHTTPSServer` / `renderHTTPSPagesServer` 均新增 `swEnabled bool` 参数。
- `swEnabled=true` → `renderAccessBlockWithSW(siteName, powEnabled, cfg)` + 追加 `renderServiceWorkerChallenger(cfg)`(现行为,两函数内部不再判断 `SWOfflineEnabled`,条件已上移到 route 层)。
- `swEnabled=false` → 纯 `renderAccessBlock`,无 challenger(与 feature 前字节一致)。
- HTTP(80)server 块保持不注入(issue #23 已定 HTTPS-only)。
- `renderAccessBlockWithSW` / `renderServiceWorkerChallenger` 保留 `cfg` 参数(HTML 内容来自 `cfg.SWOfflineHTML`),仅移除其内部开关判断。
### 4.4 Support files
`Render` 中生成条件从 `if doc.OpenRestyConfig.SWOfflineEnabled` 改为:
```go
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
}
```
### 4.5 测试
- `routeSWEnabled`:开关关 / 作用域空 / 无交集 / 单域名交集 / 多域名部分交集。
- HTTPS server 渲染:命中 → 含 `require("sw.runtime").check()` + 三个 SW location;未命中 → 与旧输出字节一致。
- `Render`:空作用域不下发 `sw/*` support files。
- 现有 `TestRenderAccessBlockWithSWMergesSingleBlock` 等适配新签名(`cfg` 语义变化:禁用时不再由内部判断,改由上层传 `swEnabled`)。
## 5. 前端(frontend/app/(main)/responses)
### 5.1 联系页 tab 布局
联系页 tab 两张卡片:
**卡片 1:离线兜底(总开关)**
- 标题「离线兜底」+ 描述。
- 右上角「保存」按钮。
- 「启用 Service Worker 离线兜底」Switch(`sw_offline_enabled`)。
- 「生效范围」区块:当前已选域名 badge 列表(可移除)+「添加域名」按钮打开弹窗;开关关闭时整卡禁用/置灰。
- 保存时 `updateBatch` 一次性提交三个 key:
```ts
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
{ key: KEY_SW_HTML, value: fields.html },
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
```
- 保存成功后 `invalidateResponseQueries`(toast 提示「请前往版本发布使配置生效」不变)。
**卡片 2:联系页 HTML**
- 复用 `HtmlEditorWorkspace`(见 5.3),无占位符,实时预览原样 HTML。
### 5.2 域名选择弹窗(scope-domain-dialog.tsx)
- 交互复用 `member-add-dialog.tsx`:搜索框(域名/zone 模糊匹配)、按 Zone 分组折叠、组内勾选/取消、全选可见/清空、已选计数。
- 无橙云开关、无 Cloudflare 依赖。
- 数据源:`ZoneService.list()` + 每 zone `ZoneService.getOverview(id)` 并行拉取(`Promise.all`),zone 根域并入对应分组。**不新增后端 API**。
- 弹窗预勾选当前已生效域名;确认后返回选中的域名字符串数组(覆盖式替换本地 fields.domains)。
- 空态:无 zone 时提示「暂无可用域名,请先在 Zone 管理中注册」。
### 5.3 HtmlEditorWorkspace 复用(泛化)
`frontend/app/(main)/error-pages/components/html-editor-workspace.tsx` 泛化并移至 `frontend/components/common/html-editor-workspace.tsx`:
- Props 扩展:
- `maxBytes?: number`(默认 `ORIGIN_ERROR_PAGE_HTML_MAX_BYTES` = 256 KiB,SW 同为 256 KiB 常量可共用)
- `preview?: (html: string) => string`(默认 `previewOriginErrorPageHTML`;SW 传 `(html) => html` 原样预览)
- `footerHint?: React.ReactNode`(预览 footer 提示文案,默认错误页的「`{{status}}`→502 · `{{host}}`→example.com」;SW 传 `null`)
- 错误页 `edit/page.tsx` 改 import 路径,行为不变。
- `frontend/components/common/` 若不存在则创建目录。
### 5.4 shared.ts 与表单
- `KEY_SW_DOMAINS = 'sw_offline_domains'`。
- `ContactPageFields` 增加 `domains: string[]`;`defaultContactPageFields.domains = []`。
- `mapOptionsToContactFields` 解析 `sw_offline_domains` JSON(容错:非法 JSON → `[]`)。
## 6. 验证
- 后端:`go test ./pkg/render/openresty/... ./internal/apps/openflare/option/... ./internal/apps/openflare/config_version/... ./internal/infra/persistence/migrator/...`
- 前端:`pnpm tsc --noEmit` + `eslint`(联系页新字段/弹窗/多 zone 并行拉取)
- 全量:`go test ./...`、`make code-check`、`make format`
- `make swagger`:无新 API(验证无变更即可)
## 7. Changelog
`docs/changelog/index.md` `[Unreleased]` 更新 SW 条目:新增「可指定生效域名范围(仅对选中的 HTTPS 域名生效)」。
## 8. 已知边界
- 作用域存域名字符串数组:域名从 zone/zone_domain 改名后需手动同步作用域(与 `route.Domains` 精确匹配)。
- 联系页 HTML 全局单份,不分域名定制。
- 空作用域 + 总开关开 → 不注入(前端置灰提示先选域名)。
- 匹配为精确匹配,不跨子域通配(选 `example.com` 不自动覆盖 `api.example.com`,需显式加入)。