--- doc_id: API-202606-001 feature_id: FEAT-202606-001-unified-entry-client type: api title: 统一入口客户端 MVP 接口契约 status: approved owner: 医梦研发团队 created_at: 2026-06-17 updated_at: 2026-06-17 reviewers: [] related_docs: - PRD-202606-001 - API-202606-002 related_modules: - emoon-openplatform - emoon-ai-device - emoon-ai-agent - emoon-ai-card tags: - 统一入口客户端 - 接口契约 - SSE - Card Action --- # 统一入口客户端 MVP 接口契约 > **受众:** 前端工程师(`emoon-terminal-client`)、终端厂商联调人员 > **版本:** v1.0-mvp > **基准:** `docs/api/openapi/医梦AI中台开放平台接口-v1.2.openapi.yaml` > **关联文档:** `对外接口联调基准.md`、`详细设计.md` --- ## 1. 接口总览 统一入口客户端引入后,接口契约的核心不是“前端传一个 `agentId` 后端回答”,而是“终端上报设备和感知事实,后端返回场景配置、动线阶段、下一步服务和可执行卡片”。MVP 保持接口数量不扩张,优先在现有 `/devices/{deviceId}/scene`、`/devices/{deviceId}/events`、`/agent/chat/stream` 中增加可选字段。 ### 1.1 终端调用白名单 前端/终端 **只允许** 调用以下接口: | 端点 | 方法 | 说明 | |------|------|------| | `/api/v1/devices/register` | POST | 设备注册 | | `/api/v1/devices/{deviceId}/heartbeat` | POST | 心跳上报 | | `/api/v1/devices/{deviceId}/scene` | GET | 获取场景配置 | | `/api/v1/agent/chat/stream` | POST | 流式对话(SSE) | | `/api/v1/files/upload` | POST | 文件上传 | | `/api/v1/files/{fileId}` | GET | 查询文件 | | `/api/v1/cards/{cardInstanceId}` | GET | 查询卡片实例 | | `/api/v1/cards/{cardInstanceId}/actions/{actionName}` | POST | 提交卡片动作 | | `/api/v1/devices/{deviceId}/events` | POST | 设备事件上报 | | `/api/v1/devices/{deviceId}/commands/poll` | GET | 设备命令轮询 | | `/api/v1/devices/{deviceId}/commands/{commandId}/ack` | POST | 设备命令确认 | ### 1.2 前端禁止调用 | 禁止调用的接口 | 原因 | |----------------|------| | Dify API(任何) | 前端不决定调用哪个 Dify App,后端 AgentRouter 统一路由 | | `/api/v1/tools/{toolName}/invoke` | HIS 写操作必须经过 Card Runtime + AgentActionOrchestrator | | 舌诊底层接口 | 舌象图片必须先通过 `/files/upload` 上传,由后端 TongueDiagnosisAdapter 统一调用 | | `/api/v1/meter/events`(写接口) | 计量事件由后端内部产生,前端不直接写计量 | | 任何 HIS/EMR 直连 | 所有 HIS 交互通过卡片动作间接完成 | --- ## 2. 鉴权 ### 2.1 新终端端点(HMAC-SHA256) 以下端点使用新鉴权(`AuthContextHolder`): - `/devices/register` - `/devices/{deviceId}/heartbeat` - `/devices/{deviceId}/scene` - `/agent/chat/stream` - `/files/upload` - `/cards/{cardInstanceId}` - `/cards/{cardInstanceId}/actions/{actionName}` - `/devices/{deviceId}/commands/poll` - `/devices/{deviceId}/commands/{commandId}/ack` 请求头: ```http X-Emoon-Access-Key: X-Emoon-Timestamp: X-Emoon-Nonce: X-Emoon-Signature: ``` 签名算法(输出为 Base64 编码的 HMAC-SHA256): ```text canonical = METHOD + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body) signature = Base64(HMAC-SHA256(secretKey, canonical)) ``` > 详情见 `docs/api/openapi/医梦AI中台开放平台接口-v1.2.openapi.yaml` securitySchemes 章节。 ### 2.2 旧端点(兼容) `/agent/chat`(同步)、`/conversation/*` 沿用旧 HMAC(`X-Emoon-Public-Key` + `X-Emoon-Sign`),终端 MVP 不使用这些端点。 --- ## 3. 启动流程(设备注册 → 场景加载) ```text 1. 终端启动 → POST /devices/register 2. 拿到 deviceId → POST /devices/{deviceId}/heartbeat(定时 30s) 3. 拿到 deviceId → GET /devices/{deviceId}/scene 4. 渲染首页 UI ``` ### 3.1 设备注册 ```bash curl -X POST https://api.demo.emoon.local/api/v1/devices/register \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -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, "id_card_ocr": true, "file_upload": true }, "location": { "floor": "1F", "area": "门诊大厅" } }' ``` 响应: ```json { "code": 200, "data": { "deviceId": "EMOON-KIOSK-001", "deviceSecret": "sk-xxx...", "activateStatus": "activated", "admissionLevel": "A", "reason": null } } ``` `activateStatus` 含义: | 值 | 说明 | |----|------| | `activated` | 准入通过,可进入核心业务 | | `pending` | 待人工准入,只能心跳 | | `rejected` | 准入拒绝,只能联系运维 | ### 3.2 心跳 ```bash curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/heartbeat \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "clientVersion": "0.1.0", "status": "online", "currentSceneCode": "outpatient_kiosk" }' ``` 响应: ```json { "code": 200, "data": { "accepted": true, "serverTime": 1717000000000 } } ``` ### 3.3 获取场景配置 ```bash curl -X GET https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/scene \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " ``` 响应: ```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"], "perceptionCapabilities": [ "QR_IDENTITY_BINDING", "NFC_TOUCH", "CARD_READ", "BLE_ZONE_DETECT" ], "journeyPolicy": { "mode": "MVP_STATIC", "serviceFindPatientEnabled": true, "requiresPatientConfirm": true }, "allowedCards": [ "department-selection", "doctor-selection", "time-slot-selection", "confirm-appointment", "appointment-success", "tongue-capture" ] } } ``` --- ## 4. 流式对话 ### 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" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "deviceContext": { "deviceId": "EMOON-KIOSK-001", "deviceType": "self_service_kiosk" }, "message": { "type": "text", "text": "我头疼三天,想挂号", "clientMessageId": "m-reg-001" } }' ``` > **`agentId` 是可选的。** 统一入口客户端不传 `agentId`,由后端 AgentRouter 根据设备类型、场景配置、用户输入和任务状态自动决定路由到哪个智能体。 感知上下文为可选字段,P0 客户端可以不传;支持 NFC/刷卡/近场能力的终端可传入最近一次感知事实,后端仍以服务端 HIS/叫号/任务状态为准。 ```json { "perceptionContext": { "identityBindingId": "bind_001", "locationLevel": "L1", "zoneCode": "clinic_room_301_door", "source": "NFC_TOUCH", "occurredAt": "2026-06-03T09:30:00+08:00" } } ``` 客户端请求不得传入 `journeyState`。P0 阶段服务端完全忽略任何通过 `inputs` 或扩展字段伪造的动线状态,动线状态只能由服务端通过 HIS/叫号/任务状态/卡片动作/设备事件推导后返回。断线恢复使用 `conversationId` 和卡片查询接口,不使用客户端回传的动线对象。 ### 4.2 SSE 事件类型 | 事件 | 说明 | MVP 必达 | |------|------|----------| | `message_delta` | 文本增量 `{"text":"好的,我来帮您..."}` | ✅ | | `message_completed` | 文本完成 `{"messageId":"msg_xxx","conversationId":"conv_xxx"}` | ✅ | | `card_created` | 卡片已创建 `{"cardInstanceId":"card_xxx","cardKey":"department-selection"}` | ✅ | | `task_updated` | 任务状态变更 `{"taskType":"REGISTRATION","currentStep":"COLLECT_SYMPTOM","status":"ACTIVE"}` | ✅ | | `journey_updated` | 动线阶段变更 `{"stageCode":"WAITING_CHECKIN","nextAction":"CHECK_IN","source":"SERVER_DERIVED"}` | ✅ | | `usage_reported` | 用量事件(P1) | ❌ | | `error` | 错误 `{"code":"xxx","message":"..."}` | ✅ | | `completed` | 本次 SSE 结束 `{"conversationId":"conv_xxx"}` | ✅ | ### 4.3 挂号流程示例 SSE 序列 ```text event: task_updated data: {"taskType":"REGISTRATION","currentStep":"COLLECT_SYMPTOM","status":"ACTIVE","taskId":"task_001"} event: journey_updated data: {"stageCode":"REGISTRATION_IN_PROGRESS","nextAction":"SELECT_DEPARTMENT","source":"SERVER_DERIVED","taskId":"task_001"} event: message_delta data: {"text":"好的,我来帮您走挂号流程。请先简单描述您的症状或选择目标科室。"} event: message_completed data: {"messageId":"msg_001","conversationId":"conv_001"} event: card_created data: {"cardInstanceId":"card_001","cardKey":"department-selection","taskId":"task_001"} event: completed data: {"conversationId":"conv_001"} ``` ### 4.4 断线恢复 1. 客户端记录 `conversationId` 2. 调用 `GET /api/v1/conversation/{conversationId}` 获取最终消息 3. 调用 `GET /api/v1/cards/{cardInstanceId}` 查询当前卡片状态(如有) --- ## 5. 卡片动作 ### 5.1 查询卡片实例 ```bash curl -X GET https://api.demo.emoon.local/api/v1/cards/card_001 \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " ``` 响应: ```json { "code": 200, "data": { "cardInstanceId": "card_001", "cardKey": "department-selection", "status": "active", "expiresAt": "2026-06-01T10:00:00", "cardData": { "departments": [ {"deptId": "neurology", "name": "神经内科", "reason": "头痛、头晕、肢体麻木等症状可先咨询"}, {"deptId": "respiratory", "name": "呼吸内科", "reason": "咳嗽、发热、气喘等症状可先咨询"} ] }, "actions": [ {"actionName": "select_department", "label": "选择科室"} ] } } ``` ### 5.2 提交卡片动作 ```bash curl -X POST https://api.demo.emoon.local/api/v1/cards/card_001/actions/select_department \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "idempotencyKey": "idem-001", "confirm": true, "payload": { "departmentId": "neurology" } }' ``` > **幂等键(`idempotencyKey`)是必填的。** 同一动作重复提交相同幂等键返回首次结果,不会重复扣费或创建重复号源。 响应: ```json { "code": 200, "data": { "status": "completed", "actionId": "action_001", "nextCard": { "cardInstanceId": "card_002", "cardKey": "doctor-selection" } } } ``` ### 5.3 MVP 挂号完整动作序列 ```text 1. 用户说"我要挂号" → SSE 返回 department-selection 卡片 (card_001) 2. 用户选择科室 → POST /cards/card_001/actions/select_department → 返回 doctor-selection 卡片 (card_002) 3. 用户选择医生 → POST /cards/card_002/actions/select_doctor → 返回 time-slot-selection 卡片 (card_003) 4. 用户选择时间 → POST /cards/card_003/actions/select_time_slot → 返回 confirm-appointment 卡片 (card_004) 5. 用户确认 → POST /cards/card_004/actions/confirm_appointment → 返回 appointment-success 卡片 (card_005) ``` --- ## 6. 文件上传 ```bash curl -X POST https://api.demo.emoon.local/api/v1/files/upload \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "businessType": "TONGUE_IMAGE", "sha256": "abc123...", "deviceId": "EMOON-KIOSK-001" }' ``` 允许的 `businessType`: | 值 | 说明 | |----|------| | `ID_CARD` | 身份证图片 | | `TONGUE_IMAGE` | 舌象采集图片 | | `REPORT_IMAGE` | 报告图片 | | `AUDIO` | 音频文件 | 响应: ```json { "code": 200, "data": { "fileId": "file_001", "businessType": "TONGUE_IMAGE", "retentionPolicy": "episode", "sensitiveLevel": "P3", "status": "available" } } ``` > **注意:** MVP 阶段文件上传返回 `fileId` 后,前端将 `fileId` 传入后续 `/agent/chat/stream` 的 `attachments` 字段中。舌诊底层接口由后端 `TongueDiagnosisAdapter` 统一调用,前端不直连。 --- ## 7. 机器人命令轮询 机器人端需要异步接收导航命令: ### 7.1 轮询命令 ```bash curl -X GET https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/poll \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " ``` 响应: ```json { "code": 200, "data": { "commands": [ { "commandId": "cmd_001", "commandType": "NAVIGATE_TO_LOCATION", "payload": { "locationId": "lab", "routeText": "从门诊大厅向左走 20 米后右转,检验科在左手边" }, "status": "PENDING", "expireAt": "2026-06-01T10:05:00" } ] } } ``` ### 7.2 确认命令 ```bash curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/cmd_001/ack \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "status": "success", "result": {} }' ``` 响应: ```json { "code": 200, "data": { "acknowledged": "true" } } ``` --- ## 8. 设备事件上报 设备事件是感知事实的入口,但不是自动业务动作。比如 `NEARBY_DETECTED` 只能说明患者或终端进入服务区域,后端还必须结合身份授权、HIS/叫号状态和患者确认,才能创建签到、缴费、导航等卡片。 ```bash curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/events \ -H "Content-Type: application/json" \ -H "X-Emoon-Access-Key: " \ -H "X-Emoon-Timestamp: 1717000000000" \ -H "X-Emoon-Nonce: " \ -H "X-Emoon-Signature: " \ -d '{ "eventId": "evt-001", "eventType": "TOUCH_INTERACTION", "eventPayload": {"screen": "home", "action": "tap_register"}, "occurredAt": "2026-06-01T09:30:00" }' ``` `eventPayload` 采用“事件类型固定字段 + 扩展字段”的方式,不要求所有事件共用同一个扁平结构。服务端按 `eventType` 校验必要字段,未识别字段只作为审计摘要保存,不参与高风险业务判断。 常用感知事件: | eventType | 必要 eventPayload | 后端用途 | |---|---|---| | `QR_SCANNED` | `{ "qrType": "visit", "visitId": "..." }` | 身份/就诊绑定 | | `CARD_READ` | `{ "cardType": "hospital_card" }` | 身份确认,不在日志保存完整卡号 | | `NEARBY_DETECTED` | `{ "zoneCode": "clinic_room_301_door", "level": "L2" }` | 推导是否进入服务区域 | | `TOUCH_INTERACTION` | `{ "screen": "home", "action": "tap_checkin" }` | 记录用户主动触发 | | `CUSTOM` | `{ "businessState": "WAITING_PAYMENT" }` | 联调阶段承载厂商扩展状态 | 隐私约束:P0 不上报连续坐标、不保存患者移动轨迹;位置字段只能表达服务区域,如诊区、诊室门口、自助机点位。 --- ## 9. 错误响应格式 所有接口统一使用 `R` 包装: 响应: ```json { "code": 200, "data": { "accepted": "true", "eventId": "evt-001" } } ``` --- ## 9. 错误响应格式 所有接口统一使用 `R` 包装: ```json { "code": 401, "msg": "INVALID_SIGNATURE", "data": null } ``` 常见错误码: | code | 说明 | |------|------| | `401` | 鉴权失败(签名错误、时间戳过期、nonce 重复) | | `403` | 设备准入拒绝、能力不允许 | | `404` | 设备/卡片/文件不存在 | | `409` | 幂等键重复(返回首次结果) | | `422` | 参数校验失败 | | `500` | 服务端错误 | --- ## 10. 前端快速启动流程 ```text ┌───────────────┐ │ 1. 加载配置 │ 读取 hospitalId、accessKey └───────┬───────┘ ↓ ┌───────────────┐ │ 2. 设备注册 │ POST /devices/register └───────┬───────┘ ↓ 拿到 deviceId ┌───────────────┐ │ 3. 开始心跳 │ setInterval 30s → POST /devices/{deviceId}/heartbeat └───────┬───────┘ ↓ ┌───────────────┐ │ 4. 获取场景 │ GET /devices/{deviceId}/scene └───────┬───────┘ ↓ 拿到 homeTemplate、allowedAgents、allowedCards ┌───────────────┐ │ 5. 渲染首页 │ 根据 sceneCode + homeTemplate 加载对应 UI └───────┬───────┘ ↓ 用户输入 ┌───────────────┐ │ 6. 流式对话 │ POST /agent/chat/stream (SSE) └───────┬───────┘ ↓ 收到 card_created 事件 ┌───────────────┐ │ 7. 渲染卡片 │ GET /cards/{cardInstanceId} └───────┬───────┘ ↓ 用户操作卡片 ┌───────────────┐ │ 8. 提交动作 │ POST /cards/{cardInstanceId}/actions/{actionName} └───────┬───────┘ ↓ 可能返回 nextCard ┌───────────────┐ │ 9. 渲染下一卡片 │ 循环步骤 7-9 直到流程结束 └───────────────┘ ``` --- ## 11. 前端卡片组件映射 | cardKey | 卡片用途 | 关键数据字段 | 支持动作 | |---------|---------|-------------|---------| | `department-selection` | 科室选择 | `departments[]` | `select_department` | | `doctor-selection` | 医生选择 | `doctors[]` | `select_doctor` | | `time-slot-selection` | 号源选择 | `slots[]` | `select_time_slot` | | `confirm-appointment` | 挂号确认 | `summary` | `confirm_appointment`(需二次确认) | | `appointment-success` | 挂号成功 | `appointmentNo` | 无动作,仅展示 | | `route-card` | 路线导航 | `routeText` | `start_navigation` | | `tongue-capture` | 舌象采集 | `uploadField` | `submit_tongue_image` | --- ## 12. 安全红线(前端) 1. **不传 `agentId`:** 统一入口客户端请求 `/agent/chat/stream` 时 **不应** 传 `agentId`,由后端 AgentRouter 决定路由。 2. **卡片动作必须带幂等键:** 所有写操作(卡片动作)必须带 `idempotencyKey`。 3. **不直连任何后端能力:** 前端不调 Dify API、HIS Tool、舌诊底层接口、计量账本接口。 4. **不构造医疗结论:** 前端只展示后端返回的文本和卡片,不自行生成诊断、用药建议、挂号承诺。 5. **文件先上传后引用:** 图片/文件通过 `/files/upload` 获得 `fileId` 后,再传入 chat 或卡片动作。 6. **Mock 结果标识:** 后端返回 `"mock":true` 的结果必须在前端明确标注为"联调演示",不可伪装为真实业务结果。