--- doc_id: API-202606-003 feature_id: FEAT-202606-001-unified-entry-client type: api title: 统一入口客户端前端接入指引 status: approved owner: 医梦研发团队 created_at: 2026-06-17 updated_at: 2026-06-17 reviewers: [] related_docs: - API-202606-001 - API-202606-002 - DDS-202606-001 related_modules: - emoon-openplatform - emoon-terminal-client tags: - 统一入口客户端 - 前端接入 - HMAC - SSE --- # 统一入口客户端 前端接入指引 v1.0 > **受众:** 前端工程师、终端厂商联调人员 > **前置阅读:** `MVP接口契约.md`(接口契约)、`详细设计.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: ``` ### 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; } // 前端 SDK 不持有密钥,调用桥接层签名 export async function buildAuthHeaders( method: string, path: string, body: string ): Promise> { 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 { 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: " \ -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_ │ → 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 { 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/api/openapi/医梦AI中台开放平台接口-v1.2.openapi.yaml \ -o packages/api-client/src/generated/schema.ts # 方案 B:openapi-generator(完整客户端) npx @openapitools/openapi-generator-cli generate \ -i docs/api/openapi/医梦AI中台开放平台接口-v1.2.openapi.yaml \ -g typescript-fetch \ -o packages/api-client/src/generated/ ``` 生成的类型会自动包含所有 endpoint 的 request/response schema,前端只需关注鉴权头和 SSE 解析。 --- ## 13. 本地开发环境 ```bash # 后端 cd emoon-backend # AI 中台后端沿用本地 MySQL;独立 Mock HIS 服务使用 SQLite,不接入 AI 中台 datasource 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` 中,首次启动前执行