# 架构决策:智能预约助手 M0 契约与数据模型 > 日期:2026-08-02
> 状态:**Accepted for M0 implementation / 尚未实现**
> Workstream:`Core Booking Flow`
> Track:`Architecture`
> Priority:`P0`(独立试验流,不高于 RC6 真实门店基线)
> Owner Agent:`System Architect`
> Paired QA:`Core Flow QA`
> Service Lock:`docs:booking-agent-m0-contract+ontology`
> Architecture Review Required:`Yes — completed by this ADR`
> Data Model Review Required:`Yes — completed for implementation proposal`
> Access / Privacy Review Required:`Yes — development boundary accepted; live provider and production review pending`
> RC Included:`No` **关联资产** - [产品与技术方案](./宠小它智能预约助手-产品与技术方案-v0.1.md) - [M0 编码任务 brief](./智能预约助手-M0编码任务brief-2026-08-02.md) - [M0 执行队列](./智能预约助手-M0执行队列-2026-08-02.md) - [`booking-intent-v1` JSON Schema](./contracts/booking-intent-v1.schema.json) - [M0 OpenAPI 契约](./contracts/booking-agent-m0.openapi.yaml) - [模型与语音供应商选型](./智能预约助手-模型与语音供应商选型-2026-08-02.md) ## 1. 决策结论 M0 冻结为 **「已登录 customer 在已选门店范围内,用文字或语音生成已验证预约草稿,再回填现有普通预约表单」**。 冻结边界: 1. 门店不是模型槽位。宠主必须先在 `CustAppointmentCreate` 选定门店,创建会话时只提交 `storeId`,服务端验证门店存在。 2. 模型只输出通过 `booking-intent-v1` 校验的非权威文本约束,不输出或决定业务 ID、价格、服务事实、号源或预约状态。 3. 宠物、服务、日期时间和号源全部由 Petstore 后端在 `current.userId + session.storeId` 范围内重新解析。 4. M0 没有 `/confirm` endpoint,不调用 `AppointmentService.createBooking`,不修改 `Appointment` 表或状态机。 5. 「带入普通预约」只返回一次页面级回填数据;最终仍由现有 `/api/appointment/create` 提交。 6. 原始语音、当轮用户原文、模型请求/响应和完整对话不落库、不进业务事件、不进日志。 7. 功能开关默认关闭,不进入 `phase2-pilot-rc6`。 ## 2. 为什么先选门店 原方案允许助手在对话内收集门店,但会同时引入门店名称歧义、地理推荐、跨店服务匹配和无门店 `BusinessEvent` 等问题。当前产品仍是单店 SaaS 试点,不需要把门店发现变成 Agent 能力。 M0 的次级入口仍位于 `CustAppointmentCreate`,行为调整为: 1. 未登录:先登录并返回原预约页; 2. 未选门店:先使用现有门店选择控件; 3. 已选有效门店:创建智能会话; 4. 会话内只解析该门店的服务与容量。 这样可以让每个 session 和漏斗事件都有明确 `storeId`,并阻止模型扩大门店数据范围。 ## 3. `booking-intent-v1` 冻结 模型输出以 [`contracts/booking-intent-v1.schema.json`](./contracts/booking-intent-v1.schema.json) 为唯一机器契约。 ### 3.1 字段语义 | 字段 | 语义 | 是否业务权威 | |---|---|---| | `schemaVersion` | 固定 `booking-intent-v1` | 是,仅契约版本 | | `intent` | `book / modify / end / fallback` | 否,状态机重新判定 | | `petQuery` | 宠物展示名搜索词 | 否 | | `serviceQuery` | 服务名称/同义表达搜索词 | 否 | | `dateExpression` | 用户日期表达 | 否 | | `timeWindow` | `HH:mm` 起止偏好 | 否 | | `remark` | 用户希望带入预约的短备注 | 否,可编辑且限 200 字 | | `clearFields` | 用户明确要求清空的草稿字段 | 否,服务端只允许白名单字段 | | `ambiguities` | 模型观察到的歧义类型 | 否,服务端重新确认 | | `nextAction` | 下一步建议 | 否,编排器可忽略 | ### 3.2 强校验顺序 1. 响应体必须是单个 JSON object,不接受 Markdown fence 或前后说明文字; 2. JSON Schema 校验通过,未知字段拒绝; 3. 文本 trim、控制字符过滤和长度校验; 4. 输出出现任意 ID、价格、手机号、号源或预约状态字段时整个响应降级; 5. 服务端时间解析、归属解析和号源查询成功后才更新 `BookingDraft`; 6. 失败不尝试从残缺 JSON 猜字段,返回 `AGENT_UNAVAILABLE` 并保留旧草稿。 ### 3.3 时间口径 - 时区固定 `Asia/Shanghai`; - 支持绝对日期、今天/明天/后天、本周 X/下周 X、精确时间、上午/下午、X 点以前/以后; - 「过几天」「最近」「周末都行」等表达不得猜成绝对日期,必须返回日期选择项; - `appointmentTime` 只有在现有容量服务返回真实 slot 后才能写入草稿; - 对外时间字符串统一为 `yyyy-MM-dd'T'HH:mm:ss`,语义为 `Asia/Shanghai` 本地时间。 ## 4. M0 会话状态机 ```mermaid stateDiagram-v2 [*] --> collecting: create session collecting --> collecting: 信息仍不足 collecting --> proposing: 可搜索真实号源 proposing --> collecting: 修改条件或需要澄清 proposing --> confirmable: 选择真实 slot confirmable --> collecting: 用户修改草稿 collecting --> fallback: 用户回填表单或 LLM 降级 proposing --> fallback: 用户回填表单或 LLM 降级 confirmable --> fallback: 带入普通预约 collecting --> cancelled: 用户结束 proposing --> cancelled: 用户结束 confirmable --> cancelled: 用户结束 collecting --> expired: TTL 到期 proposing --> expired: TTL 到期 confirmable --> expired: TTL 到期 ``` 不允许的状态:`submitting`、`booked`、`needs_reselection`。终态 `fallback / expired / cancelled` 不接受新消息或转写。 每次消息请求必须带客户端当前 `draftVersion`。服务端只在版本相等时更新,成功后加一;版本冲突返回 `DRAFT_VERSION_CONFLICT`,不得静默覆盖。 ## 5. API 冻结 唯一契约是 [`contracts/booking-agent-m0.openapi.yaml`](./contracts/booking-agent-m0.openapi.yaml)。M0 只注册五个 endpoint: | Method | Endpoint | 结果 | |---|---|---| | `POST` | `/api/booking-agent/sessions` | 在已选 `storeId` 下创建 customer 会话 | | `POST` | `/api/booking-agent/sessions/{sessionId}/messages` | 提交当轮已确认文字并刷新草稿 | | `POST` | `/api/booking-agent/sessions/{sessionId}/transcriptions` | 转写短音频,文字返回输入框,不自动发送 | | `POST` | `/api/booking-agent/sessions/{sessionId}/fallback` | 返回已验证的部分/完整草稿并结束会话 | | `DELETE` | `/api/booking-agent/sessions/{sessionId}` | 结束本人会话 | ### 5.1 身份与资源隐藏 - 所有 endpoint 都必须经过 `AuthInterceptor`; - controller 再要求 `current.role == customer`,boss/staff 返回 `FORBIDDEN`; - session 每次以 `sessionId + current.userId` 查询;不存在和他人 session 统一返回 `SESSION_NOT_FOUND`; - `storeId` 只在创建时接收并验证,后续从 session 读取; - 音频转写 endpoint 必须挂在 session 下,客户端不得上传宠物/服务词表。 ### 5.2 响应语义 沿用 `{ code, message?, bizCode?, data? }`。当前前端以 body `code` 判断业务结果;除 `AuthInterceptor` 的 HTTP 401 外,M0 不借机重构全局 HTTP status 行为。 草稿中的 `storeId / petId / serviceTypeId / appointmentTime` 是经过后端重新解析的事实;客户端回填后,现有预约创建服务仍会重新执行最终权限、服务和容量校验。 ### 5.3 业务码 | body code | bizCode | 语义 | |---|---|---| | 401 | `UNAUTHENTICATED` | session token 缺失或失效 | | 403 | `FORBIDDEN` | 非 customer 调用 | | 503 | `AGENT_DISABLED` | 功能开关关闭 | | 404 | `STORE_NOT_FOUND` | 创建时目标门店无效 | | 404 | `SESSION_NOT_FOUND` | session 不存在或不属于当前 customer | | 410 | `SESSION_EXPIRED` | 会话 TTL 到期 | | 409 | `SESSION_TERMINAL` | 会话已 fallback/cancelled | | 409 | `DRAFT_VERSION_CONFLICT` | 客户端草稿版本过旧 | | 400 | `INVALID_INPUT` | 空文本、超长或请求结构无效 | | 400 | `INVALID_AUDIO` | 空音频、格式/MIME/文件头不匹配或时长非法 | | 413 | `AUDIO_TOO_LARGE` | 超过 3 MB | | 429 | `RATE_LIMITED` | customer 请求频率超限 | | 503 | `AGENT_UNAVAILABLE` | LLM 超时、无效 JSON/schema 或外部失败 | | 503 | `ASR_UNAVAILABLE` | ASR 超时或外部失败 | 宠物/服务歧义、无号源和未选时段是正常 `200` 会话响应,不用异常码表达。 ## 6. 数据模型冻结 计划迁移:`backend/db/migrations/20260802_create_booking_agent_session.sql`。Batch 0 只冻结设计,不在 docs 任务内创建迁移。 ### 6.1 `t_booking_agent_session` | 字段 | 类型 | 规则 | |---|---|---| | `id` | BIGINT | 自增主键,只在后端和 `BusinessEvent.aggregate_id` 使用 | | `session_id` | VARCHAR(36) | UUID,唯一,对客户端暴露 | | `customer_user_id` | BIGINT | 必须是创建会话的 customer | | `store_id` | BIGINT | M0 必填;创建时验证存在,后续不接受覆盖 | | `status` | VARCHAR(24) | 仅六个 M0 状态 | | `draft_json` | TEXT | 最小已验证草稿,不存当轮原文/模型响应 | | `draft_version` | INT | 初始 0,每次成功草稿更新加一 | | `entry_source` | VARCHAR(32) | M0 固定 `appointment_create` | | `input_modality` | VARCHAR(16) | `text / voice / mixed`,首次成功消息后派生 | | `expires_at` | DATETIME | 创建时固定 `now + 30min`,M0 不滑动续期 | | `create_time / update_time` | DATETIME | 服务端时间 | 索引: - `UNIQUE uk_booking_agent_session_public_id (session_id)`; - `INDEX idx_booking_agent_customer_status_expire (customer_user_id, status, expires_at)`; - `INDEX idx_booking_agent_status_expire (status, expires_at)`。 不增加: - `appointment_id`; - `t_booking_agent_turn`; - 原始语音/转写/提示词/模型响应列; - `deleted`。会话是短期对象,使用明确终态并按 TTL 物理清理。 ### 6.2 TTL 与清理 1. 会话固定 30 分钟有效,不因消息滑动延长; 2. endpoint 发现过期时返回 `SESSION_EXPIRED`,并以幂等方式标记 `expired`; 3. 每小时清理任务将到期活动会话标记 `expired`; 4. `expires_at` 早于当前时间 24 小时的 session 物理删除; 5. 清理失败记录数量和错误类别,不记录 `draft_json`; 6. `BusinessEvent` 是独立低敏事实,不随短期 session 删除。 ## 7. 业务事件冻结 M0 只新增三个首次漏斗事实: | 事件 | 触发 | 幂等键 | metadata 白名单 | |---|---|---|---| | `booking_agent_started` | session 创建成功 | `booking_agent_started:{sessionDbId}` | `entrySource` | | `booking_agent_draft_ready` | session 首次进入 `confirmable` | `booking_agent_draft_ready:{sessionDbId}` | `inputModality` | | `booking_agent_fallback` | 用户回填表单或 LLM 降级 | `booking_agent_fallback:{sessionDbId}` | `reason=user/llm_unavailable` | 事件使用: - `storeId=session.storeId`; - `aggregateType=booking_agent_session`; - `aggregateId=session.id`; - `actorUserId=session.customerUserId`、`actorRole=customer`、`source=customer`; - 已有 `StoreCustomer` 时可关联,不能只为启动助手创建客户主档。 禁止把输入原文、转写文本、备注、宠物名、服务名、供应商 request ID 或错误原文写入 metadata。 M0 不写 `booking_agent_confirmed`,也不修改现有 `appointment_created` metadata。精确联结「助手草稿 -> 表单创建」需要另行设计服务端一次性交接机制。 ## 8. 外部调用与隐私门禁 ### 8.1 语音 - 单段不超过 60 秒、3 MB;客户端预检,服务端按字节、文件头、MIME 和解析时长复核; - ASR 使用 `qwen3-asr-flash-2026-02-10` Base64 Data URL,不中转 OSS; - 上下文只包含当前门店服务名和当前 customer 的宠物展示名; - 返回转写后立即释放音频字节,不缓存、不写文件、不返回供应商 request ID; - 转写必须由用户编辑/确认后才能调用 messages。 ### 8.2 LLM - 使用固定 `qwen-plus-2025-12-01`、非思考、非流式、无工具、无联网; - LLM 超时 3 秒,ASR 超时 5 秒;不做多次自动重试; - 请求只包含当次文字、最小草稿和枚举说明,不包含手机号、token、客户时间线或其他门店数据; - API Key、Workspace ID 和 Authorization header 不进 Git、测试 fixture、聊天、截图或日志。 ### 8.3 频率门禁 M0 默认每个 customer: - 10 分钟内最多创建 5 个 session; - 10 分钟内最多提交 30 条 message; - 10 分钟内最多转写 10 段音频。 超限返回 `RATE_LIMITED`,普通预约继续可用。当前单实例可先使用进程内限流;进入多实例前必须切换共享限流状态。 ## 9. Review 结论 ### Architecture Review **通过,可进入实现。** 条件是严格保留五个 endpoint、无 `/confirm`、门店先选、普通表单独立可用、feature flag 默认关闭。 ### Data Model Review **通过提案,可由 Backend Core 实现迁移。** 相比原 brief 的修订: - 使用 `id BIGINT` 内部主键 + `session_id UUID` 外部唯一键; - `store_id` 从可空改为 M0 必填; - 去掉 `deleted` 和 `appointment_id`; - 增加固定 TTL、物理清理和 `draft_version` 并发规则。 ### Access / Privacy Review **开发边界通过,真实供应商 smoke 与生产开启未通过。** 不需要真实密钥即可实现接口、stub、状态机和测试;真实调用前仍需完成供应商数据处理确认、小程序隐私告知、费用告警和已知情语音样本评测。 ## 10. 后果与风险 正向结果: - M0 不增加第二条预约写路径,模型失败不会污染 `Appointment`; - session 可短期恢复结构化草稿,又不形成对话原文数据库; - OpenAPI、JSON Schema 和本体可以直接驱动测试与 review; - 后续 M1 必须另立 ADR 才能新增 confirm/appointment 关联。 已知限制: - 门店必须先选,无法用一句话同时完成「找店 + 预约」; - M0 不精确统计回填后最终创建转化; - 进程内限流只适用于当前单实例; - 供应商效果仍需真实语料验证,当前推荐不是生产准入结论。 ## 11. 实现门禁 编码 PR 必须同时满足: - 引用本 ADR、OpenAPI、JSON Schema 和对应 `ontologyRefs`; - provider 单测只访问本地 HTTP stub; - 后端测试证明整个 M0 不调用 `AppointmentMapper.save`; - 未配置密钥或 feature flag 关闭时应用正常启动、普通预约正常; - 本体从 `documented` 更新为 `anchored` 只能与代码和测试同一批完成; - 真实供应商 smoke、生产配置和 RC 纳入必须重新授权。