feat: 适配管理端系统 API 与管理员保护 (#67)

This commit is contained in:
ila
2026-08-26 22:01:05 +08:00
parent 82537a15d2
commit 3898aae32e
12 changed files with 226 additions and 101 deletions
+18 -3
View File
@@ -1,13 +1,14 @@
package apis
import (
"errors"
"fmt"
"git.ilapage.cn/OPC/chorus/admin/common/global"
"net/http"
"git.ilapage.cn/OPC/chorus/admin/app/admin/models"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk"
"git.ilapage.cn/OPC/chorus/admin/app/admin/models"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
@@ -168,6 +169,11 @@ func (e SysRole) Update(c *gin.Context) {
err = s.Update(&req, cb)
if err != nil {
e.Logger.Error(err)
if errors.Is(err, service.ErrBuiltInRoleProtected) {
e.Error(http.StatusConflict, err, err.Error())
} else {
e.Error(http.StatusInternalServerError, errors.New("角色更新失败"), "角色更新失败")
}
return
}
@@ -207,7 +213,11 @@ func (e SysRole) Delete(c *gin.Context) {
err = s.Remove(&req, cb)
if err != nil {
e.Logger.Error(err)
e.Error(500, err, "")
if errors.Is(err, service.ErrBuiltInRoleProtected) {
e.Error(http.StatusConflict, err, err.Error())
} else {
e.Error(http.StatusInternalServerError, errors.New("角色删除失败"), "角色删除失败")
}
return
}
@@ -240,7 +250,12 @@ func (e SysRole) Update2Status(c *gin.Context) {
req.SetUpdateBy(user.GetUserId(c))
err = s.UpdateStatus(&req)
if err != nil {
e.Error(500, err, fmt.Sprintf("更新角色状态失败,失败原因:%s ", err.Error()))
if errors.Is(err, service.ErrBuiltInRoleProtected) {
e.Error(http.StatusConflict, err, err.Error())
} else {
e.Logger.Error(err)
e.Error(http.StatusInternalServerError, errors.New("角色状态更新失败"), "角色状态更新失败")
}
return
}
e.OK(req.GetId(), fmt.Sprintf("更新角色 %v 状态成功!", req.GetId()))
+15 -7
View File
@@ -1,8 +1,9 @@
package apis
import (
"github.com/gin-gonic/gin/binding"
"errors"
"git.ilapage.cn/OPC/chorus/admin/app/admin/models"
"github.com/gin-gonic/gin/binding"
"golang.org/x/crypto/bcrypt"
"net/http"
@@ -190,9 +191,14 @@ func (e SysUser) Delete(c *gin.Context) {
// 数据权限检查
p := actions.GetPermissionFromContext(c)
err = s.Remove(&req, p)
err = s.Remove(&req, p, user.GetUserId(c))
if err != nil {
e.Logger.Error(err)
if errors.Is(err, service.ErrCurrentAdminProtected) || errors.Is(err, service.ErrLastAdminProtected) {
e.Error(http.StatusConflict, err, err.Error())
} else {
e.Error(http.StatusInternalServerError, errors.New("管理员删除失败"), "管理员删除失败")
}
return
}
e.OK(req.GetId(), "删除成功")
@@ -275,9 +281,14 @@ func (e SysUser) UpdateStatus(c *gin.Context) {
//数据权限检查
p := actions.GetPermissionFromContext(c)
err = s.UpdateStatus(&req, p)
err = s.UpdateStatus(&req, p, user.GetUserId(c))
if err != nil {
e.Logger.Error(err)
if errors.Is(err, service.ErrCurrentAdminProtected) || errors.Is(err, service.ErrLastAdminProtected) {
e.Error(http.StatusConflict, err, err.Error())
} else {
e.Error(http.StatusInternalServerError, errors.New("管理员状态更新失败"), "管理员状态更新失败")
}
return
}
e.OK(req.GetId(), "更新成功")
@@ -385,8 +396,7 @@ func (e SysUser) GetProfile(c *gin.Context) {
sysUser := models.SysUser{}
roles := make([]models.SysRole, 0)
posts := make([]models.SysPost, 0)
err = s.GetProfile(&req, &sysUser, &roles, &posts)
err = s.GetProfile(&req, &sysUser, &roles)
if err != nil {
e.Logger.Errorf("get user profile error, %s", err.Error())
e.Error(500, err, "获取用户信息失败")
@@ -395,7 +405,6 @@ func (e SysUser) GetProfile(c *gin.Context) {
e.OK(gin.H{
"user": sysUser,
"roles": roles,
"posts": posts,
}, "查询成功")
}
@@ -452,7 +461,6 @@ func (e SysUser) GetInfo(c *gin.Context) {
}
mp["userName"] = sysUser.Username
mp["userId"] = sysUser.UserId
mp["deptId"] = sysUser.DeptId
mp["name"] = sysUser.NickName
mp["code"] = 200
e.OK(mp, "")
+8 -8
View File
@@ -11,20 +11,20 @@ type SysUser struct {
Username string `json:"username" gorm:"size:64;comment:用户名"`
Password string `json:"-" gorm:"size:128;comment:密码"`
NickName string `json:"nickName" gorm:"size:128;comment:昵称"`
Phone string `json:"phone" gorm:"size:11;comment:手机号"`
Phone string `json:"-" gorm:"size:11;comment:手机号"`
RoleId int `json:"roleId" gorm:"size:20;comment:角色ID"`
Salt string `json:"-" gorm:"size:255;comment:加盐"`
Avatar string `json:"avatar" gorm:"size:255;comment:头像"`
Sex string `json:"sex" gorm:"size:255;comment:性别"`
Email string `json:"email" gorm:"size:128;comment:邮箱"`
DeptId int `json:"deptId" gorm:"size:20;comment:部门"`
PostId int `json:"postId" gorm:"size:20;comment:岗位"`
Sex string `json:"-" gorm:"size:255;comment:性别"`
Email string `json:"-" gorm:"size:128;comment:邮箱"`
DeptId int `json:"-" gorm:"size:20;comment:部门"`
PostId int `json:"-" gorm:"size:20;comment:岗位"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
Status string `json:"status" gorm:"size:4;comment:状态"`
DeptIds []int `json:"deptIds" gorm:"-"`
PostIds []int `json:"postIds" gorm:"-"`
DeptIds []int `json:"-" gorm:"-"`
PostIds []int `json:"-" gorm:"-"`
RoleIds []int `json:"roleIds" gorm:"-"`
Dept *SysDept `json:"dept"`
Dept *SysDept `json:"-"`
models.ControlBy
models.ModelTime
}
-1
View File
@@ -19,6 +19,5 @@ func registerSysApiRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
{
r.GET("", api.GetPage)
r.GET("/:id", api.Get)
r.PUT("/:id", api.Update)
}
}
-1
View File
@@ -19,6 +19,5 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
{
r.GET("", api.GetPage)
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
-3
View File
@@ -19,9 +19,6 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
{
r.GET("", api.GetPage)
r.GET("/:id", api.Get)
r.POST("", api.Insert)
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
-1
View File
@@ -26,6 +26,5 @@ func registerSysRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
{
r1.PUT("/role-status", api.Update2Status)
r1.PUT("/roledatascope", api.Update2DataScope)
}
}
@@ -24,6 +24,15 @@ func TestSystemRouterExposesLoginWithoutCaptcha(t *testing.T) {
t.Fatalf("system route is not registered: %s", path)
}
}
for _, path := range []string{
"POST /api/v1/menu", "PUT /api/v1/menu/:id", "DELETE /api/v1/menu",
"PUT /api/v1/sys-api/:id", "DELETE /api/v1/sys-login-log",
"PUT /api/v1/roledatascope",
} {
if _, ok := routes[path]; ok {
t.Fatalf("read-only system route must not be registered: %s", path)
}
}
for _, path := range []string{"GET /api/v1/captcha", "GET /api/v1/getCaptcha"} {
if _, ok := routes[path]; ok {
t.Fatalf("captcha route must not be registered: %s", path)
@@ -0,0 +1,64 @@
package service
import (
"errors"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/OPC/chorus/admin/app/admin/models"
)
func TestProtectAdminRemovalRejectsCurrentAndLastActiveAdmin(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysUser{}); err != nil {
t.Fatal(err)
}
admin := models.SysUser{UserId: 1, Username: "admin", Status: "2"}
if err := db.Create(&admin).Error; err != nil {
t.Fatal(err)
}
if err := protectAdminRemoval(db, []models.SysUser{admin}, 1); !errors.Is(err, ErrCurrentAdminProtected) {
t.Fatalf("current admin error = %v", err)
}
if err := protectAdminRemoval(db, []models.SysUser{admin}, 99); !errors.Is(err, ErrLastAdminProtected) {
t.Fatalf("last active admin error = %v", err)
}
second := models.SysUser{UserId: 2, Username: "operator", Status: "2"}
if err := db.Create(&second).Error; err != nil {
t.Fatal(err)
}
if err := protectAdminRemoval(db, []models.SysUser{second}, 1); err != nil {
t.Fatalf("removing one of two active admins: %v", err)
}
}
func TestProtectBuiltInRole(t *testing.T) {
role := models.SysRole{RoleId: 1, RoleName: "Chorus Operator", RoleKey: BuiltInOperatorRoleKey, Status: "2"}
for name, test := range map[string]struct {
roleName string
roleKey string
status string
op string
}{
"rename display name": {"Other", BuiltInOperatorRoleKey, "2", "update"},
"rename key": {role.RoleName, "other", "2", "update"},
"disable": {role.RoleName, BuiltInOperatorRoleKey, "1", "status"},
"delete": {"", "", "", "delete"},
} {
t.Run(name, func(t *testing.T) {
if err := protectBuiltInRole(role, test.roleName, test.roleKey, test.status, test.op); !errors.Is(err, ErrBuiltInRoleProtected) {
t.Fatalf("error = %v", err)
}
})
}
if err := protectBuiltInRole(role, role.RoleName, role.RoleKey, "2", "update"); err != nil {
t.Fatalf("menu-only update should remain allowed: %v", err)
}
}
-35
View File
@@ -12,13 +12,8 @@ type SysUserGetPageReq struct {
UserId int `form:"userId" search:"type:exact;column:user_id;table:sys_user" comment:"用户ID"`
Username string `form:"username" search:"type:contains;column:username;table:sys_user" comment:"用户名"`
NickName string `form:"nickName" search:"type:contains;column:nick_name;table:sys_user" comment:"昵称"`
Phone string `form:"phone" search:"type:contains;column:phone;table:sys_user" comment:"手机号"`
RoleId string `form:"roleId" search:"type:exact;column:role_id;table:sys_user" comment:"角色ID"`
Sex string `form:"sex" search:"type:exact;column:sex;table:sys_user" comment:"性别"`
Email string `form:"email" search:"type:contains;column:email;table:sys_user" comment:"邮箱"`
PostId string `form:"postId" search:"type:exact;column:post_id;table:sys_user" comment:"岗位"`
Status string `form:"status" search:"type:exact;column:status;table:sys_user" comment:"状态"`
DeptJoin `search:"type:left;on:dept_id:dept_id;table:sys_user;join:sys_dept"`
SysUserOrder
}
@@ -29,10 +24,6 @@ type SysUserOrder struct {
CreatedAtOrder string `search:"type:order;column:created_at;table:sys_user" form:"createdAtOrder"`
}
type DeptJoin struct {
DeptId string `search:"type:contains;column:dept_path;table:sys_dept" form:"deptId"`
}
func (m *SysUserGetPageReq) GetNeedSearch() interface{} {
return *m
}
@@ -93,13 +84,7 @@ type SysUserInsertReq struct {
Username string `json:"username" comment:"用户名" vd:"len($)>0"`
Password string `json:"password" comment:"密码"`
NickName string `json:"nickName" comment:"昵称" vd:"len($)>0"`
Phone string `json:"phone" comment:"手机号" vd:"len($)>0"`
RoleId int `json:"roleId" comment:"角色ID"`
Avatar string `json:"avatar" comment:"头像"`
Sex string `json:"sex" comment:"性别"`
Email string `json:"email" comment:"邮箱" vd:"len($)>0,email"`
DeptId int `json:"deptId" comment:"部门" vd:"$>0"`
PostId int `json:"postId" comment:"岗位"`
Remark string `json:"remark" comment:"备注"`
Status string `json:"status" comment:"状态" vd:"len($)>0" default:"1"`
common.ControlBy
@@ -112,13 +97,7 @@ func (s *SysUserInsertReq) Generate(model *models.SysUser) {
model.Username = s.Username
model.Password = s.Password
model.NickName = s.NickName
model.Phone = s.Phone
model.RoleId = s.RoleId
model.Avatar = s.Avatar
model.Sex = s.Sex
model.Email = s.Email
model.DeptId = s.DeptId
model.PostId = s.PostId
model.Remark = s.Remark
model.Status = s.Status
model.CreateBy = s.CreateBy
@@ -132,15 +111,8 @@ type SysUserUpdateReq struct {
UserId int `json:"userId" comment:"用户ID"` // 用户ID
Username string `json:"username" comment:"用户名" vd:"len($)>0"`
NickName string `json:"nickName" comment:"昵称" vd:"len($)>0"`
Phone string `json:"phone" comment:"手机号" vd:"len($)>0"`
RoleId int `json:"roleId" comment:"角色ID"`
Avatar string `json:"avatar" comment:"头像"`
Sex string `json:"sex" comment:"性别"`
Email string `json:"email" comment:"邮箱" vd:"len($)>0,email"`
DeptId int `json:"deptId" comment:"部门" vd:"$>0"`
PostId int `json:"postId" comment:"岗位"`
Remark string `json:"remark" comment:"备注"`
Status string `json:"status" comment:"状态" default:"1"`
common.ControlBy
}
@@ -150,15 +122,8 @@ func (s *SysUserUpdateReq) Generate(model *models.SysUser) {
}
model.Username = s.Username
model.NickName = s.NickName
model.Phone = s.Phone
model.RoleId = s.RoleId
model.Avatar = s.Avatar
model.Sex = s.Sex
model.Email = s.Email
model.DeptId = s.DeptId
model.PostId = s.PostId
model.Remark = s.Remark
model.Status = s.Status
}
func (s *SysUserUpdateReq) GetId() interface{} {
+47 -3
View File
@@ -20,6 +20,29 @@ type SysRole struct {
service.Service
}
const BuiltInOperatorRoleKey = "chorus_operator"
var ErrBuiltInRoleProtected = errors.New("ADMIN_BUILT_IN_ROLE_PROTECTED: 内置角色不能改名、删除或停用")
func protectBuiltInRole(role models.SysRole, roleName, roleKey, status, operation string) error {
if role.RoleKey != BuiltInOperatorRoleKey {
return nil
}
switch operation {
case "update":
if roleName != role.RoleName || roleKey != role.RoleKey || status != "2" {
return ErrBuiltInRoleProtected
}
case "delete":
return ErrBuiltInRoleProtected
case "status":
if status != "2" {
return ErrBuiltInRoleProtected
}
}
return nil
}
// GetPage 获取SysRole列表
func (e *SysRole) GetPage(c *dto.SysRoleGetPageReq, list *[]models.SysRole, count *int64) error {
var err error
@@ -143,7 +166,12 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
}
var model = models.SysRole{}
var mlist = make([]models.SysMenu, 0)
tx.Preload("SysMenu").First(&model, c.GetId())
if err = tx.Preload("SysMenu").First(&model, c.GetId()).Error; err != nil {
return err
}
if err = protectBuiltInRole(model, c.RoleName, c.RoleKey, c.Status, "update"); err != nil {
return err
}
tx.Preload("SysApi").Where("menu_id in ?", c.MenuIds).Find(&mlist)
err = tx.Model(&model).Association("SysMenu").Delete(model.SysMenu)
if err != nil {
@@ -206,8 +234,19 @@ func (e *SysRole) Remove(c *dto.SysRoleDeleteReq, cb *casbin.SyncedEnforcer) err
}
}()
}
var roles []models.SysRole
if err = tx.Where("role_id IN ?", c.Ids).Find(&roles).Error; err != nil {
return err
}
for _, role := range roles {
if err = protectBuiltInRole(role, "", "", "", "delete"); err != nil {
return err
}
}
var model = models.SysRole{}
tx.Preload("SysMenu").Preload("SysDept").First(&model, c.GetId())
if err = tx.Preload("SysMenu").Preload("SysDept").First(&model, c.GetId()).Error; err != nil {
return err
}
//删除 SysRole 时,同时删除角色所有 关联其它表 记录 (SysMenu 和 SysMenu)
db := tx.Select(clause.Associations).Delete(&model)
@@ -295,7 +334,12 @@ func (e *SysRole) UpdateStatus(c *dto.UpdateStatusReq) error {
}()
}
var model = models.SysRole{}
tx.First(&model, c.GetId())
if err = tx.First(&model, c.GetId()).Error; err != nil {
return err
}
if err = protectBuiltInRole(model, model.RoleName, model.RoleKey, c.Status, "status"); err != nil {
return err
}
c.Generate(&model)
// 更新关联的数据,使用 FullSaveAssociations 模式
db := tx.Session(&gorm.Session{FullSaveAssociations: true}).Debug().Save(&model)
+65 -39
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/OPC/chorus/admin/common/actions"
cDto "git.ilapage.cn/OPC/chorus/admin/common/dto"
@@ -18,12 +19,17 @@ type SysUser struct {
service.Service
}
var (
ErrCurrentAdminProtected = errors.New("ADMIN_CURRENT_ACCOUNT_PROTECTED: 不能停用或删除当前登录管理员")
ErrLastAdminProtected = errors.New("ADMIN_LAST_ACTIVE_ACCOUNT_PROTECTED: 至少保留一个有效管理员")
)
// GetPage 获取SysUser列表
func (e *SysUser) GetPage(c *dto.SysUserGetPageReq, p *actions.DataPermission, list *[]models.SysUser, count *int64) error {
var err error
var data models.SysUser
err = e.Orm.Debug().Preload("Dept").
err = e.Orm.Debug().
Scopes(
cDto.MakeCondition(c.GetNeedSearch()),
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
@@ -136,26 +142,21 @@ func (e *SysUser) UpdateAvatar(c *dto.UpdateSysUserAvatarReq, p *actions.DataPer
}
// UpdateStatus 更新用户状态
func (e *SysUser) UpdateStatus(c *dto.UpdateSysUserStatusReq, p *actions.DataPermission) error {
var err error
var model models.SysUser
db := e.Orm.Scopes(
actions.Permission(model.TableName(), p),
).First(&model, c.GetId())
if err = db.Error; err != nil {
e.Log.Errorf("Service UpdateSysUser error: %s", err)
return err
}
if db.RowsAffected == 0 {
return errors.New("无权更新该数据")
}
err = e.Orm.Table(model.TableName()).Where("user_id =? ", c.UserId).Updates(c).Error
if err != nil {
e.Log.Errorf("Service UpdateSysUser error: %s", err)
return err
}
return nil
func (e *SysUser) UpdateStatus(c *dto.UpdateSysUserStatusReq, p *actions.DataPermission, currentUserID int) error {
return e.Orm.Transaction(func(tx *gorm.DB) error {
var model models.SysUser
if err := tx.Scopes(actions.Permission(model.TableName(), p)).First(&model, c.GetId()).Error; err != nil {
return err
}
if c.Status != "2" {
if err := protectAdminRemoval(tx, []models.SysUser{model}, currentUserID); err != nil {
return err
}
}
return tx.Model(&model).Select("status", "update_by").Updates(map[string]interface{}{
"status": c.Status, "update_by": c.UpdateBy,
}).Error
})
}
// ResetPwd 重置用户密码
@@ -182,20 +183,50 @@ func (e *SysUser) ResetPwd(c *dto.ResetSysUserPwdReq, p *actions.DataPermission)
}
// Remove 删除SysUser
func (e *SysUser) Remove(c *dto.SysUserById, p *actions.DataPermission) error {
var err error
var data models.SysUser
func (e *SysUser) Remove(c *dto.SysUserById, p *actions.DataPermission, currentUserID int) error {
return e.Orm.Transaction(func(tx *gorm.DB) error {
var targets []models.SysUser
if err := tx.Scopes(actions.Permission((&models.SysUser{}).TableName(), p)).
Where("user_id IN ?", normalizedUserIDs(c)).Find(&targets).Error; err != nil {
return err
}
if len(targets) == 0 {
return errors.New("无权删除该数据")
}
if err := protectAdminRemoval(tx, targets, currentUserID); err != nil {
return err
}
return tx.Delete(&models.SysUser{}, normalizedUserIDs(c)).Error
})
}
db := e.Orm.Model(&data).
Scopes(
actions.Permission(data.TableName(), p),
).Delete(&data, c.GetId())
if err = db.Error; err != nil {
e.Log.Errorf("Error found in RemoveSysUser : %s", err)
func normalizedUserIDs(c *dto.SysUserById) []int {
ids := append([]int(nil), c.Ids...)
if c.Id > 0 {
ids = append(ids, c.Id)
}
return ids
}
func protectAdminRemoval(tx *gorm.DB, targets []models.SysUser, currentUserID int) error {
activeTargets := 0
for _, target := range targets {
if target.UserId == currentUserID {
return ErrCurrentAdminProtected
}
if target.Status == "2" {
activeTargets++
}
}
if activeTargets == 0 {
return nil
}
var active []models.SysUser
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ?", "2").Find(&active).Error; err != nil {
return err
}
if db.RowsAffected == 0 {
return errors.New("无权删除该数据")
if len(active)-activeTargets < 1 {
return ErrLastAdminProtected
}
return nil
}
@@ -248,8 +279,8 @@ func (e *SysUser) UpdatePwd(id int, oldPassword, newPassword string, p *actions.
return nil
}
func (e *SysUser) GetProfile(c *dto.SysUserById, user *models.SysUser, roles *[]models.SysRole, posts *[]models.SysPost) error {
err := e.Orm.Preload("Dept").First(user, c.GetId()).Error
func (e *SysUser) GetProfile(c *dto.SysUserById, user *models.SysUser, roles *[]models.SysRole) error {
err := e.Orm.First(user, c.GetId()).Error
if err != nil {
return err
}
@@ -257,10 +288,5 @@ func (e *SysUser) GetProfile(c *dto.SysUserById, user *models.SysUser, roles *[]
if err != nil {
return err
}
err = e.Orm.Find(posts, user.PostIds).Error
if err != nil {
return err
}
return nil
}