diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 8496c6c..7e7e0c4 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -36,9 +36,10 @@ | 接口 | 用途 | 前端注意 | | --- | --- | --- | -| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL。 | -| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排。 | -| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态。 | +| `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;用 `next_processable_task_id` 引导用户继续处理。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段。 | +| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | @@ -48,6 +49,7 @@ | `GET /api/source-messages` | 查询来源消息安全摘要 | 列表不返回邮件正文、HTML、附件 URL 或原始 payload。 | | `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 只用于安全摘要详情。 | | `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,但展示 HTML 前必须 sanitize。 | ## 6. 不给前端直接调用的接口 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 1a00d9b..cf4c3b8 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -8,14 +8,41 @@ | 优先级 | 接口 | 页面 / 场景 | 状态 | | --- | --- | --- | --- | -| P0 | 任务列表 / 工作台接口 | 任务列表页、首页工作台 | 已实现第一版 | -| P0 | 订单详情与任务时间线接口 | 订单详情页 | 已实现第一版 | -| P1 | 订单列表接口 | 订单检索、订单入口 | 待后端设计 | -| P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 待后端设计 | -| P1 | 任务卡前端字段白名单元数据接口 | 任务详情动态渲染 | 待确认是否需要后端提供 | -| 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 已确认后置 | +| P0 | 订单列表接口 `GET /api/reservation/orders` | 订单列表页、首页工作台 | 已完成第一版 | +| P0 | 任务列表接口 `GET /api/reservation/tasks` | 任务列表菜单、订单详情任务入口 | 已完成第一版,已补来源邮件会话字段 | +| P0 | 订单详情接口 `GET /api/reservation/orders/{orderId}` | 订单详情页 | 已完成第一版,已补 `tasks[]` 每个任务的来源邮件会话字段 | +| P0 | 任务详情读取接口 `GET /api/reservation/tasks/{taskId}` | 任务详情页动态渲染 | 已完成第一版,已补来源邮件字段和 3.0 字段元数据 | +| P0 | 任务详情操作接口 | 任务详情保存、确认、OPERA、审计 | 已完成;前端可直接接入 | +| P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 | +| P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 未完成独立接口;可先通过任务列表 / 任务详情展示 `INFORMATIONAL_MESSAGE` | +| P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | +| 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | -## 3. 任务列表 / 工作台接口 +## 2.1 后端当前接口完成度核对 + +本节按 2026-07-08 当前后端 Controller 和 result record 核对,避免重复要求后端实现已经存在的接口。 + +| 接口 / 能力 | 当前后端状态 | 前端是否可直接接入 | 仍需后端处理 | +| --- | --- | --- | --- | +| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段 | 可以 | 暂无。 | +| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补 `tasks[]` 来源邮件会话字段 | 可以 | 暂无。 | +| `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 暂无。 | +| `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 暂无。 | +| `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 暂无。 | +| `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 暂无。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 已完成第一版模拟操作 | 可以 | 暂无;真实 OPERA 写入另行确认。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 已完成第一版模拟重试 | 可以 | 暂无;真实 OPERA 重试另行确认。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | 已完成 | 可以 | 暂无。 | +| `GET /api/source-messages` | 已完成安全摘要列表 | 可以 | 不能替代邮件会话全文接口。 | +| `GET /api/source-messages/{id}` | 已完成单条安全摘要 | 可以 | 不能替代邮件会话全文接口。 | +| `GET /api/source-messages/{id}/original` | 已完成单封原文受控读取 | 谨慎接入 | 只能读单封邮件,不能返回同一 conversation 全量邮件。 | +| `GET /api/reservation/orders` | 已完成第一版 | 可以 | 默认查询全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版 | 可以 | 返回完整 text/html、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key。 | +| `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 可作为后端偏好的邮件会话详情路径。 | +| `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 如需独立信息提醒页再新增;第一版可先用任务接口过滤。 | +| `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | + +## 3. 任务列表 / 工作台接口字段补齐 建议路径: @@ -23,7 +50,31 @@ GET /api/reservation/tasks ``` -当前状态:后端已按 P0 最小诉求实现第一版。接口只返回任务摘要、订单展示键、来源消息主题和实时可处理状态,不返回完整 AI payload、邮件正文或附件 URL。 +当前状态:后端已按 P0 最小诉求实现第一版,前端任务列表页可以直接接入该接口。接口已返回任务摘要、订单展示键、来源消息 ID / 主题、来源邮件会话摘要和实时可处理状态,不返回完整 AI payload、邮件正文或附件 URL。 + +本轮前端新增“任务列表”菜单,并且任务列表、订单详情任务队列都需要能跳转到该任务来源消息所在的完整邮件会话。因此建议在现有返回项上补齐来源邮件会话摘要字段。 + +已完成字段: + +| 字段 | 说明 | +| --- | --- | +| `task_id` | 任务 ID。 | +| `order_id` | 关联订单 ID。 | +| `hotel_id` | 酒店上下文 ID。 | +| `display_order_key` | 前端优先展示的业务号或临时订单号。 | +| `temporary_order_no` | 临时订单号。 | +| `task_type` | 系统主任务类型。 | +| `task_subtype` | 任务 subtype。 | +| `task_status` | 任务状态。 | +| `card_name` | 任务卡展示名称。 | +| `queue_sequence` | 同订单队列顺序。 | +| `queue_participation` | 是否参与订单执行队列。 | +| `can_process` | 当前是否可处理。 | +| `readonly_reason_code` | 只读原因代码。 | +| `source_message_id` | 来源 SourceMessage Inbox ID。 | +| `source_subject` | 来源消息主题摘要。 | +| `created_at` | 任务创建时间。 | +| `updated_at` | 任务更新时间。 | 建议入参: @@ -60,6 +111,10 @@ GET /api/reservation/tasks "readonly_reason_code": "PREVIOUS_TASK_NOT_FINISHED", "source_message_id": "30001", "source_subject": "Booking Update", + "source_sender_summary": "guest@example.com", + "source_received_at": "2026-07-08T02:58:00Z", + "external_conversation_id": "thread-20260708-001", + "conversation_message_count": 6, "created_at": "2026-07-08T03:00:00Z", "updated_at": "2026-07-08T03:10:00Z" } @@ -72,7 +127,16 @@ GET /api/reservation/tasks } ``` -## 4. 订单详情与任务时间线接口 +本轮已新增字段: + +| 字段 | 说明 | +| --- | --- | +| `source_sender_summary` | 来源消息发件人摘要,用于任务列表快速判断来源。 | +| `source_received_at` | 来源消息接收时间,用于任务列表排序和展示。 | +| `external_conversation_id` | 来源消息所属邮件会话 ID,用于打开完整邮件会话详情。 | +| `conversation_message_count` | 会话内邮件数量,用于提示用户该入口是整段会话,不是单封邮件。 | + +## 4. 订单详情与任务时间线接口字段补齐 建议路径: @@ -80,13 +144,16 @@ GET /api/reservation/tasks GET /api/reservation/orders/{orderId} ``` -当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要和同订单任务时间线;`include_tasks=false` 时只返回订单摘要。`include_source_summary` 第一版保留入参但订单时间线暂不展开来源摘要,来源主题请优先从任务列表接口读取。 +当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要和同订单任务时间线;`include_tasks=false` 时只返回订单摘要。任务时间线已补齐每个任务的来源邮件会话摘要。 + +订单详情低保真已确认沿用“订单摘要 + 同订单任务队列 + 当前任务卡 + OPERA 模拟操作 + 审计时间线”。同订单任务队列里的每个任务都需要自己的“查看邮件会话”入口,因为不同任务可能来自不同邮件或不同邮件会话。因此建议补齐 `tasks[]` 中每个任务的来源邮件会话字段。 建议入参: | 参数 | 必填 | 说明 | | --- | --- | --- | | `orderId` | 是 | 订单 ID。 | +| `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | | `include_tasks` | 否 | 是否返回任务时间线,默认 `true`。 | | `include_source_summary` | 否 | 是否返回来源消息摘要,默认 `true`。 | @@ -118,6 +185,12 @@ GET /api/reservation/orders/{orderId} "queue_participation": true, "can_process": false, "readonly_reason_code": "TASK_FINISHED", + "source_message_id": "30001", + "source_subject": "Booking Request", + "source_sender_summary": "guest@example.com", + "source_received_at": "2026-07-08T02:58:00Z", + "external_conversation_id": "thread-20260708-001", + "conversation_message_count": 6, "created_at": "2026-07-08T03:00:00Z" } ], @@ -125,6 +198,17 @@ GET /api/reservation/orders/{orderId} } ``` +本轮已新增字段: + +| 字段 | 说明 | +| --- | --- | +| `source_message_id` | 任务对应的来源 SourceMessage Inbox ID。 | +| `source_subject` | 来源消息主题摘要。 | +| `source_sender_summary` | 来源消息发件人摘要。 | +| `source_received_at` | 来源消息接收时间。 | +| `external_conversation_id` | 来源消息所属邮件会话 ID。 | +| `conversation_message_count` | 会话内邮件数量。 | + ## 5. 订单列表接口 建议路径: @@ -133,6 +217,8 @@ GET /api/reservation/orders/{orderId} GET /api/reservation/orders ``` +当前状态:后端已完成第一版。默认查询全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`,`next_processable_task_id` 按同订单队列可处理状态实时计算。 + 建议入参: | 参数 | 必填 | 说明 | @@ -154,6 +240,7 @@ GET /api/reservation/orders "order_id": "20001", "hotel_id": "HOTEL-TEST", "order_status": "ACTIVE", + "display_order_key": "GRP-001", "temporary_order_no": null, "confirmation_number": "CNF123456", "group_code": "GRP-001", @@ -171,7 +258,202 @@ GET /api/reservation/orders } ``` -## 6. Message Notification 列表 / 详情接口 +## 6. 邮件会话详情接口 + +建议优先路径: + +```text +GET /api/source-messages/{sourceMessageId}/conversation +``` + +可选补充路径: + +```text +GET /api/source-message-conversations/{externalConversationId} +``` + +当前状态:后端已完成第一版。前端入口从某个任务的 `source_message_id` 进入,后端根据该 SourceMessage 找到 `external_conversation_id`,再返回同一邮件会话下的全部邮件。 + +中文说明: + +- “全部邮件”指同一个 `externalConversationId` 下的历史邮件、当前邮件和后续回复,不是只展示任务对应的单封来源邮件。 +- 邮件会话详情页需要展示完整正文或清洗后的 HTML、附件、内联图片、发件人摘要、发送 / 接收时间、主题和关联订单 / 任务。 +- 前端不在页面上做业务截断或隐藏;但仍只调用本项目后端接口,不直接访问邮箱、AgentBus、数据库或外部附件 URL Secret。 +- 如果后端仍需要审计原文读取,应由后端在该业务接口内部处理;前端不保存 `X-TH-Hotel-Source-Original-Read-Key` 一类受控访问 key。 + +建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `sourceMessageId` | 是 | 入口来源消息 ID。后端据此定位 `externalConversationId`。 | + +当前第一版不额外接收 `hotelId`、`includeBody`、`includeRelated`。后端默认按 SourceMessage 自身酒店上下文查询同会话邮件,返回完整 text/html 和关联订单 / 任务摘要,并在内部写原文读取审计。 + +建议返参: + +```json +{ + "conversation": { + "external_conversation_id": "thread-20260708-001", + "hotel_id": "HOTEL-TEST", + "channel": "EMAIL", + "subject": "Re: Booking Update", + "message_count": 6, + "first_received_at": "2026-07-06T01:10:00Z", + "last_received_at": "2026-07-08T03:28:00Z" + }, + "messages": [ + { + "id": "30001", + "external_message_id": "msg-001", + "external_conversation_id": "thread-20260708-001", + "sender_summary": "guest@example.com", + "subject": "Booking Request", + "received_at": "2026-07-06T01:10:00Z", + "source_sent_at": "2026-07-06T01:08:00Z", + "text_body": "完整邮件正文", + "html_body": "

