修复V4复核指针运行时白名单一致性
This commit is contained in:
@@ -47,6 +47,8 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -57,6 +59,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class ReservationV4CommandServiceImpl implements ReservationV4CommandService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ReservationV4CommandServiceImpl.class);
|
||||
private static final String ACTOR_TYPE_USER = "USER";
|
||||
private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM";
|
||||
private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE";
|
||||
@@ -110,16 +113,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
"rate_code",
|
||||
"room_items",
|
||||
"trace_items");
|
||||
private static final Set<String> ROOM_INFORMATION_READONLY_FINAL_FIELDS = Set.of(
|
||||
"adult",
|
||||
"adults",
|
||||
"adult_count",
|
||||
"block_id",
|
||||
"confirmation_number",
|
||||
"group_booking_status_label",
|
||||
"nights",
|
||||
"target_order");
|
||||
|
||||
private final ReservationV4WorkflowRepository workflowRepository;
|
||||
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
private final ReservationAiWorkflowRepository auditRepository;
|
||||
@@ -692,42 +685,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
String bookingType,
|
||||
List<String> path,
|
||||
JsonNode finalValues) {
|
||||
if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) {
|
||||
return false;
|
||||
}
|
||||
if (path.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) {
|
||||
return false;
|
||||
}
|
||||
if (path.size() == 1) {
|
||||
String fieldName = path.get(0);
|
||||
if ("group_block_name".equals(fieldName)) {
|
||||
return BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(card.eventType());
|
||||
}
|
||||
if ("fit_name".equals(fieldName)) {
|
||||
return BOOKING_TYPE_FIT.equals(bookingType);
|
||||
}
|
||||
if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
if ("rate_code".equals(fieldName)) {
|
||||
return EVENT_NEW_BOOKING.equals(card.eventType());
|
||||
}
|
||||
if ("breakfast_included".equals(fieldName)) {
|
||||
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
|
||||
return BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
|
||||
}
|
||||
if ("group_booking_status".equals(fieldName)) {
|
||||
return BOOKING_TYPE_GROUP.equals(bookingType);
|
||||
}
|
||||
if ("room_items".equals(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) {
|
||||
return "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2));
|
||||
}
|
||||
return false;
|
||||
return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath(
|
||||
card.eventType(), bookingType, path, finalValues);
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessLeaves(
|
||||
@@ -1717,18 +1676,21 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
|
||||
}
|
||||
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments);
|
||||
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, pointer, segments);
|
||||
if (value != null && value.isContainerNode()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。");
|
||||
}
|
||||
JsonNode current = findPointerValue(confirmedPayload, segments);
|
||||
if (current == null || current.isMissingNode()) {
|
||||
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
|
||||
}
|
||||
if (current.isContainerNode()) {
|
||||
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_CONTAINER", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。");
|
||||
}
|
||||
if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) {
|
||||
logReviewPointerRejected(card, pointer, "POINTER_RULE_REJECTED", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
|
||||
}
|
||||
}
|
||||
@@ -1736,19 +1698,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void ensureReviewPointerInsideEditableContainer(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
String pointer,
|
||||
List<String> segments) {
|
||||
if (segments.size() < 2) {
|
||||
logReviewPointerRejected(card, pointer, "POINTER_TOO_SHALLOW", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
|
||||
}
|
||||
String root = segments.get(0);
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
if (!"basic_information".equals(root)) {
|
||||
logReviewPointerRejected(card, pointer, "BASIC_INFORMATION_ROOT_MISMATCH", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Basic Information 卡允许编辑字段内。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) {
|
||||
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, segments);
|
||||
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments);
|
||||
return;
|
||||
}
|
||||
if ("business_fields".equals(root)) {
|
||||
@@ -1758,6 +1723,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
return;
|
||||
}
|
||||
if (!"business_fields".equals(root)) {
|
||||
logReviewPointerRejected(card, pointer, "BUSINESS_ROOT_NOT_WRITABLE", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。");
|
||||
}
|
||||
}
|
||||
@@ -1765,21 +1731,63 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void ensureRoomInformationReviewPointerWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
String pointer,
|
||||
List<String> segments) {
|
||||
if (segments.size() < 3 || !"final_values".equals(segments.get(1))) {
|
||||
logReviewPointerRejected(card, pointer, "ROOM_INFORMATION_NOT_FINAL_VALUES", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
|
||||
}
|
||||
List<String> finalValuePath = segments.subList(2, segments.size());
|
||||
if (finalValuePath.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) {
|
||||
ReservationV4RoomInformationFieldPolicy.RoomInformationWriteDecision decision =
|
||||
ReservationV4RoomInformationFieldPolicy.finalValueWriteDecision(
|
||||
card.eventType(),
|
||||
textAt(confirmedPayload.path("room_information"), "booking_type"),
|
||||
finalValuePath,
|
||||
confirmedPayload.path("room_information").path("final_values"));
|
||||
if (decision.readonly()) {
|
||||
logReviewPointerRejected(card, pointer, decision.reasonCode(), confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
|
||||
}
|
||||
String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type");
|
||||
JsonNode finalValues = confirmedPayload.path("room_information").path("final_values");
|
||||
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath, finalValues)) {
|
||||
if (!decision.writable()) {
|
||||
logReviewPointerRejected(card, pointer, decision.reasonCode(), confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
|
||||
}
|
||||
}
|
||||
|
||||
private void logReviewPointerRejected(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String pointer,
|
||||
String reasonCode,
|
||||
ObjectNode confirmedPayload) {
|
||||
if (!log.isWarnEnabled()) {
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1, "
|
||||
+ "card_id={}, card_type={}, event_type={}, card_status={}, review_status={}, pointer={}, "
|
||||
+ "reason_code={}, stable_room_information_payload={}",
|
||||
card.id(),
|
||||
card.cardType(),
|
||||
card.eventType(),
|
||||
card.cardStatus(),
|
||||
card.reviewStatus(),
|
||||
safeLogPointer(pointer),
|
||||
reasonCode,
|
||||
isStableRoomInformationPayload(confirmedPayload));
|
||||
}
|
||||
|
||||
private String safeLogPointer(String pointer) {
|
||||
if (pointer == null) {
|
||||
return null;
|
||||
}
|
||||
String sanitized = pointer
|
||||
.replace('\r', '_')
|
||||
.replace('\n', '_')
|
||||
.replace('\t', '_');
|
||||
int maxLength = 256;
|
||||
return sanitized.length() <= maxLength ? sanitized : sanitized.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private JsonNode findPointerValue(JsonNode root, List<String> segments) {
|
||||
JsonNode current = root;
|
||||
for (String segment : segments) {
|
||||
|
||||
@@ -1591,28 +1591,35 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
}
|
||||
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
|
||||
if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) {
|
||||
addRoomInformationField(fields, "group_block_name", "Group Block Name", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "group_block_name", "Group Block Name",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "text", null);
|
||||
}
|
||||
if (BOOKING_TYPE_FIT.equals(bookingType) && (EVENT_NEW_BOOKING.equals(eventType) || EVENT_UPDATE_BOOKING.equals(eventType))) {
|
||||
addRoomInformationField(fields, "fit_name", "Fit Name", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "fit_name", "Fit Name",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "text", null);
|
||||
}
|
||||
addRoomInformationField(fields, "arrival_date", "入住日期", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "arrival_date", "入住日期",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "date", null);
|
||||
addRoomInformationField(fields, "departure_date", "离店日期", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "departure_date", "离店日期",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "date", null);
|
||||
if (EVENT_NEW_BOOKING.equals(eventType)) {
|
||||
addRoomInformationField(fields, "rate_code", "Rate Code", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "rate_code", "Rate Code",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "select", "reservation_v4_rate_code_catalog");
|
||||
}
|
||||
addRoomItemsFields(fields, finalValues.path("room_items"), validationErrors, availability);
|
||||
addRoomItemsFields(fields, eventType, bookingType, finalValues, validationErrors, availability);
|
||||
if (BOOKING_TYPE_FIT.equals(bookingType) && !finalValues.hasNonNull("breakfast_included")) {
|
||||
addRoomInformationField(fields, "breakfast_included", "含早", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "breakfast_included", "含早",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "checkbox", null);
|
||||
}
|
||||
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
|
||||
addRoomInformationField(fields, "group_booking_status", "Group Booking Status", finalValues, validationErrors, availability,
|
||||
addRoomInformationField(fields, eventType, bookingType, "group_booking_status", "Group Booking Status",
|
||||
finalValues, validationErrors, availability,
|
||||
true, "select", "reservation_v4_group_booking_status_fixed");
|
||||
}
|
||||
return fields;
|
||||
@@ -1620,9 +1627,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|
||||
private void addRoomItemsFields(
|
||||
List<ReservationV4TaskCardFieldResult> fields,
|
||||
JsonNode roomItems,
|
||||
String eventType,
|
||||
String bookingType,
|
||||
JsonNode finalValues,
|
||||
JsonNode validationErrors,
|
||||
ReservationV4ActionAvailabilityResult availability) {
|
||||
JsonNode roomItems = finalValues.path("room_items");
|
||||
if (roomItems == null || !roomItems.isArray()) {
|
||||
return;
|
||||
}
|
||||
@@ -1631,15 +1641,19 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (item == null || !item.isObject()) {
|
||||
continue;
|
||||
}
|
||||
addRoomItemField(fields, index, "room_type_code", "房型代码", item, validationErrors, availability,
|
||||
addRoomItemField(fields, eventType, bookingType, index, "room_type_code", "房型代码",
|
||||
item, finalValues, validationErrors, availability,
|
||||
true, "select", "reservation_v4_room_type_catalog");
|
||||
addRoomItemField(fields, index, "room_count", "房间数", item, validationErrors, availability,
|
||||
addRoomItemField(fields, eventType, bookingType, index, "room_count", "房间数",
|
||||
item, finalValues, validationErrors, availability,
|
||||
true, "number", null);
|
||||
}
|
||||
}
|
||||
|
||||
private void addRoomInformationField(
|
||||
List<ReservationV4TaskCardFieldResult> fields,
|
||||
String eventType,
|
||||
String bookingType,
|
||||
String fieldName,
|
||||
String displayName,
|
||||
JsonNode finalValues,
|
||||
@@ -1650,12 +1664,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
String optionsSource) {
|
||||
String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName);
|
||||
String fieldPath = "room_information.final_values." + fieldName;
|
||||
List<String> finalValuePath = List.of(fieldName);
|
||||
fields.add(field(
|
||||
fieldPath,
|
||||
pointer,
|
||||
displayName,
|
||||
finalValues.path(fieldName),
|
||||
roomInformationFieldEditable(availability, validationErrors, pointer, finalValues.path(fieldName)),
|
||||
roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues),
|
||||
required,
|
||||
controlType,
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
@@ -1668,10 +1683,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|
||||
private void addRoomItemField(
|
||||
List<ReservationV4TaskCardFieldResult> fields,
|
||||
String eventType,
|
||||
String bookingType,
|
||||
int index,
|
||||
String fieldName,
|
||||
String displayName,
|
||||
JsonNode roomItem,
|
||||
JsonNode finalValues,
|
||||
JsonNode validationErrors,
|
||||
ReservationV4ActionAvailabilityResult availability,
|
||||
boolean required,
|
||||
@@ -1679,12 +1697,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
String optionsSource) {
|
||||
String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName);
|
||||
String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName;
|
||||
List<String> finalValuePath = List.of("room_items", String.valueOf(index), fieldName);
|
||||
fields.add(field(
|
||||
fieldPath,
|
||||
pointer,
|
||||
displayName,
|
||||
roomItem.path(fieldName),
|
||||
roomInformationFieldEditable(availability, validationErrors, pointer, roomItem.path(fieldName)),
|
||||
roomInformationFieldEditable(availability, eventType, bookingType, finalValuePath, finalValues),
|
||||
required,
|
||||
controlType,
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
@@ -1697,13 +1716,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|
||||
private boolean roomInformationFieldEditable(
|
||||
ReservationV4ActionAvailabilityResult availability,
|
||||
JsonNode validationErrors,
|
||||
String pointer,
|
||||
JsonNode currentValue) {
|
||||
String eventType,
|
||||
String bookingType,
|
||||
List<String> finalValuePath,
|
||||
JsonNode finalValues) {
|
||||
if (!availability.editable()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return ReservationV4RoomInformationFieldPolicy.isWritableFinalValuePath(
|
||||
eventType, bookingType, finalValuePath, finalValues);
|
||||
}
|
||||
|
||||
private Set<String> validationErrorPointers(JsonNode validationErrors) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reservation V4 Room Information 字段写入策略。
|
||||
* 查询侧 fields[] 和命令侧确认 / 复核共用本类,避免前端看到可编辑但后端拒绝同一 pointer。
|
||||
*/
|
||||
final class ReservationV4RoomInformationFieldPolicy {
|
||||
|
||||
private static final String EVENT_NEW_BOOKING = "NEW_BOOKING";
|
||||
private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING";
|
||||
private static final String BOOKING_TYPE_GROUP = "GROUP";
|
||||
private static final String BOOKING_TYPE_FIT = "FIT";
|
||||
private static final Set<String> READONLY_FINAL_FIELDS = Set.of(
|
||||
"adult",
|
||||
"adults",
|
||||
"adult_count",
|
||||
"block_id",
|
||||
"confirmation_number",
|
||||
"group_booking_status_label",
|
||||
"nights",
|
||||
"target_order");
|
||||
|
||||
private ReservationV4RoomInformationFieldPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Room Information final_values 下的路径是否允许用户写入。
|
||||
*/
|
||||
static boolean isWritableFinalValuePath(
|
||||
String eventType,
|
||||
String bookingType,
|
||||
List<String> path,
|
||||
JsonNode finalValues) {
|
||||
return finalValueWriteDecision(eventType, bookingType, path, finalValues).writable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 Room Information final_values 写入决策,用于运行时诊断日志。
|
||||
*/
|
||||
static RoomInformationWriteDecision finalValueWriteDecision(
|
||||
String eventType,
|
||||
String bookingType,
|
||||
List<String> path,
|
||||
JsonNode finalValues) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return new RoomInformationWriteDecision(false, false, "PATH_EMPTY");
|
||||
}
|
||||
if (EVENT_CANCEL_BOOKING.equals(eventType)) {
|
||||
return new RoomInformationWriteDecision(false, false, "CANCEL_BOOKING_READONLY");
|
||||
}
|
||||
if (isReadonlyFinalValuePath(path)) {
|
||||
return new RoomInformationWriteDecision(false, true, "DERIVED_OR_SYSTEM_FIELD_READONLY");
|
||||
}
|
||||
if (path.size() == 1) {
|
||||
return topLevelWriteDecision(eventType, bookingType, path.get(0), finalValues);
|
||||
}
|
||||
if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) {
|
||||
boolean writable = "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2));
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "ROOM_ITEM_FIELD_NOT_WRITABLE");
|
||||
}
|
||||
return new RoomInformationWriteDecision(false, false, "PATH_NOT_IN_ROOM_INFORMATION_WHITELIST");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断路径是否命中 Room Information 系统派生或只读字段。
|
||||
*/
|
||||
static boolean isReadonlyFinalValuePath(List<String> path) {
|
||||
return path != null && path.stream().anyMatch(READONLY_FINAL_FIELDS::contains);
|
||||
}
|
||||
|
||||
private static RoomInformationWriteDecision topLevelWriteDecision(
|
||||
String eventType,
|
||||
String bookingType,
|
||||
String fieldName,
|
||||
JsonNode finalValues) {
|
||||
if ("group_block_name".equals(fieldName)) {
|
||||
boolean writable = BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType);
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "GROUP_BLOCK_NAME_ONLY_NEW_GROUP");
|
||||
}
|
||||
if ("fit_name".equals(fieldName)) {
|
||||
boolean writable = BOOKING_TYPE_FIT.equals(bookingType);
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "FIT_NAME_ONLY_FIT");
|
||||
}
|
||||
if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) {
|
||||
return new RoomInformationWriteDecision(true, false, "ALLOWED");
|
||||
}
|
||||
if ("rate_code".equals(fieldName)) {
|
||||
boolean writable = EVENT_NEW_BOOKING.equals(eventType);
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "RATE_CODE_ONLY_NEW_BOOKING");
|
||||
}
|
||||
if ("breakfast_included".equals(fieldName)) {
|
||||
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
|
||||
boolean writable = BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "BREAKFAST_ONLY_FIT_WHEN_NOT_DERIVED");
|
||||
}
|
||||
if ("group_booking_status".equals(fieldName)) {
|
||||
boolean writable = BOOKING_TYPE_GROUP.equals(bookingType);
|
||||
return new RoomInformationWriteDecision(writable, false,
|
||||
writable ? "ALLOWED" : "GROUP_BOOKING_STATUS_ONLY_GROUP");
|
||||
}
|
||||
if ("room_items".equals(fieldName)) {
|
||||
return new RoomInformationWriteDecision(true, false, "ALLOWED");
|
||||
}
|
||||
return new RoomInformationWriteDecision(false, false, "TOP_LEVEL_FIELD_NOT_WRITABLE");
|
||||
}
|
||||
|
||||
private static boolean isMissingOrNull(JsonNode node) {
|
||||
return node == null || node.isMissingNode() || node.isNull();
|
||||
}
|
||||
|
||||
private static Integer parseArrayIndex(String segment) {
|
||||
if (segment == null || segment.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (segment.length() > 1 && segment.startsWith("0")) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < segment.length(); i++) {
|
||||
if (!Character.isDigit(segment.charAt(i))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(segment);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room Information 字段写入决策,reasonCode 只用于安全日志和排查,不作为前端契约字段。
|
||||
*/
|
||||
record RoomInformationWriteDecision(boolean writable, boolean readonly, String reasonCode) {
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetR
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
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.ObjectNode;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -47,6 +51,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
@@ -91,6 +96,9 @@ class ReservationV4CommandControllerTest {
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private String adminToken;
|
||||
private String noPermissionToken;
|
||||
|
||||
@@ -1091,6 +1099,86 @@ class ReservationV4CommandControllerTest {
|
||||
.value("/room_information/final_values/room_items/0/room_type_code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptEditableReviewFieldPointersReturnedByTaskDetail() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-review-pointer-runtime-001",
|
||||
Instant.parse("2026-07-19T01:22:18Z"),
|
||||
990000000000070116L,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(),
|
||||
"UPDATE_BOOKING",
|
||||
"""
|
||||
{
|
||||
"card_type":"ROOM_INFORMATION",
|
||||
"event_type":"UPDATE_BOOKING",
|
||||
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RUNTIME-001"},
|
||||
"business_fields":{
|
||||
"event_type":"UPDATE_BOOKING",
|
||||
"after":{
|
||||
"arrival_date":"2026-08-01",
|
||||
"departure_date":"2026-08-03",
|
||||
"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"field_path": "room_information.final_values.room_items.0.room_type_code",
|
||||
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
|
||||
"message": "房型代码不在第一版目录中。",
|
||||
"detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。"
|
||||
}
|
||||
]
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
MvcResult detailResult = performAuthorized(mockMvc, adminToken(),
|
||||
get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString());
|
||||
ArrayNode overrides = objectMapper.createArrayNode();
|
||||
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
|
||||
if (!field.path("editable").asBoolean(false)
|
||||
|| !"review_resolution.field_overrides".equals(field.path("write_target").asText())) {
|
||||
continue;
|
||||
}
|
||||
String pointer = field.path("field_pointer").asText();
|
||||
ObjectNode override = objectMapper.createObjectNode();
|
||||
override.put("field_pointer", pointer);
|
||||
override.set("value", reviewOverrideValue(pointer));
|
||||
overrides.add(override);
|
||||
}
|
||||
org.assertj.core.api.Assertions.assertThat(overrides)
|
||||
.as("任务详情返回的可复核字段必须能直接提交给 review-resolution")
|
||||
.isNotEmpty();
|
||||
org.assertj.core.api.Assertions.assertThat(overrides.toString())
|
||||
.contains("/room_information/final_values/room_items/0/room_type_code");
|
||||
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("version", 0);
|
||||
request.put("reason", "回归验证详情页可编辑 pointer 与命令侧白名单一致");
|
||||
request.set("field_overrides", overrides);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post(
|
||||
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
@@ -1770,6 +1858,34 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
private JsonNode reviewOverrideValue(String pointer) {
|
||||
if (pointer.endsWith("/group_block_name")) {
|
||||
return objectMapper.getNodeFactory().textNode("GRP-V4-REVIEW-RUNTIME-001");
|
||||
}
|
||||
if (pointer.endsWith("/arrival_date")) {
|
||||
return objectMapper.getNodeFactory().textNode("2026-08-01");
|
||||
}
|
||||
if (pointer.endsWith("/departure_date")) {
|
||||
return objectMapper.getNodeFactory().textNode("2026-08-03");
|
||||
}
|
||||
if (pointer.endsWith("/rate_code")) {
|
||||
return objectMapper.getNodeFactory().textNode("GROUP");
|
||||
}
|
||||
if (pointer.endsWith("/room_type_code")) {
|
||||
return objectMapper.getNodeFactory().textNode("RM2");
|
||||
}
|
||||
if (pointer.endsWith("/room_count")) {
|
||||
return objectMapper.getNodeFactory().numberNode(2);
|
||||
}
|
||||
if (pointer.endsWith("/group_booking_status")) {
|
||||
return objectMapper.getNodeFactory().textNode("TEN");
|
||||
}
|
||||
if (pointer.endsWith("/breakfast_included")) {
|
||||
return objectMapper.getNodeFactory().booleanNode(true);
|
||||
}
|
||||
return objectMapper.getNodeFactory().textNode("UPDATED");
|
||||
}
|
||||
|
||||
private void confirmRoomInformationBusinessCard(SeededOrderTask seeded, String groupBookingStatus) throws Exception {
|
||||
String groupBookingStatusFragment = groupBookingStatus == null
|
||||
? ""
|
||||
|
||||
Reference in New Issue
Block a user