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

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

@@ -124,10 +124,14 @@ class SuperAgentMcpControllerTest {
.andExpect(jsonPath("$.id").value("mcp-tools-001"))
.andExpect(jsonPath("$.result.tools.length()").value(5))
.andExpect(jsonPath("$.result.tools[0].name").value("th_hotel_query_case_context"))
.andExpect(jsonPath("$.result.tools[0].inputSchema.required.length()").value(0))
.andExpect(jsonPath("$.result.tools[0].annotations.readOnlyHint").value(true))
.andExpect(jsonPath("$.result.tools[3].name").value("th_hotel_list_message_conversation_messages"))
.andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0))
.andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true))
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
.andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false))
.andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true));
}
@@ -142,7 +146,6 @@ class SuperAgentMcpControllerTest {
"params": {
"name": "th_hotel_query_case_context",
"arguments": {
"hotel_id": "HOTEL-TEST",
"group_code": "GRP-MCP-NOT-FOUND"
}
}
@@ -173,7 +176,6 @@ class SuperAgentMcpControllerTest {
"params": {
"name": "th_hotel_submit_task_results",
"arguments": {
"hotel_id": "HOTEL-TEST",
"source_message_id": "mail-mcp-disabled-001",
"ai_task_results": []
}

View File

@@ -44,7 +44,6 @@ class SuperAgentMcpSubmitEnabledControllerTest {
"params": {
"name": "th_hotel_submit_task_results",
"arguments": {
"hotel_id": "HOTEL-TEST",
"source_message_id": "mail-mcp-enabled-missing-001",
"ai_task_results": [
{

View File

@@ -10,6 +10,7 @@ import static org.mockito.Mockito.when;
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.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -64,7 +65,7 @@ class AgentBusFrameProcessorTest {
assertThat(status.snapshot().capturedFrameCount()).isEqualTo(1L);
ArgumentCaptor<CaptureSourceMessageCommand> captor = ArgumentCaptor.forClass(CaptureSourceMessageCommand.class);
verify(captureService).capture(captor.capture());
assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-TEST");
assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-SYSTEM");
assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001");
assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001");
}
@@ -107,18 +108,20 @@ class AgentBusFrameProcessorTest {
SourceMessageCaptureService captureService,
AgentBusConnectionStatus status,
AgentBusProperties properties) {
HotelContextService hotelContextService = mock(HotelContextService.class);
when(hotelContextService.resolveSystemHotelId()).thenReturn("HOTEL-SYSTEM");
return new AgentBusFrameProcessor(
objectMapper,
new AgentBusSourceMessageAdapter(objectMapper),
captureService,
status,
properties);
properties,
hotelContextService);
}
private AgentBusProperties properties(boolean captureEnabled, int maxFrameBytes) {
AgentBusProperties properties = new AgentBusProperties();
properties.getCapture().setEnabled(captureEnabled);
properties.getCapture().setDefaultHotelId("HOTEL-TEST");
properties.setMaxFrameBytes(maxFrameBytes);
return properties;
}

View File

@@ -192,6 +192,29 @@ class DebugEmlSuperAgentControllerTest {
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L);
}
@Test
void shouldUseSystemHotelWhenDebugUploadOmitsHotelId() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("run_label", "system-hotel-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
Long sourceCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM platform_source_message_inbox inbox
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
WHERE inbox.hotel_id = 'HOTEL-TEST'
AND inbox.provider = 'DEBUG_EML_UPLOAD'
AND payload.payload_json LIKE '%system-hotel-debug-upload%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
}
@Test
void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception {
mockStorageAndSuperAgentSuccess();

View File

@@ -0,0 +1,49 @@
package cn.nianxx.thhotel.platform.hotel.repository;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus;
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:m005_single_active_hotel;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=",
"auth.bootstrap.admin.password=",
"auth.bootstrap.default-hotel-id="
})
@ActiveProfiles("test")
class PlatformHotelSingleActiveConstraintTest {
@Autowired
private PlatformHotelRepository hotelRepository;
@Test
void shouldRejectSecondActiveHotelAtDatabaseLevel() {
hotelRepository.insertHotel(hotel("M005-ACTIVE-A", PlatformHotelStatus.ACTIVE));
assertThatThrownBy(() -> hotelRepository.insertHotel(hotel("M005-ACTIVE-B", PlatformHotelStatus.ACTIVE)))
.isInstanceOf(DataIntegrityViolationException.class);
}
private PlatformHotelEntity hotel(String hotelId, PlatformHotelStatus status) {
LocalDateTime now = LocalDateTime.now();
PlatformHotelEntity hotel = new PlatformHotelEntity();
hotel.setHotelId(hotelId);
hotel.setHotelName(hotelId);
hotel.setHotelStatus(status.name());
hotel.setTimeZone("Asia/Bangkok");
hotel.setSortOrder(10);
hotel.setCreatedAt(now);
hotel.setUpdatedAt(now);
return hotel;
}
}

View File

@@ -0,0 +1,120 @@
package cn.nianxx.thhotel.platform.hotel.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import cn.nianxx.thhotel.platform.hotel.common.enums.PlatformHotelStatus;
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
@ExtendWith(MockitoExtension.class)
class HotelContextServiceImplTest {
@Mock
private PlatformHotelRepository hotelRepository;
@Mock
private CurrentUserContextService currentUserContextService;
@Test
void shouldResolveOnlyActiveHotelAsSystemHotel() {
HotelContextServiceImpl service = service();
when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-ONLY")));
assertThat(service.resolveSystemHotelId()).isEqualTo("HOTEL-ONLY");
}
@Test
void shouldRejectMissingActiveSystemHotel() {
HotelContextServiceImpl service = service();
when(hotelRepository.listActiveHotels()).thenReturn(List.of());
assertThatThrownBy(service::resolveSystemHotelId)
.isInstanceOf(HotelContextException.class)
.extracting("status", "errorCode")
.containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_NOT_CONFIGURED");
}
@Test
void shouldRejectAmbiguousActiveSystemHotel() {
HotelContextServiceImpl service = service();
when(hotelRepository.listActiveHotels()).thenReturn(List.of(
activeHotel("HOTEL-A"),
activeHotel("HOTEL-B")));
assertThatThrownBy(service::resolveSystemHotelId)
.isInstanceOf(HotelContextException.class)
.extracting("status", "errorCode")
.containsExactly(HttpStatus.CONFLICT, "SYSTEM_HOTEL_AMBIGUOUS");
}
@Test
void shouldUseCurrentUserDefaultHotelWhenRequestHotelMissing() {
HotelContextServiceImpl service = service();
when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext(
"HOTEL-USER-DEFAULT",
List.of("HOTEL-USER-DEFAULT", "HOTEL-OTHER"))));
assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-USER-DEFAULT");
}
@Test
void shouldRejectRequestedHotelOutsideCurrentUserAccessList() {
HotelContextServiceImpl service = service();
when(currentUserContextService.currentUser()).thenReturn(Optional.of(userContext(
"HOTEL-USER-DEFAULT",
List.of("HOTEL-USER-DEFAULT"))));
assertThatThrownBy(() -> service.resolveCurrentHotelId("HOTEL-NOT-ALLOWED"))
.isInstanceOf(HotelContextException.class)
.extracting("status", "errorCode")
.containsExactly(HttpStatus.FORBIDDEN, "HOTEL_ACCESS_DENIED");
}
@Test
void shouldFallbackToSystemHotelWhenNoCurrentUser() {
HotelContextServiceImpl service = service();
when(currentUserContextService.currentUser()).thenReturn(Optional.empty());
when(hotelRepository.listActiveHotels()).thenReturn(List.of(activeHotel("HOTEL-SYSTEM")));
assertThat(service.resolveCurrentHotelId(null)).isEqualTo("HOTEL-SYSTEM");
}
private HotelContextServiceImpl service() {
return new HotelContextServiceImpl(hotelRepository, currentUserContextService);
}
private PlatformHotelEntity activeHotel(String hotelId) {
PlatformHotelEntity hotel = new PlatformHotelEntity();
hotel.setHotelId(hotelId);
hotel.setHotelName(hotelId);
hotel.setHotelStatus(PlatformHotelStatus.ACTIVE.name());
hotel.setTimeZone("Asia/Bangkok");
hotel.setSortOrder(10);
hotel.setCreatedAt(LocalDateTime.now());
hotel.setUpdatedAt(LocalDateTime.now());
return hotel;
}
private AuthenticatedUserContext userContext(String defaultHotelId, List<String> accessibleHotelIds) {
return new AuthenticatedUserContext(
1L,
"tester",
"测试用户",
false,
defaultHotelId,
accessibleHotelIds,
List.of());
}
}

View File

@@ -114,7 +114,6 @@ class ReservationAiQueryControllerTest {
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-NOT-FOUND"

View File

@@ -172,16 +172,41 @@ class SuperAgentTaskResultControllerTest {
}
@Test
void shouldRejectExternalSourceMessageIdWhenHotelIdMissing() throws Exception {
String body = minimalBody("mail-external-without-hotel-001", "New Booking", "normal_task", "new_fit_reservation", """
void shouldResolveExternalSourceMessageIdWithoutHotelIdFromSystemHotel() throws Exception {
String externalMessageId = "mail-external-without-hotel-001";
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
String body = minimalBody(externalMessageId, "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
mockMvc.perform(signedPost(body, "nonce-external-source-without-hotel-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value(externalMessageId))
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldRejectLegacyInternalSourceMessageIdWhenHotelDoesNotMatchSystemHotel() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-legacy-source-cross-hotel-001",
"HOTEL-OTHER");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {}
""");
mockMvc.perform(signedPost(body, "nonce-legacy-source-cross-hotel-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("HOTEL_ID_REQUIRED"))
.andExpect(jsonPath("$.message").value("使用外部 source_message_id 时 hotel_id 不能为空。"));
.andExpect(jsonPath("$.error_code").value("HOTEL_ID_MISMATCH"));
}
@Test
@@ -1259,8 +1284,12 @@ class SuperAgentTaskResultControllerTest {
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureSourceMessage(externalMessageId, "HOTEL-TEST");
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String hotelId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",
hotelId,
"AGENTBUS",
"EMAIL",
externalMessageId,

View File

@@ -14,6 +14,12 @@ springdoc:
swagger-ui:
enabled: false
auth:
bootstrap:
default-hotel-id: HOTEL-TEST
default-hotel-name: 测试酒店
default-hotel-time-zone: Asia/Bangkok
---
spring:
config: