diff --git a/docs/import/reusable/integrations/superagent-agentbus-portable-integration-guide.md b/docs/import/reusable/integrations/superagent-agentbus-portable-integration-guide.md index 7eb1206..fce42c1 100644 --- a/docs/import/reusable/integrations/superagent-agentbus-portable-integration-guide.md +++ b/docs/import/reusable/integrations/superagent-agentbus-portable-integration-guide.md @@ -308,6 +308,8 @@ GET ?ready=1 - `source.channel` - `source.external_message_id` - `source.external_conversation_id` +- payload 或 source 中的 `received_at` +- `source.sent_at` - frame id - session id - sender @@ -322,6 +324,9 @@ GET ?ready=1 | `source.channel` | `channel` | | `source.external_message_id` | `externalMessageId`,作为幂等键组成部分 | | `source.external_conversation_id` | `externalConversationId` | +| payload 或 source 中的 `received_at` | `receivedAt`,作为消息来源接收时间 | +| `source.sent_at` | `sourceSentAt`,作为消息来源发送时间 | +| sender | `senderSummary`,是否打码由项目业务和权限策略决定 | | frame id | `providerFrameId` | | session id | `providerSessionId` | | payload 规范 JSON | `payloadJson` 与 `payloadSha256` | diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 5d464ae..08d4865 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -103,8 +103,8 @@ POST /api/auth/logout | --- | --- | --- | | `source_message_id` | 本系统内部 SourceMessage Inbox ID。 | 打开邮件会话详情时作为路径参数传入 `/api/source-messages/{sourceMessageId}/conversation`。 | | `source_subject` | 来源邮件主题安全摘要。 | 用于列表或任务详情标题旁展示,不代表完整邮件主题一定无敏感信息。 | -| `source_sender_summary` | 来源发件人安全摘要。 | 用于辅助用户判断邮件来源。 | -| `source_received_at` | 本系统接收来源消息时间,UTC。 | 前端展示时按用户或酒店时区格式化。 | +| `source_sender_summary` | 来源发件人展示值,当前不打码。 | 用于辅助用户判断邮件来源。 | +| `source_received_at` | 邮件来源接收时间,UTC;优先取 AgentBus payload `received_at`,缺失时使用本系统接收时间。 | 前端展示时按用户或酒店时区格式化。 | | `external_conversation_id` | 外部邮件会话 ID。 | 仅用于展示或调试,不作为当前会话详情接口路径参数。 | | `conversation_message_count` | 同一外部会话下的邮件数量。 | 用于提示用户打开的是整段会话,不是单封邮件。 | @@ -113,7 +113,7 @@ POST /api/auth/logout - `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`。 - Reservation 列表、任务列表和订单详情默认不需要前端传 `hotel_id`;如果前端已经接入酒店选择器,可以把当前选中酒店作为可选 `hotel_id` 传给后端。任务详情、任务写操作和邮件会话详情当前仍按对象 ID 定位,不接收该参数。 - 后端会根据 `sourceMessageId` 定位 `external_conversation_id`,并返回同一会话下全部邮件;如果来源消息没有外部会话 ID,会降级返回当前单封邮件。 -- `messages[]` 按后端接收时间正序返回,前端不要重新按创建时间或任务时间排序。 +- `messages[]` 按邮件来源接收时间正序返回,前端不要重新按创建时间或任务时间排序。 - 返回内容包含完整 `text_body`、`html_body`、`inline_images[]`、`attachments[]`、`related_orders[]`、`related_tasks[]`。 - `html_body` 是原始 HTML 兼容字段;`html_body_sanitized` 是后端第一版清洗结果,已移除脚本标签、事件属性和危险协议链接。前端生产展示必须优先使用 `html_body_sanitized`,并可用 `html_render_mode=SANITIZED_HTML` 判断渲染模式。 - 第一版仅处理 HTML 内容安全;`inline_images[]` 和 `attachments[]` 的 `externalUrl` 来自本系统 OSS 服务,暂不做额外拦截,但前端仍不得写入普通日志、错误上报、localStorage 或 URL query。 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 d3edadc..df717cf 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -136,8 +136,8 @@ GET /api/reservation/tasks | 字段 | 说明 | | --- | --- | -| `source_sender_summary` | 来源消息发件人摘要,用于任务列表快速判断来源。 | -| `source_received_at` | 来源消息接收时间,用于任务列表排序和展示。 | +| `source_sender_summary` | 来源消息发件人展示值,当前不打码,用于任务列表快速判断来源。 | +| `source_received_at` | 邮件来源接收时间,优先取 AgentBus payload `received_at`,用于任务列表排序和展示。 | | `external_conversation_id` | 来源消息所属邮件会话 ID,用于打开完整邮件会话详情。 | | `conversation_message_count` | 会话内邮件数量,用于提示用户该入口是整段会话,不是单封邮件。 | @@ -209,8 +209,8 @@ GET /api/reservation/orders/{orderId} | --- | --- | | `source_message_id` | 任务对应的来源 SourceMessage Inbox ID。 | | `source_subject` | 来源消息主题摘要。 | -| `source_sender_summary` | 来源消息发件人摘要。 | -| `source_received_at` | 来源消息接收时间。 | +| `source_sender_summary` | 来源消息发件人展示值,当前不打码。 | +| `source_received_at` | 邮件来源接收时间,优先取 AgentBus payload `received_at`。 | | `external_conversation_id` | 来源消息所属邮件会话 ID。 | | `conversation_message_count` | 会话内邮件数量。 | @@ -322,7 +322,7 @@ GET /api/source-message-conversations/{externalConversationId} 中文说明: - “全部邮件”指同一个 `externalConversationId` 下的历史邮件、当前邮件和后续回复,不是只展示任务对应的单封来源邮件。 -- 邮件会话详情页需要展示完整正文或清洗后的 HTML、附件、内联图片、发件人摘要、发送 / 接收时间、主题和关联订单 / 任务。 +- 邮件会话详情页需要展示完整正文或清洗后的 HTML、附件、内联图片、发件人展示值、发送 / 接收时间、主题和关联订单 / 任务。 - 前端不在页面上做业务截断或隐藏;但仍只调用本项目后端接口,不直接访问邮箱、AgentBus、数据库或外部附件 URL Secret。 - 如果后端仍需要审计原文读取,应由后端在该业务接口内部处理;前端不保存 `X-TH-Hotel-Source-Original-Read-Key` 一类受控访问 key。 - 2026-07-08 后端已新增 `html_body_sanitized` 和 `html_render_mode`;前端页面展示邮件 HTML 时应优先使用 `html_body_sanitized`,`html_body` 只作为原始内容兼容字段,不建议生产直渲。 @@ -450,8 +450,8 @@ GET /api/reservation/tasks/{taskId} | 位置 | 字段 | 说明 | | --- | --- | --- | | 顶层 | `source_subject` | 任务来源消息主题摘要。 | -| 顶层 | `source_sender_summary` | 任务来源消息发件人摘要。 | -| 顶层 | `source_received_at` | 任务来源消息接收时间。 | +| 顶层 | `source_sender_summary` | 任务来源消息发件人展示值,当前不打码。 | +| 顶层 | `source_received_at` | 任务来源邮件接收时间,优先取 AgentBus payload `received_at`。 | | 顶层 | `external_conversation_id` | 任务来源消息所属邮件会话 ID。 | | 顶层 | `conversation_message_count` | 会话内邮件数量。 | | `fields[]` | `result_type` | 3.0 字段表中的结果类型,用于前端调试和字段分组校验。 | diff --git a/docs/project/frontend-development-guidelines.md b/docs/project/frontend-development-guidelines.md index 5d20f0b..05ac07a 100644 --- a/docs/project/frontend-development-guidelines.md +++ b/docs/project/frontend-development-guidelines.md @@ -462,7 +462,7 @@ pnpm test -- SomeSpecName.spec.ts - 邮件会话详情页展示同一个 `external_conversation_id` 下的全部邮件,包括历史邮件、当前邮件和后续回复。 - 页面入口可以来自订单详情任务队列、当前任务卡证据区或任务详情页,但落地页统一为“邮件会话详情”,不是单封邮件详情。 -- 每封邮件应展示完整正文或清洗后的 HTML、附件列表、内联图片信息、发送 / 接收时间、发件人摘要、主题和关联订单 / 任务。 +- 每封邮件应展示完整正文或清洗后的 HTML、附件列表、内联图片信息、发送 / 接收时间、发件人展示值、主题和关联订单 / 任务。 - 前端不在本页展示假数据;后端接口未接入时显示“接口待接入 / 暂无邮件会话数据”。 - 邮件全文属于用户在邮箱中本来可见的信息,前端页面不做业务层面的截断隐藏;但仍必须通过本项目后端接口读取,不在前端保存外部系统 Secret 或访问 key。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index f81549e..16a11db 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -211,7 +211,7 @@ - 表和字段中文注释能正常创建。 - 数据库业务时间点按 UTC 写入,接口层返回带 `Z` 的 ISO 8601 UTC 时间,例如 `2026-07-08T03:00:00Z`。 - MySQL JDBC URL 建议明确 `serverTimezone=UTC`;部署容器和 JVM 也应使用 UTC,或至少确认应用代码所有入库时间均通过 UTC 时钟生成。 -- AgentBus 邮件来源时间、SuperAgent HMAC timestamp、本系统 `created_at` / `updated_at` / `received_at` 等时间点统一按 UTC 理解;前端展示时再按用户或酒店时区格式化。 +- AgentBus 邮件来源时间、SuperAgent HMAC timestamp、本系统 `created_at` / `updated_at` 等时间点统一按 UTC 理解;SourceMessage `received_at` 优先保存 AgentBus payload `received_at`,缺失时回退本系统接收时间,前端展示时再按用户或酒店时区格式化。 - 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不应因为 UTC 换算而自动前后偏移。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 - 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 @@ -255,12 +255,12 @@ HAVING COUNT(*) > 1; - Authorization、Cookie、CSRF Token。 - Provider API Key、AgentBus Token、数据库密码。 - 邮件正文、HTML、附件 URL、原始 payload。 -- 客户姓名、完整邮箱、电话、证件号。 +- 客户姓名、电话、证件号;发件人展示字段因业务识别需要允许返回完整邮箱或名称,但不得写入普通日志、错误响应或调试状态接口。 - 支付信息。 SourceMessage 普通列表和普通详情只能返回安全摘要: -- 可以返回:SourceMessage ID、酒店 ID、provider、channel、外部邮件 ID、外部邮件链 ID、状态、时间、发送人摘要、主题摘要、安全短摘要。 +- 可以返回:SourceMessage ID、酒店 ID、provider、channel、外部邮件 ID、外部邮件链 ID、状态、时间、发件人展示值、主题摘要、安全短摘要。 - 不应返回:完整正文、HTML、附件 URL、原始 payload。 原文读取接口只允许在明确授权场景下使用: diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index 4dbb3ea..e5a733a 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -348,12 +348,14 @@ body.html body.text inline_images[] attachments[] +received_at source.channel source.channel_account source.external_message_id source.external_conversation_id source.sender source.subject +source.sent_at source.web_link reply_policy.mode reply_policy.final_only @@ -366,6 +368,9 @@ reply_policy.final_only | `source.channel` | `channel`,例如 `EMAIL` | | `source.external_message_id` | `externalMessageId`,作为幂等键组成部分 | | `source.external_conversation_id` | `externalConversationId` | +| `payload.received_at` 或 `source.received_at` | `receivedAt`,作为邮件来源接收时间 | +| `source.sent_at` | `sourceSentAt`,作为邮件来源发送时间 | +| `source.sender` | `senderSummary`,当前作为发件人展示值不打码 | | frame `id` | `agentbusFrameId` | | frame `session_id` | `agentbusSessionId` | | `payload` 规范 JSON | `payloadJson` 与 `payloadSha256` | diff --git a/docs/project/requirements/M001-source-message-inbox-prd.md b/docs/project/requirements/M001-source-message-inbox-prd.md index f1fe7f8..d6c6efd 100644 --- a/docs/project/requirements/M001-source-message-inbox-prd.md +++ b/docs/project/requirements/M001-source-message-inbox-prd.md @@ -117,7 +117,7 @@ AgentBus 邮件 JSON - 幂等去重:同一酒店、同一 provider、同一 channel、同一外部邮件 ID 不重复建记录。 - 历史查询:查询本项目已经接收并落库过的邮件。 - 邮件链查询:按 `externalConversationId` 查询同一邮件链下的记录。 -- 安全摘要:列表只返回主题、发送人摘要、时间、状态、短摘要等安全信息。 +- 安全摘要:列表只返回主题摘要、发件人展示值、时间、状态、短摘要等受控展示信息。 - 原文读取:提供统一受控接口读取 text、HTML、正文图片 URL、附件 URL。 - 媒体引用保存:保存 AgentBus 已处理好的长期有效媒体 URL,不下载、不转存、不刷新。 - 扩展来源:后续可以接入 Microsoft Graph、IMAP、Webhook、手工导入等来源。 @@ -152,9 +152,9 @@ source_message_media | `providerSessionId` | AgentBus session ID,用于排查连接会话,不作为邮件链 ID | | `payloadSha256` | 规范 JSON 的哈希值,用于排查 payload 是否变化 | | `captureStatus` | 捕获状态,例如 `RECEIVED`、`FAILED` | -| `receivedAt` | 本项目接收时间 | +| `receivedAt` | 邮件来源接收时间,优先取 AgentBus payload `received_at`;缺失时使用本项目接收时间 | | `sourceSentAt` | 邮件来源发送时间,如果 payload 提供则保存 | -| `senderSummary` | 发送人安全摘要,列表可展示,不保存或不返回完整敏感信息 | +| `senderSummary` | 发件人展示值,业务人员需要识别真实发件人,当前只清理首尾空白不打码 | | `subject` | 邮件主题 | | `safeSnippet` | 邮件安全摘要,不能包含完整正文、HTML、附件 URL 或敏感凭证 | diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java index 13bade2..072a608 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapter.java @@ -45,6 +45,7 @@ public class AgentBusSourceMessageAdapter { text(source, "external_conversation_id"), text(frame, "id"), text(frame, "session_id"), + firstInstant(source, "received_at", payload, "received_at"), parseInstant(text(source, "sent_at")), text(source, "sender"), text(source, "subject"), @@ -110,6 +111,17 @@ public class AgentBusSourceMessageAdapter { } } + /** + * 从两个候选字段中读取第一个可解析的时间,格式非法时继续尝试后续字段。 + */ + private Instant firstInstant(JsonNode firstNode, String firstField, JsonNode secondNode, String secondField) { + Instant firstValue = parseInstant(text(firstNode, firstField)); + if (firstValue != null) { + return firstValue; + } + return parseInstant(text(secondNode, secondField)); + } + /** * 将渠道等稳定代码转为大写,缺失时使用服务端默认值。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java index 4acecaf..f73d7f1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/dto/SourceMessageInboxDraft.java @@ -20,6 +20,7 @@ public record SourceMessageInboxDraft( boolean duplicatePayloadChanged, String safeErrorSummary, LocalDateTime receivedAt, + LocalDateTime capturedAt, LocalDateTime sourceSentAt, String senderSummary, String subject, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java index 8333b9b..5280a91 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/request/CaptureSourceMessageCommand.java @@ -14,6 +14,7 @@ public record CaptureSourceMessageCommand( String externalConversationId, String providerFrameId, String providerSessionId, + Instant sourceReceivedAt, Instant sourceSentAt, String senderIdentifier, String subject, @@ -23,4 +24,38 @@ public record CaptureSourceMessageCommand( String schemaVersion, List mediaItems ) { + public CaptureSourceMessageCommand( + String hotelId, + String provider, + String channel, + String externalMessageId, + String externalConversationId, + String providerFrameId, + String providerSessionId, + Instant sourceSentAt, + String senderIdentifier, + String subject, + String textBody, + String htmlBody, + String payloadJson, + String schemaVersion, + List mediaItems) { + this( + hotelId, + provider, + channel, + externalMessageId, + externalConversationId, + providerFrameId, + providerSessionId, + null, + sourceSentAt, + senderIdentifier, + subject, + textBody, + htmlBody, + payloadJson, + schemaVersion, + mediaItems); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java index 8ab4f25..597c5fa 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/common/result/SourceMessageConversationMessageResult.java @@ -10,9 +10,9 @@ import java.util.List; * @param id 内部 SourceMessage Inbox ID * @param externalMessageId 外部单封邮件 ID * @param externalConversationId 外部邮件会话 ID - * @param senderSummary 发送人安全摘要 + * @param senderSummary 发件人展示值,当前不打码 * @param subject 邮件主题摘要 - * @param receivedAt 本系统接收时间,UTC + * @param receivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param sourceSentAt 邮件来源发送时间,UTC * @param textBody 完整纯文本正文 * @param htmlBody 完整 HTML 正文,前端展示前必须 sanitize diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java index cb4774c..9579150 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/domain/SourceMessageInboxEntity.java @@ -36,11 +36,11 @@ public class SourceMessageInboxEntity { private Boolean duplicatePayloadChanged; /** 安全错误摘要,不包含正文、附件 URL、Token 或个人敏感信息。 */ private String safeErrorSummary; - /** 本项目接收消息的 UTC 时间。 */ + /** 邮件来源接收 UTC 时间,缺失时使用本项目接收消息的 UTC 时间。 */ private LocalDateTime receivedAt; /** 来源系统提供的邮件发送 UTC 时间,缺失时为空。 */ private LocalDateTime sourceSentAt; - /** 发送人安全摘要,普通查询可展示。 */ + /** 发件人展示值,业务人员需要识别真实发件人,当前只清理首尾空白不打码。 */ private String senderSummary; /** 脱敏后的邮件主题,普通查询可展示。 */ private String subject; diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index d7ce4f9..26fe246 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -244,7 +244,7 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe insertPayload(draft, inbox.getId()); if (SourceMessageCaptureStatus.RECEIVED.code().equals(draft.captureStatus())) { insertBody(draft, inbox.getId()); - insertMedia(draft.mediaItems(), inbox.getId(), draft.receivedAt()); + insertMedia(draft.mediaItems(), inbox.getId(), draft.capturedAt()); } return inbox.getId(); } @@ -340,8 +340,8 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe inbox.setSenderSummary(draft.senderSummary()); inbox.setSubject(draft.subject()); inbox.setSafeSnippet(draft.safeSnippet()); - inbox.setCreatedAt(draft.receivedAt()); - inbox.setUpdatedAt(draft.receivedAt()); + inbox.setCreatedAt(draft.capturedAt()); + inbox.setUpdatedAt(draft.capturedAt()); return inbox; } @@ -354,7 +354,7 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe payload.setPayloadJson(draft.payloadJson()); payload.setPayloadSha256(draft.payloadSha256()); payload.setSchemaVersion(draft.schemaVersion()); - payload.setCreatedAt(draft.receivedAt()); + payload.setCreatedAt(draft.capturedAt()); payloadMapper.insert(payload); } @@ -368,7 +368,7 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe body.setTextBody(draft.textBody()); body.setHtmlBody(draft.htmlBody()); body.setBodySha256(sha256(draft.textBody() + "\n" + draft.htmlBody())); - body.setCreatedAt(draft.receivedAt()); + body.setCreatedAt(draft.capturedAt()); bodyMapper.insert(body); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java index c29f9a6..1489d86 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageCaptureServiceImpl.java @@ -213,7 +213,7 @@ public class SourceMessageCaptureServiceImpl implements SourceMessageCaptureServ } /** - * 构建入库草稿,统一生成安全主题、发送人摘要和正文摘要。 + * 构建入库草稿,统一生成安全主题、发件人展示值和正文摘要。 */ private SourceMessageInboxDraft buildDraft( CaptureSourceMessageCommand command, @@ -224,7 +224,9 @@ public class SourceMessageCaptureServiceImpl implements SourceMessageCaptureServ String payloadSha256, SourceMessageCaptureStatus captureStatus, String safeErrorSummary) { - LocalDateTime now = nowUtc(); + LocalDateTime capturedAt = nowUtc(); + LocalDateTime sourceReceivedAt = toLocalDateTime(command.sourceReceivedAt()); + LocalDateTime receivedAt = sourceReceivedAt == null ? capturedAt : sourceReceivedAt; String textBody = command.textBody() == null ? "" : command.textBody(); String htmlBody = command.htmlBody() == null ? "" : command.htmlBody(); // 邮件主题也可能携带联系方式或带 token 的 URL,普通查询只保存可展示摘要。 @@ -244,7 +246,8 @@ public class SourceMessageCaptureServiceImpl implements SourceMessageCaptureServ captureStatus.code(), false, safeErrorSummary, - now, + receivedAt, + capturedAt, toLocalDateTime(command.sourceSentAt()), safetySanitizer.senderSummary(command.senderIdentifier()), safeSubject, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java index f5d71d8..c8eb154 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageSafetySanitizer.java @@ -1,11 +1,10 @@ package cn.nianxx.thhotel.platform.message.service.impl; -import java.util.Locale; import java.util.regex.Pattern; import org.springframework.stereotype.Component; /** - * 来源消息安全摘要工具。列表和普通详情只能使用脱敏后的摘要,不能透出原文或媒体 URL。 + * 来源消息安全摘要工具。正文和主题仍做安全摘要;发件人展示值只清理首尾空白。 */ @Component public class SourceMessageSafetySanitizer { @@ -18,21 +17,10 @@ public class SourceMessageSafetySanitizer { private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); /** - * 生成发送人安全摘要,避免普通列表直接暴露完整邮箱或客户标识。 + * 生成发送人展示值。业务人员需要识别邮件真实发件人,因此这里只清理首尾空白,不再打码。 */ String senderSummary(String senderIdentifier) { - String value = trimToEmpty(senderIdentifier); - if (value.isEmpty()) { - return ""; - } - int atIndex = value.indexOf('@'); - if (atIndex > 0 && atIndex < value.length() - 1) { - return value.charAt(0) + "***" + value.substring(atIndex).toLowerCase(Locale.ROOT); - } - if (value.length() <= 2) { - return "*"; - } - return value.charAt(0) + "***" + value.charAt(value.length() - 1); + return trimToEmpty(senderIdentifier); } /** diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java index b8fb38b..5b1d74f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationMessagesResult.java @@ -27,9 +27,9 @@ public record ReservationMessageConversationMessagesResult( * * @param externalSourceMessageId 外部来源消息 ID,对应 AgentBus source.external_message_id * @param externalConversationId 外部邮件会话 ID - * @param senderSummary 发送人安全摘要 + * @param senderSummary 发件人展示值,当前不打码 * @param subject 邮件主题安全摘要 - * @param receivedAt 本系统接收该邮件的 UTC 时间 + * @param receivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param sourceSentAt 来源系统发送时间 * @param textBody 纯文本正文 * @param htmlBodySanitized 后端清洗后的 HTML 正文 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java index baa9254..3b94490 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationMessageConversationTasksResult.java @@ -29,7 +29,7 @@ public record ReservationMessageConversationTasksResult( * @param orderId 任务当前挂靠订单 ID * @param externalSourceMessageId 外部来源消息 ID,对应 AgentBus source.external_message_id * @param externalConversationId 外部邮件会话 ID - * @param sourceReceivedAt 本系统接收该邮件的 UTC 时间 + * @param sourceReceivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param sourceEventIndex AI transition 来源事件序号 * @param catalogCode Skill 目录代码 * @param skillId Skill 标识 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java index 12b6234..fc88c18 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java @@ -17,8 +17,8 @@ import java.time.OffsetDateTime; * @param readonlyReasonCode 只读原因代码 * @param sourceMessageId 来源 SourceMessage Inbox ID * @param sourceSubject 来源消息主题摘要 - * @param sourceSenderSummary 来源发送人安全摘要 - * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param sourceSenderSummary 来源发件人展示值,当前不打码 + * @param sourceReceivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param externalConversationId 外部邮件会话 ID * @param conversationMessageCount 同一外部邮件会话下的消息数量 * @param createdAt 任务创建时间 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java index e1485bc..c8bb068 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -11,8 +11,8 @@ import java.util.List; * @param orderId 订单 ID * @param sourceMessageId 来源 SourceMessage Inbox ID * @param sourceSubject 来源消息主题摘要 - * @param sourceSenderSummary 来源发送人安全摘要 - * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param sourceSenderSummary 来源发件人展示值,当前不打码 + * @param sourceReceivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param externalConversationId 外部邮件会话 ID * @param conversationMessageCount 同一外部邮件会话下的消息数量 * @param systemTaskType 系统主任务类型 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java index b67049b..3094bf3 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java @@ -21,8 +21,8 @@ import java.time.OffsetDateTime; * @param readonlyReasonCode 只读原因代码 * @param sourceMessageId 来源 SourceMessage Inbox ID * @param sourceSubject 来源消息主题摘要 - * @param sourceSenderSummary 来源发送人安全摘要 - * @param sourceReceivedAt 本系统接收来源消息时间,UTC + * @param sourceSenderSummary 来源发件人展示值,当前不打码 + * @param sourceReceivedAt 邮件来源接收时间,UTC;缺失时回退本系统接收时间 * @param externalConversationId 外部邮件会话 ID * @param conversationMessageCount 同一外部邮件会话下的消息数量 * @param createdAt 任务创建时间 diff --git a/server/src/main/resources/db/migration/V14__clarify_source_message_received_time_and_sender_display.sql b/server/src/main/resources/db/migration/V14__clarify_source_message_received_time_and_sender_display.sql new file mode 100644 index 0000000..fae4c32 --- /dev/null +++ b/server/src/main/resources/db/migration/V14__clarify_source_message_received_time_and_sender_display.sql @@ -0,0 +1,46 @@ +-- M001 SourceMessage 字段语义修正:received_at 表示邮件实际接收时间,sender_summary 不再打码。 +-- 说明:MySQL 8.0+ 执行以下注释指令;H2 MySQL Mode 会跳过,避免测试库不支持完整 MySQL COMMENT 语法。 + +/*!80000 ALTER TABLE platform_source_message_inbox + MODIFY COLUMN received_at DATETIME(6) NOT NULL COMMENT '邮件来源接收时间,优先取 AgentBus payload received_at;缺失时使用本项目接收 UTC 时间' */; + +/*!80000 ALTER TABLE platform_source_message_inbox + MODIFY COLUMN sender_summary VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '发件人展示值,业务人员需要识别真实发件人,当前只清理首尾空白不打码' */; + +-- 历史数据修正:已保存原始 payload 的旧记录,尽量回填邮件来源接收时间和完整发件人展示值。 +-- 说明:只处理合法 JSON 且字段存在的记录;无法解析的旧记录保留原值,避免迁移阻断上线。 +/*!80000 UPDATE platform_source_message_inbox inbox + JOIN ( + SELECT + payload.inbox_id, + NULLIF(TRIM( + CASE + WHEN JSON_VALID(payload.payload_json) THEN JSON_UNQUOTE(COALESCE( + JSON_EXTRACT(payload.payload_json, '$.source.sender'), + JSON_EXTRACT(payload.payload_json, '$.sender'))) + ELSE NULL + END + ), '') AS source_sender, + NULLIF(TRIM( + CASE + WHEN JSON_VALID(payload.payload_json) THEN JSON_UNQUOTE(COALESCE( + JSON_EXTRACT(payload.payload_json, '$.source.received_at'), + JSON_EXTRACT(payload.payload_json, '$.received_at'))) + ELSE NULL + END + ), '') AS source_received_at + FROM platform_source_message_payload payload + ) payload_source ON payload_source.inbox_id = inbox.id + SET + inbox.sender_summary = COALESCE(payload_source.source_sender, inbox.sender_summary), + inbox.received_at = CASE + WHEN payload_source.source_received_at REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$' + THEN CASE + WHEN payload_source.source_received_at REGEXP '\\.[0-9]{1,6}Z$' + THEN STR_TO_DATE(REPLACE(REPLACE(payload_source.source_received_at, 'T', ' '), 'Z', ''), '%Y-%m-%d %H:%i:%s.%f') + ELSE STR_TO_DATE(REPLACE(REPLACE(payload_source.source_received_at, 'T', ' '), 'Z', ''), '%Y-%m-%d %H:%i:%s') + END + ELSE inbox.received_at + END + WHERE payload_source.source_sender IS NOT NULL + OR payload_source.source_received_at REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$' */; diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java index 4074b90..199399b 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusSourceMessageAdapterTest.java @@ -44,6 +44,7 @@ class AgentBusSourceMessageAdapterTest { "external_url": "https://media.example.test/booking.pdf" } ], + "received_at": "2026-07-09T05:56:56Z", "source": { "channel": "email", "external_message_id": "outlook-message-001", @@ -65,6 +66,7 @@ class AgentBusSourceMessageAdapterTest { assertThat(command.externalConversationId()).isEqualTo("outlook-conversation-001"); assertThat(command.providerFrameId()).isEqualTo("frame-agentbus-001"); assertThat(command.providerSessionId()).isEqualTo("session-agentbus-001"); + assertThat(command.sourceReceivedAt()).isEqualTo(Instant.parse("2026-07-09T05:56:56Z")); assertThat(command.sourceSentAt()).isEqualTo(Instant.parse("2026-07-06T08:00:00Z")); assertThat(command.senderIdentifier()).isEqualTo("guest@example.test"); assertThat(command.subject()).isEqualTo("Booking change"); @@ -78,4 +80,40 @@ class AgentBusSourceMessageAdapterTest { assertThat(command.mediaItems().get(1).mediaType()).isEqualTo("ATTACHMENT"); assertThat(command.mediaItems().get(1).fileName()).isEqualTo("booking.pdf"); } + + @Test + void shouldPreferValidSourceReceivedAtAndFallbackToPayloadReceivedAt() throws Exception { + JsonNode sourceReceivedFrame = objectMapper.readTree(""" + { + "id": "frame-agentbus-002", + "payload": { + "received_at": "2026-07-09T05:56:56Z", + "source": { + "channel": "email", + "external_message_id": "outlook-message-002", + "received_at": "2026-07-09T05:50:00Z" + } + } + } + """); + JsonNode invalidSourceReceivedFrame = objectMapper.readTree(""" + { + "id": "frame-agentbus-003", + "payload": { + "received_at": "2026-07-09T05:56:56Z", + "source": { + "channel": "email", + "external_message_id": "outlook-message-003", + "received_at": "bad-time" + } + } + } + """); + + CaptureSourceMessageCommand sourceCommand = adapter.toCaptureCommand("HOTEL-TEST", sourceReceivedFrame); + CaptureSourceMessageCommand fallbackCommand = adapter.toCaptureCommand("HOTEL-TEST", invalidSourceReceivedFrame); + + assertThat(sourceCommand.sourceReceivedAt()).isEqualTo(Instant.parse("2026-07-09T05:50:00Z")); + assertThat(fallbackCommand.sourceReceivedAt()).isEqualTo(Instant.parse("2026-07-09T05:56:56Z")); + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java index dce7999..fd7283d 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/service/SourceMessageCaptureServiceImplTest.java @@ -106,7 +106,7 @@ class SourceMessageCaptureServiceImplTest { assertThat(summary.externalMessageId()).isEqualTo("mail-m001-001"); assertThat(summary.externalConversationId()).isEqualTo("conversation-m001"); assertThat(summary.captureStatus()).isEqualTo("RECEIVED"); - assertThat(summary.senderSummary()).isEqualTo("g***@example.test"); + assertThat(summary.senderSummary()).isEqualTo("guest@example.test"); assertThat(summary.subject()).isEqualTo("Booking change request"); assertThat(summary.safeSnippet()).contains("Please change the arrival date"); assertThat(summary.safeSnippet()).doesNotContain("13800138000"); @@ -187,6 +187,40 @@ class SourceMessageCaptureServiceImplTest { assertThat(inbox.getSafeErrorSummary()).contains("重复投递 payload"); } + @Test + void shouldPersistSourceReceivedAtAndExposeFullSenderIdentifier() { + CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( + "HOTEL-TEST", + "AGENTBUS", + "EMAIL", + "mail-source-received-time-001", + "conversation-source-received-time", + "frame-source-received-time-001", + "session-source-received-time", + Instant.parse("2026-07-09T05:56:56Z"), + Instant.parse("2026-07-09T05:55:00Z"), + "guest@example.test", + "Source received time", + "Please check received time.", + "Please check received time.", + "{\"received_at\":\"2026-07-09T05:56:56Z\",\"source\":{\"external_message_id\":\"mail-source-received-time-001\"}}", + "agentbus-outlook-v1", + List.of() + ); + + SourceMessageCaptureResult result = captureService.capture(command); + + SourceMessageSummaryResponse summary = queryService.getSummary(result.inboxId()).orElseThrow(); + assertThat(summary.receivedAt().toInstant()).isEqualTo(Instant.parse("2026-07-09T05:56:56Z")); + assertThat(summary.sourceSentAt().toInstant()).isEqualTo(Instant.parse("2026-07-09T05:55:00Z")); + assertThat(summary.senderSummary()).isEqualTo("guest@example.test"); + + SourceMessageInboxEntity inbox = inboxMapper.selectById(result.inboxId()); + assertThat(inbox.getReceivedAt()).isEqualTo(LocalDateTime.parse("2026-07-09T05:56:56")); + assertThat(inbox.getCreatedAt()).isNotEqualTo(inbox.getReceivedAt()); + assertThat(inbox.getUpdatedAt()).isEqualTo(inbox.getCreatedAt()); + } + @Test void shouldPersistFailedInboxWhenExternalMessageIdIsMissing() { CaptureSourceMessageCommand command = new CaptureSourceMessageCommand( @@ -319,7 +353,7 @@ class SourceMessageCaptureServiceImplTest { false, LocalDateTime.parse("2026-07-06T09:50:00"), LocalDateTime.parse("2026-07-06T09:50:00"), - "g***@example.test", + "guest@example.test", "Race delivery", "Race body" ); @@ -373,7 +407,7 @@ class SourceMessageCaptureServiceImplTest { true, LocalDateTime.parse("2026-07-06T10:00:00"), LocalDateTime.parse("2026-07-06T10:00:00"), - "g***@example.test", + "guest@example.test", "Duplicate diagnostic fail", "Original body" ); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index 7496624..6996c99 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -91,7 +91,7 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.items[0].readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.items[0].source_message_id").value(source.inboxId().toString())) .andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query List Smoke")) - .andExpect(jsonPath("$.items[0].source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.items[0].source_sender_summary").value("guest@example.test")) .andExpect(jsonPath("$.items[0].source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.items[0].external_conversation_id") .value("thread-mail-frontend-task-list-001")) @@ -193,7 +193,7 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.tasks[0].task_status").value("COMPLETED")) .andExpect(jsonPath("$.tasks[0].source_message_id").value(source.inboxId().toString())) .andExpect(jsonPath("$.tasks[0].source_subject").value("Frontend Query Detail Smoke")) - .andExpect(jsonPath("$.tasks[0].source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.tasks[0].source_sender_summary").value("guest@example.test")) .andExpect(jsonPath("$.tasks[0].source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.tasks[0].external_conversation_id") .value("thread-mail-frontend-order-detail-001")) diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 81adef7..dbaa2f0 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -702,7 +702,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING")) .andExpect(jsonPath("$.source_subject").value("M002 SuperAgent intake")) - .andExpect(jsonPath("$.source_sender_summary").value("g***@example.test")) + .andExpect(jsonPath("$.source_sender_summary").value("guest@example.test")) .andExpect(jsonPath("$.source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.external_conversation_id").value("thread-mail-detail-matrix-001")) .andExpect(jsonPath("$.conversation_message_count").value(1))