## Context 当前登录系统通过 `auth.Service.Login()` 方法查询用户的角色权限,并返回扁平的权限码列表 `permissions: []string`。前端收到这个列表后,需要: 1. **菜单渲染问题**:权限码是扁平的(如 `["user:menu", "user:list:menu", "order:menu"]`),但前端侧边栏需要树形结构。前端要么额外请求菜单接口 `GET /api/admin/permissions/tree`,要么在本地维护菜单配置与权限码的映射关系。 2. **按钮控制问题**:权限码中混合了菜单权限和按钮权限,前端需要自行区分哪些是用于显示/隐藏按钮的权限(如 `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:** 1. 登录时返回结构化的权限数据,前端无需二次处理或额外请求 2. 菜单数据为树形结构,可直接用于渲染侧边栏(基于 `parent_id` 构建) 3. 按钮权限为扁平列表,可直接用于 `hasPermission()` 判断 4. 根据 `device` 参数自动过滤平台(避免泄露其他端的菜单) 5. 保持向后兼容性(保留原有 `permissions` 字段) 6. 性能可控(登录响应时间增加 < 50ms) **Non-Goals:** 1. **不修改 GetMe 接口**:避免频繁查询和构建菜单树,前端应将菜单数据缓存到 localStorage 2. **不添加额外字段**:MenuNode 不包含 `icon`, `badge`, `hidden` 等扩展字段(保持简洁) 3. **不修改数据库 schema**:复用现有 `tb_permission` 表的字段 4. **不实现动态菜单刷新**:权限变更后需要重新登录(短期方案) 5. **不处理权限变更通知**:不引入 WebSocket 推送(长期优化项) ## Decisions ### 决策 1: 数据结构设计 **选择**:新增 `MenuNode` DTO,包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段 **理由**: - `id`: 用于调试和日志追踪(虽然前端主要使用 `perm_code`) - `perm_code`: 前端可能用于路由匹配或权限验证 - `name`: 显示在侧边栏的文本 - `url`: 路由路径(前端用于 ``) - `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) 时间复杂度) **算法**: ```go 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` 字段 **过滤规则**: ```go // 在分类权限时应用过滤 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`),不查询角色关联表 **实现**: ```go 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` 字段升序) **实现**: ```go 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 结构**: ```go 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(登录后只构建一次) **前端使用模式**: ```javascript // 登录成功 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) ``` ### 文件修改清单 **新增文件**:无 **修改文件**: 1. `internal/model/dto/auth_dto.go` - 新增 `MenuNode` 结构体 - 修改 `LoginResponse` 结构体(新增 `Menus` 和 `Buttons` 字段) 2. `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 混乱 **风险**:如果权限配置不当(用户有子菜单权限但无父菜单权限),孤儿节点会被提升为根节点,可能破坏前端菜单层级设计。 **示例**: - 预期:用户管理 > 用户列表(二级菜单) - 实际:用户列表(一级菜单)← 孤儿节点提升 **缓解措施**: - **开发阶段**:在角色分配时进行校验,确保子菜单权限必须与父菜单权限一起分配 - **运行时**:记录警告日志(检测到孤儿节点时) ```go if parentID != nil && !nodeMap[*parentID] { logger.Warn("检测到孤儿节点", zap.Uint("child_id", p.ID), zap.Uint("parent_id", *parentID)) } ``` - **管理界面**:角色权限分配时自动勾选父菜单(前端实现) **替代方案**: - 孤儿节点直接丢弃(不返回) - 缺点:用户有权限但看不到菜单,体验更差 --- ### 风险 4: 前端缓存过期问题 **风险**:前端将菜单数据缓存到 localStorage 后,如果后端权限变更,用户需要重新登录才能看到最新菜单。 **场景**: 1. 管理员为某角色新增菜单权限 2. 用户的 Token 仍有效(24 小时内) 3. 用户继续使用旧的菜单数据(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 ### 部署步骤 **第一阶段:后端部署**(向后兼容) 1. 部署新版本代码(包含 `menus` 和 `buttons` 字段) 2. 验证登录接口返回新字段 3. 确认旧版前端仍可正常使用 `permissions` 字段 **第二阶段:前端升级**(可选) 1. 前端适配新字段(使用 `menus` 渲染侧边栏) 2. 前端适配新字段(使用 `buttons` 控制按钮显示) 3. 灰度发布(10% → 50% → 100%) **第三阶段:废弃旧字段**(6 个月后) 1. 监控 `permissions` 字段的使用情况 2. 确认所有前端已升级 3. 通过 API 版本控制移除 `permissions` 字段 ### 回滚策略 **触发条件**: - 登录接口响应时间增加 > 100ms - 登录失败率增加 > 5% - 前端报告菜单渲染异常 **回滚步骤**: 1. 回滚到上一个稳定版本 2. 保留原有 `getUserPermissions()` 逻辑 3. 移除 `getUserPermissionsAndMenus()` 调用 **数据库回滚**:无需回滚(未修改数据库 schema) ### 测试计划 **单元测试**: - `buildMenuTree()` 方法(树构建逻辑) - 测试场景:根节点、多级嵌套、孤儿节点、排序 - `classifyPermissions()` 方法(权限分类) - 测试场景:平台过滤、菜单/按钮分类、超级管理员 - `sortMenuNodes()` 方法(递归排序) - 测试场景:同级排序、子节点排序、稳定排序 **集成测试**: - 登录接口测试 - 场景:普通用户登录、超级管理员登录、无权限用户登录 - 验证:响应包含 `menus`, `buttons`, `permissions` 三个字段 - 验证:菜单树结构正确,排序正确,平台过滤正确 - GetMe 接口测试 - 场景:已登录用户调用 GetMe - 验证:响应不包含 `menus` 和 `buttons` 字段 **性能测试**: - 登录接口性能基准测试 - 场景:50 个权限、100 个权限、200 个权限 - 目标:响应时间增加 < 50ms **兼容性测试**: - 旧版前端仍可使用 `permissions` 字段 - 新版前端可以使用 `menus` 和 `buttons` 字段 ## Open Questions 1. **是否需要为菜单树引入缓存?** - 当前设计:每次登录都重新构建菜单树 - 优化方案:将菜单树缓存到 Redis(以 `user_id + device` 为 key) - 决策点:登录频率低(一天 1-2 次),暂不引入缓存;后续根据性能监控决定 2. **是否需要支持前端动态刷新菜单?** - 当前设计:权限变更后需要重新登录 - 优化方案:提供 `GET /api/admin/menus` 端点或 WebSocket 推送 - 决策点:短期方案(重新登录),长期优化(按需刷新) 3. **是否需要为 MenuNode 添加扩展字段(icon, badge, hidden)?** - 当前设计:保持最小化(6 个字段) - 扩展方案:根据前端需求逐步添加 - 决策点:先实现基础功能,根据反馈迭代