修复V4复核指针部署证明和运行时诊断

This commit is contained in:
andy
2026-07-21 14:07:35 +07:00
parent 4bf53768ad
commit ab766cf4e2
17 changed files with 586 additions and 27 deletions

View File

@@ -26,6 +26,7 @@
<angus-mail.version>2.0.3</angus-mail.version>
<aliyun-oss.version>3.18.3</aliyun-oss.version>
<apache-poi.version>5.4.1</apache-poi.version>
<th.hotel.build.commit>UNKNOWN</th.hotel.build.commit>
</properties>
<dependencies>
@@ -102,6 +103,19 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<additionalProperties>
<commit>${th.hotel.build.commit}</commit>
<runtimeMarker>m002_v4_review_pointer_deployment_proof_v1</runtimeMarker>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -113,4 +127,18 @@
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>build-commit-from-env</id>
<activation>
<property>
<name>env.TH_HOTEL_BUILD_COMMIT</name>
</property>
</activation>
<properties>
<th.hotel.build.commit>${env.TH_HOTEL_BUILD_COMMIT}</th.hotel.build.commit>
</properties>
</profile>
</profiles>
</project>

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.platform.system.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 系统健康检查响应。公开探活接口只返回服务健康和非敏感构建证明。
*/
public record HealthResult(
/** 服务健康状态,当前固定为 UP。 */
String status,
/** 后端服务稳定名称。 */
String service,
/** 运行时代码标记,用于测试机确认当前包包含指定修复。 */
@JsonProperty("runtime_marker")
String runtimeMarker,
/** 构建提交号;未注入时返回 UNKNOWN。 */
@JsonProperty("build_commit")
String buildCommit,
/** 构建时间;未注入时返回 UNKNOWN。 */
@JsonProperty("build_time")
String buildTime,
/** 后端构建版本。 */
@JsonProperty("build_version")
String buildVersion
) {
}

View File

@@ -0,0 +1,22 @@
package cn.nianxx.thhotel.platform.system.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 系统构建信息响应。只包含可公开的部署证明字段不包含环境变量、Secret 或数据库细节。
*/
public record SystemBuildInfoResult(
/** 运行时代码标记,用于证明当前包包含某次可观测性修复。 */
@JsonProperty("runtime_marker")
String runtimeMarker,
/** 构建提交号;未注入时返回 UNKNOWN。 */
@JsonProperty("build_commit")
String buildCommit,
/** 构建时间;未注入时返回 UNKNOWN。 */
@JsonProperty("build_time")
String buildTime,
/** 后端构建版本。 */
@JsonProperty("build_version")
String buildVersion
) {
}

View File

