实现系统管理菜单树增强接口
This commit is contained in:
@@ -71,7 +71,7 @@
|
||||
| `GET/POST/PUT /api/admin/users...` | 系统管理用户维护 | 需要 Bearer token 和 `SYSTEM_USER_MANAGE`;用户 ID 返回字符串;禁用用户会撤销其 ACTIVE session。 |
|
||||
| `GET/POST/PUT /api/admin/roles...` | 系统管理角色权限维护 | 需要 `SYSTEM_ROLE_MANAGE`;内置角色只读,自定义角色可新增、编辑和分配权限。 |
|
||||
| `GET /api/admin/permissions` | 权限码只读列表 | 需要 `SYSTEM_ROLE_MANAGE`;前端只展示和选择已有权限码,不自行造权限码。 |
|
||||
| `GET/POST/PUT /api/admin/menus...` | 系统管理菜单维护 | 需要 `SYSTEM_MENU_MANAGE`;允许保存未知路由,前端必须有未知路由兜底页。 |
|
||||
| `GET/POST/PUT /api/admin/menus...` | 系统管理菜单维护 | 需要 `SYSTEM_MENU_MANAGE`;允许保存未知路由,前端必须有未知路由兜底页;已提供完整菜单树查询和批量树排序保存。 |
|
||||
| `GET/POST/PUT /api/admin/hotels...` | 系统管理酒店维护 | 需要 `HOTEL_MANAGE`;新增酒店默认 `DISABLED`,单酒店阶段不能启用第二家 `ACTIVE`。 |
|
||||
| `GET /api/admin/audits` | 系统管理操作审计 | 需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`;用于查看管理后台写操作审计,不包含密码、token、secret。 |
|
||||
|
||||
@@ -330,6 +330,8 @@ run_label: 可选调试标签
|
||||
- 内置角色 `system_builtin=true` 时只读,前端应禁用编辑和权限分配按钮;后端仍会返回 409 兜底。
|
||||
- 新增自定义角色后,用户需要重新登录或刷新 `/api/auth/me` 才能拿到最新权限上下文。
|
||||
- 新增菜单允许未知路由;未知路由可以保存,但正式开放可见前要确认前端页面已经存在或兜底页可接受。
|
||||
- 菜单管理交互升级已提供 `GET /api/admin/menus/tree` 和 `PUT /api/admin/menus/tree-order`:前者返回完整菜单树,后者批量保存 `parent_id` 和 `sort_order`。这两个接口仍属于 `/api/admin/menus/**`,必须带 Bearer token,并需要 `SYSTEM_MENU_MANAGE`。
|
||||
- `PUT /api/admin/menus/tree-order` 只能修改菜单父级和排序,不能顺带修改菜单名称、路由、权限码、可见性或状态;后端会校验父级存在、自引用和循环树,并写入 `platform_admin_audit_log`。`sort_order` 可为空;为空时后端按请求 `items[]` 顺序生成 `100`、`200`、`300`... 的稳定排序号。
|
||||
- 新增酒店默认 `DISABLED`,`hotel_id` 新增后不能修改。
|
||||
- 单酒店阶段只允许一家 `ACTIVE` 酒店,后端会拒绝启用第二家 `ACTIVE`,也会拒绝禁用最后一家 `ACTIVE`。
|
||||
- 系统管理写操作会写入 `platform_admin_audit_log`;审计接口 `GET /api/admin/audits` 可按 `target_type`、`target_id`、`action` 查询。
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| P1 | S10/S99 源邮件只读通知卡与旧 S000/S999 兼容 | 任务列表、任务详情来源邮件查看 | 已完成第一版:旧 S000/S999 兼容,新结构化 S10/S99 可入站并在任务列表 / 详情只读展示 |
|
||||
| P1 | type-known manual review 同卡复核解阻 | 任务详情复核 | 已完成第一版:原业务任务卡复核、字段修正、订单归属确认、READY 流转 |
|
||||
| P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 |
|
||||
| P1 | 系统管理菜单树增强接口 | 系统设置 / 菜单管理树形交互 | 已完成:完整菜单树查询、批量保存父级和排序 |
|
||||
| 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 |
|
||||
|
||||
## 2.1 后端当前接口完成度核对
|
||||
@@ -765,7 +766,134 @@ V3 S10 结构化详情当前增量:
|
||||
|
||||
前端注意:这两个数组不是任务队列,不提供编辑、确认、执行 OPERA 或重试入口;只用于解释为什么同一封邮件中的某些 event 没有变成业务任务。
|
||||
|
||||
## 10. 任务卡前端字段白名单元数据接口
|
||||
## 10. 系统管理菜单树增强接口
|
||||
|
||||
为支持系统设置中“菜单管理”从表格交互升级为“左侧菜单树 + 右侧配置面板”,前端希望后端补两个增强接口。该能力仍属于系统管理后台 `/api/admin/menus/**`,不改变菜单可见性和后端接口权限的边界。
|
||||
|
||||
### 10.1 完整菜单树查询
|
||||
|
||||
建议路径:
|
||||
|
||||
```text
|
||||
GET /api/admin/menus/tree
|
||||
```
|
||||
|
||||
权限要求:
|
||||
|
||||
| 要求 | 说明 |
|
||||
| --- | --- |
|
||||
| 登录 | 必须携带 `Authorization: Bearer <access_token>` |
|
||||
| 权限 | 需要 `SYSTEM_MENU_MANAGE` |
|
||||
| 审计 | 只读查询不需要写管理审计 |
|
||||
|
||||
查询行为:
|
||||
|
||||
- 返回完整菜单树,不分页。
|
||||
- 默认返回全部菜单,包括 `ACTIVE` / `DISABLED`、`visible=true` / `false`。
|
||||
- 按 `parent_id` 组树,根节点 `parent_id=null`。
|
||||
- 同级按 `sort_order` 升序,其次按 `menu_name` 或 `id` 稳定排序。
|
||||
- `BIGINT` ID 继续以字符串返回。
|
||||
- 如果存在脏数据,例如 `parent_id` 指向不存在菜单,应 fail-safe:该节点作为根级异常节点返回,或在响应中提供 `warnings[]`,不要导致接口 500。
|
||||
|
||||
建议返参:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "10001",
|
||||
"parent_id": null,
|
||||
"menu_code": "SYSTEM_SETTINGS",
|
||||
"menu_name": "系统设置",
|
||||
"menu_type": "PAGE",
|
||||
"route_path": "/system",
|
||||
"component_key": "system",
|
||||
"icon_key": "pi pi-cog",
|
||||
"permission_code": "SYSTEM_ADMIN_CONSOLE_ACCESS",
|
||||
"sort_order": 900,
|
||||
"visible": true,
|
||||
"menu_status": "ACTIVE",
|
||||
"known_route": true,
|
||||
"created_at": "2026-07-16T00:00:00Z",
|
||||
"updated_at": "2026-07-16T00:00:00Z",
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 批量调整菜单父级和排序
|
||||
|
||||
建议路径:
|
||||
|
||||
```text
|
||||
PUT /api/admin/menus/tree-order
|
||||
```
|
||||
|
||||
权限要求:
|
||||
|
||||
| 要求 | 说明 |
|
||||
| --- | --- |
|
||||
| 登录 | 必须携带 `Authorization: Bearer <access_token>` |
|
||||
| 权限 | 需要 `SYSTEM_MENU_MANAGE` |
|
||||
| 审计 | 写操作必须写 `platform_admin_audit_log` |
|
||||
|
||||
请求体建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"menu_id": "10002",
|
||||
"parent_id": "10001",
|
||||
"sort_order": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
后端要求:
|
||||
|
||||
- 只允许修改 `parent_id` 和 `sort_order`,不允许顺带修改 `menu_name`、`route_path`、`permission_code`、`visible`、`menu_status`。
|
||||
- 使用事务保存。
|
||||
- 校验 `menu_id` 必须存在。
|
||||
- 校验 `parent_id` 为空或存在。
|
||||
- 禁止把自己设为自己的父级。
|
||||
- 禁止形成循环菜单树。
|
||||
- `sort_order` 可为空;为空时后端按请求 `items[]` 顺序生成 `100`、`200`、`300`... 的稳定排序号。
|
||||
- 成功后返回更新后的完整菜单树,方便前端立即刷新。
|
||||
- 审计中记录调整前后的 `parent_id` / `sort_order`,不记录 token、secret 或敏感信息。
|
||||
|
||||
建议成功返参:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "10001",
|
||||
"parent_id": null,
|
||||
"menu_code": "SYSTEM_SETTINGS",
|
||||
"menu_name": "系统设置",
|
||||
"sort_order": 900,
|
||||
"visible": true,
|
||||
"menu_status": "ACTIVE",
|
||||
"known_route": true,
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
前端接入注意:
|
||||
|
||||
- 前端菜单树管理页优先使用 `GET /api/admin/menus/tree`,不再依赖分页菜单列表拼完整树。
|
||||
- `GET /api/admin/menus` 仍保留给表格分页、搜索和兼容页面使用。
|
||||
- `PUT /api/admin/menus/{menuId}` 仍用于单条菜单基础字段编辑。
|
||||
- `PUT /api/admin/menus/tree-order` 只用于批量保存树结构和排序。
|
||||
|
||||
## 11. 任务卡前端字段白名单元数据接口
|
||||
|
||||
是否需要该接口待确认。如果任务详情接口 `fields[]` 已透出 3.0 所需元数据,则第一版可以不做独立白名单接口;如果后续需要字段矩阵调试页、版本对齐页或前端预加载全部任务卡配置,再补独立接口。
|
||||
|
||||
@@ -803,7 +931,7 @@ GET /api/reservation/task-card-field-whitelist
|
||||
}
|
||||
```
|
||||
|
||||
## 11. 已确认后置接口
|
||||
## 12. 已确认后置接口
|
||||
|
||||
普通任务切换订单接口继续后置,前端暂不开发提交能力。后续如果恢复开发,建议另行确认:
|
||||
|
||||
@@ -832,7 +960,7 @@ POST /api/reservation/tasks/{taskId}/order-binding
|
||||
}
|
||||
```
|
||||
|
||||
## 12. 待确认问题
|
||||
## 13. 待确认问题
|
||||
|
||||
- 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。
|
||||
- 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`。
|
||||
@@ -841,3 +969,4 @@ POST /api/reservation/tasks/{taskId}/order-binding
|
||||
- 邮件会话全文读取的审计策略由后端内部处理;前端不保存原文读取 key。
|
||||
- `GET /api/reservation/tasks` 结构化 S10/S99 行的 `task_type` 返回值请后端最终确认:前端已兼容 `SOURCE_MESSAGE_ONLY` 和 `MESSAGE_NOTIFICATION`,但文档口径最好稳定一个。
|
||||
- `manual-review-resolutions` 成功响应中的 `opera_operations[]` 数量请后端最终确认;前端不写死两条,只按返回内容刷新展示。
|
||||
- 系统管理菜单树增强接口已完成:`GET /api/admin/menus/tree`、`PUT /api/admin/menus/tree-order`。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | 0.4 |
|
||||
| 日期 | 2026-07-10 |
|
||||
| 状态 | V1 已按当前代码实现更新 |
|
||||
| 文档版本 | 0.6 |
|
||||
| 日期 | 2026-07-16 |
|
||||
| 状态 | V1 已按当前代码实现更新;CP4-4a 菜单树增强接口已完成 |
|
||||
| 适用范围 | 用户、角色、权限、菜单、酒店和用户酒店授权的后台维护 |
|
||||
| 依赖前置 | M003 登录权限与酒店菜单底座、M005 酒店上下文统一收口 |
|
||||
| 主要读者 | 产品、后端、前端、测试、后续协作 agent |
|
||||
@@ -255,6 +255,8 @@ platform.hotel.control
|
||||
| 编辑菜单 | 修改名称、图标、排序、可见性、状态、权限码 |
|
||||
| 新增菜单 | 第一版允许新增菜单配置,包括当前前端尚未注册的菜单 |
|
||||
| 菜单排序 | 支持保存排序号 |
|
||||
| 菜单树查询 | 返回完整菜单树,用于前端左侧树形菜单管理 |
|
||||
| 批量调整父级和排序 | 支持一次性保存菜单父子关系和同级排序 |
|
||||
|
||||
建议限制:
|
||||
|
||||
@@ -262,6 +264,8 @@ platform.hotel.control
|
||||
- 第一版允许新增未知菜单,但未知 `route_path` 不能被视为已可用页面;前端路由不存在时需要展示安全兜底或跳转到无权限 / 未找到页面。
|
||||
- 新增菜单如果配置了未知路由,建议默认 `visible=false` 或 `menu_status=DISABLED`,由管理员在前端支持到位后再开放。
|
||||
- 菜单只决定入口可见性,不替代后端接口权限。
|
||||
- 批量调整菜单树时只允许修改 `parent_id` 和 `sort_order`,不能顺带修改路由、权限、可见性或状态。
|
||||
- 后端必须校验父级存在、禁止把自己设为父级、禁止形成循环树;失败时返回受控业务错误。
|
||||
|
||||
### 6.5 酒店管理后端能力
|
||||
|
||||
@@ -400,6 +404,181 @@ client/src/types/systemAdmin.ts
|
||||
|
||||
前端请求仍统一复用当前 `httpClient`,由它自动携带 Bearer token。
|
||||
|
||||
### 7.7 当前前端交互问题
|
||||
|
||||
当前 `/system` 页面已经接通 V1 后端接口,但页面交互仍偏“开发调试式 CRUD”,主要问题如下:
|
||||
|
||||
- 系统设置入口只有横向子路由 tab,缺少设置中心的信息分组;用户不容易理解“账号权限、菜单导航、酒店配置、审计日志”之间的关系。
|
||||
- 用户、角色、菜单、酒店页面把筛选、新建、列表、详情和编辑堆在同一页,管理员容易迷失当前操作上下文。
|
||||
- 角色、酒店、权限分配使用原生多选框,数据量稍大时难以搜索、比对和确认变更。
|
||||
- 菜单天然是树形导航配置,目前以表格平铺展示,父子关系、排序、可见性和未知路由风险不够直观。
|
||||
- 高风险操作缺少清晰确认,例如重置密码、禁用用户、启用 / 禁用酒店、修改角色权限和开放菜单。
|
||||
- 保存中、保存成功、保存失败、未保存离开、只读原因等状态反馈不够明显。
|
||||
|
||||
### 7.8 系统设置整体低保真结构
|
||||
|
||||
系统设置建议从“每页堆表单”调整为“设置中心 + 模块列表 + 详情抽屉 / 专用编辑区”。除菜单树查询和批量树排序这两个已确认增强点外,其他第一阶段交互调整尽量复用当前 `/api/admin/**` 能力。
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 系统设置 │
|
||||
│ 管理账号、权限、菜单导航、酒店配置和后台审计。 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 分组导航 │
|
||||
│ [账号与权限] [菜单与导航] [酒店配置] [审计日志] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 当前模块标题 [主要操作按钮] │
|
||||
│ 简短说明 / 权限提示 / 当前酒店或系统状态 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 筛选栏:关键词、状态、业务分组、刷新 │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 列表 / 树 / 审计时间线 │ 详情抽屉 / 编辑面板 │
|
||||
│ - 行选中态 │ - 基础信息 │
|
||||
│ - 状态标签 │ - 授权配置 │
|
||||
│ - 操作入口 │ - 变更摘要 │
|
||||
│ │ - 保存 / 取消 / 高风险确认 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
交互规则:
|
||||
|
||||
- 默认进入 `/system` 时仍按当前权限跳转到第一个可访问子页面;后续可以增加 `/system/overview`,但不是 V1 必需项。
|
||||
- 新建动作优先使用抽屉或弹窗,不再常驻占用页面首屏。
|
||||
- 列表行点击后在右侧抽屉展示详情;窄屏下抽屉改为全屏面板。
|
||||
- 编辑和详情放在同一个抽屉里,通过“查看 / 编辑”状态切换,避免页面底部出现第二个编辑表单。
|
||||
- 写操作提交前展示变更摘要;涉及权限、酒店、菜单可见性和密码重置时展示二次确认。
|
||||
- 接口 401 / 403 / 409 / 5xx 使用模块内错误态,不静默失败。
|
||||
|
||||
### 7.9 各模块低保真结构
|
||||
|
||||
#### 用户管理
|
||||
|
||||
```text
|
||||
┌ 用户管理 ─────────────────────────────────────────────────────┐
|
||||
│ [关键词] [状态] [角色] [酒店] [新建用户] │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 用户列表 │ 用户详情抽屉 │
|
||||
│ 用户名 / 展示名 / 状态 │ 基础信息 │
|
||||
│ 角色摘要 / 默认酒店 / 更新时间 │ 角色授权 │
|
||||
│ 操作:查看 │ 酒店授权与默认酒店 │
|
||||
│ │ 安全操作:重置密码 / 禁用 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 角色和酒店授权改为可搜索多选列表,已选项以标签展示。
|
||||
- 默认酒店只能从已授权酒店中选择;未满足时禁用保存并给出明确提示。
|
||||
- 重置密码只在详情抽屉的“安全操作”区域展示,结果只展示一次,不写入普通日志或 URL。
|
||||
|
||||
#### 角色权限
|
||||
|
||||
```text
|
||||
┌ 角色权限 ─────────────────────────────────────────────────────┐
|
||||
│ [关键词] [状态] [新建角色] │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 角色列表 │ 角色详情抽屉 │
|
||||
│ 角色名 / 角色代码 / 内置标记 │ 基础信息 │
|
||||
│ 权限数量 / 使用人数 │ 权限矩阵 │
|
||||
│ │ 变更摘要:新增 / 移除权限 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
权限矩阵建议:
|
||||
|
||||
```text
|
||||
[搜索权限码或名称]
|
||||
|
||||
SYSTEM
|
||||
[ ] 用户管理 SYSTEM_USER_MANAGE
|
||||
[ ] 角色管理 SYSTEM_ROLE_MANAGE
|
||||
[ ] 菜单管理 SYSTEM_MENU_MANAGE
|
||||
|
||||
RESERVATION
|
||||
[ ] 订单读取 RESERVATION_ORDER_READ
|
||||
[ ] 任务确认 RESERVATION_TASK_CONFIRM
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 使用 `permission_group` 分组展示,权限码作为次要信息,不让管理员只面对代码列表。
|
||||
- 内置角色只读时,整块权限矩阵禁用,并展示“内置角色由系统同步,不允许页面修改”。
|
||||
- 保存前展示“将新增 N 个权限、移除 M 个权限”,降低误操作风险。
|
||||
|
||||
#### 菜单管理
|
||||
|
||||
菜单管理建议优先改成树形结构,因为它直接影响侧边栏和子菜单的真实体验。
|
||||
|
||||
```text
|
||||
┌ 菜单管理 ─────────────────────────────────────────────────────┐
|
||||
│ [搜索菜单 / 路由] [状态] [只看可见] [新建菜单] │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 菜单树 │ 菜单配置面板 │
|
||||
│ ▾ 系统设置 │ 基础配置 │
|
||||
│ ├─ 用户管理 可见 ACTIVE │ 路由与组件 │
|
||||
│ ├─ 角色权限 可见 ACTIVE │ 权限与可见性 │
|
||||
│ ├─ 菜单管理 可见 ACTIVE │ 排序与状态 │
|
||||
│ ▾ 订单处理 │ 影响预览 / 未知路由提示 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
菜单配置面板建议字段:
|
||||
|
||||
| 区域 | 字段 | 交互建议 |
|
||||
| --- | --- | --- |
|
||||
| 基础配置 | 菜单名称、菜单代码、父级菜单、图标 | 菜单代码新增后不建议修改;图标使用可搜索选择器 |
|
||||
| 路由与组件 | `route_path`、`component_key`、`known_route` | 未知路由展示风险提示,默认不建议设为可见启用 |
|
||||
| 权限与可见性 | `permission_code`、`visible`、`menu_status` | 明确提示“菜单可见不等于接口授权” |
|
||||
| 排序与预览 | `sort_order`、侧边栏预览 | 保存前预览菜单在侧边栏中的位置 |
|
||||
|
||||
当前 `AdminMenuResult` 已包含 `parent_id`、`sort_order`、`visible` 和 `known_route`。为避免分页列表导致前端树不完整,菜单管理交互升级时建议后端补 `GET /api/admin/menus/tree`;前端树形管理页优先使用后端完整树接口,不再依赖分页列表自行拼完整树。
|
||||
|
||||
#### 酒店管理
|
||||
|
||||
```text
|
||||
┌ 酒店管理 ─────────────────────────────────────────────────────┐
|
||||
│ [关键词] [状态] [新建酒店] │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 酒店列表 │ 酒店详情抽屉 │
|
||||
│ 酒店名 / 状态 / 时区 │ 基础信息 │
|
||||
│ 授权用户数 / 更新时间 │ 启用 / 禁用确认 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 单酒店阶段把“只能有一家 ACTIVE 酒店”作为常驻提示,不放在表单底部。
|
||||
- 启用第二家 ACTIVE 酒店、禁用最后一家 ACTIVE 酒店会由后端返回 409,前端需要把错误展示成可理解的业务提示。
|
||||
|
||||
#### 审计日志
|
||||
|
||||
```text
|
||||
┌ 审计日志 ─────────────────────────────────────────────────────┐
|
||||
│ [对象类型] [对象 ID] [操作类型] [时间范围] [刷新] │
|
||||
├──────────────────────────────┬───────────────────────────────┤
|
||||
│ 审计列表 / 时间线 │ 审计详情抽屉 │
|
||||
│ 操作 / 对象 / 操作人 / 时间 │ 操作前快照 │
|
||||
│ │ 操作后快照 │
|
||||
└──────────────────────────────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
建议:
|
||||
|
||||
- 审计页保持只读,不出现编辑控件。
|
||||
- `before_snapshot_json` / `after_snapshot_json` 可用折叠 JSON 或差异视图展示,但不要展示密码、token、secret。
|
||||
- 如果后端暂不支持时间范围筛选,前端不伪造筛选,只保留当前接口已支持的对象类型、对象 ID 和操作类型。
|
||||
|
||||
### 7.10 动效和状态反馈建议
|
||||
|
||||
动效只服务可用性,建议控制在 160ms - 220ms:
|
||||
|
||||
- 子导航 active indicator 平移动效,帮助用户识别当前模块。
|
||||
- 列表行 hover / selected 使用轻微背景和边框变化。
|
||||
- 详情抽屉进入、关闭、切换编辑状态使用短过渡。
|
||||
- 保存按钮展示 loading;成功后出现短暂 success 状态;失败时在表单顶部固定展示错误。
|
||||
- 权限勾选、菜单可见性开关、酒店状态切换提供明确即时反馈。
|
||||
- 支持 `prefers-reduced-motion`,用户关闭系统动效时前端应降低或取消动画。
|
||||
|
||||
## 8. 接口草案
|
||||
|
||||
正式开发前需要再细化字段、分页格式和错误码。第一版接口前缀建议统一使用 `/api/admin`,先按以下方向设计。
|
||||
@@ -445,11 +624,77 @@ GET /api/admin/permissions
|
||||
```text
|
||||
GET /api/admin/menus
|
||||
GET /api/admin/menus/{menuId}
|
||||
GET /api/admin/menus/tree
|
||||
POST /api/admin/menus
|
||||
PUT /api/admin/menus/{menuId}
|
||||
PUT /api/admin/menus/tree-order
|
||||
```
|
||||
|
||||
说明:菜单排序第一版通过单条菜单的 `sort_order` 字段保存;路由选项接口未做,前端允许手工输入未知路由并通过兜底页保护。
|
||||
说明:菜单排序第一版可以继续通过单条菜单的 `sort_order` 字段保存;树形菜单管理升级后,前端优先使用 `GET /api/admin/menus/tree` 获取完整树,使用 `PUT /api/admin/menus/tree-order` 批量保存父级和排序。路由选项接口未做,前端允许手工输入未知路由并通过兜底页保护。
|
||||
|
||||
菜单树查询建议:
|
||||
|
||||
```text
|
||||
GET /api/admin/menus/tree
|
||||
```
|
||||
|
||||
返回完整菜单树,不分页;默认包含 `ACTIVE` / `DISABLED`、`visible=true` / `false` 的全部菜单。同级排序按 `sort_order` 升序,其次按 `menu_name` 或 `id` 稳定排序。
|
||||
|
||||
建议节点结构沿用现有菜单结果并增加 `children[]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "10001",
|
||||
"parent_id": null,
|
||||
"menu_code": "SYSTEM_SETTINGS",
|
||||
"menu_name": "系统设置",
|
||||
"route_path": "/system",
|
||||
"permission_code": "SYSTEM_ADMIN_CONSOLE_ACCESS",
|
||||
"sort_order": 900,
|
||||
"visible": true,
|
||||
"menu_status": "ACTIVE",
|
||||
"known_route": true,
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
批量调整父级和排序建议:
|
||||
|
||||
```text
|
||||
PUT /api/admin/menus/tree-order
|
||||
```
|
||||
|
||||
请求体建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"menu_id": "10002",
|
||||
"parent_id": "10001",
|
||||
"sort_order": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 必须登录并拥有 `SYSTEM_MENU_MANAGE`。
|
||||
- 只允许修改 `parent_id` 和 `sort_order`。
|
||||
- 使用事务保存。
|
||||
- 校验 `menu_id` 存在。
|
||||
- 校验 `parent_id` 为空或存在。
|
||||
- 禁止把自己设为自己的父级。
|
||||
- 禁止形成循环菜单树。
|
||||
- `sort_order` 可为空;为空时后端按请求 `items[]` 顺序生成稳定排序号 `100`、`200`、`300`...
|
||||
- 成功后返回更新后的完整菜单树,方便前端立即刷新。
|
||||
- 写操作必须写 `platform_admin_audit_log`,审计中记录调整前后的 `parent_id` / `sort_order`。
|
||||
|
||||
### 8.4 酒店管理
|
||||
|
||||
@@ -554,6 +799,27 @@ GET /api/admin/audits
|
||||
|
||||
当前实现状态:已完成。菜单支持新增和编辑;酒店支持新增、编辑和状态切换;前端提供未知路由兜底页。
|
||||
|
||||
### CP4-4a:菜单树增强接口
|
||||
|
||||
目标:支撑前端菜单管理从表格交互升级为“左侧菜单树 + 右侧配置面板”。
|
||||
|
||||
范围:
|
||||
|
||||
- 后端新增 `GET /api/admin/menus/tree`,返回完整菜单树,不分页。
|
||||
- 后端新增 `PUT /api/admin/menus/tree-order`,批量保存菜单父级和排序。
|
||||
- 批量保存时校验父级存在、禁止自引用、禁止循环树。
|
||||
- 批量保存只修改 `parent_id` 和 `sort_order`。
|
||||
- 批量保存写入 `platform_admin_audit_log`。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 前端可以不依赖分页菜单列表,直接渲染完整菜单树。
|
||||
- 拖拽排序或批量调整层级后,前端可以一次性保存并刷新完整树。
|
||||
- 无 token 返回 401,无 `SYSTEM_MENU_MANAGE` 返回 403。
|
||||
- 循环树、自引用、不存在的菜单或父级返回受控业务错误。
|
||||
|
||||
当前实现状态:已完成。后端已提供完整菜单树查询和批量树排序保存;批量保存 `sort_order` 允许为空,后端按请求 `items[]` 顺序生成 `100`、`200`、`300`... 的稳定排序号。
|
||||
|
||||
### CP4-4b:管理操作审计页
|
||||
|
||||
目标:系统管理员可以查看管理后台写操作审计。
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
| `/api/admin/users/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_USER_MANAGE` | 保持;禁用用户撤销 session | 写操作必须记录 `platform_admin_audit_log` |
|
||||
| `/api/admin/roles/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持;内置角色只读 | 写操作必须记录管理审计 |
|
||||
| `/api/admin/permissions` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持只读;前端不能自造权限码 | 不需要写审计 |
|
||||
| `/api/admin/menus/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_MENU_MANAGE` | 保持;菜单可见性不替代后端权限 | 写操作必须记录管理审计 |
|
||||
| `/api/admin/menus/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_MENU_MANAGE`;`GET /tree`、`PUT /tree-order` 已沿用该权限 | 保持;菜单可见性不替代后端权限;批量树排序只允许修改 `parent_id` 和 `sort_order`,`sort_order` 为空时按请求顺序生成稳定排序 | 写操作必须记录管理审计,树排序审计记录调整前后的父级和排序;树查询不写审计 |
|
||||
| `/api/admin/hotels/**` | `FRONTEND_ADMIN` | 已强制登录和 `HOTEL_MANAGE` | 保持;单酒店阶段只能一家 `ACTIVE` | 写操作必须记录管理审计 |
|
||||
| `GET /api/admin/audits` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ADMIN_CONSOLE_ACCESS` | 保持;不返回 Secret、密码或 token | 查询审计不再写审计 |
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.platform.navigation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 管理后台单个菜单树排序调整项。只允许调整父级和排序。
|
||||
*/
|
||||
public record AdminMenuTreeOrderItemRequest(
|
||||
/** 被调整菜单内部 ID 字符串。 */
|
||||
@JsonProperty("menu_id")
|
||||
String menuId,
|
||||
/** 新父菜单内部 ID 字符串;根菜单传 null。 */
|
||||
@JsonProperty("parent_id")
|
||||
String parentId,
|
||||
/** 新排序号;为空时按请求顺序生成稳定排序号。 */
|
||||
@JsonProperty("sort_order")
|
||||
Integer sortOrder
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.platform.navigation.common.request;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理后台批量调整菜单父级和排序请求。
|
||||
*/
|
||||
public record AdminMenuTreeOrderUpdateRequest(
|
||||
/** 批量调整项列表。 */
|
||||
List<AdminMenuTreeOrderItemRequest> items
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.nianxx.thhotel.platform.navigation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理后台菜单树节点结果。字段沿用菜单结果,并补充子节点列表。
|
||||
*/
|
||||
public record AdminMenuTreeNodeResult(
|
||||
/** 菜单内部 ID 字符串。 */
|
||||
String id,
|
||||
/** 父菜单内部 ID 字符串。 */
|
||||
@JsonProperty("parent_id")
|
||||
String parentId,
|
||||
/** 稳定菜单代码。 */
|
||||
@JsonProperty("menu_code")
|
||||
String menuCode,
|
||||
/** 菜单展示名称。 */
|
||||
@JsonProperty("menu_name")
|
||||
String menuName,
|
||||
/** 菜单类型。 */
|
||||
@JsonProperty("menu_type")
|
||||
String menuType,
|
||||
/** 前端路由路径。 */
|
||||
@JsonProperty("route_path")
|
||||
String routePath,
|
||||
/** 前端组件标识。 */
|
||||
@JsonProperty("component_key")
|
||||
String componentKey,
|
||||
/** 前端图标标识。 */
|
||||
@JsonProperty("icon_key")
|
||||
String iconKey,
|
||||
/** 菜单入口权限码。 */
|
||||
@JsonProperty("permission_code")
|
||||
String permissionCode,
|
||||
/** 排序号。 */
|
||||
@JsonProperty("sort_order")
|
||||
Integer sortOrder,
|
||||
/** 是否菜单可见。 */
|
||||
Boolean visible,
|
||||
/** 菜单状态。 */
|
||||
@JsonProperty("menu_status")
|
||||
String menuStatus,
|
||||
/** 是否当前前端已知路由。 */
|
||||
@JsonProperty("known_route")
|
||||
Boolean knownRoute,
|
||||
/** 创建 UTC 时间。 */
|
||||
@JsonProperty("created_at")
|
||||
OffsetDateTime createdAt,
|
||||
/** 更新 UTC 时间。 */
|
||||
@JsonProperty("updated_at")
|
||||
OffsetDateTime updatedAt,
|
||||
/** 子菜单节点。 */
|
||||
List<AdminMenuTreeNodeResult> children
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.nianxx.thhotel.platform.navigation.common.result;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理后台完整菜单树结果。warnings 用于返回脏数据降级提示,避免查询 500。
|
||||
*/
|
||||
public record AdminMenuTreeResult(
|
||||
/** 根级菜单节点列表。 */
|
||||
List<AdminMenuTreeNodeResult> items,
|
||||
/** 菜单树脏数据或降级处理提示。 */
|
||||
List<String> warnings
|
||||
) {
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package cn.nianxx.thhotel.platform.navigation.control;
|
||||
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
|
||||
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService;
|
||||
@@ -51,6 +53,15 @@ public class AdminMenuController {
|
||||
return menuManagementService.queryMenus(keyword, menuStatus, pageNum, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询完整菜单树,需要菜单管理权限;只读查询不写管理审计。
|
||||
*/
|
||||
@GetMapping(value = "/tree", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public AdminMenuTreeResult listMenuTree() {
|
||||
authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_MENU_MANAGE.name());
|
||||
return menuManagementService.queryMenuTree();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询菜单详情,需要菜单管理权限。
|
||||
*/
|
||||
@@ -70,6 +81,17 @@ public class AdminMenuController {
|
||||
return menuManagementService.createMenu(request, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调整菜单树父级和排序,需要菜单管理权限,并写入管理审计。
|
||||
*/
|
||||
@PutMapping(value = "/tree-order", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public AdminMenuTreeResult updateMenuTreeOrder(
|
||||
@RequestBody(required = false) AdminMenuTreeOrderUpdateRequest request) {
|
||||
AuthenticatedUserContext actor = authorizationService.requirePermission(
|
||||
PlatformPermissionCode.SYSTEM_MENU_MANAGE.name());
|
||||
return menuManagementService.updateMenuTreeOrder(request, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑菜单配置,需要菜单管理权限,菜单代码不允许修改。
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package cn.nianxx.thhotel.platform.navigation.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,11 @@ public interface AdminMenuManagementService {
|
||||
*/
|
||||
AdminMenuResult getMenu(String menuId);
|
||||
|
||||
/**
|
||||
* 查询完整菜单树,不分页。
|
||||
*/
|
||||
AdminMenuTreeResult queryMenuTree();
|
||||
|
||||
/**
|
||||
* 新增菜单定义。
|
||||
*/
|
||||
@@ -34,4 +41,11 @@ public interface AdminMenuManagementService {
|
||||
* 编辑菜单定义,菜单代码不允许修改。
|
||||
*/
|
||||
AdminMenuResult updateMenu(String menuId, AdminMenuUpdateRequest request, AuthenticatedUserContext actor);
|
||||
|
||||
/**
|
||||
* 批量调整菜单父级和排序。
|
||||
*/
|
||||
AdminMenuTreeResult updateMenuTreeOrder(
|
||||
AdminMenuTreeOrderUpdateRequest request,
|
||||
AuthenticatedUserContext actor);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@ import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult;
|
||||
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderItemRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeNodeResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
|
||||
import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity;
|
||||
import cn.nianxx.thhotel.platform.navigation.repository.PlatformNavigationRepository;
|
||||
import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService;
|
||||
@@ -21,7 +25,15 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -36,6 +48,9 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
private static final int DEFAULT_PAGE_NUM = 1;
|
||||
private static final int DEFAULT_PAGE_SIZE = 20;
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
private static final int GENERATED_SORT_ORDER_STEP = 100;
|
||||
private static final String TARGET_TYPE_PLATFORM_MENU = "PLATFORM_MENU";
|
||||
private static final String ACTION_UPDATE_MENU_TREE_ORDER = "UPDATE_MENU_TREE_ORDER";
|
||||
private static final Set<String> KNOWN_ROUTES = Set.of(
|
||||
"/reservation/orders",
|
||||
"/reservation/tasks",
|
||||
@@ -99,6 +114,14 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
.orElseThrow(() -> notFound("菜单不存在。"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询完整菜单树。脏父级数据会降级为根级异常节点并返回 warnings,避免前端管理页 500。
|
||||
*/
|
||||
@Override
|
||||
public AdminMenuTreeResult queryMenuTree() {
|
||||
return buildMenuTree(navigationRepository.listAllMenus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增菜单定义。菜单代码作为稳定业务键,一旦创建不提供修改入口。
|
||||
*/
|
||||
@@ -148,6 +171,74 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
return after;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量调整菜单父级和排序。只更新 parent_id、sort_order 和 updated_at,并逐项写管理审计。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public AdminMenuTreeResult updateMenuTreeOrder(
|
||||
AdminMenuTreeOrderUpdateRequest request,
|
||||
AuthenticatedUserContext actor) {
|
||||
if (request == null || request.items() == null || request.items().isEmpty()) {
|
||||
throw invalidRequest("菜单树排序请求不能为空。");
|
||||
}
|
||||
List<PlatformMenuEntity> allMenus = navigationRepository.listAllMenus();
|
||||
Map<Long, PlatformMenuEntity> menuById = menuById(allMenus);
|
||||
Map<Long, Long> proposedParentById = new HashMap<>();
|
||||
for (PlatformMenuEntity menu : allMenus) {
|
||||
proposedParentById.put(menu.getId(), menu.getParentId());
|
||||
}
|
||||
|
||||
Set<Long> requestedMenuIds = new HashSet<>();
|
||||
List<MenuTreeOrderChange> changes = new ArrayList<>();
|
||||
for (int index = 0; index < request.items().size(); index++) {
|
||||
AdminMenuTreeOrderItemRequest item = request.items().get(index);
|
||||
if (item == null) {
|
||||
throw invalidRequest("菜单树排序项不能为空。");
|
||||
}
|
||||
Long menuId = parseId(item.menuId(), "菜单 ID 不合法。");
|
||||
Long parentId = parseNullableId(item.parentId(), "父菜单 ID 不合法。");
|
||||
if (!requestedMenuIds.add(menuId)) {
|
||||
throw invalidRequest("菜单树排序请求中存在重复菜单 ID。");
|
||||
}
|
||||
PlatformMenuEntity menu = menuById.get(menuId);
|
||||
if (menu == null) {
|
||||
throw notFound("菜单不存在。");
|
||||
}
|
||||
if (parentId != null && !menuById.containsKey(parentId)) {
|
||||
throw invalidRequest("父菜单不存在。");
|
||||
}
|
||||
if (menuId.equals(parentId)) {
|
||||
throw invalidRequest("不能把菜单设为自己的父级。");
|
||||
}
|
||||
Integer sortOrder = item.sortOrder() == null ? (index + 1) * GENERATED_SORT_ORDER_STEP : item.sortOrder();
|
||||
proposedParentById.put(menuId, parentId);
|
||||
changes.add(new MenuTreeOrderChange(
|
||||
menu,
|
||||
treeOrderSnapshot(menu.getParentId(), menu.getSortOrder()),
|
||||
treeOrderSnapshot(parentId, sortOrder),
|
||||
parentId,
|
||||
sortOrder));
|
||||
}
|
||||
validateNoMenuTreeCycle(proposedParentById);
|
||||
|
||||
LocalDateTime now = nowUtc();
|
||||
for (MenuTreeOrderChange change : changes) {
|
||||
change.menu().setParentId(change.parentId());
|
||||
change.menu().setSortOrder(change.sortOrder());
|
||||
change.menu().setUpdatedAt(now);
|
||||
navigationRepository.updateMenu(change.menu());
|
||||
audit(
|
||||
actor,
|
||||
TARGET_TYPE_PLATFORM_MENU,
|
||||
stringId(change.menu().getId()),
|
||||
ACTION_UPDATE_MENU_TREE_ORDER,
|
||||
change.beforeSnapshot(),
|
||||
change.afterSnapshot());
|
||||
}
|
||||
return buildMenuTree(navigationRepository.listAllMenus());
|
||||
}
|
||||
|
||||
private AdminMenuResult toResult(PlatformMenuEntity menu) {
|
||||
return new AdminMenuResult(
|
||||
stringId(menu.getId()),
|
||||
@@ -167,6 +258,131 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(menu.getUpdatedAt()));
|
||||
}
|
||||
|
||||
private AdminMenuTreeResult buildMenuTree(List<PlatformMenuEntity> menus) {
|
||||
List<PlatformMenuEntity> sortedMenus = menus == null
|
||||
? List.of()
|
||||
: menus.stream().filter(Objects::nonNull).sorted(menuComparator()).toList();
|
||||
Map<Long, PlatformMenuEntity> menuById = menuById(sortedMenus);
|
||||
Map<Long, List<PlatformMenuEntity>> childrenByParentId = new HashMap<>();
|
||||
List<PlatformMenuEntity> roots = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
for (PlatformMenuEntity menu : sortedMenus) {
|
||||
Long parentId = menu.getParentId();
|
||||
if (parentId == null) {
|
||||
roots.add(menu);
|
||||
} else if (!menuById.containsKey(parentId)) {
|
||||
roots.add(menu);
|
||||
warnings.add("菜单 " + stringId(menu.getId()) + " 的 parent_id=" + parentId
|
||||
+ " 不存在,已按根级异常节点返回。");
|
||||
} else {
|
||||
childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(menu);
|
||||
}
|
||||
}
|
||||
sortMenus(roots);
|
||||
childrenByParentId.values().forEach(this::sortMenus);
|
||||
|
||||
Set<Long> emittedMenuIds = new HashSet<>();
|
||||
List<AdminMenuTreeNodeResult> items = new ArrayList<>();
|
||||
for (PlatformMenuEntity root : roots) {
|
||||
items.add(toTreeNode(root, childrenByParentId, new HashSet<>(), emittedMenuIds, warnings));
|
||||
}
|
||||
for (PlatformMenuEntity menu : sortedMenus) {
|
||||
if (menu.getId() != null && !emittedMenuIds.contains(menu.getId())) {
|
||||
warnings.add("菜单 " + stringId(menu.getId()) + " 未能从根节点连接,已按根级异常节点返回。");
|
||||
items.add(toTreeNode(menu, childrenByParentId, new HashSet<>(), emittedMenuIds, warnings));
|
||||
}
|
||||
}
|
||||
return new AdminMenuTreeResult(items, warnings);
|
||||
}
|
||||
|
||||
private AdminMenuTreeNodeResult toTreeNode(
|
||||
PlatformMenuEntity menu,
|
||||
Map<Long, List<PlatformMenuEntity>> childrenByParentId,
|
||||
Set<Long> visitingMenuIds,
|
||||
Set<Long> emittedMenuIds,
|
||||
List<String> warnings) {
|
||||
Long menuId = menu.getId();
|
||||
if (menuId != null) {
|
||||
emittedMenuIds.add(menuId);
|
||||
visitingMenuIds.add(menuId);
|
||||
}
|
||||
List<AdminMenuTreeNodeResult> children = new ArrayList<>();
|
||||
for (PlatformMenuEntity child : childrenByParentId.getOrDefault(menuId, List.of())) {
|
||||
if (child.getId() != null && visitingMenuIds.contains(child.getId())) {
|
||||
warnings.add("菜单 " + stringId(child.getId()) + " 存在循环父级,已截断循环子节点。");
|
||||
continue;
|
||||
}
|
||||
children.add(toTreeNode(child, childrenByParentId, visitingMenuIds, emittedMenuIds, warnings));
|
||||
}
|
||||
if (menuId != null) {
|
||||
visitingMenuIds.remove(menuId);
|
||||
}
|
||||
AdminMenuResult base = toResult(menu);
|
||||
return new AdminMenuTreeNodeResult(
|
||||
base.id(),
|
||||
base.parentId(),
|
||||
base.menuCode(),
|
||||
base.menuName(),
|
||||
base.menuType(),
|
||||
base.routePath(),
|
||||
base.componentKey(),
|
||||
base.iconKey(),
|
||||
base.permissionCode(),
|
||||
base.sortOrder(),
|
||||
base.visible(),
|
||||
base.menuStatus(),
|
||||
base.knownRoute(),
|
||||
base.createdAt(),
|
||||
base.updatedAt(),
|
||||
children);
|
||||
}
|
||||
|
||||
private Map<Long, PlatformMenuEntity> menuById(List<PlatformMenuEntity> menus) {
|
||||
Map<Long, PlatformMenuEntity> menuById = new HashMap<>();
|
||||
for (PlatformMenuEntity menu : menus) {
|
||||
if (menu != null && menu.getId() != null) {
|
||||
menuById.put(menu.getId(), menu);
|
||||
}
|
||||
}
|
||||
return menuById;
|
||||
}
|
||||
|
||||
private void validateNoMenuTreeCycle(Map<Long, Long> parentById) {
|
||||
for (Long menuId : parentById.keySet()) {
|
||||
Set<Long> path = new HashSet<>();
|
||||
Long current = menuId;
|
||||
while (current != null) {
|
||||
if (!path.add(current)) {
|
||||
throw invalidRequest("菜单树不能形成循环。");
|
||||
}
|
||||
Long parentId = parentById.get(current);
|
||||
if (parentId == null || !parentById.containsKey(parentId)) {
|
||||
break;
|
||||
}
|
||||
current = parentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> treeOrderSnapshot(Long parentId, Integer sortOrder) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("parent_id", stringId(parentId));
|
||||
snapshot.put("sort_order", sortOrder);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private void sortMenus(List<PlatformMenuEntity> menus) {
|
||||
menus.sort(menuComparator());
|
||||
}
|
||||
|
||||
private Comparator<PlatformMenuEntity> menuComparator() {
|
||||
return Comparator
|
||||
.comparing(PlatformMenuEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(PlatformMenuEntity::getMenuName, Comparator.nullsLast(String::compareTo))
|
||||
.thenComparing(PlatformMenuEntity::getId, Comparator.nullsLast(Long::compareTo));
|
||||
}
|
||||
|
||||
private int normalizePageNum(Integer pageNum) {
|
||||
return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum;
|
||||
}
|
||||
@@ -186,8 +402,12 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
}
|
||||
|
||||
private Long parseId(String id, String message) {
|
||||
String normalized = trimToNull(id);
|
||||
if (normalized == null) {
|
||||
throw invalidRequest(message);
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(id);
|
||||
return Long.valueOf(normalized);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message);
|
||||
}
|
||||
@@ -322,4 +542,13 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
|
||||
"系统管理审计序列化失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private record MenuTreeOrderChange(
|
||||
PlatformMenuEntity menu,
|
||||
Map<String, Object> beforeSnapshot,
|
||||
Map<String, Object> afterSnapshot,
|
||||
Long parentId,
|
||||
Integer sortOrder
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,15 @@ import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus;
|
||||
import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuType;
|
||||
import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity;
|
||||
import cn.nianxx.thhotel.platform.navigation.repository.PlatformNavigationRepository;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -59,6 +65,8 @@ class AdminReadonlyControllerTest {
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
@Autowired
|
||||
private PlatformNavigationRepository navigationRepository;
|
||||
@Autowired
|
||||
private AuthPasswordService passwordService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -440,6 +448,218 @@ class AdminReadonlyControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueryCompleteMenuTreeWithStableSortingAndWarnings() throws Exception {
|
||||
String token = tokenFrom(login("m006-admin", "Admin@123456"));
|
||||
String suffix = String.valueOf(System.nanoTime());
|
||||
PlatformMenuEntity root = insertMenu("M006_TREE_ROOT_" + suffix, null, "树根", 500,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity beta = insertMenu("M006_TREE_BETA_" + suffix, root.getId(), "Beta 子菜单", 100,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity alpha = insertMenu("M006_TREE_ALPHA_" + suffix, root.getId(), "Alpha 子菜单", 100,
|
||||
false, PlatformMenuStatus.DISABLED.name());
|
||||
PlatformMenuEntity orphan = insertMenu("M006_TREE_ORPHAN_" + suffix, Long.MAX_VALUE - 10, "孤儿菜单", 700,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/api/admin/menus/tree")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode rootNode = requireMenuNode(json.path("items"), root.getMenuCode());
|
||||
Assertions.assertEquals(alpha.getMenuCode(), rootNode.path("children").get(0).path("menu_code").asText());
|
||||
Assertions.assertEquals(beta.getMenuCode(), rootNode.path("children").get(1).path("menu_code").asText());
|
||||
JsonNode alphaNode = requireMenuNode(json.path("items"), alpha.getMenuCode());
|
||||
Assertions.assertFalse(alphaNode.path("visible").asBoolean());
|
||||
Assertions.assertEquals(PlatformMenuStatus.DISABLED.name(), alphaNode.path("menu_status").asText());
|
||||
JsonNode orphanNode = requireMenuNode(json.path("items"), orphan.getMenuCode());
|
||||
Assertions.assertEquals(orphan.getId().toString(), orphanNode.path("id").asText());
|
||||
Assertions.assertTrue(json.path("warnings").size() >= 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMenuTreeQueryWhenTokenMissingOrPermissionMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/menus/tree"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_AUTH_REQUIRED"));
|
||||
|
||||
String viewerToken = tokenFrom(login("m006-viewer", "Viewer@123456"));
|
||||
mockMvc.perform(get("/api/admin/menus/tree")
|
||||
.header("Authorization", "Bearer " + viewerToken))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdateMenuTreeOrderAndWriteAdminAudit() throws Exception {
|
||||
String token = tokenFrom(login("m006-admin", "Admin@123456"));
|
||||
String suffix = String.valueOf(System.nanoTime());
|
||||
PlatformMenuEntity oldRoot = insertMenu("M006_ORDER_OLD_" + suffix, null, "旧父级", 610,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity newRoot = insertMenu("M006_ORDER_NEW_" + suffix, null, "新父级", 620,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity child = insertMenu("M006_ORDER_CHILD_" + suffix, oldRoot.getId(), "待移动菜单", 630,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
|
||||
MvcResult updateResult = mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"menu_id": "%s",
|
||||
"parent_id": "%s",
|
||||
"sort_order": 123
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(child.getId(), newRoot.getId())))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode json = objectMapper.readTree(updateResult.getResponse().getContentAsString());
|
||||
JsonNode newRootNode = requireMenuNode(json.path("items"), newRoot.getMenuCode());
|
||||
JsonNode movedChildNode = requireMenuNode(newRootNode.path("children"), child.getMenuCode());
|
||||
Assertions.assertEquals("123", movedChildNode.path("sort_order").asText());
|
||||
Assertions.assertEquals(newRoot.getId().toString(), movedChildNode.path("parent_id").asText());
|
||||
|
||||
mockMvc.perform(get("/api/admin/audits")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.param("target_type", "PLATFORM_MENU")
|
||||
.param("target_id", child.getId().toString())
|
||||
.param("action", "UPDATE_MENU_TREE_ORDER"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items[0].before_snapshot_json").value(org.hamcrest.Matchers.containsString("parent_id")))
|
||||
.andExpect(jsonPath("$.items[0].before_snapshot_json").value(org.hamcrest.Matchers.containsString(oldRoot.getId().toString())))
|
||||
.andExpect(jsonPath("$.items[0].after_snapshot_json").value(org.hamcrest.Matchers.containsString("parent_id")))
|
||||
.andExpect(jsonPath("$.items[0].after_snapshot_json").value(org.hamcrest.Matchers.containsString(newRoot.getId().toString())))
|
||||
.andExpect(jsonPath("$.items[0].after_snapshot_json").value(org.hamcrest.Matchers.containsString("sort_order")))
|
||||
.andExpect(jsonPath("$.items[0].after_snapshot_json").value(org.hamcrest.Matchers.containsString("123")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGenerateStableSortOrderWhenTreeOrderSortOrderMissing() throws Exception {
|
||||
String token = tokenFrom(login("m006-admin", "Admin@123456"));
|
||||
String suffix = String.valueOf(System.nanoTime());
|
||||
PlatformMenuEntity root = insertMenu("M006_SORT_ROOT_" + suffix, null, "排序父级", 640,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity first = insertMenu("M006_SORT_FIRST_" + suffix, null, "排序一", 10,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity second = insertMenu("M006_SORT_SECOND_" + suffix, null, "排序二", 20,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
|
||||
MvcResult updateResult = mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{ "menu_id": "%s", "parent_id": "%s" },
|
||||
{ "menu_id": "%s", "parent_id": "%s" }
|
||||
]
|
||||
}
|
||||
""".formatted(first.getId(), root.getId(), second.getId(), root.getId())))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode json = objectMapper.readTree(updateResult.getResponse().getContentAsString());
|
||||
JsonNode rootNode = requireMenuNode(json.path("items"), root.getMenuCode());
|
||||
Assertions.assertEquals(first.getMenuCode(), rootNode.path("children").get(0).path("menu_code").asText());
|
||||
Assertions.assertEquals(100, rootNode.path("children").get(0).path("sort_order").asInt());
|
||||
Assertions.assertEquals(second.getMenuCode(), rootNode.path("children").get(1).path("menu_code").asText());
|
||||
Assertions.assertEquals(200, rootNode.path("children").get(1).path("sort_order").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidMenuTreeOrderRequests() throws Exception {
|
||||
String token = tokenFrom(login("m006-admin", "Admin@123456"));
|
||||
String suffix = String.valueOf(System.nanoTime());
|
||||
PlatformMenuEntity parent = insertMenu("M006_INVALID_PARENT_" + suffix, null, "父级", 650,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
PlatformMenuEntity child = insertMenu("M006_INVALID_CHILD_" + suffix, parent.getId(), "子级", 660,
|
||||
true, PlatformMenuStatus.ACTIVE.name());
|
||||
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{ "menu_id": "%s", "parent_id": "%s", "sort_order": 100 }
|
||||
]
|
||||
}
|
||||
""".formatted(parent.getId(), parent.getId())))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_INVALID_REQUEST"));
|
||||
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{ "menu_id": "%s", "parent_id": "9223372036854770000", "sort_order": 100 }
|
||||
]
|
||||
}
|
||||
""".formatted(parent.getId())))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_INVALID_REQUEST"));
|
||||
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{ "menu_id": "%s", "parent_id": "%s", "sort_order": 100 }
|
||||
]
|
||||
}
|
||||
""".formatted(parent.getId(), child.getId())))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_INVALID_REQUEST"));
|
||||
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": [
|
||||
{ "menu_id": "9223372036854770000", "parent_id": null, "sort_order": 100 }
|
||||
]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_TARGET_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTreeOrderWhenTokenMissingOrPermissionMissing() throws Exception {
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_AUTH_REQUIRED"));
|
||||
|
||||
String viewerToken = tokenFrom(login("m006-viewer", "Viewer@123456"));
|
||||
|
||||
mockMvc.perform(put("/api/admin/menus/tree-order")
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("ADMIN_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateDisabledHotelAndRejectBreakingSingleActiveHotelRule() throws Exception {
|
||||
String token = tokenFrom(login("m006-admin", "Admin@123456"));
|
||||
@@ -514,4 +734,52 @@ class AdminReadonlyControllerTest {
|
||||
accessRepository.insertPermission(permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
private PlatformMenuEntity insertMenu(
|
||||
String menuCode,
|
||||
Long parentId,
|
||||
String menuName,
|
||||
int sortOrder,
|
||||
boolean visible,
|
||||
String menuStatus) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformMenuEntity menu = new PlatformMenuEntity();
|
||||
menu.setParentId(parentId);
|
||||
menu.setMenuCode(menuCode);
|
||||
menu.setMenuName(menuName);
|
||||
menu.setMenuType(PlatformMenuType.PAGE.name());
|
||||
menu.setRoutePath("/system/menus");
|
||||
menu.setComponentKey("SystemMenus");
|
||||
menu.setIconKey("pi pi-sitemap");
|
||||
menu.setPermissionCode("SYSTEM_MENU_MANAGE");
|
||||
menu.setSortOrder(sortOrder);
|
||||
menu.setVisible(visible);
|
||||
menu.setMenuStatus(menuStatus);
|
||||
menu.setCreatedAt(now);
|
||||
menu.setUpdatedAt(now);
|
||||
navigationRepository.insertMenu(menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
private JsonNode requireMenuNode(JsonNode nodes, String menuCode) {
|
||||
JsonNode found = findMenuNode(nodes, menuCode);
|
||||
Assertions.assertTrue(Objects.nonNull(found), "未找到菜单节点:" + menuCode);
|
||||
return found;
|
||||
}
|
||||
|
||||
private JsonNode findMenuNode(JsonNode nodes, String menuCode) {
|
||||
if (nodes == null || !nodes.isArray()) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode node : nodes) {
|
||||
if (menuCode.equals(node.path("menu_code").asText())) {
|
||||
return node;
|
||||
}
|
||||
JsonNode child = findMenuNode(node.path("children"), menuCode);
|
||||
if (child != null) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user