diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ceea23..6f131c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG - go/service +## v1.5.11 (2026-06-06) +- **修复: 路由与静态文件匹配鲁棒性增强**: + - **路径参数提取**: 修复了正则匹配路由(如 `{name}`)无法正确提取并注入路径参数到业务函数的问题。 + - **静态文件匹配**: 引入 `hostStatics` 有序路由表,实现“最长前缀匹配”策略,解决在复杂或重叠的静态目录配置下的匹配歧义问题。 + - **URL 兼容性**: 针对包含空格、中文字符及特殊符号的复杂 URL,在路由与静态文件匹配阶段统一进行 Robust 处理,彻底解决 404 隐患。 + - **Host 匹配增强**: 验证并明确了 Host 匹配的灵活性,支持 `hostname:port`, `hostname`, `:port` 的自动降级匹配。 + ## v1.5.10 (2026-06-05) - **修复: Static 服务 URL 解码**: - 修复了 \`service.Static()\` 在处理包含空格或特殊字符(如 \`%20\`)的请求路径时,因未解码导致文件匹配失败的问题。 diff --git a/handler.go b/handler.go index abdcc10..b8bde48 100644 --- a/handler.go +++ b/handler.go @@ -157,7 +157,7 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { goto filter } - s, wsc = ws.findService(r.Method, host, path) + s, wsc = ws.findService(r.Method, host, path, args) // 4. 参数解析 (Form & Body) parseRequestArgs(request, args) @@ -245,7 +245,7 @@ func hostOnly(host string) string { return h } -func (ws *webServer) findService(method, host, path string) (*webServiceType, *websocketServiceType) { +func (ws *webServer) findService(method, host, path string, args map[string]any) (*webServiceType, *websocketServiceType) { ws.webServicesLock.RLock() defer ws.webServicesLock.RUnlock() @@ -289,6 +289,14 @@ func (ws *webServer) findService(method, host, path string) (*webServiceType, *w continue } if s.pathMatcher != nil && s.pathMatcher.MatchString(path) { + matches := s.pathMatcher.FindStringSubmatch(path) + if len(matches) > 1 { + for i, name := range s.pathArgs { + if i+1 < len(matches) { + args[name] = matches[i+1] + } + } + } return s, nil } } diff --git a/robustness_test.go b/robustness_test.go new file mode 100644 index 0000000..9b86cd4 --- /dev/null +++ b/robustness_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "net/url" +) + +func TestStaticRobustness(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "robustness_test") + defer os.RemoveAll(tempDir) + + // 创建复杂的目录结构 + subDir := filepath.Join(tempDir, "The NPC Awakens", "The Loop", "scene", "M") + _ = os.MkdirAll(subDir, 0755) + + fileName := "画面逐渐亮起,铁匠铺的铁锤在无人操作的情况下,机械地敲击着烧红的铁块。_large.webp" + testFile := filepath.Join(subDir, fileName) + content := []byte("fake webp content") + _ = os.WriteFile(testFile, content, 0644) + + // 注册静态目录 + ws := newWebServer() + ws.Config.App = "test" + ws.Static("/img/", tempDir) + + rh := &RouteHandler{ws: ws} + + // 构造编码后的请求路径 + encodedPath := "/img/" + url.PathEscape("The NPC Awakens/The Loop/scene/M/画面逐渐亮起,铁匠铺的铁锤在无人操作的情况下,机械地敲击着烧红的铁块。_large.webp") + + // 测试静态文件访问 + req := httptest.NewRequest("GET", encodedPath+"?v=1780317467305", nil) + w := httptest.NewRecorder() + rh.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200 for complex static file, got %d. Path: %s", w.Code, encodedPath) + } else if string(w.Body.Bytes()) != string(content) { + t.Errorf("Content mismatch for complex static file") + } +} + +func TestDynamicRobustness(t *testing.T) { + ws := newWebServer() + ws.Config.App = "test" + + pathPattern := "/api/scene/{name}" + ws.Host("*").GET(pathPattern, func(in struct{ Name string }) string { + return "Hello " + in.Name + }) + + rh := &RouteHandler{ws: ws} + + complexName := "画面逐渐亮起,铁匠铺的铁锤" + encodedPath := "/api/scene/" + url.PathEscape(complexName) + + req := httptest.NewRequest("GET", encodedPath, nil) + w := httptest.NewRecorder() + rh.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200 for complex dynamic path, got %d", w.Code) + } + expectedBody := "Hello " + complexName + if w.Body.String() != expectedBody { + t.Errorf("Got body: %s, expected: %s", w.Body.String(), expectedBody) + } +} + +func TestHostMatching(t *testing.T) { + ws := newWebServer() + ws.Config.App = "test" + + // 1. 注册只带端口的 Host + ws.Host(":8080").GET("/port", func() string { return "port" }) + // 2. 注册只带域名的 Host + ws.Host("localhost").GET("/host", func() string { return "host" }) + // 3. 注册完整 Host + ws.Host("example.com:9000").GET("/full", func() string { return "full" }) + + rh := &RouteHandler{ws: ws} + + tests := []struct { + requestHost string + path string + expected string + code int + }{ + {"localhost:8080", "/port", "port", http.StatusOK}, + {"otherhost:8080", "/port", "port", http.StatusOK}, + {"localhost:9999", "/host", "host", http.StatusOK}, + {"example.com:9000", "/full", "full", http.StatusOK}, + {"example.com:8080", "/port", "port", http.StatusOK}, + {"localhost:8080", "/host", "host", http.StatusOK}, + } + + for _, tt := range tests { + req := httptest.NewRequest("GET", tt.path, nil) + req.Host = tt.requestHost + w := httptest.NewRecorder() + rh.ServeHTTP(w, req) + + if w.Code != tt.code { + t.Errorf("Host [%s] Path [%s] expected code %d, got %d", tt.requestHost, tt.path, tt.code, w.Code) + } + if tt.code == http.StatusOK && w.Body.String() != tt.expected { + t.Errorf("Host [%s] Path [%s] expected body %s, got %s", tt.requestHost, tt.path, tt.expected, w.Body.String()) + } + } +} diff --git a/server.go b/server.go index 2b1e73b..d044f04 100644 --- a/server.go +++ b/server.go @@ -23,6 +23,11 @@ import ( "time" ) +type staticType struct { + path string + rootPath *string +} + type webServer struct { Config ServiceConfig server *http.Server @@ -67,6 +72,7 @@ type webServer struct { codeStatics map[string]map[string]*string fileStatics map[string]map[string]*string dynamicStatics map[string]map[string]*string + hostStatics map[string][]*staticType staticsByHostLock sync.RWMutex // 过滤器与拦截器 @@ -124,6 +130,7 @@ func newWebServer() *webServer { codeStatics: make(map[string]map[string]*string), fileStatics: make(map[string]map[string]*string), dynamicStatics: make(map[string]map[string]*string), + hostStatics: make(map[string][]*staticType), webAuthCheckers: make(map[int]func(int, *log.Logger, *string, map[string]any, *Request, *Response, *WebServiceOptions) (pass bool, object any)), injectObjects: make(map[reflect.Type]any), injectFunctions: make(map[reflect.Type]func() any), @@ -266,6 +273,16 @@ func (ws *webServer) rebuildStaticsUnderLock(host string) { } else { ws.staticsByHost[host] = combined } + + // 构造有序的静态路由列表 (按路径长度降序排列,实现最长匹配) + sorted := make([]*staticType, 0, len(combined)) + for k, v := range combined { + sorted = append(sorted, &staticType{path: k, rootPath: v}) + } + sort.Slice(sorted, func(i, j int) bool { + return len(sorted[i].path) > len(sorted[j].path) + }) + ws.hostStatics[host] = sorted } // Start 启动服务,实现 starter.Service 接口 diff --git a/static.go b/static.go index f4f59f5..33e4e88 100644 --- a/static.go +++ b/static.go @@ -82,20 +82,18 @@ func (ws *webServer) getStaticFilePath(requestPath, host string) string { defer ws.staticsByHostLock.RUnlock() // 优先匹配指定域名的配置 - if hostConfig, exists := ws.staticsByHost[host]; exists { - if filePath := ws.findMatchedPath(hostConfig, requestPath); filePath != "" { - return filePath - } + if filePath := ws.findMatchedPathSorted(ws.hostStatics[host], requestPath); filePath != "" { + return filePath } // 匹配全局配置 - return ws.findMatchedPath(ws.statics, requestPath) + return ws.findMatchedPathSorted(ws.hostStatics[""], requestPath) } -func (ws *webServer) findMatchedPath(config map[string]*string, requestPath string) string { - for urlPath, rootPath := range config { - if strings.HasPrefix(requestPath, urlPath) { - return filepath.Join(*rootPath, requestPath[len(urlPath):]) +func (ws *webServer) findMatchedPathSorted(config []*staticType, requestPath string) string { + for _, rule := range config { + if strings.HasPrefix(requestPath, rule.path) { + return filepath.Join(*rule.rootPath, requestPath[len(rule.path):]) } } return ""