修复V4目录初始化和lookup空结果元数据

This commit is contained in:
andy
2026-07-19 14:56:11 +07:00
parent 88f98a0eb6
commit 8b37232ac9
12 changed files with 342 additions and 18 deletions

View File

@@ -10,7 +10,7 @@
## 1. 当前 Checkpoint
- 名称:`M002-V4-CP11-db-catalog-lookup-api-v1`
- 状态Done已新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code`,将 `QBD_TRAVEL``LIAN_TAI``HANATOUR_TD`、Room Type、Rate Code、Market、Source 固定种子导入数据库,并开放 Account / Room Type / Rate Code lookup API。
- 状态Done已新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code`,将 `QBD_TRAVEL``LIAN_TAI``HANATOUR_TD`、Room Type、Rate Code、Market、Source 固定种子导入数据库,并开放 Account / Room Type / Rate Code lookup API;已补启动阶段默认酒店目录初始化和 lookup keyword 无结果时的 wrapper 元数据修正
- 目标:让 V4 任务详情字段控件不再依赖前端硬编码选项,后端入站、确认、复核统一按当前酒店数据库目录做校验。
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不做前端页面。
@@ -34,7 +34,7 @@
- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。
- 后续每完成一个 Feature 或 Checkpoint需要更新本文件避免项目状态继续沉淀在聊天记录里。
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配M002 V4 CP2 已完成订单任务与多卡领域模型设计M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线M002 V4 CP4 已完成入站写入新模型M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ackM002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验M002 V4 CP11 已完成数据库目录、初始化种子、Account / Room Type / Rate Code lookup API并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录。V4 前端页面、目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配M002 V4 CP2 已完成订单任务与多卡领域模型设计M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线M002 V4 CP4 已完成入站写入新模型M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ackM002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录。V4 前端页面、目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
- M002 V4 CP2 已确认V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束匹配多条进人工复核Basic Information 必须先确认Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。
## 5. Next Steps

View File

@@ -58,9 +58,9 @@
| `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id``order_id``order_task_status``card_status``keyword``page_num``page_size``order_task_status``OPEN` / `COMPLETED` 返回 400`card_status` 非 V4 卡状态返回 400`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task``source_message_summary``source_message_card``basic_information_card``business_cards[]``card_counts``adapter_contract_errors[]``availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 |
| `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 |
| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;返回统一 wrapper`hotel_id``catalog_type=ACCOUNT``catalog_source``catalog_version``stale``items[]``page``warnings[]`。前端在 `options_source=reservation_v4_account_catalog` 时调用,只提交 `items[].code`Market / Source 以后端确认派生结果为准。 |
| `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 |
| `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` Rate Code`pricing_available=false` 表示后端未接真实价格,不要据此展示价格。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 |
| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;返回统一 wrapper`hotel_id``catalog_type=ACCOUNT``catalog_source``catalog_version``stale``items[]``page``warnings[]``keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `options_source=reservation_v4_account_catalog` 时调用,只提交 `items[].code`Market / Source 以后端确认派生结果为准。 |
| `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 |
| `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` Rate Code`pricing_available=false` 表示后端未接真实价格,不要据此展示价格。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]``reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED``review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
@@ -508,7 +508,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- 业务卡目录校验会递归检查 `business_fields` 下的嵌套结构。例如 `UPDATE_BOOKING` 的房型可能位于 `/business_fields/after/room_items/0/room_type_code`,错误详情会使用 `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":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*``business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段;如果是目录校验错误,也可以提交后端 `fields[].validation_errors` 对应的字段 pointer。前端不要提交来源邮件、路由、`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 为只读派生字段。业务卡会按展示 payload 里的业务叶子字段返回字段白名单,例如 `/room_items/0/room_type_code``/business_fields/room_items/0/pms_room_type_code`;前端不要自行补未返回字段。
- 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 或目录完整对象;后端确认前仍会重新校验目录。
- 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 或目录完整对象;后端确认前仍会重新校验目录。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。
- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。
- M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT``adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。

View File