@@ -1,6 +1,8 @@
package cn.nianxx.thhotel.platform.system.control;
import java.util.Map;
import cn.nianxx.thhotel.platform.system.common.result.HealthResult;
import cn.nianxx.thhotel.platform.system.common.result.SystemBuildInfoResult;
import cn.nianxx.thhotel.platform.system.service.SystemBuildInfoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -10,14 +12,28 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
private final SystemBuildInfoService buildInfoService;
/**
* 返回后端最小健康状态,用于前端联通性检查和部署探活
* 注入构建信息服务,健康检查不直接读取环境变量或 build-info 文件
*/
public HealthController(SystemBuildInfoService buildInfoService) {
this.buildInfoService = buildInfoService;
}
/**
* 返回后端最小健康状态和非敏感构建证明,用于前端联通性检查和部署探活。
*/
@GetMapping("/api/health")
public Map<String, String> health() {
return Map.of(
"status", "UP",
"service", "th-hotel-server"
public HealthResult health() {
SystemBuildInfoResult buildInfo = buildInfoService.getBuildInfo();
return new HealthResult(
"UP",
"th-hotel-server",
buildInfo.runtimeMarker(),
buildInfo.buildCommit(),
buildInfo.buildTime(),
buildInfo.buildVersion()
);
}
}

View File

@@ -0,0 +1,16 @@
package cn.nianxx.thhotel.platform.system.service;
import cn.nianxx.thhotel.platform.system.common.result.SystemBuildInfoResult;
/**
* 系统构建信息服务。用于公开健康检查和启动日志输出同一份非敏感部署证明。
*/
public interface SystemBuildInfoService {
/**
* 读取当前运行包的构建证明信息。
*
* @return 构建证明响应
*/
SystemBuildInfoResult getBuildInfo();
}

View File

@@ -0,0 +1,84 @@
package cn.nianxx.thhotel.platform.system.service.impl;
import cn.nianxx.thhotel.platform.system.common.result.SystemBuildInfoResult;
import cn.nianxx.thhotel.platform.system.service.SystemBuildInfoService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* 系统构建信息服务实现。构建提交号只读取随 Jar 打包进入的 Spring Boot build-info避免运行时环境变量伪装部署版本。
*/
@Service
public class SystemBuildInfoServiceImpl implements SystemBuildInfoService {
public static final String RUNTIME_MARKER = "m002_v4_review_pointer_deployment_proof_v1";
private static final String UNKNOWN = "UNKNOWN";
private final BuildProperties buildProperties;
/**
* 注入可选 build-info测试或本地未生成 build-info 时仍可返回 UNKNOWN。
*/
public SystemBuildInfoServiceImpl(ObjectProvider<BuildProperties> buildPropertiesProvider) {
this.buildProperties = buildPropertiesProvider.getIfAvailable();
}
/**
* 返回当前运行包的安全构建证明字段,不读取或输出 Secret。
*/
@Override
public SystemBuildInfoResult getBuildInfo() {
return new SystemBuildInfoResult(
RUNTIME_MARKER,
embeddedBuildCommit(),
firstConfigured(
buildTime(),
buildProperty("time")),
firstConfigured(
buildVersion(),
packageVersion())
);
}
private String buildProperty(String name) {
return buildProperties == null ? null : buildProperties.get(name);
}
private String buildTime() {
return buildProperties == null || buildProperties.getTime() == null
? null
: buildProperties.getTime().toString();
}
private String buildVersion() {
return buildProperties == null ? null : buildProperties.getVersion();
}
private String packageVersion() {
Package currentPackage = SystemBuildInfoServiceImpl.class.getPackage();
return currentPackage == null ? null : currentPackage.getImplementationVersion();
}
private String firstConfigured(String... candidates) {
for (String candidate : candidates) {
if (isConfigured(candidate)) {
return candidate.trim();
}
}
return UNKNOWN;
}
private String embeddedBuildCommit() {
String buildCommit = buildProperty("commit");
if (isConfigured(buildCommit) && !UNKNOWN.equals(buildCommit.trim())) {
return buildCommit.trim();
}
return UNKNOWN;
}
private boolean isConfigured(String value) {
return StringUtils.hasText(value) && !value.contains("${");
}
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.platform.system.service.impl;
import cn.nianxx.thhotel.platform.system.common.result.SystemBuildInfoResult;
import cn.nianxx.thhotel.platform.system.service.SystemBuildInfoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 系统构建信息启动日志。用于测试机无接口观察能力时从启动日志证明运行包版本。
*/
@Component
public class SystemBuildInfoStartupLogger implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(SystemBuildInfoStartupLogger.class);
private final SystemBuildInfoService buildInfoService;
/**
* 注入构建信息服务,启动日志和健康接口保持同一份字段来源。
*/
public SystemBuildInfoStartupLogger(SystemBuildInfoService buildInfoService) {
this.buildInfoService = buildInfoService;
}
/**
* 应用启动完成后输出非敏感构建证明,不包含 Secret、配置详情或数据库信息。
*/
@Override
public void run(ApplicationArguments args) {
SystemBuildInfoResult buildInfo = buildInfoService.getBuildInfo();
log.info(
"TH Hotel backend build info. runtime_marker={}, build_commit={}, build_time={}, build_version={}",
buildInfo.runtimeMarker(),
buildInfo.buildCommit(),
buildInfo.buildTime(),
buildInfo.buildVersion());
}
}

View File

@@ -1614,13 +1614,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
Set<String> seenPointers = new HashSet<>();
for (ReservationV4ReviewFieldOverrideRequest override : fieldOverrides) {
String pointer = trimToNull(override == null ? null : override.fieldPointer());
List<String> segments = decodeJsonPointer(pointer);
List<String> segments = decodeReviewJsonPointer(card, confirmedPayload, pointer);
JsonNode value = override == null || override.value() == null ? objectMapper.nullNode() : override.value();
ensureReviewPointerWritable(card, confirmedPayload, pointer, segments, value);
if (!seenPointers.add(pointer)) {
logReviewPointerRejected(card, pointer, "V4_REVIEW_POINTER_DUPLICATED", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_DUPLICATED", "复核字段不能重复提交。");
}
setPointerValue(confirmedPayload, segments, value);
setPointerValue(card, confirmedPayload, pointer, segments, value);
Map<String, Object> normalizedOverride = new LinkedHashMap<>();
normalizedOverride.put("field_pointer", pointer);
normalizedOverride.put("value", value);
@@ -1629,6 +1630,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return normalized;
}
private List<String> decodeReviewJsonPointer(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
String pointer) {
try {
return decodeJsonPointer(pointer);
} catch (ReservationTaskWorkflowException exception) {
logReviewPointerRejected(card, pointer, exception.getErrorCode(), confirmedPayload);
throw exception;
}
}
private List<String> decodeJsonPointer(String pointer) {
if (!hasText(pointer) || !pointer.startsWith("/")) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针必须是 RFC 6901 JSON Pointer。");
@@ -1671,13 +1684,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
List<String> segments,
JsonNode value) {
if (segments.isEmpty()) {
logReviewPointerRejected(card, pointer, "V4_REVIEW_POINTER_INVALID", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针不能为空。");
}
if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
logReviewPointerRejected(card, pointer, "V4_REVIEW_POINTER_READONLY", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
}
ensureReviewPointerInsideEditableContainer(card, confirmedPayload, pointer, segments);
if (value != null && value.isContainerNode()) {
logReviewPointerRejected(card, pointer, "V4_REVIEW_VALUE_INVALID", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
}
JsonNode current = findPointerValue(confirmedPayload, segments);
@@ -1762,20 +1778,115 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (!log.isWarnEnabled()) {
return;
}
List<String> querySideEditablePointers = roomInformationQuerySideEditablePointers(card, confirmedPayload);
List<String> commandSideAllowedPointers = roomInformationCommandSideAllowedPointers(card, confirmedPayload);
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={}",
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1, "
+ "order_task_id={}, card_id={}, card_type={}, event_type={}, card_status={}, review_status={}, "
+ "incoming_pointer={}, query_side_editable_pointers={}, command_side_allowed_pointers={}, "
+ "reject_reason={}, stable_room_information_payload={}",
card.v4OrderTaskId(),
card.id(),
card.cardType(),
card.eventType(),
card.cardStatus(),
card.reviewStatus(),
safeLogPointer(pointer),
querySideEditablePointers,
commandSideAllowedPointers,
reasonCode,
isStableRoomInformationPayload(confirmedPayload));
}
private List<String> roomInformationQuerySideEditablePointers(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload) {
if (!isStableRoomInformationPayload(confirmedPayload)) {
return List.of();
}
JsonNode roomInformation = confirmedPayload.path("room_information");
return ReservationV4RoomInformationFieldPolicy.editableFinalValuePointers(
card.eventType(),
textAt(roomInformation, "booking_type"),
roomInformation.path("final_values"));
}
private List<String> roomInformationCommandSideAllowedPointers(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload) {
if (!isStableRoomInformationPayload(confirmedPayload)) {
return List.of();
}
JsonNode roomInformation = confirmedPayload.path("room_information");
JsonNode finalValues = roomInformation.path("final_values");
if (!finalValues.isObject()) {
return List.of();
}
List<String> pointers = new ArrayList<>();
collectRoomInformationCommandAllowedPointers(
pointers,
card,
confirmedPayload,
finalValues,
List.of());
return List.copyOf(pointers);
}
private void collectRoomInformationCommandAllowedPointers(
List<String> pointers,
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
JsonNode current,
List<String> finalValuePath) {
if (current == null || current.isMissingNode() || current.isNull()) {
addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath);
return;
}
if (current.isObject()) {
current.fields().forEachRemaining(field -> collectRoomInformationCommandAllowedPointers(
pointers,
card,
confirmedPayload,
field.getValue(),
appendPath(finalValuePath, field.getKey())));
return;
}
if (current.isArray()) {
for (int index = 0; index < current.size(); index++) {
collectRoomInformationCommandAllowedPointers(
pointers,
card,
confirmedPayload,
current.get(index),
appendPath(finalValuePath, String.valueOf(index)));
}
return;
}
addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath);
}
private void addRoomInformationCommandAllowedPointer(
List<String> pointers,
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
JsonNode current,
List<String> finalValuePath) {
if (finalValuePath.isEmpty()) {
return;
}
JsonNode roomInformation = confirmedPayload.path("room_information");
ReservationV4RoomInformationFieldPolicy.RoomInformationWriteDecision decision =
ReservationV4RoomInformationFieldPolicy.finalValueWriteDecision(
card.eventType(),
textAt(roomInformation, "booking_type"),
finalValuePath,
roomInformation.path("final_values"));
String pointer = "/room_information/final_values/" + toJsonPointer(finalValuePath);
if (decision.writable() && isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) {
pointers.add(pointer);
}
}
private String safeLogPointer(String pointer) {
if (pointer == null) {
return null;
@@ -1784,6 +1895,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
.replace('\r', '_')
.replace('\n', '_')
.replace('\t', '_');
sanitized = sanitized.replaceAll("(?i)https?://[^\\s,\\]}]+", "[URL]");
sanitized = sanitized.replaceAll("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}", "[EMAIL]");
sanitized = sanitized.replaceAll("(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])", "[TOKEN]");
sanitized = sanitized.replaceAll("[^A-Za-z0-9_./~\\-\\[\\]]", "_");
int maxLength = 256;
return sanitized.length() <= maxLength ? sanitized : sanitized.substring(0, maxLength) + "...";
}
@@ -1806,7 +1921,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return current;
}
private void setPointerValue(ObjectNode root, List<String> segments, JsonNode value) {
private void setPointerValue(
ReservationV4TaskCardSnapshot card,
ObjectNode root,
String pointer,
List<String> segments,
JsonNode value) {
JsonNode parent = root;
for (int i = 0; i < segments.size() - 1; i++) {
String segment = segments.get(i);
@@ -1819,6 +1939,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
parent = null;
}
if (parent == null || parent.isMissingNode()) {
logReviewPointerRejected(card, pointer, "SET_POINTER_PARENT_MISSING", root);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
}
@@ -1831,11 +1952,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (parent.isArray()) {
Integer index = parseArrayIndex(leaf);
if (index == null || index >= parent.size()) {
logReviewPointerRejected(card, pointer, "SET_POINTER_ARRAY_INDEX_INVALID", root);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
((ArrayNode) parent).set(index, safeValue);
return;
}
logReviewPointerRejected(card, pointer, "SET_POINTER_PARENT_NOT_CONTAINER", root);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
@@ -1865,6 +1988,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return appended;
}
private String toJsonPointer(List<String> path) {
List<String> escaped = new ArrayList<>(path.size());
for (String segment : path) {
escaped.add(escapeJsonPointer(segment));
}
return String.join("/", escaped);
}
private String escapeJsonPointer(String segment) {
return segment.replace("~", "~0").replace("/", "~1");
}
/**
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
*/

View File

@@ -1,6 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -38,6 +39,36 @@ final class ReservationV4RoomInformationFieldPolicy {
return finalValueWriteDecision(eventType, bookingType, path, finalValues).writable();
}
/**
* 计算 Room Information 详情页和命令侧共同认可的可写 pointer 清单,只返回字段指针不返回字段值。
*/
static List<String> editableFinalValuePointers(
String eventType,
String bookingType,
JsonNode finalValues) {
if (EVENT_CANCEL_BOOKING.equals(eventType) || finalValues == null || !finalValues.isObject()) {
return List.of();
}
List<String> pointers = new ArrayList<>();
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("group_block_name"));
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("fit_name"));
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("arrival_date"));
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("departure_date"));
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("rate_code"));
JsonNode roomItems = finalValues.path("room_items");
if (roomItems.isArray()) {
for (int index = 0; index < roomItems.size(); index++) {
addIfWritable(pointers, eventType, bookingType, finalValues,
List.of("room_items", String.valueOf(index), "room_type_code"));
addIfWritable(pointers, eventType, bookingType, finalValues,
List.of("room_items", String.valueOf(index), "room_count"));
}
}
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("breakfast_included"));
addIfWritable(pointers, eventType, bookingType, finalValues, List.of("group_booking_status"));
return List.copyOf(pointers);
}
/**
* 返回 Room Information final_values 写入决策,用于运行时诊断日志。
*/
@@ -113,6 +144,29 @@ final class ReservationV4RoomInformationFieldPolicy {
return new RoomInformationWriteDecision(false, false, "TOP_LEVEL_FIELD_NOT_WRITABLE");
}
private static void addIfWritable(
List<String> pointers,
String eventType,
String bookingType,
JsonNode finalValues,
List<String> path) {
if (isWritableFinalValuePath(eventType, bookingType, path, finalValues)) {
pointers.add("/room_information/final_values/" + toJsonPointer(path));
}
}
private static String toJsonPointer(List<String> path) {
List<String> escaped = new ArrayList<>(path.size());
for (String segment : path) {
escaped.add(escapeJsonPointer(segment));
}
return String.join("/", escaped);
}
private static String escapeJsonPointer(String segment) {
return segment.replace("~", "~0").replace("/", "~1");
}
private static boolean isMissingOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.isNull();
}

