diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 1db07b2..47c804f 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -40,9 +40,8 @@ ## 5. 当前已明确后置事项 - 普通任务切换订单接口后置。 -- 任务列表按订单状态 `order_status` 筛选待后端补充或确认。 -- 任务详情 / 任务写操作是否需要显式 `hotel_id` 待后端确认;前端已先统一配置 `VITE_RESERVATION_HOTEL_ID` 并用于列表和订单详情查询。 -- 邮件 `html_body` 已标记为需要 sanitize:前端联调阶段临时直渲只用于本地看效果,生产前请后端返回已清洗 HTML 或明确清洗字段,并确认 CSP / 外链图片 / 附件 URL 策略。 +- 任务详情 / 任务写操作是否需要显式 `hotel_id` 已确认后置;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 +- 邮件会话详情已返回 `html_body_sanitized` 和 `html_render_mode`;前端展示 HTML 时优先使用清洗字段,`html_body` 只作为原始内容兼容字段。 - SuperAgent 查询接口 4 后置,当前先不开发。 - SuperAgent 查询接口 3 涉及附件解析、OCR、Excel、voucher、rooming list 等能力,当前系统暂不具备,仍后置。 - 用户身份 / 权限方案后置;当前后端审计 actor 仍是本地占位。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index f319ea5..ee213c5 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -37,7 +37,7 @@ | 接口 | 用途 | 前端注意 | | --- | --- | --- | | `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/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段。 | | `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | @@ -49,7 +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。2026-07-08 联调阶段前端可临时直渲 `html_body` 看效果;生产方案必须对 `html_body` 做 sanitize,建议后端返回已清洗 HTML 或明确清洗字段。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | ### 5.1 本轮新增 / 修改接口说明 @@ -58,10 +58,10 @@ | 接口 | 本轮变化 | 前端接入注意 | | --- | --- | --- | | `GET /api/reservation/orders` | 新增订单列表接口。 | `order_status` 不传时默认查询全部订单状态;`page_num` 从 1 开始;`page_size` 后端有最大值保护;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;`next_processable_task_id` 为空表示当前没有可继续处理的任务。 | -| `GET /api/reservation/tasks` | 补齐来源邮件会话摘要字段。 | 列表仍然只返回安全摘要,不返回正文、HTML、附件 URL 或 AI 原始 payload;点击邮件入口时使用 `source_message_id` 调会话详情。 | +| `GET /api/reservation/tasks` | 补齐来源邮件会话摘要字段,并新增 `order_status` 查询参数。 | `order_status` 按任务所属订单状态过滤,支持 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`;列表仍然只返回安全摘要,不返回正文、HTML、附件 URL 或 AI 原始 payload;点击邮件入口时使用 `source_message_id` 调会话详情。 | | `GET /api/reservation/orders/{orderId}` | 补齐 `tasks[]` 每条任务的来源邮件会话摘要字段。 | `include_tasks=false` 可只取订单摘要;时间线顺序由后端按订单队列返回,前端不要自行按创建时间重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 | | `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 | -| `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口。 | 当前唯一推荐路径是这个接口;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | ### 5.2 来源邮件会话字段说明 @@ -83,8 +83,8 @@ - 后端会根据 `sourceMessageId` 定位 `external_conversation_id`,并返回同一会话下全部邮件;如果来源消息没有外部会话 ID,会降级返回当前单封邮件。 - `messages[]` 按后端接收时间正序返回,前端不要重新按创建时间或任务时间排序。 - 返回内容包含完整 `text_body`、`html_body`、`inline_images[]`、`attachments[]`、`related_orders[]`、`related_tasks[]`。 -- `html_body` 已标记为需要 sanitize;`html_sanitize_required=true` 时,生产渲染必须使用清洗后的 HTML。当前为了联调观察效果,前端临时直接渲染原始 `html_body`,上线前需要后端 / 前端共同确认清洗字段、CSP、外链图片和附件 URL 策略。 -- `inline_images[]` 和 `attachments[]` 里的 `externalUrl` 可能包含有时效或访问凭证的外链,前端不得写入普通日志、错误上报、localStorage 或 URL query。 +- `html_body` 是原始 HTML 兼容字段;`html_body_sanitized` 是后端第一版清洗结果,已移除脚本标签、事件属性和危险协议链接。前端生产展示必须优先使用 `html_body_sanitized`,并可用 `html_render_mode=SANITIZED_HTML` 判断渲染模式。 +- 第一版仅处理 HTML 内容安全;`inline_images[]` 和 `attachments[]` 的 `externalUrl` 来自本系统 OSS 服务,暂不做额外拦截,但前端仍不得写入普通日志、错误上报、localStorage 或 URL query。 - 会话详情接口由后端内部写原文读取审计,前端不传 `X-TH-Hotel-Source-Original-Read-Key`。 - 会话详情外层字段主要是 snake_case,但媒体对象沿用原文读取接口字段,当前是 `mediaType`、`fileName`、`contentType`、`sizeBytes`、`externalUrl`、`externalMediaId` 这种 camelCase,前端类型定义需要单独处理。 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 5df1001..fe1fe08 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -9,7 +9,7 @@ | 优先级 | 接口 | 页面 / 场景 | 状态 | | --- | --- | --- | --- | | P0 | 订单列表接口 `GET /api/reservation/orders` | 订单列表页、首页工作台 | 已完成第一版 | -| P0 | 任务列表接口 `GET /api/reservation/tasks` | 任务列表菜单、订单详情任务入口 | 已完成第一版,已补来源邮件会话字段;`order_status` 筛选仍待后端补充或确认 | +| P0 | 任务列表接口 `GET /api/reservation/tasks` | 任务列表菜单、订单详情任务入口 | 已完成第一版,已补来源邮件会话字段和 `order_status` 筛选 | | P0 | 订单详情接口 `GET /api/reservation/orders/{orderId}` | 订单详情页 | 已完成第一版,已补 `tasks[]` 每个任务的来源邮件会话字段 | | P0 | 任务详情读取接口 `GET /api/reservation/tasks/{taskId}` | 任务详情页动态渲染 | 已完成第一版,已补来源邮件字段和 3.0 字段元数据 | | P0 | 任务详情操作接口 | 任务详情保存、确认、OPERA、审计 | 已完成;前端可直接接入 | @@ -25,7 +25,7 @@ | 接口 / 能力 | 当前后端状态 | 前端是否可直接接入 | 仍需后端处理 | | --- | --- | --- | --- | -| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段 | 可以 | 前端暂不传 `order_status`,如任务列表需要按订单状态筛选,请后端补 `order_status` 入参并明确匹配逻辑。 | +| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 | | `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补 `tasks[]` 来源邮件会话字段 | 可以 | 暂无。 | | `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | @@ -38,7 +38,7 @@ | `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。当前前端为看效果临时直渲 `html_body`;请后端标记 / 保障 `html_body` 需要 sanitize,生产前返回已清洗 HTML 或明确清洗字段、CSP、附件 URL 策略。 | +| `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版,已补 `html_body_sanitized` 和 `html_render_mode` | 可以 | 返回完整 text/html、后端清洗后的 HTML、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key,页面展示优先使用 `html_body_sanitized`。 | | `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | | `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 如需独立信息提醒页再新增;第一版可先用任务接口过滤。 | @@ -87,7 +87,7 @@ GET /api/reservation/tasks | `task_type` | 否 | `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`INFORMATIONAL_MESSAGE`。 | | `task_status` | 否 | 任务状态过滤。 | | `task_subtype` | 否 | 任务卡 subtype 过滤。 | -| `order_status` | 待补 | 前端任务列表已有订单状态筛选控件,但当前后端 Controller 未接收该参数,前端先禁用该筛选。建议后端确认是否支持按任务所属订单状态过滤。 | +| `order_status` | 否 | 按任务所属订单状态过滤,支持 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`;不传时保持当前行为。 | | `queue_participation` | 否 | 是否参与订单执行队列。 | | `keyword` | 否 | Group Code、Confirmation No、临时订单号、来源消息安全摘要关键词。 | | `page_num` | 否 | 页码,建议从 1 开始。 | @@ -324,7 +324,8 @@ GET /api/source-message-conversations/{externalConversationId} - 邮件会话详情页需要展示完整正文或清洗后的 HTML、附件、内联图片、发件人摘要、发送 / 接收时间、主题和关联订单 / 任务。 - 前端不在页面上做业务截断或隐藏;但仍只调用本项目后端接口,不直接访问邮箱、AgentBus、数据库或外部附件 URL Secret。 - 如果后端仍需要审计原文读取,应由后端在该业务接口内部处理;前端不保存 `X-TH-Hotel-Source-Original-Read-Key` 一类受控访问 key。 -- 2026-07-08 联调临时策略:前端为快速看效果,会优先直接渲染后端返回的 `html_body`;这不是最终安全方案。请后端明确标记 `html_body` 需要 sanitize,并确认是否输出已清洗 HTML、是否增加 `html_body_sanitized` / `html_render_mode` 等字段,以及生产环境 CSP 和外链图片策略。 +- 2026-07-08 后端已新增 `html_body_sanitized` 和 `html_render_mode`;前端页面展示邮件 HTML 时应优先使用 `html_body_sanitized`,`html_body` 只作为原始内容兼容字段,不建议生产直渲。 +- 第一版仅处理 HTML 内容清洗;附件和内联图片 URL 来自本系统 OSS 服务,暂不做额外拦截或代理转换。 建议入参: @@ -332,7 +333,7 @@ GET /api/source-message-conversations/{externalConversationId} | --- | --- | --- | | `sourceMessageId` | 是 | 入口来源消息 ID。后端据此定位 `externalConversationId`。 | -当前第一版不额外接收 `hotelId`、`includeBody`、`includeRelated`。后端默认按 SourceMessage 自身酒店上下文查询同会话邮件,返回完整 text/html 和关联订单 / 任务摘要,并在内部写原文读取审计。 +当前第一版不额外接收 `hotelId`、`includeBody`、`includeRelated`。后端默认按 SourceMessage 自身酒店上下文查询同会话邮件,返回完整 text/html、清洗后的 HTML 和关联订单 / 任务摘要,并在内部写原文读取审计。 建议返参: @@ -358,7 +359,9 @@ GET /api/source-message-conversations/{externalConversationId} "source_sent_at": "2026-07-06T01:08:00Z", "text_body": "完整邮件正文", "html_body": "

