修复 V4 确认接口边界
This commit is contained in:
@@ -7,6 +7,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDra
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition;
|
||||
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;
|
||||
@@ -118,6 +119,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId);
|
||||
requireHotelAccess(notification.hotelId());
|
||||
validateSourceNotificationAckRoute(notification);
|
||||
if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) {
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
@@ -143,6 +145,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
|
||||
private void validateSourceNotificationAckRoute(ReservationV4SourceNotificationSnapshot notification) {
|
||||
boolean ackableRoute = ReservationAiRouteDefinition.findByRouteCode(notification.routeCode())
|
||||
.map(ReservationAiRouteDefinition::sourceMessageNotification)
|
||||
.orElse(false);
|
||||
if (!ackableRoute) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_SOURCE_NOTIFICATION_ROUTE_NOT_ACKABLE",
|
||||
"该来源通知路由不允许通过 S10/S99 ack 接口确认。");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCardConfirmable(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
|
||||
@@ -195,10 +195,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
Map<Long, List<ReservationV4OrderTaskSnapshot>> queueContext = findQueueContext(
|
||||
orderTask.hotelId(),
|
||||
List.of(orderTask));
|
||||
ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext);
|
||||
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskId(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id());
|
||||
ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext, cards);
|
||||
ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null);
|
||||
ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name())
|
||||
.map(card -> toCardResult(card, orderAvailability, basicCard))
|
||||
@@ -263,7 +263,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
ReservationV4SourceMessageSummaryResult sourceSummary,
|
||||
Map<Long, List<ReservationV4OrderTaskSnapshot>> queueContext,
|
||||
List<ReservationV4TaskCardSnapshot> cards) {
|
||||
ReservationV4ActionAvailabilityResult availability = orderTaskAvailability(orderTask, queueContext);
|
||||
ReservationV4ActionAvailabilityResult availability = orderTaskAvailability(orderTask, queueContext, cards);
|
||||
return new ReservationV4OrderTaskListItemResult(
|
||||
toOrderTaskSummary(orderTask),
|
||||
sourceSummary,
|
||||
@@ -395,7 +395,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|
||||
private ReservationV4ActionAvailabilityResult orderTaskAvailability(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
Map<Long, List<ReservationV4OrderTaskSnapshot>> queueContext) {
|
||||
Map<Long, List<ReservationV4OrderTaskSnapshot>> queueContext,
|
||||
List<ReservationV4TaskCardSnapshot> cards) {
|
||||
ReservationV4OrderTaskSnapshot blocker = findPriorBlockingOrderTask(orderTask, queueContext);
|
||||
if (blocker != null) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
@@ -421,16 +422,78 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
null,
|
||||
null);
|
||||
}
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
return openOrderTaskAvailability(cards);
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult openOrderTaskAvailability(List<ReservationV4TaskCardSnapshot> cards) {
|
||||
List<ReservationV4TaskCardSnapshot> safeCards = cards == null ? List.of() : cards;
|
||||
ReservationV4TaskCardSnapshot basicCard = findFirstCard(
|
||||
safeCards,
|
||||
ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null);
|
||||
if (basicCard != null && ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(basicCard.cardStatus())) {
|
||||
return readOnlyAvailability(
|
||||
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
|
||||
null,
|
||||
"V4 人工复核写接口将在后续 checkpoint 开放。");
|
||||
}
|
||||
boolean hasConfirmableCard = safeCards.stream()
|
||||
.anyMatch(card -> cardConfirmableAtOrderLevel(card, basicCard));
|
||||
if (hasConfirmableCard) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
if (basicCard != null && !ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus())
|
||||
&& hasPendingBusinessCard(safeCards)) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(),
|
||||
null,
|
||||
basicCard.id().toString(),
|
||||
"Basic Information 卡未确认前,业务卡只能查看。");
|
||||
}
|
||||
boolean hasReviewCard = safeCards.stream()
|
||||
.anyMatch(card -> ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus()));
|
||||
if (hasReviewCard) {
|
||||
return readOnlyAvailability(
|
||||
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
|
||||
null,
|
||||
"V4 人工复核写接口将在后续 checkpoint 开放。");
|
||||
}
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
|
||||
private boolean cardConfirmableAtOrderLevel(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ReservationV4TaskCardSnapshot basicCard) {
|
||||
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) {
|
||||
return false;
|
||||
}
|
||||
if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return true;
|
||||
}
|
||||
return basicCard != null && ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus());
|
||||
}
|
||||
|
||||
private boolean hasPendingBusinessCard(List<ReservationV4TaskCardSnapshot> cards) {
|
||||
return cards.stream()
|
||||
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
|
||||
.filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType()))
|
||||
.anyMatch(card -> ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus()));
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult cardAvailability(
|
||||
|
||||
@@ -259,6 +259,78 @@ class ReservationV4CommandControllerTest {
|
||||
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAckS99SourceNotification() throws Exception {
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-s99-ack-001",
|
||||
"S99",
|
||||
Instant.parse("2026-07-19T01:35:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"))
|
||||
.andExpect(jsonPath("$.notification.route_code").value("S99"));
|
||||
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnsupportedRouteSourceNotificationAck() throws Exception {
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-unsupported-ack-001",
|
||||
"S88",
|
||||
Instant.parse("2026-07-19T01:36:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_SOURCE_NOTIFICATION_ROUTE_NOT_ACKABLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTreatRepeatedSourceNotificationAckAsIdempotentWithoutExtraAudit() throws Exception {
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-repeat-ack-001",
|
||||
"S10",
|
||||
Instant.parse("2026-07-19T01:37:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 1
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"));
|
||||
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV4CommandsWhenPermissionMissing() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
|
||||
@@ -250,6 +250,23 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(content().string(not(containsString("https://private.example.test"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask(
|
||||
"mail-v4-query-review-only-001",
|
||||
Instant.parse("2026-07-18T03:05:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.availability.confirmable").value(false))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING"))
|
||||
.andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false))
|
||||
.andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code")
|
||||
.value("REVIEW_API_PENDING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnV4OrderTaskListWithCardStatusFilter() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-list-001",
|
||||
@@ -478,6 +495,38 @@ class ReservationV4QueryControllerTest {
|
||||
return orderTask;
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot seedReviewRequiredOrderTask(String externalMessageId, Instant receivedAt) {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Review", receivedAt, HOTEL_ID);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
|
||||
HOTEL_ID,
|
||||
source.inboxId(),
|
||||
990000000000000301L,
|
||||
"order-review-only",
|
||||
1,
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-REVIEW-ONLY-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 Review"}}
|
||||
""");
|
||||
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """
|
||||
{"card_type":"BASIC_INFORMATION","missing_fields":["account_code"]}
|
||||
""");
|
||||
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """
|
||||
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","missing_fields":["room_items"]}
|
||||
""");
|
||||
return orderTask;
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
|
||||
String externalMessageId,
|
||||
String routeCode,
|
||||
|
||||
Reference in New Issue
Block a user