From 8b37232ac93ee8da7a4b35001ae7959434126cec Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 14:56:11 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DV4=E7=9B=AE=E5=BD=95=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E5=92=8Clookup=E7=A9=BA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 4 +- .../backend-to-frontend-notes.md | 8 +- docs/project/go-live-notes.md | 4 +- .../M002-order-task-workflow-v3.md | 2 +- .../M002-v4-real-catalog-lookup-api-design.md | 7 +- .../impl/PlatformIdentityBootstrapRunner.java | 3 + ...MybatisReservationV4CatalogRepository.java | 64 +++++++ .../ReservationV4CatalogRepository.java | 22 +++ .../ReservationV4CatalogBootstrapRunner.java | 157 ++++++++++++++++++ ...ReservationV4CatalogLookupServiceImpl.java | 24 ++- ...servationV4CatalogBootstrapRunnerTest.java | 51 ++++++ ...ervationV4CatalogLookupControllerTest.java | 14 ++ 12 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogBootstrapRunner.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogBootstrapRunnerTest.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index be99dea..edbb63e 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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 来源通知 ack;M002 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 来源通知 ack;M002 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 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index cd7e5a4..c94fcb7 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -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 路由修订均已完成。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 5afe102..f6d7a5b 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -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 或测试库已经跑过。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 27f7aca..f3c6232 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -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 批量迁移。 diff --git a/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md b/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md index 68bae25..3b787cc 100644 --- a/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md +++ b/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md @@ -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 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index ae0c9af..2ca3050 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -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; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java index 7801a3a..af3a6f2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java @@ -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.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.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.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.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(), diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java index b9a3d68..65ea284 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java @@ -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); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogBootstrapRunner.java new file mode 100644 index 0000000..1021faf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogBootstrapRunner.java @@ -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 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 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 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) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java index c76de97..fe2f3ce 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java @@ -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 items = page.items().stream() .map(this::accountItem) .toList(); + List 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 items = page.items().stream() .map(snapshot -> codeItem(catalogType, snapshot)) .toList(); + List 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)); } /** diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogBootstrapRunnerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogBootstrapRunnerTest.java new file mode 100644 index 0000000..e34cabe --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogBootstrapRunnerTest.java @@ -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")); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java index 09d7ea8..71d2088 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java @@ -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")