21 KiB
Context
当前登录系统通过 auth.Service.Login() 方法查询用户的角色权限,并返回扁平的权限码列表 permissions: []string。前端收到这个列表后,需要:
- 菜单渲染问题:权限码是扁平的(如
["user:menu", "user:list:menu", "order:menu"]),但前端侧边栏需要树形结构。前端要么额外请求菜单接口GET /api/admin/permissions/tree,要么在本地维护菜单配置与权限码的映射关系。 - 按钮控制问题:权限码中混合了菜单权限和按钮权限,前端需要自行区分哪些是用于显示/隐藏按钮的权限(如
user:create,user:delete)。
现有的 tb_permission 表已经包含了构建菜单树所需的所有字段:
perm_type: 权限类型(1=菜单权限,2=按钮权限)parent_id: 父级权限 ID(用于树形结构)platform: 平台标识(web/h5/all)sort: 排序字段
现有的权限查询逻辑(auth.Service.getUserPermissions())已经能够获取用户的所有权限 ID,并查询权限详情。我们只需要在此基础上增加分类和树形结构构建逻辑。
Goals / Non-Goals
Goals:
- 登录时返回结构化的权限数据,前端无需二次处理或额外请求
- 菜单数据为树形结构,可直接用于渲染侧边栏(基于
parent_id构建) - 按钮权限为扁平列表,可直接用于
hasPermission()判断 - 根据
device参数自动过滤平台(避免泄露其他端的菜单) - 保持向后兼容性(保留原有
permissions字段) - 性能可控(登录响应时间增加 < 50ms)
Non-Goals:
- 不修改 GetMe 接口:避免频繁查询和构建菜单树,前端应将菜单数据缓存到 localStorage
- 不添加额外字段:MenuNode 不包含
icon,badge,hidden等扩展字段(保持简洁) - 不修改数据库 schema:复用现有
tb_permission表的字段 - 不实现动态菜单刷新:权限变更后需要重新登录(短期方案)
- 不处理权限变更通知:不引入 WebSocket 推送(长期优化项)
Decisions
决策 1: 数据结构设计
选择:新增 MenuNode DTO,包含 id, perm_code, name, url, sort, children 字段
理由:
id: 用于调试和日志追踪(虽然前端主要使用perm_code)perm_code: 前端可能用于路由匹配或权限验证name: 显示在侧边栏的文本url: 路由路径(前端用于<router-link>)sort: 保持菜单顺序(前端直接渲染,不需要再排序)children: 递归结构(支持无限层级)
替代方案:
- 方案 A(被拒绝):只返回菜单 ID 列表,前端根据 ID 查询菜单详情
- 缺点:增加前端复杂度,需要维护 ID 到菜单对象的映射
- 方案 B(被拒绝):返回完整的
Permission对象(包含所有字段)- 缺点:响应体过大,包含前端不需要的字段(如
creator,updater,created_at)
- 缺点:响应体过大,包含前端不需要的字段(如
决策 2: 权限分类逻辑
选择:基于 perm_type 字段分类(1=菜单,2=按钮),在 Service 层实现
数据流:
查询用户权限 → 获取 Permission 对象列表
↓
遍历权限,根据 perm_type 分类:
├── perm_type = 1 → 菜单权限列表(用于构建树)
├── perm_type = 2 → 按钮权限码列表(直接提取 perm_code)
└── 所有权限码 → permissions 字段(向后兼容)
↓
菜单权限 → buildMenuTree() → 树形结构
按钮权限 → 扁平列表
实现位置:auth.Service.getUserPermissionsAndMenus()(新增方法)
理由:
- 复用现有的权限查询逻辑(
getUserPermissions()已经能获取所有权限) - 单一职责:权限分类逻辑放在 Service 层(不在 Handler 层处理)
- 可测试性:分类和树构建逻辑可以独立单元测试
替代方案:
- 方案 A(被拒绝):在数据库查询时分别查询菜单权限和按钮权限
- 缺点:需要两次查询,增加数据库负载
- 方案 B(被拒绝):在前端分类
- 缺点:增加前端复杂度,需要理解
perm_type字段含义
- 缺点:增加前端复杂度,需要理解
决策 3: 菜单树构建算法
选择:使用 HashMap + 单次遍历构建树(O(n) 时间复杂度)
算法:
func buildMenuTree(permissions []*model.Permission) []dto.MenuNode {
// 第一步:创建节点映射(ID → MenuNode)
nodeMap := make(map[uint]*dto.MenuNode)
for _, p := range permissions {
nodeMap[p.ID] = &dto.MenuNode{
ID: p.ID,
PermCode: p.PermCode,
Name: p.PermName,
URL: p.URL,
Sort: p.Sort,
Children: make([]dto.MenuNode, 0),
}
}
// 第二步:组织父子关系
var roots []dto.MenuNode
for _, p := range permissions {
node := nodeMap[p.ID]
if p.ParentID == nil || *p.ParentID == 0 {
// 根节点
roots = append(roots, *node)
} else if parent, ok := nodeMap[*p.ParentID]; ok {
// 有父节点 → 追加到父节点的 children
parent.Children = append(parent.Children, *node)
} else {
// 孤儿节点(父节点不在权限列表中)→ 提升为根节点
roots = append(roots, *node)
}
}
// 第三步:递归排序
sortMenuNodes(roots)
return roots
}
理由:
- 时间复杂度 O(n),空间复杂度 O(n)(n 为权限数量,通常 < 100)
- 单次遍历,无需递归查询数据库
- 自然处理孤儿节点(无父权限的子菜单提升为根节点)
孤儿节点处理:
- 场景:用户有
user:list:menu(parent_id=1)但没有user:menu(id=1) - 行为:将
user:list:menu提升为根节点 - 理由:避免菜单丢失,同时暴露权限配置问题(应该在角色分配时避免)
替代方案:
- 方案 A(被拒绝):递归查询数据库构建树
- 缺点:N+1 查询问题,性能差
- 方案 B(被拒绝):孤儿节点直接丢弃
- 缺点:用户有权限但看不到菜单,体验差
决策 4: 平台过滤策略
选择:在 Service 层根据 device 参数过滤 platform 字段
过滤规则:
// 在分类权限时应用过滤
for _, perm := range permissions {
// 平台过滤
if perm.Platform != constants.PlatformAll && perm.Platform != device {
continue // 跳过不匹配的权限
}
// 分类
if perm.PermType == constants.PermTypeMenu {
menuPerms = append(menuPerms, perm)
} else if perm.PermType == constants.PermTypeButton {
buttonCodes = append(buttonCodes, perm.PermCode)
}
}
理由:
- 安全性:避免泄露其他端的菜单结构(如 Web 后台登录不返回 H5 菜单)
- 减少响应体大小:只返回当前端口需要的菜单
- 简化前端逻辑:前端无需二次过滤
默认值:device 未指定时默认为 "web"
替代方案:
- 方案 A(被拒绝):返回所有平台的菜单,前端自行过滤
- 缺点:安全风险(泄露其他端的菜单结构),响应体增大
- 方案 B(被拒绝):在数据库查询时过滤
- 缺点:需要修改现有的权限查询逻辑,增加复杂度
决策 5: 超级管理员特殊处理
选择:超级管理员直接查询所有启用的权限(status=1),不查询角色关联表
实现:
func (s *Service) getUserPermissionsAndMenus(ctx, userID, userType, device) {
if userType == constants.UserTypeSuperAdmin {
// 超级管理员:查询所有启用的权限
allPerms, err := s.permissionStore.GetAll(ctx, nil)
// 应用平台过滤 + 分类
return s.classifyPermissions(allPerms, device)
}
// 普通用户:查询角色权限
// ... (现有逻辑)
}
理由:
- 超级管理员需要看到所有功能模块(管理员职责)
- 复用现有的
GetAll()方法(已有实现) - 仍然应用平台过滤(超管在 Web 后台登录不应该看到 H5 菜单)
性能考虑:
- 数据库查询:
SELECT * FROM tb_permission WHERE status = 1(单次查询,无 JOIN) - 预计权限数量 < 200,查询时间 < 10ms
替代方案:
- 方案 A(被拒绝):超级管理员也查询角色权限
- 缺点:需要为超管分配角色,管理复杂度增加
- 方案 B(被拒绝):超级管理员返回硬编码的菜单列表
- 缺点:不灵活,新增菜单需要修改代码
决策 6: 排序实现
选择:递归排序(根据 sort 字段升序)
实现:
func sortMenuNodes(nodes []dto.MenuNode) {
// 排序当前层级
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Sort < nodes[j].Sort
})
// 递归排序子节点
for i := range nodes {
if len(nodes[i].Children) > 0 {
sortMenuNodes(nodes[i].Children)
}
}
}
理由:
- 前端直接渲染,无需再次排序
- 支持多层级排序(递归应用到所有子节点)
- 稳定排序(相同
sort值时保持原有顺序)
替代方案:
- 方案 A(被拒绝):在数据库查询时排序
- 缺点:只能排序扁平列表,无法处理树形结构的层级排序
- 方案 B(被拒绝):前端排序
- 缺点:增加前端复杂度
决策 7: 向后兼容性
选择:保留原有 permissions 字段,同时新增 menus 和 buttons 字段
LoginResponse 结构:
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User UserInfo `json:"user"`
// 向后兼容(现有字段)
Permissions []string `json:"permissions"`
// 新增字段
Menus []MenuNode `json:"menus"`
Buttons []string `json:"buttons"`
}
理由:
- 旧版前端仍可使用
permissions字段正常工作 - 新版前端可以选择使用
menus和buttons字段 - 平滑迁移(无需强制升级前端)
后续优化(可选):
- 6 个月后评估是否废弃
permissions字段 - 通过 API 版本控制(
/api/v2/login)彻底移除旧字段
替代方案:
- 方案 A(被拒绝):直接替换
permissions字段为menus和buttons- 缺点:破坏性变更,旧版前端无法工作
- 方案 B(被拒绝):通过
Accept头或 query 参数控制返回格式- 缺点:增加复杂度,难以维护
决策 8: GetMe 接口不返回菜单
选择:GET /api/admin/me 和 GET /api/h5/me 保持不变(只返回 user 和 permissions)
理由:
- GetMe 是高频接口(前端可能每次路由切换都调用)
- 菜单树构建有计算成本(虽然很小,但不必要)
- 前端应该将菜单数据缓存到 localStorage(登录后只构建一次)
前端使用模式:
// 登录成功
const response = await api.login({username, password});
localStorage.setItem('menus', JSON.stringify(response.menus));
localStorage.setItem('buttons', JSON.stringify(response.buttons));
// 页面刷新
const menus = JSON.parse(localStorage.getItem('menus'));
renderSidebar(menus);
// 权限变更后(可选)
// 调用 api.login() 重新获取菜单,或者提供"刷新权限"按钮
替代方案:
- 方案 A(被拒绝):GetMe 也返回菜单
- 缺点:高频接口性能下降,不必要的计算
- 方案 B(被拒绝):提供单独的
GET /api/admin/menus端点- 缺点:增加端点数量,前端需要额外请求
Architecture
模块依赖关系
internal/handler/admin/auth.go (AuthHandler.Login)
↓ 调用
internal/service/auth/service.go (Service.Login)
↓ 调用(新增)
internal/service/auth/service.go (Service.getUserPermissionsAndMenus)
↓ 调用
internal/store/postgres/permission_store.go (PermissionStore.GetByIDs / GetAll)
↓ 返回
[]*model.Permission
↓ 分类和构建
internal/service/auth/service.go (classifyPermissions, buildMenuTree)
↓ 返回
([]dto.MenuNode, []string)
文件修改清单
新增文件:无
修改文件:
-
internal/model/dto/auth_dto.go- 新增
MenuNode结构体 - 修改
LoginResponse结构体(新增Menus和Buttons字段)
- 新增
-
internal/service/auth/service.go- 修改
Login()方法:调用getUserPermissionsAndMenus()替代getUserPermissions() - 新增
getUserPermissionsAndMenus()方法:查询权限并分类 - 新增
classifyPermissions()方法:分类菜单和按钮权限,应用平台过滤 - 新增
buildMenuTree()方法:构建菜单树 - 新增
sortMenuNodes()方法:递归排序菜单节点 - 新增
getAllPermissionsForSuperAdmin()方法:超级管理员获取所有权限
- 修改
数据流图
用户登录 (device="web")
↓
AuthHandler.Login()
↓
Service.Login()
↓ 验证用户名/密码
↓ 生成 Token
↓
Service.getUserPermissionsAndMenus(userID, userType, device)
↓
├─ 超级管理员?
│ └─> PermissionStore.GetAll(ctx, nil)
│ → 所有启用的权限
│
└─ 普通用户?
└─> AccountRoleStore.GetByAccountID(userID)
→ RolePermissionStore.GetPermIDsByRoleIDs(roleIDs)
→ PermissionStore.GetByIDs(permIDs)
→ 用户的权限列表
↓
Service.classifyPermissions(permissions, device)
↓
├─ 遍历权限,平台过滤
│ ├─ platform == "all" 或 platform == device?✅
│ └─ 否则跳过
│
├─ perm_type == 1 (菜单) → 收集到 menuPerms
├─ perm_type == 2 (按钮) → 收集到 buttonCodes
└─ 所有权限码 → allCodes
↓
buildMenuTree(menuPerms)
↓
├─ 第一步:创建节点映射 (HashMap)
├─ 第二步:组织父子关系
│ ├─ parent_id == NULL → 根节点
│ ├─ parent_id 存在 → 追加到 parent.Children
│ └─ parent_id 不存在 → 孤儿节点提升为根节点
└─ 第三步:递归排序
↓
返回 LoginResponse {
menus: []MenuNode (树形结构),
buttons: []string (扁平),
permissions: []string (所有权限码)
}
Risks / Trade-offs
风险 1: 登录响应体增大
风险:菜单树包含完整的节点信息(id, perm_code, name, url, sort, children),响应体可能增加 5-10KB。
影响:
- 普通用户(20-30 个权限):响应体增加约 3-5KB
- 超级管理员(100+ 个权限):响应体增加约 8-12KB
缓解措施:
- 使用 Fiber 的自动 Gzip 压缩(压缩率约 60-70%)
- 前端使用 localStorage 缓存,登录后只需传输一次
- MenuNode 结构保持最小化(不包含非必要字段)
监控:登录接口响应体大小(目标 < 20KB)
风险 2: 菜单树构建性能
风险:菜单树构建算法(HashMap + 遍历 + 递归排序)可能在权限数量大时影响性能。
影响分析:
- 时间复杂度:O(n) + O(n log n)(遍历 + 排序)
- 100 个权限的场景:预计耗时 < 10ms
- 500 个权限的场景(极端情况):预计耗时 < 50ms
缓解措施:
- 算法复杂度已优化(避免递归查询数据库)
- 超级管理员的权限数量可控(< 200)
- 登录频率低(用户一天登录 1-2 次)
备选方案(长期优化):
- 引入 Redis 缓存:缓存已构建的菜单树(以
user_id + device为 key) - 缓存失效策略:权限变更时清除相关用户的缓存
风险 3: 孤儿节点提升可能导致 UI 混乱
风险:如果权限配置不当(用户有子菜单权限但无父菜单权限),孤儿节点会被提升为根节点,可能破坏前端菜单层级设计。
示例:
- 预期:用户管理 > 用户列表(二级菜单)
- 实际:用户列表(一级菜单)← 孤儿节点提升
缓解措施:
- 开发阶段:在角色分配时进行校验,确保子菜单权限必须与父菜单权限一起分配
- 运行时:记录警告日志(检测到孤儿节点时)
if parentID != nil && !nodeMap[*parentID] { logger.Warn("检测到孤儿节点", zap.Uint("child_id", p.ID), zap.Uint("parent_id", *parentID)) } - 管理界面:角色权限分配时自动勾选父菜单(前端实现)
替代方案:
- 孤儿节点直接丢弃(不返回)
- 缺点:用户有权限但看不到菜单,体验更差
风险 4: 前端缓存过期问题
风险:前端将菜单数据缓存到 localStorage 后,如果后端权限变更,用户需要重新登录才能看到最新菜单。
场景:
- 管理员为某角色新增菜单权限
- 用户的 Token 仍有效(24 小时内)
- 用户继续使用旧的菜单数据(localStorage 中的缓存)
缓解措施(短期):
- 前端提供"刷新权限"按钮(调用
POST /api/admin/login重新登录) - 文档说明:权限变更后需要重新登录生效
长期优化方案:
- 引入 WebSocket 推送:权限变更时通知在线用户刷新
- 提供独立的
GET /api/admin/menus端点(按需刷新菜单) - Token 过期时间缩短(如 12 小时)
权衡: GetMe 不返回菜单
权衡:GetMe 接口不返回菜单,前端依赖 localStorage 缓存。
优点:
- GetMe 性能保持高效(高频接口)
- 菜单只在登录时构建一次
缺点:
- 前端需要管理 localStorage 缓存(增加复杂度)
- 页面刷新时如果缓存丢失,需要重新登录
缓解措施:
- 前端 SDK 封装缓存逻辑(提供统一的
getMenus()方法) - 文档提供最佳实践示例
权衡: 向后兼容性 vs 响应体大小
权衡:保留 permissions 字段导致响应体包含冗余数据。
数据冗余:
permissions: 所有权限码(菜单 + 按钮)menus: 菜单权限码(包含在permissions中)buttons: 按钮权限码(包含在permissions中)
优点:平滑迁移,旧版前端仍可工作
缺点:响应体增加约 1-2KB(权限码重复传输)
长期方案:
- 6 个月后评估前端升级情况
- 通过 API 版本控制(
/api/v2/login)移除permissions字段
Migration Plan
部署步骤
第一阶段:后端部署(向后兼容)
- 部署新版本代码(包含
menus和buttons字段) - 验证登录接口返回新字段
- 确认旧版前端仍可正常使用
permissions字段
第二阶段:前端升级(可选)
- 前端适配新字段(使用
menus渲染侧边栏) - 前端适配新字段(使用
buttons控制按钮显示) - 灰度发布(10% → 50% → 100%)
第三阶段:废弃旧字段(6 个月后)
- 监控
permissions字段的使用情况 - 确认所有前端已升级
- 通过 API 版本控制移除
permissions字段
回滚策略
触发条件:
- 登录接口响应时间增加 > 100ms
- 登录失败率增加 > 5%
- 前端报告菜单渲染异常
回滚步骤:
- 回滚到上一个稳定版本
- 保留原有
getUserPermissions()逻辑 - 移除
getUserPermissionsAndMenus()调用
数据库回滚:无需回滚(未修改数据库 schema)
测试计划
单元测试:
buildMenuTree()方法(树构建逻辑)- 测试场景:根节点、多级嵌套、孤儿节点、排序
classifyPermissions()方法(权限分类)- 测试场景:平台过滤、菜单/按钮分类、超级管理员
sortMenuNodes()方法(递归排序)- 测试场景:同级排序、子节点排序、稳定排序
集成测试:
- 登录接口测试
- 场景:普通用户登录、超级管理员登录、无权限用户登录
- 验证:响应包含
menus,buttons,permissions三个字段 - 验证:菜单树结构正确,排序正确,平台过滤正确
- GetMe 接口测试
- 场景:已登录用户调用 GetMe
- 验证:响应不包含
menus和buttons字段
性能测试:
- 登录接口性能基准测试
- 场景:50 个权限、100 个权限、200 个权限
- 目标:响应时间增加 < 50ms
兼容性测试:
- 旧版前端仍可使用
permissions字段 - 新版前端可以使用
menus和buttons字段
Open Questions
-
是否需要为菜单树引入缓存?
- 当前设计:每次登录都重新构建菜单树
- 优化方案:将菜单树缓存到 Redis(以
user_id + device为 key) - 决策点:登录频率低(一天 1-2 次),暂不引入缓存;后续根据性能监控决定
-
是否需要支持前端动态刷新菜单?
- 当前设计:权限变更后需要重新登录
- 优化方案:提供
GET /api/admin/menus端点或 WebSocket 推送 - 决策点:短期方案(重新登录),长期优化(按需刷新)
-
是否需要为 MenuNode 添加扩展字段(icon, badge, hidden)?
- 当前设计:保持最小化(6 个字段)
- 扩展方案:根据前端需求逐步添加
- 决策点:先实现基础功能,根据反馈迭代