Compare commits

...

2 Commits

Author SHA1 Message Date
andy
3527659dd0 修复V4复核字段指针运行时校验 2026-07-21 14:46:27 +07:00
andy
ef6658aa34 修复后端构建提交号注入 2026-07-21 14:21:09 +07:00
10 changed files with 207 additions and 91 deletions

View File

@@ -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 运行时规则收口、复核 pointer 部署证明与运行时 trace、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug 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 目录供给方案。 |
| 当前阶段 | 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、部署包 build commit 注入收口、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug 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 字段策略。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error 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-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`先访问 `/api/health` 确认 `build_commit` 是否为预期提交;`build_commit` 优先来自 Jar 内嵌 build-info不能仅靠运行时环境变量证明代码包已更新本次修复必须先提交后再用新 HEAD 打包,不能把未提交工作区代码标记为旧提交;再检索日志 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`。没有 health build 字段或没有该 marker优先按部署包未更新 / 未进入新代码路径处理,不继续改业务逻辑
- 名称:`M002-V4-review-pointer-command-policy-runtime-fix`
- 状态:In Progress正在收口 Room Information 复核 pointer 查询侧 / 命令侧白名单一致性
- 目标:`GET /api/reservation/order-tasks/{orderTaskId}` 返回 `editable=true``write_target=review_resolution.field_overrides` 的 Room Information pointer 时,`POST /review-resolution` 原样提交同 pointer 不应再返回 `V4_REVIEW_POINTER_NOT_ALLOWED`;如果字段级值或目录校验失败,应返回对应字段级错误
- 边界:本 checkpoint 不处理 `build_commit=UNKNOWN`,不做 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`,日志会输出 query-side editable pointers、command-side allowed pointers、validation error pointers、incoming pointer、card type / event type / status、`display_payload_has_room_information_final_values`、稳定 Room Information payload 判断和 reject reason且不得输出 payload、邮件正文或附件 URL
## 2. 当前优先级

View File

@@ -41,16 +41,24 @@ docs/
```bash
cd server
./mvnw test
./mvnw verify
TH_HOTEL_BUILD_COMMIT=$(git rev-parse --short HEAD) ./mvnw verify
./mvnw spring-boot:run
```
中文说明:
- `./mvnw test`:运行后端单元测试和 Spring 集成测试。
- `./mvnw verify`:运行 Maven verify 阶段,用于提交前完整检查。
- `TH_HOTEL_BUILD_COMMIT=$(git rev-parse --short HEAD) ./mvnw verify`:运行 Maven verify 阶段,用于部署级完整检查;该命令会要求当前 Git 提交真实存在且工作区干净
- `./mvnw spring-boot:run`:本地启动后端服务,默认端口为 `8080`
后端部署包必须写入真实 Git commit不能生成 `build_commit=UNKNOWN` 的 Jar。测试机 / UAT / 生产打包统一使用:
```bash
scripts/package-server-with-build-info.sh -DskipTests
```
中文说明:该脚本会要求 Git 工作区干净,从当前 `HEAD` 解析 commit设置 `TH_HOTEL_BUILD_COMMIT` 后执行 `server` 打包,并检查 `target/classes/META-INF/build-info.properties` 中的 `build.commit`。直接执行 `cd server && ./mvnw package` / `./mvnw verify` 时,如果没有提供真实 commit 或工作区不干净,也会在 `prepare-package` 阶段失败,避免部署包在 `/api/health` 中返回 `build_commit=UNKNOWN`
## 前端命令
```bash
@@ -130,6 +138,8 @@ GET /api/health
返回后端最小健康状态,用于本地开发、部署探活和前端连通性验证。
部署验证时必须检查 `build_commit` 是否为本次部署 commit`UNKNOWN` 只表示本地测试或未生成 build-info不能作为测试机 / UAT 已部署证明。
## M001 SourceMessage Inbox
当前实现 checkpoint 聚焦:

View File

