555 lines
18 KiB
Go
555 lines
18 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
"strings"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
v1 "github.com/qianfree/team-api/api/admin/v1"
|
|
"github.com/qianfree/team-api/internal/consts"
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
"github.com/qianfree/team-api/internal/utility/crypto"
|
|
"github.com/qianfree/team-api/internal/utility/export"
|
|
)
|
|
|
|
// ListUsers returns a paginated list of admin users.
|
|
func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.AdminUserListRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
total, err := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
|
|
|
|
var users []struct {
|
|
Id int64 `json:"id" orm:"id"`
|
|
Username string `json:"username" orm:"username"`
|
|
Email string `json:"email" orm:"email"`
|
|
DisplayName string `json:"display_name" orm:"display_name"`
|
|
Role string `json:"role" orm:"role"`
|
|
Status string `json:"status" orm:"status"`
|
|
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
|
|
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
|
|
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until"`
|
|
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
|
}
|
|
err = m.OrderDesc("id").
|
|
Page(page, pageSize).
|
|
Scan(&users)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 批量取角色,避免逐个账号各查一次(N+1)
|
|
userIDs := make([]int64, len(users))
|
|
for i, u := range users {
|
|
userIDs[i] = u.Id
|
|
}
|
|
rolesByUser, err := loadRolesByUsers(ctx, userIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]v1.AdminUserItem, len(users))
|
|
for i, u := range users {
|
|
lockedUntil := ""
|
|
if u.LockedUntil != nil {
|
|
lockedUntil = u.LockedUntil.String()
|
|
}
|
|
items[i] = v1.AdminUserItem{
|
|
ID: u.Id,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
DisplayName: u.DisplayName,
|
|
Role: u.Role,
|
|
Status: u.Status,
|
|
LastLoginAt: u.LastLoginAt.String(),
|
|
LastLoginIp: u.LastLoginIp,
|
|
LockedUntil: lockedUntil,
|
|
CreatedAt: u.CreatedAt.String(),
|
|
Roles: rolesByUser[u.Id],
|
|
}
|
|
}
|
|
|
|
return &v1.AdminUserListRes{
|
|
List: items,
|
|
Total: int(total),
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// assertCanManageAdminUser 校验当前调用者是否有资格操作目标管理员账号。
|
|
//
|
|
// 账号管理(改资料 / 改密 / 禁用 / 删除 / 配特批权限)按 user:* 授权、允许下放,
|
|
// 但下放的前提是「操作本身不能带来提权」。两条底线:
|
|
// - 目标是超级管理员 → 非超管一律不可操作。否则重置一次超管密码就能接管整个系统,
|
|
// 角色管理限定超管的那道闸门形同虚设。
|
|
// - 目标的有效权限不是调用者的子集 → 不可操作。否则一个只有 user:edit 的账号可以
|
|
// 重置权限更高账号的密码,登进去就完成了横向提权。
|
|
//
|
|
// 超级管理员短路放行;操作自己不受限(自身权限的变更另有 sanitizeGrantedPermissions 把关)。
|
|
// 与 sanitizeGrantedPermissions 的「不能授予自己不具备的权限」同源:能碰的对象不得强于自己。
|
|
func assertCanManageAdminUser(ctx context.Context, targetUserID int64, targetRole string) error {
|
|
operatorRole := common.GetCtxUserRole(ctx)
|
|
if operatorRole == "super_admin" {
|
|
return nil
|
|
}
|
|
if targetRole == "super_admin" {
|
|
return common.NewBusinessError(consts.CodeForbidden, "不能操作超级管理员账号")
|
|
}
|
|
|
|
operatorID := common.GetCtxUserID(ctx)
|
|
// 无调用者身份(命令行工具、内部调用)时不做判定,交由上层接口鉴权把关
|
|
if operatorID == 0 || operatorID == targetUserID {
|
|
return nil
|
|
}
|
|
|
|
targetPerms, err := GetEffectivePermissions(ctx, targetUserID, targetRole)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ownPerms, err := GetEffectivePermissions(ctx, operatorID, operatorRole)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ownSet := make(map[string]bool, len(ownPerms))
|
|
for _, p := range ownPerms {
|
|
ownSet[p] = true
|
|
}
|
|
for _, p := range targetPerms {
|
|
if !ownSet[p] {
|
|
return common.NewBusinessError(consts.CodeForbidden, "目标账号拥有你不具备的权限,无法对其执行此操作")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// assertCanGrantSuperAdmin 拦截「非超管把账号设成超级管理员」。
|
|
//
|
|
// role 字段的取值校验(ValidateAdminRole)只管值合不合法,管不了谁有资格赋这个值。
|
|
// 少了这道判断,一个拿到 user:create / user:edit 的账号可以直接把自己或新建账号
|
|
// 提成 super_admin —— 这是比分配角色更直接的提权入口,必须与角色分配同级管控。
|
|
func assertCanGrantSuperAdmin(ctx context.Context, role string) error {
|
|
if role != "super_admin" || common.GetCtxUserRole(ctx) == "super_admin" {
|
|
return nil
|
|
}
|
|
return common.NewBusinessError(consts.CodeForbidden, "只有超级管理员才能设置超级管理员账号")
|
|
}
|
|
|
|
// CreateUser creates a new admin user.
|
|
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
|
|
username := strings.TrimSpace(req.Username)
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
// Validate username format
|
|
if err := common.ValidateUsername(username); err != nil {
|
|
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
|
|
}
|
|
|
|
// Check username uniqueness
|
|
count, err := dao.SysAdminUsers.Ctx(ctx).
|
|
Where("username", username).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
|
|
}
|
|
|
|
// Check email uniqueness if provided (normalized to lowercase so that
|
|
// e.g. "User@Example.com" and "user@example.com" collide as the same address).
|
|
if email != "" {
|
|
count, err = dao.SysAdminUsers.Ctx(ctx).
|
|
Where("email", email).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
|
}
|
|
}
|
|
|
|
// Validate password policy
|
|
if err := common.ValidatePassword(req.Password); err != nil {
|
|
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
|
|
}
|
|
|
|
// Hash password
|
|
passwordHash, err := crypto.HashPassword(req.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate role
|
|
role := req.Role
|
|
if role == "" {
|
|
role = "admin"
|
|
}
|
|
if err := common.ValidateAdminRole(role); err != nil {
|
|
return nil, common.NewBadRequestError(err.Error())
|
|
}
|
|
if err := assertCanGrantSuperAdmin(ctx, role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。
|
|
// 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号,
|
|
// 等价于给自己提权。这里明确报错而非静默忽略——静默会造出一个零权限账号,
|
|
// 使用者以为分配成功了。
|
|
if len(req.RoleIDs) > 0 && common.GetCtxUserRole(ctx) != "super_admin" {
|
|
return nil, common.NewBusinessError(consts.CodeForbidden, "分配角色仅超级管理员可用,请先创建账号再由超级管理员分配角色")
|
|
}
|
|
|
|
// 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403,
|
|
// 是「系统缺乏角色管理」最直接的体感来源。
|
|
var id int64
|
|
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{
|
|
Username: username,
|
|
PasswordHash: passwordHash,
|
|
Email: email,
|
|
DisplayName: username,
|
|
Role: role,
|
|
Status: "active",
|
|
}).Insert()
|
|
if err != nil {
|
|
// Race condition: another request inserted a colliding username/email
|
|
// between our pre-check and this insert. Translate the DB unique
|
|
// violation into a friendly business error.
|
|
if common.IsDuplicateKeyError(err) {
|
|
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
|
}
|
|
return err
|
|
}
|
|
|
|
id, err = result.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 超级管理员权限来自账号属性而非角色,忽略传入的 role_ids
|
|
if role == "super_admin" || len(req.RoleIDs) == 0 {
|
|
return nil
|
|
}
|
|
return replaceUserRoles(ctx, id, req.RoleIDs)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.AdminUserCreateRes{ID: id}, nil
|
|
}
|
|
|
|
// UpdateUser updates an admin user.
|
|
func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v1.AdminUserUpdateRes, error) {
|
|
// Prevent modification of super_admin accounts
|
|
var targetUser *struct {
|
|
Role string `json:"role"`
|
|
}
|
|
err := dao.SysAdminUsers.Ctx(ctx).
|
|
Where("id", req.Id).Scan(&targetUser)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if targetUser == nil {
|
|
return nil, common.NewNotFoundError("管理员")
|
|
}
|
|
if targetUser.Role == "super_admin" {
|
|
return nil, common.NewBadRequestError("不能修改超级管理员信息")
|
|
}
|
|
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := do.SysAdminUsers{}
|
|
if req.DisplayName != nil {
|
|
data.DisplayName = *req.DisplayName
|
|
}
|
|
if req.Email != nil {
|
|
email := strings.ToLower(strings.TrimSpace(*req.Email))
|
|
// Check uniqueness (normalized to lowercase)
|
|
count, err := dao.SysAdminUsers.Ctx(ctx).
|
|
Where("email", email).Where("id != ?", req.Id).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
|
}
|
|
data.Email = email
|
|
}
|
|
if req.Role != nil {
|
|
if err := common.ValidateAdminRole(*req.Role); err != nil {
|
|
return nil, common.NewBadRequestError("角色无效")
|
|
}
|
|
if err := assertCanGrantSuperAdmin(ctx, *req.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
data.Role = *req.Role
|
|
}
|
|
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(data)
|
|
if err != nil {
|
|
if common.IsDuplicateKeyError(err) {
|
|
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// DeleteUser deletes an admin user.
|
|
func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v1.AdminUserDeleteRes, error) {
|
|
currentUserID := common.GetCtxUserID(ctx)
|
|
|
|
if req.Id == currentUserID {
|
|
return nil, common.NewBadRequestError("不能删除当前登录的用户")
|
|
}
|
|
|
|
// Check if target is super_admin
|
|
var user *struct {
|
|
Role string `json:"role"`
|
|
}
|
|
err := dao.SysAdminUsers.Ctx(ctx).
|
|
Where("id", req.Id).Scan(&user)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, common.NewNotFoundError("管理员")
|
|
}
|
|
if user.Role == "super_admin" {
|
|
return nil, common.NewBadRequestError("不能删除超级管理员")
|
|
}
|
|
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Delete user first, then revoke sessions to avoid leaving the user
|
|
// in a state where sessions are gone but the user still exists.
|
|
//
|
|
// 权限相关记录同事务清理:这些表都没有外键,数据库不会代劳,
|
|
// 漏删会留下悬空的角色关联与特批权限。
|
|
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
if _, err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := dao.SysAdminRolePerms.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil {
|
|
return err
|
|
}
|
|
_, err := dao.SysAdminDataScopes.Ctx(ctx).Where("admin_user_id", req.Id).Delete()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
InvalidateUserPermCache(ctx, req.Id)
|
|
|
|
// Revoke all sessions
|
|
common.RevokeAllSessions(ctx, "admin", req.Id)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// UpdateUserStatus enables or disables an admin user.
|
|
func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (*v1.AdminUserUpdateStatusRes, error) {
|
|
currentUserID := common.GetCtxUserID(ctx)
|
|
|
|
if req.Id == currentUserID {
|
|
return nil, common.NewBadRequestError("不能修改当前登录用户的状态")
|
|
}
|
|
|
|
if req.Status != "active" && req.Status != "disabled" {
|
|
return nil, common.NewBadRequestError("状态值无效")
|
|
}
|
|
|
|
// Check if target is super_admin
|
|
var user *struct {
|
|
Role string `json:"role"`
|
|
}
|
|
err := dao.SysAdminUsers.Ctx(ctx).
|
|
Where("id", req.Id).Scan(&user)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, common.NewNotFoundError("管理员")
|
|
}
|
|
if user.Role == "super_admin" {
|
|
return nil, common.NewBadRequestError("不能修改超级管理员状态")
|
|
}
|
|
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{
|
|
Status: req.Status,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If disabling, revoke all sessions
|
|
if req.Status == "disabled" {
|
|
common.RevokeAllSessions(ctx, "admin", req.Id)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。
|
|
//
|
|
// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解
|
|
// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。
|
|
func (s *sAdmin) UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) {
|
|
var user *struct {
|
|
Id int64 `json:"id"`
|
|
Role string `json:"role"`
|
|
}
|
|
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("id, role").Scan(&user)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, common.NewNotFoundError("管理员")
|
|
}
|
|
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Data(map[string]interface{}{
|
|
"failed_attempts": 0,
|
|
"locked_until": nil,
|
|
}).Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ResetUserPassword resets an admin user's password.
|
|
func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error) {
|
|
if err := common.ValidatePassword(req.NewPassword); err != nil {
|
|
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
|
|
}
|
|
|
|
// 改密等于账号接管:此前不校验目标是谁,拿到 user:edit 就能重置超级管理员的密码。
|
|
var targetUser *struct {
|
|
Role string `json:"role"`
|
|
}
|
|
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("role").Scan(&targetUser)
|
|
if err = common.IgnoreScanNoRows(err); err != nil {
|
|
return nil, err
|
|
}
|
|
if targetUser == nil {
|
|
return nil, common.NewNotFoundError("管理员")
|
|
}
|
|
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
passwordHash, err := crypto.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{
|
|
PasswordHash: passwordHash,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Force re-login
|
|
common.RevokeAllSessions(ctx, "admin", req.Id)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// buildUserFilters builds the WHERE conditions for admin user queries.
|
|
func buildUserFilters(m *gdb.Model, keyword, role, status string) *gdb.Model {
|
|
if keyword != "" {
|
|
kw := "%" + strings.TrimSpace(keyword) + "%"
|
|
m = m.Where("username LIKE ? OR email LIKE ?", kw, kw)
|
|
}
|
|
if role != "" {
|
|
m = m.Where("role", role)
|
|
}
|
|
if status != "" {
|
|
m = m.Where("status", status)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ExportUsers exports admin users to CSV or Excel.
|
|
func (s *sAdmin) ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*v1.AdminUserExportRes, error) {
|
|
config := export.Config{
|
|
Format: req.Format,
|
|
Filename: "管理员_" + gtime.Now().Format("Ymd_His"),
|
|
Columns: []export.Column{
|
|
{Field: "id", Header: "ID"},
|
|
{Field: "username", Header: "用户名"},
|
|
{Field: "email", Header: "邮箱"},
|
|
{Field: "display_name", Header: "显示名称"},
|
|
{Field: "role", Header: "角色"},
|
|
{Field: "status", Header: "状态"},
|
|
{Field: "last_login_at", Header: "最后登录时间"},
|
|
{Field: "last_login_ip", Header: "最后登录IP"},
|
|
{Field: "created_at", Header: "创建时间"},
|
|
},
|
|
}
|
|
|
|
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
|
|
offset := 0
|
|
for {
|
|
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
|
|
var batch []struct {
|
|
Id int64 `json:"id" orm:"id"`
|
|
Username string `json:"username" orm:"username"`
|
|
Email string `json:"email" orm:"email"`
|
|
DisplayName string `json:"display_name" orm:"display_name"`
|
|
Role string `json:"role" orm:"role"`
|
|
Status string `json:"status" orm:"status"`
|
|
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
|
|
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
|
|
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
|
}
|
|
if err := m.OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
|
return
|
|
}
|
|
for _, u := range batch {
|
|
if !yield(map[string]any{
|
|
"id": u.Id,
|
|
"username": u.Username,
|
|
"email": u.Email,
|
|
"display_name": u.DisplayName,
|
|
"role": u.Role,
|
|
"status": u.Status,
|
|
"last_login_at": u.LastLoginAt.String(),
|
|
"last_login_ip": u.LastLoginIp,
|
|
"created_at": u.CreatedAt.String(),
|
|
}) {
|
|
return
|
|
}
|
|
}
|
|
if len(batch) < 1000 {
|
|
break
|
|
}
|
|
offset += 1000
|
|
}
|
|
})
|
|
}
|