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(), "-", "") }