补齐 HTTP 层:实现用户模块、路由与中间件接线、COS 上传及优雅启动
- 新增用户与上传错误码文件及 Recovery 中间件 - 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入 - 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto - 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装 - 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像 - 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出 - 新增 configs 示例配置、global 配置结构及 go.mod 依赖
This commit is contained in:
@@ -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 秒时间戳)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(), "-", "")
|
||||
}
|
||||
Reference in New Issue
Block a user