完整邮件 HTML

", + "html_sanitize_required": true, + "inline_images": [], + "attachments": [ + { + "mediaType": "ATTACHMENT", + "fileName": "rooming-list.xlsx", + "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "sizeBytes": 10240, + "externalUrl": "由后端决定是否返回可访问 URL", + "externalMediaId": "media-001" + } + ], + "related_orders": [ + { + "order_id": "20001", + "display_order_key": "GRP-001", + "order_status": "ACTIVE" + } + ], + "related_tasks": [ + { + "task_id": "10001", + "order_id": "20001", + "task_type": "NEW_BOOKING", + "task_subtype": "NEW_BOOKING", + "task_status": "PENDING_CONFIRM", + "card_name": "New Booking" + } + ] + } + ] +} +``` + +## 7. 任务详情接口字段元数据扩展 + +建议路径: + +```text +GET /api/reservation/tasks/{taskId} +``` + +当前状态:后端已有任务详情接口,前端任务详情页可以接入。该接口已返回 `fields[]`、草稿、确认 payload、可处理状态和 OPERA 模拟操作;本轮已透出来源邮件会话字段,以及 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 中 P0 需要的字段元数据。 + +任务详情页面相关已完成接口: + +| 接口 | 用途 | 后端状态 | +| --- | --- | --- | +| `GET /api/reservation/tasks/{taskId}` | 读取任务详情、字段矩阵、当前值、可处理状态、OPERA 操作摘要。 | 已完成第一版,已补本节字段。 | +| `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务卡草稿。 | 已完成。 | +| `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务卡字段。 | 已完成。 | +| `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水。 | 已完成。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作。 | 已完成第一版模拟。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败的 OPERA 模拟操作。 | 已完成第一版模拟。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | 将 Fallback / manual_review 转换为具体任务类型。 | 已完成。 | + +已完成的 `fields[]` 字段: + +| 字段 | 说明 | +| --- | --- | +| `row_number` | 字段矩阵行号。 | +| `card_name` | 任务卡名称。 | +| `display_area` | 前端展示区域。 | +| `field_path` | 字段路径。 | +| `display_name` | 展示名。 | +| `visible` | 是否展示。 | +| `editable` | 是否可编辑。 | +| `input_editable` | 是否输入方式编辑。 | +| `select_editable` | 是否下拉方式编辑。 | +| `date_picker` | 是否日期选择。 | +| `number_input` | 是否数字输入。 | +| `file_display` | 是否文件展示。 | +| `table_editable` | 是否表格编辑。 | +| `enum_options` | 枚举选项。 | +| `required_rule` | 必填规则。 | +| `display_condition` | 展示条件。 | +| `validation_rule` | 校验规则。 | +| `write_path` | 写入路径。 | +| `opera_write_participation` | 是否参与 OPERA 写入。 | +| `opera_parameter_mapping` | OPERA 参数映射。 | +| `notes` | 备注。 | +| `value` | 当前回显值。 | + +本轮已补字段: + +| 位置 | 字段 | 说明 | +| --- | --- | --- | +| 顶层 | `source_subject` | 任务来源消息主题摘要。 | +| 顶层 | `source_sender_summary` | 任务来源消息发件人摘要。 | +| 顶层 | `source_received_at` | 任务来源消息接收时间。 | +| 顶层 | `external_conversation_id` | 任务来源消息所属邮件会话 ID。 | +| 顶层 | `conversation_message_count` | 会话内邮件数量。 | +| `fields[]` | `result_type` | 3.0 字段表中的结果类型,用于前端调试和字段分组校验。 | +| `fields[]` | `task_type` | 3.0 字段表中的任务主类型。 | +| `fields[]` | `task_subtype` | 3.0 字段表中的任务 subtype / 业务动作。 | +| `fields[]` | `default_value_source` | 3.0 字段表中的默认值 / 回显来源。 | + +建议返参增量示例: + +```json +{ + "task_id": "10001", + "order_id": "20001", + "source_message_id": "30001", + "source_subject": "Booking Update", + "source_sender_summary": "guest@example.com", + "source_received_at": "2026-07-08T02:58:00Z", + "external_conversation_id": "thread-20260708-001", + "conversation_message_count": 6, + "system_task_type": "NEW_BOOKING", + "task_card_type": "NEW_BOOKING", + "task_status": "PENDING_CONFIRM", + "field_contract_version": "20260708-3.0", + "fields": [ + { + "row_number": 2, + "card_name": "New Booking", + "result_type": "RESERVATION", + "task_type": "NEW_BOOKING", + "task_subtype": "NEW_BOOKING", + "display_area": "基础信息", + "field_path": "case_keys.group_code", + "display_name": "Group Code", + "visible": "是", + "editable": "否", + "default_value_source": "AI识别结果 / 已确认草稿回显", + "value": "GRP-001" + } + ] +} +``` + +中文说明: + +- 如果前端只做“按后端字段直接渲染”,现有 `fields[]` 可以支撑第一版表单展示;本轮已经扩展 `ReservationTaskFieldResult`,避免前端维护第二套字段矩阵。 +- `result_type`、`task_type`、`task_subtype`、`default_value_source` 当前从后端字段矩阵定义透出。 +- 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 + +## 8. Message Notification 列表 / 详情接口 + +当前状态:未发现后端独立 Message Notification 列表 / 详情接口。当前后端已经支持 `INFORMATIONAL_MESSAGE` 任务类型进入任务体系,第一版前端可以先通过 `GET /api/reservation/tasks?task_type=INFORMATIONAL_MESSAGE` 和 `GET /api/reservation/tasks/{taskId}` 展示信息提醒任务。仅当产品确认需要独立“信息提醒页”时,再新增本节接口。 建议路径: @@ -205,13 +487,14 @@ GET /api/reservation/message-notifications/{taskId} "informational_message": "该消息仅作信息提醒,不需要执行 OPERA 操作。", "attachments": [], "source_message_id": "30009", + "external_conversation_id": "thread-20260708-009", "created_at": "2026-07-08T03:00:00Z" } ``` -## 7. 任务卡前端字段白名单元数据接口 +## 9. 任务卡前端字段白名单元数据接口 -是否需要该接口待确认。如果前端直接读取或内置 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 转换后的配置,则第一版可以不做。 +是否需要该接口待确认。如果任务详情接口 `fields[]` 已透出 3.0 所需元数据,则第一版可以不做独立白名单接口;如果后续需要字段矩阵调试页、版本对齐页或前端预加载全部任务卡配置,再补独立接口。 建议路径: @@ -247,7 +530,7 @@ GET /api/reservation/task-card-field-whitelist } ``` -## 8. 已确认后置接口 +## 10. 已确认后置接口 普通任务切换订单接口继续后置,前端暂不开发提交能力。后续如果恢复开发,建议另行确认: @@ -276,9 +559,10 @@ POST /api/reservation/tasks/{taskId}/order-binding } ``` -## 9. 待确认问题 +## 11. 待确认问题 -- 任务列表和订单列表是否统一使用同一个分页结构。 -- 前端字段白名单由后端接口提供,还是由前端从 Excel 转成静态配置。 +- 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。 +- 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 +- 任务详情 `fields[]` 已由后端直接透出 P0 需要的 3.0 元数据;独立字段白名单接口后置。 - Message Notification 是否独立成页面,还是只在订单详情中展示。 -- SourceMessage 原文读取在前端页面中的入口和权限方案仍待用户 / 权限体系确认。 +- 邮件会话全文读取的审计策略由后端内部处理;前端不保存原文读取 key。 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index b4a2cde..c82a788 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -314,7 +314,12 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - 已实现 `GET /api/reservation/tasks` 第一版,支持酒店、订单、任务类型、任务状态、任务 subtype、队列参与、关键词和分页筛选。 - 已实现 `GET /api/reservation/orders/{orderId}` 第一版,支持返回订单摘要和任务时间线,`include_tasks=false` 时只返回订单摘要。 - 两个接口均复用同订单队列可处理状态计算,`FAILED` 和 `COMPLETED` 视为结束,不阻塞后续任务。 -- 本轮未实现订单列表、Message Notification 独立列表 / 详情、任务卡前端字段白名单元数据接口。 +- 已补齐 `GET /api/reservation/tasks` 来源邮件会话摘要字段:`source_sender_summary`、`source_received_at`、`external_conversation_id`、`conversation_message_count`。 +- 已补齐 `GET /api/reservation/orders/{orderId}` 的 `tasks[]` 来源邮件会话摘要字段。 +- 已补齐 `GET /api/reservation/tasks/{taskId}` 顶层来源邮件会话字段,并在 `fields[]` 透出 `result_type`、`task_type`、`task_subtype`、`default_value_source`。 +- 已实现订单列表接口 `GET /api/reservation/orders`,默认查询全部订单状态,支持酒店、订单状态、Group Code、Confirmation No.、关键词和分页筛选;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 +- 已实现邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation`,根据 SourceMessage 定位外部会话,返回完整 text/html、附件外链、内联图片、来源摘要和关联订单 / 任务摘要;原文读取审计由后端内部写入。 +- Message Notification 独立列表 / 详情、任务卡前端字段白名单独立接口继续后置;第一版分别复用任务列表 / 任务详情和 `fields[]` 元数据。 ## 12. 建议开发节奏 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java new file mode 100644 index 0000000..5aa687b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java @@ -0,0 +1,52 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 邮件会话中的单封邮件详情。该结果会返回完整 text/html 和媒体外链,只能用于受控会话详情场景。 + * + * @param id 内部 SourceMessage Inbox ID + * @param externalMessageId 外部单封邮件 ID + * @param externalConversationId 外部邮件会话 ID + * @param senderSummary 发送人安全摘要 + * @param subject 邮件主题摘要 + * @param receivedAt 本系统接收时间,UTC + * @param sourceSentAt 邮件来源发送时间,UTC + * @param textBody 完整纯文本正文 + * @param htmlBody 完整 HTML 正文,前端展示前必须 sanitize + * @param htmlSanitizeRequired 是否要求前端 sanitize HTML + * @param inlineImages 内联图片外链 + * @param attachments 附件外链 + * @param relatedOrders 关联订单摘要 + * @param relatedTasks 关联任务摘要 + */ +public record SourceMessageConversationMessageResult( + String id, + @JsonProperty("external_message_id") + String externalMessageId, + @JsonProperty("external_conversation_id") + String externalConversationId, + @JsonProperty("sender_summary") + String senderSummary, + String subject, + @JsonProperty("received_at") + OffsetDateTime receivedAt, + @JsonProperty("source_sent_at") + OffsetDateTime sourceSentAt, + @JsonProperty("text_body") + String textBody, + @JsonProperty("html_body") + String htmlBody, + @JsonProperty("html_sanitize_required") + Boolean htmlSanitizeRequired, + @JsonProperty("inline_images") + List inlineImages, + List attachments, + @JsonProperty("related_orders") + List relatedOrders, + @JsonProperty("related_tasks") + List relatedTasks +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationResult.java new file mode 100644 index 0000000..42d63a7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import java.util.List; + +/** + * SourceMessage 邮件会话详情结果。包含会话摘要和同会话所有邮件原文。 + * + * @param conversation 会话摘要 + * @param messages 同一会话内全部邮件,按接收时间正序返回 + */ +public record SourceMessageConversationResult( + SourceMessageConversationSummaryResult conversation, + List messages +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationSummaryResult.java new file mode 100644 index 0000000..ccca397 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationSummaryResult.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 邮件会话摘要。用于前端在会话详情顶部展示来源链路信息。 + * + * @param hotelId 酒店上下文 ID + * @param channel 来源渠道 + * @param externalConversationId 外部邮件会话 ID + * @param subject 当前定位消息的主题摘要 + * @param messageCount 会话内邮件数量 + * @param firstReceivedAt 会话第一封入库时间,UTC + * @param lastReceivedAt 会话最后一封入库时间,UTC + */ +public record SourceMessageConversationSummaryResult( + @JsonProperty("hotel_id") + String hotelId, + String channel, + @JsonProperty("external_conversation_id") + String externalConversationId, + String subject, + @JsonProperty("message_count") + Long messageCount, + @JsonProperty("first_received_at") + OffsetDateTime firstReceivedAt, + @JsonProperty("last_received_at") + OffsetDateTime lastReceivedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedContextResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedContextResult.java new file mode 100644 index 0000000..4360309 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedContextResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import java.util.List; + +/** + * SourceMessage 关联业务上下文。由业务模块实现提供,平台会话服务只做聚合展示。 + * + * @param relatedOrders 关联订单摘要列表 + * @param relatedTasks 关联任务摘要列表 + */ +public record SourceMessageRelatedContextResult( + List relatedOrders, + List relatedTasks +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedOrderResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedOrderResult.java new file mode 100644 index 0000000..0a7266b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedOrderResult.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * SourceMessage 会话中关联订单摘要。平台层只定义通用展示字段,具体来源由业务模块提供。 + * + * @param orderId 订单 ID + * @param displayOrderKey 前端展示用订单号或临时订单号 + * @param orderStatus 订单状态 + */ +public record SourceMessageRelatedOrderResult( + @JsonProperty("order_id") + String orderId, + @JsonProperty("display_order_key") + String displayOrderKey, + @JsonProperty("order_status") + String orderStatus +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedTaskResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedTaskResult.java new file mode 100644 index 0000000..fb209ab --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageRelatedTaskResult.java @@ -0,0 +1,29 @@ +package cn.nianxx.thhotel.platform.message.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * SourceMessage 会话中关联任务摘要。用于前端从邮件原文跳转到任务详情。 + * + * @param taskId 任务 ID + * @param orderId 任务当前挂靠订单 ID + * @param taskType 系统主任务类型 + * @param taskSubtype 任务 subtype + * @param taskStatus 任务状态 + * @param cardName 任务卡名称 + */ +public record SourceMessageRelatedTaskResult( + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_type") + String taskType, + @JsonProperty("task_subtype") + String taskSubtype, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("card_name") + String cardName +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java index 54f9f3b..a7d9efd 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java @@ -1,9 +1,11 @@ package cn.nianxx.thhotel.platform.message.control; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; +import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService; import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; @@ -25,15 +27,18 @@ public class SourceMessageController { private final SourceMessageQueryService queryService; private final SourceMessageOriginalService originalService; + private final SourceMessageConversationService conversationService; /** * 注入 SourceMessage 查询与原文读取服务,Controller 不直接访问 Mapper 或 Repository。 */ public SourceMessageController( SourceMessageQueryService queryService, - SourceMessageOriginalService originalService) { + SourceMessageOriginalService originalService, + SourceMessageConversationService conversationService) { this.queryService = queryService; this.originalService = originalService; + this.conversationService = conversationService; } /** @@ -85,6 +90,15 @@ public class SourceMessageController { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); } + /** + * 读取指定 SourceMessage 所在邮件会话完整详情。该接口由后端内部记录原文读取审计,前端不传原文 key。 + */ + @GetMapping("/{id}/conversation") + public SourceMessageConversationResult conversation(@PathVariable Long id) { + return conversationService.getConversation(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found")); + } + /** * 校验请求头文本是否有效,避免空白调用方或场景进入审计记录。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index 0df7326..7f87571 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -27,7 +27,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -90,6 +92,24 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe .toList(); } + /** + * 查询同一外部会话下的全部 SourceMessage 安全快照,按接收时间正序返回。 + */ + @Override + public List findByExternalConversationId(String hotelId, String externalConversationId) { + if (!hasText(hotelId) || !hasText(externalConversationId)) { + return List.of(); + } + return inboxMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageInboxEntity::getHotelId, trim(hotelId)) + .eq(SourceMessageInboxEntity::getExternalConversationId, trim(externalConversationId)) + .orderByAsc(SourceMessageInboxEntity::getReceivedAt) + .orderByAsc(SourceMessageInboxEntity::getId)) + .stream() + .map(this::toSnapshot) + .toList(); + } + /** * 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。 */ @@ -160,6 +180,30 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe .toList(); } + /** + * 批量统计会话消息数。该方法只访问 Inbox 索引表,不读取正文或媒体 URL。 + */ + @Override + public Map countByExternalConversationIds(String hotelId, List externalConversationIds) { + if (!hasText(hotelId) || externalConversationIds == null || externalConversationIds.isEmpty()) { + return Map.of(); + } + List safeConversationIds = externalConversationIds.stream() + .filter(this::hasText) + .map(this::trim) + .distinct() + .toList(); + if (safeConversationIds.isEmpty()) { + return Map.of(); + } + Map counts = new LinkedHashMap<>(); + inboxMapper.selectList(Wrappers.lambdaQuery() + .eq(SourceMessageInboxEntity::getHotelId, trim(hotelId)) + .in(SourceMessageInboxEntity::getExternalConversationId, safeConversationIds)) + .forEach(entity -> counts.merge(entity.getExternalConversationId(), 1L, Long::sum)); + return counts; + } + /** * 插入 Inbox 主记录及原始 payload;RECEIVED 状态额外保存正文和媒体引用。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index 93a039b..2f3091e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -8,6 +8,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -25,6 +26,11 @@ public interface SourceMessageInboxRepository { */ List findByIds(List ids); + /** + * 按外部邮件会话 ID 查询同一会话全部 Inbox 安全快照。 + */ + List findByExternalConversationId(String hotelId, String externalConversationId); + /** * 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。 */ @@ -47,6 +53,11 @@ public interface SourceMessageInboxRepository { */ List findIdsBySafeKeyword(String hotelId, String keyword, int limit); + /** + * 批量统计外部邮件会话下的 SourceMessage 数量,用于任务和订单前端摘要。 + */ + Map countByExternalConversationIds(String hotelId, List externalConversationIds); + /** * 插入 Inbox 及其 payload/body/media 子记录,返回内部 SourceMessage ID。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java new file mode 100644 index 0000000..dcf1feb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageConversationService.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; +import java.util.Optional; + +/** + * SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入内部读取审计。 + */ +public interface SourceMessageConversationService { + + /** + * 读取指定 SourceMessage 所在邮件会话的完整详情。 + * + * @param sourceMessageId 当前定位的 SourceMessage Inbox ID + * @return 找到时返回同一会话全部邮件详情;不存在时为空 + */ + Optional getConversation(Long sourceMessageId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java index efb21de..ceb38cb 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java @@ -4,6 +4,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryRespons import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -43,4 +44,13 @@ public interface SourceMessageQueryService { * @return 匹配到的安全摘要列表,不返回正文、HTML、附件 URL 或 payload */ List getSummariesByIds(List inboxIds); + + /** + * 批量统计外部邮件会话下的 SourceMessage 数量,供业务摘要字段展示。 + * + * @param hotelId 酒店上下文 ID + * @param externalConversationIds 外部邮件会话 ID 列表 + * @return 会话 ID 到消息数量的映射 + */ + Map countByExternalConversationIds(String hotelId, List externalConversationIds); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageRelatedContextProvider.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageRelatedContextProvider.java new file mode 100644 index 0000000..29292e0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageRelatedContextProvider.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult; + +/** + * SourceMessage 关联业务上下文 Provider。业务模块实现该接口,平台会话服务只做聚合展示。 + */ +public interface SourceMessageRelatedContextProvider { + + /** + * 查询单封 SourceMessage 关联的业务摘要。 + * + * @param hotelId 酒店上下文 ID + * @param sourceMessageId SourceMessage Inbox ID + * @return 关联订单和任务摘要;没有关联时返回空列表结果 + */ + SourceMessageRelatedContextResult findRelatedContext(String hotelId, Long sourceMessageId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java new file mode 100644 index 0000000..2a02fb3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageConversationServiceImpl.java @@ -0,0 +1,176 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +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; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationMessageResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationSummaryResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalMediaResponse; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService; +import cn.nianxx.thhotel.platform.message.service.SourceMessageRelatedContextProvider; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入后端内部审计。 + */ +@Service +public class SourceMessageConversationServiceImpl implements SourceMessageConversationService { + + private static final String INTERNAL_ACTOR_ID = "system:source-message-conversation"; + private static final String ACCESS_SCENE = "source-message-conversation"; + private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE"; + private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT"; + + private final SourceMessageInboxRepository inboxRepository; + private final List relatedContextProviders; + + /** + * 注入 SourceMessage 持久化端口和业务关联上下文 Provider 列表。 + */ + public SourceMessageConversationServiceImpl( + SourceMessageInboxRepository inboxRepository, + List relatedContextProviders) { + this.inboxRepository = inboxRepository; + this.relatedContextProviders = relatedContextProviders == null ? List.of() : relatedContextProviders; + } + + /** + * 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入内部读取审计。 + */ + @Override + @Transactional + public Optional getConversation(Long sourceMessageId) { + Optional sourceOptional = inboxRepository.findById(sourceMessageId); + if (sourceOptional.isEmpty()) { + return Optional.empty(); + } + SourceMessageInboxSnapshot source = sourceOptional.get(); + List conversationMessages = findConversationMessages(source); + SourceMessageConversationSummaryResult conversation = toConversationSummary(source, conversationMessages); + List messages = conversationMessages.stream() + .map(this::toConversationMessage) + .toList(); + return Optional.of(new SourceMessageConversationResult(conversation, messages)); + } + + /** + * 查询同一外部会话邮件;外部会话 ID 缺失时降级为单封邮件详情。 + */ + private List findConversationMessages(SourceMessageInboxSnapshot source) { + if (source.externalConversationId() == null || source.externalConversationId().isBlank()) { + return List.of(source); + } + List messages = inboxRepository.findByExternalConversationId( + source.hotelId(), + source.externalConversationId()); + return messages.isEmpty() ? List.of(source) : messages; + } + + /** + * 生成会话顶部摘要,时间统一转为 UTC OffsetDateTime。 + */ + private SourceMessageConversationSummaryResult toConversationSummary( + SourceMessageInboxSnapshot source, + List messages) { + SourceMessageInboxSnapshot first = messages.get(0); + SourceMessageInboxSnapshot last = messages.get(messages.size() - 1); + return new SourceMessageConversationSummaryResult( + source.hotelId(), + source.channel(), + source.externalConversationId(), + source.subject(), + (long) messages.size(), + UtcTimeFormatter.toUtcOffsetDateTime(first.receivedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(last.receivedAt())); + } + + /** + * 将 Inbox 快照转换为会话邮件详情,并附带原文、媒体外链和业务关联摘要。 + */ + private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) { + SourceMessageOriginalContent originalContent = readOriginalAndAudit(message); + SourceMessageRelatedContextResult relatedContext = findRelatedContext(message); + return new SourceMessageConversationMessageResult( + message.id().toString(), + message.externalMessageId(), + message.externalConversationId(), + message.senderSummary(), + message.subject(), + UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()), + originalContent.textBody(), + originalContent.htmlBody(), + true, + filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE), + filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT), + relatedContext.relatedOrders(), + relatedContext.relatedTasks()); + } + + /** + * 读取原文并写入内部审计。会话接口不依赖前端传原文读取 key。 + */ + private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) { + SourceMessageOriginalContent content = inboxRepository.findOriginalContent(message.id()) + .orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of())); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + inboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft( + message.id(), + INTERNAL_ACTOR_ID, + ACCESS_SCENE, + SourceMessageOriginalAccessResult.GRANTED.code(), + now)); + return content; + } + + /** + * 聚合所有业务模块提供的关联上下文。当前 reservation 模块会提供订单和任务摘要。 + */ + private SourceMessageRelatedContextResult findRelatedContext(SourceMessageInboxSnapshot message) { + List contexts = relatedContextProviders.stream() + .map(provider -> provider.findRelatedContext(message.hotelId(), message.id())) + .toList(); + return new SourceMessageRelatedContextResult( + contexts.stream().flatMap(context -> context.relatedOrders().stream()).toList(), + contexts.stream().flatMap(context -> context.relatedTasks().stream()).toList()); + } + + /** + * 按媒体类型筛选并转换原文媒体引用。 + */ + private List filterMedia( + List mediaItems, + String mediaType) { + if (mediaItems == null || mediaItems.isEmpty()) { + return List.of(); + } + return mediaItems.stream() + .filter(item -> mediaType.equals(item.mediaType())) + .map(this::toMediaResponse) + .toList(); + } + + /** + * 转换媒体内部 DTO 为接口响应对象。 + */ + private SourceMessageOriginalMediaResponse toMediaResponse(SourceMessageOriginalMediaItem item) { + return new SourceMessageOriginalMediaResponse( + item.mediaType(), + item.fileName(), + item.contentType(), + item.sizeBytes(), + item.externalUrl(), + item.externalMediaId()); + } +} 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 73934e4..ab58d78 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 @@ -8,6 +8,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import java.util.List; +import java.util.Map; import java.util.Optional; import org.springframework.stereotype.Service; @@ -72,6 +73,14 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService .toList(); } + /** + * 批量统计邮件会话消息数,只访问 SourceMessage Inbox 索引表。 + */ + @Override + public Map countByExternalConversationIds(String hotelId, List externalConversationIds) { + return inboxRepository.countByExternalConversationIds(hotelId, externalConversationIds); + } + /** * 规范化页码,缺失或非法页码统一回到第一页。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOrderListQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOrderListQueryRequest.java new file mode 100644 index 0000000..8c992c9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOrderListQueryRequest.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * 前端订单列表查询条件。第一版默认查询全部订单状态,按酒店上下文做强过滤。 + * + * @param hotelId 酒店上下文 ID + * @param orderStatus 订单状态,可为空;为空时查询全部状态 + * @param groupCode Group Code 精确或模糊查询值 + * @param confirmationNumber Confirmation No. 精确或模糊查询值 + * @param keyword 订单展示名、业务号或临时订单号关键词 + * @param pageNum 页码,从 1 开始 + * @param pageSize 每页数量 + */ +public record ReservationOrderListQueryRequest( + String hotelId, + String orderStatus, + String groupCode, + String confirmationNumber, + String keyword, + Integer pageNum, + Integer pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java new file mode 100644 index 0000000..f3a870e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListItemResult.java @@ -0,0 +1,48 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 前端订单列表单行结果。用于工作台订单视角快速判断订单状态和可继续处理的任务。 + * + * @param orderId 订单 ID,按字符串返回避免前端长整型精度问题 + * @param hotelId 酒店上下文 ID + * @param orderStatus 订单状态 + * @param displayOrderKey 前端优先展示的订单业务号或临时订单号 + * @param temporaryOrderNo 临时订单号 + * @param groupCode Group Code,仅订单业务号类型为 GROUP_CODE 时返回 + * @param confirmationNumber Confirmation No.,仅订单业务号类型为 CONFIRMATION_NUMBER 时返回 + * @param displayName 订单展示名 + * @param openTaskCount 未关闭任务数,排除 COMPLETED 和 FAILED + * @param nextProcessableTaskId 下一条当前可处理任务 ID + * @param createdAt 订单创建时间,UTC + * @param updatedAt 订单更新时间,UTC + */ +public record ReservationOrderListItemResult( + @JsonProperty("order_id") + String orderId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("order_status") + String orderStatus, + @JsonProperty("display_order_key") + String displayOrderKey, + @JsonProperty("temporary_order_no") + String temporaryOrderNo, + @JsonProperty("group_code") + String groupCode, + @JsonProperty("confirmation_number") + String confirmationNumber, + @JsonProperty("display_name") + String displayName, + @JsonProperty("open_task_count") + Integer openTaskCount, + @JsonProperty("next_processable_task_id") + String nextProcessableTaskId, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListResult.java new file mode 100644 index 0000000..3fdc715 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderListResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import java.util.List; + +/** + * 前端订单列表分页结果。items 为当前页订单,page 为统一分页摘要。 + * + * @param items 当前页订单列表 + * @param page 分页信息 + */ +public record ReservationOrderListResult( + List items, + ReservationPaginationResult page +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java index 4a75cc2..12b6234 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java @@ -15,6 +15,12 @@ import java.time.OffsetDateTime; * @param queueParticipation 是否参与执行队列 * @param canProcess 当前是否可处理 * @param readonlyReasonCode 只读原因代码 + * @param sourceMessageId 来源 SourceMessage Inbox ID + * @param sourceSubject 来源消息主题摘要 + * @param sourceSenderSummary 来源发送人安全摘要 + * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param externalConversationId 外部邮件会话 ID + * @param conversationMessageCount 同一外部邮件会话下的消息数量 * @param createdAt 任务创建时间 */ public record ReservationOrderTaskTimelineItemResult( @@ -36,6 +42,18 @@ public record ReservationOrderTaskTimelineItemResult( Boolean canProcess, @JsonProperty("readonly_reason_code") String readonlyReasonCode, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("source_subject") + String sourceSubject, + @JsonProperty("source_sender_summary") + String sourceSenderSummary, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + @JsonProperty("external_conversation_id") + String externalConversationId, + @JsonProperty("conversation_message_count") + Long conversationMessageCount, @JsonProperty("created_at") OffsetDateTime createdAt ) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java index 47c0922..3ebd594 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -1,10 +1,29 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; import java.util.List; /** * 任务详情结果。提供队列可处理状态、任务卡 payload 和按矩阵生成的字段列表。 + * + * @param taskId 任务 ID + * @param orderId 订单 ID + * @param sourceMessageId 来源 SourceMessage Inbox ID + * @param sourceSubject 来源消息主题摘要 + * @param sourceSenderSummary 来源发送人安全摘要 + * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param externalConversationId 外部邮件会话 ID + * @param conversationMessageCount 同一外部邮件会话下的消息数量 + * @param systemTaskType 系统主任务类型 + * @param taskCardType 任务卡类型 + * @param taskStatus 任务状态 + * @param fieldContractVersion 字段矩阵契约版本 + * @param draftPayload 草稿 payload + * @param confirmedPayload 最终确认 payload + * @param availability 当前可处理状态 + * @param fields 按字段矩阵生成的字段列表 + * @param operaOperations OPERA 模拟操作列表 */ public record ReservationTaskDetailResult( @JsonProperty("task_id") @@ -13,6 +32,16 @@ public record ReservationTaskDetailResult( String orderId, @JsonProperty("source_message_id") String sourceMessageId, + @JsonProperty("source_subject") + String sourceSubject, + @JsonProperty("source_sender_summary") + String sourceSenderSummary, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + @JsonProperty("external_conversation_id") + String externalConversationId, + @JsonProperty("conversation_message_count") + Long conversationMessageCount, @JsonProperty("system_task_type") String systemTaskType, @JsonProperty("task_card_type") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java index 0c80a44..8c6fef1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java @@ -4,12 +4,45 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** * 任务详情字段结果。字段规则来自矩阵,value 为当前可回显值。 + * + * @param rowNumber 矩阵行号 + * @param cardName 任务卡展示名称 + * @param resultType AI 结果类型白名单值 + * @param taskType 任务类型白名单值 + * @param taskSubtype 任务 subtype 白名单值 + * @param displayArea 前端展示区域 + * @param fieldPath 字段路径 + * @param displayName 字段展示名称 + * @param visible 是否可见 + * @param editable 是否可编辑 + * @param inputEditable 是否文本输入可编辑 + * @param selectEditable 是否下拉选择可编辑 + * @param datePicker 是否日期选择 + * @param numberInput 是否数字输入 + * @param fileDisplay 是否文件展示 + * @param tableEditable 是否表格编辑 + * @param enumOptions 枚举选项 + * @param requiredRule 必填规则 + * @param defaultValueSource 默认值来源 + * @param displayCondition 展示条件 + * @param validationRule 校验规则 + * @param writePath 确认写入路径 + * @param operaWriteParticipation 是否参与 OPERA 参数组装 + * @param operaParameterMapping OPERA 参数映射说明 + * @param notes 备注说明 + * @param value 当前回显值 */ public record ReservationTaskFieldResult( @JsonProperty("row_number") Integer rowNumber, @JsonProperty("card_name") String cardName, + @JsonProperty("result_type") + String resultType, + @JsonProperty("task_type") + String taskType, + @JsonProperty("task_subtype") + String taskSubtype, @JsonProperty("display_area") String displayArea, @JsonProperty("field_path") @@ -34,6 +67,8 @@ public record ReservationTaskFieldResult( String enumOptions, @JsonProperty("required_rule") String requiredRule, + @JsonProperty("default_value_source") + String defaultValueSource, @JsonProperty("display_condition") String displayCondition, @JsonProperty("validation_rule") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java index f91d132..b67049b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java @@ -21,6 +21,10 @@ import java.time.OffsetDateTime; * @param readonlyReasonCode 只读原因代码 * @param sourceMessageId 来源 SourceMessage Inbox ID * @param sourceSubject 来源消息主题摘要 + * @param sourceSenderSummary 来源发送人安全摘要 + * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param externalConversationId 外部邮件会话 ID + * @param conversationMessageCount 同一外部邮件会话下的消息数量 * @param createdAt 任务创建时间 * @param updatedAt 任务更新时间 */ @@ -55,6 +59,14 @@ public record ReservationTaskWorkbenchItemResult( String sourceMessageId, @JsonProperty("source_subject") String sourceSubject, + @JsonProperty("source_sender_summary") + String sourceSenderSummary, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + @JsonProperty("external_conversation_id") + String externalConversationId, + @JsonProperty("conversation_message_count") + Long conversationMessageCount, @JsonProperty("created_at") OffsetDateTime createdAt, @JsonProperty("updated_at") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java index 6b3b128..f2329b4 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.control; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult; import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService; import org.springframework.http.MediaType; @@ -53,6 +55,28 @@ public class ReservationFrontendQueryController { pageSize)); } + /** + * 查询订单列表,默认返回全部订单状态,并实时聚合未关闭任务数量。 + */ + @GetMapping(value = "/orders", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationOrderListResult listOrders( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(name = "order_status", required = false) String orderStatus, + @RequestParam(name = "group_code", required = false) String groupCode, + @RequestParam(name = "confirmation_number", required = false) String confirmationNumber, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + return frontendQueryService.queryOrders(new ReservationOrderListQueryRequest( + hotelId, + orderStatus, + groupCode, + confirmationNumber, + keyword, + pageNum, + pageSize)); + } + /** * 查询订单详情和任务时间线,供订单详情页展示当前订单处理脉络。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index 59028cc..7836a72 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -18,6 +18,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDra import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; @@ -371,6 +372,70 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork .toList(); } + /** + * 按来源消息 ID 批量查询订单快照,供 SourceMessage 会话关联摘要使用。 + */ + @Override + public List findAiQueryOrdersBySourceMessageIds( + String hotelId, + List sourceMessageIds) { + if (sourceMessageIds == null || sourceMessageIds.isEmpty()) { + return List.of(); + } + return orderMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationOrderEntity::getHotelId, hotelId) + .in(ReservationOrderEntity::getSourceMessageId, sourceMessageIds) + .orderByDesc(ReservationOrderEntity::getUpdatedAt)) + .stream() + .map(this::toAiQueryOrderSnapshot) + .toList(); + } + + /** + * 前端订单列表分页查询。状态参数为空时不加状态过滤,默认返回全部订单状态。 + */ + @Override + public ReservationPageSnapshot queryFrontendOrders( + ReservationOrderListQueryRequest request, + int pageNum, + int pageSize) { + Page page = orderMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.lambdaQuery() + .eq(ReservationOrderEntity::getHotelId, request.hotelId()) + .eq(hasText(request.orderStatus()), + ReservationOrderEntity::getOrderStatus, + trim(request.orderStatus())) + .and(hasText(request.groupCode()), wrapper -> wrapper + .like(ReservationOrderEntity::getOrderBusinessKey, trim(request.groupCode())) + .or() + .like(ReservationOrderEntity::getActiveBusinessKey, trim(request.groupCode())) + .or() + .like(ReservationOrderEntity::getDisplayName, trim(request.groupCode()))) + .and(hasText(request.confirmationNumber()), wrapper -> wrapper + .like(ReservationOrderEntity::getOrderBusinessKey, trim(request.confirmationNumber())) + .or() + .like(ReservationOrderEntity::getActiveBusinessKey, trim(request.confirmationNumber())) + .or() + .like(ReservationOrderEntity::getDisplayName, trim(request.confirmationNumber()))) + .and(hasText(request.keyword()), wrapper -> wrapper + .like(ReservationOrderEntity::getOrderBusinessKey, trim(request.keyword())) + .or() + .like(ReservationOrderEntity::getActiveBusinessKey, trim(request.keyword())) + .or() + .like(ReservationOrderEntity::getTemporaryOrderCode, trim(request.keyword())) + .or() + .like(ReservationOrderEntity::getOrderStatus, trim(request.keyword())) + .or() + .like(ReservationOrderEntity::getDisplayName, trim(request.keyword()))) + .orderByDesc(ReservationOrderEntity::getUpdatedAt) + .orderByDesc(ReservationOrderEntity::getId)); + return new ReservationPageSnapshot<>( + page.getRecords().stream().map(this::toAiQueryOrderSnapshot).toList(), + page.getTotal(), + pageNum, + pageSize); + } + /** * 前端任务列表分页查询。只读取任务摘要字段,完整任务卡 payload 仍走任务详情接口。 */ @@ -428,6 +493,24 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return toAiQueryTaskSnapshots(hotelId, tasks); } + /** + * 按来源消息 ID 批量查询任务快照,供 SourceMessage 会话关联摘要使用。 + */ + @Override + public List findAiQueryTasksBySourceMessageIds( + String hotelId, + List sourceMessageIds) { + if (sourceMessageIds == null || sourceMessageIds.isEmpty()) { + return List.of(); + } + List tasks = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationTaskEntity::getHotelId, hotelId) + .in(ReservationTaskEntity::getSourceMessageId, sourceMessageIds) + .orderByAsc(ReservationTaskEntity::getOrderId) + .orderByAsc(ReservationTaskEntity::getExecutionOrder)); + return toAiQueryTaskSnapshots(hotelId, tasks); + } + /** * 按 AI transition 中冗余的 Group Code 或 Confirmation Number 查询任务。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index 5b11b56..2521275 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -19,6 +19,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSna import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -109,6 +110,21 @@ public interface ReservationAiWorkflowRepository { */ List findAiQueryOrdersByIds(String hotelId, List orderIds); + /** + * 按来源消息 ID 批量查询订单快照,用于 SourceMessage 会话关联业务摘要。 + */ + List findAiQueryOrdersBySourceMessageIds( + String hotelId, + List sourceMessageIds); + + /** + * 分页查询前端订单列表,默认包含全部订单状态。 + */ + ReservationPageSnapshot queryFrontendOrders( + ReservationOrderListQueryRequest request, + int pageNum, + int pageSize); + /** * 分页查询前端任务列表 / 工作台摘要。 */ @@ -123,6 +139,13 @@ public interface ReservationAiWorkflowRepository { */ List findAiQueryTasksByOrderIds(String hotelId, List orderIds); + /** + * 按来源消息 ID 批量查询任务快照,用于邮件会话关联任务摘要。 + */ + List findAiQueryTasksBySourceMessageIds( + String hotelId, + List sourceMessageIds); + /** * 按 AI transition 冗余业务 key 查询任务,用于查到待处理任务但尚无正式订单的场景。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java index 1541e59..5c0996f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.service; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult; /** @@ -14,6 +16,11 @@ public interface ReservationFrontendQueryService { */ ReservationTaskWorkbenchListResult queryTaskWorkbench(ReservationTaskWorkbenchQueryRequest request); + /** + * 查询订单列表,并实时聚合未关闭任务数量和下一条可处理任务。 + */ + ReservationOrderListResult queryOrders(ReservationOrderListQueryRequest request); + /** * 查询订单详情和同订单任务时间线,供前端订单详情页展示。 */ 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 b61b6be..efaef48 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 @@ -9,8 +9,11 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapsho import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListItemResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderTaskTimelineItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult; @@ -19,6 +22,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWork import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService; +import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -78,19 +82,50 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Map availabilityByTaskId = findAvailabilityByTaskId( normalizedRequest.hotelId(), page.items()); - Map sourceSubjectsById = findSourceSubjectsById(page.items()); + Map sourceContextsById = findSourceContextsById( + normalizedRequest.hotelId(), + page.items()); List items = page.items().stream() .map(task -> toWorkbenchItem( task, ordersById.get(task.orderId()), availabilityOrReadOnly(task, availabilityByTaskId), - sourceSubjectsById.get(task.sourceMessageId()))) + sourceContextsById.get(task.sourceMessageId()))) .toList(); return new ReservationTaskWorkbenchListResult( items, new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total())); } + /** + * 查询订单列表,并按当前任务队列实时计算未关闭任务数量和下一条可处理任务。 + */ + @Override + @Transactional(readOnly = true) + public ReservationOrderListResult queryOrders(ReservationOrderListQueryRequest request) { + ReservationOrderListQueryRequest normalizedRequest = normalizeOrderListRequest(request); + int pageNum = normalizePageNum(normalizedRequest.pageNum()); + int pageSize = normalizePageSize(normalizedRequest.pageSize()); + ReservationPageSnapshot page = + workflowRepository.queryFrontendOrders(normalizedRequest, pageNum, pageSize); + List orderIds = page.items().stream().map(ReservationAiQueryOrderSnapshot::id).toList(); + List orderTasks = workflowRepository.findAiQueryTasksByOrderIds( + normalizedRequest.hotelId(), + orderIds); + Map> tasksByOrderId = groupTasksByOrderId(orderTasks); + Map availabilityByTaskId = + calculateAvailabilityByTaskId(orderTasks, orderTasks); + List items = page.items().stream() + .map(order -> toOrderListItem( + order, + tasksByOrderId.getOrDefault(order.id(), List.of()), + availabilityByTaskId)) + .toList(); + return new ReservationOrderListResult( + items, + new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + /** * 查询订单详情。includeTasks=false 时只返回订单摘要,便于后续前端轻量使用。 */ @@ -114,8 +149,14 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Map availabilityByTaskId = calculateAvailabilityByTaskId( taskSnapshots, taskSnapshots); + Map sourceContextsById = findSourceContextsById( + normalizedHotelId, + taskSnapshots); List tasks = taskSnapshots.stream() - .map(task -> toTimelineItem(task, availabilityOrReadOnly(task, availabilityByTaskId))) + .map(task -> toTimelineItem( + task, + availabilityOrReadOnly(task, availabilityByTaskId), + sourceContextsById.get(task.sourceMessageId()))) .toList(); return new ReservationOrderDetailResult(toOrderSummary(order), tasks, List.of()); } @@ -148,6 +189,30 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ request.pageSize()); } + /** + * 标准化订单列表查询条件。订单状态为空时保留为空,表示查询全部状态。 + */ + private ReservationOrderListQueryRequest normalizeOrderListRequest(ReservationOrderListQueryRequest request) { + if (request == null) { + return new ReservationOrderListQueryRequest( + DEFAULT_HOTEL_ID, + null, + null, + null, + null, + DEFAULT_PAGE_NUM, + DEFAULT_PAGE_SIZE); + } + return new ReservationOrderListQueryRequest( + normalizeHotelId(request.hotelId()), + trimToNull(request.orderStatus()), + trimToNull(request.groupCode()), + trimToNull(request.confirmationNumber()), + trimToNull(request.keyword()), + request.pageNum(), + request.pageSize()); + } + /** * 批量读取订单快照并按订单 ID 建立索引。 */ @@ -192,7 +257,9 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ /** * 批量读取来源消息主题摘要,避免任务列表按每条任务调用 SourceMessage 详情查询。 */ - private Map findSourceSubjectsById(List tasks) { + private Map findSourceContextsById( + String hotelId, + List tasks) { if (tasks == null || tasks.isEmpty()) { return Map.of(); } @@ -201,9 +268,22 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ .filter(Objects::nonNull) .distinct() .toList(); - Map result = new LinkedHashMap<>(); - for (SourceMessageSummaryResponse summary : sourceMessageQueryService.getSummariesByIds(sourceMessageIds)) { - result.put(Long.valueOf(summary.id()), summary.subject()); + List summaries = sourceMessageQueryService.getSummariesByIds(sourceMessageIds); + Map conversationCounts = sourceMessageQueryService.countByExternalConversationIds( + hotelId, + summaries.stream() + .map(SourceMessageSummaryResponse::externalConversationId) + .filter(Objects::nonNull) + .distinct() + .toList()); + Map result = new LinkedHashMap<>(); + for (SourceMessageSummaryResponse summary : summaries) { + result.put(Long.valueOf(summary.id()), new SourceMessageDisplayContext( + summary.subject(), + summary.senderSummary(), + summary.receivedAt(), + summary.externalConversationId(), + conversationCounts.get(summary.externalConversationId()))); } return result; } @@ -225,7 +305,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ ReservationAiQueryTaskSnapshot task, ReservationAiQueryOrderSnapshot order, ReservationTaskAvailabilityResult availability, - String sourceSubject) { + SourceMessageDisplayContext sourceContext) { return new ReservationTaskWorkbenchItemResult( task.id().toString(), task.orderId().toString(), @@ -241,7 +321,11 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ canProcess(availability), readonlyReasonCode(task, availability), task.sourceMessageId().toString(), - sourceSubject, + sourceContext == null ? null : sourceContext.subject(), + sourceContext == null ? null : sourceContext.senderSummary(), + sourceContext == null ? null : sourceContext.receivedAt(), + sourceContext == null ? null : sourceContext.externalConversationId(), + sourceContext == null ? null : sourceContext.conversationMessageCount(), UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()), UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt())); } @@ -251,7 +335,8 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ */ private ReservationOrderTaskTimelineItemResult toTimelineItem( ReservationAiQueryTaskSnapshot task, - ReservationTaskAvailabilityResult availability) { + ReservationTaskAvailabilityResult availability, + SourceMessageDisplayContext sourceContext) { return new ReservationOrderTaskTimelineItemResult( task.id().toString(), task.systemTaskType(), @@ -262,9 +347,37 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ task.queueParticipation(), canProcess(availability), readonlyReasonCode(task, availability), + task.sourceMessageId().toString(), + sourceContext == null ? null : sourceContext.subject(), + sourceContext == null ? null : sourceContext.senderSummary(), + sourceContext == null ? null : sourceContext.receivedAt(), + sourceContext == null ? null : sourceContext.externalConversationId(), + sourceContext == null ? null : sourceContext.conversationMessageCount(), UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt())); } + /** + * 转换订单快照为前端订单列表单行结果。 + */ + private ReservationOrderListItemResult toOrderListItem( + ReservationAiQueryOrderSnapshot order, + List tasks, + Map availabilityByTaskId) { + return new ReservationOrderListItemResult( + order.id().toString(), + order.hotelId(), + order.orderStatus(), + displayOrderKey(order), + order.temporaryOrderCode(), + groupCode(order), + confirmationNumber(order), + order.displayName(), + openTaskCount(tasks), + nextProcessableTaskId(tasks, availabilityByTaskId), + UtcTimeFormatter.toUtcOffsetDateTime(order.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt())); + } + /** * 转换订单快照为前端订单摘要。 */ @@ -283,6 +396,41 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt())); } + /** + * 按订单 ID 对任务分组,并保持数据库返回顺序。 + */ + private Map> groupTasksByOrderId( + List tasks) { + Map> tasksByOrderId = new LinkedHashMap<>(); + for (ReservationAiQueryTaskSnapshot task : tasks) { + tasksByOrderId.computeIfAbsent(task.orderId(), ignored -> new java.util.ArrayList<>()).add(task); + } + return tasksByOrderId; + } + + /** + * 统计未关闭任务数量,排除 COMPLETED 和 FAILED。 + */ + private Integer openTaskCount(List tasks) { + return Math.toIntExact(tasks.stream() + .filter(task -> !ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus())) + .filter(task -> !ReservationTaskStatus.FAILED.name().equals(task.taskStatus())) + .count()); + } + + /** + * 找到当前第一条可处理任务。FAILED 已结束,不会阻塞后续任务。 + */ + private String nextProcessableTaskId( + List tasks, + Map availabilityByTaskId) { + return tasks.stream() + .filter(task -> canProcess(availabilityOrReadOnly(task, availabilityByTaskId))) + .map(task -> task.id().toString()) + .findFirst() + .orElse(null); + } + /** * 转换 AI 查询任务快照为可处理状态解析器使用的通用任务快照。 */ @@ -417,4 +565,15 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return value.trim(); } + /** + * 来源邮件会话展示上下文。只包含 Inbox 安全摘要,不包含正文、HTML 或附件 URL。 + */ + private record SourceMessageDisplayContext( + String subject, + String senderSummary, + OffsetDateTime receivedAt, + String externalConversationId, + Long conversationMessageCount) { + } + } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationSourceMessageRelatedContextProvider.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationSourceMessageRelatedContextProvider.java new file mode 100644 index 0000000..3e865a5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationSourceMessageRelatedContextProvider.java @@ -0,0 +1,117 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedOrderResult; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedTaskResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageRelatedContextProvider; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** + * Reservation 模块提供的 SourceMessage 关联上下文实现。用于邮件会话详情展示关联订单和任务摘要。 + */ +@Service +public class ReservationSourceMessageRelatedContextProvider implements SourceMessageRelatedContextProvider { + + private final ReservationAiWorkflowRepository workflowRepository; + + /** + * 注入 Reservation 工作流持久化边界,避免平台层直接依赖 reservation 内部表。 + */ + public ReservationSourceMessageRelatedContextProvider(ReservationAiWorkflowRepository workflowRepository) { + this.workflowRepository = workflowRepository; + } + + /** + * 查询指定 SourceMessage 在 reservation 工作流中的订单和任务关联摘要。 + */ + @Override + public SourceMessageRelatedContextResult findRelatedContext(String hotelId, Long sourceMessageId) { + if (sourceMessageId == null) { + return new SourceMessageRelatedContextResult(List.of(), List.of()); + } + List tasks = workflowRepository.findAiQueryTasksBySourceMessageIds( + hotelId, + List.of(sourceMessageId)); + Map ordersById = findRelatedOrders(hotelId, sourceMessageId, tasks); + return new SourceMessageRelatedContextResult( + ordersById.values().stream().map(this::toRelatedOrder).toList(), + tasks.stream().map(this::toRelatedTask).toList()); + } + + /** + * 合并来源消息直接创建的订单和任务当前挂靠订单,避免只展示其中一侧。 + */ + private Map findRelatedOrders( + String hotelId, + Long sourceMessageId, + List tasks) { + Map ordersById = new LinkedHashMap<>(); + workflowRepository.findAiQueryOrdersBySourceMessageIds(hotelId, List.of(sourceMessageId)) + .forEach(order -> ordersById.put(order.id(), order)); + List taskOrderIds = tasks.stream() + .map(ReservationAiQueryTaskSnapshot::orderId) + .distinct() + .toList(); + workflowRepository.findAiQueryOrdersByIds(hotelId, taskOrderIds) + .forEach(order -> ordersById.put(order.id(), order)); + return ordersById; + } + + /** + * 转换订单快照为 SourceMessage 会话可展示的关联订单摘要。 + */ + private SourceMessageRelatedOrderResult toRelatedOrder(ReservationAiQueryOrderSnapshot order) { + return new SourceMessageRelatedOrderResult( + order.id().toString(), + displayOrderKey(order), + order.orderStatus()); + } + + /** + * 转换任务快照为 SourceMessage 会话可展示的关联任务摘要。 + */ + private SourceMessageRelatedTaskResult toRelatedTask(ReservationAiQueryTaskSnapshot task) { + return new SourceMessageRelatedTaskResult( + task.id().toString(), + task.orderId().toString(), + task.systemTaskType(), + task.taskSubtype(), + task.taskStatus(), + task.taskCardType()); + } + + /** + * 生成订单展示键,优先真实业务号,其次临时订单号。 + */ + private String displayOrderKey(ReservationAiQueryOrderSnapshot order) { + String activeBusinessKey = trimToNull(order.activeBusinessKey()); + if (activeBusinessKey != null) { + return activeBusinessKey; + } + String orderBusinessKey = trimToNull(order.orderBusinessKey()); + if (orderBusinessKey != null) { + return orderBusinessKey; + } + if (ReservationOrderKeyType.TEMPORARY.name().equals(order.orderKeyType())) { + return order.temporaryOrderCode(); + } + return order.displayName(); + } + + /** + * 去除空白字符串,空字符串按 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/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index ad59ddd..7e1c5b0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -1,5 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptDraft; @@ -38,6 +40,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.util.ArrayList; @@ -74,6 +77,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl private final ReservationAiWorkflowRepository workflowRepository; private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider; private final ReservationTaskAvailabilityResolver availabilityResolver; + private final SourceMessageQueryService sourceMessageQueryService; private final ObjectMapper objectMapper; /** @@ -83,10 +87,12 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationAiWorkflowRepository workflowRepository, ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider, ReservationTaskAvailabilityResolver availabilityResolver, + SourceMessageQueryService sourceMessageQueryService, ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; this.fieldDefinitionProvider = fieldDefinitionProvider; this.availabilityResolver = availabilityResolver; + this.sourceMessageQueryService = sourceMessageQueryService; this.objectMapper = objectMapper; } @@ -103,10 +109,16 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task); List fields = buildFieldResults(task, taskCard); List operaOperations = findOperaOperationResults(task); + SourceMessageDetailContext sourceContext = findSourceMessageDetailContext(task); return new ReservationTaskDetailResult( task.id().toString(), task.orderId().toString(), task.sourceMessageId().toString(), + sourceContext.subject(), + sourceContext.senderSummary(), + sourceContext.receivedAt(), + sourceContext.externalConversationId(), + sourceContext.conversationMessageCount(), task.systemTaskType(), task.taskCardType(), task.taskStatus(), @@ -1230,6 +1242,30 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .toList(); } + /** + * 查询任务详情顶部来源消息会话摘要。只读取安全摘要,不读取正文或附件 URL。 + */ + private SourceMessageDetailContext findSourceMessageDetailContext(ReservationTaskSnapshot task) { + SourceMessageSummaryResponse summary = sourceMessageQueryService.getSummary(task.sourceMessageId()) + .orElse(null); + if (summary == null) { + return new SourceMessageDetailContext(null, null, null, null, null); + } + Long conversationMessageCount = null; + if (trimToNull(summary.externalConversationId()) != null) { + conversationMessageCount = sourceMessageQueryService.countByExternalConversationIds( + task.hotelId(), + List.of(summary.externalConversationId())) + .get(summary.externalConversationId()); + } + return new SourceMessageDetailContext( + summary.subject(), + summary.senderSummary(), + summary.receivedAt(), + summary.externalConversationId(), + conversationMessageCount); + } + /** * 将字段定义转换为详情字段结果,并从 AI payload 中读取当前值。 */ @@ -1237,6 +1273,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return new ReservationTaskFieldResult( definition.rowNumber(), definition.cardName(), + definition.resultType(), + definition.taskType(), + definition.taskSubtype(), definition.displayArea(), definition.fieldPath(), definition.displayName(), @@ -1250,6 +1289,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl definition.tableEditable(), definition.enumOptions(), definition.requiredRule(), + definition.defaultValueSource(), definition.displayCondition(), definition.validationRule(), definition.writePath(), @@ -1259,6 +1299,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl valueAt(aiPayload, definition.fieldPath())); } + /** + * 任务详情来源消息展示上下文。只包含 SourceMessage Inbox 安全摘要字段。 + */ + private record SourceMessageDetailContext( + String subject, + String senderSummary, + OffsetDateTime receivedAt, + String externalConversationId, + Long conversationMessageCount) { + } + /** * 从 JSON 中按简单点路径读取值,支持矩阵中的数组路径后缀。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java index 1110118..e0e3aeb 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.platform.message.control; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -125,6 +126,62 @@ class SourceMessageControllerTest { org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(1L); } + @Test + void shouldReadConversationOriginalContentAndRelatedReservationSummaryWithoutFrontendKey() throws Exception { + SourceMessageCaptureResult first = captureService.capture(conversationCommand( + "mail-conversation-p0-001", + "conversation-p0-001", + "First full text body with booking context.", + "

