fix(service): enhance routing robustness and static file matching v1.5.11

This commit is contained in:
AI Engineer 2026-06-06 08:59:14 +08:00
parent 582de60053
commit fbf7e6475c
5 changed files with 155 additions and 11 deletions

View File

@ -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\`)的请求路径时,因未解码导致文件匹配失败的问题。

View File

@ -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
}
}

114
robustness_test.go Normal file
View File

@ -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())
}
}
}

View File

@ -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 接口

View File

@ -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 != "" {
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 ""