From 666fb41328de16fe5c449c19c07c478aa8230137 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 9 Jul 2026 19:31:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=99=BB=E5=BD=95=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/frontend-backend/README.md | 4 +- .../backend-to-frontend-notes.md | 43 +- docs/project/go-live-notes.md | 43 +- .../M003-identity-access-hotel-menu-v1.md | 666 ++++++++++++++++++ server/pom.xml | 4 + .../cn/nianxx/thhotel/ThHotelApplication.java | 4 + .../common/enums/PlatformPermissionCode.java | 22 + .../enums/PlatformPermissionStatus.java | 9 + .../access/common/enums/PlatformRoleCode.java | 10 + .../common/enums/PlatformRoleStatus.java | 9 + .../domain/PlatformPermissionEntity.java | 48 ++ .../access/domain/PlatformRoleEntity.java | 44 ++ .../domain/PlatformRolePermissionEntity.java | 32 + .../access/domain/PlatformUserRoleEntity.java | 32 + .../mapper/PlatformPermissionMapper.java | 10 + .../access/mapper/PlatformRoleMapper.java | 10 + .../mapper/PlatformRolePermissionMapper.java | 10 + .../access/mapper/PlatformUserRoleMapper.java | 10 + .../MybatisPlatformAccessRepository.java | 148 ++++ .../repository/PlatformAccessRepository.java | 57 ++ .../access/service/AccessControlService.java | 19 + .../impl/AccessControlServiceImpl.java | 53 ++ .../common/enums/PlatformHotelStatus.java | 9 + .../hotel/domain/PlatformHotelEntity.java | 48 ++ .../hotel/domain/PlatformUserHotelEntity.java | 36 + .../hotel/mapper/PlatformHotelMapper.java | 10 + .../hotel/mapper/PlatformUserHotelMapper.java | 10 + .../MybatisPlatformHotelRepository.java | 100 +++ .../repository/PlatformHotelRepository.java | 46 ++ .../hotel/service/HotelAccessService.java | 26 + .../service/impl/HotelAccessServiceImpl.java | 102 +++ .../common/dto/AuthSessionSnapshot.java | 17 + .../enums/PlatformUserSessionStatus.java | 10 + .../common/enums/PlatformUserStatus.java | 9 + .../common/request/AuthLoginRequest.java | 20 + .../common/result/AuthErrorResponse.java | 15 + .../common/result/AuthHotelResult.java | 22 + .../common/result/AuthLoginResult.java | 32 + .../common/result/AuthLogoutResult.java | 10 + .../identity/common/result/AuthMeResult.java | 22 + .../common/result/AuthMenuResult.java | 31 + .../common/result/AuthUserResult.java | 20 + .../identity/control/AuthController.java | 61 ++ .../control/AuthControllerAdvice.java | 34 + .../identity/domain/PlatformUserEntity.java | 64 ++ .../domain/PlatformUserSessionEntity.java | 64 ++ .../identity/mapper/PlatformUserMapper.java | 10 + .../mapper/PlatformUserSessionMapper.java | 10 + .../MybatisPlatformIdentityRepository.java | 74 ++ .../PlatformIdentityRepository.java | 51 ++ .../identity/service/AuthService.java | 35 + .../identity/service/impl/AuthException.java | 26 + .../service/impl/AuthPasswordService.java | 30 + .../identity/service/impl/AuthProperties.java | 134 ++++ .../service/impl/AuthServiceImpl.java | 301 ++++++++ .../service/impl/AuthTokenService.java | 39 + .../impl/PlatformIdentityBootstrapRunner.java | 371 ++++++++++ .../common/enums/PlatformMenuCode.java | 12 + .../common/enums/PlatformMenuStatus.java | 9 + .../common/enums/PlatformMenuType.java | 10 + .../navigation/domain/PlatformMenuEntity.java | 72 ++ .../navigation/mapper/PlatformMenuMapper.java | 10 + .../MybatisPlatformNavigationRepository.java | 59 ++ .../PlatformNavigationRepository.java | 31 + .../navigation/service/NavigationService.java | 15 + .../service/impl/NavigationServiceImpl.java | 40 ++ .../common/dto/AuthenticatedUserContext.java | 24 + .../service/CurrentUserContextService.java | 20 + .../impl/CurrentUserContextHolder.java | 35 + .../impl/CurrentUserContextServiceImpl.java | 34 + .../service/impl/OptionalAuthTokenFilter.java | 46 ++ server/src/main/resources/application-dev.yml | 13 + .../src/main/resources/application-prod.yml | 13 + .../src/main/resources/application-test.yml | 13 + server/src/main/resources/application.yml | 5 + .../V9__create_identity_access_hotel_menu.sql | 138 ++++ .../identity/control/AuthControllerTest.java | 180 +++++ 77 files changed, 3946 insertions(+), 19 deletions(-) create mode 100644 docs/project/requirements/M003-identity-access-hotel-menu-v1.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleCode.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformPermissionEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRoleEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRolePermissionEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformUserRoleEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformPermissionMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRoleMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRolePermissionMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformUserRoleMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/service/AccessControlService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AccessControlServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/enums/PlatformHotelStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformHotelEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformUserHotelEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformHotelMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformUserHotelMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelAccessService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/dto/AuthSessionSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserSessionStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AuthLoginRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthErrorResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthHotelResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLoginResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLogoutResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMeResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMenuResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthUserResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerAdvice.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserSessionEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserSessionMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AuthService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthException.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthPasswordService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthProperties.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthTokenService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuCode.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuType.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/domain/PlatformMenuEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/mapper/PlatformMenuMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/NavigationService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/NavigationServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/common/dto/AuthenticatedUserContext.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/CurrentUserContextService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextHolder.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/OptionalAuthTokenFilter.java create mode 100644 server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 16890a9..afe9455 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -43,9 +43,7 @@ - 普通任务切换订单接口后置。 - 任务详情 / 任务写操作是否需要显式 `hotel_id` 已确认后置;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 - 邮件会话详情已返回 `html_body_sanitized` 和 `html_render_mode`;前端展示 HTML 时优先使用清洗字段,`html_body` 只作为原始内容兼容字段。 -- SuperAgent 查询接口 4 后置,当前先不开发。 -- SuperAgent 查询接口 3 涉及附件解析、OCR、Excel、voucher、rooming list 等能力,当前系统暂不具备,仍后置。 -- 用户身份 / 权限方案后置;当前后端审计 actor 仍是本地占位。 +- 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单、管理后台和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。 ## 6. 前端开发注意事项 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 083dc9f..9ec1b0e 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -29,13 +29,16 @@ - 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 - Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 -- Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;当前 actor 仍是本地占位,正式用户身份后置。 +- Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;登录权限底座已提供,具体业务审计 actor 迁移仍后置。 - 普通任务切换订单接口已确认后置,前端第一版不要把普通任务拖拽或切换订单做成可提交能力。 ## 5. 当前前端可用接口注意事项 | 接口 | 用途 | 前端注意 | | --- | --- | --- | +| `POST /api/auth/login` | 用户名密码登录 | 成功后返回 `access_token`、当前用户、可访问酒店、权限码和可见菜单;token 只放 `sessionStorage`,不要放 `localStorage`、URL、日志或错误上报。 | +| `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer ` 调用;401 时清理 token 并进入登录页。 | +| `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 token 和当前用户上下文。 | | `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;用 `next_processable_task_id` 引导用户继续处理。 | | `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段。 | @@ -65,7 +68,29 @@ | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | | `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留和安全 HTML 字段。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报。 | -### 5.2 来源邮件会话字段说明 +### 5.2 登录权限接入注意 + +后端已提供 M003 登录和权限底座第一版接口: + +```text +POST /api/auth/login +GET /api/auth/me +POST /api/auth/logout +``` + +前端注意: + +- 登录成功后只把 `access_token` 保存到 `sessionStorage`;刷新同一浏览器会话可恢复,关闭浏览器后需要重新登录。 +- 所有需要登录态的后端请求使用 `Authorization: Bearer `。 +- 当前后端第一版不强制拦截既有 Reservation / SourceMessage 业务接口;但是前端接入登录后应统一带上 Bearer token,方便后续审计 actor 和权限收口。 +- `/api/auth/me` 返回 `user`、`default_hotel_id`、`hotels[]`、`permissions[]`、`menus[]`;菜单入口应优先使用 `menus[]`,不要继续硬编码订单列表、任务队列、Debug EML。 +- `menus[]` 只包含可见菜单;订单详情、任务详情和邮件会话详情是隐藏详情路由,不会作为菜单项返回。 +- `DEBUG_EML_SUPERAGENT` 菜单第一版只授予 `SYSTEM_ADMIN`;这只表示页面入口是否可见,不代表后端会把 `X-TH-Hotel-Debug-Upload-Key` 下发给前端。 +- `user.id` 是字符串;前端不要把任何后端 ID 转成 JavaScript number。 +- 登录失败统一显示用户名或密码错误,不要根据错误文案推断账号是否存在或是否禁用。 +- 401 的 `AUTH_TOKEN_REQUIRED` / `AUTH_SESSION_INVALID` 应统一走清理 token、回登录页的逻辑。 + +### 5.3 来源邮件会话字段说明 任务列表、订单详情任务时间线、任务详情顶层会返回以下来源邮件字段: @@ -78,7 +103,7 @@ | `external_conversation_id` | 外部邮件会话 ID。 | 仅用于展示或调试,不作为当前会话详情接口路径参数。 | | `conversation_message_count` | 同一外部会话下的邮件数量。 | 用于提示用户打开的是整段会话,不是单封邮件。 | -### 5.3 邮件会话详情接入注意 +### 5.4 邮件会话详情接入注意 - `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`。 - 前端当前通过 `VITE_RESERVATION_HOTEL_ID` 统一配置 Reservation 默认酒店上下文,并会在订单列表、任务列表和订单详情查询中传 `hotel_id`;任务详情、任务写操作和邮件会话详情当前后端接口不接收该参数。 @@ -90,7 +115,7 @@ - 会话详情接口由后端内部写原文读取审计,前端不传 `X-TH-Hotel-Source-Original-Read-Key`。 - 会话详情外层字段主要是 snake_case,但媒体对象沿用原文读取接口字段,当前是 `mediaType`、`fileName`、`contentType`、`sizeBytes`、`externalUrl`、`externalMediaId` 这种 camelCase,前端类型定义需要单独处理。 -### 5.4 订单列表接入注意 +### 5.5 订单列表接入注意 - `GET /api/reservation/orders` 默认返回全部订单状态,包括 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 - `keyword` 会匹配订单业务号、临时订单号、展示名、订单状态,也会匹配来源消息安全摘要命中的 SourceMessage ID;前端可以用邮件主题、外部消息 ID 或会话 ID 辅助查订单。 @@ -99,7 +124,7 @@ - `display_order_key` 是前端优先展示的订单业务号或临时订单号;`group_code` 和 `confirmation_number` 只有在当前订单业务号类型匹配时返回。 - 订单 ID、任务 ID、SourceMessage ID 在这些前端接口中按字符串返回,前端不要转换成 JavaScript number。 -### 5.5 前端联调演示数据 seed 接口 +### 5.6 前端联调演示数据 seed 接口 后端提供一个受控的 dev/test 演示数据入口,方便前端在空库或本地环境快速看到页面效果。 @@ -137,7 +162,7 @@ Content-Type: application/json - Message Notification 只读任务。 - 同一邮件会话下多封邮件、完整 HTML、附件外链和内联图片外链。 -### 5.6 任务详情字段元数据接入注意 +### 5.7 任务详情字段元数据接入注意 - `fields[]` 第一版服务于任务详情动态展示,字段来源与白名单规则以 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 为准。 - `result_type`、`task_type`、`task_subtype`、`default_value_source` 已透出给前端,用于和最新前端白名单对齐。 @@ -145,7 +170,7 @@ Content-Type: application/json - 前端保存草稿时不要自行按 `write_path` 重组 OPERA 参数;第一版按任务详情返回的字段和值提交即可,真实 OPERA 参数组装后续由后端 adapter / 转换层处理。 - 任务详情页控制按钮时以 `availability.editable`、`availability.confirmable`、`availability.executable`、`availability.read_only` 和 `availability.blocked` 为准;`can_process` 和 `readonly_reason_code` 只出现在任务列表 / 订单时间线摘要里。 -### 5.7 Debug EML 上传接口接入注意 +### 5.8 Debug EML 上传接口接入注意 后端已提供 Debug 页面专用的 `.eml` 上传和 SuperAgent 调试入口: @@ -187,8 +212,6 @@ run_label: 可选调试标签 ## 7. 需要持续提醒的后置事项 -- SuperAgent 查询接口 3 文件解析当前不能做。 -- SuperAgent 查询接口 4 已确认继续后置。 - 普通任务切换订单接口继续后置。 -- 用户 / 权限方案继续后置。 +- 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单和管理后台仍后置。 - 真实 OPERA / OHIP 接入继续后置。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 6d6d840..e8bb04d 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -18,6 +18,7 @@ - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 - SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 - Debug EML 上传到 SuperAgent 调试链路:受控上传 `.eml`、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并返回调试结果。 +- 登录权限底座:支持用户名密码登录、登出、当前用户上下文、数据库 session token、可访问酒店、权限码和可见菜单。 当前不要把以下能力当作已上线: @@ -26,9 +27,10 @@ - 自动 ACK、`task.result` 或客户回复。 - 业务前端页面展示邮件原文。 - OHIP / OPERA 或其他业务系统真实写操作。 -- SuperAgent 查询上下文接口 3、4。 - 普通任务切换订单接口。 -- 用户身份、权限和真实审计 actor。 +- 用户、角色、权限、菜单和酒店管理后台 CRUD。 +- 现有业务接口强制登录和强制权限拦截。 +- 业务审计 actor 全量迁移到当前登录用户。 - Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;未接入正式用户权限前不要开放给普通用户。 ## 2. 上线前必须确认 @@ -55,7 +57,28 @@ | `TH_HOTEL_DB_PASSWORD` | 是 | 只能通过 Secret 注入,不写入仓库。 | | `TH_HOTEL_DB_DRIVER` | 否 | MySQL 使用 `com.mysql.cj.jdbc.Driver`。 | -### 3.2 SourceMessage +### 3.2 登录权限 + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `AUTH_DEV_BOOTSTRAP_ADMIN_USERNAME` / `AUTH_TEST_BOOTSTRAP_ADMIN_USERNAME` / `AUTH_PROD_BOOTSTRAP_ADMIN_USERNAME` | 否 | 初始超级管理员用户名;系统已有超级管理员后不会覆盖。 | +| `AUTH_DEV_BOOTSTRAP_ADMIN_PASSWORD` / `AUTH_TEST_BOOTSTRAP_ADMIN_PASSWORD` / `AUTH_PROD_BOOTSTRAP_ADMIN_PASSWORD` | 是 | 初始超级管理员密码,只能通过 Secret 注入;系统已有超级管理员后不会覆盖。 | +| `AUTH_DEV_BOOTSTRAP_ADMIN_DISPLAY_NAME` / `AUTH_TEST_BOOTSTRAP_ADMIN_DISPLAY_NAME` / `AUTH_PROD_BOOTSTRAP_ADMIN_DISPLAY_NAME` | 否 | 初始超级管理员展示名。 | +| `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID` / `AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_ID` / `AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_ID` | 否 | 登录权限底座初始化默认酒店;prod 必须配置真实酒店 ID。 | +| `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_NAME` / `AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_NAME` / `AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_NAME` | 否 | 默认酒店展示名称。 | +| `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE` / `AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE` / `AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE` | 否 | 默认酒店本地时区,默认 `Asia/Bangkok`。 | +| `AUTH_DEV_SESSION_TTL_MINUTES` / `AUTH_TEST_SESSION_TTL_MINUTES` / `AUTH_PROD_SESSION_TTL_MINUTES` | 否 | 数据库 session token 有效分钟数,默认 `720`。 | +| `AUTH_BOOTSTRAP_ADMIN_USERNAME` / `AUTH_BOOTSTRAP_ADMIN_PASSWORD` / `AUTH_SESSION_TTL_MINUTES` 等通用变量 | 视具体变量而定 | 兼容兜底变量;新环境优先使用环境专属变量。 | + +注意: + +- 数据库只保存 `token_hash`,不保存明文 `access_token`。 +- `access_token` 只在登录成功响应中返回一次;前端只能放 `sessionStorage`,不能放 `localStorage`、URL、日志或错误上报。 +- 当前第一版只做可选 Bearer token 解析,现有 Reservation / SourceMessage 业务接口仍不强制登录。 +- `/api/auth/me` 和 `/api/auth/logout` 需要 `Authorization: Bearer `。 +- 管理后台还未上线时,不要把数据库手工改用户、角色、权限作为常规运营手段。 + +### 3.3 SourceMessage | 变量 | 是否 Secret | 上线注意事项 | | --- | --- | --- | @@ -69,7 +92,7 @@ - 原文读取 key 不是用户体系,后续接入正式登录和角色权限后应替换。 - 任何能读取原文的调用都必须有调用方和访问场景,并写入审计表。 -### 3.3 AgentBus +### 3.4 AgentBus | 变量 | 是否 Secret | 上线注意事项 | | --- | --- | --- | @@ -88,7 +111,7 @@ - `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。 - 当前实现不发送 ACK、不发送 `task.result`、不自动回复客户。 -### 3.4 SuperAgent HMAC +### 3.5 SuperAgent HMAC | 变量 | 是否 Secret | 上线注意事项 | | --- | --- | --- | @@ -108,7 +131,7 @@ - 任务结果通知接口里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 - SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 -### 3.5 Debug EML / SuperAgent Open API / 阿里云 OSS +### 3.6 Debug EML / SuperAgent Open API / 阿里云 OSS | 变量 | 是否 Secret | 上线注意事项 | | --- | --- | --- | @@ -160,6 +183,14 @@ - `server/src/main/resources/db/migration/V7__create_debug_eml_superagent_run.sql` +当前 SourceMessage duplicate 诊断相关 migration: + +- `server/src/main/resources/db/migration/V8__create_source_message_payload_duplicate.sql` + +当前 M003 登录权限相关 migration: + +- `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` + 上线前确认: - 目标数据库为空库或 Flyway history 与当前代码一致。 diff --git a/docs/project/requirements/M003-identity-access-hotel-menu-v1.md b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md new file mode 100644 index 0000000..da660a4 --- /dev/null +++ b/docs/project/requirements/M003-identity-access-hotel-menu-v1.md @@ -0,0 +1,666 @@ +# M003 Identity Access Hotel Menu 登录权限与酒店菜单底座 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-09 | +| 状态 | CP1 后端第一版已实现,管理后台和前端登录页待做 | +| 适用范围 | 本系统用户登录、角色权限、菜单、酒店授权和当前用户上下文底座 | +| 主要读者 | 产品、后端、前端、测试、后续协作 agent | + +## 1. 文档定位 + +本文记录 M003 登录权限与酒店菜单底座的第一版设计。该模块是平台通用能力,不属于 +`workflows.reservation`,后续 Reservation、SourceMessage、审计、OPERA 执行等业务能力 +都应通过该底座获取当前用户、权限和酒店上下文。 + +第一期目标是先让系统具备登录态、当前用户上下文、权限码、可访问酒店和可见菜单能力; +管理后台 CRUD 暂不实现,但必须在数据模型和接口边界上为后续扩展留好位置。 + +## 2. 已确认决策 + +- 第一版采用用户名密码登录,本系统自己管理用户、角色、权限、菜单和酒店。 +- 登录态采用数据库 session token,不使用 JWT。 +- 登录成功后后端生成随机 `access_token`,数据库只保存 `token_hash`。 +- 前端请求使用 `Authorization: Bearer `。 +- 前端第一版将 `access_token` 保存在 `sessionStorage`,刷新同一浏览器会话可恢复,关闭浏览器后需要重新登录;不得放入 `localStorage`、URL 或普通日志。 +- session 默认有效期第一版为 12 小时,即 `AUTH_SESSION_TTL_MINUTES=720`,可按环境覆盖。 +- 用户和酒店关系采用第三种模型: + - 管理员可访问全部酒店。 + - 普通用户绑定一个或多个酒店。 + - 普通用户有默认酒店,可在授权酒店之间切换。 +- 当前前端已有菜单入口为订单列表、任务队列和 Debug EML;订单详情、任务详情和邮件会话属于隐藏详情路由,不作为菜单返回。 +- 初始超级管理员通过环境变量初始化: + - `AUTH_BOOTSTRAP_ADMIN_USERNAME` + - `AUTH_BOOTSTRAP_ADMIN_PASSWORD` + - `AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME` +- 系统中已存在超级管理员后,不再使用环境变量覆盖管理员账号或密码。 +- 第一期开启登录和权限底座,但不强制拦截现有业务接口。 +- 管理后台还没有做,必须明确后置。 + +## 3. 核心目标 + +第一期要解决以下问题: + +- 前端可以登录、登出,并获取当前用户信息。 +- 前端可以拿到当前用户可访问酒店、默认酒店和可见菜单。 +- 后端可以从 token 解析当前用户上下文。 +- 后端可以判断当前用户是否拥有某个权限码。 +- 后端可以判断当前用户是否可访问某个 `hotel_id`。 +- 审计 actor 后续可以从当前用户上下文读取,不再依赖本地占位值。 +- SourceMessage 原文读取等高敏能力后续可以从临时 access-key 逐步迁移到权限码。 + +## 4. 非目标范围 + +第一期不做以下能力: + +- 用户新增、编辑、禁用、重置密码接口。 +- 角色新增、编辑、删除接口。 +- 权限分配接口。 +- 菜单新增、编辑、排序接口。 +- 酒店新增、编辑、停用接口。 +- 用户绑定酒店管理接口。 +- 管理后台页面。 +- 强制要求现有 Reservation / SourceMessage 业务接口必须带 token。 +- 外部 SSO、OIDC、企业微信、LDAP 或网关注入用户。 +- JWT refresh token 体系。 +- 密码找回、短信验证码、MFA、多端设备管理。 + +这些能力后续作为管理后台和安全增强 checkpoint 独立设计。 + +## 5. 模块边界 + +后端建议拆成平台模块: + +```text +platform.identity +// 用户、登录、session、当前用户上下文 + +platform.access +// 角色、权限、用户角色、权限判断 + +platform.navigation +// 菜单树、菜单权限、当前用户可见菜单 + +platform.hotel +// 酒店基础信息、用户可访问酒店、默认酒店 + +platform.security +// 请求过滤器、Bearer token 解析、可选鉴权上下文 +``` + +中文说明: + +| 模块 | 职责 | 不负责 | +| --- | --- | --- | +| `platform.identity` | 用户账号、登录、登出、session token、当前用户 | 不维护业务订单和任务 | +| `platform.access` | 角色、权限码、用户角色、权限判断 | 不直接决定菜单 UI 样式 | +| `platform.navigation` | 菜单树、菜单权限、用户可见菜单 | 不做业务接口权限校验 | +| `platform.hotel` | 酒店基础资料、用户酒店授权、默认酒店 | 不保存订单、任务或邮件正文 | +| `platform.security` | 请求级 token 解析、当前用户上下文注入 | 不替代 SuperAgent / AgentBus 服务间鉴权 | + +目录仍遵守当前后端规范: + +```text + +├── control +├── service +│ └── impl +├── domain +├── mapper +├── repository +└── common + ├── dto + ├── request + ├── result + └── enums +``` + +## 6. 数据模型建议 + +### 6.1 用户表 `platform_user` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 用户内部 ID | +| `username` | 登录用户名,全局唯一 | +| `password_hash` | 密码哈希,禁止保存明文密码 | +| `display_name` | 用户展示名称 | +| `email` | 邮箱,可为空 | +| `phone` | 手机号,可为空 | +| `user_status` | 用户状态,例如 `ACTIVE`、`DISABLED` | +| `super_admin` | 是否超级管理员 | +| `password_changed_at` | 最近密码变更时间 | +| `last_login_at` | 最近登录时间 | +| `created_at` / `updated_at` | 创建和更新时间 | + +密码哈希建议使用 BCrypt 或同等级单向哈希算法。日志、错误响应和审计不得输出密码、密码哈希或 token。 + +### 6.2 Session 表 `platform_user_session` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | session 内部 ID | +| `user_id` | 所属用户 ID | +| `token_hash` | access token 哈希,数据库不保存明文 token | +| `session_status` | session 状态,例如 `ACTIVE`、`REVOKED`、`EXPIRED` | +| `issued_at` | 签发时间 | +| `expires_at` | 过期时间 | +| `revoked_at` | 注销或撤销时间 | +| `last_seen_at` | 最近使用时间 | +| `client_ip` | 登录或最近请求 IP,第一版可为空 | +| `user_agent_summary` | User-Agent 摘要,第一版可为空 | + +第一版 session 过期时间通过配置控制,例如 `AUTH_SESSION_TTL_MINUTES`,默认使用 720 分钟。 + +### 6.3 角色表 `platform_role` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 角色内部 ID | +| `role_code` | 稳定角色代码,例如 `SYSTEM_ADMIN`、`RESERVATION_OPERATOR` | +| `role_name` | 角色展示名称 | +| `role_status` | 角色状态 | +| `system_builtin` | 是否系统内置角色 | +| `created_at` / `updated_at` | 创建和更新时间 | + +### 6.4 权限表 `platform_permission` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 权限内部 ID | +| `permission_code` | 稳定权限代码 | +| `permission_name` | 权限展示名称 | +| `permission_group` | 权限分组,例如 `RESERVATION`、`SOURCE_MESSAGE`、`SYSTEM` | +| `permission_status` | 权限状态 | +| `system_builtin` | 是否系统内置权限 | + +权限码只能使用稳定英文代码,不使用中文或菜单文案做业务判断。 + +### 6.5 用户角色表 `platform_user_role` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 关系内部 ID | +| `user_id` | 用户 ID | +| `role_id` | 角色 ID | +| `created_at` | 创建时间 | + +### 6.6 角色权限表 `platform_role_permission` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 关系内部 ID | +| `role_id` | 角色 ID | +| `permission_id` | 权限 ID | +| `created_at` | 创建时间 | + +### 6.7 酒店表 `platform_hotel` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 酒店内部 ID | +| `hotel_id` | 业务酒店 ID,例如 `HOTEL-TEST` | +| `hotel_name` | 酒店展示名称 | +| `hotel_status` | 酒店状态,例如 `ACTIVE`、`DISABLED` | +| `time_zone` | 酒店本地时区,用于入住日期、离店日期等酒店本地业务日期 | +| `sort_order` | 排序号 | +| `created_at` / `updated_at` | 创建和更新时间 | + +现有业务表中的 `hotel_id` 继续作为业务上下文 ID。用户酒店权限必须围绕该字段校验。 + +### 6.8 用户酒店授权表 `platform_user_hotel` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 关系内部 ID | +| `user_id` | 用户 ID | +| `hotel_id` | 酒店业务 ID | +| `default_hotel` | 是否该用户默认酒店 | +| `created_at` | 创建时间 | + +规则: + +- 普通用户至少应有一个可访问酒店。 +- 普通用户最多只能有一个默认酒店。 +- 超级管理员不需要为每个酒店插授权关系,默认可访问全部启用酒店。 +- 业务接口收到 `hotel_id` 时,后续强制鉴权阶段必须校验当前用户是否可访问该酒店。 + +### 6.9 菜单表 `platform_menu` + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 菜单内部 ID | +| `parent_id` | 父菜单 ID,根菜单为空 | +| `menu_code` | 稳定菜单代码 | +| `menu_name` | 菜单展示名称 | +| `menu_type` | 菜单类型,例如 `GROUP`、`PAGE`、`ACTION` | +| `route_path` | 前端路由路径 | +| `component_key` | 前端组件标识,第一版可为空 | +| `icon_key` | 前端图标标识 | +| `permission_code` | 访问该菜单需要的权限码,可为空 | +| `sort_order` | 菜单排序 | +| `visible` | 是否可见 | +| `menu_status` | 菜单状态 | + +菜单权限只决定入口是否可见,不替代后端接口权限校验。 + +## 7. 初始内置数据 + +第一期可内置以下角色: + +| 角色代码 | 中文说明 | +| --- | --- | +| `SYSTEM_ADMIN` | 系统管理员,可访问全部酒店和系统能力 | +| `RESERVATION_OPERATOR` | 预订处理员,可处理 Reservation 任务 | +| `RESERVATION_VIEWER` | 预订只读查看员,只能查看任务、订单和邮件摘要 | + +第一期建议内置以下权限码: + +| 权限码 | 中文说明 | +| --- | --- | +| `SOURCE_MESSAGE_READ` | 查看来源消息安全摘要 | +| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看来源消息原文和媒体 URL | +| `RESERVATION_ORDER_READ` | 查看订单 | +| `RESERVATION_TASK_READ` | 查看任务 | +| `RESERVATION_TASK_EDIT` | 保存任务草稿 | +| `RESERVATION_TASK_CONFIRM` | 最终确认任务 | +| `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 | +| `RESERVATION_AUDIT_READ` | 查看任务审计 | +| `HOTEL_SWITCH` | 在授权酒店之间切换 | +| `SYSTEM_AUTH_READ` | 读取当前用户、菜单和权限信息 | +| `SYSTEM_USER_MANAGE` | 用户管理,第一期不开放管理接口 | +| `SYSTEM_ROLE_MANAGE` | 角色权限管理,第一期不开放管理接口 | +| `SYSTEM_MENU_MANAGE` | 菜单管理,第一期不开放管理接口 | +| `HOTEL_MANAGE` | 酒店管理,第一期不开放管理接口 | +| `SYSTEM_DEBUG_EML_RUN` | 访问 Debug EML 上传到 SuperAgent 调试页面 | + +第一期内置角色权限矩阵: + +| 角色代码 | 默认权限 | +| --- | --- | +| `SYSTEM_ADMIN` | 全部权限,包括当前第一期权限和后续管理类占位权限 | +| `RESERVATION_OPERATOR` | `SYSTEM_AUTH_READ`、`HOTEL_SWITCH`、`RESERVATION_ORDER_READ`、`RESERVATION_TASK_READ`、`RESERVATION_TASK_EDIT`、`RESERVATION_TASK_CONFIRM`、`RESERVATION_OPERA_SIM_EXECUTE`、`RESERVATION_AUDIT_READ`、`SOURCE_MESSAGE_READ`、`SOURCE_MESSAGE_ORIGINAL_READ` | +| `RESERVATION_VIEWER` | `SYSTEM_AUTH_READ`、`HOTEL_SWITCH`、`RESERVATION_ORDER_READ`、`RESERVATION_TASK_READ`、`RESERVATION_AUDIT_READ`、`SOURCE_MESSAGE_READ` | + +中文说明: + +- `SYSTEM_ADMIN` 用于系统初始化和调试能力,第一版可访问所有启用酒店。 +- `RESERVATION_OPERATOR` 需要查看邮件原文和附件外链来处理任务,因此第一版包含 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- `RESERVATION_VIEWER` 只读查看订单、任务、审计和来源消息安全摘要;不允许保存、确认、执行 OPERA 模拟,也不允许访问 Debug EML。 +- `SYSTEM_DEBUG_EML_RUN` 第一版只授予 `SYSTEM_ADMIN`,避免普通业务用户触发 SuperAgent 调试链路。 + +第一期可内置菜单,需和当前前端路由保持一致: + +| 菜单代码 | 路由 | 权限码 | 图标 Key | 可见性 | 中文说明 | +| --- | --- | --- | --- | --- | --- | +| `RESERVATION_ORDERS` | `/reservation/orders` | `RESERVATION_ORDER_READ` | `pi pi-list` | 可见 | 订单列表 | +| `RESERVATION_TASKS` | `/reservation/tasks` | `RESERVATION_TASK_READ` | `pi pi-check-square` | 可见 | 任务队列 | +| `DEBUG_EML_SUPERAGENT` | `/debug/eml-superagent` | `SYSTEM_DEBUG_EML_RUN` | `pi pi-upload` | 有权限时可见 | Debug EML 上传到 SuperAgent | +| `SOURCE_MESSAGES` | `/source-messages` | `SOURCE_MESSAGE_READ` | `pi pi-envelope` | 第一版隐藏 | 来源消息独立页面,前端尚未实现 | +| `SYSTEM_SETTINGS` | `/system` | `SYSTEM_USER_MANAGE` | `pi pi-cog` | 第一版隐藏 | 系统设置入口,占位为后续管理后台使用 | + +当前前端隐藏详情路由不作为菜单返回: + +| 路由 | 中文说明 | +| --- | --- | +| `/reservation/orders/{orderId}` | 订单详情,通过订单列表或任务入口进入 | +| `/reservation/tasks/{taskId}` | 任务详情,通过任务列表或订单时间线进入 | +| `/reservation/source-messages/{sourceMessageId}/conversation` | 邮件会话详情,通过任务或订单详情来源入口进入 | + +菜单数据应允许后续通过管理后台维护。第一版 `icon_key` 先兼容当前 PrimeIcons class;如果后续换成后端稳定图标代码,前端再统一做映射。 + +## 8. 接口契约第一版 + +### 8.1 登录 + +```text +POST /api/auth/login +``` + +请求体: + +```json +{ + "username": "admin", + "password": "password" +} +``` + +成功响应: + +```json +{ + "access_token": "plain-token-only-return-once", + "token_type": "Bearer", + "expires_at": "2026-07-09T12:00:00Z", + "user": { + "id": "1900000000000000001", + "username": "admin", + "display_name": "系统管理员", + "super_admin": true + }, + "default_hotel_id": "HOTEL-TEST", + "hotels": [ + { + "hotel_id": "HOTEL-TEST", + "hotel_name": "测试酒店", + "time_zone": "Asia/Bangkok", + "default_hotel": true + } + ], + "permissions": [ + "SYSTEM_AUTH_READ", + "RESERVATION_ORDER_READ", + "RESERVATION_TASK_READ" + ], + "menus": [ + { + "menu_code": "RESERVATION_ORDERS", + "menu_name": "订单列表", + "route_path": "/reservation/orders", + "component_key": "ReservationOrders", + "icon_key": "pi pi-list", + "permission_code": "RESERVATION_ORDER_READ", + "sort_order": 10 + }, + { + "menu_code": "RESERVATION_TASKS", + "menu_name": "任务队列", + "route_path": "/reservation/tasks", + "component_key": "ReservationTasks", + "icon_key": "pi pi-check-square", + "permission_code": "RESERVATION_TASK_READ", + "sort_order": 20 + }, + { + "menu_code": "DEBUG_EML_SUPERAGENT", + "menu_name": "Debug EML", + "route_path": "/debug/eml-superagent", + "component_key": "DebugEmlSuperAgent", + "icon_key": "pi pi-upload", + "permission_code": "SYSTEM_DEBUG_EML_RUN", + "sort_order": 30 + } + ] +} +``` + +说明: + +- 登录失败统一返回用户名或密码错误,不暴露账号是否存在。 +- 禁用用户不能登录。 +- token 明文只在登录成功响应中返回一次。 +- 数据库只保存 token hash。 +- 前端第一版使用 `sessionStorage` 保存 token;退出登录时必须清理本地 token。 +- 超级管理员如果拥有 `SYSTEM_DEBUG_EML_RUN`,`menus[]` 可包含 `/debug/eml-superagent`;普通预订角色不返回该菜单。 + +### 8.2 登出 + +```text +POST /api/auth/logout +Authorization: Bearer +``` + +行为: + +- 将当前 session 标记为 `REVOKED`。 +- 重复登出可以幂等返回成功。 + +### 8.3 当前用户 + +```text +GET /api/auth/me +Authorization: Bearer +``` + +返回: + +```json +{ + "user": { + "id": "1900000000000000001", + "username": "admin", + "display_name": "系统管理员", + "super_admin": true + }, + "default_hotel_id": "HOTEL-TEST", + "hotels": [ + { + "hotel_id": "HOTEL-TEST", + "hotel_name": "测试酒店", + "time_zone": "Asia/Bangkok", + "default_hotel": true + } + ], + "permissions": [ + "RESERVATION_TASK_READ" + ], + "menus": [ + { + "menu_code": "RESERVATION_TASKS", + "menu_name": "任务列表", + "route_path": "/reservation/tasks", + "component_key": "ReservationTasks", + "icon_key": "pi pi-check-square", + "permission_code": "RESERVATION_TASK_READ", + "sort_order": 20 + } + ] +} +``` + +说明: + +- `GET /api/auth/me` 是前端启动后恢复登录态、菜单和酒店上下文的核心接口。 +- 超级管理员的 `hotels` 可返回全部启用酒店。 +- 普通用户只返回授权酒店。 + +## 9. 现有业务接口接入策略 + +第一期不强制拦截现有业务接口。 + +行为规则: + +- `/api/auth/login` 不需要 token。 +- `/api/auth/logout` 和 `/api/auth/me` 需要 token。 +- 现有 Reservation / SourceMessage 查询和操作接口第一期继续保持未登录可访问,避免打断当前前后端联调。 +- 如果现有业务接口请求带合法 token,后端可解析当前用户上下文。 +- 如果现有业务接口未带 token,后端按匿名上下文处理,行为保持现状。 +- 如果现有业务接口带了无效 token,第一期可对非强制鉴权接口忽略该 token,避免旧前端因为缓存脏 token 被整体打断。 +- Debug EML 上传接口当前仍使用 `X-TH-Hotel-Debug-Upload-Key` 受控访问;M003 第一版只决定菜单是否可见,不把 debug access key 下发给前端。 +- 后续强制鉴权 checkpoint 再逐步将业务接口改为必须登录和权限校验。 + +后续强制鉴权时应按接口分批启用: + +1. 查询类接口先要求登录和酒店权限。 +2. 写操作再要求具体操作权限。 +3. 邮件原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +4. OPERA 模拟迁移到 `RESERVATION_OPERA_SIM_EXECUTE`。 +5. 审计查询迁移到 `RESERVATION_AUDIT_READ`。 + +## 10. 酒店上下文规则 + +第一期登录态接入后,后端应提供统一的酒店上下文解析能力。 + +规则: + +- 请求显式传 `hotel_id` 时,后续强制鉴权阶段必须校验用户是否可访问该酒店。 +- 请求未传 `hotel_id` 时,普通用户使用默认酒店。 +- 超级管理员不传 `hotel_id` 时: + - 管理后台可查询全部酒店。 + - 业务页面建议仍使用默认酒店或要求前端选择酒店,避免误查全部数据。 +- SuperAgent / AgentBus 服务间接口仍使用 HMAC / token 等服务间鉴权,不走人工用户登录态。 +- SuperAgent / AgentBus 请求中的 `hotel_id` 后续应校验酒店是否存在并处于启用状态。 + +## 11. 安全要求 + +- 密码不能明文保存。 +- token 不能明文入库。 +- token、密码和密码哈希不能出现在日志、错误响应、审计详情或前端配置。 +- 登录失败不暴露账号是否存在。 +- session 需要过期时间。 +- 登出后 token 必须失效。 +- 管理员初始化密码只能来自环境变量或部署 Secret。 +- 前端不能保存任何服务端 Secret。 +- 菜单隐藏不能替代后端权限校验。 + +## 12. 审计和 actor 迁移 + +当前部分业务审计仍使用本地占位 actor。M003 第一期开启当前用户上下文后,后续业务模块应逐步迁移: + +| 场景 | 第一版迁移策略 | +| --- | --- | +| 用户保存任务草稿 | 有 token 时记录当前用户;无 token 时继续兼容本地占位 | +| 用户最终确认任务 | 有 token 时记录当前用户;无 token 时继续兼容本地占位 | +| Fallback 转换 | 有 token 时记录当前用户 | +| OPERA 模拟执行 / 重试 | 有 token 时记录当前用户 | +| 邮件原文读取 | 后续从 access-key 迁移到 `SOURCE_MESSAGE_ORIGINAL_READ` 权限 | + +第一期不强制改完所有业务审计 actor,但需要提供可复用的当前用户上下文接口。 + +## 13. 配置项建议 + +| 配置项 | 是否 Secret | 中文说明 | +| --- | --- | --- | +| `AUTH_BOOTSTRAP_ADMIN_USERNAME` | 否 | 初始超级管理员用户名 | +| `AUTH_BOOTSTRAP_ADMIN_PASSWORD` | 是 | 初始超级管理员密码,只用于首次初始化 | +| `AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME` | 否 | 初始超级管理员展示名 | +| `AUTH_SESSION_TTL_MINUTES` | 否 | session 有效期,第一版默认 `720` 分钟 | +| `AUTH_BOOTSTRAP_DEFAULT_HOTEL_ID` | 否 | 初始默认酒店 ID,dev/test 可使用 `HOTEL-TEST` 或当前环境约定值 | +| `AUTH_BOOTSTRAP_DEFAULT_HOTEL_NAME` | 否 | 初始默认酒店名称 | +| `AUTH_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE` | 否 | 初始默认酒店时区 | + +生产环境必须通过部署 Secret 或环境变量注入管理员初始密码,不得写入仓库、镜像或普通文档。 + +当前后端已按环境拆分配置: + +| 环境 | 推荐变量前缀 | 默认酒店 | +| --- | --- | --- | +| dev | `AUTH_DEV_*`,并兼容 `AUTH_*` 通用变量 | `HOTEL-DEV` / `开发酒店` | +| test | `AUTH_TEST_*`,并兼容 `AUTH_*` 通用变量 | `HOTEL-TEST` / `测试酒店` | +| prod | `AUTH_PROD_*`,并兼容 `AUTH_*` 通用变量 | 必须由生产 Secret 显式配置 | + +例如 test 环境可配置: + +```text +AUTH_TEST_BOOTSTRAP_ADMIN_USERNAME=admin +AUTH_TEST_BOOTSTRAP_ADMIN_PASSWORD=<测试环境初始密码> +AUTH_TEST_BOOTSTRAP_ADMIN_DISPLAY_NAME=系统管理员 +AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_ID=HOTEL-TEST +AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_NAME=测试酒店 +AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE=Asia/Bangkok +AUTH_TEST_SESSION_TTL_MINUTES=720 +``` + +说明: + +- 启动初始化发现系统中已经存在超级管理员后,不会继续用环境变量覆盖管理员用户名或密码。 +- 数据库 `platform_user_session` 只保存 `token_hash`,不保存明文 token。 +- 当前后端提供可选 Bearer token 解析,现有 Reservation / SourceMessage 业务接口第一版仍不强制登录。 + +## 14. Checkpoint 建议 + +### CP1:登录和权限底座 + +状态:后端第一版已实现。 + +范围: + +- Flyway 创建用户、角色、权限、菜单、酒店和 session 表。 +- 内置角色、权限、菜单基础数据。 +- 支持环境变量初始化超级管理员和默认酒店。 +- 实现登录、登出、当前用户接口。 +- 实现数据库 session token。 +- 实现可选 token 解析,不强制拦截现有业务接口。 +- 提供当前用户上下文、权限判断和酒店授权判断服务。 + +不做: + +- 管理后台 CRUD。 +- 强制拦截现有业务接口。 +- 前端登录页面。 +- 外部 SSO。 + +### CP2:前端登录和菜单接入 + +范围: + +- 前端登录页。 +- 使用 `sessionStorage` 保存 access token,登出或 token 失效时清理。 +- 启动时调用 `/api/auth/me`。 +- 根据 `menus[]` 渲染菜单,替换当前 `ReservationAppShell` 中订单列表、任务队列、Debug EML 的硬编码菜单。 +- 根据 `hotels[]` 支持酒店选择或展示默认酒店。 + +### CP3:业务接口逐步强制鉴权 + +范围: + +- Reservation 查询接口校验登录和酒店权限。 +- Reservation 写接口校验具体权限。 +- SourceMessage 原文读取迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`。 +- 审计 actor 全面迁移到当前用户。 + +### CP4:管理后台接口 + +范围: + +- 用户管理。 +- 角色管理。 +- 权限分配。 +- 菜单管理。 +- 酒店管理。 +- 用户酒店授权。 + +该 checkpoint 还没有做,后续流程梳理或目标模式时必须提醒。 + +## 15. 待确认问题 + +| 问题 | 建议 | 状态 | +| --- | --- | --- | +| session 默认有效期是多少? | 第一版使用 12 小时,即 `AUTH_SESSION_TTL_MINUTES=720`,可配置 | 已确认 | +| 初始默认酒店在 dev/test/prod 的具体 ID 和名称是什么? | dev/test 先沿用现有 `HOTEL-DEV` / `HOTEL-TEST` 习惯,生产必须配置真实酒店 | 待确认 | +| 前端菜单图标使用什么 icon key? | 第一版兼容当前 PrimeIcons class,例如 `pi pi-list`、`pi pi-check-square`、`pi pi-upload` | 已确认 | +| 前端 token 保存在哪里? | 第一版使用 `sessionStorage`,不使用 `localStorage` | 已确认 | +| Debug EML 菜单谁能看到? | 第一版仅 `SYSTEM_ADMIN` 通过 `SYSTEM_DEBUG_EML_RUN` 看到 | 已确认 | +| 现有业务接口强制鉴权从哪一批开始? | M003 CP3 单独确认 | 后置 | +| 管理后台什么时候做? | M003 CP4 单独确认 | 后置 | + +## 16. 目标模式提示词建议 + +如果要进入目标模式开发 CP1,可以这样说: + +```text +进入目标模式,目标:实现 M003 CP1 登录和权限底座。 + +范围: +1. 新增用户、角色、权限、菜单、酒店、用户酒店授权、session 的 Flyway 表结构。 +2. 初始化内置权限、角色权限矩阵、菜单、默认酒店和超级管理员。 +3. 实现用户名密码登录、登出、当前用户接口。 +4. 登录态使用数据库 session token,数据库只保存 token_hash。 +5. session 默认有效期 12 小时,支持 `AUTH_SESSION_TTL_MINUTES` 覆盖。 +6. 初始化菜单需和当前前端路由对齐: + - `RESERVATION_ORDERS` -> `/reservation/orders` + - `RESERVATION_TASKS` -> `/reservation/tasks` + - `DEBUG_EML_SUPERAGENT` -> `/debug/eml-superagent` + - 订单详情、任务详情、邮件会话是隐藏路由,不作为菜单返回。 +7. `SYSTEM_DEBUG_EML_RUN` 第一版只授予 `SYSTEM_ADMIN`。 +8. 实现当前用户上下文、权限判断和酒店授权判断服务。 +9. 第一版不强制拦截现有 Reservation / SourceMessage 业务接口;请求带合法 token 时可识别当前用户,未带 token 时保持现有行为。 +10. Debug EML 上传接口仍使用 `X-TH-Hotel-Debug-Upload-Key`;M003 CP1 只控制菜单可见性,不下发 debug key。 +11. 保持当前后端代码规范:control、service、service.impl、domain、mapper、repository、common.request、common.result、common.dto、common.enums。 +12. Controller、Service、ServiceImpl 方法加中文注释;Entity 字段加中文注释。 +13. 补充测试。 + +不做: +1. 用户 / 角色 / 权限 / 菜单 / 酒店管理后台 CRUD。 +2. 前端登录页面。 +3. 强制拦截现有业务接口。 +4. 外部 SSO / JWT / MFA。 +5. 真实 OPERA / OHIP。 + +完成后: +更新文档,code review,运行测试,中文提交。 +``` diff --git a/server/pom.xml b/server/pom.xml index a8bfed6..82f19a7 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -69,6 +69,10 @@ angus-mail ${angus-mail.version} + + org.springframework.security + spring-security-crypto + com.aliyun.oss aliyun-sdk-oss diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index 8745098..e91aa79 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -10,6 +10,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @MapperScan({ "cn.nianxx.thhotel.platform.message.mapper", "cn.nianxx.thhotel.platform.debug.mapper", + "cn.nianxx.thhotel.platform.identity.mapper", + "cn.nianxx.thhotel.platform.access.mapper", + "cn.nianxx.thhotel.platform.hotel.mapper", + "cn.nianxx.thhotel.platform.navigation.mapper", "cn.nianxx.thhotel.workflows.reservation.mapper", "cn.nianxx.thhotel.integrations.ai.superagent.mapper" }) diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java new file mode 100644 index 0000000..5f37083 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.access.common.enums; + +/** + * 平台内置权限稳定代码。业务判断只能依赖该代码,不依赖展示文案。 + */ +public enum PlatformPermissionCode { + SOURCE_MESSAGE_READ, + SOURCE_MESSAGE_ORIGINAL_READ, + RESERVATION_ORDER_READ, + RESERVATION_TASK_READ, + RESERVATION_TASK_EDIT, + RESERVATION_TASK_CONFIRM, + RESERVATION_OPERA_SIM_EXECUTE, + RESERVATION_AUDIT_READ, + HOTEL_SWITCH, + SYSTEM_AUTH_READ, + SYSTEM_USER_MANAGE, + SYSTEM_ROLE_MANAGE, + SYSTEM_MENU_MANAGE, + HOTEL_MANAGE, + SYSTEM_DEBUG_EML_RUN +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionStatus.java new file mode 100644 index 0000000..083a38e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.platform.access.common.enums; + +/** + * 平台权限状态稳定代码。 + */ +public enum PlatformPermissionStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleCode.java new file mode 100644 index 0000000..b134f21 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleCode.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.access.common.enums; + +/** + * 平台内置角色稳定代码。 + */ +public enum PlatformRoleCode { + SYSTEM_ADMIN, + RESERVATION_OPERATOR, + RESERVATION_VIEWER +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleStatus.java new file mode 100644 index 0000000..7722d6a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformRoleStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.platform.access.common.enums; + +/** + * 平台角色状态稳定代码。 + */ +public enum PlatformRoleStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformPermissionEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformPermissionEntity.java new file mode 100644 index 0000000..0934999 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformPermissionEntity.java @@ -0,0 +1,48 @@ +package cn.nianxx.thhotel.platform.access.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台权限实体。权限码是前后端共同使用的稳定业务代码。 + */ +@TableName("platform_permission") +public class PlatformPermissionEntity { + + /** 权限内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 稳定权限代码。 */ + private String permissionCode; + /** 权限展示名称。 */ + private String permissionName; + /** 权限分组,例如 RESERVATION、SOURCE_MESSAGE、SYSTEM。 */ + private String permissionGroup; + /** 权限状态:ACTIVE、DISABLED。 */ + private String permissionStatus; + /** 是否系统内置权限。 */ + private Boolean systemBuiltin; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPermissionCode() { return permissionCode; } + public void setPermissionCode(String permissionCode) { this.permissionCode = permissionCode; } + public String getPermissionName() { return permissionName; } + public void setPermissionName(String permissionName) { this.permissionName = permissionName; } + public String getPermissionGroup() { return permissionGroup; } + public void setPermissionGroup(String permissionGroup) { this.permissionGroup = permissionGroup; } + public String getPermissionStatus() { return permissionStatus; } + public void setPermissionStatus(String permissionStatus) { this.permissionStatus = permissionStatus; } + public Boolean getSystemBuiltin() { return systemBuiltin; } + public void setSystemBuiltin(Boolean systemBuiltin) { this.systemBuiltin = systemBuiltin; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRoleEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRoleEntity.java new file mode 100644 index 0000000..ac7eb38 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRoleEntity.java @@ -0,0 +1,44 @@ +package cn.nianxx.thhotel.platform.access.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台角色实体。保存稳定角色代码和展示名称。 + */ +@TableName("platform_role") +public class PlatformRoleEntity { + + /** 角色内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 稳定角色代码,例如 SYSTEM_ADMIN。 */ + private String roleCode; + /** 角色展示名称。 */ + private String roleName; + /** 角色状态:ACTIVE、DISABLED。 */ + private String roleStatus; + /** 是否系统内置角色。 */ + private Boolean systemBuiltin; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getRoleCode() { return roleCode; } + public void setRoleCode(String roleCode) { this.roleCode = roleCode; } + public String getRoleName() { return roleName; } + public void setRoleName(String roleName) { this.roleName = roleName; } + public String getRoleStatus() { return roleStatus; } + public void setRoleStatus(String roleStatus) { this.roleStatus = roleStatus; } + public Boolean getSystemBuiltin() { return systemBuiltin; } + public void setSystemBuiltin(Boolean systemBuiltin) { this.systemBuiltin = systemBuiltin; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRolePermissionEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRolePermissionEntity.java new file mode 100644 index 0000000..d20fa4c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformRolePermissionEntity.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.platform.access.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台角色权限关系实体。 + */ +@TableName("platform_role_permission") +public class PlatformRolePermissionEntity { + + /** 角色权限关系 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 角色 ID。 */ + private Long roleId; + /** 权限 ID。 */ + private Long permissionId; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getRoleId() { return roleId; } + public void setRoleId(Long roleId) { this.roleId = roleId; } + public Long getPermissionId() { return permissionId; } + public void setPermissionId(Long permissionId) { this.permissionId = permissionId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformUserRoleEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformUserRoleEntity.java new file mode 100644 index 0000000..fcc59b5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/domain/PlatformUserRoleEntity.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.platform.access.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台用户角色关系实体。 + */ +@TableName("platform_user_role") +public class PlatformUserRoleEntity { + + /** 用户角色关系 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 用户 ID。 */ + private Long userId; + /** 角色 ID。 */ + private Long roleId; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public Long getRoleId() { return roleId; } + public void setRoleId(Long roleId) { this.roleId = roleId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformPermissionMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformPermissionMapper.java new file mode 100644 index 0000000..5f765e8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformPermissionMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.access.mapper; + +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台权限 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformPermissionMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRoleMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRoleMapper.java new file mode 100644 index 0000000..289b475 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRoleMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.access.mapper; + +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台角色 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformRoleMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRolePermissionMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRolePermissionMapper.java new file mode 100644 index 0000000..bb53158 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformRolePermissionMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.access.mapper; + +import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台角色权限关系 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformRolePermissionMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformUserRoleMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformUserRoleMapper.java new file mode 100644 index 0000000..7617d9a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/mapper/PlatformUserRoleMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.access.mapper; + +import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台用户角色关系 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformUserRoleMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java new file mode 100644 index 0000000..08126f9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/MybatisPlatformAccessRepository.java @@ -0,0 +1,148 @@ +package cn.nianxx.thhotel.platform.access.repository; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionStatus; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleStatus; +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRolePermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformUserRoleEntity; +import cn.nianxx.thhotel.platform.access.mapper.PlatformPermissionMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformRoleMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformRolePermissionMapper; +import cn.nianxx.thhotel.platform.access.mapper.PlatformUserRoleMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的平台角色权限仓储实现。 + */ +@Repository +public class MybatisPlatformAccessRepository implements PlatformAccessRepository { + + private final PlatformRoleMapper roleMapper; + private final PlatformPermissionMapper permissionMapper; + private final PlatformUserRoleMapper userRoleMapper; + private final PlatformRolePermissionMapper rolePermissionMapper; + + public MybatisPlatformAccessRepository( + PlatformRoleMapper roleMapper, + PlatformPermissionMapper permissionMapper, + PlatformUserRoleMapper userRoleMapper, + PlatformRolePermissionMapper rolePermissionMapper) { + this.roleMapper = roleMapper; + this.permissionMapper = permissionMapper; + this.userRoleMapper = userRoleMapper; + this.rolePermissionMapper = rolePermissionMapper; + } + + @Override + public Optional findRoleByCode(String roleCode) { + return Optional.ofNullable(roleMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformRoleEntity::getRoleCode, roleCode) + .last("LIMIT 1"))); + } + + @Override + public Optional findPermissionByCode(String permissionCode) { + return Optional.ofNullable(permissionMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformPermissionEntity::getPermissionCode, permissionCode) + .last("LIMIT 1"))); + } + + @Override + public void insertRole(PlatformRoleEntity role) { + roleMapper.insert(role); + } + + @Override + public void updateRole(PlatformRoleEntity role) { + roleMapper.updateById(role); + } + + @Override + public void insertPermission(PlatformPermissionEntity permission) { + permissionMapper.insert(permission); + } + + @Override + public void updatePermission(PlatformPermissionEntity permission) { + permissionMapper.updateById(permission); + } + + @Override + public void ensureRolePermission(Long roleId, Long permissionId) { + Long existing = rolePermissionMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformRolePermissionEntity::getRoleId, roleId) + .eq(PlatformRolePermissionEntity::getPermissionId, permissionId)); + if (existing != null && existing > 0) { + return; + } + PlatformRolePermissionEntity relation = new PlatformRolePermissionEntity(); + relation.setRoleId(roleId); + relation.setPermissionId(permissionId); + relation.setCreatedAt(nowUtc()); + rolePermissionMapper.insert(relation); + } + + @Override + public void ensureUserRole(Long userId, Long roleId) { + Long existing = userRoleMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformUserRoleEntity::getUserId, userId) + .eq(PlatformUserRoleEntity::getRoleId, roleId)); + if (existing != null && existing > 0) { + return; + } + PlatformUserRoleEntity relation = new PlatformUserRoleEntity(); + relation.setUserId(userId); + relation.setRoleId(roleId); + relation.setCreatedAt(nowUtc()); + userRoleMapper.insert(relation); + } + + @Override + public List listActivePermissionCodesByUserId(Long userId) { + List userRoles = userRoleMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformUserRoleEntity::getUserId, userId)); + if (userRoles.isEmpty()) { + return Collections.emptyList(); + } + Set roleIds = new LinkedHashSet<>(userRoles.stream() + .map(PlatformUserRoleEntity::getRoleId) + .toList()); + List roles = roleMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformRoleEntity::getId, roleIds) + .eq(PlatformRoleEntity::getRoleStatus, PlatformRoleStatus.ACTIVE.name())); + if (roles.isEmpty()) { + return Collections.emptyList(); + } + Set activeRoleIds = new LinkedHashSet<>(roles.stream() + .map(PlatformRoleEntity::getId) + .toList()); + List rolePermissions = rolePermissionMapper.selectList( + Wrappers.lambdaQuery() + .in(PlatformRolePermissionEntity::getRoleId, activeRoleIds)); + if (rolePermissions.isEmpty()) { + return Collections.emptyList(); + } + Set permissionIds = new LinkedHashSet<>(rolePermissions.stream() + .map(PlatformRolePermissionEntity::getPermissionId) + .toList()); + return permissionMapper.selectList(Wrappers.lambdaQuery() + .in(PlatformPermissionEntity::getId, permissionIds) + .eq(PlatformPermissionEntity::getPermissionStatus, PlatformPermissionStatus.ACTIVE.name())) + .stream() + .map(PlatformPermissionEntity::getPermissionCode) + .toList(); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java new file mode 100644 index 0000000..ac76f24 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/repository/PlatformAccessRepository.java @@ -0,0 +1,57 @@ +package cn.nianxx.thhotel.platform.access.repository; + +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import java.util.List; +import java.util.Optional; + +/** + * 平台角色权限仓储。负责内置权限、角色和用户授权关系的持久化。 + */ +public interface PlatformAccessRepository { + + /** + * 按角色代码查询角色。 + */ + Optional findRoleByCode(String roleCode); + + /** + * 按权限代码查询权限。 + */ + Optional findPermissionByCode(String permissionCode); + + /** + * 新增角色。 + */ + void insertRole(PlatformRoleEntity role); + + /** + * 更新角色。 + */ + void updateRole(PlatformRoleEntity role); + + /** + * 新增权限。 + */ + void insertPermission(PlatformPermissionEntity permission); + + /** + * 更新权限。 + */ + void updatePermission(PlatformPermissionEntity permission); + + /** + * 确保角色权限关系存在。 + */ + void ensureRolePermission(Long roleId, Long permissionId); + + /** + * 确保用户角色关系存在。 + */ + void ensureUserRole(Long userId, Long roleId); + + /** + * 查询用户全部启用权限码。 + */ + List listActivePermissionCodesByUserId(Long userId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AccessControlService.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AccessControlService.java new file mode 100644 index 0000000..fd7f882 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/AccessControlService.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.access.service; + +import java.util.List; + +/** + * 平台权限判断服务。业务模块通过该服务读取权限码,不直接查询角色权限表。 + */ +public interface AccessControlService { + + /** + * 查询当前用户权限码;超级管理员返回全部内置权限码。 + */ + List listPermissionCodes(Long userId, boolean superAdmin); + + /** + * 判断用户是否拥有指定权限码。 + */ + boolean hasPermission(Long userId, boolean superAdmin, String permissionCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AccessControlServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AccessControlServiceImpl.java new file mode 100644 index 0000000..dbe882c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/service/impl/AccessControlServiceImpl.java @@ -0,0 +1,53 @@ +package cn.nianxx.thhotel.platform.access.service.impl; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.access.service.AccessControlService; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * 平台权限判断服务实现。第一版只基于内置权限码和角色权限关系。 + */ +@Service +public class AccessControlServiceImpl implements AccessControlService { + + private final PlatformAccessRepository accessRepository; + + /** + * 注入角色权限仓储,权限服务不直接依赖 MyBatis Mapper。 + */ + public AccessControlServiceImpl(PlatformAccessRepository accessRepository) { + this.accessRepository = accessRepository; + } + + /** + * 查询当前用户权限码,并按内置枚举顺序输出,保证前后端响应稳定。 + */ + @Override + public List listPermissionCodes(Long userId, boolean superAdmin) { + if (superAdmin) { + return List.of(PlatformPermissionCode.values()).stream() + .map(Enum::name) + .toList(); + } + Set ownedCodes = new LinkedHashSet<>(accessRepository.listActivePermissionCodesByUserId(userId)); + return List.of(PlatformPermissionCode.values()).stream() + .map(Enum::name) + .filter(ownedCodes::contains) + .toList(); + } + + /** + * 判断用户是否拥有指定权限码,超级管理员默认拥有全部内置权限。 + */ + @Override + public boolean hasPermission(Long userId, boolean superAdmin, String permissionCode) { + if (permissionCode == null || permissionCode.isBlank()) { + return false; + } + return listPermissionCodes(userId, superAdmin).contains(permissionCode); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/enums/PlatformHotelStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/enums/PlatformHotelStatus.java new file mode 100644 index 0000000..3dcdda8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/common/enums/PlatformHotelStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.platform.hotel.common.enums; + +/** + * 平台酒店状态稳定代码。 + */ +public enum PlatformHotelStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformHotelEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformHotelEntity.java new file mode 100644 index 0000000..e329926 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformHotelEntity.java @@ -0,0 +1,48 @@ +package cn.nianxx.thhotel.platform.hotel.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台酒店实体。保存登录态和业务请求使用的酒店上下文。 + */ +@TableName("platform_hotel") +public class PlatformHotelEntity { + + /** 酒店内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 业务酒店 ID,例如 HOTEL-TEST。 */ + private String hotelId; + /** 酒店展示名称。 */ + private String hotelName; + /** 酒店状态:ACTIVE、DISABLED。 */ + private String hotelStatus; + /** 酒店本地时区,例如 Asia/Bangkok。 */ + private String timeZone; + /** 排序号。 */ + private Integer sortOrder; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public String getHotelName() { return hotelName; } + public void setHotelName(String hotelName) { this.hotelName = hotelName; } + public String getHotelStatus() { return hotelStatus; } + public void setHotelStatus(String hotelStatus) { this.hotelStatus = hotelStatus; } + public String getTimeZone() { return timeZone; } + public void setTimeZone(String timeZone) { this.timeZone = timeZone; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformUserHotelEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformUserHotelEntity.java new file mode 100644 index 0000000..21200be --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/domain/PlatformUserHotelEntity.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.platform.hotel.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台用户酒店授权实体。普通用户只可访问授权酒店。 + */ +@TableName("platform_user_hotel") +public class PlatformUserHotelEntity { + + /** 用户酒店授权关系 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 用户 ID。 */ + private Long userId; + /** 酒店业务 ID。 */ + private String hotelId; + /** 是否该用户默认酒店。 */ + private Boolean defaultHotel; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Boolean getDefaultHotel() { return defaultHotel; } + public void setDefaultHotel(Boolean defaultHotel) { this.defaultHotel = defaultHotel; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformHotelMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformHotelMapper.java new file mode 100644 index 0000000..237a22f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformHotelMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.hotel.mapper; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台酒店 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformHotelMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformUserHotelMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformUserHotelMapper.java new file mode 100644 index 0000000..259f9d7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/mapper/PlatformUserHotelMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.hotel.mapper; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台用户酒店授权 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformUserHotelMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java new file mode 100644 index 0000000..350a4e6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/MybatisPlatformHotelRepository.java @@ -0,0 +1,100 @@ +package cn.nianxx.thhotel.platform.hotel.repository; + +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformUserHotelEntity; +import cn.nianxx.thhotel.platform.hotel.mapper.PlatformHotelMapper; +import cn.nianxx.thhotel.platform.hotel.mapper.PlatformUserHotelMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的平台酒店仓储实现。 + */ +@Repository +public class MybatisPlatformHotelRepository implements PlatformHotelRepository { + + private final PlatformHotelMapper hotelMapper; + private final PlatformUserHotelMapper userHotelMapper; + + public MybatisPlatformHotelRepository( + PlatformHotelMapper hotelMapper, + PlatformUserHotelMapper userHotelMapper) { + this.hotelMapper = hotelMapper; + this.userHotelMapper = userHotelMapper; + } + + @Override + public Optional findHotelByHotelId(String hotelId) { + return Optional.ofNullable(hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformHotelEntity::getHotelId, hotelId) + .last("LIMIT 1"))); + } + + @Override + public List listActiveHotels() { + return hotelMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformHotelEntity::getHotelStatus, PlatformHotelStatus.ACTIVE.name()) + .orderByAsc(PlatformHotelEntity::getSortOrder) + .orderByAsc(PlatformHotelEntity::getHotelId)); + } + + @Override + public List listUserHotelIds(Long userId) { + return userHotelMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getUserId, userId) + .orderByDesc(PlatformUserHotelEntity::getDefaultHotel) + .orderByAsc(PlatformUserHotelEntity::getHotelId)) + .stream() + .map(PlatformUserHotelEntity::getHotelId) + .toList(); + } + + @Override + public Optional findUserDefaultHotelId(Long userId) { + return Optional.ofNullable(userHotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getUserId, userId) + .eq(PlatformUserHotelEntity::getDefaultHotel, true) + .last("LIMIT 1"))) + .map(PlatformUserHotelEntity::getHotelId); + } + + @Override + public void insertHotel(PlatformHotelEntity hotel) { + hotelMapper.insert(hotel); + } + + @Override + public void updateHotel(PlatformHotelEntity hotel) { + hotelMapper.updateById(hotel); + } + + @Override + public void ensureUserHotel(Long userId, String hotelId, boolean defaultHotel) { + PlatformUserHotelEntity existing = userHotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformUserHotelEntity::getUserId, userId) + .eq(PlatformUserHotelEntity::getHotelId, hotelId) + .last("LIMIT 1")); + if (existing != null) { + if (defaultHotel && !Boolean.TRUE.equals(existing.getDefaultHotel())) { + existing.setDefaultHotel(true); + userHotelMapper.updateById(existing); + } + return; + } + PlatformUserHotelEntity relation = new PlatformUserHotelEntity(); + relation.setUserId(userId); + relation.setHotelId(hotelId); + relation.setDefaultHotel(defaultHotel); + relation.setCreatedAt(nowUtc()); + userHotelMapper.insert(relation); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java new file mode 100644 index 0000000..95e0cb8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelRepository.java @@ -0,0 +1,46 @@ +package cn.nianxx.thhotel.platform.hotel.repository; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import java.util.List; +import java.util.Optional; + +/** + * 平台酒店仓储。负责酒店基础信息和用户酒店授权关系。 + */ +public interface PlatformHotelRepository { + + /** + * 按酒店业务 ID 查询酒店。 + */ + Optional findHotelByHotelId(String hotelId); + + /** + * 查询全部启用酒店。 + */ + List listActiveHotels(); + + /** + * 查询普通用户授权酒店 ID 列表。 + */ + List listUserHotelIds(Long userId); + + /** + * 查询普通用户默认酒店 ID。 + */ + Optional findUserDefaultHotelId(Long userId); + + /** + * 新增酒店。 + */ + void insertHotel(PlatformHotelEntity hotel); + + /** + * 更新酒店。 + */ + void updateHotel(PlatformHotelEntity hotel); + + /** + * 确保用户酒店授权关系存在。 + */ + void ensureUserHotel(Long userId, String hotelId, boolean defaultHotel); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelAccessService.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelAccessService.java new file mode 100644 index 0000000..a23ba76 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelAccessService.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.hotel.service; + +import cn.nianxx.thhotel.platform.identity.common.result.AuthHotelResult; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import java.util.List; + +/** + * 平台酒店授权服务。负责当前用户可访问酒店和默认酒店解析。 + */ +public interface HotelAccessService { + + /** + * 查询当前用户可访问酒店列表。 + */ + List listAccessibleHotels(PlatformUserEntity user, String preferredHotelId); + + /** + * 判断当前用户是否可访问指定酒店。 + */ + boolean canAccessHotel(PlatformUserEntity user, String hotelId); + + /** + * 从酒店响应列表中解析默认酒店 ID。 + */ + String resolveDefaultHotelId(List hotels); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java new file mode 100644 index 0000000..3fac859 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelAccessServiceImpl.java @@ -0,0 +1,102 @@ +package cn.nianxx.thhotel.platform.hotel.service.impl; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.hotel.service.HotelAccessService; +import cn.nianxx.thhotel.platform.identity.common.result.AuthHotelResult; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthProperties; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * 平台酒店授权服务实现。超级管理员可访问全部启用酒店,普通用户只访问授权酒店。 + */ +@Service +public class HotelAccessServiceImpl implements HotelAccessService { + + private final PlatformHotelRepository hotelRepository; + private final AuthProperties authProperties; + + /** + * 注入酒店仓储和登录配置,用于解析当前用户酒店上下文。 + */ + public HotelAccessServiceImpl( + PlatformHotelRepository hotelRepository, + AuthProperties authProperties) { + this.hotelRepository = hotelRepository; + this.authProperties = authProperties; + } + + /** + * 查询当前用户可访问酒店,并根据前端偏好、用户默认酒店和系统默认酒店标记默认项。 + */ + @Override + public List listAccessibleHotels(PlatformUserEntity user, String preferredHotelId) { + List activeHotels = hotelRepository.listActiveHotels(); + Set accessibleHotelIds = resolveAccessibleHotelIds(user, activeHotels); + String defaultHotelId = resolveDefaultHotelId(user, accessibleHotelIds, preferredHotelId); + return activeHotels.stream() + .filter(hotel -> accessibleHotelIds.contains(hotel.getHotelId())) + .map(hotel -> new AuthHotelResult( + hotel.getHotelId(), + hotel.getHotelName(), + hotel.getTimeZone(), + hotel.getHotelId().equals(defaultHotelId))) + .toList(); + } + + /** + * 判断当前用户是否可访问指定酒店。空酒店 ID 第一版视为不可访问。 + */ + @Override + public boolean canAccessHotel(PlatformUserEntity user, String hotelId) { + if (user == null || hotelId == null || hotelId.isBlank()) { + return false; + } + return resolveAccessibleHotelIds(user, hotelRepository.listActiveHotels()).contains(hotelId); + } + + /** + * 从酒店响应列表中解析默认酒店 ID;没有显式默认时返回第一家可访问酒店。 + */ + @Override + public String resolveDefaultHotelId(List hotels) { + return hotels.stream() + .filter(hotel -> Boolean.TRUE.equals(hotel.defaultHotel())) + .map(AuthHotelResult::hotelId) + .findFirst() + .orElseGet(() -> hotels.isEmpty() ? null : hotels.get(0).hotelId()); + } + + private Set resolveAccessibleHotelIds(PlatformUserEntity user, List activeHotels) { + if (Boolean.TRUE.equals(user.getSuperAdmin())) { + return new LinkedHashSet<>(activeHotels.stream() + .map(PlatformHotelEntity::getHotelId) + .toList()); + } + return new LinkedHashSet<>(hotelRepository.listUserHotelIds(user.getId())); + } + + private String resolveDefaultHotelId( + PlatformUserEntity user, + Set accessibleHotelIds, + String preferredHotelId) { + if (preferredHotelId != null && accessibleHotelIds.contains(preferredHotelId)) { + return preferredHotelId; + } + if (!Boolean.TRUE.equals(user.getSuperAdmin())) { + String userDefaultHotelId = hotelRepository.findUserDefaultHotelId(user.getId()).orElse(null); + if (userDefaultHotelId != null && accessibleHotelIds.contains(userDefaultHotelId)) { + return userDefaultHotelId; + } + } + String configuredDefaultHotelId = authProperties.getBootstrap().getDefaultHotelId(); + if (configuredDefaultHotelId != null && accessibleHotelIds.contains(configuredDefaultHotelId)) { + return configuredDefaultHotelId; + } + return accessibleHotelIds.stream().findFirst().orElse(null); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/dto/AuthSessionSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/dto/AuthSessionSnapshot.java new file mode 100644 index 0000000..e1d9d79 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/dto/AuthSessionSnapshot.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.platform.identity.common.dto; + +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import java.time.LocalDateTime; + +/** + * 解析有效 session 后得到的内部快照。该对象不包含明文 token。 + */ +public record AuthSessionSnapshot( + /** 当前有效 session ID。 */ + Long sessionId, + /** session 过期 UTC 时间。 */ + LocalDateTime expiresAt, + /** session 所属用户实体。 */ + PlatformUserEntity user +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserSessionStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserSessionStatus.java new file mode 100644 index 0000000..c30b533 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserSessionStatus.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.identity.common.enums; + +/** + * 平台用户 session 状态稳定代码。 + */ +public enum PlatformUserSessionStatus { + ACTIVE, + REVOKED, + EXPIRED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserStatus.java new file mode 100644 index 0000000..6521225 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/enums/PlatformUserStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.platform.identity.common.enums; + +/** + * 平台用户状态稳定代码。 + */ +public enum PlatformUserStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AuthLoginRequest.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AuthLoginRequest.java new file mode 100644 index 0000000..58ac01c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/request/AuthLoginRequest.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.identity.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求。第一版只支持本系统用户名密码登录。 + */ +public record AuthLoginRequest( + /** 登录用户名。 */ + @NotBlank(message = "username is required") + String username, + /** 登录密码明文,只允许在请求入口短暂存在,禁止写入日志或数据库。 */ + @NotBlank(message = "password is required") + String password, + /** 前端当前选择的酒店 ID,第一版可为空,后端会按用户默认酒店兜底。 */ + @JsonProperty("preferred_hotel_id") + String preferredHotelId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthErrorResponse.java new file mode 100644 index 0000000..17dac9d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthErrorResponse.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 登录权限接口错误响应。错误信息不区分用户不存在、密码错误和禁用等内部状态。 + */ +public record AuthErrorResponse( + /** 稳定错误码,供前端做统一处理。 */ + @JsonProperty("error_code") + String errorCode, + /** 面向用户的错误提示。 */ + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthHotelResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthHotelResult.java new file mode 100644 index 0000000..6c864ca --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthHotelResult.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 当前用户可访问酒店响应。用于前端酒店切换器和默认酒店展示。 + */ +public record AuthHotelResult( + /** 酒店业务 ID。 */ + @JsonProperty("hotel_id") + String hotelId, + /** 酒店展示名称。 */ + @JsonProperty("hotel_name") + String hotelName, + /** 酒店本地时区。 */ + @JsonProperty("time_zone") + String timeZone, + /** 是否当前用户默认酒店。 */ + @JsonProperty("default_hotel") + Boolean defaultHotel +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLoginResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLoginResult.java new file mode 100644 index 0000000..a879b58 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLoginResult.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 登录成功响应。只返回明文 access token 一次,数据库只保存 token 哈希。 + */ +public record AuthLoginResult( + /** 访问令牌,前端放入 Authorization Bearer。 */ + @JsonProperty("access_token") + String accessToken, + /** token 类型,固定为 Bearer。 */ + @JsonProperty("token_type") + String tokenType, + /** token 过期 UTC 时间。 */ + @JsonProperty("expires_at") + OffsetDateTime expiresAt, + /** 当前登录用户信息。 */ + AuthUserResult user, + /** 当前用户默认酒店 ID。 */ + @JsonProperty("default_hotel_id") + String defaultHotelId, + /** 当前用户可访问酒店列表。 */ + List hotels, + /** 当前用户权限码列表。 */ + List permissions, + /** 当前用户可见菜单列表。 */ + List menus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLogoutResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLogoutResult.java new file mode 100644 index 0000000..ab79007 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthLogoutResult.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +/** + * 登出响应。只表达当前 token 是否已被撤销。 + */ +public record AuthLogoutResult( + /** 是否登出成功。 */ + Boolean success +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMeResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMeResult.java new file mode 100644 index 0000000..178b2b4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMeResult.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 当前用户上下文响应。用于前端刷新页面后恢复登录态、菜单和酒店上下文。 + */ +public record AuthMeResult( + /** 当前登录用户信息。 */ + AuthUserResult user, + /** 当前用户默认酒店 ID。 */ + @JsonProperty("default_hotel_id") + String defaultHotelId, + /** 当前用户可访问酒店列表。 */ + List hotels, + /** 当前用户权限码列表。 */ + List permissions, + /** 当前用户可见菜单列表。 */ + List menus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMenuResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMenuResult.java new file mode 100644 index 0000000..1f21041 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthMenuResult.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 当前用户可见菜单响应。菜单只控制前端入口,不替代接口权限校验。 + */ +public record AuthMenuResult( + /** 菜单稳定代码。 */ + @JsonProperty("menu_code") + String menuCode, + /** 菜单展示名称。 */ + @JsonProperty("menu_name") + String menuName, + /** 前端路由路径。 */ + @JsonProperty("route_path") + String routePath, + /** 前端组件标识,第一版可为空。 */ + @JsonProperty("component_key") + String componentKey, + /** 图标标识,第一版使用 PrimeIcons class。 */ + @JsonProperty("icon_key") + String iconKey, + /** 访问菜单需要的权限码。 */ + @JsonProperty("permission_code") + String permissionCode, + /** 排序号。 */ + @JsonProperty("sort_order") + Integer sortOrder +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthUserResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthUserResult.java new file mode 100644 index 0000000..02df703 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/common/result/AuthUserResult.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.identity.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 当前用户基础信息响应。敏感字段如密码哈希、token 哈希永远不返回。 + */ +public record AuthUserResult( + /** 用户内部 ID,按字符串返回以避免前端长整型精度问题。 */ + String id, + /** 登录用户名。 */ + String username, + /** 用户展示名称。 */ + @JsonProperty("display_name") + String displayName, + /** 是否超级管理员。 */ + @JsonProperty("super_admin") + Boolean superAdmin +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthController.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthController.java new file mode 100644 index 0000000..491cf40 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthController.java @@ -0,0 +1,61 @@ +package cn.nianxx.thhotel.platform.identity.control; + +import cn.nianxx.thhotel.platform.identity.common.request.AuthLoginRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLoginResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLogoutResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthMeResult; +import cn.nianxx.thhotel.platform.identity.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 登录权限 Controller。提供登录、登出和当前用户上下文接口。 + */ +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + /** + * 注入登录认证服务,Controller 只负责 HTTP 参数转换。 + */ + public AuthController(AuthService authService) { + this.authService = authService; + } + + /** + * 用户名密码登录,成功后返回 Bearer token 和当前用户上下文。 + */ + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public AuthLoginResult login( + @Valid @RequestBody AuthLoginRequest request, + HttpServletRequest servletRequest) { + return authService.login(request, servletRequest); + } + + /** + * 获取当前登录用户、权限、酒店和菜单上下文。 + */ + @GetMapping(value = "/me", produces = MediaType.APPLICATION_JSON_VALUE) + public AuthMeResult currentUser( + @RequestHeader(name = "Authorization", required = false) String authorizationHeader) { + return authService.currentUser(authorizationHeader); + } + + /** + * 登出当前 token,撤销当前数据库 session。 + */ + @PostMapping(value = "/logout", produces = MediaType.APPLICATION_JSON_VALUE) + public AuthLogoutResult logout( + @RequestHeader(name = "Authorization", required = false) String authorizationHeader) { + return authService.logout(authorizationHeader); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerAdvice.java new file mode 100644 index 0000000..88554fc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerAdvice.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.platform.identity.control; + +import cn.nianxx.thhotel.platform.identity.common.result.AuthErrorResponse; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 登录权限接口统一异常处理。错误响应不暴露密码、token 或账号内部状态。 + */ +@RestControllerAdvice(assignableTypes = AuthController.class) +public class AuthControllerAdvice { + + /** + * 处理登录认证受控异常。 + */ + @ExceptionHandler(AuthException.class) + public ResponseEntity handleAuthException(AuthException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new AuthErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 处理请求参数校验异常。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException() { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new AuthErrorResponse("AUTH_INVALID_REQUEST", "登录参数不完整。")); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserEntity.java new file mode 100644 index 0000000..1f4f51a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserEntity.java @@ -0,0 +1,64 @@ +package cn.nianxx.thhotel.platform.identity.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台用户实体。保存本系统登录账号,不保存明文密码。 + */ +@TableName("platform_user") +public class PlatformUserEntity { + + /** 用户内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 登录用户名,全局唯一。 */ + private String username; + /** 密码单向哈希,禁止输出到日志、响应或审计。 */ + private String passwordHash; + /** 用户展示名称。 */ + private String displayName; + /** 用户邮箱,第一版可为空。 */ + private String email; + /** 用户手机号,第一版可为空。 */ + private String phone; + /** 用户状态:ACTIVE、DISABLED。 */ + private String userStatus; + /** 是否超级管理员,超级管理员默认可访问全部启用酒店。 */ + private Boolean superAdmin; + /** 最近密码变更 UTC 时间。 */ + private LocalDateTime passwordChangedAt; + /** 最近登录 UTC 时间。 */ + private LocalDateTime lastLoginAt; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getUserStatus() { return userStatus; } + public void setUserStatus(String userStatus) { this.userStatus = userStatus; } + public Boolean getSuperAdmin() { return superAdmin; } + public void setSuperAdmin(Boolean superAdmin) { this.superAdmin = superAdmin; } + public LocalDateTime getPasswordChangedAt() { return passwordChangedAt; } + public void setPasswordChangedAt(LocalDateTime passwordChangedAt) { this.passwordChangedAt = passwordChangedAt; } + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserSessionEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserSessionEntity.java new file mode 100644 index 0000000..3ebb5fa --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/domain/PlatformUserSessionEntity.java @@ -0,0 +1,64 @@ +package cn.nianxx.thhotel.platform.identity.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台用户 session 实体。数据库只保存 token_hash,不保存明文 access token。 + */ +@TableName("platform_user_session") +public class PlatformUserSessionEntity { + + /** session 内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 所属用户 ID。 */ + private Long userId; + /** access token 的 SHA-256 哈希。 */ + private String tokenHash; + /** session 状态:ACTIVE、REVOKED、EXPIRED。 */ + private String sessionStatus; + /** token 签发 UTC 时间。 */ + private LocalDateTime issuedAt; + /** token 过期 UTC 时间。 */ + private LocalDateTime expiresAt; + /** 注销或撤销 UTC 时间。 */ + private LocalDateTime revokedAt; + /** 最近使用 UTC 时间。 */ + private LocalDateTime lastSeenAt; + /** 登录或最近请求 IP,第一版可为空。 */ + private String clientIp; + /** User-Agent 摘要,第一版可为空。 */ + private String userAgentSummary; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getTokenHash() { return tokenHash; } + public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } + public String getSessionStatus() { return sessionStatus; } + public void setSessionStatus(String sessionStatus) { this.sessionStatus = sessionStatus; } + public LocalDateTime getIssuedAt() { return issuedAt; } + public void setIssuedAt(LocalDateTime issuedAt) { this.issuedAt = issuedAt; } + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public LocalDateTime getRevokedAt() { return revokedAt; } + public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; } + public LocalDateTime getLastSeenAt() { return lastSeenAt; } + public void setLastSeenAt(LocalDateTime lastSeenAt) { this.lastSeenAt = lastSeenAt; } + public String getClientIp() { return clientIp; } + public void setClientIp(String clientIp) { this.clientIp = clientIp; } + public String getUserAgentSummary() { return userAgentSummary; } + public void setUserAgentSummary(String userAgentSummary) { this.userAgentSummary = userAgentSummary; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserMapper.java new file mode 100644 index 0000000..5f39868 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.identity.mapper; + +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台用户 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformUserMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserSessionMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserSessionMapper.java new file mode 100644 index 0000000..296cda0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/mapper/PlatformUserSessionMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.identity.mapper; + +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台用户 session Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformUserSessionMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java new file mode 100644 index 0000000..3871538 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/MybatisPlatformIdentityRepository.java @@ -0,0 +1,74 @@ +package cn.nianxx.thhotel.platform.identity.repository; + +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; +import cn.nianxx.thhotel.platform.identity.mapper.PlatformUserMapper; +import cn.nianxx.thhotel.platform.identity.mapper.PlatformUserSessionMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的平台用户与 session 仓储实现。 + */ +@Repository +public class MybatisPlatformIdentityRepository implements PlatformIdentityRepository { + + private final PlatformUserMapper userMapper; + private final PlatformUserSessionMapper sessionMapper; + + public MybatisPlatformIdentityRepository( + PlatformUserMapper userMapper, + PlatformUserSessionMapper sessionMapper) { + this.userMapper = userMapper; + this.sessionMapper = sessionMapper; + } + + @Override + public Optional findUserByUsername(String username) { + return Optional.ofNullable(userMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformUserEntity::getUsername, username) + .last("LIMIT 1"))); + } + + @Override + public Optional findUserById(Long userId) { + if (userId == null) { + return Optional.empty(); + } + return Optional.ofNullable(userMapper.selectById(userId)); + } + + @Override + public long countSuperAdminUsers() { + return userMapper.selectCount(Wrappers.lambdaQuery() + .eq(PlatformUserEntity::getSuperAdmin, true)); + } + + @Override + public void insertUser(PlatformUserEntity user) { + userMapper.insert(user); + } + + @Override + public void updateUser(PlatformUserEntity user) { + userMapper.updateById(user); + } + + @Override + public void insertSession(PlatformUserSessionEntity session) { + sessionMapper.insert(session); + } + + @Override + public Optional findSessionByTokenHash(String tokenHash) { + return Optional.ofNullable(sessionMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformUserSessionEntity::getTokenHash, tokenHash) + .last("LIMIT 1"))); + } + + @Override + public void updateSession(PlatformUserSessionEntity session) { + sessionMapper.updateById(session); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java new file mode 100644 index 0000000..b6a0f86 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/repository/PlatformIdentityRepository.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.platform.identity.repository; + +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; +import java.util.Optional; + +/** + * 平台用户与 session 仓储。隔离登录服务对 MyBatis Mapper 的直接依赖。 + */ +public interface PlatformIdentityRepository { + + /** + * 按用户名查询用户账号。 + */ + Optional findUserByUsername(String username); + + /** + * 按用户内部 ID 查询用户账号。 + */ + Optional findUserById(Long userId); + + /** + * 统计系统中已有超级管理员数量。 + */ + long countSuperAdminUsers(); + + /** + * 新增用户账号。 + */ + void insertUser(PlatformUserEntity user); + + /** + * 更新用户账号。 + */ + void updateUser(PlatformUserEntity user); + + /** + * 新增用户 session。 + */ + void insertSession(PlatformUserSessionEntity session); + + /** + * 根据 token 哈希查询 session。 + */ + Optional findSessionByTokenHash(String tokenHash); + + /** + * 更新用户 session。 + */ + void updateSession(PlatformUserSessionEntity session); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AuthService.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AuthService.java new file mode 100644 index 0000000..345148f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/AuthService.java @@ -0,0 +1,35 @@ +package cn.nianxx.thhotel.platform.identity.service; + +import cn.nianxx.thhotel.platform.identity.common.request.AuthLoginRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLoginResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLogoutResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthMeResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * 登录认证服务。负责用户名密码登录、登出、当前用户上下文和可选 Bearer token 解析。 + */ +public interface AuthService { + + /** + * 用户名密码登录,成功后签发数据库 session token。 + */ + AuthLoginResult login(AuthLoginRequest request, HttpServletRequest servletRequest); + + /** + * 查询当前登录用户上下文,缺少或无效 token 时返回受控 401。 + */ + AuthMeResult currentUser(String authorizationHeader); + + /** + * 撤销当前 token 对应 session。 + */ + AuthLogoutResult logout(String authorizationHeader); + + /** + * 可选解析 Bearer token,供过滤器注入上下文;无 token 或无效 token 不抛业务异常。 + */ + Optional resolveOptionalContext(String authorizationHeader); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthException.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthException.java new file mode 100644 index 0000000..a058821 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * 登录权限模块受控异常。ControllerAdvice 负责转换为稳定 HTTP 响应。 + */ +public class AuthException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public AuthException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthPasswordService.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthPasswordService.java new file mode 100644 index 0000000..ca49232 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthPasswordService.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * 密码哈希服务。统一封装 BCrypt,避免业务代码直接处理算法细节。 + */ +@Component +public class AuthPasswordService { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + /** + * 对明文密码生成单向哈希。 + */ + public String hash(String rawPassword) { + return encoder.encode(rawPassword); + } + + /** + * 校验明文密码和已保存哈希是否匹配。 + */ + public boolean matches(String rawPassword, String passwordHash) { + if (rawPassword == null || passwordHash == null || passwordHash.isBlank()) { + return false; + } + return encoder.matches(rawPassword, passwordHash); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthProperties.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthProperties.java new file mode 100644 index 0000000..79a230e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthProperties.java @@ -0,0 +1,134 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 登录权限底座配置。账号密码等敏感配置只能通过环境变量或部署 Secret 注入。 + */ +@Component +@ConfigurationProperties(prefix = "auth") +public class AuthProperties { + + /** 启动初始化配置。 */ + private Bootstrap bootstrap = new Bootstrap(); + /** session 配置。 */ + private Session session = new Session(); + + public Bootstrap getBootstrap() { + return bootstrap; + } + + public void setBootstrap(Bootstrap bootstrap) { + this.bootstrap = bootstrap; + } + + public Session getSession() { + return session; + } + + public void setSession(Session session) { + this.session = session; + } + + /** + * 启动初始化配置。 + */ + public static class Bootstrap { + + /** 初始超级管理员配置。 */ + private Admin admin = new Admin(); + /** 默认酒店业务 ID。 */ + private String defaultHotelId = ""; + /** 默认酒店名称。 */ + private String defaultHotelName = ""; + /** 默认酒店时区。 */ + private String defaultHotelTimeZone = "Asia/Bangkok"; + + public Admin getAdmin() { + return admin; + } + + public void setAdmin(Admin admin) { + this.admin = admin; + } + + public String getDefaultHotelId() { + return defaultHotelId; + } + + public void setDefaultHotelId(String defaultHotelId) { + this.defaultHotelId = defaultHotelId; + } + + public String getDefaultHotelName() { + return defaultHotelName; + } + + public void setDefaultHotelName(String defaultHotelName) { + this.defaultHotelName = defaultHotelName; + } + + public String getDefaultHotelTimeZone() { + return defaultHotelTimeZone; + } + + public void setDefaultHotelTimeZone(String defaultHotelTimeZone) { + this.defaultHotelTimeZone = defaultHotelTimeZone; + } + } + + /** + * 初始超级管理员配置。 + */ + public static class Admin { + + /** 初始超级管理员用户名。 */ + private String username = ""; + /** 初始超级管理员密码。 */ + private String password = ""; + /** 初始超级管理员展示名。 */ + private String displayName = "系统管理员"; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + } + + /** + * session 配置。 + */ + public static class Session { + + /** session 有效分钟数,默认 12 小时。 */ + private long ttlMinutes = 720L; + + public long getTtlMinutes() { + return ttlMinutes; + } + + public void setTtlMinutes(long ttlMinutes) { + this.ttlMinutes = ttlMinutes; + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..75e9ef1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthServiceImpl.java @@ -0,0 +1,301 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import cn.nianxx.thhotel.platform.access.service.AccessControlService; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.service.HotelAccessService; +import cn.nianxx.thhotel.platform.identity.common.dto.AuthSessionSnapshot; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserSessionStatus; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.common.request.AuthLoginRequest; +import cn.nianxx.thhotel.platform.identity.common.result.AuthHotelResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLoginResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthLogoutResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthMeResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthMenuResult; +import cn.nianxx.thhotel.platform.identity.common.result.AuthUserResult; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserSessionEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.AuthService; +import cn.nianxx.thhotel.platform.navigation.service.NavigationService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 登录认证服务实现。第一版使用数据库 session token,不使用 JWT。 + */ +@Service +public class AuthServiceImpl implements AuthService { + + private static final String TOKEN_TYPE = "Bearer"; + private static final String INVALID_CREDENTIALS_MESSAGE = "用户名或密码错误。"; + + private final PlatformIdentityRepository identityRepository; + private final AccessControlService accessControlService; + private final HotelAccessService hotelAccessService; + private final NavigationService navigationService; + private final AuthPasswordService passwordService; + private final AuthTokenService tokenService; + private final AuthProperties authProperties; + + /** + * 注入登录所需仓储、上下文服务和 token / 密码工具。 + */ + public AuthServiceImpl( + PlatformIdentityRepository identityRepository, + AccessControlService accessControlService, + HotelAccessService hotelAccessService, + NavigationService navigationService, + AuthPasswordService passwordService, + AuthTokenService tokenService, + AuthProperties authProperties) { + this.identityRepository = identityRepository; + this.accessControlService = accessControlService; + this.hotelAccessService = hotelAccessService; + this.navigationService = navigationService; + this.passwordService = passwordService; + this.tokenService = tokenService; + this.authProperties = authProperties; + } + + /** + * 用户名密码登录。失败时统一返回通用错误,避免泄露账号是否存在或是否被禁用。 + */ + @Override + @Transactional + public AuthLoginResult login(AuthLoginRequest request, HttpServletRequest servletRequest) { + String username = request.username() == null ? "" : request.username().trim(); + PlatformUserEntity user = identityRepository.findUserByUsername(username) + .filter(this::isActiveUser) + .filter(existingUser -> passwordService.matches(request.password(), existingUser.getPasswordHash())) + .orElseThrow(() -> new AuthException( + HttpStatus.UNAUTHORIZED, + "AUTH_INVALID_CREDENTIALS", + INVALID_CREDENTIALS_MESSAGE)); + + LocalDateTime now = nowUtc(); + LocalDateTime expiresAt = now.plusMinutes(Math.max(1L, authProperties.getSession().getTtlMinutes())); + String accessToken = tokenService.generateToken(); + PlatformUserSessionEntity session = new PlatformUserSessionEntity(); + session.setUserId(user.getId()); + session.setTokenHash(tokenService.hashToken(accessToken)); + session.setSessionStatus(PlatformUserSessionStatus.ACTIVE.name()); + session.setIssuedAt(now); + session.setExpiresAt(expiresAt); + session.setLastSeenAt(now); + session.setClientIp(clientIp(servletRequest)); + session.setUserAgentSummary(userAgent(servletRequest)); + session.setCreatedAt(now); + session.setUpdatedAt(now); + identityRepository.insertSession(session); + + user.setLastLoginAt(now); + user.setUpdatedAt(now); + identityRepository.updateUser(user); + + AuthMeResult context = buildMeResult(user, request.preferredHotelId()); + return new AuthLoginResult( + accessToken, + TOKEN_TYPE, + UtcTimeFormatter.toUtcOffsetDateTime(expiresAt), + context.user(), + context.defaultHotelId(), + context.hotels(), + context.permissions(), + context.menus()); + } + + /** + * 查询当前登录用户上下文。该接口是登录态恢复入口,因此缺少 token 必须返回 401。 + */ + @Override + @Transactional + public AuthMeResult currentUser(String authorizationHeader) { + AuthSessionSnapshot snapshot = resolveRequiredSession(authorizationHeader); + touchSession(snapshot.sessionId()); + return buildMeResult(snapshot.user(), null); + } + + /** + * 登出当前 token。第一版只撤销当前 session,不影响同一用户其他登录态。 + */ + @Override + @Transactional + public AuthLogoutResult logout(String authorizationHeader) { + String token = extractRequiredBearerToken(authorizationHeader); + PlatformUserSessionEntity session = identityRepository.findSessionByTokenHash(tokenService.hashToken(token)) + .orElseThrow(this::invalidSession); + if (PlatformUserSessionStatus.REVOKED.name().equals(session.getSessionStatus()) + || PlatformUserSessionStatus.EXPIRED.name().equals(session.getSessionStatus())) { + return new AuthLogoutResult(true); + } + LocalDateTime now = nowUtc(); + session.setSessionStatus(PlatformUserSessionStatus.REVOKED.name()); + session.setRevokedAt(now); + session.setUpdatedAt(now); + identityRepository.updateSession(session); + return new AuthLogoutResult(true); + } + + /** + * 可选解析 token。该方法专供请求过滤器使用,不让无效 token 破坏现有业务接口兼容性。 + */ + @Override + @Transactional + public Optional resolveOptionalContext(String authorizationHeader) { + String token = extractBearerToken(authorizationHeader).orElse(null); + if (token == null) { + return Optional.empty(); + } + return resolveSessionByToken(token, false) + .map(snapshot -> { + touchSession(snapshot.sessionId()); + return buildSecurityContext(snapshot.user()); + }); + } + + private AuthMeResult buildMeResult(PlatformUserEntity user, String preferredHotelId) { + List hotels = hotelAccessService.listAccessibleHotels(user, preferredHotelId); + String defaultHotelId = hotelAccessService.resolveDefaultHotelId(hotels); + List permissions = accessControlService.listPermissionCodes( + user.getId(), + Boolean.TRUE.equals(user.getSuperAdmin())); + List menus = navigationService.listVisibleMenus(permissions); + return new AuthMeResult( + new AuthUserResult( + String.valueOf(user.getId()), + user.getUsername(), + user.getDisplayName(), + Boolean.TRUE.equals(user.getSuperAdmin())), + defaultHotelId, + hotels, + permissions, + menus); + } + + private AuthenticatedUserContext buildSecurityContext(PlatformUserEntity user) { + List hotels = hotelAccessService.listAccessibleHotels(user, null); + List permissions = accessControlService.listPermissionCodes( + user.getId(), + Boolean.TRUE.equals(user.getSuperAdmin())); + return new AuthenticatedUserContext( + user.getId(), + user.getUsername(), + user.getDisplayName(), + Boolean.TRUE.equals(user.getSuperAdmin()), + hotelAccessService.resolveDefaultHotelId(hotels), + hotels.stream().map(AuthHotelResult::hotelId).toList(), + permissions); + } + + private AuthSessionSnapshot resolveRequiredSession(String authorizationHeader) { + String token = extractRequiredBearerToken(authorizationHeader); + return resolveSessionByToken(token, true).orElseThrow(this::invalidSession); + } + + private Optional resolveSessionByToken(String token, boolean strict) { + String tokenHash = tokenService.hashToken(token); + Optional session = identityRepository.findSessionByTokenHash(tokenHash); + if (session.isEmpty()) { + return Optional.empty(); + } + PlatformUserSessionEntity existingSession = session.get(); + if (!PlatformUserSessionStatus.ACTIVE.name().equals(existingSession.getSessionStatus())) { + return Optional.empty(); + } + if (existingSession.getExpiresAt() != null && existingSession.getExpiresAt().isBefore(nowUtc())) { + markExpired(existingSession); + return Optional.empty(); + } + Optional user = identityRepository.findUserById(existingSession.getUserId()) + .filter(this::isActiveUser); + if (user.isEmpty() && strict) { + throw invalidSession(); + } + return user.map(value -> new AuthSessionSnapshot(existingSession.getId(), existingSession.getExpiresAt(), value)); + } + + private void touchSession(Long sessionId) { + if (sessionId == null) { + return; + } + PlatformUserSessionEntity session = new PlatformUserSessionEntity(); + session.setId(sessionId); + session.setLastSeenAt(nowUtc()); + session.setUpdatedAt(nowUtc()); + identityRepository.updateSession(session); + } + + private void markExpired(PlatformUserSessionEntity session) { + session.setSessionStatus(PlatformUserSessionStatus.EXPIRED.name()); + session.setUpdatedAt(nowUtc()); + identityRepository.updateSession(session); + } + + private boolean isActiveUser(PlatformUserEntity user) { + return PlatformUserStatus.ACTIVE.name().equals(user.getUserStatus()); + } + + private String extractRequiredBearerToken(String authorizationHeader) { + return extractBearerToken(authorizationHeader) + .orElseThrow(() -> new AuthException( + HttpStatus.UNAUTHORIZED, + "AUTH_TOKEN_REQUIRED", + "请先登录。")); + } + + private Optional extractBearerToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + return Optional.empty(); + } + String prefix = TOKEN_TYPE + " "; + if (!authorizationHeader.regionMatches(true, 0, prefix, 0, prefix.length())) { + return Optional.empty(); + } + String token = authorizationHeader.substring(prefix.length()).trim(); + return token.isBlank() ? Optional.empty() : Optional.of(token); + } + + private AuthException invalidSession() { + return new AuthException( + HttpStatus.UNAUTHORIZED, + "AUTH_SESSION_INVALID", + "登录已失效,请重新登录。"); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private String clientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + return truncate(forwardedFor.split(",")[0].trim(), 64); + } + return truncate(request.getRemoteAddr(), 64); + } + + private String userAgent(HttpServletRequest request) { + if (request == null) { + return null; + } + return truncate(request.getHeader("User-Agent"), 256); + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthTokenService.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthTokenService.java new file mode 100644 index 0000000..7f4b6c8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/AuthTokenService.java @@ -0,0 +1,39 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +/** + * 登录 token 服务。数据库只保存哈希,明文 token 只在登录响应和请求头中出现。 + */ +@Component +public class AuthTokenService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + /** + * 生成高熵 Bearer token。 + */ + public String generateToken() { + byte[] randomBytes = new byte[48]; + SECURE_RANDOM.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + /** + * 计算 token 的 SHA-256 十六进制哈希。 + */ + public String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is not available", exception); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java new file mode 100644 index 0000000..47830d9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -0,0 +1,371 @@ +package cn.nianxx.thhotel.platform.identity.service.impl; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionStatus; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleCode; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleStatus; +import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity; +import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity; +import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository; +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +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.navigation.common.enums.PlatformMenuCode; +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 java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * M003 登录权限底座启动初始化。只写入系统内置数据和首个超级管理员。 + */ +@Component +public class PlatformIdentityBootstrapRunner implements ApplicationRunner { + + private final AuthProperties authProperties; + private final AuthPasswordService passwordService; + private final PlatformIdentityRepository identityRepository; + private final PlatformAccessRepository accessRepository; + private final PlatformHotelRepository hotelRepository; + private final PlatformNavigationRepository navigationRepository; + + /** + * 注入启动初始化所需配置、密码工具和平台仓储。 + */ + public PlatformIdentityBootstrapRunner( + AuthProperties authProperties, + AuthPasswordService passwordService, + PlatformIdentityRepository identityRepository, + PlatformAccessRepository accessRepository, + PlatformHotelRepository hotelRepository, + PlatformNavigationRepository navigationRepository) { + this.authProperties = authProperties; + this.passwordService = passwordService; + this.identityRepository = identityRepository; + this.accessRepository = accessRepository; + this.hotelRepository = hotelRepository; + this.navigationRepository = navigationRepository; + } + + /** + * 应用启动时初始化内置权限、角色、菜单、默认酒店和首个超级管理员。 + */ + @Override + @Transactional + public void run(ApplicationArguments args) { + seedPermissions(); + seedRoles(); + seedRolePermissions(); + seedMenus(); + seedDefaultHotel(); + seedBootstrapAdmin(); + } + + private void seedPermissions() { + for (PlatformPermissionCode code : PlatformPermissionCode.values()) { + PlatformPermissionEntity permission = accessRepository.findPermissionByCode(code.name()) + .orElseGet(PlatformPermissionEntity::new); + boolean creating = permission.getId() == null; + permission.setPermissionCode(code.name()); + permission.setPermissionName(permissionName(code)); + permission.setPermissionGroup(permissionGroup(code)); + permission.setPermissionStatus(PlatformPermissionStatus.ACTIVE.name()); + permission.setSystemBuiltin(true); + setAuditTime(permission, creating); + if (creating) { + accessRepository.insertPermission(permission); + } else { + accessRepository.updatePermission(permission); + } + } + } + + private void seedRoles() { + for (PlatformRoleCode code : PlatformRoleCode.values()) { + PlatformRoleEntity role = accessRepository.findRoleByCode(code.name()) + .orElseGet(PlatformRoleEntity::new); + boolean creating = role.getId() == null; + role.setRoleCode(code.name()); + role.setRoleName(roleName(code)); + role.setRoleStatus(PlatformRoleStatus.ACTIVE.name()); + role.setSystemBuiltin(true); + setAuditTime(role, creating); + if (creating) { + accessRepository.insertRole(role); + } else { + accessRepository.updateRole(role); + } + } + } + + private void seedRolePermissions() { + Map> matrix = rolePermissionMatrix(); + for (Map.Entry> entry : matrix.entrySet()) { + PlatformRoleEntity role = accessRepository.findRoleByCode(entry.getKey().name()) + .orElseThrow(() -> new IllegalStateException("role not seeded: " + entry.getKey())); + for (PlatformPermissionCode permissionCode : entry.getValue()) { + PlatformPermissionEntity permission = accessRepository.findPermissionByCode(permissionCode.name()) + .orElseThrow(() -> new IllegalStateException("permission not seeded: " + permissionCode)); + accessRepository.ensureRolePermission(role.getId(), permission.getId()); + } + } + } + + private void seedMenus() { + upsertMenu( + PlatformMenuCode.RESERVATION_ORDERS, + "订单列表", + "/reservation/orders", + "ReservationOrders", + "pi pi-list", + PlatformPermissionCode.RESERVATION_ORDER_READ.name(), + 10, + true); + upsertMenu( + PlatformMenuCode.RESERVATION_TASKS, + "任务队列", + "/reservation/tasks", + "ReservationTasks", + "pi pi-check-square", + PlatformPermissionCode.RESERVATION_TASK_READ.name(), + 20, + true); + upsertMenu( + PlatformMenuCode.DEBUG_EML_SUPERAGENT, + "Debug EML", + "/debug/eml-superagent", + "DebugEmlSuperAgent", + "pi pi-upload", + PlatformPermissionCode.SYSTEM_DEBUG_EML_RUN.name(), + 30, + true); + upsertMenu( + PlatformMenuCode.SOURCE_MESSAGES, + "邮件消息", + "/source-messages", + "SourceMessages", + "pi pi-envelope", + PlatformPermissionCode.SOURCE_MESSAGE_READ.name(), + 90, + false); + upsertMenu( + PlatformMenuCode.SYSTEM_SETTINGS, + "系统设置", + "/system", + "SystemSettings", + "pi pi-cog", + PlatformPermissionCode.SYSTEM_USER_MANAGE.name(), + 100, + false); + } + + private void seedDefaultHotel() { + String defaultHotelId = authProperties.getBootstrap().getDefaultHotelId(); + if (isBlank(defaultHotelId)) { + return; + } + PlatformHotelEntity hotel = hotelRepository.findHotelByHotelId(defaultHotelId) + .orElseGet(PlatformHotelEntity::new); + boolean creating = hotel.getId() == null; + hotel.setHotelId(defaultHotelId); + hotel.setHotelName(defaultString(authProperties.getBootstrap().getDefaultHotelName(), defaultHotelId)); + hotel.setHotelStatus(PlatformHotelStatus.ACTIVE.name()); + hotel.setTimeZone(defaultString(authProperties.getBootstrap().getDefaultHotelTimeZone(), "Asia/Bangkok")); + hotel.setSortOrder(10); + setAuditTime(hotel, creating); + if (creating) { + hotelRepository.insertHotel(hotel); + } else { + hotelRepository.updateHotel(hotel); + } + } + + private void seedBootstrapAdmin() { + AuthProperties.Admin admin = authProperties.getBootstrap().getAdmin(); + if (isBlank(admin.getUsername()) || isBlank(admin.getPassword()) || identityRepository.countSuperAdminUsers() > 0) { + return; + } + LocalDateTime now = nowUtc(); + PlatformUserEntity user = identityRepository.findUserByUsername(admin.getUsername().trim()) + .orElseGet(PlatformUserEntity::new); + boolean creating = user.getId() == null; + user.setUsername(admin.getUsername().trim()); + user.setPasswordHash(passwordService.hash(admin.getPassword())); + user.setDisplayName(defaultString(admin.getDisplayName(), admin.getUsername().trim())); + user.setUserStatus(PlatformUserStatus.ACTIVE.name()); + user.setSuperAdmin(true); + user.setPasswordChangedAt(now); + setAuditTime(user, creating); + if (creating) { + identityRepository.insertUser(user); + } else { + identityRepository.updateUser(user); + } + + PlatformRoleEntity adminRole = accessRepository.findRoleByCode(PlatformRoleCode.SYSTEM_ADMIN.name()) + .orElseThrow(() -> new IllegalStateException("SYSTEM_ADMIN role not seeded")); + accessRepository.ensureUserRole(user.getId(), adminRole.getId()); + + String defaultHotelId = authProperties.getBootstrap().getDefaultHotelId(); + if (!isBlank(defaultHotelId)) { + hotelRepository.ensureUserHotel(user.getId(), defaultHotelId, true); + } + } + + private void upsertMenu( + PlatformMenuCode code, + String name, + String routePath, + String componentKey, + String iconKey, + String permissionCode, + int sortOrder, + boolean visible) { + PlatformMenuEntity menu = navigationRepository.findMenuByCode(code.name()) + .orElseGet(PlatformMenuEntity::new); + boolean creating = menu.getId() == null; + menu.setMenuCode(code.name()); + menu.setMenuName(name); + menu.setMenuType(PlatformMenuType.PAGE.name()); + menu.setRoutePath(routePath); + menu.setComponentKey(componentKey); + menu.setIconKey(iconKey); + menu.setPermissionCode(permissionCode); + menu.setSortOrder(sortOrder); + menu.setVisible(visible); + menu.setMenuStatus(PlatformMenuStatus.ACTIVE.name()); + setAuditTime(menu, creating); + if (creating) { + navigationRepository.insertMenu(menu); + } else { + navigationRepository.updateMenu(menu); + } + } + + private Map> rolePermissionMatrix() { + Map> matrix = new EnumMap<>(PlatformRoleCode.class); + matrix.put(PlatformRoleCode.SYSTEM_ADMIN, List.of(PlatformPermissionCode.values())); + matrix.put(PlatformRoleCode.RESERVATION_OPERATOR, List.of( + PlatformPermissionCode.SYSTEM_AUTH_READ, + PlatformPermissionCode.HOTEL_SWITCH, + PlatformPermissionCode.RESERVATION_ORDER_READ, + PlatformPermissionCode.RESERVATION_TASK_READ, + PlatformPermissionCode.RESERVATION_TASK_EDIT, + PlatformPermissionCode.RESERVATION_TASK_CONFIRM, + PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE, + PlatformPermissionCode.RESERVATION_AUDIT_READ, + PlatformPermissionCode.SOURCE_MESSAGE_READ, + PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ)); + matrix.put(PlatformRoleCode.RESERVATION_VIEWER, List.of( + PlatformPermissionCode.SYSTEM_AUTH_READ, + PlatformPermissionCode.HOTEL_SWITCH, + PlatformPermissionCode.RESERVATION_ORDER_READ, + PlatformPermissionCode.RESERVATION_TASK_READ, + PlatformPermissionCode.RESERVATION_AUDIT_READ, + PlatformPermissionCode.SOURCE_MESSAGE_READ)); + return matrix; + } + + private String permissionName(PlatformPermissionCode code) { + return switch (code) { + case SOURCE_MESSAGE_READ -> "读取邮件消息"; + case SOURCE_MESSAGE_ORIGINAL_READ -> "读取邮件原文"; + case RESERVATION_ORDER_READ -> "读取订单"; + case RESERVATION_TASK_READ -> "读取任务"; + case RESERVATION_TASK_EDIT -> "编辑任务草稿"; + case RESERVATION_TASK_CONFIRM -> "确认任务"; + case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟"; + case RESERVATION_AUDIT_READ -> "读取任务审计"; + case HOTEL_SWITCH -> "切换酒店"; + case SYSTEM_AUTH_READ -> "读取当前登录上下文"; + case SYSTEM_USER_MANAGE -> "管理用户"; + case SYSTEM_ROLE_MANAGE -> "管理角色"; + case SYSTEM_MENU_MANAGE -> "管理菜单"; + case HOTEL_MANAGE -> "管理酒店"; + case SYSTEM_DEBUG_EML_RUN -> "运行 Debug EML"; + }; + } + + private String permissionGroup(PlatformPermissionCode code) { + return switch (code) { + case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE"; + case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT, + RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE, + RESERVATION_AUDIT_READ -> "RESERVATION"; + case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL"; + default -> "SYSTEM"; + }; + } + + private String roleName(PlatformRoleCode code) { + return switch (code) { + case SYSTEM_ADMIN -> "系统管理员"; + case RESERVATION_OPERATOR -> "预订操作员"; + case RESERVATION_VIEWER -> "预订只读用户"; + }; + } + + private void setAuditTime(PlatformPermissionEntity entity, boolean creating) { + LocalDateTime now = nowUtc(); + if (creating) { + entity.setCreatedAt(now); + } + entity.setUpdatedAt(now); + } + + private void setAuditTime(PlatformRoleEntity entity, boolean creating) { + LocalDateTime now = nowUtc(); + if (creating) { + entity.setCreatedAt(now); + } + entity.setUpdatedAt(now); + } + + private void setAuditTime(PlatformHotelEntity entity, boolean creating) { + LocalDateTime now = nowUtc(); + if (creating) { + entity.setCreatedAt(now); + } + entity.setUpdatedAt(now); + } + + private void setAuditTime(PlatformMenuEntity entity, boolean creating) { + LocalDateTime now = nowUtc(); + if (creating) { + entity.setCreatedAt(now); + } + entity.setUpdatedAt(now); + } + + private void setAuditTime(PlatformUserEntity entity, boolean creating) { + LocalDateTime now = nowUtc(); + if (creating) { + entity.setCreatedAt(now); + } + entity.setUpdatedAt(now); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String defaultString(String value, String fallback) { + return isBlank(value) ? fallback : value; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuCode.java new file mode 100644 index 0000000..26f3ed9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuCode.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.navigation.common.enums; + +/** + * 平台内置菜单稳定代码。 + */ +public enum PlatformMenuCode { + RESERVATION_ORDERS, + RESERVATION_TASKS, + DEBUG_EML_SUPERAGENT, + SOURCE_MESSAGES, + SYSTEM_SETTINGS +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuStatus.java new file mode 100644 index 0000000..7cca0b7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.platform.navigation.common.enums; + +/** + * 平台菜单状态稳定代码。 + */ +public enum PlatformMenuStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuType.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuType.java new file mode 100644 index 0000000..9d48237 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/common/enums/PlatformMenuType.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.navigation.common.enums; + +/** + * 平台菜单类型稳定代码。 + */ +public enum PlatformMenuType { + GROUP, + PAGE, + ACTION +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/domain/PlatformMenuEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/domain/PlatformMenuEntity.java new file mode 100644 index 0000000..57955e6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/domain/PlatformMenuEntity.java @@ -0,0 +1,72 @@ +package cn.nianxx.thhotel.platform.navigation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * 平台菜单实体。菜单可见性只控制入口,不替代后端接口权限校验。 + */ +@TableName("platform_menu") +public class PlatformMenuEntity { + + /** 菜单内部 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 父菜单 ID,根菜单为空。 */ + private Long parentId; + /** 稳定菜单代码。 */ + private String menuCode; + /** 菜单展示名称。 */ + private String menuName; + /** 菜单类型:GROUP、PAGE、ACTION。 */ + private String menuType; + /** 前端路由路径。 */ + private String routePath; + /** 前端组件标识,第一版可为空。 */ + private String componentKey; + /** 前端图标标识,第一版兼容 PrimeIcons class。 */ + private String iconKey; + /** 访问该菜单需要的权限码,可为空。 */ + private String permissionCode; + /** 菜单排序号。 */ + private Integer sortOrder; + /** 是否作为菜单入口可见。 */ + private Boolean visible; + /** 菜单状态:ACTIVE、DISABLED。 */ + private String menuStatus; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + public String getMenuCode() { return menuCode; } + public void setMenuCode(String menuCode) { this.menuCode = menuCode; } + public String getMenuName() { return menuName; } + public void setMenuName(String menuName) { this.menuName = menuName; } + public String getMenuType() { return menuType; } + public void setMenuType(String menuType) { this.menuType = menuType; } + public String getRoutePath() { return routePath; } + public void setRoutePath(String routePath) { this.routePath = routePath; } + public String getComponentKey() { return componentKey; } + public void setComponentKey(String componentKey) { this.componentKey = componentKey; } + public String getIconKey() { return iconKey; } + public void setIconKey(String iconKey) { this.iconKey = iconKey; } + public String getPermissionCode() { return permissionCode; } + public void setPermissionCode(String permissionCode) { this.permissionCode = permissionCode; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public Boolean getVisible() { return visible; } + public void setVisible(Boolean visible) { this.visible = visible; } + public String getMenuStatus() { return menuStatus; } + public void setMenuStatus(String menuStatus) { this.menuStatus = menuStatus; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/mapper/PlatformMenuMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/mapper/PlatformMenuMapper.java new file mode 100644 index 0000000..a1d9c3d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/mapper/PlatformMenuMapper.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.platform.navigation.mapper; + +import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 平台菜单 Mapper。自带 MyBatis-Plus 基础方法,语义化查询由 Repository 封装。 + */ +public interface PlatformMenuMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java new file mode 100644 index 0000000..b8338af --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/MybatisPlatformNavigationRepository.java @@ -0,0 +1,59 @@ +package cn.nianxx.thhotel.platform.navigation.repository; + +import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus; +import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; +import cn.nianxx.thhotel.platform.navigation.mapper.PlatformMenuMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * 基于 MyBatis-Plus 的平台菜单仓储实现。 + */ +@Repository +public class MybatisPlatformNavigationRepository implements PlatformNavigationRepository { + + private final PlatformMenuMapper menuMapper; + + public MybatisPlatformNavigationRepository(PlatformMenuMapper menuMapper) { + this.menuMapper = menuMapper; + } + + @Override + public Optional findMenuByCode(String menuCode) { + return Optional.ofNullable(menuMapper.selectOne(Wrappers.lambdaQuery() + .eq(PlatformMenuEntity::getMenuCode, menuCode) + .last("LIMIT 1"))); + } + + @Override + public void insertMenu(PlatformMenuEntity menu) { + menuMapper.insert(menu); + } + + @Override + public void updateMenu(PlatformMenuEntity menu) { + menuMapper.updateById(menu); + } + + @Override + public List listVisibleMenus(List permissionCodes) { + if (permissionCodes == null || permissionCodes.isEmpty()) { + return menuMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformMenuEntity::getMenuStatus, PlatformMenuStatus.ACTIVE.name()) + .eq(PlatformMenuEntity::getVisible, true) + .isNull(PlatformMenuEntity::getPermissionCode) + .orderByAsc(PlatformMenuEntity::getSortOrder) + .orderByAsc(PlatformMenuEntity::getMenuCode)); + } + return menuMapper.selectList(Wrappers.lambdaQuery() + .eq(PlatformMenuEntity::getMenuStatus, PlatformMenuStatus.ACTIVE.name()) + .eq(PlatformMenuEntity::getVisible, true) + .and(wrapper -> wrapper.isNull(PlatformMenuEntity::getPermissionCode) + .or() + .in(!permissionCodes.isEmpty(), PlatformMenuEntity::getPermissionCode, permissionCodes)) + .orderByAsc(PlatformMenuEntity::getSortOrder) + .orderByAsc(PlatformMenuEntity::getMenuCode)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java new file mode 100644 index 0000000..d962207 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/repository/PlatformNavigationRepository.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.platform.navigation.repository; + +import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity; +import java.util.List; +import java.util.Optional; + +/** + * 平台菜单仓储。负责菜单定义和当前用户可见菜单查询。 + */ +public interface PlatformNavigationRepository { + + /** + * 按菜单代码查询菜单定义。 + */ + Optional findMenuByCode(String menuCode); + + /** + * 新增菜单定义。 + */ + void insertMenu(PlatformMenuEntity menu); + + /** + * 更新菜单定义。 + */ + void updateMenu(PlatformMenuEntity menu); + + /** + * 查询指定权限集合可见的启用菜单。 + */ + List listVisibleMenus(List permissionCodes); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/NavigationService.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/NavigationService.java new file mode 100644 index 0000000..2f207fc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/NavigationService.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.navigation.service; + +import cn.nianxx.thhotel.platform.identity.common.result.AuthMenuResult; +import java.util.List; + +/** + * 平台菜单服务。根据权限码返回当前用户可见菜单。 + */ +public interface NavigationService { + + /** + * 查询当前用户可见菜单列表。 + */ + List listVisibleMenus(List permissionCodes); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/NavigationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/NavigationServiceImpl.java new file mode 100644 index 0000000..2bce39c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/navigation/service/impl/NavigationServiceImpl.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.platform.navigation.service.impl; + +import cn.nianxx.thhotel.platform.identity.common.result.AuthMenuResult; +import cn.nianxx.thhotel.platform.navigation.repository.PlatformNavigationRepository; +import cn.nianxx.thhotel.platform.navigation.service.NavigationService; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * 平台菜单服务实现。菜单响应仅返回可见入口,隐藏详情路由不作为菜单项输出。 + */ +@Service +public class NavigationServiceImpl implements NavigationService { + + private final PlatformNavigationRepository navigationRepository; + + /** + * 注入菜单仓储,菜单服务只负责权限过滤后的响应转换。 + */ + public NavigationServiceImpl(PlatformNavigationRepository navigationRepository) { + this.navigationRepository = navigationRepository; + } + + /** + * 根据权限码过滤可见菜单,并转换为前端需要的稳定字段。 + */ + @Override + public List listVisibleMenus(List permissionCodes) { + return navigationRepository.listVisibleMenus(permissionCodes).stream() + .map(menu -> new AuthMenuResult( + menu.getMenuCode(), + menu.getMenuName(), + menu.getRoutePath(), + menu.getComponentKey(), + menu.getIconKey(), + menu.getPermissionCode(), + menu.getSortOrder())) + .toList(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/common/dto/AuthenticatedUserContext.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/common/dto/AuthenticatedUserContext.java new file mode 100644 index 0000000..b1c8bd6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/common/dto/AuthenticatedUserContext.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.platform.security.common.dto; + +import java.util.List; + +/** + * 请求级当前用户上下文。第一版只做可选注入,不强制拦截现有业务接口。 + */ +public record AuthenticatedUserContext( + /** 用户内部 ID。 */ + Long userId, + /** 登录用户名。 */ + String username, + /** 用户展示名称。 */ + String displayName, + /** 是否超级管理员。 */ + Boolean superAdmin, + /** 默认酒店 ID。 */ + String defaultHotelId, + /** 可访问酒店 ID 列表。 */ + List accessibleHotelIds, + /** 权限码列表。 */ + List permissionCodes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/CurrentUserContextService.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/CurrentUserContextService.java new file mode 100644 index 0000000..aaf89f0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/CurrentUserContextService.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.security.service; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import java.util.Optional; + +/** + * 请求级当前用户上下文服务。业务模块后续可通过该服务读取可选登录用户。 + */ +public interface CurrentUserContextService { + + /** + * 获取当前请求解析出的登录用户上下文。 + */ + Optional currentUser(); + + /** + * 判断当前请求用户是否拥有指定权限。 + */ + boolean hasPermission(String permissionCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextHolder.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextHolder.java new file mode 100644 index 0000000..20d9145 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextHolder.java @@ -0,0 +1,35 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 请求线程内当前用户上下文。过滤器负责设置和清理。 + */ +public final class CurrentUserContextHolder { + + private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + + private CurrentUserContextHolder() { + } + + /** + * 设置当前请求用户上下文。 + */ + public static void set(AuthenticatedUserContext context) { + CURRENT_USER.set(context); + } + + /** + * 获取当前请求用户上下文。 + */ + public static AuthenticatedUserContext get() { + return CURRENT_USER.get(); + } + + /** + * 清理当前请求用户上下文,避免线程复用导致串号。 + */ + public static void clear() { + CURRENT_USER.remove(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextServiceImpl.java new file mode 100644 index 0000000..8365872 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/CurrentUserContextServiceImpl.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** + * 当前用户上下文服务实现。第一版仅读取过滤器注入的 ThreadLocal 上下文。 + */ +@Service +public class CurrentUserContextServiceImpl implements CurrentUserContextService { + + /** + * 获取当前请求用户上下文。未登录或 token 无效时返回空。 + */ + @Override + public Optional currentUser() { + return Optional.ofNullable(CurrentUserContextHolder.get()); + } + + /** + * 判断当前请求用户是否拥有指定权限码。 + */ + @Override + public boolean hasPermission(String permissionCode) { + if (permissionCode == null || permissionCode.isBlank()) { + return false; + } + return currentUser() + .map(context -> context.permissionCodes().contains(permissionCode)) + .orElse(false); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/OptionalAuthTokenFilter.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/OptionalAuthTokenFilter.java new file mode 100644 index 0000000..8869188 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/OptionalAuthTokenFilter.java @@ -0,0 +1,46 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import cn.nianxx.thhotel.platform.identity.service.AuthService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * 可选登录态过滤器。只负责解析 Bearer token 并注入上下文,不强制拦截现有业务接口。 + */ +@Component +public class OptionalAuthTokenFilter extends OncePerRequestFilter { + + private final AuthService authService; + + /** + * 注入登录认证服务,过滤器只做可选 token 解析。 + */ + public OptionalAuthTokenFilter(AuthService authService) { + this.authService = authService; + } + + /** + * 每个请求尝试解析 Bearer token;失败时保持兼容,不直接返回 401。 + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + Optional context = authService.resolveOptionalContext( + request.getHeader("Authorization")); + context.ifPresent(CurrentUserContextHolder::set); + filterChain.doFilter(request, response); + } finally { + CurrentUserContextHolder.clear(); + } + } +} diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index 6a8f289..3c063bf 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -56,6 +56,19 @@ debug: access-key: ${DEBUG_EML_UPLOAD_DEV_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} max-file-bytes: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} +auth: + bootstrap: + admin: + # dev 初始超级管理员只在系统没有超级管理员时创建;密码必须走环境变量。 + username: ${AUTH_DEV_BOOTSTRAP_ADMIN_USERNAME:${AUTH_BOOTSTRAP_ADMIN_USERNAME:}} + password: ${AUTH_DEV_BOOTSTRAP_ADMIN_PASSWORD:${AUTH_BOOTSTRAP_ADMIN_PASSWORD:}} + display-name: ${AUTH_DEV_BOOTSTRAP_ADMIN_DISPLAY_NAME:${AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME:系统管理员}} + default-hotel-id: ${AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_ID:HOTEL-DEV}} + default-hotel-name: ${AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_NAME:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_NAME:开发酒店}} + default-hotel-time-zone: ${AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:Asia/Bangkok}} + session: + ttl-minutes: ${AUTH_DEV_SESSION_TTL_MINUTES:${AUTH_SESSION_TTL_MINUTES:720}} + reservation: demo-data: # dev 本地联调默认开启演示数据造数接口;生产环境不得开启。 diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 010e182..79bd2b9 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -54,6 +54,19 @@ debug: access-key: ${DEBUG_EML_UPLOAD_PROD_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY}} max-file-bytes: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} +auth: + bootstrap: + admin: + # prod 初始超级管理员必须通过生产 Secret 注入;系统已有超级管理员后不会覆盖。 + username: ${AUTH_PROD_BOOTSTRAP_ADMIN_USERNAME:${AUTH_BOOTSTRAP_ADMIN_USERNAME}} + password: ${AUTH_PROD_BOOTSTRAP_ADMIN_PASSWORD:${AUTH_BOOTSTRAP_ADMIN_PASSWORD}} + display-name: ${AUTH_PROD_BOOTSTRAP_ADMIN_DISPLAY_NAME:${AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME:系统管理员}} + default-hotel-id: ${AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_ID:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_ID}} + default-hotel-name: ${AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_NAME:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_NAME}} + default-hotel-time-zone: ${AUTH_PROD_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:Asia/Bangkok}} + session: + ttl-minutes: ${AUTH_PROD_SESSION_TTL_MINUTES:${AUTH_SESSION_TTL_MINUTES:720}} + reservation: demo-data: # prod 永远不启用演示数据造数接口。 diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 9c76d8e..9c2b209 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -56,6 +56,19 @@ debug: access-key: ${DEBUG_EML_UPLOAD_TEST_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} max-file-bytes: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} +auth: + bootstrap: + admin: + # test 初始超级管理员只在系统没有超级管理员时创建;密码必须走环境变量。 + username: ${AUTH_TEST_BOOTSTRAP_ADMIN_USERNAME:${AUTH_BOOTSTRAP_ADMIN_USERNAME:}} + password: ${AUTH_TEST_BOOTSTRAP_ADMIN_PASSWORD:${AUTH_BOOTSTRAP_ADMIN_PASSWORD:}} + display-name: ${AUTH_TEST_BOOTSTRAP_ADMIN_DISPLAY_NAME:${AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME:系统管理员}} + default-hotel-id: ${AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_ID:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_ID:HOTEL-TEST}} + default-hotel-name: ${AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_NAME:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_NAME:测试酒店}} + default-hotel-time-zone: ${AUTH_TEST_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:${AUTH_BOOTSTRAP_DEFAULT_HOTEL_TIME_ZONE:Asia/Bangkok}} + session: + ttl-minutes: ${AUTH_TEST_SESSION_TTL_MINUTES:${AUTH_SESSION_TTL_MINUTES:720}} + reservation: demo-data: # test 默认关闭演示数据造数接口,需要联调时显式开启并配置访问口令。 diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 2e58b53..72b2094 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -27,6 +27,11 @@ superagent: nonce-ttl-seconds: 600 max-body-bytes: 1048576 +auth: + session: + # 登录 session 默认 12 小时;各环境可通过 AUTH_*_SESSION_TTL_MINUTES 覆盖。 + ttl-minutes: ${AUTH_SESSION_TTL_MINUTES:720} + mcp: # SuperAgent MCP 默认关闭;启用时必须通过部署环境配置高熵 Bearer Token。 enabled: ${MCP_ENABLED:false} diff --git a/server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql b/server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql new file mode 100644 index 0000000..a19e6a1 --- /dev/null +++ b/server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql @@ -0,0 +1,138 @@ +-- M003 登录权限底座:本系统用户账号表,密码只保存单向哈希。 +CREATE TABLE platform_user ( + id BIGINT NOT NULL COMMENT '用户内部 ID', + username VARCHAR(128) NOT NULL COMMENT '登录用户名,全局唯一', + password_hash VARCHAR(128) NOT NULL COMMENT '密码单向哈希,禁止保存明文密码', + display_name VARCHAR(128) NOT NULL COMMENT '用户展示名称', + email VARCHAR(256) NULL COMMENT '用户邮箱,第一版可为空', + phone VARCHAR(64) NULL COMMENT '用户手机号,第一版可为空', + user_status VARCHAR(32) NOT NULL COMMENT '用户状态:ACTIVE、DISABLED', + super_admin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超级管理员,超级管理员默认可访问全部启用酒店', + password_changed_at DATETIME(6) NULL COMMENT '最近密码变更 UTC 时间', + last_login_at DATETIME(6) NULL COMMENT '最近登录 UTC 时间', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_user_username (username), + KEY idx_platform_user_status (user_status) +) COMMENT='平台用户账号表,保存本系统登录用户基础信息'; + +-- M003 登录权限底座:数据库 session 表,只保存 token_hash。 +CREATE TABLE platform_user_session ( + id BIGINT NOT NULL COMMENT '用户 session 内部 ID', + user_id BIGINT NOT NULL COMMENT '所属用户 ID', + token_hash CHAR(64) NOT NULL COMMENT 'access token 的 SHA-256 哈希,数据库不保存明文 token', + session_status VARCHAR(32) NOT NULL COMMENT 'session 状态:ACTIVE、REVOKED、EXPIRED', + issued_at DATETIME(6) NOT NULL COMMENT 'token 签发 UTC 时间', + expires_at DATETIME(6) NOT NULL COMMENT 'token 过期 UTC 时间', + revoked_at DATETIME(6) NULL COMMENT '注销或撤销 UTC 时间', + last_seen_at DATETIME(6) NULL COMMENT '最近使用 UTC 时间', + client_ip VARCHAR(64) NULL COMMENT '登录或最近请求 IP,第一版可为空', + user_agent_summary VARCHAR(256) NULL COMMENT 'User-Agent 摘要,第一版可为空', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_user_session_token_hash (token_hash), + KEY idx_platform_user_session_user_status (user_id, session_status), + KEY idx_platform_user_session_expires_at (expires_at) +) COMMENT='平台用户登录 session 表,用于数据库 session token 登录态'; + +-- M003 登录权限底座:角色表。 +CREATE TABLE platform_role ( + id BIGINT NOT NULL COMMENT '角色内部 ID', + role_code VARCHAR(128) NOT NULL COMMENT '稳定角色代码,例如 SYSTEM_ADMIN', + role_name VARCHAR(128) NOT NULL COMMENT '角色展示名称', + role_status VARCHAR(32) NOT NULL COMMENT '角色状态:ACTIVE、DISABLED', + system_builtin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置角色', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_role_code (role_code), + KEY idx_platform_role_status (role_status) +) COMMENT='平台角色表,保存本系统稳定角色定义'; + +-- M003 登录权限底座:权限码表。 +CREATE TABLE platform_permission ( + id BIGINT NOT NULL COMMENT '权限内部 ID', + permission_code VARCHAR(128) NOT NULL COMMENT '稳定权限代码,业务判断只能使用该字段', + permission_name VARCHAR(128) NOT NULL COMMENT '权限展示名称', + permission_group VARCHAR(64) NOT NULL COMMENT '权限分组,例如 RESERVATION、SOURCE_MESSAGE、SYSTEM', + permission_status VARCHAR(32) NOT NULL COMMENT '权限状态:ACTIVE、DISABLED', + system_builtin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置权限', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_permission_code (permission_code), + KEY idx_platform_permission_group (permission_group, permission_status) +) COMMENT='平台权限码表,保存后端和前端共同使用的稳定权限代码'; + +-- M003 登录权限底座:用户角色关系。 +CREATE TABLE platform_user_role ( + id BIGINT NOT NULL COMMENT '用户角色关系 ID', + user_id BIGINT NOT NULL COMMENT '用户 ID', + role_id BIGINT NOT NULL COMMENT '角色 ID', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_user_role_user_role (user_id, role_id), + KEY idx_platform_user_role_role (role_id) +) COMMENT='平台用户角色关系表'; + +-- M003 登录权限底座:角色权限关系。 +CREATE TABLE platform_role_permission ( + id BIGINT NOT NULL COMMENT '角色权限关系 ID', + role_id BIGINT NOT NULL COMMENT '角色 ID', + permission_id BIGINT NOT NULL COMMENT '权限 ID', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_role_permission_role_perm (role_id, permission_id), + KEY idx_platform_role_permission_perm (permission_id) +) COMMENT='平台角色权限关系表'; + +-- M003 登录权限底座:酒店基础信息。 +CREATE TABLE platform_hotel ( + id BIGINT NOT NULL COMMENT '酒店内部 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '业务酒店 ID,例如 HOTEL-TEST', + hotel_name VARCHAR(128) NOT NULL COMMENT '酒店展示名称', + hotel_status VARCHAR(32) NOT NULL COMMENT '酒店状态:ACTIVE、DISABLED', + time_zone VARCHAR(64) NOT NULL COMMENT '酒店本地时区,例如 Asia/Bangkok', + sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单或选择器排序号', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_hotel_business_id (hotel_id), + KEY idx_platform_hotel_status_sort (hotel_status, sort_order) +) COMMENT='平台酒店基础信息表,用于登录态和业务酒店上下文授权'; + +-- M003 登录权限底座:普通用户酒店授权关系。 +CREATE TABLE platform_user_hotel ( + id BIGINT NOT NULL COMMENT '用户酒店授权关系 ID', + user_id BIGINT NOT NULL COMMENT '用户 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店业务 ID', + default_hotel TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否该用户默认酒店', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_user_hotel_user_hotel (user_id, hotel_id), + KEY idx_platform_user_hotel_default (user_id, default_hotel) +) COMMENT='平台用户酒店授权关系表,普通用户只可访问授权酒店'; + +-- M003 登录权限底座:菜单定义表。 +CREATE TABLE platform_menu ( + id BIGINT NOT NULL COMMENT '菜单内部 ID', + parent_id BIGINT NULL COMMENT '父菜单 ID,根菜单为空', + menu_code VARCHAR(128) NOT NULL COMMENT '稳定菜单代码', + menu_name VARCHAR(128) NOT NULL COMMENT '菜单展示名称', + menu_type VARCHAR(32) NOT NULL COMMENT '菜单类型:GROUP、PAGE、ACTION', + route_path VARCHAR(256) NULL COMMENT '前端路由路径', + component_key VARCHAR(128) NULL COMMENT '前端组件标识,第一版可为空', + icon_key VARCHAR(128) NULL COMMENT '前端图标标识,第一版兼容 PrimeIcons class', + permission_code VARCHAR(128) NULL COMMENT '访问该菜单需要的权限码,可为空', + sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序号', + visible TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否作为菜单入口可见', + menu_status VARCHAR(32) NOT NULL COMMENT '菜单状态:ACTIVE、DISABLED', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_platform_menu_code (menu_code), + KEY idx_platform_menu_parent_sort (parent_id, sort_order), + KEY idx_platform_menu_visible_status (visible, menu_status) +) COMMENT='平台菜单定义表,决定当前用户可见导航入口'; diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java new file mode 100644 index 0000000..8451bf3 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -0,0 +1,180 @@ +package cn.nianxx.thhotel.platform.identity.control; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "auth.bootstrap.admin.username=m003-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=系统管理员", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店", + "auth.bootstrap.default-hotel-time-zone=Asia/Bangkok", + "auth.session.ttl-minutes=720" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthControllerTest { + + private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void shouldLoginBootstrapAdminAndReturnCurrentUserContext() throws Exception { + MvcResult loginResult = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "m003-admin", + "password": "Admin@123456" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").value(notNullValue())) + .andExpect(jsonPath("$.token_type").value("Bearer")) + .andExpect(jsonPath("$.expires_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.user.username").value("m003-admin")) + .andExpect(jsonPath("$.user.display_name").value("系统管理员")) + .andExpect(jsonPath("$.user.super_admin").value(true)) + .andExpect(jsonPath("$.default_hotel_id").value("HOTEL-TEST")) + .andExpect(jsonPath("$.hotels[0].hotel_id").value("HOTEL-TEST")) + .andExpect(jsonPath("$.hotels[0].hotel_name").value("测试酒店")) + .andExpect(jsonPath("$.hotels[0].time_zone").value("Asia/Bangkok")) + .andExpect(jsonPath("$.permissions").value(contains( + "SOURCE_MESSAGE_READ", + "SOURCE_MESSAGE_ORIGINAL_READ", + "RESERVATION_ORDER_READ", + "RESERVATION_TASK_READ", + "RESERVATION_TASK_EDIT", + "RESERVATION_TASK_CONFIRM", + "RESERVATION_OPERA_SIM_EXECUTE", + "RESERVATION_AUDIT_READ", + "HOTEL_SWITCH", + "SYSTEM_AUTH_READ", + "SYSTEM_USER_MANAGE", + "SYSTEM_ROLE_MANAGE", + "SYSTEM_MENU_MANAGE", + "HOTEL_MANAGE", + "SYSTEM_DEBUG_EML_RUN"))) + .andExpect(jsonPath("$.menus[0].menu_code").value("RESERVATION_ORDERS")) + .andExpect(jsonPath("$.menus[0].route_path").value("/reservation/orders")) + .andExpect(jsonPath("$.menus[0].icon_key").value("pi pi-list")) + .andExpect(jsonPath("$.menus[1].menu_code").value("RESERVATION_TASKS")) + .andExpect(jsonPath("$.menus[1].route_path").value("/reservation/tasks")) + .andExpect(jsonPath("$.menus[1].icon_key").value("pi pi-check-square")) + .andExpect(jsonPath("$.menus[2].menu_code").value("DEBUG_EML_SUPERAGENT")) + .andExpect(jsonPath("$.menus[2].route_path").value("/debug/eml-superagent")) + .andExpect(jsonPath("$.menus[2].icon_key").value("pi pi-upload")) + .andReturn(); + + String token = tokenFrom(loginResult); + + mockMvc.perform(get("/api/auth/me") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.username").value("m003-admin")) + .andExpect(jsonPath("$.default_hotel_id").value("HOTEL-TEST")) + .andExpect(jsonPath("$.menus[2].menu_code").value("DEBUG_EML_SUPERAGENT")); + } + + @Test + void shouldRejectInvalidLoginWithoutLeakingAccountState() throws Exception { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "m003-admin", + "password": "wrong-password" + } + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_INVALID_CREDENTIALS")) + .andExpect(jsonPath("$.message").value("用户名或密码错误。")) + .andExpect(content().string(not("DISABLED"))) + .andExpect(content().string(not("not found"))); + } + + @Test + void shouldRequireTokenForAuthMeAndRevokeSessionOnLogout() throws Exception { + mockMvc.perform(get("/api/auth/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + + String token = tokenFrom(login()); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + mockMvc.perform(get("/api/auth/me") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_SESSION_INVALID")); + } + + @Test + void shouldKeepExistingBusinessEndpointsCompatibleWhenTokenIsMissingOrInvalid() throws Exception { + mockMvc.perform(get("/api/reservation/orders") + .param("hotel_id", "HOTEL-TEST") + .param("page_num", "1") + .param("page_size", "1")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/reservation/orders") + .header("Authorization", "Bearer invalid-token") + .param("hotel_id", "HOTEL-TEST") + .param("page_num", "1") + .param("page_size", "1")) + .andExpect(status().isOk()); + } + + private MvcResult login() throws Exception { + return mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "m003-admin", + "password": "Admin@123456" + } + """)) + .andExpect(status().isOk()) + .andReturn(); + } + + private String tokenFrom(MvcResult result) throws Exception { + JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString()); + return json.path("access_token").asText(); + } +}