diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 2cca0690..0a483cfa 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -23,7 +23,7 @@ sidebar: false
### 变更
-- 边缘缓存默认策略调整为「标准静态资源」:开启站点缓存后默认仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」行为保留为「所有可缓存 GET」。
+- 边缘缓存默认策略调整为「标准静态资源」:新建开启缓存时推荐仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」与空策略存量行为保留为「所有可缓存 GET」。重新发布节点配置后生效。
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
diff --git a/docs/design/edge-cache-design.md b/docs/design/edge-cache-design.md
index ed06f1a5..4ff2e89a 100644
--- a/docs/design/edge-cache-design.md
+++ b/docs/design/edge-cache-design.md
@@ -142,9 +142,10 @@ access.log cache_status=$upstream_cache_status
| 数据 | 处理 |
| --- | --- |
-| DB 中 `cache_policy=''` 或 `url` | 读取/发布时规范为 `all`;可选一次性 SQL 更新为 `all` |
-| 新建路由 | 默认 `cache_enabled=false`;若用户开启缓存,表单默认策略 **`static`** |
-| 已开启且 `url` 的站点 | 迁移后为 `all`,**缓存范围不变** |
+| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读取 / 快照 / 渲染均规范为 **`all`**,保证存量「宽缓存」不变 |
+| API 写入时 `enabled` 且 policy 为空 | 规范为 **`all`**(兼容旧客户端);UI 新建开启时**显式提交** `static` |
+| 新建路由 | 默认 `cache_enabled=false`;表单开启缓存时默认策略 **`static`** |
+| 已开启且 `url` 的站点 | 显示与发布为 `all`,**缓存范围不变** |
| 期望「只缓存静态」的旧站点 | 用户在 UI 改为 `static` 或自定义 `suffix` |
**发布说明建议:** 说明默认策略变更仅影响**新配置**;存量 `url` 视为 `all`。
diff --git a/docs/plan/20260718-edge-cache-static-default.md b/docs/plan/20260718-edge-cache-static-default.md
index d0d0c386..e10c598f 100644
--- a/docs/plan/20260718-edge-cache-static-default.md
+++ b/docs/plan/20260718-edge-cache-static-default.md
@@ -4,19 +4,35 @@
## 目标
-路由开启缓存后默认仅缓存标准静态扩展名(`static`);存量 `url` 映射为 `all`。
+路由开启缓存后,**新建推荐**仅缓存标准静态扩展名(`static`);存量 `url`/空策略映射为 `all`,不收窄缓存范围。
+
+## 兼容规则(评审后定稿)
+
+| 场景 | 行为 |
+| --- | --- |
+| 已启用 + `''` / `url` | 读 API / 快照 / 渲染 → **`all`** |
+| 写入时 enabled 且 policy 为空 | 规范为 **`all`**(旧客户端兼容) |
+| UI 新建/推荐默认 | **显式提交** `static` |
+| 关闭缓存 | policy 存 `''`,rules 清空 |
## 修改清单
-1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;`url`/`all` 无路径限制
-2. **校验** `internal/apps/openflare/proxy_route/helpers.go`:策略枚举与规范化
-3. **前端** `cache-section.tsx` + helpers:默认 `static`,选项文案
-4. **测试** render + helpers
-5. **changelog**
+1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;空/`url`/`all` 无路径限制
+2. **校验/展示** `proxy_route/helpers.go`:`normalizeCachePolicy` + `displayCachePolicy`
+3. **快照** `config_version/logics.go`:`normalizeSnapshotCachePolicy`
+4. **前端** `cache-section.tsx` + helpers:存量 empty/url→`all`;关闭时提交 `''`;新建默认 `static`
+5. **测试** render + proxy_route
+6. **设计/changelog** 同步兼容说明
## 验证
```bash
-go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/
-make code-check # 或至少 go test + frontend tsc
+go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/ ./internal/apps/openflare/config_version/
+# 已通过(2026-07-18)
```
+
+## 状态
+
+- [x] 功能实现 + 评审修复(empty→all,禁止静默收窄)
+- [ ] 提交 `fix(cache): ...`(待用户确认)
+- [ ] 合并 / 发布后需重新发布节点配置
diff --git a/frontend/app/(main)/proxy-routes/components/helpers.ts b/frontend/app/(main)/proxy-routes/components/helpers.ts
index 3cee39b2..a7b031fb 100644
--- a/frontend/app/(main)/proxy-routes/components/helpers.ts
+++ b/frontend/app/(main)/proxy-routes/components/helpers.ts
@@ -335,12 +335,17 @@ export function buildPayloadFromRoute(
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
- cache_policy:
- !route.cache_policy || route.cache_policy === 'url'
- ? route.cache_policy === 'url'
- ? 'all'
- : 'static'
- : route.cache_policy,
+ cache_policy: (() => {
+ if (!route.cache_enabled) {
+ return 'static';
+ }
+ const policy = (route.cache_policy || '').trim();
+ // Legacy empty/url → all (same as backend displayCachePolicy).
+ if (!policy || policy === 'url' || policy === 'all') {
+ return 'all';
+ }
+ return policy;
+ })(),
cache_rules: route.cache_rule_list ?? [],
custom_headers: route.custom_header_list ?? [],
basic_auth_enabled: route.basic_auth_enabled,
diff --git a/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx b/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx
index fd22f82f..c1a36904 100644
--- a/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx
+++ b/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx
@@ -39,7 +39,6 @@ const cacheSchema = z
cache_policy: z.enum([
'static',
'all',
- 'url',
'suffix',
'path_prefix',
'path_exact',
@@ -70,10 +69,17 @@ interface CacheSectionProps {
onSavingChange?: (saving: boolean) => void;
}
-function normalizeCachePolicyValue(policy: string | undefined | null) {
+/** Map API/DB values for the form. Legacy empty/url → all (compat). */
+function normalizeCachePolicyValue(
+ policy: string | undefined | null,
+ enabled = true,
+) {
+ if (!enabled) {
+ return 'static';
+ }
const value = (policy || '').trim();
- if (!value || value === 'static') return 'static';
- if (value === 'url' || value === 'all') return 'all';
+ if (!value || value === 'url' || value === 'all') return 'all';
+ if (value === 'static') return 'static';
if (value === 'suffix' || value === 'path_prefix' || value === 'path_exact') {
return value;
}
@@ -103,6 +109,7 @@ export function CacheSection({
cache_enabled: route.cache_enabled,
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
+ route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
@@ -113,6 +120,7 @@ export function CacheSection({
cache_enabled: route.cache_enabled,
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
+ route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
@@ -161,9 +169,7 @@ export function CacheSection({
await save(
{
cache_enabled: values.cache_enabled,
- cache_policy: values.cache_enabled
- ? normalizeCachePolicyValue(values.cache_policy)
- : 'static',
+ cache_policy: values.cache_enabled ? values.cache_policy : '',
cache_rules:
values.cache_enabled &&
needsRulesForPolicy(values.cache_policy)
@@ -182,8 +188,9 @@ export function CacheSection({
启用站点缓存
- 开启后默认仅缓存标准静态扩展名(不含 HTML)。仍会自动绕过非
- GET、Authorization 与常见登录态 Cookie。
+ 新建推荐「标准静态资源」(不含
+ HTML)。须同时开启性能设置中的全局 OpenResty
+ 缓存。仍会绕过非 GET、Authorization 与常见登录 Cookie。
diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go
index 0e2e00f5..2202c962 100644
--- a/internal/apps/openflare/config_version/logics.go
+++ b/internal/apps/openflare/config_version/logics.go
@@ -347,10 +347,35 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].BasicAuthPassword = ""
}
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
+ routes[index].CachePolicy = normalizeSnapshotCachePolicy(
+ routes[index].CacheEnabled,
+ routes[index].CachePolicy,
+ )
+ if !routes[index].CacheEnabled {
+ routes[index].CacheRules = nil
+ }
}
return routes
}
+// normalizeSnapshotCachePolicy aligns published policy with edge-cache-design:
+// legacy empty/url → all; disabled → empty; static/suffix/... kept.
+func normalizeSnapshotCachePolicy(enabled bool, raw string) string {
+ if !enabled {
+ return ""
+ }
+ policy := strings.TrimSpace(strings.ToLower(raw))
+ switch policy {
+ case "", "url", "all":
+ return "all"
+ case "static", "suffix", "path_prefix", "path_exact":
+ return policy
+ default:
+ // Unknown: prefer static over caching everything.
+ return "static"
+ }
+}
+
func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute {
siteMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
diff --git a/internal/apps/openflare/proxy_route/helpers.go b/internal/apps/openflare/proxy_route/helpers.go
index 32baf57e..57697c9e 100644
--- a/internal/apps/openflare/proxy_route/helpers.go
+++ b/internal/apps/openflare/proxy_route/helpers.go
@@ -436,17 +436,20 @@ func decodeStoredCustomHeaders(raw string) ([]CustomHeaderInput, error) {
return normalizeCustomHeaders(headers)
}
+// normalizeCachePolicy stores API write values.
+// When enabling with empty/url policy, keep legacy "all" semantics so old rows
+// and clients that omit policy do not silently narrow cache to static extensions.
+// New UI should send policy=static explicitly when choosing the recommended default.
func normalizeCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
- case "", proxyRouteCachePolicyStatic:
- return proxyRouteCachePolicyStatic
- case proxyRouteCachePolicyURL, proxyRouteCachePolicyAll:
- // Legacy url is normalized to all (full GET allow after security bypass).
+ case "", proxyRouteCachePolicyURL, proxyRouteCachePolicyAll:
return proxyRouteCachePolicyAll
+ case proxyRouteCachePolicyStatic:
+ return proxyRouteCachePolicyStatic
case proxyRouteCachePolicySuffix, proxyRouteCachePolicyPathPrefix, proxyRouteCachePolicyPathExact:
return policy
default:
@@ -454,6 +457,14 @@ func normalizeCachePolicy(enabled bool, raw string) string {
}
}
+// displayCachePolicy normalizes values for API list/get (and UI).
+func displayCachePolicy(enabled bool, raw string) string {
+ if !enabled {
+ return ""
+ }
+ return normalizeCachePolicy(true, raw)
+}
+
func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) {
if !enabled {
return []string{}, nil
diff --git a/internal/apps/openflare/proxy_route/logics.go b/internal/apps/openflare/proxy_route/logics.go
index d7aaae7f..1b781a84 100644
--- a/internal/apps/openflare/proxy_route/logics.go
+++ b/internal/apps/openflare/proxy_route/logics.go
@@ -309,7 +309,7 @@ func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, e
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
- CachePolicy: route.CachePolicy,
+ CachePolicy: displayCachePolicy(route.CacheEnabled, route.CachePolicy),
CacheRules: route.CacheRules,
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
diff --git a/internal/apps/openflare/proxy_route/logics_test.go b/internal/apps/openflare/proxy_route/logics_test.go
index be8d528e..1af61011 100644
--- a/internal/apps/openflare/proxy_route/logics_test.go
+++ b/internal/apps/openflare/proxy_route/logics_test.go
@@ -83,12 +83,18 @@ func TestCreateProxyRouteHTTPSRequiresCoveringCertificate(t *testing.T) {
func TestNormalizeCachePolicyDefaultsAndLegacy(t *testing.T) {
assert.Equal(t, "", normalizeCachePolicy(false, "static"))
- assert.Equal(t, proxyRouteCachePolicyStatic, normalizeCachePolicy(true, ""))
+ // Empty/url on write = legacy all (compat); UI sends static explicitly for new default.
+ assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, ""))
assert.Equal(t, proxyRouteCachePolicyStatic, normalizeCachePolicy(true, "static"))
assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, "url"))
assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, "all"))
assert.Equal(t, proxyRouteCachePolicySuffix, normalizeCachePolicy(true, "suffix"))
+ assert.Equal(t, "", displayCachePolicy(false, "all"))
+ assert.Equal(t, proxyRouteCachePolicyAll, displayCachePolicy(true, ""))
+ assert.Equal(t, proxyRouteCachePolicyAll, displayCachePolicy(true, "url"))
+ assert.Equal(t, proxyRouteCachePolicyStatic, displayCachePolicy(true, "static"))
+
rules, err := normalizeCacheRules(true, "url", []string{"css"})
require.NoError(t, err)
assert.Empty(t, rules)
diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go
index 6890b059..8204bb27 100644
--- a/pkg/render/openresty/render.go
+++ b/pkg/render/openresty/render.go
@@ -497,13 +497,16 @@ func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
}
}
+// normalizeRenderCachePolicy maps stored policy for OpenResty generation.
+// Legacy empty and "url" mean "all GETs after security bypass" (pre-static default).
+// Explicit "static" uses the built-in extension allowlist. Unknown policies fall back to static.
func normalizeRenderCachePolicy(raw string) string {
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
- case "", cachePolicyStatic:
- return cachePolicyStatic
- case cachePolicyURL, cachePolicyAll:
+ case "", cachePolicyURL, cachePolicyAll:
return cachePolicyAll
+ case cachePolicyStatic:
+ return cachePolicyStatic
case cachePolicySuffix, cachePolicyPathPrefix, cachePolicyPathExact:
return policy
default:
diff --git a/pkg/render/openresty/render_test.go b/pkg/render/openresty/render_test.go
index b029dad4..bcf605bf 100644
--- a/pkg/render/openresty/render_test.go
+++ b/pkg/render/openresty/render_test.go
@@ -411,9 +411,10 @@ func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
t.Fatalf("static policy must not include html, got:\n%s", staticBlock)
}
+ // Legacy empty/url = all (wide cache after security bypass).
emptyPolicy := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: ""})
- if emptyPolicy == "" {
- t.Fatal("empty policy should default to static condition")
+ if emptyPolicy != "" {
+ t.Fatalf("empty policy should map to all (no path filter), got %q", emptyPolicy)
}
allBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "all"})