From 41df889a1a05cf53a438ea096953aed07e6c83b9 Mon Sep 17 00:00:00 2001 From: chiyi Date: Thu, 3 Sep 2026 15:32:07 +0800 Subject: [PATCH] v2.0.0: error-channel APIs, correctness fixes, and server hardening BREAKING CHANGES: - Guard interface: Guard(ctx Ctx) error; returning error short-circuits (subsequent guards and the method no longer execute) - Wired[T]() (*T, error); New() may return error; failed wiring is sticky - BindService/BindGuard/BindRoute return error; routes accept guards (guards receive merged query/form params as Ctx.State) - registration panics after Start; Ctx.Ip honors X-Forwarded-For/X-Real-IP - internal errors sanitized to fixed client messages FIXES: - int64 precision loss: /cell data decoded from raw JSON bytes and responses serialized with json.Number (no float64 round-trip) - enum values range-checked before uint8 conversion (256 no longer truncates to 0 and slips through) - logger: files failing name parsing are no longer deleted; log channel never blocks request goroutines; ConfigLogger is race-free; logWriterWorker unlock bug fixed - stream writer panics recovered (process no longer crashes); streamDone closed exactly once - generated Go client emits definitions for pointer-to-enum/struct fields - anonymous structs rejected at registration; isPrivate safe on empty names - removed dead Result.Id and RequestInfo.Type ADDITIONS: - graceful shutdown (Shutdown), StartOn, server timeouts by default (Read 60s/Idle 120s/Write off), SetTimeouts/SetMaxConcurrency - big-int-safe JSON in the generated TS client (>2^53 as BigInt) - example/ demo services and cmd/genexample artifact generator --- box.go | 219 ++++++++++++++++--------- bugfix_test.go | 24 ++- check.go | 53 ++++++- cmd/genexample/main.go | 34 ++++ ctx.go | 13 +- enum.go | 1 + example/demo/demo.go | 105 ++++++++++++ fun.go | 179 +++++++++++++++++---- fun_test.go | 19 ++- gen_go.go | 5 + gen_reliability_test.go | 16 +- guard.go | 7 +- handle.go | 72 +++++++-- hardening_test.go | 343 ++++++++++++++++++++++++++++++++++++++++ logger.go | 56 +++++-- regression_test.go | 247 +++++++++++++++++++++++++++++ request.go | 6 - result.go | 8 +- route.go | 53 +++++-- route_test.go | 4 +- stream_protocol_test.go | 8 +- template_go.go | 3 +- template_ts.go | 56 ++++++- utils.go | 39 ++--- 24 files changed, 1356 insertions(+), 214 deletions(-) create mode 100644 cmd/genexample/main.go create mode 100644 example/demo/demo.go create mode 100644 hardening_test.go create mode 100644 regression_test.go diff --git a/box.go b/box.go index 0dbcb08..672a32c 100644 --- a/box.go +++ b/box.go @@ -1,119 +1,171 @@ package fun import ( + "fmt" "reflect" ) -// Wired 创建并注册一个依赖实例;auto 标签字段递归注入依赖;存在 New() 则调用 -func Wired[T any]() *T { +// boxEntry 依赖容器条目:装配完成的单例,或粘性初始化错误。 +// 装配失败的类型记录错误后不再重试,也不把半初始化实例暴露给后续装配 +type boxEntry struct { + val reflect.Value + err error +} + +// Wired 创建并注册一个依赖实例;auto 标签字段递归注入依赖,存在 New() 则调用。 +// New 支持 () 与 () error 两种签名,返回非 nil error 即装配失败。 +// 失败以 error 返回;同类型再次 Wired 返回同一错误(粘性),避免启动期反复重连 +func Wired[T any]() (*T, error) { t := reflect.TypeFor[T]() - data := new(T) if t.Kind() != reflect.Struct { panic("Fun: " + t.Name() + " It must be a structure") } + if t.Name() == "" { + panic("Fun: Wired requires a named struct type") + } if isPrivate(t.Name()) { panic("Fun:" + t.Name() + " cannot be Private") } - if newMethod, found := t.MethodByName("New"); found { - if newMethod.Type.NumIn() != 1 || newMethod.Type.NumOut() != 0 { - panic("Fun:" + t.Name() + " New method must have no parameters and no return values") - } - } + pt := reflect.TypeFor[*T]() + checkNewSignature(pt, t.Name()) f := GetFun() - if box, isWired := f.boxes.Load(reflect.TypeFor[*T]()); isWired { - return box.(reflect.Value).Interface().(*T) + f.mu.Lock() + defer f.mu.Unlock() + if entry, ok := f.boxes.Load(pt); ok { + e := entry.(boxEntry) + if e.err != nil { + return nil, e.err + } + return e.val.Interface().(*T), nil } + data := new(T) v := reflect.ValueOf(data) - f.boxes.Store(reflect.TypeFor[*T](), v) - boxList := map[reflect.Type]bool{} + // 先入容器再装配:循环依赖(A→B→A)靠占位引用解开; + // 失败时下面覆盖为粘性错误,容器中不留可用半成品 + f.boxes.Store(pt, boxEntry{val: v}) + if err := f.wireStruct(v.Elem()); err != nil { + f.boxes.Store(pt, boxEntry{err: err}) + return nil, err + } + if err := callNewIfPresent(v); err != nil { + f.boxes.Store(pt, boxEntry{err: err}) + return nil, err + } + return data, nil +} + +// wireStruct 注入 auto 标签字段;依赖缺失时递归装配(须持有 f.mu) +func (f *Fun) wireStruct(structValue reflect.Value) error { + t := structValue.Type() for i := 0; i < t.NumField(); i++ { c := t.Field(i) - fieldTag := newTag(c.Tag) - if _, isAuto := fieldTag.getTag("auto"); isAuto { - if dependency, loaded := f.boxes.Load(c.Type); loaded { - v.Elem().Field(i).Set(dependency.(reflect.Value)) - } else { - checkBox(c, boxList) - f.autowired(v.Elem().Field(i)) + if _, isAuto := newTag(c.Tag).getTag("auto"); !isAuto { + continue + } + if c.Anonymous { + panic("Fun:" + c.Name + " cannot be Anonymous") + } + if entry, loaded := f.boxes.Load(c.Type); loaded { + e := entry.(boxEntry) + if e.err != nil { + return e.err } + structValue.Field(i).Set(e.val) + continue + } + if err := f.autowired(structValue.Field(i)); err != nil { + return err } } - newMethod := v.MethodByName("New") - if newMethod.IsValid() { - newMethod.Call(nil) - } - return data + return nil } -// autowired 递归创建依赖实例并注入 auto 标签字段 -func (f *Fun) autowired(fieldValue reflect.Value) { - instance := reflect.New(fieldValue.Type().Elem()) - f.boxes.Store(fieldValue.Type(), instance) +// autowired 递归创建依赖实例并注入 auto 字段(须持有 f.mu)。 +// 实例先入容器再装配字段,供循环依赖拿到占位引用;失败时覆盖为粘性错误 +func (f *Fun) autowired(fieldValue reflect.Value) error { + if fieldValue.Kind() != reflect.Ptr || fieldValue.Type().Elem().Kind() != reflect.Struct { + panic("Fun: auto field " + fieldValue.Type().String() + " must be a pointer to a struct") + } + if isPrivate(fieldValue.Type().Elem().Name()) { + panic("Fun:" + fieldValue.Type().Elem().Name() + " cannot be Private") + } + pt := fieldValue.Type() + checkNewSignature(pt, pt.Elem().Name()) + instance := reflect.New(pt.Elem()) + f.boxes.Store(pt, boxEntry{val: instance}) fieldValue.Set(instance) - structValue := instance.Elem() - for i := 0; i < structValue.NumField(); i++ { - structField := structValue.Type().Field(i) - fieldTag := newTag(structField.Tag) - if _, isAuto := fieldTag.getTag("auto"); isAuto { - if dependency, loaded := f.boxes.Load(structField.Type); loaded { - structValue.Field(i).Set(dependency.(reflect.Value)) - } else { - f.autowired(structValue.Field(i)) - } - } + if err := f.wireStruct(instance.Elem()); err != nil { + f.boxes.Store(pt, boxEntry{err: err}) + return err } - newMethod := instance.MethodByName("New") - if newMethod.IsValid() { - newMethod.Call(nil) + if err := callNewIfPresent(instance); err != nil { + f.boxes.Store(pt, boxEntry{err: err}) + return err } + return nil } -// checkBox 校验 auto 注入字段:必须是指针+struct、非匿名、非私有;New() 必须无参无返回值 -func checkBox(s reflect.StructField, boxList map[reflect.Type]bool) { - if _, ok := boxList[s.Type]; ok { +// checkNewSignature 校验 New 方法签名:无参数,返回 () 或 (error)。 +// 在指针类型上查找,兼容值接收器与指针接收器两种定义 +func checkNewSignature(pt reflect.Type, name string) { + m, found := pt.MethodByName("New") + if !found { return } - boxList[s.Type] = true - if s.Anonymous { - panic("Fun:" + s.Name + " cannot be Anonymous") + mt := m.Type + if mt.NumIn() != 1 { // 仅接收者 + panic("Fun:" + name + " New method must have no parameters") } - if s.Type.Kind() != reflect.Ptr || s.Type.Elem().Kind() != reflect.Struct { - panic("Fun:" + s.Name + " Must be a pointer and a struct") + if mt.NumOut() == 0 { + return } - if isPrivate(s.Name) { - panic("Fun:" + s.Name + " cannot be Private") - } - if newMethod, found := s.Type.MethodByName("New"); found { - if newMethod.Type.NumIn() != 1 || newMethod.Type.NumOut() != 0 { - panic("Fun:" + s.Name + " New method must have no parameters and no return values") - } - } - for i := 0; i < s.Type.Elem().NumField(); i++ { - f := s.Type.Elem().Field(i) - fieldTag := newTag(f.Tag) - if _, isAuto := fieldTag.getTag("auto"); isAuto { - checkBox(f, boxList) - } + if mt.NumOut() == 1 && mt.Out(0) == errorType { + return } + panic("Fun:" + name + " New method must return nothing or error") } -// boxWired 注册期预初始化服务结构体字段中的 Box 依赖 -func boxWired(service any, f *Fun) { - serviceInstance := reflect.New(reflect.TypeOf(service).Elem()).Elem() +// callNewIfPresent 调用指针上的 New()(若存在),支持 () 与 () error 两种签名 +func callNewIfPresent(ptr reflect.Value) error { + m := ptr.MethodByName("New") + if !m.IsValid() { + return nil + } + out := m.Call(nil) + if len(out) == 1 { + if err, ok := out[0].Interface().(error); ok { + return err + } + } + return nil +} + +// boxWired 注册期预初始化服务结构体字段中的 Box 依赖(须持有 f.mu)。 +// 字段对应类型装配失败时向上返回 error +func boxWired(service any, f *Fun) error { + svcType := reflect.TypeOf(service).Elem() + serviceInstance := reflect.New(svcType).Elem() for i := 0; i < serviceInstance.NumField(); i++ { field := serviceInstance.Field(i) if field.Type() == ctxType { continue } if field.Type().Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { - if _, isWired := f.boxes.Load(field.Type()); !isWired { - f.autowired(field) + if entry, isWired := f.boxes.Load(field.Type()); isWired { + if e := entry.(boxEntry); e.err != nil { + return e.err + } + continue + } + if err := f.autowired(field); err != nil { + return fmt.Errorf("fun: wire %s.%s: %w", svcType.Name(), svcType.Field(i).Name, err) } } } + return nil } -// serviceWired 每请求把 Ctx 与 Box 依赖注入到新创建的服务实例 +// serviceWired 每请求把 Ctx 与 Box 依赖注入到新创建的服务实例(只读容器,无锁) func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) { for i := 0; i < serviceInstance.NumField(); i++ { field := serviceInstance.Field(i) @@ -123,7 +175,9 @@ func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) { if field.Type() == ctxType { field.Set(reflect.ValueOf(*ctx)) } else if dependency, ok := f.boxes.Load(field.Type()); ok { - field.Set(dependency.(reflect.Value)) + if e := dependency.(boxEntry); e.err == nil { + field.Set(e.val) + } } } } @@ -139,8 +193,8 @@ func checkGuard(guard Guard) { } } -// serviceGuardWired 创建 Guard 实例并注入 Box 依赖,返回 guard 引用 -func serviceGuardWired(guard Guard, f *Fun) *any { +// serviceGuardWired 创建 Guard 实例并注入 Box 依赖,返回 guard 引用(须持有 f.mu) +func serviceGuardWired(guard Guard, f *Fun) (*any, error) { t := reflect.TypeOf(guard).Elem() guardInstance := reflect.New(t).Elem() for i := 0; i < guardInstance.NumField(); i++ { @@ -148,12 +202,21 @@ func serviceGuardWired(guard Guard, f *Fun) *any { if !field.CanSet() { continue } - if dependency, ok := f.boxes.Load(field.Type()); ok { - field.Set(dependency.(reflect.Value)) - } else { - f.autowired(field) + if field.Type() == ctxType { + continue + } + if entry, ok := f.boxes.Load(field.Type()); ok { + e := entry.(boxEntry) + if e.err != nil { + return nil, e.err + } + field.Set(e.val) + } else if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if err := f.autowired(field); err != nil { + return nil, err + } } } g := guardInstance.Addr().Interface() - return &g + return &g, nil } diff --git a/bugfix_test.go b/bugfix_test.go index e486f2f..10b76fa 100644 --- a/bugfix_test.go +++ b/bugfix_test.go @@ -39,7 +39,9 @@ func (s *BugSvc) Ticker() (string, *Stream, error) { func bugInvoke(t *testing.T, method string, data map[string]any) (*Result[any], error) { t.Helper() f := New() - f.BindService(&BugSvc{}) + if err := f.BindService(&BugSvc{}); err != nil { + t.Fatal(err) + } if data == nil { data = map[string]any{} } @@ -76,7 +78,9 @@ func TestBugNullableEnum(t *testing.T) { // bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void func TestBugGenErrorOnly(t *testing.T) { isolateGeneratorGlobals(t) - GetFun().BindService(&BugSvc{}) + if err := GetFun().BindService(&BugSvc{}); err != nil { + t.Fatal(err) + } SetOutput(t.TempDir()) GenCode(GenGo{}, GenTs{}) goSrc, err := os.ReadFile(filepath.Join(getDirectory(), "go", "bug_svc.go")) @@ -98,7 +102,9 @@ func TestBugGenErrorOnly(t *testing.T) { // bug4+5: 响应键应为小写;(T, stream, error) 的 T 应作为流的第一条消息下发 func TestBugJsonKeysAndStreamFirst(t *testing.T) { f := New() - f.BindService(&BugSvc{}) + if err := f.BindService(&BugSvc{}); err != nil { + t.Fatal(err) + } go f.Start(39003) time.Sleep(300 * time.Millisecond) @@ -150,7 +156,9 @@ func (s *NullSlicSvc) Save(dto NullSlicDto) (string, error) { return "ok", nil } func TestBugSliceNull(t *testing.T) { f := New() - f.BindService(&NullSlicSvc{}) + if err := f.BindService(&NullSlicSvc{}); err != nil { + t.Fatal(err) + } data := map[string]any{"tags": nil} c := &Ctx{Ip: "1", MethodName: "Save", ServiceName: "NullSlicSvc", Data: &data} var streamCh chan any @@ -177,7 +185,9 @@ func (s *LeakSvc) Fail() (*Stream, error) { func TestBugStreamLeak(t *testing.T) { f := New() - f.BindService(&LeakSvc{}) + if err := f.BindService(&LeakSvc{}); err != nil { + t.Fatal(err) + } c := &Ctx{Ip: "1", MethodName: "Fail", ServiceName: "LeakSvc"} var streamCh chan any var streamDone chan struct{} @@ -202,7 +212,9 @@ func (s *CollideSvc) Cookie() (string, error) { return "cookie", nil } func TestBugMethodNameCollision(t *testing.T) { f := New() - f.BindService(&CollideSvc{}) + if err := f.BindService(&CollideSvc{}); err != nil { + t.Fatal(err) + } if _, ok := f.methods["CollideSvc.Cookie"]; !ok { t.Fatal("Cookie method dropped due to name collision with fasthttp.RequestCtx") } diff --git a/check.go b/check.go index 121ed53..adba59e 100644 --- a/check.go +++ b/check.go @@ -3,12 +3,16 @@ package fun import ( "errors" "fmt" + "math" "reflect" "strings" "unicode" ) func isPrivate(value string) bool { + if value == "" { + return false // 匿名类型名:交由各处的具名校验给出明确报错,不在此越界 panic + } return !unicode.IsUpper([]rune(value)[0]) } @@ -36,6 +40,9 @@ func checkType(t reflect.Type) { } } case reflect.Struct: + if t.Name() == "" { + panic("fun: anonymous struct types are not supported, define a named type") + } if t.NumField() == 0 { panic("fun: " + t.Name() + " must have at least one field") } @@ -49,7 +56,11 @@ func checkType(t reflect.Type) { case reflect.Slice: checkType(t.Elem()) default: - panic("fun:Unsupported types " + t.Name()) + name := t.Name() + if name == "" { + name = t.String() // 匿名/内建组合类型(如 interface{})给出可读的名字 + } + panic("fun:Unsupported types " + name) } } @@ -122,7 +133,9 @@ func checkDto(dtoType reflect.Type, dtoMap any, methodName string) error { return nil } -// checkEnumValue 运行时校验枚举值是否在范围内 +// checkEnumValue 运行时校验枚举值是否在范围内。 +// 必须在原始数值上先判范围再转 uint8:否则 256/512 等越界值先被截断成 +// 合法小值(256→0),绕过范围检查后静默落到错误的枚举项上 func checkEnumValue(t reflect.Type, value any, name string) error { var max uint8 enumValue := reflect.New(t).Elem() @@ -131,35 +144,67 @@ func checkEnumValue(t reflect.Type, value any, name string) error { } else { max = uint8(len(enumValue.Interface().(enum).Names())) } + outOfRange := callError(errors.New("Fun:" + name + " Dto value out of range")) var num uint8 switch v := value.(type) { case float64: + if v < 0 || v != math.Trunc(v) || v >= float64(max) { + return outOfRange + } num = uint8(v) case float32: - num = uint8(v) + f := float64(v) + if f < 0 || f != math.Trunc(f) || f >= float64(max) { + return outOfRange + } + num = uint8(f) case uint8: num = v case uint16: + if v >= uint16(max) { + return outOfRange + } num = uint8(v) case uint32: + if v >= uint32(max) { + return outOfRange + } num = uint8(v) case uint64: + if v >= uint64(max) { + return outOfRange + } num = uint8(v) case int: + if v < 0 || v >= int(max) { + return outOfRange + } num = uint8(v) case int8: + if v < 0 || int(v) >= int(max) { + return outOfRange + } num = uint8(v) case int16: + if v < 0 || int(v) >= int(max) { + return outOfRange + } num = uint8(v) case int32: + if v < 0 || int64(v) >= int64(max) { + return outOfRange + } num = uint8(v) case int64: + if v < 0 || v >= int64(max) { + return outOfRange + } num = uint8(v) default: return callError(errors.New("Fun:" + name + " Dto enum value type is not supported")) } if num >= max { - return callError(errors.New("Fun:" + name + " Dto value out of range")) + return outOfRange } return nil } diff --git a/cmd/genexample/main.go b/cmd/genexample/main.go new file mode 100644 index 0000000..27e2805 --- /dev/null +++ b/cmd/genexample/main.go @@ -0,0 +1,34 @@ +// genexample 重新生成 example/gen 下的客户端产物(Go + TS)。 +// 在仓库根目录执行:go run ./cmd/genexample +package main + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/cyi-cc/fun" + "github.com/cyi-cc/fun/example/demo" +) + +func main() { + f := fun.New() + for _, svc := range []any{&demo.OrderSvc{}, &demo.ChatSvc{}} { + f.BindServiceForGen(svc) // 只登记元信息,不触发依赖装配 + } + fun.SetOutput("./example/gen") + fun.GenCode(fun.GenGo{}, fun.GenTs{}) + + err := filepath.WalkDir("./example/gen", func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + rel, _ := filepath.Rel("./example/gen", path) + fmt.Println("生成:", filepath.ToSlash(rel)) + } + return err + }) + if err != nil { + fmt.Fprintln(os.Stderr, "walk output:", err) + os.Exit(1) + } +} diff --git a/ctx.go b/ctx.go index 987bc2d..35b8217 100644 --- a/ctx.go +++ b/ctx.go @@ -1,6 +1,7 @@ package fun import ( + "bytes" "encoding/json" "net" "reflect" @@ -18,6 +19,10 @@ type Ctx struct { ServiceName string Data *map[string]any RequestCtx *fasthttp.RequestCtx + + // rawData 请求 data 字段的原始 JSON 字节(HTTP 路径填充)。 + // 业务 DTO 解码优先用它:不经 map[string]any 的 float64 往返,大整数无精度丢失 + rawData []byte } var ctxType = reflect.TypeFor[Ctx]() @@ -49,10 +54,14 @@ func (c *Ctx) send(result Result[any]) { _, _ = c.write(out) } -// lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写 +// lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写。 +// 数字以 json.Number 原文保留,不落入 float64——否则响应侧 int64 +// 超过 2^53 会丢精度(9007199254740993 → ...992) func lowerKeysFromJSON(data []byte) (any, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() var raw any - if err := json.Unmarshal(data, &raw); err != nil { + if err := dec.Decode(&raw); err != nil { return nil, err } return lowerKeys(raw), nil diff --git a/enum.go b/enum.go index a48578b..6a58a05 100644 --- a/enum.go +++ b/enum.go @@ -23,4 +23,5 @@ var ( errMethodNotFound = errors.New("method not found") errEmptyFields = errors.New("serviceName and methodName cannot be empty") errDTORequired = errors.New("method requires a DTO but none provided") + errInvalidData = errors.New("invalid request data") // 详细原因只记服务端日志 ) diff --git a/example/demo/demo.go b/example/demo/demo.go new file mode 100644 index 0000000..6b9d86f --- /dev/null +++ b/example/demo/demo.go @@ -0,0 +1,105 @@ +// Package demo 演示 fun 框架各特性的示例服务集: +// 定宽整型与可空指针字段、枚举、业务错误码、NDJSON 流式(纯流 + 首条消息流)。 +// 产物参考见 example/gen(由 cmd/genexample 生成)。 +package demo + +import ( + "github.com/cyi-cc/fun" +) + +// OrderStatus 订单状态枚举:uint8 底层 + Names/DisplayNames +type OrderStatus uint8 + +func (OrderStatus) Names() []string { return []string{"Pending", "Paid", "Shipped"} } +func (OrderStatus) DisplayNames() []string { return []string{"待支付", "已支付", "已发货"} } + +// CreateOrderDto 下单参数。规则:非指针字段必传;指针/slice 字段可省略; +// 数值一律定宽整型(int64),小数用 string 传输 +type CreateOrderDto struct { + Sku string // 必传 + Count int64 // 必传 + Note *string // 可空 + Status *OrderStatus // 可空,缺省 Pending + Tags []string // 可省略 +} + +// OrderDto 订单视图。Id 为雪花 ID:全链路 int64 精度,TS 侧解析为 BigInt +type OrderDto struct { + Id int64 + Status OrderStatus + Amount string // 小数金额用字符串传输 + Items []string +} + +type GetOrderDto struct { + Id int64 +} + +type CancelDto struct { + Id int64 + Reason *string +} + +// OrderSvc 订单服务:普通请求/响应、无 DTO、error-only、业务错误码 +type OrderSvc struct { + fun.Ctx +} + +func (s *OrderSvc) Create(dto CreateOrderDto) (OrderDto, error) { + return OrderDto{ + Id: 9007199254740993, // 超过 2^53 的大整数示例 + Status: OrderStatus(0), + Amount: "199.00", + Items: dto.Tags, + }, nil +} + +func (s *OrderSvc) Get(dto GetOrderDto) (OrderDto, error) { + return OrderDto{Id: dto.Id, Status: OrderStatus(1), Amount: "0.01"}, nil +} + +// Cancel error-only 签名:无返回数据 +func (s *OrderSvc) Cancel(dto CancelDto) error { + if dto.Reason == nil { + // 业务错误:Code/Msg 原样透传给前端(status=2) + return fun.Error(4004, "必须填写取消原因") + } + return nil +} + +type ChatDto struct { + Prompt string +} + +type AskDto struct { + Prompt string +} + +// ChatSvc 流式服务:纯流 + 首条消息流两种签名 +type ChatSvc struct { + fun.Ctx +} + +// Chat 纯流式:响应为 application/x-ndjson,逐行推送 +func (s *ChatSvc) Chat(dto ChatDto) (*fun.Stream, error) { + st := &fun.Stream{} + go func() { + for _, chunk := range []string{"你好", ",这是", "流式示例"} { + if err := st.Send(chunk); err != nil { + return // 连接断开 + } + } + st.Close() // 必须关闭,否则客户端一直等 + }() + return st, nil +} + +// Ask (T, stream, error):T 作为流的第一条消息下发 +func (s *ChatSvc) Ask(dto AskDto) (string, *fun.Stream, error) { + st := &fun.Stream{} + go func() { + _ = st.Send("思考中...") + st.Close() + }() + return "收到:" + dto.Prompt, st, nil +} diff --git a/fun.go b/fun.go index a3136e8..411eb89 100644 --- a/fun.go +++ b/fun.go @@ -1,37 +1,69 @@ package fun import ( + "context" "fmt" "reflect" "sync" + "sync/atomic" + "time" "github.com/valyala/fasthttp" ) type Fun struct { methods map[string]methodInfo - routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配) - wildcardRoutes map[string][]wildcardRoute - boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value - guards []*any // 全局 Guard - serviceGuards map[string][]*any // 服务级 Guard,按服务名 - bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB + routes map[string]boundRoute // 自定义路由:"GET /path" → 绑定的处理器与 Guard(精确匹配) + wildcardRoutes map[string][]wildcardRoute // 通配路由,按 HTTP 方法 + boxes *sync.Map // 依赖容器:reflect.Type → boxEntry(单例或粘性错误) + guards []*any // 全局 Guard + serviceGuards map[string][]*any // 服务级 Guard,按服务名 + bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB + + readTimeout time.Duration // 读超时,默认 60s(slowloris 防线) + writeTimeout time.Duration // 写超时,默认 0 不限制(避免掐断长流式响应) + idleTimeout time.Duration // keep-alive 空闲超时,默认 120s + maxConcurrency int // 最大并发连接数;0 = 不限制 + + server atomic.Pointer[fasthttp.Server] + started atomic.Bool + + mu sync.Mutex // 注册与依赖装配互斥:保护 methods/routes/boxes 的写入 } // SetBodyLimit 设置请求体上限(字节),须在 Start 前调用。 // multipart 上传等大请求体的自定义路由需要时设置;0 或负数恢复默认。 func (f *Fun) SetBodyLimit(n int) { + f.mustNotStarted("SetBodyLimit") if n < 0 { n = 0 } f.bodyLimit = n } +// SetTimeouts 配置服务器超时,须在 Start 前调用;单项传 0 表示不限制。 +// 默认 ReadTimeout 60s / IdleTimeout 120s / WriteTimeout 不限制 +func (f *Fun) SetTimeouts(read, write, idle time.Duration) { + f.mustNotStarted("SetTimeouts") + f.mu.Lock() + defer f.mu.Unlock() + f.readTimeout, f.writeTimeout, f.idleTimeout = read, write, idle +} + +// SetMaxConcurrency 配置最大并发连接数(0 = 不限制),须在 Start 前调用。 +// 防御慢 handler 堆积 goroutine 打爆内存 +func (f *Fun) SetMaxConcurrency(n int) { + f.mustNotStarted("SetMaxConcurrency") + f.mu.Lock() + defer f.mu.Unlock() + f.maxConcurrency = n +} + // wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册): // prefix 如 "/image",匹配 prefix 与 prefix 下任意子路径 type wildcardRoute struct { - prefix string - handler RouteHandler + prefix string + route boundRoute } var ( @@ -39,34 +71,48 @@ var ( streamType = reflect.TypeFor[*Stream]() ) -var fun *Fun +var ( + fun *Fun + funMu sync.Mutex +) // methodInfo 已注册方法的元信息 type methodInfo struct { serviceType reflect.Type // 服务值类型(非指针),每请求新建实例 methodIndex int // 方法在实例上的反射索引 dtoType reflect.Type // DTO 参数类型,无参数时为 nil - isStream bool // 返回签名带 *Stream,走 RequestStreamType + isStream bool // 返回签名带 *Stream,响应走 NDJSON 流式 } -func New() *Fun { - f := &Fun{ +func newFun() *Fun { + return &Fun{ methods: map[string]methodInfo{}, - routes: map[string]RouteHandler{}, + routes: map[string]boundRoute{}, wildcardRoutes: map[string][]wildcardRoute{}, boxes: &sync.Map{}, serviceGuards: map[string][]*any{}, + readTimeout: 60 * time.Second, + idleTimeout: 120 * time.Second, + // writeTimeout 保持 0:流式响应可能长时间推送,写超时会掐断连接 } +} + +func New() *Fun { + f := newFun() + funMu.Lock() if fun == nil { fun = f } + funMu.Unlock() return f } -// GetFun 返回默认 Fun 实例,未初始化时自动创建 +// GetFun 返回默认 Fun 实例,未初始化时自动创建(并发安全) func GetFun() *Fun { + funMu.Lock() + defer funMu.Unlock() if fun == nil { - fun = New() + fun = newFun() } return fun } @@ -76,18 +122,30 @@ func GetFun() *Fun { // - 参数:最多一个,且必须是 struct(作为 DTO) // - 返回值:只支持四种签名——(error)、(T, error)、(stream, error)、(T, stream, error) // -// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行 -func (f *Fun) BindService(service any, guardList ...Guard) { +// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行。 +// 依赖装配失败(New() 返回 error)以 error 返回,由调用方决定退出或降级; +// 用法错误(非结构体指针、非法签名)仍为 panic,等价编译期检查 +func (f *Fun) BindService(service any, guardList ...Guard) error { t, name := serviceType(service) - boxWired(service, f) + f.mu.Lock() + defer f.mu.Unlock() + f.mustNotStarted("BindService") + if err := boxWired(service, f); err != nil { + return err + } serviceGuards := make([]*any, 0, len(guardList)) for _, guard := range guardList { checkGuard(guard) - serviceGuards = append(serviceGuards, serviceGuardWired(guard, f)) + g, err := serviceGuardWired(guard, f) + if err != nil { + return fmt.Errorf("fun: wire guard %T: %w", guard, err) + } + serviceGuards = append(serviceGuards, g) } f.serviceGuards[name] = serviceGuards f.bindServiceMethods(t, name) + return nil } // BindServiceForGen registers service metadata for code generation without @@ -166,27 +224,90 @@ func (f *Fun) bindServiceMethods(t reflect.Type, name string) { } // BindGuard 注册全局 Guard,对所有服务生效 -func (f *Fun) BindGuard(guard Guard) { +func (f *Fun) BindGuard(guard Guard) error { checkGuard(guard) - f.guards = append(f.guards, serviceGuardWired(guard, f)) + f.mu.Lock() + defer f.mu.Unlock() + f.mustNotStarted("BindGuard") + g, err := serviceGuardWired(guard, f) + if err != nil { + return fmt.Errorf("fun: wire guard %T: %w", guard, err) + } + f.guards = append(f.guards, g) + return nil } -// callGuard 按 全局 → 服务级 顺序执行 Guard -func (f *Fun) callGuard(c *Ctx, serviceName string) { +// callGuard 按全局 → 服务级顺序执行 Guard,首个非 nil error 短路返回 +func (f *Fun) callGuard(c *Ctx, serviceName string) error { for _, g := range f.guards { - (*g).(Guard).Guard(*c) + if err := (*g).(Guard).Guard(*c); err != nil { + return err + } } for _, g := range f.serviceGuards[serviceName] { - (*g).(Guard).Guard(*c) + if err := (*g).(Guard).Guard(*c); err != nil { + return err + } + } + return nil +} + +// mustNotStarted 注册期 API 在 Start 后调用即 panic: +// 运行期对 methods/routes 等注册表的读取不持锁,晚注册与并发请求是数据竞争 +func (f *Fun) mustNotStarted(op string) { + if f.started.Load() { + panic("fun: " + op + " must be called before Start") } } +// Start 在指定端口启动 HTTP 服务(阻塞)。 +// 默认 ReadTimeout 60s、IdleTimeout 120s(slowloris 防线,SetTimeouts 可调), +// WriteTimeout 默认不限制,长流式响应不会被掐断。 +// 优雅停机用 Shutdown;重复 Start panic func (f *Fun) Start(port uint16) { - server := &fasthttp.Server{Handler: f.handle} - if f.bodyLimit > 0 { - server.MaxRequestBodySize = f.bodyLimit + f.StartOn(fmt.Sprintf(":%d", port)) +} + +// StartOn 在指定地址(":8080"、"127.0.0.1:9000" 等)启动服务,语义同 Start +func (f *Fun) StartOn(addr string) { + f.mu.Lock() + if f.started.Swap(true) { + f.mu.Unlock() + panic("fun: Start already called") } - if err := server.ListenAndServe(fmt.Sprintf(":%d", port)); err != nil { + srv := f.newServer() + f.server.Store(srv) + f.mu.Unlock() + if err := srv.ListenAndServe(addr); err != nil { panic(err.Error()) } } + +// Shutdown 优雅停机:停止接受新连接,等待在途请求(含流式响应)完成或 ctx 超时。 +// 未启动或已停机时为空操作 +func (f *Fun) Shutdown(ctx context.Context) error { + if s := f.server.Load(); s != nil { + return s.ShutdownWithContext(ctx) + } + return nil +} + +func (f *Fun) newServer() *fasthttp.Server { + srv := &fasthttp.Server{Handler: f.handle} + if f.bodyLimit > 0 { + srv.MaxRequestBodySize = f.bodyLimit + } + if f.readTimeout > 0 { + srv.ReadTimeout = f.readTimeout + } + if f.writeTimeout > 0 { + srv.WriteTimeout = f.writeTimeout + } + if f.idleTimeout > 0 { + srv.IdleTimeout = f.idleTimeout + } + if f.maxConcurrency > 0 { + srv.Concurrency = f.maxConcurrency + } + return srv +} diff --git a/fun_test.go b/fun_test.go index ab402c2..13bc228 100644 --- a/fun_test.go +++ b/fun_test.go @@ -29,8 +29,9 @@ var guardHit = false type TestGuard struct{} -func (g *TestGuard) Guard(ctx Ctx) { +func (g *TestGuard) Guard(ctx Ctx) error { guardHit = true + return nil } func (s *TestSvc) Hello(dto TestDto) (string, error) { @@ -58,7 +59,9 @@ func (s *TestSvc) Count(dto TestDto) (*Stream, error) { func TestCtxBoxInject(t *testing.T) { f := New() guardHit = false - f.BindService(&TestSvc{}, &TestGuard{}) + if err := f.BindService(&TestSvc{}, &TestGuard{}); err != nil { + t.Fatal(err) + } c := &Ctx{Ip: "1.2.3.4", MethodName: "Hello", ServiceName: "TestSvc"} data := map[string]any{"name": "tom", "age": 1} c.Data = &data @@ -79,7 +82,9 @@ func TestCtxBoxInject(t *testing.T) { func TestCheckDtoRequired(t *testing.T) { f := New() - f.BindService(&TestSvc{}) + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } c := &Ctx{Ip: "x", MethodName: "Hello", ServiceName: "TestSvc"} data := map[string]any{"age": 1} c.Data = &data @@ -94,7 +99,9 @@ func TestCheckDtoRequired(t *testing.T) { func TestGenCode(t *testing.T) { isolateGeneratorGlobals(t) - GetFun().BindService(&TestSvc{}) + if err := GetFun().BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } SetOutput(t.TempDir()) GenCode(GenGo{}, GenTs{}) if _, err := os.Stat(filepath.Join(getDirectory(), "go", "test_svc.go")); err != nil { @@ -107,7 +114,9 @@ func TestGenCode(t *testing.T) { func startServer(t *testing.T, port uint16) *Fun { f := New() - f.BindService(&TestSvc{}) + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } go f.Start(port) time.Sleep(300 * time.Millisecond) return f diff --git a/gen_go.go b/gen_go.go index 17048e1..239d825 100644 --- a/gen_go.go +++ b/gen_go.go @@ -175,6 +175,11 @@ func (ctx GenGo) genStruct(t reflect.Type) *genImportType { }) } + // 指针字段解引用后再检查嵌套类型,与 TS 生成器一致: + // 否则 *Enum/*Struct 字段的定义文件会被漏生成(Kind 是 Ptr,三个分支全跳过) + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } if fieldType.Kind() == reflect.Struct { ctx.genStruct(fieldType) } diff --git a/gen_reliability_test.go b/gen_reliability_test.go index 2fdb955..1b1853e 100644 --- a/gen_reliability_test.go +++ b/gen_reliability_test.go @@ -95,9 +95,11 @@ func TestBindServiceForGenDoesNotInitializeDependencies(t *testing.T) { func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) { isolateGeneratorGlobals(t) f := GetFun() - f.BindService(&ZebraGenSvc{}) - f.BindService(&MixedGenSvc{}) - f.BindService(&AlphaGenSvc{}) + for _, svc := range []any{&ZebraGenSvc{}, &MixedGenSvc{}, &AlphaGenSvc{}} { + if err := f.BindService(svc); err != nil { + t.Fatal(err) + } + } SetOutput(t.TempDir()) GenCode(GenTs{}) @@ -168,9 +170,11 @@ func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) { func TestGeneratedSourcesAreDeterministic(t *testing.T) { isolateGeneratorGlobals(t) f := GetFun() - f.BindService(&ZebraGenSvc{}) - f.BindService(&AlphaGenSvc{}) - f.BindService(&MixedGenSvc{}) + for _, svc := range []any{&ZebraGenSvc{}, &AlphaGenSvc{}, &MixedGenSvc{}} { + if err := f.BindService(svc); err != nil { + t.Fatal(err) + } + } root := t.TempDir() SetOutput(root) GenCode(GenGo{}, GenTs{}) diff --git a/guard.go b/guard.go index 54669ca..bbd6ea3 100644 --- a/guard.go +++ b/guard.go @@ -1,5 +1,10 @@ package fun +// Guard 方法调用前的拦截器。 +// +// 返回 nil 放行;返回 error 短路:后续 Guard 与业务方法不再执行, +// error 走统一 Result 错误响应(返回 fun.Error(code, msg) 可携带错误码)。 +// 不再需要通过写响应或 panic 表达拒绝 type Guard interface { - Guard(ctx Ctx) + Guard(ctx Ctx) error } diff --git a/handle.go b/handle.go index 8f6aa40..32d100e 100644 --- a/handle.go +++ b/handle.go @@ -8,6 +8,7 @@ import ( "reflect" "runtime/debug" "strings" + "sync" "github.com/valyala/fasthttp" ) @@ -18,13 +19,13 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { defer f.handlePanic(ctx) method, path := string(fastCtx.Method()), string(fastCtx.Path()) - if handler, ok := f.routes[method+" "+path]; ok { - f.handleRoute(fastCtx, handler, "") + if r, ok := f.routes[method+" "+path]; ok { + f.handleRoute(fastCtx, r, "") return } for _, r := range f.wildcardRoutes[method] { if path == r.prefix || strings.HasPrefix(path, r.prefix+"/") { - f.handleRoute(fastCtx, r.handler, strings.TrimPrefix(path, r.prefix+"/")) + f.handleRoute(fastCtx, r.route, strings.TrimPrefix(path, r.prefix+"/")) return } } @@ -39,9 +40,11 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { } body := ctx.postBody() - var requestInfo RequestInfo[map[string]any] + // Data 以原始字节保存:校验用解码后的 map,业务 DTO 解码用原始字节, + // 大整数不经过 float64 往返,避免 int64 精度丢失 + var requestInfo RequestInfo[json.RawMessage] if err := json.Unmarshal(body, &requestInfo); err != nil { - ctx.sendError(err) + ctx.send(internalError("invalid request body", err)) return } requestInfo.MethodName = firstLetterToUpper(requestInfo.MethodName) @@ -51,11 +54,21 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { return } - ctx.Ip = ctx.remoteIP().String() + ctx.Ip = clientIP(fastCtx) ctx.State = requestInfo.State ctx.MethodName = requestInfo.MethodName ctx.ServiceName = requestInfo.ServiceName - ctx.Data = requestInfo.Data + if requestInfo.Data != nil { + var dataMap map[string]any + if err := json.Unmarshal(*requestInfo.Data, &dataMap); err != nil { + ctx.send(internalError("invalid request data", err)) + return + } + if dataMap != nil { // "data": null 视为未提供数据 + ctx.Data = &dataMap + ctx.rawData = *requestInfo.Data + } + } // 流式方法:响应保持打开,以 NDJSON 行推送(Streamable HTTP) // streamCh != nil 表示流式方法;业务返回的 *Stream 在 invoke 内完成注入 @@ -72,6 +85,17 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { fastCtx.Response.Header.Set("Cache-Control", "no-cache") fastCtx.Response.Header.Set("Connection", "keep-alive") fastCtx.SetBodyStreamWriter(func(w *bufio.Writer) { + // 流式写出运行在 fasthttp 的写出 goroutine 上,handle 的 defer 兜不住: + // 这里必须自行 recover,否则业务 Send 的值 MarshalJSON panic 会击穿进程。 + // finish 保证 streamDone 恰好 close 一次,解除业务 Send 阻塞 + var closeDone sync.Once + finish := func() { closeDone.Do(func() { close(streamDone) }) } + defer func() { + if v := recover(); v != nil { + ErrorLogger(fmt.Sprintf("fun: stream writer panic (%s.%s): %v", ctx.ServiceName, ctx.MethodName, v), "\n"+string(debug.Stack())) + finish() + } + }() writeLine := func(v any) bool { data, err := json.Marshal(v) if err != nil { @@ -94,24 +118,26 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { // (T, stream, error):T 作为流的第一条消息下发 if result.Data != nil { if !writeLine(*result.Data) { - close(streamDone) + finish() return } } for message := range streamCh { if !writeLine(message) { - close(streamDone) + finish() return } } - close(streamDone) + finish() }) return } ctx.send(*result) } -// handlePanic 兜底处理 panic:归一为 error 后写回错误响应,并记录完整堆栈日志 +// handlePanic 兜底处理 panic:完整堆栈记日志; +// 业务以 panic 抛出的 fun.Error 原样透传,其余 panic 只回通用错误—— +// panic 消息可能包含 SQL/内部路径等细节,不外泄给客户端 func (f *Fun) handlePanic(c *Ctx) { if v := recover(); v != nil { var err error @@ -121,7 +147,12 @@ func (f *Fun) handlePanic(c *Ctx) { err = fmt.Errorf("panic (%s.%s): %v", c.ServiceName, c.MethodName, v) } ErrorLogger(err.Error(), "\n"+string(debug.Stack())) - c.sendError(err) + var result Result[any] + if errors.As(err, &result) { + c.sendError(err) + return + } + c.send(internalError("internal error", err)) } } @@ -135,7 +166,9 @@ func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Re return nil, errMethodNotFound } - f.callGuard(c, c.ServiceName) + if err := f.callGuard(c, c.ServiceName); err != nil { + return nil, err + } var args []reflect.Value if method.dtoType != nil { @@ -146,8 +179,17 @@ func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Re return nil, err } dto := reflect.New(method.dtoType).Elem() - if err := convert(c.Data, dto.Addr().Interface()); err != nil { - return nil, err + // 优先按原始字节精确解码(不经 float64);直接构造 Ctx 调 invoke(无 rawData)时回退 map 往返 + var decodeErr error + if len(c.rawData) > 0 { + decodeErr = json.Unmarshal(c.rawData, dto.Addr().Interface()) + } else { + decodeErr = convert(*c.Data, dto.Addr().Interface()) + } + if decodeErr != nil { + // 客户端只收通用提示;详细原因记服务端日志,不外泄 Go 类型等内部信息 + ErrorLogger("fun: decode request data (" + c.ServiceName + "." + c.MethodName + "): ", decodeErr.Error()) + return nil, errInvalidData } args = append(args, dto) } diff --git a/hardening_test.go b/hardening_test.go new file mode 100644 index 0000000..9a23615 --- /dev/null +++ b/hardening_test.go @@ -0,0 +1,343 @@ +package fun + +// 硬化批次回归测试:真实 IP、路由 Guard、started 护栏与优雅停机、 +// 内部错误脱敏、流式 writer panic 兜底、匿名 struct 拒绝、TS 大整数往返 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/valyala/fasthttp" +) + +// ---- 真实 IP:X-Forwarded-For > X-Real-IP > RemoteAddr ---- + +func TestClientIP(t *testing.T) { + mk := func(headers map[string]string, remote string) *fasthttp.RequestCtx { + fc := &fasthttp.RequestCtx{} + for k, v := range headers { + fc.Request.Header.Set(k, v) + } + if remote != "" { + fc.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(remote), Port: 1234}) + } + return fc + } + if got := clientIP(mk(map[string]string{"X-Forwarded-For": "1.1.1.1, 2.2.2.2"}, "")); got != "2.2.2.2" { + t.Fatalf("XFF last segment: got %q", got) + } + if got := clientIP(mk(map[string]string{"X-Real-IP": "3.3.3.3"}, "")); got != "3.3.3.3" { + t.Fatalf("X-Real-IP: got %q", got) + } + if got := clientIP(mk(nil, "9.9.9.9")); got != "9.9.9.9" { + t.Fatalf("RemoteAddr fallback: got %q", got) + } + if got := clientIP(mk(nil, "")); got != "127.0.0.1" { + t.Fatalf("no remote addr: got %q", got) + } + if got := clientIP(mk(map[string]string{"X-Real-IP": "::1"}, "")); got != "127.0.0.1" { + t.Fatalf("loopback normalize: got %q", got) + } +} + +type EchoIpSvc struct { + Ctx +} + +func (s *EchoIpSvc) Get() (string, error) { return s.Ip, nil } + +func TestClientIPEndtoEnd(t *testing.T) { + f := New() + if err := f.BindService(&EchoIpSvc{}); err != nil { + t.Fatal(err) + } + go f.Start(39015) + time.Sleep(300 * time.Millisecond) + defer f.Shutdown(context.Background()) + + req, err := http.NewRequest("POST", "http://127.0.0.1:39015/cell", + strings.NewReader(`{"serviceName":"EchoIpSvc","methodName":"Get"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Forwarded-For", "203.0.113.9") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out Result[any] + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out.Status != 0 || out.Data == nil || (*out.Data).(string) != "203.0.113.9" { + t.Fatalf("client ip not honored: %+v", out) + } +} + +// ---- 路由 Guard:短路 + 查询参数进 State ---- + +type RouteTokenGuard struct{} + +func (g *RouteTokenGuard) Guard(ctx Ctx) error { + if ctx.State["token"] != "ok" { + return Error(4401, "unauthorized") + } + return nil +} + +func TestRouteGuard(t *testing.T) { + f := New() + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } + handlerRan := false + open := func(c *RouteCtx) error { + handlerRan = true + c.RequestCtx.WriteString("open") + return nil + } + secret := func(c *RouteCtx) error { + handlerRan = true + c.RequestCtx.WriteString("secret") + return nil + } + files := func(c *RouteCtx) error { + handlerRan = true + c.RequestCtx.WriteString("file:" + c.Wildcard) + return nil + } + if err := f.BindRoute("GET", "/open", open, &OrderFirstGuard{}); err != nil { + t.Fatal(err) + } + if err := f.BindRoute("GET", "/secret", secret, &OrderRejectGuard{}); err != nil { + t.Fatal(err) + } + if err := f.BindRoute("GET", "/file/*", files, &RouteTokenGuard{}); err != nil { + t.Fatal(err) + } + + do := func(path string) *fasthttp.RequestCtx { + fc := &fasthttp.RequestCtx{} + fc.Request.Header.SetMethod("GET") + fc.Request.SetRequestURI(path) + f.handle(fc) + return fc + } + + handlerRan = false + if fc := do("/open"); !handlerRan || string(fc.Response.Body()) != "open" { + t.Fatalf("passing guard: ran=%v body=%q", handlerRan, fc.Response.Body()) + } + + handlerRan = false + fc := do("/secret") + if handlerRan { + t.Fatal("handler must not run when guard rejects") + } + if body := string(fc.Response.Body()); !strings.Contains(body, `"code":4003`) || !strings.Contains(body, `"status":2`) { + t.Fatalf("guard rejection body: %s", body) + } + + // 通配路由 + Guard 从查询参数取 token(State 合并) + handlerRan = false + if fc := do("/file/a/b.txt?token=ok"); !handlerRan || string(fc.Response.Body()) != "file:a/b.txt" { + t.Fatalf("wildcard with token: ran=%v body=%q", handlerRan, fc.Response.Body()) + } + handlerRan = false + if fc := do("/file/a.txt"); handlerRan || !strings.Contains(string(fc.Response.Body()), `"code":4401`) { + t.Fatal("missing token must be rejected by route guard") + } +} + +// ---- started 护栏 + 优雅停机 ---- + +func TestStartedGuardAndShutdown(t *testing.T) { + f := New() + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := uint16(ln.Addr().(*net.TCPAddr).Port) + _ = ln.Close() + + go f.Start(port) + time.Sleep(300 * time.Millisecond) + if err := f.Shutdown(context.Background()); err != nil { + t.Fatalf("shutdown: %v", err) + } + if err := f.Shutdown(context.Background()); err != nil { + t.Fatalf("second shutdown should be no-op: %v", err) + } + + catch := func(fn func()) (msg string) { + defer func() { msg = fmt.Sprint(recover()) }() + fn() + return "" + } + if m := catch(func() { _ = f.BindService(&TestSvc{}) }); m == "" { + t.Fatal("BindService after Start must panic") + } + if m := catch(func() { _ = f.BindRoute("GET", "/x", func(*RouteCtx) error { return nil }) }); m == "" { + t.Fatal("BindRoute after Start must panic") + } + if m := catch(func() { f.SetTimeouts(time.Second, 0, time.Second) }); m == "" { + t.Fatal("SetTimeouts after Start must panic") + } +} + +// ---- 内部错误脱敏:客户端只收固定提示,不泄露 Go 内部细节 ---- + +func TestInternalErrorsSanitized(t *testing.T) { + f := New() + if err := f.BindService(&PrecisionSvc{}); err != nil { + t.Fatal(err) + } + go f.Start(39016) + time.Sleep(300 * time.Millisecond) + defer f.Shutdown(context.Background()) + + post := func(body string) string { + t.Helper() + resp, err := http.Post("http://127.0.0.1:39016/cell", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b) + } + + // data 字段类型错误(字符串进 int64)→ 通用提示,不泄露 unmarshal/类型名 + got := post(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":"not-a-number"}}`) + if !strings.Contains(got, "invalid request data") { + t.Fatalf("expected sanitized message, got: %s", got) + } + for _, leak := range []string{"unmarshal", "int64", "PrecisionDto", "Go struct"} { + if strings.Contains(got, leak) { + t.Fatalf("internal detail %q leaked: %s", leak, got) + } + } + + // 损坏的请求体 → 通用提示 + got = post(`{"serviceName":`) + if !strings.Contains(got, "invalid request body") { + t.Fatalf("expected sanitized body message, got: %s", got) + } +} + +// ---- 流式 writer panic 兜底:进程不崩、服务器仍可用 ---- + +type boomMarshaler struct{} + +func (boomMarshaler) MarshalJSON() ([]byte, error) { panic("boom-json") } + +type PanicStreamSvc struct{} + +func (s *PanicStreamSvc) Go() (*Stream, error) { + st := &Stream{} + go func() { + _ = st.Send(boomMarshaler{}) + st.Close() + }() + return st, nil +} + +func TestStreamWriterPanicRecovered(t *testing.T) { + f := New() + if err := f.BindService(&PanicStreamSvc{}); err != nil { + t.Fatal(err) + } + go f.Start(39017) + time.Sleep(300 * time.Millisecond) + defer f.Shutdown(context.Background()) + + resp, err := http.Post("http://127.0.0.1:39017/cell", "application/json", + strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`)) + if err != nil { + t.Fatalf("first stream request: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + // panic 被兜底后服务器必须仍然可用 + resp2, err := http.Post("http://127.0.0.1:39017/cell", "application/json", + strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`)) + if err != nil { + t.Fatalf("server died after stream writer panic: %v", err) + } + _, _ = io.Copy(io.Discard, resp2.Body) + _ = resp2.Body.Close() +} + +// ---- 匿名 struct 注册期拒绝 / isPrivate 空名安全 ---- + +func TestAnonymousStructRejected(t *testing.T) { + type withAnon struct { + Inner struct{ A string } + } + defer func() { + if recover() == nil { + t.Fatal("anonymous struct must be rejected at registration") + } + }() + checkType(reflect.TypeFor[withAnon]()) + _ = isPrivate("") // 空名不再越界 panic +} + +// ---- TS 客户端大整数往返(node 真实执行生成物) ---- + +func TestTypeScriptBigIntRoundTrip(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil { + t.Fatal(err) + } + const script = ` +import { Client } from "./client.ts"; + +// 响应侧:mock 返回原文 JSON(不经 JS number),大整数必须解析为 BigInt +globalThis.fetch = async () => new Response('{"status":0,"data":{"id":9007199254740993}}', { + headers: { "Content-Type": "application/json" }, +}); +const client = new Client("http://example.test"); +const result = await client.request("Svc", "BigInt"); +if (typeof result.data.id !== "bigint") throw new Error("expected bigint, got " + typeof result.data.id); +if (result.data.id !== 9007199254740993n) throw new Error("bigint value lost"); + +// 请求侧:dto 里的 BigInt 序列化为数字字面量;普通数值不受影响 +let requestBody; +globalThis.fetch = async (_url, init) => { + requestBody = init.body; + return new Response('{"status":0}', { headers: { "Content-Type": "application/json" } }); +}; +await client.request("Svc", "BigInt", { id: 9007199254740993n }); +if (!requestBody.includes('"id":9007199254740993')) throw new Error("bigint not serialized: " + requestBody); +await client.request("Svc", "Small", { id: 42 }); +if (!requestBody.includes('"id":42')) throw new Error("small int broken: " + requestBody); +` + scriptPath := filepath.Join(dir, "bigint.mjs") + if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { + t.Fatal(err) + } + if output, err := exec.Command(node, "--experimental-strip-types", scriptPath).CombinedOutput(); err != nil { + t.Fatalf("bigint round trip failed: %v\n%s", err, output) + } +} diff --git a/logger.go b/logger.go index f0a39cb..6e0b87f 100644 --- a/logger.go +++ b/logger.go @@ -24,14 +24,15 @@ const ( ) var logChan = make(chan string, 100) -var logWg sync.WaitGroup const ( TerminalMode uint8 = iota FileMode ) -var logMutex sync.Mutex +var logMutex sync.Mutex // 串行化实际写出(后台 worker 与通道满时的同步兜底) + +var loggerMu sync.RWMutex // 保护 logger 配置的读写,消除 ConfigLogger 数据竞争 type Logger struct { Level uint8 @@ -59,11 +60,12 @@ func init() { func logWriterWorker() { for text := range logChan { logMutex.Lock() - if logger.Mode == FileMode { + if currentLogger().Mode == FileMode { fileLogger(text) } else { fmt.Println(text) } + logMutex.Unlock() } } @@ -74,13 +76,20 @@ func deleteLogWorker() { for { select { case <-ticker.C: - if logger.Mode == FileMode { + if currentLogger().Mode == FileMode { cleanupExpiredLogs() } } } } +// currentLogger 读取当前日志配置快照,避免与 ConfigLogger 的数据竞争 +func currentLogger() Logger { + loggerMu.RLock() + defer loggerMu.RUnlock() + return logger +} + func getLogFilePath() string { if logger.LogFilePath == "" { return "./log" @@ -89,7 +98,8 @@ func getLogFilePath() string { } func cleanupExpiredLogs() { - if logger.ExpireLogsDays <= 0 { + cfg := currentLogger() + if cfg.ExpireLogsDays <= 0 { return } _, err := os.Stat(getLogFilePath()) @@ -124,24 +134,24 @@ func cleanupExpiredLogs() { } } +// getFileNameInfo 解析日志文件名 "日期.log.序号"。 +// 解析失败仅视为非托管文件并跳过——绝不删除:日志目录里用户放入的 +// 任何无关文件(配置、说明)不属于框架管辖范围 func getFileNameInfo(name string) fileName { fileNameParts := strings.Split(name, ".log.") if len(fileNameParts) != 2 { - deleteLog(name) return fileName{} } dateLayout := "2006-01-02" dateString := fileNameParts[0] fileDate, err := time.Parse(dateLayout, dateString) if err != nil { - deleteLog(name) return fileName{} } indexString := fileNameParts[1] indexString = strings.TrimSuffix(indexString, ".log") fileIndex, err := strconv.ParseInt(indexString, 10, 32) if err != nil { - deleteLog(name) return fileName{} } return fileName{ @@ -189,10 +199,11 @@ func fileLogger(text string) { } func removeOldestLogFile(entries []os.DirEntry) { - if logger.MaxNumberFiles == 0 { + cfg := currentLogger() + if cfg.MaxNumberFiles == 0 { return } - if uint64(len(entries)) < logger.MaxNumberFiles { + if uint64(len(entries)) < cfg.MaxNumberFiles { return } var newEntries []fileName @@ -202,10 +213,10 @@ func removeOldestLogFile(entries []os.DirEntry) { newEntries = append(newEntries, fileNameInfo) } } - if uint64(len(newEntries)) < logger.MaxNumberFiles { + if uint64(len(newEntries)) < cfg.MaxNumberFiles { return } - delNum := uint64(len(newEntries)) - logger.MaxNumberFiles + 1 + delNum := uint64(len(newEntries)) - cfg.MaxNumberFiles + 1 sort.Slice(newEntries, func(i, j int) bool { if newEntries[i].LoggerTime != newEntries[j].LoggerTime { return newEntries[i].LoggerTime < newEntries[j].LoggerTime @@ -247,7 +258,7 @@ func getNextLogFile(dirPath, dateStr string, text string) (string, error) { removeOldestLogFile(entries) return filepath.Join(dirPath, dateStr+".log.1"), nil } - if logger.MaxSizeFile > 0 && maxIndex > 0 { + if currentLogger().MaxSizeFile > 0 && maxIndex > 0 { currentFile := filepath.Join(dirPath, fmt.Sprintf("%s.log.%d", dateStr, maxIndex)) if fileInfo, err := os.Stat(currentFile); err == nil { maxSizeBytes := int64(logger.MaxSizeFile) * 1024 * 1024 @@ -263,7 +274,9 @@ func getNextLogFile(dirPath, dateStr string, text string) (string, error) { } func ConfigLogger(log Logger) { + loggerMu.Lock() logger = log + loggerMu.Unlock() } func getCurrentTime() string { @@ -304,7 +317,8 @@ func getLevelName(level uint8) string { } func sendLogWorker(level uint8, message []any) { - if logger.Level >= level { + cfg := currentLogger() + if cfg.Level >= level { var text1 strings.Builder for _, m := range message { var msgStr string @@ -350,8 +364,18 @@ func sendLogWorker(level uint8, message []any) { text1.WriteString(msgStr + " ") } text := "[" + getCurrentTime() + "] [" + padString(getLevelName(level), 7) + "] " + getMethodNameLogger() + text1.String() - logWg.Add(1) - logChan <- text + select { + case logChan <- text: + default: + // 通道满时同步写出兜底:日志绝不阻塞(也绝不丢弃)请求处理 goroutine + logMutex.Lock() + if cfg.Mode == FileMode { + fileLogger(text) + } else { + fmt.Println(text) + } + logMutex.Unlock() + } } } diff --git a/regression_test.go b/regression_test.go new file mode 100644 index 0000000..5491848 --- /dev/null +++ b/regression_test.go @@ -0,0 +1,247 @@ +package fun + +// 本文件是 v1.3.3 之后五项修复的回归测试: +// 1. 枚举越界值先截断后判范围,256/512 等被洗成合法小值绕过校验 +// 2. /cell 请求 data 经 map[string]any 往返,int64 超过 2^53 精度丢失 +// 3. Guard 无 error 返回,无法干净短路 +// 4. 依赖装配无 error 通道且容器留半初始化实例 +// 5. 日志解析失败的文件名直接删除用户文件 + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// ---- 1. 枚举:越界值必须被拒绝,不能截断洗白 ---- + +func TestEnumOutOfRangeRejected(t *testing.T) { + typ := reflect.TypeFor[BugStatus]() // Names: A, B → 合法值 0/1 + mustReject := []any{ + float64(-1), float64(2), float64(3), float64(255), + float64(256), float64(257), float64(258), float64(1e9), float64(1.5), + uint16(256), uint32(300), int(-2), int64(999), + } + for _, v := range mustReject { + if err := checkEnumValue(typ, v, "BugStatus"); err == nil { + t.Fatalf("value %v (%T) should be rejected", v, v) + } + } + mustPass := []any{float64(0), float64(1), uint8(1), int(0), int64(1)} + for _, v := range mustPass { + if err := checkEnumValue(typ, v, "BugStatus"); err != nil { + t.Fatalf("value %v (%T) should pass: %v", v, v, err) + } + } +} + +// ---- 2. int64 精度:原始字节解码,不经 float64 ---- + +type PrecisionDto struct { + Id int64 +} + +type PrecisionSvc struct{} + +func (s *PrecisionSvc) Get(dto PrecisionDto) (int64, error) { return dto.Id, nil } + +func TestInt64PrecisionInvoke(t *testing.T) { + f := New() + if err := f.BindService(&PrecisionSvc{}); err != nil { + t.Fatal(err) + } + const big = int64(9007199254740993) // 2^53+1,float64 无法精确表示 + c := &Ctx{Ip: "1", MethodName: "Get", ServiceName: "PrecisionSvc"} + data := map[string]any{"id": float64(big)} // 校验视图只查存在性 + c.Data = &data + c.rawData = []byte(`{"id":9007199254740993}`) // HTTP 路径的真实输入 + var streamCh chan any + var streamDone chan struct{} + res, err := f.invoke(c, &streamCh, &streamDone) + if err != nil { + t.Fatalf("invoke: %v", err) + } + if got := (*res.Data).(int64); got != big { + t.Fatalf("precision lost: got %d want %d", got, big) + } +} + +func TestInt64PrecisionE2E(t *testing.T) { + f := New() + if err := f.BindService(&PrecisionSvc{}); err != nil { + t.Fatal(err) + } + go f.Start(39011) + time.Sleep(300 * time.Millisecond) + resp, err := http.Post("http://127.0.0.1:39011/cell", "application/json", + strings.NewReader(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":9007199254740993}}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body := make([]byte, 512) + n, _ := resp.Body.Read(body) + if !strings.Contains(string(body[:n]), "9007199254740993") { + t.Fatalf("int64 precision lost in response: %s", string(body[:n])) + } +} + +// ---- 3. Guard:返回 error 短路,业务方法不得执行 ---- + +var guardOrderLog []string + +type OrderFirstGuard struct{} + +func (g *OrderFirstGuard) Guard(ctx Ctx) error { + guardOrderLog = append(guardOrderLog, "first") + return nil +} + +type OrderRejectGuard struct{} + +func (g *OrderRejectGuard) Guard(ctx Ctx) error { + guardOrderLog = append(guardOrderLog, "reject") + return Error(4003, "denied") +} + +type OrderLastGuard struct{} + +func (g *OrderLastGuard) Guard(ctx Ctx) error { + guardOrderLog = append(guardOrderLog, "last") // 短路后不应执行 + return nil +} + +var guardedMethodRan bool + +type GuardedSvc struct { + Ctx +} + +func (s *GuardedSvc) Ping() error { + guardedMethodRan = true + return nil +} + +func TestGuardShortCircuit(t *testing.T) { + f := New() + if err := f.BindService(&GuardedSvc{}, &OrderFirstGuard{}, &OrderRejectGuard{}, &OrderLastGuard{}); err != nil { + t.Fatal(err) + } + guardOrderLog = nil + guardedMethodRan = false + c := &Ctx{Ip: "1", MethodName: "Ping", ServiceName: "GuardedSvc"} + var streamCh chan any + var streamDone chan struct{} + _, err := f.invoke(c, &streamCh, &streamDone) + if err == nil { + t.Fatal("expected guard rejection") + } + if guardedMethodRan { + t.Fatal("business method must not run after guard rejection") + } + if len(guardOrderLog) != 2 || guardOrderLog[0] != "first" || guardOrderLog[1] != "reject" { + t.Fatalf("guards after rejection must not run: %v", guardOrderLog) + } + var result Result[any] + if !errors.As(err, &result) { + t.Fatalf("error should carry Result, got %T", err) + } + if result.Code == nil || *result.Code != 4003 || result.Status != errorCode { + t.Fatalf("unexpected result: %+v", result) + } +} + +// ---- 4. 依赖装配:error 通道 + 粘性失败 ---- + +var wOkBoxNewCalled bool + +type WOkBox struct{} + +func (b *WOkBox) New() error { wOkBoxNewCalled = true; return nil } + +type WFailBox struct{} + +func (b *WFailBox) New() error { return errors.New("connect refused") } + +type WParentBox struct { + Fail *WFailBox `fun:"auto"` +} + +type WSvc struct { + Ctx + Fail *WFailBox +} + +func (s *WSvc) Ping() error { return nil } + +func TestWiredErrorAndStickyFailure(t *testing.T) { + old := fun + fun = New() + defer func() { fun = old }() + + wOkBoxNewCalled = false + if _, err := Wired[WOkBox](); err != nil { + t.Fatalf("ok box: %v", err) + } + if !wOkBoxNewCalled { + t.Fatal("New() not called") + } + + _, err := Wired[WFailBox]() + if err == nil || !strings.Contains(err.Error(), "connect refused") { + t.Fatalf("expected failure, got %v", err) + } + // 粘性错误:重复 Wired 返回同一错误,不重试 + if _, err2 := Wired[WFailBox](); err2 == nil || err2.Error() != err.Error() { + t.Fatalf("sticky failure expected, got %v then %v", err, err2) + } + // 失败类型不暴露半初始化实例 + if entry, ok := fun.boxes.Load(reflect.TypeFor[*WFailBox]()); ok { + if e := entry.(boxEntry); e.err == nil { + t.Fatal("failed box must not hold a usable instance") + } + } +} + +func TestWiredNestedFailurePropagates(t *testing.T) { + old := fun + fun = New() + defer func() { fun = old }() + + if _, err := Wired[WParentBox](); err == nil || !strings.Contains(err.Error(), "connect refused") { + t.Fatalf("nested failure should propagate, got %v", err) + } +} + +func TestBindServiceWireFailurePropagates(t *testing.T) { + f := New() + if err := f.BindService(&WSvc{}); err == nil { + t.Fatal("BindService should propagate wiring failure") + } +} + +// ---- 5. 日志:解析失败的文件名不删除 ---- + +func TestLoggerKeepsUnknownFiles(t *testing.T) { + dir := t.TempDir() + keep := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(keep, []byte("keep me"), 0644); err != nil { + t.Fatal(err) + } + ConfigLogger(Logger{Level: TraceLevel, Mode: FileMode, LogFilePath: dir, ExpireLogsDays: 3}) + defer ConfigLogger(Logger{Level: TraceLevel, Mode: TerminalMode}) + + // 写入路径:getNextLogFile 遍历目录解析文件名,旧逻辑会删除解析失败的文件 + fileLogger("[test] hello") + // 清理路径 + cleanupExpiredLogs() + + if _, err := os.Stat(keep); err != nil { + t.Fatalf("unknown file deleted: %v", err) + } +} diff --git a/request.go b/request.go index db9680c..19680ae 100644 --- a/request.go +++ b/request.go @@ -1,14 +1,8 @@ package fun -const ( - RequestNormalType uint8 = iota - RequestStreamType -) - type RequestInfo[T any] struct { MethodName string ServiceName string Data *T State map[string]string - Type uint8 } diff --git a/result.go b/result.go index 765d41a..169a6ca 100644 --- a/result.go +++ b/result.go @@ -12,7 +12,6 @@ const ( ) type Result[T any] struct { - Id string `json:"id,omitempty"` Code *uint16 `json:"code,omitempty"` Data *T `json:"data,omitempty"` Msg *string `json:"msg,omitempty"` @@ -40,6 +39,13 @@ func callError(err error) Result[any] { return Result[any]{Msg: new(err.Error()), Status: cellErrorCode} } +// internalError 框架内部错误脱敏:客户端只收到固定提示, +// 完整错误(JSON 解析细节、Go 类型名等)记服务端日志,不外泄 +func internalError(clientMsg string, err error) Result[any] { + ErrorLogger("fun: internal error: ", err.Error()) + return Result[any]{Msg: &clientMsg, Status: cellErrorCode} +} + // success 构造成功响应,空切片规范化为 [] 而不是 null func success(data any) Result[any] { return Result[any]{Data: nonNil(data), Status: successCode} diff --git a/route.go b/route.go index a9f83b6..2713915 100644 --- a/route.go +++ b/route.go @@ -14,6 +14,12 @@ import ( // (如支付回调要求的纯文本 "success" 应答)。 type RouteHandler func(ctx *RouteCtx) error +// boundRoute 路由绑定的处理器与其 Guard +type boundRoute struct { + handler RouteHandler + guards []*any +} + // RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先), // 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。 // Wildcard 为通配符路由(/prefix/*)匹配到的剩余路径(不含前导 "/")。 @@ -32,12 +38,15 @@ func (c *RouteCtx) Param(name string) string { // BindRoute 注册自定义路由(方法大小写不敏感;path 精确匹配,或以 "/*" 结尾做前缀通配), // 用于 GET 直链、健康检查、支付回调等无法走 POST /cell RPC 的场景。 // +// - guardList 为该路由绑定的 Guard,处理器前按注册顺序执行: +// Guard 收到的 Ctx.State 已合并 URL 查询与表单参数(token 放查询参数即可鉴权), +// 返回 error 时短路——处理器不执行,error 走统一 Result 错误响应 // - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册 // - 通配符形式如 "/image/*":匹配 "/image/a/b.png" 等任意子路径, // 匹配到的剩余路径(去掉前导 "/",如 "a/b.png")经 RouteCtx.Wildcard 取出 -// - 同一 方法+路径 重复注册直接 panic -// - 与 BindService 一致,需在 Start 前完成注册(启动阶段单线程) -func (f *Fun) BindRoute(method, path string, handler RouteHandler) { +// - Guard 依赖装配失败以 error 返回;非法参数与重复注册仍 panic +// - 须在 Start 前完成注册 +func (f *Fun) BindRoute(method, path string, handler RouteHandler, guardList ...Guard) error { if handler == nil { panic("fun: BindRoute handler cannot be nil") } @@ -51,6 +60,21 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) { if path == "/cell" || path == "/cell/*" { panic("fun: /cell is reserved for RPC") } + + f.mu.Lock() + defer f.mu.Unlock() + f.mustNotStarted("BindRoute") + + br := boundRoute{handler: handler} + for _, guard := range guardList { + checkGuard(guard) + g, err := serviceGuardWired(guard, f) + if err != nil { + return fmt.Errorf("fun: wire route guard %T: %w", guard, err) + } + br.guards = append(br.guards, g) + } + if prefix, ok := strings.CutSuffix(path, "/*"); ok { if prefix == "" || strings.HasSuffix(prefix, "/") { panic(fmt.Sprintf("fun: BindRoute wildcard path %q invalid (no trailing '/' allowed before /*)", path)) @@ -60,19 +84,21 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) { panic(fmt.Sprintf("fun: route %s %s/* already bound", method, prefix)) } } - f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, handler: handler}) - return + f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, route: br}) + return nil } key := method + " " + path if _, exists := f.routes[key]; exists { panic(fmt.Sprintf("fun: route %s already bound", key)) } - f.routes[key] = handler + f.routes[key] = br + return nil } // handleRoute 执行自定义路由:合并查询与表单参数(application/x-www-form-urlencoded), -// 处理器返回 error 时按统一 Result 格式输出错误响应;wildcard 为通配路由匹配的剩余路径 -func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wildcard string) { +// 先按序执行路由 Guard(State 即合并参数,token 放查询参数即可鉴权), +// 任一 Guard 返回 error 则短路;处理器返回 error 时按统一 Result 格式输出错误响应 +func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, r boundRoute, wildcard string) { data := map[string]string{} fastCtx.QueryArgs().VisitAll(func(k, v []byte) { data[string(k)] = string(v) @@ -80,7 +106,14 @@ func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wi fastCtx.PostArgs().VisitAll(func(k, v []byte) { data[string(k)] = string(v) }) - if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil { - (&Ctx{RequestCtx: fastCtx}).sendError(err) + ctx := &Ctx{RequestCtx: fastCtx, Ip: clientIP(fastCtx), State: data} + for _, g := range r.guards { + if err := (*g).(Guard).Guard(*ctx); err != nil { + ctx.sendError(err) + return + } + } + if err := r.handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil { + ctx.sendError(err) } } diff --git a/route_test.go b/route_test.go index f6cedf4..417d33f 100644 --- a/route_test.go +++ b/route_test.go @@ -12,7 +12,9 @@ import ( func startRouteServer(t *testing.T, port uint16) *Fun { t.Helper() f := New() - f.BindService(&TestSvc{}) + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } // GET:查询参数 + 纯文本自定义响应 f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error { diff --git a/stream_protocol_test.go b/stream_protocol_test.go index d261c9b..0b4d997 100644 --- a/stream_protocol_test.go +++ b/stream_protocol_test.go @@ -66,7 +66,9 @@ func streamPost(t *testing.T, url, method string) (*http.Response, []byte) { func TestServerStreamContentTypeAndEmptyStream(t *testing.T) { f := New() - f.BindService(&ProtocolStreamSvc{}) + if err := f.BindService(&ProtocolStreamSvc{}); err != nil { + t.Fatal(err) + } response, body := streamPost(t, serveFun(t, f), "Empty") if got := response.Header.Get("Content-Type"); got != "application/x-ndjson" { t.Fatalf("Content-Type = %q", got) @@ -78,7 +80,9 @@ func TestServerStreamContentTypeAndEmptyStream(t *testing.T) { func TestServerStreamSetupErrorsUseResultProtocol(t *testing.T) { f := New() - f.BindService(&ProtocolStreamSvc{}) + if err := f.BindService(&ProtocolStreamSvc{}); err != nil { + t.Fatal(err) + } url := serveFun(t, f) for _, test := range []struct { method string diff --git a/template_go.go b/template_go.go index 561f110..d079520 100644 --- a/template_go.go +++ b/template_go.go @@ -16,7 +16,6 @@ import ( // Result 统一响应结构 type Result[T any] struct { - Id string Code *uint16 Data *T Msg *string @@ -114,7 +113,7 @@ func Request[T any](c *Client, serviceName string, methodName string, dto ...any v := any(*out.Data) anyData = &v } - anyResult := Result[any]{Id: out.Id, Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status} + anyResult := Result[any]{Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status} for _, i := range c.responseInterceptors { if err := i(serviceName, methodName, anyResult); err != nil { return Result[T]{Status: 2, Msg: ptr(err.Error())} diff --git a/template_ts.go b/template_ts.go index 69d393b..7186938 100644 --- a/template_ts.go +++ b/template_ts.go @@ -3,8 +3,52 @@ package fun type templateTs struct{} func (ctx templateTs) genClientTemplate() string { - return `export type result = { - id?: string; + return `// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量 +// 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。 +// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可 +const BIGINT_PREFIX = "\u0000fun-bigint:"; + +function reviveBigint(value: any): any { + if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) && + /^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) { + return BigInt(value.slice(BIGINT_PREFIX.length)); + } + if (Array.isArray(value)) return value.map(reviveBigint); + if (value !== null && typeof value === "object") { + for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]); + return value; + } + return value; +} + +function parseLossless(text: string): any { + // 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先, + // 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt + const guarded = text.replace( + /"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, + token => { + if (token.startsWith('"')) return token; + if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) { + // 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符), + // JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头) + return '"\\u0000fun-bigint:' + token + '"'; + } + return token; + } + ); + return reviveBigint(JSON.parse(guarded)); +} + +function stringifyLossless(value: any): string | undefined { + const raw = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v + ); + if (raw === undefined) return undefined; + // 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"(NUL 被转义),去引号还原为数字字面量 + return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1"); +} + +export type result = { code?: number; data?: T; msg?: string; @@ -130,7 +174,7 @@ function parseResult(response: Response, text: string): result { let value: unknown; try { - value = JSON.parse(body); + value = parseLossless(body); } catch { if (!response.ok) return externalFailure(response, excerpt(body)); const type = mediaType(response); @@ -260,7 +304,7 @@ export class Client { let body: string; try { - const serialized = JSON.stringify({ + const serialized = stringifyLossless({ serviceName, methodName, data: dto, @@ -350,7 +394,7 @@ export class Client { let body: string; try { - const serialized = JSON.stringify({ + const serialized = stringifyLossless({ serviceName, methodName, data: dto, @@ -464,7 +508,7 @@ export class Client { if (!payload) return; let data: T; try { - data = JSON.parse(payload) as T; + data = parseLossless(payload) as T; } catch (error) { failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `); cause = error; diff --git a/utils.go b/utils.go index 9830771..e96f4e3 100644 --- a/utils.go +++ b/utils.go @@ -2,28 +2,31 @@ package fun import ( "net" - "net/http" "strings" + + "github.com/valyala/fasthttp" ) -// getIP 获取客户端真实 IP -// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr -func getIP(r *http.Request) string { - // 1. 优先获取真实 IP(多层代理时取最后一个非空段) - if ip := lastNonEmpty(r.Header.Get("X-Forwarded-For")); ip != "" { +// clientIP 解析客户端真实 IP。 +// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr。 +// 部署在反向代理(nginx 等)后时由代理写入这两个头; +// 直连无代理头时回退到连接对端地址 +func clientIP(ctx *fasthttp.RequestCtx) string { + // 1. X-Forwarded-For 取最后一个非空段: + // 该段由离服务最近的一层代理追加,是代理链中最可信的一段 + if ip := lastNonEmpty(string(ctx.Request.Header.Peek("X-Forwarded-For"))); ip != "" { return toLoopback(ip) } - // 2. X-Real-IP(通常由 Nginx 设置) - if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" { + // 2. X-Real-IP(通常由 nginx 设置) + if ip := strings.TrimSpace(string(ctx.Request.Header.Peek("X-Real-IP"))); ip != "" { return toLoopback(ip) } - // 3. 最终回退到 RemoteAddr(兼容带端口、IPv6 方括号、无端口) - if ip := hostOf(r.RemoteAddr); ip != "" { - return toLoopback(ip) + // 3. 回退到连接对端地址;无对端或未指定地址(0.0.0.0,测试/直驱场景)按本机处理 + if remote := ctx.RemoteIP(); remote != nil && !remote.IsUnspecified() { + return toLoopback(remote.String()) } - return "127.0.0.1" } @@ -39,18 +42,6 @@ func lastNonEmpty(xff string) string { return "" } -// hostOf 从 RemoteAddr 中提取 IP 部分 -// "203.0.113.9:4567" → "203.0.113.9","[::1]:4567" → "::1", -// "198.51.100.88"(无端口)→ 原样返回 -func hostOf(remoteAddr string) string { - raw := strings.TrimSpace(remoteAddr) - host, _, err := net.SplitHostPort(raw) - if err == nil && host != "" { - return host - } - return raw -} - // toLoopback 回环地址统一返回 127.0.0.1,其余原样返回 func toLoopback(ip string) string { if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {