package controllers import ( "encoding/json" "strconv" "strings" "server/models" "server/pkg/identity" "server/services" authsvc "server/services/auth" beego "github.com/beego/beego/v2/server/web" ) type BackendMenuController struct { beego.Controller } type AdminMenuController = BackendMenuController // 菜单接口被平台端和租户端共用同一个控制器(AdminMenuController 是别名), // 所以按「调用者所属端」而不是路由前缀来定权限: // - 写接口(增删改、改状态)只有平台端能用,租户端能改菜单等于给自己开功能; // - 读接口两端都能用,但租户端只能读租户端那一份(cid 强制为 2)。 func (c *BackendMenuController) denyMenu(code int, msg string) { // 必须同时设置 HTTP 状态码:只把 code 写进 body 而返回 200 时, // 前端 axios 拦截器会把这次鉴权失败当成成功响应,既不静默续期也不跳登录, // 菜单等页面只会停在「token已过期」的报错里。 c.Ctx.Output.SetStatus(code) c.Data["json"] = map[string]interface{}{"code": code, "msg": msg} _ = c.ServeJSON() } // requirePlatformCaller 菜单写操作专用:失败时已写好响应,调用方必须立即 return。 func (c *BackendMenuController) requirePlatformCaller() bool { if _, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndPlatform); err != nil { code := identity.Status(err) c.denyMenu(code, err.Error()) return false } return true } // menuCallerEnd 解析菜单读接口的调用端,同时放行平台端与租户端。 // 取不到合法令牌时返回 ok=false,调用方必须立即 return。 func (c *BackendMenuController) menuCallerEnd() (identity.End, bool) { claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndPlatform, identity.EndBackend) if err != nil { code := identity.Status(err) c.denyMenu(code, err.Error()) return "", false } return identity.End(claims.UserType), true } // requireBackendCaller 仅租户端可用的读接口。 func (c *BackendMenuController) requireBackendCaller() bool { end, ok := c.menuCallerEnd() if !ok { return false } if end != identity.EndBackend { c.denyMenu(403, identity.ErrWrongEnd.Error()) return false } return true } type menuPayload struct { Pid *int64 `json:"pid"` Title *string `json:"title"` Path *string `json:"path"` ComponentPath *string `json:"component_path"` Icon *string `json:"icon"` Sort *int64 `json:"sort"` Status *int8 `json:"status"` IsVisible *int8 `json:"is_visible"` RequireLogin *int8 `json:"require_login"` // 1=必须登录(默认) 0=可免登录(游客可用) Views []int `json:"views"` Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=继承所属模块 Type *int8 `json:"type"` Permission *string `json:"permission"` } func parseViews(raw *string) []int { if raw == nil || strings.TrimSpace(*raw) == "" { return nil } var arr []int if err := json.Unmarshal([]byte(*raw), &arr); err != nil { return nil } return arr } func hasView(arr []int, v int) bool { for _, n := range arr { if n == v { return true } } return false } func filterMenusByView(menus []models.SystemMenu, v int) []models.SystemMenu { out := make([]models.SystemMenu, 0, len(menus)) for _, m := range menus { views := parseViews(m.Views) if v == 1 && len(views) == 0 { out = append(out, m) continue } if hasView(views, v) { out = append(out, m) } } return out } // GetMenu 平台端菜单树(views=1),只归平台端。 func (c *BackendMenuController) GetMenu() { if !c.requirePlatformCaller() { return } var menus []models.SystemMenu _, err := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&menus) if err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取菜单失败: " + err.Error(), "data": nil} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(filterMenusByView(menus, 1), 0)} _ = c.ServeJSON() } // GetBackendMenu 租户端当前用户的可见菜单树,只归租户端令牌。 func (c *BackendMenuController) GetBackendMenu() { if !c.requireBackendCaller() { return } var menus []models.SystemMenu _, err := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&menus) if err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取菜单失败: " + err.Error(), "data": nil} _ = c.ServeJSON() return } // 先按 views 过滤租户端,再按展示端过滤出 Web 端可见菜单。 // 菜单未单独配置 platforms 时继承所属模块,因此要传入全部模块。 allModules, _ := services.ListAllModulesForScope() backendMenus := services.FilterMenusByPlatformWithModules(filterMenusByView(menus, 2), allModules, models.PlatformWeb) // 按当前登录用户角色过滤可见功能菜单: // 角色 rights 为空(或未分配角色)→ 全权限,返回全部后端菜单; // 否则仅返回角色允许访问的菜单(含其祖先节点,保证树结构完整)。 idStr := c.Ctx.Input.Param(":id") uid, _ := strconv.ParseUint(idStr, 10, 64) tid := uint64(0) groupID := uint64(0) if uid > 0 { // URL 参数是认证中心 identity_id,不能直接当 yz_system_tenant_user 主键查: // 主键恰好与 identity_id 相同时会套用**别人**的角色权限—— // 如 identity_id=1(李增炎)命中主键 1(李志强 tid=1、group_id=0), // 反而免过滤拿到全量菜单(越权);identity_id=2 则命中李增炎的受限角色, // 菜单被错误过滤(基础设置 404)。 // 解析顺序:认证绑定表(identity_id+tid → group_id,与 /auth/userinfo 同源) // → 业务表身份解析链(identity_id 换算 → 主键 → 老 uid)。 jwtTid := backendJWTTenantID(c.Ctx.Request.Header.Get("Authorization")) if bind, e := authsvc.GetTenantUser(uid, jwtTid); e == nil { tid = bind.Tid groupID = bind.GroupID } else if tu, e := findBackendTenantUser(uid, jwtTid, false); e == nil { tid = tu.Tid groupID = tu.GroupID } } if groupID > 0 { var role models.AdminRole if re := models.Orm.QueryTable(new(models.AdminRole)). Filter("id", groupID). Filter("cid", 2). One(&role); re == nil { backendMenus = filterMenusByRights(backendMenus, role.Rights) } } // 套餐功能开通标记:未开通模块的菜单保留但标记 locked(前端展示锁定态与升级提示,不再 404) backendMenus, lockedMenuIDs := filterMenusByTenantPackageLocked(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid, true) menuTree := buildMenuTree(backendMenus, 0) markTreeLocked(menuTree, lockedMenuIDs) c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": menuTree} _ = c.ServeJSON() } // backendJWTTenantID 从 Authorization 的 Bearer Token 中解析租户ID;取不到时返回 0。 // 说明:backend 端登录时 token 里写入了租户ID(GenerateToken 的 tenantId), // 用它做套餐功能过滤比依赖前端传参更可靠。只认 backend 端令牌—— // 平台令牌本就无租户,app 端令牌的租户也不能拿来决定 Web 端能看到哪些菜单。 func backendJWTTenantID(auth string) uint64 { claims, err := identity.Claims(auth, identity.EndBackend) if err != nil || claims == nil || claims.TenantId <= 0 { return 0 } return uint64(claims.TenantId) } // filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单(剔除未开通模块,用于角色菜单分配等场景)。 // tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。 func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu { filtered, _ := filterMenusByTenantPackageLocked(authHeader, menus, tid, false) return filtered } // filterMenusByTenantPackageLocked 按租户套餐处理菜单。 // markLocked=false:剔除未开通模块的菜单(原行为);markLocked=true:保留菜单并返回锁定菜单ID集合(含后代)。 func filterMenusByTenantPackageLocked(authHeader string, menus []models.SystemMenu, tid uint64, markLocked bool) ([]models.SystemMenu, map[uint64]bool) { if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 { tid = jwtTid } if tid == 0 { return menus, nil } allModules, err := services.ListEnabledModules() if err != nil || len(allModules) == 0 { return menus, nil } allowed := services.GetTenantModuleCodes(tid) // 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤, // 避免因套餐数据缺失导致租户端菜单整体消失。 if len(allowed) == 0 { return menus, nil } if markLocked { return services.MarkMenusLockedByTenantModules(menus, allModules, allowed) } return services.FilterMenusByTenantModules(menus, allModules, allowed), nil } // markTreeLocked 将菜单树中位于锁定集合的节点标记为 locked func markTreeLocked(nodes []*menuNode, locked map[uint64]bool) { if len(nodes) == 0 || len(locked) == 0 { return } for _, n := range nodes { if locked[n.ID] { n.Locked = true } if len(n.Children) > 0 { markTreeLocked(n.Children, locked) } } } // filterMenusByRights 按角色 rights(菜单 ID 集合)过滤菜单,并补全祖先节点,保证树结构完整。 // rights 为空(nil / 空串)→ 原样返回(全权限)。 func filterMenusByRights(menus []models.SystemMenu, rights *string) []models.SystemMenu { if rights == nil || strings.TrimSpace(*rights) == "" { return menus } allowed := services.ParseMenuRights(*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 { tid = jwtTid.(uint64) } if tid == 0 { c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录或非法请求"} _ = c.ServeJSON() return } var menus []models.SystemMenu if _, err := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&menus); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取失败:" + err.Error()} _ = c.ServeJSON() return } tree := buildMenuTree(filterMenusByView(menus, 2), 0) c.Data["json"] = map[string]interface{}{ "code": 200, "msg": "获取成功", "data": map[string]interface{}{"list": tree, "total": len(tree)}, } _ = c.ServeJSON() } // GetAllMenus 两端共用:平台端可查任意端的菜单树,租户端只固定看自己那一份。 // 此前 cid 完全由查询参数决定,租户传 ?cid=1 就能整棵拉走平台端菜单树。 func (c *BackendMenuController) GetAllMenus() { end, ok := c.menuCallerEnd() if !ok { return } var menus []models.SystemMenu cid, _ := c.GetInt("cid") if end != identity.EndPlatform { cid = 2 } if _, err := models.Orm.QueryTable(new(models.SystemMenu)).All(&menus); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取菜单失败: " + err.Error(), "data": nil} _ = c.ServeJSON() return } if cid == 1 { menus = filterMenusByView(menus, 1) } else if cid == 2 { menus = filterMenusByView(menus, 2) } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(menus, 0)} _ = c.ServeJSON() } // filterAssignableMenus 仅保留「已启用且已显示」的菜单。 // 停用或隐藏的菜单不参与角色权限分配,直接在服务端屏蔽,不依赖前端过滤。 func filterAssignableMenus(menus []models.SystemMenu) []models.SystemMenu { out := make([]models.SystemMenu, 0, len(menus)) for _, m := range menus { if m.Status != 1 { continue } // is_visible 为空视为显示 if m.IsVisible != nil && *m.IsVisible == 0 { continue } out = append(out, m) } return out } // GetAssignableMenus 角色权限树(租户端角色编辑器用),只归租户端。 // cid 同样固定为 2:平台端菜单不在租户可分配范围内,传 cid=1 会整棵泄露平台菜单。 // GET /backend/assignableMenus?cid=2 func (c *BackendMenuController) GetAssignableMenus() { if !c.requireBackendCaller() { return } var menus []models.SystemMenu cid := 2 if _, err := models.Orm.QueryTable(new(models.SystemMenu)).All(&menus); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取菜单失败: " + err.Error(), "data": nil} _ = c.ServeJSON() return } if cid == 1 || cid == 2 { menus = filterMenusByView(menus, cid) } menus = filterAssignableMenus(menus) // 租户端:仅可分配当前租户套餐已开通的功能模块下的菜单 if cid == 2 { menus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), menus, 0) } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(menus, 0)} _ = c.ServeJSON() } func (c *BackendMenuController) GetAllBackendMenus() { var menus []models.SystemMenu if _, err := models.Orm.QueryTable(new(models.SystemMenu)).All(&menus); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取菜单失败: " + err.Error(), "data": nil} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(filterMenusByView(menus, 2), 0)} _ = c.ServeJSON() } type menuNode struct { ID uint64 `json:"id"` Pid int64 `json:"pid"` Title string `json:"title"` Path string `json:"path,omitempty"` ComponentPath string `json:"component_path,omitempty"` Icon string `json:"icon,omitempty"` Sort int64 `json:"sort"` Status int8 `json:"status"` IsVisible *int8 `json:"is_visible,omitempty"` RequireLogin int8 `json:"require_login"` // 1=必须登录 0=可免登录(游客) Views []int `json:"views,omitempty"` Platforms []string `json:"platforms,omitempty"` // 展示端,空=继承所属模块 Type int8 `json:"type"` Permission string `json:"permission,omitempty"` Locked bool `json:"locked,omitempty"` Children []*menuNode `json:"children,omitempty"` } func buildMenuTree(menus []models.SystemMenu, pid int64) []*menuNode { var tree []*menuNode for _, m := range menus { if m.Pid == pid { node := &menuNode{ ID: m.ID, Pid: m.Pid, Title: m.Title, Sort: m.Sort, Status: m.Status, IsVisible: m.IsVisible, RequireLogin: m.RequireLogin, Views: parseViews(m.Views), Platforms: services.ParsePlatforms(m.Platforms), Type: m.Type, } if m.Path != nil { node.Path = *m.Path } if m.ComponentPath != nil { node.ComponentPath = *m.ComponentPath } if m.Icon != nil { node.Icon = *m.Icon } if m.Permission != nil { node.Permission = *m.Permission } if children := buildMenuTree(menus, int64(m.ID)); len(children) > 0 { node.Children = children } tree = append(tree, node) } } return tree } func (c *BackendMenuController) UpdateMenuStatus() { if !c.requirePlatformCaller() { return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效菜单ID"} _ = c.ServeJSON() return } var body struct { Status *int8 `json:"status"` } if err := json.Unmarshal(c.Ctx.Input.RequestBody, &body); err != nil || body.Status == nil { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} _ = c.ServeJSON() return } if _, err = models.Orm.QueryTable(new(models.SystemMenu)).Filter("id", id).Update(map[string]interface{}{"status": *body.Status}); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "success": true} _ = c.ServeJSON() } func (c *BackendMenuController) CreateMenu() { if !c.requirePlatformCaller() { return } payload, ok := c.parseMenuPayload(true) if !ok { return } var viewsStr string views := payload.Views if len(views) == 0 { views = []int{1} } if b, err := json.Marshal(views); err == nil { viewsStr = string(b) } menu := models.SystemMenu{ Pid: valueInt64(payload.Pid, 0), Title: strings.TrimSpace(valueString(payload.Title, "")), Sort: valueInt64(payload.Sort, 0), Status: valueInt8(payload.Status, 1), IsVisible: ptrInt8(valueInt8(payload.IsVisible, 1)), RequireLogin: valueInt8(payload.RequireLogin, 1), Views: &viewsStr, Type: valueInt8(payload.Type, 1), Path: ptrString(valueString(payload.Path, "")), ComponentPath: ptrString(valueString(payload.ComponentPath, "")), Icon: ptrString(valueString(payload.Icon, "")), Permission: ptrString(valueString(payload.Permission, "")), // 空串表示「继承所属模块的展示端」(模块也未配置则全端) Platforms: ptrString(services.NormalizePlatforms(payload.Platforms)), } id, err := models.Orm.Insert(&menu) if err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "创建成功", "data": map[string]interface{}{"id": id}} _ = c.ServeJSON() } func (c *BackendMenuController) UpdateMenu() { if !c.requirePlatformCaller() { return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效菜单ID"} _ = c.ServeJSON() return } payload, ok := c.parseMenuPayload(false) if !ok { return } views := payload.Views if len(views) == 0 { views = []int{1} } viewsBytes, _ := json.Marshal(views) update := map[string]interface{}{ "pid": valueInt64(payload.Pid, 0), "title": strings.TrimSpace(valueString(payload.Title, "")), "path": valueString(payload.Path, ""), "component_path": valueString(payload.ComponentPath, ""), "icon": valueString(payload.Icon, ""), "sort": valueInt64(payload.Sort, 0), "status": valueInt8(payload.Status, 1), "is_visible": valueInt8(payload.IsVisible, 1), "require_login": valueInt8(payload.RequireLogin, 1), "views": string(viewsBytes), "platforms": services.NormalizePlatforms(payload.Platforms), "type": valueInt8(payload.Type, 1), "permission": valueString(payload.Permission, ""), } if _, err = models.Orm.QueryTable(new(models.SystemMenu)).Filter("id", id).Update(update); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} _ = c.ServeJSON() } func (c *BackendMenuController) DeleteMenu() { if !c.requirePlatformCaller() { return } id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效菜单ID"} _ = c.ServeJSON() return } if _, err = models.Orm.QueryTable(new(models.SystemMenu)).Filter("id", id).Delete(); err != nil { c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} _ = c.ServeJSON() return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功", "success": true} _ = c.ServeJSON() } func (c *BackendMenuController) parseMenuPayload(needTitle bool) (*menuPayload, bool) { var payload menuPayload if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"} _ = c.ServeJSON() return nil, false } if needTitle && strings.TrimSpace(valueString(payload.Title, "")) == "" { c.Data["json"] = map[string]interface{}{"code": 400, "msg": "菜单名称不能为空"} _ = c.ServeJSON() return nil, false } return &payload, true } func valueString(v *string, def string) string { if v == nil { return def } return *v } func valueInt8(v *int8, def int8) int8 { if v == nil { return def } return *v } func valueInt64(v *int64, def int64) int64 { if v == nil { return def } return *v } func ptrString(v string) *string { return &v } func ptrInt8(v int8) *int8 { return &v }