You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
6.3 KiB
207 lines
6.3 KiB
package service
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"laic-backend/cache"
|
|
"laic-backend/common"
|
|
"laic-backend/logger"
|
|
"laic-backend/model"
|
|
"laic-backend/token"
|
|
"laic-backend/tool"
|
|
"laic-backend/vo"
|
|
)
|
|
|
|
type UserService struct{}
|
|
|
|
var DefaultUserService = &UserService{}
|
|
|
|
// Register 注册用户(默认角色 user),成功后直接返回登录态
|
|
func (s *UserService) Register(name, phone, email, password string) (*vo.LoginResp, *common.BusiError) {
|
|
// 手机号唯一性预检(兼顾并发,唯一键兜底)
|
|
var count int64
|
|
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
if count > 0 {
|
|
return nil, common.ErrUserPhoneExists
|
|
}
|
|
|
|
id, err := tool.NextID()
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
|
|
user := &model.User{
|
|
ID: id,
|
|
Name: name,
|
|
Phone: phone,
|
|
Email: email,
|
|
Password: string(hash),
|
|
Role: common.RoleUser,
|
|
Status: 1,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := common.DB.Create(user).Error; err != nil {
|
|
if code, _ := common.ParseError(err); code == 1062 {
|
|
return nil, common.ErrUserPhoneExists
|
|
}
|
|
logger.ERROR("注册用户失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
_ = common.SetUserRole(id, common.RoleUser)
|
|
|
|
return s.buildLoginResp(user)
|
|
}
|
|
|
|
// Login 手机号 + 密码登录
|
|
func (s *UserService) Login(phone, password string) (*vo.LoginResp, *common.BusiError) {
|
|
var user model.User
|
|
if err := common.DB.Where("phone = ? AND status = 1", phone).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrPasswordError // 统一返回"用户名或密码错误",避免暴露账号是否存在
|
|
}
|
|
logger.ERROR("登录查询用户失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
if !checkPassword(password, user.Password) {
|
|
return nil, common.ErrPasswordError
|
|
}
|
|
|
|
// 更新最后登录时间(失败不阻断登录)
|
|
common.DB.Model(&user).Update("last_login", time.Now())
|
|
|
|
return s.buildLoginResp(&user)
|
|
}
|
|
|
|
// Refresh 用 refresh token 换取新的 access + refresh token(刷新令牌轮换)
|
|
func (s *UserService) Refresh(refreshToken string) (*vo.LoginResp, *common.BusiError) {
|
|
userID, err := token.ParseRefreshToken(refreshToken)
|
|
if err != nil {
|
|
return nil, common.ErrTokenInvalid
|
|
}
|
|
|
|
// 校验 refresh token 是否仍存证(登出会删除)
|
|
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
|
|
stored, err := common.GetString(refreshKey)
|
|
if err != nil || stored != refreshToken {
|
|
return nil, common.ErrTokenInvalid
|
|
}
|
|
|
|
var user model.User
|
|
if err := common.DB.Where("id = ? AND status = 1", userID).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrUserNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
|
|
// 轮换:删除旧 refresh 存证
|
|
_ = common.Delete(refreshKey)
|
|
|
|
return s.buildLoginResp(&user)
|
|
}
|
|
|
|
// Logout 登出:删除 access 存证(及可选 refresh 存证),使 token 立即失效
|
|
func (s *UserService) Logout(userID int64, accessToken, refreshToken string) *common.BusiError {
|
|
accessKey := cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken))
|
|
_ = common.Delete(accessKey)
|
|
if refreshToken != "" {
|
|
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
|
|
_ = common.Delete(refreshKey)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendSmsCode 生成并"发送"短信验证码(暂存 Redis,短信通道未接入前记日志)
|
|
func (s *UserService) SendSmsCode(phone string) (string, *common.BusiError) {
|
|
code := tool.RandDigit(6)
|
|
key := cache.SmsCodeKeyOf(phone)
|
|
if err := common.SetValueWithExpired(key, code, 5*60); err != nil {
|
|
logger.ERROR("短信验证码写 Redis 失败", err)
|
|
return "", common.ErrInternal
|
|
}
|
|
// TODO: 接入短信服务商后,此处调用真实发送接口;当前仅在日志输出验证码便于联调
|
|
logger.INFO("send sms code to", phone, "code:", code)
|
|
return code, nil
|
|
}
|
|
|
|
// ResetPassword 通过短信验证码重置密码
|
|
func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.BusiError {
|
|
key := cache.SmsCodeKeyOf(phone)
|
|
stored, err := common.GetString(key)
|
|
if err != nil || stored == "" || stored != smsCode {
|
|
return common.ErrSmsCodeError
|
|
}
|
|
|
|
var user model.User
|
|
if err := common.DB.Where("phone = ?", phone).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return common.ErrUserNotFound
|
|
}
|
|
return common.ErrInternal
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
if err := common.DB.Model(&user).Updates(map[string]any{
|
|
"password": string(hash),
|
|
"updated_at": time.Now(),
|
|
}).Error; err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
_ = common.Delete(key)
|
|
return nil
|
|
}
|
|
|
|
// GetByID 按 ID 查询用户
|
|
func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) {
|
|
var user model.User
|
|
if err := common.DB.First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrUserNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// buildLoginResp 生成 token 并存证 Redis,返回登录响应
|
|
func (s *UserService) buildLoginResp(user *model.User) (*vo.LoginResp, *common.BusiError) {
|
|
roles := []string{user.Role}
|
|
accessToken, refreshToken, err := token.GenerateToken(user.ID, user.Phone, roles)
|
|
if err != nil {
|
|
logger.ERROR("生成 token 失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
|
|
accessKey := cache.UserTokenKeyOf(user.ID, token.GenerateShortID(accessToken))
|
|
refreshKey := cache.RefreshTokenKeyOf(user.ID, token.GenerateShortID(refreshToken))
|
|
if err := common.SetValueWithExpired(accessKey, "1", token.AccessExpireSeconds()); err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
if err := common.SetValueWithExpired(refreshKey, refreshToken, token.RefreshExpireSeconds()); err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
|
|
return &vo.LoginResp{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
User: vo.NewUserVO(user),
|
|
}, nil
|
|
}
|
|
|
|
func checkPassword(password, hash string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
|
}
|
|
|