Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-30-login-response-menus-buttons/design.md
2026-01-30 17:22:38 +08:00

21 KiB
Raw Blame History

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: 路由路径(前端用于 <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:menuparent_id=1但没有 user:menuid=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 字段,同时新增 menusbuttons 字段

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 字段正常工作
  • 新版前端可以选择使用 menusbuttons 字段
  • 平滑迁移(无需强制升级前端)

后续优化(可选):

  • 6 个月后评估是否废弃 permissions 字段
  • 通过 API 版本控制(/api/v2/login)彻底移除旧字段

替代方案

  • 方案 A被拒绝:直接替换 permissions 字段为 menusbuttons
    • 缺点:破坏性变更,旧版前端无法工作
  • 方案 B被拒绝:通过 Accept 头或 query 参数控制返回格式
    • 缺点:增加复杂度,难以维护

决策 8: GetMe 接口不返回菜单

选择GET /api/admin/meGET /api/h5/me 保持不变(只返回 userpermissions

理由

  • 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)

文件修改清单

新增文件:无

修改文件

  1. internal/model/dto/auth_dto.go

    • 新增 MenuNode 结构体
    • 修改 LoginResponse 结构体(新增 MenusButtons 字段)
  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 混乱

风险:如果权限配置不当(用户有子菜单权限但无父菜单权限),孤儿节点会被提升为根节点,可能破坏前端菜单层级设计。

示例

  • 预期:用户管理 > 用户列表(二级菜单)
  • 实际:用户列表(一级菜单)← 孤儿节点提升

缓解措施

  • 开发阶段:在角色分配时进行校验,确保子菜单权限必须与父菜单权限一起分配
  • 运行时:记录警告日志(检测到孤儿节点时)
    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. 部署新版本代码(包含 menusbuttons 字段)
  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
    • 验证:响应不包含 menusbuttons 字段

性能测试

  • 登录接口性能基准测试
    • 场景50 个权限、100 个权限、200 个权限
    • 目标:响应时间增加 < 50ms

兼容性测试

  • 旧版前端仍可使用 permissions 字段
  • 新版前端可以使用 menusbuttons 字段

Open Questions

  1. 是否需要为菜单树引入缓存?

    • 当前设计:每次登录都重新构建菜单树
    • 优化方案:将菜单树缓存到 Redisuser_id + device 为 key
    • 决策点:登录频率低(一天 1-2 次),暂不引入缓存;后续根据性能监控决定
  2. 是否需要支持前端动态刷新菜单?

    • 当前设计:权限变更后需要重新登录
    • 优化方案:提供 GET /api/admin/menus 端点或 WebSocket 推送
    • 决策点:短期方案(重新登录),长期优化(按需刷新)
  3. 是否需要为 MenuNode 添加扩展字段icon, badge, hidden

    • 当前设计保持最小化6 个字段)
    • 扩展方案:根据前端需求逐步添加
    • 决策点:先实现基础功能,根据反馈迭代