View File

@@ -25,6 +25,10 @@ class HealthControllerTest {
mockMvc.perform(get("/api/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.service").value("th-hotel-server"));
.andExpect(jsonPath("$.service").value("th-hotel-server"))
.andExpect(jsonPath("$.runtime_marker").value("m002_v4_review_pointer_deployment_proof_v1"))
.andExpect(jsonPath("$.build_commit").isNotEmpty())
.andExpect(jsonPath("$.build_time").isNotEmpty())
.andExpect(jsonPath("$.build_version").isNotEmpty());
}
}

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.platform.system.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import cn.nianxx.thhotel.platform.system.common.result.SystemBuildInfoResult;
import java.util.Properties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties;
class SystemBuildInfoServiceImplTest {
@Test
void shouldReturnEmbeddedBuildCommitAsDeploymentProof() {
Properties entries = new Properties();
entries.setProperty("commit", "4bf5376");
entries.setProperty("version", "0.0.1-SNAPSHOT");
entries.setProperty("time", "2026-07-21T05:00:00Z");
SystemBuildInfoServiceImpl service = new SystemBuildInfoServiceImpl(
buildPropertiesProvider(new BuildProperties(entries)));
SystemBuildInfoResult result = service.getBuildInfo();
assertThat(result.runtimeMarker()).isEqualTo("m002_v4_review_pointer_deployment_proof_v1");
assertThat(result.buildCommit()).isEqualTo("4bf5376");
assertThat(result.buildTime()).isEqualTo("2026-07-21T05:00:00Z");
assertThat(result.buildVersion()).isEqualTo("0.0.1-SNAPSHOT");
}
@Test
void shouldReturnUnknownWhenEmbeddedBuildCommitIsUnknown() {
Properties entries = new Properties();
entries.setProperty("commit", "UNKNOWN");
entries.setProperty("version", "0.0.1-SNAPSHOT");
SystemBuildInfoServiceImpl service = new SystemBuildInfoServiceImpl(
buildPropertiesProvider(new BuildProperties(entries)));
SystemBuildInfoResult result = service.getBuildInfo();
assertThat(result.buildCommit()).isEqualTo("UNKNOWN");
}
private ObjectProvider<BuildProperties> buildPropertiesProvider(BuildProperties buildProperties) {
return new ObjectProvider<>() {
@Override
public BuildProperties getIfAvailable() {
return buildProperties;
}
};
}
}

