实现前端演示数据受控生成接口

This commit is contained in:
andy
2026-07-08 19:09:17 +08:00
parent 3c1649a567
commit c83935a26c
17 changed files with 1271 additions and 16 deletions

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation 前端演示数据生成请求。该请求只用于 dev/test 受控 seed不允许作为生产业务入口。
*
* @param hotelId 酒店上下文 ID为空时使用演示默认酒店
* @param runLabel 本次演示数据的人类可读标签,会参与生成可搜索的 demo_run_id
*/
public record ReservationDemoDataSeedRequest(
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("run_label")
String runLabel
) {
}

View File

@@ -0,0 +1,23 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation 演示数据中的订单摘要。用于前端快速跳转订单详情。
*
* @param scenarioCode 演示场景代码
* @param orderId 订单 ID
* @param orderStatus 订单状态
* @param displayOrderKey 前端展示订单号或临时订单号
*/
public record ReservationDemoDataOrderResult(
@JsonProperty("scenario_code")
String scenarioCode,
@JsonProperty("order_id")
String orderId,
@JsonProperty("order_status")
String orderStatus,
@JsonProperty("display_order_key")
String displayOrderKey
) {
}

View File

@@ -0,0 +1,30 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
/**
* Reservation 前端演示数据生成结果。返回本次 seed 的关键 ID 和可直接访问的后端查询入口。
*
* @param demoRunId 本次演示数据唯一运行 ID可作为 keyword 过滤
* @param hotelId 酒店上下文 ID
* @param sourceMessages 本次生成的来源消息列表
* @param orders 本次生成的订单列表
* @param tasks 本次生成的任务列表
* @param entrypoints 前端可直接调用的后端查询 URL
* @param notes 使用注意事项
*/
public record ReservationDemoDataSeedResult(
@JsonProperty("demo_run_id")
String demoRunId,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("source_messages")
List<ReservationDemoDataSourceMessageResult> sourceMessages,
List<ReservationDemoDataOrderResult> orders,
List<ReservationDemoDataTaskResult> tasks,
Map<String, String> entrypoints,
List<String> notes
) {
}

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation 演示数据中的来源消息摘要。ID 均以字符串返回,避免前端 Long 精度问题。
*
* @param scenarioCode 演示场景代码
* @param sourceMessageId SourceMessage Inbox 内部 ID
* @param externalMessageId 外部邮件消息 ID
* @param externalConversationId 外部邮件会话 ID
* @param subject 邮件主题
*/
public record ReservationDemoDataSourceMessageResult(
@JsonProperty("scenario_code")
String scenarioCode,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("external_message_id")
String externalMessageId,
@JsonProperty("external_conversation_id")
String externalConversationId,
String subject
) {
}

View File

@@ -0,0 +1,29 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation 演示数据中的任务摘要。用于前端快速跳转任务详情和验证状态展示。
*
* @param scenarioCode 演示场景代码
* @param taskId 任务 ID
* @param orderId 任务所属订单 ID
* @param taskType 系统主任务类型
* @param taskSubtype 任务 subtype
* @param taskStatus 任务状态
*/
public record ReservationDemoDataTaskResult(
@JsonProperty("scenario_code")
String scenarioCode,
@JsonProperty("task_id")
String taskId,
@JsonProperty("order_id")
String orderId,
@JsonProperty("task_type")
String taskType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("task_status")
String taskStatus
) {
}

View File

@@ -0,0 +1,42 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationDemoDataService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Reservation 演示数据 Controller。该入口只在 dev/test 显式开启时注册,用于前端联调造数。
*/
@RestController
@RequestMapping("/api/system/reservation/demo-data")
@ConditionalOnProperty(prefix = "reservation.demo-data", name = "enabled", havingValue = "true")
public class ReservationDemoDataController {
private final ReservationDemoDataService demoDataService;
/**
* 注入演示数据服务Controller 不直接访问持久化层。
*/
public ReservationDemoDataController(ReservationDemoDataService demoDataService) {
this.demoDataService = demoDataService;
}
/**
* 生成一批前端演示数据;调用方必须携带受控访问口令。
*/
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ReservationDemoDataSeedResult> seed(
@RequestHeader(name = "X-TH-Hotel-Demo-Data-Key", required = false) String accessKey,
@RequestBody(required = false) ReservationDemoDataSeedRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(demoDataService.seed(request, accessKey));
}
}

View File

