diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 475aaa7..74e72f6 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -50,6 +50,7 @@ | `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 只用于安全摘要详情。 | | `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key,展示 HTML 时优先使用 `html_body_sanitized`。 | +| `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 并调用 SuperAgent | 仅 dev/test 受控调试使用;会写入 SourceMessage Inbox,但不创建订单和任务。 | ### 5.1 本轮新增 / 修改接口说明 @@ -62,6 +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 或错误上报。 | ### 5.2 来源邮件会话字段说明 @@ -143,9 +145,35 @@ Content-Type: application/json - 前端保存草稿时不要自行按 `write_path` 重组 OPERA 参数;第一版按任务详情返回的字段和值提交即可,真实 OPERA 参数组装后续由后端 adapter / 转换层处理。 - 任务详情页控制按钮时以 `availability.editable`、`availability.confirmable`、`availability.executable`、`availability.read_only` 和 `availability.blocked` 为准;`can_process` 和 `readonly_reason_code` 只出现在任务列表 / 订单时间线摘要里。 +### 5.7 Debug EML 上传接口接入注意 + +后端已提供 Debug 页面专用的 `.eml` 上传和 SuperAgent 调试入口: + +```text +POST /api/system/debug/eml-superagent-runs +Header: X-TH-Hotel-Debug-Upload-Key: <调试访问口令> +Content-Type: multipart/form-data + +file: .eml 文件 +hotel_id: HOTEL-TEST +run_label: 可选调试标签 +``` + +前端注意: + +- 该接口只用于 dev/test 调试页面,不是生产普通业务页面接口。 +- 接口会解析 `.eml`,上传原始邮件、内联图片和附件到本系统阿里云 OSS,替换 HTML 内 `cid:` 图片,再写入 SourceMessage Inbox。 +- SourceMessage 来源 provider 固定为 `DEBUG_EML_UPLOAD`,用于和 AgentBus 入库邮件区分。 +- `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。 +- `superagent_parsed_json` 为空时,前端展示 `superagent_raw_answer` 和 `warnings[]`,不要假定 SuperAgent 总能返回 JSON。 + ## 6. 不给前端直接调用的接口 - `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 +- `POST /api/system/debug/eml-superagent-runs` 只用于 dev/test Debug 页面,不是生产普通业务页面接口;访问口令不能进入前端代码或构建产物。 - `POST /api/integrations/superagent/task-results` 是 SuperAgent 到后端的服务到服务入站接口。 - `POST /api/ai-query/v1/case-context` 和 `POST /api/ai-query/v1/object-detail` 是 SuperAgent 查询上下文接口,不是前端页面接口。 - `GET /api/source-message-conversations/{externalConversationId}` 是历史讨论过的候选路径,当前后端不提供,前端不要接入。 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 3871bc1..2f0f761 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -15,6 +15,7 @@ | P0 | 任务详情操作接口 | 任务详情保存、确认、OPERA、审计 | 已完成;前端可直接接入 | | P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 | | 联调 | 演示数据 seed 接口 `POST /api/system/reservation/demo-data` | 本地 / test 前端页面看效果 | 已完成;仅 dev/test 受控使用 | +| 联调 | Debug EML 上传接口 `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 看 SuperAgent 结果 | 已完成第一版;仅 dev/test 受控使用 | | P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 未完成独立接口;可先通过任务列表 / 任务详情展示 `INFORMATIONAL_MESSAGE` | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | | 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | @@ -40,6 +41,7 @@ | `GET /api/reservation/orders` | 已完成第一版 | 可以 | 默认查询全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`。 | | `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 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | +| `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;第一版只展示 SuperAgent 结果,不创建订单和任务。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | | `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 如需独立信息提醒页再新增;第一版可先用任务接口过滤。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index b64cf66..eb4af8d 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -17,6 +17,7 @@ - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 - Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 - SuperAgent 查询上下文接口 1、2:支持 HMAC 鉴权的订单上下文查询和对象详情查询。 +- Debug EML 上传到 SuperAgent 调试链路:受控上传 `.eml`、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并返回调试结果。 当前不要把以下能力当作已上线: @@ -28,6 +29,7 @@ - SuperAgent 查询上下文接口 3、4。 - 普通任务切换订单接口。 - 用户身份、权限和真实审计 actor。 +- Debug EML 上传链路不属于生产普通业务页面能力,生产默认关闭;未接入正式用户权限前不要开放给普通用户。 ## 2. 上线前必须确认 @@ -106,6 +108,37 @@ - 任务结果通知接口里的 `source_message_id` 是外部来源消息 ID,对应 AgentBus `source.external_message_id`;正式请求必须带 `hotel_id`,后端用 `hotel_id + provider + channel + external_message_id` 反查内部 SourceMessage Inbox。 - SuperAgent 查询上下文接口中的 `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询和校验;不要依赖它们限制查询范围。 +### 3.5 Debug EML / SuperAgent Open API / 阿里云 OSS + +| 变量 | 是否 Secret | 上线注意事项 | +| --- | --- | --- | +| `DEBUG_EML_UPLOAD_DEV_ENABLED` | 否 | dev 是否启用 Debug EML 上传接口,未配置时可兜底 `DEBUG_EML_UPLOAD_ENABLED`。 | +| `DEBUG_EML_UPLOAD_TEST_ENABLED` | 否 | test 是否启用 Debug EML 上传接口,默认关闭。 | +| `DEBUG_EML_UPLOAD_PROD_ENABLED` | 否 | prod 默认必须保持 `false`;未接入正式用户权限前不要开放。 | +| `DEBUG_EML_UPLOAD_DEV_ACCESS_KEY` | 是 | dev Debug EML 上传访问口令,未配置时可兜底 `DEBUG_EML_UPLOAD_ACCESS_KEY`。 | +| `DEBUG_EML_UPLOAD_TEST_ACCESS_KEY` | 是 | test Debug EML 上传访问口令,未配置时可兜底 `DEBUG_EML_UPLOAD_ACCESS_KEY`。 | +| `DEBUG_EML_UPLOAD_PROD_ACCESS_KEY` | 是 | prod Debug EML 上传访问口令;生产通常不应启用该接口。 | +| `DEBUG_EML_UPLOAD_MAX_FILE_BYTES` | 否 | `.eml` 上传大小上限,默认 `10485760`。 | +| `DEERFLOW_DEV_BASE_URL` / `DEERFLOW_TEST_BASE_URL` / `DEERFLOW_PROD_BASE_URL` | 否 | SuperAgent / DeerFlow Open API 基础地址,未配置时可兜底 `DEERFLOW_BASE_URL`。 | +| `DEERFLOW_DEV_OPEN_API_KEY` / `DEERFLOW_TEST_OPEN_API_KEY` / `DEERFLOW_PROD_OPEN_API_KEY` | 是 | SuperAgent Open API Key,未配置时可兜底 `DEERFLOW_OPEN_API_KEY`。 | +| `SUPERAGENT_DEV_OPEN_API_ENABLED` / `SUPERAGENT_TEST_OPEN_API_ENABLED` / `SUPERAGENT_PROD_OPEN_API_ENABLED` | 否 | 是否启用真实 SuperAgent Open API 调用;prod 默认关闭。 | +| `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` | 否 | Debug EML 创建 SuperAgent session 的 external subject id。 | +| `SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT` | 否 | SuperAgent Open API 建连超时,默认 `15s`。 | +| `SUPERAGENT_DEBUG_EML_READ_TIMEOUT` | 否 | SuperAgent SSE 读取超时,默认 `180s`。 | +| `ALIYUN_OSS_DEV_ENDPOINT` / `ALIYUN_OSS_TEST_ENDPOINT` / `ALIYUN_OSS_PROD_ENDPOINT` | 否 | 阿里云 OSS Endpoint,未配置时可兜底 `ALIYUN_OSS_ENDPOINT`。 | +| `ALIYUN_OSS_DEV_BUCKET` / `ALIYUN_OSS_TEST_BUCKET` / `ALIYUN_OSS_PROD_BUCKET` | 否 | 阿里云 OSS Bucket,未配置时可兜底 `ALIYUN_OSS_BUCKET`。 | +| `ALIYUN_OSS_DEV_ACCESS_KEY_ID` / `ALIYUN_OSS_TEST_ACCESS_KEY_ID` / `ALIYUN_OSS_PROD_ACCESS_KEY_ID` | 是 | 阿里云 OSS AccessKey ID,未配置时可兜底 `ALIYUN_OSS_ACCESS_KEY_ID`。 | +| `ALIYUN_OSS_DEV_ACCESS_KEY_SECRET` / `ALIYUN_OSS_TEST_ACCESS_KEY_SECRET` / `ALIYUN_OSS_PROD_ACCESS_KEY_SECRET` | 是 | 阿里云 OSS AccessKey Secret,未配置时可兜底 `ALIYUN_OSS_ACCESS_KEY_SECRET`。 | +| `ALIYUN_OSS_DEV_PUBLIC_BASE_URL` / `ALIYUN_OSS_TEST_PUBLIC_BASE_URL` / `ALIYUN_OSS_PROD_PUBLIC_BASE_URL` | 否 | OSS 对外访问基础 URL,未配置时可兜底 `ALIYUN_OSS_PUBLIC_BASE_URL`。 | +| `ALIYUN_OSS_DEBUG_EML_PREFIX` | 否 | Debug EML 上传对象路径前缀,默认 `debug/eml/`。 | + +注意: + +- 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 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 + ## 4. 数据库上线注意事项 当前 SourceMessage 相关 migration: @@ -120,6 +153,10 @@ - `server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql` - `server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql` +当前 M004 Debug EML 相关 migration: + +- `server/src/main/resources/db/migration/V7__create_debug_eml_superagent_run.sql` + 上线前确认: - 目标数据库为空库或 Flyway history 与当前代码一致。 diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md new file mode 100644 index 0000000..cfe6648 --- /dev/null +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -0,0 +1,477 @@ +# M004 Debug EML SuperAgent Upload 调试邮件上传链路 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-09 | +| 状态 | 第一版后端已实现 | +| 适用范围 | Debug 页面上传 `.eml` 邮件、转存阿里云 OSS、写入 SourceMessage Inbox、调用 SuperAgent Open API 并展示结果 | +| 主要读者 | 产品、后端、前端、测试、运维、后续协作 agent | + +## 1. 文档定位 + +本文记录 Debug EML 上传链路的第一版后端设计。该能力用于在没有 AgentBus 实时入口的情况下, +由调试页面上传 `.eml` 邮件文件,后端解析邮件、转存附件和内联图片到本系统阿里云 OSS, +再组装成 SuperAgent 可处理的邮件输入并调用 SuperAgent Open API。 + +该能力是平台调试能力,不属于 `workflows.reservation` 主业务流。第一版必须写入 SourceMessage +Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任务结果通知接口,也不执行 OPERA。 + +## 2. 已确认决策 + +- Debug 页面上传的邮件文件格式为 `.eml`。 +- 第一版通过 SuperAgent Open API 调用 SuperAgent,不走 SuperAgent 调用本系统的任务结果通知接口。 +- 第一版只展示 SuperAgent 生成结果,不落业务订单和任务。 +- 上传邮件仍要写入 SourceMessage Inbox。 +- Debug 上传来源必须和 AgentBus 来源区分,建议 `provider=DEBUG_EML_UPLOAD`。 +- 使用真实阿里云 OSS,不使用本地 mock OSS。 +- 原始 `.eml` 文件本身也上传到阿里云 OSS,便于后续调试追溯。 +- 内联图片和普通附件都上传到阿里云 OSS。 +- HTML 正文中的 `cid:` 图片引用需要替换成本系统 OSS 图片地址。 +- Debug 上传接口第一版使用内部调试访问 key 保护,不等待完整用户权限体系。 +- 用户 / 权限体系和管理后台仍是后续能力,本功能不依赖 M003 完成。 + +## 2.1 第一版实现记录 + +当前后端已完成: + +- `POST /api/system/debug/eml-superagent-runs`。 +- `.eml` MIME 解析:邮件头、纯文本正文、HTML 正文、内联图片和附件。 +- 阿里云 OSS 上传端口和适配器:原始 `.eml`、内联图片和附件。 +- HTML `cid:` 图片替换为 OSS URL。 +- AgentBus-like payload 组装。 +- SourceMessage Inbox 写入,`provider=DEBUG_EML_UPLOAD`,`schema_version=debug-eml-upload-v1`。 +- SourceMessage 写入成功后先把 debug run 标记为 `SOURCE_CAPTURED`;即使后续 SuperAgent 调用失败,也保留 SourceMessage 和 OSS 原文追溯信息。 +- SuperAgent Open API client:创建 session、发送 `messages/stream`、解析 SSE 最终回答。 +- `platform_debug_eml_superagent_run` 持久化。 +- Controller / Service / ServiceImpl / Repository / Mapper / Entity / DTO / Result 按当前后端目录规范落位。 +- 单元测试和集成测试覆盖 EML 解析、cid 替换、SourceMessage 写入参数、OSS mock、SuperAgent mock 和接口鉴权。 + +## 3. 核心目标 + +第一版要解决以下问题: + +- 前端 Debug 页面可以上传一封 `.eml` 邮件。 +- 后端可以解析邮件头、纯文本正文、HTML 正文、内联图片和附件。 +- 后端可以把原始 `.eml`、内联图片和附件上传到阿里云 OSS。 +- 后端可以把 HTML 正文中的 `cid:` 引用替换成 OSS URL。 +- 后端可以组装 AgentBus-like 邮件 payload,保持和当前 SourceMessage Inbox / SuperAgent 输入口径接近。 +- 后端可以写入 SourceMessage Inbox,并明确标记来源为 Debug EML Upload。 +- 后端可以调用 SuperAgent Open API 并解析最终返回。 +- 前端可以看到 SourceMessage ID、上传媒体、处理后的 HTML、发送给 SuperAgent 的 payload、SuperAgent 原始回答和解析后的 JSON。 + +## 4. 非目标范围 + +第一版不做以下能力: + +- 不创建 Reservation 订单。 +- 不创建 Reservation 任务。 +- 不写 `workflow_reservation_*` 表。 +- 不调用 `POST /api/integrations/superagent/task-results`。 +- 不做真实 OPERA / OHIP 接入。 +- 不做邮件多封批量上传。 +- 不做 ZIP、MSG、PDF、图片 OCR 或 Excel 解析。 +- 不做 SourceMessage Replay 到 MessageEvent / Evidence。 +- 不做普通任务切换订单。 +- 不做 SuperAgent 查询接口 3、4。 +- 不接入完整用户 / 权限体系。 +- 不在前端保存或暴露 SuperAgent Open API Key、阿里云 OSS Secret 或调试上传 key。 + +## 5. 总体流程 + +```text +Debug 页面上传 .eml +→ 后端校验 X-TH-Hotel-Debug-Upload-Key +→ 解析 MIME 邮件结构 +→ 上传原始 .eml 到阿里云 OSS +→ 上传内联图片到阿里云 OSS +→ 上传普通附件到阿里云 OSS +→ 替换 HTML 正文中的 cid: 图片引用 +→ 组装 AgentBus-like payload +→ 调用 SourceMessageCaptureService 写入 SourceMessage Inbox +→ debug run 标记为 SOURCE_CAPTURED +→ 调用 SuperAgent Open API 创建 session +→ 调用 messages/stream 发送邮件 payload +→ 解析 SSE 最终回答 +→ 保存 debug run 记录 +→ 返回 debug 结果给前端展示 +``` + +中文说明: + +- SourceMessage Inbox 仍然只表达来源事实,不表达 AI 结论、订单归属或任务状态。 +- Debug 上传链路不能伪装成 AgentBus;必须在 `provider`、`schema_version` 或 debug run 中留下可追溯来源。 +- SuperAgent 返回内容第一版只作为调试展示,不进入 M002 订单任务主流程。 + +## 6. 后端接口设计 + +### 6.1 上传并调用 SuperAgent + +```text +POST /api/system/debug/eml-superagent-runs +Content-Type: multipart/form-data +Header: X-TH-Hotel-Debug-Upload-Key: +``` + +请求参数: + +| 参数 | 是否必填 | 中文说明 | +| --- | --- | --- | +| `file` | 是 | `.eml` 邮件文件 | +| `hotel_id` | 是 | 酒店上下文 ID,用于 SourceMessage Inbox 幂等键和后续排查 | +| `run_label` | 否 | 前端传入的调试标签,例如 `frontend-debug-smoke` | + +响应字段: + +| 字段 | 中文说明 | +| --- | --- | +| `debug_run_id` | 本次 Debug 运行 ID | +| `source_message_id` | 本系统内部 SourceMessage Inbox ID | +| `source_provider` | 固定建议为 `DEBUG_EML_UPLOAD` | +| `external_message_id` | Debug 链路生成的外部消息 ID,用于 SourceMessage 幂等 | +| `external_conversation_id` | Debug 链路生成或解析出的邮件会话 ID | +| `original_eml_oss_url` | 原始 `.eml` 文件 OSS 地址 | +| `original_eml_sha256` | 原始 `.eml` 文件 SHA-256 | +| `uploaded_media[]` | 已上传 OSS 的内联图片和附件 | +| `html_body_with_oss_urls` | 替换 `cid:` 后的 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,失败时为空 | +| `superagent_raw_answer` | SuperAgent 最终文本回答 | +| `superagent_parsed_json` | 后端尝试解析出的 JSON 对象,无法解析时为空 | +| `warnings[]` | 可展示的安全警告,例如缺失 HTML、无法替换某个 cid、SuperAgent 返回非 JSON | +| `status` | Debug 运行状态 | + +### 6.2 状态码建议 + +| 场景 | HTTP 状态 | 中文说明 | +| --- | --- | --- | +| 调试 key 缺失或错误 | `401` | 不执行解析和外部调用 | +| 文件缺失或非 `.eml` | `400` | 参数错误 | +| 邮件解析失败 | `422` | 文件存在但 MIME 结构无法解析 | +| OSS 上传失败 | `502` | 外部存储失败 | +| SuperAgent 调用失败 | `502` | 外部 AI Provider 失败 | +| 写入 SourceMessage 失败 | `500` | 本系统持久化失败 | + +错误响应不得返回 Secret、完整邮件正文、完整 HTML、附件 URL 中的签名参数或原始 Provider 报文。 + +## 7. SourceMessage 写入口径 + +Debug 链路调用现有 `SourceMessageCaptureService.capture`,建议映射如下: + +| Capture 字段 | Debug EML 来源 | +| --- | --- | +| `hotelId` | 请求参数 `hotel_id` | +| `provider` | `DEBUG_EML_UPLOAD` | +| `channel` | `EMAIL` | +| `externalMessageId` | 优先邮件 `Message-ID` 的规范化值;缺失时使用 `debug-eml-{sha256前缀}` | +| `externalConversationId` | 优先邮件 `In-Reply-To` / `References` / `Thread-Index` 可解析会话值;缺失时使用 `debug-eml-thread-{externalMessageId}` | +| `providerFrameId` | `debug_run_id` | +| `providerSessionId` | 可使用 `debug-eml-upload` 或后续当前用户 session 摘要 | +| `sourceSentAt` | 邮件 `Date` 头,解析失败则为空 | +| `senderIdentifier` | 邮件 `From` | +| `subject` | 邮件 `Subject` | +| `textBody` | 解析出的纯文本正文 | +| `htmlBody` | 已替换 OSS URL 的 HTML 正文 | +| `payloadJson` | AgentBus-like payload JSON | +| `schemaVersion` | `debug-eml-upload-v1` | +| `mediaItems` | 上传到 OSS 后的内联图片、附件和原始 `.eml` 引用 | + +媒体类型建议: + +| 类型 | 中文说明 | +| --- | --- | +| `INLINE_IMAGE` | HTML 正文内联图片 | +| `ATTACHMENT` | 普通邮件附件 | +| `ORIGINAL_EMAIL` | Debug 链路上传的原始 `.eml` 文件,若当前枚举不支持,需要新增枚举或作为附件类型加 `external_media_id` 区分 | + +若新增 `ORIGINAL_EMAIL` 媒体类型,需要同步更新 SourceMessage 媒体枚举、接口文档和前端展示说明。 + +## 8. AgentBus-like Payload 结构建议 + +第一版 payload 目标是让 SuperAgent 获得接近 AgentBus 邮件输入的结构,而不是完整复刻 AgentBus 协议。 + +```json +{ + "source": { + "channel": "EMAIL", + "provider": "DEBUG_EML_UPLOAD", + "external_message_id": "debug-eml-4f2c9a8b7d6e", + "external_conversation_id": "debug-eml-thread-4f2c9a8b7d6e", + "sender": "sender@example.com", + "subject": "Booking Request", + "sent_at": "2026-07-09T01:30:00Z" + }, + "body": { + "content_type": "MIXED", + "text": "plain text body", + "html": "..." + }, + "inline_images": [], + "attachments": [], + "reply_policy": { + "mode": "debug_only", + "final_only": true + }, + "debug_context": { + "debug_run_id": "1900000000000000001", + "run_label": "frontend-debug-smoke", + "original_eml_oss_url": "https://oss.example/debug/eml/..." + } +} +``` + +中文说明: + +- `inline_images[]` 和 `attachments[]` 中的 URL 必须是本系统 OSS URL。 +- `reply_policy.mode=debug_only` 表示本链路只用于调试,不允许 SuperAgent 或本系统发送客户回复。 +- 如果后续 AgentBus 正式 payload 有字段变化,本 Debug 链路可以通过 schema version 单独升级。 + +## 9. SuperAgent Open API 调用口径 + +项目现有文档已验证的 SuperAgent Open API 形态为: + +```text +POST /api/open/agent-sessions +POST /api/open/agent-sessions/{sessionId}/messages/stream +``` + +第一版建议: + +- 后端使用 `DEERFLOW_BASE_URL` 和 `DEERFLOW_OPEN_API_KEY` 调用 SuperAgent。 +- 状态变更请求使用 CSRF double-submit:`X-CSRF-Token` 和 `Cookie: csrf_token=`。 +- 创建 session 时使用 `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` 作为 `external_subject_id`。 +- `idempotency_key` 使用 `debug_run_id` 派生,保证同一次 Debug 运行不会重复创建不可追溯请求。 +- 发送消息时把 AgentBus-like payload 序列化为 JSON 文本,并附加中文指令,要求 SuperAgent 输出结构化 JSON。 +- SSE 解析仍按项目现有经验,从后期 `values.messages[]` 中寻找 `type=ai` 且 `finish_reason=stop` 的最终回答。 + +当前未确认项: + +- SuperAgent 是否需要指定具体 agent、profile 或 prompt。 +- SuperAgent 邮件处理 Open API 是否有比“发送 JSON 文本消息”更稳定的专用入参。 + +第一版实现时应把 profile / prompt 相关内容做成配置或低耦合适配,不写死在业务流程里。 + +## 10. 阿里云 OSS 设计 + +后端新增对象存储端口,业务层不直接依赖阿里云 SDK: + +```text +integrations.storage +└── aliyunoss + ├── adapter + ├── common.request + ├── common.result + └── service / service.impl +``` + +对象路径建议: + +```text +debug/eml/{yyyyMMdd}/{debugRunId}/raw/{originalFileName}.eml +debug/eml/{yyyyMMdd}/{debugRunId}/inline/{index}-{safeFileName} +debug/eml/{yyyyMMdd}/{debugRunId}/attachments/{index}-{safeFileName} +``` + +配置建议: + +```text +ALIYUN_OSS_ENDPOINT +ALIYUN_OSS_BUCKET +ALIYUN_OSS_ACCESS_KEY_ID +ALIYUN_OSS_ACCESS_KEY_SECRET +ALIYUN_OSS_PUBLIC_BASE_URL +ALIYUN_OSS_DEBUG_EML_PREFIX +``` + +安全要求: + +- 阿里云 OSS AccessKey 不得进入前端代码、文档真实值或普通日志。 +- 上传文件名必须清洗,避免路径穿越和日志污染。 +- Content-Type 以解析结果为准,但不能信任邮件原始文件名。 +- 第一版可以使用长期可访问的 OSS URL;如果后续改为私有 bucket,需要补签名 URL 或后端代理读取方案。 + +## 11. 数据模型建议 + +新增 debug run 表: + +```text +platform_debug_eml_superagent_run +``` + +字段建议: + +| 字段 | 中文说明 | +| --- | --- | +| `id` | Debug 运行 ID | +| `hotel_id` | 酒店上下文 ID | +| `run_label` | 前端调试标签 | +| `source_message_id` | 内部 SourceMessage Inbox ID | +| `external_message_id` | Debug 链路外部消息 ID | +| `external_conversation_id` | Debug 链路外部会话 ID | +| `original_file_name` | 原始上传文件名安全摘要 | +| `original_eml_oss_url` | 原始 `.eml` OSS URL | +| `original_eml_sha256` | 原始 `.eml` SHA-256 | +| `payload_json` | AgentBus-like payload JSON | +| `superagent_session_id` | SuperAgent session ID | +| `superagent_run_id` | SuperAgent run ID | +| `superagent_raw_answer` | SuperAgent 最终文本回答 | +| `superagent_parsed_json` | 后端解析出的 JSON | +| `run_status` | `CREATED`、`SOURCE_CAPTURED`、`SUPERAGENT_SUCCEEDED`、`SUPERAGENT_FAILED`、`FAILED` | +| `safe_error_summary` | 安全错误摘要,不包含正文、Secret 或附件签名 URL | +| `created_at` / `updated_at` | 创建和更新时间,按 UTC 写入 | + +说明: + +- Debug run 表用于调试追溯,不替代 SourceMessage Inbox。 +- 大字段是否长期保存需结合库容量评估;第一版可以保存 payload 和 SuperAgent answer,禁止保存 Secret。 +- 如果后续需要调试历史列表,可基于该表新增只读查询接口。 + +## 12. 后端模块建议 + +```text +platform.debug +├── control +├── service +│ └── impl +├── domain +├── mapper +├── repository +└── common + ├── dto + ├── request + ├── result + └── enums + +platform.message +└── service / service.impl + // EML 解析和 SourceMessage 捕获命令组装 + +integrations.storage.aliyunoss +└── adapter / service / service.impl + // 阿里云 OSS 上传适配 + +integrations.ai.superagent +└── service / service.impl / adapter + // SuperAgent Open API 和 SSE 解析 +``` + +中文说明: + +- `platform.debug` 负责编排 Debug 运行,不直接解析厂商协议。 +- `platform.message` 可承接 EML 到 SourceMessage 捕获命令的转换,因为 EML 是消息来源处理能力。 +- `integrations.storage.aliyunoss` 隔离阿里云 OSS SDK。 +- `integrations.ai.superagent` 隔离 SuperAgent Open API、CSRF、SSE 和 Provider DTO。 +- `workflows.reservation` 不参与第一版 Debug 上传链路。 + +## 13. 依赖建议 + +后端实现可能需要新增依赖: + +| 依赖 | 用途 | 说明 | +| --- | --- | --- | +| Jakarta Mail / Angus Mail | 解析 `.eml` MIME 邮件 | 需要确认 Spring Boot 3.5 兼容版本 | +| 阿里云 OSS Java SDK | 上传原始邮件、附件和内联图片 | 只在 integration adapter 使用 | + +新增依赖前必须验证: + +- Java 17 兼容。 +- Spring Boot 3.5 兼容。 +- 测试环境无需真实 OSS 时可以通过端口 mock 或禁用真实上传。 +- 不引入和现有 MyBatis / Spring Boot starter 冲突的依赖。 + +## 14. 安全与审计 + +- Debug 上传接口必须校验 `X-TH-Hotel-Debug-Upload-Key`。 +- `DEBUG_EML_UPLOAD_ACCESS_KEY` 必须按环境变量或部署 Secret 注入。 +- 上传文件大小需要配置上限,避免误传超大邮件。 +- 日志不得输出完整邮件正文、完整 HTML、附件 URL 签名参数、SuperAgent API Key、阿里云 OSS Secret。 +- SourceMessage 原文读取仍按既有受控读取和审计规则处理。 +- Debug run 错误摘要必须是安全摘要。 +- 前端不得把 debug 上传 key、OSS Secret、SuperAgent Open API Key 放入构建产物。 + +## 15. 配置建议 + +```text +DEBUG_EML_UPLOAD_ENABLED=true +DEBUG_EML_UPLOAD_ACCESS_KEY= +DEBUG_EML_UPLOAD_MAX_FILE_BYTES=10485760 + +ALIYUN_OSS_ENDPOINT= +ALIYUN_OSS_BUCKET= +ALIYUN_OSS_ACCESS_KEY_ID= +ALIYUN_OSS_ACCESS_KEY_SECRET= +ALIYUN_OSS_PUBLIC_BASE_URL= +ALIYUN_OSS_DEBUG_EML_PREFIX=debug/eml/ + +DEERFLOW_BASE_URL= +DEERFLOW_OPEN_API_KEY= +SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID=th-hotel-debug-eml-upload +SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT=15s +SUPERAGENT_DEBUG_EML_READ_TIMEOUT=180s +``` + +分环境建议: + +- dev 可以默认关闭真实 SuperAgent 调用,但允许配置后开启。 +- test 需要真实 OSS 和真实 SuperAgent 时,必须由部署环境注入 Secret。 +- prod 默认不建议开放 Debug 上传;如必须开放,应先接入正式用户权限和审计策略。 + +## 16. 验收标准 + +第一版完成后应满足: + +- 上传 `.eml` 后,原始 `.eml`、附件和内联图片均能在阿里云 OSS 找到。 +- HTML 正文中的 `cid:` 图片被替换为 OSS URL。 +- SourceMessage Inbox 中可以查到 `provider=DEBUG_EML_UPLOAD` 的来源消息。 +- SourceMessage 媒体引用中可以查到内联图片、附件和原始 `.eml` 引用。 +- Debug run 表记录 SourceMessage ID、原始文件 hash、OSS URL、SuperAgent session / run 和运行状态。 +- SuperAgent 成功时,接口返回最终 answer 和可解析 JSON。 +- SuperAgent 返回非 JSON 时,接口不报业务成功伪结果,而是返回原始 answer 和 warning。 +- OSS 或 SuperAgent 失败时,接口返回安全错误,不泄漏 Secret 和邮件原文。 +- 现有 Reservation 任务列表、订单详情、任务详情接口不受影响。 + +## 17. 建议目标模式提示词 + +```text +进入目标模式,目标:实现 M004 Debug EML 上传到 SuperAgent 调试链路。 + +范围: +1. 实现 POST /api/system/debug/eml-superagent-runs。 +2. 支持上传 .eml,解析邮件头、text/html 正文、内联图片和附件。 +3. 接入阿里云 OSS,上传原始 .eml、内联图片和附件。 +4. 替换 HTML 正文中的 cid: 图片为 OSS URL。 +5. 组装 AgentBus-like payload。 +6. 写入 SourceMessage Inbox,provider 使用 DEBUG_EML_UPLOAD,schema_version 使用 debug-eml-upload-v1。 +7. 调用 SuperAgent Open API 创建 session 并发送 messages/stream。 +8. 解析 SuperAgent SSE 最终回答,返回 raw answer 和 parsed json。 +9. 新增 debug run 表、Entity、Mapper、Repository、Service、Controller。 +10. 按当前后端代码规范放置 control、service、service.impl、domain、mapper、repository、common.request、common.result、common.dto、common.enums。 +11. Controller、Service、ServiceImpl 方法加中文注释;新增 Entity 字段和 DTO/Result 字段注释符合规范。 +12. 补充测试,覆盖 eml 解析、cid 替换、SourceMessage 写入参数、OSS adapter mock、SuperAgent client mock 和接口鉴权。 +13. 更新前后端沟通文档、上线注意事项和相关配置文档。 + +不做: +1. 不创建订单。 +2. 不创建任务。 +3. 不调用 SuperAgent 任务结果通知接口。 +4. 不做真实 OPERA / OHIP。 +5. 不做批量上传。 +6. 不做 SourceMessage Replay。 +7. 不做普通任务切换订单。 +8. 不做 SuperAgent 查询接口 3、4。 +9. 不接入完整用户 / 权限体系。 + +完成后: +code review,运行测试,中文提交。 +``` + +## 18. 仍需后续关注 + +- SuperAgent 是否会提供邮件处理专用 Open API 入参、profile 或 prompt 配置。 +- `ORIGINAL_EMAIL` 是否作为 SourceMessage 媒体新类型,还是第一版复用 `ATTACHMENT` 并用 `external_media_id` 标记。 +- Debug 上传是否需要历史列表和单次详情查询接口。 +- 私有 OSS bucket 场景下,前端展示附件和图片是否改为短时签名 URL 或后端代理。 +- 正式用户 / 权限体系上线后,Debug 上传接口需要从调试 key 迁移到权限码控制。 diff --git a/server/pom.xml b/server/pom.xml index afead70..a8bfed6 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -23,6 +23,8 @@ true 3.5.16 2.8.17 + 2.0.3 + 3.18.3 @@ -62,6 +64,16 @@ springdoc-openapi-starter-webmvc-ui ${springdoc.version} + + org.eclipse.angus + angus-mail + ${angus-mail.version} + + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-oss.version} + com.h2database diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index e90d7b5..8745098 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -9,6 +9,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; */ @MapperScan({ "cn.nianxx.thhotel.platform.message.mapper", + "cn.nianxx.thhotel.platform.debug.mapper", "cn.nianxx.thhotel.workflows.reservation.mapper", "cn.nianxx.thhotel.integrations.ai.superagent.mapper" }) diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentMailDebugRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentMailDebugRequest.java new file mode 100644 index 0000000..e695ba8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentMailDebugRequest.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.request; + +import java.util.Map; + +/** + * SuperAgent 邮件 Debug 调用请求。该对象是内部稳定请求,不暴露给前端。 + * + * @param message 要发送给 SuperAgent 的消息文本 + * @param idempotencyKey 本次 Debug 调用幂等键 + * @param metadata 调用元数据,不包含 Secret + */ +public record SuperAgentMailDebugRequest( + String message, + String idempotencyKey, + Map metadata +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java new file mode 100644 index 0000000..ba83655 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.result; + +import java.util.List; + +/** + * SuperAgent Open API 调用结果。只保存最终回答和安全元数据,不保存 API Key、Cookie 或内部思考过程。 + * + * @param sessionId SuperAgent session ID + * @param runId SuperAgent run ID + * @param profileId 实际使用的 profile ID + * @param profileVersionId 实际使用的 profile version ID + * @param modelName 模型名称 + * @param rawAnswer 最终 AI 文本回答 + * @param inputTokens 输入 token 数 + * @param outputTokens 输出 token 数 + * @param totalTokens 总 token 数 + * @param eventTypes SSE 事件类型列表 + */ +public record SuperAgentOpenApiResult( + String sessionId, + String runId, + String profileId, + String profileVersionId, + String modelName, + String rawAnswer, + Integer inputTokens, + Integer outputTokens, + Integer totalTokens, + List eventTypes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java new file mode 100644 index 0000000..24c2da0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentMailDebugRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; + +/** + * SuperAgent Open API 客户端端口。业务层只依赖该接口,不直接拼 HTTP 或解析 SSE。 + */ +public interface SuperAgentOpenApiClient { + + /** + * 创建 SuperAgent session 并发送邮件 Debug 消息,返回最终 AI 回答。 + */ + SuperAgentOpenApiResult invokeMailDebug(SuperAgentMailDebugRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java new file mode 100644 index 0000000..5cc89cd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java @@ -0,0 +1,145 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentMailDebugRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +/** + * SuperAgent Open API HTTP 客户端实现。负责创建 session、发送 SSE 消息和解析最终回答。 + */ +@Service +public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { + + private final SuperAgentOpenApiProperties properties; + private final SuperAgentOpenApiSseParser sseParser; + private final ObjectMapper objectMapper; + + /** + * 注入 SuperAgent 配置、SSE 解析器和 JSON 工具。 + */ + public SuperAgentOpenApiClientImpl( + SuperAgentOpenApiProperties properties, + SuperAgentOpenApiSseParser sseParser, + ObjectMapper objectMapper) { + this.properties = properties; + this.sseParser = sseParser; + this.objectMapper = objectMapper; + } + + /** + * 调用 SuperAgent Open API 邮件 Debug 流程;配置缺失时直接失败,避免静默跳过真实调用。 + */ + @Override + public SuperAgentOpenApiResult invokeMailDebug(SuperAgentMailDebugRequest request) { + validateProperties(); + try { + RestClient restClient = RestClient.builder() + .baseUrl(properties.getBaseUrl()) + .requestFactory(requestFactory()) + .build(); + String sessionId = createSession(restClient, request); + String sseBody = sendMessage(restClient, sessionId, request); + return sseParser.parse(sessionId, sseBody); + } catch (SuperAgentOpenApiException exception) { + throw exception; + } catch (Exception exception) { + throw new SuperAgentOpenApiException("SuperAgent Open API 调用失败。", exception); + } + } + + /** + * 创建 SuperAgent session。 + */ + private String createSession(RestClient restClient, SuperAgentMailDebugRequest request) throws Exception { + String csrfToken = UUID.randomUUID().toString(); + Map body = new LinkedHashMap<>(); + body.put("external_subject_id", properties.getExternalSubjectId()); + body.put("idempotency_key", request.idempotencyKey() + "-session"); + body.put("metadata", request.metadata()); + String response = restClient.post() + .uri("/api/open/agent-sessions") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + properties.getApiKey()) + .header("X-CSRF-Token", csrfToken) + .header("Cookie", "csrf_token=" + csrfToken) + .body(body) + .retrieve() + .body(String.class); + JsonNode json = objectMapper.readTree(response == null ? "{}" : response); + String sessionId = text(json, "session_id"); + if (sessionId == null) { + sessionId = text(json, "id"); + } + if (sessionId == null) { + throw new SuperAgentOpenApiException("SuperAgent 创建 session 响应缺少 session_id。"); + } + return sessionId; + } + + /** + * 发送 Debug 邮件消息并读取 SSE 文本响应。 + */ + private String sendMessage(RestClient restClient, String sessionId, SuperAgentMailDebugRequest request) { + String csrfToken = UUID.randomUUID().toString(); + Map body = new LinkedHashMap<>(); + body.put("message", request.message()); + body.put("idempotency_key", request.idempotencyKey() + "-message"); + body.put("metadata", request.metadata()); + return restClient.post() + .uri("/api/open/agent-sessions/{sessionId}/messages/stream", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.TEXT_EVENT_STREAM) + .header("Authorization", "Bearer " + properties.getApiKey()) + .header("X-CSRF-Token", csrfToken) + .header("Cookie", "csrf_token=" + csrfToken) + .body(body) + .retrieve() + .body(String.class); + } + + /** + * 校验 SuperAgent Open API 必要配置。 + */ + private void validateProperties() { + if (!properties.isEnabled()) { + throw new SuperAgentOpenApiException("SuperAgent Open API 未启用。"); + } + if (blank(properties.getBaseUrl()) || blank(properties.getApiKey()) || blank(properties.getExternalSubjectId())) { + throw new SuperAgentOpenApiException("SuperAgent Open API 配置不完整。"); + } + } + + /** + * 构造带超时设置的 HTTP 请求工厂,避免 SSE 调用无限等待。 + */ + private SimpleClientHttpRequestFactory requestFactory() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.getConnectTimeout()); + requestFactory.setReadTimeout(properties.getReadTimeout()); + return requestFactory; + } + + /** + * 读取 JSON 文本字段。 + */ + private String text(JsonNode json, String fieldName) { + JsonNode value = json.path(fieldName); + return value.isMissingNode() || value.isNull() || value.asText().isBlank() ? null : value.asText(); + } + + /** + * 判断字符串是否为空白。 + */ + private boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiException.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiException.java new file mode 100644 index 0000000..f07de65 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiException.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +/** + * SuperAgent Open API 调用异常。错误消息必须脱敏,不包含 API Key、Cookie 或原始邮件正文。 + */ +public class SuperAgentOpenApiException extends RuntimeException { + + public SuperAgentOpenApiException(String message) { + super(message); + } + + public SuperAgentOpenApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java new file mode 100644 index 0000000..6e0b277 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java @@ -0,0 +1,74 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * SuperAgent Open API 配置。API Key 只能来自环境变量或部署 Secret。 + */ +@Component +@ConfigurationProperties(prefix = "superagent.open-api") +public class SuperAgentOpenApiProperties { + + /** 是否启用真实 SuperAgent Open API 调用。 */ + private boolean enabled = false; + /** SuperAgent / DeerFlow Open API 基础地址。 */ + private String baseUrl = ""; + /** SuperAgent Open API Key。 */ + private String apiKey = ""; + /** Debug EML 创建 session 时使用的 external_subject_id。 */ + private String externalSubjectId = "th-hotel-debug-eml-upload"; + /** 建立连接超时。 */ + private Duration connectTimeout = Duration.ofSeconds(15); + /** SSE 读取超时。 */ + private Duration readTimeout = Duration.ofSeconds(180); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getExternalSubjectId() { + return externalSubjectId; + } + + public void setExternalSubjectId(String externalSubjectId) { + this.externalSubjectId = externalSubjectId; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java new file mode 100644 index 0000000..c192097 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java @@ -0,0 +1,170 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** + * SuperAgent SSE 响应解析器。优先从 values.messages 中提取 finish_reason=stop 的最终 AI 回答。 + */ +@Component +public class SuperAgentOpenApiSseParser { + + private final ObjectMapper objectMapper; + + /** + * 使用默认 ObjectMapper,便于单元测试直接 new。 + */ + public SuperAgentOpenApiSseParser() { + this(new ObjectMapper()); + } + + /** + * 注入 JSON 解析器。 + */ + public SuperAgentOpenApiSseParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 解析 SSE 文本,返回最终 AI 回答和调用元数据。 + */ + public SuperAgentOpenApiResult parse(String sessionId, String sseBody) { + if (sseBody == null || sseBody.isBlank()) { + throw new SuperAgentOpenApiException("SuperAgent SSE 响应为空。"); + } + Set eventTypes = new LinkedHashSet<>(); + ParsedState state = new ParsedState(); + for (SseEvent event : splitEvents(sseBody)) { + eventTypes.add(event.eventType()); + consumeEvent(event, state); + } + if (state.rawAnswer == null || state.rawAnswer.isBlank()) { + throw new SuperAgentOpenApiException("SuperAgent SSE 未找到最终 AI 回答。"); + } + return new SuperAgentOpenApiResult( + sessionId, + state.runId, + state.profileId, + state.profileVersionId, + state.modelName, + state.rawAnswer, + state.inputTokens, + state.outputTokens, + state.totalTokens, + List.copyOf(eventTypes)); + } + + /** + * 消费单个 SSE 事件。 + */ + private void consumeEvent(SseEvent event, ParsedState state) { + try { + JsonNode data = objectMapper.readTree(event.data()); + if ("metadata".equals(event.eventType())) { + state.runId = text(data, "run_id", state.runId); + state.profileId = text(data, "resolved_profile_id", state.profileId); + state.profileVersionId = text(data, "resolved_profile_version_id", state.profileVersionId); + } + if ("values".equals(event.eventType())) { + consumeValuesEvent(data, state); + } + } catch (Exception exception) { + throw new SuperAgentOpenApiException("SuperAgent SSE JSON 解析失败。", exception); + } + } + + /** + * 从 values.messages 中提取最终 AI 消息和 token 用量。 + */ + private void consumeValuesEvent(JsonNode data, ParsedState state) { + JsonNode messages = data.path("messages"); + if (!messages.isArray()) { + return; + } + for (JsonNode message : messages) { + if (!"ai".equals(message.path("type").asText())) { + continue; + } + String finishReason = message.path("response_metadata").path("finish_reason").asText(); + String content = message.path("content").asText(null); + if ("stop".equals(finishReason) && content != null && !content.isBlank()) { + state.rawAnswer = content; + state.modelName = text(message.path("response_metadata"), "model_name", state.modelName); + JsonNode usage = message.path("usage_metadata"); + state.inputTokens = intValue(usage, "input_tokens", state.inputTokens); + state.outputTokens = intValue(usage, "output_tokens", state.outputTokens); + state.totalTokens = intValue(usage, "total_tokens", state.totalTokens); + } + } + } + + /** + * 拆分 SSE 事件块,支持多行 data。 + */ + private List splitEvents(String sseBody) { + String[] blocks = sseBody.split("\\R\\s*\\R"); + List events = new ArrayList<>(); + for (String block : blocks) { + String eventType = "message"; + StringBuilder data = new StringBuilder(); + for (String line : block.split("\\R")) { + if (line.startsWith("event:")) { + eventType = line.substring("event:".length()).trim(); + continue; + } + if (line.startsWith("data:")) { + if (!data.isEmpty()) { + data.append('\n'); + } + data.append(line.substring("data:".length()).trim()); + } + } + if (!data.isEmpty()) { + events.add(new SseEvent(eventType, data.toString())); + } + } + return events; + } + + /** + * 读取文本字段,缺失时保留已有值。 + */ + private String text(JsonNode node, String fieldName, String fallback) { + JsonNode value = node.path(fieldName); + return value.isMissingNode() || value.isNull() ? fallback : value.asText(); + } + + /** + * 读取整数字段,缺失时保留已有值。 + */ + private Integer intValue(JsonNode node, String fieldName, Integer fallback) { + JsonNode value = node.path(fieldName); + return value.isMissingNode() || value.isNull() ? fallback : value.asInt(); + } + + /** + * SSE 单事件。 + */ + private record SseEvent(String eventType, String data) { + } + + /** + * SSE 解析过程中的可变状态。 + */ + private static final class ParsedState { + private String runId; + private String profileId; + private String profileVersionId; + private String modelName; + private String rawAnswer; + private Integer inputTokens; + private Integer outputTokens; + private Integer totalTokens; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/request/ObjectStoragePutRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/request/ObjectStoragePutRequest.java new file mode 100644 index 0000000..55baa57 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/request/ObjectStoragePutRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request; + +/** + * 对象存储上传请求。调用方提供对象路径和字节内容,Adapter 负责上传到阿里云 OSS。 + * + * @param objectKey OSS 对象路径 + * @param fileName 原始或安全文件名 + * @param contentType MIME 类型 + * @param sizeBytes 文件大小字节数 + * @param content 文件字节内容 + */ +public record ObjectStoragePutRequest( + String objectKey, + String fileName, + String contentType, + Long sizeBytes, + byte[] content +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/result/ObjectStoragePutResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/result/ObjectStoragePutResult.java new file mode 100644 index 0000000..40d860e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/common/result/ObjectStoragePutResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result; + +/** + * 对象存储上传结果。只返回业务需要的对象路径和访问 URL,不暴露 OSS Secret。 + * + * @param objectKey OSS 对象路径 + * @param publicUrl 对外访问 URL + * @param contentType MIME 类型 + * @param sizeBytes 文件大小字节数 + */ +public record ObjectStoragePutResult( + String objectKey, + String publicUrl, + String contentType, + Long sizeBytes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/ObjectStorageService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/ObjectStorageService.java new file mode 100644 index 0000000..01f714a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/ObjectStorageService.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.service; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; + +/** + * 对象存储上传端口。业务层依赖该端口,不直接依赖阿里云 OSS SDK。 + */ +public interface ObjectStorageService { + + /** + * 上传对象并返回可保存到 SourceMessage 的访问 URL。 + */ + ObjectStoragePutResult putObject(ObjectStoragePutRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssObjectStorageServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssObjectStorageServiceImpl.java new file mode 100644 index 0000000..2018c2f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssObjectStorageServiceImpl.java @@ -0,0 +1,90 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.ObjectMetadata; +import java.io.ByteArrayInputStream; +import org.springframework.stereotype.Service; + +/** + * 阿里云 OSS 上传适配实现。负责 SDK 调用和 URL 拼接,业务层不感知 OSS SDK。 + */ +@Service +public class AliyunOssObjectStorageServiceImpl implements ObjectStorageService { + + private final AliyunOssProperties properties; + + /** + * 注入阿里云 OSS 配置。 + */ + public AliyunOssObjectStorageServiceImpl(AliyunOssProperties properties) { + this.properties = properties; + } + + /** + * 上传对象到阿里云 OSS;配置缺失时失败,避免误以为本地 mock 已生效。 + */ + @Override + public ObjectStoragePutResult putObject(ObjectStoragePutRequest request) { + validateProperties(); + if (request == null || request.objectKey() == null || request.objectKey().isBlank()) { + throw new ObjectStorageException("OSS object key 不能为空。"); + } + byte[] content = request.content() == null ? new byte[0] : request.content(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(content.length); + if (request.contentType() != null && !request.contentType().isBlank()) { + metadata.setContentType(request.contentType()); + } + OSS ossClient = new OSSClientBuilder().build( + properties.getEndpoint(), + properties.getAccessKeyId(), + properties.getAccessKeySecret()); + try { + ossClient.putObject( + properties.getBucket(), + request.objectKey(), + new ByteArrayInputStream(content), + metadata); + return new ObjectStoragePutResult( + request.objectKey(), + publicUrl(request.objectKey()), + request.contentType(), + (long) content.length); + } catch (Exception exception) { + throw new ObjectStorageException("阿里云 OSS 上传失败。", exception); + } finally { + ossClient.shutdown(); + } + } + + /** + * 校验 OSS 必要配置,错误消息不包含任何 Secret。 + */ + private void validateProperties() { + if (blank(properties.getEndpoint()) || blank(properties.getBucket()) + || blank(properties.getAccessKeyId()) || blank(properties.getAccessKeySecret()) + || blank(properties.getPublicBaseUrl())) { + throw new ObjectStorageException("阿里云 OSS 配置不完整。"); + } + } + + /** + * 根据公开访问基础 URL 拼接对象 URL。 + */ + private String publicUrl(String objectKey) { + String baseUrl = properties.getPublicBaseUrl(); + String normalizedBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + return normalizedBase + "/" + objectKey; + } + + /** + * 判断字符串是否为空白。 + */ + private boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssProperties.java new file mode 100644 index 0000000..55c4a54 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/AliyunOssProperties.java @@ -0,0 +1,73 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 阿里云 OSS 配置。Secret 只能由环境变量或部署平台注入,不得提交真实值。 + */ +@Component +@ConfigurationProperties(prefix = "aliyun.oss") +public class AliyunOssProperties { + + /** OSS Endpoint。 */ + private String endpoint = ""; + /** OSS Bucket 名称。 */ + private String bucket = ""; + /** OSS AccessKey ID。 */ + private String accessKeyId = ""; + /** OSS AccessKey Secret。 */ + private String accessKeySecret = ""; + /** OSS 公开访问基础 URL。 */ + private String publicBaseUrl = ""; + /** Debug EML 对象路径前缀。 */ + private String debugEmlPrefix = "debug/eml/"; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getAccessKeySecret() { + return accessKeySecret; + } + + public void setAccessKeySecret(String accessKeySecret) { + this.accessKeySecret = accessKeySecret; + } + + public String getPublicBaseUrl() { + return publicBaseUrl; + } + + public void setPublicBaseUrl(String publicBaseUrl) { + this.publicBaseUrl = publicBaseUrl; + } + + public String getDebugEmlPrefix() { + return debugEmlPrefix; + } + + public void setDebugEmlPrefix(String debugEmlPrefix) { + this.debugEmlPrefix = debugEmlPrefix; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/ObjectStorageException.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/ObjectStorageException.java new file mode 100644 index 0000000..f7b5cbf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/ObjectStorageException.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl; + +/** + * 对象存储异常。对外只暴露安全摘要,不包含 AccessKey、签名或完整对象内容。 + */ +public class ObjectStorageException extends RuntimeException { + + public ObjectStorageException(String message) { + super(message); + } + + public ObjectStorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunDraft.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunDraft.java new file mode 100644 index 0000000..786b997 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunDraft.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.debug.common.dto; + +import java.time.LocalDateTime; + +/** + * Debug EML 运行入库草稿,由 Service 编排完成后交给 Repository 持久化。 + * + * @param hotelId 酒店或业务上下文 ID + * @param runLabel 前端传入的调试标签 + * @param runStatus 初始运行状态 + * @param createdAt 创建 UTC 时间 + */ +public record DebugEmlSuperAgentRunDraft( + String hotelId, + String runLabel, + String runStatus, + LocalDateTime createdAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunSnapshot.java new file mode 100644 index 0000000..1396a9b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunSnapshot.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.platform.debug.common.dto; + +import java.time.LocalDateTime; + +/** + * Debug EML 运行快照,用于 Service 返回或后续查询,不暴露数据库 Entity。 + * + * @param id Debug 运行 ID + * @param hotelId 酒店或业务上下文 ID + * @param runStatus 当前运行状态 + * @param sourceMessageId 关联的内部 SourceMessage ID + * @param createdAt 创建 UTC 时间 + */ +public record DebugEmlSuperAgentRunSnapshot( + Long id, + String hotelId, + String runStatus, + Long sourceMessageId, + LocalDateTime createdAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunStatusUpdate.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunStatusUpdate.java new file mode 100644 index 0000000..012bba6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunStatusUpdate.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.debug.common.dto; + +import java.time.LocalDateTime; + +/** + * Debug EML 运行状态更新草稿,用于只更新链路状态和安全错误摘要。 + * + * @param id Debug 运行 ID + * @param runStatus 运行状态 + * @param safeErrorSummary 安全错误摘要 + * @param updatedAt 更新 UTC 时间 + */ +public record DebugEmlSuperAgentRunStatusUpdate( + Long id, + String runStatus, + String safeErrorSummary, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunUpdate.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunUpdate.java new file mode 100644 index 0000000..27a7ee6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/dto/DebugEmlSuperAgentRunUpdate.java @@ -0,0 +1,53 @@ +package cn.nianxx.thhotel.platform.debug.common.dto; + +import java.time.LocalDateTime; + +/** + * Debug EML 运行更新草稿,用于一次性回填 SourceMessage、OSS 和 SuperAgent 调用结果。 + * + * @param id Debug 运行 ID + * @param sourceMessageId 内部 SourceMessage ID + * @param externalMessageId Debug 外部邮件 ID + * @param externalConversationId Debug 外部会话 ID + * @param originalFileName 原始上传文件安全文件名 + * @param originalEmlOssUrl 原始 .eml OSS URL + * @param originalEmlSha256 原始 .eml SHA-256 + * @param payloadJson AgentBus-like payload JSON + * @param superagentSessionId SuperAgent session ID + * @param superagentRunId SuperAgent run ID + * @param superagentProfileId SuperAgent profile ID + * @param superagentProfileVersionId SuperAgent profile version ID + * @param superagentModelName SuperAgent 模型名称 + * @param superagentRawAnswer SuperAgent 原始最终回答 + * @param superagentParsedJson 后端解析出的 JSON 字符串 + * @param superagentInputTokens 输入 token 数 + * @param superagentOutputTokens 输出 token 数 + * @param superagentTotalTokens 总 token 数 + * @param runStatus 运行状态 + * @param safeErrorSummary 安全错误摘要 + * @param updatedAt 更新 UTC 时间 + */ +public record DebugEmlSuperAgentRunUpdate( + Long id, + Long sourceMessageId, + String externalMessageId, + String externalConversationId, + String originalFileName, + String originalEmlOssUrl, + String originalEmlSha256, + String payloadJson, + String superagentSessionId, + String superagentRunId, + String superagentProfileId, + String superagentProfileVersionId, + String superagentModelName, + String superagentRawAnswer, + String superagentParsedJson, + Integer superagentInputTokens, + Integer superagentOutputTokens, + Integer superagentTotalTokens, + String runStatus, + String safeErrorSummary, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java new file mode 100644 index 0000000..f96d2f3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/enums/DebugEmlSuperAgentRunStatus.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.platform.debug.common.enums; + +/** + * Debug EML 上传到 SuperAgent 的运行状态。只表达调试链路状态,不代表业务任务状态。 + */ +public enum DebugEmlSuperAgentRunStatus { + + CREATED, + SOURCE_CAPTURED, + SUPERAGENT_SUCCEEDED, + SUPERAGENT_FAILED, + FAILED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlErrorResponse.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlErrorResponse.java new file mode 100644 index 0000000..df8c5fe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlErrorResponse.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.platform.debug.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Debug EML 接口错误响应。错误消息必须是安全摘要。 + * + * @param errorCode 稳定错误码 + * @param message 安全错误摘要 + */ +public record DebugEmlErrorResponse( + @JsonProperty("error_code") + String errorCode, + String message +) { +} 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 new file mode 100644 index 0000000..6a8111a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlSuperAgentRunResult.java @@ -0,0 +1,60 @@ +package cn.nianxx.thhotel.platform.debug.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; + +/** + * Debug EML 上传到 SuperAgent 的响应结果。仅用于调试页面,不代表业务任务创建成功。 + * + * @param debugRunId Debug 运行 ID + * @param sourceMessageId 内部 SourceMessage ID + * @param sourceProvider 来源 provider,固定 DEBUG_EML_UPLOAD + * @param externalMessageId 外部邮件 ID + * @param externalConversationId 外部会话 ID + * @param originalEmlOssUrl 原始 .eml OSS URL + * @param originalEmlSha256 原始 .eml SHA-256 + * @param uploadedMedia 上传后的媒体列表 + * @param htmlBodyWithOssUrls 替换 cid 后的 HTML + * @param agentbusLikePayload 发送给 SuperAgent 的结构化 payload + * @param superagentSessionId SuperAgent session ID + * @param superagentRunId SuperAgent run ID + * @param superagentRawAnswer SuperAgent 原始最终回答 + * @param superagentParsedJson 后端解析出的 JSON + * @param warnings 可展示的安全警告 + * @param status Debug 运行状态 + */ +public record DebugEmlSuperAgentRunResult( + @JsonProperty("debug_run_id") + String debugRunId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("source_provider") + String sourceProvider, + @JsonProperty("external_message_id") + String externalMessageId, + @JsonProperty("external_conversation_id") + String externalConversationId, + @JsonProperty("original_eml_oss_url") + String originalEmlOssUrl, + @JsonProperty("original_eml_sha256") + String originalEmlSha256, + @JsonProperty("uploaded_media") + List uploadedMedia, + @JsonProperty("html_body_with_oss_urls") + String htmlBodyWithOssUrls, + @JsonProperty("agentbus_like_payload") + Map agentbusLikePayload, + @JsonProperty("superagent_session_id") + String superagentSessionId, + @JsonProperty("superagent_run_id") + String superagentRunId, + @JsonProperty("superagent_raw_answer") + String superagentRawAnswer, + @JsonProperty("superagent_parsed_json") + JsonNode superagentParsedJson, + List warnings, + String status +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlUploadedMediaResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlUploadedMediaResult.java new file mode 100644 index 0000000..ebdc84c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/common/result/DebugEmlUploadedMediaResult.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.platform.debug.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Debug EML 上传后的媒体结果,用于前端调试展示和 SourceMessage 追溯。 + * + * @param mediaType 媒体类型 + * @param fileName 文件名 + * @param contentType MIME 类型 + * @param sizeBytes 文件大小字节数 + * @param externalUrl OSS 访问 URL + * @param externalMediaId 调试链路媒体 ID + * @param objectKey OSS 对象路径 + */ +public record DebugEmlUploadedMediaResult( + @JsonProperty("media_type") + String mediaType, + @JsonProperty("file_name") + String fileName, + @JsonProperty("content_type") + String contentType, + @JsonProperty("size_bytes") + Long sizeBytes, + @JsonProperty("external_url") + String externalUrl, + @JsonProperty("external_media_id") + String externalMediaId, + @JsonProperty("object_key") + String objectKey +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java new file mode 100644 index 0000000..fe74f3b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentController.java @@ -0,0 +1,44 @@ +package cn.nianxx.thhotel.platform.debug.control; + +import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlSuperAgentRunResult; +import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * Debug EML 上传 Controller。该入口只用于受控调试,不创建业务订单或任务。 + */ +@RestController +@RequestMapping("/api/system/debug/eml-superagent-runs") +@ConditionalOnProperty(prefix = "debug.eml-upload", name = "enabled", havingValue = "true") +public class DebugEmlSuperAgentController { + + private final DebugEmlSuperAgentRunService runService; + + /** + * 注入 Debug EML 服务,Controller 不直接访问 OSS、SuperAgent 或 Mapper。 + */ + public DebugEmlSuperAgentController(DebugEmlSuperAgentRunService runService) { + this.runService = runService; + } + + /** + * 上传单封 .eml 邮件,写入 SourceMessage 后调用 SuperAgent Open API 并返回调试结果。 + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity upload( + @RequestHeader(name = "X-TH-Hotel-Debug-Upload-Key", required = false) String accessKey, + @RequestParam("file") MultipartFile file, + @RequestParam("hotel_id") String hotelId, + @RequestParam(name = "run_label", required = false) String runLabel) { + return ResponseEntity.status(HttpStatus.CREATED).body(runService.uploadAndRun(accessKey, file, hotelId, runLabel)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerAdvice.java new file mode 100644 index 0000000..63ddaff --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerAdvice.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.platform.debug.control; + +import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlErrorResponse; +import cn.nianxx.thhotel.platform.debug.service.impl.DebugEmlSuperAgentException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +/** + * Debug EML 接口异常转换。对外只返回安全错误摘要,不暴露邮件正文、HTML、OSS Secret 或 Provider Secret。 + */ +@RestControllerAdvice(assignableTypes = DebugEmlSuperAgentController.class) +public class DebugEmlSuperAgentControllerAdvice { + + /** + * 转换 Debug EML 业务异常。 + */ + @ExceptionHandler(DebugEmlSuperAgentException.class) + public ResponseEntity handleDebugEmlException(DebugEmlSuperAgentException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new DebugEmlErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 转换缺少 multipart 文件或必填请求参数的异常。 + */ + @ExceptionHandler({MissingServletRequestPartException.class, MissingServletRequestParameterException.class}) + public ResponseEntity handleMissingRequestPart(Exception exception) { + return ResponseEntity.badRequest() + .body(new DebugEmlErrorResponse("REQUEST_FIELD_REQUIRED", "Debug EML 上传请求缺少必填字段。")); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/domain/DebugEmlSuperAgentRunEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/domain/DebugEmlSuperAgentRunEntity.java new file mode 100644 index 0000000..7b3d2b0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/domain/DebugEmlSuperAgentRunEntity.java @@ -0,0 +1,255 @@ +package cn.nianxx.thhotel.platform.debug.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Debug EML 上传到 SuperAgent 运行实体。记录调试链路的来源捕获、OSS 转存和 AI 调用结果。 + */ +@TableName("platform_debug_eml_superagent_run") +public class DebugEmlSuperAgentRunEntity { + + /** Debug 运行 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 前端传入的调试标签。 */ + private String runLabel; + /** 关联的内部 SourceMessage Inbox ID。 */ + private Long sourceMessageId; + /** Debug 链路解析或生成的外部邮件 ID。 */ + private String externalMessageId; + /** Debug 链路解析或生成的外部邮件会话 ID。 */ + private String externalConversationId; + /** 原始上传 .eml 文件名的安全清洗结果。 */ + private String originalFileName; + /** 原始 .eml 文件 OSS URL。 */ + private String originalEmlOssUrl; + /** 原始 .eml 文件 SHA-256。 */ + private String originalEmlSha256; + /** AgentBus-like payload JSON。 */ + private String payloadJson; + /** SuperAgent Open API session ID。 */ + private String superagentSessionId; + /** SuperAgent run ID。 */ + private String superagentRunId; + /** SuperAgent profile ID。 */ + private String superagentProfileId; + /** SuperAgent profile version ID。 */ + private String superagentProfileVersionId; + /** SuperAgent 模型名称。 */ + private String superagentModelName; + /** SuperAgent 最终原始文本回答。 */ + private String superagentRawAnswer; + /** 后端解析出的 SuperAgent JSON 字符串。 */ + private String superagentParsedJson; + /** SuperAgent 输入 token 数。 */ + private Integer superagentInputTokens; + /** SuperAgent 输出 token 数。 */ + private Integer superagentOutputTokens; + /** SuperAgent 总 token 数。 */ + private Integer superagentTotalTokens; + /** Debug 运行状态。 */ + private String runStatus; + /** 安全错误摘要,不包含邮件正文、Secret 或附件签名 URL。 */ + private String safeErrorSummary; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHotelId() { + return hotelId; + } + + public void setHotelId(String hotelId) { + this.hotelId = hotelId; + } + + public String getRunLabel() { + return runLabel; + } + + public void setRunLabel(String runLabel) { + this.runLabel = runLabel; + } + + public Long getSourceMessageId() { + return sourceMessageId; + } + + public void setSourceMessageId(Long sourceMessageId) { + this.sourceMessageId = sourceMessageId; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getExternalConversationId() { + return externalConversationId; + } + + public void setExternalConversationId(String externalConversationId) { + this.externalConversationId = externalConversationId; + } + + public String getOriginalFileName() { + return originalFileName; + } + + public void setOriginalFileName(String originalFileName) { + this.originalFileName = originalFileName; + } + + public String getOriginalEmlOssUrl() { + return originalEmlOssUrl; + } + + public void setOriginalEmlOssUrl(String originalEmlOssUrl) { + this.originalEmlOssUrl = originalEmlOssUrl; + } + + public String getOriginalEmlSha256() { + return originalEmlSha256; + } + + public void setOriginalEmlSha256(String originalEmlSha256) { + this.originalEmlSha256 = originalEmlSha256; + } + + public String getPayloadJson() { + return payloadJson; + } + + public void setPayloadJson(String payloadJson) { + this.payloadJson = payloadJson; + } + + public String getSuperagentSessionId() { + return superagentSessionId; + } + + public void setSuperagentSessionId(String superagentSessionId) { + this.superagentSessionId = superagentSessionId; + } + + public String getSuperagentRunId() { + return superagentRunId; + } + + public void setSuperagentRunId(String superagentRunId) { + this.superagentRunId = superagentRunId; + } + + public String getSuperagentProfileId() { + return superagentProfileId; + } + + public void setSuperagentProfileId(String superagentProfileId) { + this.superagentProfileId = superagentProfileId; + } + + public String getSuperagentProfileVersionId() { + return superagentProfileVersionId; + } + + public void setSuperagentProfileVersionId(String superagentProfileVersionId) { + this.superagentProfileVersionId = superagentProfileVersionId; + } + + public String getSuperagentModelName() { + return superagentModelName; + } + + public void setSuperagentModelName(String superagentModelName) { + this.superagentModelName = superagentModelName; + } + + public String getSuperagentRawAnswer() { + return superagentRawAnswer; + } + + public void setSuperagentRawAnswer(String superagentRawAnswer) { + this.superagentRawAnswer = superagentRawAnswer; + } + + public String getSuperagentParsedJson() { + return superagentParsedJson; + } + + public void setSuperagentParsedJson(String superagentParsedJson) { + this.superagentParsedJson = superagentParsedJson; + } + + public Integer getSuperagentInputTokens() { + return superagentInputTokens; + } + + public void setSuperagentInputTokens(Integer superagentInputTokens) { + this.superagentInputTokens = superagentInputTokens; + } + + public Integer getSuperagentOutputTokens() { + return superagentOutputTokens; + } + + public void setSuperagentOutputTokens(Integer superagentOutputTokens) { + this.superagentOutputTokens = superagentOutputTokens; + } + + public Integer getSuperagentTotalTokens() { + return superagentTotalTokens; + } + + public void setSuperagentTotalTokens(Integer superagentTotalTokens) { + this.superagentTotalTokens = superagentTotalTokens; + } + + public String getRunStatus() { + return runStatus; + } + + public void setRunStatus(String runStatus) { + this.runStatus = runStatus; + } + + public String getSafeErrorSummary() { + return safeErrorSummary; + } + + public void setSafeErrorSummary(String safeErrorSummary) { + this.safeErrorSummary = safeErrorSummary; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/mapper/DebugEmlSuperAgentRunMapper.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/mapper/DebugEmlSuperAgentRunMapper.java new file mode 100644 index 0000000..e8a680c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/mapper/DebugEmlSuperAgentRunMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.platform.debug.mapper; + +import cn.nianxx.thhotel.platform.debug.domain.DebugEmlSuperAgentRunEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Debug EML 运行表 Mapper,只负责本表 MyBatis-Plus 基础访问。 + */ +@Mapper +public interface DebugEmlSuperAgentRunMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/DebugEmlSuperAgentRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/DebugEmlSuperAgentRunRepository.java new file mode 100644 index 0000000..270362b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/DebugEmlSuperAgentRunRepository.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.platform.debug.repository; + +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunDraft; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunSnapshot; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate; +import java.util.Optional; + +/** + * Debug EML 运行持久化端口,Service 不直接依赖 Mapper 和数据库 Entity。 + */ +public interface DebugEmlSuperAgentRunRepository { + + /** + * 新建 Debug 运行记录,返回运行 ID。 + */ + Long insert(DebugEmlSuperAgentRunDraft draft); + + /** + * 回填 Debug 运行结果,包括 SourceMessage、OSS 和 SuperAgent 信息。 + */ + void updateResult(DebugEmlSuperAgentRunUpdate update); + + /** + * 更新 Debug 运行状态和安全错误摘要,不覆盖已落库的 SourceMessage 或 OSS 信息。 + */ + void updateStatus(DebugEmlSuperAgentRunStatusUpdate update); + + /** + * 按 Debug 运行 ID 查询安全快照。 + */ + Optional findById(Long id); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java new file mode 100644 index 0000000..d3ba841 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/repository/MybatisDebugEmlSuperAgentRunRepository.java @@ -0,0 +1,105 @@ +package cn.nianxx.thhotel.platform.debug.repository; + +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunDraft; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunSnapshot; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate; +import cn.nianxx.thhotel.platform.debug.domain.DebugEmlSuperAgentRunEntity; +import cn.nianxx.thhotel.platform.debug.mapper.DebugEmlSuperAgentRunMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * Debug EML 运行 MyBatis 持久化实现。负责 Entity 转换,不把 Mapper 暴露给 Service。 + */ +@Repository +public class MybatisDebugEmlSuperAgentRunRepository implements DebugEmlSuperAgentRunRepository { + + private final DebugEmlSuperAgentRunMapper runMapper; + + /** + * 注入 Debug EML Mapper。 + */ + public MybatisDebugEmlSuperAgentRunRepository(DebugEmlSuperAgentRunMapper runMapper) { + this.runMapper = runMapper; + } + + /** + * 新建 Debug 运行记录,初始只保存酒店、标签和状态。 + */ + @Override + public Long insert(DebugEmlSuperAgentRunDraft draft) { + DebugEmlSuperAgentRunEntity entity = new DebugEmlSuperAgentRunEntity(); + entity.setHotelId(draft.hotelId()); + entity.setRunLabel(draft.runLabel()); + entity.setRunStatus(draft.runStatus()); + entity.setCreatedAt(draft.createdAt()); + entity.setUpdatedAt(draft.createdAt()); + runMapper.insert(entity); + return entity.getId(); + } + + /** + * 回填 Debug 运行结果;调用方负责保证错误摘要已经脱敏。 + */ + @Override + public void updateResult(DebugEmlSuperAgentRunUpdate update) { + DebugEmlSuperAgentRunEntity entity = new DebugEmlSuperAgentRunEntity(); + entity.setId(update.id()); + entity.setSourceMessageId(update.sourceMessageId()); + entity.setExternalMessageId(update.externalMessageId()); + entity.setExternalConversationId(update.externalConversationId()); + entity.setOriginalFileName(update.originalFileName()); + entity.setOriginalEmlOssUrl(update.originalEmlOssUrl()); + entity.setOriginalEmlSha256(update.originalEmlSha256()); + entity.setPayloadJson(update.payloadJson()); + entity.setSuperagentSessionId(update.superagentSessionId()); + entity.setSuperagentRunId(update.superagentRunId()); + entity.setSuperagentProfileId(update.superagentProfileId()); + entity.setSuperagentProfileVersionId(update.superagentProfileVersionId()); + entity.setSuperagentModelName(update.superagentModelName()); + entity.setSuperagentRawAnswer(update.superagentRawAnswer()); + entity.setSuperagentParsedJson(update.superagentParsedJson()); + entity.setSuperagentInputTokens(update.superagentInputTokens()); + entity.setSuperagentOutputTokens(update.superagentOutputTokens()); + entity.setSuperagentTotalTokens(update.superagentTotalTokens()); + entity.setRunStatus(update.runStatus()); + entity.setSafeErrorSummary(update.safeErrorSummary()); + entity.setUpdatedAt(update.updatedAt()); + runMapper.updateById(entity); + } + + /** + * 只更新运行状态和安全错误摘要,保留前面阶段已经写入的调试上下文。 + */ + @Override + public void updateStatus(DebugEmlSuperAgentRunStatusUpdate update) { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(DebugEmlSuperAgentRunEntity::getId, update.id()) + .set(DebugEmlSuperAgentRunEntity::getRunStatus, update.runStatus()) + .set(DebugEmlSuperAgentRunEntity::getSafeErrorSummary, update.safeErrorSummary()) + .set(DebugEmlSuperAgentRunEntity::getUpdatedAt, update.updatedAt()); + runMapper.update(null, wrapper); + } + + /** + * 按 ID 查询 Debug 运行安全快照。 + */ + @Override + public Optional findById(Long id) { + return Optional.ofNullable(runMapper.selectById(id)).map(this::toSnapshot); + } + + /** + * 将数据库 Entity 转换为安全快照。 + */ + private DebugEmlSuperAgentRunSnapshot toSnapshot(DebugEmlSuperAgentRunEntity entity) { + return new DebugEmlSuperAgentRunSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getRunStatus(), + entity.getSourceMessageId(), + entity.getCreatedAt()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlSuperAgentRunService.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlSuperAgentRunService.java new file mode 100644 index 0000000..f073ac2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/DebugEmlSuperAgentRunService.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.platform.debug.service; + +import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlSuperAgentRunResult; +import org.springframework.web.multipart.MultipartFile; + +/** + * Debug EML 上传到 SuperAgent 的服务契约。Controller 不直接处理解析、OSS 或外部 AI 调用。 + */ +public interface DebugEmlSuperAgentRunService { + + /** + * 上传并处理单封 EML,写入 SourceMessage 后调用 SuperAgent Open API。 + */ + DebugEmlSuperAgentRunResult uploadAndRun( + String accessKey, + MultipartFile file, + String hotelId, + String runLabel); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentException.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentException.java new file mode 100644 index 0000000..b4548bc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentException.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.platform.debug.service.impl; + +import org.springframework.http.HttpStatus; + +/** + * Debug EML 业务异常。HTTP 状态和错误码在 ControllerAdvice 中统一转换。 + */ +public class DebugEmlSuperAgentException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public DebugEmlSuperAgentException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public DebugEmlSuperAgentException(HttpStatus status, String errorCode, String message, Throwable cause) { + super(message, cause); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java new file mode 100644 index 0000000..d2ce146 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentProperties.java @@ -0,0 +1,43 @@ +package cn.nianxx.thhotel.platform.debug.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Debug EML 上传接口配置。访问口令只能通过环境变量或部署 Secret 注入。 + */ +@Component +@ConfigurationProperties(prefix = "debug.eml-upload") +public class DebugEmlSuperAgentProperties { + + /** 是否启用 Debug EML 上传接口。 */ + private boolean enabled = false; + /** Debug EML 上传访问口令。 */ + private String accessKey = ""; + /** 允许上传的最大文件字节数。 */ + private long maxFileBytes = 10 * 1024 * 1024L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public long getMaxFileBytes() { + return maxFileBytes; + } + + public void setMaxFileBytes(long maxFileBytes) { + this.maxFileBytes = maxFileBytes; + } +} 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 new file mode 100644 index 0000000..8c9a3af --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java @@ -0,0 +1,677 @@ +package cn.nianxx.thhotel.platform.debug.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentMailDebugRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentOpenApiException; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.AliyunOssProperties; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.ObjectStorageException; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunDraft; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunStatusUpdate; +import cn.nianxx.thhotel.platform.debug.common.dto.DebugEmlSuperAgentRunUpdate; +import cn.nianxx.thhotel.platform.debug.common.enums.DebugEmlSuperAgentRunStatus; +import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlSuperAgentRunResult; +import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlUploadedMediaResult; +import cn.nianxx.thhotel.platform.debug.repository.DebugEmlSuperAgentRunRepository; +import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService; +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem; +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; +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.impl.EmlMessageParseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +/** + * Debug EML 上传到 SuperAgent 服务实现。编排解析、OSS、SourceMessage 和 SuperAgent 调用。 + */ +@Service +public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunService { + + private static final String SOURCE_PROVIDER = "DEBUG_EML_UPLOAD"; + 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 final DebugEmlSuperAgentProperties properties; + private final AliyunOssProperties ossProperties; + private final EmlMessageParseService parseService; + private final ObjectStorageService objectStorageService; + private final SourceMessageCaptureService sourceMessageCaptureService; + private final SuperAgentOpenApiClient superAgentOpenApiClient; + private final DebugEmlSuperAgentRunRepository runRepository; + private final ObjectMapper objectMapper; + + /** + * 注入 Debug EML 所需的内部服务和外部端口。 + */ + public DebugEmlSuperAgentRunServiceImpl( + DebugEmlSuperAgentProperties properties, + AliyunOssProperties ossProperties, + EmlMessageParseService parseService, + ObjectStorageService objectStorageService, + SourceMessageCaptureService sourceMessageCaptureService, + SuperAgentOpenApiClient superAgentOpenApiClient, + DebugEmlSuperAgentRunRepository runRepository, + ObjectMapper objectMapper) { + this.properties = properties; + this.ossProperties = ossProperties; + this.parseService = parseService; + this.objectStorageService = objectStorageService; + this.sourceMessageCaptureService = sourceMessageCaptureService; + this.superAgentOpenApiClient = superAgentOpenApiClient; + this.runRepository = runRepository; + this.objectMapper = objectMapper; + } + + /** + * 处理单封 Debug EML 上传;第一版只展示 SuperAgent 结果,不创建订单或任务。 + */ + @Override + public DebugEmlSuperAgentRunResult uploadAndRun( + String accessKey, + MultipartFile file, + String hotelId, + String runLabel) { + validateAccessKey(accessKey); + String normalizedHotelId = requireText(hotelId, "hotel_id"); + validateFile(file); + byte[] emlBytes = readFileBytes(file); + String safeFileName = safeFileName(file.getOriginalFilename(), "debug-email.eml"); + if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".eml")) { + throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "INVALID_FILE_TYPE", "只支持上传 .eml 邮件文件。"); + } + + LocalDateTime now = nowUtc(); + Long runId = runRepository.insert(new DebugEmlSuperAgentRunDraft( + normalizedHotelId, + trimToNull(runLabel), + DebugEmlSuperAgentRunStatus.CREATED.name(), + now)); + try { + return doUploadAndRun(runId, normalizedHotelId, trimToNull(runLabel), safeFileName, emlBytes, now); + } catch (DebugEmlSuperAgentException exception) { + markFailed(runId, exception.getMessage(), statusForException(exception), nowUtc()); + throw exception; + } catch (EmlMessageParseException exception) { + markFailed(runId, "EML 邮件解析失败。", DebugEmlSuperAgentRunStatus.FAILED, nowUtc()); + throw new DebugEmlSuperAgentException( + HttpStatus.UNPROCESSABLE_ENTITY, + "EML_PARSE_FAILED", + "EML 邮件解析失败。", + exception); + } catch (ObjectStorageException exception) { + markFailed(runId, "OSS 上传失败。", DebugEmlSuperAgentRunStatus.FAILED, nowUtc()); + throw new DebugEmlSuperAgentException( + HttpStatus.BAD_GATEWAY, + "OSS_UPLOAD_FAILED", + "OSS 上传失败。", + exception); + } catch (SuperAgentOpenApiException exception) { + markFailed(runId, "SuperAgent 调用失败。", DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED, nowUtc()); + throw new DebugEmlSuperAgentException( + HttpStatus.BAD_GATEWAY, + "SUPERAGENT_OPEN_API_FAILED", + "SuperAgent 调用失败。", + exception); + } catch (Exception exception) { + markFailed(runId, "Debug EML 上传处理失败。", DebugEmlSuperAgentRunStatus.FAILED, nowUtc()); + throw new DebugEmlSuperAgentException( + HttpStatus.INTERNAL_SERVER_ERROR, + "DEBUG_EML_RUN_FAILED", + "Debug EML 上传处理失败。", + exception); + } + } + + /** + * 执行已创建 runId 的主流程。 + */ + private DebugEmlSuperAgentRunResult doUploadAndRun( + Long runId, + String hotelId, + String runLabel, + String safeFileName, + byte[] emlBytes, + 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); + List warnings = new ArrayList<>(); + List uploadedMedia = new ArrayList<>(); + + uploadedMedia.add(uploadOriginalEml(runId, safeFileName, emlBytes, createdAt)); + int inlineIndex = 1; + int attachmentIndex = 1; + for (ParsedEmlMediaItem mediaItem : parsed.mediaItems()) { + if (SourceMessageMediaType.INLINE_IMAGE.code().equals(mediaItem.mediaType())) { + uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "inline", inlineIndex++)); + } else { + uploadedMedia.add(uploadParsedMedia(runId, createdAt, mediaItem, "attachments", attachmentIndex++)); + } + } + + String htmlWithOssUrls = replaceCidReferences(parsed.htmlBody(), uploadedMedia, warnings); + Map payload = buildAgentBusLikePayload( + runId, + runLabel, + externalMessageId, + externalConversationId, + parsed, + htmlWithOssUrls, + uploadedMedia); + String payloadJson = objectMapper.writeValueAsString(payload); + SourceMessageCaptureResult captureResult = sourceMessageCaptureService.capture(new CaptureSourceMessageCommand( + hotelId, + SOURCE_PROVIDER, + SOURCE_CHANNEL, + externalMessageId, + externalConversationId, + runId.toString(), + "debug-eml-upload", + parsed.sentAt(), + parsed.sender(), + parsed.subject(), + parsed.textBody(), + htmlWithOssUrls, + payloadJson, + SCHEMA_VERSION, + uploadedMedia.stream().map(UploadedMedia::captureMedia).toList())); + updateSourceCapturedRun( + runId, + captureResult.inboxId(), + externalMessageId, + externalConversationId, + safeFileName, + uploadedMedia.get(0).result().externalUrl(), + sha256, + payloadJson); + + SuperAgentOpenApiResult superAgentResult = superAgentOpenApiClient.invokeMailDebug(new SuperAgentMailDebugRequest( + buildSuperAgentMessage(payloadJson), + "debug-eml-" + runId, + Map.of( + "source", "th-hotel-debug-eml-upload", + "debug_run_id", runId.toString(), + "source_message_id", captureResult.inboxId().toString(), + "hotel_id", hotelId))); + JsonNode parsedJson = parseSuperAgentJson(superAgentResult.rawAnswer(), warnings); + String parsedJsonText = parsedJson == null ? null : objectMapper.writeValueAsString(parsedJson); + DebugEmlSuperAgentRunStatus status = DebugEmlSuperAgentRunStatus.SUPERAGENT_SUCCEEDED; + runRepository.updateResult(new DebugEmlSuperAgentRunUpdate( + runId, + captureResult.inboxId(), + externalMessageId, + externalConversationId, + safeFileName, + uploadedMedia.get(0).result().externalUrl(), + sha256, + payloadJson, + superAgentResult.sessionId(), + superAgentResult.runId(), + superAgentResult.profileId(), + superAgentResult.profileVersionId(), + superAgentResult.modelName(), + superAgentResult.rawAnswer(), + parsedJsonText, + superAgentResult.inputTokens(), + superAgentResult.outputTokens(), + superAgentResult.totalTokens(), + status.name(), + null, + nowUtc())); + + return new DebugEmlSuperAgentRunResult( + runId.toString(), + captureResult.inboxId().toString(), + SOURCE_PROVIDER, + externalMessageId, + externalConversationId, + uploadedMedia.get(0).result().externalUrl(), + sha256, + uploadedMedia.stream().map(UploadedMedia::result).toList(), + htmlWithOssUrls, + payload, + superAgentResult.sessionId(), + superAgentResult.runId(), + superAgentResult.rawAnswer(), + parsedJson, + List.copyOf(warnings), + status.name()); + } + + /** + * SourceMessage 已写入后先回填 Debug run,保证后续 SuperAgent 失败时仍可追踪入库消息和 OSS 原文。 + */ + private void updateSourceCapturedRun( + Long runId, + Long sourceMessageId, + String externalMessageId, + String externalConversationId, + String safeFileName, + String originalEmlOssUrl, + String sha256, + String payloadJson) { + runRepository.updateResult(new DebugEmlSuperAgentRunUpdate( + runId, + sourceMessageId, + externalMessageId, + externalConversationId, + safeFileName, + originalEmlOssUrl, + sha256, + payloadJson, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + DebugEmlSuperAgentRunStatus.SOURCE_CAPTURED.name(), + null, + nowUtc())); + } + + /** + * 上传原始 .eml 文件。 + */ + private UploadedMedia uploadOriginalEml(Long runId, String safeFileName, byte[] emlBytes, LocalDateTime now) { + String objectKey = objectKey(now, runId, "raw", safeFileName); + ObjectStoragePutResult putResult = objectStorageService.putObject(new ObjectStoragePutRequest( + objectKey, + safeFileName, + "message/rfc822", + (long) emlBytes.length, + emlBytes)); + return uploadedMedia( + SourceMessageMediaType.ORIGINAL_EMAIL.code(), + safeFileName, + "message/rfc822", + putResult.sizeBytes(), + putResult.publicUrl(), + "original-eml-" + runId, + putResult.objectKey(), + null); + } + + /** + * 上传解析出的内联图片或附件。 + */ + private UploadedMedia uploadParsedMedia( + Long runId, + LocalDateTime now, + ParsedEmlMediaItem mediaItem, + String folder, + int index) { + String fileName = safeFileName(mediaItem.fileName(), folder + "-" + index); + String objectKey = objectKey(now, runId, folder, index + "-" + fileName); + ObjectStoragePutResult putResult = objectStorageService.putObject(new ObjectStoragePutRequest( + objectKey, + fileName, + mediaItem.contentType(), + mediaItem.sizeBytes(), + mediaItem.bytes())); + String externalMediaId = SourceMessageMediaType.INLINE_IMAGE.code().equals(mediaItem.mediaType()) + ? "cid:" + mediaItem.contentId() + : "attachment:" + index; + return uploadedMedia( + mediaItem.mediaType(), + fileName, + mediaItem.contentType(), + putResult.sizeBytes(), + putResult.publicUrl(), + externalMediaId, + putResult.objectKey(), + mediaItem.contentId()); + } + + /** + * 构造上传媒体结果和 SourceMessage 捕获媒体项。 + */ + private UploadedMedia uploadedMedia( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String externalUrl, + String externalMediaId, + String objectKey, + String contentId) { + DebugEmlUploadedMediaResult result = new DebugEmlUploadedMediaResult( + mediaType, + fileName, + contentType, + sizeBytes, + externalUrl, + externalMediaId, + objectKey); + CaptureSourceMessageMedia captureMedia = new CaptureSourceMessageMedia( + mediaType, + fileName, + contentType, + sizeBytes, + externalUrl, + externalMediaId); + return new UploadedMedia(result, captureMedia, contentId); + } + + /** + * 替换 HTML 中的 cid: 引用为 OSS URL。 + */ + private String replaceCidReferences(String htmlBody, List uploadedMedia, List warnings) { + if (htmlBody == null || htmlBody.isBlank()) { + warnings.add("EML 未解析到 HTML 正文。"); + return htmlBody; + } + String replaced = htmlBody; + 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()); + } + if (replaced.contains("cid:")) { + warnings.add("HTML 正文仍包含未匹配的 cid 图片引用。"); + } + return replaced; + } + + /** + * 组装接近 AgentBus 邮件输入的 payload。 + */ + private Map buildAgentBusLikePayload( + Long runId, + String runLabel, + String externalMessageId, + String externalConversationId, + ParsedEmlMessage parsed, + String htmlWithOssUrls, + List uploadedMedia) { + Map source = new LinkedHashMap<>(); + source.put("channel", SOURCE_CHANNEL); + source.put("provider", SOURCE_PROVIDER); + source.put("external_message_id", externalMessageId); + source.put("external_conversation_id", externalConversationId); + source.put("sender", parsed.sender()); + source.put("subject", parsed.subject()); + source.put("sent_at", parsed.sentAt() == null ? null : parsed.sentAt().toString()); + + Map body = new LinkedHashMap<>(); + body.put("content_type", contentType(parsed, htmlWithOssUrls)); + body.put("text", parsed.textBody()); + body.put("html", htmlWithOssUrls); + + List> inlineImages = mediaPayload(uploadedMedia, SourceMessageMediaType.INLINE_IMAGE.code()); + List> attachments = mediaPayload(uploadedMedia, SourceMessageMediaType.ATTACHMENT.code()); + + Map replyPolicy = new LinkedHashMap<>(); + replyPolicy.put("mode", "debug_only"); + replyPolicy.put("final_only", true); + + Map debugContext = new LinkedHashMap<>(); + debugContext.put("debug_run_id", runId.toString()); + debugContext.put("run_label", runLabel); + debugContext.put("original_eml_oss_url", uploadedMedia.get(0).result().externalUrl()); + + Map payload = new LinkedHashMap<>(); + payload.put("schema_version", SCHEMA_VERSION); + payload.put("source", source); + payload.put("body", body); + payload.put("inline_images", inlineImages); + payload.put("attachments", attachments); + payload.put("reply_policy", replyPolicy); + payload.put("debug_context", debugContext); + return payload; + } + + /** + * 构造媒体 payload 数组。 + */ + private List> mediaPayload(List uploadedMedia, String mediaType) { + return uploadedMedia.stream() + .filter(item -> mediaType.equals(item.result().mediaType())) + .map(item -> { + Map media = new LinkedHashMap<>(); + media.put("media_type", item.result().mediaType()); + media.put("file_name", item.result().fileName()); + media.put("content_type", item.result().contentType()); + media.put("size_bytes", item.result().sizeBytes()); + media.put("external_url", item.result().externalUrl()); + media.put("external_media_id", item.result().externalMediaId()); + media.put("object_key", item.result().objectKey()); + if (item.contentId() != null) { + media.put("content_id", item.contentId()); + } + return media; + }) + .toList(); + } + + /** + * 根据正文存在情况返回 body content type。 + */ + private String contentType(ParsedEmlMessage parsed, String htmlWithOssUrls) { + boolean hasText = parsed.textBody() != null && !parsed.textBody().isBlank(); + boolean hasHtml = htmlWithOssUrls != null && !htmlWithOssUrls.isBlank(); + if (hasText && hasHtml) { + return "MIXED"; + } + return hasHtml ? "HTML" : "TEXT"; + } + + /** + * 构造发送给 SuperAgent 的消息文本。 + */ + private String buildSuperAgentMessage(String payloadJson) { + return "请基于以下 Debug 邮件 JSON 输出结构化任务抽取结果,只返回 JSON,不要创建订单或任务:\n" + payloadJson; + } + + /** + * 尝试把 SuperAgent 最终回答解析为 JSON,失败时返回 null 并记录 warning。 + */ + private JsonNode parseSuperAgentJson(String rawAnswer, List warnings) { + if (rawAnswer == null || rawAnswer.isBlank()) { + warnings.add("SuperAgent 最终回答为空。"); + return null; + } + try { + return objectMapper.readTree(rawAnswer); + } catch (Exception exception) { + warnings.add("SuperAgent 最终回答不是合法 JSON,已保留 raw answer。"); + return null; + } + } + + /** + * 标记 Debug 运行失败。 + */ + private void markFailed( + Long runId, + String safeSummary, + DebugEmlSuperAgentRunStatus status, + LocalDateTime updatedAt) { + if (runId == null) { + return; + } + runRepository.updateStatus(new DebugEmlSuperAgentRunStatusUpdate( + runId, + status.name(), + truncate(safeSummary, 512), + updatedAt)); + } + + /** + * 根据异常选择 Debug run 状态。 + */ + private DebugEmlSuperAgentRunStatus statusForException(DebugEmlSuperAgentException exception) { + return exception.getStatus() == HttpStatus.BAD_GATEWAY + ? DebugEmlSuperAgentRunStatus.SUPERAGENT_FAILED + : DebugEmlSuperAgentRunStatus.FAILED; + } + + /** + * 校验调试访问口令。 + */ + private void validateAccessKey(String accessKey) { + if (properties.getAccessKey() == null || properties.getAccessKey().isBlank() + || accessKey == null || !properties.getAccessKey().equals(accessKey)) { + throw new DebugEmlSuperAgentException(HttpStatus.UNAUTHORIZED, "DEBUG_UPLOAD_KEY_INVALID", "Debug 上传访问口令缺失或错误。"); + } + } + + /** + * 校验上传文件基本属性。 + */ + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "EML_FILE_REQUIRED", "请上传 .eml 邮件文件。"); + } + if (file.getSize() > properties.getMaxFileBytes()) { + throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "EML_FILE_TOO_LARGE", "上传的 .eml 文件超过大小限制。"); + } + } + + /** + * 读取上传文件字节。 + */ + private byte[] readFileBytes(MultipartFile file) { + try { + return file.getBytes(); + } catch (Exception exception) { + throw new DebugEmlSuperAgentException( + HttpStatus.BAD_REQUEST, + "EML_FILE_READ_FAILED", + "读取上传 .eml 文件失败。", + exception); + } + } + + /** + * 生成 OSS 对象路径。 + */ + private String objectKey(LocalDateTime now, Long runId, String folder, String fileName) { + String prefix = ossProperties.getDebugEmlPrefix(); + String normalizedPrefix = prefix == null || prefix.isBlank() ? "debug/eml/" : prefix; + if (!normalizedPrefix.endsWith("/")) { + normalizedPrefix = normalizedPrefix + "/"; + } + return normalizedPrefix + + DATE_FOLDER_FORMATTER.format(now.toLocalDate()) + + "/" + runId + + "/" + folder + + "/" + fileName; + } + + /** + * 文件名安全清洗,避免路径穿越和日志污染。 + */ + private String safeFileName(String fileName, String fallback) { + String candidate = fileName == null || fileName.isBlank() ? fallback : fileName; + int slashIndex = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')); + if (slashIndex >= 0) { + candidate = candidate.substring(slashIndex + 1); + } + String sanitized = candidate.replaceAll("[^A-Za-z0-9._-]", "_"); + if (sanitized.isBlank()) { + sanitized = fallback; + } + return sanitized.length() > 160 ? sanitized.substring(sanitized.length() - 160) : sanitized; + } + + /** + * 计算 SHA-256。 + */ + private String sha256(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception); + } + } + + /** + * 必填文本校验。 + */ + private String requireText(String value, String fieldName) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw new DebugEmlSuperAgentException(HttpStatus.BAD_REQUEST, "REQUEST_FIELD_REQUIRED", fieldName + " 不能为空。"); + } + return trimmed; + } + + /** + * 返回第一个非空文本。 + */ + private String firstNonBlank(String first, String second) { + String normalizedFirst = trimToNull(first); + return normalizedFirst == null ? second : normalizedFirst; + } + + /** + * 空白字符串转 null。 + */ + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 截断安全摘要,避免超过数据库限制。 + */ + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + + /** + * 当前 UTC 时间。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** + * 上传媒体内部组合对象。 + */ + private record UploadedMedia( + DebugEmlUploadedMediaResult result, + CaptureSourceMessageMedia captureMedia, + String contentId + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMediaItem.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMediaItem.java new file mode 100644 index 0000000..2993899 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMediaItem.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +/** + * EML 邮件解析出的媒体项,字节内容只在调试上传链路内短暂使用,不直接持久化到数据库。 + * + * @param mediaType 媒体类型,INLINE_IMAGE 或 ATTACHMENT + * @param fileName 解析并清洗前的文件名,可能为空 + * @param contentType MIME 类型 + * @param sizeBytes 文件大小字节数 + * @param contentId 内联图片 Content-ID,普通附件为空 + * @param bytes 文件二进制内容 + */ +public record ParsedEmlMediaItem( + String mediaType, + String fileName, + String contentType, + Long sizeBytes, + String contentId, + byte[] bytes +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMessage.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMessage.java new file mode 100644 index 0000000..98847e1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/ParsedEmlMessage.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.platform.message.common.dto; + +import java.time.Instant; +import java.util.List; + +/** + * EML 邮件解析结果。该对象表达邮件来源事实,不表达 AI 结论或业务任务。 + * + * @param messageId 规范化后的 Message-ID + * @param conversationId 规范化后的会话 ID + * @param sender 发件人地址或展示摘要 + * @param subject 邮件主题 + * @param sentAt 邮件 Date 头对应的发送时间 + * @param textBody 纯文本正文 + * @param htmlBody HTML 正文 + * @param mediaItems 解析出的内联图片和附件 + */ +public record ParsedEmlMessage( + String messageId, + String conversationId, + String sender, + String subject, + Instant sentAt, + String textBody, + String htmlBody, + List mediaItems +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java index 6db7523..5b3f0cb 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/enums/SourceMessageMediaType.java @@ -6,7 +6,8 @@ package cn.nianxx.thhotel.platform.message.common.enums; public enum SourceMessageMediaType { INLINE_IMAGE("INLINE_IMAGE"), - ATTACHMENT("ATTACHMENT"); + ATTACHMENT("ATTACHMENT"), + ORIGINAL_EMAIL("ORIGINAL_EMAIL"); private final String code; diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/EmlMessageParseService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/EmlMessageParseService.java new file mode 100644 index 0000000..5375405 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/EmlMessageParseService.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.platform.message.service; + +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; + +/** + * EML 邮件解析服务。只负责把原始 .eml 转为平台稳定消息结构,不调用 OSS 或 SuperAgent。 + */ +public interface EmlMessageParseService { + + /** + * 解析单封 .eml 邮件,返回邮件头、正文、内联图片和附件。 + */ + ParsedEmlMessage parse(byte[] emlBytes, String originalFileName); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseException.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseException.java new file mode 100644 index 0000000..2bfa45b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseException.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +/** + * EML 邮件解析异常。错误消息必须是安全摘要,不包含完整邮件正文或附件内容。 + */ +public class EmlMessageParseException extends RuntimeException { + + public EmlMessageParseException(String message, Throwable cause) { + super(message, cause); + } + + public EmlMessageParseException(String message) { + super(message); + } +} 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 new file mode 100644 index 0000000..6a641c4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImpl.java @@ -0,0 +1,250 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem; +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; +import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType; +import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService; +import jakarta.mail.BodyPart; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeUtility; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import org.springframework.stereotype.Service; + +/** + * EML 邮件解析服务实现。基于 Jakarta Mail 解析 MIME 层级,并把附件字节交给上层上传 OSS。 + */ +@Service +public class EmlMessageParseServiceImpl implements EmlMessageParseService { + + /** + * 解析单封 .eml 邮件,缺失 Message-ID 时由上层基于文件 hash 生成外部消息 ID。 + */ + @Override + public ParsedEmlMessage parse(byte[] emlBytes, String originalFileName) { + if (emlBytes == null || emlBytes.length == 0) { + throw new EmlMessageParseException("上传的 .eml 文件为空。"); + } + try { + MimeMessage message = new MimeMessage( + Session.getInstance(new Properties()), + new ByteArrayInputStream(emlBytes)); + ParsedParts parts = new ParsedParts(); + collectPart(message, parts); + String messageId = normalizeMessageId(message.getMessageID()); + return new ParsedEmlMessage( + messageId, + resolveConversationId(message, messageId), + resolveSender(message), + message.getSubject(), + toInstant(message.getSentDate()), + parts.textBody.toString(), + parts.htmlBody.toString(), + List.copyOf(parts.mediaItems)); + } catch (MessagingException | IOException exception) { + throw new EmlMessageParseException("EML 邮件 MIME 结构解析失败。", exception); + } + } + + /** + * 递归解析 MIME part,分别收集正文、HTML、内联图片和附件。 + */ + private void collectPart(Part part, ParsedParts parts) throws MessagingException, IOException { + if (part.isMimeType("multipart/*")) { + Multipart multipart = (Multipart) part.getContent(); + for (int index = 0; index < multipart.getCount(); index++) { + BodyPart bodyPart = multipart.getBodyPart(index); + collectPart(bodyPart, parts); + } + return; + } + + String disposition = part.getDisposition(); + String fileName = decodeFileName(part.getFileName()); + String contentId = normalizeContentId(firstHeader(part, "Content-ID")); + boolean attachment = Part.ATTACHMENT.equalsIgnoreCase(disposition); + boolean inlineImage = contentId != null && contentType(part).startsWith("image/"); + if (attachment || inlineImage || shouldTreatAsAttachment(fileName, disposition)) { + byte[] bytes = part.getInputStream().readAllBytes(); + String mediaType = inlineImage + ? SourceMessageMediaType.INLINE_IMAGE.code() + : SourceMessageMediaType.ATTACHMENT.code(); + parts.mediaItems.add(new ParsedEmlMediaItem( + mediaType, + fileName, + contentType(part), + (long) bytes.length, + contentId, + bytes)); + return; + } + + if (part.isMimeType("text/plain")) { + appendText(parts.textBody, readText(part)); + return; + } + if (part.isMimeType("text/html")) { + appendText(parts.htmlBody, readText(part)); + } + } + + /** + * 判断带文件名但未明确 disposition 的 part 是否应视为附件。 + */ + private boolean shouldTreatAsAttachment(String fileName, String disposition) { + if (fileName == null || fileName.isBlank()) { + return false; + } + return disposition == null || Part.INLINE.equalsIgnoreCase(disposition); + } + + /** + * 读取文本 part 内容,Jakarta Mail 会根据 charset 解码字符串。 + */ + private String readText(Part part) throws MessagingException, IOException { + Object content = part.getContent(); + if (content instanceof String text) { + return text; + } + return new String(part.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + /** + * 追加正文片段,多个 text/html part 之间用换行隔开。 + */ + private void appendText(StringBuilder builder, String value) { + if (value == null || value.isBlank()) { + return; + } + if (!builder.isEmpty()) { + builder.append('\n'); + } + builder.append(value); + } + + /** + * 解析发件人,优先返回邮箱地址,缺失时返回 Jakarta Mail 的原始展示值。 + */ + private String resolveSender(MimeMessage message) throws MessagingException { + jakarta.mail.Address[] from = message.getFrom(); + if (from == null || from.length == 0) { + return null; + } + if (from[0] instanceof InternetAddress address && address.getAddress() != null) { + return address.getAddress(); + } + return from[0].toString(); + } + + /** + * 解析会话 ID,优先使用 References / In-Reply-To 的第一项,缺失时使用 Message-ID。 + */ + private String resolveConversationId(MimeMessage message, String messageId) throws MessagingException { + String references = message.getHeader("References", null); + String firstReference = firstMessageId(references); + if (firstReference != null) { + return firstReference; + } + String inReplyTo = firstMessageId(message.getHeader("In-Reply-To", null)); + return inReplyTo == null ? messageId : inReplyTo; + } + + /** + * 从 References 类头字段中提取第一个 message id。 + */ + private String firstMessageId(String header) { + if (header == null || header.isBlank()) { + return null; + } + String[] parts = header.trim().split("\\s+"); + return normalizeMessageId(parts.length == 0 ? header : parts[0]); + } + + /** + * 规范化 Message-ID 或 Content-ID,去掉尖括号和 cid: 前缀。 + */ + private String normalizeMessageId(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + if (normalized.toLowerCase(Locale.ROOT).startsWith("cid:")) { + normalized = normalized.substring(4); + } + if (normalized.startsWith("<") && normalized.endsWith(">") && normalized.length() > 2) { + normalized = normalized.substring(1, normalized.length() - 1); + } + return normalized.isBlank() ? null : normalized; + } + + /** + * 规范化 Content-ID。 + */ + private String normalizeContentId(String value) { + return normalizeMessageId(value); + } + + /** + * 读取 MIME part 的首个头字段值,缺失时返回 null。 + */ + private String firstHeader(Part part, String headerName) throws MessagingException { + String[] values = part.getHeader(headerName); + return values == null || values.length == 0 ? null : values[0]; + } + + /** + * 解码 MIME 文件名,解析失败时回退原始文件名。 + */ + private String decodeFileName(String fileName) { + if (fileName == null) { + return null; + } + try { + return MimeUtility.decodeText(fileName); + } catch (Exception exception) { + return fileName; + } + } + + /** + * 读取 part 的基础 MIME 类型,去掉 charset/name 等参数。 + */ + private String contentType(Part part) throws MessagingException { + String contentType = part.getContentType(); + if (contentType == null) { + return "application/octet-stream"; + } + int semicolonIndex = contentType.indexOf(';'); + String baseType = semicolonIndex >= 0 ? contentType.substring(0, semicolonIndex) : contentType; + return baseType.trim().toLowerCase(Locale.ROOT); + } + + /** + * 将 java.util.Date 转为 Instant,缺失时返回 null。 + */ + private Instant toInstant(Date date) { + return date == null ? null : date.toInstant(); + } + + /** + * 递归解析过程中的可变收集器。 + */ + private static final class ParsedParts { + private final StringBuilder textBody = new StringBuilder(); + private final StringBuilder htmlBody = new StringBuilder(); + private final List mediaItems = new ArrayList<>(); + } +} diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index 6edb5a5..6a8f289 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -31,6 +31,30 @@ superagent: task-result: # dev SuperAgent HMAC 密钥;优先使用 dev 专属变量,兼容旧通用变量。 hmac-secret: ${SUPERAGENT_DEV_TASK_RESULT_HMAC_SECRET:${SUPERAGENT_TASK_RESULT_HMAC_SECRET:}} + open-api: + # dev Debug EML 调用 SuperAgent Open API;默认关闭,配置完整后再显式开启。 + enabled: ${SUPERAGENT_DEV_OPEN_API_ENABLED:${SUPERAGENT_OPEN_API_ENABLED:false}} + base-url: ${DEERFLOW_DEV_BASE_URL:${DEERFLOW_BASE_URL:}} + api-key: ${DEERFLOW_DEV_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY:}} + external-subject-id: ${SUPERAGENT_DEV_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + connect-timeout: ${SUPERAGENT_DEV_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} + read-timeout: ${SUPERAGENT_DEV_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + +aliyun: + oss: + endpoint: ${ALIYUN_OSS_DEV_ENDPOINT:${ALIYUN_OSS_ENDPOINT:}} + bucket: ${ALIYUN_OSS_DEV_BUCKET:${ALIYUN_OSS_BUCKET:}} + access-key-id: ${ALIYUN_OSS_DEV_ACCESS_KEY_ID:${ALIYUN_OSS_ACCESS_KEY_ID:}} + access-key-secret: ${ALIYUN_OSS_DEV_ACCESS_KEY_SECRET:${ALIYUN_OSS_ACCESS_KEY_SECRET:}} + public-base-url: ${ALIYUN_OSS_DEV_PUBLIC_BASE_URL:${ALIYUN_OSS_PUBLIC_BASE_URL:}} + debug-eml-prefix: ${ALIYUN_OSS_DEV_DEBUG_EML_PREFIX:${ALIYUN_OSS_DEBUG_EML_PREFIX:debug/eml/}} + +debug: + eml-upload: + # dev 默认关闭,需要本地或联调环境显式开启并配置访问口令。 + enabled: ${DEBUG_EML_UPLOAD_DEV_ENABLED:${DEBUG_EML_UPLOAD_ENABLED:false}} + access-key: ${DEBUG_EML_UPLOAD_DEV_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} + max-file-bytes: ${DEBUG_EML_UPLOAD_DEV_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} reservation: demo-data: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 539ad5c..010e182 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -29,6 +29,30 @@ superagent: task-result: # prod SuperAgent HMAC 密钥不能为空;生产优先使用 prod 专属变量,兼容旧通用变量。 hmac-secret: ${SUPERAGENT_PROD_TASK_RESULT_HMAC_SECRET:${SUPERAGENT_TASK_RESULT_HMAC_SECRET}} + open-api: + # prod 默认不开放 Debug EML 真实调用;如需启用,必须先接入正式权限和审计策略。 + enabled: ${SUPERAGENT_PROD_OPEN_API_ENABLED:false} + base-url: ${DEERFLOW_PROD_BASE_URL:${DEERFLOW_BASE_URL}} + api-key: ${DEERFLOW_PROD_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY}} + external-subject-id: ${SUPERAGENT_PROD_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + connect-timeout: ${SUPERAGENT_PROD_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} + read-timeout: ${SUPERAGENT_PROD_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + +aliyun: + oss: + endpoint: ${ALIYUN_OSS_PROD_ENDPOINT:${ALIYUN_OSS_ENDPOINT}} + bucket: ${ALIYUN_OSS_PROD_BUCKET:${ALIYUN_OSS_BUCKET}} + access-key-id: ${ALIYUN_OSS_PROD_ACCESS_KEY_ID:${ALIYUN_OSS_ACCESS_KEY_ID}} + access-key-secret: ${ALIYUN_OSS_PROD_ACCESS_KEY_SECRET:${ALIYUN_OSS_ACCESS_KEY_SECRET}} + public-base-url: ${ALIYUN_OSS_PROD_PUBLIC_BASE_URL:${ALIYUN_OSS_PUBLIC_BASE_URL}} + debug-eml-prefix: ${ALIYUN_OSS_PROD_DEBUG_EML_PREFIX:${ALIYUN_OSS_DEBUG_EML_PREFIX:debug/eml/}} + +debug: + eml-upload: + # prod 默认关闭;不要在未接入正式用户权限前开放。 + enabled: ${DEBUG_EML_UPLOAD_PROD_ENABLED:false} + access-key: ${DEBUG_EML_UPLOAD_PROD_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY}} + max-file-bytes: ${DEBUG_EML_UPLOAD_PROD_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} reservation: demo-data: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 5fc9081..9c76d8e 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -31,6 +31,30 @@ superagent: task-result: # test SuperAgent HMAC 密钥;优先使用 test 专属变量,兼容旧通用变量。 hmac-secret: ${SUPERAGENT_TEST_TASK_RESULT_HMAC_SECRET:${SUPERAGENT_TASK_RESULT_HMAC_SECRET:}} + open-api: + # test Debug EML 调用 SuperAgent Open API;默认关闭,联调时显式开启。 + enabled: ${SUPERAGENT_TEST_OPEN_API_ENABLED:${SUPERAGENT_OPEN_API_ENABLED:false}} + base-url: ${DEERFLOW_TEST_BASE_URL:${DEERFLOW_BASE_URL:}} + api-key: ${DEERFLOW_TEST_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY:}} + external-subject-id: ${SUPERAGENT_TEST_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + connect-timeout: ${SUPERAGENT_TEST_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} + read-timeout: ${SUPERAGENT_TEST_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + +aliyun: + oss: + endpoint: ${ALIYUN_OSS_TEST_ENDPOINT:${ALIYUN_OSS_ENDPOINT:}} + bucket: ${ALIYUN_OSS_TEST_BUCKET:${ALIYUN_OSS_BUCKET:}} + access-key-id: ${ALIYUN_OSS_TEST_ACCESS_KEY_ID:${ALIYUN_OSS_ACCESS_KEY_ID:}} + access-key-secret: ${ALIYUN_OSS_TEST_ACCESS_KEY_SECRET:${ALIYUN_OSS_ACCESS_KEY_SECRET:}} + public-base-url: ${ALIYUN_OSS_TEST_PUBLIC_BASE_URL:${ALIYUN_OSS_PUBLIC_BASE_URL:}} + debug-eml-prefix: ${ALIYUN_OSS_TEST_DEBUG_EML_PREFIX:${ALIYUN_OSS_DEBUG_EML_PREFIX:debug/eml/}} + +debug: + eml-upload: + # test 默认关闭,测试机联调时显式开启并配置访问口令。 + enabled: ${DEBUG_EML_UPLOAD_TEST_ENABLED:${DEBUG_EML_UPLOAD_ENABLED:false}} + access-key: ${DEBUG_EML_UPLOAD_TEST_ACCESS_KEY:${DEBUG_EML_UPLOAD_ACCESS_KEY:}} + max-file-bytes: ${DEBUG_EML_UPLOAD_TEST_MAX_FILE_BYTES:${DEBUG_EML_UPLOAD_MAX_FILE_BYTES:10485760}} reservation: demo-data: diff --git a/server/src/main/resources/db/migration/V7__create_debug_eml_superagent_run.sql b/server/src/main/resources/db/migration/V7__create_debug_eml_superagent_run.sql new file mode 100644 index 0000000..d03e043 --- /dev/null +++ b/server/src/main/resources/db/migration/V7__create_debug_eml_superagent_run.sql @@ -0,0 +1,31 @@ +-- M004 Debug EML 上传链路:记录单次上传、SourceMessage 捕获和 SuperAgent Open API 调用结果。 +CREATE TABLE platform_debug_eml_superagent_run ( + id BIGINT NOT NULL COMMENT 'Debug EML 运行 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + run_label VARCHAR(128) NULL COMMENT '前端传入的调试标签,仅用于联调筛选和排查', + source_message_id BIGINT NULL COMMENT '关联的内部 SourceMessage Inbox ID,写入成功后回填', + external_message_id VARCHAR(256) NULL COMMENT 'Debug 链路生成或解析出的外部邮件 ID', + external_conversation_id VARCHAR(256) NULL COMMENT 'Debug 链路生成或解析出的外部邮件会话 ID', + original_file_name VARCHAR(512) NULL COMMENT '原始上传 .eml 文件名的安全清洗结果', + original_eml_oss_url VARCHAR(2048) NULL COMMENT '原始 .eml 文件在阿里云 OSS 上的访问 URL', + original_eml_sha256 CHAR(64) NULL COMMENT '原始 .eml 文件 SHA-256,用于排查重复和内容追溯', + payload_json LONGTEXT NULL COMMENT '发送给 SuperAgent 的 AgentBus-like 邮件 payload JSON,不包含 Secret', + superagent_session_id VARCHAR(128) NULL COMMENT 'SuperAgent Open API session ID', + superagent_run_id VARCHAR(128) NULL COMMENT 'SuperAgent 返回的 run ID', + superagent_profile_id VARCHAR(128) NULL COMMENT 'SuperAgent 实际使用的 profile ID', + superagent_profile_version_id VARCHAR(128) NULL COMMENT 'SuperAgent 实际使用的 profile version ID', + superagent_model_name VARCHAR(128) NULL COMMENT 'SuperAgent 响应元数据中的模型名称', + superagent_raw_answer LONGTEXT NULL COMMENT 'SuperAgent 最终 AI 文本回答,用于 Debug 展示和排查', + superagent_parsed_json LONGTEXT NULL COMMENT '后端从最终回答中解析出的 JSON,无法解析时为空', + superagent_input_tokens INT NULL COMMENT 'SuperAgent 输入 token 数,供应商返回时保存', + superagent_output_tokens INT NULL COMMENT 'SuperAgent 输出 token 数,供应商返回时保存', + superagent_total_tokens INT NULL COMMENT 'SuperAgent 总 token 数,供应商返回时保存', + run_status VARCHAR(32) NOT NULL COMMENT 'Debug 运行状态:CREATED、SOURCE_CAPTURED、SUPERAGENT_SUCCEEDED、SUPERAGENT_FAILED、FAILED', + safe_error_summary VARCHAR(512) NULL COMMENT '安全错误摘要,不包含正文、HTML、Secret 或附件签名 URL', + created_at DATETIME(6) NOT NULL COMMENT '记录创建时间,按 UTC 写入', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新时间,按 UTC 写入', + PRIMARY KEY (id), + KEY idx_debug_eml_hotel_time (hotel_id, created_at), + KEY idx_debug_eml_source_message (source_message_id), + KEY idx_debug_eml_status_time (run_status, created_at) +) COMMENT='Debug EML 上传到 SuperAgent 调试运行表,记录来源捕获、OSS 转存和 AI 调用结果'; diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java new file mode 100644 index 0000000..705a7eb --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import org.junit.jupiter.api.Test; + +class SuperAgentOpenApiSseParserTest { + + private final SuperAgentOpenApiSseParser parser = new SuperAgentOpenApiSseParser(); + + @Test + void shouldExtractFinalAiAnswerFromValuesEvent() { + String sse = """ + event: metadata + data: {"run_id":"run-debug-001","resolved_profile_id":"profile-debug"} + + event: messages + data: {"type":"ai","content":"partial answer"} + + event: values + data: {"messages":[{"type":"human","content":"input"},{"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"New Booking\\"}]}","response_metadata":{"finish_reason":"stop","model_name":"debug-model"},"usage_metadata":{"input_tokens":11,"output_tokens":7,"total_tokens":18}}]} + + event: end + data: {} + + """; + + SuperAgentOpenApiResult result = parser.parse("session-debug-001", sse); + + assertThat(result.sessionId()).isEqualTo("session-debug-001"); + assertThat(result.runId()).isEqualTo("run-debug-001"); + assertThat(result.profileId()).isEqualTo("profile-debug"); + assertThat(result.rawAnswer()).isEqualTo("{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}"); + assertThat(result.modelName()).isEqualTo("debug-model"); + assertThat(result.inputTokens()).isEqualTo(11); + assertThat(result.outputTokens()).isEqualTo(7); + assertThat(result.totalTokens()).isEqualTo(18); + assertThat(result.eventTypes()).containsExactly("metadata", "messages", "values", "end"); + } +} 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 new file mode 100644 index 0000000..3eeb2c3 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/debug/control/DebugEmlSuperAgentControllerTest.java @@ -0,0 +1,230 @@ +package cn.nianxx.thhotel.platform.debug.control; + +import static org.hamcrest.Matchers.containsString; +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.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentOpenApiException; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "debug.eml-upload.enabled=true", + "debug.eml-upload.access-key=test-debug-upload-key", + "debug.eml-upload.max-file-bytes=1048576", + "aliyun.oss.debug-eml-prefix=debug/eml/", + "superagent.open-api.enabled=true", + "superagent.open-api.external-subject-id=test-debug-eml" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DebugEmlSuperAgentControllerTest { + + private static final String ENDPOINT = "/api/system/debug/eml-superagent-runs"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private ObjectStorageService objectStorageService; + + @MockBean + private SuperAgentOpenApiClient superAgentOpenApiClient; + + @Test + void shouldRejectUploadWhenDebugKeyMissing() throws Exception { + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST")) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("test-debug-upload-key")))); + } + + @Test + void shouldUploadEmlToOssCaptureSourceMessageAndReturnSuperAgentResult() throws Exception { + 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"))); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .param("run_label", "controller-test") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isCreated()) + .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("$.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("$.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.reply_policy.mode").value("debug_only")) + .andExpect(jsonPath("$.superagent_session_id").value("session-debug-001")) + .andExpect(jsonPath("$.superagent_run_id").value("run-debug-001")) + .andExpect(jsonPath("$.superagent_raw_answer", containsString("ai_task_results"))) + .andExpect(jsonPath("$.superagent_parsed_json.ai_task_results[0].task_type").value("New Booking")) + .andExpect(jsonPath("$.status").value("SUPERAGENT_SUCCEEDED")) + .andExpect(content().string(not(containsString("test-debug-upload-key")))); + + 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 payload.schema_version = 'debug-eml-upload-v1' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L); + + Long originalEmailMediaCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_source_message_media media + JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id + WHERE inbox.provider = 'DEBUG_EML_UPLOAD' + AND media.media_type = 'ORIGINAL_EMAIL' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L); + + Long debugRunCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_debug_eml_superagent_run + WHERE hotel_id = 'HOTEL-TEST' + AND run_status = 'SUPERAGENT_SUCCEEDED' + AND superagent_session_id = 'session-debug-001' + AND superagent_run_id = 'run-debug-001' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L); + } + + @Test + void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception { + 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())) + .thenThrow(new SuperAgentOpenApiException("SuperAgent Open API 调用失败。")); + + mockMvc.perform(multipart(ENDPOINT) + .file(emlFile()) + .param("hotel_id", "HOTEL-TEST") + .header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key")) + .andExpect(status().isBadGateway()) + .andExpect(jsonPath("$.error_code").value("SUPERAGENT_OPEN_API_FAILED")) + .andExpect(content().string(not(containsString("test-debug-upload-key")))) + .andExpect(content().string(not(containsString("Please create booking")))); + + Long linkedFailedRunCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM platform_debug_eml_superagent_run + WHERE hotel_id = 'HOTEL-TEST' + AND run_status = 'SUPERAGENT_FAILED' + AND source_message_id IS NOT NULL + AND original_eml_oss_url IS NOT NULL + AND safe_error_summary = 'SuperAgent 调用失败。' + """, Long.class); + org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L); + } + + private MockMultipartFile emlFile() { + return new MockMultipartFile( + "file", + "debug-booking.eml", + MediaType.TEXT_PLAIN_VALUE, + emlBytes()); + } + + private byte[] emlBytes() { + return """ + From: Guest + To: Reservations + Subject: 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: multipart/alternative; boundary="alt-boundary" + + --alt-boundary + Content-Type: text/plain; charset=UTF-8 + + Please create booking from debug email. + + --alt-boundary + Content-Type: text/html; charset=UTF-8 + +

