|
@@ -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` 中,首次启动前执行
|