From 5390b8f71b7fd9c2a5167929f33fb3b3dc4d6670 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 16 Jul 2026 11:57:25 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=B3=BB=E7=BB=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=8F=9C=E5=8D=95=E6=A0=91=E5=A2=9E=E5=BC=BA=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend-to-frontend-notes.md | 4 +- .../frontend-to-backend-api-requests.md | 135 ++++++++- ...M006-system-admin-management-console-v1.md | 274 +++++++++++++++++- .../security-access-control-boundary.md | 2 +- .../AdminMenuTreeOrderItemRequest.java | 19 ++ .../AdminMenuTreeOrderUpdateRequest.java | 12 + .../result/AdminMenuTreeNodeResult.java | 57 ++++ .../common/result/AdminMenuTreeResult.java | 14 + .../control/AdminMenuController.java | 22 ++ .../service/AdminMenuManagementService.java | 14 + .../impl/AdminMenuManagementServiceImpl.java | 231 ++++++++++++++- .../control/AdminReadonlyControllerTest.java | 268 +++++++++++++++++ 12 files changed, 1042 insertions(+), 10 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderItemRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderUpdateRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeNodeResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeResult.java diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 984c735..e972f63 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -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` 查询。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index f98e096..79f21c9 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -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 ` | +| 权限 | 需要 `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 ` | +| 权限 | 需要 `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`。 diff --git a/docs/project/requirements/M006-system-admin-management-console-v1.md b/docs/project/requirements/M006-system-admin-management-console-v1.md index 6d10513..e64b59a 100644 --- a/docs/project/requirements/M006-system-admin-management-console-v1.md +++ b/docs/project/requirements/M006-system-admin-management-console-v1.md @@ -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:管理操作审计页 目标:系统管理员可以查看管理后台写操作审计。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index cb47c8e..44aa542 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -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 | 查询审计不再写审计 | diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderItemRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderItemRequest.java new file mode 100644 index 0000000..8068d4e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderItemRequest.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderUpdateRequest.java new file mode 100644 index 0000000..059ea1d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/request/AdminMenuTreeOrderUpdateRequest.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.navigation.common.request; + +import java.util.List; + +/** + * 管理后台批量调整菜单父级和排序请求。 + */ +public record AdminMenuTreeOrderUpdateRequest( + /** 批量调整项列表。 */ + List items +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeNodeResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeNodeResult.java new file mode 100644 index 0000000..3297870 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeNodeResult.java @@ -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 children +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeResult.java new file mode 100644 index 0000000..e5717cf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/result/AdminMenuTreeResult.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.navigation.common.result; + +import java.util.List; + +/** + * 管理后台完整菜单树结果。warnings 用于返回脏数据降级提示,避免查询 500。 + */ +public record AdminMenuTreeResult( + /** 根级菜单节点列表。 */ + List items, + /** 菜单树脏数据或降级处理提示。 */ + List warnings +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java index 877396c..1f06aeb 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/control/AdminMenuController.java @@ -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); + } + /** * 编辑菜单配置,需要菜单管理权限,菜单代码不允许修改。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java index 2c5573d..d17fcc5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/AdminMenuManagementService.java @@ -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); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java index 1284cd5..a58fe77 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/AdminMenuManagementServiceImpl.java @@ -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 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 allMenus = navigationRepository.listAllMenus(); + Map menuById = menuById(allMenus); + Map proposedParentById = new HashMap<>(); + for (PlatformMenuEntity menu : allMenus) { + proposedParentById.put(menu.getId(), menu.getParentId()); + } + + Set requestedMenuIds = new HashSet<>(); + List 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 menus) { + List sortedMenus = menus == null + ? List.of() + : menus.stream().filter(Objects::nonNull).sorted(menuComparator()).toList(); + Map menuById = menuById(sortedMenus); + Map> childrenByParentId = new HashMap<>(); + List roots = new ArrayList<>(); + List 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 emittedMenuIds = new HashSet<>(); + List 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> childrenByParentId, + Set visitingMenuIds, + Set emittedMenuIds, + List warnings) { + Long menuId = menu.getId(); + if (menuId != null) { + emittedMenuIds.add(menuId); + visitingMenuIds.add(menuId); + } + List 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 menuById(List menus) { + Map menuById = new HashMap<>(); + for (PlatformMenuEntity menu : menus) { + if (menu != null && menu.getId() != null) { + menuById.put(menu.getId(), menu); + } + } + return menuById; + } + + private void validateNoMenuTreeCycle(Map parentById) { + for (Long menuId : parentById.keySet()) { + Set 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 treeOrderSnapshot(Long parentId, Integer sortOrder) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("parent_id", stringId(parentId)); + snapshot.put("sort_order", sortOrder); + return snapshot; + } + + private void sortMenus(List menus) { + menus.sort(menuComparator()); + } + + private Comparator 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 beforeSnapshot, + Map afterSnapshot, + Long parentId, + Integer sortOrder + ) { + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java index 2d9e81b..dd158ac 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/AdminReadonlyControllerTest.java @@ -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; + } }