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