refactor: 自定义业务错误 (#33)

* refactor: 自定义业务错误

* refactor: 隐藏服务器错误与统一 panic 响应
This commit is contained in:
NepetaLemon
2025-12-26 08:47:00 +08:00
committed by GitHub
parent b31698b9f2
commit 8d7a497553
8 changed files with 158 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,7 +143,7 @@ func TestErrorFrom(t *testing.T) {
wantHTTPCode: http.StatusInternalServerError,
wantBody: Response{
Code: http.StatusInternalServerError,
Message: "boom",
Message: infraerrors.UnknownMessage,
},
},
}

View File

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