收口V4 Trace卡后端契约

This commit is contained in:
andy
2026-07-21 17:47:25 +07:00
parent 0c4f5e298d
commit 38661257d1
16 changed files with 1054 additions and 21 deletions

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation V4 任务卡字段固定选项结果。用于无独立 lookup API 的小范围固定枚举。
*
* @param value 选项稳定值,前端提交时使用
* @param label 选项展示文案
*/
public record ReservationV4TaskCardFieldOptionResult(
@JsonProperty("value")
String value,
@JsonProperty("label")
String label
) {
}

View File

@@ -17,6 +17,7 @@ import java.util.List;
* @param editScope 编辑范围,区分普通确认、人工复核和只读
* @param writeTarget 用户修改值写入目标
* @param optionsSource 选项来源,目录未接口化前使用稳定代码
* @param fixedOptions 固定选项列表;仅用于无 lookup API 的小范围枚举
* @param rawReadonly 是否来源、派生或诊断字段,只读展示
* @param validationErrors 当前字段对应的后端校验错误
* @param controlHint 给前端的控件补充提示
@@ -39,6 +40,8 @@ public record ReservationV4TaskCardFieldResult(
String writeTarget,
@JsonProperty("options_source")
String optionsSource,
@JsonProperty("fixed_options")
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
@JsonProperty("raw_readonly")
Boolean rawReadonly,
@JsonProperty("validation_errors")

View File

@@ -423,7 +423,7 @@ final class ReservationAiV4CallbackValidator {
|| !traceItem.has("department_code")
|| !isStringOrNull(traceItem.path("target_room_type_code"))
|| !isStringOrNull(traceItem.path("department_code"))
|| !isPositiveIntOrNull(traceItem.path("extra_bed_room_count"))) {
|| !isNumberOrNull(traceItem.path("extra_bed_room_count"))) {
return new ReservationAiV4CallbackIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。");
}
continue;
@@ -624,6 +624,13 @@ final class ReservationAiV4CallbackValidator {
return node != null && (node.isNull() || node.isTextual());
}
/**
* 判断字段值是否为数字或 null。Trace 加床数量的正整数业务校验由 V4 卡片校验承接,避免可人工修正值被契约层拦截。
*/
private boolean isNumberOrNull(JsonNode node) {
return node != null && (node.isNull() || node.isNumber());
}
/**
* 判断 JSON 节点是否为布尔 true。
*/

View File

@@ -538,6 +538,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
overlayRoomInformationPayload(orderTask, card, payload, submittedPayload);
return;
}
if (isTraceCard(card)) {
overlayTracePayload(payload, submittedPayload);
return;
}
overlayBusinessPayload(card, payload, submittedPayload);
}
@@ -684,6 +688,40 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
/**
* Trace 卡只允许前端写入当前正式字段,不接受 content、target_order 或新增结构。
*/
private void overlayTracePayload(ObjectNode payload, ObjectNode submittedPayload) {
JsonNode traceItems = payload.path("trace_items");
if (!traceItems.isArray()) {
return;
}
JsonNode submittedTraceItems = submittedPayload.path("trace_items");
if (!submittedTraceItems.isArray()) {
submittedTraceItems = submittedPayload.path("business_fields").path("trace_items");
}
if (!submittedTraceItems.isArray()) {
return;
}
int size = Math.min(traceItems.size(), submittedTraceItems.size());
for (int index = 0; index < size; index++) {
JsonNode currentItem = traceItems.get(index);
JsonNode submittedItem = submittedTraceItems.get(index);
if (currentItem == null || !currentItem.isObject() || submittedItem == null || !submittedItem.isObject()) {
continue;
}
ObjectNode targetItem = (ObjectNode) currentItem;
for (ReservationV4TraceFieldPolicy.TraceEditableField field :
ReservationV4TraceFieldPolicy.editableFields(currentItem)) {
JsonNode submittedValue = submittedItem.get(field.fieldName());
JsonNode normalizedValue = ReservationV4TraceFieldPolicy.normalizedScalarValue(field.fieldName(), submittedValue);
if (normalizedValue != null) {
targetItem.set(field.fieldName(), normalizedValue);
}
}
}
}
private boolean isRoomInformationPathWritable(
ReservationV4TaskCardSnapshot card,
String bookingType,
@@ -782,12 +820,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) {
return false;
}
if (isTraceCard(card)) {
return isTraceBusinessPathWritable(path);
}
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
return false;
}
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
}
private boolean isTraceBusinessPathWritable(List<String> path) {
if (path == null || path.size() != 3 || !"trace_items".equals(path.get(0))) {
return false;
}
String fieldName = path.get(2);
return ReservationV4TraceFieldPolicy.FIELD_TEXT.equals(fieldName)
|| ReservationV4TraceFieldPolicy.FIELD_DEPARTMENT_CODE.equals(fieldName)
|| ReservationV4TraceFieldPolicy.FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName)
|| ReservationV4TraceFieldPolicy.FIELD_EXTRA_BED_ROOM_COUNT.equals(fieldName);
}
private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) {
List<String> details = new ArrayList<>();
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
@@ -851,6 +903,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details);
return;
}
if (isTraceCard(card)) {
JsonNode traceItems = ReservationV4TraceFieldPolicy.traceItems(payload);
String fieldPath = payload.path("business_fields").isObject() ? "business_fields.trace_items" : "trace_items";
ReservationV4TraceFieldPolicy.validateTraceItems(
traceItems,
fieldPath,
details,
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
return;
}
JsonNode businessFields = payload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
@@ -953,6 +1015,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(hotelId, field.getValue(), childPath, details);
} else if ("trace_items".equals(field.getKey())) {
ReservationV4TraceFieldPolicy.validateTraceItems(
field.getValue(),
childPath,
details,
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
} else {
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
}
@@ -1018,6 +1086,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
}
private boolean isTraceCard(ReservationV4TaskCardSnapshot card) {
return card != null && ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType());
}
private String textValue(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) {
return null;
@@ -1056,6 +1128,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) {
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
}
if (isTraceCard(card)) {
return traceConfirmedPayloadBase(card, displayPayload);
}
return displayPayload;
}
@@ -1070,9 +1145,25 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) {
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
}
if (isTraceCard(card)) {
return traceConfirmedPayloadBase(card, displayPayload);
}
return displayPayload;
}
/**
* 生成 Trace 确认基础快照,对外只保留正式 trace_items 字段。
*/
private ObjectNode traceConfirmedPayloadBase(ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
payload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
objectMapper,
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
return payload;
}
private boolean shouldUseStableRoomInformationPayload(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
@@ -1711,9 +1802,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
}
boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer);
boolean tracePointer = isTraceReviewPointer(card, confirmedPayload, segments);
JsonNode current = findPointerValue(confirmedPayload, segments);
if (current == null || current.isMissingNode()) {
if (stableRoomInformationPointer) {
if (stableRoomInformationPointer || tracePointer) {
return;
}
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
@@ -1750,6 +1842,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments);
return;
}
if (isTraceCard(card)) {
if (!ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload)) {
logReviewPointerRejected(card, pointer, "TRACE_POINTER_NOT_WRITABLE", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Trace 卡允许编辑字段内。");
}
return;
}
if ("business_fields".equals(root)) {
return;
}
@@ -1826,6 +1925,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
&& pointer.startsWith("/room_information/final_values/");
}
private boolean isTraceReviewPointer(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
List<String> segments) {
return isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload);
}
private List<String> roomInformationQuerySideEditablePointers(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload) {
@@ -1970,6 +2076,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
&& pointer.startsWith("/room_information/final_values/")) {
return true;
}
if (isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(
decodeJsonPointer(pointer),
confirmedPayload)) {
return true;
}
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
if (validationPointers.contains(pointer)) {
return true;

View File

@@ -39,6 +39,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source
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;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldOptionResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
@@ -88,6 +89,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
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 List<ReservationV4TaskCardFieldOptionResult> TRACE_DEPARTMENT_FIXED_OPTIONS = List.of(
new ReservationV4TaskCardFieldOptionResult("FO", "FO"),
new ReservationV4TaskCardFieldOptionResult("HSK", "HSK"),
new ReservationV4TaskCardFieldOptionResult("FO+HSK", "FO+HSK"));
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
@@ -460,6 +465,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) {
return paymentDisplayPayload(displayPayload, paymentAttachmentSources);
}
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
return traceDisplayPayload(card, displayPayload);
}
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
return safeBusinessPayload(displayPayload);
}
@@ -482,6 +490,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return safeBasicInformationPayload(confirmedPayload);
}
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
if (!hasText(card.confirmedPayloadJson())) {
return NullNode.getInstance();
}
return traceDisplayPayload(card, confirmedPayload);
}
return safeBusinessPayload(confirmedPayload);
}
@@ -532,6 +546,22 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return copied;
}
/**
* 生成 Trace 卡安全展示 payload前端只读取 trace_items[].text 等正式字段。
*/
private JsonNode traceDisplayPayload(ReservationV4TaskCardSnapshot card, JsonNode displayPayload) {
ObjectNode safePayload = objectMapper.createObjectNode();
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
safePayload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
if (card.sourceEventIndex() != null) {
safePayload.put("source_event_index", card.sourceEventIndex());
}
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
objectMapper,
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
return safePayload;
}
/**
* 生成 Payment 卡展示 payload补充付款凭证附件安全摘要不返回任何 URL 字段。
*/
@@ -1366,6 +1396,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) {
return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability);
}
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
return traceFields(card, displayPayload, validationErrors, availability);
}
return businessFields(card, displayPayload, validationErrors, availability);
}
@@ -2033,6 +2066,77 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
}
/**
* 构造 Trace 卡专属字段白名单;字段指针固定使用 /trace_items/{index}/...。
*/
private List<ReservationV4TaskCardFieldResult> traceFields(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
JsonNode traceItems = displayPayload.path("trace_items");
if (!traceItems.isArray()) {
traceItems = ReservationV4TraceFieldPolicy.traceItems(displayPayload);
}
if (!traceItems.isArray()) {
return List.of();
}
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
for (int index = 0; index < traceItems.size(); index++) {
JsonNode traceItem = traceItems.get(index);
for (ReservationV4TraceFieldPolicy.TraceEditableField editableField :
ReservationV4TraceFieldPolicy.editableFields(traceItem)) {
String fieldPath = "trace_items." + index + "." + editableField.fieldName();
String pointer = "/trace_items/" + index + "/" + escapeJsonPointer(editableField.fieldName());
JsonNode value = traceItem == null ? NullNode.getInstance() : traceItem.get(editableField.fieldName());
if (value == null || value.isMissingNode()) {
value = NullNode.getInstance();
}
List<String> fieldValidationErrors = traceValidationMessages(validationErrors, pointer, fieldPath);
boolean editable = traceFieldEditable(availability);
fields.add(field(
fieldPath,
pointer,
editableField.displayName(),
value,
editable,
true,
editableField.controlType(),
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
editableField.optionsSource(),
traceFixedOptions(editableField),
false,
fieldValidationErrors,
editableField.controlHint()));
}
}
return fields;
}
private List<ReservationV4TaskCardFieldOptionResult> traceFixedOptions(
ReservationV4TraceFieldPolicy.TraceEditableField editableField) {
if (ReservationV4TraceFieldPolicy.OPTIONS_SOURCE_DEPARTMENT.equals(editableField.optionsSource())) {
return TRACE_DEPARTMENT_FIXED_OPTIONS;
}
return List.of();
}
private boolean traceFieldEditable(ReservationV4ActionAvailabilityResult availability) {
return availability.editable();
}
private List<String> traceValidationMessages(JsonNode validationErrors, String pointer, String fieldPath) {
List<String> messages = validationMessages(validationErrors, pointer, fieldPath);
if (!messages.isEmpty()) {
return messages;
}
return validationMessages(
validationErrors,
"/business_fields" + pointer,
"business_fields." + fieldPath);
}
private void collectBusinessLeafFields(
List<ReservationV4TaskCardFieldResult> fields,
ReservationV4TaskCardSnapshot card,
@@ -2122,6 +2226,38 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
Boolean rawReadonly,
List<String> validationErrors,
String controlHint) {
return field(
fieldPath,
fieldPointer,
displayName,
value,
editable,
required,
controlType,
editScope,
writeTarget,
optionsSource,
List.of(),
rawReadonly,
validationErrors,
controlHint);
}
private ReservationV4TaskCardFieldResult field(
String fieldPath,
String fieldPointer,
String displayName,
JsonNode value,
Boolean editable,
Boolean required,
String controlType,
String editScope,
String writeTarget,
String optionsSource,
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
Boolean rawReadonly,
List<String> validationErrors,
String controlHint) {
return new ReservationV4TaskCardFieldResult(
fieldPath,
fieldPointer,
@@ -2133,6 +2269,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
editScope,
writeTarget,
optionsSource,
fixedOptions == null ? List.of() : fixedOptions,
rawReadonly,
validationErrors == null ? List.of() : validationErrors,
controlHint);

View File

@@ -214,7 +214,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
Long orderTaskId,
ReservationV4AcceptedEventDraft acceptedEvent,
LocalDateTime now) {
ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson());
ObjectNode displayPayload = normalizedBusinessDisplayPayload(
acceptedEvent.eventType(),
objectJson(acceptedEvent.displayPayloadJson()));
List<String> validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload);
boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty();
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
@@ -235,6 +237,22 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
));
}
/**
* 归一化业务卡展示快照Trace 卡只保留正式字段,避免 content 或原始定位字段进入后端存储展示层。
*/
private ObjectNode normalizedBusinessDisplayPayload(String eventType, ObjectNode displayPayload) {
if (!"TRACE_RESERVATION_NOTES".equals(eventType)) {
return displayPayload;
}
ObjectNode safePayload = objectMapper.createObjectNode();
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
safePayload.put("event_type", eventType);
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
objectMapper,
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
return safePayload;
}
/**
* 按 order_ref 保留 V4 event 原始数组顺序。
*/
@@ -399,6 +417,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
*/
private List<String> validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) {
List<String> details = new ArrayList<>();
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(textAt(displayPayload, "card_type"))) {
ReservationV4TraceFieldPolicy.validateTraceItems(
ReservationV4TraceFieldPolicy.traceItems(displayPayload),
"trace_items",
details,
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
return details;
}
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
@@ -420,6 +446,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
} else if ("room_items".equals(field.getKey())) {
validateRoomItems(hotelId, field.getValue(), childPath, details);
} else if ("trace_items".equals(field.getKey())) {
ReservationV4TraceFieldPolicy.validateTraceItems(
field.getValue(),
childPath,
details,
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
} else {
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
}

View File

@@ -0,0 +1,289 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
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 com.fasterxml.jackson.databind.node.TextNode;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
/**
* Reservation V4 Trace 卡字段策略。统一查询展示、确认写入、复核指针和字段校验的白名单。
*/
final class ReservationV4TraceFieldPolicy {
static final String OPTIONS_SOURCE_DEPARTMENT = "reservation_v4_trace_department_fixed";
static final String OPTIONS_SOURCE_ROOM_TYPE = "reservation_v4_room_type_catalog";
static final String ITEM_TYPE_GENERAL = "GENERAL";
static final String ITEM_TYPE_EXTRA_BED = "EXTRA_BED";
static final String FIELD_TEXT = "text";
static final String FIELD_DEPARTMENT_CODE = "department_code";
static final String FIELD_TARGET_ROOM_TYPE_CODE = "target_room_type_code";
static final String FIELD_EXTRA_BED_ROOM_COUNT = "extra_bed_room_count";
private static final String FIELD_ITEM_TYPE = "item_type";
private static final Set<String> ALLOWED_DEPARTMENT_CODES = Set.of("FO", "HSK", "FO+HSK");
private ReservationV4TraceFieldPolicy() {
}
/**
* 从当前 payload 中提取 Trace 事项数组,兼容历史 business_fields 包裹结构。
*/
static JsonNode traceItems(JsonNode payload) {
if (payload == null || payload.isMissingNode() || payload.isNull()) {
return NullNode.getInstance();
}
JsonNode businessFields = payload.path("business_fields");
if (businessFields.isObject()) {
return businessFields.path("trace_items");
}
return payload.path("trace_items");
}
/**
* 生成安全 Trace 事项数组,只保留当前后端正式字段,不透出 content、target_order 或原始证据。
*/
static ArrayNode sanitizedTraceItems(ObjectMapper objectMapper, JsonNode traceItems) {
ArrayNode sanitizedItems = objectMapper.createArrayNode();
if (traceItems == null || !traceItems.isArray()) {
return sanitizedItems;
}
for (JsonNode traceItem : traceItems) {
if (traceItem == null || !traceItem.isObject()) {
continue;
}
ObjectNode sanitizedItem = objectMapper.createObjectNode();
copyScalarField(traceItem, sanitizedItem, FIELD_ITEM_TYPE);
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
if (ITEM_TYPE_GENERAL.equals(itemType)) {
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
} else if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
} else {
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
}
sanitizedItems.add(sanitizedItem);
}
return sanitizedItems;
}
/**
* 返回指定 Trace item 当前允许前端编辑的字段清单。
*/
static List<TraceEditableField> editableFields(JsonNode traceItem) {
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
return List.of(
new TraceEditableField(FIELD_TARGET_ROOM_TYPE_CODE, "目标房型", "select",
OPTIONS_SOURCE_ROOM_TYPE, "Room Type 使用酒店目录 lookup不能自由输入。"),
new TraceEditableField(FIELD_EXTRA_BED_ROOM_COUNT, "加床房间数", "number",
null, "加床房间数必须是正整数。"),
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
OPTIONS_SOURCE_DEPARTMENT, "固定选项FO / HSK / FO+HSK。"));
}
return List.of(
new TraceEditableField(FIELD_TEXT, "Trace 内容", "textarea",
null, "普通 Trace 事项内容,以 trace_items[].text 为准。"),
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
OPTIONS_SOURCE_DEPARTMENT, "固定选项FO / HSK / FO+HSK。"));
}
/**
* 判断复核或确认写入 pointer 是否属于 Trace 卡当前正式字段白名单。
*/
static boolean isWritablePointer(List<String> segments, JsonNode payload) {
if (segments == null || segments.size() != 3 || !"trace_items".equals(segments.get(0))) {
return false;
}
Integer index = parseArrayIndex(segments.get(1));
if (index == null) {
return false;
}
JsonNode traceItems = traceItems(payload);
if (!traceItems.isArray() || index >= traceItems.size()) {
return false;
}
String fieldName = segments.get(2);
for (TraceEditableField field : editableFields(traceItems.get(index))) {
if (field.fieldName().equals(fieldName)) {
return true;
}
}
return false;
}
/**
* 校验 Trace 字段值,字段路径由调用方决定是否带 business_fields 前缀。
*/
static void validateTraceItems(
JsonNode traceItems,
String fieldPath,
List<String> details,
Predicate<String> roomTypeExists) {
if (traceItems == null || traceItems.isMissingNode() || traceItems.isNull()) {
details.add(fieldPath + ": Trace 事项不能为空。");
return;
}
if (!traceItems.isArray() || traceItems.isEmpty()) {
details.add(fieldPath + ": Trace 事项必须是非空数组。");
return;
}
for (int index = 0; index < traceItems.size(); index++) {
JsonNode item = traceItems.get(index);
String itemPath = fieldPath + "." + index;
if (item == null || !item.isObject()) {
details.add(itemPath + ": Trace 事项必须是对象。");
continue;
}
String itemType = textAt(item, FIELD_ITEM_TYPE);
if (!ITEM_TYPE_GENERAL.equals(itemType) && !ITEM_TYPE_EXTRA_BED.equals(itemType)) {
details.add(itemPath + ".item_type: Trace item_type 只能为 GENERAL 或 EXTRA_BED。");
continue;
}
validateDepartmentCode(item, itemPath, details);
if (ITEM_TYPE_GENERAL.equals(itemType)) {
validateRequiredText(item, FIELD_TEXT, itemPath + "." + FIELD_TEXT, "Trace 内容", details);
} else {
validateTargetRoomTypeCode(item, itemPath, details, roomTypeExists);
validateExtraBedRoomCount(item, itemPath, details);
}
}
}
/**
* 判断 Department Code 是否属于第一版固定三值。
*/
static boolean isAllowedDepartmentCode(String departmentCode) {
return departmentCode != null && ALLOWED_DEPARTMENT_CODES.contains(departmentCode);
}
private static void validateDepartmentCode(JsonNode item, String itemPath, List<String> details) {
String departmentCode = textAt(item, FIELD_DEPARTMENT_CODE);
if (!isAllowedDepartmentCode(departmentCode)) {
details.add(itemPath + "." + FIELD_DEPARTMENT_CODE + ": Department Code 只允许 FO、HSK、FO+HSK。");
}
}
private static void validateTargetRoomTypeCode(
JsonNode item,
String itemPath,
List<String> details,
Predicate<String> roomTypeExists) {
String code = textAt(item, FIELD_TARGET_ROOM_TYPE_CODE);
if (code == null) {
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 加床目标房型不能为空。");
return;
}
if (roomTypeExists != null && !roomTypeExists.test(code)) {
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 房型代码不在第一版目录中。");
}
}
private static void validateExtraBedRoomCount(JsonNode item, String itemPath, List<String> details) {
JsonNode value = item.get(FIELD_EXTRA_BED_ROOM_COUNT);
if (value == null
|| value.isNull()
|| !value.isIntegralNumber()
|| value.bigIntegerValue().compareTo(BigInteger.ZERO) <= 0
|| value.bigIntegerValue().compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
details.add(itemPath + "." + FIELD_EXTRA_BED_ROOM_COUNT + ": 加床房间数必须为正整数。");
}
}
private static void validateRequiredText(
JsonNode item,
String fieldName,
String fieldPath,
String displayName,
List<String> details) {
if (textAt(item, fieldName) == null) {
details.add(fieldPath + ": " + displayName + "不能为空。");
}
}
private static void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
JsonNode value = source.get(fieldName);
JsonNode normalizedValue = normalizedScalarValue(fieldName, value);
if (normalizedValue == null) {
return;
}
target.set(fieldName, normalizedValue);
}
/**
* 归一化 Trace 稳定代码字段,避免查询、确认和复核因为前后空格产生目录校验或展示差异。
*/
static JsonNode normalizedScalarValue(String fieldName, JsonNode value) {
if (value == null || value.isMissingNode() || value.isContainerNode()) {
return null;
}
if (value.isTextual() && isStableCodeField(fieldName)) {
return TextNode.valueOf(value.asText().trim());
}
return value;
}
private static boolean isStableCodeField(String fieldName) {
return FIELD_ITEM_TYPE.equals(fieldName)
|| FIELD_DEPARTMENT_CODE.equals(fieldName)
|| FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName);
}
private static String textAt(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode()) {
return null;
}
JsonNode value = node.get(fieldName);
if (value == null || value.isNull() || !value.isTextual()) {
return null;
}
String text = value.asText().trim();
return text.isEmpty() ? null : text;
}
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.valueOf(segment);
} catch (NumberFormatException exception) {
return null;
}
}
/**
* Trace 前端可编辑字段元数据。
*
* @param fieldName 字段名
* @param displayName 中文展示名
* @param controlType 前端控件类型
* @param optionsSource 选项来源
* @param controlHint 控件提示
*/
record TraceEditableField(
String fieldName,
String displayName,
String controlType,
String optionsSource,
String controlHint) {
}
}