Please create booking.

+ + --alt-boundary-- + --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: application/pdf; name="booking.pdf" + Content-Transfer-Encoding: base64 + Content-Disposition: attachment; filename="booking.pdf" + + cGRmLWNvbnRlbnQ= + --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 new file mode 100644 index 0000000..8090cb1 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/impl/EmlMessageParseServiceImplTest.java @@ -0,0 +1,83 @@ +package cn.nianxx.thhotel.platform.message.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem; +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; +import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class EmlMessageParseServiceImplTest { + + private final EmlMessageParseService parseService = new EmlMessageParseServiceImpl(); + + @Test + void shouldParseHeadersBodiesInlineImagesAndAttachmentsFromEml() { + ParsedEmlMessage message = parseService.parse(emlBytes(), "booking.eml"); + + assertThat(message.messageId()).isEqualTo("debug-message-001@example.test"); + assertThat(message.conversationId()).isEqualTo("debug-message-001@example.test"); + assertThat(message.sender()).isEqualTo("guest@example.test"); + assertThat(message.subject()).isEqualTo("Booking Request"); + assertThat(message.sentAt()).isEqualTo(Instant.parse("2026-07-09T01:30:00Z")); + assertThat(message.textBody()).contains("Plain booking body"); + assertThat(message.htmlBody()).contains("cid:inline-001"); + assertThat(message.mediaItems()).hasSize(2); + + ParsedEmlMediaItem inlineImage = message.mediaItems().get(0); + assertThat(inlineImage.mediaType()).isEqualTo("INLINE_IMAGE"); + assertThat(inlineImage.fileName()).isEqualTo("inline.png"); + assertThat(inlineImage.contentType()).isEqualTo("image/png"); + assertThat(inlineImage.contentId()).isEqualTo("inline-001"); + assertThat(inlineImage.bytes()).isEqualTo("inline-image".getBytes(StandardCharsets.UTF_8)); + + ParsedEmlMediaItem attachment = message.mediaItems().get(1); + assertThat(attachment.mediaType()).isEqualTo("ATTACHMENT"); + assertThat(attachment.fileName()).isEqualTo("booking.pdf"); + assertThat(attachment.contentType()).isEqualTo("application/pdf"); + assertThat(attachment.bytes()).isEqualTo("pdf-content".getBytes(StandardCharsets.UTF_8)); + } + + private byte[] emlBytes() { + return """ + From: Guest + To: Reservations + Subject: Booking Request + 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: multipart/alternative; boundary="alt-boundary" + + --alt-boundary + Content-Type: text/plain; charset=UTF-8 + + Plain booking body + + --alt-boundary + Content-Type: text/html; charset=UTF-8 + +

HTML booking body

+ + --alt-boundary-- + --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: application/pdf; name="booking.pdf" + Content-Transfer-Encoding: base64 + Content-Disposition: attachment; filename="booking.pdf" + + cGRmLWNvbnRlbnQ= + --rel-boundary-- + """.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + } +}