统一V4待处理计数并补齐审计查询

This commit is contained in:
andy
2026-07-20 12:19:05 +07:00
parent c5244bc652
commit 5b4257b125
15 changed files with 438 additions and 19 deletions

View File

@@ -15,6 +15,7 @@ import java.time.OffsetDateTime;
* @param confirmationNumber Confirmation No.,仅订单业务号类型为 CONFIRMATION_NUMBER 时返回
* @param displayName 订单展示名
* @param openTaskCount 未关闭任务数,排除 COMPLETED 和 FAILED
* @param openWorkItemCount 订单列表统一待处理工作项数量,第一版按 V4 未完成订单任务数计算,不叠加旧任务
* @param nextProcessableTaskId 下一条当前可处理任务 ID
* @param nextV4OrderTaskId 当前订单下第一条仍需用户处理的 V4 订单任务 ID
* @param nextV4ActionCardId 当前 V4 订单任务下第一张仍需确认或复核的卡片 ID
@@ -43,6 +44,8 @@ public record ReservationOrderListItemResult(
String displayName,
@JsonProperty("open_task_count")
Integer openTaskCount,
@JsonProperty("open_work_item_count")
Integer openWorkItemCount,
@JsonProperty("next_processable_task_id")
String nextProcessableTaskId,
@JsonProperty("next_v4_order_task_id")

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* V4 订单任务审计列表响应。用于前端展示卡片确认和人工复核解阻流水。
*/
public record ReservationV4OrderTaskAuditListResult(
/** V4 订单任务 ID。 */
@JsonProperty("order_task_id")
String orderTaskId,
/** 已脱敏的审计流水列表。 */
List<ReservationTaskAuditLogResult> items
) {
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* V4 来源通知审计列表响应。用于前端展示 S10/S99 已读确认流水。
*/
public record ReservationV4SourceNotificationAuditListResult(
/** V4 来源通知 ID。 */
@JsonProperty("notification_id")
String notificationId,
/** 已脱敏的审计流水列表。 */
List<ReservationTaskAuditLogResult> items
) {
}

View File

@@ -4,8 +4,10 @@ import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
@@ -89,6 +91,17 @@ public class ReservationV4QueryController {
return v4QueryService.getOrderTaskDetail(hotelId, orderTaskId);
}
/**
* 查询 V4 订单任务审计流水,用于前端展示卡片确认和复核记录。
*/
@GetMapping(value = "/order-tasks/{orderTaskId}/audits", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4OrderTaskAuditListResult listOrderTaskAudits(
@PathVariable Long orderTaskId,
@RequestParam(name = "hotel_id", required = false) String hotelId) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name());
return v4QueryService.listOrderTaskAudits(hotelId, orderTaskId);
}
/**
* 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。
*/
@@ -99,4 +112,15 @@ public class ReservationV4QueryController {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return v4QueryService.getSourceNotificationDetail(hotelId, notificationId);
}
/**
* 查询 V4 来源通知审计流水,用于前端展示 S10/S99 确认已读记录。
*/
@GetMapping(value = "/source-notifications/{notificationId}/audits", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits(
@PathVariable Long notificationId,
@RequestParam(name = "hotel_id", required = false) String hotelId) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name());
return v4QueryService.listSourceNotificationAudits(hotelId, notificationId);
}
}

View File

@@ -57,6 +57,11 @@ import org.springframework.stereotype.Repository;
@Repository
public class MybatisReservationAiWorkflowRepository implements ReservationAiWorkflowRepository {
private static final List<String> V4_ORDER_TASK_AUDIT_ACTIONS = List.of(
"V4_CARD_CONFIRM",
"V4_CARD_REVIEW_RESOLVE");
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
private final ReservationAiBatchMapper batchMapper;
private final ReservationAiTransitionMapper transitionMapper;
private final ReservationOrderMapper orderMapper;
@@ -900,6 +905,60 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.toList();
}
/**
* 查询 V4 订单任务审计流水。V4 审计写入时不占用旧 task_id通过快照目标 ID 做第一版关联。
*/
@Override
public List<ReservationAuditLogSnapshot> findAuditLogsByV4OrderTaskId(String hotelId, Long orderTaskId) {
return findAuditLogsBySnapshotTarget(
hotelId,
V4_ORDER_TASK_AUDIT_ACTIONS,
"v4_order_task_id",
orderTaskId);
}
/**
* 查询 V4 来源通知审计流水。第一版仅包含确认已读 / 已处理 ack 记录。
*/
@Override
public List<ReservationAuditLogSnapshot> findAuditLogsByV4SourceNotificationId(String hotelId, Long notificationId) {
return findAuditLogsBySnapshotTarget(
hotelId,
List.of(ACTION_V4_SOURCE_NOTIFICATION_ACK),
"v4_source_notification_id",
notificationId);
}
/**
* 按审计快照中的目标字段过滤 V4 审计记录,避免旧 task_id 为空导致 V4 审计不可查。
*/
private List<ReservationAuditLogSnapshot> findAuditLogsBySnapshotTarget(
String hotelId,
List<String> actions,
String targetFieldName,
Long targetId) {
if (targetId == null) {
return List.of();
}
String targetFieldToken = "\"" + targetFieldName + "\"";
String targetIdToken = "\"" + targetId + "\"";
return auditLogMapper.selectList(Wrappers.<ReservationAuditLogEntity>lambdaQuery()
.eq(ReservationAuditLogEntity::getHotelId, hotelId)
.in(ReservationAuditLogEntity::getAction, actions)
.and(wrapper -> wrapper
.and(beforeSnapshot -> beforeSnapshot
.like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetFieldToken)
.like(ReservationAuditLogEntity::getBeforeSnapshotJson, targetIdToken))
.or(afterSnapshot -> afterSnapshot
.like(ReservationAuditLogEntity::getAfterSnapshotJson, targetFieldToken)
.like(ReservationAuditLogEntity::getAfterSnapshotJson, targetIdToken)))
.orderByAsc(ReservationAuditLogEntity::getOccurredAt)
.orderByAsc(ReservationAuditLogEntity::getId))
.stream()
.map(this::toAuditLogSnapshot)
.toList();
}
/**
* 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文不替代订单查询。
*/

View File

@@ -283,4 +283,14 @@ public interface ReservationAiWorkflowRepository {
* 查询任务审计流水。
*/
List<ReservationAuditLogSnapshot> findAuditLogsByTaskId(String hotelId, Long taskId);
/**
* 按 V4 订单任务 ID 查询审计流水。V4 审计第一版通过快照中的目标 ID 关联。
*/
List<ReservationAuditLogSnapshot> findAuditLogsByV4OrderTaskId(String hotelId, Long orderTaskId);
/**
* 按 V4 来源通知 ID 查询审计流水。第一版仅返回 S10/S99 ack 相关记录。
*/
List<ReservationAuditLogSnapshot> findAuditLogsByV4SourceNotificationId(String hotelId, Long notificationId);
}

View File

@@ -3,7 +3,9 @@ package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
@@ -27,8 +29,18 @@ public interface ReservationV4QueryService {
*/
ReservationV4OrderTaskDetailResult getOrderTaskDetail(String hotelId, Long orderTaskId);
/**
* 查询 V4 订单任务审计流水,只返回前端可展示的脱敏审计摘要。
*/
ReservationV4OrderTaskAuditListResult listOrderTaskAudits(String hotelId, Long orderTaskId);
/**
* 查询 V4 S10/S99 来源通知详情,不返回业务订单任务结构。
*/
ReservationV4SourceNotificationDetailResult getSourceNotificationDetail(String hotelId, Long notificationId);
/**
* 查询 V4 来源通知审计流水,只返回 S10/S99 ack 相关脱敏审计摘要。
*/
ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits(String hotelId, Long notificationId);
}

View File

