Files
EchoChat/backend/go-service/app/auth/service/auth_service.go
bujinyuan c3e8600b24 fix: 前后台 Token Redis 存储隔离,修复同账号互相覆盖问题
核心变更:
- Redis key 加入 clientType 前缀:echo:auth:token:{frontend|admin}:{user_id}
- JWT Claims 新增 client_type 字段区分前台/管理端
- 新增 constants/client_type.go 定义 ClientTypeFrontend / ClientTypeAdmin
- 全链路传递 clientType:Controller → Service → TokenStore → Redis
- 中间件从 JWT Claims 提取 clientType 做 Redis 校验
- 登出只删除当前端 Token,不影响另一端

前端变更:
- 管理端 login API 改为调用 POST /api/v1/admin/auth/login(之前错误调用了前台接口)

文档更新:
- 系统设计文档、实施计划、API文档、开发规范、项目进度全部同步

Made-with: Cursor
2026-03-02 14:28:45 +08:00

442 lines
13 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 提供 auth 模块的核心业务逻辑
package service
import (
"context"
"errors"
"github.com/echochat/backend/app/auth/dao"
"github.com/echochat/backend/app/auth/model"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
ErrUserAlreadyExists = errors.New("用户名或邮箱已被注册")
ErrUserNotFound = errors.New("用户不存在")
ErrPasswordWrong = errors.New("密码错误")
ErrUserDisabled = errors.New("账号已被禁用")
ErrUserDeleted = errors.New("账号已注销")
ErrNotAdmin = errors.New("该账号无管理员权限")
ErrRefreshTokenType = errors.New("无效的 Refresh Token 类型")
)
// AuthService 认证服务处理注册、登录、Token 管理、个人信息等业务逻辑
type AuthService struct {
userDAO *dao.UserDAO
roleDAO *dao.RoleDAO
jwtCfg *config.JWTConfig
tokenStore *TokenStore
}
// NewAuthService 创建认证服务实例
func NewAuthService(userDAO *dao.UserDAO, roleDAO *dao.RoleDAO, jwtCfg *config.JWTConfig, tokenStore *TokenStore) *AuthService {
return &AuthService{
userDAO: userDAO,
roleDAO: roleDAO,
jwtCfg: jwtCfg,
tokenStore: tokenStore,
}
}
// Register 用户注册前台用户端专用clientType 固定为 frontend
// 流程:检查用户名/邮箱是否重复 → 加密密码 → 创建用户 → 分配默认角色 → 生成 Token
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
funcName := "service.auth_service.Register"
logs.Info(ctx, funcName, "开始处理注册",
zap.String("username", req.Username),
zap.String("email", logs.MaskEmail(req.Email)),
)
var err error
defer func() {
if err != nil {
logs.Error(ctx, funcName, "注册处理失败",
zap.String("username", req.Username),
zap.Error(err),
)
}
}()
// 检查用户名是否已存在
existing, _ := s.userDAO.FindByUsername(ctx, req.Username)
if existing != nil {
err = ErrUserAlreadyExists
return nil, err
}
// 检查邮箱是否已存在
existing, _ = s.userDAO.FindByEmail(ctx, req.Email)
if existing != nil {
err = ErrUserAlreadyExists
return nil, err
}
// 加密密码
hashedPassword, hashErr := utils.HashPassword(req.Password)
if hashErr != nil {
err = hashErr
return nil, err
}
// 创建用户
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
user := &model.User{
Username: req.Username,
Email: req.Email,
PasswordHash: hashedPassword,
Nickname: nickname,
Status: constants.UserStatusActive,
}
if err = s.userDAO.Create(ctx, user); err != nil {
return nil, err
}
// 分配默认角色(普通用户)
defaultRole, roleErr := s.roleDAO.FindByCode(ctx, constants.RoleUser)
if roleErr != nil {
logs.Warn(ctx, funcName, "查找默认角色失败,跳过角色分配", zap.Error(roleErr))
} else {
if assignErr := s.roleDAO.AssignRole(ctx, user.ID, defaultRole.ID); assignErr != nil {
logs.Warn(ctx, funcName, "分配默认角色失败", zap.Error(assignErr))
}
}
// 获取角色列表并生成 Token注册只有前台clientType 为 frontend
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(ctx, user, roles, constants.ClientTypeFrontend)
if err != nil {
return nil, err
}
logs.Info(ctx, funcName, "注册成功", zap.Int64("user_id", user.ID))
return resp, nil
}
// Login 用户登录
// 流程:查找用户 → 校验密码 → 检查账号状态 → 更新登录信息 → 生成 Token
// clientType 区分前台frontend和管理端adminToken 在 Redis 中隔离存储
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, clientIP, clientType string) (*dto.LoginResponse, error) {
funcName := "service.auth_service.Login"
logs.Info(ctx, funcName, "开始处理登录",
zap.String("account", req.Account),
)
var err error
defer func() {
if err != nil {
logs.Error(ctx, funcName, "登录处理失败",
zap.String("account", req.Account),
zap.Error(err),
)
}
}()
// 按用户名或邮箱查找用户(用户不存在时统一返回密码错误,防止枚举攻击)
user, findErr := s.userDAO.FindByAccount(ctx, req.Account)
if findErr != nil {
if errors.Is(findErr, gorm.ErrRecordNotFound) {
err = ErrPasswordWrong
} else {
err = findErr
}
return nil, err
}
// 校验密码
if !utils.CheckPassword(req.Password, user.PasswordHash) {
err = ErrPasswordWrong
return nil, err
}
// 检查账号状态
if err = s.checkUserStatus(user.Status); err != nil {
return nil, err
}
// 更新最后登录信息
_ = s.userDAO.UpdateLastLogin(ctx, user.ID, clientIP)
// 获取角色列表并生成 Token同时存入 Redis按 clientType 隔离)
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(ctx, user, roles, clientType)
if err != nil {
return nil, err
}
logs.Info(ctx, funcName, "登录成功",
zap.Int64("user_id", user.ID),
zap.String("username", user.Username),
zap.String("client_type", clientType),
)
return resp, nil
}
// AdminLogin 管理后台登录
// 与普通登录相同,但额外检查用户是否拥有 admin 或 super_admin 角色
// clientType 固定为 constants.ClientTypeAdmin
func (s *AuthService) AdminLogin(ctx context.Context, req *dto.LoginRequest, clientIP string) (*dto.LoginResponse, error) {
funcName := "service.auth_service.AdminLogin"
logs.Info(ctx, funcName, "开始处理管理员登录",
zap.String("account", req.Account),
)
resp, err := s.Login(ctx, req, clientIP, constants.ClientTypeAdmin)
if err != nil {
return nil, err
}
// 检查是否拥有管理员角色
hasAdmin := false
for _, role := range resp.User.Roles {
if role == constants.RoleAdmin || role == constants.RoleSuperAdmin {
hasAdmin = true
break
}
}
if !hasAdmin {
logs.Warn(ctx, funcName, "非管理员尝试后台登录",
zap.String("account", req.Account),
)
return nil, ErrNotAdmin
}
logs.Info(ctx, funcName, "管理员登录成功",
zap.String("account", req.Account),
)
return resp, nil
}
// RefreshToken 刷新 Access Token
// 验证 Refresh Token 有效性后,重新生成一对新的 Token
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*dto.LoginResponse, error) {
funcName := "service.auth_service.RefreshToken"
// 解析 Refresh Token
claims, err := utils.ParseToken(s.jwtCfg, refreshToken)
if err != nil {
logs.Warn(ctx, funcName, "Refresh Token 解析失败", zap.Error(err))
return nil, err
}
// 验证 Token 类型
if claims.Subject != "refresh" {
return nil, ErrRefreshTokenType
}
// 从 claims 中获取 clientType前台 / 管理端)
clientType := claims.ClientType
if clientType == "" {
clientType = constants.ClientTypeFrontend
}
// 验证 Refresh Token 是否与 Redis 中存储的一致
if !s.tokenStore.ValidateRefreshToken(ctx, claims.UserID, clientType, refreshToken) {
logs.Warn(ctx, funcName, "Refresh Token 已失效(不在 Redis 中)",
zap.Int64("user_id", claims.UserID),
zap.String("client_type", clientType),
)
return nil, ErrRefreshTokenType
}
// 查找用户(确保用户仍然有效)
user, err := s.userDAO.FindByID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
if err = s.checkUserStatus(user.Status); err != nil {
return nil, err
}
// 获取角色并生成新 Token同时存入 Redis 覆盖旧 Token保持 clientType 不变)
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
resp, err := s.buildLoginResponse(ctx, user, roles, clientType)
if err != nil {
return nil, err
}
logs.Info(ctx, funcName, "Token 刷新成功",
zap.Int64("user_id", user.ID),
zap.String("client_type", clientType),
)
return resp, nil
}
// Logout 用户登出
// 从 Redis 中删除指定 clientType 下该用户的 Token前台登出不影响管理端
func (s *AuthService) Logout(ctx context.Context, userID int64, clientType string) error {
funcName := "service.auth_service.Logout"
logs.Info(ctx, funcName, "用户登出",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return s.tokenStore.RemoveTokens(ctx, userID, clientType)
}
// ValidateAccessToken 校验 Access Token 是否在 Redis 中有效
// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验
func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool {
return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token)
}
// GetProfile 获取用户个人信息
func (s *AuthService) GetProfile(ctx context.Context, userID int64) (*dto.UserInfo, error) {
funcName := "service.auth_service.GetProfile"
logs.Debug(ctx, funcName, "获取用户信息", zap.Int64("user_id", userID))
user, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
return s.buildUserInfo(user, roles), nil
}
// UpdateProfile 更新用户个人资料
func (s *AuthService) UpdateProfile(ctx context.Context, userID int64, req *dto.UpdateProfileRequest) (*dto.UserInfo, error) {
funcName := "service.auth_service.UpdateProfile"
logs.Info(ctx, funcName, "更新用户资料", zap.Int64("user_id", userID))
user, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
if req.Nickname != "" {
user.Nickname = req.Nickname
}
if req.Avatar != "" {
user.Avatar = req.Avatar
}
if req.Gender != nil {
user.Gender = *req.Gender
}
if req.Phone != "" {
user.Phone = &req.Phone
}
if err = s.userDAO.Update(ctx, user); err != nil {
return nil, err
}
roles, _ := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
logs.Info(ctx, funcName, "用户资料更新成功", zap.Int64("user_id", userID))
return s.buildUserInfo(user, roles), nil
}
// ChangePassword 修改密码
func (s *AuthService) ChangePassword(ctx context.Context, userID int64, req *dto.ChangePasswordRequest) error {
funcName := "service.auth_service.ChangePassword"
logs.Info(ctx, funcName, "修改密码", zap.Int64("user_id", userID))
user, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUserNotFound
}
return err
}
// 校验旧密码
if !utils.CheckPassword(req.OldPassword, user.PasswordHash) {
return ErrPasswordWrong
}
// 加密新密码
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
return err
}
user.PasswordHash = hashedPassword
if err = s.userDAO.Update(ctx, user); err != nil {
return err
}
logs.Info(ctx, funcName, "密码修改成功", zap.Int64("user_id", userID))
return nil
}
// checkUserStatus 检查用户账号状态
func (s *AuthService) checkUserStatus(status int) error {
switch status {
case constants.UserStatusDisabled:
return ErrUserDisabled
case constants.UserStatusDeleted:
return ErrUserDeleted
default:
return nil
}
}
// buildLoginResponse 构建登录响应(生成 Token + 存入 Redis + 用户信息)
// clientType 决定 Token 存入 Redis 的 key 前缀和 JWT Claims 中的 client_type 字段
func (s *AuthService) buildLoginResponse(ctx context.Context, user *model.User, roles []string, clientType string) (*dto.LoginResponse, error) {
if roles == nil {
roles = []string{}
}
token, err := utils.GenerateToken(s.jwtCfg, user.ID, user.Username, roles, clientType)
if err != nil {
return nil, err
}
refreshToken, err := utils.GenerateRefreshToken(s.jwtCfg, user.ID, clientType)
if err != nil {
return nil, err
}
// 将 Token 存入 Redis按 clientType 隔离,前台和管理端互不影响)
if err = s.tokenStore.SaveTokens(ctx, user.ID, clientType, token, refreshToken); err != nil {
return nil, err
}
return &dto.LoginResponse{
Token: token,
RefreshToken: refreshToken,
ExpiresIn: int64(s.jwtCfg.AccessExpireMin) * 60,
User: *s.buildUserInfo(user, roles),
}, nil
}
// buildUserInfo 从 model.User 构建 dto.UserInfo
func (s *AuthService) buildUserInfo(user *model.User, roles []string) *dto.UserInfo {
if roles == nil {
roles = []string{}
}
info := &dto.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Nickname: user.Nickname,
Avatar: user.Avatar,
Gender: user.Gender,
Roles: roles,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
}
if user.Phone != nil {
info.Phone = *user.Phone
}
return info
}