diff --git a/docs/project/backend-development-guidelines.md b/docs/project/backend-development-guidelines.md index c8cae2f..b122c93 100644 --- a/docs/project/backend-development-guidelines.md +++ b/docs/project/backend-development-guidelines.md @@ -209,7 +209,8 @@ groupCode - SQL 文件应使用中文行注释划分表、索引、约束等主要结构。 - 注释必须说明业务含义、来源或代码值范围。 - 禁止使用“字段1”“备用字段”等模糊注释。 -- 业务时间以 UTC 写入数据库,API 层负责返回 ISO 8601。 +- 业务时间点以 UTC 写入数据库,API 层负责返回带 `Z` 的 ISO 8601 UTC 时间,例如 `2026-07-08T03:00:00Z`。 +- 酒店本地业务日期,例如入住日期、离店日期、营业日,优先使用 `LocalDate` 或明确酒店时区语义的字段,不和 UTC 时间点混用。 - JSON 字段只用于扩展元数据,不替代需要查询、约束或索引的正式列。 ## 11. 后端代码注释规范 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index af5f467..8496c6c 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -9,6 +9,8 @@ - 前端只调用本项目后端接口,不直接调用 SuperAgent、AgentBus、OPERA、OHIP 或数据库。 - API 调用应统一放在前端 `src/services`,页面组件不要直接拼接后端 URL。 - 业务判断必须使用后端返回的稳定 code,不使用中文或英文展示文案做判断。 +- 后端返回的时间点字段统一是带 `Z` 的 ISO 8601 UTC 时间,例如 `created_at`、`updated_at`、`received_at`、`last_updated_at`;前端展示时再按用户或酒店时区格式化。 +- 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不要按 UTC 时间点自动换算日期。 - 前端不得保存或传递后端 Secret、replay access key、Provider API Key、Oracle 凭证、AgentBus Token。 - 后端数据库 ID 未来应尽量以字符串形式给前端,避免 JavaScript 长整型精度问题;如果当前接口仍返回数字,前端不要自行做数学运算。 - 接口字段发生变化前,需要先更新本目录沟通文档或对应需求文档。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index c0f7ce3..2ce34bf 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -88,7 +88,7 @@ | 变量 | 是否 Secret | 上线注意事项 | | --- | --- | --- | | `SUPERAGENT_TASK_RESULT_HMAC_SECRET` | 是 | SuperAgent 查询接口 1、2 和任务结果通知接口共用的 HMAC secret。生产不能为空,只能通过 Secret 注入。 | -| `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒。上线前确认双方机器时间已同步。 | +| `SUPERAGENT_TASK_RESULT_CLOCK_SKEW_SECONDS` | 否 | 请求时间允许偏移,默认 `300` 秒。上线前确认本系统、SuperAgent 和 AgentBus 所在机器时间已通过 NTP 同步。 | | `SUPERAGENT_TASK_RESULT_NONCE_TTL_SECONDS` | 否 | nonce 防重放窗口,默认 `600` 秒。 | | `SUPERAGENT_TASK_RESULT_MAX_BODY_BYTES` | 否 | SuperAgent 入站请求体最大字节数,默认 `1048576`。 | @@ -120,7 +120,10 @@ - MySQL 版本满足项目要求,默认使用 MySQL 8.0+。 - migration 在 UAT 或测试库已经跑过。 - 表和字段中文注释能正常创建。 -- 数据库时间按 UTC 写入,接口层负责返回 ISO 8601。 +- 数据库业务时间点按 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 理解;前端展示时再按用户或酒店时区格式化。 +- 入住日期、离店日期、酒店营业日属于酒店本地业务日期,不应因为 UTC 换算而自动前后偏移。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 - 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 8b76945..0221d9e 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -221,8 +221,8 @@ SuperAgent 不应知道或依赖内部 SourceMessage Inbox ID。任务结果通 "status": "ACTIVE", "source_message_id": "1900000000000000001", "created_from_task_id": null, - "created_at": "2026-07-08T01:00:00", - "last_updated_at": "2026-07-08T01:10:00", + "created_at": "2026-07-08T01:00:00Z", + "last_updated_at": "2026-07-08T01:10:00Z", "arrival_date": null, "departure_date": null, "nights": null, diff --git a/docs/project/requirements/M002-ai-query-minimal-fields.md b/docs/project/requirements/M002-ai-query-minimal-fields.md index e028664..dfa6120 100644 --- a/docs/project/requirements/M002-ai-query-minimal-fields.md +++ b/docs/project/requirements/M002-ai-query-minimal-fields.md @@ -71,6 +71,12 @@ | `X-TH-Hotel-Request-Id` | 否 | 调用方生成的请求 ID,用于日志串联 | | `X-TH-Hotel-AI-Trace-Id` | 否 | AI 运行链路 ID | +时间约定: + +- 请求 Header `X-TH-Hotel-SuperAgent-Timestamp` 必须是带时区的 UTC ISO-8601 时间,例如 `2026-07-08T03:00:00Z`。 +- 响应中的 `created_at`、`last_updated_at`、`occurred_at` 等时间点统一返回带 `Z` 的 ISO-8601 UTC 时间。 +- `source_message_id`、`source_event_index` 第一版仅兼容接收,不参与查询范围过滤;时间统一不依赖这两个字段是否已在本系统落库。 + 规范签名串: ```text diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/common/time/UtcTimeFormatter.java b/server/src/main/java/cn/nianxx/thhotel/platform/common/time/UtcTimeFormatter.java new file mode 100644 index 0000000..6bb9455 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/platform/common/time/UtcTimeFormatter.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.platform.common.time; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/** + * UTC 时间格式工具。数据库中的业务时间点统一按 UTC LocalDateTime 保存,API 出口统一补 UTC offset。 + */ +public final class UtcTimeFormatter { + + private UtcTimeFormatter() { + } + + /** + * 将数据库 UTC LocalDateTime 转为带 UTC offset 的结构化时间。 + */ + public static OffsetDateTime toUtcOffsetDateTime(LocalDateTime value) { + if (value == null) { + return null; + } + return value.atOffset(ZoneOffset.UTC); + } +} 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 2f51a93..0df7326 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 @@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -70,6 +71,25 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe return Optional.ofNullable(inboxMapper.selectById(id)).map(this::toSnapshot); } + /** + * 批量读取 Inbox 安全快照,只读取 Inbox 索引表字段,不访问正文、媒体或 payload 表。 + */ + @Override + public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + List safeIds = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (safeIds.isEmpty()) { + return List.of(); + } + return inboxMapper.selectList(Wrappers.lambdaQuery() + .in(SourceMessageInboxEntity::getId, safeIds)) + .stream() + .map(this::toSnapshot) + .toList(); + } + /** * 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index 8243b33..93a039b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -20,6 +20,11 @@ public interface SourceMessageInboxRepository { */ Optional findById(Long id); + /** + * 按内部 SourceMessage ID 批量查询 Inbox 安全快照,供列表类接口预取摘要。 + */ + List findByIds(List ids); + /** * 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java index 209c52b..efb21de 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java @@ -35,4 +35,12 @@ public interface SourceMessageQueryService { * @return 存在时返回安全摘要,不存在时为空 */ Optional getSummary(Long inboxId); + + /** + * 按内部 SourceMessage ID 批量读取安全摘要,供业务列表避免逐条查询。 + * + * @param inboxIds 内部 SourceMessage Inbox ID 列表 + * @return 匹配到的安全摘要列表,不返回正文、HTML、附件 URL 或 payload + */ + List getSummariesByIds(List inboxIds); } diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java index 83b0f0b..73934e4 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java @@ -1,14 +1,12 @@ package cn.nianxx.thhotel.platform.message.service.impl; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest; import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult; import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Service; @@ -61,6 +59,19 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService return inboxRepository.findById(inboxId).map(this::toSummary); } + /** + * 批量读取 SourceMessage 安全摘要,用于业务列表预取主题等摘要字段,避免逐条查询。 + */ + @Override + public List getSummariesByIds(List inboxIds) { + if (inboxIds == null || inboxIds.isEmpty()) { + return List.of(); + } + return inboxRepository.findByIds(inboxIds).stream() + .map(this::toSummary) + .toList(); + } + /** * 规范化页码,缺失或非法页码统一回到第一页。 */ @@ -94,21 +105,11 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService snapshot.externalConversationId(), snapshot.captureStatus(), snapshot.duplicatePayloadChanged(), - toOffsetDateTime(snapshot.receivedAt()), - toOffsetDateTime(snapshot.sourceSentAt()), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.receivedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.sourceSentAt()), snapshot.senderSummary(), snapshot.subject(), snapshot.safeSnippet() ); } - - /** - * 将数据库 UTC 时间转换为带 UTC offset 的 API 时间字段。 - */ - private OffsetDateTime toOffsetDateTime(LocalDateTime value) { - if (value == null) { - return null; - } - return value.atOffset(ZoneOffset.UTC); - } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java index 334a36c..d89982c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java @@ -1,7 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; /** @@ -68,7 +68,7 @@ public record ReservationAiCaseContextResult( @JsonProperty("source_table") String sourceTable, @JsonProperty("last_updated_at") - LocalDateTime lastUpdatedAt + OffsetDateTime lastUpdatedAt ) { } @@ -133,7 +133,7 @@ public record ReservationAiCaseContextResult( @JsonProperty("blocked_until_parent_completed") Boolean blockedUntilParentCompleted, @JsonProperty("last_updated_at") - LocalDateTime lastUpdatedAt + OffsetDateTime lastUpdatedAt ) { } @@ -161,9 +161,9 @@ public record ReservationAiCaseContextResult( String status, String reason, @JsonProperty("occurred_at") - LocalDateTime occurredAt, + OffsetDateTime occurredAt, @JsonProperty("last_updated_at") - LocalDateTime lastUpdatedAt + OffsetDateTime lastUpdatedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java index a894c94..7321ed1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java @@ -1,7 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; /** @@ -62,9 +62,9 @@ public record ReservationAiObjectDetailResult( @JsonProperty("created_from_task_id") String createdFromTaskId, @JsonProperty("created_at") - LocalDateTime createdAt, + OffsetDateTime createdAt, @JsonProperty("last_updated_at") - LocalDateTime lastUpdatedAt, + OffsetDateTime lastUpdatedAt, @JsonProperty("arrival_date") String arrivalDate, @JsonProperty("departure_date") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java index 892b902..64813e9 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; /** * 前端订单详情页的订单摘要。只包含本系统已有订单快照,不伪造 OPERA 投影字段。 @@ -37,8 +38,8 @@ public record ReservationOrderSummaryResult( @JsonProperty("display_name") String displayName, @JsonProperty("created_at") - String createdAt, + OffsetDateTime createdAt, @JsonProperty("updated_at") - String updatedAt + OffsetDateTime updatedAt ) { } 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 116770d..4a75cc2 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 @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; /** * 订单详情页任务时间线单项。用于前端按同订单任务顺序展示处理状态。 @@ -36,6 +37,6 @@ public record ReservationOrderTaskTimelineItemResult( @JsonProperty("readonly_reason_code") String readonlyReasonCode, @JsonProperty("created_at") - String createdAt + OffsetDateTime createdAt ) { } 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 e881374..f91d132 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 @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; /** * 前端任务列表 / 工作台单行结果。只返回摘要和可处理状态,不返回 AI 原始 payload。 @@ -55,8 +56,8 @@ public record ReservationTaskWorkbenchItemResult( @JsonProperty("source_subject") String sourceSubject, @JsonProperty("created_at") - String createdAt, + OffsetDateTime createdAt, @JsonProperty("updated_at") - String updatedAt + OffsetDateTime updatedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java index d3297ef..1be7281 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; @@ -148,8 +149,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService order.orderStatus(), idString(order.sourceMessageId()), idString(order.createdFromTaskId()), - order.createdAt(), - order.updatedAt(), + UtcTimeFormatter.toUtcOffsetDateTime(order.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()), null, null, null, @@ -180,7 +181,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService order.orderStatus(), order.businessKeySource(), SOURCE_TABLE_ORDER, - order.updatedAt()); + UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt())); } private ReservationAiCaseContextResult.PendingOrOpenTask toPendingOrOpenTask( @@ -204,7 +205,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService task.parentSourceEventIndex(), task.linkedTaskGroupId(), task.blockedUntilParentCompleted(), - task.updatedAt()); + UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt())); } private List terminatedRecords( @@ -218,8 +219,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService idString(order.id()), order.orderStatus(), order.logicDeletedReason(), - terminatedAt(order), - order.updatedAt())) + UtcTimeFormatter.toUtcOffsetDateTime(terminatedAt(order)), + UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt()))) .toList(); List taskRecords = tasks.stream() .filter(task -> TERMINATED_TASK_STATUSES.contains(task.taskStatus())) @@ -228,8 +229,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService idString(task.id()), task.taskStatus(), task.lastFailureReason(), - task.completedAt(), - task.updatedAt())) + UtcTimeFormatter.toUtcOffsetDateTime(task.completedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt()))) .toList(); return Stream.concat(orderRecords.stream(), taskRecords.stream()).toList(); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index 2a51725..b61b6be 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot; @@ -18,11 +19,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWork import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService; -import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Objects; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -75,8 +75,16 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Map ordersById = findOrdersById( normalizedRequest.hotelId(), page.items().stream().map(ReservationAiQueryTaskSnapshot::orderId).toList()); + Map availabilityByTaskId = findAvailabilityByTaskId( + normalizedRequest.hotelId(), + page.items()); + Map sourceSubjectsById = findSourceSubjectsById(page.items()); List items = page.items().stream() - .map(task -> toWorkbenchItem(task, ordersById.get(task.orderId()))) + .map(task -> toWorkbenchItem( + task, + ordersById.get(task.orderId()), + availabilityOrReadOnly(task, availabilityByTaskId), + sourceSubjectsById.get(task.sourceMessageId()))) .toList(); return new ReservationTaskWorkbenchListResult( items, @@ -100,12 +108,15 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "订单不存在。")); - List tasks = Boolean.FALSE.equals(includeTasks) + List taskSnapshots = Boolean.FALSE.equals(includeTasks) ? List.of() - : workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id())) - .stream() - .map(this::toTimelineItem) - .toList(); + : workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id())); + Map availabilityByTaskId = calculateAvailabilityByTaskId( + taskSnapshots, + taskSnapshots); + List tasks = taskSnapshots.stream() + .map(task -> toTimelineItem(task, availabilityOrReadOnly(task, availabilityByTaskId))) + .toList(); return new ReservationOrderDetailResult(toOrderSummary(order), tasks, List.of()); } @@ -147,6 +158,56 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return result; } + /** + * 批量计算任务可处理状态,列表页按订单一次性读取队列上下文,避免每条任务单独查询前置任务。 + */ + private Map findAvailabilityByTaskId( + String hotelId, + List targetTasks) { + if (targetTasks == null || targetTasks.isEmpty()) { + return Map.of(); + } + List orderIds = targetTasks.stream() + .map(ReservationAiQueryTaskSnapshot::orderId) + .filter(Objects::nonNull) + .distinct() + .toList(); + List queueContextTasks = workflowRepository.findAiQueryTasksByOrderIds( + hotelId, + orderIds); + return calculateAvailabilityByTaskId(targetTasks, queueContextTasks); + } + + /** + * 使用已加载的队列上下文批量计算可处理状态,供订单详情复用同一批任务数据。 + */ + private Map calculateAvailabilityByTaskId( + List targetTasks, + List queueContextTasks) { + return availabilityResolver.calculateAvailabilityByTaskId( + targetTasks.stream().map(this::toTaskSnapshot).toList(), + queueContextTasks.stream().map(this::toTaskSnapshot).toList()); + } + + /** + * 批量读取来源消息主题摘要,避免任务列表按每条任务调用 SourceMessage 详情查询。 + */ + private Map findSourceSubjectsById(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return Map.of(); + } + List sourceMessageIds = tasks.stream() + .map(ReservationAiQueryTaskSnapshot::sourceMessageId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map result = new LinkedHashMap<>(); + for (SourceMessageSummaryResponse summary : sourceMessageQueryService.getSummariesByIds(sourceMessageIds)) { + result.put(Long.valueOf(summary.id()), summary.subject()); + } + return result; + } + /** * 按来源消息安全摘要关键词查找 SourceMessage ID,避免任务列表直接读取来源消息正文。 */ @@ -162,8 +223,9 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ */ private ReservationTaskWorkbenchItemResult toWorkbenchItem( ReservationAiQueryTaskSnapshot task, - ReservationAiQueryOrderSnapshot order) { - ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(toTaskSnapshot(task)); + ReservationAiQueryOrderSnapshot order, + ReservationTaskAvailabilityResult availability, + String sourceSubject) { return new ReservationTaskWorkbenchItemResult( task.id().toString(), task.orderId().toString(), @@ -179,16 +241,17 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ canProcess(availability), readonlyReasonCode(task, availability), task.sourceMessageId().toString(), - sourceSubject(task.sourceMessageId()), - toIsoString(task.createdAt()), - toIsoString(task.updatedAt())); + sourceSubject, + UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(task.updatedAt())); } /** * 转换任务快照为订单详情任务时间线结果。 */ - private ReservationOrderTaskTimelineItemResult toTimelineItem(ReservationAiQueryTaskSnapshot task) { - ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(toTaskSnapshot(task)); + private ReservationOrderTaskTimelineItemResult toTimelineItem( + ReservationAiQueryTaskSnapshot task, + ReservationTaskAvailabilityResult availability) { return new ReservationOrderTaskTimelineItemResult( task.id().toString(), task.systemTaskType(), @@ -199,7 +262,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ task.queueParticipation(), canProcess(availability), readonlyReasonCode(task, availability), - toIsoString(task.createdAt())); + UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt())); } /** @@ -216,8 +279,8 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ null, null, order.displayName(), - toIsoString(order.createdAt()), - toIsoString(order.updatedAt())); + UtcTimeFormatter.toUtcOffsetDateTime(order.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(order.updatedAt())); } /** @@ -252,6 +315,19 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return availability.editable() || availability.confirmable() || availability.executable(); } + /** + * 兜底返回只读状态;正常情况下批量可处理状态结果应覆盖所有目标任务。 + */ + private ReservationTaskAvailabilityResult availabilityOrReadOnly( + ReservationAiQueryTaskSnapshot task, + Map availabilityByTaskId) { + ReservationTaskAvailabilityResult availability = availabilityByTaskId.get(task.id()); + if (availability != null) { + return availability; + } + return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null); + } + /** * 将实时可处理状态转换为前端稳定原因代码。 */ @@ -286,17 +362,6 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return orderBusinessKey == null ? order.temporaryOrderCode() : orderBusinessKey; } - /** - * 读取来源消息安全主题摘要,不返回邮件正文或附件 URL。 - */ - private String sourceSubject(Long sourceMessageId) { - if (sourceMessageId == null) { - return null; - } - Optional summary = sourceMessageQueryService.getSummary(sourceMessageId); - return summary.map(SourceMessageSummaryResponse::subject).orElse(null); - } - /** * 从订单快照中提取 Group Code。 */ @@ -352,10 +417,4 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ return value.trim(); } - /** - * 将数据库 UTC 时间转换为 ISO 字符串。 - */ - private String toIsoString(LocalDateTime value) { - return value == null ? null : value.toString(); - } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java index 91b672f..d148f4d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java @@ -4,6 +4,12 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapsho import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; import org.springframework.stereotype.Component; /** @@ -25,15 +31,55 @@ public class ReservationTaskAvailabilityResolver { * 计算任务可处理状态。FAILED 和 COMPLETED 视为前置任务结束,不阻塞后续任务。 */ public ReservationTaskAvailabilityResult calculateAvailability(ReservationTaskSnapshot task) { - if (!Boolean.TRUE.equals(task.queueParticipation())) { - return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null); - } ReservationTaskSnapshot blockingTask = workflowRepository .findQueueTasksBefore(task.hotelId(), task.orderId(), task.executionOrder()) .stream() .filter(this::isBlockingTask) .findFirst() .orElse(null); + return calculateAvailability(task, blockingTask); + } + + /** + * 批量计算任务可处理状态。调用方需传入目标任务及其同订单完整队列上下文,避免列表接口逐条查询数据库。 + */ + public Map calculateAvailabilityByTaskId( + List targetTasks, + List queueContextTasks) { + if (targetTasks == null || targetTasks.isEmpty()) { + return Map.of(); + } + Map> contextByOrderId = queueContextTasks == null + ? Map.of() + : queueContextTasks.stream() + .filter(task -> Boolean.TRUE.equals(task.queueParticipation())) + .collect(Collectors.groupingBy(ReservationTaskSnapshot::orderId)); + Map result = new LinkedHashMap<>(); + for (ReservationTaskSnapshot targetTask : targetTasks) { + ReservationTaskSnapshot blockingTask = contextByOrderId + .getOrDefault(targetTask.orderId(), List.of()) + .stream() + .filter(candidate -> isBefore(candidate, targetTask)) + .filter(this::isBlockingTask) + .sorted(Comparator + .comparing(ReservationTaskSnapshot::executionOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ReservationTaskSnapshot::id, Comparator.nullsLast(Long::compareTo))) + .findFirst() + .orElse(null); + result.put(targetTask.id(), calculateAvailability(targetTask, blockingTask)); + } + return result; + } + + /** + * 根据已知前置阻塞任务生成可处理状态,保证单任务和批量计算规则一致。 + */ + private ReservationTaskAvailabilityResult calculateAvailability( + ReservationTaskSnapshot task, + ReservationTaskSnapshot blockingTask) { + if (!Boolean.TRUE.equals(task.queueParticipation())) { + return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null); + } if (blockingTask != null) { return new ReservationTaskAvailabilityResult( true, @@ -49,6 +95,20 @@ public class ReservationTaskAvailabilityResolver { return new ReservationTaskAvailabilityResult(false, false, pendingConfirm, pendingConfirm, ready, null, null); } + /** + * 判断候选任务是否排在目标任务之前;同一执行序号不视为前置任务。 + */ + private boolean isBefore(ReservationTaskSnapshot candidate, ReservationTaskSnapshot targetTask) { + if (!Objects.equals(targetTask.hotelId(), candidate.hotelId()) + || !Objects.equals(targetTask.orderId(), candidate.orderId())) { + return false; + } + if (candidate.executionOrder() == null || targetTask.executionOrder() == null) { + return false; + } + return candidate.executionOrder() < targetTask.executionOrder(); + } + /** * 判断前置任务是否仍阻塞后续任务。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java index e95c456..1a7370c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.not; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -46,6 +47,7 @@ class ReservationAiQueryControllerTest { private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail"; private static final String CLIENT_ID = "superagent-test-client"; private static final String SECRET = "test-superagent-secret"; + private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"; @Autowired private MockMvc mockMvc; @@ -84,9 +86,13 @@ class ReservationAiQueryControllerTest { .andExpect(jsonPath("$.data.matched_order_records[0].object_type").value("group_block")) .andExpect(jsonPath("$.data.matched_order_records[0].order_id").value("920000000000000101")) .andExpect(jsonPath("$.data.matched_order_records[0].group_code").value("GRP-AIQUERY-001")) + .andExpect(jsonPath("$.data.matched_order_records[0].last_updated_at") + .value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_id").value("920000000000000301")) .andExpect(jsonPath("$.data.pending_or_open_tasks[0].source_event_index").value(1)) .andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_status").value("PENDING_CONFIRM")) + .andExpect(jsonPath("$.data.pending_or_open_tasks[0].last_updated_at") + .value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.data.active_workflows.length()").value(0)) .andExpect(jsonPath("$.data.terminated_records.length()").value(0)) .andExpect(jsonPath("$.data.target_object_validation.status").value("conflict")) @@ -275,6 +281,8 @@ class ReservationAiQueryControllerTest { .andExpect(jsonPath("$.data.block_id").value(nullValue())) .andExpect(jsonPath("$.data.room_items.length()").value(0)) .andExpect(jsonPath("$.data.rate_code_price").value(nullValue())) + .andExpect(jsonPath("$.data.created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.data.last_updated_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.data.can_update").value(true)) .andExpect(jsonPath("$.data.can_cancel").value(true)) .andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE")); 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 39dd4c0..d2bb120 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 @@ -1,6 +1,13 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.matchesPattern; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -9,6 +16,8 @@ import cn.nianxx.thhotel.ThHotelApplication; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; @@ -17,6 +26,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest(classes = ThHotelApplication.class) @@ -25,6 +35,7 @@ import org.springframework.test.web.servlet.MockMvc; class ReservationFrontendQueryControllerTest { private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"; @Autowired private MockMvc mockMvc; @@ -32,6 +43,12 @@ class ReservationFrontendQueryControllerTest { @Autowired private SourceMessageCaptureService captureService; + @MockitoSpyBean + private SourceMessageQueryService sourceMessageQueryService; + + @MockitoSpyBean + private ReservationAiWorkflowRepository workflowRepository; + @Autowired private JdbcTemplate jdbcTemplate; @@ -52,6 +69,8 @@ class ReservationFrontendQueryControllerTest { "NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1); insertTask(secondTaskId, orderId, source.inboxId(), 930000000000000202L, "Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2); + reset(sourceMessageQueryService); + reset(workflowRepository); mockMvc.perform(get("/api/reservation/tasks") .param("hotel_id", HOTEL_ID) @@ -71,6 +90,8 @@ 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].created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.items[0].updated_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.items[1].task_id").value(secondTaskId.toString())) .andExpect(jsonPath("$.items[1].task_type").value("UPDATE_BOOKING")) .andExpect(jsonPath("$.items[1].can_process").value(false)) @@ -88,6 +109,8 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query List Smoke")) .andExpect(jsonPath("$.items[1].source_subject").value("Frontend Query List Smoke")) .andExpect(jsonPath("$.page.total").value(2)); + verify(sourceMessageQueryService, never()).getSummary(anyLong()); + verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any()); } @Test @@ -107,6 +130,7 @@ class ReservationFrontendQueryControllerTest { "NEW_BOOKING", "NEW_BOOKING", "COMPLETED", 1); insertTask(secondTaskId, orderId, source.inboxId(), 930000000000000502L, "Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2); + reset(workflowRepository); mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId) .param("hotel_id", HOTEL_ID) @@ -120,14 +144,18 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.order.group_code").value("GRP-FRONTEND-DETAIL-001")) .andExpect(jsonPath("$.order.confirmation_number").value(nullValue())) .andExpect(jsonPath("$.order.display_name").value("GRP-FRONTEND-DETAIL-001")) + .andExpect(jsonPath("$.order.created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.order.updated_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.tasks[0].task_id").value(firstTaskId.toString())) .andExpect(jsonPath("$.tasks[0].task_status").value("COMPLETED")) .andExpect(jsonPath("$.tasks[0].readonly_reason_code").value("TASK_FINISHED")) + .andExpect(jsonPath("$.tasks[0].created_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.tasks[1].task_id").value(secondTaskId.toString())) .andExpect(jsonPath("$.tasks[1].task_status").value("PENDING_CONFIRM")) .andExpect(jsonPath("$.tasks[1].can_process").value(true)) .andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.warnings.length()").value(0)); + verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any()); mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId) .param("hotel_id", HOTEL_ID)