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