@@ -52,7 +52,7 @@
- 源邮件只读通知卡上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。旧 `S000/S999` 和新结构化 `S10/S99` 都沿用该酒店解析约束。
- 系统管理后台上线前,必须确认至少存在一个 `ACTIVE` 超级管理员账号,且该账号拥有 `SYSTEM_ADMIN_CONSOLE_ACCESS` 和各系统管理权限。
- 单酒店阶段上线前,必须确认 `platform_hotel` 中只有一家 `ACTIVE` 酒店;新增酒店可以存在但应保持 `DISABLED`
- M002 V4 lookup 上线前,必须确认当前 `platform_hotel` 唯一 `ACTIVE` 酒店有可用目录数据。V24 会初始化 `HOTEL-TEST``HOTEL-DEV`,并给迁移执行时已经存在的 `ACTIVE` 酒店导入固定种子;如果生产真实酒店是在迁移后由 bootstrap 创建,不能使用固定种子作为业务事实源,必须补正式目录数据或新增导入脚本。
- M002 V4 lookup 上线前,必须确认当前 `platform_hotel` 唯一 `ACTIVE` 酒店有可用目录数据。V24 会初始化 `HOTEL-TEST``HOTEL-DEV`,并给迁移执行时已经存在的 `ACTIVE` 酒店导入固定种子;如果生产真实酒店是在迁移后由 bootstrap 创建,`ReservationV4CatalogBootstrapRunner` 会在该酒店目录为空时补一份 `FIXED_SEED_IMPORT` 初始化目录。生产如不能使用固定种子作为业务事实源,必须在上线前补正式目录数据或新增导入脚本。
- 管理后台启用后,不要继续把手工改库作为常规运营方式;用户、角色、菜单和酒店变更应通过 `/api/admin/**` 并写入管理审计。
- 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。
- 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。
@@ -280,7 +280,7 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系:
- 目标数据库为空库或 Flyway history 与当前代码一致。
- 如果某个环境已经在缺少 V10 的临时提交上执行过 V11 / V12不能直接用默认 Flyway 策略补跑 V10应先重建测试库或按运维窗口明确 out-of-order / repair 策略。
- V21 会为 `workflow_reservation_order` 增加 `latest_activity_at`,并按订单更新时间和历史任务最新来源 / 创建时间回填一次;上线后订单列表依赖该字段排序,不再在列表查询时聚合全量任务。发布后需要确认 Flyway 已执行到 V21且订单列表能按最新业务活动倒序返回。
- V24 会新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code`,并初始化 `HOTEL-TEST``HOTEL-DEV` 以及迁移执行时已有 `ACTIVE` 酒店的 Account、Market、Source、Room Type、Rate Code 种子目录。发布后需要确认 Flyway 已执行到 V24且当前酒店 lookup 能返回目录项;真实 PMS 同步、目录管理后台和 `workflow_reservation_catalog_sync_run` 仍未实现。
- V24 会新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code`,并初始化 `HOTEL-TEST``HOTEL-DEV` 以及迁移执行时已有 `ACTIVE` 酒店的 Account、Market、Source、Room Type、Rate Code 种子目录。启动后 `ReservationV4CatalogBootstrapRunner` 会补齐迁移后由平台 bootstrap 创建且目录为空的 `ACTIVE` 酒店。发布后需要确认 Flyway 已执行到 V24且当前酒店 lookup 能返回目录项;真实 PMS 同步、目录管理后台和 `workflow_reservation_catalog_sync_run` 仍未实现。
- V22 会新增 `workflow_reservation_invoice_generation`,用于记录 Manual Invoice 生成状态、Excel / PDF OSS 对象、金额摘要和安全错误摘要;发布后需要确认 `RESERVATION_INVOICE_GENERATE` 权限已由启动同步写入平台权限表,预订操作员或目标角色已拥有该权限。
- MySQL 版本满足项目要求,默认使用 MySQL 8.0+。
- migration 在 UAT 或测试库已经跑过。

View File

@@ -485,5 +485,5 @@ M002 V4 CP2 设计文档已落地:
- V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。
- V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。
- V4 前端页面模型切换。
- 真实 PMS 目录同步、Rate Code 价格 / 适用范围配置中心、目录管理后台和 SuperAgent 目录机器接口仍后置;当前 CP11 只是把固定种子导入数据库并开放前端 lookup API不代表已接 PMS 全量目录。
- 真实 PMS 目录同步、Rate Code 价格 / 适用范围配置中心、目录管理后台和 SuperAgent 目录机器接口仍后置;当前 CP11 只是把固定种子导入数据库、为迁移后创建的默认酒店做启动补种子并开放前端 lookup API不代表已接 PMS 全量目录。
- 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。

View File

@@ -19,13 +19,13 @@ M002 V4 CP8 已实现第一版固定种子目录校验M002 V4 CP11 已把该
- Room Type / Rate Code 第一版使用当前酒店数据库目录校验和字段选项提示。
- 目录错误会让对应 V4 卡片进入 `REVIEW_REQUIRED`,用户通过复核解阻选择合法 code。
本文记录真实目录和 lookup API 的设计与 CP11 第一版实现。CP11 新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code` 两张表,并通过 Flyway 初始化 `HOTEL-TEST``HOTEL-DEV` 以及迁移执行时已存在的 `ACTIVE` 平台酒店的固定种子目录;不再在运行时代码中把固定种子作为全局目录事实。`workflow_reservation_catalog_sync_run`、真实 PMS / OPERA / OHIP 同步、目录管理后台和 SuperAgent 机器目录供给接口继续后置。
本文记录真实目录和 lookup API 的设计与 CP11 第一版实现。CP11 新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code` 两张表,并通过 Flyway 初始化 `HOTEL-TEST``HOTEL-DEV` 以及迁移执行时已存在的 `ACTIVE` 平台酒店的固定种子目录;同时新增 `ReservationV4CatalogBootstrapRunner`,在平台默认酒店由启动流程创建后,如果该酒店目录为空,会补一份 `FIXED_SEED_IMPORT` 初始化目录。不再在运行时代码中把固定种子作为全局目录事实。`workflow_reservation_catalog_sync_run`、真实 PMS / OPERA / OHIP 同步、目录管理后台和 SuperAgent 机器目录供给接口继续后置。
后续若本文与 `M002-v4-agent-callback-field-contract.md` 的 Agent 输入字段冲突,以 Agent 字段契约为准;若与 `security-access-control-boundary.md` 的接口权限冲突,以安全边界为准。
## 2. 当前固定种子目录
CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService``FixedReservationV4DirectoryServiceImpl`。CP11 起实现改为 `ReservationV4DatabaseDirectoryServiceImpl`,目录读取只走数据库目录表;固定种子通过 `V24__create_reservation_catalog_tables.sql` 初始化导入 dev/test 基准酒店和迁移时已有 `ACTIVE` 酒店,来源标记为 `FIXED_SEED_IMPORT`
CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService``FixedReservationV4DirectoryServiceImpl`。CP11 起实现改为 `ReservationV4DatabaseDirectoryServiceImpl`,目录读取只走数据库目录表;固定种子通过 `V24__create_reservation_catalog_tables.sql` 初始化导入 dev/test 基准酒店和迁移时已有 `ACTIVE` 酒店,并由 `ReservationV4CatalogBootstrapRunner` 补齐迁移后才创建的默认 `ACTIVE` 酒店,来源标记为 `FIXED_SEED_IMPORT`
| 目录 | 当前用途 | 当前固定值 | 当前限制 |
| --- | --- | --- | --- |
@@ -54,7 +54,7 @@ CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService` 和 `F
| 阶段 | 来源 | 中文说明 | 适用目录 |
| --- | --- | --- | --- |
| Phase 0 | `FIXED_SEED_IMPORT` | CP11 已将固定种子通过 Flyway 导入数据库,不再作为运行时代码全局 Map | 当前 Account、Market、Source、Room Type、Rate Code |
| Phase 0 | `FIXED_SEED_IMPORT` | CP11 已将固定种子通过 Flyway 和启动补种子流程导入数据库,不再作为运行时代码全局 Map | 当前 Account、Market、Source、Room Type、Rate Code |
| Phase 1 | `SYSTEM_MANAGED` | 本系统数据库目录,由初始化脚本、管理后台或导入文件维护 | Account、Market、Source也可临时维护 Room Type、Rate Code |
| Phase 2 | `PMS_SYNC` | 后端从 PMS / OPERA / OHIP 同步目录到本系统本地表,业务查询只读本地快照 | Room Type、Rate Code 优先Account、Market、Source 视 PMS 能力再接 |
@@ -203,6 +203,7 @@ Lookup API 属于前端业务查询接口,不给 SuperAgent 或 AgentBus 调
- 酒店隔离:`hotel_id` 可选;不传时按当前用户默认酒店解析,传入时必须校验用户可访问。
- 返回:只返回目录 code、显示名、状态、来源和安全元数据不返回外部 PMS 原始响应。
- 分页:`page_num` 从 1 开始,`page_size` 后端限制最大值。
- `keyword` 无匹配时,`items=[]``page.total=0`;如果当前酒店未过滤的 ACTIVE 目录仍存在,`catalog_source` / `catalog_version` 继续返回真实目录元数据,不把筛选无结果误报为 `DATABASE_EMPTY`
### 8.1 Account Lookup