@@ -574,6 +574,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
confirmationNumber(order),
order.displayName(),
openTaskCount(tasks),
v4NextAction.openOrderTaskCount(),
nextProcessableTaskId(tasks, availabilityByTaskId),
v4NextAction.orderTaskId(),
v4NextAction.cardId(),

View File

@@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
@@ -24,13 +25,16 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4Workb
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiTransitionDisplayResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditLogResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4ActionAvailabilityResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CardCountsResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationAuditListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult;
@@ -44,6 +48,7 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectorySer
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDateTime;
@@ -53,6 +58,7 @@ import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -251,6 +257,21 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
orderAvailability);
}
/**
* 查询 V4 订单任务审计流水,按订单任务实际酒店做访问权校验后返回脱敏摘要。
*/
@Override
@Transactional(readOnly = true)
public ReservationV4OrderTaskAuditListResult listOrderTaskAudits(String hotelId, Long orderTaskId) {
ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId, hotelId);
List<ReservationTaskAuditLogResult> items = aiWorkflowRepository
.findAuditLogsByV4OrderTaskId(orderTask.hotelId(), orderTask.id())
.stream()
.map(this::toAuditLogResult)
.toList();
return new ReservationV4OrderTaskAuditListResult(orderTask.id().toString(), items);
}
/**
* 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。
*/
@@ -269,6 +290,23 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
availability);
}
/**
* 查询 V4 来源通知审计流水,按通知实际酒店做访问权校验后返回脱敏摘要。
*/
@Override
@Transactional(readOnly = true)
public ReservationV4SourceNotificationAuditListResult listSourceNotificationAudits(
String hotelId,
Long notificationId) {
ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId, hotelId);
List<ReservationTaskAuditLogResult> items = aiWorkflowRepository
.findAuditLogsByV4SourceNotificationId(notification.hotelId(), notification.id())
.stream()
.map(this::toAuditLogResult)
.toList();
return new ReservationV4SourceNotificationAuditListResult(notification.id().toString(), items);
}
private ReservationPageSnapshot<ReservationV4SourceNotificationSnapshot> querySourceNotificationPage(
String hotelId,
String notificationStatus,
@@ -399,6 +437,89 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
availability);
}
/**
* 转换 V4 审计快照为前端审计行,时间统一输出 UTC ISO 字符串。
*/
private ReservationTaskAuditLogResult toAuditLogResult(ReservationAuditLogSnapshot snapshot) {
return new ReservationTaskAuditLogResult(
snapshot.id().toString(),
snapshot.orderId() == null ? null : snapshot.orderId().toString(),
snapshot.taskId() == null ? null : snapshot.taskId().toString(),
snapshot.operationId() == null ? null : snapshot.operationId().toString(),
snapshot.actorType(),
snapshot.actorId(),
snapshot.action(),
snapshot.reason(),
auditPayloadToObject(snapshot.beforeSnapshotJson()),
auditPayloadToObject(snapshot.afterSnapshotJson()),
auditOccurredAt(snapshot));
}
/**
* 格式化审计发生时间,固定输出带秒的 UTC ISO 8601 字符串。
*/
private String auditOccurredAt(ReservationAuditLogSnapshot snapshot) {
if (snapshot.occurredAt() == null) {
return null;
}
return UtcTimeFormatter.toUtcOffsetDateTime(snapshot.occurredAt()).toInstant().toString();
}
/**
* 将审计 JSON 快照转换为安全对象,避免业务审计展示泄漏原始正文或敏感 token。
*/
private Object auditPayloadToObject(String payloadJson) {
JsonNode payload = parseJson(payloadJson);
if (payload == null || payload.isNull() || payload.isMissingNode()) {
return null;
}
return objectMapper.convertValue(sanitizeAuditPayload(payload), Object.class);
}
/**
* 递归清理审计快照中的敏感字段,保留状态码、目标 ID 和普通业务摘要。
*/
private JsonNode sanitizeAuditPayload(JsonNode payload) {
if (payload == null || payload.isNull() || payload.isMissingNode()) {
return NullNode.getInstance();
}
if (payload.isObject()) {
ObjectNode sanitized = objectMapper.createObjectNode();
Iterator<Map.Entry<String, JsonNode>> iterator = payload.fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> entry = iterator.next();
if (isSensitiveAuditField(entry.getKey())) {
continue;
}
sanitized.set(entry.getKey(), sanitizeAuditPayload(entry.getValue()));
}
return sanitized;
}
if (payload.isArray()) {
ArrayNode sanitized = objectMapper.createArrayNode();
for (JsonNode item : payload) {
sanitized.add(sanitizeAuditPayload(item));
}
return sanitized;
}
return payload;
}
/**
* 判断审计字段名是否属于前端不应展示的敏感信息。
*/
private boolean isSensitiveAuditField(String fieldName) {
String normalized = fieldName == null ? "" : fieldName.toLowerCase(Locale.ROOT);
return "raw".equals(normalized)
|| normalized.endsWith("_raw")
|| normalized.contains("payload_json")
|| normalized.contains("private_url")
|| normalized.contains("secret")
|| normalized.contains("token")
|| normalized.contains("body")
|| normalized.contains("html");
}
private ReservationV4CardCountsResult cardCounts(List<ReservationV4TaskCardSnapshot> cards) {
List<ReservationV4TaskCardSnapshot> safeCards = cards == null ? List.of() : cards;
return new ReservationV4CardCountsResult(

View File

@@ -588,12 +588,16 @@ class ReservationFrontendQueryControllerTest {
.value(contains("ACTIVE")))
.andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].open_task_count")
.value(contains(1)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].open_work_item_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + activeOrderId + "')].next_processable_task_id")
.value(contains(pendingTaskId.toString())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].order_status")
.value(contains("ENDED")))
.andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].open_task_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + endedOrderId + "')].open_work_item_count")
.value(contains(0)))
.andExpect(jsonPath("$.page.total").value(2));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
@@ -732,6 +736,8 @@ class ReservationFrontendQueryControllerTest {
.value(contains(legacyTaskId.toString())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].v4_open_order_task_count")
.value(contains(2)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].open_work_item_count")
.value(contains(2)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].next_v4_order_task_id")
.value(contains(basicFirstOrderTask.id().toString())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + basicFirstOrderId + "')].next_v4_action_card_id")
@@ -742,6 +748,8 @@ class ReservationFrontendQueryControllerTest {
.value(contains("PENDING_CONFIRM")))
.andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].v4_open_order_task_count")
.value(contains(1)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].open_work_item_count")
.value(contains(1)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].next_v4_order_task_id")
.value(contains(reviewOrderTask.id().toString())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + reviewFirstOrderId + "')].next_v4_action_card_id")
@@ -760,6 +768,8 @@ class ReservationFrontendQueryControllerTest {
.value(contains("PENDING_CONFIRM")))
.andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].v4_open_order_task_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].open_work_item_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].next_v4_order_task_id")
.value(contains(nullValue())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + completedOnlyOrderId + "')].next_v4_action_type")
@@ -768,6 +778,8 @@ class ReservationFrontendQueryControllerTest {
.value(contains(nullValue())))
.andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].v4_open_order_task_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].open_work_item_count")
.value(contains(0)))
.andExpect(jsonPath("$.items[?(@.order_id=='" + noV4OrderId + "')].next_v4_action_type")
.value(contains("NONE")));
}

