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

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

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