修复 V4 确认接口边界

This commit is contained in:
andy
2026-07-19 10:58:58 +07:00
parent fdb16cc4c1
commit 080266c633
7 changed files with 216 additions and 17 deletions

View File

@@ -59,7 +59,7 @@
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task``source_message_summary``source_message_card``basic_information_card``business_cards[]``card_counts``adapter_contract_errors[]``availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。 |
| `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;该动作不创建订单、不参与订单阻塞。 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`仅允许 `route_code=S10/S99`确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 |
| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment``notification``manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]``unhandled_intents[]` 查看同批次未建任务的诊断信息type-known manual review 会返回顶层 `review_status``review_resolution``manual_review`。 |
| `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 |

View File

@@ -614,7 +614,7 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution
- 校验通过后直接 `CONFIRMED`
- 写业务审计。
### 13.3 S10 通知确认
### 13.3 S10/S99 通知确认
```text
POST /api/reservation/source-notifications/{notificationId}/ack
@@ -627,8 +627,9 @@ S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或
请求要点:
- 必须带 `version` 做并发校验。
- 仅允许 `route_code=S10/S99` 的来源通知;其它路由即使状态为 `ACK_REQUIRED` 也不能通过该接口确认。
- 确认后 `notification_status=ACKED`,写确认人和 UTC 确认时间。
- 重复提交已确认通知应返回幂等成功或明确的已确认状态,不允许回退到 `ACK_REQUIRED`
- 重复提交已确认通知幂等成功返回当前已确认状态,不新增审计,不允许回退到 `ACK_REQUIRED`
- 返回刷新后的 `GET /api/reservation/source-notifications/{notificationId}` 详情结构。
## 14. 旧 V3 / V2 兼容和废弃边界

View File

@@ -55,7 +55,7 @@
| `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 保持Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload | 必须写业务审计actor 使用当前登录用户 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计,记录复核修正和订单归属确认摘要 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 V4 S10/S99 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞 | 必须写业务审计,记录已读 / 已处理确认actor 使用当前登录用户 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 `route_code=S10/S99` 的 V4 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞;重复 ack 幂等返回当前状态且不新增审计 | 首次确认必须写业务审计,记录已读 / 已处理确认actor 使用当前登录用户 |
| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 |
| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 |
| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt |

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,