View File

@@ -556,6 +556,11 @@ class ReservationV4CommandControllerTest {
"mail-v4-command-confirm-unknown-trace-target-room-001",
Instant.parse("2026-07-19T01:13:05Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-UNKNOWN-ROOM-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","text":"Please add one extra bed.","target_room_type_code":"UNKNOWN_ROOM","extra_bed_room_count":1,"department_code":"FO"}]}}
""");
@@ -572,7 +577,217 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("business_fields.trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
.andExpect(jsonPath("$.details[0]").value("trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
}
@Test
void shouldRejectBusinessCardConfirmWhenTraceDepartmentCodeInvalid() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-invalid-trace-department-001",
Instant.parse("2026-07-19T01:13:06Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-DEPARTMENT-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"GENERAL","text":"Guest requests quiet rooms.","department_code":"SALES"}]}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"));
}
@Test
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountNotPositive() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-invalid-trace-extra-bed-count-001",
Instant.parse("2026-07-19T01:13:07Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":0,"department_code":"FO+HSK"}]}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
}
@Test
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountDecimal() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-decimal-trace-extra-bed-count-001",
Instant.parse("2026-07-19T01:13:07Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-DECIMAL-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1.5,"department_code":"FO+HSK"}]}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
}
@Test
void shouldNormalizeTraceStableCodesWhenConfirming() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-trace-normalize-codes-001",
Instant.parse("2026-07-19T01:13:07Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-NORMALIZE-CODES-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":" TWN ","extra_bed_room_count":1,"department_code":" FO+HSK "}]}}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"trace_items": [
{
"item_type": "EXTRA_BED",
"target_room_type_code": " DBL ",
"extra_bed_room_count": 2,
"department_code": " FO "
}
]
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].target_room_type_code")
.value("DBL"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
.value("FO"));
}
@Test
void shouldConfirmTraceCardWithOnlyWhitelistedFields() throws Exception {
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-confirm-trace-whitelist-001",
Instant.parse("2026-07-19T01:13:08Z"),
null,
"GROUP",
"GROUP_CODE",
"GRP-V4-COMMAND-TRACE-WHITELIST-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{
"card_type":"TRACE_RESERVATION_NOTES",
"event_type":"TRACE_RESERVATION_NOTES",
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE-TARGET"},
"business_fields":{
"trace_items":[
{"item_type":"GENERAL","text":"Original trace","department_code":"FO","content":"SHOULD-NOT-PERSIST-CONTENT"},
{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1,"department_code":"FO+HSK","content":"SHOULD-NOT-PERSIST-EXTRA-CONTENT","attachment_url":"https://oss.example.test/trace-confirm-secret.png"}
]
}
}
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"target_order": {"locator_value": "SHOULD-NOT-PERSIST-SUBMITTED-TARGET"},
"trace_items": [
{
"item_type": "GENERAL",
"text": "Updated trace",
"department_code": "HSK",
"content": "SHOULD-NOT-PERSIST-SUBMITTED-CONTENT"
},
{
"item_type": "EXTRA_BED",
"target_room_type_code": "DBL",
"extra_bed_room_count": 2,
"department_code": "FO",
"content": "SHOULD-NOT-PERSIST-SUBMITTED-EXTRA-CONTENT",
"attachment_url": "https://oss.example.test/trace-submitted-secret.png"
}
]
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].text")
.value("Updated trace"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
.value("HSK"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].target_room_type_code")
.value("DBL"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].extra_bed_room_count")
.value(2))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].department_code")
.value("FO"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].content").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].attachment_url").doesNotExist())
.andExpect(content().string(not(containsString("SHOULD-NOT-PERSIST"))))
.andExpect(content().string(not(containsString("https://oss.example.test"))));
}
@Test
@@ -1266,6 +1481,95 @@ class ReservationV4CommandControllerTest {
.value("RM2"));
}
@Test
void shouldAcceptEditableTraceReviewFieldPointersReturnedByTaskDetail() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-trace-pointer-runtime-001",
Instant.parse("2026-07-19T01:22:28Z"),
990000000000070126L,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
"""
{
"card_type":"TRACE_RESERVATION_NOTES",
"event_type":"TRACE_RESERVATION_NOTES",
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE"},
"business_fields":{
"event_type":"TRACE_RESERVATION_NOTES",
"trace_items":[
{
"item_type":"GENERAL",
"text":"Guest requests quiet rooms.",
"department_code":"SALES",
"content":"SHOULD-NOT-PERSIST-CONTENT"
}
]
}
}
""",
"""
[
{
"field_path": "trace_items.0.department_code",
"field_pointer": "/trace_items/0/department_code",
"message": "Department Code 只允许 FO、HSK、FO+HSK。",
"detail": "trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"
}
]
""");
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("Trace 详情返回的可复核字段必须能直接提交给 review-resolution")
.isNotEmpty();
org.assertj.core.api.Assertions.assertThat(overrides.toString())
.contains("/trace_items/0/text")
.contains("/trace_items/0/department_code")
.doesNotContain("/business_fields/");
ObjectNode request = objectMapper.createObjectNode();
request.put("version", 0);
request.put("reason", "回归验证 Trace 详情页可编辑 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.trace_items[0].text")
.value("UPDATED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
.value("FO"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist());
}
@Test
void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
@@ -2111,6 +2415,12 @@ class ReservationV4CommandControllerTest {
if (pointer.endsWith("/room_count")) {
return objectMapper.getNodeFactory().numberNode(2);
}
if (pointer.endsWith("/extra_bed_room_count")) {
return objectMapper.getNodeFactory().numberNode(1);
}
if (pointer.endsWith("/department_code")) {
return objectMapper.getNodeFactory().textNode("FO");
}
if (pointer.endsWith("/group_booking_status")) {
return objectMapper.getNodeFactory().textNode("TEN");
}

View File

@@ -789,6 +789,77 @@ class ReservationV4QueryControllerTest {
.andExpect(content().string(not(containsString("att-payment-real-external-001"))));
}
@Test
void shouldReturnStableTraceFieldsAndSafeDisplayPayload() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
"mail-v4-query-trace-fields-001",
Instant.parse("2026-07-18T02:45:00Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-TRACE-FIELDS-001",
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
"TRACE_RESERVATION_NOTES",
ReservationV4CardStatus.CONFIRMED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{
"card_type":"TRACE_RESERVATION_NOTES",
"event_type":"TRACE_RESERVATION_NOTES",
"target_order":{"locator_value":"SHOULD-NOT-LEAK-TRACE"},
"business_fields":{
"order_ref":"ORDER-TRACE",
"event_type":"TRACE_RESERVATION_NOTES",
"trace_items":[
{
"item_type":"GENERAL",
"text":"Guest requests quiet rooms.",
"content":"SHOULD-NOT-LEAK-CONTENT",
"department_code":"FO"
},
{
"item_type":"EXTRA_BED",
"content":"SHOULD-NOT-LEAK-EXTRA-BED-CONTENT",
"target_room_type_code":"TWN",
"extra_bed_room_count":1,
"department_code":"FO+HSK",
"raw_evidence":"SHOULD-NOT-LEAK-RAW",
"attachment_url":"https://oss.example.test/trace-secret.png"
}
]
}
}
""",
null);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].text")
.value("Guest requests quiet rooms."))
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].target_room_type_code")
.value("TWN"))
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].extra_bed_room_count")
.value(1))
.andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].content").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].content").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].editable")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].control_type")
.value(contains("textarea")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/department_code')].options_source")
.value(contains("reservation_v4_trace_department_fixed")))
.andExpect(content().string(containsString(
"\"fixed_options\":[{\"value\":\"FO\",\"label\":\"FO\"},{\"value\":\"HSK\",\"label\":\"HSK\"},{\"value\":\"FO+HSK\",\"label\":\"FO+HSK\"}]")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/target_room_type_code')].options_source")
.value(contains("reservation_v4_room_type_catalog")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/extra_bed_room_count')].control_type")
.value(contains("number")))
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))))
.andExpect(content().string(not(containsString("https://oss.example.test"))));
}
@Test
void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception {
Long orderId = 990000000000777002L;

View File

@@ -1383,10 +1383,64 @@ class SuperAgentTaskResultControllerTest {
LIMIT 1
""", String.class, source.inboxId());
assertThat(traceValidationErrors)
.contains("business_fields.trace_items.0.target_room_type_code")
.contains("trace_items.0.target_room_type_code")
.contains("房型代码不在第一版目录中");
}
@Test
void shouldMarkV4TraceReviewRequiredWhenDepartmentCodeInvalid() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-department-001");
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-department-001")
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-DEPARTMENT-001")
.replace("\"department_code\": \"FO\"", "\"department_code\": \"SALES\"");
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-department-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3));
String traceValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'TRACE_RESERVATION_NOTES'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(traceValidationErrors)
.contains("trace_items.0.department_code")
.contains("Department Code 只允许 FO、HSK、FO+HSK。");
}
@Test
void shouldMarkV4TraceReviewRequiredWhenExtraBedRoomCountInvalid() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-extra-bed-count-001");
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-extra-bed-count-001")
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-EXTRA-BED-COUNT-001")
.replace("\"item_type\": \"GENERAL\"", "\"item_type\": \"EXTRA_BED\"")
.replace("\"text\": \"Guest requests quiet rooms.\"", "\"text\": \"Please add one extra bed.\"")
.replace("\"department_code\": \"FO\"", "\"target_room_type_code\": \"TWN\",\n"
+ " \"extra_bed_room_count\": 0,\n"
+ " \"department_code\": \"FO\"");
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-extra-bed-count-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3));
String traceValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'TRACE_RESERVATION_NOTES'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(traceValidationErrors)
.contains("trace_items.0.extra_bed_room_count")
.contains("加床房间数必须为正整数");
}
@Test
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");