MVP接口契约.md 20 KB


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

请求头:

X-Emoon-Access-Key: <access-key>
X-Emoon-Timestamp: <unix-millis>
X-Emoon-Nonce: <random-uuid>
X-Emoon-Signature: <base64(hmac-sha256)>

签名算法(输出为 Base64 编码的 HMAC-SHA256):

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. 启动流程(设备注册 → 场景加载)

1. 终端启动 → POST /devices/register
2. 拿到 deviceId → POST /devices/{deviceId}/heartbeat(定时 30s)
3. 拿到 deviceId → GET /devices/{deviceId}/scene
4. 渲染首页 UI

3.1 设备注册

curl -X POST https://api.demo.emoon.local/api/v1/devices/register \
  -H "Content-Type: application/json" \
  -H "X-Emoon-Access-Key: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -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,
      "id_card_ocr": true,
      "file_upload": true
    },
    "location": {
      "floor": "1F",
      "area": "门诊大厅"
    }
  }'

响应:

{
  "code": 200,
  "data": {
    "deviceId": "EMOON-KIOSK-001",
    "deviceSecret": "sk-xxx...",
    "activateStatus": "activated",
    "admissionLevel": "A",
    "reason": null
  }
}

activateStatus 含义:

说明
activated 准入通过,可进入核心业务
pending 待人工准入,只能心跳
rejected 准入拒绝,只能联系运维

3.2 心跳

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: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>" \
  -d '{
    "clientVersion": "0.1.0",
    "status": "online",
    "currentSceneCode": "outpatient_kiosk"
  }'

响应:

{
  "code": 200,
  "data": {
    "accepted": true,
    "serverTime": 1717000000000
  }
}

3.3 获取场景配置

curl -X GET https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/scene \
  -H "X-Emoon-Access-Key: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>"

响应:

{
  "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 请求

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: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <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/叫号/任务状态为准。

{
  "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 序列

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 查询卡片实例

curl -X GET https://api.demo.emoon.local/api/v1/cards/card_001 \
  -H "X-Emoon-Access-Key: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>"

响应:

{
  "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 提交卡片动作

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: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>" \
  -d '{
    "idempotencyKey": "idem-001",
    "confirm": true,
    "payload": {
      "departmentId": "neurology"
    }
  }'

幂等键(idempotencyKey)是必填的。 同一动作重复提交相同幂等键返回首次结果,不会重复扣费或创建重复号源。

响应:

{
  "code": 200,
  "data": {
    "status": "completed",
    "actionId": "action_001",
    "nextCard": {
      "cardInstanceId": "card_002",
      "cardKey": "doctor-selection"
    }
  }
}

5.3 MVP 挂号完整动作序列

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. 文件上传

curl -X POST https://api.demo.emoon.local/api/v1/files/upload \
  -H "Content-Type: application/json" \
  -H "X-Emoon-Access-Key: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>" \
  -d '{
    "businessType": "TONGUE_IMAGE",
    "sha256": "abc123...",
    "deviceId": "EMOON-KIOSK-001"
  }'

允许的 businessType

说明
ID_CARD 身份证图片
TONGUE_IMAGE 舌象采集图片
REPORT_IMAGE 报告图片
AUDIO 音频文件

响应:

{
  "code": 200,
  "data": {
    "fileId": "file_001",
    "businessType": "TONGUE_IMAGE",
    "retentionPolicy": "episode",
    "sensitiveLevel": "P3",
    "status": "available"
  }
}

注意: MVP 阶段文件上传返回 fileId 后,前端将 fileId 传入后续 /agent/chat/streamattachments 字段中。舌诊底层接口由后端 TongueDiagnosisAdapter 统一调用,前端不直连。


7. 机器人命令轮询

机器人端需要异步接收导航命令:

7.1 轮询命令

curl -X GET https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/poll \
  -H "X-Emoon-Access-Key: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>"

响应:

{
  "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 确认命令

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: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <signature>" \
  -d '{
    "status": "success",
    "result": {}
  }'

响应:

{
  "code": 200,
  "data": {
    "acknowledged": "true"
  }
}

8. 设备事件上报

设备事件是感知事实的入口,但不是自动业务动作。比如 NEARBY_DETECTED 只能说明患者或终端进入服务区域,后端还必须结合身份授权、HIS/叫号状态和患者确认,才能创建签到、缴费、导航等卡片。

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: <access-key>" \
  -H "X-Emoon-Timestamp: 1717000000000" \
  -H "X-Emoon-Nonce: <uuid>" \
  -H "X-Emoon-Signature: <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<T> 包装:

响应:

{
  "code": 200,
  "data": {
    "accepted": "true",
    "eventId": "evt-001"
  }
}

9. 错误响应格式

所有接口统一使用 R<T> 包装:

{
  "code": 401,
  "msg": "INVALID_SIGNATURE",
  "data": null
}

常见错误码:

code 说明
401 鉴权失败(签名错误、时间戳过期、nonce 重复)
403 设备准入拒绝、能力不允许
404 设备/卡片/文件不存在
409 幂等键重复(返回首次结果)
422 参数校验失败
500 服务端错误

10. 前端快速启动流程

┌───────────────┐
│ 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 的结果必须在前端明确标注为"联调演示",不可伪装为真实业务结果。