This commit is contained in:
2026-08-16 20:58:57 +08:00
parent a85477be80
commit a9e1f9e6f8
42 changed files with 1569 additions and 156 deletions
+28 -8
View File
@@ -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())
}
}
+1 -1
View File
@@ -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)
}
}
+2 -9
View File
@@ -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)