View File

@@ -44,9 +44,12 @@ import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
@@ -67,6 +70,7 @@ import org.springframework.test.web.servlet.MvcResult;
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@ExtendWith(OutputCaptureExtension.class)
class ReservationV4CommandControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
@@ -1248,7 +1252,7 @@ class ReservationV4CommandControllerTest {
}
@Test
void shouldRejectRoomInformationReviewWhenPointerTargetsDerivedReadonlyField() throws Exception {
void shouldRejectRoomInformationReviewWhenPointerTargetsDerivedReadonlyField(CapturedOutput output) throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-room-info-readonly-001",
@@ -1294,9 +1298,25 @@ class ReservationV4CommandControllerTest {
{"field_pointer": "/room_information/final_values/nights", "value": 7}
]
}
"""))
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
org.assertj.core.api.Assertions.assertThat(output.getOut())
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
.contains("order_task_id=" + seeded.orderTask().id())
.contains("card_id=" + seeded.businessCard().id())
.contains("incoming_pointer=/room_information/final_values/nights")
.contains("query_side_editable_pointers=")
.contains("/room_information/final_values/room_items/0/room_type_code")
.contains("command_side_allowed_pointers=")
.contains("reject_reason=DERIVED_OR_SYSTEM_FIELD_READONLY")
.doesNotContain("target_order")
.doesNotContain("business_fields")
.doesNotContain("raw_evidence")
.doesNotContain("html_body")
.doesNotContain("http://")
.doesNotContain("https://");
}
@Test
@@ -1483,7 +1503,7 @@ class ReservationV4CommandControllerTest {
}
@Test
void shouldRejectReviewResolutionForIllegalPointer() throws Exception {
void shouldRejectReviewResolutionForIllegalPointer(CapturedOutput output) throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-illegal-pointer-001",
@@ -1508,6 +1528,54 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
org.assertj.core.api.Assertions.assertThat(output.getOut())
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
.contains("order_task_id=" + seeded.orderTask().id())
.contains("card_id=" + seeded.businessCard().id())
.contains("incoming_pointer=business_fields/room_items/0/pms_room_type_code")
.contains("reject_reason=V4_REVIEW_POINTER_INVALID")
.doesNotContain("target_order")
.doesNotContain("raw_evidence")
.doesNotContain("html_body")
.doesNotContain("http://")
.doesNotContain("https://");
}
@Test
void shouldSanitizeUnsafeReviewPointerInRejectedLogs(CapturedOutput output) throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-unsafe-pointer-log-001",
Instant.parse("2026-07-19T01:23:30Z"),
990000000000070115L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "bad/user@example.test/abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH/https://example.test/raw", "value": "RM2"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
org.assertj.core.api.Assertions.assertThat(output.getOut())
.contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1")
.contains("incoming_pointer=bad/[EMAIL]/[TOKEN]/[URL]")
.contains("reject_reason=V4_REVIEW_POINTER_INVALID")
.doesNotContain("user@example.test")
.doesNotContain("abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH")
.doesNotContain("https://example.test");
}
@Test