diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index bd32c33..bd9955f 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -4,16 +4,16 @@
| --- | --- |
| 最近更新 | 2026-07-21 |
| 当前分支 | `feature/huangting` |
-| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
-| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
+| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
+| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。测试机部署证明已补齐:`GET /api/health` 返回 `runtime_marker`、`build_commit`、`build_time`、`build_version`,启动日志也输出同一组非敏感字段;复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
## 1. 当前 Checkpoint
-- 名称:`M002-V4-review-pointer-runtime-fix`
-- 状态:Backend Done,已把 Room Information 详情 fields 白名单和复核命令 pointer 校验收口到同一套字段策略。
-- 目标:`fields[]` 中 `editable=true` 且 `write_target=review_resolution.field_overrides` 的 Room Information pointer 必须可被 `review-resolution` 接收;普通业务卡详情继续不暴露 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 或 PMS 原始响应。
+- 名称:`M002-V4-review-pointer-deployment-proof-and-runtime-trace`
+- 状态:Backend Done,已补齐部署证明字段和复核 pointer 拒绝前安全 trace。
+- 目标:先通过 `/api/health` 和启动日志证明测试机运行包版本,再在确实进入新代码路径但仍拒绝时,用安全日志对比 query-side editable pointers 与 command-side allowed pointers。
- 边界:本 checkpoint 不做 Payment 附件预览、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。
-- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,后端日志应出现 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1` marker;没有该 marker 通常说明运行包未包含本次修复路径。
+- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,先访问 `/api/health` 确认 `build_commit` 是否为预期提交;`build_commit` 优先来自 Jar 内嵌 build-info,不能仅靠运行时环境变量证明代码包已更新;本次修复必须先提交后再用新 HEAD 打包,不能把未提交工作区代码标记为旧提交;再检索日志 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`。没有 health build 字段或没有该 marker,优先按部署包未更新 / 未进入新代码路径处理,不继续改业务逻辑。
## 2. 当前优先级
diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md
index 08346b8..26693ce 100644
--- a/docs/project/frontend-backend/backend-to-frontend-notes.md
+++ b/docs/project/frontend-backend/backend-to-frontend-notes.md
@@ -542,7 +542,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照。
- 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`。
- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*`、`room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。
-- V4 `fields[]` 第一版字段说明:Basic Information 固定返回 `/basic_information/account_code`、`/basic_information/market_code`、`/basic_information/source_code`;其中 Account `control_type=select`、`options_source=reservation_v4_account_catalog`,Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`;`REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer;查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略,测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,可让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1` 判断是否运行到新包路径;前端不要自行补未返回字段。
+- V4 `fields[]` 第一版字段说明:Basic Information 固定返回 `/basic_information/account_code`、`/basic_information/market_code`、`/basic_information/source_code`;其中 Account `control_type=select`、`options_source=reservation_v4_account_catalog`,Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`;`REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer;查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,先访问 `GET /api/health` 看 `build_commit` 是否为最新后端提交,且 `runtime_marker=m002_v4_review_pointer_deployment_proof_v1`;`build_commit` 应来自后端构建包内嵌 build-info,不能只靠运行时环境变量证明代码已部署;确认部署后仍失败,再让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,日志会输出两侧 pointer 白名单和拒绝原因。前端不要自行补未返回字段。
- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。Rate Code 下一阶段依赖 Account + `booking_type`:前端需在 Account 已选 / 已确认且能取得当前业务 event `booking_type` 后再请求 Rate Code,Account 改变后清空或重新校验已选 Rate Code;缺失条件时禁用或空态,不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source`、`catalog_version` 和 `warnings[]`。
- M002 V4 CP12 前端已接入上述三个 lookup API:V4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]`、`stale=true` 和 `warnings[]` 作为非阻塞提示展示;Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。
diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md
index af62662..c793417 100644
--- a/docs/project/go-live-notes.md
+++ b/docs/project/go-live-notes.md
@@ -6,7 +6,7 @@
当前后端已经具备以下能力:
-- `GET /api/health`:后端健康检查。
+- `GET /api/health`:后端健康检查和非敏感部署证明。
- `GET /api/source-messages`:查询 SourceMessage Inbox 安全摘要。
- `GET /api/source-messages/{id}`:查询单条 SourceMessage 安全摘要。
- `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。
@@ -42,7 +42,7 @@
上线前至少确认以下事项:
-- 当前分支、提交和部署包来源清楚,不能混入本地临时文件、真实 Secret、真实客户邮件样本或构建产物。
+- 当前分支、提交和部署包来源清楚,不能混入本地临时文件、真实 Secret、真实客户邮件样本或构建产物;测试机 / UAT 构建包必须在本次代码提交后再打包,且能通过 `GET /api/health` 的 `build_commit` 证明当前运行提交。
- `server` 后端通过完整检查:`cd server && ./mvnw verify`。
- 生产或 UAT 数据库已经备份,并确认 Flyway migration 只新增不修改历史脚本。
- 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。
@@ -59,6 +59,18 @@
## 3. 环境变量
+### 3.0 构建与部署证明
+
+| 变量 | 是否 Secret | 上线注意事项 |
+| --- | --- | --- |
+| `TH_HOTEL_BUILD_COMMIT` | 否 | 构建包时设置为已提交后的当前 Git commit,例如 `TH_HOTEL_BUILD_COMMIT=$(git rev-parse --short HEAD) ./mvnw clean package`;该值会写入 Jar 内嵌 build-info,`/api/health` 返回内嵌值;未设置时 `/api/health` 返回 `build_commit=UNKNOWN`,不能作为“已部署指定提交”的证明。不要用未提交工作区代码打包后仍标记旧 HEAD,否则 build commit 只能证明旧提交,不能证明本次修复。 |
+
+说明:
+
+- `GET /api/health` 会返回 `runtime_marker`、`build_commit`、`build_time`、`build_version`,这些字段不包含 Secret,只用于部署排查;`build_commit` 应以构建阶段写入 Jar 的 build-info 为准,不把运行时临时环境变量或未提交工作区状态当作已部署代码证明。
+- 应用启动日志也会输出同一组 build info;如果测试机接口不可访问,可先看启动日志确认运行包。
+- 如果 `build_commit` 不是预期提交,先修部署或重新打包,不要继续改业务逻辑。
+
### 3.1 数据库
| 变量 | 是否 Secret | 上线注意事项 |
@@ -394,6 +406,8 @@ GET /api/health
- HTTP 200。
- `status = UP`。
+- `runtime_marker = m002_v4_review_pointer_deployment_proof_v1`。
+- `build_commit` 为本次预期部署提交;如果是 `UNKNOWN` 或旧提交,先修部署包。
```text
GET /api/system/agentbus-probe
diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
index 1709452..86b56be 100644
--- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
+++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
@@ -174,7 +174,7 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护
- CP11 起确认和复核都会校验当前酒店数据库目录字段;Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code`。
- 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。
- `field_overrides[].field_pointer` 必须是当前卡 `fields[]` 白名单中允许编辑的 RFC 6901 JSON Pointer。`REVIEW_REQUIRED` 是整张原业务卡的复核状态,前端仍在原卡片内展示业务表单,问题字段用红字 / `validation_errors` 强调;用户可修改当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。
-- Room Information 卡的 `fields[].editable` 和 `review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true` 且 `write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`。如果仍被拒绝,应优先排查运行包是否包含 `M002-V4-review-pointer-runtime-fix`,后端日志 marker 为 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`。
+- Room Information 卡的 `fields[].editable` 和 `review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true` 且 `write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`。如果仍被拒绝,应先通过 `GET /api/health` 检查 `build_commit` 和 `runtime_marker=m002_v4_review_pointer_deployment_proof_v1`,确认测试机运行包是否包含最新修复;如果确认已部署,再看后端日志 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,其中会输出 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers 和 reject reason,但不得输出 payload、邮件正文、附件 URL 或敏感数据。
- 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。
- 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。
- 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。
diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md
index 71b9724..1927711 100644
--- a/docs/project/security-access-control-boundary.md
+++ b/docs/project/security-access-control-boundary.md
@@ -34,7 +34,7 @@
| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 |
| --- | --- | --- | --- | --- |
-| `GET /api/health` | `PUBLIC` | 无登录;只返回健康状态 | 保持公开,但不得返回配置、版本 Secret 或数据库细节 | 不需要业务审计 |
+| `GET /api/health` | `PUBLIC` | 无登录;返回健康状态和非敏感部署证明字段 `runtime_marker`、`build_commit`、`build_time`、`build_version` | 保持公开,但不得返回配置、版本 Secret、环境变量值、数据库细节、客户数据或外部系统状态;`build_commit` 只用于确认测试机 / UAT 是否运行预期构建包 | 不需要业务审计 |
| `POST /api/auth/login` | `PUBLIC` | 用户名密码登录,返回一次性 `access_token` | 增加登录失败频率控制和登录安全审计可后置 | 建议补登录安全审计 |
| `GET /api/auth/me` | `FRONTEND_USER` | 必须 Bearer token | 保持强制登录,返回权限、菜单和酒店上下文 | 不需要每次写业务审计 |
| `POST /api/auth/logout` | `FRONTEND_USER` | 必须 Bearer token | 保持强制登录,撤销当前 session | 可记录安全审计 |
diff --git a/server/pom.xml b/server/pom.xml
index fd8f048..b39c38a 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -26,6 +26,7 @@
2.0.3
3.18.3
5.4.1
+
UNKNOWN
@@ -102,6 +103,19 @@
org.springframework.boot
spring-boot-maven-plugin
+
+
+
+ build-info
+
+
+
+ ${th.hotel.build.commit}
+ m002_v4_review_pointer_deployment_proof_v1
+
+
+
+
org.apache.maven.plugins
@@ -113,4 +127,18 @@
+
+
+
+ build-commit-from-env
+
+
+ env.TH_HOTEL_BUILD_COMMIT
+
+
+
+ ${env.TH_HOTEL_BUILD_COMMIT}
+
+
+
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/HealthResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/HealthResult.java
new file mode 100644
index 0000000..77b174d
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/HealthResult.java
@@ -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
+) {
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/SystemBuildInfoResult.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/SystemBuildInfoResult.java
new file mode 100644
index 0000000..932c0f4
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/common/result/SystemBuildInfoResult.java
@@ -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
+) {
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java
index 10f6045..6f3c6b8 100644
--- a/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/control/HealthController.java
@@ -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 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()
);
}
}
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/service/SystemBuildInfoService.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/SystemBuildInfoService.java
new file mode 100644
index 0000000..0b6ff63
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/SystemBuildInfoService.java
@@ -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();
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImpl.java
new file mode 100644
index 0000000..bb981ea
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImpl.java
@@ -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 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("${");
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoStartupLogger.java b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoStartupLogger.java
new file mode 100644
index 0000000..f3da140
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoStartupLogger.java
@@ -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());
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
index 0a0e5f6..7d809fa 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
@@ -1614,13 +1614,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
Set seenPointers = new HashSet<>();
for (ReservationV4ReviewFieldOverrideRequest override : fieldOverrides) {
String pointer = trimToNull(override == null ? null : override.fieldPointer());
- List segments = decodeJsonPointer(pointer);
+ List 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 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 decodeReviewJsonPointer(
+ ReservationV4TaskCardSnapshot card,
+ ObjectNode confirmedPayload,
+ String pointer) {
+ try {
+ return decodeJsonPointer(pointer);
+ } catch (ReservationTaskWorkflowException exception) {
+ logReviewPointerRejected(card, pointer, exception.getErrorCode(), confirmedPayload);
+ throw exception;
+ }
+ }
+
private List 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 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 querySideEditablePointers = roomInformationQuerySideEditablePointers(card, confirmedPayload);
+ List 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 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 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 pointers = new ArrayList<>();
+ collectRoomInformationCommandAllowedPointers(
+ pointers,
+ card,
+ confirmedPayload,
+ finalValues,
+ List.of());
+ return List.copyOf(pointers);
+ }
+
+ private void collectRoomInformationCommandAllowedPointers(
+ List pointers,
+ ReservationV4TaskCardSnapshot card,
+ ObjectNode confirmedPayload,
+ JsonNode current,
+ List 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 pointers,
+ ReservationV4TaskCardSnapshot card,
+ ObjectNode confirmedPayload,
+ JsonNode current,
+ List 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("(? segments, JsonNode value) {
+ private void setPointerValue(
+ ReservationV4TaskCardSnapshot card,
+ ObjectNode root,
+ String pointer,
+ List 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 path) {
+ List 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");
+ }
+
/**
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
*/
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java
index f80dcab..4a16486 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4RoomInformationFieldPolicy.java
@@ -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 editableFinalValuePointers(
+ String eventType,
+ String bookingType,
+ JsonNode finalValues) {
+ if (EVENT_CANCEL_BOOKING.equals(eventType) || finalValues == null || !finalValues.isObject()) {
+ return List.of();
+ }
+ List 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 pointers,
+ String eventType,
+ String bookingType,
+ JsonNode finalValues,
+ List path) {
+ if (isWritableFinalValuePath(eventType, bookingType, path, finalValues)) {
+ pointers.add("/room_information/final_values/" + toJsonPointer(path));
+ }
+ }
+
+ private static String toJsonPointer(List path) {
+ List 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();
}
diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java
index c9332f7..0e4f845 100644
--- a/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java
+++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/control/HealthControllerTest.java
@@ -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());
}
}
diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImplTest.java
new file mode 100644
index 0000000..bd368c0
--- /dev/null
+++ b/server/src/test/java/cn/nianxx/thhotel/platform/system/service/impl/SystemBuildInfoServiceImplTest.java
@@ -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 buildPropertiesProvider(BuildProperties buildProperties) {
+ return new ObjectProvider<>() {
+ @Override
+ public BuildProperties getIfAvailable() {
+ return buildProperties;
+ }
+ };
+ }
+}
diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java
index d194f69..4faaac1 100644
--- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java
+++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java
@@ -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