补齐相关日志日志

This commit is contained in:
2026-09-16 00:23:49 +08:00
parent 8c2ea84939
commit b2e5eef3c8
39 changed files with 1576 additions and 176 deletions
+28 -6
View File
@@ -2,6 +2,7 @@ package middleware
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -30,6 +31,17 @@ func sanitizeQuery(query string) string {
return values.Encode()
}
// maxUserAgentLen user_agent 日志字段最大长度:超出截断,避免异常长 UA 撑大日志体积。
const maxUserAgentLen = 256
// truncateUserAgent 截断超长 User-Agent(仅用于日志展示)。
func truncateUserAgent(ua string) string {
if len(ua) <= maxUserAgentLen {
return ua
}
return ua[:maxUserAgentLen] + "...(truncated)"
}
func GinSlogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -40,16 +52,26 @@ func GinSlogMiddleware() gin.HandlerFunc {
// 处理请求
c.Next()
// 请求结束后打日志
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
"request_id", GetRequestID(c.Request.Context()),
// 请求结束后按状态码分级打日志:5xx=Error、4xx(除 401)=Warn、其余=Info。
ctx := c.Request.Context()
status := c.Writer.Status()
attrs := []any{
"request_id", GetRequestID(ctx),
"method", c.Request.Method,
"path", path,
"query", query,
"status", c.Writer.Status(),
"status", status,
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"user_agent", c.Request.UserAgent(),
)
"user_agent", truncateUserAgent(c.Request.UserAgent()),
}
switch {
case status >= http.StatusInternalServerError:
slog.ErrorContext(ctx, "GIN_REQUEST", attrs...)
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
slog.WarnContext(ctx, "GIN_REQUEST", attrs...)
default:
slog.InfoContext(ctx, "GIN_REQUEST", attrs...)
}
}
}
+25 -3
View File
@@ -28,6 +28,8 @@ func JWTAuth() gin.HandlerFunc {
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
slog.WarnContext(c.Request.Context(), "jwt auth: authorization header missing",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
c.Abort()
return
@@ -35,6 +37,8 @@ func JWTAuth() gin.HandlerFunc {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
slog.WarnContext(c.Request.Context(), "jwt auth: invalid authorization format",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
c.Abort()
return
@@ -45,10 +49,14 @@ func JWTAuth() gin.HandlerFunc {
if err != nil {
switch {
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
slog.InfoContext(c.Request.Context(), "jwt auth: token expired", "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
slog.WarnContext(c.Request.Context(), "jwt auth: token type mismatch", "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
default:
slog.WarnContext(c.Request.Context(), "jwt auth: parse token failed",
"path", c.Request.URL.Path, "ip", c.ClientIP(), "error", err)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
}
c.Abort()
@@ -59,6 +67,8 @@ func JWTAuth() gin.HandlerFunc {
sessionUID, serr := redisdao.AuthUserIDByAccess(c.Request.Context(), parts[1])
if serr != nil {
if errors.Is(serr, redis.Nil) {
slog.WarnContext(c.Request.Context(), "jwt auth: token session invalid",
"user_id", claims.UserID, "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
c.Abort()
return
@@ -67,8 +77,12 @@ func JWTAuth() gin.HandlerFunc {
// 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。
// 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问;
// 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "error", serr)
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback",
"user_id", claims.UserID, "path", c.Request.URL.Path, "ip", c.ClientIP(), "error", serr)
} else if sessionUID != claims.UserID {
slog.ErrorContext(c.Request.Context(), "jwt auth: token user mismatch",
"user_id", claims.UserID, "session_user_id", sessionUID,
"path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
c.Abort()
return
@@ -78,21 +92,29 @@ func JWTAuth() gin.HandlerFunc {
userInfo, err := redisdao.UserInfoGet(c.Request.Context(), claims.UserID)
if err != nil {
if !errors.Is(err, redis.Nil) {
slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db", "error", err)
slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db",
"user_id", claims.UserID, "error", err)
}
// 缓存未命中或 Redis 抖动:回落数据库。
dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID)
if dbErr != nil {
slog.WarnContext(c.Request.Context(), "jwt auth: query user failed",
"user_id", claims.UserID, "path", c.Request.URL.Path, "error", dbErr)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在"))
c.Abort()
return
}
userInfo = dbUser
_ = redisdao.UserInfoSet(c.Request.Context(), dbUser, 0)
if cerr := redisdao.UserInfoSet(c.Request.Context(), dbUser, 0); cerr != nil {
slog.DebugContext(c.Request.Context(), "jwt auth: backfill user cache failed",
"user_id", claims.UserID, "error", cerr)
}
}
// 状态非正常(1=正常)一律拒绝
if userInfo.Status != 1 {
slog.WarnContext(c.Request.Context(), "jwt auth: user disabled",
"user_id", claims.UserID, "status", userInfo.Status, "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UserDisabled)
c.Abort()
return
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"runtime/debug"
"strings"
"github.com/gin-gonic/gin"
)
// Recovery 替代 gin.Recovery():panic 恢复语义保持一致(客户端断开类 panic 仅记录并中止,
// 其余 panic 返回 500 Internal Server Error),但 panic 与堆栈统一走 slog 结构化输出,
// 与全站 JSON 日志(含 request_id / trace_id)对齐,避免默认实现写 stderr 非结构化日志。
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
ctx := c.Request.Context()
if isBrokenPipe(r) {
// 连接已断开,无法再写响应:仅记录并中止。
slog.ErrorContext(ctx, "GIN_PANIC_BROKEN_PIPE",
"method", c.Request.Method, "path", c.Request.URL.Path, "recover", fmt.Sprint(r))
c.Abort()
return
}
slog.ErrorContext(ctx, "GIN_PANIC",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP(),
"recover", fmt.Sprint(r), "stack", string(debug.Stack()))
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}
// isBrokenPipe 判定 panic 是否源于客户端断开(broken pipe / connection reset),与 gin.Recovery 判定一致。
func isBrokenPipe(r any) bool {
ne, ok := r.(*net.OpError)
if !ok {
return false
}
var se *os.SyscallError
if !errors.As(ne, &se) {
return false
}
seStr := strings.ToLower(se.Error())
return strings.Contains(seStr, "broken pipe") || strings.Contains(seStr, "connection reset by peer")
}