补齐 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
+54
View File
@@ -0,0 +1,54 @@
package dto
// UserWxLoginRequest 微信登录请求
type UserWxLoginRequest struct {
Code string `json:"code" binding:"required"` // 微信登录code
}
// UserWxLoginResponse 登录响应
type UserWxLoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(绝对 Unix 秒时间戳)
UserInfo UserInfoGetBySelfResponse `json:"user_info"`
}
type UserInfoGetBySelfRequest struct{}
// UserInfoGetBySelfResponse 用户信息
type UserInfoGetBySelfResponse struct {
Username string `json:"username"`
Avatar string `json:"avatar"` // 可访问 URL(COS key 已转为 CDN 签名地址)
Status int8 `json:"status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
UserUUID string `json:"user_uuid"`
}
// UserProfileUpdateBySelfRequest 修改用户资料
type UserProfileUpdateBySelfRequest struct {
Username string `json:"username" binding:"max=32"` // 昵称
Avatar string `json:"avatar" binding:"max=500"` // 头像地址
}
// UserProfileUpdateBySelfResponse 修改用户资料响应
type UserProfileUpdateBySelfResponse struct{}
// UserAvatarUploadRequest 上传头像(multipart,字段名 file)
type UserAvatarUploadRequest struct{}
// UserAvatarUploadResponse 上传头像响应
type UserAvatarUploadResponse struct {
Avatar string `json:"avatar"` // 头像可访问 URL
}
// UserRefreshTokenRequest 刷新用户token请求。
// 纯 JWT 无状态方案:只校验 refresh token 本身,不校验/吊销服务端会话。
type UserRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// UserRefreshTokenResponse 刷新用户token响应
type UserRefreshTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(绝对 Unix 秒时间戳)
}
+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
}
+63
View File
@@ -0,0 +1,63 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
type UserDao struct {
db *gorm.DB
}
func NewUserDao() *UserDao {
return &UserDao{db: global.MysqlEngine}
}
func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error {
if err := d.db.WithContext(ctx).Create(user).Error; err != nil {
return err
}
return nil
}
func (d *UserDao) UserUpdateByID(ctx context.Context, user *mysqlmodel.User) error {
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", user.ID).Updates(user).Error; err != nil {
return err
}
return nil
}
func (d *UserDao) UserUpdateByUUID(ctx context.Context, user *mysqlmodel.User) error {
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("user_uuid = ?", user.UserUUID).Updates(user).Error; err != nil {
return err
}
return nil
}
func (d *UserDao) UserGetByID(ctx context.Context, id int64) (*mysqlmodel.User, error) {
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *UserDao) UserGetByWechatOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) {
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("wechat_open_id = ?", openID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
+54
View File
@@ -0,0 +1,54 @@
package mysqlmodel
import (
"context"
"fmt"
"time"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/setting"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
)
type Model struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
CreatedAt int64 `gorm:"column:created_at"` // 创建时间
UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间
CreatedBy int64 `gorm:"column:created_by"` // 创建人
UpdatedBy int64 `gorm:"column:updated_by"` // 更新人
DeletedBy int64 `gorm:"column:deleted_by"` // 删除人
}
// NewMysqlEngine 根据配置创建 GORM MySQL 连接并完成连接池初始化。
func NewMysqlEngine(mysqlSetting *setting.MysqlSettingS) (*gorm.DB, error) {
s := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=10s&writeTimeout=10s",
mysqlSetting.UserName,
mysqlSetting.Password,
mysqlSetting.Host,
mysqlSetting.Port,
mysqlSetting.DBName,
)
db, err := gorm.Open(mysql.Open(s), &gorm.Config{})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(mysqlSetting.MaxIdleConns)
sqlDB.SetMaxOpenConns(mysqlSetting.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Duration(mysqlSetting.ConnMaxLifeTime) * time.Second)
sqlDB.SetConnMaxIdleTime(time.Duration(mysqlSetting.ConnMaxIdleTime) * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
+22
View File
@@ -0,0 +1,22 @@
package mysqlmodel
// 用户状态:1=正常 2=临时禁用 3=永久禁用 4=已注销。
const (
UserStatusNormal int8 = 1
UserStatusTempBanned int8 = 2
UserStatusBanned int8 = 3
UserStatusCancelled int8 = 4
)
type User struct {
Model
UserUUID string `gorm:"column:user_uuid;type:varchar(32);not null;uniqueIndex:idx_user_uuid"`
WechatOpenID string `gorm:"column:wechat_open_id;type:varchar(32);not null;uniqueIndex:idx_wechat_open_id"`
Username string `gorm:"column:username;type:varchar(64);not null"`
Avatar string `gorm:"column:avatar;type:varchar(255);not null"`
Status int8 `gorm:"column:status;type:tinyint(1);not null"`
}
func (User) TableName() string {
return "users"
}
+119
View File
@@ -0,0 +1,119 @@
// Package v1 提供 /api/v1 版本的 HTTP handler,负责请求绑定、鉴权透传与统一响应,
// 业务逻辑与归属校验均在 internal/service 层完成。
package v1
import (
"net/http"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// UserHandler 用户相关接口。
type UserHandler struct {
svc *service.UserService
}
// NewUserHandler 创建用户 handler。
func NewUserHandler() *UserHandler {
return &UserHandler{svc: service.NewUserService()}
}
// WxLogin 微信登录(公开)。
func (h *UserHandler) WxLogin(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserWxLoginRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.WxLogin(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// RefreshToken 刷新访问令牌(公开)。
func (h *UserHandler) RefreshToken(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserRefreshTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.RefreshToken(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// InfoGetBySelf 获取当前用户信息。
func (h *UserHandler) InfoGetBySelf(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// ProfileUpdate 修改当前用户资料。
func (h *UserHandler) ProfileUpdate(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
var request dto.UserProfileUpdateBySelfRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.ProfileUpdate(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// AvatarUpload 上传用户头像(multipart,字段名 file)。
func (h *UserHandler) AvatarUpload(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
// 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
// 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
fileHeader, err := c.FormFile("file")
if err != nil {
response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
return
}
out, e := h.svc.AvatarUpload(c.Request.Context(), userID, fileHeader)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
+49
View File
@@ -0,0 +1,49 @@
package routers
import (
"net/http"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
v1 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers/api/v1"
"github.com/gin-gonic/gin"
)
// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。
func NewRouter() *gin.Engine {
r := gin.New()
// 中间件链:Recovery -> RequestID -> Slog -> CORS
r.Use(
middleware.Recovery(),
middleware.RequestID(),
middleware.GinSlogMiddleware(),
middleware.CORS(),
)
// 健康检查
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// handler 实例
userHandler := v1.NewUserHandler()
apiV1 := r.Group("/api/v1")
// ===== 公开路由(无需登录) =====
{
apiV1.POST("/user/wxlogin", userHandler.WxLogin)
apiV1.POST("/user/refresh-token", userHandler.RefreshToken)
}
// ===== 鉴权路由(需登录) =====
auth := apiV1.Group("")
auth.Use(middleware.JWTAuth())
{
auth.GET("/user/info", userHandler.InfoGetBySelf)
auth.PUT("/user/profile", userHandler.ProfileUpdate)
auth.POST("/user/avatar/upload", userHandler.AvatarUpload)
}
return r
}
+330
View File
@@ -0,0 +1,330 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"os"
"strings"
"time"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/upload"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/wechat"
"github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"gorm.io/gorm"
)
// 图片魔数校验需要读取的头部字节数(jpeg/png/gif/webp 最长 12 字节)。
const imageMagicHeaderLen = 12
// UserService 用户业务服务。
type UserService struct {
userDao *mysqldao.UserDao
}
// NewUserService 创建用户服务。
func NewUserService() *UserService {
return &UserService{
userDao: mysqldao.NewUserDao(),
}
}
// WxLogin 微信小程序登录:code2session 换取 openid,首次登录自动注册。
func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginRequest) (*dto.UserWxLoginResponse, *errcode.Error) {
sess, err := wechat.Code2Session(ctx, request.Code)
if err != nil {
var codeErr *wechat.Code2SessionError
if errors.As(err, &codeErr) && codeErr.IsInvalidCode() {
slog.WarnContext(ctx, "wx login: invalid code", "error", err)
return nil, errcode.UserWxCodeInvalid
}
slog.ErrorContext(ctx, "wx login: code2session failed", "error", err)
return nil, errcode.UserWxLoginFailed
}
user, err := s.userDao.UserGetByWechatOpenID(ctx, sess.OpenID)
if err != nil {
if !isNotFound(err) {
slog.ErrorContext(ctx, "wx login: query user failed", "error", err)
return nil, errcode.DatabaseError
}
// 首次登录:注册新用户。
user = &mysqlmodel.User{
UserUUID: genUserUUID(),
WechatOpenID: sess.OpenID,
Username: "微信用户",
Status: mysqlmodel.UserStatusNormal,
}
if cerr := s.userDao.UserCreate(ctx, user); cerr != nil {
if isDuplicateKey(cerr) {
// 并发注册冲突:重查已存在的用户继续登录。
slog.InfoContext(ctx, "wx login: concurrent register conflict, reload user")
user, err = s.userDao.UserGetByWechatOpenID(ctx, sess.OpenID)
if err != nil {
slog.ErrorContext(ctx, "wx login: reload user after register conflict failed", "error", err)
return nil, errcode.DatabaseError
}
} else {
slog.ErrorContext(ctx, "wx login: create user failed", "error", cerr)
return nil, errcode.UserCreateFailed
}
} else {
slog.InfoContext(ctx, "wx login: new user registered", "user_id", user.ID)
}
}
// 非正常状态账号(临时/永久禁用、已注销)禁止登录。
if user.Status != mysqlmodel.UserStatusNormal {
slog.WarnContext(ctx, "wx login: user disabled", "user_id", user.ID, "status", user.Status)
return nil, errcode.UserDisabled
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
if terr != nil {
slog.ErrorContext(ctx, "wx login: generate tokens failed", "user_id", user.ID, "error", terr)
return nil, errcode.UnauthorizedTokenGenerate
}
slog.InfoContext(ctx, "user login", "user_id", user.ID)
return &dto.UserWxLoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
UserInfo: buildUserInfoResponse(user),
}, nil
}
// RefreshToken 使用 refresh token 刷新访问令牌。
// 纯 JWT 无状态方案:不吊销旧令牌,仅校验令牌本身与用户当前状态后重新签发。
func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefreshTokenRequest) (*dto.UserRefreshTokenResponse, *errcode.Error) {
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
if err != nil {
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
slog.InfoContext(ctx, "refresh token: token expired")
return nil, errcode.UnauthorizedTokenTimeout
}
slog.WarnContext(ctx, "refresh token: parse token failed", "error", err)
return nil, errcode.UnauthorizedTokenError
}
// 校验用户仍存在且状态正常,避免已注销/封禁账号凭旧 refresh token 续期。
user, uerr := s.userDao.UserGetByID(ctx, claims.UserID)
if uerr != nil {
if isNotFound(uerr) {
slog.WarnContext(ctx, "refresh token: user not found", "user_id", claims.UserID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "refresh token: query user failed", "user_id", claims.UserID, "error", uerr)
return nil, errcode.DatabaseError
}
if user.Status != mysqlmodel.UserStatusNormal {
slog.WarnContext(ctx, "refresh token: user disabled", "user_id", user.ID, "status", user.Status)
return nil, errcode.UserDisabled
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
if terr != nil {
slog.ErrorContext(ctx, "refresh token: generate tokens failed", "user_id", user.ID, "error", terr)
return nil, errcode.UnauthorizedTokenGenerate
}
slog.DebugContext(ctx, "refresh token: tokens rotated", "user_id", user.ID)
return &dto.UserRefreshTokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
}, nil
}
// InfoGetBySelf 获取当前用户信息。
func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.UserInfoGetBySelfResponse, *errcode.Error) {
user, err := s.userDao.UserGetByID(ctx, userID)
if err != nil {
if isNotFound(err) {
slog.WarnContext(ctx, "get user info: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "get user info: query user failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
info := buildUserInfoResponse(user)
return &info, nil
}
// ProfileUpdate 修改当前用户资料,仅更新提交的非空字段。
func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) {
// 字段校验:昵称长度、头像长度(binding 已限制字节长度,此处按字符数二次校验)。
if u := strings.TrimSpace(request.Username); u != "" {
if len([]rune(u)) > 32 {
return nil, errcode.InvalidParams.WithDetails("昵称长度不能超过 32 个字符")
}
request.Username = u
}
if request.Avatar != "" && len([]rune(request.Avatar)) > 500 {
return nil, errcode.InvalidParams.WithDetails("头像地址过长")
}
// GORM Updates(struct) 忽略零值字段,仅非空字段参与更新。
updates := &mysqlmodel.User{Model: mysqlmodel.Model{ID: userID}}
changed := false
if request.Username != "" {
updates.Username = request.Username
changed = true
}
if request.Avatar != "" {
updates.Avatar = request.Avatar
changed = true
}
if !changed {
return &dto.UserProfileUpdateBySelfResponse{}, nil
}
if err := s.userDao.UserUpdateByID(ctx, updates); err != nil {
slog.ErrorContext(ctx, "profile update: update user failed", "user_id", userID, "error", err)
return nil, errcode.UserUpdateFailed
}
slog.InfoContext(ctx, "profile update success", "user_id", userID)
return &dto.UserProfileUpdateBySelfResponse{}, nil
}
// AvatarUpload 上传用户头像:校验图片 → 落临时目录 → 上传 COS → 更新 users.avatar(存 COS key),
// 返回可直接访问的头像 URL。
func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) {
if fileHeader == nil || fileHeader.Size == 0 {
slog.DebugContext(ctx, "avatar upload: empty file", "user_id", userID)
return nil, errcode.UploadFileEmpty
}
// 1. 扩展名与大小校验。
ext := upload.FileGetExt(fileHeader.Filename)
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
slog.DebugContext(ctx, "avatar upload: extension not allowed", "user_id", userID, "ext", ext)
return nil, errcode.UploadFileExtNotAllow
}
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
slog.DebugContext(ctx, "avatar upload: file too large", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileTooLarge
}
// 2. 只读文件头做真实图片校验(防伪造扩展名上传脚本等),避免整图读入内存。
header, herr := readFileHeader(fileHeader, imageMagicHeaderLen)
if herr != nil {
slog.ErrorContext(ctx, "avatar upload: read file header failed", "user_id", userID, "error", herr)
return nil, errcode.UploadFileSaveFailed
}
if !upload.FileCheckImageMagic(header) {
slog.WarnContext(ctx, "avatar upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileExtNotAllow
}
// 3. 取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。
user, uerr := s.userDao.UserGetByID(ctx, userID)
if uerr != nil {
if isNotFound(uerr) {
slog.WarnContext(ctx, "avatar upload: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "avatar upload: query user failed", "user_id", userID, "error", uerr)
return nil, errcode.DatabaseError
}
fileName, nerr := upload.FileGenName(fileHeader.Filename)
if nerr != nil {
slog.ErrorContext(ctx, "avatar upload: generate file name failed", "user_id", userID, "error", nerr)
return nil, errcode.UploadFileSaveFailed
}
// 4. 落临时文件(流式复制),无论成败都必须清理:
// 上传接口无频率限制,临时文件残留会让单个认证用户循环上传写满磁盘。
tmpPath, serr := upload.FileSaveTmp(ctx, fileHeader, fileName, userID)
if serr != nil {
slog.ErrorContext(ctx, "avatar upload: save temp file failed", "user_id", userID, "error", serr)
return nil, errcode.UploadFileSaveFailed
}
defer func() {
if rerr := os.Remove(tmpPath); rerr != nil && !os.IsNotExist(rerr) {
slog.WarnContext(ctx, "avatar upload: remove temp file failed",
"user_id", userID, "tmp_path", tmpPath, "error", rerr)
}
}()
cosKey := fmt.Sprintf("%s/%d/avatar/%s", user.UserUUID, userID, fileName)
if cerr := upload.FileUploadCos(ctx, cosKey, tmpPath); cerr != nil {
slog.ErrorContext(ctx, "avatar upload: upload to cos failed", "user_id", userID, "cos_key", cosKey, "error", cerr)
return nil, errcode.UploadFileSaveFailed
}
// 5. avatar 存 COS key,读取时统一转可访问 URL。
if uerr := s.userDao.UserUpdateByID(ctx, &mysqlmodel.User{Model: mysqlmodel.Model{ID: userID}, Avatar: cosKey}); uerr != nil {
slog.ErrorContext(ctx, "avatar upload: update user avatar failed", "user_id", userID, "error", uerr)
return nil, errcode.UserUpdateFailed
}
slog.InfoContext(ctx, "avatar upload success", "user_id", userID, "cos_key", cosKey)
return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil
}
// buildUserInfoResponse 将用户模型映射为用户信息响应,头像统一转为可访问 URL。
func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse {
return dto.UserInfoGetBySelfResponse{
Username: user.Username,
Avatar: upload.AvatarURL(user.Avatar),
Status: user.Status,
UserUUID: user.UserUUID,
}
}
// readFileHeader 同步读取上传文件头部最多 n 字节(请求结束 multipart 临时文件即被清理,
// 需在本函数内完成读取)。
func readFileHeader(fileHeader *multipart.FileHeader, n int) ([]byte, error) {
src, err := fileHeader.Open()
if err != nil {
return nil, err
}
defer src.Close()
buf := make([]byte, n)
read, err := io.ReadFull(src, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return nil, err
}
return buf[:read], nil
}
// isNotFound 判断 gorm 是否为「记录不存在」。
func isNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于并发注册回退重查。
func isDuplicateKey(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
// nowUnix 当前秒级时间戳。
func nowUnix() int64 {
return time.Now().Unix()
}
// genUserUUID 生成 32 位十六进制用户标识(去连字符 UUID),与 users.user_uuid varchar(32) 对齐。
func genUserUUID() string {
return strings.ReplaceAll(uuid.NewString(), "-", "")
}