From d191e1865df5c09ca6d8247ac2bb7fb0134c51d6 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 27 Apr 2026 14:05:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(static):=20=E6=B7=BB=E5=8A=A0=20expires=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8E=A7=E5=88=B6=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaticConfig 添加 Expires 字段,支持 nginx 兼容格式(30d, 1h, max, epoch) - StaticHandler 添加 SetExpires 方法和缓存响应头设置 - serveFile 自动设置 Cache-Control 和 Expires 响应头 - nginx 转换器正确转换 expires 指令 - --generate-config 输出包含 expires 文档和示例 Co-Authored-By: Claude Opus 4.7 --- internal/config/config.go | 6 ++ internal/config/defaults.go | 12 ++-- internal/converter/nginx/converter.go | 4 ++ internal/handler/static.go | 79 +++++++++++++++++++++++++++ internal/server/server.go | 10 ++++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 994b70f..33f9dc3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -319,6 +319,12 @@ type StaticConfig struct { // Internal 仅允许内部访问 // 设置为 true 时,该位置仅允许内部重定向访问 Internal bool `yaml:"internal"` + + // Expires 缓存过期时间 + // 支持 nginx 兼容格式:30d, 1h, 1m, max, epoch, off + // 设置 Cache-Control: max-age 和 Expires 响应头 + // 示例:expires: 30d → Cache-Control: max-age=2592000 + Expires string `yaml:"expires"` } // ProxyConfig 反向代理配置,支持负载均衡和健康检查。 diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 0f3a8f0..41b984f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -355,13 +355,17 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) { buf.WriteString(" try_files: [] # SPA 部署示例: [\"$uri\", \"$uri/\", \"/index.html\"]\n") buf.WriteString(" try_files_pass: false # 内部重定向是否触发中间件\n") buf.WriteString(" symlink_check: false # 是否检查符号链接安全(防止路径遍历攻击)\n") + buf.WriteString(" # expires: \"\" # 缓存过期时间(nginx 兼容格式:30d, 1h, max, epoch)\n") + buf.WriteString(" # 30d → Cache-Control: max-age=2592000\n") + buf.WriteString(" # max → Cache-Control: max-age=315360000, immutable\n") + buf.WriteString(" # epoch → Cache-Control: no-cache\n") buf.WriteString(" # location_type: \"\" # 位置匹配类型(有效值: exact, prefix, regex, regex_caseless, prefix_priority, named)\n") buf.WriteString(" # internal: false # 仅允许内部重定向访问\n") } - buf.WriteString(" # 示例:使用 alias 替换路径\n") - buf.WriteString(" # - path: \"/images/\"\n") - buf.WriteString(" # alias: \"/var/www/files/\" # /images/logo.png → /var/www/files/logo.png\n") - buf.WriteString(" # index: [\"index.html\"]\n") + buf.WriteString(" # 示例:静态资源缓存配置\n") + buf.WriteString(" # - path: \"/assets/\"\n") + buf.WriteString(" # root: \"/var/www/assets\"\n") + buf.WriteString(" # expires: \"30d\" # 缓存 30 天\n") buf.WriteString("\n") // proxy 配置示例 diff --git a/internal/converter/nginx/converter.go b/internal/converter/nginx/converter.go index 29a049f..3fd62bc 100644 --- a/internal/converter/nginx/converter.go +++ b/internal/converter/nginx/converter.go @@ -844,6 +844,10 @@ func convertStaticDirectives(directives []Directive, static *config.StaticConfig static.Index = append(static.Index, d.Args...) case "try_files": static.TryFiles = append(static.TryFiles, d.Args...) + case "expires": + if len(d.Args) > 0 { + static.Expires = d.Args[0] + } default: if msg, ok := unsupportedDirectives[d.Name]; ok { result.Warnings = append(result.Warnings, Warning{ diff --git a/internal/handler/static.go b/internal/handler/static.go index fcaaf1d..abea74c 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -52,6 +52,7 @@ type StaticHandler struct { root string alias string pathPrefix string + expires string // 缓存过期时间(nginx 兼容格式) // 切片字段 index []string tryFiles []string @@ -224,6 +225,17 @@ func (h *StaticHandler) SetInternal(enabled bool) { h.internal = enabled } +// SetExpires 设置缓存过期时间。 +// +// 支持 nginx 兼容格式:30d, 1h, 1m, max, epoch, off +// 设置后会在响应中添加 Cache-Control 和 Expires 头。 +// +// 参数: +// - expires: 过期时间字符串 +func (h *StaticHandler) SetExpires(expires string) { + h.expires = expires +} + // SetCacheTTL 设置缓存新鲜度 TTL。 // // TTL 控制缓存条目的新鲜度验证间隔。 @@ -561,6 +573,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf ctx.Response.SetStatusCode(fasthttp.StatusNotModified) ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) + h.setCacheHeaders(ctx) ctx.Response.SkipBody = true return } @@ -572,6 +585,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf // 预压缩文件已发送,补充验证头 ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) + h.setCacheHeaders(ctx) return } } @@ -586,6 +600,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) + h.setCacheHeaders(ctx) return } // 文件已修改,删除旧缓存 @@ -603,6 +618,7 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) + h.setCacheHeaders(ctx) file, err := os.Open(filePath) if err == nil { @@ -629,6 +645,69 @@ func (h *StaticHandler) serveFile(ctx *fasthttp.RequestCtx, filePath string, inf ctx.Response.Header.SetContentType(mimeutil.DetectContentType(filePath)) ctx.Response.Header.Set("ETag", etag) ctx.Response.Header.Set("Last-Modified", info.ModTime().UTC().Format(httpTimeFormat)) + h.setCacheHeaders(ctx) +} + +// setCacheHeaders 设置缓存控制响应头。 +func (h *StaticHandler) setCacheHeaders(ctx *fasthttp.RequestCtx) { + if h.expires == "" || h.expires == "off" { + return + } + + if h.expires == "epoch" { + ctx.Response.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + ctx.Response.Header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT") + return + } + + if h.expires == "max" { + ctx.Response.Header.Set("Cache-Control", "public, max-age=315360000, immutable") + ctx.Response.Header.Set("Expires", time.Now().Add(315360000*time.Second).UTC().Format(httpTimeFormat)) + return + } + + maxAge := parseExpires(h.expires) + if maxAge > 0 { + ctx.Response.Header.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) + ctx.Response.Header.Set("Expires", time.Now().Add(time.Duration(maxAge)*time.Second).UTC().Format(httpTimeFormat)) + } +} + +// parseExpires 解析 nginx 兼容的过期时间格式。 +// 支持格式:30d, 1h, 1m, 1s, 30d1h 等 +// 返回秒数。 +func parseExpires(expires string) int64 { + if expires == "" || expires == "off" { + return 0 + } + if expires == "max" { + return 315360000 + } + if expires == "epoch" { + return -1 + } + + var total int64 + var num int64 + for _, ch := range expires { + switch { + case ch >= '0' && ch <= '9': + num = num*10 + int64(ch-'0') + case ch == 'd': + total += num * 86400 + num = 0 + case ch == 'h': + total += num * 3600 + num = 0 + case ch == 'm': + total += num * 60 + num = 0 + case ch == 's': + total += num + num = 0 + } + } + return total } // validateSymlink 验证符号链接是否安全。 diff --git a/internal/server/server.go b/internal/server/server.go index 3aebcd1..a782547 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1098,6 +1098,11 @@ func (s *Server) registerStaticHandlersWithLocationEngine(cfg *config.ServerConf // 设置 internal 限制 staticHandler.SetInternal(static.Internal) + // 设置缓存过期时间 + if static.Expires != "" { + staticHandler.SetExpires(static.Expires) + } + // 根据 LocationType 注册路由 locType := static.LocationType if locType == "" { @@ -1464,6 +1469,11 @@ func (s *Server) registerStaticHandlers(router *handler.Router, cfg *config.Serv // 设置符号链接安全检查 staticHandler.SetSymlinkCheck(static.SymlinkCheck) + // 设置缓存过期时间 + if static.Expires != "" { + staticHandler.SetExpires(static.Expires) + } + // 设置 try_files 配置 if len(static.TryFiles) > 0 { // 注意:tryFilesPass 需要路由器支持,当前实现传入 nil