实现酒店上下文单酒店收口

This commit is contained in:
andy
2026-07-10 15:31:51 +08:00
parent 7492c21350
commit d69f3975ef
52 changed files with 1183 additions and 188 deletions

View File

@@ -4,7 +4,7 @@ import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTas
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
import cn.nianxx.thhotel.integrations.messaging.agentbus.adapter.AgentBusProperties;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import java.nio.charset.StandardCharsets;
@@ -29,7 +29,7 @@ public class SuperAgentTaskResultController {
private final SuperAgentTaskResultSecurityService securityService;
private final SuperAgentTaskResultProperties properties;
private final AgentBusProperties agentBusProperties;
private final HotelContextService hotelContextService;
private final ReservationAiTaskIntakeService intakeService;
/**
@@ -38,11 +38,11 @@ public class SuperAgentTaskResultController {
public SuperAgentTaskResultController(
SuperAgentTaskResultSecurityService securityService,
SuperAgentTaskResultProperties properties,
AgentBusProperties agentBusProperties,
HotelContextService hotelContextService,
ReservationAiTaskIntakeService intakeService) {
this.securityService = securityService;
this.properties = properties;
this.agentBusProperties = agentBusProperties;
this.hotelContextService = hotelContextService;
this.intakeService = intakeService;
}
@@ -74,7 +74,7 @@ public class SuperAgentTaskResultController {
requestBody,
clientId,
requestId,
agentBusProperties.getCapture().getDefaultHotelId());
hotelContextService.resolveSystemHotelId());
HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED;
return ResponseEntity.status(status).body(response);
}

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.integrations.ai.superagent.control;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException;
import java.util.List;
import org.springframework.http.ResponseEntity;
@@ -34,6 +35,16 @@ public class SuperAgentTaskResultControllerAdvice {
.body(error(exception.getErrorCode(), exception.getMessage()));
}
/**
* 处理系统酒店缺失、多 ACTIVE 酒店或酒店访问受限等上下文异常。
*/
@ExceptionHandler(HotelContextException.class)
public ResponseEntity<SuperAgentTaskResultErrorResponse> handleHotelContextException(
HotelContextException exception) {
return ResponseEntity.status(exception.getStatus())
.body(error(exception.getErrorCode(), exception.getMessage()));
}
/**
* 构建统一错误响应,第一版不回显 request_id避免异常路径暴露未经校验的外部输入。
*/

View File

@@ -8,6 +8,8 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcp
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolDefinition;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult;
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest;
@@ -48,6 +50,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
private final ReservationAiTaskIntakeService intakeService;
private final SuperAgentMcpProperties properties;
private final ObjectMapper objectMapper;
private final HotelContextService hotelContextService;
/**
* 注入已有业务服务和 JSON 工具MCP 层不直接访问 Mapper 或数据库。
@@ -56,11 +59,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
ReservationAiQueryService aiQueryService,
ReservationAiTaskIntakeService intakeService,
SuperAgentMcpProperties properties,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
HotelContextService hotelContextService) {
this.aiQueryService = aiQueryService;
this.intakeService = intakeService;
this.properties = properties;
this.objectMapper = objectMapper;
this.hotelContextService = hotelContextService;
}
/**
@@ -154,6 +159,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
exception.getErrorCode(),
exception.getMessage(),
Map.of("http_status", exception.getStatus().value())));
} catch (HotelContextException exception) {
return SuperAgentMcpToolCallResult.error(
"TH Hotel 酒店上下文解析失败:" + exception.getMessage(),
errorStructuredContent(
exception.getErrorCode(),
exception.getMessage(),
Map.of("http_status", exception.getStatus().value())));
} catch (IllegalArgumentException exception) {
return SuperAgentMcpToolCallResult.error(
"MCP 工具参数不合法。",
@@ -238,7 +250,11 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
Map.of("tool", TOOL_SUBMIT_TASK_RESULTS)));
}
String rawBody = objectMapper.writeValueAsString(arguments);
SuperAgentTaskResultResponse response = intakeService.accept(rawBody, MCP_CLIENT_ID, null);
SuperAgentTaskResultResponse response = intakeService.accept(
rawBody,
MCP_CLIENT_ID,
null,
hotelContextService.resolveSystemHotelId());
return SuperAgentMcpToolCallResult.success(TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", response);
}
@@ -311,37 +327,37 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
private Map<String, Object> caseContextSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("hotel_id", stringField("酒店上下文 ID"));
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
propertiesMap.put("group_code", nullableStringField("Group / Allotment 查询 key"));
propertiesMap.put("confirmation_number", nullableStringField("FIT confirmation number 查询 key"));
propertiesMap.put("reservation_no", nullableStringField("OPERA reservation no"));
propertiesMap.put("object_type_hint", nullableStringField("调用方推测的对象类型"));
propertiesMap.put("target_key_source", nullableStringField("key 来源,例如 body_current"));
propertiesMap.put("body_thread_used_only_as_evidence", Map.of("type", "boolean", "description", "历史线程 key 是否仅作为证据"));
return objectSchema(propertiesMap, List.of("hotel_id"));
return objectSchema(propertiesMap, List.of());
}
private Map<String, Object> objectDetailSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("hotel_id", stringField("酒店上下文 ID"));
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
propertiesMap.put("object_id", stringField("查询对象 ID第一版支持 ORDER:{order_id}"));
propertiesMap.put("object_type", nullableStringField("调用方对象类型提示"));
return objectSchema(propertiesMap, List.of("hotel_id", "object_id"));
return objectSchema(propertiesMap, List.of("object_id"));
}
private Map<String, Object> conversationQuerySchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("hotel_id", stringField("酒店上下文 ID"));
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS"));
propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL"));
propertiesMap.put("external_conversation_id", nullableStringField("外部邮件会话 ID"));
propertiesMap.put("source_message_id", nullableStringField("外部来源消息 ID可作为锚点反查会话"));
return objectSchema(propertiesMap, List.of("hotel_id"));
return objectSchema(propertiesMap, List.of());
}
private Map<String, Object> submitTaskResultsSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("hotel_id", stringField("酒店上下文 ID"));
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店"));
propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS"));
propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL"));
propertiesMap.put("source_message_id", stringField("外部来源消息 ID对应 AgentBus source.external_message_id"));
@@ -353,7 +369,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
"type", "array",
"description", "AI 抽取警告",
"items", Map.of("type", "object")));
return objectSchema(propertiesMap, List.of("hotel_id", "source_message_id", "ai_task_results"));
return objectSchema(propertiesMap, List.of("source_message_id", "ai_task_results"));
}
private Map<String, Object> objectSchema(Map<String, Object> propertiesMap, List<String> required) {

View File

@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter;
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.hotel.service.HotelContextService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -26,6 +27,7 @@ public class AgentBusFrameProcessor {
private final SourceMessageCaptureService captureService;
private final AgentBusConnectionStatus status;
private final AgentBusProperties properties;
private final HotelContextService hotelContextService;
/**
* 注入 AgentBus frame 处理依赖。外部协议转换与平台捕获服务通过稳定命令隔离。
@@ -35,12 +37,14 @@ public class AgentBusFrameProcessor {
AgentBusSourceMessageAdapter sourceMessageAdapter,
SourceMessageCaptureService captureService,
AgentBusConnectionStatus status,
AgentBusProperties properties) {
AgentBusProperties properties,
HotelContextService hotelContextService) {
this.objectMapper = objectMapper;
this.sourceMessageAdapter = sourceMessageAdapter;
this.captureService = captureService;
this.status = status;
this.properties = properties;
this.hotelContextService = hotelContextService;
}
/**
@@ -69,8 +73,9 @@ public class AgentBusFrameProcessor {
return AgentBusFrameProcessResult.ignored();
}
try {
String hotelId = hotelContextService.resolveSystemHotelId();
CaptureSourceMessageCommand command = sourceMessageAdapter.toCaptureCommand(
properties.getCapture().getDefaultHotelId(),
hotelId,
frame);
SourceMessageCaptureResult result = captureService.capture(command);
status.markFrameCaptured();

View File

@@ -123,8 +123,6 @@ public class AgentBusProperties {
/** 是否把业务 frame 写入 SourceMessage Inbox。 */
private boolean enabled = true;
/** AgentBus 未提供酒店上下文时使用的默认酒店 ID。 */
private String defaultHotelId = "HOTEL-TEST";
public boolean isEnabled() {
return enabled;
@@ -133,13 +131,5 @@ public class AgentBusProperties {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getDefaultHotelId() {
return defaultHotelId;
}
public void setDefaultHotelId(String defaultHotelId) {
this.defaultHotelId = defaultHotelId;
}
}
}

View File

@@ -37,7 +37,7 @@ public class DebugEmlSuperAgentController {
public ResponseEntity<DebugEmlSuperAgentRunResult> upload(
@RequestHeader(name = "X-TH-Hotel-Debug-Upload-Key", required = false) String accessKey,
@RequestParam("file") MultipartFile file,
@RequestParam("hotel_id") String hotelId,
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "run_label", required = false) String runLabel) {
return ResponseEntity.status(HttpStatus.CREATED).body(runService.uploadAndRun(accessKey, file, hotelId, runLabel));
}

View File

@@ -18,6 +18,8 @@ import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlSuperAgentRunResul
import cn.nianxx.thhotel.platform.debug.common.result.DebugEmlUploadedMediaResult;
import cn.nianxx.thhotel.platform.debug.repository.DebugEmlSuperAgentRunRepository;
import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem;
import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType;
@@ -76,6 +78,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
private final SuperAgentOpenApiClient superAgentOpenApiClient;
private final DebugEmlSuperAgentRunRepository runRepository;
private final ObjectMapper objectMapper;
private final HotelContextService hotelContextService;
/**
* 注入 Debug EML 所需的内部服务和外部端口。
@@ -89,7 +92,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
SourceMessageHtmlSanitizerService htmlSanitizerService,
SuperAgentOpenApiClient superAgentOpenApiClient,
DebugEmlSuperAgentRunRepository runRepository,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
HotelContextService hotelContextService) {
this.properties = properties;
this.ossProperties = ossProperties;
this.parseService = parseService;
@@ -99,6 +103,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
this.superAgentOpenApiClient = superAgentOpenApiClient;
this.runRepository = runRepository;
this.objectMapper = objectMapper;
this.hotelContextService = hotelContextService;
}
/**
@@ -111,7 +116,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
String hotelId,
String runLabel) {
validateAccessKey(accessKey);
String normalizedHotelId = requireText(hotelId, "hotel_id");
String normalizedHotelId = normalizeHotelId(hotelId);
validateFile(file);
byte[] emlBytes = readFileBytes(file);
String safeFileName = safeFileName(file.getOriginalFilename(), "debug-email.eml");
@@ -161,6 +166,21 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
}
}
/**
* 解析 Debug 上传酒店上下文。第一版可不传 hotel_id由单酒店系统上下文兜底。
*/
private String normalizeHotelId(String hotelId) {
try {
return hotelContextService.resolveCurrentHotelId(hotelId);
} catch (HotelContextException exception) {
throw new DebugEmlSuperAgentException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage(),
exception);
}
}
/**
* 生成 SuperAgent 失败安全摘要,只记录错误类型,不保存响应 body、API Key、正文或附件 URL。
*/

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.platform.hotel.service;
import org.springframework.http.HttpStatus;
/**
* 酒店上下文解析受控异常。用于把 hotel_id 来源不明确、系统酒店未配置和用户无权限转换为安全错误。
*/
public class HotelContextException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public HotelContextException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,22 @@
package cn.nianxx.thhotel.platform.hotel.service;
/**
* 酒店上下文解析服务。统一收口机器入口和用户入口的运行时 hotel_id 来源。
*/
public interface HotelContextService {
/**
* 解析系统酒店 ID。单酒店阶段要求平台酒店表中必须且只能有一家 ACTIVE 酒店。
*/
String resolveSystemHotelId();
/**
* 解析当前用户请求酒店 ID。已登录时校验用户授权未登录兼容入口回退系统酒店。
*/
String resolveCurrentHotelId(String requestedHotelId);
/**
* 校验当前用户是否可访问指定酒店。未登录时按系统酒店兼容校验。
*/
String requireAccessibleHotel(String hotelId);
}