@@ -11,7 +11,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
*/
@RestControllerAdvice(assignableTypes = {
ReservationTaskController.class,
ReservationFrontendQueryController.class
ReservationFrontendQueryController.class,
ReservationDemoDataController.class
})
public class ReservationTaskControllerAdvice {

View File

@@ -397,8 +397,10 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
@Override
public ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
ReservationOrderListQueryRequest request,
List<Long> keywordSourceMessageIds,
int pageNum,
int pageSize) {
List<Long> sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds;
Page<ReservationOrderEntity> page = orderMapper.selectPage(Page.of(pageNum, pageSize),
Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, request.hotelId())
@@ -426,7 +428,11 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.or()
.like(ReservationOrderEntity::getOrderStatus, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.keyword())))
.like(ReservationOrderEntity::getDisplayName, trim(request.keyword()))
.or(!sourceMessageIds.isEmpty())
.in(!sourceMessageIds.isEmpty(),
ReservationOrderEntity::getSourceMessageId,
sourceMessageIds))
.orderByDesc(ReservationOrderEntity::getUpdatedAt)
.orderByDesc(ReservationOrderEntity::getId));
return new ReservationPageSnapshot<>(

View File

@@ -118,10 +118,11 @@ public interface ReservationAiWorkflowRepository {
List<Long> sourceMessageIds);
/**
* 分页查询前端订单列表,默认包含全部订单状态。
* 分页查询前端订单列表,默认包含全部订单状态keyword 可匹配订单字段或来源消息安全摘要命中的 ID
*/
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> queryFrontendOrders(
ReservationOrderListQueryRequest request,
List<Long> keywordSourceMessageIds,
int pageNum,
int pageSize);

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult;
/**
* Reservation 前端演示数据服务。只在 dev/test 受控启用,用已有业务服务生成真实落库数据。
*/
public interface ReservationDemoDataService {
/**
* 生成一批前端演示数据,并返回订单、任务和来源消息关键 ID。
*/
ReservationDemoDataSeedResult seed(ReservationDemoDataSeedRequest request, String accessKey);
}

View File

@@ -0,0 +1,43 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Reservation 演示数据 seed 配置。默认关闭,访问口令只能通过环境变量或本地配置注入。
*/
@Component
@ConfigurationProperties(prefix = "reservation.demo-data")
public class ReservationDemoDataProperties {
/** 是否启用演示数据 seed 接口。 */
private boolean enabled = false;
/** 演示数据 seed 访问口令。 */
private String accessKey = "";
/** 演示数据默认酒店上下文。 */
private String defaultHotelId = "HOTEL-TEST";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getAccessKey() {
return accessKey;
}
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
}
public String getDefaultHotelId() {
return defaultHotelId;
}
public void setDefaultHotelId(String defaultHotelId) {
this.defaultHotelId = defaultHotelId;
}
}

View File

