# 架构决策:智能预约助手 M0 契约与数据模型
> 日期:2026-08-02
> 状态:**Accepted / Batch 2 implemented — petstore-backend `3c00065`**
> 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 — proposal and implementation diff completed`
> 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`(petstore-backend `3c00065`)。
### 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` 并发规则。
**实现 diff 复核通过。** `BookingAgentSession` JPA、迁移与 mapper 保持上述冻结字段和三个索引;六态与 `input_modality` 有数据库 `CHECK`,owner 查询持悲观锁,未增加 `appointment_id`、`deleted`、turn 表或原文列。迁移测试、版本/TTL/清理测试均已落地。
### 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 纳入必须重新授权。
## 12. Batch 2 实现证据
- Backend:`3c00065 feat: add booking agent session orchestration`;
- 五个 endpoint、JPA 会话、迁移、owner/store 数据边界、版本与 TTL 已实现;
- 本体 `BookingAgentSession`、五个动作、三个事件、五条规则和十条关系已从 `documented` 更新为 `anchored`;
- 后端全量测试 292 项通过,本体端点 73/73、JPA 实体 17/17,无漂移;
- 功能开关仍默认关闭,真实供应商 smoke、Batch 3 前端和生产开启仍未完成。