diff --git a/backend/Makefile b/backend/Makefile index 291e2fe9..96b0129e 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -16,14 +16,14 @@ build-embed: @echo "构建完成: bin/server (with embedded frontend)" test-unit: - @go test ./... $(TEST_ARGS) + @go test -tags unit ./... -count=1 test-integration: - @go test -tags integration ./internal/repository -count=1 -race -parallel=8 + @go test -tags integration ./... -count=1 -race -parallel=8 test-cover-integration: @echo "运行集成测试并生成覆盖率报告..." - @go test -tags=integration -cover -coverprofile=coverage.out -count=1 -race -parallel=8 ./internal/repository/... + @go test -tags=integration -cover -coverprofile=coverage.out -count=1 -race -parallel=8 ./... @go tool cover -func=coverage.out | tail -1 @go tool cover -html=coverage.out -o coverage.html @echo "覆盖率报告已生成: coverage.html" diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index adb43234..d035d9a7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -84,7 +84,7 @@ func main() { func runSetupServer() { r := gin.New() - r.Use(gin.Recovery()) + r.Use(middleware.Recovery()) r.Use(middleware.CORS()) // Register setup routes diff --git a/backend/internal/infrastructure/errors/errors.go b/backend/internal/infrastructure/errors/errors.go index 64a98cc2..89977f99 100644 --- a/backend/internal/infrastructure/errors/errors.go +++ b/backend/internal/infrastructure/errors/errors.go @@ -7,8 +7,9 @@ import ( ) const ( - UnknownCode = http.StatusInternalServerError - UnknownReason = "" + UnknownCode = http.StatusInternalServerError + UnknownReason = "" + UnknownMessage = "internal error" ) type Status struct { @@ -153,5 +154,5 @@ func FromError(err error) *ApplicationError { } // Fall back to a generic internal error. - return New(UnknownCode, UnknownReason, err.Error()).WithCause(err) + return New(UnknownCode, UnknownReason, UnknownMessage).WithCause(err) } diff --git a/backend/internal/infrastructure/errors/errors_test.go b/backend/internal/infrastructure/errors/errors_test.go index 8170ca26..1a1c842e 100644 --- a/backend/internal/infrastructure/errors/errors_test.go +++ b/backend/internal/infrastructure/errors/errors_test.go @@ -111,14 +111,14 @@ func TestFromError_Generic(t *testing.T) { err: stderrors.New("boom"), wantCode: UnknownCode, wantReason: UnknownReason, - wantMsg: "boom", + wantMsg: UnknownMessage, }, { name: "wrapped_plain_error", err: fmt.Errorf("wrap: %w", io.EOF), wantCode: UnknownCode, wantReason: UnknownReason, - wantMsg: "wrap: EOF", + wantMsg: UnknownMessage, }, } diff --git a/backend/internal/middleware/recovery.go b/backend/internal/middleware/recovery.go new file mode 100644 index 00000000..04ea6f9d --- /dev/null +++ b/backend/internal/middleware/recovery.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "errors" + "net" + "net/http" + "os" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/gin-gonic/gin" +) + +// Recovery converts panics into the project's standard JSON error envelope. +// +// It preserves Gin's broken-pipe handling by not attempting to write a response +// when the client connection is already gone. +func Recovery() gin.HandlerFunc { + return gin.CustomRecoveryWithWriter(gin.DefaultErrorWriter, func(c *gin.Context, recovered any) { + recoveredErr, _ := recovered.(error) + + if isBrokenPipe(recoveredErr) { + if recoveredErr != nil { + _ = c.Error(recoveredErr) + } + c.Abort() + return + } + + if c.Writer.Written() { + c.Abort() + return + } + + response.ErrorWithDetails( + c, + http.StatusInternalServerError, + infraerrors.UnknownMessage, + infraerrors.UnknownReason, + nil, + ) + c.Abort() + }) +} + +func isBrokenPipe(err error) bool { + if err == nil { + return false + } + + var opErr *net.OpError + if !errors.As(err, &opErr) { + return false + } + + var syscallErr *os.SyscallError + if !errors.As(opErr.Err, &syscallErr) { + return false + } + + msg := strings.ToLower(syscallErr.Error()) + return strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") +} diff --git a/backend/internal/middleware/recovery_test.go b/backend/internal/middleware/recovery_test.go new file mode 100644 index 00000000..5edb6da0 --- /dev/null +++ b/backend/internal/middleware/recovery_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestRecovery(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + handler gin.HandlerFunc + wantHTTPCode int + wantBody response.Response + }{ + { + name: "panic_returns_standard_json_500", + handler: func(c *gin.Context) { + panic("boom") + }, + wantHTTPCode: http.StatusInternalServerError, + wantBody: response.Response{ + Code: http.StatusInternalServerError, + Message: infraerrors.UnknownMessage, + }, + }, + { + name: "no_panic_passthrough", + handler: func(c *gin.Context) { + response.Success(c, gin.H{"ok": true}) + }, + wantHTTPCode: http.StatusOK, + wantBody: response.Response{ + Code: 0, + Message: "success", + Data: map[string]any{"ok": true}, + }, + }, + { + name: "panic_after_write_does_not_override_body", + handler: func(c *gin.Context) { + response.Success(c, gin.H{"ok": true}) + panic("boom") + }, + wantHTTPCode: http.StatusOK, + wantBody: response.Response{ + Code: 0, + Message: "success", + Data: map[string]any{"ok": true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := gin.New() + r.Use(Recovery()) + r.GET("/t", tt.handler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + r.ServeHTTP(w, req) + + require.Equal(t, tt.wantHTTPCode, w.Code) + + var got response.Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, tt.wantBody, got) + }) + } +} diff --git a/backend/internal/pkg/response/response_test.go b/backend/internal/pkg/response/response_test.go index af6c2875..13b184af 100644 --- a/backend/internal/pkg/response/response_test.go +++ b/backend/internal/pkg/response/response_test.go @@ -143,7 +143,7 @@ func TestErrorFrom(t *testing.T) { wantHTTPCode: http.StatusInternalServerError, wantBody: Response{ Code: http.StatusInternalServerError, - Message: "boom", + Message: infraerrors.UnknownMessage, }, }, } diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index cb827e32..f9ab1174 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -3,6 +3,7 @@ package server import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/middleware" "github.com/Wei-Shaw/sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/service" "net/http" @@ -25,7 +26,7 @@ func ProvideRouter(cfg *config.Config, handlers *handler.Handlers, services *ser } r := gin.New() - r.Use(gin.Recovery()) + r.Use(middleware.Recovery()) return SetupRouter(r, cfg, handlers, services, repos) }