View File

@@ -25,6 +25,8 @@ import java.util.List;
import java.util.Map;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional;
* M003 登录权限底座启动初始化。只写入系统内置数据和首个超级管理员。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
private final AuthProperties authProperties;

View File

@@ -132,6 +132,70 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 判断当前酒店是否已有 Account 目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
*/
@Override
public boolean hasAnyAccount(String hotelId) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return false;
}
return accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, normalizedHotelId)) > 0;
}
/**
* 判断当前酒店是否已有通用代码目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
*/
@Override
public boolean hasAnyCatalogCode(String hotelId) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return false;
}
return codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, normalizedHotelId)) > 0;
}
/**
* Account Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
*/
@Override
public void insertAccountIfAbsent(ReservationCatalogAccountEntity entity) {
if (entity == null
|| trimToNull(entity.getHotelId()) == null
|| trimToNull(entity.getAccountCode()) == null) {
return;
}
Long existing = accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
.eq(ReservationCatalogAccountEntity::getHotelId, entity.getHotelId())
.eq(ReservationCatalogAccountEntity::getAccountCode, entity.getAccountCode()));
if (existing == null || existing == 0) {
accountMapper.insert(entity);
}
}
/**
* 通用目录 Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
*/
@Override
public void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity) {
if (entity == null
|| trimToNull(entity.getHotelId()) == null
|| trimToNull(entity.getCatalogType()) == null
|| trimToNull(entity.getCode()) == null) {
return;
}
Long existing = codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
.eq(ReservationCatalogCodeEntity::getHotelId, entity.getHotelId())
.eq(ReservationCatalogCodeEntity::getCatalogType, entity.getCatalogType())
.eq(ReservationCatalogCodeEntity::getCode, entity.getCode()));
if (existing == null || existing == 0) {
codeMapper.insert(entity);
}
}
private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) {
return new ReservationV4CatalogAccountSnapshot(
entity.getId(),

View File

@@ -3,6 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import java.util.Optional;
/**
@@ -38,4 +40,24 @@ public interface ReservationV4CatalogRepository {
String keyword,
int pageNum,
int pageSize);
/**
* 判断当前酒店是否已有任意 Account 目录记录,用于启动初始化避免覆盖人工目录。
*/
boolean hasAnyAccount(String hotelId);
/**
* 判断当前酒店是否已有任意通用代码目录记录,用于启动初始化避免覆盖人工目录。
*/
boolean hasAnyCatalogCode(String hotelId);
/**
* Account Code 不存在时插入固定种子目录,存在时保持原数据不变。
*/
void insertAccountIfAbsent(ReservationCatalogAccountEntity entity);
/**
* 通用目录 Code 不存在时插入固定种子目录,存在时保持原数据不变。
*/
void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity);
}

View File

