Files
zt-online-game-backend/internal/service/user.go
T
zhengchiliu ff9add8655 补齐 HTTP 层:实现用户模块、路由与中间件接线、COS 上传及优雅启动
- 新增用户与上传错误码文件及 Recovery 中间件
- 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入
- 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto
- 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装
- 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像
- 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出
- 新增 configs 示例配置、global 配置结构及 go.mod 依赖
2026-09-27 15:02:01 +08:00

331 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(), "-", "")
}