优化平台端租户端

This commit is contained in:
2026-09-09 00:30:44 +08:00
parent 672c22d9b6
commit 34b2449462
20 changed files with 847 additions and 42 deletions
+24
View File
@@ -22,6 +22,7 @@ type backendUserInfoDTO struct {
ID uint64 `json:"id"`
Tid uint64 `json:"tid"`
Uid uint64 `json:"uid"`
GroupID uint64 `json:"group_id"`
Account *string `json:"account"`
Name *string `json:"name"`
Phone *string `json:"phone"`
@@ -40,6 +41,7 @@ type backendUserInfoDTO struct {
type backendTenantUserPayload struct {
Tid uint64 `json:"tid"`
Uid uint64 `json:"uid"`
GroupID *uint64 `json:"group_id"`
Account *string `json:"account"`
Name *string `json:"name"`
Phone *string `json:"phone"`
@@ -95,6 +97,7 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
ID: u.ID,
Tid: u.Tid,
Uid: u.Uid,
GroupID: u.GroupID,
Account: u.Account,
Name: u.Name,
Phone: u.Phone,
@@ -395,6 +398,23 @@ func (c *BackendAdminUserController) AddUser() {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
}
// 关联角色(group_id):0 表示未分配,前端展示为"未分配/全权限"
if p.GroupID != nil && *p.GroupID > 0 {
models.EnsureTenantUserGroupColumn()
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"group_id": *p.GroupID})
} else if isDefault == 1 {
// 租户第一个账号(普通租户)即租户管理员:自动绑定「租户管理员」角色,
// 使"普通租户"与"租户管理员"合二为一(均可登录 backend 并拥有全权限)。
if adminRoleID, err := models.GetTenantAdminRole(p.Tid); err == nil && adminRoleID > 0 {
models.EnsureTenantUserGroupColumn()
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"group_id": adminRoleID})
}
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
@@ -493,6 +513,10 @@ func (c *BackendAdminUserController) EditUser() {
update["remark"] = *p.Remark
}
if p.GroupID != nil {
update["group_id"] = *p.GroupID
}
if len(update) == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
_ = c.ServeJSON()
+99 -1
View File
@@ -77,6 +77,9 @@ func (c *BackendMenuController) GetMenu() {
}
func (c *BackendMenuController) GetBackendMenu() {
// 确保租户用户 group_id 列存在(老库兼容)
models.EnsureTenantUserGroupColumn()
var menus []models.SystemMenu
_, err := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&menus)
if err != nil {
@@ -84,10 +87,105 @@ func (c *BackendMenuController) GetBackendMenu() {
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(filterMenusByView(menus, 2), 0)}
backendMenus := filterMenusByView(menus, 2)
// 按当前登录用户角色过滤可见功能菜单:
// 角色 rights 为空(或未分配角色)→ 全权限,返回全部后端菜单;
// 否则仅返回角色允许访问的菜单(含其祖先节点,保证树结构完整)。
idStr := c.Ctx.Input.Param(":id")
uid, _ := strconv.ParseUint(idStr, 10, 64)
if uid > 0 {
var tu models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", uid).
Filter("delete_time__isnull", true).
One(&tu); e == nil && tu.GroupID > 0 {
var role models.AdminRole
if re := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", tu.GroupID).
Filter("cid", 2).
One(&role); re == nil {
backendMenus = filterMenusByRights(backendMenus, role.Rights)
}
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)}
_ = c.ServeJSON()
}
// parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)
func parseRightsToSet(raw string) map[uint64]bool {
set := map[uint64]bool{}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
for _, s := range arr {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if n, e := strconv.ParseUint(s, 10, 64); e == nil {
set[n] = true
}
}
return set
}
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if n, e := strconv.ParseUint(s, 10, 64); e == nil {
set[n] = true
}
}
return set
}
// filterMenusByRights 按角色 rights(菜单 ID 集合)过滤菜单,并补全祖先节点,保证树结构完整。
// rights 为空(nil / 空串)→ 原样返回(全权限)。
func filterMenusByRights(menus []models.SystemMenu, rights *string) []models.SystemMenu {
if rights == nil || strings.TrimSpace(*rights) == "" {
return menus
}
allowed := parseRightsToSet(*rights)
if len(allowed) == 0 {
return menus
}
pidOf := make(map[uint64]int64, len(menus))
byID := make(map[uint64]models.SystemMenu, len(menus))
for _, m := range menus {
pidOf[m.ID] = m.Pid
byID[m.ID] = m
}
final := make(map[uint64]bool, len(allowed))
for id := range allowed {
cur := id
for {
if _, ok := byID[cur]; !ok {
break
}
if final[cur] {
break
}
final[cur] = true
p := pidOf[cur]
if p == 0 {
break
}
cur = uint64(p)
}
}
out := make([]models.SystemMenu, 0, len(final))
for id := range final {
out = append(out, byID[id])
}
return out
}
func (c *BackendMenuController) GetTenantList() {
var tid uint64
if jwtTid := c.Ctx.Input.GetData("tid"); jwtTid != nil {
+249
View File
@@ -0,0 +1,249 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// BackendRoleController 租户端(backend)角色管理(yz_system_admin_role, cid=2)
// 角色按租户隔离:所有读写均以当前登录租户(JWT 中的 tenant_id)为边界,
// 租户仅能查看/管理自己名下的角色,无法越权访问其他租户数据。
type BackendRoleController struct {
beego.Controller
}
type backendRolePayload struct {
Name string `json:"name"`
Status *uint8 `json:"status"`
Rights interface{} `json:"rights"`
}
// currentTenantID 从 Bearer Token 解析当前登录租户 ID。
// 返回 (tid, true) 表示成功;失败时已写入 401/403 响应并返回 (0, false)。
func (c *BackendRoleController) currentTenantID() (uint64, bool) {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录"}
_ = c.ServeJSON()
return 0, false
}
authParts := strings.SplitN(authHeader, " ", 2)
if len(authParts) != 2 || authParts[0] != "Bearer" {
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "认证信息格式错误"}
_ = c.ServeJSON()
return 0, false
}
claims, err := jwtutil.ParseToken(authParts[1])
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "无效的token"}
_ = c.ServeJSON()
return 0, false
}
if claims.UserType != "backend" && claims.UserType != "app" {
c.Data["json"] = map[string]interface{}{"code": 403, "msg": "无权访问"}
_ = c.ServeJSON()
return 0, false
}
if claims.TenantId <= 0 {
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "租户信息缺失"}
_ = c.ServeJSON()
return 0, false
}
return uint64(claims.TenantId), true
}
// GetAllRoles 获取当前租户的角色列表
// GET /backend/allRoles
func (c *BackendRoleController) GetAllRoles() {
tid, ok := c.currentTenantID()
if !ok {
return
}
// 懒加载补齐当前租户的默认角色(租户管理员/部门负责人/员工)
models.EnsureDefaultTenantRoles(tid)
var rows []models.AdminRole
_, err := models.Orm.QueryTable(new(models.AdminRole)).
Filter("cid", 2).
Filter("tenant_id", tid).
OrderBy("-id").
All(&rows)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows}
_ = c.ServeJSON()
}
// GetRoleByID 获取当前租户的角色详情
// GET /backend/roles/:id
func (c *BackendRoleController) GetRoleByID() {
tid, ok := c.currentTenantID()
if !ok {
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"}
_ = c.ServeJSON()
return
}
var role models.AdminRole
if err := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", id).
Filter("cid", 2).
Filter("tenant_id", tid).
One(&role); err != nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "角色不存在"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": role}
_ = c.ServeJSON()
}
// CreateRole 创建当前租户的角色
// POST /backend/roles
func (c *BackendRoleController) CreateRole() {
tid, ok := c.currentTenantID()
if !ok {
return
}
var p backendRolePayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
_ = c.ServeJSON()
return
}
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "name 不能为空"}
_ = c.ServeJSON()
return
}
status := uint8(1)
if p.Status != nil {
status = *p.Status
}
rights := normalizeRights(p.Rights)
role := &models.AdminRole{
TenantID: tid,
Cid: 2,
Name: p.Name,
Status: status,
Rights: rights,
}
id, err := models.Orm.Insert(role)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
_ = c.ServeJSON()
}
// UpdateRole 更新当前租户的角色
// PUT /backend/roles/:id
func (c *BackendRoleController) UpdateRole() {
tid, ok := c.currentTenantID()
if !ok {
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"}
_ = c.ServeJSON()
return
}
var p backendRolePayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
_ = c.ServeJSON()
return
}
update := map[string]interface{}{}
if strings.TrimSpace(p.Name) != "" {
update["name"] = strings.TrimSpace(p.Name)
}
if p.Status != nil {
update["status"] = *p.Status
}
if p.Rights != nil {
update["rights"] = normalizeRights(p.Rights)
}
if len(update) == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
_ = c.ServeJSON()
return
}
// 仅允许更新本租户名下的角色,防止越权改到其他租户
cnt, err := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", id).
Filter("cid", 2).
Filter("tenant_id", tid).
Update(update)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败"}
_ = c.ServeJSON()
return
}
if cnt == 0 {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "角色不存在"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
// DeleteRole 删除当前租户的角色
// DELETE /backend/roles/:id
func (c *BackendRoleController) DeleteRole() {
tid, ok := c.currentTenantID()
if !ok {
return
}
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "id 不能为空"}
_ = c.ServeJSON()
return
}
// 仅允许删除本租户名下的角色
cnt, err := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", id).
Filter("cid", 2).
Filter("tenant_id", tid).
Delete()
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败"}
_ = c.ServeJSON()
return
}
if cnt == 0 {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "角色不存在"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
+8
View File
@@ -202,6 +202,14 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
}
if isDefault == 1 {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
// 租户第一个账号(普通租户)即租户管理员:自动绑定「租户管理员」角色,
// 使"普通租户"与"租户管理员"合二为一(均可登录 backend 并拥有全权限)。
if adminRoleID, err := models.GetTenantAdminRole(p.Tid); err == nil && adminRoleID > 0 {
models.EnsureTenantUserGroupColumn()
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"group_id": adminRoleID})
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
+2 -1
View File
@@ -5,7 +5,8 @@ import "time"
// AdminRole 平台角色表 yz_system_admin_role
type AdminRole struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Cid uint8 `orm:"column(cid);default(1)" json:"cid"` // 1平台角色 2租户角色
TenantID uint64 `orm:"column(tenant_id);default(0)" json:"tenant_id"` // 租户ID:0=平台/全局,>0=具体租户(仅 cid=2 租户角色使用)
Cid uint8 `orm:"column(cid);default(1)" json:"cid"` // 1平台角色 2租户角色
Name string `orm:"column(name);size(32)" json:"name"`
Status uint8 `orm:"column(status);default(1)" json:"status"`
Rights *string `orm:"column(rights);type(text);null" json:"rights"`
+80
View File
@@ -0,0 +1,80 @@
package models
import (
"sync"
"github.com/beego/beego/v2/client/orm"
)
var adminRoleTenantColOnce sync.Once
// EnsureAdminRoleTenantColumn 为角色表补齐 tenant_id 列(历史库可能早于租户隔离特性建表)。
// 字段已存在时 MySQL 报 duplicate column,统一忽略,不阻断主流程。
func EnsureAdminRoleTenantColumn() {
if Orm == nil {
return
}
adminRoleTenantColOnce.Do(func() {
_, _ = Orm.Raw(`
ALTER TABLE yz_system_admin_role
ADD COLUMN tenant_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '租户ID 0-平台/全局 >0-具体租户'
`).Exec()
})
}
// EnsureDefaultTenantRoles 保证指定租户(cid=2)的默认角色齐全:
// - 租户管理员:全权限(rights 为空,见 permission.go 空权限即放行)
// - 部门负责人
// - 员工
//
// 默认角色按租户隔离:每个租户首次访问角色列表时补建自己的一套,互不影响。
// 缺失的自动插入;已存在(同名同 cid 同租户)的不动。任何失败均静默忽略不阻断主流程。
func EnsureDefaultTenantRoles(tid uint64) {
if Orm == nil || tid == 0 {
return
}
// 确保 tenant_id 列存在(兼容老表)
EnsureAdminRoleTenantColumn()
defaults := []string{"租户管理员", "部门负责人", "员工"}
for _, name := range defaults {
cnt, err := Orm.QueryTable(new(AdminRole)).
Filter("cid", 2).
Filter("tenant_id", tid).
Filter("name", name).
Count()
if err != nil {
continue
}
if cnt > 0 {
continue
}
role := &AdminRole{
TenantID: tid,
Cid: 2,
Name: name,
Status: 1,
Rights: nil, // 空权限 = 全权限(permission.go)
}
_, _ = Orm.Insert(role)
}
}
// GetTenantAdminRole 返回指定租户(cid=2)的「租户管理员」角色 ID。
// 角色不存在时先调用 EnsureDefaultTenantRoles 补建再查;任何失败返回 (0, err)。
// 普通租户(租户第一个账号)与租户管理员实为同一概念,均绑定此角色以获得 backend 全权限。
func GetTenantAdminRole(tid uint64) (uint64, error) {
if Orm == nil || tid == 0 {
return 0, orm.ErrNoRows
}
EnsureDefaultTenantRoles(tid)
var role AdminRole
if err := Orm.QueryTable(new(AdminRole)).
Filter("cid", 2).
Filter("tenant_id", tid).
Filter("name", "租户管理员").
One(&role); err != nil {
return 0, err
}
return role.ID, nil
}
+21 -1
View File
@@ -1,11 +1,15 @@
package models
import "time"
import (
"sync"
"time"
)
type SystemTenantUser struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
Uid uint64 `orm:"column(uid)" json:"uid"`
GroupID uint64 `orm:"column(group_id);default(0)" json:"group_id"` // 角色ID:关联 yz_system_admin_role(id, cid=2);0=未分配(视为全权限)
Account *string `orm:"column(account);size(64);null" json:"account"`
Name *string `orm:"column(name);size(64);null" json:"name"`
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
@@ -24,3 +28,19 @@ type SystemTenantUser struct {
func (m *SystemTenantUser) TableName() string {
return "yz_system_tenant_user"
}
var tenantUserGroupColOnce sync.Once
// EnsureTenantUserGroupColumn 为租户用户表补齐 group_id(角色)列。
// 历史库可能早于"用户-角色"特性建表,字段已存在时 MySQL 报 duplicate column,统一忽略。
func EnsureTenantUserGroupColumn() {
if Orm == nil {
return
}
tenantUserGroupColOnce.Do(func() {
_, _ = Orm.Raw(`
ALTER TABLE yz_system_tenant_user
ADD COLUMN group_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '角色ID 关联 yz_system_admin_role(cid=2)'
`).Exec()
})
}
+12
View File
@@ -10,6 +10,17 @@ import (
// 该端不包含平台菜单配置接口。
func Register() {
RegisterAuthRoutes()
RegisterRoleRoutes()
}
// RegisterRoleRoutes 注册 backend 租户角色管理路由。
func RegisterRoleRoutes() {
// 租户角色管理(yz_system_admin_role, cid=2)
beego.Router("/backend/allRoles", &controllers.BackendRoleController{}, "get:GetAllRoles")
beego.Router("/backend/roles/:id", &controllers.BackendRoleController{}, "get:GetRoleByID")
beego.Router("/backend/roles", &controllers.BackendRoleController{}, "post:CreateRole")
beego.Router("/backend/roles/:id", &controllers.BackendRoleController{}, "put:UpdateRole")
beego.Router("/backend/roles/:id", &controllers.BackendRoleController{}, "delete:DeleteRole")
}
// RegisterAuthRoutes 注册 backend 认证相关路由。
@@ -33,6 +44,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/getCurrentUser", &controllers.BackendAuthController{}, "get:GetCurrentUser")
// 菜单接口
beego.Router("/backend/allmenu", &controllers.BackendMenuController{}, "get:GetAllMenus")
beego.Router("/backend/menu/:id", &controllers.BackendMenuController{}, "get:GetBackendMenu")
// 前端菜单接口
beego.Router("/backend/frontmenus", &controllers.BackendMenuFrontController{}, "get:List;post:Create")
+4
View File
@@ -8,6 +8,10 @@ import (
// BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值)
func BindTenantUser(tid, uid uint64, account, name, phone, email *string, sex *uint8, birth *string, password *string, isDefault, status int8, remark *string) (uint64, error) {
// 确保 group_id(角色)列存在,避免老表插入时报 Unknown column。
// INSERT 会带上该列,列不存在会直接失败,故必须在写入前补齐。
models.EnsureTenantUserGroupColumn()
var existed models.SystemTenantUser
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).