@@ -334,9 +334,11 @@ INTERNAL_ONLY
```bash
cd server
./mvnw test
./mvnw verify
TH_HOTEL_BUILD_COMMIT=$(git rev-parse --short HEAD) ./mvnw verify
```
中文说明:`./mvnw test` 用于日常开发和普通回归;`verify` 会进入部署包生命周期,必须提供真实 `TH_HOTEL_BUILD_COMMIT` 且 Git 工作区干净,否则会在 `prepare-package` 阶段失败。测试机 / UAT / 生产打包优先使用仓库根目录的 `scripts/package-server-with-build-info.sh`
聚焦开发时可先运行相关测试:
```bash
@@ -377,4 +379,4 @@ cd server
- [ ] 是否没有提交真实 Secret 或客户数据?
- [ ] 是否没有把 Provider 输出直接当业务事实?
- [ ] 是否运行了 `./mvnw test` 或说明了无法运行原因?
- [ ] 是否运行了 `./mvnw verify` 或说明了无法运行原因?
- [ ] 是否在干净工作区运行了带 `TH_HOTEL_BUILD_COMMIT` `./mvnw verify`或说明了无法运行原因?

View File

@@ -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`先访问 `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 `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即使该叶子字段原始值缺失、详情页显示 `value=null`,前端仍可按原样提交该 pointer。查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`确认部署后让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出两侧 pointer 白名单、validation error pointers、`display_payload_has_room_information_final_values` 和拒绝原因。前端不要自行补未返回字段。
- 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 CodeAccount 改变后清空或重新校验已选 Rate Code缺失条件时禁用或空态不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `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 多卡最终接口。

View File

