diff --git a/README.md b/README.md index 37cd42b..2d2554d 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ pnpm build - `pnpm lint`:运行 ESLint 检查。 - `pnpm build`:执行类型检查并构建生产产物。 -本地联调指定后端和酒店上下文示例: +本地联调指定后端示例: ```bash -VITE_API_PROXY_TARGET=http://127.0.0.1:8080 VITE_RESERVATION_HOTEL_ID=HOTEL-TEST pnpm --dir client dev +VITE_API_PROXY_TARGET=http://127.0.0.1:8080 pnpm --dir client dev ``` -中文说明:`VITE_RESERVATION_HOTEL_ID` 是公开前端配置,不是 Secret,用于 Reservation 订单列表、任务列表和订单详情接口的默认 `hotel_id`。 +中文说明:Reservation 查询默认不再依赖前端环境变量传 `hotel_id`。单酒店阶段后端从 `platform_hotel` 唯一 `ACTIVE` 酒店解析系统酒店;登录后前端可按 `/api/auth/me` 的当前选中酒店传可选 `hotel_id`,后端仍会校验访问权限。`VITE_RESERVATION_HOTEL_ID` 仅保留为本地夹具或临时覆盖,不作为生产业务事实来源。 Debug EML 上传到 SuperAgent 调试页面为隐藏入口,不放在普通业务菜单中: diff --git a/client/src/config/reservationConfig.ts b/client/src/config/reservationConfig.ts index 4bf3fdd..9bf9b01 100644 --- a/client/src/config/reservationConfig.ts +++ b/client/src/config/reservationConfig.ts @@ -1,6 +1,6 @@ const configuredReservationHotelId = import.meta.env.VITE_RESERVATION_HOTEL_ID?.trim() -export const reservationHotelId = configuredReservationHotelId || 'HOTEL-TEST' +export const reservationHotelId = configuredReservationHotelId || null let reservationHotelIdProvider: (() => string | null | undefined) | null = null @@ -8,7 +8,7 @@ export function setReservationHotelIdProvider(provider: (() => string | null | u reservationHotelIdProvider = provider } -export function getReservationHotelId(): string { +export function getReservationHotelId(): string | null { const providedHotelId = reservationHotelIdProvider?.()?.trim() return providedHotelId || reservationHotelId } diff --git a/client/src/services/debugEmlService.ts b/client/src/services/debugEmlService.ts index 0f568d8..11a216e 100644 --- a/client/src/services/debugEmlService.ts +++ b/client/src/services/debugEmlService.ts @@ -29,7 +29,10 @@ export async function uploadDebugEmlSuperAgentRun( ): Promise { const form = new FormData() form.append('file', input.file) - form.append('hotel_id', input.hotelId.trim()) + const hotelId = input.hotelId?.trim() + if (hotelId) { + form.append('hotel_id', hotelId) + } const runLabel = input.runLabel?.trim() if (runLabel) { form.append('run_label', runLabel) diff --git a/client/src/services/reservationService.ts b/client/src/services/reservationService.ts index 78a4367..d029109 100644 --- a/client/src/services/reservationService.ts +++ b/client/src/services/reservationService.ts @@ -152,7 +152,7 @@ function withQuery(path: string, params: object): string { function withReservationHotel(filters: T): T { return { - hotel_id: getReservationHotelId(), + hotel_id: getReservationHotelId() ?? undefined, ...filters, } } diff --git a/client/src/tests/debugEmlService.spec.ts b/client/src/tests/debugEmlService.spec.ts index 6a4f4bf..cad0f78 100644 --- a/client/src/tests/debugEmlService.spec.ts +++ b/client/src/tests/debugEmlService.spec.ts @@ -77,6 +77,29 @@ describe('debugEmlService', () => { expect(result.debug_run_id).toBe('90001') }) + it('omits hotel id when debug upload does not choose a hotel', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse({ + debug_run_id: '90002', + source_message_id: null, + uploaded_media: [], + warnings: [], + status: 'CREATED', + }), + ) + const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' }) + + await uploadDebugEmlSuperAgentRun({ + debugUploadKey: 'manual-debug-key', + file, + }) + + const [, init] = fetchMock.mock.calls[0]! + const formData = init?.body as FormData + expect(formData.has('hotel_id')).toBe(false) + expect(formData.get('file')).toBe(file) + }) + it('throws a typed safe error for backend error_code responses', async () => { const unauthorizedHandler = vi.fn() setUnauthorizedHandler(unauthorizedHandler) diff --git a/client/src/tests/reservationService.spec.ts b/client/src/tests/reservationService.spec.ts index b23b5df..4f87367 100644 --- a/client/src/tests/reservationService.spec.ts +++ b/client/src/tests/reservationService.spec.ts @@ -72,7 +72,7 @@ describe('reservationService real API mode', () => { }) expect(fetchMock).toHaveBeenCalledWith( - '/api/reservation/tasks?hotel_id=HOTEL-TEST&task_status=PENDING_CONFIRM&order_status=ACTIVE&page_num=1&page_size=20', + '/api/reservation/tasks?task_status=PENDING_CONFIRM&order_status=ACTIVE&page_num=1&page_size=20', expect.objectContaining({ method: 'GET' }), ) expect(result.items).toHaveLength(1) @@ -99,12 +99,12 @@ describe('reservationService real API mode', () => { }) expect(fetchMock).toHaveBeenCalledWith( - '/api/reservation/tasks?hotel_id=HOTEL-TEST&task_subtype=RATE_CHANGE&task_status=FAILED&page_num=1&page_size=20', + '/api/reservation/tasks?task_subtype=RATE_CHANGE&task_status=FAILED&page_num=1&page_size=20', expect.objectContaining({ method: 'GET' }), ) }) - it('uses the selected auth hotel before falling back to the environment hotel id', async () => { + it('uses the selected auth hotel when available', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( mockJsonResponse({ items: [], @@ -162,7 +162,7 @@ describe('reservationService real API mode', () => { }) expect(fetchMock).toHaveBeenCalledWith( - '/api/reservation/orders?hotel_id=HOTEL-TEST&keyword=GRP-001&page_num=1&page_size=20', + '/api/reservation/orders?keyword=GRP-001&page_num=1&page_size=20', expect.objectContaining({ method: 'GET' }), ) expect(result.items[0]?.display_order_key).toBe('GRP-001') @@ -191,7 +191,7 @@ describe('reservationService real API mode', () => { }) expect(fetchMock).toHaveBeenCalledWith( - '/api/reservation/orders?hotel_id=HOTEL-TEST&order_status=LOGIC_DELETED&group_code=GRP-001&confirmation_number=CNF-001&keyword=VIP&page_num=2&page_size=10', + '/api/reservation/orders?order_status=LOGIC_DELETED&group_code=GRP-001&confirmation_number=CNF-001&keyword=VIP&page_num=2&page_size=10', expect.objectContaining({ method: 'GET' }), ) }) @@ -257,7 +257,7 @@ describe('reservationService real API mode', () => { const result = await fetchReservationOrderDetail('20001') expect(fetchMock).toHaveBeenCalledWith( - '/api/reservation/orders/20001?hotel_id=HOTEL-TEST&include_tasks=true&include_source_summary=true', + '/api/reservation/orders/20001?include_tasks=true&include_source_summary=true', expect.objectContaining({ method: 'GET' }), ) expect(result.order.display_name).toBe('GRP-001') diff --git a/client/src/types/debugEml.ts b/client/src/types/debugEml.ts index 7392a47..75cfa9d 100644 --- a/client/src/types/debugEml.ts +++ b/client/src/types/debugEml.ts @@ -11,7 +11,7 @@ export type DebugEmlErrorCode = | string export interface DebugEmlUploadInput { - hotelId: string + hotelId?: string | null debugUploadKey: string file: File runLabel?: string diff --git a/client/src/views/debug/DebugEmlSuperAgentRunView.vue b/client/src/views/debug/DebugEmlSuperAgentRunView.vue index 97c69a6..2a97036 100644 --- a/client/src/views/debug/DebugEmlSuperAgentRunView.vue +++ b/client/src/views/debug/DebugEmlSuperAgentRunView.vue @@ -348,7 +348,7 @@ type DebugEmlStatus = 'idle' | 'uploading' | 'succeeded' | 'failed' const { t } = useI18n() -const hotelId = ref(reservationHotelId) +const hotelId = ref(reservationHotelId ?? '') const debugUploadKey = ref('') const runLabel = ref('') const selectedFile = ref(null) @@ -359,7 +359,7 @@ const errorMessage = ref('') const busy = computed(() => status.value === 'uploading') const submitDisabled = computed( - () => busy.value || !hotelId.value.trim() || !debugUploadKey.value.trim() || !selectedFile.value, + () => busy.value || !debugUploadKey.value.trim() || !selectedFile.value, ) const statusLabel = computed(() => t(`debugEml.statuses.${status.value}`)) const errorDisplayMessage = computed(() => { diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 0167959..5d464ae 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -71,6 +71,8 @@ | `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 字段和 S000/S999 识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回 S000/S999 时不是 JSON 解析失败。 | +酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。前端默认可以不传;后端会按当前登录用户酒店上下文或平台酒店表唯一 `ACTIVE` 酒店解析。如果前端传了当前选中酒店,后端会校验该酒店是否可访问。 + ### 5.2 登录权限接入注意 后端已提供 M003 登录和权限底座第一版接口: @@ -109,7 +111,7 @@ POST /api/auth/logout ### 5.4 邮件会话详情接入注意 - `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`。 -- 前端当前通过 `VITE_RESERVATION_HOTEL_ID` 统一配置 Reservation 默认酒店上下文,并会在订单列表、任务列表和订单详情查询中传 `hotel_id`;任务详情、任务写操作和邮件会话详情当前后端接口不接收该参数。 +- Reservation 列表、任务列表和订单详情默认不需要前端传 `hotel_id`;如果前端已经接入酒店选择器,可以把当前选中酒店作为可选 `hotel_id` 传给后端。任务详情、任务写操作和邮件会话详情当前仍按对象 ID 定位,不接收该参数。 - 后端会根据 `sourceMessageId` 定位 `external_conversation_id`,并返回同一会话下全部邮件;如果来源消息没有外部会话 ID,会降级返回当前单封邮件。 - `messages[]` 按后端接收时间正序返回,前端不要重新按创建时间或任务时间排序。 - 返回内容包含完整 `text_body`、`html_body`、`inline_images[]`、`attachments[]`、`related_orders[]`、`related_tasks[]`。 @@ -140,7 +142,6 @@ Header: X-TH-Hotel-Demo-Data-Key: <本地演示数据访问口令> Content-Type: application/json { - "hotel_id": "HOTEL-TEST", "run_label": "frontend-smoke" } ``` @@ -186,7 +187,7 @@ Header: X-TH-Hotel-Debug-Upload-Key: <调试访问口令> Content-Type: multipart/form-data file: .eml 文件 -hotel_id: HOTEL-TEST +hotel_id: 可选;缺省使用后端系统酒店,显式传值时必须是当前可访问酒店 run_label: 可选调试标签 ``` @@ -195,6 +196,7 @@ run_label: 可选调试标签 前端注意: - 该接口只用于 dev/test 调试页面,不是生产普通业务页面接口。 +- `hotel_id` 第一版可不传;单酒店阶段后端按平台酒店表唯一 `ACTIVE` 酒店解析。只有在调试人员明确要覆盖当前酒店时,前端才传当前选中酒店。 - 接口会解析 `.eml`,上传原始邮件、内联图片和附件到本系统阿里云 OSS,替换 HTML 内 `cid:` 图片,再写入 SourceMessage Inbox。 - SourceMessage 来源 provider 固定为 `DEBUG_EML_UPLOAD`,用于和 AgentBus 入库邮件区分。 - 当前 AgentBus 实时收到邮件后自动推 SuperAgent 还没有做;这个接口是人工 Debug 上传链路,不代表实时生产链路。 diff --git a/docs/project/frontend-backend/debug-eml-page-integration-guide.md b/docs/project/frontend-backend/debug-eml-page-integration-guide.md index 2d3b713..8b2b812 100644 --- a/docs/project/frontend-backend/debug-eml-page-integration-guide.md +++ b/docs/project/frontend-backend/debug-eml-page-integration-guide.md @@ -31,7 +31,7 @@ Debug EML 页面第一版只做一件事: | 区域 | 展示 / 操作 | 说明 | | --- | --- | --- | -| 上传配置区 | `hotel_id`、Debug 上传口令、`run_label`、`.eml` 文件选择、提交按钮 | Debug 上传口令只能由调试人员临时输入,不能写进前端源码、环境变量、localStorage 或 URL。 | +| 上传配置区 | 可选酒店覆盖、Debug 上传口令、`run_label`、`.eml` 文件选择、提交按钮 | Debug 上传口令只能由调试人员临时输入,不能写进前端源码、环境变量、localStorage 或 URL;单酒店阶段默认不需要手填酒店。 | | 执行状态区 | loading、成功、失败、耗时、本次 `debug_run_id` | 提交后禁用按钮,避免重复点击;失败时展示安全错误摘要。 | | SourceMessage 追溯区 | `source_message_id`、`source_provider`、`external_message_id`、`external_conversation_id` | 用于确认已写入 SourceMessage Inbox。 | | 邮件内容预览区 | `html_body_sanitized`、纯文本、附件列表、内联图片列表 | HTML 展示必须优先使用 `html_body_sanitized`。 | @@ -59,7 +59,7 @@ Header: X-TH-Hotel-Debug-Upload-Key: <调试上传口令> | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `file` | File | 是 | `.eml` 邮件文件。前端文件选择器建议 `accept=".eml,message/rfc822"`。 | -| `hotel_id` | string | 是 | 酒店上下文 ID;本地 / test 可使用当前前端配置的 `VITE_RESERVATION_HOTEL_ID`。 | +| `hotel_id` | string | 否 | 可选酒店上下文覆盖;缺省由后端按当前登录用户上下文或平台酒店表唯一 `ACTIVE` 酒店解析。 | | `run_label` | string | 否 | 调试标签,例如 `frontend-debug-smoke`,方便后端日志和数据库排查。 | 前端调用示例: @@ -67,14 +67,16 @@ Header: X-TH-Hotel-Debug-Upload-Key: <调试上传口令> ```ts export async function uploadDebugEml(input: { baseUrl: string - hotelId: string + hotelId?: string | null debugUploadKey: string file: File runLabel?: string }) { const form = new FormData() form.append('file', input.file) - form.append('hotel_id', input.hotelId) + if (input.hotelId?.trim()) { + form.append('hotel_id', input.hotelId.trim()) + } if (input.runLabel?.trim()) { form.append('run_label', input.runLabel.trim()) } @@ -171,7 +173,7 @@ S000/S999 解析示例: | HTTP 状态 | `error_code` | 前端建议 | | --- | --- | --- | -| `400` | `REQUEST_FIELD_REQUIRED` | 提示缺少必填字段,检查文件、`hotel_id` 或请求参数。 | +| `400` | `REQUEST_FIELD_REQUIRED` | 提示缺少必填字段,检查文件或请求参数。 | | `400` | `EML_FILE_REQUIRED` | 提示请选择 `.eml` 文件。 | | `400` | `EML_FILE_TOO_LARGE` | 提示文件超过后端限制;当前默认上限通常为 10MB,以环境配置为准。 | | `400` | `INVALID_FILE_TYPE` | 提示只支持 `.eml`。 | @@ -204,7 +206,7 @@ idle 实现建议: -- 文件未选择、`hotel_id` 为空、Debug 上传口令为空时禁用提交按钮。 +- 文件未选择或 Debug 上传口令为空时禁用提交按钮;`hotel_id` 为空是允许的,表示使用后端系统酒店。 - 提交后禁用文件选择和提交按钮,避免重复上传。 - SuperAgent 调用可能耗时较长,页面 loading 文案不要只写“上传中”,建议写“正在解析邮件并等待 SuperAgent 返回”。 - 成功后保留本次响应在页面内存中;当前没有 Debug run 查询接口,刷新页面后需要重新上传。 @@ -233,7 +235,7 @@ idle 前端需要具备: - 可配置后端 `BASE_URL`。 -- 可配置或输入 `hotel_id`。 +- 可配置或输入可选 `hotel_id`;单酒店联调默认可留空。 - 调试人员临时输入 Debug 上传口令。 - 准备一封 `.eml` 样例邮件。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 60253f1..d3edadc 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -84,7 +84,7 @@ GET /api/reservation/tasks | 参数 | 必填 | 说明 | | --- | --- | --- | -| `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | +| `hotel_id` | 否 | 酒店 ID。单酒店阶段默认可为空,由后端按当前用户上下文或平台酒店表唯一 `ACTIVE` 酒店解析;显式传值时后端会校验访问权限。 | | `order_id` | 否 | 按订单过滤。 | | `task_type` | 否 | `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`INFORMATIONAL_MESSAGE`、`SOURCE_MESSAGE_ONLY`。其中 `INFORMATIONAL_MESSAGE` 仅历史兼容,新入口 S000/S999 使用 `SOURCE_MESSAGE_ONLY`。 | | `task_status` | 否 | 任务状态过滤。 | @@ -158,7 +158,7 @@ GET /api/reservation/orders/{orderId} | 参数 | 必填 | 说明 | | --- | --- | --- | | `orderId` | 是 | 订单 ID。 | -| `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | +| `hotel_id` | 否 | 酒店 ID。单酒店阶段默认可为空,由后端解析;显式传值时后端会校验访问权限。 | | `include_tasks` | 否 | 是否返回任务时间线,默认 `true`。 | | `include_source_summary` | 否 | 是否返回来源消息摘要,默认 `true`;第一版参数保留,前端暂不要依赖它做字段裁剪。 | @@ -228,7 +228,7 @@ GET /api/reservation/orders | 参数 | 必填 | 说明 | | --- | --- | --- | -| `hotel_id` | 否 | 酒店 ID。 | +| `hotel_id` | 否 | 酒店 ID。单酒店阶段默认可为空,由后端解析;显式传值时后端会校验访问权限。 | | `order_status` | 否 | `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 | | `group_code` | 否 | 按 Group Code 精确或模糊查询,后端决定。 | | `confirmation_number` | 否 | 按 Confirmation No 查询。 | @@ -285,7 +285,6 @@ POST /api/system/reservation/demo-data ```json { - "hotel_id": "HOTEL-TEST", "run_label": "frontend-smoke" } ``` @@ -500,7 +499,7 @@ GET /api/reservation/tasks/{taskId} - 如果前端只做“按后端字段直接渲染”,现有 `fields[]` 可以支撑第一版表单展示;本轮已经扩展 `ReservationTaskFieldResult`,避免前端维护第二套字段矩阵。 - `result_type`、`task_type`、`task_subtype`、`default_value_source` 当前从后端字段矩阵定义透出。 - 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 -- 前端已统一配置 `VITE_RESERVATION_HOTEL_ID`,并会在 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动传 `hotel_id`。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 +- 前端默认不需要为 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动拼 `hotel_id`;如已接入酒店选择器,可以传当前选中酒店,后端会校验访问权限。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 ## 9. S000/S999 特殊只读任务与历史 Message Notification @@ -529,7 +528,7 @@ GET /api/reservation/message-notifications/{taskId} | 参数 | 必填 | 说明 | | --- | --- | --- | -| `hotel_id` | 否 | 酒店 ID。 | +| `hotel_id` | 否 | 酒店 ID。单酒店阶段默认可为空,由后端解析;显式传值时后端会校验访问权限。 | | `order_id` | 否 | 按临时订单或真实订单过滤。 | | `keyword` | 否 | 邮件主题、摘要、发送人关键词。 | | `page_num` | 否 | 页码。 | diff --git a/docs/project/frontend-development-guidelines.md b/docs/project/frontend-development-guidelines.md index d710d69..5d20f0b 100644 --- a/docs/project/frontend-development-guidelines.md +++ b/docs/project/frontend-development-guidelines.md @@ -177,7 +177,6 @@ th-TH VITE_API_BASE_URL=http://localhost:8080 VITE_APP_ENV=local VITE_DEFAULT_LOCALE=zh-CN -VITE_RESERVATION_HOTEL_ID=HOTEL-TEST VITE_ENABLE_MOCKS=false ``` @@ -191,7 +190,8 @@ VITE_API_PROXY_TARGET=http://127.0.0.1:8081 - 公开配置可以放 `VITE_*`。 - Secret 一律不进入 `VITE_*`。 -- `VITE_RESERVATION_HOTEL_ID` 是当前前端 Reservation 页面默认酒店上下文,用于订单列表、任务列表和订单详情查询;它不是 Secret,但需要随本地 / test / prod 环境显式配置,避免页面查到错误酒店数据。 +- Reservation 查询默认不再依赖 `VITE_RESERVATION_HOTEL_ID`。单酒店阶段可不传 `hotel_id`,由后端按平台酒店表唯一 `ACTIVE` 酒店或当前登录用户上下文解析;如前端有酒店选择器,只传当前选中的酒店,后端负责校验访问权限。 +- `VITE_RESERVATION_HOTEL_ID` 仅允许作为本地夹具或临时调试覆盖,不作为 test / prod 业务事实来源。 - 生产需要运行时配置时,应由部署系统生成公开配置文件,例如 `/app-config.json`。 - 需要秘密的外部调用一律经后端代理或适配器。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 4230878..3a91942 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -43,7 +43,7 @@ - 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。 - 生产默认不保存 AgentBus raw frame 样本。 - AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 -- S000/S999 特殊入口结果上线前,必须确认 `AGENTBUS_DEFAULT_HOTEL_ID` 已配置且对应 SourceMessage Inbox 入库酒店一致。 +- S000/S999 特殊入口结果上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。 - 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。 - 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。 @@ -77,6 +77,10 @@ - `access_token` 只在登录成功响应中返回一次;前端只能放 `sessionStorage`,不能放 `localStorage`、URL、日志或错误上报。 - 当前第一版只做可选 Bearer token 解析,现有 Reservation / SourceMessage 业务接口仍不强制登录。 - `/api/auth/me` 和 `/api/auth/logout` 需要 `Authorization: Bearer `。 +- 初始管理员 bootstrap 只以“启用状态超级管理员”为阻断条件;如果测试库或生产库只剩禁用超级管理员,应通过环境变量恢复一个可登录超级管理员后再排查账号运营问题。 +- 内置角色权限矩阵在启动时按代码同步,矩阵移除的旧权限关系会被清理;管理后台上线前不要手工给内置角色追加临时权限作为长期方案。 +- 普通用户默认酒店由后端写入逻辑和数据库唯一索引共同保持单默认;V10 migration 会在建约束前把历史重复默认清理为每个用户保留 id 最大的一条。 +- 单酒店阶段系统酒店由 `platform_hotel` 唯一 `ACTIVE` 酒店决定;V12 migration 会通过唯一索引阻止第二家 `ACTIVE` 酒店。上线前如果已有多家 `ACTIVE` 酒店,必须先调整数据,否则迁移或运行时解析会失败。 - 管理后台还未上线时,不要把数据库手工改用户、角色、权限作为常规运营手段。 ### 3.3 SourceMessage @@ -104,7 +108,7 @@ | `AGENTBUS_CONNECT_TIMEOUT` | 否 | 连接超时,默认 `15s`。 | | `AGENTBUS_MAX_FRAME_BYTES` | 否 | 单个入站 frame 最大字节数,默认 `1048576`。 | | `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否把业务 frame 写入 SourceMessage Inbox。 | -| `AGENTBUS_DEFAULT_HOTEL_ID` | 否 | AgentBus 未提供酒店上下文时的默认业务上下文。 | +| `AGENTBUS_DEFAULT_HOTEL_ID` | 否 | 旧兼容变量;M005 后 AgentBus 捕获不再使用它作为运行时酒店来源,系统酒店来自 `platform_hotel` 唯一 `ACTIVE` 酒店。 | 注意: @@ -129,8 +133,8 @@ - 查询接口和任务结果通知接口使用同一套 Header、签名串、secret、timestamp 和 nonce 规则。 - SuperAgent 侧也需要配置同一个 secret,并按原始请求体计算 SHA-256。 - 当前第一版只支持一个 HMAC secret,secret 轮换需要协调部署窗口。 -- 任务结果通知接口 JSON body 里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式 JSON 请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 -- 任务结果通知接口也支持 `text/plain` 的 `S000,source_message_id` 和 `S999,source_message_id`。这类请求不在 body 里带 `hotel_id`,后端使用 `AGENTBUS_DEFAULT_HOTEL_ID` 查询 SourceMessage Inbox。 +- 任务结果通知接口 JSON body 里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;SuperAgent 默认不传 `hotel_id`,后端用系统酒店 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 +- 任务结果通知接口也支持 `text/plain` 的 `S000,source_message_id` 和 `S999,source_message_id`。这类请求不在 body 里带 `hotel_id`,后端同样使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。 - `application/json` 和 `text/plain` 都必须使用原始请求体计算 SHA-256 并参与 HMAC 签名;SuperAgent 侧不能签名格式化后的 JSON 或二次拼接字符串。 - S000/S999 会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务和隐藏技术订单,任务列表可见,订单列表不可见,不允许编辑、确认、转换订单或执行 OPERA。 - SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 @@ -196,6 +200,7 @@ 当前 M003 登录权限相关 migration: - `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` +- `server/src/main/resources/db/migration/V10__enforce_single_default_user_hotel.sql` 上线前确认: diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index 1437ddf..4dbb3ea 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -268,8 +268,8 @@ AGENTBUS_SAMPLE_DIR=var/agentbus-samples AGENTBUS_MAX_FRAME_BYTES=1048576 AGENTBUS_MAX_SAMPLES=100 AGENTBUS_CAPTURE_ENABLED=true -AGENTBUS_DEFAULT_HOTEL_ID=HOTEL-TEST AGENTBUS_REPLY_MODE=NONE +AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID=HOTEL-DEV SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY= SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= ``` @@ -289,7 +289,7 @@ SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= | `AGENTBUS_MAX_FRAME_BYTES` | 否 | 单个入站 frame 最大字节数。 | | `AGENTBUS_MAX_SAMPLES` | 否 | 最多保留的本地样本数。 | | `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否写入 SourceMessage Inbox。 | -| `AGENTBUS_DEFAULT_HOTEL_ID` | 否 | AgentBus 未提供租户上下文时的默认业务上下文。 | +| `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID` | 否 | dev 初始化平台酒店;M005 后 AgentBus 捕获运行时从 `platform_hotel` 唯一 `ACTIVE` 酒店解析系统酒店,不再依赖 `AGENTBUS_DEFAULT_HOTEL_ID`。 | | `AGENTBUS_REPLY_MODE` | 否 | 调试回复模式。真实客户渠道应保持 `NONE`。 | | `SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY` | 是 | dev 原文读取接口的临时受控访问 key,后续可替换为正式权限体系。 | | `SOURCE_MESSAGE_TEST_ORIGINAL_READ_ACCESS_KEY` | 是 | test 原文读取接口的临时受控访问 key。 | @@ -390,6 +390,8 @@ reply_policy.final_only hotel_id + provider + channel + external_message_id ``` +中文说明:M005 后 `hotel_id` 由本系统后端解析,不要求 AgentBus payload 携带酒店;单酒店阶段要求 `platform_hotel` 只有一家 `ACTIVE` 酒店。 + 重复投递时返回已有 Inbox,不覆盖原始 payload,不创建重复记录。 如果 payload 缺少必要字段或格式不符合预期,也应保存为 `FAILED` Inbox,并记录安全错误 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index d82fa8d..89c2ceb 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.4 | +| 文档版本 | 0.5 | | 日期 | 2026-07-10 | -| 状态 | 已增加 S000/S999 特殊入口结果处理 | +| 状态 | 已增加 S000/S999 特殊入口结果处理,并完成单酒店阶段 hotel_id 后端解析收口 | | 适用范围 | SuperAgent 调用本系统查询上下文、查询邮件会话、提交 AI 任务结果 | | 主要读者 | SuperAgent 对接方、后端、测试、运维 | @@ -17,7 +17,7 @@ | 参数 | 当前联调值 | 中文说明 | | --- | --- | --- | | `TH_HOTEL_API_BASE_URL` | `http://8.138.234.141:18087` | 本系统后端基础地址;本地联调用 8080,部署环境改为实际网关或服务地址。 | -| `HOTEL_ID` | `HOTEL-DEV` | 当前 dev profile 下 AgentBus 入库默认酒店 ID;查询接口和 JSON 任务结果请求体应传入 `hotel_id`,S000/S999 文本结果使用本系统默认酒店。 | +| `SYSTEM_HOTEL` | 后端平台酒店表唯一 `ACTIVE` 酒店 | SuperAgent 不需要配置或传入 `hotel_id`;单酒店阶段由 TH Hotel 后端从 `platform_hotel` 解析。 | | `SUPERAGENT_CLIENT_ID` | `superagent-debug` | SuperAgent 调用方 ID,对应 Header `X-TH-Hotel-SuperAgent-Client-Id`。 | | `SUPERAGENT_HMAC_SECRET` | `th-hotel-superagent-debug-20260709-change-before-prod` | dev/test 联调临时 HMAC 密钥;生产上线前必须更换为新的高强度随机密钥。 | @@ -99,11 +99,11 @@ X-TH-Hotel-SuperAgent-Signature: sha256= | 外部来源消息 ID | AgentBus 邮件 payload 中的 `source.external_message_id`,SuperAgent / Main Agent 在最终 JSON 中原样带回为 `source_message_id` | SuperAgent 任务结果通知接口入参和响应回显 | | 内部 SourceMessage Inbox ID | `platform_source_message_inbox.id`,本系统数据库内部主键 | `workflow_*` 表的 `source_message_id` 外键、前端和运维排查 | -SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通知接口收到外部 `source_message_id` 后,后端使用 `hotel_id + provider + channel + external_message_id` 反查内部 Inbox 记录,再用内部 ID 写入业务表。 +SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通知接口收到外部 `source_message_id` 后,后端先解析系统酒店,再使用 `hotel_id + provider + channel + external_message_id` 反查内部 Inbox 记录,最后用内部 ID 写入业务表。 查询接口 1、2 在 SuperAgent 查询阶段不依赖当前邮件是否已经入库。若请求体兼容旧契约传入 `source_message_id` 或 `source_event_index`,第一版后端会接收但忽略,不校验它们的格式,也不把它们作为查询边界。 -查询接口 3、4 面向已经入库的邮件会话:`external_conversation_id` 按 `hotel_id + source_provider + source_channel + external_conversation_id` 查询;`source_message_id` 表示外部来源消息 ID,可作为锚点反查该邮件所属会话。 +查询接口 3、4 面向已经入库的邮件会话:缺省酒店由后端解析,`external_conversation_id` 最终仍按 `hotel_id + source_provider + source_channel + external_conversation_id` 查询;`source_message_id` 表示外部来源消息 ID,可作为锚点反查该邮件所属会话。 ## 4. 接口 1:查询订单上下文 @@ -121,7 +121,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "group_code": "GRP-001", "confirmation_number": null, "reservation_no": null, @@ -135,7 +134,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `hotel_id` | 是 | 酒店上下文 ID | +| `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,后端按平台酒店表唯一 `ACTIVE` 酒店解析。若兼容旧契约传入,单酒店阶段必须与系统酒店一致。 | | `group_code` | 条件必填 | Group / Allotment 查询 key | | `confirmation_number` | 条件必填 | FIT Confirmation Number 查询 key | | `reservation_no` | 条件必填 | OPERA reservation no;当前系统无可靠表源,只传该字段时会返回人工复核原因 | @@ -145,7 +144,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 `group_code`、`confirmation_number`、`reservation_no` 至少一个非空。当前稳定查询能力优先支持 `group_code` 和 `confirmation_number`。 -全局上下文查询只依赖 `hotel_id + 业务 key`;`source_message_id` 和 `source_event_index` 不作为查询边界,传入时也不会影响查询结果。 +全局上下文查询最终依赖“后端解析出的酒店 ID + 业务 key”;`source_message_id` 和 `source_event_index` 不作为查询边界,传入时也不会影响查询结果。 ### 4.3 成功响应 @@ -205,7 +204,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "object_id": "ORDER:1900000000000000100", "object_type": "group_block" } @@ -215,7 +213,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `hotel_id` | 是 | 酒店上下文 ID | +| `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,后端解析系统酒店。 | | `object_id` | 是 | 查询对象 ID,第一版只支持 `ORDER:{order_id}` | | `object_type` | 否 | 调用方对象类型提示,第一版不作为强校验 | @@ -285,7 +283,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "source_provider": "AGENTBUS", "source_channel": "EMAIL", "external_conversation_id": "thread-20260708-0001" @@ -296,7 +293,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "source_provider": "AGENTBUS", "source_channel": "EMAIL", "source_message_id": "mail-20260708-0001" @@ -307,7 +303,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `hotel_id` | 是 | 酒店上下文 ID | +| `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,后端解析系统酒店。 | | `source_provider` | 否 | 来源提供方,按会话 ID 查询和按 `source_message_id` 反查时都参与隔离,缺省为 `AGENTBUS` | | `source_channel` | 否 | 来源渠道,按会话 ID 查询和按 `source_message_id` 反查时都参与隔离,缺省为 `EMAIL` | | `external_conversation_id` | 条件必填 | 外部邮件会话 ID,对应 AgentBus `source.external_conversation_id` | @@ -383,7 +379,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "source_provider": "AGENTBUS", "source_channel": "EMAIL", "source_message_id": "mail-20260708-0001" @@ -444,7 +439,6 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 ```json { - "hotel_id": "", "source_message_id": "mail-20260708-0001", "ai_task_results": [ { @@ -479,7 +473,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | 字段 | 是否必填 | 中文说明 | | --- | --- | --- | -| `hotel_id` | 是 | 酒店上下文 ID,用于反查 SourceMessage Inbox 幂等键 | +| `hotel_id` | 否 | 酒店上下文 ID;SuperAgent 默认不传,后端解析系统酒店后用于反查 SourceMessage Inbox 幂等键。若兼容旧契约传入,单酒店阶段必须与系统酒店一致。 | | `source_message_id` | 是 | SuperAgent / Main Agent 原样带回的外部来源消息 ID,对应 AgentBus `source.external_message_id`,一次请求只能有一个 | | `source_provider` | 否 | 来源提供方,第一版缺省为 `AGENTBUS` | | `source_channel` | 否 | 来源渠道,第一版缺省为 `EMAIL` | @@ -493,7 +487,7 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | | `ai_task_results[].extracted_fields` | 否 | 业务字段主体 | -正式联调时,后端通过 `hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id` 查找 `platform_source_message_inbox.external_message_id`。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 +正式联调时,SuperAgent 不需要传 `hotel_id`。后端通过系统酒店 `hotel_id + source_provider(默认 AGENTBUS) + source_channel(默认 EMAIL) + source_message_id` 查找 `platform_source_message_inbox.external_message_id`。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 `informational_message` 结构化任务仅用于历史兼容。新入口如果是纯信息类邮件或无法形成业务素材包,不要提交空数组,也不要生成 `informational_message`;应使用下面的 S000/S999 文本请求体。 @@ -519,7 +513,7 @@ S999,mail-20260708-0001 | `S999` | 入口阶段无法形成业务素材包,不需要进入业务执行。 | | `mail-20260708-0001` | 外部来源消息 ID,对应 SourceMessage Inbox 的 `external_message_id`。 | -第一版 S000/S999 不在 body 里传 `hotel_id`,后端使用系统默认酒店 `AGENTBUS_DEFAULT_HOTEL_ID` 查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。 +第一版 S000/S999 不在 body 里传 `hotel_id`,后端使用平台酒店表唯一 `ACTIVE` 酒店查询 SourceMessage Inbox。命中后创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务:任务列表可见,订单列表不可见,不允许编辑、确认、转换订单、执行 OPERA 或重试 OPERA,也不参与同订单任务执行顺序阻塞。 ### 8.4 成功响应 @@ -621,7 +615,9 @@ S000/S999 成功响应示例: | `MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED` | 400 | 会话查询缺少 `external_conversation_id` 或 `source_message_id` | | `OBJECT_NOT_FOUND` | 404 | 对象详情查询目标不存在 | | `MESSAGE_CONVERSATION_NOT_FOUND` | 404 | 外部邮件会话尚未写入 SourceMessage Inbox | -| `HOTEL_ID_REQUIRED` | 400 | 查询接口和 JSON 任务结果缺少必填 `hotel_id`;S000/S999 使用系统默认酒店 | +| `SYSTEM_HOTEL_NOT_CONFIGURED` | 409 | 平台酒店表没有可用 `ACTIVE` 酒店 | +| `SYSTEM_HOTEL_AMBIGUOUS` | 409 | 单酒店阶段平台酒店表存在多家 `ACTIVE` 酒店 | +| `HOTEL_ACCESS_DENIED` | 403 | 显式传入的 `hotel_id` 与系统酒店或当前用户授权酒店不一致 | | `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | ## 10. HMAC 上线配置 diff --git a/docs/project/integrations/superagent-mcp/test-cases.md b/docs/project/integrations/superagent-mcp/test-cases.md index aaa883d..80f687b 100644 --- a/docs/project/integrations/superagent-mcp/test-cases.md +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -32,10 +32,11 @@ | 用例 ID | 场景 | 输入要点 | 期望结果 | | --- | --- | --- | --- | -| MCP-T01-001 | 按 group code 查询 | `hotel_id` + `group_code` | 返回成功 envelope | -| MCP-T01-002 | 按 confirmation 查询 | `hotel_id` + `confirmation_number` | 返回成功 envelope | -| MCP-T01-003 | 缺少查询 key | 只有 `hotel_id` | 返回 `QUERY_KEY_REQUIRED` | -| MCP-T01-004 | 缺少 hotel id | 不传 `hotel_id` | 返回 `HOTEL_ID_REQUIRED` | +| MCP-T01-001 | 按 group code 查询 | `group_code`,`hotel_id` 可选 | 返回成功 envelope | +| MCP-T01-002 | 按 confirmation 查询 | `confirmation_number`,`hotel_id` 可选 | 返回成功 envelope | +| MCP-T01-003 | 缺少查询 key | 不传 `group_code` 和 `confirmation_number` | 返回 `QUERY_KEY_REQUIRED` | +| MCP-T01-004 | 缺省 hotel id | 不传 `hotel_id`,传 `group_code` | 后端按系统唯一 ACTIVE 酒店查询并返回成功 envelope | +| MCP-T01-005 | hotel id 不一致 | 传非系统酒店 `hotel_id` | 返回 `HOTEL_ACCESS_DENIED` 或 `HOTEL_ID_MISMATCH` | ## 5. th_hotel_query_object_detail @@ -87,8 +88,9 @@ | MCP-T05-002 | 提交 manual review | `result_type=manual_review` | 写入人工复核任务 | | MCP-T05-003 | 提交 informational message | `result_type=informational_message` | 写入提示类信息 | | MCP-T05-004 | source message 不存在 | 不存在的外部 `source_message_id` | 返回 `SOURCE_MESSAGE_NOT_FOUND` | -| MCP-T05-005 | 缺少 hotel id | 不传 `hotel_id` | 返回 `HOTEL_ID_REQUIRED` | +| MCP-T05-005 | 缺省 hotel id | 不传 `hotel_id`,source message 属于系统酒店 | 后端按系统唯一 ACTIVE 酒店写入成功 | | MCP-T05-006 | 重复提交同一幂等任务 | 使用相同幂等信息 | 不重复创建业务任务 | +| MCP-T05-007 | hotel id 不一致 | 显式传非系统酒店 `hotel_id` | 返回 `HOTEL_ID_MISMATCH` | 写入验证: diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md index a1bd26b..99041e7 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -17,6 +17,7 @@ | 字段 | 默认值 | 中文说明 | | --- | --- | --- | +| `hotel_id` | 后端解析 | SuperAgent 默认不传;单酒店阶段由 TH Hotel 后端从 `platform_hotel` 唯一 `ACTIVE` 酒店解析,兼容旧调用传入时必须与系统酒店一致。 | | `source_provider` | `AGENTBUS` | 来源提供方 | | `source_channel` | `EMAIL` | 来源渠道 | @@ -65,8 +66,8 @@ "additionalProperties": false, "properties": { "hotel_id": { - "type": "string", - "description": "酒店上下文 ID" + "type": ["string", "null"], + "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" }, "group_code": { "type": ["string", "null"], @@ -93,7 +94,7 @@ "description": "历史线程 key 是否仅作为证据" } }, - "required": ["hotel_id"] + "required": [] } ``` @@ -135,8 +136,8 @@ POST /api/ai-query/v1/case-context "additionalProperties": false, "properties": { "hotel_id": { - "type": "string", - "description": "酒店上下文 ID" + "type": ["string", "null"], + "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" }, "object_id": { "type": "string", @@ -147,7 +148,7 @@ POST /api/ai-query/v1/case-context "description": "调用方对象类型提示" } }, - "required": ["hotel_id", "object_id"] + "required": ["object_id"] } ``` @@ -193,8 +194,8 @@ SuperAgent 提交任务结果时的外部 `source_message_id`。 "additionalProperties": false, "properties": { "hotel_id": { - "type": "string", - "description": "酒店上下文 ID" + "type": ["string", "null"], + "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" }, "source_provider": { "type": ["string", "null"], @@ -213,7 +214,7 @@ SuperAgent 提交任务结果时的外部 `source_message_id`。 "description": "外部来源消息 ID,可作为锚点反查会话" } }, - "required": ["hotel_id"] + "required": [] } ``` @@ -221,7 +222,7 @@ SuperAgent 提交任务结果时的外部 `source_message_id`。 - `external_conversation_id`、`source_message_id` 至少一个非空。 - 两者同时传入时,后端按 `external_conversation_id` 查询为准。 -- 查询按 `hotel_id + source_provider + source_channel + external_conversation_id` 隔离。 +- 查询最终按“后端解析出的酒店 ID + source_provider + source_channel + external_conversation_id”隔离。 ### 5.4 输出 @@ -282,8 +283,8 @@ POST /api/ai-query/v1/message-conversation/tasks "additionalProperties": false, "properties": { "hotel_id": { - "type": "string", - "description": "酒店上下文 ID" + "type": ["string", "null"], + "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" }, "source_provider": { "type": ["string", "null"], @@ -302,7 +303,7 @@ POST /api/ai-query/v1/message-conversation/tasks "description": "外部来源消息 ID,可作为锚点反查会话" } }, - "required": ["hotel_id"] + "required": [] } ``` @@ -334,14 +335,14 @@ POST /api/ai-query/v1/message-conversation/messages ### 7.2 何时使用 - SuperAgent 已完成当前邮件的最终任务拆分。 -- 已确认 `hotel_id` 和外部 `source_message_id` 来自 AgentBus payload。 +- 已确认外部 `source_message_id` 来自 AgentBus payload;`hotel_id` 由 TH Hotel 后端解析。 - 需要把 AI 任务结果交给 TH Hotel 后端进入人工确认流程。 ### 7.3 不应使用 - 不应在试探、草稿、未完成推理阶段调用。 - 不应把历史邮件中的外部来源消息 ID 当作当前邮件 ID 提交。 -- 不应在缺少 `hotel_id` 或 `source_message_id` 时调用。 +- 不应在缺少 `source_message_id` 时调用;`hotel_id` 不需要 SuperAgent 提供。 ### 7.4 输入 Schema @@ -351,8 +352,8 @@ POST /api/ai-query/v1/message-conversation/messages "additionalProperties": false, "properties": { "hotel_id": { - "type": "string", - "description": "酒店上下文 ID" + "type": ["string", "null"], + "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" }, "source_provider": { "type": ["string", "null"], @@ -381,7 +382,7 @@ POST /api/ai-query/v1/message-conversation/messages } } }, - "required": ["hotel_id", "source_message_id", "ai_task_results"] + "required": ["source_message_id", "ai_task_results"] } ``` diff --git a/docs/project/requirements/M002-ai-query-minimal-fields.md b/docs/project/requirements/M002-ai-query-minimal-fields.md index dfa6120..41046ff 100644 --- a/docs/project/requirements/M002-ai-query-minimal-fields.md +++ b/docs/project/requirements/M002-ai-query-minimal-fields.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | +| 文档版本 | 0.3 | | 日期 | 2026-07-08 | | 状态 | 第一版后端实现依据与落地记录 | | 适用范围 | SuperAgent / Main Agent 调用本系统查询订单和任务上下文 | @@ -33,7 +33,7 @@ - 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence` 和 `body_thread_used_only_as_evidence=true`。 - 第一版不伪造 OPERA 字段。当前系统没有可靠来源的字段返回 `null`,并在 `warnings` 或 `hard_validation_warnings` 中说明。 - SuperAgent 允许作为全局上下文查询方按任意业务 key 查询;`source_message_id` 和 `source_event_index` 在查询阶段对本系统没有业务作用,第一版接收但忽略,不做格式校验,也不作为查询边界。 -- 导入契约没有显式要求 `hotel_id`,但本系统订单、任务和 AI 过渡表均按 `hotel_id` 隔离。第一版请求体必须显式传 `hotel_id`。 +- 导入契约没有显式要求 `hotel_id`,本系统订单、任务和 AI 过渡表仍按 `hotel_id` 隔离。M005 后 SuperAgent 默认不传 `hotel_id`,后端按平台酒店表唯一 `ACTIVE` 酒店解析;兼容旧调用传入时必须与系统酒店一致。 ## 3. Skill 对接口 1、2 的实际需要 @@ -146,7 +146,7 @@ POST /api/ai-query/v1/case-context | 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | | --- | --- | --- | --- | -| `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 | +| `hotel_id` | 否 | 酒店或业务上下文 ID | SuperAgent 默认不传;后端解析系统酒店后用于隔离 `workflow_reservation_*` 表 | | `source_message_id` | 否 | SuperAgent 透传的外部来源消息 ID | 全局上下文查询可不传;传入时后端接收但忽略,不做格式校验 | | `source_event_index` | 否 | SuperAgent 透传的 current 事件序号 | 全局上下文查询可不传;传入时后端接收但忽略,不做正整数校验 | | `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 | @@ -321,7 +321,7 @@ POST /api/ai-query/v1/object-detail | 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | | --- | --- | --- | --- | -| `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离订单和任务 | +| `hotel_id` | 否 | 酒店或业务上下文 ID | SuperAgent 默认不传;后端解析系统酒店后用于隔离订单和任务 | | `object_id` | 是 | 接口 1 返回的对象 ID | 第一版支持 `ORDER:{order_id}` | | `object_type` | 否 | 对象类型提示 | 用于校验调用方预期和实际对象类型 | @@ -425,8 +425,8 @@ POST /api/ai-query/v1/object-detail | 能力 | 当前来源 | | --- | --- | -| 按 `hotel_id + GROUP_CODE` 查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | -| 按 `hotel_id + CONFIRMATION_NUMBER` 查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | +| 按“后端解析出的酒店 ID + GROUP_CODE”查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | +| 按“后端解析出的酒店 ID + CONFIRMATION_NUMBER”查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | | 查询临时订单、终止订单、逻辑删除订单 | `workflow_reservation_order.order_status` | | 查询同订单任务队列 | `workflow_reservation_task.order_id`、`queue_participation`、`execution_order` | | 查询 pending/open task | `workflow_reservation_task.task_status` | @@ -463,7 +463,7 @@ POST /api/ai-query/v1/object-detail 已落地能力: -- 接口 1 可按 `hotel_id + group_code` 或 `hotel_id + confirmation_number` 查询订单上下文,允许不传 `source_message_id` 和 `source_event_index` 的全局上下文查询;即使传入这两个字段,后端也不把它们作为查询或校验条件。 +- 接口 1 可按“后端解析出的酒店 ID + group_code”或“后端解析出的酒店 ID + confirmation_number”查询订单上下文,允许不传 `source_message_id` 和 `source_event_index` 的全局上下文查询;即使传入这两个字段,后端也不把它们作为查询或校验条件。 - 接口 1 返回 `matched_order_records`、`pending_or_open_tasks`、`active_workflows`、`terminated_records`、`target_object_validation` 和 `key_relationships`。 - `active_workflows` 当前无独立表源,固定返回空数组。 - 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。 diff --git a/docs/project/requirements/M002-superagent-task-result-api-contract.md b/docs/project/requirements/M002-superagent-task-result-api-contract.md index cf73d21..1d76de8 100644 --- a/docs/project/requirements/M002-superagent-task-result-api-contract.md +++ b/docs/project/requirements/M002-superagent-task-result-api-contract.md @@ -444,7 +444,9 @@ S000 / S999 文本结果创建成功时,同样返回 `201 Created`。这类结 | 409 | `AUTH_NONCE_REPLAY` | Nonce 重放 | | 413 | `REQUEST_BODY_TOO_LARGE` | 请求体过大 | | 400 | `INVALID_JSON` | JSON 不可解析 | -| 400 | `HOTEL_ID_REQUIRED` | 使用外部 `source_message_id` 时缺少 `hotel_id` | +| 400 | `HOTEL_ID_MISMATCH` | 显式 `hotel_id` 或历史内部 SourceMessage ID 所属酒店与系统酒店不一致 | +| 409 | `SYSTEM_HOTEL_NOT_CONFIGURED` | 平台酒店表没有 ACTIVE 酒店,无法解析系统酒店 | +| 409 | `SYSTEM_HOTEL_AMBIGUOUS` | 单酒店阶段平台酒店表存在多家 ACTIVE 酒店 | | 400 | `SOURCE_MESSAGE_REQUIRED` | `source_message_id` 缺失 | | 404 | `SOURCE_MESSAGE_NOT_FOUND` | 外部来源消息尚未写入 SourceMessage Inbox | | 400 | `TASK_RESULTS_EMPTY` | `ai_task_results[]` 为空 | diff --git a/docs/project/requirements/M005-hotel-context-unification-plan.md b/docs/project/requirements/M005-hotel-context-unification-plan.md new file mode 100644 index 0000000..6364b00 --- /dev/null +++ b/docs/project/requirements/M005-hotel-context-unification-plan.md @@ -0,0 +1,391 @@ +# M005 Hotel Context 统一收口改造计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-10 | +| 状态 | 已落地第一版:单酒店阶段严格要求 `platform_hotel` 必须且只能有一家 `ACTIVE` 酒店 | +| 适用范围 | `hotel_id` 来源、用户酒店上下文、SuperAgent / MCP、AgentBus、Reservation 查询、SourceMessage 查询、Debug / Demo 入口 | +| 主要读者 | 产品、后端、前端、测试、后续协作 agent | + +## 1. 文档定位 + +本文记录当前系统 `hotel_id` 使用方式的统一收口方案。目标不是删除业务表里的 +`hotel_id`,而是把运行时 `hotel_id` 的来源从“外部系统传入、前端环境变量、后端硬编码和配置默认值混用” +调整为“后端从平台酒店表和当前用户上下文解析”。 + +当前已明确: + +- SuperAgent 不会传 `hotel_id`。 +- AgentBus 不会传 `hotel_id`。 +- 本系统上线前暂时只服务一家酒店。 +- 后端已经有平台酒店表 `platform_hotel` 和用户酒店授权表 `platform_user_hotel`。 +- 登录态第一版采用数据库 session token,不使用 JWT;token 字符串本身不携带酒店 ID。 +- 单酒店阶段采用严格规则:`platform_hotel` 没有 `ACTIVE` 酒店时报错,多家 `ACTIVE` 酒店也报错;数据库通过唯一约束阻止新增第二家 `ACTIVE` 酒店。 + +因此,后续业务入口不应再要求 SuperAgent、AgentBus 或前端环境变量作为酒店上下文事实来源。 +系统内部仍然必须保留 `hotel_id`,用于数据隔离、幂等键、查询过滤、审计和未来多酒店扩展。 + +## 2. 核心结论 + +这次想法与现有系统没有根本冲突,可以实现,但需要把现有几个来源不一致的入口统一改造。 + +关键判断如下: + +- `hotel_id` 字段继续保留在 SourceMessage、Reservation、Task、OPERA、审计等业务表中。 +- 外部系统不传 `hotel_id` 是合理的,酒店归属应由本系统后端根据平台酒店表解析。 +- token 不是 JWT,不会把 `hotel_id` 放在 token 字符串里;后端可通过 session token 解析出 + `AuthenticatedUserContext.defaultHotelId` 和 `accessibleHotelIds`。 +- 前端展示酒店已经可以从 `/api/auth/me` 返回的 `hotels[]` 和 `default_hotel_id` 获取;Reservation 查询默认不再依赖 `VITE_RESERVATION_HOTEL_ID` 自动拼查询参数。 +- 任务详情、任务写操作和 OPERA 操作当前多处已从任务自身读取 `task.hotelId()`,这类逻辑方向正确, + 后续重点是补权限校验和查询隔离,不需要前端再传 `hotel_id`。 + +## 3. 非目标范围 + +本次 M005 不做以下事情: + +- 不删除任何业务表中的 `hotel_id` 字段。 +- 不把 token 改成 JWT。 +- 不实现完整多酒店路由策略,例如按邮箱、AgentBus channel、SuperAgent org 或 OHIP 配置自动映射酒店。 +- 不新增酒店管理后台 CRUD。 +- 不改变 SuperAgent 和 AgentBus 的系统定位:AgentBus 仍是消息入口适配器,SuperAgent 仍是 AI / Agent 能力提供方。 +- 不让前端直接调用数据库、SuperAgent、AgentBus 或任何持有 Secret 的外部系统。 + +## 4. 当前现状梳理 + +| 入口或模块 | 当前 `hotel_id` 来源 | 问题 | 目标方向 | +| --- | --- | --- | --- | +| AgentBus 入站捕获 | 改造前使用 `agentbus.capture.default-hotel-id`,代码默认 `HOTEL-TEST` | AgentBus 不会传酒店,配置默认值和平台酒店表割裂 | 已改为从平台酒店表解析系统酒店 | +| SuperAgent 任务结果 REST | 改造前 JSON 正式请求要求 `hotel_id`;S000 / S999 文本请求使用 AgentBus 默认酒店 | SuperAgent 不会传酒店,正式契约与真实能力冲突 | 已支持缺省 `hotel_id` 使用系统酒店,再用 SourceMessage 自身酒店写业务表 | +| SuperAgent MCP 查询工具 | 改造前 tools schema 要求 `hotel_id` | SuperAgent MCP 配置页面和调用方不应承担酒店上下文 | 已将 `hotel_id` 改为可选,服务端解析系统酒店 | +| Reservation AI 查询服务 | 改造前 `ReservationAiQueryServiceImpl` 校验 `hotel_id` 必填 | 机器查询入口无法在无酒店参数时工作 | 已统一通过酒店上下文服务解析 | +| 前端 Reservation 列表 / 详情 | 改造前 `VITE_RESERVATION_HOTEL_ID` 自动拼 `hotel_id` | 前端环境变量成为业务事实来源,容易和平台酒店表不一致 | 已改为默认不传;如有当前选中酒店才传,后端校验 | +| 任务详情 / 任务写操作 / OPERA | 先按 taskId 找任务,再使用 `task.hotelId()` | 方向正确,但后续需要当前用户可访问酒店校验 | 保持从业务对象自身取酒店,并补权限边界 | +| SourceMessage 列表 | 改造前 `hotelId` 查询参数可选;为空时仓储不加酒店过滤 | 单酒店阶段可能还能工作,未来会有跨酒店暴露风险 | 已默认解析当前或系统酒店,不允许无边界列表 | +| SourceMessage 会话详情 | 先按内部 SourceMessage ID 找源消息,再用 `source.hotelId()` 查同会话 | 方向正确 | 保持源消息自身酒店上下文 | +| Debug EML | 改造前 multipart 必填 `hotel_id` | 测试人员需要手填,和平台酒店表割裂 | 已改为可选,缺省使用系统酒店,显式传值必须校验 | +| Demo seed | 改造前请求可传 `hotel_id`,否则使用 demo 配置默认值,默认 `HOTEL-TEST` | 演示数据入口也有独立默认来源 | 已改为系统酒店,返回入口 URL 不再强依赖手写酒店参数 | +| 登录 / 当前用户 | `/api/auth/me` 已返回 `default_hotel_id` 和 `hotels[]` | 后端业务接口尚未统一消费当前用户酒店上下文 | 作为用户请求的酒店上下文来源 | + +## 5. 目标酒店上下文模型 + +建议新增一个平台级服务,统一承担酒店上下文解析。命名可在实现时二选一: + +```text +cn.nianxx.thhotel.platform.hotel.service.HotelContextService +cn.nianxx.thhotel.platform.hotel.service.impl.HotelContextServiceImpl +``` + +或: + +```text +cn.nianxx.thhotel.platform.hotel.service.HotelContextResolver +cn.nianxx.thhotel.platform.hotel.service.impl.HotelContextResolverImpl +``` + +职责建议: + +| 方法 | 使用场景 | 规则 | +| --- | --- | --- | +| `resolveSystemHotelId()` | AgentBus、SuperAgent、MCP、Debug、Demo 等机器入口 | 从 `platform_hotel` 解析当前系统酒店;单酒店阶段要求只有一个启用酒店,或存在明确默认酒店 | +| `resolveCurrentHotelId(String requestedHotelId)` | 前端用户查询入口 | 有 token 时优先校验请求酒店是否在 `accessibleHotelIds`;请求为空时使用 `defaultHotelId` | +| `requireAccessibleHotel(String hotelId)` | 后续写操作、详情操作、审计查询 | 当前用户必须可访问该酒店;无 token 的兼容策略需单独声明 | + +单酒店阶段建议使用严格规则: + +1. 如果平台酒店表没有启用酒店,启动或首次调用时返回明确错误,例如 `SYSTEM_HOTEL_NOT_CONFIGURED`。 +2. 如果平台酒店表存在多家启用酒店,但没有明确默认酒店,返回明确错误,例如 `SYSTEM_HOTEL_AMBIGUOUS`。 +3. 当前阶段不要继续用 `HOTEL-TEST` 作为运行时兜底值;`HOTEL-TEST` 只能保留在测试夹具、示例文档或 bootstrap 默认配置中。 + +## 6. 各入口目标规则 + +### 6.1 AgentBus 入站捕获 + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java` +- `server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java` +- `server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java` + +目标规则: + +- `AgentBusFrameProcessor` 不再从 `AgentBusProperties.capture.defaultHotelId` 取酒店。 +- 捕获前调用 `HotelContextService.resolveSystemHotelId()`。 +- `SourceMessageCaptureServiceImpl` 仍要求 `CaptureSourceMessageCommand.hotelId` 非空,因为落库必须有稳定酒店上下文。 +- `agentbus.capture.default-hotel-id` 后续可以废弃,或仅作为临时兼容配置,优先级低于平台酒店表。 + +### 6.2 SuperAgent 任务结果 REST + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java` + +目标规则: + +- JSON 正式请求不再要求 SuperAgent 传 `hotel_id`。 +- 当请求体没有 `hotel_id` 时,后端使用 `resolveSystemHotelId()` 查 SourceMessage。 +- 命中 SourceMessage 后,后续 Reservation 批次、订单、任务、审计都继续使用 `sourceMessage.hotelId()`,不直接信任外部请求体。 +- 如果外部请求体仍传了 `hotel_id`,单酒店阶段建议校验它必须等于系统酒店;不一致时返回明确错误,避免静默写错酒店。 +- 旧的“无 `hotel_id` 时按内部 SourceMessage ID 查询”的本地兼容路径应继续限制为 dev / test 或明确标注为非正式契约。 + +### 6.3 SuperAgent MCP + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java` +- `docs/project/integrations/superagent-mcp/tools.md` +- `docs/project/integrations/superagent-mcp/test-cases.md` + +目标规则: + +- MCP tools schema 中的 `hotel_id` 从 `required` 移除。 +- 查询类工具在参数缺少 `hotel_id` 时,由 MCP 服务端补入系统酒店。 +- 写入类工具 `submit_ai_task_results` 也不再要求 `hotel_id`,但仍必须要求 `source_message_id` 和结果列表。 +- 文档中应明确:SuperAgent 不需要知道酒店 ID;酒店上下文由 TH Hotel 后端托管。 +- 现有 `HOTEL_ID_REQUIRED` 的 MCP 测试用例要改成“不传 `hotel_id` 仍成功或进入业务查询缺失 key 错误”。 + +### 6.4 Reservation AI 查询服务 + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiObjectDetailQueryRequest.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationMessageConversationQueryRequest.java` + +目标规则: + +- `hotel_id` 在请求 DTO 中保留,但语义改为可选。 +- 进入查询服务时统一调用酒店上下文服务解析有效酒店 ID。 +- 查询仓储层继续显式按 `hotel_id` 过滤,保证业务数据隔离。 +- 对 SuperAgent / MCP 机器入口,缺省酒店来自系统酒店。 +- 对用户入口,缺省酒店来自当前用户默认酒店,显式酒店必须通过可访问酒店校验。 + +### 6.5 前端 Reservation 查询 + +当前代码位置: + +- `client/src/services/reservationService.ts` +- `client/src/config/reservationConfig.ts` +- `client/src/stores/authStore.ts` +- `client/src/layouts/ReservationAppShell.vue` + +目标规则: + +- 前端酒店展示以 `authStore.hotels`、`authStore.defaultHotelId`、`authStore.selectedHotelId` 为准。 +- Reservation 列表、任务列表、订单详情默认不再依赖 `VITE_RESERVATION_HOTEL_ID`。 +- 单酒店阶段可以默认不传 `hotel_id`,由后端按当前用户上下文解析。 +- 如果保留酒店选择器,前端传当前选中的 `selectedHotelId`,后端必须校验该酒店在当前用户可访问列表中。 +- `VITE_RESERVATION_HOTEL_ID` 后续只适合保留为 fixture / 本地兼容配置,不作为真实业务上下文。 + +### 6.6 前端 Reservation 查询 Controller + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java` + +目标规则: + +- Controller 可以继续接收可选 `hotel_id`,用于未来用户切换酒店。 +- Service 不再用 `DEFAULT_HOTEL_ID = "HOTEL-TEST"` 兜底。 +- Service 调用 `resolveCurrentHotelId(request.hotelId())` 得到有效酒店 ID。 +- 没有登录态的兼容行为需要明确:建议 dev / test 可回退系统酒店,生产逐步要求 token。 + +### 6.7 SourceMessage 查询 + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java` +- `server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java` +- `server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java` + +目标规则: + +- SourceMessage 列表接口不应在 `hotelId` 为空时返回跨酒店结果。 +- 列表接口缺省酒店时调用 `resolveCurrentHotelId(null)` 或 `resolveSystemHotelId()`,具体取决于是否要求登录。 +- 会话详情和原文读取已经能从 SourceMessage 自身获取 `source.hotelId()`,方向正确;后续只补当前用户是否可访问该酒店的校验。 + +### 6.8 Debug EML 与 Demo Seed + +当前代码位置: + +- `server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java` +- `server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java` +- `server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java` + +目标规则: + +- Debug EML 的 `hotel_id` 从必填改为可选。 +- dev / test 缺省时使用系统酒店。 +- 如果调试人员显式传 `hotel_id`,后端校验它必须存在于平台酒店表;生产不建议开放该覆盖能力。 +- Demo seed 缺省酒店改为系统酒店,不再有独立 `defaultHotelId = "HOTEL-TEST"` 运行时兜底。 + +## 7. 配置调整建议 + +| 配置 | 当前用途 | 调整建议 | +| --- | --- | --- | +| `AUTH_*_BOOTSTRAP_DEFAULT_HOTEL_ID` | 初始化平台酒店和管理员默认酒店 | 保留,用于 bootstrap,不作为每次请求的运行时兜底 | +| `AGENTBUS_*_DEFAULT_HOTEL_ID` | 旧 AgentBus fallback | 第一版运行时已不再使用,后续清理配置文件和环境模板 | +| `RESERVATION_*_DEMO_DATA_DEFAULT_HOTEL_ID` | 旧 Demo seed 默认酒店 | 第一版运行时已不再使用,后续清理配置文件和环境模板 | +| `VITE_RESERVATION_HOTEL_ID` | 前端 Reservation 默认酒店 | 从真实联调和生产配置中移除,保留 fixture / 本地兼容说明 | + +配置文件调整原则: + +- `application-test.yml` 和 `application-prod.yml` 不应继续写死运行时端口、数据库、酒店 ID 等会由环境覆盖的值。 +- 平台酒店初始化可以继续通过 bootstrap env 控制,但业务请求解析应查平台酒店表。 +- 如果系统酒店无法解析,应快速失败并给出明确错误,而不是默默回到 `HOTEL-TEST`。 + +## 8. 分阶段落地计划 + +### Phase 1:酒店上下文服务底座 + +修改范围: + +- 新增 `platform.hotel.service.HotelContextService`。 +- 新增 `platform.hotel.service.impl.HotelContextServiceImpl`。 +- 扩展 `PlatformHotelRepository`,支持解析启用酒店和默认酒店。 +- 为系统酒店解析、当前用户默认酒店、无酒店、多酒店歧义写单元测试。 + +验收标准: + +- 单启用酒店时可解析系统酒店。 +- 无启用酒店时报 `SYSTEM_HOTEL_NOT_CONFIGURED`。 +- 多启用酒店且无默认规则时报 `SYSTEM_HOTEL_AMBIGUOUS`。 +- 当前用户有默认酒店时,用户请求解析使用默认酒店。 +- 显式请求酒店不在当前用户可访问列表时拒绝。 + +### Phase 2:机器入口收口 + +修改范围: + +- AgentBus 捕获改用 `resolveSystemHotelId()`。 +- SuperAgent REST 入站缺省 `hotel_id` 时使用系统酒店。 +- MCP tools schema 移除 `hotel_id` required,并由服务端补酒店。 +- Reservation AI 查询服务把 `hotel_id` 必填校验改成上下文解析。 + +验收标准: + +- AgentBus 入站 frame 不带 `hotel_id` 也能入库 SourceMessage。 +- SuperAgent JSON 任务结果不带 `hotel_id` 也能通过外部 `source_message_id` 找到 SourceMessage。 +- MCP 查询工具不传 `hotel_id` 也能执行。 +- 业务写入最终使用 SourceMessage 自身 `hotelId`。 + +### Phase 3:用户入口和前端收口 + +修改范围: + +- Reservation 前端查询服务不再默认拼 `VITE_RESERVATION_HOTEL_ID`。 +- Reservation 后端查询服务移除 `DEFAULT_HOTEL_ID = "HOTEL-TEST"`。 +- SourceMessage 列表接口缺省酒店时按当前用户或系统酒店过滤。 +- 任务详情、任务写操作、OPERA 操作增加当前用户可访问酒店校验。 + +验收标准: + +- 登录后前端酒店名称来自 `/api/auth/me` 的 `hotels[]`。 +- Reservation 列表、任务列表、订单详情在不传 `hotel_id` 时也能按默认酒店查询。 +- 用户显式切换酒店时,后端校验酒店访问权限。 +- SourceMessage 列表不会因为缺少 `hotelId` 返回跨酒店数据。 + +### Phase 4:配置、文档和兼容清理 + +修改范围: + +- 更新 `README.md` 中 `VITE_RESERVATION_HOTEL_ID` 的说明。 +- 更新 SuperAgent API / MCP 文档,明确外部系统不需要传 `hotel_id`。 +- 更新 go-live notes,移除“正式 JSON 请求必须带 `hotel_id`”的上线要求。 +- 清理 `application-*.yml` 中和运行时默认酒店相关的硬编码。 +- 保留测试夹具里的 `HOTEL-TEST`,但标注为测试数据。 + +验收标准: + +- 文档与真实契约一致。 +- prod / test 启动依赖平台酒店表,不依赖散落默认酒店配置。 +- 搜索运行时代码时,不再存在作为兜底逻辑的 `DEFAULT_HOTEL_ID = "HOTEL-TEST"`。 + +## 9. 数据和迁移注意事项 + +上线前需要确认: + +- `platform_hotel` 中存在当前酒店的启用记录。 +- 单酒店阶段 `platform_hotel` 只能有一家 `ACTIVE` 酒店;迁移脚本 `V12__enforce_single_active_platform_hotel.sql` 会通过唯一索引阻止第二家 `ACTIVE` 酒店。 +- 现有 SourceMessage、Reservation、Task、OPERA、审计数据的 `hotel_id` 与 `platform_hotel.hotel_id` 一致。 +- 如果现有数据使用 `HOTEL-TEST`,而正式平台酒店 ID 不是 `HOTEL-TEST`,需要先设计数据迁移脚本。 +- 管理员用户在 `platform_user_hotel` 中有当前酒店默认授权,或超级管理员可访问全部启用酒店。 +- SuperAgent / MCP 文档和配置页面中的服务地址、鉴权 token、工具 schema 已同步更新。 + +建议先在测试环境执行检查: + +```sql +SELECT hotel_id, hotel_name, hotel_status, time_zone +FROM platform_hotel +ORDER BY sort_order, id; + +SELECT hotel_id, COUNT(*) AS source_message_count +FROM platform_source_message_inbox +GROUP BY hotel_id; + +SELECT hotel_id, COUNT(*) AS reservation_task_count +FROM workflow_reservation_task +GROUP BY hotel_id; +``` + +中文说明:第一条确认平台酒店表;第二条和第三条确认已有消息与任务数据的酒店 ID 是否和平台酒店表一致。 + +## 10. 测试建议 + +后端建议覆盖: + +- `HotelContextServiceImplTest` +- `AgentBusFrameProcessorTest` +- `SuperAgentTaskResultControllerTest` +- `ReservationAiTaskIntakeServiceImplTest` +- `SuperAgentMcpServiceImplTest` +- `ReservationAiQueryServiceImplTest` +- `ReservationFrontendQueryServiceImplTest` +- `SourceMessageController` 或相关查询服务测试 + +前端建议覆盖: + +- `reservationService.spec.ts`:不再默认拼 `VITE_RESERVATION_HOTEL_ID`,或改为使用当前选中酒店。 +- `authStore.spec.ts`:继续验证默认酒店和可访问酒店选择逻辑。 +- `reservationAppShell.spec.ts`:酒店展示来自登录上下文。 + +集成验收建议: + +- AgentBus frame 入站不带酒店字段,SourceMessage 落库有正确 `hotel_id`。 +- SuperAgent 任务结果 JSON 不带 `hotel_id`,仍能写入正确酒店下的订单和任务。 +- MCP `tools/call` 不带 `hotel_id`,仍可查询订单上下文。 +- 前端登录后不配置 `VITE_RESERVATION_HOTEL_ID`,订单列表和任务列表仍可查询。 +- 非授权用户访问其他酒店数据时被拒绝。 + +## 11. 当前决策和后置事项 + +已确认并落地: + +1. 单酒店阶段“系统酒店”采用严格规则:`platform_hotel` 必须且只能有一家 `ACTIVE` 酒店。 +2. 如果机器入口或前端兼容旧契约显式传入 `hotel_id`,后端严格校验它必须等于系统酒店或当前用户可访问酒店。 +3. SuperAgent、AgentBus、MCP tools 和 Debug EML 默认都不需要传 `hotel_id`。 +4. `HOTEL-TEST` 只能保留在测试夹具、示例文档或 bootstrap 默认配置中,不作为业务运行时兜底酒店。 + +后置事项: + +1. 生产环境是否强制 Reservation / SourceMessage 用户接口必须带登录 token,等权限拦截策略整体收口时再确认。 +2. 如果正式酒店 ID 不是 `HOTEL-TEST`,需要先迁移测试机或存量数据里的 `hotel_id`。 +3. `AGENTBUS_*_DEFAULT_HOTEL_ID`、`RESERVATION_*_DEMO_DATA_DEFAULT_HOTEL_ID` 等旧配置项后续可以从配置模板中清理;本次第一版先保证运行时代码不再依赖它们。 + +## 12. 最终验收口径 + +M005 完成后,应满足以下口径: + +- SuperAgent 和 AgentBus 不传 `hotel_id`,系统仍能正常入库、查询和写入 Reservation 任务。 +- 前端酒店展示来自平台酒店表和当前登录用户上下文。 +- 前端 Reservation 查询不依赖 `VITE_RESERVATION_HOTEL_ID` 作为真实业务上下文。 +- 后端所有查询和写入最终都带有明确 `hotel_id` 过滤或落库值。 +- `hotel_id` 的运行时来源统一为平台酒店表或当前用户上下文。 +- 运行时代码中不再出现 `HOTEL-TEST` 作为业务兜底酒店。 +- 文档、测试用例、SuperAgent MCP tools schema 与真实能力一致。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java index f9f1200..d8133b1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultController.java @@ -4,7 +4,7 @@ import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTas import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties; -import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusProperties; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; import java.nio.charset.StandardCharsets; @@ -29,7 +29,7 @@ public class SuperAgentTaskResultController { private final SuperAgentTaskResultSecurityService securityService; private final SuperAgentTaskResultProperties properties; - private final AgentBusProperties agentBusProperties; + private final HotelContextService hotelContextService; private final ReservationAiTaskIntakeService intakeService; /** @@ -38,11 +38,11 @@ public class SuperAgentTaskResultController { public SuperAgentTaskResultController( SuperAgentTaskResultSecurityService securityService, SuperAgentTaskResultProperties properties, - AgentBusProperties agentBusProperties, + HotelContextService hotelContextService, ReservationAiTaskIntakeService intakeService) { this.securityService = securityService; this.properties = properties; - this.agentBusProperties = agentBusProperties; + this.hotelContextService = hotelContextService; this.intakeService = intakeService; } @@ -74,7 +74,7 @@ public class SuperAgentTaskResultController { requestBody, clientId, requestId, - agentBusProperties.getCapture().getDefaultHotelId()); + hotelContextService.resolveSystemHotelId()); HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED; return ResponseEntity.status(status).body(response); } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java index d7adb77..8de7623 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/control/SuperAgentTaskResultControllerAdvice.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.integrations.ai.superagent.control; import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse; import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException; import java.util.List; import org.springframework.http.ResponseEntity; @@ -34,6 +35,16 @@ public class SuperAgentTaskResultControllerAdvice { .body(error(exception.getErrorCode(), exception.getMessage())); } + /** + * 处理系统酒店缺失、多 ACTIVE 酒店或酒店访问受限等上下文异常。 + */ + @ExceptionHandler(HotelContextException.class) + public ResponseEntity handleHotelContextException( + HotelContextException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(error(exception.getErrorCode(), exception.getMessage())); + } + /** * 构建统一错误响应,第一版不回显 request_id,避免异常路径暴露未经校验的外部输入。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java index eb7f7ab..844d767 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -8,6 +8,8 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcp import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolDefinition; import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult; import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest; @@ -48,6 +50,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { private final ReservationAiTaskIntakeService intakeService; private final SuperAgentMcpProperties properties; private final ObjectMapper objectMapper; + private final HotelContextService hotelContextService; /** * 注入已有业务服务和 JSON 工具,MCP 层不直接访问 Mapper 或数据库。 @@ -56,11 +59,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { ReservationAiQueryService aiQueryService, ReservationAiTaskIntakeService intakeService, SuperAgentMcpProperties properties, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + HotelContextService hotelContextService) { this.aiQueryService = aiQueryService; this.intakeService = intakeService; this.properties = properties; this.objectMapper = objectMapper; + this.hotelContextService = hotelContextService; } /** @@ -154,6 +159,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { exception.getErrorCode(), exception.getMessage(), Map.of("http_status", exception.getStatus().value()))); + } catch (HotelContextException exception) { + return SuperAgentMcpToolCallResult.error( + "TH Hotel 酒店上下文解析失败:" + exception.getMessage(), + errorStructuredContent( + exception.getErrorCode(), + exception.getMessage(), + Map.of("http_status", exception.getStatus().value()))); } catch (IllegalArgumentException exception) { return SuperAgentMcpToolCallResult.error( "MCP 工具参数不合法。", @@ -238,7 +250,11 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { Map.of("tool", TOOL_SUBMIT_TASK_RESULTS))); } String rawBody = objectMapper.writeValueAsString(arguments); - SuperAgentTaskResultResponse response = intakeService.accept(rawBody, MCP_CLIENT_ID, null); + SuperAgentTaskResultResponse response = intakeService.accept( + rawBody, + MCP_CLIENT_ID, + null, + hotelContextService.resolveSystemHotelId()); return SuperAgentMcpToolCallResult.success(TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", response); } @@ -311,37 +327,37 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { private Map caseContextSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); propertiesMap.put("group_code", nullableStringField("Group / Allotment 查询 key")); propertiesMap.put("confirmation_number", nullableStringField("FIT confirmation number 查询 key")); propertiesMap.put("reservation_no", nullableStringField("OPERA reservation no")); propertiesMap.put("object_type_hint", nullableStringField("调用方推测的对象类型")); propertiesMap.put("target_key_source", nullableStringField("key 来源,例如 body_current")); propertiesMap.put("body_thread_used_only_as_evidence", Map.of("type", "boolean", "description", "历史线程 key 是否仅作为证据")); - return objectSchema(propertiesMap, List.of("hotel_id")); + return objectSchema(propertiesMap, List.of()); } private Map objectDetailSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); propertiesMap.put("object_id", stringField("查询对象 ID,第一版支持 ORDER:{order_id}")); propertiesMap.put("object_type", nullableStringField("调用方对象类型提示")); - return objectSchema(propertiesMap, List.of("hotel_id", "object_id")); + return objectSchema(propertiesMap, List.of("object_id")); } private Map conversationQuerySchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS")); propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL")); propertiesMap.put("external_conversation_id", nullableStringField("外部邮件会话 ID")); propertiesMap.put("source_message_id", nullableStringField("外部来源消息 ID,可作为锚点反查会话")); - return objectSchema(propertiesMap, List.of("hotel_id")); + return objectSchema(propertiesMap, List.of()); } private Map submitTaskResultsSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS")); propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL")); propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id")); @@ -353,7 +369,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { "type", "array", "description", "AI 抽取警告", "items", Map.of("type", "object"))); - return objectSchema(propertiesMap, List.of("hotel_id", "source_message_id", "ai_task_results")); + return objectSchema(propertiesMap, List.of("source_message_id", "ai_task_results")); } private Map objectSchema(Map propertiesMap, List required) { diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java index f5ffff4..3002981 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java @@ -3,6 +3,7 @@ package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; 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 cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,6 +27,7 @@ public class AgentBusFrameProcessor { private final SourceMessageCaptureService captureService; private final AgentBusConnectionStatus status; private final AgentBusProperties properties; + private final HotelContextService hotelContextService; /** * 注入 AgentBus frame 处理依赖。外部协议转换与平台捕获服务通过稳定命令隔离。 @@ -35,12 +37,14 @@ public class AgentBusFrameProcessor { AgentBusSourceMessageAdapter sourceMessageAdapter, SourceMessageCaptureService captureService, AgentBusConnectionStatus status, - AgentBusProperties properties) { + AgentBusProperties properties, + HotelContextService hotelContextService) { this.objectMapper = objectMapper; this.sourceMessageAdapter = sourceMessageAdapter; this.captureService = captureService; this.status = status; this.properties = properties; + this.hotelContextService = hotelContextService; } /** @@ -69,8 +73,9 @@ public class AgentBusFrameProcessor { return AgentBusFrameProcessResult.ignored(); } try { + String hotelId = hotelContextService.resolveSystemHotelId(); CaptureSourceMessageCommand command = sourceMessageAdapter.toCaptureCommand( - properties.getCapture().getDefaultHotelId(), + hotelId, frame); SourceMessageCaptureResult result = captureService.capture(command); status.markFrameCaptured(); diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java index 343773b..b17deec 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusProperties.java @@ -123,8 +123,6 @@ public class AgentBusProperties { /** 是否把业务 frame 写入 SourceMessage Inbox。 */ private boolean enabled = true; - /** AgentBus 未提供酒店上下文时使用的默认酒店 ID。 */ - private String defaultHotelId = "HOTEL-TEST"; public boolean isEnabled() { return enabled; @@ -133,13 +131,5 @@ public class AgentBusProperties { public void setEnabled(boolean enabled) { this.enabled = enabled; } - - public String getDefaultHotelId() { - return defaultHotelId; - } - - public void setDefaultHotelId(String defaultHotelId) { - this.defaultHotelId = defaultHotelId; - } } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java index fe74f3b..ae168c7 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java @@ -37,7 +37,7 @@ public class DebugEmlSuperAgentController { public ResponseEntity upload( @RequestHeader(name = "X-TH-Hotel-Debug-Upload-Key", required = false) String accessKey, @RequestParam("file") MultipartFile file, - @RequestParam("hotel_id") String hotelId, + @RequestParam(name = "hotel_id", required = false) String hotelId, @RequestParam(name = "run_label", required = false) String runLabel) { return ResponseEntity.status(HttpStatus.CREATED).body(runService.uploadAndRun(accessKey, file, hotelId, runLabel)); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java index 86401b5..687067e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -18,6 +18,8 @@ import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlSuperAgentRunResul import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlUploadedMediaResult; import cn.nianxx.thhotel.platform.debug.repository.DebugEmlSuperAgentRunRepository; import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem; import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; @@ -76,6 +78,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe private final SuperAgentOpenApiClient superAgentOpenApiClient; private final DebugEmlSuperAgentRunRepository runRepository; private final ObjectMapper objectMapper; + private final HotelContextService hotelContextService; /** * 注入 Debug EML 所需的内部服务和外部端口。 @@ -89,7 +92,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe SourceMessageHtmlSanitizerService htmlSanitizerService, SuperAgentOpenApiClient superAgentOpenApiClient, DebugEmlSuperAgentRunRepository runRepository, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + HotelContextService hotelContextService) { this.properties = properties; this.ossProperties = ossProperties; this.parseService = parseService; @@ -99,6 +103,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe this.superAgentOpenApiClient = superAgentOpenApiClient; this.runRepository = runRepository; this.objectMapper = objectMapper; + this.hotelContextService = hotelContextService; } /** @@ -111,7 +116,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe String hotelId, String runLabel) { validateAccessKey(accessKey); - String normalizedHotelId = requireText(hotelId, "hotel_id"); + String normalizedHotelId = normalizeHotelId(hotelId); validateFile(file); byte[] emlBytes = readFileBytes(file); String safeFileName = safeFileName(file.getOriginalFilename(), "debug-email.eml"); @@ -161,6 +166,21 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe } } + /** + * 解析 Debug 上传酒店上下文。第一版可不传 hotel_id,由单酒店系统上下文兜底。 + */ + private String normalizeHotelId(String hotelId) { + try { + return hotelContextService.resolveCurrentHotelId(hotelId); + } catch (HotelContextException exception) { + throw new DebugEmlSuperAgentException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage(), + exception); + } + } + /** * 生成 SuperAgent 失败安全摘要,只记录错误类型,不保存响应 body、API Key、正文或附件 URL。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelContextException.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelContextException.java new file mode 100644 index 0000000..8007382 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelContextException.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.platform.hotel.service; + +import org.springframework.http.HttpStatus; + +/** + * 酒店上下文解析受控异常。用于把 hotel_id 来源不明确、系统酒店未配置和用户无权限转换为安全错误。 + */ +public class HotelContextException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public HotelContextException(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/hotel/service/HotelContextService.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelContextService.java new file mode 100644 index 0000000..99c9b87 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/HotelContextService.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.platform.hotel.service; + +/** + * 酒店上下文解析服务。统一收口机器入口和用户入口的运行时 hotel_id 来源。 + */ +public interface HotelContextService { + + /** + * 解析系统酒店 ID。单酒店阶段要求平台酒店表中必须且只能有一家 ACTIVE 酒店。 + */ + String resolveSystemHotelId(); + + /** + * 解析当前用户请求酒店 ID。已登录时校验用户授权;未登录兼容入口回退系统酒店。 + */ + String resolveCurrentHotelId(String requestedHotelId); + + /** + * 校验当前用户是否可访问指定酒店。未登录时按系统酒店兼容校验。 + */ + String requireAccessibleHotel(String hotelId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImpl.java new file mode 100644 index 0000000..0ff1027 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImpl.java @@ -0,0 +1,133 @@ +package cn.nianxx.thhotel.platform.hotel.service.impl; + +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** + * 酒店上下文解析服务实现。单酒店阶段以平台酒店表唯一 ACTIVE 酒店作为系统酒店事实来源。 + */ +@Service +public class HotelContextServiceImpl implements HotelContextService { + + private final PlatformHotelRepository hotelRepository; + private final CurrentUserContextService currentUserContextService; + + /** + * 注入平台酒店仓储和当前用户上下文服务,避免业务模块各自读取配置兜底酒店。 + */ + public HotelContextServiceImpl( + PlatformHotelRepository hotelRepository, + CurrentUserContextService currentUserContextService) { + this.hotelRepository = hotelRepository; + this.currentUserContextService = currentUserContextService; + } + + /** + * 解析系统酒店。单酒店阶段严格要求平台酒店表只有一家 ACTIVE 酒店。 + */ + @Override + public String resolveSystemHotelId() { + List activeHotels = hotelRepository.listActiveHotels(); + if (activeHotels == null || activeHotels.isEmpty()) { + throw new HotelContextException( + HttpStatus.CONFLICT, + "SYSTEM_HOTEL_NOT_CONFIGURED", + "系统酒店未配置,请先在平台酒店表配置一家 ACTIVE 酒店。"); + } + if (activeHotels.size() > 1) { + throw new HotelContextException( + HttpStatus.CONFLICT, + "SYSTEM_HOTEL_AMBIGUOUS", + "单酒店阶段只允许平台酒店表存在一家 ACTIVE 酒店。"); + } + String hotelId = trimToNull(activeHotels.get(0).getHotelId()); + if (hotelId == null) { + throw new HotelContextException( + HttpStatus.CONFLICT, + "SYSTEM_HOTEL_NOT_CONFIGURED", + "系统酒店 ID 为空,请检查平台酒店表。"); + } + return hotelId; + } + + /** + * 解析当前请求酒店。登录用户按授权酒店校验;未登录兼容入口按系统酒店解析并严格比对显式请求值。 + */ + @Override + public String resolveCurrentHotelId(String requestedHotelId) { + String normalizedRequestedHotelId = trimToNull(requestedHotelId); + return currentUserContextService.currentUser() + .map(context -> resolveUserHotelId(context, normalizedRequestedHotelId)) + .orElseGet(() -> resolveAnonymousHotelId(normalizedRequestedHotelId)); + } + + /** + * 要求当前请求可访问指定酒店。空入参走当前酒店解析,非空入参走授权校验。 + */ + @Override + public String requireAccessibleHotel(String hotelId) { + return resolveCurrentHotelId(hotelId); + } + + /** + * 已登录用户酒店解析。显式请求酒店必须在可访问列表内,缺省时使用默认酒店。 + */ + private String resolveUserHotelId(AuthenticatedUserContext context, String requestedHotelId) { + List accessibleHotelIds = context.accessibleHotelIds() == null + ? List.of() + : context.accessibleHotelIds(); + if (requestedHotelId != null) { + if (accessibleHotelIds.contains(requestedHotelId)) { + return requestedHotelId; + } + throw accessDenied(); + } + String defaultHotelId = trimToNull(context.defaultHotelId()); + if (defaultHotelId != null && accessibleHotelIds.contains(defaultHotelId)) { + return defaultHotelId; + } + return accessibleHotelIds.stream() + .filter(this::hasText) + .findFirst() + .orElseThrow(this::accessDenied); + } + + /** + * 未登录兼容入口酒店解析。请求未传酒店时使用系统酒店,显式酒店必须等于系统酒店。 + */ + private String resolveAnonymousHotelId(String requestedHotelId) { + String systemHotelId = resolveSystemHotelId(); + if (requestedHotelId == null || systemHotelId.equals(requestedHotelId)) { + return systemHotelId; + } + throw accessDenied(); + } + + /** + * 构造酒店访问拒绝异常,不回显用户或酒店授权细节。 + */ + private HotelContextException accessDenied() { + return new HotelContextException( + HttpStatus.FORBIDDEN, + "HOTEL_ACCESS_DENIED", + "当前用户无权访问该酒店。"); + } + + private boolean hasText(String value) { + return trimToNull(value) != null; + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} 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 new file mode 100644 index 0000000..4a424e5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerAdvice.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.platform.message.control; + +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * SourceMessage 查询接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。 + */ +@RestControllerAdvice(assignableTypes = SourceMessageController.class) +public class SourceMessageControllerAdvice { + + /** + * 处理酒店上下文解析失败,例如单酒店未配置、多 ACTIVE 酒店或当前用户无权限访问。 + */ + @ExceptionHandler(HotelContextException.class) + public ResponseEntity> handleHotelContextException(HotelContextException 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/message/service/impl/SourceMessageQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java index ab58d78..f1542ec 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.platform.message.service.impl; import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; @@ -24,12 +25,16 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService private static final int MAX_SAFE_KEYWORD_MATCHES = 500; private final SourceMessageInboxRepository inboxRepository; + private final HotelContextService hotelContextService; /** - * 注入 SourceMessage 持久化边界,查询服务不直接依赖 Mapper。 + * 注入 SourceMessage 持久化边界和酒店上下文服务,查询服务不直接依赖 Mapper。 */ - public SourceMessageQueryServiceImpl(SourceMessageInboxRepository inboxRepository) { + public SourceMessageQueryServiceImpl( + SourceMessageInboxRepository inboxRepository, + HotelContextService hotelContextService) { this.inboxRepository = inboxRepository; + this.hotelContextService = hotelContextService; } /** @@ -37,9 +42,10 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService */ @Override public SourceMessagePageResult query(SourceMessageQueryRequest request) { - int pageNum = normalizePageNum(request.pageNum()); - int pageSize = normalizePageSize(request.pageSize()); - SourceMessagePageResult page = inboxRepository.query(request, pageNum, pageSize); + SourceMessageQueryRequest normalizedRequest = normalizeRequest(request); + int pageNum = normalizePageNum(normalizedRequest.pageNum()); + int pageSize = normalizePageSize(normalizedRequest.pageSize()); + SourceMessagePageResult page = inboxRepository.query(normalizedRequest, pageNum, pageSize); List items = page.items().stream().map(this::toSummary).toList(); return new SourceMessagePageResult<>(items, page.total(), pageNum, pageSize); } @@ -49,7 +55,10 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService */ @Override public List findIdsBySafeKeyword(String hotelId, String keyword) { - return inboxRepository.findIdsBySafeKeyword(hotelId, keyword, MAX_SAFE_KEYWORD_MATCHES); + return inboxRepository.findIdsBySafeKeyword( + hotelContextService.resolveCurrentHotelId(hotelId), + keyword, + MAX_SAFE_KEYWORD_MATCHES); } /** @@ -78,7 +87,31 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService */ @Override public Map countByExternalConversationIds(String hotelId, List externalConversationIds) { - return inboxRepository.countByExternalConversationIds(hotelId, externalConversationIds); + return inboxRepository.countByExternalConversationIds( + hotelContextService.resolveCurrentHotelId(hotelId), + externalConversationIds); + } + + /** + * 标准化查询条件。hotel_id 可选,缺省时由当前用户或单酒店系统上下文解析。 + */ + private SourceMessageQueryRequest normalizeRequest(SourceMessageQueryRequest request) { + if (request == null) { + return new SourceMessageQueryRequest( + hotelContextService.resolveCurrentHotelId(null), + null, + null, + null, + null, + null); + } + return new SourceMessageQueryRequest( + hotelContextService.resolveCurrentHotelId(request.hotelId()), + request.externalMessageId(), + request.externalConversationId(), + request.captureStatus(), + request.pageNum(), + request.pageSize()); } /** diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java index fadc6f0..b50c670 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiTaskIntakeService.java @@ -13,7 +13,7 @@ public interface ReservationAiTaskIntakeService { SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId); /** - * 接收已通过鉴权的 SuperAgent 原始请求体;defaultHotelId 仅用于 S000/S999 文本结果反查 SourceMessage。 + * 接收已通过鉴权的 SuperAgent 原始请求体;defaultHotelId 用于外部 source_message_id 缺省 hotel_id 时反查 SourceMessage。 */ SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java index 01e33a8..9cdb393 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.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.HotelContextService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent; @@ -65,6 +67,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService private final ReservationAiWorkflowRepository repository; private final SourceMessageInboxRepository sourceMessageInboxRepository; private final SourceMessageHtmlSanitizerService htmlSanitizerService; + private final HotelContextService hotelContextService; /** * 注入 Reservation 工作流持久化边界、SourceMessage 持久化边界和 HTML 清洗服务。 @@ -72,10 +75,12 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService public ReservationAiQueryServiceImpl( ReservationAiWorkflowRepository repository, SourceMessageInboxRepository sourceMessageInboxRepository, - SourceMessageHtmlSanitizerService htmlSanitizerService) { + SourceMessageHtmlSanitizerService htmlSanitizerService, + HotelContextService hotelContextService) { this.repository = repository; this.sourceMessageInboxRepository = sourceMessageInboxRepository; this.htmlSanitizerService = htmlSanitizerService; + this.hotelContextService = hotelContextService; } /** @@ -84,7 +89,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService @Override public ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request) { validateCaseContextRequest(request); - String hotelId = trimToNull(request.hotelId()); + String hotelId = resolveHotelId(request.hotelId()); String groupCode = trimToNull(request.groupCode()); String confirmationNumber = trimToNull(request.confirmationNumber()); if (groupCode == null && confirmationNumber == null) { @@ -149,7 +154,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService @Override public ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request) { validateObjectDetailRequest(request); - String hotelId = trimToNull(request.hotelId()); + String hotelId = resolveHotelId(request.hotelId()); Long orderId = parseOrderObjectId(request.objectId()); ReservationAiQueryOrderSnapshot order = repository.findAiQueryOrderById(hotelId, orderId) .orElseThrow(() -> new ReservationAiQueryException( @@ -200,8 +205,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService public ReservationMessageConversationTasksResult queryMessageConversationTasks( ReservationMessageConversationQueryRequest request) { validateConversationRequest(request); - String hotelId = trimToNull(request.hotelId()); - List messages = findConversationMessages(request); + String hotelId = resolveHotelId(request.hotelId()); + List messages = findConversationMessages(request, hotelId); Map messageIndex = indexMessages(messages); List tasks = repository .findAiQueryTasksBySourceMessageIds(hotelId, messages.stream().map(SourceMessageInboxSnapshot::id).toList()) @@ -232,8 +237,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService public ReservationMessageConversationMessagesResult queryMessageConversationMessages( ReservationMessageConversationQueryRequest request) { validateConversationRequest(request); - String hotelId = trimToNull(request.hotelId()); - List messages = findConversationMessages(request); + String hotelId = resolveHotelId(request.hotelId()); + List messages = findConversationMessages(request, hotelId); List resultMessages = messages.stream() .map(this::toConversationMessage) .toList(); @@ -247,8 +252,9 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService /** * 根据会话 ID 或外部 SourceMessage ID 找到同一邮件链的全部消息。 */ - private List findConversationMessages(ReservationMessageConversationQueryRequest request) { - String hotelId = trimToNull(request.hotelId()); + private List findConversationMessages( + ReservationMessageConversationQueryRequest request, + String hotelId) { String externalConversationId = trimToNull(request.externalConversationId()); if (externalConversationId != null) { List messages = sourceMessageInboxRepository.findByExternalConversationId( @@ -547,7 +553,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService if (request == null) { throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); } - requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); if (trimToNull(request.groupCode()) == null && trimToNull(request.confirmationNumber()) == null && trimToNull(request.reservationNo()) == null) { @@ -559,7 +564,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService if (request == null) { throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); } - requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空"); } @@ -567,7 +571,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService if (request == null) { throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空"); } - requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空"); if (trimToNull(request.externalConversationId()) == null && trimToNull(request.sourceMessageId()) == null) { throw badRequest( "MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED", @@ -583,6 +586,20 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService return new ReservationAiQueryException(HttpStatus.NOT_FOUND, code, message); } + /** + * 解析 AI 查询酒店上下文。SuperAgent 可不传 hotel_id;如果传入,单酒店阶段必须与系统酒店一致。 + */ + private String resolveHotelId(String requestedHotelId) { + try { + return hotelContextService.requireAccessibleHotel(requestedHotelId); + } catch (HotelContextException exception) { + throw new ReservationAiQueryException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + private void requireText(String value, String code, String message) { if (trimToNull(value) == null) { throw badRequest(code, message); diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index 7a11b24..4947085 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -101,7 +101,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta requestBody); } JsonNode root = parseJson(rawBody); - ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root); + ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root, defaultHotelId); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); Long sourceMessageId = sourceMessage.id(); String responseSourceMessageId = resolvedSourceMessage.responseSourceMessageId(); @@ -743,16 +743,24 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** * 解析 SuperAgent 来源消息引用。正式契约使用外部邮件 ID,旧本地夹具仍兼容内部 SourceMessage ID。 */ - private ResolvedSourceMessage resolveSourceMessage(JsonNode root) { + private ResolvedSourceMessage resolveSourceMessage(JsonNode root, String defaultHotelId) { String sourceMessageReference = trimToNull(textAt(root, "source_message_id")); if (sourceMessageReference == null) { throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 缺失。"); } validateLength(sourceMessageReference, "source_message_id", LENGTH_256); - String hotelId = trimToNull(textAt(root, "hotel_id")); + String requestHotelId = trimToNull(textAt(root, "hotel_id")); + String systemHotelId = trimToNull(defaultHotelId); + if (requestHotelId != null && systemHotelId != null && !requestHotelId.equals(systemHotelId)) { + throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "请求 hotel_id 与系统酒店不一致。"); + } + if (requestHotelId == null && isLongText(sourceMessageReference)) { + return resolveLegacyInternalSourceMessage(sourceMessageReference, systemHotelId); + } + String hotelId = requestHotelId == null ? systemHotelId : requestHotelId; if (hotelId == null) { - return resolveLegacyInternalSourceMessage(sourceMessageReference); + return resolveLegacyInternalSourceMessage(sourceMessageReference, null); } validateLength(hotelId, "hotel_id", LENGTH_64); String sourceProvider = optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32); @@ -766,9 +774,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询。 + * 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询,但必须校验系统酒店边界。 */ - private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId) { + private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId, String expectedHotelId) { Long sourceMessageId; try { sourceMessageId = Long.valueOf(rawId); @@ -777,12 +785,27 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId) .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。")); + if (expectedHotelId != null && !expectedHotelId.equals(sourceMessage.hotelId())) { + throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "SourceMessage 所属酒店与系统酒店不一致。"); + } String responseSourceMessageId = trimToNull(sourceMessage.externalMessageId()) == null ? sourceMessageId.toString() : sourceMessage.externalMessageId(); return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId); } + /** + * 判断来源消息引用是否为历史本地兼容的内部 SourceMessage ID。 + */ + private boolean isLongText(String value) { + try { + Long.valueOf(value); + return true; + } catch (NumberFormatException exception) { + return false; + } + } + /** * 从对象节点中读取文本字段,缺失或 null 时返回 null。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java index e9c0e73..2bf0464 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataProperties.java @@ -14,8 +14,6 @@ public class ReservationDemoDataProperties { private boolean enabled = false; /** 演示数据 seed 访问口令。 */ private String accessKey = ""; - /** 演示数据默认酒店上下文。 */ - private String defaultHotelId = "HOTEL-TEST"; public boolean isEnabled() { return enabled; @@ -32,12 +30,4 @@ public class ReservationDemoDataProperties { public void setAccessKey(String accessKey) { this.accessKey = accessKey; } - - public String getDefaultHotelId() { - return defaultHotelId; - } - - public void setDefaultHotelId(String defaultHotelId) { - this.defaultHotelId = defaultHotelId; - } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java index 88228b0..d756814 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationDemoDataServiceImpl.java @@ -1,5 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; @@ -64,6 +66,7 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic private final SourceMessageCaptureService captureService; private final ReservationAiTaskIntakeService intakeService; private final ReservationTaskWorkflowService taskWorkflowService; + private final HotelContextService hotelContextService; /** * 注入演示配置、JSON 工具和已有业务服务,避免 seed 功能直接写表。 @@ -73,12 +76,14 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic ObjectMapper objectMapper, SourceMessageCaptureService captureService, ReservationAiTaskIntakeService intakeService, - ReservationTaskWorkflowService taskWorkflowService) { + ReservationTaskWorkflowService taskWorkflowService, + HotelContextService hotelContextService) { this.properties = properties; this.objectMapper = objectMapper; this.captureService = captureService; this.intakeService = intakeService; this.taskWorkflowService = taskWorkflowService; + this.hotelContextService = hotelContextService; } /** @@ -588,7 +593,6 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic * 生成前端可直接调用的查询入口。 */ private Map entrypoints(String hotelId, String demoRunId, SeedAccumulator accumulator) { - String encodedHotelId = urlEncode(hotelId); String encodedRunId = urlEncode(demoRunId); String queueOrderId = accumulator.orders().stream() .filter(order -> SCENARIO_QUEUE.equals(order.scenarioCode())) @@ -606,12 +610,12 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic .findFirst() .orElse(""); Map entrypoints = new LinkedHashMap<>(); - entrypoints.put("task_list_url", "/api/reservation/tasks?hotel_id=" + encodedHotelId - + "&keyword=" + encodedRunId + "&page_num=1&page_size=20"); - entrypoints.put("order_list_url", "/api/reservation/orders?hotel_id=" + encodedHotelId - + "&keyword=" + encodedRunId + "&page_num=1&page_size=20"); + entrypoints.put("task_list_url", "/api/reservation/tasks?keyword=" + encodedRunId + + "&page_num=1&page_size=20"); + entrypoints.put("order_list_url", "/api/reservation/orders?keyword=" + encodedRunId + + "&page_num=1&page_size=20"); entrypoints.put("queue_order_detail_url", "/api/reservation/orders/" + queueOrderId - + "?hotel_id=" + encodedHotelId + "&include_tasks=true&include_source_summary=true"); + + "?include_tasks=true&include_source_summary=true"); entrypoints.put("failed_task_detail_url", "/api/reservation/tasks/" + failedTaskId); entrypoints.put("source_conversation_url", "/api/source-messages/" + queueSourceMessageId + "/conversation"); return entrypoints; @@ -641,8 +645,14 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic * 标准化酒店上下文 ID。 */ private String normalizeHotelId(String rawHotelId) { - String hotelId = trimToNull(rawHotelId); - return hotelId == null ? properties.getDefaultHotelId() : hotelId; + try { + return hotelContextService.resolveCurrentHotelId(rawHotelId); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } } /** 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 d1a8a3e..7572a54 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 @@ -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.HotelContextService; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; 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.ReservationAiQueryOrderSnapshot; @@ -38,7 +40,6 @@ import org.springframework.transaction.annotation.Transactional; @Service public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQueryService { - private static final String DEFAULT_HOTEL_ID = "HOTEL-TEST"; private static final int DEFAULT_PAGE_NUM = 1; private static final int DEFAULT_PAGE_SIZE = 20; private static final int MAX_PAGE_SIZE = 100; @@ -50,6 +51,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ private final ReservationAiWorkflowRepository workflowRepository; private final SourceMessageQueryService sourceMessageQueryService; private final ReservationTaskAvailabilityResolver availabilityResolver; + private final HotelContextService hotelContextService; /** * 注入持久化边界、SourceMessage 安全摘要服务和可处理状态解析器。 @@ -57,10 +59,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ public ReservationFrontendQueryServiceImpl( ReservationAiWorkflowRepository workflowRepository, SourceMessageQueryService sourceMessageQueryService, - ReservationTaskAvailabilityResolver availabilityResolver) { + ReservationTaskAvailabilityResolver availabilityResolver, + HotelContextService hotelContextService) { this.workflowRepository = workflowRepository; this.sourceMessageQueryService = sourceMessageQueryService; this.availabilityResolver = availabilityResolver; + this.hotelContextService = hotelContextService; } /** @@ -177,7 +181,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ private ReservationTaskWorkbenchQueryRequest normalizeRequest(ReservationTaskWorkbenchQueryRequest request) { if (request == null) { return new ReservationTaskWorkbenchQueryRequest( - DEFAULT_HOTEL_ID, + normalizeHotelId(null), null, null, null, @@ -207,7 +211,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ private ReservationOrderListQueryRequest normalizeOrderListRequest(ReservationOrderListQueryRequest request) { if (request == null) { return new ReservationOrderListQueryRequest( - DEFAULT_HOTEL_ID, + normalizeHotelId(null), null, null, null, @@ -556,11 +560,17 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ } /** - * 标准化酒店 ID。第一版未接用户酒店上下文时使用本地默认酒店。 + * 标准化酒店 ID。前端可不传酒店,后端按当前用户上下文或单酒店系统上下文解析。 */ private String normalizeHotelId(String hotelId) { - String trimmedHotelId = trimToNull(hotelId); - return trimmedHotelId == null ? DEFAULT_HOTEL_ID : trimmedHotelId; + try { + return hotelContextService.resolveCurrentHotelId(hotelId); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } } /** diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index f810bec..b577e28 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -30,7 +30,6 @@ agentbus: connect-timeout: 15s capture: enabled: true - default-hotel-id: HOTEL-DEV superagent: task-result: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 236229a..2831b77 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -28,7 +28,6 @@ agentbus: connect-timeout: 15s capture: enabled: true - default-hotel-id: ${AGENTBUS_PROD_DEFAULT_HOTEL_ID} superagent: task-result: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 73b8c57..6a3fbae 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -30,7 +30,6 @@ agentbus: connect-timeout: 15s capture: enabled: true - default-hotel-id: HOTEL-TEST superagent: task-result: diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 4f4e15c..f35dd1f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -24,7 +24,6 @@ agentbus: max-frame-bytes: 1048576 capture: enabled: ${AGENTBUS_CAPTURE_ENABLED:true} - default-hotel-id: HOTEL-TEST superagent: task-result: diff --git a/server/src/main/resources/db/migration/V12__enforce_single_active_platform_hotel.sql b/server/src/main/resources/db/migration/V12__enforce_single_active_platform_hotel.sql new file mode 100644 index 0000000..01dd51d --- /dev/null +++ b/server/src/main/resources/db/migration/V12__enforce_single_active_platform_hotel.sql @@ -0,0 +1,10 @@ +-- M005 酒店上下文统一:单酒店阶段数据库级约束平台酒店最多只能有一家 ACTIVE。 +-- 上线前如已有多家 ACTIVE 酒店,应先人工确认保留哪一家,不能由迁移脚本静默禁用其他酒店。 +ALTER TABLE platform_hotel + ADD COLUMN active_hotel_singleton_key VARCHAR(16) GENERATED ALWAYS AS ( + CASE WHEN hotel_status = 'ACTIVE' THEN 'ACTIVE' ELSE NULL END + ); + +-- MySQL 唯一索引允许多个 NULL,因此只会限制 platform_hotel 最多一条 hotel_status='ACTIVE'。 +CREATE UNIQUE INDEX uk_platform_hotel_single_active + ON platform_hotel (active_hotel_singleton_key); diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java index d793bfb..f80a7f2 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java @@ -124,10 +124,14 @@ class SuperAgentMcpControllerTest { .andExpect(jsonPath("$.id").value("mcp-tools-001")) .andExpect(jsonPath("$.result.tools.length()").value(5)) .andExpect(jsonPath("$.result.tools[0].name").value("th_hotel_query_case_context")) + .andExpect(jsonPath("$.result.tools[0].inputSchema.required.length()").value(0)) .andExpect(jsonPath("$.result.tools[0].annotations.readOnlyHint").value(true)) .andExpect(jsonPath("$.result.tools[3].name").value("th_hotel_list_message_conversation_messages")) + .andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0)) .andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true)) .andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results")) .andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false)) .andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true)); } @@ -142,7 +146,6 @@ class SuperAgentMcpControllerTest { "params": { "name": "th_hotel_query_case_context", "arguments": { - "hotel_id": "HOTEL-TEST", "group_code": "GRP-MCP-NOT-FOUND" } } @@ -173,7 +176,6 @@ class SuperAgentMcpControllerTest { "params": { "name": "th_hotel_submit_task_results", "arguments": { - "hotel_id": "HOTEL-TEST", "source_message_id": "mail-mcp-disabled-001", "ai_task_results": [] } diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java index 0c66b8c..1ea2379 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java @@ -44,7 +44,6 @@ class SuperAgentMcpSubmitEnabledControllerTest { "params": { "name": "th_hotel_submit_task_results", "arguments": { - "hotel_id": "HOTEL-TEST", "source_message_id": "mail-mcp-enabled-missing-001", "ai_task_results": [ { diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java index 6437554..d437443 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.when; 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 cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -64,7 +65,7 @@ class AgentBusFrameProcessorTest { assertThat(status.snapshot().capturedFrameCount()).isEqualTo(1L); ArgumentCaptor captor = ArgumentCaptor.forClass(CaptureSourceMessageCommand.class); verify(captureService).capture(captor.capture()); - assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-TEST"); + assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-SYSTEM"); assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001"); assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001"); } @@ -107,18 +108,20 @@ class AgentBusFrameProcessorTest { SourceMessageCaptureService captureService, AgentBusConnectionStatus status, AgentBusProperties properties) { + HotelContextService hotelContextService = mock(HotelContextService.class); + when(hotelContextService.resolveSystemHotelId()).thenReturn("HOTEL-SYSTEM"); return new AgentBusFrameProcessor( objectMapper, new AgentBusSourceMessageAdapter(objectMapper), captureService, status, - properties); + properties, + hotelContextService); } private AgentBusProperties properties(boolean captureEnabled, int maxFrameBytes) { AgentBusProperties properties = new AgentBusProperties(); properties.getCapture().setEnabled(captureEnabled); - properties.getCapture().setDefaultHotelId("HOTEL-TEST"); properties.setMaxFrameBytes(maxFrameBytes); return properties; } diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java index d91f9c7..05be90f 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -192,6 +192,29 @@ class DebugEmlSuperAgentControllerTest { org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L); } + @Test + void shouldUseSystemHotelWhenDebugUploadOmitsHotelId() throws Exception { + mockStorageAndSuperAgentSuccess(); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("run_label", "system-hotel-debug-upload") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD")) + .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-"))); + + Long sourceCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_source_message_inbox inbox + JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id + WHERE inbox.hotel_id = 'HOTEL-TEST' + AND inbox.provider = 'DEBUG_EML_UPLOAD' + AND payload.payload_json LIKE '%system-hotel-debug-upload%' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L); + } + @Test void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception { mockStorageAndSuperAgentSuccess(); diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelSingleActiveConstraintTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelSingleActiveConstraintTest.java new file mode 100644 index 0000000..d704e15 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/repository/PlatformHotelSingleActiveConstraintTest.java @@ -0,0 +1,49 @@ +package cn.nianxx.thhotel.platform.hotel.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus; +import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m005_single_active_hotel;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=", + "auth.bootstrap.admin.password=", + "auth.bootstrap.default-hotel-id=" + }) +@ActiveProfiles("test") +class PlatformHotelSingleActiveConstraintTest { + + @Autowired + private PlatformHotelRepository hotelRepository; + + @Test + void shouldRejectSecondActiveHotelAtDatabaseLevel() { + hotelRepository.insertHotel(hotel("M005-ACTIVE-A", PlatformHotelStatus.ACTIVE)); + + assertThatThrownBy(() -> hotelRepository.insertHotel(hotel("M005-ACTIVE-B", PlatformHotelStatus.ACTIVE))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private PlatformHotelEntity hotel(String hotelId, PlatformHotelStatus status) { + LocalDateTime now = LocalDateTime.now(); + PlatformHotelEntity hotel = new PlatformHotelEntity(); + hotel.setHotelId(hotelId); + hotel.setHotelName(hotelId); + hotel.setHotelStatus(status.name()); + hotel.setTimeZone("Asia/Bangkok"); + hotel.setSortOrder(10); + hotel.setCreatedAt(now); + hotel.setUpdatedAt(now); + return hotel; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImplTest.java new file mode 100644 index 0000000..e4ac2b4 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/hotel/service/impl/HotelContextServiceImplTest.java @@ -0,0 +1,120 @@ +package cn.nianxx.thhotel.platform.hotel.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +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.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +class HotelContextServiceImplTest { + + @Mock + private PlatformHotelRepository hotelRepository; + @Mock + private CurrentUserContextService currentUserContextService; + + @Test + void shouldResolveOnlyActiveHotelAsSystemHotel() { + HotelContextServiceImpl service = service(); + when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-ONLY"))); + + assertThat(service.resolveSystemHotelId()).isEqualTo("HOTEL-ONLY"); + } + + @Test + void shouldRejectMissingActiveSystemHotel() { + HotelContextServiceImpl service = service(); + when(hotelRepository.listActiveHotels()).thenReturn(List.of()); + + assertThatThrownBy(service::resolveSystemHotelId) + .isInstanceOf(HotelContextException.class) + .extracting("status", "errorCode") + .containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_NOT_CONFIGURED"); + } + + @Test + void shouldRejectAmbiguousActiveSystemHotel() { + HotelContextServiceImpl service = service(); + when(hotelRepository.listActiveHotels()).thenReturn(List.of( + activeHotel("HOTEL-A"), + activeHotel("HOTEL-B"))); + + assertThatThrownBy(service::resolveSystemHotelId) + .isInstanceOf(HotelContextException.class) + .extracting("status", "errorCode") + .containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_AMBIGUOUS"); + } + + @Test + void shouldUseCurrentUserDefaultHotelWhenRequestHotelMissing() { + HotelContextServiceImpl service = service(); + when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext( + "HOTEL-USER-DEFAULT", + List.of("HOTEL-USER-DEFAULT", "HOTEL-OTHER")))); + + assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-USER-DEFAULT"); + } + + @Test + void shouldRejectRequestedHotelOutsideCurrentUserAccessList() { + HotelContextServiceImpl service = service(); + when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext( + "HOTEL-USER-DEFAULT", + List.of("HOTEL-USER-DEFAULT")))); + + assertThatThrownBy(() -> service.resolveCurrentHotelId("HOTEL-NOT-ALLOWED")) + .isInstanceOf(HotelContextException.class) + .extracting("status", "errorCode") + .containsExactly(HttpStatus.FORBIDDEN, "HOTEL_ACCESS_DENIED"); + } + + @Test + void shouldFallbackToSystemHotelWhenNoCurrentUser() { + HotelContextServiceImpl service = service(); + when(currentUserContextService.currentUser()).thenReturn(Optional.empty()); + when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-SYSTEM"))); + + assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-SYSTEM"); + } + + private HotelContextServiceImpl service() { + return new HotelContextServiceImpl(hotelRepository, currentUserContextService); + } + + private PlatformHotelEntity activeHotel(String hotelId) { + PlatformHotelEntity hotel = new PlatformHotelEntity(); + hotel.setHotelId(hotelId); + hotel.setHotelName(hotelId); + hotel.setHotelStatus(PlatformHotelStatus.ACTIVE.name()); + hotel.setTimeZone("Asia/Bangkok"); + hotel.setSortOrder(10); + hotel.setCreatedAt(LocalDateTime.now()); + hotel.setUpdatedAt(LocalDateTime.now()); + return hotel; + } + + private AuthenticatedUserContext userContext(String defaultHotelId, List accessibleHotelIds) { + return new AuthenticatedUserContext( + 1L, + "tester", + "测试用户", + false, + defaultHotelId, + accessibleHotelIds, + List.of()); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java index 3151417..de560d6 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -114,7 +114,6 @@ class ReservationAiQueryControllerTest { String body = """ { - "hotel_id": "HOTEL-TEST", "source_message_id": "%s", "source_event_index": 1, "group_code": "GRP-AIQUERY-NOT-FOUND" 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 12cd8f2..81adef7 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 @@ -172,16 +172,41 @@ class SuperAgentTaskResultControllerTest { } @Test - void shouldRejectExternalSourceMessageIdWhenHotelIdMissing() throws Exception { - String body = minimalBody("mail-external-without-hotel-001", "New Booking", "normal_task", "new_fit_reservation", """ + void shouldResolveExternalSourceMessageIdWithoutHotelIdFromSystemHotel() throws Exception { + String externalMessageId = "mail-external-without-hotel-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId); + String body = minimalBody(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """ "case_keys": {}, "extracted_fields": {} """); mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value(externalMessageId)) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM")); + + Long transitionCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_transition + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(transitionCount).isEqualTo(1L); + } + + @Test + void shouldRejectLegacyInternalSourceMessageIdWhenHotelDoesNotMatchSystemHotel() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage( + "mail-legacy-source-cross-hotel-001", + "HOTEL-OTHER"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": {} + """); + + mockMvc.perform(signedPost(body, "nonce-legacy-source-cross-hotel-001")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_code").value("HOTEL_ID_REQUIRED")) - .andExpect(jsonPath("$.message").value("使用外部 source_message_id 时 hotel_id 不能为空。")); + .andExpect(jsonPath("$.error_code").value("HOTEL_ID_MISMATCH")); } @Test @@ -1259,8 +1284,12 @@ class SuperAgentTaskResultControllerTest { } private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { + return captureSourceMessage(externalMessageId, "HOTEL-TEST"); + } + + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) { return captureService.capture(new CaptureSourceMessageCommand( - "HOTEL-TEST", + hotelId, "AGENTBUS", "EMAIL", externalMessageId, diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index 723c483..853325b 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -14,6 +14,12 @@ springdoc: swagger-ui: enabled: false +auth: + bootstrap: + default-hotel-id: HOTEL-TEST + default-hotel-name: 测试酒店 + default-hotel-time-zone: Asia/Bangkok + --- spring: config: