From 5c0a5d21d059605d4ca32c4a1c23bdbc51a4d719 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 16 Jul 2026 11:27:23 +0700 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E9=A2=84=E7=BA=A6=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=8E=A5=E5=8F=A3=E6=9D=83=E9=99=90=E4=B8=8E=E9=85=92?= =?UTF-8?q?=E5=BA=97=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend-to-frontend-notes.md | 24 ++- .../security-access-control-boundary.md | 192 +++++++++++++++++ .../control/SourceMessageController.java | 24 ++- .../SourceMessageControllerAdvice.java | 13 ++ .../service/FrontendAuthorizationService.java | 19 ++ .../impl/FrontendAuthorizationException.java | 26 +++ .../FrontendAuthorizationServiceImpl.java | 82 ++++++++ .../ReservationFrontendQueryController.java | 11 +- .../control/ReservationTaskController.java | 10 +- .../ReservationTaskControllerAdvice.java | 15 ++ ...ybatisReservationAiWorkflowRepository.java | 11 + .../ReservationAiWorkflowRepository.java | 5 + .../ReservationFrontendQueryServiceImpl.java | 31 ++- .../ReservationTaskWorkflowServiceImpl.java | 21 ++ .../identity/control/AuthControllerTest.java | 8 +- .../control/SourceMessageControllerTest.java | 30 ++- .../support/MockMvcAuthTestSupport.java | 48 +++++ ...ontendReadAuthorizationControllerTest.java | 199 ++++++++++++++++++ .../ReservationDemoDataControllerTest.java | 34 ++- ...eservationFrontendQueryControllerTest.java | 47 ++++- .../SuperAgentTaskResultControllerTest.java | 58 +++-- ...gentTaskResultP0FixtureRegressionTest.java | 32 ++- 22 files changed, 877 insertions(+), 63 deletions(-) create mode 100644 docs/project/security-access-control-boundary.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/FrontendAuthorizationService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationException.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationServiceImpl.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/support/MockMvcAuthTestSupport.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index a69601b..0e969bd 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -15,6 +15,8 @@ - 前端不得保存或传递后端 Secret、replay access key、Provider API Key、Oracle 凭证、AgentBus Token。 - 后端数据库 ID 未来应尽量以字符串形式给前端,避免 JavaScript 长整型精度问题;如果当前接口仍返回数字,前端不要自行做数学运算。 - 接口字段发生变化前,需要先更新本目录沟通文档或对应需求文档。 +- 前端接口新增或字段变更时,后端需同步更新 `docs/project/security-access-control-boundary.md`,前端也应按该文档区分普通业务、系统管理、Debug 和第三方接口。 +- 前端页面不得把 Debug、Demo、Replay、Probe 等系统调试接口当成普通用户能力;这类入口需要环境开关和专门权限。 ## 3. 字段来源注意事项 @@ -50,19 +52,19 @@ | `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` | 查询订单列表 | 默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | -| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。 | -| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | -| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | +| `GET /api/reservation/orders` | 查询订单列表 | 必须带 `Authorization: Bearer `,需要 `RESERVATION_ORDER_READ`;默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。 | +| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | type-known manual review 同卡复核解阻 | 只用于已知业务类型的 `result_type=manual_review` 任务;提交 `field_overrides[]` 和当前订单归属确认,通过后进入 `READY` 并生成两条 OPERA 模拟操作。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作 | 当前是模拟,不调用真实 OPERA。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败 OPERA 模拟操作 | 重试会追加 attempt 历史,前端不要覆盖旧失败记录。 | -| `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 用于展示人工确认、转换、模拟操作等轨迹。 | -| `GET /api/source-messages` | 查询来源消息安全摘要 | 列表不返回邮件正文、HTML、附件 URL 或原始 payload。 | -| `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 只用于安全摘要详情。 | +| `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按任务所属酒店做访问校验;用于展示人工确认、转换、模拟操作等轨迹。 | +| `GET /api/source-messages` | 查询来源消息安全摘要 | 必须带 Bearer token,需要 `SOURCE_MESSAGE_READ`;列表不返回邮件正文、HTML、附件 URL 或原始 payload。 | +| `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 必须带 Bearer token,需要 `SOURCE_MESSAGE_READ`,后端按消息所属酒店做访问校验;只用于安全摘要详情。 | | `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | | `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 并调用 SuperAgent | 仅 dev/test 受控调试使用;会写入 SourceMessage Inbox,但不创建订单和任务。 | @@ -86,7 +88,7 @@ | `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 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 | -酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。前端默认可以不传;后端会按当前登录用户酒店上下文或平台酒店表唯一 `ACTIVE` 酒店解析。如果前端传了当前选中酒店,后端会校验该酒店是否可访问。 +酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。对已收口的 Reservation / SourceMessage 只读接口,前端必须先登录并带 Bearer token;不传 `hotel_id` 时后端按当前登录用户默认酒店或对象所属酒店校验,传了当前选中酒店时后端会校验该酒店是否可访问。Debug EML 仍按调试入口规则受控,不属于本轮登录权限收口范围。 ### 5.2 登录权限接入注意 @@ -102,13 +104,17 @@ POST /api/auth/logout - 登录成功后只把 `access_token` 保存到 `sessionStorage`;刷新同一浏览器会话可恢复,关闭浏览器后需要重新登录。 - 所有需要登录态的后端请求使用 `Authorization: Bearer `。 -- 当前后端第一版不强制拦截既有 Reservation / SourceMessage 业务接口;但是前端接入登录后应统一带上 Bearer token,方便后续审计 actor 和权限收口。 +- 当前后端已强制拦截第一批 Reservation / SourceMessage 只读接口:任务列表、订单列表、订单详情、任务详情、任务审计列表、SourceMessage 摘要列表和摘要详情。调用这些接口必须带 Bearer token。 +- 第一批只读接口权限码分别是:`RESERVATION_TASK_READ`、`RESERVATION_ORDER_READ`、`RESERVATION_AUDIT_READ`、`SOURCE_MESSAGE_READ`。前端菜单、按钮和路由守卫应使用 `/api/auth/me` 返回的 `permissions[]` 与 `menus[]`。 +- 后端会按当前登录用户的可访问酒店集合做隔离;显式传 `hotel_id` 时会校验该酒店是否可访问,按 `orderId`、`taskId`、`sourceMessageId` 定位的详情接口会反查对象实际所属酒店并校验访问权。 +- Reservation 写操作、邮件原文 / conversation 完整正文、Debug / Demo / Replay / Probe 等接口仍按 `../security-access-control-boundary.md` 的分阶段计划继续收口,前端不要自行假设它们和第一批只读接口完全一致。 - `/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、回登录页的逻辑。 +- 403 的 `FRONTEND_PERMISSION_DENIED` 表示当前用户没有对应业务权限;`HOTEL_ACCESS_DENIED` 表示用户无权访问目标酒店或对象所属酒店,前端应展示无权限状态,不要重试或静默降级为 404。 ### 5.3 来源邮件会话字段说明 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md new file mode 100644 index 0000000..cb47c8e --- /dev/null +++ b/docs/project/security-access-control-boundary.md @@ -0,0 +1,192 @@ +# TH Hotel 接口暴露、权限与审计边界 + +## 1. 文档定位 + +本文是当前项目的接口安全边界总表,用于后续新增接口、大改调用方、调整权限或补审计时统一判断: + +- 哪些接口给前端用户调用。 +- 哪些接口给第三方系统调用。 +- 哪些能力只能后端内部使用,不能暴露给前端或第三方。 +- 每类接口应采用什么鉴权方式、权限码、酒店隔离和审计策略。 + +如本文与具体接口契约冲突: + +- SuperAgent / MCP / AgentBus 对外契约以 `docs/project/integrations/superagent-api-contract.md`、`docs/project/integrations/superagent-mcp/README.md` 和对应集成文档为准。 +- 前端展示和字段契约以 `docs/project/frontend-backend/README.md` 指向的当前有效文档为准。 +- 权限、审计、暴露边界以本文为补充检查清单,接口变更时必须同步更新。 + +## 2. 调用方分类 + +| 分类 | 中文说明 | 典型调用方 | 默认鉴权方式 | +| --- | --- | --- | --- | +| `PUBLIC` | 公开基础接口,只能返回非敏感健康或登录入口信息 | 浏览器、运维探活 | 无登录;不能返回业务数据 | +| `FRONTEND_USER` | 普通业务前端接口 | 登录后的酒店业务用户 | Bearer session token + 权限码 + 酒店访问权 | +| `FRONTEND_ADMIN` | 系统管理后台接口 | 系统管理员 | Bearer session token + 管理权限码 + 管理审计 | +| `FRONTEND_DEBUG` | 调试或演示接口 | 开发、测试、受控管理员 | 环境开关 + 登录权限或临时 access key + 调试审计 | +| `THIRD_PARTY_SUPERAGENT` | SuperAgent HTTP 对接接口 | SuperAgent Runtime / Skill | HMAC-SHA256 + timestamp + nonce + body hash | +| `THIRD_PARTY_AGENTBUS` | AgentBus 实时消息入口 | AgentBus WebSocket | AgentBus Token + 环境开关 + 入库幂等 | +| `THIRD_PARTY_MCP` | SuperAgent MCP 写入工具 | SuperAgent MCP Client | Bearer Token + 工具级能力限制 | +| `INTERNAL_ONLY` | 后端内部能力,不对外直接暴露 | Worker、Adapter、Repository、Mapper | 不提供外部入口;通过 Service / Port 调用 | + +## 3. 当前接口边界矩阵 + +### 3.1 公开和登录接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `GET /api/health` | `PUBLIC` | 无登录;只返回健康状态 | 保持公开,但不得返回配置、版本 Secret 或数据库细节 | 不需要业务审计 | +| `POST /api/auth/login` | `PUBLIC` | 用户名密码登录,返回一次性 `access_token` | 增加登录失败频率控制和登录安全审计可后置 | 建议补登录安全审计 | +| `GET /api/auth/me` | `FRONTEND_USER` | 必须 Bearer token | 保持强制登录,返回权限、菜单和酒店上下文 | 不需要每次写业务审计 | +| `POST /api/auth/logout` | `FRONTEND_USER` | 必须 Bearer token | 保持强制登录,撤销当前 session | 可记录安全审计 | + +### 3.2 前端业务接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `GET /api/reservation/tasks` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 只读查询默认不写业务审计 | +| `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权 | 只读查询默认不写业务审计 | +| `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权 | 只读查询默认不写业务审计 | +| `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | +| `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | +| `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 | +| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 | +| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | +| `GET /api/reservation/tasks/{taskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 酒店访问权 | 查询审计不再写审计 | + +### 3.3 来源邮件接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `GET /api/source-messages` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;列表条件中的酒店按当前用户可访问酒店校验 | 保持登录 + `SOURCE_MESSAGE_READ` + 酒店访问权 | 只读摘要不写审计 | +| `GET /api/source-messages/{id}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `SOURCE_MESSAGE_READ`;按消息实际所属酒店校验访问权 | 保持登录 + `SOURCE_MESSAGE_READ` + 消息所属酒店访问权 | 只读摘要不写审计 | +| `GET /api/source-messages/{id}/conversation` | `FRONTEND_USER` | 返回会话完整 text/html 和媒体 URL;后端内部写原文读取审计 | 登录 + `SOURCE_MESSAGE_READ`,如返回完整正文则还需 `SOURCE_MESSAGE_ORIGINAL_READ` | 必须写原文读取审计 | +| `GET /api/source-messages/{id}/original` | `FRONTEND_USER` | 当前使用受控原文读取 key | 迁移为登录 + `SOURCE_MESSAGE_ORIGINAL_READ` + 酒店访问权,access key 仅作兼容或关闭 | 必须写原文读取审计 | + +### 3.4 系统管理后台接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `/api/admin/users/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_USER_MANAGE` | 保持;禁用用户撤销 session | 写操作必须记录 `platform_admin_audit_log` | +| `/api/admin/roles/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持;内置角色只读 | 写操作必须记录管理审计 | +| `/api/admin/permissions` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持只读;前端不能自造权限码 | 不需要写审计 | +| `/api/admin/menus/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_MENU_MANAGE` | 保持;菜单可见性不替代后端权限 | 写操作必须记录管理审计 | +| `/api/admin/hotels/**` | `FRONTEND_ADMIN` | 已强制登录和 `HOTEL_MANAGE` | 保持;单酒店阶段只能一家 `ACTIVE` | 写操作必须记录管理审计 | +| `GET /api/admin/audits` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ADMIN_CONSOLE_ACCESS` | 保持;不返回 Secret、密码或 token | 查询审计不再写审计 | + +### 3.5 调试和系统接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `POST /api/system/debug/eml-superagent-runs` | `FRONTEND_DEBUG` | 环境开关 + `X-TH-Hotel-Debug-Upload-Key` | dev/test 可保留 access key;长期目标为登录 + `SYSTEM_DEBUG_EML_RUN` + 环境开关 | 写 Debug run,必要时补管理 / 调试审计 | +| `GET /api/system/debug/eml-superagent-runs/{runId}` | `FRONTEND_DEBUG` | 环境开关 + access key | 登录 + `SYSTEM_DEBUG_EML_RUN`;避免向普通用户暴露 AI 原始结果 | 只读调试可记录访问日志 | +| `POST /api/system/debug/eml-superagent-runs/stream` | `FRONTEND_DEBUG` | 环境开关 + access key | 登录 + `SYSTEM_DEBUG_EML_RUN`;生产默认关闭 | 写 Debug run 和安全错误摘要 | +| `GET /api/system/agentbus-probe` | `FRONTEND_DEBUG` | 当前系统状态接口 | 登录 + `SYSTEM_AGENTBUS_PROBE_READ` 或系统管理入口权限 | 不返回 Token、raw frame 或邮件正文 | +| `POST /api/system/reservation/demo-data` | `FRONTEND_DEBUG` | 环境开关 + demo access key | dev/test 使用;生产必须关闭 | 写入演示数据时建议记录调试审计 | + +### 3.6 第三方机器接口 + +| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 | +| --- | --- | --- | --- | --- | +| `POST /api/ai-query/v1/case-context` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不使用用户 Bearer token | 记录请求 ID、client_id 和安全错误 | +| `POST /api/ai-query/v1/object-detail` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;返回最小必要上下文 | 记录请求 ID、client_id 和安全错误 | +| `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不返回邮件原文 | 记录请求 ID、client_id 和安全错误 | +| `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 | +| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;外部 `source_message_id` 必须匹配 Inbox | 记录 batch、transition、错误和幂等结果 | +| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果和业务入站结果 | +| AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage,不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run | + +### 3.7 后端内部能力 + +| 能力 | 分类 | 暴露规则 | 审计 / 追踪 | +| --- | --- | --- | --- | +| Repository / Mapper / Entity | `INTERNAL_ONLY` | 不对 Controller、前端或第三方直接暴露 | 通过 Service 写审计 | +| SuperAgent Open API Client | `INTERNAL_ONLY` | 只能后端 Adapter 使用,Secret 不出后端 | 通过 debug run 或 dispatch run 追踪 | +| OSS Adapter | `INTERNAL_ONLY` | 前端只能拿后端返回的安全 URL,不能拿 OSS Secret | 上传和读取入口记录安全摘要 | +| AgentBus dispatch worker | `INTERNAL_ONLY` | 只由后端调度或受控管理入口触发 | `platform_superagent_dispatch_run` | +| Flyway / bootstrap 初始化 | `INTERNAL_ONLY` | 不提供运行时外部接口 | 通过部署记录和数据库 history 追踪 | +| 未来 OPERA / OHIP Adapter | `INTERNAL_ONLY` | 浏览器不得直接调用;只能业务服务触发 | 必须记录操作、attempt 和外部结果摘要 | + +## 4. 权限码规划口径 + +当前管理后台权限码已经落地。业务接口收口时建议新增或确认以下权限码,不要求一次性全部实现: + +| 权限码 | 中文含义 | 适用接口 | +| --- | --- | --- | +| `RESERVATION_ORDER_READ` | 查看订单列表和订单详情 | 订单列表、订单详情 | +| `RESERVATION_TASK_READ` | 查看任务列表和任务详情 | 任务列表、任务详情 | +| `RESERVATION_TASK_EDIT` | 保存任务草稿或编辑可写字段 | 草稿保存 | +| `RESERVATION_TASK_CONFIRM` | 最终确认任务 | 任务确认 | +| `RESERVATION_MANUAL_REVIEW_RESOLVE` | 处理人工复核和 Fallback 转换 | 复核解阻、Fallback 转换 | +| `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 / 未来真实操作 | OPERA execute / retry | +| `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 | +| `SOURCE_MESSAGE_READ` | 查看来源邮件安全摘要 | SourceMessage 列表、详情、会话摘要 | +| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文 | +| `SYSTEM_DEBUG_EML_RUN` | 使用 Debug EML 调试链路 | Debug EML 上传、查询、stream | +| `SYSTEM_AGENTBUS_PROBE_READ` | 查看 AgentBus 安全状态 | AgentBus probe | + +如果新增权限码,必须同步: + +1. `PlatformPermissionCode` 枚举。 +2. 内置角色权限矩阵。 +3. `docs/project/security-access-control-boundary.md`。 +4. 前后端协作文档中对应页面按钮或菜单说明。 + +## 5. 酒店隔离规则 + +- 前端用户接口必须从当前登录用户解析可访问酒店集合。 +- 显式传入 `hotel_id` 时,后端必须校验该用户是否可访问该酒店。 +- 未传 `hotel_id` 时,单酒店阶段可按用户默认酒店或系统唯一 `ACTIVE` 酒店解析。 +- 直接按 `taskId`、`orderId`、`sourceMessageId` 定位的接口,必须反查对象所属 `hotel_id` 并校验访问权;当前已覆盖 Reservation 订单详情、任务详情、任务审计列表和 SourceMessage 摘要详情。 +- 第三方 SuperAgent 接口第一版不依赖用户酒店权限,但必须使用系统酒店解析 SourceMessage,并防止跨酒店误匹配。 +- AgentBus 入站不接受外部随意指定酒店;单酒店阶段由系统唯一 `ACTIVE` 酒店解析。 + +## 6. 审计分层 + +| 审计类型 | 当前载体 | 必须记录的动作 | +| --- | --- | --- | +| 管理审计 | `platform_admin_audit_log` | 用户、角色、权限、菜单、酒店的写操作 | +| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、OPERA 执行 / 重试 | +| 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链 | +| SuperAgent 入站追踪 | `workflow_reservation_ai_batch`、`workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error | +| AgentBus 分发追踪 | `platform_superagent_dispatch_run` | SourceMessage 自动分发 SuperAgent、重试、失败摘要 | +| 安全审计 | 后续可新增平台安全审计表 | 登录失败、签名失败、nonce 重放、越权访问 | + +业务侧后续收口重点: + +- 业务写操作 actor 从本地占位迁移到当前登录用户。 +- 第三方入站 actor 保持机器身份,例如 `SUPERAGENT`、`MCP`、`AGENTBUS`。 +- 审计快照不得写入密码、token、secret、完整邮件正文、附件签名 URL 或支付敏感信息。 + +## 7. 接口变更同步规则 + +新增或修改任何 Controller、第三方入口、调试入口或后台 worker 触发入口时,必须先回答: + +1. 这个接口给谁调用:前端用户、管理员、第三方系统,还是内部 worker? +2. 是否需要登录?如果需要,权限码是什么? +3. 是否涉及 `hotel_id`?如何校验用户可访问酒店? +4. 是否返回敏感数据:正文、HTML、附件 URL、AI 原始 payload、trace、Secret? +5. 是否是写操作?写什么审计?actor 从哪里来? +6. 是否生产允许?是否需要环境开关? +7. 是否影响 SuperAgent、MCP、AgentBus 或前端契约? + +如果任一答案为“是”,必须同步更新以下文档中相关部分: + +- 本文档。 +- `AGENTS.md` 或 `docs/project/backend-development-guidelines.md` 中的开发规则,如果新增了通用规范。 +- `docs/project/frontend-backend/backend-to-frontend-notes.md`,如果影响前端。 +- `docs/project/integrations/superagent-api-contract.md` 或 MCP / AgentBus 集成文档,如果影响第三方。 +- 对应需求文档,例如 M002、M004、M006、M007。 + +## 8. 建议实施顺序 + +后续开发权限收口时建议按以下顺序推进: + +1. 已完成 CP1:Reservation / SourceMessage 第一批只读查询接口已收口登录、权限码和酒店访问权,包括任务列表、订单列表、订单详情、任务详情、任务审计列表、SourceMessage 摘要列表和摘要详情。 +2. 再收口邮件原文和会话完整正文读取:迁移到 `SOURCE_MESSAGE_ORIGINAL_READ`,保留审计。 +3. 再收口 Reservation 写操作:草稿、确认、人工复核、OPERA 模拟。 +4. 迁移业务审计 actor 到当前登录用户。 +5. 最后处理 Debug、Demo、Replay、AgentBus Probe 等系统调试入口。 + +每一步都应保持第三方机器接口不被误拦截,SuperAgent / MCP / AgentBus 继续使用机器鉴权。 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java index a7d9efd..a444cd6 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java @@ -1,10 +1,13 @@ package cn.nianxx.thhotel.platform.message.control; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService; import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; @@ -28,6 +31,8 @@ public class SourceMessageController { private final SourceMessageQueryService queryService; private final SourceMessageOriginalService originalService; private final SourceMessageConversationService conversationService; + private final FrontendAuthorizationService authorizationService; + private final HotelContextService hotelContextService; /** * 注入 SourceMessage 查询与原文读取服务,Controller 不直接访问 Mapper 或 Repository。 @@ -35,10 +40,14 @@ public class SourceMessageController { public SourceMessageController( SourceMessageQueryService queryService, SourceMessageOriginalService originalService, - SourceMessageConversationService conversationService) { + SourceMessageConversationService conversationService, + FrontendAuthorizationService authorizationService, + HotelContextService hotelContextService) { this.queryService = queryService; this.originalService = originalService; this.conversationService = conversationService; + this.authorizationService = authorizationService; + this.hotelContextService = hotelContextService; } /** @@ -52,6 +61,7 @@ public class SourceMessageController { @RequestParam(required = false) String captureStatus, @RequestParam(required = false) Integer pageNum, @RequestParam(required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name()); return queryService.query(new SourceMessageQueryRequest( hotelId, externalMessageId, @@ -67,8 +77,11 @@ public class SourceMessageController { */ @GetMapping("/{id}") public SourceMessageSummaryResponse detail(@PathVariable Long id) { - return queryService.getSummary(id) + authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name()); + SourceMessageSummaryResponse summary = queryService.getSummary(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); + requireSourceMessageHotelAccess(summary); + return summary; } /** @@ -105,4 +118,11 @@ public class SourceMessageController { private boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } + + /** + * 按 SourceMessage 实际归属酒店校验当前用户访问权,避免跨酒店按 ID 读取摘要。 + */ + private void requireSourceMessageHotelAccess(SourceMessageSummaryResponse summary) { + hotelContextService.requireAccessibleHotel(summary.hotelId()); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java index 4a424e5..99db6a1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.platform.message.control; import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException; import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -22,4 +23,16 @@ public class SourceMessageControllerAdvice { "error_code", exception.getErrorCode(), "message", exception.getMessage())); } + + /** + * 处理 SourceMessage 摘要接口登录或权限不足异常,避免泄漏内部鉴权细节。 + */ + @ExceptionHandler(FrontendAuthorizationException.class) + public ResponseEntity> handleFrontendAuthorizationException( + FrontendAuthorizationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(Map.of( + "error_code", exception.getErrorCode(), + "message", exception.getMessage())); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/FrontendAuthorizationService.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/FrontendAuthorizationService.java new file mode 100644 index 0000000..00a45b1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/FrontendAuthorizationService.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.security.service; + +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; + +/** + * 前端业务接口强制鉴权服务。用于普通业务页面接口的登录和权限边界,不复用管理后台错误码。 + */ +public interface FrontendAuthorizationService { + + /** + * 要求当前请求已登录,并返回当前用户上下文。 + */ + AuthenticatedUserContext requireLogin(); + + /** + * 要求当前请求用户拥有指定业务权限码。 + */ + AuthenticatedUserContext requirePermission(String permissionCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationException.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationException.java new file mode 100644 index 0000000..d7d895c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.security.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * 前端业务接口鉴权受控异常。ControllerAdvice 负责转换为稳定 HTTP 响应。 + */ +public class FrontendAuthorizationException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public FrontendAuthorizationException(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/security/service/impl/FrontendAuthorizationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationServiceImpl.java new file mode 100644 index 0000000..c355849 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/security/service/impl/FrontendAuthorizationServiceImpl.java @@ -0,0 +1,82 @@ +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 cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * 前端业务接口强制鉴权服务实现。复用可选 token 解析结果,并返回业务接口专用错误码。 + */ +@Service +public class FrontendAuthorizationServiceImpl implements FrontendAuthorizationService { + + private final CurrentUserContextService currentUserContextService; + private final HttpServletRequest request; + + /** + * 注入当前用户上下文服务和原始请求,用于区分缺少 token 与 token 失效。 + */ + public FrontendAuthorizationServiceImpl( + CurrentUserContextService currentUserContextService, + HttpServletRequest request) { + this.currentUserContextService = currentUserContextService; + this.request = request; + } + + /** + * 要求当前请求已登录;没有有效 token 时返回前端业务接口专用 401。 + */ + @Override + public AuthenticatedUserContext requireLogin() { + return currentUserContextService.currentUser() + .orElseThrow(this::missingOrInvalidLogin); + } + + /** + * 要求当前请求用户拥有指定业务权限码;已登录但无权限时返回 403。 + */ + @Override + public AuthenticatedUserContext requirePermission(String permissionCode) { + AuthenticatedUserContext context = requireLogin(); + if (permissionCode == null || permissionCode.isBlank() + || !context.permissionCodes().contains(permissionCode)) { + throw new FrontendAuthorizationException( + HttpStatus.FORBIDDEN, + "FRONTEND_PERMISSION_DENIED", + "当前用户没有访问该业务能力的权限。"); + } + return context; + } + + /** + * 根据 Authorization 头判断是未登录还是登录态失效,便于前端做统一提示。 + */ + private FrontendAuthorizationException missingOrInvalidLogin() { + String authorizationHeader = request.getHeader("Authorization"); + if (hasBearerToken(authorizationHeader)) { + return new FrontendAuthorizationException( + HttpStatus.UNAUTHORIZED, + "AUTH_SESSION_INVALID", + "登录已失效,请重新登录。"); + } + return new FrontendAuthorizationException( + HttpStatus.UNAUTHORIZED, + "AUTH_TOKEN_REQUIRED", + "请先登录后再访问该业务能力。"); + } + + /** + * 判断请求是否提供了非空 Bearer token。 + */ + private boolean hasBearerToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + return false; + } + String prefix = "Bearer "; + return authorizationHeader.regionMatches(true, 0, prefix, 0, prefix.length()) + && !authorizationHeader.substring(prefix.length()).trim().isBlank(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java index bb33258..4431098 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java @@ -1,5 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult; @@ -21,12 +23,16 @@ import org.springframework.web.bind.annotation.RestController; public class ReservationFrontendQueryController { private final ReservationFrontendQueryService frontendQueryService; + private final FrontendAuthorizationService authorizationService; /** * 注入前端查询服务,Controller 只负责 HTTP 参数到查询对象的转换。 */ - public ReservationFrontendQueryController(ReservationFrontendQueryService frontendQueryService) { + public ReservationFrontendQueryController( + ReservationFrontendQueryService frontendQueryService, + FrontendAuthorizationService authorizationService) { this.frontendQueryService = frontendQueryService; + this.authorizationService = authorizationService; } /** @@ -44,6 +50,7 @@ public class ReservationFrontendQueryController { @RequestParam(required = false) String keyword, @RequestParam(name = "page_num", required = false) Integer pageNum, @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); return frontendQueryService.queryTaskWorkbench(new ReservationTaskWorkbenchQueryRequest( hotelId, orderId, @@ -69,6 +76,7 @@ public class ReservationFrontendQueryController { @RequestParam(required = false) String keyword, @RequestParam(name = "page_num", required = false) Integer pageNum, @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_ORDER_READ.name()); return frontendQueryService.queryOrders(new ReservationOrderListQueryRequest( hotelId, orderStatus, @@ -88,6 +96,7 @@ public class ReservationFrontendQueryController { @RequestParam(name = "hotel_id", required = false) String hotelId, @RequestParam(name = "include_tasks", required = false) Boolean includeTasks, @RequestParam(name = "include_source_summary", required = false) Boolean includeSourceSummary) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_ORDER_READ.name()); return frontendQueryService.getOrderDetail(hotelId, orderId, includeTasks, includeSourceSummary); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java index 73ab089..d0521f2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java @@ -1,5 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; @@ -28,12 +30,16 @@ import org.springframework.web.bind.annotation.RestController; public class ReservationTaskController { private final ReservationTaskWorkflowService taskWorkflowService; + private final FrontendAuthorizationService authorizationService; /** * 注入任务工作流服务,Controller 不直接访问 Mapper 或 Repository。 */ - public ReservationTaskController(ReservationTaskWorkflowService taskWorkflowService) { + public ReservationTaskController( + ReservationTaskWorkflowService taskWorkflowService, + FrontendAuthorizationService authorizationService) { this.taskWorkflowService = taskWorkflowService; + this.authorizationService = authorizationService; } /** @@ -41,6 +47,7 @@ public class ReservationTaskController { */ @GetMapping(value = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE) public ReservationTaskDetailResult detail(@PathVariable Long taskId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); return taskWorkflowService.getTaskDetail(taskId); } @@ -129,6 +136,7 @@ public class ReservationTaskController { */ @GetMapping(value = "/{taskId}/audits", produces = MediaType.APPLICATION_JSON_VALUE) public ReservationTaskAuditListResult listAudits(@PathVariable Long taskId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name()); return taskWorkflowService.listTaskAudits(taskId); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 3d51333..648a6cf 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.control; +import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; +import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -28,4 +30,17 @@ public class ReservationTaskControllerAdvice { exception.getMessage(), exception.getDetails())); } + + /** + * 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。 + */ + @ExceptionHandler(FrontendAuthorizationException.class) + public ResponseEntity handleFrontendAuthorizationException( + FrontendAuthorizationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new ReservationWorkflowErrorResponse( + exception.getErrorCode(), + exception.getMessage(), + List.of())); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index c0f6035..080e50d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -400,6 +400,17 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return Optional.ofNullable(entity).map(this::toAiQueryOrderSnapshot); } + /** + * 按订单 ID 查询前端详情快照。该方法不带酒店条件,仅供读取后再做酒店访问权校验。 + */ + @Override + public Optional findAiQueryOrderById(Long orderId) { + if (orderId == null) { + return Optional.empty(); + } + return Optional.ofNullable(orderMapper.selectById(orderId)).map(this::toAiQueryOrderSnapshot); + } + /** * 按订单 ID 批量查询订单快照,供前端任务列表补充订单展示键。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index 115e647..d0253c1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -116,6 +116,11 @@ public interface ReservationAiWorkflowRepository { */ Optional findAiQueryOrderById(String hotelId, Long orderId); + /** + * 按订单 ID 查询前端详情使用的订单快照,调用方必须在读取后按订单实际酒店做访问权校验。 + */ + Optional findAiQueryOrderById(Long orderId); + /** * 按订单 ID 批量查询订单快照,供前端列表补充展示字段。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index 8342e1b..e5647a3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -144,9 +144,8 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Long orderId, Boolean includeTasks, Boolean includeSourceSummary) { - String normalizedHotelId = normalizeHotelId(hotelId); ReservationAiQueryOrderSnapshot order = workflowRepository - .findAiQueryOrderById(normalizedHotelId, orderId) + .findAiQueryOrderById(orderId) .orElseThrow(() -> new ReservationTaskWorkflowException( HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", @@ -157,14 +156,15 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ "ORDER_NOT_FOUND", "订单不存在。"); } + String orderHotelId = requireOrderHotelAccess(order, hotelId); List taskSnapshots = Boolean.FALSE.equals(includeTasks) ? List.of() - : workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id())); + : workflowRepository.findAiQueryTasksByOrderIds(orderHotelId, List.of(order.id())); Map availabilityByTaskId = calculateAvailabilityByTaskId( taskSnapshots, taskSnapshots); Map sourceContextsById = findSourceContextsById( - normalizedHotelId, + orderHotelId, taskSnapshots); List tasks = taskSnapshots.stream() .map(task -> toTimelineItem( @@ -567,6 +567,29 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return order != null && ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility()); } + /** + * 按订单实际归属酒店校验当前用户访问权。详情接口不能只按请求 hotel_id 过滤,否则跨酒店 ID 会被误判为不存在。 + */ + private String requireOrderHotelAccess(ReservationAiQueryOrderSnapshot order, String requestedHotelId) { + try { + String normalizedRequestedHotelId = trimToNull(requestedHotelId); + String hotelIdToCheck = normalizedRequestedHotelId == null ? order.hotelId() : normalizedRequestedHotelId; + String resolvedHotelId = hotelContextService.requireAccessibleHotel(hotelIdToCheck); + if (!Objects.equals(order.hotelId(), resolvedHotelId)) { + throw new ReservationTaskWorkflowException( + HttpStatus.FORBIDDEN, + "HOTEL_ACCESS_DENIED", + "当前用户无权访问该酒店数据。"); + } + return order.hotelId(); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + /** * 标准化酒店 ID。前端可不传酒店,后端按当前用户上下文或单酒店系统上下文解析。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index b53ace7..200d572 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -1,6 +1,8 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; @@ -95,6 +97,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider; private final ReservationTaskAvailabilityResolver availabilityResolver; private final SourceMessageQueryService sourceMessageQueryService; + private final HotelContextService hotelContextService; private final ObjectMapper objectMapper; /** @@ -105,11 +108,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider, ReservationTaskAvailabilityResolver availabilityResolver, SourceMessageQueryService sourceMessageQueryService, + HotelContextService hotelContextService, ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; this.fieldDefinitionProvider = fieldDefinitionProvider; this.availabilityResolver = availabilityResolver; this.sourceMessageQueryService = sourceMessageQueryService; + this.hotelContextService = hotelContextService; this.objectMapper = objectMapper; } @@ -120,6 +125,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl @Transactional(readOnly = true) public ReservationTaskDetailResult getTaskDetail(Long taskId) { ReservationTaskSnapshot task = findTaskOrThrow(taskId); + requireTaskHotelAccess(task); ReservationTaskCardSnapshot taskCard = workflowRepository .findTaskCardByTaskId(task.hotelId(), task.id()) .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。")); @@ -403,6 +409,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl @Transactional(readOnly = true) public ReservationTaskAuditListResult listTaskAudits(Long taskId) { ReservationTaskSnapshot task = findTaskOrThrow(taskId); + requireTaskHotelAccess(task); List items = workflowRepository .findAuditLogsByTaskId(task.hotelId(), task.id()) .stream() @@ -753,6 +760,20 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_NOT_FOUND", "任务不存在。")); } + /** + * 按任务实际归属酒店校验当前用户访问权,避免跨酒店按任务 ID 读取详情或审计。 + */ + private void requireTaskHotelAccess(ReservationTaskSnapshot task) { + try { + hotelContextService.requireAccessibleHotel(task.hotelId()); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + /** * 查询任务卡,不存在时抛出受控 404。 */ 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 index 025b6ce..c086255 100644 --- 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 @@ -149,19 +149,21 @@ class AuthControllerTest { } @Test - void shouldKeepExistingBusinessEndpointsCompatibleWhenTokenIsMissingOrInvalid() throws Exception { + void shouldRequireTokenForFrontendReadonlyBusinessEndpoints() throws Exception { mockMvc.perform(get("/api/reservation/orders") .param("hotel_id", "HOTEL-TEST") .param("page_num", "1") .param("page_size", "1")) - .andExpect(status().isOk()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); 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()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_SESSION_INVALID")); } private MvcResult login() throws Exception { diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java index 1ecbabe..0b48e3f 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java @@ -3,6 +3,8 @@ package cn.nianxx.thhotel.platform.message.control; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -25,7 +27,17 @@ import org.springframework.test.web.servlet.MockMvc; @SpringBootTest( classes = ThHotelApplication.class, - properties = "source-message.original-read.access-key=test-original-read-key") + properties = { + "source-message.original-read.access-key=test-original-read-key", + "spring.datasource.url=jdbc:h2:mem:source_message_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=source-message-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=SourceMessage管理员", + "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 SourceMessageControllerTest { @@ -39,6 +51,18 @@ class SourceMessageControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + private String adminToken; + + /** + * 获取 SourceMessage 摘要接口测试管理员 token。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "source-message-admin", "Admin@123456"); + } + return adminToken; + } + @Test void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception { SourceMessageCaptureResult result = captureService.capture(command( @@ -49,7 +73,7 @@ class SourceMessageControllerTest { "https://media.example.test/private.pdf?token=secret" )); - mockMvc.perform(get("/api/source-messages") + performAuthorized(mockMvc, adminToken(), get("/api/source-messages") .param("hotelId", "HOTEL-TEST") .param("externalConversationId", "conversation-api-001") .param("pageNum", "1") @@ -66,7 +90,7 @@ class SourceMessageControllerTest { .andExpect(content().string(not(containsString("token=secret")))) .andExpect(content().string(not(containsString("payloadJson")))); - mockMvc.perform(get("/api/source-messages/{id}", result.inboxId())) + performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{id}", result.inboxId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(result.inboxId().toString())) .andExpect(jsonPath("$.externalMessageId").value("mail-api-001")) diff --git a/server/src/test/java/cn/nianxx/thhotel/support/MockMvcAuthTestSupport.java b/server/src/test/java/cn/nianxx/thhotel/support/MockMvcAuthTestSupport.java new file mode 100644 index 0000000..c05a12a --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/support/MockMvcAuthTestSupport.java @@ -0,0 +1,48 @@ +package cn.nianxx.thhotel.support; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +/** + * MockMvc 登录辅助工具。测试仍走真实登录接口,避免绕过后端安全链路。 + */ +public final class MockMvcAuthTestSupport { + + private MockMvcAuthTestSupport() { + } + + /** + * 通过真实登录接口获取 Bearer token。 + */ + public static String loginToken(MockMvc mockMvc, String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "password": "%s", + "preferred_hotel_id": "HOTEL-TEST" + } + """.formatted(username, password))) + .andExpect(status().isOk()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.access_token"); + } + + /** + * 使用指定 token 执行已授权请求。 + */ + public static ResultActions performAuthorized( + MockMvc mockMvc, + String token, + MockHttpServletRequestBuilder requestBuilder) throws Exception { + return mockMvc.perform(requestBuilder.header("Authorization", "Bearer " + token)); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java new file mode 100644 index 0000000..58e48a7 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java @@ -0,0 +1,199 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +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.identity.service.impl.AuthPasswordService; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:frontend_read_auth_cp1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=cp1-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", + "superagent.task-result.hmac-secret=test-superagent-secret" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class FrontendReadAuthorizationControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private SourceMessageCaptureService captureService; + @Autowired + private PlatformIdentityRepository identityRepository; + @Autowired + private PlatformHotelRepository hotelRepository; + @Autowired + private AuthPasswordService passwordService; + + @BeforeEach + void ensureNoPermissionUser() { + PlatformUserEntity user = identityRepository.findUserByUsername("cp1-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("cp1-no-permission"); + created.setPasswordHash(passwordService.hash("NoPerm@123456")); + created.setDisplayName("无权限用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true); + } + + @Test + void shouldRejectReservationReadWhenTokenMissing() throws Exception { + mockMvc.perform(get("/api/reservation/tasks") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + } + + @Test + void shouldRejectReservationReadWhenPermissionMissing() throws Exception { + String token = loginToken(mockMvc, "cp1-no-permission", "NoPerm@123456"); + + performAuthorized(mockMvc, token, get("/api/reservation/tasks") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + } + + @Test + void shouldRejectReservationAndSourceMessageDetailAcrossHotels() throws Exception { + String token = loginToken(mockMvc, "cp1-admin", "Admin@123456"); + SourceMessageCaptureResult source = captureOtherHotelSourceMessage(); + Long orderId = 970000000000000101L; + Long taskId = 970000000000000301L; + insertOtherHotelOrderTask(orderId, taskId, source.inboxId()); + + performAuthorized(mockMvc, token, get("/api/reservation/orders/{orderId}", orderId)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + + performAuthorized(mockMvc, token, get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + + performAuthorized(mockMvc, token, get("/api/reservation/tasks/{taskId}/audits", taskId)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + + performAuthorized(mockMvc, token, get("/api/source-messages/{id}", source.inboxId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + } + + @Test + void shouldKeepSuperAgentTaskResultEndpointOutsideFrontendLoginInterceptor() throws Exception { + mockMvc.perform(post("/api/integrations/superagent/task-results") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING")); + } + + private SourceMessageCaptureResult captureOtherHotelSourceMessage() { + return captureService.capture(new CaptureSourceMessageCommand( + OTHER_HOTEL_ID, + "AGENTBUS", + "EMAIL", + "cp1-other-hotel-mail-001", + "cp1-other-hotel-thread-001", + "frame-cp1-other", + "session-cp1-other", + Instant.parse("2026-07-13T01:00:00Z"), + "guest@example.test", + "Other hotel message", + "Other hotel body", + "Other hotel body", + "{\"source\":{\"external_message_id\":\"cp1-other-hotel-mail-001\"}}", + "agentbus-outlook-v1", + List.of())); + } + + private void insertOtherHotelOrderTask(Long orderId, Long taskId, Long sourceMessageId) { + Long transitionId = taskId - 1; + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, order_visibility, business_key_source, display_name, + source_message_id, version, created_at, updated_at, latest_activity_at + ) + VALUES (?, ?, 'GROUP_CODE', 'GRP-CP1-OTHER', 'GRP-CP1-OTHER', + 'TMP-CP1-OTHER', 'ACTIVE', 'VISIBLE', 'AI_CANDIDATE', 'GRP-CP1-OTHER', + ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, orderId, OTHER_HOTEL_ID, sourceMessageId); + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_ai_transition ( + id, hotel_id, batch_id, source_message_id, source_event_index, array_index, + execution_order, catalog_code, skill_id, result_type, ai_task_type, + system_task_type, task_card_type, task_subtype, current_or_history, + group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed, + ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at + ) + VALUES (?, ?, ?, ?, 1, 1, 1, 'S02', 'cp1_skill', + 'normal_task', 'New Booking', 'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block', + 'current', 'GRP-CP1-OTHER', ?, ?, 0, '{}', '{}', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, transitionId, OTHER_HOTEL_ID, transitionId - 1, sourceMessageId, "1".repeat(64), + transitionId.toString()); + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task ( + id, hotel_id, order_id, source_message_id, ai_transition_id, + result_type, ai_task_type, system_task_type, task_card_type, task_subtype, + task_status, queue_participation, execution_order, blocked_until_parent_completed, + version, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, 'normal_task', 'New Booking', + 'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block', 'PENDING_CONFIRM', + 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, taskId, OTHER_HOTEL_ID, orderId, sourceMessageId, transitionId); + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task_card ( + id, hotel_id, task_id, task_card_type, field_contract_version, + ai_payload_json, created_at, updated_at + ) + VALUES (?, ?, ?, 'NEW_BOOKING', 'test', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, taskId + 1, OTHER_HOTEL_ID, taskId); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java index 0a27d1e..755f412 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationDemoDataControllerTest.java @@ -4,6 +4,8 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.blankOrNullString; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; 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.jsonPath; @@ -24,8 +26,16 @@ import org.springframework.test.web.servlet.MvcResult; @SpringBootTest( classes = ThHotelApplication.class, properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_demo_data_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", "reservation.demo-data.enabled=true", - "reservation.demo-data.access-key=test-demo-data-key" + "reservation.demo-data.access-key=test-demo-data-key", + "auth.bootstrap.admin.username=demo-data-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") @@ -37,6 +47,18 @@ class ReservationDemoDataControllerTest { @Autowired private MockMvc mockMvc; + private String adminToken; + + /** + * 获取演示数据查询阶段使用的管理员 token。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "demo-data-admin", "Admin@123456"); + } + return adminToken; + } + @Test void shouldRejectDemoDataSeedWhenAccessKeyMissing() throws Exception { mockMvc.perform(post("/api/system/reservation/demo-data") @@ -76,7 +98,7 @@ class ReservationDemoDataControllerTest { String blockedTaskId = first(response, "$.tasks[?(@.scenario_code=='QUEUE_BLOCKED')].task_id"); String failedTaskId = first(response, "$.tasks[?(@.scenario_code=='OPERA_FAILED')].task_id"); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) .param("keyword", demoRunId) .param("page_num", "1") @@ -84,7 +106,7 @@ class ReservationDemoDataControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5)))); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", HOTEL_ID) .param("keyword", demoRunId) .param("page_num", "1") @@ -92,7 +114,7 @@ class ReservationDemoDataControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5)))); - mockMvc.perform(get("/api/reservation/orders/{orderId}", queueOrderId) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", queueOrderId) .param("hotel_id", HOTEL_ID) .param("include_tasks", "true") .param("include_source_summary", "true")) @@ -100,13 +122,13 @@ class ReservationDemoDataControllerTest { .andExpect(jsonPath("$.tasks", hasSize(greaterThanOrEqualTo(2)))) .andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PREVIOUS_TASK_NOT_FINISHED")); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", blockedTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", blockedTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.availability.blocked").value(true)) .andExpect(jsonPath("$.availability.read_only").value(true)) .andExpect(jsonPath("$.availability.blocked_by_task_id", not(blankOrNullString()))); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", failedTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", failedTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.opera_operations[0].operation_status").value("FAILED")) .andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1)); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index 01ff0d3..9555d5c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -10,6 +10,8 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,7 +42,18 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; -@SpringBootTest(classes = ThHotelApplication.class) +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_frontend_query_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=frontend-query-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 ReservationFrontendQueryControllerTest { @@ -63,6 +76,18 @@ class ReservationFrontendQueryControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + private String adminToken; + + /** + * 获取前端查询管理员 token,测试通过真实登录接口覆盖认证链路。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "frontend-query-admin", "Admin@123456"); + } + return adminToken; + } + @Test void shouldReturnTaskWorkbenchListWithRealtimeAvailability() throws Exception { SourceMessageCaptureResult source = captureSourceMessage( @@ -83,7 +108,7 @@ class ReservationFrontendQueryControllerTest { reset(sourceMessageQueryService); reset(workflowRepository); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) .param("order_id", orderId.toString()) .param("page_num", "1") @@ -119,7 +144,7 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.page.page_size").value(20)) .andExpect(jsonPath("$.page.total").value(2)); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) .param("keyword", "Frontend Query List Smoke") .param("page_num", "1") @@ -152,7 +177,7 @@ class ReservationFrontendQueryControllerTest { insertTask(endedTaskId, endedOrderId, source.inboxId(), 930000000000001202L, "Cancel Booking", "CANCEL_BOOKING", "CANCEL_BOOKING", "PENDING_CONFIRM", 1); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) .param("order_status", "ENDED") .param("keyword", "GRP-FRONTEND-TASK-") @@ -191,7 +216,7 @@ class ReservationFrontendQueryControllerTest { insertTask(latestTaskId, latestOrderId, latestSource.inboxId(), 930000000000001502L, "Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 1); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) .param("keyword", "GRP-FRONTEND-TASK-SORT-") .param("page_num", "1") @@ -235,7 +260,7 @@ class ReservationFrontendQueryControllerTest { updateOrderLatestActivityAt(staleButRecentlyUpdatedOrderId, Instant.parse("2026-07-09T00:00:00Z")); updateOrderLatestActivityAt(latestActivityOrderId, Instant.parse("2026-07-10T09:30:00Z")); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", HOTEL_ID) .param("keyword", "GRP-FRONTEND-ORDER-SORT-") .param("page_num", "1") @@ -276,7 +301,7 @@ class ReservationFrontendQueryControllerTest { "GRP-FRONTEND-VISIBLE-FILTER-ARCHIVED", "ACTIVE"); updateOrderVisibility(archivedOrderId, "ARCHIVED_SYSTEM"); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", HOTEL_ID) .param("keyword", "GRP-FRONTEND-VISIBLE-FILTER-") .param("page_num", "1") @@ -355,7 +380,7 @@ class ReservationFrontendQueryControllerTest { "UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2); reset(workflowRepository); - mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId) .param("hotel_id", HOTEL_ID) .param("include_tasks", "true") .param("include_source_summary", "true")) @@ -387,7 +412,7 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.warnings.length()").value(0)); verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any()); - mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId) .param("hotel_id", HOTEL_ID) .param("include_tasks", "false")) .andExpect(status().isOk()) @@ -420,7 +445,7 @@ class ReservationFrontendQueryControllerTest { insertTask(failedTaskId, activeOrderId, source.inboxId(), 930000000000000803L, "Cancel Booking", "CANCEL_BOOKING", "CANCEL_BOOKING", "FAILED", 3); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", HOTEL_ID) .param("keyword", "GRP-FRONTEND-ORDERS-") .param("page_num", "1") @@ -439,7 +464,7 @@ class ReservationFrontendQueryControllerTest { .value(contains(0))) .andExpect(jsonPath("$.page.total").value(2)); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", HOTEL_ID) .param("order_status", "ENDED") .param("keyword", "GRP-FRONTEND-ORDERS-") diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index b10a6d5..27b8027 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -6,6 +6,8 @@ import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.not; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; 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.request.MockMvcRequestBuilders.put; @@ -40,11 +42,19 @@ import org.springframework.test.web.servlet.MvcResult; @SpringBootTest( classes = ThHotelApplication.class, properties = { + "spring.datasource.url=jdbc:h2:mem:superagent_task_result_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", "superagent.task-result.hmac-secret=test-superagent-secret", "superagent.task-result.clock-skew-seconds=300", "superagent.task-result.nonce-ttl-seconds=600", "superagent.task-result.max-body-bytes=12000", - "superagent.task-result.allow-legacy-internal-source-message-id=true" + "superagent.task-result.allow-legacy-internal-source-message-id=true", + "auth.bootstrap.admin.username=superagent-result-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=SuperAgent回归管理员", + "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") @@ -64,6 +74,18 @@ class SuperAgentTaskResultControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + private String adminToken; + + /** + * 获取 SuperAgent 回调测试里前端只读接口验证使用的管理员 token。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "superagent-result-admin", "Admin@123456"); + } + return adminToken; + } + @Test void shouldRejectRequestWithoutHmacHeaders() throws Exception { String body = minimalBody("1", "New Booking", "normal_task", "new_fit_reservation", """ @@ -522,7 +544,7 @@ class SuperAgentTaskResultControllerTest { String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); String orderId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].order_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.system_task_type").value("SOURCE_MESSAGE_ONLY")) .andExpect(jsonPath("$.task_card_type").value("SOURCE_MESSAGE_ONLY")) @@ -560,7 +582,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("TASK_NOT_MANUAL_REVIEW")); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", "HOTEL-TEST") .param("task_type", "SOURCE_MESSAGE_ONLY") .param("keyword", "mail-s000-entry-result-001")) @@ -572,7 +594,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.items[0].queue_participation").value(false)) .andExpect(jsonPath("$.items[0].can_process").value(false)); - mockMvc.perform(get("/api/reservation/orders") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders") .param("hotel_id", "HOTEL-TEST") .param("keyword", "mail-s000-entry-result-001")) .andExpect(status().isOk()) @@ -871,7 +893,7 @@ class SuperAgentTaskResultControllerTest { "$.items[0].task_id" ); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", "HOTEL-TEST") .param("task_type", "SOURCE_MESSAGE_ONLY") .param("keyword", "mail-v3-s99-frontend-visible-001")) @@ -882,7 +904,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.items[0].route_code").value("S99")) .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.result_type").value("source_message_review_notification")) .andExpect(jsonPath("$.ai_task_type").value("S99")) @@ -1465,7 +1487,7 @@ class SuperAgentTaskResultControllerTest { "$.items[1].task_id" ); - mockMvc.perform(get("/api/reservation/tasks") + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks") .param("hotel_id", "HOTEL-TEST") .param("keyword", "mail-v3-frontend-route-blocks-001")) .andExpect(status().isOk()) @@ -1475,7 +1497,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")) .andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK")); - mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId) .param("hotel_id", "HOTEL-TEST") .param("include_tasks", "true")) .andExpect(status().isOk()) @@ -1485,7 +1507,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.tasks[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL")) .andExpect(jsonPath("$.tasks[0].system_process_category").value("BUSINESS_TASK")); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.result_type").value("normal_task")) .andExpect(jsonPath("$.ai_task_type").value("New Booking")) @@ -1646,7 +1668,7 @@ class SuperAgentTaskResultControllerTest { String firstTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); String secondTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[1].task_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.task_id").value(secondTaskId)) .andExpect(jsonPath("$.availability.blocked").value(true)) @@ -1662,7 +1684,7 @@ class SuperAgentTaskResultControllerTest { WHERE id = ? """, Long.valueOf(firstTaskId)); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.availability.blocked").value(true)) .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId)); @@ -1673,7 +1695,7 @@ class SuperAgentTaskResultControllerTest { WHERE id = ? """, Long.valueOf(firstTaskId)); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.availability.blocked").value(true)) .andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId)); @@ -1684,7 +1706,7 @@ class SuperAgentTaskResultControllerTest { WHERE id = ? """, Long.valueOf(firstTaskId)); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.availability.blocked").value(false)) .andExpect(jsonPath("$.availability.read_only").value(false)) @@ -1705,7 +1727,7 @@ class SuperAgentTaskResultControllerTest { .andReturn(); String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING")) .andExpect(jsonPath("$.source_subject").value("M002 SuperAgent intake")) @@ -1820,7 +1842,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2)) .andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING")); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.opera_operations.length()").value(2)) .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1)) @@ -1938,7 +1960,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.attempts[0].attempt_number").value(1)) .andExpect(jsonPath("$.attempts[0].attempt_status").value("SUCCEEDED")); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.opera_operations[0].operation_id").value(firstOperationId)) .andExpect(jsonPath("$.opera_operations[0].operation_status").value("SUCCEEDED")) @@ -2065,7 +2087,7 @@ class SuperAgentTaskResultControllerTest { """)) .andExpect(status().isOk()); - mockMvc.perform(get("/api/reservation/tasks/{taskId}/audits", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}/audits", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.task_id").value(taskId)) .andExpect(jsonPath("$.items[?(@.action=='TASK_CONFIRM')].action") @@ -2208,7 +2230,7 @@ class SuperAgentTaskResultControllerTest { WHERE source_message_id = ? """, Long.class, source.inboxId()); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.result_type").value("manual_review")) .andExpect(jsonPath("$.review_status").value("PENDING")) diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java index b533c8d..63a1cdd 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -3,6 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; 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.request.MockMvcRequestBuilders.put; @@ -40,10 +42,18 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde @SpringBootTest( classes = ThHotelApplication.class, properties = { + "spring.datasource.url=jdbc:h2:mem:superagent_task_result_p0_fixture_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", "superagent.task-result.hmac-secret=test-superagent-secret", "superagent.task-result.clock-skew-seconds=300", "superagent.task-result.nonce-ttl-seconds=600", - "superagent.task-result.max-body-bytes=20000" + "superagent.task-result.max-body-bytes=20000", + "auth.bootstrap.admin.username=p0-fixture-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=P0基线管理员", + "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") @@ -73,6 +83,18 @@ class SuperAgentTaskResultP0FixtureRegressionTest { @Autowired private JdbcTemplate jdbcTemplate; + private String adminToken; + + /** + * 获取 P0 fixture 回归测试里前端只读接口验证使用的管理员 token。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "p0-fixture-admin", "Admin@123456"); + } + return adminToken; + } + @Test void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception { JsonNode cases = fixture("main_outcomes.json").path("cases"); @@ -99,7 +121,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest { String s10TaskId = com.jayway.jsonpath.JsonPath.read( s10Result.getResponse().getContentAsString(), "$.items[0].task_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", s10TaskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", s10TaskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.fields.length()").value(0)); @@ -466,7 +488,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest { createResult.getResponse().getContentAsString(), "$.items[2].order_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.group_code')].field_pointer") .value(contains("/case_keys/group_code"))); @@ -679,7 +701,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest { result.getResponse().getContentAsString(), "$.items[0].task_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].field_pointer") .value(contains("/extracted_fields/room_items/0/room_quantity"))) @@ -856,7 +878,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest { result.getResponse().getContentAsString(), "$.items[0].order_id"); - mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer") .value(contains("/extracted_fields/room_items/0/pms_room_type_code")))