diff --git a/fun.go b/fun.go index a780e18..33872e4 100644 --- a/fun.go +++ b/fun.go @@ -10,9 +10,10 @@ import ( type Fun struct { methods map[string]methodInfo - boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value - guards []*any // 全局 Guard - serviceGuards map[string][]*any // 服务级 Guard,按服务名 + routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器 + boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value + guards []*any // 全局 Guard + serviceGuards map[string][]*any // 服务级 Guard,按服务名 } var ( @@ -33,6 +34,7 @@ type methodInfo struct { func New() *Fun { f := &Fun{ methods: map[string]methodInfo{}, + routes: map[string]RouteHandler{}, boxes: &sync.Map{}, serviceGuards: map[string][]*any{}, } diff --git a/handle.go b/handle.go index df1dc53..7afef44 100644 --- a/handle.go +++ b/handle.go @@ -11,11 +11,16 @@ import ( "github.com/valyala/fasthttp" ) -// handle 处理 HTTP 请求 +// handle 处理 HTTP 请求:先匹配自定义路由(BindRoute),未命中走 /cell RPC func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { ctx := &Ctx{RequestCtx: fastCtx} defer f.handlePanic(ctx) + if handler, ok := f.routes[string(fastCtx.Method())+" "+string(fastCtx.Path())]; ok { + f.handleRoute(fastCtx, handler) + return + } + if ctx.path() != "/cell" { ctx.setStatusCode(fasthttp.StatusNotFound) return diff --git a/route.go b/route.go new file mode 100644 index 0000000..229e04f --- /dev/null +++ b/route.go @@ -0,0 +1,70 @@ +package fun + +import ( + "fmt" + "strings" + + "github.com/valyala/fasthttp" +) + +// RouteHandler 自定义 HTTP 路由处理器。 +// +// 返回 error 时框架统一输出错误响应;返回 nil 视为已自行写回响应—— +// 可直接操作 RouteCtx.RequestCtx 完全自定义状态码与内容 +// (如支付回调要求的纯文本 "success" 应答)。 +type RouteHandler func(ctx *RouteCtx) error + +// RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先), +// 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。 +// 独立于服务内嵌的 Ctx:后者辅助方法刻意全小写以防混入 RPC 方法集,路由不复用该类型。 +type RouteCtx struct { + RequestCtx *fasthttp.RequestCtx + Data map[string]string +} + +// Param 取查询/表单参数,不存在返回空串 +func (c *RouteCtx) Param(name string) string { + return c.Data[name] +} + +// BindRoute 注册自定义路由(方法大小写不敏感 + 精确路径匹配),用于 GET 直链、 +// 健康检查、支付回调等无法走 POST /cell RPC 的场景。 +// +// - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册 +// - 同一 方法+路径 重复注册直接 panic +// - 与 BindService 一致,需在 Start 前完成注册(启动阶段单线程) +func (f *Fun) BindRoute(method, path string, handler RouteHandler) { + if handler == nil { + panic("fun: BindRoute handler cannot be nil") + } + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + panic("fun: BindRoute method cannot be empty") + } + if !strings.HasPrefix(path, "/") { + panic(fmt.Sprintf("fun: BindRoute path %q must start with '/'", path)) + } + if path == "/cell" { + panic("fun: /cell is reserved for RPC") + } + key := method + " " + path + if _, exists := f.routes[key]; exists { + panic(fmt.Sprintf("fun: route %s already bound", key)) + } + f.routes[key] = handler +} + +// handleRoute 执行自定义路由:合并查询与表单参数(application/x-www-form-urlencoded), +// 处理器返回 error 时按统一 Result 格式输出错误响应 +func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler) { + data := map[string]string{} + fastCtx.QueryArgs().VisitAll(func(k, v []byte) { + data[string(k)] = string(v) + }) + fastCtx.PostArgs().VisitAll(func(k, v []byte) { + data[string(k)] = string(v) + }) + if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data}); err != nil { + (&Ctx{RequestCtx: fastCtx}).sendError(err) + } +} diff --git a/route_test.go b/route_test.go new file mode 100644 index 0000000..f6cedf4 --- /dev/null +++ b/route_test.go @@ -0,0 +1,135 @@ +package fun + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func startRouteServer(t *testing.T, port uint16) *Fun { + t.Helper() + f := New() + f.BindService(&TestSvc{}) + + // GET:查询参数 + 纯文本自定义响应 + f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error { + ctx.RequestCtx.SetContentType("text/plain; charset=utf-8") + ctx.RequestCtx.WriteString("pong " + ctx.Param("echo")) + return nil + }) + // POST:支付回调场景,form-urlencoded 参数合并取值,成功回纯文本 success + f.BindRoute("POST", "/pay/notify", func(ctx *RouteCtx) error { + if ctx.Param("trade_status") != "TRADE_SUCCESS" { + return Error(4001, "invalid trade_status") + } + ctx.RequestCtx.SetContentType("text/plain; charset=utf-8") + ctx.RequestCtx.WriteString("success") + return nil + }) + // 返回 error:应输出统一错误响应 + f.BindRoute("GET", "/boom", func(ctx *RouteCtx) error { + return fmt.Errorf("kaboom") + }) + + go f.Start(port) + time.Sleep(300 * time.Millisecond) + return f +} + +func TestBindRouteGet(t *testing.T) { + startRouteServer(t, 39101) + resp, err := http.Get("http://127.0.0.1:39101/ping?echo=hi") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + body := make([]byte, 32) + n, _ := resp.Body.Read(body) + if got := strings.TrimSpace(string(body[:n])); got != "pong hi" { + t.Fatalf("unexpected body: %q", got) + } +} + +func TestBindRoutePayNotifyForm(t *testing.T) { + startRouteServer(t, 39102) + form := url.Values{"out_trade_no": {"T123"}, "trade_status": {"TRADE_SUCCESS"}, "money": {"0.01"}} + resp, err := http.PostForm("http://127.0.0.1:39102/pay/notify", form) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := make([]byte, 64) + n, _ := resp.Body.Read(buf) + if got := strings.TrimSpace(string(buf[:n])); got != "success" { + t.Fatalf("unexpected body: %q", got) + } + + // 非成功状态 → 统一错误响应 + resp2, err := http.PostForm("http://127.0.0.1:39102/pay/notify", + url.Values{"trade_status": {"WAIT_BUYER_PAY"}}) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + buf2 := make([]byte, 256) + n2, _ := resp2.Body.Read(buf2) + if !strings.Contains(string(buf2[:n2]), "invalid trade_status") { + t.Fatalf("unexpected error body: %q", string(buf2[:n2])) + } +} + +func TestBindRouteErrorAndCellUnaffected(t *testing.T) { + startRouteServer(t, 39103) + // error 路由 → 统一错误 JSON + resp, err := http.Get("http://127.0.0.1:39103/boom") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := make([]byte, 256) + n, _ := resp.Body.Read(buf) + if !strings.Contains(string(buf[:n]), "kaboom") { + t.Fatalf("unexpected error body: %q", string(buf[:n])) + } + // 未注册路径仍 404,/cell RPC 不受影响 + if resp2, err := http.Get("http://127.0.0.1:39103/nope"); err != nil || resp2.StatusCode != 404 { + t.Fatalf("unregistered route should 404: %v %+v", err, resp2) + } else { + resp2.Body.Close() + } + res := postCell(t, 39103, `{"serviceName":"TestSvc","methodName":"Hello","data":{"name":"tom","age":1}}`) + if res.Status != 0 || res.Data == nil || (*res.Data).(string) != "hi tom" { + t.Fatalf("/cell broken by routes: %+v", res) + } + // 方法不匹配(GET 打 POST 路由)→ 404 + if resp3, err := http.Get("http://127.0.0.1:39103/pay/notify"); err != nil || resp3.StatusCode != 404 { + t.Fatalf("method mismatch should 404: %v %+v", err, resp3) + } else { + resp3.Body.Close() + } +} + +func TestBindRoutePanics(t *testing.T) { + f := New() + catch := func(fn func()) (msg string) { + defer func() { msg = fmt.Sprint(recover()) }() + fn() + return "" + } + if m := catch(func() { f.BindRoute("GET", "no-slash", func(*RouteCtx) error { return nil }) }); m == "" { + t.Fatal("path without leading / should panic") + } + if m := catch(func() { f.BindRoute("GET", "/cell", func(*RouteCtx) error { return nil }) }); m == "" { + t.Fatal("/cell reservation should panic") + } + f.BindRoute("get", "/dup", func(*RouteCtx) error { return nil }) + if m := catch(func() { f.BindRoute("GET", "/dup", func(*RouteCtx) error { return nil }) }); m == "" { + t.Fatal("duplicate route should panic") + } +}