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
This commit is contained in:
2026-09-03 15:32:07 +08:00
parent 9019798382
commit 41df889a1a
24 changed files with 1356 additions and 214 deletions
+247
View File
@@ -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+1float64 无法精确表示
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)
}
}