修正邮件接收时间和发件人展示

This commit is contained in:
andy
2026-07-10 17:36:56 +08:00
parent 4745e20b9a
commit e76cb80f28
25 changed files with 226 additions and 59 deletions

View File

@@ -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));
}
/**
* 将渠道等稳定代码转为大写,缺失时使用服务端默认值。
*/

View File

@@ -20,6 +20,7 @@ public record SourceMessageInboxDraft(
boolean duplicatePayloadChanged,
String safeErrorSummary,
LocalDateTime receivedAt,
LocalDateTime capturedAt,
LocalDateTime sourceSentAt,
String senderSummary,
String subject,

View File

@@ -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<CaptureSourceMessageMedia> 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<CaptureSourceMessageMedia> mediaItems) {
this(
hotelId,
provider,
channel,
externalMessageId,
externalConversationId,
providerFrameId,
providerSessionId,
null,
sourceSentAt,
senderIdentifier,
subject,
textBody,
htmlBody,
payloadJson,
schemaVersion,
mediaItems);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);
}
/**

View File

@@ -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 正文

View File

@@ -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 标识

View File

@@ -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 任务创建时间

View File

@@ -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 系统主任务类型

View File

@@ -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 任务创建时间

View File

@@ -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$' */;

View File

@@ -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"));
}
}

View File

@@ -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.",
"<html>Please check received time.</html>",
"{\"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"
);

View File

@@ -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"))

View File

@@ -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))