Просмотр исходного кода

docs(ai-terminal): add comprehensive frontend integration guide v1.0

13 sections covering complete terminal-to-backend integration:
- Architecture overview (frontend monorepo ↔ backend modules)
- HMAC-SHA256 auth with native shell signing pattern
- Startup flow (register → heartbeat → scene → home, with code)
- SSE chat stream (full backend flow trace + EventSource parse code)
- Card action closed loop (full registration sequence + implementation)
- File upload flow
- Robot command polling/ack
- Device event ingestion
- Three-device capability matrix
- Error handling reference table
- Security red lines
- OpenAPI SDK generation
- Local dev setup
WangKang 2 недель назад
Родитель
Сommit
0545d00b19
1 измененных файлов с 745 добавлено и 0 удалено
  1. 745 0
      docs/架构文档/统一入口客户端前端接入指引_v1.0.md

+ 745 - 0
docs/架构文档/统一入口客户端前端接入指引_v1.0.md

@@ -0,0 +1,745 @@
+# 统一入口客户端 前端接入指引 v1.0
+
+> **受众:** 前端工程师、终端厂商联调人员  
+> **前置阅读:** `terminal-client-mvp-contract.md`(接口契约)、`统一入口客户端技术设计文档_v1.0.md`(架构设计)  
+> **目标:** 从零开始,完成终端接入后端的完整联调
+
+---
+
+## 1. 架构速览
+
+```
+┌─────────────────────────────────────────────────────────┐
+│  emoon-terminal-client (独立仓库,Vue 3 + TypeScript)     │
+│                                                         │
+│  apps/robot-client    apps/kiosk-client   apps/guide-screen-client
+│  └─ 终端壳持有密钥并签名                                   │
+│                                                         │
+│  packages/api-client  ← 从 openapi.yaml 生成              │
+│  packages/medical-cards  ← 卡片组件库                     │
+│  packages/terminal-bridge ← JSBridge 抽象                 │
+└──────────────────────┬──────────────────────────────────┘
+                       │ HMAC-SHA256 签名 HTTP
+                       ▼
+┌─────────────────────────────────────────────────────────┐
+│  emoon-backend (AI 中台)                                 │
+│                                                         │
+│  DeviceController    ← 设备注册/心跳/场景/命令/事件         │
+│  AgentChatController ← SSE 流式对话                       │
+│  CardActionController← 卡片查询/动作                       │
+│  FileController      ← 文件上传                           │
+│                                                         │
+│  内部链路:Router → IntentClassifier → Task → Card → MCP  │
+└─────────────────────────────────────────────────────────┘
+```
+
+**核心原则:前端不决定业务逻辑。** 前端只发送用户输入和设备上下文,后端决定路由到哪个智能体、创建什么卡片、调用什么工具。
+
+---
+
+## 2. 鉴权接入
+
+### 2.1 签名算法
+
+所有终端接口使用 HMAC-SHA256 签名,输出为 **Base64 编码**:
+
+```text
+canonical = METHOD + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + SHA256(body)
+signature = Base64(HMAC-SHA256(secretKey, canonical))
+```
+
+### 2.2 请求头
+
+```http
+X-Emoon-Access-Key: pk-xxxx
+X-Emoon-Timestamp: 1717000000000
+X-Emoon-Nonce: 550e8400-e29b-41d4-a716-446655440000
+X-Emoon-Signature: <base64-output>
+```
+
+### 2.3 前端实现建议
+
+**密钥不得放入 `VITE_*` 环境变量**(会编译进 web bundle,浏览器可直接读取)。
+
+正确做法:
+
+```typescript
+// packages/terminal-bridge/src/auth.ts
+
+// 由受控终端壳(Android WebView / Electron / 自助机原生层)注入
+interface NativeSigner {
+  signRequest(method: string, path: string, timestamp: string,
+              nonce: string, bodyHash: string): Promise<string>;
+}
+
+// 前端 SDK 不持有密钥,调用桥接层签名
+export async function buildAuthHeaders(
+  method: string, path: string, body: string
+): Promise<Record<string, string>> {
+  const timestamp = String(Date.now());
+  const nonce = crypto.randomUUID();
+  const bodyHash = await sha256Hex(body);
+  const signature = await nativeSigner.signRequest(method, path, timestamp, nonce, bodyHash);
+
+  return {
+    'X-Emoon-Access-Key': accessKey,
+    'X-Emoon-Timestamp': timestamp,
+    'X-Emoon-Nonce': nonce,
+    'X-Emoon-Signature': signature,
+  };
+}
+
+async function sha256Hex(data: string): Promise<string> {
+  const hashBuffer = await crypto.subtle.digest('SHA-256',
+    new TextEncoder().encode(data));
+  return Array.from(new Uint8Array(hashBuffer))
+    .map(b => b.toString(16).padStart(2, '0')).join('');
+}
+```
+
+### 2.4 鉴权错误处理
+
+| HTTP 状态 | 错误码 | 原因 | 前端处理 |
+|-----------|--------|------|----------|
+| 401 | `AUTH_TIMESTAMP_EXPIRED` | 时间戳偏差 > 5 分钟 | 检查终端系统时间 |
+| 401 | `AUTH_NONCE_REUSED` | 重放攻击检测 | 生成新 nonce 重试 |
+| 401 | `AUTH_SIGNATURE_INVALID` | 签名错误 | 检查密钥和 canonical 构造 |
+| 401 | `PROJECT_NOT_EXISTS` | accessKey 无效 | 检查配置 |
+
+---
+
+## 3. 启动流程(必读)
+
+终端启动后必须按以下顺序调用:
+
+```text
+step 1: POST /devices/register       → 拿到 deviceId + activateStatus
+step 2: setInterval 30s: POST /devices/{deviceId}/heartbeat
+step 3: GET /devices/{deviceId}/scene → 拿到场景配置,渲染首页
+step 4: 用户输入 → POST /agent/chat/stream → SSE 事件驱动 UI 更新
+```
+
+### 3.1 Step 1:设备注册
+
+**请求:**
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/devices/register \
+  -H "Content-Type: application/json" \
+  -H "X-Emoon-Access-Key: pk-kiosk-demo" \
+  -H "X-Emoon-Timestamp: 1717000000000" \
+  -H "X-Emoon-Nonce: uuid-001" \
+  -H "X-Emoon-Signature: <signature>" \
+  -d '{
+    "hospitalId": "H001",
+    "deviceCode": "EMOON-KIOSK-001",
+    "deviceType": "self_service_kiosk",
+    "vendor": "emoon",
+    "model": "kiosk-v2",
+    "clientVersion": "0.1.0",
+    "capabilities": {"touch": true, "camera": true},
+    "location": {"floor": "1F", "area": "门诊大厅"}
+  }'
+```
+
+**后端流程:**
+
+```text
+DeviceController.register()
+  → DeviceRegistryFacade.register()
+    → 查 ai_device_registry 是否已有该 deviceCode
+    → 已有且 activated/online → 返回 activated
+    → 已有且 rejected → 返回 rejected + reason
+    → 已有且 pending → 返回 pending + reason
+    → 新设备 → INSERT,默认 status=pending, admission_level=B → 返回 pending
+```
+
+**响应处理:**
+
+| activateStatus | 前端行为 |
+|----------------|----------|
+| `activated` | ✅ 正常进入 step 2 |
+| `pending` | ⚠️ 展示"等待激活"页面,仅允许心跳,不进入核心业务 |
+| `rejected` | 🚫 展示拒绝原因 + 联系运维入口 |
+
+**响应:**
+
+```json
+{
+  "code": 200,
+  "data": {
+    "deviceId": "EMOON-KIOSK-001",
+    "deviceSecret": "sk-xxx",
+    "activateStatus": "activated",
+    "admissionLevel": "A",
+    "reason": null
+  }
+}
+```
+
+### 3.2 Step 2:心跳
+
+**请求:**
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/heartbeat \
+  -H "Content-Type: application/json" \
+  -d '{"clientVersion":"0.1.0","status":"online","currentSceneCode":"outpatient_kiosk"}'
+```
+
+**后端流程:**
+
+```text
+DeviceController.heartbeat()
+  → DeviceRegistryFacade.heartbeat()
+    → 查 ai_device_registry(deviceId, projectId)
+    → device==null → 返回 false
+    → status=="rejected" → 返回 false(设备已拒绝)
+    → status=="pending" → 更新 last_heartbeat/version,保持 status=pending → 返回 false(准入未通过)
+    → status=="activated"/"online" → 更新 status/last_heartbeat/version → 返回 true
+```
+
+**前端实现:**
+
+```typescript
+// 启动后立即开始,每 30 秒一次
+const heartbeatTimer = setInterval(async () => {
+  const resp = await api.heartbeat(deviceId, {
+    clientVersion: APP_VERSION,
+    status: 'online',
+    currentSceneCode: currentScene,
+  });
+  if (!resp.data.accepted) {
+    // 设备被拒绝或等待激活
+    handleDeviceBlocked();
+  }
+}, 30_000);
+```
+
+### 3.3 Step 3:获取场景
+
+**请求:**
+
+```bash
+curl https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/scene
+```
+
+**后端流程:**
+
+```text
+DeviceController.scene()
+  → DeviceRegistryFacade.scene()
+    → 查 ai_device_registry(deviceId, projectId)
+    → device==null → 返回 null(404)
+    → status 非 activated/online → 返回 null(准入阻断)
+    → 查 ai_device_scene_binding(deviceId)
+    → binding==null → 返回最小 profile(sceneCode=null, homeTemplate="default")
+    → binding 存在 → 解析 agentBindingsJson + cardScopesJson + capabilities → 返回完整 SceneProfileResult
+```
+
+**响应:**
+
+```json
+{
+  "code": 200,
+  "data": {
+    "deviceId": "EMOON-KIOSK-001",
+    "deviceType": "self_service_kiosk",
+    "sceneCode": "outpatient_kiosk",
+    "homeTemplate": "kiosk_home_v1",
+    "defaultAgent": "opd-guide-agent",
+    "allowedAgents": ["opd-guide-agent","opd-triage-agent","opd-registration-agent","tongue-diagnosis-agent"],
+    "blockedIntents": [],
+    "capabilities": ["touch","camera","id_card_ocr","file_upload"],
+    "allowedCards": ["department-selection","doctor-selection","time-slot-selection","confirm-appointment","appointment-success","tongue-capture"]
+  }
+}
+```
+
+**前端使用:**
+
+```typescript
+// sceneProfile 决定整个终端的 UI 能力
+const scene = await api.getScene(deviceId);
+
+// 1. 根据 homeTemplate 加载首页布局
+renderHome(scene.homeTemplate);  // "kiosk_home_v1"
+
+// 2. allowedCards 控制哪些卡片组件可用
+cardRegistry.enableOnly(scene.allowedCards);
+
+// 3. capabilities 控制硬件能力入口
+if (scene.capabilities.includes('camera')) showCameraButton();
+if (scene.capabilities.includes('id_card_ocr')) showIdCardReader();
+```
+
+---
+
+## 4. 流式对话(SSE)
+
+### 4.1 请求
+
+```bash
+curl -N -X POST https://api.demo.emoon.local/api/v1/agent/chat/stream \
+  -H "Content-Type: application/json" \
+  -H "Accept: text/event-stream" \
+  -d '{
+    "deviceContext": {"deviceId": "EMOON-KIOSK-001", "deviceType": "self_service_kiosk"},
+    "message": {"type": "text", "text": "我头疼三天,想挂号", "clientMessageId": "m-reg-001"}
+  }'
+```
+
+> **注意:不传 `agentId`。** 后端 AgentRouter 根据设备类型、场景配置、用户输入、任务状态自动决定路由。
+
+### 4.2 后端完整流程
+
+```text
+AgentChatController.chatStream()
+  │
+  ├─ 1. AuthContextHolder.require() — HMAC 鉴权
+  │
+  ├─ 2. ConversationTerminalService.createOrLoad()
+  │     → 有 conversationId → 加载已有会话
+  │     → 无 conversationId → 创建 conv_<snowflake>
+  │     → appendMessage("user", text, clientMessageId, traceId)
+  │
+  ├─ 3. DeviceRegistryFacade.scene() — 获取设备场景
+  │     → 解析 allowedAgents / blockedIntents
+  │
+  ├─ 4. AgentRouterService.route()
+  │     ├─ Device policy check — guide_screen 阻断挂号/舌诊
+  │     ├─ Active task check — 有活跃任务继续推进
+  │     ├─ IntentClassifier.classify()
+  │     │   ├─ 关键词命中:挂号/舌诊/带我去/查科室 → direct decision
+  │     │   ├─ 关键词 + allowedAgents 白名单校验(不在白名单→blocked)
+  │     │   ├─ DeepSeek JSON 分类(兜底)
+  │     │   └─ 低置信度 → clarify
+  │     └─ 返回 RouteDecision {intentCode, routeAgentCode, taskPolicy, ...}
+  │
+  ├─ 5. TerminalReplyTemplateService.reply(intentCode)
+  │     → 从静态模板选回复文本(MVP 不用 Dify 生成)
+  │
+  ├─ 6. TaskStateService.createTask() — 按 taskPolicy 创建/继续任务
+  │     → REGISTRATION → currentStep=COLLECT_SYMPTOM
+  │     → TONGUE_DIAGNOSIS → currentStep=COLLECT_CHIEF_COMPLAINT
+  │     → GUIDE → currentStep=UNDERSTAND_DESTINATION
+  │
+  ├─ 7. CardInstanceService.createCard() — 按 taskType 创建首张卡片
+  │     → REGISTRATION → department-selection 卡片
+  │     → TONGUE_DIAGNOSIS → tongue-capture 卡片
+  │     → GUIDE+ROUTE_NAVIGATION → route-card 卡片
+  │
+  ├─ 8. SSE 事件发送
+  │     → event: task_updated   data: {taskId, taskType, currentStep}
+  │     → event: message_delta  data: {text: "好的,我来帮您完成挂号..."}
+  │     → event: message_completed data: {messageId, conversationId}
+  │     → event: card_created   data: {cardInstanceId, cardKey}
+  │     → event: completed      data: {conversationId, taskId, traceId}
+  │
+  └─ 9. MeterEventProducer.produce("AGENT_CHAT_COMPLETED", ...)
+```
+
+### 4.3 SSE 事件类型
+
+| 事件名 | 何时触发 | data 示例 | 前端动作 |
+|--------|----------|-----------|----------|
+| `task_updated` | 任务创建/状态变更 | `{"taskId":"task_001","taskType":"REGISTRATION","currentStep":"COLLECT_SYMPTOM"}` | 更新任务进度 UI |
+| `message_delta` | 文本增量 | `{"text":"好的,我来帮您..."}` | 逐字追加到对话气泡 |
+| `message_completed` | 文本完成 | `{"messageId":"msg_001","conversationId":"conv_001"}` | 标记消息完成 |
+| `card_created` | 卡片实例已创建 | `{"cardInstanceId":"card_001","cardKey":"department-selection"}` | 调用 GET /cards/{id} 拉取卡片详情并渲染 |
+| `error` | 任何环节异常 | `{"message":"系统处理异常"}` | 展示错误提示 |
+| `completed` | SSE 流结束 | `{"conversationId":"conv_001","traceId":"trace_xxx"}` | 保存 conversationId 用于断线恢复 |
+
+### 4.4 前端 SSE 实现
+
+```typescript
+// packages/api-client/src/chat.ts
+
+export async function chatStream(
+  deviceId: string, deviceType: string, text: string, clientMessageId: string,
+  conversationId?: string,
+  onEvent: (event: SseEvent) => void,
+  onError: (error: Error) => void
+): Promise<AbortController> {
+  const controller = new AbortController();
+  const headers = await buildAuthHeaders('POST', '/api/v1/agent/chat/stream',
+    JSON.stringify({ deviceContext: { deviceId, deviceType },
+                     message: { type: 'text', text, clientMessageId },
+                     conversationId }));
+
+  const response = await fetch(`${BASE_URL}/api/v1/agent/chat/stream`, {
+    method: 'POST',
+    headers: { ...headers, 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
+    body: JSON.stringify({ deviceContext: { deviceId, deviceType },
+                           message: { type: 'text', text, clientMessageId },
+                           conversationId }),
+    signal: controller.signal,
+  });
+
+  const reader = response.body!.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '';
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    buffer += decoder.decode(value, { stream: true });
+
+    // Parse SSE: "event: xxx\ndata: {...}\n\n"
+    const events = buffer.split('\n\n');
+    buffer = events.pop() || '';
+
+    for (const raw of events) {
+      const eventMatch = raw.match(/^event: (.+)$/m);
+      const dataMatch = raw.match(/^data: (.+)$/m);
+      if (eventMatch && dataMatch) {
+        onEvent({ type: eventMatch[1], data: JSON.parse(dataMatch[1]) });
+      }
+    }
+  }
+  return controller;
+}
+```
+
+### 4.5 断线恢复
+
+```typescript
+// SSE 断线后:用 conversationId 恢复状态
+async function recoverFromDisconnect(conversationId: string) {
+  // 1. 查会话最终消息
+  const conv = await api.getConversation(conversationId);
+
+  // 2. 还原历史消息到 UI
+  for (const msg of conv.messages) {
+    appendMessage(msg.role, msg.content);
+  }
+
+  // 3. 如果有活跃任务,查任务状态
+  if (conv.activeTaskId) {
+    const task = conv.activeTask;
+    updateTaskUI(task.taskType, task.currentStep, task.status);
+  }
+}
+```
+
+---
+
+## 5. 卡片动作闭环
+
+### 5.1 挂号完整序列
+
+这是 MVP 最核心的业务闭环。前端按以下顺序依次渲染卡片并提交动作:
+
+```text
+用户说"我要挂号"
+  → SSE 返回 card_created {cardKey: "department-selection"}
+  → 前端 GET /cards/{cardInstanceId} 获取卡片数据
+  → 渲染科室选择 UI
+  → 用户选择科室 → POST /cards/{id}/actions/select_department
+  → 后端返回 nextCard {cardKey: "doctor-selection"}
+  → 渲染医生选择 UI
+  → 用户选择医生 → POST /cards/{id}/actions/select_doctor
+  → 后端返回 nextCard {cardKey: "time-slot-selection"}
+  → 渲染号源选择 UI
+  → 用户选择时间 → POST /cards/{id}/actions/select_time_slot
+  → 后端返回 nextCard {cardKey: "confirm-appointment"}
+  → 渲染确认卡片(含挂号摘要 + 二次确认按钮)
+  → 用户确认 → POST /cards/{id}/actions/confirm_appointment {confirm: true}
+  → 后端返回 nextCard {cardKey: "appointment-success"}
+  → 渲染"挂号成功"卡片(标注 Mock 标识)
+```
+
+### 5.2 查询卡片
+
+```bash
+curl https://api.demo.emoon.local/api/v1/cards/card_001
+```
+
+响应中的关键字段:
+
+| 字段 | 用途 |
+|------|------|
+| `cardKey` | 决定渲染哪个卡片组件 |
+| `cardData` | 卡片渲染数据(科室列表、医生列表等) |
+| `actions` | 该卡片支持的动作列表 |
+| `status` | `active`=可操作,`expired`/`completed`=只读 |
+| `expiresAt` | 过期时间,超时后不能再提交 |
+
+### 5.3 提交动作
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/cards/card_001/actions/select_department \
+  -d '{"idempotencyKey":"idem-001","confirm":true,"payload":{"departmentId":"neurology"}}'
+```
+
+**后端流程:**
+
+```text
+CardActionController.cardAction()
+  │
+  ├─ 1. AuthContextHolder.require()
+  │
+  ├─ 2. CardInstanceService.findByInstanceId() → 加载卡片实例
+  │     → 解析 conversationId / taskId / contextJson
+  │
+  ├─ 3. CardActionService.submit()
+  │     ├─ 幂等键检查 → 重复返回首次结果
+  │     ├─ 卡片状态校验 → expired/completed 拒绝
+  │     ├─ actionName 合法性校验 → 不在定义中拒绝
+  │     ├─ 确认门校验 → 高风险动作需要 confirm:true
+  │     └─ 写入 ai_card_action_log → 状态 submitted→processing
+  │
+  ├─ 4. AgentActionOrchestrator.execute()
+  │     ├─ select_department → McpToolService.queryDoctors() → 创建 doctor-selection 卡片
+  │     ├─ select_doctor → McpToolService.querySchedules() → 创建 time-slot-selection 卡片
+  │     ├─ select_time_slot → 创建 confirm-appointment 卡片
+  │     ├─ confirm_appointment → McpToolService.createAppointment() → complete task → 创建 appointment-success 卡片
+  │     └─ submit_tongue_image → TongueDiagnosisAdapter.startDiagnosis() → 创建结果卡片
+  │
+  ├─ 5. CardActionService.complete() → 当前卡片标记 completed
+  │
+  └─ 6. 返回 {status:"completed", actionId, nextCard}
+```
+
+**前端实现要点:**
+
+```typescript
+// 1. 幂等键:同一逻辑操作复用同一个 key,防止重复提交
+const idempotencyKey = `${cardInstanceId}-${actionName}-${payloadHash}`;
+
+// 2. 需要二次确认的动作
+const needsConfirm = action.requiredConfirm === true;
+
+// 3. 提交
+const result = await api.cardAction(cardInstanceId, actionName, {
+  idempotencyKey,
+  confirm: needsConfirm ? userHasConfirmed : true,
+  payload: collectedFormData,
+});
+
+// 4. 如有下一张卡片,继续渲染
+if (result.data.nextCard) {
+  const nextCard = await api.getCard(result.data.nextCard.cardInstanceId);
+  renderCard(nextCard.data);
+}
+```
+
+### 5.4 MVP 卡片组件 → cardKey 映射
+
+| cardKey | 渲染组件 | 卡片数据 | 支持动作 |
+|---------|----------|----------|----------|
+| `department-selection` | 科室列表 | `departments[{deptId,name,reason}]` | `select_department` |
+| `doctor-selection` | 医生列表 | `doctors[{doctorId,name,title}]` | `select_doctor` |
+| `time-slot-selection` | 号源列表 | `slots[{slotId,timePeriod,remaining}]` | `select_time_slot` |
+| `confirm-appointment` | 确认卡片 | `summary{...}` | `confirm_appointment`(需二次确认) |
+| `appointment-success` | 成功页 | `appointmentNo` | 无 |
+| `route-card` | 路线展示 | `routeText` | `start_navigation` |
+| `tongue-capture` | 拍照引导 | `uploadField` | `submit_tongue_image` |
+
+---
+
+## 6. 文件上传
+
+### 6.1 请求
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/files/upload \
+  -d '{"businessType":"TONGUE_IMAGE","sha256":"abc123...","deviceId":"EMOON-KIOSK-001"}'
+```
+
+### 6.2 允许的 businessType
+
+| 值 | 说明 | 后续使用方式 |
+|----|------|-------------|
+| `ID_CARD` | 身份证图片 | fileId 传入 chat attachments |
+| `TONGUE_IMAGE` | 舌象采集 | fileId 传入卡片动作 submit_tongue_image |
+| `REPORT_IMAGE` | 报告图片 | fileId 传入 chat attachments |
+| `AUDIO` | 语音文件 | fileId 传入 chat attachments |
+
+### 6.3 后端流程
+
+```text
+FileController.upload()
+  → 校验 businessType 是否在允许列表
+  → FileService.upload() → 生成 fileId + local-dev:// 路径(MVP)
+  → 写入 ai_file_object
+  → 返回 {fileId, businessType, retentionPolicy, sensitiveLevel}
+```
+
+### 6.4 前端实现
+
+```typescript
+// 1. 先上传文件拿到 fileId
+const uploadResult = await api.uploadFile({
+  businessType: 'TONGUE_IMAGE',
+  sha256: await computeFileHash(file),
+  deviceId: currentDeviceId,
+});
+const fileId = uploadResult.data.fileId;
+
+// 2. 将 fileId 传入卡片动作
+await api.cardAction(cardInstanceId, 'submit_tongue_image', {
+  idempotencyKey: generateKey(),
+  confirm: true,
+  payload: { fileId },
+});
+```
+
+---
+
+## 7. 机器人命令(仅机器人端)
+
+### 7.1 轮询命令
+
+机器人端需要定时轮询后端下发的导航/操作命令:
+
+```bash
+curl https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/poll
+```
+
+**后端流程:**
+
+```text
+DeviceController.pollCommands()
+  → DeviceRegistryFacade.scene() — 校验设备存在
+  → DeviceCommandService.pollCommands()
+    → expireStaleCommands() — 过期命令标记 EXPIRED
+    → selectPendingByDeviceId() — 查 PENDING 命令
+    → 返回 [{commandId, commandType, payload, expireAt}]
+```
+
+### 7.2 确认命令
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/cmd_001/ack \
+  -d '{"status":"success","result":{}}'
+```
+
+### 7.3 前端实现
+
+```typescript
+// 每 5 秒轮询一次
+setInterval(async () => {
+  const resp = await api.pollCommands(deviceId);
+  for (const cmd of resp.data.commands) {
+    switch (cmd.commandType) {
+      case 'NAVIGATE_TO_LOCATION':
+        // 调用机器人 SDK 导航
+        await robotBridge.navigate(cmd.payload.locationId, cmd.payload.routeText);
+        await api.ackCommand(deviceId, cmd.commandId, 'success', {});
+        break;
+    }
+  }
+}, 5000);
+```
+
+---
+
+## 8. 设备事件上报
+
+当终端发生硬件事件(扫码、读卡、打印完成等)时,上报到后端用于审计和监控:
+
+```bash
+curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/events \
+  -d '{"eventId":"evt-001","eventType":"TOUCH_INTERACTION",
+       "eventPayload":{"screen":"home","action":"tap_register"},
+       "occurredAt":"2026-06-01T09:30:00"}'
+```
+
+**后端流程:**
+
+```text
+DeviceController.ingestEvent()
+  → 校验设备存在(scene 不为 null)
+  → DeviceEventService.ingest()
+    → 构建 AiDeviceEventDO(含 projectId 用于多租户隔离)
+    → INSERT INTO ai_device_event
+    → 返回 {accepted:true, eventId}
+```
+
+---
+
+## 9. 三端差异速查
+
+| 场景 | 机器人端 | 自助机端 | 导诊大屏 |
+|------|----------|----------|----------|
+| FAQ 问答 | ✅ | ✅ | ✅ |
+| 路线导航 | ✅ + 机器人 SDK 移动 | ✅ 展示路线 | ✅ 展示路线 |
+| 科室推荐 | ✅ | ✅ | ✅ |
+| 挂号流程 | ❌ 引导到自助机 | ✅ Mock 挂号 | ❌ |
+| 舌象采集 | ❌ | ✅ | ❌ |
+| 实名建档 | ❌ | ✅ | ❌ |
+| 报告解读 | ❌ | ❌ | ❌ |
+| 命令轮询 | ✅ 导航/语音 | ❌ | ❌ |
+| 读卡/打印 | ❌ | ✅ 硬件桥接 | ❌ |
+
+---
+
+## 10. 错误处理总表
+
+| 错误码 | 含义 | 前端处理 |
+|--------|------|----------|
+| `DEVICE_NOT_FOUND` | 设备未注册或已过期 | 回到注册流程 |
+| `CARD_NOT_FOUND` | 卡片实例不存在 | 刷新对话或重新发起 |
+| `CARD_EXPIRED` | 卡片已过期 | 提示用户重新操作 |
+| `CARD_ACTION_NOT_ALLOWED` | 动作不被允许 | 检查卡片定义 |
+| `PATIENT_CONFIRM_REQUIRED` | 需要二次确认 | 展示确认弹窗 |
+| `INVALID_BUSINESS_TYPE` | 文件类型不支持 | 检查 businessType |
+| `FILE_NOT_FOUND` | 文件不存在 | 重新上传 |
+
+---
+
+## 11. 前端安全红线
+
+| 禁止行为 | 原因 |
+|----------|------|
+| 请求中传 `agentId` | 后端 AgentRouter 决定路由,前端不应写死 |
+| 卡片动作不传 `idempotencyKey` | 写操作必须幂等,防止重复挂号/扣费 |
+| 直连 Dify API | 前端不掌握 Dify Key,也不应决定流程编排 |
+| 直连舌诊底层接口 | 舌象图片必须先通过 /files/upload + 卡片动作闭环 |
+| 隐藏 `mock:true` 标识 | 后端返回 mock 结果时,前端必须明确标注"联调演示" |
+| 把签名密钥放进 web bundle | 使用终端壳签名,不放在 VITE_* 环境变量 |
+| 自行构造医疗文本 | 只展示后端返回的文本和卡片 |
+
+---
+
+## 12. SDK 生成建议
+
+从 OpenAPI YAML 自动生成 TypeScript 类型和 HTTP 客户端:
+
+```bash
+# 方案 A:openapi-typescript(仅类型)
+npx openapi-typescript \
+  docs/接口文档/emoon-ai-openplatform-api-v1.2.openapi.yaml \
+  -o packages/api-client/src/generated/schema.ts
+
+# 方案 B:openapi-generator(完整客户端)
+npx @openapitools/openapi-generator-cli generate \
+  -i docs/接口文档/emoon-ai-openplatform-api-v1.2.openapi.yaml \
+  -g typescript-fetch \
+  -o packages/api-client/src/generated/
+```
+
+生成的类型会自动包含所有 endpoint 的 request/response schema,前端只需关注鉴权头和 SSE 解析。
+
+---
+
+## 13. 本地开发环境
+
+```bash
+# 后端
+cd emoon-backend
+# 确保 application-dev.yml 中 datasource 指向本地 MySQL
+mvn -pl emoon-admin spring-boot:run
+
+# 前端
+cd emoon-terminal-client
+pnpm install
+pnpm --filter kiosk-client dev
+```
+
+**本地联调要点:**
+
+1. 后端默认 `dev` profile 下,`resolveAllowedAgents` 在无设备 ID 时有宽松 fallback
+2. HMAC 签名可以使用 `emoon-openplatform` 的 `OpenPlatformAuthInterceptorTest` 中的工具方法作为参考
+3. 卡片种子数据(科室/医生/号源)在 `script/sql/ai-terminal-mvp.sql` 中,首次启动前执行