优化
This commit is contained in:
@@ -3,22 +3,25 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"kra/internal/server/httpx"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorAudit supplies the database sink that the Error-level logging core
|
||||
// provides. Expected authentication, permission and input failures are not
|
||||
// system errors and therefore are not inserted into sys_error.
|
||||
func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
||||
// ErrorAudit emits a structured Error-level log for unexpected HTTP failures.
|
||||
// The logging core is the single persistence path for sys_error, matching the
|
||||
// reference behavior and avoiding duplicate rows for HTTP failures.
|
||||
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||
// sysError writes must never audit themselves. Log-viewer failures are
|
||||
// already recorded by the handler with the underlying filesystem error;
|
||||
// emitting again from the response envelope would duplicate both the
|
||||
// classified error file and the sys_error row.
|
||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||
return
|
||||
}
|
||||
var response httpx.Response
|
||||
@@ -32,10 +35,27 @@ func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func failureLogModule(path string) string {
|
||||
for _, marker := range []string{"/fileUploadAndDownload/", "/mediaUpload/", "/attachmentCategory/"} {
|
||||
if strings.Contains(path, marker) {
|
||||
return "upload"
|
||||
}
|
||||
}
|
||||
if strings.Contains(path, "/timedTask/") {
|
||||
return "timedTask"
|
||||
}
|
||||
if strings.Contains(path, "/logViewer/") {
|
||||
return "log-viewer"
|
||||
}
|
||||
return "biz"
|
||||
}
|
||||
|
||||
func expectedClientFailure(message string) bool {
|
||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||
if strings.Contains(message, value) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.GET(path, func(c *gin.Context) {
|
||||
c.Set(ctxRespBufferKey, bytes.NewBufferString(response))
|
||||
c.Set("request_id", "request-1")
|
||||
c.Set("trace_id", "trace-1")
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(responseRecorder, request)
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsUnexpectedBusinessFailure(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, logger)
|
||||
|
||||
if text := output.String(); !strings.Contains(text, `"mod":"biz"`) || !strings.Contains(text, `"error":"数据库写入失败"`) {
|
||||
t.Fatalf("unexpected error log: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditUsesFeatureModule(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/timedTask/triggerTimedTask", `{"code":7,"data":{},"msg":"任务执行失败"}`, logger)
|
||||
if text := output.String(); !strings.Contains(text, `"mod":"timedTask"`) {
|
||||
t.Fatalf("unexpected feature log module: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditSkipsExpectedClientFailure(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"参数错误"}`, logger)
|
||||
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("expected no error log, got %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditAllowsNilLogger(t *testing.T) {
|
||||
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, nil)
|
||||
}
|
||||
|
||||
func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
|
||||
runErrorAudit(t, "/logViewer/content", `{"code":7,"data":{},"msg":"日志文件不可读取"}`, logger)
|
||||
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
|
||||
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
|
||||
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
|
||||
if cacheErr == nil && int(count) > config.LimitCount {
|
||||
httpx.Fail(c, "请求太过频繁,请稍后再试")
|
||||
c.JSON(200, gin.H{"code": httpx.CodeError, "msg": "请求太过频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type rateLimitSecurityRepo struct{}
|
||||
|
||||
func (rateLimitSecurityRepo) SecurityConfig(context.Context) (*biz.SecurityConfig, error) {
|
||||
return &biz.SecurityConfig{LimitEnable: true, LimitWindow: 60, LimitCount: 1}, nil
|
||||
}
|
||||
func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type rateLimitCache struct{}
|
||||
|
||||
func (rateLimitCache) Get(context.Context, string) (string, bool, error) { return "", false, nil }
|
||||
func (rateLimitCache) Set(context.Context, string, string, time.Duration) error { return nil }
|
||||
func (rateLimitCache) Delete(context.Context, string) error { return nil }
|
||||
func (rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) { return 2, nil }
|
||||
|
||||
func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
settings := service.NewSecurityService(biz.NewSecurityUsecase(rateLimitSecurityRepo{}, rateLimitCache{}, nil, nil))
|
||||
engine := gin.New()
|
||||
engine.Use(SecurityRateLimit(settings))
|
||||
engine.POST("/base/login", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/base/login", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, `"code":7`) || !strings.Contains(body, `"msg":"请求太过频繁,请稍后再试"`) || strings.Contains(body, `"data"`) {
|
||||
t.Fatalf("unexpected rate-limit response: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -10,13 +9,10 @@ import (
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc {
|
||||
func Recovery(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
panicValue := recover()
|
||||
@@ -31,12 +27,9 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc
|
||||
}
|
||||
}
|
||||
request, _ := httputil.DumpRequest(c.Request, false)
|
||||
info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack())
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "error", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
||||
logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "http", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
|
||||
if brokenPipe {
|
||||
if err, ok := panicValue.(error); ok {
|
||||
_ = c.Error(err)
|
||||
|
||||
Reference in New Issue
Block a user