refactor: 自定义业务错误 (#33)
* refactor: 自定义业务错误 * refactor: 隐藏服务器错误与统一 panic 响应
This commit is contained in:
@@ -16,14 +16,14 @@ build-embed:
|
|||||||
@echo "构建完成: bin/server (with embedded frontend)"
|
@echo "构建完成: bin/server (with embedded frontend)"
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
@go test ./... $(TEST_ARGS)
|
@go test -tags unit ./... -count=1
|
||||||
|
|
||||||
test-integration:
|
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:
|
test-cover-integration:
|
||||||
@echo "运行集成测试并生成覆盖率报告..."
|
@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 -func=coverage.out | tail -1
|
||||||
@go tool cover -html=coverage.out -o coverage.html
|
@go tool cover -html=coverage.out -o coverage.html
|
||||||
@echo "覆盖率报告已生成: coverage.html"
|
@echo "覆盖率报告已生成: coverage.html"
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func main() {
|
|||||||
|
|
||||||
func runSetupServer() {
|
func runSetupServer() {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(middleware.Recovery())
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
// Register setup routes
|
// Register setup routes
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UnknownCode = http.StatusInternalServerError
|
UnknownCode = http.StatusInternalServerError
|
||||||
UnknownReason = ""
|
UnknownReason = ""
|
||||||
|
UnknownMessage = "internal error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -153,5 +154,5 @@ func FromError(err error) *ApplicationError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to a generic internal error.
|
// Fall back to a generic internal error.
|
||||||
return New(UnknownCode, UnknownReason, err.Error()).WithCause(err)
|
return New(UnknownCode, UnknownReason, UnknownMessage).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ func TestFromError_Generic(t *testing.T) {
|
|||||||
err: stderrors.New("boom"),
|
err: stderrors.New("boom"),
|
||||||
wantCode: UnknownCode,
|
wantCode: UnknownCode,
|
||||||
wantReason: UnknownReason,
|
wantReason: UnknownReason,
|
||||||
wantMsg: "boom",
|
wantMsg: UnknownMessage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrapped_plain_error",
|
name: "wrapped_plain_error",
|
||||||
err: fmt.Errorf("wrap: %w", io.EOF),
|
err: fmt.Errorf("wrap: %w", io.EOF),
|
||||||
wantCode: UnknownCode,
|
wantCode: UnknownCode,
|
||||||
wantReason: UnknownReason,
|
wantReason: UnknownReason,
|
||||||
wantMsg: "wrap: EOF",
|
wantMsg: UnknownMessage,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
backend/internal/middleware/recovery.go
Normal file
64
backend/internal/middleware/recovery.go
Normal 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")
|
||||||
|
}
|
||||||
81
backend/internal/middleware/recovery_test.go
Normal file
81
backend/internal/middleware/recovery_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ func TestErrorFrom(t *testing.T) {
|
|||||||
wantHTTPCode: http.StatusInternalServerError,
|
wantHTTPCode: http.StatusInternalServerError,
|
||||||
wantBody: Response{
|
wantBody: Response{
|
||||||
Code: http.StatusInternalServerError,
|
Code: http.StatusInternalServerError,
|
||||||
Message: "boom",
|
Message: infraerrors.UnknownMessage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
"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/repository"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,7 +26,7 @@ func ProvideRouter(cfg *config.Config, handlers *handler.Handlers, services *ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(middleware.Recovery())
|
||||||
|
|
||||||
return SetupRouter(r, cfg, handlers, services, repos)
|
return SetupRouter(r, cfg, handlers, services, repos)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user