402 lines
12 KiB
Go
402 lines
12 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"time"
|
||
|
||
"github.com/goravel/framework/contracts/http"
|
||
"github.com/goravel/framework/facades"
|
||
"github.com/goravel/framework/support/str"
|
||
"github.com/spf13/cast"
|
||
|
||
"goravel/app/errors"
|
||
"goravel/app/http/helpers"
|
||
"goravel/app/models"
|
||
"goravel/app/utils"
|
||
"goravel/app/utils/errorlog"
|
||
"goravel/app/utils/logger"
|
||
)
|
||
|
||
type AuthService interface {
|
||
// Login 管理员登录
|
||
Login(ctx http.Context, username, password string) (*models.Admin, string, error)
|
||
// GetAdminInfo 获取管理员完整信息(包括权限和菜单)
|
||
GetAdminInfo(ctx http.Context) (*models.Admin, []models.Permission, []models.Menu, error)
|
||
// RecordLoginLog 记录登录日志
|
||
RecordLoginLog(ctx http.Context, adminID uint, username string, status uint8, message string, request string) error
|
||
}
|
||
|
||
type AuthServiceImpl struct {
|
||
adminService AdminService
|
||
tokenService TokenService
|
||
}
|
||
|
||
func NewAuthServiceImpl(adminService AdminService, tokenService TokenService) *AuthServiceImpl {
|
||
return &AuthServiceImpl{
|
||
adminService: adminService,
|
||
tokenService: tokenService,
|
||
}
|
||
}
|
||
|
||
// Login 管理员登录
|
||
//
|
||
// 参数:
|
||
// - ctx: HTTP 上下文
|
||
// - username: 用户名
|
||
// - password: 密码
|
||
//
|
||
// 返回:
|
||
// - *models.Admin: 管理员对象
|
||
// - string: JWT token
|
||
// - error: 错误信息
|
||
func (s *AuthServiceImpl) Login(ctx http.Context, username, password string) (*models.Admin, string, error) {
|
||
// 验证用户名是否存在
|
||
exists, err := facades.Orm().Query().Model(&models.Admin{}).Where("username", username).Exists()
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if !exists {
|
||
return nil, "", errors.ErrUsernameOrPasswordErr
|
||
}
|
||
|
||
// 获取管理员信息
|
||
var admin models.Admin
|
||
if err := facades.Orm().Query().Where("username", username).First(&admin); err != nil {
|
||
return nil, "", err
|
||
}
|
||
|
||
if admin.Status == 0 {
|
||
return nil, "", errors.ErrAccountDisabled
|
||
}
|
||
|
||
// 验证密码
|
||
if !facades.Hash().Check(password, admin.Password) {
|
||
// 记录登录失败日志(注意:这个方法可能不再使用,但为了兼容性保留)
|
||
requestData := ""
|
||
if allInputs := ctx.Request().All(); len(allInputs) > 0 {
|
||
if data, err := json.Marshal(allInputs); err == nil {
|
||
requestData = string(data)
|
||
}
|
||
}
|
||
s.RecordLoginLog(ctx, 0, username, 0, "password_error", requestData)
|
||
return nil, "", errors.ErrPasswordError
|
||
}
|
||
|
||
// 生成token并存入数据库(类似Laravel Sanctum)
|
||
// 按配置的过期时间生成token,如果需要永久token,可以在创建token时设置 expiresAt 为 nil
|
||
var expiresAt *time.Time
|
||
ttl := facades.Config().GetInt("jwt.ttl", 60) // 默认60分钟
|
||
if ttl > 0 {
|
||
// 如果配置了过期时间,设置过期时间
|
||
exp := time.Now().Add(time.Duration(ttl) * time.Minute)
|
||
expiresAt = &exp
|
||
}
|
||
// 如果 ttl 为 0 或负数,expiresAt 为 nil,表示永不过期
|
||
|
||
// 获取浏览器和操作系统信息
|
||
browser, os := helpers.GetBrowserAndOS(ctx)
|
||
// 获取真实IP地址
|
||
ip := helpers.GetRealIP(ctx)
|
||
// sessionID将在CreateToken中自动生成
|
||
|
||
// 生成token
|
||
plainToken, _, err := s.tokenService.CreateToken("admin", admin.ID, "admin-token", expiresAt, browser, ip, os, "")
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
token := plainToken
|
||
|
||
// 更新最后登录时间(ORM会自动更新UpdatedAt)
|
||
facades.Orm().Query().Save(&admin)
|
||
|
||
// 记录登录成功日志
|
||
requestData := ""
|
||
if allInputs := ctx.Request().All(); len(allInputs) > 0 {
|
||
if data, err := json.Marshal(allInputs); err == nil {
|
||
requestData = string(data)
|
||
}
|
||
}
|
||
s.RecordLoginLog(ctx, admin.ID, username, 1, "login_success", requestData)
|
||
|
||
return &admin, token, nil
|
||
}
|
||
|
||
// GetAdminInfo 获取管理员完整信息(包括权限和菜单)
|
||
//
|
||
// 参数:
|
||
// - ctx: HTTP 上下文
|
||
//
|
||
// 返回:
|
||
// - *models.Admin: 管理员对象
|
||
// - []models.Permission: 权限列表
|
||
// - []models.Menu: 菜单列表
|
||
// - error: 错误信息
|
||
func (s *AuthServiceImpl) GetAdminInfo(ctx http.Context) (*models.Admin, []models.Permission, []models.Menu, error) {
|
||
// 从context中获取admin信息(由JWT中间件设置)
|
||
adminValue := ctx.Value("admin")
|
||
if adminValue == nil {
|
||
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin value is nil in context")
|
||
return nil, nil, nil, errors.ErrNotLoggedIn
|
||
}
|
||
|
||
var admin models.Admin
|
||
// 尝试值类型
|
||
if adminVal, ok := adminValue.(models.Admin); ok {
|
||
admin = adminVal
|
||
} else if adminPtr, ok := adminValue.(*models.Admin); ok {
|
||
// 尝试指针类型
|
||
if adminPtr == nil {
|
||
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin pointer is nil")
|
||
return nil, nil, nil, errors.ErrNotLoggedIn
|
||
}
|
||
admin = *adminPtr
|
||
} else {
|
||
logger.ErrorfHTTP(ctx, "GetAdminInfo: admin value type assertion failed, type: %T, value: %+v", adminValue, adminValue)
|
||
return nil, nil, nil, errors.ErrNotLoggedIn
|
||
}
|
||
|
||
// facades.Log().Debugf("GetAdminInfo: admin found, ID: %d, Username: %s", admin.ID, admin.Username)
|
||
|
||
// 重新查询admin并加载关联(避免使用已存在的admin对象,可能导致关联加载问题)
|
||
var adminWithRelations models.Admin
|
||
if err := facades.Orm().Query().With("Department").With("Roles").Where("id", admin.ID).First(&adminWithRelations); err != nil {
|
||
errorlog.RecordHTTP(ctx, "auth", "Failed to load admin with relations", map[string]any{
|
||
"error": err.Error(),
|
||
"admin_id": admin.ID,
|
||
}, "GetAdminInfo: failed to load admin with relations, error: %v", err)
|
||
return nil, nil, nil, err
|
||
}
|
||
admin = adminWithRelations
|
||
|
||
// 批量加载所有角色的权限和菜单,避免 N+1 查询
|
||
if len(admin.Roles) > 0 {
|
||
var roleIDs []uint
|
||
for _, role := range admin.Roles {
|
||
roleIDs = append(roleIDs, role.ID)
|
||
}
|
||
|
||
// 批量加载权限
|
||
type RolePermission struct {
|
||
RoleID uint `gorm:"column:role_id"`
|
||
PermissionID uint `gorm:"column:permission_id"`
|
||
}
|
||
var rolePermissions []RolePermission
|
||
if err := facades.Orm().Query().Table("role_permission").Where("role_id IN ?", roleIDs).Find(&rolePermissions); err == nil {
|
||
var permissionIDs []uint
|
||
rolePermissionMap := make(map[uint][]uint)
|
||
for _, rp := range rolePermissions {
|
||
rolePermissionMap[rp.RoleID] = append(rolePermissionMap[rp.RoleID], rp.PermissionID)
|
||
permissionIDs = append(permissionIDs, rp.PermissionID)
|
||
}
|
||
if len(permissionIDs) > 0 {
|
||
var permissions []models.Permission
|
||
if err := facades.Orm().Query().Where("id IN ?", permissionIDs).Find(&permissions); err == nil {
|
||
permissionMap := make(map[uint]models.Permission)
|
||
for _, perm := range permissions {
|
||
permissionMap[perm.ID] = perm
|
||
}
|
||
for i := range admin.Roles {
|
||
if permIDs, ok := rolePermissionMap[admin.Roles[i].ID]; ok {
|
||
for _, permID := range permIDs {
|
||
if perm, ok := permissionMap[permID]; ok {
|
||
admin.Roles[i].Permissions = append(admin.Roles[i].Permissions, perm)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量加载菜单
|
||
type RoleMenu struct {
|
||
RoleID uint `gorm:"column:role_id"`
|
||
MenuID uint `gorm:"column:menu_id"`
|
||
}
|
||
var roleMenus []RoleMenu
|
||
if err := facades.Orm().Query().Table("role_menu").Where("role_id IN ?", roleIDs).Find(&roleMenus); err == nil {
|
||
var menuIDs []uint
|
||
roleMenuMap := make(map[uint][]uint)
|
||
for _, rm := range roleMenus {
|
||
roleMenuMap[rm.RoleID] = append(roleMenuMap[rm.RoleID], rm.MenuID)
|
||
menuIDs = append(menuIDs, rm.MenuID)
|
||
}
|
||
if len(menuIDs) > 0 {
|
||
var menus []models.Menu
|
||
if err := facades.Orm().Query().Where("id IN ?", menuIDs).Find(&menus); err == nil {
|
||
menuMap := make(map[uint]models.Menu)
|
||
for _, menu := range menus {
|
||
menuMap[menu.ID] = menu
|
||
}
|
||
for i := range admin.Roles {
|
||
if mIDs, ok := roleMenuMap[admin.Roles[i].ID]; ok {
|
||
for _, menuID := range mIDs {
|
||
if menu, ok := menuMap[menuID]; ok {
|
||
admin.Roles[i].Menus = append(admin.Roles[i].Menus, menu)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否是超级管理员
|
||
const SuperAdminRoleSlug = "super-admin"
|
||
isSuperAdmin := false
|
||
for _, role := range admin.Roles {
|
||
if role.Slug == SuperAdminRoleSlug && role.Status == 1 {
|
||
isSuperAdmin = true
|
||
break
|
||
}
|
||
}
|
||
|
||
// 收集所有角色的权限和菜单(去重)
|
||
permissionMap := make(map[uint]models.Permission)
|
||
menuMap := make(map[uint]models.Menu)
|
||
|
||
for _, role := range admin.Roles {
|
||
for _, perm := range role.Permissions {
|
||
permissionMap[perm.ID] = perm
|
||
}
|
||
for _, menu := range role.Menus {
|
||
menuMap[menu.ID] = menu
|
||
}
|
||
}
|
||
|
||
// 如果是超级管理员,返回所有菜单(用于前端显示)
|
||
// 但不需要返回所有权限,因为权限检查在中间件中会跳过
|
||
if isSuperAdmin {
|
||
var allMenus []models.Menu
|
||
if err := facades.Orm().Query().Where("status", 1).Order("sort ASC").Find(&allMenus); err == nil {
|
||
for _, menu := range allMenus {
|
||
menuMap[menu.ID] = menu
|
||
}
|
||
}
|
||
}
|
||
|
||
// 转换为切片
|
||
var permissions []models.Permission
|
||
var menus []models.Menu
|
||
for _, perm := range permissionMap {
|
||
permissions = append(permissions, perm)
|
||
}
|
||
for _, menu := range menuMap {
|
||
menus = append(menus, menu)
|
||
}
|
||
|
||
// 检查是否需要隐藏服务监控菜单
|
||
// 只有当配置值不为空且不等于 "0" 时才隐藏("0" 表示不隐藏)
|
||
monitorHidden := facades.Config().GetString("admin.monitor_hidden", "")
|
||
if monitorHidden != "" && monitorHidden != "0" {
|
||
// 检查是否是开发者管理员
|
||
developerIDsStr := facades.Config().GetString("admin.developer_ids", "2")
|
||
isDeveloperAdmin := s.isDeveloperAdmin(admin.ID, developerIDsStr)
|
||
|
||
// 如果不是开发者管理员,则过滤掉服务监控菜单
|
||
if !isDeveloperAdmin {
|
||
var filteredMenus []models.Menu
|
||
for _, menu := range menus {
|
||
if menu.Slug != "monitor" {
|
||
filteredMenus = append(filteredMenus, menu)
|
||
}
|
||
}
|
||
menus = filteredMenus
|
||
}
|
||
}
|
||
|
||
// 检查是否需要隐藏开发工具菜单
|
||
enableDevTool := facades.Config().GetBool("app.enable_dev_tool")
|
||
if !enableDevTool {
|
||
var filteredMenus []models.Menu
|
||
for _, menu := range menus {
|
||
if menu.Slug != "dev" {
|
||
filteredMenus = append(filteredMenus, menu)
|
||
}
|
||
}
|
||
menus = filteredMenus
|
||
}
|
||
|
||
return &admin, permissions, menus, nil
|
||
}
|
||
|
||
// RecordLoginLog 记录登录日志
|
||
func (s *AuthServiceImpl) RecordLoginLog(ctx http.Context, adminID uint, username string, status uint8, message string, request string) error {
|
||
ip := ctx.Request().Ip()
|
||
|
||
// 先创建登录日志记录(Location 字段先为空,避免阻塞登录流程)
|
||
loginLog := models.LoginLog{
|
||
AdminID: adminID,
|
||
Username: username,
|
||
IP: ip,
|
||
UserAgent: ctx.Request().Header("User-Agent", ""),
|
||
Location: "", // 先为空,异步更新
|
||
Status: status,
|
||
Message: message,
|
||
Request: request,
|
||
}
|
||
|
||
if err := facades.Orm().Query().Create(&loginLog); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 异步查询 IP 地理位置信息并更新日志记录
|
||
// 这样不会阻塞登录流程
|
||
go func() {
|
||
// 添加 panic 恢复机制
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
facades.Log().Errorf("Recovered from panic in IP location update: %v", r)
|
||
}
|
||
}()
|
||
|
||
// 添加上下文超时控制(5秒超时)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
location := utils.GetIPLocation(ip)
|
||
if location != "" {
|
||
// 更新登录日志的 Location 字段
|
||
if _, err := facades.Orm().Query().
|
||
Model(&models.LoginLog{}).
|
||
Where("id", loginLog.ID).
|
||
Update("location", location); err != nil {
|
||
facades.Log().Errorf("Failed to update login log location: %v", err)
|
||
}
|
||
}
|
||
|
||
// 检查上下文是否超时
|
||
select {
|
||
case <-ctx.Done():
|
||
if ctx.Err() == context.DeadlineExceeded {
|
||
facades.Log().Errorf("IP location update timeout for login log ID: %d", loginLog.ID)
|
||
}
|
||
default:
|
||
}
|
||
}()
|
||
|
||
return nil
|
||
}
|
||
|
||
// isDeveloperAdmin 检查是否是开发者管理员
|
||
func (s *AuthServiceImpl) isDeveloperAdmin(adminID uint, developerIDsStr string) bool {
|
||
if developerIDsStr == "" {
|
||
return false
|
||
}
|
||
|
||
// 解析开发者ID列表
|
||
parts := str.Of(developerIDsStr).Split(",")
|
||
for _, part := range parts {
|
||
part = str.Of(part).Trim().String()
|
||
if !str.Of(part).IsEmpty() {
|
||
if id := cast.ToUint(part); id > 0 && id == adminID {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|