From 6d2b3e8ccd279ea1c6bd6807a07c16f05767c4d8 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 9 Jul 2026 15:25:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Debug=20EML=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=B0=83=E8=AF=95=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/frontend-backend/README.md | 1 + .../backend-to-frontend-notes.md | 10 +- .../debug-eml-page-integration-guide.md | 241 ++++++++++++++++++ docs/project/go-live-notes.md | 4 + .../M004-debug-eml-superagent-upload-v1.md | 27 +- .../result/DebugEmlSuperAgentRunResult.java | 9 + .../DebugEmlSuperAgentRunServiceImpl.java | 88 ++++++- .../SourceMessageHtmlSanitizerService.java | 17 ++ .../impl/EmlMessageParseServiceImpl.java | 19 +- .../SourceMessageConversationServiceImpl.java | 47 +--- ...SourceMessageHtmlSanitizerServiceImpl.java | 52 ++++ .../DebugEmlSuperAgentControllerTest.java | 132 +++++++++- .../impl/EmlMessageParseServiceImplTest.java | 24 ++ 13 files changed, 615 insertions(+), 56 deletions(-) create mode 100644 docs/project/frontend-backend/debug-eml-page-integration-guide.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageHtmlSanitizerService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageHtmlSanitizerServiceImpl.java diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 47c804f..16890a9 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -12,6 +12,7 @@ | --- | --- | | `backend-to-frontend-notes.md` | 后端提醒前端的注意事项,包含项目开发、业务规则、接口使用和安全边界。 | | `frontend-to-backend-api-requests.md` | 前端提醒后端需要增加或补齐的接口,包含建议入参和返参草案。 | +| `debug-eml-page-integration-guide.md` | Debug EML 页面前端对接指南,包含页面结构、上传接口、响应展示、错误处理和安全注意事项。 | ## 3. 当前字段来源分工 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 74e72f6..083dc9f 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -63,7 +63,7 @@ | `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` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | -| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报。 | +| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留和安全 HTML 字段。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报。 | ### 5.2 来源邮件会话字段说明 @@ -159,15 +159,21 @@ hotel_id: HOTEL-TEST run_label: 可选调试标签 ``` +页面级对接细节请优先阅读 `docs/project/frontend-backend/debug-eml-page-integration-guide.md`。 + 前端注意: - 该接口只用于 dev/test 调试页面,不是生产普通业务页面接口。 - 接口会解析 `.eml`,上传原始邮件、内联图片和附件到本系统阿里云 OSS,替换 HTML 内 `cid:` 图片,再写入 SourceMessage Inbox。 - SourceMessage 来源 provider 固定为 `DEBUG_EML_UPLOAD`,用于和 AgentBus 入库邮件区分。 +- 当前 AgentBus 实时收到邮件后自动推 SuperAgent 还没有做;这个接口是人工 Debug 上传链路,不代表实时生产链路。 +- `external_message_id` 是后端生成的 Debug 独立 ID,格式类似 `debug-eml-run-{debugRunId}-{sha256前缀}`;原始邮件 `Message-ID` 保存在 `agentbus_like_payload.source.original_message_id`。 +- 邮件会话解析支持 `References`、`In-Reply-To` 和 `Thread-Index`,但 Debug EML 的 `external_message_id` 不使用原始 `Message-ID` 做幂等。 - `agentbus_like_payload.schema_version` 固定为 `debug-eml-upload-v1`,前端可用于调试展示和版本判断。 - 第一版只返回 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 - `X-TH-Hotel-Debug-Upload-Key` 只能由调试人员在受控环境手动提供,不能放入 `VITE_*`、源码、构建产物、URL query、localStorage、错误上报或普通日志。 -- 返回的 `uploaded_media[]`、`original_eml_oss_url`、`html_body_with_oss_urls` 可能包含 OSS URL;前端不要写入普通日志、埋点、错误上报或 URL query。 +- 返回的 `html_body_sanitized` 复用邮件会话详情的安全策略,前端展示 HTML 时优先使用;`html_body_with_oss_urls` 只作为调试原始处理结果,不建议直接渲染。 +- 返回的 `uploaded_media[]`、`original_eml_oss_url`、`html_body_with_oss_urls`、`html_body_sanitized` 可能包含 OSS URL;前端不要写入普通日志、埋点、错误上报或 URL query。 - `superagent_parsed_json` 为空时,前端展示 `superagent_raw_answer` 和 `warnings[]`,不要假定 SuperAgent 总能返回 JSON。 ## 6. 不给前端直接调用的接口 diff --git a/docs/project/frontend-backend/debug-eml-page-integration-guide.md b/docs/project/frontend-backend/debug-eml-page-integration-guide.md new file mode 100644 index 0000000..a01e86e --- /dev/null +++ b/docs/project/frontend-backend/debug-eml-page-integration-guide.md @@ -0,0 +1,241 @@ +# Debug EML 页面前端对接指南 + +## 1. 文档定位 + +本文给前端 Debug 页面开发使用,说明 `.eml` 邮件上传到 SuperAgent 调试链路应该做什么、调用哪个接口、如何组织请求、如何展示返回结果,以及哪些安全边界不能越过。 + +相关背景文档: + +- `docs/project/requirements/M004-debug-eml-superagent-upload-v1.md` +- `docs/project/frontend-backend/backend-to-frontend-notes.md` + +## 2. 页面目标 + +Debug EML 页面第一版只做一件事: + +上传单封 `.eml` 邮件,后端解析邮件、上传原始邮件 / 内联图片 / 附件到本系统阿里云 OSS,替换 HTML 中的 `cid:` 图片,写入 SourceMessage Inbox,然后调用 SuperAgent Open API,并把 SuperAgent 返回结果展示给调试人员。 + +第一版明确不做: + +- 不创建 Reservation 订单。 +- 不创建 Reservation 任务。 +- 不调用 SuperAgent 任务结果通知接口。 +- 不触发 AgentBus 实时链路。 +- 不做批量上传。 +- 不提供 Debug run 历史列表或详情查询。 +- 不在生产普通业务页面开放。 + +## 3. 页面建议结构 + +建议页面分为以下区域: + +| 区域 | 展示 / 操作 | 说明 | +| --- | --- | --- | +| 上传配置区 | `hotel_id`、Debug 上传口令、`run_label`、`.eml` 文件选择、提交按钮 | Debug 上传口令只能由调试人员临时输入,不能写进前端源码、环境变量、localStorage 或 URL。 | +| 执行状态区 | loading、成功、失败、耗时、本次 `debug_run_id` | 提交后禁用按钮,避免重复点击;失败时展示安全错误摘要。 | +| SourceMessage 追溯区 | `source_message_id`、`source_provider`、`external_message_id`、`external_conversation_id` | 用于确认已写入 SourceMessage Inbox。 | +| 邮件内容预览区 | `html_body_sanitized`、纯文本、附件列表、内联图片列表 | HTML 展示必须优先使用 `html_body_sanitized`。 | +| SuperAgent 结果区 | `superagent_parsed_json`、`superagent_raw_answer`、`warnings[]` | JSON 可以格式化展示;raw answer 用于排查 SuperAgent 非 JSON 输出。 | +| 调试 Payload 区 | `agentbus_like_payload` | 只用于调试展示,不让用户编辑后重新提交。 | + +## 4. 接口 + +```text +POST /api/system/debug/eml-superagent-runs +Content-Type: multipart/form-data +Accept: application/json +Header: X-TH-Hotel-Debug-Upload-Key: <调试上传口令> +``` + +注意: + +- 该接口只有在后端 `debug.eml-upload.enabled=true` 时存在;如果后端未开启,前端可能收到 `404`。 +- 请求必须使用 `FormData`。 +- 前端不要手动设置 `Content-Type`,让浏览器自动生成 multipart boundary。 +- `X-TH-Hotel-Debug-Upload-Key` 不能进入 `VITE_*`、源码、构建产物、localStorage、URL query、错误上报或普通日志。 + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | File | 是 | `.eml` 邮件文件。前端文件选择器建议 `accept=".eml,message/rfc822"`。 | +| `hotel_id` | string | 是 | 酒店上下文 ID;本地 / test 可使用当前前端配置的 `VITE_RESERVATION_HOTEL_ID`。 | +| `run_label` | string | 否 | 调试标签,例如 `frontend-debug-smoke`,方便后端日志和数据库排查。 | + +前端调用示例: + +```ts +export async function uploadDebugEml(input: { + baseUrl: string + hotelId: string + debugUploadKey: string + file: File + runLabel?: string +}) { + const form = new FormData() + form.append('file', input.file) + form.append('hotel_id', input.hotelId) + if (input.runLabel?.trim()) { + form.append('run_label', input.runLabel.trim()) + } + + const response = await fetch(`${input.baseUrl}/api/system/debug/eml-superagent-runs`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-TH-Hotel-Debug-Upload-Key': input.debugUploadKey, + }, + body: form, + }) + + const data = await response.json() + if (!response.ok) { + throw data + } + return data +} +``` + +## 5. 成功响应 + +成功状态码:`201 Created` + +核心字段: + +| 字段 | 类型 | 前端用途 | +| --- | --- | --- | +| `debug_run_id` | string | 展示本次调试运行 ID。 | +| `source_message_id` | string | 展示 SourceMessage 内部 ID;可用于后续排查。 | +| `source_provider` | string | 固定为 `DEBUG_EML_UPLOAD`。 | +| `external_message_id` | string | Debug 独立外部消息 ID,格式类似 `debug-eml-run-{debugRunId}-{sha256前缀}`。 | +| `external_conversation_id` | string | 邮件会话 ID,可能来自 `References` / `In-Reply-To` / `Thread-Index`。 | +| `original_eml_oss_url` | string | 原始 `.eml` OSS URL;只在受控调试页面展示。 | +| `original_eml_sha256` | string | 原始 `.eml` SHA-256。 | +| `uploaded_media[]` | array | 原始邮件、内联图片和附件列表。 | +| `html_body_with_oss_urls` | string | 替换 `cid:` 后的 HTML,作为调试原始处理结果展示,不建议直接渲染。 | +| `html_body_sanitized` | string | 后端清洗后的 HTML,前端预览邮件正文时优先使用。 | +| `html_sanitize_required` | boolean | 当前为 `true`,提醒前端不要直接信任原始 HTML。 | +| `html_render_mode` | string | 当前可能为 `SANITIZED_HTML` 或 `TEXT_ONLY`。 | +| `agentbus_like_payload` | object | 后端发送给 SuperAgent 的结构化 payload。 | +| `superagent_session_id` | string | SuperAgent session ID。 | +| `superagent_run_id` | string | SuperAgent run ID。 | +| `superagent_raw_answer` | string | SuperAgent 最终原始文本回答。 | +| `superagent_parsed_json` | object/null | 后端尝试解析出的 JSON;解析失败时可能为空。 | +| `warnings[]` | string[] | 安全或解析警告,可在页面顶部或结果区展示。 | +| `status` | string | Debug run 状态。 | + +`uploaded_media[]` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `media_type` | string | `ORIGINAL_EMAIL`、`INLINE_IMAGE`、`ATTACHMENT`。 | +| `file_name` | string | 文件名。 | +| `content_type` | string | MIME 类型。 | +| `size_bytes` | number | 文件大小。 | +| `external_url` | string | OSS URL。 | +| `external_media_id` | string | 调试链路媒体 ID,例如 `cid:inline-001`。 | +| `object_key` | string | OSS 对象路径。 | + +关键展示规则: + +- 邮件正文 iframe / 富文本预览优先使用 `html_body_sanitized`。 +- `html_body_with_oss_urls` 可以放在“原始处理 HTML”折叠面板中,不作为默认渲染内容。 +- `agentbus_like_payload.source.original_message_id` 是原始邮件 `Message-ID`。 +- `external_message_id` 是 Debug 链路生成的独立 ID,不等同于原始 `Message-ID`。 +- `superagent_parsed_json` 有值时优先展示格式化 JSON;没有值时展示 `superagent_raw_answer`。 + +## 6. 错误响应 + +错误响应结构: + +```json +{ + "error_code": "DEBUG_UPLOAD_KEY_INVALID", + "message": "Debug 上传访问口令缺失或错误。" +} +``` + +常见错误: + +| HTTP 状态 | `error_code` | 前端建议 | +| --- | --- | --- | +| `400` | `REQUEST_FIELD_REQUIRED` | 提示缺少必填字段,检查文件、`hotel_id` 或请求参数。 | +| `400` | `EML_FILE_REQUIRED` | 提示请选择 `.eml` 文件。 | +| `400` | `EML_FILE_TOO_LARGE` | 提示文件超过后端限制;当前默认上限通常为 10MB,以环境配置为准。 | +| `400` | `INVALID_FILE_TYPE` | 提示只支持 `.eml`。 | +| `401` | `DEBUG_UPLOAD_KEY_INVALID` | 提示调试上传口令缺失或错误。 | +| `404` | 无固定结构 | 后端未开启 Debug EML 接口或路径错误。 | +| `422` | `EML_PARSE_FAILED` | 提示邮件文件无法解析。 | +| `502` | `OSS_UPLOAD_FAILED` | 提示 OSS 上传失败,调试人员联系后端排查。 | +| `502` | `SUPERAGENT_OPEN_API_FAILED` | 提示 SuperAgent 调用失败;SourceMessage 可能已经写入。 | +| `500` | `DEBUG_EML_RUN_FAILED` | 提示 Debug 链路内部失败。 | + +错误展示原则: + +- 页面只展示 `message` 和 `error_code`。 +- 不展示请求 header、上传口令、完整邮件正文、完整 HTML、OSS 签名 URL 或 Provider 原始错误。 +- `SUPERAGENT_OPEN_API_FAILED` 时,不要把页面理解为“邮件上传没有发生”;后端可能已经完成 OSS 上传和 SourceMessage 写入。 + +## 7. 前端状态流转 + +建议页面状态: + +```text +idle +→ validating +→ uploading +→ waiting_superagent +→ succeeded +或 +→ failed +``` + +实现建议: + +- 文件未选择、`hotel_id` 为空、Debug 上传口令为空时禁用提交按钮。 +- 提交后禁用文件选择和提交按钮,避免重复上传。 +- SuperAgent 调用可能耗时较长,页面 loading 文案不要只写“上传中”,建议写“正在解析邮件并等待 SuperAgent 返回”。 +- 成功后保留本次响应在页面内存中;当前没有 Debug run 查询接口,刷新页面后需要重新上传。 +- 再次上传同一封 `.eml` 会生成新的 `external_message_id` 和新的 SourceMessage,前端不要按原始 `Message-ID` 去重。 + +## 8. 安全与日志 + +前端必须遵守: + +- 不把 Debug 上传口令写入源码、`.env`、`VITE_*`、localStorage、sessionStorage、URL query、错误上报或普通日志。 +- 不把 `uploaded_media[].external_url`、`original_eml_oss_url`、邮件 HTML、附件 URL 写入普通日志或埋点。 +- 不在生产普通业务菜单暴露该页面。 +- 不允许前端直接调用 SuperAgent、AgentBus、OSS、数据库或 OPERA。 +- 不使用 Debug EML 页面结果改变订单、任务或 OPERA 状态。 + +## 9. 联调前置条件 + +后端环境需要具备: + +- `DEBUG_EML_UPLOAD_*_ENABLED=true`。 +- `DEBUG_EML_UPLOAD_*_ACCESS_KEY` 已配置。 +- 阿里云 OSS endpoint、bucket、access key、public base URL 已配置。 +- `SUPERAGENT_*_OPEN_API_ENABLED=true`。 +- SuperAgent / DeerFlow Open API base URL 和 API key 已配置。 + +前端需要具备: + +- 可配置后端 `BASE_URL`。 +- 可配置或输入 `hotel_id`。 +- 调试人员临时输入 Debug 上传口令。 +- 准备一封 `.eml` 样例邮件。 + +## 10. 与其他页面的关系 + +- Debug EML 页面是调试工具,不是 Message Notification 页面。 +- Debug EML 页面写入的 SourceMessage `provider=DEBUG_EML_UPLOAD`,用于和 AgentBus 来源区分。 +- Debug EML 页面第一版不创建订单和任务,所以上传成功后任务列表和订单列表不会因为这次上传自动新增业务数据。 +- AgentBus 实时收到邮件后自动推 SuperAgent 当前还没做,不能用 Debug EML 页面代表生产实时链路。 + +## 11. 当前后置事项 + +- Debug run 历史列表 / 详情查询接口未做。 +- Debug run 取消或超时轮询接口未做。 +- 批量上传未做。 +- 前端白名单元数据独立接口未做。 +- 用户身份 / 权限体系未接入;当前依赖调试上传口令保护。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index eb4af8d..6d6d840 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -137,7 +137,10 @@ - Debug EML 上传接口会接收原始邮件、上传 OSS 并调用 SuperAgent,风险和成本高于普通查询接口。 - Debug EML 上传 key、SuperAgent Open API Key、阿里云 OSS AccessKey 都不得进入前端源码、`VITE_*`、镜像、普通日志或文档真实值。 - Debug EML 写入 SourceMessage Inbox 时 `provider=DEBUG_EML_UPLOAD`,不能伪装为 AgentBus 来源。 +- Debug EML 写入 SourceMessage Inbox 时 `external_message_id` 是后端生成的 `debug-eml-run-{debugRunId}-{sha256前缀}`;原始邮件 `Message-ID` 只保存在 payload 的 `source.original_message_id`。 +- Debug EML 返回 `html_body_sanitized`、`html_sanitize_required`、`html_render_mode`,前端展示 HTML 时应优先使用清洗字段。 - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 +- AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 ## 4. 数据库上线注意事项 @@ -232,6 +235,7 @@ Header: X-TH-Hotel-Access-Scene AgentBus 是消息入口,不是 AI Provider。生产实时链路必须保持以下边界: - 只写 SourceMessage Inbox。 +- 不自动调用 SuperAgent。 - 不创建 MessageEvent、Evidence、Case、Task、Operation、Receipt。 - 不调用 OHIP、ERP、支付系统等业务写接口。 - 不自动发送 ACK、`task.result` 或客户回复。 diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md index cfe6648..29baa14 100644 --- a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -12,7 +12,7 @@ ## 1. 文档定位 -本文记录 Debug EML 上传链路的第一版后端设计。该能力用于在没有 AgentBus 实时入口的情况下, +本文记录 Debug EML 上传链路的第一版后端设计。该能力用于在不依赖 AgentBus 实时自动处理的情况下, 由调试页面上传 `.eml` 邮件文件,后端解析邮件、转存附件和内联图片到本系统阿里云 OSS, 再组装成 SuperAgent 可处理的邮件输入并调用 SuperAgent Open API。 @@ -26,6 +26,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - 第一版只展示 SuperAgent 生成结果,不落业务订单和任务。 - 上传邮件仍要写入 SourceMessage Inbox。 - Debug 上传来源必须和 AgentBus 来源区分,建议 `provider=DEBUG_EML_UPLOAD`。 +- AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现;现有 AgentBus 实时链路只负责写入 SourceMessage Inbox。 - 使用真实阿里云 OSS,不使用本地 mock OSS。 - 原始 `.eml` 文件本身也上传到阿里云 OSS,便于后续调试追溯。 - 内联图片和普通附件都上传到阿里云 OSS。 @@ -43,7 +44,12 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - HTML `cid:` 图片替换为 OSS URL。 - AgentBus-like payload 组装。 - SourceMessage Inbox 写入,`provider=DEBUG_EML_UPLOAD`,`schema_version=debug-eml-upload-v1`。 +- Debug EML 的 `external_message_id` 由后端生成,格式为 `debug-eml-run-{debugRunId}-{sha256前缀}`,避免同一封 `.eml` 多次上传被 SourceMessage 幂等覆盖。 +- 原始邮件 `Message-ID` 不再作为 Debug EML 的 `external_message_id`,而是保存到 `agentbus_like_payload.source.original_message_id`。 +- EML 会话解析支持 `References` / `In-Reply-To` / `Thread-Index`;缺失时再回退到当前消息 ID 或 debug 会话 ID。 - SourceMessage 写入成功后先把 debug run 标记为 `SOURCE_CAPTURED`;即使后续 SuperAgent 调用失败,也保留 SourceMessage 和 OSS 原文追溯信息。 +- HTML 正文复用邮件会话详情的 sanitize 策略,返回 `html_body_sanitized`、`html_sanitize_required` 和 `html_render_mode`。 +- `cid:` 图片替换支持大小写不敏感的 scheme,并兼容常见的尖括号和 URL 编码尖括号形式。 - SuperAgent Open API client:创建 session、发送 `messages/stream`、解析 SSE 最终回答。 - `platform_debug_eml_superagent_run` 持久化。 - Controller / Service / ServiceImpl / Repository / Mapper / Entity / DTO / Result 按当前后端目录规范落位。 @@ -57,6 +63,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - 后端可以解析邮件头、纯文本正文、HTML 正文、内联图片和附件。 - 后端可以把原始 `.eml`、内联图片和附件上传到阿里云 OSS。 - 后端可以把 HTML 正文中的 `cid:` 引用替换成 OSS URL。 +- 后端可以返回清洗后的 HTML 字段,前端优先展示 `html_body_sanitized`。 - 后端可以组装 AgentBus-like 邮件 payload,保持和当前 SourceMessage Inbox / SuperAgent 输入口径接近。 - 后端可以写入 SourceMessage Inbox,并明确标记来源为 Debug EML Upload。 - 后端可以调用 SuperAgent Open API 并解析最终返回。 @@ -74,6 +81,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - 不做邮件多封批量上传。 - 不做 ZIP、MSG、PDF、图片 OCR 或 Excel 解析。 - 不做 SourceMessage Replay 到 MessageEvent / Evidence。 +- 不做 AgentBus 实时邮件自动调用 SuperAgent;该能力后续需要单独设计触发、幂等、限流和失败补偿。 - 不做普通任务切换订单。 - 不做 SuperAgent 查询接口 3、4。 - 不接入完整用户 / 权限体系。 @@ -136,6 +144,9 @@ Header: X-TH-Hotel-Debug-Upload-Key: | `original_eml_sha256` | 原始 `.eml` 文件 SHA-256 | | `uploaded_media[]` | 已上传 OSS 的内联图片和附件 | | `html_body_with_oss_urls` | 替换 `cid:` 后的 HTML 正文 | +| `html_body_sanitized` | 复用邮件会话安全策略清洗后的 HTML 正文,前端调试展示优先使用 | +| `html_sanitize_required` | 固定提示前端 HTML 需要按安全策略展示 | +| `html_render_mode` | HTML 推荐渲染模式,当前优先返回 `SANITIZED_HTML` | | `agentbus_like_payload` | 发送给 SuperAgent 的结构化邮件 payload,包含 `schema_version=debug-eml-upload-v1` | | `superagent_session_id` | SuperAgent Open API session ID,失败时为空 | | `superagent_run_id` | SuperAgent 返回的 run ID,失败时为空 | @@ -166,7 +177,7 @@ Debug 链路调用现有 `SourceMessageCaptureService.capture`,建议映射如 | `hotelId` | 请求参数 `hotel_id` | | `provider` | `DEBUG_EML_UPLOAD` | | `channel` | `EMAIL` | -| `externalMessageId` | 优先邮件 `Message-ID` 的规范化值;缺失时使用 `debug-eml-{sha256前缀}` | +| `externalMessageId` | Debug 链路生成值,格式为 `debug-eml-run-{debugRunId}-{sha256前缀}`;同一封 `.eml` 重复上传也会生成独立 SourceMessage | | `externalConversationId` | 优先邮件 `In-Reply-To` / `References` / `Thread-Index` 可解析会话值;缺失时使用 `debug-eml-thread-{externalMessageId}` | | `providerFrameId` | `debug_run_id` | | `providerSessionId` | 可使用 `debug-eml-upload` 或后续当前用户 session 摘要 | @@ -189,6 +200,12 @@ Debug 链路调用现有 `SourceMessageCaptureService.capture`,建议映射如 若新增 `ORIGINAL_EMAIL` 媒体类型,需要同步更新 SourceMessage 媒体枚举、接口文档和前端展示说明。 +补充说明: + +- 原始邮件 `Message-ID` 保存到 `payloadJson.source.original_message_id`,不参与 Debug EML SourceMessage 幂等键。 +- 原始解析出的会话 ID 保存到 `payloadJson.source.original_conversation_id`,便于排查 Debug 会话和真实邮件头之间的关系。 +- `Thread-Index` 仅作为 `References` / `In-Reply-To` 缺失时的会话 fallback,不覆盖明确的邮件回复链。 + ## 8. AgentBus-like Payload 结构建议 第一版 payload 目标是让 SuperAgent 获得接近 AgentBus 邮件输入的结构,而不是完整复刻 AgentBus 协议。 @@ -198,8 +215,10 @@ Debug 链路调用现有 `SourceMessageCaptureService.capture`,建议映射如 "source": { "channel": "EMAIL", "provider": "DEBUG_EML_UPLOAD", - "external_message_id": "debug-eml-4f2c9a8b7d6e", - "external_conversation_id": "debug-eml-thread-4f2c9a8b7d6e", + "external_message_id": "debug-eml-run-1900000000000000001-4f2c9a8b7d6e", + "external_conversation_id": "debug-eml-thread-debug-eml-run-1900000000000000001-4f2c9a8b7d6e", + "original_message_id": "message-id-from-eml@example.com", + "original_conversation_id": "thread-index-or-reference-from-eml", "sender": "sender@example.com", "subject": "Booking Request", "sent_at": "2026-07-09T01:30:00Z" diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlSuperAgentRunResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlSuperAgentRunResult.java index 6a8111a..874c014 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlSuperAgentRunResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlSuperAgentRunResult.java @@ -17,6 +17,9 @@ import java.util.Map; * @param originalEmlSha256 原始 .eml SHA-256 * @param uploadedMedia 上传后的媒体列表 * @param htmlBodyWithOssUrls 替换 cid 后的 HTML + * @param htmlBodySanitized 后端清洗后的 HTML,前端调试展示应优先使用 + * @param htmlSanitizeRequired 是否要求前端按安全 HTML 规则展示 + * @param htmlRenderMode HTML 渲染建议模式 * @param agentbusLikePayload 发送给 SuperAgent 的结构化 payload * @param superagentSessionId SuperAgent session ID * @param superagentRunId SuperAgent run ID @@ -44,6 +47,12 @@ public record DebugEmlSuperAgentRunResult( List uploadedMedia, @JsonProperty("html_body_with_oss_urls") String htmlBodyWithOssUrls, + @JsonProperty("html_body_sanitized") + String htmlBodySanitized, + @JsonProperty("html_sanitize_required") + Boolean htmlSanitizeRequired, + @JsonProperty("html_render_mode") + String htmlRenderMode, @JsonProperty("agentbus_like_payload") Map agentbusLikePayload, @JsonProperty("superagent_session_id") diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java index 8c9a3af..917b727 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -25,9 +25,11 @@ import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMed import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService; import cn.nianxx.thhotel.platform.message.service.impl.EmlMessageParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -40,6 +42,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -54,12 +58,15 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe private static final String SOURCE_CHANNEL = "EMAIL"; private static final String SCHEMA_VERSION = "debug-eml-upload-v1"; private static final DateTimeFormatter DATE_FOLDER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final Pattern CID_REFERENCE_PATTERN = Pattern.compile( + "(?i)cid:(?:<[^>]+>|%3c[^\\s\"'>]+%3e|[^\\s\"'>]+)"); private final DebugEmlSuperAgentProperties properties; private final AliyunOssProperties ossProperties; private final EmlMessageParseService parseService; private final ObjectStorageService objectStorageService; private final SourceMessageCaptureService sourceMessageCaptureService; + private final SourceMessageHtmlSanitizerService htmlSanitizerService; private final SuperAgentOpenApiClient superAgentOpenApiClient; private final DebugEmlSuperAgentRunRepository runRepository; private final ObjectMapper objectMapper; @@ -73,6 +80,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe EmlMessageParseService parseService, ObjectStorageService objectStorageService, SourceMessageCaptureService sourceMessageCaptureService, + SourceMessageHtmlSanitizerService htmlSanitizerService, SuperAgentOpenApiClient superAgentOpenApiClient, DebugEmlSuperAgentRunRepository runRepository, ObjectMapper objectMapper) { @@ -81,6 +89,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe this.parseService = parseService; this.objectStorageService = objectStorageService; this.sourceMessageCaptureService = sourceMessageCaptureService; + this.htmlSanitizerService = htmlSanitizerService; this.superAgentOpenApiClient = superAgentOpenApiClient; this.runRepository = runRepository; this.objectMapper = objectMapper; @@ -158,8 +167,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe LocalDateTime createdAt) throws Exception { String sha256 = sha256(emlBytes); ParsedEmlMessage parsed = parseService.parse(emlBytes, safeFileName); - String externalMessageId = firstNonBlank(parsed.messageId(), "debug-eml-" + sha256.substring(0, 12)); - String externalConversationId = firstNonBlank(parsed.conversationId(), "debug-eml-thread-" + externalMessageId); + String originalMessageId = parsed.messageId(); + String originalConversationId = parsed.conversationId(); + String externalMessageId = debugExternalMessageId(runId, sha256); + String externalConversationId = firstNonBlank(originalConversationId, "debug-eml-thread-" + externalMessageId); List warnings = new ArrayList<>(); List uploadedMedia = new ArrayList<>(); @@ -175,11 +186,15 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe } String htmlWithOssUrls = replaceCidReferences(parsed.htmlBody(), uploadedMedia, warnings); + String htmlBodySanitized = htmlSanitizerService.sanitizeHtml(htmlWithOssUrls); + String htmlRenderMode = htmlSanitizerService.htmlRenderMode(htmlWithOssUrls); Map payload = buildAgentBusLikePayload( runId, runLabel, externalMessageId, externalConversationId, + originalMessageId, + originalConversationId, parsed, htmlWithOssUrls, uploadedMedia); @@ -254,6 +269,9 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe sha256, uploadedMedia.stream().map(UploadedMedia::result).toList(), htmlWithOssUrls, + htmlBodySanitized, + true, + htmlRenderMode, payload, superAgentResult.sessionId(), superAgentResult.runId(), @@ -390,18 +408,28 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe warnings.add("EML 未解析到 HTML 正文。"); return htmlBody; } - String replaced = htmlBody; + Map cidUrlMap = new LinkedHashMap<>(); for (UploadedMedia media : uploadedMedia) { if (media.contentId() == null || media.contentId().isBlank()) { continue; } - replaced = replaced.replace("cid:" + media.contentId(), media.result().externalUrl()); - replaced = replaced.replace("cid:<" + media.contentId() + ">", media.result().externalUrl()); + cidUrlMap.put(normalizeCidValue(media.contentId()), media.result().externalUrl()); } - if (replaced.contains("cid:")) { + Matcher matcher = CID_REFERENCE_PATTERN.matcher(htmlBody); + StringBuffer replaced = new StringBuffer(); + while (matcher.find()) { + String externalUrl = cidUrlMap.get(normalizeCidReference(matcher.group())); + if (externalUrl == null) { + continue; + } + matcher.appendReplacement(replaced, Matcher.quoteReplacement(externalUrl)); + } + matcher.appendTail(replaced); + String result = replaced.toString(); + if (CID_REFERENCE_PATTERN.matcher(result).find()) { warnings.add("HTML 正文仍包含未匹配的 cid 图片引用。"); } - return replaced; + return result; } /** @@ -412,6 +440,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe String runLabel, String externalMessageId, String externalConversationId, + String originalMessageId, + String originalConversationId, ParsedEmlMessage parsed, String htmlWithOssUrls, List uploadedMedia) { @@ -420,6 +450,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe source.put("provider", SOURCE_PROVIDER); source.put("external_message_id", externalMessageId); source.put("external_conversation_id", externalConversationId); + source.put("original_message_id", originalMessageId); + source.put("original_conversation_id", originalConversationId); source.put("sender", parsed.sender()); source.put("subject", parsed.subject()); source.put("sent_at", parsed.sentAt() == null ? null : parsed.sentAt().toString()); @@ -590,6 +622,48 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe + "/" + fileName; } + /** + * 生成 Debug 专属外部消息 ID,避免重复上传同一封 EML 时命中 SourceMessage 幂等复用。 + */ + private String debugExternalMessageId(Long runId, String sha256) { + return "debug-eml-run-" + runId + "-" + sha256.substring(0, 12); + } + + /** + * 规范化 HTML 中匹配到的 cid: 引用。 + */ + private String normalizeCidReference(String cidReference) { + if (cidReference == null || cidReference.length() <= 4) { + return ""; + } + return normalizeCidValue(cidReference.substring(4)); + } + + /** + * 规范化 Content-ID,兼容尖括号、URL 编码和大小写差异。 + */ + private String normalizeCidValue(String value) { + if (value == null) { + return ""; + } + String normalized = urlDecode(value.trim()); + if (normalized.startsWith("<") && normalized.endsWith(">") && normalized.length() > 2) { + normalized = normalized.substring(1, normalized.length() - 1); + } + return normalized.trim().toLowerCase(Locale.ROOT); + } + + /** + * URL 解码 cid 内容;保留加号字面值,解码失败时保留原始值继续匹配。 + */ + private String urlDecode(String value) { + try { + return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (Exception exception) { + return value; + } + } + /** * 文件名安全清洗,避免路径穿越和日志污染。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageHtmlSanitizerService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageHtmlSanitizerService.java new file mode 100644 index 0000000..9bb7c83 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageHtmlSanitizerService.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.platform.message.service; + +/** + * SourceMessage HTML 清洗服务。统一邮件会话和 Debug 邮件上传的 HTML 安全展示口径。 + */ +public interface SourceMessageHtmlSanitizerService { + + /** + * 清洗邮件 HTML,移除脚本标签、事件属性、样式属性和危险协议链接。 + */ + String sanitizeHtml(String htmlBody); + + /** + * 返回前端渲染建议;有 HTML 时优先使用后端清洗后的安全 HTML。 + */ + String htmlRenderMode(String htmlBody); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImpl.java index 6a641c4..0104831 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImpl.java @@ -150,7 +150,7 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService { } /** - * 解析会话 ID,优先使用 References / In-Reply-To 的第一项,缺失时使用 Message-ID。 + * 解析会话 ID,优先使用 References / In-Reply-To 的第一项,其次使用 Thread-Index,最后回退 Message-ID。 */ private String resolveConversationId(MimeMessage message, String messageId) throws MessagingException { String references = message.getHeader("References", null); @@ -159,7 +159,11 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService { return firstReference; } String inReplyTo = firstMessageId(message.getHeader("In-Reply-To", null)); - return inReplyTo == null ? messageId : inReplyTo; + if (inReplyTo != null) { + return inReplyTo; + } + String threadIndex = trimToNull(message.getHeader("Thread-Index", null)); + return threadIndex == null ? messageId : threadIndex; } /** @@ -197,6 +201,17 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService { return normalizeMessageId(value); } + /** + * 空白字符串转 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + /** * 读取 MIME part 的首个头字段值,缺失时返回 null。 */ 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 acd6514..b1cf90b 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 @@ -13,12 +13,12 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalMed 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.SourceMessageHtmlSanitizerService; 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 java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,32 +32,21 @@ 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; + private final SourceMessageHtmlSanitizerService htmlSanitizerService; /** * 注入 SourceMessage 持久化端口和业务关联上下文 Provider 列表。 */ public SourceMessageConversationServiceImpl( SourceMessageInboxRepository inboxRepository, - List relatedContextProviders) { + List relatedContextProviders, + SourceMessageHtmlSanitizerService htmlSanitizerService) { this.inboxRepository = inboxRepository; this.relatedContextProviders = relatedContextProviders == null ? List.of() : relatedContextProviders; + this.htmlSanitizerService = htmlSanitizerService; } /** @@ -127,9 +116,9 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()), originalContent.textBody(), htmlBody, - sanitizeHtml(htmlBody), + htmlSanitizerService.sanitizeHtml(htmlBody), true, - htmlRenderMode(htmlBody), + htmlSanitizerService.htmlRenderMode(htmlBody), filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE), filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT), relatedContext.relatedOrders(), @@ -192,26 +181,4 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver 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/platform/message/service/impl/SourceMessageHtmlSanitizerServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageHtmlSanitizerServiceImpl.java new file mode 100644 index 0000000..20a9191 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageHtmlSanitizerServiceImpl.java @@ -0,0 +1,52 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** + * SourceMessage HTML 清洗服务实现。第一版使用白盒正则策略,后续可替换为专用 HTML sanitizer。 + */ +@Service +public class SourceMessageHtmlSanitizerServiceImpl implements SourceMessageHtmlSanitizerService { + + 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>]+)"); + + /** + * 清洗邮件 HTML,保留正文基本结构并移除高风险内容。 + */ + @Override + public 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 是否存在返回前端渲染模式。 + */ + @Override + public 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/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java index 3eeb2c3..9ac177e 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -99,13 +100,18 @@ class DebugEmlSuperAgentControllerTest { .andExpect(jsonPath("$.debug_run_id").isNotEmpty()) .andExpect(jsonPath("$.source_message_id").isNotEmpty()) .andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD")) - .andExpect(jsonPath("$.external_message_id").value("debug-controller-message-001@example.test")) + .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-"))) .andExpect(jsonPath("$.original_eml_oss_url", containsString("/raw/debug-booking.eml"))) .andExpect(jsonPath("$.uploaded_media", hasSize(greaterThanOrEqualTo(3)))) .andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/"))) .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:inline-001")))) + .andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/"))) + .andExpect(jsonPath("$.html_sanitize_required").value(true)) + .andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML")) .andExpect(jsonPath("$.agentbus_like_payload.schema_version").value("debug-eml-upload-v1")) .andExpect(jsonPath("$.agentbus_like_payload.source.provider").value("DEBUG_EML_UPLOAD")) + .andExpect(jsonPath("$.agentbus_like_payload.source.original_message_id") + .value("debug-controller-message-001@example.test")) .andExpect(jsonPath("$.agentbus_like_payload.reply_policy.mode").value("debug_only")) .andExpect(jsonPath("$.superagent_session_id").value("session-debug-001")) .andExpect(jsonPath("$.superagent_run_id").value("run-debug-001")) @@ -122,6 +128,7 @@ class DebugEmlSuperAgentControllerTest { AND inbox.provider = 'DEBUG_EML_UPLOAD' AND inbox.channel = 'EMAIL' AND payload.schema_version = 'debug-eml-upload-v1' + AND payload.payload_json LIKE '%controller-test%' """, Long.class); org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L); @@ -129,8 +136,10 @@ class DebugEmlSuperAgentControllerTest { SELECT COUNT(*) FROM platform_source_message_media media JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id + JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id WHERE inbox.provider = 'DEBUG_EML_UPLOAD' AND media.media_type = 'ORIGINAL_EMAIL' + AND payload.payload_json LIKE '%controller-test%' """, Long.class); org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L); @@ -141,10 +150,68 @@ class DebugEmlSuperAgentControllerTest { AND run_status = 'SUPERAGENT_SUCCEEDED' AND superagent_session_id = 'session-debug-001' AND superagent_run_id = 'run-debug-001' + AND run_label = 'controller-test' """, Long.class); org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L); } + @Test + void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception { + mockStorageAndSuperAgentSuccess(); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "repeat-debug-upload") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-"))); + + reset(objectStorageService, superAgentOpenApiClient); + mockStorageAndSuperAgentSuccess(); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "repeat-debug-upload") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-"))); + + Long sourceCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_source_message_inbox inbox + JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id + WHERE inbox.hotel_id = 'HOTEL-TEST' + AND inbox.provider = 'DEBUG_EML_UPLOAD' + AND inbox.channel = 'EMAIL' + AND inbox.external_message_id LIKE 'debug-eml-run-%' + AND payload.payload_json LIKE '%repeat-debug-upload%' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L); + } + + @Test + void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception { + mockStorageAndSuperAgentSuccess(); + + mockMvc.perform(multipart(ENDPOINT) + .file(unsafeHtmlEmlFile()) + .param("hotel_id", "HOTEL-TEST") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/"))) + .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("CID:inline-001")))) + .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline-001%3E")))) + .andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline%2Bplus%3E")))) + .andExpect(jsonPath("$.html_body_sanitized", not(containsString(" { @@ -179,6 +246,28 @@ class DebugEmlSuperAgentControllerTest { org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L); } + private void mockStorageAndSuperAgentSuccess() { + when(objectStorageService.putObject(any())).thenAnswer(invocation -> { + ObjectStoragePutRequest request = invocation.getArgument(0); + return new ObjectStoragePutResult( + request.objectKey(), + "https://oss.example.test/" + request.objectKey(), + request.contentType(), + request.sizeBytes()); + }); + when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult( + "session-debug-001", + "run-debug-001", + "profile-debug", + "profile-version-debug", + "debug-model", + "{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}", + 11, + 7, + 18, + List.of("metadata", "values", "end"))); + } + private MockMultipartFile emlFile() { return new MockMultipartFile( "file", @@ -187,6 +276,14 @@ class DebugEmlSuperAgentControllerTest { emlBytes()); } + private MockMultipartFile unsafeHtmlEmlFile() { + return new MockMultipartFile( + "file", + "unsafe-debug-booking.eml", + MediaType.TEXT_PLAIN_VALUE, + unsafeHtmlEmlBytes()); + } + private byte[] emlBytes() { return """ From: Guest @@ -227,4 +324,37 @@ class DebugEmlSuperAgentControllerTest { --rel-boundary-- """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); } + + private byte[] unsafeHtmlEmlBytes() { + return """ + From: Guest + To: Reservations + Subject: Unsafe Debug Booking + Date: Thu, 09 Jul 2026 01:30:00 +0000 + Message-ID: + MIME-Version: 1.0 + Content-Type: multipart/related; boundary="rel-boundary" + + --rel-boundary + Content-Type: text/html; charset=UTF-8 + + bad + + --rel-boundary + Content-Type: image/png; name="inline.png" + Content-Transfer-Encoding: base64 + Content-ID: + Content-Disposition: inline; filename="inline.png" + + aW5saW5lLWltYWdl + --rel-boundary + Content-Type: image/png; name="inline-plus.png" + Content-Transfer-Encoding: base64 + Content-ID: + Content-Disposition: inline; filename="inline-plus.png" + + aW5saW5lLXBsdXM= + --rel-boundary-- + """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImplTest.java index 8090cb1..c7a3499 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImplTest.java @@ -40,6 +40,15 @@ class EmlMessageParseServiceImplTest { assertThat(attachment.bytes()).isEqualTo("pdf-content".getBytes(StandardCharsets.UTF_8)); } + @Test + void shouldUseThreadIndexAsConversationIdWhenReplyHeadersMissing() { + ParsedEmlMessage message = parseService.parse(threadIndexEmlBytes(), "thread-index.eml"); + + assertThat(message.messageId()).isEqualTo("thread-index-message-001@example.test"); + assertThat(message.conversationId()).isEqualTo("AcvThreadIndexDebug001"); + assertThat(message.textBody()).contains("Thread index body"); + } + private byte[] emlBytes() { return """ From: Guest @@ -80,4 +89,19 @@ class EmlMessageParseServiceImplTest { --rel-boundary-- """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); } + + private byte[] threadIndexEmlBytes() { + return """ + From: Guest + To: Reservations + Subject: Thread Index Booking Request + Date: Thu, 09 Jul 2026 01:30:00 +0000 + Message-ID: + Thread-Index: AcvThreadIndexDebug001 + MIME-Version: 1.0 + Content-Type: text/plain; charset=UTF-8 + + Thread index body + """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + } }