统一系统时间输出并优化任务列表查询
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<SourceMessageInboxSnapshot> findByIds(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> safeIds = ids.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (safeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return inboxMapper.selectList(Wrappers.<SourceMessageInboxEntity>lambdaQuery()
|
||||
.in(SourceMessageInboxEntity::getId, safeIds))
|
||||
.stream()
|
||||
.map(this::toSnapshot)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 SourceMessage 幂等键读取已有记录,用于重复投递判断。
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,11 @@ public interface SourceMessageInboxRepository {
|
||||
*/
|
||||
Optional<SourceMessageInboxSnapshot> findById(Long id);
|
||||
|
||||
/**
|
||||
* 按内部 SourceMessage ID 批量查询 Inbox 安全快照,供列表类接口预取摘要。
|
||||
*/
|
||||
List<SourceMessageInboxSnapshot> findByIds(List<Long> ids);
|
||||
|
||||
/**
|
||||
* 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。
|
||||
*/
|
||||
|
||||
@@ -35,4 +35,12 @@ public interface SourceMessageQueryService {
|
||||
* @return 存在时返回安全摘要,不存在时为空
|
||||
*/
|
||||
Optional<SourceMessageSummaryResponse> getSummary(Long inboxId);
|
||||
|
||||
/**
|
||||
* 按内部 SourceMessage ID 批量读取安全摘要,供业务列表避免逐条查询。
|
||||
*
|
||||
* @param inboxIds 内部 SourceMessage Inbox ID 列表
|
||||
* @return 匹配到的安全摘要列表,不返回正文、HTML、附件 URL 或 payload
|
||||
*/
|
||||
List<SourceMessageSummaryResponse> getSummariesByIds(List<Long> inboxIds);
|
||||
}
|
||||
|
||||
@@ -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<SourceMessageSummaryResponse> getSummariesByIds(List<Long> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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<ReservationAiCaseContextResult.TerminatedRecord> 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<ReservationAiCaseContextResult.TerminatedRecord> 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();
|
||||
}
|
||||
|
||||
@@ -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<Long, ReservationAiQueryOrderSnapshot> ordersById = findOrdersById(
|
||||
normalizedRequest.hotelId(),
|
||||
page.items().stream().map(ReservationAiQueryTaskSnapshot::orderId).toList());
|
||||
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId = findAvailabilityByTaskId(
|
||||
normalizedRequest.hotelId(),
|
||||
page.items());
|
||||
Map<Long, String> sourceSubjectsById = findSourceSubjectsById(page.items());
|
||||
List<ReservationTaskWorkbenchItemResult> 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<ReservationOrderTaskTimelineItemResult> tasks = Boolean.FALSE.equals(includeTasks)
|
||||
List<ReservationAiQueryTaskSnapshot> 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<Long, ReservationTaskAvailabilityResult> availabilityByTaskId = calculateAvailabilityByTaskId(
|
||||
taskSnapshots,
|
||||
taskSnapshots);
|
||||
List<ReservationOrderTaskTimelineItemResult> 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<Long, ReservationTaskAvailabilityResult> findAvailabilityByTaskId(
|
||||
String hotelId,
|
||||
List<ReservationAiQueryTaskSnapshot> targetTasks) {
|
||||
if (targetTasks == null || targetTasks.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> orderIds = targetTasks.stream()
|
||||
.map(ReservationAiQueryTaskSnapshot::orderId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<ReservationAiQueryTaskSnapshot> queueContextTasks = workflowRepository.findAiQueryTasksByOrderIds(
|
||||
hotelId,
|
||||
orderIds);
|
||||
return calculateAvailabilityByTaskId(targetTasks, queueContextTasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用已加载的队列上下文批量计算可处理状态,供订单详情复用同一批任务数据。
|
||||
*/
|
||||
private Map<Long, ReservationTaskAvailabilityResult> calculateAvailabilityByTaskId(
|
||||
List<ReservationAiQueryTaskSnapshot> targetTasks,
|
||||
List<ReservationAiQueryTaskSnapshot> queueContextTasks) {
|
||||
return availabilityResolver.calculateAvailabilityByTaskId(
|
||||
targetTasks.stream().map(this::toTaskSnapshot).toList(),
|
||||
queueContextTasks.stream().map(this::toTaskSnapshot).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取来源消息主题摘要,避免任务列表按每条任务调用 SourceMessage 详情查询。
|
||||
*/
|
||||
private Map<Long, String> findSourceSubjectsById(List<ReservationAiQueryTaskSnapshot> tasks) {
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> sourceMessageIds = tasks.stream()
|
||||
.map(ReservationAiQueryTaskSnapshot::sourceMessageId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, String> 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<Long, ReservationTaskAvailabilityResult> 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<SourceMessageSummaryResponse> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long, ReservationTaskAvailabilityResult> calculateAvailabilityByTaskId(
|
||||
List<ReservationTaskSnapshot> targetTasks,
|
||||
List<ReservationTaskSnapshot> queueContextTasks) {
|
||||
if (targetTasks == null || targetTasks.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, List<ReservationTaskSnapshot>> contextByOrderId = queueContextTasks == null
|
||||
? Map.of()
|
||||
: queueContextTasks.stream()
|
||||
.filter(task -> Boolean.TRUE.equals(task.queueParticipation()))
|
||||
.collect(Collectors.groupingBy(ReservationTaskSnapshot::orderId));
|
||||
Map<Long, ReservationTaskAvailabilityResult> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断前置任务是否仍阻塞后续任务。
|
||||
*/
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user