First HTML body

", + List.of( + media("INLINE_IMAGE", "image-001.png", "image/png", 100L, + "https://media.example.test/image-001.png?token=secret", "image-001"), + media("ATTACHMENT", "first.pdf", "application/pdf", 1000L, + "https://media.example.test/first.pdf?token=secret", "attachment-001") + ))); + SourceMessageCaptureResult second = captureService.capture(conversationCommand( + "mail-conversation-p0-002", + "conversation-p0-001", + "Second full text body.", + "

Second HTML body

", + List.of())); + Long orderId = 940000000000000101L; + Long transitionId = 940000000000000201L; + Long taskId = 940000000000000301L; + insertRelatedOrder(orderId, first.inboxId(), "GRP-CONVERSATION-P0-001"); + insertRelatedTransition(transitionId, first.inboxId(), "GRP-CONVERSATION-P0-001"); + insertRelatedTask(taskId, orderId, first.inboxId(), transitionId); + + mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", first.inboxId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.conversation.external_conversation_id").value("conversation-p0-001")) + .andExpect(jsonPath("$.conversation.message_count").value(2)) + .andExpect(jsonPath("$.messages.length()").value(2)) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].text_body") + .value(contains(containsString("First full text body")))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body") + .value(contains(containsString("First HTML body")))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].inline_images[0].externalUrl") + .value(contains("https://media.example.test/image-001.png?token=secret"))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].attachments[0].externalUrl") + .value(contains("https://media.example.test/first.pdf?token=secret"))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].related_orders[0].order_id") + .value(contains(orderId.toString()))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].related_tasks[0].task_id") + .value(contains(taskId.toString()))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-002')].text_body") + .value(contains(containsString("Second full text body")))); + + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_source_message_original_access_audit + WHERE inbox_id IN (?, ?) + AND actor_id = 'system:source-message-conversation' + AND access_scene = 'source-message-conversation' + """, Long.class, first.inboxId(), second.inboxId()); + org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(2L); + } + private CaptureSourceMessageCommand command( String externalMessageId, String externalConversationId, @@ -155,4 +212,81 @@ class SourceMessageControllerTest { "attachment-api-001")) ); } + + private CaptureSourceMessageCommand conversationCommand( + String externalMessageId, + String externalConversationId, + String textBody, + String htmlBody, + List mediaItems) { + return new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + externalMessageId, + externalConversationId, + "frame-" + externalMessageId, + "session-api", + Instant.parse("2026-07-06T10:00:00Z"), + "guest@example.test", + "Reservation update", + textBody, + htmlBody, + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + mediaItems + ); + } + + private CaptureSourceMessageMedia media( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalUrl, + String externalMediaId) { + return new CaptureSourceMessageMedia(mediaType, fileName, contentType, sizeBytes, externalUrl, externalMediaId); + } + + private void insertRelatedOrder(Long orderId, Long sourceMessageId, String groupCode) { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_order ( + id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, business_key_source, display_name, + source_message_id, version, created_at, updated_at + ) + VALUES (?, 'HOTEL-TEST', 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, + ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, orderId, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId); + } + + private void insertRelatedTransition(Long transitionId, Long sourceMessageId, String groupCode) { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_ai_transition ( + id, hotel_id, batch_id, source_message_id, source_event_index, array_index, + execution_order, catalog_code, skill_id, result_type, ai_task_type, + system_task_type, task_card_type, task_subtype, current_or_history, + group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed, + ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at + ) + VALUES (?, 'HOTEL-TEST', ?, ?, 1, 1, 1, 'S02', 'conversation_skill', + 'normal_task', 'New Booking', 'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block', + 'current', ?, ?, ?, 0, '{}', '{}', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, transitionId, transitionId - 1, sourceMessageId, groupCode, "1".repeat(64), + transitionId.toString()); + } + + private void insertRelatedTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId) { + jdbcTemplate.update(""" + INSERT INTO workflow_reservation_task ( + id, hotel_id, order_id, source_message_id, ai_transition_id, + result_type, ai_task_type, system_task_type, task_card_type, task_subtype, + task_status, queue_participation, execution_order, blocked_until_parent_completed, + version, created_at, updated_at + ) + VALUES (?, 'HOTEL-TEST', ?, ?, ?, 'normal_task', 'New Booking', + 'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block', 'PENDING_CONFIRM', + 1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, taskId, orderId, sourceMessageId, transitionId); + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index d2bb120..ac486ef 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.matchesPattern; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -90,10 +91,18 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.items[0].readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.items[0].source_message_id").value(source.inboxId().toString())) .andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query List Smoke")) + .andExpect(jsonPath("$.items[0].source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.items[0].source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.items[0].external_conversation_id") + .value("thread-mail-frontend-task-list-001")) + .andExpect(jsonPath("$.items[0].conversation_message_count").value(1)) .andExpect(jsonPath("$.items[0].created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.items[0].updated_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.items[1].task_id").value(secondTaskId.toString())) .andExpect(jsonPath("$.items[1].task_type").value("UPDATE_BOOKING")) + .andExpect(jsonPath("$.items[1].external_conversation_id") + .value("thread-mail-frontend-task-list-001")) + .andExpect(jsonPath("$.items[1].conversation_message_count").value(1)) .andExpect(jsonPath("$.items[1].can_process").value(false)) .andExpect(jsonPath("$.items[1].readonly_reason_code").value("PREVIOUS_TASK_NOT_FINISHED")) .andExpect(jsonPath("$.page.page_num").value(1)) @@ -148,6 +157,13 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.order.updated_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.tasks[0].task_id").value(firstTaskId.toString())) .andExpect(jsonPath("$.tasks[0].task_status").value("COMPLETED")) + .andExpect(jsonPath("$.tasks[0].source_message_id").value(source.inboxId().toString())) + .andExpect(jsonPath("$.tasks[0].source_subject").value("Frontend Query Detail Smoke")) + .andExpect(jsonPath("$.tasks[0].source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.tasks[0].source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.tasks[0].external_conversation_id") + .value("thread-mail-frontend-order-detail-001")) + .andExpect(jsonPath("$.tasks[0].conversation_message_count").value(1)) .andExpect(jsonPath("$.tasks[0].readonly_reason_code").value("TASK_FINISHED")) .andExpect(jsonPath("$.tasks[0].created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.tasks[1].task_id").value(secondTaskId.toString())) @@ -165,6 +181,62 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.tasks.length()").value(0)); } + @Test + void shouldReturnOrderListWithOpenTaskCountAndNextProcessableTask() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage( + "mail-frontend-order-list-001", + "Frontend Query Order List Smoke"); + Long activeOrderId = 930000000000000701L; + Long endedOrderId = 930000000000000702L; + Long pendingTaskId = 930000000000000901L; + Long completedTaskId = 930000000000000902L; + Long failedTaskId = 930000000000000903L; + insertGroupOrder(activeOrderId, source.inboxId(), "GRP-FRONTEND-ORDERS-ACTIVE-001", "ACTIVE"); + insertGroupOrder(endedOrderId, source.inboxId(), "GRP-FRONTEND-ORDERS-ENDED-001", "ENDED"); + insertTransition(930000000000000801L, source.inboxId(), 1, "GRP-FRONTEND-ORDERS-ACTIVE-001", + "New Booking", "NEW_BOOKING", "NEW_BOOKING"); + insertTransition(930000000000000802L, source.inboxId(), 2, "GRP-FRONTEND-ORDERS-ACTIVE-001", + "Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING"); + insertTransition(930000000000000803L, source.inboxId(), 3, "GRP-FRONTEND-ORDERS-ACTIVE-001", + "Cancel Booking", "CANCEL_BOOKING", "CANCEL_BOOKING"); + insertTask(pendingTaskId, activeOrderId, source.inboxId(), 930000000000000801L, "New Booking", + "NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1); + insertTask(completedTaskId, activeOrderId, source.inboxId(), 930000000000000802L, "Update Booking", + "UPDATE_BOOKING", "UPDATE_BOOKING", "COMPLETED", 2); + insertTask(failedTaskId, activeOrderId, source.inboxId(), 930000000000000803L, "Cancel Booking", + "CANCEL_BOOKING", "CANCEL_BOOKING", "FAILED", 3); + + mockMvc.perform(get("/api/reservation/orders") + .param("hotel_id", HOTEL_ID) + .param("keyword", "GRP-FRONTEND-ORDERS-") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items.length()").value(2)) + .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].order_status") + .value(contains("ACTIVE"))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].open_task_count") + .value(contains(1))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].next_processable_task_id") + .value(contains(pendingTaskId.toString()))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].order_status") + .value(contains("ENDED"))) + .andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].open_task_count") + .value(contains(0))) + .andExpect(jsonPath("$.page.total").value(2)); + + mockMvc.perform(get("/api/reservation/orders") + .param("hotel_id", HOTEL_ID) + .param("order_status", "ENDED") + .param("keyword", "GRP-FRONTEND-ORDERS-") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items.length()").value(1)) + .andExpect(jsonPath("$.items[0].order_id").value(endedOrderId.toString())) + .andExpect(jsonPath("$.items[0].order_status").value("ENDED")); + } + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String subject) { return captureService.capture(new CaptureSourceMessageCommand( HOTEL_ID, @@ -186,15 +258,19 @@ class ReservationFrontendQueryControllerTest { } private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) { + insertGroupOrder(orderId, sourceMessageId, groupCode, "ACTIVE"); + } + + private void insertGroupOrder(Long orderId, Long sourceMessageId, String groupCode, String orderStatus) { jdbcTemplate.update(""" INSERT INTO workflow_reservation_order ( id, hotel_id, order_key_type, order_business_key, active_business_key, temporary_order_code, order_status, business_key_source, display_name, source_message_id, version, created_at, updated_at ) - VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, ?, 0, + VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, ?, 'AI_CANDIDATE', ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId); + """, orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, orderStatus, groupCode, sourceMessageId); } private void insertTransition( 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 bc3983c..719699f 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 @@ -1,6 +1,8 @@ package cn.nianxx.thhotel.workflows.reservation.control; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.not; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -48,6 +50,7 @@ class SuperAgentTaskResultControllerTest { private static final String ENDPOINT = "/api/integrations/superagent/task-results"; private static final String CLIENT_ID = "superagent-test-client"; private static final String SECRET = "test-superagent-secret"; + private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"; @Autowired private MockMvc mockMvc; @@ -519,16 +522,29 @@ class SuperAgentTaskResultControllerTest { mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.source_subject").value("M002 SuperAgent intake")) + .andExpect(jsonPath("$.source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.external_conversation_id").value("thread-mail-detail-matrix-001")) + .andExpect(jsonPath("$.conversation_message_count").value(1)) .andExpect(jsonPath("$.field_contract_version").value("code-v1")) .andExpect(jsonPath("$.fields.length()").value(18)) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].display_name") - .value(org.hamcrest.Matchers.contains("Confirmation No."))) + .value(contains("Confirmation No."))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].result_type") + .value(contains("normal_task"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].task_type") + .value(contains("New Booking"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].task_subtype") + .value(contains("new_fit_reservation"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].default_value_source") + .value(contains("AI提取值"))) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].editable") - .value(org.hamcrest.Matchers.contains("是"))) + .value(contains("是"))) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].write_path") - .value(org.hamcrest.Matchers.contains("confirmed_payload_json.case_keys.confirmation_number"))) + .value(contains("confirmed_payload_json.case_keys.confirmation_number"))) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].value") - .value(org.hamcrest.Matchers.contains("CNF-MATRIX-001"))); + .value(contains("CNF-MATRIX-001"))); } @Test