修复 V4 确认白名单和嵌套目录校验
This commit is contained in:
@@ -84,6 +84,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
"task_subtype",
|
||||
"task_type",
|
||||
"validation_errors");
|
||||
private static final Set<String> BUSINESS_WRITABLE_ROOT_FIELDS = Set.of(
|
||||
"arrival_date",
|
||||
"attachment_ids",
|
||||
"booking_scenario",
|
||||
"departure_date",
|
||||
"evidence_url",
|
||||
"guest_name",
|
||||
"rate_code",
|
||||
"room_items",
|
||||
"trace_items");
|
||||
|
||||
private final ReservationV4WorkflowRepository workflowRepository;
|
||||
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
@@ -466,13 +476,138 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
|
||||
private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
|
||||
ObjectNode payload = mutableDisplayPayload(card);
|
||||
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
|
||||
if (!confirmedPayload.isObject()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。");
|
||||
}
|
||||
return ((ObjectNode) confirmedPayload).deepCopy();
|
||||
overlayConfirmedPayload(card, payload, (ObjectNode) confirmedPayload);
|
||||
}
|
||||
return mutableDisplayPayload(card);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前卡片白名单合并前端确认值,避免未开放字段污染 confirmed_payload_json。
|
||||
*/
|
||||
private void overlayConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload, ObjectNode submittedPayload) {
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
overlayBasicInformationPayload(payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
overlayBusinessPayload(payload, submittedPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic Information 第一版只允许前端改 Account Code,其它字段由后端展示快照或目录派生。
|
||||
*/
|
||||
private void overlayBasicInformationPayload(ObjectNode payload, ObjectNode submittedPayload) {
|
||||
ObjectNode basicInformation = ensureBasicInformationObject(payload);
|
||||
JsonNode submittedBasic = submittedPayload.path("basic_information");
|
||||
JsonNode accountCode = submittedBasic.isObject()
|
||||
? submittedBasic.get("account_code")
|
||||
: submittedPayload.get("account_code");
|
||||
if (accountCode != null && !accountCode.isMissingNode() && !accountCode.isContainerNode()) {
|
||||
basicInformation.set("account_code", accountCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务卡只合并展示快照中已经存在且后端允许编辑的叶子字段,不接受新增字段或整体替换对象 / 数组。
|
||||
*/
|
||||
private void overlayBusinessPayload(ObjectNode payload, ObjectNode submittedPayload) {
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
JsonNode submittedBusinessFields = submittedPayload.path("business_fields");
|
||||
JsonNode submittedRoot = submittedBusinessFields.isObject() ? submittedBusinessFields : submittedPayload;
|
||||
if (businessFields.isObject()) {
|
||||
overlayEditableBusinessLeaves((ObjectNode) businessFields, submittedRoot, List.of(), true);
|
||||
return;
|
||||
}
|
||||
overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false);
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessLeaves(
|
||||
ObjectNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (submitted == null || !submitted.isObject()) {
|
||||
return;
|
||||
}
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = target.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
List<String> childPath = appendPath(path, field.getKey());
|
||||
if (!isBusinessPathWritable(childPath, wrappedBusinessFields)) {
|
||||
continue;
|
||||
}
|
||||
JsonNode submittedValue = submitted.get(field.getKey());
|
||||
overlayEditableBusinessValue(target, field.getKey(), field.getValue(), submittedValue, childPath, wrappedBusinessFields);
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessArray(
|
||||
ArrayNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (submitted == null || !submitted.isArray()) {
|
||||
return;
|
||||
}
|
||||
int size = Math.min(target.size(), submitted.size());
|
||||
for (int index = 0; index < size; index++) {
|
||||
List<String> childPath = appendPath(path, String.valueOf(index));
|
||||
overlayEditableBusinessValue(target, index, target.get(index), submitted.get(index), childPath, wrappedBusinessFields);
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessValue(
|
||||
ObjectNode parent,
|
||||
String fieldName,
|
||||
JsonNode currentValue,
|
||||
JsonNode submittedValue,
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
|
||||
parent.set(fieldName, submittedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void overlayEditableBusinessValue(
|
||||
ArrayNode parent,
|
||||
int index,
|
||||
JsonNode currentValue,
|
||||
JsonNode submittedValue,
|
||||
List<String> path,
|
||||
boolean wrappedBusinessFields) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableBusinessLeaves((ObjectNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableBusinessArray((ArrayNode) currentValue, submittedValue, path, wrappedBusinessFields);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()) {
|
||||
parent.set(index, submittedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBusinessPathWritable(List<String> path, boolean wrappedBusinessFields) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
|
||||
return false;
|
||||
}
|
||||
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
|
||||
}
|
||||
|
||||
private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) {
|
||||
@@ -531,34 +666,59 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void validateBusinessCardPayload(ObjectNode payload, List<String> details) {
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
|
||||
validateRateCode(fieldRoot.path("rate_code"), details);
|
||||
validateRoomItems(fieldRoot.path("room_items"), details);
|
||||
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
|
||||
}
|
||||
|
||||
private void validateRateCode(JsonNode rateCode, List<String> details) {
|
||||
String code = textValue(rateCode);
|
||||
if (code != null && !directoryService.isKnownRateCode(code)) {
|
||||
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
|
||||
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String childPath = fieldPath + "." + field.getKey();
|
||||
if ("rate_code".equals(field.getKey())) {
|
||||
validateRateCode(field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(field.getValue(), childPath, details);
|
||||
} else {
|
||||
validateBusinessCatalogFields(field.getValue(), childPath, details);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (int index = 0; index < node.size(); index++) {
|
||||
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRoomItems(JsonNode roomItems, List<String> details) {
|
||||
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
|
||||
String code = textValue(rateCode);
|
||||
if (code != null && !directoryService.isKnownRateCode(code)) {
|
||||
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
|
||||
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (!roomItems.isArray()) {
|
||||
details.add("business_fields.room_items: 房型明细必须是数组。");
|
||||
details.add(fieldPath + ": 房型明细必须是数组。");
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < roomItems.size(); index++) {
|
||||
JsonNode item = roomItems.get(index);
|
||||
if (item == null || !item.isObject()) {
|
||||
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
|
||||
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
|
||||
continue;
|
||||
}
|
||||
String roomTypeCode = textAt(item, "room_type_code");
|
||||
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
|
||||
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
|
||||
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,7 +830,7 @@ 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, segments);
|
||||
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, segments);
|
||||
if (value != null && value.isContainerNode()) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。");
|
||||
}
|
||||
@@ -688,6 +848,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
|
||||
private void ensureReviewPointerInsideEditableContainer(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
List<String> segments) {
|
||||
if (segments.size() < 2) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
|
||||
@@ -699,6 +860,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ("business_fields".equals(root)) {
|
||||
return;
|
||||
}
|
||||
if (!confirmedPayload.path("business_fields").isObject() && BUSINESS_WRITABLE_ROOT_FIELDS.contains(root)) {
|
||||
return;
|
||||
}
|
||||
if (!"business_fields".equals(root)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。");
|
||||
}
|
||||
@@ -774,6 +941,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> appendPath(List<String> path, String segment) {
|
||||
List<String> appended = new ArrayList<>(path.size() + 1);
|
||||
appended.addAll(path);
|
||||
appended.add(segment);
|
||||
return appended;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -400,35 +401,60 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
List<String> details = new ArrayList<>();
|
||||
JsonNode businessFields = displayPayload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
|
||||
validateRateCode(fieldRoot.path("rate_code"), details);
|
||||
validateRoomItems(fieldRoot.path("room_items"), details);
|
||||
validateBusinessCatalogFields(fieldRoot, "business_fields", details);
|
||||
return details;
|
||||
}
|
||||
|
||||
private void validateRateCode(JsonNode rateCode, List<String> details) {
|
||||
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
|
||||
if (code != null && !directoryService.isKnownRateCode(code)) {
|
||||
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
|
||||
private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List<String> details) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String childPath = fieldPath + "." + field.getKey();
|
||||
if ("rate_code".equals(field.getKey())) {
|
||||
validateRateCode(field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(field.getValue(), childPath, details);
|
||||
} else {
|
||||
validateBusinessCatalogFields(field.getValue(), childPath, details);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (int index = 0; index < node.size(); index++) {
|
||||
validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRoomItems(JsonNode roomItems, List<String> details) {
|
||||
private void validateRateCode(JsonNode rateCode, String fieldPath, List<String> details) {
|
||||
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
|
||||
if (code != null && !directoryService.isKnownRateCode(code)) {
|
||||
details.add(fieldPath + ": Rate Code 不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRoomItems(JsonNode roomItems, String fieldPath, List<String> details) {
|
||||
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
|
||||
return;
|
||||
}
|
||||
if (!roomItems.isArray()) {
|
||||
details.add("business_fields.room_items: 房型明细必须是数组。");
|
||||
details.add(fieldPath + ": 房型明细必须是数组。");
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < roomItems.size(); index++) {
|
||||
JsonNode item = roomItems.get(index);
|
||||
if (item == null || !item.isObject()) {
|
||||
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
|
||||
details.add(fieldPath + "." + index + ": 房型明细必须是对象。");
|
||||
continue;
|
||||
}
|
||||
String roomTypeCode = trimToNull(textAt(item, "room_type_code"));
|
||||
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
|
||||
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
|
||||
details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user