补齐 HTTP 层:实现用户模块、路由与中间件接线、COS 上传及优雅启动

- 新增用户与上传错误码文件及 Recovery 中间件
- 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入
- 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto
- 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装
- 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像
- 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出
- 新增 configs 示例配置、global 配置结构及 go.mod 依赖
This commit is contained in:
2026-09-27 15:02:01 +08:00
parent 8f08d94a56
commit ff9add8655
33 changed files with 2473 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"strings"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件。
// 允许来源通过 Server.Domain 配置(如 test.com),自动放行其 http/https 两种协议的 Origin。
// 未配置时回退为 *(保持兼容)。
func CORS() gin.HandlerFunc {
domain := ""
if global.ServerSetting != nil {
domain = strings.TrimSpace(global.ServerSetting.Domain)
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
allowOrigin := "*"
if domain != "" && domain != "*" {
allowOrigin = ""
if originAllowed(origin, domain) {
allowOrigin = origin
}
}
if allowOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowOrigin)
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
c.Header("Access-Control-Expose-Headers", "Content-Length")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。
func originAllowed(origin, domain string) bool {
o := strings.TrimSuffix(strings.TrimSpace(origin), "/")
return o == "https://"+domain || o == "http://"+domain
}
+77
View File
@@ -0,0 +1,77 @@
package middleware
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
func sanitizeQuery(query string) string {
if query == "" {
return ""
}
values, err := url.ParseQuery(query)
if err != nil {
return "***"
}
sensitive := map[string]bool{
"token": true, "code": true, "secret": true, "password": true,
"access_token": true, "refresh_token": true, "key": true,
}
for k := range values {
if sensitive[strings.ToLower(k)] {
values.Set(k, "***")
}
}
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) {
start := time.Now()
path := c.Request.URL.Path
query := sanitizeQuery(c.Request.URL.RawQuery)
// 处理请求
c.Next()
// 请求结束后按状态码分级打日志: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", status,
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"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...)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"context"
"errors"
"log/slog"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt"
"github.com/gin-gonic/gin"
)
type ctxKey string
const (
userIDKey ctxKey = "userID"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
response := app.NewResponse(c)
// 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
}
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
}
// 2. 解析 token
claims, err := lzcwyjwt.ParseAccessToken(parts[1])
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()
return
}
// 4. 把 userID注入 context
ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// GetUserID 从 context 取登录用户 ID(在 handler 里用)
func GetUserID(ctx context.Context) (int64, bool) {
uid, ok := ctx.Value(userIDKey).(int64)
return uid, ok
}
+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)对齐,避免默认实现写 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")
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"context"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
)
const requestIDKey ctxKey = "requestID"
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
// 注入 context 并写入响应头,便于全链路追踪。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" {
rid = uuid.New().String()
}
// 写入 gin context(供 handler 取用)
c.Set("requestID", rid)
// 写入 request context(供 service/dao 的 slog 取用)
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
c.Request = c.Request.WithContext(ctx)
// 响应头
c.Header("X-Request-ID", rid)
c.Next()
}
}
// GetRequestID 从 context 取出 request-id。
func GetRequestID(ctx context.Context) string {
rid, _ := ctx.Value(requestIDKey).(string)
return rid
}