View File

@@ -0,0 +1,133 @@
package cn.nianxx.thhotel.platform.hotel.service.impl;
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* 酒店上下文解析服务实现。单酒店阶段以平台酒店表唯一 ACTIVE 酒店作为系统酒店事实来源。
*/
@Service
public class HotelContextServiceImpl implements HotelContextService {
private final PlatformHotelRepository hotelRepository;
private final CurrentUserContextService currentUserContextService;
/**
* 注入平台酒店仓储和当前用户上下文服务,避免业务模块各自读取配置兜底酒店。
*/
public HotelContextServiceImpl(
PlatformHotelRepository hotelRepository,
CurrentUserContextService currentUserContextService) {
this.hotelRepository = hotelRepository;
this.currentUserContextService = currentUserContextService;
}
/**
* 解析系统酒店。单酒店阶段严格要求平台酒店表只有一家 ACTIVE 酒店。
*/
@Override
public String resolveSystemHotelId() {
List<PlatformHotelEntity> activeHotels = hotelRepository.listActiveHotels();
if (activeHotels == null || activeHotels.isEmpty()) {
throw new HotelContextException(
HttpStatus.CONFLICT,
"SYSTEM_HOTEL_NOT_CONFIGURED",
"系统酒店未配置,请先在平台酒店表配置一家 ACTIVE 酒店。");
}
if (activeHotels.size() > 1) {
throw new HotelContextException(
HttpStatus.CONFLICT,
"SYSTEM_HOTEL_AMBIGUOUS",
"单酒店阶段只允许平台酒店表存在一家 ACTIVE 酒店。");
}
String hotelId = trimToNull(activeHotels.get(0).getHotelId());
if (hotelId == null) {
throw new HotelContextException(
HttpStatus.CONFLICT,
"SYSTEM_HOTEL_NOT_CONFIGURED",
"系统酒店 ID 为空,请检查平台酒店表。");
}
return hotelId;
}
/**
* 解析当前请求酒店。登录用户按授权酒店校验;未登录兼容入口按系统酒店解析并严格比对显式请求值。
*/
@Override
public String resolveCurrentHotelId(String requestedHotelId) {
String normalizedRequestedHotelId = trimToNull(requestedHotelId);
return currentUserContextService.currentUser()
.map(context -> resolveUserHotelId(context, normalizedRequestedHotelId))
.orElseGet(() -> resolveAnonymousHotelId(normalizedRequestedHotelId));
}
/**
* 要求当前请求可访问指定酒店。空入参走当前酒店解析,非空入参走授权校验。
*/
@Override
public String requireAccessibleHotel(String hotelId) {
return resolveCurrentHotelId(hotelId);
}
/**
* 已登录用户酒店解析。显式请求酒店必须在可访问列表内,缺省时使用默认酒店。
*/
private String resolveUserHotelId(AuthenticatedUserContext context, String requestedHotelId) {
List<String> accessibleHotelIds = context.accessibleHotelIds() == null
? List.of()
: context.accessibleHotelIds();
if (requestedHotelId != null) {
if (accessibleHotelIds.contains(requestedHotelId)) {
return requestedHotelId;
}
throw accessDenied();
}
String defaultHotelId = trimToNull(context.defaultHotelId());
if (defaultHotelId != null && accessibleHotelIds.contains(defaultHotelId)) {
return defaultHotelId;
}
return accessibleHotelIds.stream()
.filter(this::hasText)
.findFirst()
.orElseThrow(this::accessDenied);
}
/**
* 未登录兼容入口酒店解析。请求未传酒店时使用系统酒店,显式酒店必须等于系统酒店。
*/
private String resolveAnonymousHotelId(String requestedHotelId) {
String systemHotelId = resolveSystemHotelId();
if (requestedHotelId == null || systemHotelId.equals(requestedHotelId)) {
return systemHotelId;
}
throw accessDenied();
}
/**
* 构造酒店访问拒绝异常,不回显用户或酒店授权细节。
*/
private HotelContextException accessDenied() {
return new HotelContextException(
HttpStatus.FORBIDDEN,
"HOTEL_ACCESS_DENIED",
"当前用户无权访问该酒店。");
}
private boolean hasText(String value) {
return trimToNull(value) != null;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.platform.message.control;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* SourceMessage 查询接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。
*/
@RestControllerAdvice(assignableTypes = SourceMessageController.class)
public class SourceMessageControllerAdvice {
/**
* 处理酒店上下文解析失败,例如单酒店未配置、多 ACTIVE 酒店或当前用户无权限访问。
*/
@ExceptionHandler(HotelContextException.class)
public ResponseEntity<Map<String, Object>> handleHotelContextException(HotelContextException exception) {
return ResponseEntity.status(exception.getStatus())
.body(Map.of(
"error_code", exception.getErrorCode(),
"message", exception.getMessage()));
}
}

View File

@@ -1,6 +1,7 @@
package cn.nianxx.thhotel.platform.message.service.impl;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
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;
@@ -24,12 +25,16 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
private static final int MAX_SAFE_KEYWORD_MATCHES = 500;
private final SourceMessageInboxRepository inboxRepository;
private final HotelContextService hotelContextService;
/**
* 注入 SourceMessage 持久化边界,查询服务不直接依赖 Mapper。
* 注入 SourceMessage 持久化边界和酒店上下文服务,查询服务不直接依赖 Mapper。
*/
public SourceMessageQueryServiceImpl(SourceMessageInboxRepository inboxRepository) {
public SourceMessageQueryServiceImpl(
SourceMessageInboxRepository inboxRepository,
HotelContextService hotelContextService) {
this.inboxRepository = inboxRepository;
this.hotelContextService = hotelContextService;
}
/**
@@ -37,9 +42,10 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
*/
@Override
public SourceMessagePageResult<SourceMessageSummaryResponse> query(SourceMessageQueryRequest request) {
int pageNum = normalizePageNum(request.pageNum());
int pageSize = normalizePageSize(request.pageSize());
SourceMessagePageResult<SourceMessageInboxSnapshot> page = inboxRepository.query(request, pageNum, pageSize);
SourceMessageQueryRequest normalizedRequest = normalizeRequest(request);
int pageNum = normalizePageNum(normalizedRequest.pageNum());
int pageSize = normalizePageSize(normalizedRequest.pageSize());
SourceMessagePageResult<SourceMessageInboxSnapshot> page = inboxRepository.query(normalizedRequest, pageNum, pageSize);
List<SourceMessageSummaryResponse> items = page.items().stream().map(this::toSummary).toList();
return new SourceMessagePageResult<>(items, page.total(), pageNum, pageSize);
}
@@ -49,7 +55,10 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
*/
@Override
public List<Long> findIdsBySafeKeyword(String hotelId, String keyword) {
return inboxRepository.findIdsBySafeKeyword(hotelId, keyword, MAX_SAFE_KEYWORD_MATCHES);
return inboxRepository.findIdsBySafeKeyword(
hotelContextService.resolveCurrentHotelId(hotelId),
keyword,
MAX_SAFE_KEYWORD_MATCHES);
}
/**
@@ -78,7 +87,31 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
*/
@Override
public Map<String, Long> countByExternalConversationIds(String hotelId, List<String> externalConversationIds) {
return inboxRepository.countByExternalConversationIds(hotelId, externalConversationIds);
return inboxRepository.countByExternalConversationIds(
hotelContextService.resolveCurrentHotelId(hotelId),
externalConversationIds);
}
/**
* 标准化查询条件。hotel_id 可选,缺省时由当前用户或单酒店系统上下文解析。
*/
private SourceMessageQueryRequest normalizeRequest(SourceMessageQueryRequest request) {
if (request == null) {
return new SourceMessageQueryRequest(
hotelContextService.resolveCurrentHotelId(null),
null,
null,
null,
null,
null);
}
return new SourceMessageQueryRequest(
hotelContextService.resolveCurrentHotelId(request.hotelId()),
request.externalMessageId(),
request.externalConversationId(),
request.captureStatus(),
request.pageNum(),
request.pageSize());
}
/**

View File

@@ -13,7 +13,7 @@ public interface ReservationAiTaskIntakeService {
SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId);
/**
* 接收已通过鉴权的 SuperAgent 原始请求体defaultHotelId 用于 S000/S999 文本结果反查 SourceMessage。
* 接收已通过鉴权的 SuperAgent 原始请求体defaultHotelId 用于外部 source_message_id 缺省 hotel_id 时反查 SourceMessage。
*/
SuperAgentTaskResultResponse accept(String rawBody, String clientId, String requestId, String defaultHotelId);
}

View File

@@ -1,6 +1,8 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
@@ -65,6 +67,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
private final ReservationAiWorkflowRepository repository;
private final SourceMessageInboxRepository sourceMessageInboxRepository;
private final SourceMessageHtmlSanitizerService htmlSanitizerService;
private final HotelContextService hotelContextService;
/**
* 注入 Reservation 工作流持久化边界、SourceMessage 持久化边界和 HTML 清洗服务。
@@ -72,10 +75,12 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
public ReservationAiQueryServiceImpl(
ReservationAiWorkflowRepository repository,
SourceMessageInboxRepository sourceMessageInboxRepository,
SourceMessageHtmlSanitizerService htmlSanitizerService) {
SourceMessageHtmlSanitizerService htmlSanitizerService,
HotelContextService hotelContextService) {
this.repository = repository;
this.sourceMessageInboxRepository = sourceMessageInboxRepository;
this.htmlSanitizerService = htmlSanitizerService;
this.hotelContextService = hotelContextService;
}
/**
@@ -84,7 +89,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
@Override
public ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request) {
validateCaseContextRequest(request);
String hotelId = trimToNull(request.hotelId());
String hotelId = resolveHotelId(request.hotelId());
String groupCode = trimToNull(request.groupCode());
String confirmationNumber = trimToNull(request.confirmationNumber());
if (groupCode == null && confirmationNumber == null) {
@@ -149,7 +154,7 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
@Override
public ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request) {
validateObjectDetailRequest(request);
String hotelId = trimToNull(request.hotelId());
String hotelId = resolveHotelId(request.hotelId());
Long orderId = parseOrderObjectId(request.objectId());
ReservationAiQueryOrderSnapshot order = repository.findAiQueryOrderById(hotelId, orderId)
.orElseThrow(() -> new ReservationAiQueryException(
@@ -200,8 +205,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
public ReservationMessageConversationTasksResult queryMessageConversationTasks(
ReservationMessageConversationQueryRequest request) {
validateConversationRequest(request);
String hotelId = trimToNull(request.hotelId());
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request);
String hotelId = resolveHotelId(request.hotelId());
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request, hotelId);
Map<Long, SourceMessageInboxSnapshot> messageIndex = indexMessages(messages);
List<ReservationMessageConversationTasksResult.TaskRecord> tasks = repository
.findAiQueryTasksBySourceMessageIds(hotelId, messages.stream().map(SourceMessageInboxSnapshot::id).toList())
@@ -232,8 +237,8 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
public ReservationMessageConversationMessagesResult queryMessageConversationMessages(
ReservationMessageConversationQueryRequest request) {
validateConversationRequest(request);
String hotelId = trimToNull(request.hotelId());
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request);
String hotelId = resolveHotelId(request.hotelId());
List<SourceMessageInboxSnapshot> messages = findConversationMessages(request, hotelId);
List<ReservationMessageConversationMessagesResult.MessageRecord> resultMessages = messages.stream()
.map(this::toConversationMessage)
.toList();
@@ -247,8 +252,9 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
/**
* 根据会话 ID 或外部 SourceMessage ID 找到同一邮件链的全部消息。
*/
private List<SourceMessageInboxSnapshot> findConversationMessages(ReservationMessageConversationQueryRequest request) {
String hotelId = trimToNull(request.hotelId());
private List<SourceMessageInboxSnapshot> findConversationMessages(
ReservationMessageConversationQueryRequest request,
String hotelId) {
String externalConversationId = trimToNull(request.externalConversationId());
if (externalConversationId != null) {
List<SourceMessageInboxSnapshot> messages = sourceMessageInboxRepository.findByExternalConversationId(
@@ -547,7 +553,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
if (request == null) {
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
}
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
if (trimToNull(request.groupCode()) == null
&& trimToNull(request.confirmationNumber()) == null
&& trimToNull(request.reservationNo()) == null) {
@@ -559,7 +564,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
if (request == null) {
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
}
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空");
}
@@ -567,7 +571,6 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
if (request == null) {
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
}
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
if (trimToNull(request.externalConversationId()) == null && trimToNull(request.sourceMessageId()) == null) {
throw badRequest(
"MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED",
@@ -583,6 +586,20 @@ public class ReservationAiQueryServiceImpl implements ReservationAiQueryService
return new ReservationAiQueryException(HttpStatus.NOT_FOUND, code, message);
}
/**
* 解析 AI 查询酒店上下文。SuperAgent 可不传 hotel_id如果传入单酒店阶段必须与系统酒店一致。
*/
private String resolveHotelId(String requestedHotelId) {
try {
return hotelContextService.requireAccessibleHotel(requestedHotelId);
} catch (HotelContextException exception) {
throw new ReservationAiQueryException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
private void requireText(String value, String code, String message) {
if (trimToNull(value) == null) {
throw badRequest(code, message);

View File

@@ -101,7 +101,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
requestBody);
}
JsonNode root = parseJson(rawBody);
ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root);
ResolvedSourceMessage resolvedSourceMessage = resolveSourceMessage(root, defaultHotelId);
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
Long sourceMessageId = sourceMessage.id();
String responseSourceMessageId = resolvedSourceMessage.responseSourceMessageId();
@@ -743,16 +743,24 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
/**
* 解析 SuperAgent 来源消息引用。正式契约使用外部邮件 ID旧本地夹具仍兼容内部 SourceMessage ID。
*/
private ResolvedSourceMessage resolveSourceMessage(JsonNode root) {
private ResolvedSourceMessage resolveSourceMessage(JsonNode root, String defaultHotelId) {
String sourceMessageReference = trimToNull(textAt(root, "source_message_id"));
if (sourceMessageReference == null) {
throw error(HttpStatus.BAD_REQUEST, "SOURCE_MESSAGE_REQUIRED", "source_message_id 缺失。");
}
validateLength(sourceMessageReference, "source_message_id", LENGTH_256);
String hotelId = trimToNull(textAt(root, "hotel_id"));
String requestHotelId = trimToNull(textAt(root, "hotel_id"));
String systemHotelId = trimToNull(defaultHotelId);
if (requestHotelId != null && systemHotelId != null && !requestHotelId.equals(systemHotelId)) {
throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "请求 hotel_id 与系统酒店不一致。");
}
if (requestHotelId == null && isLongText(sourceMessageReference)) {
return resolveLegacyInternalSourceMessage(sourceMessageReference, systemHotelId);
}
String hotelId = requestHotelId == null ? systemHotelId : requestHotelId;
if (hotelId == null) {
return resolveLegacyInternalSourceMessage(sourceMessageReference);
return resolveLegacyInternalSourceMessage(sourceMessageReference, null);
}
validateLength(hotelId, "hotel_id", LENGTH_64);
String sourceProvider = optionalText(textAt(root, "source_provider"), "source_provider", LENGTH_32);
@@ -766,9 +774,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
/**
* 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询。
* 兼容历史本地测试和旧接口调用:无 hotel_id 时按内部 SourceMessage ID 查询,但必须校验系统酒店边界
*/
private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId) {
private ResolvedSourceMessage resolveLegacyInternalSourceMessage(String rawId, String expectedHotelId) {
Long sourceMessageId;
try {
sourceMessageId = Long.valueOf(rawId);
@@ -777,12 +785,27 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
SourceMessageInboxSnapshot sourceMessage = sourceMessageInboxRepository.findById(sourceMessageId)
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "SOURCE_MESSAGE_NOT_FOUND", "SourceMessage 不存在。"));
if (expectedHotelId != null && !expectedHotelId.equals(sourceMessage.hotelId())) {
throw error(HttpStatus.BAD_REQUEST, "HOTEL_ID_MISMATCH", "SourceMessage 所属酒店与系统酒店不一致。");
}
String responseSourceMessageId = trimToNull(sourceMessage.externalMessageId()) == null
? sourceMessageId.toString()
: sourceMessage.externalMessageId();
return new ResolvedSourceMessage(sourceMessage, responseSourceMessageId);
}
/**
* 判断来源消息引用是否为历史本地兼容的内部 SourceMessage ID。
*/
private boolean isLongText(String value) {
try {
Long.valueOf(value);
return true;
} catch (NumberFormatException exception) {
return false;
}
}
/**
* 从对象节点中读取文本字段,缺失或 null 时返回 null。
*/

View File

@@ -14,8 +14,6 @@ public class ReservationDemoDataProperties {
private boolean enabled = false;
/** 演示数据 seed 访问口令。 */
private String accessKey = "";
/** 演示数据默认酒店上下文。 */
private String defaultHotelId = "HOTEL-TEST";
public boolean isEnabled() {
return enabled;
@@ -32,12 +30,4 @@ public class ReservationDemoDataProperties {
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
}
public String getDefaultHotelId() {
return defaultHotelId;
}
public void setDefaultHotelId(String defaultHotelId) {
this.defaultHotelId = defaultHotelId;
}
}

View File

@@ -1,5 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
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;
@@ -64,6 +66,7 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic
private final SourceMessageCaptureService captureService;
private final ReservationAiTaskIntakeService intakeService;
private final ReservationTaskWorkflowService taskWorkflowService;
private final HotelContextService hotelContextService;
/**
* 注入演示配置、JSON 工具和已有业务服务,避免 seed 功能直接写表。
@@ -73,12 +76,14 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic
ObjectMapper objectMapper,
SourceMessageCaptureService captureService,
ReservationAiTaskIntakeService intakeService,
ReservationTaskWorkflowService taskWorkflowService) {
ReservationTaskWorkflowService taskWorkflowService,
HotelContextService hotelContextService) {
this.properties = properties;
this.objectMapper = objectMapper;
this.captureService = captureService;
this.intakeService = intakeService;
this.taskWorkflowService = taskWorkflowService;
this.hotelContextService = hotelContextService;
}
/**
@@ -588,7 +593,6 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic
* 生成前端可直接调用的查询入口。
*/
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()))
@@ -606,12 +610,12 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic
.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("task_list_url", "/api/reservation/tasks?keyword=" + encodedRunId
+ "&page_num=1&page_size=20");
entrypoints.put("order_list_url", "/api/reservation/orders?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");
+ "?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;
@@ -641,8 +645,14 @@ public class ReservationDemoDataServiceImpl implements ReservationDemoDataServic
* 标准化酒店上下文 ID。
*/
private String normalizeHotelId(String rawHotelId) {
String hotelId = trimToNull(rawHotelId);
return hotelId == null ? properties.getDefaultHotelId() : hotelId;
try {
return hotelContextService.resolveCurrentHotelId(rawHotelId);
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
/**

View File

@@ -1,6 +1,8 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
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;
@@ -38,7 +40,6 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQueryService {
private static final String DEFAULT_HOTEL_ID = "HOTEL-TEST";
private static final int DEFAULT_PAGE_NUM = 1;
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
@@ -50,6 +51,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
private final ReservationAiWorkflowRepository workflowRepository;
private final SourceMessageQueryService sourceMessageQueryService;
private final ReservationTaskAvailabilityResolver availabilityResolver;
private final HotelContextService hotelContextService;
/**
* 注入持久化边界、SourceMessage 安全摘要服务和可处理状态解析器。
@@ -57,10 +59,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
public ReservationFrontendQueryServiceImpl(
ReservationAiWorkflowRepository workflowRepository,
SourceMessageQueryService sourceMessageQueryService,
ReservationTaskAvailabilityResolver availabilityResolver) {
ReservationTaskAvailabilityResolver availabilityResolver,
HotelContextService hotelContextService) {
this.workflowRepository = workflowRepository;
this.sourceMessageQueryService = sourceMessageQueryService;
this.availabilityResolver = availabilityResolver;
this.hotelContextService = hotelContextService;
}
/**
@@ -177,7 +181,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
private ReservationTaskWorkbenchQueryRequest normalizeRequest(ReservationTaskWorkbenchQueryRequest request) {
if (request == null) {
return new ReservationTaskWorkbenchQueryRequest(
DEFAULT_HOTEL_ID,
normalizeHotelId(null),
null,
null,
null,
@@ -207,7 +211,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
private ReservationOrderListQueryRequest normalizeOrderListRequest(ReservationOrderListQueryRequest request) {
if (request == null) {
return new ReservationOrderListQueryRequest(
DEFAULT_HOTEL_ID,
normalizeHotelId(null),
null,
null,
null,
@@ -556,11 +560,17 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
}
/**
* 标准化酒店 ID。第一版未接用户酒店上下文时使用本地默认酒店
* 标准化酒店 ID。前端可不传酒店,后端按当前用户上下文或单酒店系统上下文解析
*/
private String normalizeHotelId(String hotelId) {
String trimmedHotelId = trimToNull(hotelId);
return trimmedHotelId == null ? DEFAULT_HOTEL_ID : trimmedHotelId;
try {
return hotelContextService.resolveCurrentHotelId(hotelId);
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
/**