View File

@@ -22,6 +22,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResu
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
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;
@@ -367,6 +368,58 @@ class ReservationV4QueryControllerTest {
.andExpect(content().string(not(containsString("Sensitive raw notification body"))));
}
@Test
void shouldReturnV4OrderTaskAudits() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTask(
"mail-v4-query-order-task-audits-001",
Instant.parse("2026-07-18T04:10:00Z"));
insertV4OrderTaskAudit(orderTask, "V4_CARD_CONFIRM", "v4-query-admin",
Instant.parse("2026-07-18T04:11:00Z"));
insertV4OrderTaskAudit(orderTask, "V4_CARD_REVIEW_RESOLVE", "v4-query-admin",
Instant.parse("2026-07-18T04:12:00Z"));
ReservationV4OrderTaskSnapshot otherOrderTask = seedOrderTask(
"mail-v4-query-order-task-audits-other-001",
Instant.parse("2026-07-18T04:13:00Z"));
insertV4OrderTaskAudit(otherOrderTask, "V4_CARD_CONFIRM", "v4-query-admin",
Instant.parse("2026-07-18T04:14:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits",
orderTask.id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.items.length()").value(2))
.andExpect(jsonPath("$.items[0].action").value("V4_CARD_CONFIRM"))
.andExpect(jsonPath("$.items[0].actor_id").value("v4-query-admin"))
.andExpect(jsonPath("$.items[0].after_snapshot.v4_order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.items[1].action").value("V4_CARD_REVIEW_RESOLVE"))
.andExpect(jsonPath("$.items[1].after_snapshot.v4_order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
.andExpect(content().string(not(containsString(otherOrderTask.id().toString()))))
.andExpect(content().string(not(containsString("Sensitive raw audit body"))));
}
@Test
void shouldReturnV4SourceNotificationAudits() throws Exception {
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
"mail-v4-query-notification-audits-001",
"S10",
Instant.parse("2026-07-18T04:20:00Z"));
insertV4SourceNotificationAudit(notification, "v4-query-admin",
Instant.parse("2026-07-18T04:21:00Z"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}/audits",
notification.id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notification_id").value(notification.id().toString()))
.andExpect(jsonPath("$.items.length()").value(1))
.andExpect(jsonPath("$.items[0].action").value("V4_SOURCE_NOTIFICATION_ACK"))
.andExpect(jsonPath("$.items[0].actor_type").value("USER"))
.andExpect(jsonPath("$.items[0].after_snapshot.v4_source_notification_id")
.value(notification.id().toString()))
.andExpect(jsonPath("$.items[0].occurred_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
.andExpect(content().string(not(containsString("Sensitive raw audit body"))));
}
@Test
void shouldRejectV4WorkbenchItemsWithoutLogin() throws Exception {
mockMvc.perform(get("/api/reservation/workbench-items")
@@ -400,6 +453,14 @@ class ReservationV4QueryControllerTest {
notification.id()).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}/audits",
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}/audits",
notification.id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
@@ -427,6 +488,14 @@ class ReservationV4QueryControllerTest {
notification.id()).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}/audits",
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}/audits",
notification.id()).param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
@@ -670,4 +739,49 @@ class ReservationV4QueryControllerTest {
"PAYMENT.attachment_ids 未匹配来源附件。",
now));
}
private void insertV4OrderTaskAudit(
ReservationV4OrderTaskSnapshot orderTask,
String action,
String actorId,
Instant occurredAt) {
aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft(
HOTEL_ID,
orderTask.orderId(),
null,
null,
"USER",
actorId,
action,
"前端查询 V4 审计测试。",
"""
{"v4_order_task_id":"%s","card_status":"PENDING_CONFIRM","raw":"Sensitive raw audit body"}
""".formatted(orderTask.id()),
"""
{"v4_order_task_id":"%s","card_status":"CONFIRMED","field_pointers":["/basic_information/account_code"]}
""".formatted(orderTask.id()),
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
}
private void insertV4SourceNotificationAudit(
ReservationV4SourceNotificationSnapshot notification,
String actorId,
Instant occurredAt) {
aiWorkflowRepository.insertAuditLog(new ReservationAuditLogDraft(
HOTEL_ID,
null,
null,
null,
"USER",
actorId,
"V4_SOURCE_NOTIFICATION_ACK",
"确认已处理。",
"""
{"v4_source_notification_id":"%s","notification_status":"ACK_REQUIRED","raw":"Sensitive raw audit body"}
""".formatted(notification.id()),
"""
{"v4_source_notification_id":"%s","notification_status":"ACKED"}
""".formatted(notification.id()),
LocalDateTime.ofInstant(occurredAt, ZoneOffset.UTC)));
}
}