实现 M002 V4 查询接口

This commit is contained in:
andy
2026-07-19 01:44:32 +07:00
parent 99528b483f
commit dc6c8f48ab
36 changed files with 2213 additions and 47 deletions

View File

@@ -0,0 +1,415 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
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.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_v4_query_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=v4-query-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=V4查询管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationV4QueryControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
private static final String OTHER_HOTEL_ID = "HOTEL-OTHER";
private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$";
@Autowired
private MockMvc mockMvc;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private ReservationV4WorkflowRepository workflowRepository;
@Autowired
private ReservationV4SourceNotificationRepository sourceNotificationRepository;
@Autowired
private PlatformIdentityRepository identityRepository;
@Autowired
private PlatformHotelRepository hotelRepository;
@Autowired
private AuthPasswordService passwordService;
private String adminToken;
private String noPermissionToken;
@BeforeEach
void ensureNoPermissionUser() {
PlatformUserEntity user = identityRepository.findUserByUsername("v4-query-no-permission")
.orElseGet(() -> {
LocalDateTime now = LocalDateTime.now();
PlatformUserEntity created = new PlatformUserEntity();
created.setUsername("v4-query-no-permission");
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
created.setDisplayName("V4 查询无权限用户");
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
created.setSuperAdmin(false);
created.setPasswordChangedAt(now);
created.setCreatedAt(now);
created.setUpdatedAt(now);
identityRepository.insertUser(created);
return created;
});
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
}
/**
* 获取 V4 查询管理员 token测试通过真实登录链路覆盖前端鉴权。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "v4-query-admin", "Admin@123456");
}
return adminToken;
}
/**
* 获取没有 RESERVATION_TASK_READ 权限的普通用户 token。
*/
private String noPermissionToken() throws Exception {
if (noPermissionToken == null) {
noPermissionToken = loginToken(mockMvc, "v4-query-no-permission", "NoPerm@123456");
}
return noPermissionToken;
}
@Test
void shouldReturnV4WorkbenchItemsWithOrderTaskAndSourceNotification() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-workbench-order-001",
Instant.parse("2026-07-18T01:00:00Z"));
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
"mail-v4-query-workbench-notification-001",
"S10",
Instant.parse("2026-07-18T02:00:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items")
.param("hotel_id", HOTEL_ID)
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].item_type").value("SOURCE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].target_id").value(notification.id().toString()))
.andExpect(jsonPath("$.items[0].notification_status").value("ACK_REQUIRED"))
.andExpect(jsonPath("$.items[0].display_status").value("ACK_REQUIRED"))
.andExpect(jsonPath("$.items[0].source_message_summary.subject").value("V4 Query Notification"))
.andExpect(jsonPath("$.items[1].item_type").value("ORDER_TASK"))
.andExpect(jsonPath("$.items[1].target_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.items[1].display_order_key").value("GRP-V4-QUERY-001"))
.andExpect(jsonPath("$.items[1].card_counts.total_count").value(3))
.andExpect(jsonPath("$.items[1].card_counts.pending_confirm_count").value(2))
.andExpect(jsonPath("$.items[1].next_action_card_id").exists())
.andExpect(jsonPath("$.items[1].source_message_summary.received_at")
.value(matchesPattern(UTC_INSTANT_PATTERN)))
.andExpect(jsonPath("$.page.page_num").value(1))
.andExpect(jsonPath("$.page.page_size").value(20))
.andExpect(jsonPath("$.page.total").value(2))
.andExpect(content().string(not(containsString("ai_payload_json"))))
.andExpect(content().string(not(containsString("Sensitive raw notification body"))));
}
@Test
void shouldReturnV4OrderTaskDetailWithSafeCards() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-detail-001",
Instant.parse("2026-07-18T03:00:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.order_task.order_ref").value("order-1"))
.andExpect(jsonPath("$.order_task.target_locator_value").value("GRP-V4-QUERY-001"))
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_DISPLAY"))
.andExpect(jsonPath("$.basic_information_card.card_type").value("BASIC_INFORMATION"))
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("COMMAND_API_PENDING"))
.andExpect(jsonPath("$.source_message_summary.subject").value("V4 Query Business"))
.andExpect(content().string(not(containsString("https://private.example.test"))));
}
@Test
void shouldReturnV4OrderTaskListWithCardStatusFilter() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-list-001",
Instant.parse("2026-07-18T03:30:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks")
.param("hotel_id", HOTEL_ID)
.param("card_status", "PENDING_CONFIRM")
.param("keyword", "mail-v4-query-list-001")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items.length()").value(1))
.andExpect(jsonPath("$.items[0].order_task.order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.items[0].order_task.display_order_key").value("GRP-V4-QUERY-001"))
.andExpect(jsonPath("$.items[0].card_counts.pending_confirm_count").value(2))
.andExpect(jsonPath("$.items[0].display_status").value("OPEN"))
.andExpect(jsonPath("$.items[0].availability.read_only").value(true))
.andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("COMMAND_API_PENDING"))
.andExpect(jsonPath("$.page.total").value(1));
}
@Test
void shouldReturnV4SourceNotificationDetailWithoutRawPayload() throws Exception {
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
"mail-v4-query-notification-detail-001",
"S99",
Instant.parse("2026-07-18T04:00:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}",
notification.id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notification.notification_id").value(notification.id().toString()))
.andExpect(jsonPath("$.notification.route_code").value("S99"))
.andExpect(jsonPath("$.notification.notification_status").value("ACK_REQUIRED"))
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.conversation_summary.source_message_id")
.value(notification.sourceMessageId().toString()))
.andExpect(jsonPath("$.availability.ackable").value(false))
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.raw_payload_json").doesNotExist())
.andExpect(content().string(not(containsString("Sensitive raw notification body"))));
}
@Test
void shouldRejectV4WorkbenchItemsWithoutLogin() throws Exception {
mockMvc.perform(get("/api/reservation/workbench-items")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@Test
void shouldRejectV4QueriesWhenPermissionMissing() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-no-permission-001",
Instant.parse("2026-07-18T05:00:00Z"));
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
"mail-v4-query-no-permission-notification-001",
"S10",
Instant.parse("2026-07-18T05:10:00Z"));
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/workbench-items")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/order-tasks")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/source-notifications/{notificationId}",
notification.id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectV4QueriesWhenHotelAccessDenied() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-cross-hotel-001",
Instant.parse("2026-07-18T06:00:00Z"));
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
"mail-v4-query-cross-hotel-notification-001",
"S99",
Instant.parse("2026-07-18T06:10:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items")
.param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks")
.param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}",
notification.id()).param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
void shouldRejectUnsupportedWorkbenchItemType() throws Exception {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items")
.param("hotel_id", HOTEL_ID)
.param("item_type", "UNKNOWN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_WORKBENCH_ITEM_TYPE_INVALID"));
}
@Test
void shouldCapHugeWorkbenchPageSafely() throws Exception {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items")
.param("hotel_id", HOTEL_ID)
.param("page_num", String.valueOf(Integer.MAX_VALUE))
.param("page_size", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.page_num").value(100))
.andExpect(jsonPath("$.page.page_size").value(100));
}
private ReservationV4OrderTaskSnapshot seedOrderTask(String externalMessageId, Instant receivedAt) {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt);
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
HOTEL_ID,
source.inboxId(),
990000000000000001L,
"order-1",
1,
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-QUERY-001",
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4OrderTaskStatus.OPEN.name(),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
now));
insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, """
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}}
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"BASIC_INFORMATION","account_code":"QBD_TRAVEL"}
""");
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""");
return orderTask;
}
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
String externalMessageId,
String routeCode,
Instant receivedAt) {
SourceMessageCaptureResult source = captureSourceMessage(
externalMessageId,
"V4 Query Notification",
receivedAt);
return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft(
HOTEL_ID,
source.inboxId(),
990000000000000101L + Math.abs(routeCode.hashCode()),
null,
routeCode,
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
"""
{"route_code":"%s","source_message":{"body":"Sensitive raw notification body"}}
""".formatted(routeCode),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC)));
}
private void insertCard(
ReservationV4OrderTaskSnapshot orderTask,
String cardType,
String eventType,
Integer sourceEventIndex,
Integer sortOrder,
String cardStatus,
String reviewStatus,
String displayPayloadJson) {
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
HOTEL_ID,
orderTask.id(),
orderTask.sourceMessageId(),
eventType == null ? null : 990000000000000201L + sourceEventIndex,
cardType,
eventType,
sourceEventIndex,
sortOrder,
cardStatus,
reviewStatus,
"""
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
""",
displayPayloadJson,
null,
orderTask.createdAt()));
}
private SourceMessageCaptureResult captureSourceMessage(
String externalMessageId,
String subject,
Instant receivedAt) {
return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID,
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-v4-query",
receivedAt,
null,
"guest@example.test",
subject,
"Please handle V4 query message.",
"<html><body>Please handle V4 query message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
}