@@ -0,0 +1,758 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationDemoDataSeedRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataOrderResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSeedResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataSourceMessageResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationDemoDataTaskResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskPayloadMutationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultItemResponse;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationDemoDataService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation 演示数据服务实现。通过真实 SourceMessage、SuperAgent 入站、任务确认和 OPERA 模拟服务造数。
*/
@Service
public class ReservationDemoDataServiceImpl implements ReservationDemoDataService {
private static final String DEMO_CLIENT_ID = "reservation-demo-data-seed";
private static final String SOURCE_PROVIDER = "AGENTBUS";
private static final String SOURCE_CHANNEL = "EMAIL";
private static final String SOURCE_SCHEMA_VERSION = "agentbus-outlook-v1";
private static final String SCENARIO_QUEUE = "QUEUE_BLOCKED";
private static final String SCENARIO_QUEUE_REPLY = "QUEUE_CONVERSATION_REPLY";
private static final String SCENARIO_QUEUE_FIRST = "QUEUE_FIRST";
private static final String SCENARIO_COMPLETED = "OPERA_COMPLETED";
private static final String SCENARIO_FAILED = "OPERA_FAILED";
private static final String SCENARIO_FALLBACK = "FALLBACK_REVIEW";
private static final String SCENARIO_INFORMATIONAL = "MESSAGE_NOTIFICATION";
private static final DateTimeFormatter RUN_ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final ReservationDemoDataProperties properties;
private final ObjectMapper objectMapper;
private final SourceMessageCaptureService captureService;
private final ReservationAiTaskIntakeService intakeService;
private final ReservationTaskWorkflowService taskWorkflowService;
/**
* 注入演示配置、JSON 工具和已有业务服务,避免 seed 功能直接写表。
*/
public ReservationDemoDataServiceImpl(
ReservationDemoDataProperties properties,
ObjectMapper objectMapper,
SourceMessageCaptureService captureService,
ReservationAiTaskIntakeService intakeService,
ReservationTaskWorkflowService taskWorkflowService) {
this.properties = properties;
this.objectMapper = objectMapper;
this.captureService = captureService;
this.intakeService = intakeService;
this.taskWorkflowService = taskWorkflowService;
}
/**
* 生成前端 P0 页面可直接查看的一批演示数据。该方法默认事务回滚失败的半成品。
*/
@Override
@Transactional
public ReservationDemoDataSeedResult seed(ReservationDemoDataSeedRequest request, String accessKey) {
ensureAuthorized(accessKey);
String hotelId = normalizeHotelId(request == null ? null : request.hotelId());
String demoRunId = buildDemoRunId(request == null ? null : request.runLabel());
SeedAccumulator accumulator = new SeedAccumulator();
seedQueueBlockedScenario(hotelId, demoRunId, accumulator);
seedCompletedOperaScenario(hotelId, demoRunId, accumulator);
seedFailedOperaScenario(hotelId, demoRunId, accumulator);
seedFallbackScenario(hotelId, demoRunId, accumulator);
seedInformationalScenario(hotelId, demoRunId, accumulator);
return new ReservationDemoDataSeedResult(
demoRunId,
hotelId,
List.copyOf(accumulator.sourceMessages()),
List.copyOf(accumulator.orders()),
List.copyOf(accumulator.tasks()),
entrypoints(hotelId, demoRunId, accumulator),
notes());
}
/**
* 生成同订单两条任务,第一条未完成,第二条用于前端验证只读阻塞状态。
*/
private void seedQueueBlockedScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) {
String conversationId = code("thread-demo-queue", demoRunId);
String externalMessageId = code("demo-queue-main", demoRunId);
CapturedSourceMessage mainSource = captureSourceMessage(
hotelId,
externalMessageId,
conversationId,
"Demo " + demoRunId + " queue booking request",
"Please create group booking and then update the stay dates. Demo run " + demoRunId + ".",
"<html><body><p>Please create group booking and then update the stay dates.</p>"
+ "<img src=\"cid:demo-inline\" /></body></html>",
Instant.now().minusSeconds(900),
List.of(
new CaptureSourceMessageMedia(
SourceMessageMediaType.ATTACHMENT.code(),
"demo-rooming-list-" + demoRunId + ".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2048L,
"https://example.test/demo/" + demoRunId + "/rooming-list.xlsx",
code("att-rooming", demoRunId)),
new CaptureSourceMessageMedia(
SourceMessageMediaType.INLINE_IMAGE.code(),
"demo-inline-" + demoRunId + ".png",
"image/png",
1024L,
"https://example.test/demo/" + demoRunId + "/inline.png",
code("img-inline", demoRunId))));
CapturedSourceMessage replySource = captureSourceMessage(
hotelId,
code("demo-queue-reply", demoRunId),
conversationId,
"Re: Demo " + demoRunId + " queue booking request",
"Adding a note to the same conversation for demo run " + demoRunId + ".",
"<html><body><p>Adding a note to the same conversation.</p></body></html>",
Instant.now().minusSeconds(600),
List.of());
String groupCode = code("GRP-DEMO-QUEUE", demoRunId).toUpperCase(Locale.ROOT);
SuperAgentTaskResultResponse response = intake(
twoTaskBody(hotelId, mainSource.externalMessageId(), demoRunId, groupCode),
demoRunId,
SCENARIO_QUEUE);
SuperAgentTaskResultItemResponse first = response.items().get(0);
SuperAgentTaskResultItemResponse blocked = response.items().get(1);
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_QUEUE, mainSource));
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_QUEUE_REPLY, replySource));
accumulator.orders().add(orderResult(SCENARIO_QUEUE, first, groupCode));
accumulator.tasks().add(taskResult(SCENARIO_QUEUE_FIRST, first, "new_group_block"));
accumulator.tasks().add(taskResult(SCENARIO_QUEUE, blocked, "update_stay_dates"));
}
/**
* 生成已确认并两条 OPERA 模拟操作全部成功的任务,用于前端查看 completed 状态。
*/
private void seedCompletedOperaScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) {
CapturedSourceMessage source = captureSimpleSourceMessage(
hotelId,
SCENARIO_COMPLETED.toLowerCase(Locale.ROOT),
demoRunId,
"Demo " + demoRunId + " completed booking request");
String confirmationNumber = code("CNF-DEMO-DONE", demoRunId).toUpperCase(Locale.ROOT);
SuperAgentTaskResultResponse response = intake(
newBookingBody(hotelId, source.externalMessageId(), demoRunId, confirmationNumber),
demoRunId,
SCENARIO_COMPLETED);
SuperAgentTaskResultItemResponse item = response.items().get(0);
ReservationTaskPayloadMutationResult confirmed = confirmTask(item.taskId());
executeOperation(item.taskId(), confirmed.operaOperations().get(0).operationId(), true);
executeOperation(item.taskId(), confirmed.operaOperations().get(1).operationId(), true);
ReservationTaskDetailResult detail = taskWorkflowService.getTaskDetail(Long.valueOf(item.taskId()));
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_COMPLETED, source));
accumulator.orders().add(orderResult(SCENARIO_COMPLETED, item, confirmationNumber));
accumulator.tasks().add(taskResult(SCENARIO_COMPLETED, item, "new_fit_reservation", detail.taskStatus()));
}
/**
* 生成已确认但第一条 OPERA 模拟失败的任务,用于前端查看失败和重试入口。
*/
private void seedFailedOperaScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) {
CapturedSourceMessage source = captureSimpleSourceMessage(
hotelId,
SCENARIO_FAILED.toLowerCase(Locale.ROOT),
demoRunId,
"Demo " + demoRunId + " failed operation booking request");
String confirmationNumber = code("CNF-DEMO-FAIL", demoRunId).toUpperCase(Locale.ROOT);
SuperAgentTaskResultResponse response = intake(
newBookingBody(hotelId, source.externalMessageId(), demoRunId, confirmationNumber),
demoRunId,
SCENARIO_FAILED);
SuperAgentTaskResultItemResponse item = response.items().get(0);
ReservationTaskPayloadMutationResult confirmed = confirmTask(item.taskId());
executeOperation(item.taskId(), confirmed.operaOperations().get(0).operationId(), false);
ReservationTaskDetailResult detail = taskWorkflowService.getTaskDetail(Long.valueOf(item.taskId()));
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_FAILED, source));
accumulator.orders().add(orderResult(SCENARIO_FAILED, item, confirmationNumber));
accumulator.tasks().add(taskResult(SCENARIO_FAILED, item, "new_fit_reservation", detail.taskStatus()));
}
/**
* 生成 Fallback 人工复核任务,用于前端查看异常任务转换前状态。
*/
private void seedFallbackScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) {
CapturedSourceMessage source = captureSimpleSourceMessage(
hotelId,
SCENARIO_FALLBACK.toLowerCase(Locale.ROOT),
demoRunId,
"Demo " + demoRunId + " fallback review request");
SuperAgentTaskResultResponse response = intake(
fallbackBody(hotelId, source.externalMessageId(), demoRunId),
demoRunId,
SCENARIO_FALLBACK);
SuperAgentTaskResultItemResponse item = response.items().get(0);
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_FALLBACK, source));
accumulator.orders().add(orderResult(SCENARIO_FALLBACK, item, temporaryOrderCode(source)));
accumulator.tasks().add(taskResult(SCENARIO_FALLBACK, item, "manual_review"));
}
/**
* 生成 Message Notification 任务,用于前端验证非执行队列消息提醒。
*/
private void seedInformationalScenario(String hotelId, String demoRunId, SeedAccumulator accumulator) {
CapturedSourceMessage source = captureSimpleSourceMessage(
hotelId,
SCENARIO_INFORMATIONAL.toLowerCase(Locale.ROOT),
demoRunId,
"Demo " + demoRunId + " informational reply");
SuperAgentTaskResultResponse response = intake(
informationalBody(hotelId, source.externalMessageId(), demoRunId),
demoRunId,
SCENARIO_INFORMATIONAL);
SuperAgentTaskResultItemResponse item = response.items().get(0);
accumulator.sourceMessages().add(sourceMessageResult(SCENARIO_INFORMATIONAL, source));
accumulator.orders().add(orderResult(SCENARIO_INFORMATIONAL, item, temporaryOrderCode(source)));
accumulator.tasks().add(taskResult(SCENARIO_INFORMATIONAL, item, "thank_you"));
}
/**
* 校验 seed 开关和访问口令,口令比较使用常量时间比较,避免明显时序差异。
*/
private void ensureAuthorized(String accessKey) {
if (!properties.isEnabled()) {
throw error(HttpStatus.SERVICE_UNAVAILABLE, "DEMO_DATA_DISABLED", "演示数据 seed 接口未启用。");
}
String configuredKey = trimToNull(properties.getAccessKey());
if (configuredKey == null) {
throw error(HttpStatus.SERVICE_UNAVAILABLE, "DEMO_DATA_ACCESS_KEY_NOT_CONFIGURED", "演示数据访问口令未配置。");
}
if (!accessKeyMatches(configuredKey, accessKey)) {
throw error(HttpStatus.FORBIDDEN, "DEMO_DATA_ACCESS_DENIED", "演示数据访问被拒绝。");
}
}
/**
* 生成同订单两任务的 SuperAgent 入站请求体。
*/
private String twoTaskBody(String hotelId, String externalMessageId, String demoRunId, String groupCode) {
return taskResultBody(hotelId, externalMessageId, List.of(
taskItem(
1,
"S01",
"S01_new_booking_skill",
"normal_task",
"New Booking",
"new_group_block",
"AI extracted demo new group booking " + demoRunId + ".",
"Please create the group booking for " + demoRunId + ".",
Map.of("group_code", groupCode),
Map.of(
"booking_object_type", "Group Block",
"arrival_date", "2026-08-01",
"departure_date", "2026-08-04",
"room_quantity", 2,
"room_type", "Deluxe Twin",
"pms_room_type_code", "RM2",
"rate_code_result", Map.of(
"rate_code", "GRPA1-900",
"settlement_price", 900),
"fix_charge_required", false),
Map.of("demo_run_id", demoRunId)),
taskItem(
2,
"S02",
"S02_update_booking_amendment_skill",
"normal_task",
"Update Booking",
"update_stay_dates",
"AI extracted demo update booking " + demoRunId + ".",
"Please update the group booking for " + demoRunId + ".",
Map.of("group_code", groupCode),
Map.of(
"update_subtypes", List.of("update_stay_dates"),
"before_after", Map.of(
"arrival_date", Map.of("before", "2026-08-01", "after", "2026-08-02"),
"departure_date", Map.of("before", "2026-08-04", "after", "2026-08-05"))),
Map.of("demo_run_id", demoRunId))));
}
/**
* 生成 New Booking 单任务 SuperAgent 入站请求体。
*/
private String newBookingBody(
String hotelId,
String externalMessageId,
String demoRunId,
String confirmationNumber) {
return taskResultBody(hotelId, externalMessageId, List.of(taskItem(
1,
"S01",
"S01_new_booking_skill",
"normal_task",
"New Booking",
"new_fit_reservation",
"AI extracted demo FIT booking " + demoRunId + ".",
"Please create the FIT booking for " + demoRunId + ".",
Map.of("confirmation_number", confirmationNumber),
Map.of(
"booking_object_type", "FIT Reservation",
"arrival_date", "2026-08-10",
"departure_date", "2026-08-12",
"room_quantity", 1,
"room_type", "Deluxe King",
"pms_room_type_code", "RM2",
"rate_code_result", Map.of(
"rate_code", "GRPA1-900",
"settlement_price", 900),
"fix_charge_required", false),
Map.of("demo_run_id", demoRunId))));
}
/**
* 生成 Fallback 人工复核 SuperAgent 入站请求体。
*/
private String fallbackBody(String hotelId, String externalMessageId, String demoRunId) {
Map<String, Object> item = taskItem(
1,
"S99",
"S99_fallback_manual_review_skill",
"manual_review",
"Fallback",
"manual_review",
"AI could not classify demo message " + demoRunId + ".",
"Please review ambiguous booking message for " + demoRunId + ".",
Map.of("confirmation_number", code("CNF-DEMO-FB", demoRunId).toUpperCase(Locale.ROOT)),
Map.of(
"booking_object_type", "FIT Reservation",
"arrival_date", "2026-09-01",
"departure_date", "2026-09-03"),
Map.of("demo_run_id", demoRunId));
item.put("manual_review", Map.of(
"reason_code", "ambiguous_task_type",
"reason_text", "Demo fallback needs human classification.",
"candidate_task_types", List.of("New Booking", "Update Booking", "Cancel Booking")));
return taskResultBody(hotelId, externalMessageId, List.of(item));
}
/**
* 生成 Message Notification SuperAgent 入站请求体。
*/
private String informationalBody(String hotelId, String externalMessageId, String demoRunId) {
Map<String, Object> item = taskItem(
1,
"S98",
"S98_message_notification_skill",
"informational_message",
"Message Notification",
"thank_you",
"AI extracted demo informational message " + demoRunId + ".",
"Guest replied thanks for " + demoRunId + ".",
Map.of(),
Map.of(),
Map.of("demo_run_id", demoRunId));
item.put("informational_message", Map.of(
"category", "guest_reply",
"summary", "Guest acknowledged the reservation update.",
"severity", "low"));
return taskResultBody(hotelId, externalMessageId, List.of(item));
}
/**
* 组装 SuperAgent 顶层请求体。
*/
private String taskResultBody(String hotelId, String externalMessageId, List<Map<String, Object>> items) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("hotel_id", hotelId);
root.put("source_message_id", externalMessageId);
root.put("ai_task_results", items);
root.put("extraction_warnings", List.of());
return toJson(root);
}
/**
* 组装单条 ai_task_results item保留 SuperAgent 已稳定字段。
*/
private Map<String, Object> taskItem(
int sourceEventIndex,
String catalogCode,
String skillId,
String resultType,
String taskType,
String taskSubtype,
String visibleReason,
String relevantMessageExcerpt,
Map<String, Object> caseKeys,
Map<String, Object> extractedFields,
Map<String, Object> contextUsed) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("source_event_index", sourceEventIndex);
item.put("catalog_code", catalogCode);
item.put("skill_id", skillId);
item.put("result_type", resultType);
item.put("task_type", taskType);
item.put("task_subtype", taskSubtype);
item.put("current_or_history", "current");
item.put("visible_reason", visibleReason);
item.put("relevant_message_excerpt", relevantMessageExcerpt);
item.put("attachments", List.of());
item.put("file_references", List.of());
item.put("context_used", contextUsed);
item.put("case_keys", caseKeys);
item.put("extracted_fields", extractedFields);
item.put("additional_operations", List.of());
item.put("idempotency_key", null);
return item;
}
/**
* 捕获一封简单演示邮件。
*/
private CapturedSourceMessage captureSimpleSourceMessage(
String hotelId,
String scenarioCode,
String demoRunId,
String subject) {
return captureSourceMessage(
hotelId,
code("demo-" + scenarioCode, demoRunId),
code("thread-demo-" + scenarioCode, demoRunId),
subject,
"Demo mail for " + demoRunId + " scenario " + scenarioCode + ".",
"<html><body><p>Demo mail for " + demoRunId + " scenario " + scenarioCode + ".</p></body></html>",
Instant.now().minusSeconds(300),
List.of());
}
/**
* 调用 SourceMessage 捕获服务落库演示邮件。
*/
private CapturedSourceMessage captureSourceMessage(
String hotelId,
String externalMessageId,
String externalConversationId,
String subject,
String textBody,
String htmlBody,
Instant sourceSentAt,
List<CaptureSourceMessageMedia> mediaItems) {
SourceMessageCaptureResult result = captureService.capture(new CaptureSourceMessageCommand(
hotelId,
SOURCE_PROVIDER,
SOURCE_CHANNEL,
externalMessageId,
externalConversationId,
code("frame", externalMessageId),
code("session-demo-data", hotelId),
sourceSentAt,
"demo.guest@example.test",
subject,
textBody,
htmlBody,
toJson(Map.of(
"demo_run_id", externalMessageId,
"source", Map.of("external_message_id", externalMessageId))),
SOURCE_SCHEMA_VERSION,
mediaItems));
if (!"RECEIVED".equals(result.captureStatus())) {
throw error(HttpStatus.CONFLICT, "DEMO_SOURCE_MESSAGE_CAPTURE_FAILED", "演示来源消息入库失败。");
}
return new CapturedSourceMessage(result.inboxId(), externalMessageId, externalConversationId, subject);
}
/**
* 调用 AI 入站服务创建订单、任务和任务卡。
*/
private SuperAgentTaskResultResponse intake(String rawBody, String demoRunId, String scenarioCode) {
SuperAgentTaskResultResponse response = intakeService.accept(
rawBody,
DEMO_CLIENT_ID,
"demo-data-" + scenarioCode.toLowerCase(Locale.ROOT) + "-" + demoRunId);
if (response.items().isEmpty()) {
throw error(HttpStatus.CONFLICT, "DEMO_INTAKE_EMPTY", "演示 AI 入站未创建任务。");
}
return response;
}
/**
* 确认任务并生成固定两条 OPERA 模拟操作。
*/
private ReservationTaskPayloadMutationResult confirmTask(String taskId) {
return taskWorkflowService.confirmTask(
Long.valueOf(taskId),
new ReservationTaskPayloadMutationRequest(Map.of()));
}
/**
* 执行一条 OPERA 模拟操作,成功或失败由调用方指定。
*/
private ReservationOperaOperationResult executeOperation(String taskId, String operationId, boolean success) {
return taskWorkflowService.executeOperaOperation(
Long.valueOf(taskId),
Long.valueOf(operationId),
new ReservationOperaSimulationRequest(success, success ? null : "Demo OPERA failure for retry."));
}
/**
* 转换来源消息为 seed 响应对象。
*/
private ReservationDemoDataSourceMessageResult sourceMessageResult(
String scenarioCode,
CapturedSourceMessage source) {
return new ReservationDemoDataSourceMessageResult(
scenarioCode,
source.sourceMessageId().toString(),
source.externalMessageId(),
source.externalConversationId(),
source.subject());
}
/**
* 转换订单为 seed 响应对象。
*/
private ReservationDemoDataOrderResult orderResult(
String scenarioCode,
SuperAgentTaskResultItemResponse item,
String displayOrderKey) {
return new ReservationDemoDataOrderResult(
scenarioCode,
item.orderId(),
item.orderStatus(),
displayOrderKey);
}
/**
* 转换任务为 seed 响应对象,使用 AI 入站后的初始状态。
*/
private ReservationDemoDataTaskResult taskResult(
String scenarioCode,
SuperAgentTaskResultItemResponse item,
String taskSubtype) {
return taskResult(scenarioCode, item, taskSubtype, item.taskStatus());
}
/**
* 转换任务为 seed 响应对象,允许覆盖任务后续状态。
*/
private ReservationDemoDataTaskResult taskResult(
String scenarioCode,
SuperAgentTaskResultItemResponse item,
String taskSubtype,
String taskStatus) {
return new ReservationDemoDataTaskResult(
scenarioCode,
item.taskId(),
item.orderId(),
item.systemTaskType(),
taskSubtype,
taskStatus);
}
/**
* 生成前端可直接调用的查询入口。
*/
private Map<String, String> entrypoints(String hotelId, String demoRunId, SeedAccumulator accumulator) {
String encodedHotelId = urlEncode(hotelId);
String encodedRunId = urlEncode(demoRunId);
String queueOrderId = accumulator.orders().stream()
.filter(order -> SCENARIO_QUEUE.equals(order.scenarioCode()))
.map(ReservationDemoDataOrderResult::orderId)
.findFirst()
.orElse("");
String queueSourceMessageId = accumulator.sourceMessages().stream()
.filter(source -> SCENARIO_QUEUE.equals(source.scenarioCode()))
.map(ReservationDemoDataSourceMessageResult::sourceMessageId)
.findFirst()
.orElse("");
String failedTaskId = accumulator.tasks().stream()
.filter(task -> SCENARIO_FAILED.equals(task.scenarioCode()))
.map(ReservationDemoDataTaskResult::taskId)
.findFirst()
.orElse("");
Map<String, String> entrypoints = new LinkedHashMap<>();
entrypoints.put("task_list_url", "/api/reservation/tasks?hotel_id=" + encodedHotelId
+ "&keyword=" + encodedRunId + "&page_num=1&page_size=20");
entrypoints.put("order_list_url", "/api/reservation/orders?hotel_id=" + encodedHotelId
+ "&keyword=" + encodedRunId + "&page_num=1&page_size=20");
entrypoints.put("queue_order_detail_url", "/api/reservation/orders/" + queueOrderId
+ "?hotel_id=" + encodedHotelId + "&include_tasks=true&include_source_summary=true");
entrypoints.put("failed_task_detail_url", "/api/reservation/tasks/" + failedTaskId);
entrypoints.put("source_conversation_url", "/api/source-messages/" + queueSourceMessageId + "/conversation");
return entrypoints;
}
/**
* 返回演示数据使用注意事项。
*/
private List<String> notes() {
return List.of(
"该接口默认关闭,仅用于 dev/test 前端联调造数。",
"演示数据通过真实业务服务写入,可用于任务列表、订单列表、订单详情、任务详情和邮件会话详情。",
"OPERA 仍为模拟骨架,失败场景只用于验证前端重试入口。");
}
/**
* 生成安全短 run id用于业务号、邮件主题和 keyword 查询。
*/
private String buildDemoRunId(String rawRunLabel) {
String label = sanitizeCode(trimToNull(rawRunLabel) == null ? "demo" : rawRunLabel);
String time = LocalDateTime.now(ZoneOffset.UTC).format(RUN_ID_TIME_FORMATTER);
String suffix = UUID.randomUUID().toString().substring(0, 8);
return label + "-" + time + "-" + suffix;
}
/**
* 标准化酒店上下文 ID。
*/
private String normalizeHotelId(String rawHotelId) {
String hotelId = trimToNull(rawHotelId);
return hotelId == null ? properties.getDefaultHotelId() : hotelId;
}
/**
* 拼接稳定代码,保持长度适合外部消息 ID 和业务号字段。
*/
private String code(String prefix, String suffix) {
return prefix + "-" + sanitizeCode(suffix);
}
/**
* 仅保留适合业务号和消息 ID 的字符,并限制长度。
*/
private String sanitizeCode(String value) {
String sanitized = value == null
? "demo"
: value.trim().replaceAll("[^A-Za-z0-9_-]", "-").replaceAll("-+", "-");
if (sanitized.isBlank()) {
return "demo";
}
return sanitized.length() > 48 ? sanitized.substring(0, 48) : sanitized;
}
/**
* 访问口令常量时间比较。
*/
private boolean accessKeyMatches(String configuredKey, String providedKey) {
if (providedKey == null) {
return false;
}
return MessageDigest.isEqual(
sha256Bytes(configuredKey),
sha256Bytes(providedKey));
}
/**
* 计算访问口令摘要,用固定长度字节数组做常量时间比较。
*/
private byte[] sha256Bytes(String value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception);
}
}
/**
* 生成与订单创建逻辑一致的临时订单展示号。
*/
private String temporaryOrderCode(CapturedSourceMessage source) {
return "TMP-SM-" + source.sourceMessageId() + "-1";
}
/**
* JSON 序列化工具,避免手工拼接 SuperAgent 请求体。
*/
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("演示数据 JSON 构建失败。", exception);
}
}
/**
* URL 参数编码。
*/
private String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
/**
* 清理空白字符串。
*/
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
/**
* 构建受控业务异常。
*/
private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) {
return new ReservationTaskWorkflowException(status, errorCode, message);
}
/**
* seed 过程中的来源消息引用。
*/
private record CapturedSourceMessage(
Long sourceMessageId,
String externalMessageId,
String externalConversationId,
String subject
) {
}
/**
* seed 过程中的响应累加器,保持返回顺序稳定。
*/
private record SeedAccumulator(
List<ReservationDemoDataSourceMessageResult> sourceMessages,
List<ReservationDemoDataOrderResult> orders,
List<ReservationDemoDataTaskResult> tasks
) {
private SeedAccumulator() {
this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
}
}

View File

@@ -106,8 +106,11 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
ReservationOrderListQueryRequest normalizedRequest = normalizeOrderListRequest(request);
int pageNum = normalizePageNum(normalizedRequest.pageNum());
int pageSize = normalizePageSize(normalizedRequest.pageSize());
List<Long> keywordSourceMessageIds = findSourceMessageIdsByKeyword(
normalizedRequest.hotelId(),
normalizedRequest.keyword());
ReservationPageSnapshot<ReservationAiQueryOrderSnapshot> page =
workflowRepository.queryFrontendOrders(normalizedRequest, pageNum, pageSize);
workflowRepository.queryFrontendOrders(normalizedRequest, keywordSourceMessageIds, pageNum, pageSize);
List<Long> orderIds = page.items().stream().map(ReservationAiQueryOrderSnapshot::id).toList();
List<ReservationAiQueryTaskSnapshot> orderTasks = workflowRepository.findAiQueryTasksByOrderIds(
normalizedRequest.hotelId(),