统一系统时间输出并优化任务列表查询

This commit is contained in:
andy
2026-07-08 15:15:42 +08:00
parent 936a5921b0
commit 8a196f14ed
20 changed files with 308 additions and 79 deletions

View File

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

View File

@@ -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 幂等键读取已有记录,用于重复投递判断。
*/

View File

@@ -20,6 +20,11 @@ public interface SourceMessageInboxRepository {
*/
Optional<SourceMessageInboxSnapshot> findById(Long id);
/**
* 按内部 SourceMessage ID 批量查询 Inbox 安全快照,供列表类接口预取摘要。
*/
List<SourceMessageInboxSnapshot> findByIds(List<Long> ids);
/**
* 按酒店、来源、渠道、外部邮件 ID 查询幂等记录。
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
/**
* 判断前置任务是否仍阻塞后续任务。
*/

View File

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

View File

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