补齐 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,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