# 架构决策：智能预约助手 M0 契约与数据模型

> 日期：2026-08-02<br>
> 状态：**Accepted for M0 implementation / 尚未实现**<br>
> Workstream：`Core Booking Flow`<br>
> Track：`Architecture`<br>
> Priority：`P0`（独立试验流，不高于 RC6 真实门店基线）<br>
> Owner Agent：`System Architect`<br>
> Paired QA：`Core Flow QA`<br>
> Service Lock：`docs:booking-agent-m0-contract+ontology`<br>
> Architecture Review Required：`Yes — completed by this ADR`<br>
> Data Model Review Required：`Yes — completed for implementation proposal`<br>
> Access / Privacy Review Required：`Yes — development boundary accepted; live provider and production review pending`<br>
> 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 纳入必须重新授权。