完整邮件 HTML

", + "html_body_sanitized": "

完整邮件 HTML

", "html_sanitize_required": true, + "html_render_mode": "SANITIZED_HTML", "inline_images": [], "attachments": [ { @@ -495,7 +498,7 @@ GET /api/reservation/tasks/{taskId} - 如果前端只做“按后端字段直接渲染”,现有 `fields[]` 可以支撑第一版表单展示;本轮已经扩展 `ReservationTaskFieldResult`,避免前端维护第二套字段矩阵。 - `result_type`、`task_type`、`task_subtype`、`default_value_source` 当前从后端字段矩阵定义透出。 - 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 -- 前端已统一配置 `VITE_RESERVATION_HOTEL_ID`,并会在 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动传 `hotel_id`。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`,请后端确认 taskId / operationId 是否全局唯一;如果不是,需要补可选 `hotel_id` 以避免多酒店数据串读或误写。 +- 前端已统一配置 `VITE_RESERVATION_HOTEL_ID`,并会在 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动传 `hotel_id`。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 ## 9. Message Notification 列表 / 详情接口 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index 596921f..ad27735 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -311,14 +311,14 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity 当前实现记录: -- 已实现 `GET /api/reservation/tasks` 第一版,支持酒店、订单、任务类型、任务状态、任务 subtype、队列参与、关键词和分页筛选。 +- 已实现 `GET /api/reservation/tasks` 第一版,支持酒店、订单、任务类型、任务状态、任务 subtype、任务所属订单状态、队列参与、关键词和分页筛选。 - 已实现 `GET /api/reservation/orders/{orderId}` 第一版,支持返回订单摘要和任务时间线,`include_tasks=false` 时只返回订单摘要。 - 两个接口均复用同订单队列可处理状态计算,`FAILED` 和 `COMPLETED` 视为结束,不阻塞后续任务。 - 已补齐 `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.、关键词和分页筛选;`keyword` 可匹配订单字段,也可匹配来源消息安全摘要命中的 SourceMessage ID;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 -- 已实现邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation`,根据 SourceMessage 定位外部会话,返回完整 text/html、附件外链、内联图片、来源摘要和关联订单 / 任务摘要;原文读取审计由后端内部写入。 +- 已实现邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation`,根据 SourceMessage 定位外部会话,返回完整 text/html、`html_body_sanitized`、`html_render_mode`、附件外链、内联图片、来源摘要和关联订单 / 任务摘要;原文读取审计由后端内部写入,前端展示 HTML 时优先使用清洗字段。 - 已实现 dev/test 受控演示数据 seed 接口 `POST /api/system/reservation/demo-data`,默认关闭,需配置 `reservation.demo-data.enabled=true` 和访问口令;生成真实落库的任务列表、订单列表、订单详情、任务详情和邮件会话详情演示数据。 - Message Notification 独立列表 / 详情、任务卡前端字段白名单独立接口继续后置;第一版分别复用任务列表 / 任务详情和 `fields[]` 元数据。 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 index 5aa687b..8ab4f25 100644 --- 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 @@ -16,7 +16,9 @@ import java.util.List; * @param sourceSentAt 邮件来源发送时间,UTC * @param textBody 完整纯文本正文 * @param htmlBody 完整 HTML 正文,前端展示前必须 sanitize + * @param htmlBodySanitized 后端第一版清洗后的 HTML,前端生产展示应优先使用 * @param htmlSanitizeRequired 是否要求前端 sanitize HTML + * @param htmlRenderMode HTML 渲染建议模式,例如 SANITIZED_HTML 或 TEXT_ONLY * @param inlineImages 内联图片外链 * @param attachments 附件外链 * @param relatedOrders 关联订单摘要 @@ -39,8 +41,12 @@ public record SourceMessageConversationMessageResult( String textBody, @JsonProperty("html_body") String htmlBody, + @JsonProperty("html_body_sanitized") + String htmlBodySanitized, @JsonProperty("html_sanitize_required") Boolean htmlSanitizeRequired, + @JsonProperty("html_render_mode") + String htmlRenderMode, @JsonProperty("inline_images") List inlineImages, List attachments, 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 index 2a02fb3..acd6514 100644 --- 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 @@ -18,6 +18,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,20 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver 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 static final String HTML_RENDER_MODE_SANITIZED = "SANITIZED_HTML"; + private static final String HTML_RENDER_MODE_TEXT_ONLY = "TEXT_ONLY"; + private static final Pattern DANGEROUS_TAG_PATTERN = Pattern.compile( + "(?is)<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*>.*?<\\s*/\\s*\\1\\s*>" + + "|<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*/?\\s*>"); + private static final Pattern EVENT_ATTRIBUTE_PATTERN = Pattern.compile( + "(?i)\\s+on[a-z0-9_-]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)"); + private static final Pattern STYLE_ATTRIBUTE_PATTERN = Pattern.compile( + "(?i)\\s+style\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)"); + private static final Pattern DANGEROUS_URL_ATTRIBUTE_PATTERN = Pattern.compile( + "(?i)\\s+(href|src|xlink:href|formaction)\\s*=\\s*" + + "(\"\\s*(?:javascript|data|vbscript):[^\"]*\"" + + "|'\\s*(?:javascript|data|vbscript):[^']*'" + + "|\\s*(?:javascript|data|vbscript):[^\\s>]+)"); private final SourceMessageInboxRepository inboxRepository; private final List relatedContextProviders; @@ -101,6 +116,7 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) { SourceMessageOriginalContent originalContent = readOriginalAndAudit(message); SourceMessageRelatedContextResult relatedContext = findRelatedContext(message); + String htmlBody = originalContent.htmlBody(); return new SourceMessageConversationMessageResult( message.id().toString(), message.externalMessageId(), @@ -110,8 +126,10 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver UtcTimeFormatter.toUtcOffsetDateTime(message.receivedAt()), UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()), originalContent.textBody(), - originalContent.htmlBody(), + htmlBody, + sanitizeHtml(htmlBody), true, + htmlRenderMode(htmlBody), filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE), filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT), relatedContext.relatedOrders(), @@ -173,4 +191,27 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver item.externalUrl(), item.externalMediaId()); } + + /** + * 第一版 HTML 清洗:保留邮件正文基本结构,移除脚本标签、事件属性和危险协议链接。 + */ + private String sanitizeHtml(String htmlBody) { + if (htmlBody == null) { + return null; + } + String sanitized = DANGEROUS_TAG_PATTERN.matcher(htmlBody).replaceAll(""); + sanitized = EVENT_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll(""); + sanitized = STYLE_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll(""); + return DANGEROUS_URL_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll(""); + } + + /** + * 返回前端渲染建议;有 HTML 时优先使用后端清洗后的安全 HTML。 + */ + private String htmlRenderMode(String htmlBody) { + if (htmlBody == null || htmlBody.isBlank()) { + return HTML_RENDER_MODE_TEXT_ONLY; + } + return HTML_RENDER_MODE_SANITIZED; + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java index 12f0757..25d830a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java @@ -8,6 +8,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.request; * @param taskType 系统主任务类型过滤 * @param taskStatus 任务状态过滤 * @param taskSubtype 任务 subtype 过滤 + * @param orderStatus 任务所属订单状态过滤 * @param queueParticipation 是否参与订单执行队列 * @param keyword 业务号、临时订单号、任务字段或来源消息安全摘要关键词 * @param pageNum 页码,从 1 开始 @@ -19,6 +20,7 @@ public record ReservationTaskWorkbenchQueryRequest( String taskType, String taskStatus, String taskSubtype, + String orderStatus, Boolean queueParticipation, String keyword, Integer pageNum, 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 f2329b4..bb33258 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 @@ -39,6 +39,7 @@ public class ReservationFrontendQueryController { @RequestParam(name = "task_type", required = false) String taskType, @RequestParam(name = "task_status", required = false) String taskStatus, @RequestParam(name = "task_subtype", required = false) String taskSubtype, + @RequestParam(name = "order_status", required = false) String orderStatus, @RequestParam(name = "queue_participation", required = false) Boolean queueParticipation, @RequestParam(required = false) String keyword, @RequestParam(name = "page_num", required = false) Integer pageNum, @@ -49,6 +50,7 @@ public class ReservationFrontendQueryController { taskType, taskStatus, taskSubtype, + orderStatus, queueParticipation, keyword, pageNum, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java index 55a4bd4..94b66c1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java @@ -2,11 +2,78 @@ package cn.nianxx.thhotel.workflows.reservation.mapper; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * Reservation 任务 Mapper,只负责本表持久化访问。 */ @Mapper public interface ReservationTaskMapper extends BaseMapper { + + /** + * 分页查询前端任务列表摘要,支持按任务所属订单状态过滤。 + */ + @Select(""" + + """) + Page selectFrontendTaskPage( + Page page, + @Param("hotelId") String hotelId, + @Param("orderId") Long orderId, + @Param("orderStatus") String orderStatus, + @Param("taskType") String taskType, + @Param("taskStatus") String taskStatus, + @Param("taskSubtype") String taskSubtype, + @Param("queueParticipation") Boolean queueParticipation, + @Param("keyword") String keyword, + @Param("keywordOrderIds") List keywordOrderIds, + @Param("sourceMessageIds") List sourceMessageIds); } 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 82865d2..cc58623 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 @@ -453,29 +453,18 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork int pageSize) { List keywordOrderIds = findOrderIdsByKeyword(request.hotelId(), request.keyword()); List sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds; - Page page = taskMapper.selectPage(Page.of(pageNum, pageSize), - Wrappers.lambdaQuery() - .eq(ReservationTaskEntity::getHotelId, request.hotelId()) - .eq(request.orderId() != null, ReservationTaskEntity::getOrderId, request.orderId()) - .eq(hasText(request.taskType()), ReservationTaskEntity::getSystemTaskType, trim(request.taskType())) - .eq(hasText(request.taskStatus()), ReservationTaskEntity::getTaskStatus, trim(request.taskStatus())) - .eq(hasText(request.taskSubtype()), ReservationTaskEntity::getTaskSubtype, trim(request.taskSubtype())) - .eq(request.queueParticipation() != null, - ReservationTaskEntity::getQueueParticipation, - request.queueParticipation()) - .and(hasText(request.keyword()), wrapper -> { - wrapper.like(ReservationTaskEntity::getSystemTaskType, trim(request.keyword())) - .or() - .like(ReservationTaskEntity::getTaskSubtype, trim(request.keyword())); - if (!keywordOrderIds.isEmpty()) { - wrapper.or().in(ReservationTaskEntity::getOrderId, keywordOrderIds); - } - if (!sourceMessageIds.isEmpty()) { - wrapper.or().in(ReservationTaskEntity::getSourceMessageId, sourceMessageIds); - } - }) - .orderByAsc(ReservationTaskEntity::getOrderId) - .orderByAsc(ReservationTaskEntity::getExecutionOrder)); + Page page = taskMapper.selectFrontendTaskPage( + Page.of(pageNum, pageSize), + request.hotelId(), + request.orderId(), + trim(request.orderStatus()), + trim(request.taskType()), + trim(request.taskStatus()), + trim(request.taskSubtype()), + request.queueParticipation(), + trim(request.keyword()), + keywordOrderIds, + sourceMessageIds); return new ReservationPageSnapshot<>( toAiQueryTaskSnapshots(request.hotelId(), page.getRecords()), page.getTotal(), 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 fb215ee..119f39c 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 @@ -177,6 +177,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ null, null, null, + null, DEFAULT_PAGE_NUM, DEFAULT_PAGE_SIZE); } @@ -186,6 +187,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ trimToNull(request.taskType()), trimToNull(request.taskStatus()), trimToNull(request.taskSubtype()), + trimToNull(request.orderStatus()), request.queueParticipation(), trimToNull(request.keyword()), request.pageNum(), 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 e0e3aeb..26f9416 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 @@ -132,7 +132,8 @@ class SourceMessageControllerTest { "mail-conversation-p0-001", "conversation-p0-001", "First full text body with booking context.", - "

First HTML body

", + "

First HTML body

" + + "unsafe", List.of( media("INLINE_IMAGE", "image-001.png", "image/png", 100L, "https://media.example.test/image-001.png?token=secret", "image-001"), @@ -161,6 +162,20 @@ class SourceMessageControllerTest { .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')].html_body") + .value(contains(containsString("")))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized") + .value(contains(containsString("First HTML body")))) + .andExpect(jsonPath("$.messages[?(@.external_message_id=='mail-conversation-p0-001')].html_body_sanitized") + .value(contains(not(containsString("