feat(cache): 开启缓存默认仅缓存标准静态资源

路由缓存策略新增 static(内置扩展名,不含 HTML)与 all;
存量 url 规范为 all。OpenResty 渲染与代理路由 UI 同步。
This commit is contained in:
ryan
2026-07-18 23:32:49 +08:00
parent 7401f5d0b4
commit 04f029c705
11 changed files with 201 additions and 29 deletions
+1
View File
@@ -23,6 +23,7 @@ sidebar: false
### 变更
- 边缘缓存默认策略调整为「标准静态资源」:开启站点缓存后默认仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」行为保留为「所有可缓存 GET」。
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
@@ -0,0 +1,22 @@
# 边缘缓存默认 static 策略 — 实现计划
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
## 目标
路由开启缓存后默认仅缓存标准静态扩展名(`static`);存量 `url` 映射为 `all`。
## 修改清单
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**
## 验证
```bash
go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/
make code-check # 或至少 go test + frontend tsc
```
+1
View File
@@ -17,6 +17,7 @@
* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
## 使用建议
@@ -278,10 +278,10 @@ export function normalizeLimitRate(value: string) {
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
if (policy === 'static' || policy === 'all' || policy === 'url') {
return null;
}
@@ -335,7 +335,12 @@ 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 || 'url',
cache_policy:
!route.cache_policy || route.cache_policy === 'url'
? route.cache_policy === 'url'
? 'all'
: 'static'
: route.cache_policy,
cache_rules: route.cache_rule_list ?? [],
custom_headers: route.custom_header_list ?? [],
basic_auth_enabled: route.basic_auth_enabled,
@@ -229,7 +229,7 @@ export function ProxyRouteCreateSheet({
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_policy: 'static',
cache_rules: [],
custom_headers: [],
basic_auth_enabled: false,
@@ -36,7 +36,14 @@ import { SectionShell } from './section-shell';
const cacheSchema = z
.object({
cache_enabled: z.boolean(),
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
cache_policy: z.enum([
'static',
'all',
'url',
'suffix',
'path_prefix',
'path_exact',
]),
cache_rules_text: z.string(),
})
.superRefine((value, context) => {
@@ -63,6 +70,22 @@ interface CacheSectionProps {
onSavingChange?: (saving: boolean) => void;
}
function normalizeCachePolicyValue(policy: string | undefined | null) {
const value = (policy || '').trim();
if (!value || value === 'static') return 'static';
if (value === 'url' || value === 'all') return 'all';
if (value === 'suffix' || value === 'path_prefix' || value === 'path_exact') {
return value;
}
return 'static';
}
function needsRulesForPolicy(policy: string) {
return (
policy === 'suffix' || policy === 'path_prefix' || policy === 'path_exact'
);
}
export function CacheSection({
route,
onRouteUpdate,
@@ -78,8 +101,9 @@ export function CacheSection({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
@@ -87,14 +111,19 @@ export function CacheSection({
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
const watchedEnabled = form.watch('cache_enabled');
const watchedPolicy = form.watch('cache_policy');
const needsRules =
watchedPolicy === 'suffix' ||
watchedPolicy === 'path_prefix' ||
watchedPolicy === 'path_exact';
const rulesHint =
watchedPolicy === 'suffix'
@@ -103,7 +132,9 @@ export function CacheSection({
? '每行一个路径前缀,例如 /assets、/static。'
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: '按 URL 缓存时无需额外规则。';
: watchedPolicy === 'static'
? '标准静态资源使用内置扩展名列表(不含 HTML),无需填写规则。'
: '所有可缓存 GET 无需额外规则(仍会绕过登录态与 Authorization)。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
@@ -112,7 +143,7 @@ export function CacheSection({
? '/assets\n/static'
: watchedPolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存时无需额外规则';
: '当前策略无需额外规则';
return (
<SectionShell
@@ -131,10 +162,11 @@ export function CacheSection({
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled
? values.cache_policy
: 'url',
? normalizeCachePolicyValue(values.cache_policy)
: 'static',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url'
values.cache_enabled &&
needsRulesForPolicy(values.cache_policy)
? rules
: [],
},
@@ -150,8 +182,8 @@ export function CacheSection({
<div className='space-y-0.5'>
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie
的请求。
开启后默认仅缓存标准静态扩展名(不含 HTML)。仍会自动绕过非
GET、Authorization 与常见登录态 Cookie。
</FormDescription>
</div>
<FormControl>
@@ -181,12 +213,17 @@ export function CacheSection({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='url'>按 URL 缓存</SelectItem>
<SelectItem value='suffix'>按后缀缓存</SelectItem>
<SelectItem value='path_prefix'>按路径前缀缓存</SelectItem>
<SelectItem value='path_exact'>按精确路径缓存</SelectItem>
<SelectItem value='static'>标准静态资源(推荐)</SelectItem>
<SelectItem value='all'>所有可缓存 GET(高级)</SelectItem>
<SelectItem value='suffix'>自定义后缀</SelectItem>
<SelectItem value='path_prefix'>路径前缀</SelectItem>
<SelectItem value='path_exact'>精确路径</SelectItem>
</SelectContent>
</Select>
<FormDescription>
标准静态资源含 css/js/图片/字体/媒体等,默认不缓存 HTML
与接口路径。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -201,7 +238,7 @@ export function CacheSection({
<FormControl>
<Textarea
className='min-h-32'
disabled={!watchedEnabled || watchedPolicy === 'url'}
disabled={!watchedEnabled || !needsRules}
placeholder={rulesPlaceholder}
{...field}
/>
+15 -6
View File
@@ -25,7 +25,9 @@ var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
const (
proxyRouteCachePolicyURL = "url"
proxyRouteCachePolicyStatic = "static"
proxyRouteCachePolicyAll = "all"
proxyRouteCachePolicyURL = "url" // legacy alias of all
proxyRouteCachePolicySuffix = "suffix"
proxyRouteCachePolicyPathPrefix = "path_prefix"
proxyRouteCachePolicyPathExact = "path_exact"
@@ -438,11 +440,18 @@ func normalizeCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
policy := strings.TrimSpace(raw)
if policy == "" {
return proxyRouteCachePolicyURL
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).
return proxyRouteCachePolicyAll
case proxyRouteCachePolicySuffix, proxyRouteCachePolicyPathPrefix, proxyRouteCachePolicyPathExact:
return policy
default:
return policy
}
return policy
}
func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) {
@@ -451,7 +460,7 @@ func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]stri
}
policy := normalizeCachePolicy(enabled, rawPolicy)
switch policy {
case proxyRouteCachePolicyURL:
case proxyRouteCachePolicyStatic, proxyRouteCachePolicyAll, proxyRouteCachePolicyURL:
return []string{}, nil
case proxyRouteCachePolicySuffix:
return normalizeCacheSuffixRules(rules)
@@ -80,3 +80,23 @@ func TestCreateProxyRouteHTTPSRequiresCoveringCertificate(t *testing.T) {
_, err := CreateProxyRoute(ctx, Input{SiteName: "api", ZoneDomainIDs: []uint{domain.ID}, OriginURL: "http://origin.example.com:8080", EnableHTTPS: true})
require.EqualError(t, err, errProxyRouteCertRequired)
}
func TestNormalizeCachePolicyDefaultsAndLegacy(t *testing.T) {
assert.Equal(t, "", normalizeCachePolicy(false, "static"))
assert.Equal(t, proxyRouteCachePolicyStatic, 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"))
rules, err := normalizeCacheRules(true, "url", []string{"css"})
require.NoError(t, err)
assert.Empty(t, rules)
rules, err = normalizeCacheRules(true, "static", nil)
require.NoError(t, err)
assert.Empty(t, rules)
_, err = normalizeCacheRules(true, "suffix", nil)
require.Error(t, err)
}
+22 -2
View File
@@ -479,15 +479,35 @@ func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
}
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
switch cacheConfig.Policy {
policy := normalizeRenderCachePolicy(cacheConfig.Policy)
switch policy {
case cachePolicyStatic:
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions)))
case cachePolicySuffix:
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules)))
case cachePolicyPathPrefix:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules)))
case cachePolicyPathExact:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules)))
default:
case cachePolicyAll, cachePolicyURL:
return ""
default:
// Unknown policy: treat as static for safety (do not cache everything).
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions)))
}
}
func normalizeRenderCachePolicy(raw string) string {
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
case "", cachePolicyStatic:
return cachePolicyStatic
case cachePolicyURL, cachePolicyAll:
return cachePolicyAll
case cachePolicySuffix, cachePolicyPathPrefix, cachePolicyPathExact:
return policy
default:
return policy
}
}
+43
View File
@@ -398,3 +398,46 @@ func TestRenderRouteConfigPagesWithSPAFallbackServesRoot(t *testing.T) {
t.Fatalf("expected spa fallback try_files in location /, got:\n%s", routeConfig)
}
}
func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
staticBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "static"})
if staticBlock == "" {
t.Fatal("static policy should emit a path condition")
}
if !strings.Contains(staticBlock, "css") || !strings.Contains(staticBlock, "woff2") {
t.Fatalf("static policy should include default extensions, got:\n%s", staticBlock)
}
if strings.Contains(staticBlock, "html") {
t.Fatalf("static policy must not include html, got:\n%s", staticBlock)
}
emptyPolicy := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: ""})
if emptyPolicy == "" {
t.Fatal("empty policy should default to static condition")
}
allBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "all"})
if allBlock != "" {
t.Fatalf("all policy should not add path condition, got %q", allBlock)
}
urlBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "url"})
if urlBlock != "" {
t.Fatalf("legacy url policy should map to all, got %q", urlBlock)
}
}
func TestRenderRouteCacheBlockIncludesStaticWhenEnabled(t *testing.T) {
block := renderRouteCacheBlock(
routeCacheConfig{Enabled: true, Policy: "static"},
ConfigSnapshot{CacheEnabled: true},
)
if !strings.Contains(block, "proxy_cache openflare_cache") {
t.Fatalf("expected proxy_cache, got:\n%s", block)
}
if !strings.Contains(block, "\\.(?:") {
t.Fatalf("expected static suffix pattern, got:\n%s", block)
}
if !strings.Contains(block, "request_method != GET") {
t.Fatalf("expected security bypass for non-GET, got:\n%s", block)
}
}
+14
View File
@@ -25,6 +25,9 @@ const (
)
const (
cachePolicyStatic = "static"
cachePolicyAll = "all"
cachePolicyURL = "url" // legacy alias of all
cachePolicySuffix = "suffix"
cachePolicyPathPrefix = "path_prefix"
cachePolicyPathExact = "path_exact"
@@ -33,6 +36,17 @@ const (
anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
)
// DefaultStaticCacheExtensions is the built-in suffix allowlist for cache_policy=static.
// HTML is intentionally excluded (Cloudflare-like default).
var DefaultStaticCacheExtensions = []string{
"css", "js", "mjs", "map", "json",
"ico", "cur", "gif", "jpg", "jpeg", "png", "webp", "avif", "svg", "svgz",
"ttf", "otf", "woff", "woff2", "eot",
"mp3", "mp4", "webm", "ogg", "flac",
"wasm", "pdf",
"zip", "7z", "gz", "tar",
}
// OpenFlareRuntimeUser is the dedicated service account shared by the agent
// process and OpenResty worker processes.
const OpenFlareRuntimeUser = "openflare"