@@ -43,7 +43,7 @@
上线前至少确认以下事项:
- 当前分支、提交和部署包来源清楚,不能混入本地临时文件、真实 Secret、真实客户邮件样本或构建产物测试机 / UAT 构建包必须在本次代码提交后再打包,且能通过 `GET /api/health``build_commit` 证明当前运行提交。
- `server` 后端通过完整检查:`cd server && ./mvnw verify`
- `server` 后端通过完整检查:在干净 Git 工作区执行 `cd server && TH_HOTEL_BUILD_COMMIT=$(git rev-parse --short HEAD) ./mvnw verify`,或使用仓库根目录 `scripts/package-server-with-build-info.sh` 生成部署包
- 生产或 UAT 数据库已经备份,并确认 Flyway migration 只新增不修改历史脚本。
- 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。
- 生产默认不保存 AgentBus raw frame 样本。
@@ -63,12 +63,13 @@
| 变量 | 是否 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 只能证明旧提交,不能证明本次修复。 |
| `TH_HOTEL_BUILD_COMMIT` | 否 | 构建包时必须设置为已提交后的当前 Git commit;推荐在仓库根目录执行 `scripts/package-server-with-build-info.sh -DskipTests`,由脚本自动从干净工作区的 `HEAD` 注入。该值会写入 Jar 内嵌 build-info`/api/health` 返回内嵌值;未设置或不是有效 Git commit 时,`server``package` / `verify` 会在打包前失败,不允许继续产出 `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如果测试机接口不可访问可先看启动日志确认运行包。
- 测试机 / UAT / 生产 Jar 必须从干净 Git 工作区打包;当前 POM 会在 `prepare-package` 阶段校验 `TH_HOTEL_BUILD_COMMIT` 是否是有效 Git commit并校验工作区是否干净。工作区 dirty、CI 没有 Git 提交信息或人工忘记注入 commit 时,打包应直接失败并重新走标准打包流程。
- 如果 `build_commit` 不是预期提交,先修部署或重新打包,不要继续改业务逻辑。
### 3.1 数据库

View File

@@ -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`如果仍被拒绝,应先通过 `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 或敏感数据。
- Room Information 卡的 `fields[].editable``review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true``write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`稳定 Room Information 模型允许复核补写当前卡白名单内缺失叶子字段,例如 `final_values.room_items[0].room_type_code` 原始值缺失但详情页返回可编辑时,命令侧必须接受同一 pointer。若仍被拒绝应先通过 `GET /api/health` 检查测试机运行包;确认已部署看后端日志 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,其中会输出 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers、`display_payload_has_room_information_final_values` 和 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`;普通任务任意切换订单继续后置。

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
git update-index -q --refresh
if [[ -n "$(git status --porcelain)" ]]; then
echo "ERROR: Git worktree is not clean. Commit or stash changes before packaging a deployment-proof Jar." >&2
git status --short >&2
exit 1
fi
build_commit="$(git rev-parse --short HEAD 2>/dev/null || true)"
if [[ -z "$build_commit" || "$build_commit" == "UNKNOWN" ]]; then
echo "ERROR: Cannot resolve current Git commit for TH_HOTEL_BUILD_COMMIT." >&2
exit 1
fi
echo "Packaging TH Hotel backend with TH_HOTEL_BUILD_COMMIT=$build_commit"
cd "$repo_root/server"
TH_HOTEL_BUILD_COMMIT="$build_commit" ./mvnw clean package "$@"
build_info_file="$repo_root/server/target/classes/META-INF/build-info.properties"
if ! grep -q "^build.commit=$build_commit$" "$build_info_file"; then
echo "ERROR: build-info verification failed. Expected build.commit=$build_commit in $build_info_file." >&2
exit 1
fi
echo "Build proof OK: build.commit=$build_commit"

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>
<maven-antrun-plugin.version>1.8</maven-antrun-plugin.version>
<th.hotel.build.commit>UNKNOWN</th.hotel.build.commit>
</properties>
@@ -125,6 +126,51 @@
<parameters>${maven.compiler.parameters}</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>${maven-antrun-plugin.version}</version>
<executions>
<execution>
<id>verify-build-commit-before-package</id>
<phase>prepare-package</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target name="verify-build-commit-before-package">
<condition property="th.hotel.build.commit.unknown">
<equals arg1="${th.hotel.build.commit}" arg2="UNKNOWN"/>
</condition>
<fail if="th.hotel.build.commit.unknown"
message="TH_HOTEL_BUILD_COMMIT is required before packaging. Commit changes first, then use scripts/package-server-with-build-info.sh or set TH_HOTEL_BUILD_COMMIT to the current Git commit."/>
<exec executable="git" failonerror="true">
<arg value="rev-parse"/>
<arg value="--verify"/>
<arg value="--quiet"/>
<arg value="${th.hotel.build.commit}^{commit}"/>
</exec>
<exec executable="git" failonerror="true">
<arg value="update-index"/>
<arg value="-q"/>
<arg value="--refresh"/>
</exec>
<exec executable="git" failonerror="true" outputproperty="th.hotel.git.status">
<arg value="status"/>
<arg value="--porcelain"/>
</exec>
<condition property="th.hotel.git.dirty">
<not>
<equals arg1="${th.hotel.git.status}" arg2=""/>
</not>
</condition>
<fail if="th.hotel.git.dirty"
message="Git worktree must be clean before packaging a deployment-proof Jar. Commit or stash changes first. Dirty entries: ${th.hotel.git.status}"/>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@@ -1696,8 +1696,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
logReviewPointerRejected(card, pointer, "V4_REVIEW_VALUE_INVALID", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
}
boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer);
JsonNode current = findPointerValue(confirmedPayload, segments);
if (current == null || current.isMissingNode()) {
if (stableRoomInformationPointer) {
return;
}
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
@@ -1780,11 +1784,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
List<String> querySideEditablePointers = roomInformationQuerySideEditablePointers(card, confirmedPayload);
List<String> commandSideAllowedPointers = roomInformationCommandSideAllowedPointers(card, confirmedPayload);
Set<String> validationErrorPointers = collectValidationErrorPointers(card.validationErrorsJson());
log.warn(
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1, "
"V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_fix_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={}",
+ "validation_error_pointers={}, reject_reason={}, "
+ "display_payload_has_room_information_final_values={}, stable_room_information_payload={}",
card.v4OrderTaskId(),
card.id(),
card.cardType(),
@@ -1794,10 +1800,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
safeLogPointer(pointer),
querySideEditablePointers,
commandSideAllowedPointers,
validationErrorPointers,
reasonCode,
isStableRoomInformationPayload(confirmedPayload),
isStableRoomInformationPayload(confirmedPayload));
}
private boolean isStableRoomInformationReviewPointer(ObjectNode confirmedPayload, String pointer) {
return isStableRoomInformationPayload(confirmedPayload)
&& pointer != null
&& pointer.startsWith("/room_information/final_values/");
}
private List<String> roomInformationQuerySideEditablePointers(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload) {
@@ -1814,77 +1828,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
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);
}
return roomInformationQuerySideEditablePointers(card, confirmedPayload);
}
private String safeLogPointer(String pointer) {

View File

@@ -1183,6 +1183,86 @@ class ReservationV4CommandControllerTest {
.value("RM2"));
}
@Test
void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-pointer-missing-leaf-001",
Instant.parse("2026-07-19T01:22:19Z"),
990000000000070117L,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardType.ROOM_INFORMATION.name(),
"UPDATE_BOOKING",
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"UPDATE_BOOKING",
"target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-MISSING-LEAF-001"},
"business_fields":{
"event_type":"UPDATE_BOOKING",
"after":{
"arrival_date":"2026-08-01",
"departure_date":"2026-08-03",
"room_items":[{"room_count":1}]
}
}
}
""",
"""
[
{
"field_path": "room_information.final_values.room_items.0.room_type_code",
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"message": "房型代码不能为空。",
"detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不能为空。"
}
]
""");
confirmBasicCard(seeded);
MvcResult detailResult = performAuthorized(mockMvc, adminToken(),
get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andReturn();
JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString());
JsonNode editableField = null;
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
if ("/room_information/final_values/room_items/0/room_type_code"
.equals(field.path("field_pointer").asText())) {
editableField = field;
break;
}
}
org.assertj.core.api.Assertions.assertThat(editableField)
.as("任务详情必须返回缺失叶子字段的可复核 pointer")
.isNotNull();
org.assertj.core.api.Assertions.assertThat(editableField.path("editable").asBoolean(false)).isTrue();
org.assertj.core.api.Assertions.assertThat(editableField.path("write_target").asText())
.isEqualTo("review_resolution.field_overrides");
ObjectNode request = objectMapper.createObjectNode();
request.put("version", 0);
request.put("reason", "按详情页返回的缺失叶子字段补房型");
ArrayNode overrides = request.putArray("field_overrides");
ObjectNode override = overrides.addObject();
override.put("field_pointer", editableField.path("field_pointer").asText());
override.put("value", "TWN");
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
.value("TWN"));
}
@Test
void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
@@ -1303,14 +1383,17 @@ class ReservationV4CommandControllerTest {
.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("review_pointer_policy=m002_v4_review_pointer_runtime_fix_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("validation_error_pointers=")
.contains("/room_information/final_values/nights")
.contains("reject_reason=DERIVED_OR_SYSTEM_FIELD_READONLY")
.contains("display_payload_has_room_information_final_values=true")
.doesNotContain("target_order")
.doesNotContain("business_fields")
.doesNotContain("raw_evidence")
@@ -1530,7 +1613,7 @@ class ReservationV4CommandControllerTest {
.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("review_pointer_policy=m002_v4_review_pointer_runtime_fix_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")
@@ -1570,7 +1653,7 @@ class ReservationV4CommandControllerTest {
.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("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1")
.contains("incoming_pointer=bad/[EMAIL]/[TOKEN]/[URL]")
.contains("reject_reason=V4_REVIEW_POINTER_INVALID")
.doesNotContain("user@example.test")