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

591 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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) 时间复杂度)
**算法**
```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 个字段)
- 扩展方案:根据前端需求逐步添加
- 决策点:先实现基础功能,根据反馈迭代