@@ -0,0 +1,157 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogSourceSystem;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation V4 目录启动初始化器。补齐 Flyway 之后由平台启动流程创建的默认酒店目录种子。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public class ReservationV4CatalogBootstrapRunner implements ApplicationRunner {
private static final String SEED_CATALOG_VERSION = "seed-20260719-v1";
private static final String MARKET_CODE = "LEISURE";
private static final String SOURCE_CODE = "TRAVEL_AGENT";
private static final List<AccountSeed> ACCOUNT_SEEDS = List.of(
new AccountSeed("QBD_TRAVEL", "Q.B.D. TRAVEL GROUP CO., LTD"),
new AccountSeed("LIAN_TAI", "LIAN TAI TRAVEL (THAILAND) CO., LTD."),
new AccountSeed("HANATOUR_TD", "HANATOUR TD CO., LTD."));
private static final List<CodeSeed> CODE_SEEDS = List.of(
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TWN", "TWN", 10, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "KING", "KING", 20, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "DBL", "DBL", 30, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "SGL", "SGL", 40, "{\"adult_capacity\":1}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TRP", "TRP", 50, "{\"adult_capacity\":3}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM1", "RM1", 60, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM2", "RM2", 70, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM3", "RM3", 80, "{\"adult_capacity\":2}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "BAR", "BAR", 10, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "RACK", "RACK", 20, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "PACKAGE", "PACKAGE", 30, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "GROUP", "GROUP", 40, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "FIT", "FIT", 50, "{\"pricing_available\":false}"),
new CodeSeed(ReservationV4CatalogType.MARKET.name(), MARKET_CODE, MARKET_CODE, 10, null),
new CodeSeed(ReservationV4CatalogType.SOURCE.name(), SOURCE_CODE, SOURCE_CODE, 10, null));
private final PlatformHotelRepository hotelRepository;
private final ReservationV4CatalogRepository catalogRepository;
/**
* 注入平台酒店 Repository 和 Reservation 目录 Repository保持平台模块不反向依赖业务目录。
*/
public ReservationV4CatalogBootstrapRunner(
PlatformHotelRepository hotelRepository,
ReservationV4CatalogRepository catalogRepository) {
this.hotelRepository = hotelRepository;
this.catalogRepository = catalogRepository;
}
/**
* 应用启动时为当前 ACTIVE 酒店补齐初始化目录;已有目录的酒店不会被覆盖。
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
List<PlatformHotelEntity> activeHotels = hotelRepository.listActiveHotels();
if (activeHotels == null || activeHotels.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
for (PlatformHotelEntity hotel : activeHotels) {
seedHotelCatalogIfEmpty(hotel == null ? null : hotel.getHotelId(), now);
}
}
/**
* 当前酒店目录为空时插入固定初始化目录,避免 Flyway 之后新建的默认酒店没有可用 lookup。
*/
private void seedHotelCatalogIfEmpty(String hotelId, LocalDateTime now) {
String normalizedHotelId = trimToNull(hotelId);
if (normalizedHotelId == null) {
return;
}
if (!catalogRepository.hasAnyAccount(normalizedHotelId)) {
for (AccountSeed seed : ACCOUNT_SEEDS) {
catalogRepository.insertAccountIfAbsent(accountEntity(normalizedHotelId, seed, now));
}
}
if (!catalogRepository.hasAnyCatalogCode(normalizedHotelId)) {
for (CodeSeed seed : CODE_SEEDS) {
catalogRepository.insertCatalogCodeIfAbsent(codeEntity(normalizedHotelId, seed, now));
}
}
}
/**
* 构造 Account 固定种子实体,主键交给 MyBatis-Plus 生成。
*/
private ReservationCatalogAccountEntity accountEntity(String hotelId, AccountSeed seed, LocalDateTime now) {
ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity();
entity.setHotelId(hotelId);
entity.setAccountCode(seed.accountCode());
entity.setAccountName(seed.accountName());
entity.setMarketCode(MARKET_CODE);
entity.setSourceCode(SOURCE_CODE);
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
entity.setCatalogVersion(SEED_CATALOG_VERSION);
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
return entity;
}
/**
* 构造通用代码固定种子实体,主键交给 MyBatis-Plus 生成。
*/
private ReservationCatalogCodeEntity codeEntity(String hotelId, CodeSeed seed, LocalDateTime now) {
ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity();
entity.setHotelId(hotelId);
entity.setCatalogType(seed.catalogType());
entity.setCode(seed.code());
entity.setDisplayName(seed.displayName());
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
entity.setSortOrder(seed.sortOrder());
entity.setCatalogVersion(SEED_CATALOG_VERSION);
entity.setMetadataJson(seed.metadataJson());
entity.setVersion(0L);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
return entity;
}
/**
* 去除字符串首尾空白,空白值统一视为 null。
*/
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
private record AccountSeed(String accountCode, String accountName) {
}
private record CodeSeed(String catalogType, String code, String displayName, int sortOrder, String metadataJson) {
}
}

View File

@@ -27,6 +27,7 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_NUM = 1000;
private static final int MAX_PAGE_SIZE = 100;
private static final int METADATA_PAGE_SIZE = 1;
private static final String EMPTY_CATALOG_SOURCE = "DATABASE_EMPTY";
private static final String EMPTY_CATALOG_VERSION = "unknown";
@@ -62,15 +63,20 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(this::accountItem)
.toList();
List<ReservationV4CatalogAccountSnapshot> metadataItems = catalogRepository.queryActiveAccounts(
hotelId,
null,
DEFAULT_PAGE_NUM,
METADATA_PAGE_SIZE).items();
return new ReservationV4CatalogLookupResult(
hotelId,
ReservationV4CatalogType.ACCOUNT.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
sourceSystem(metadataItems),
catalogVersion(metadataItems),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
warnings(metadataItems));
}
/**
@@ -107,15 +113,21 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
.map(snapshot -> codeItem(catalogType, snapshot))
.toList();
List<ReservationV4CatalogCodeSnapshot> metadataItems = catalogRepository.queryActiveCatalogCodes(
hotelId,
catalogType.name(),
null,
DEFAULT_PAGE_NUM,
METADATA_PAGE_SIZE).items();
return new ReservationV4CatalogLookupResult(
hotelId,
catalogType.name(),
sourceSystem(page.items()),
catalogVersion(page.items()),
sourceSystem(metadataItems),
catalogVersion(metadataItems),
false,
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
warnings(page.items()));
warnings(metadataItems));
}
/**

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import org.junit.jupiter.api.Test;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_v4_catalog_bootstrap_runner_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=v4-catalog-fresh-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=V4目录新酒店管理员",
"auth.bootstrap.default-hotel-id=HOTEL-FRESH",
"auth.bootstrap.default-hotel-name=新酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationV4CatalogBootstrapRunnerTest {
private static final String HOTEL_ID = "HOTEL-FRESH";
@Autowired
private MockMvc mockMvc;
@Test
void shouldSeedCatalogForBootstrapDefaultHotelWhenFlywayDidNotSeeHotel() throws Exception {
String token = loginToken(mockMvc, "v4-catalog-fresh-admin", "Admin@123456");
performAuthorized(mockMvc, token, get("/api/reservation/lookups/accounts")
.param("hotel_id", HOTEL_ID)
.param("keyword", "QBD"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT"))
.andExpect(jsonPath("$.catalog_version").value("seed-20260719-v1"))
.andExpect(jsonPath("$.items[0].code").value("QBD_TRAVEL"));
}
}

View File

@@ -118,6 +118,20 @@ class ReservationV4CatalogLookupControllerTest {
.andExpect(jsonPath("$.items[0].pricing_available").value(false));
}
@Test
void shouldKeepCatalogMetadataWhenKeywordMatchesNothing() throws Exception {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts")
.param("hotel_id", HOTEL_ID)
.param("keyword", "NO_SUCH_ACCOUNT"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.catalog_type").value("ACCOUNT"))
.andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT"))
.andExpect(jsonPath("$.catalog_version").value("seed-20260719-v1"))
.andExpect(jsonPath("$.items").isEmpty())
.andExpect(jsonPath("$.page.total").value(0));
}
@Test
void shouldRejectCatalogLookupWithoutPermissionOrHotelAccess() throws Exception {
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/lookups/accounts")