前端接入指引.md 26 KB


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 编码

canonical = METHOD + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + SHA256(body)
signature = Base64(HMAC-SHA256(secretKey, canonical))

2.2 请求头

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,浏览器可直接读取)。

正确做法:

// 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. 启动流程(必读)

终端启动后必须按以下顺序调用:

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:设备注册

请求:

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": "门诊大厅"}
  }'

后端流程:

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 🚫 展示拒绝原因 + 联系运维入口

响应:

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

3.2 Step 2:心跳

请求:

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"}'

后端流程:

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

前端实现:

// 启动后立即开始,每 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:获取场景

请求:

curl https://api.demo.emoon.local/api/v1/devices/EMOON-KIOSK-001/scene

后端流程:

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

响应:

{
  "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"]
  }
}

前端使用:

// 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 请求

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 后端完整流程

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 实现

// 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 断线恢复

// 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 最核心的业务闭环。前端按以下顺序依次渲染卡片并提交动作:

用户说"我要挂号"
  → 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 查询卡片

curl https://api.demo.emoon.local/api/v1/cards/card_001

响应中的关键字段:

字段 用途
cardKey 决定渲染哪个卡片组件
cardData 卡片渲染数据(科室列表、医生列表等)
actions 该卡片支持的动作列表
status active=可操作,expired/completed=只读
expiresAt 过期时间,超时后不能再提交

5.3 提交动作

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"}}'

后端流程:

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}

前端实现要点:

// 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 请求

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 后端流程

FileController.upload()
  → 校验 businessType 是否在允许列表
  → FileService.upload() → 生成 fileId + local-dev:// 路径(MVP)
  → 写入 ai_file_object
  → 返回 {fileId, businessType, retentionPolicy, sensitiveLevel}

6.4 前端实现

// 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 轮询命令

机器人端需要定时轮询后端下发的导航/操作命令:

curl https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/poll

后端流程:

DeviceController.pollCommands()
  → DeviceRegistryFacade.scene() — 校验设备存在
  → DeviceCommandService.pollCommands()
    → expireStaleCommands() — 过期命令标记 EXPIRED
    → selectPendingByDeviceId() — 查 PENDING 命令
    → 返回 [{commandId, commandType, payload, expireAt}]

7.2 确认命令

curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/cmd_001/ack \
  -d '{"status":"success","result":{}}'

7.3 前端实现

// 每 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. 设备事件上报

当终端发生硬件事件(扫码、读卡、打印完成等)时,上报到后端用于审计和监控:

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"}'

后端流程:

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 客户端:

# 方案 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. 本地开发环境

# 后端
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-openplatformOpenPlatformAuthInterceptorTest 中的工具方法作为参考
  3. 卡片种子数据(科室/医生/号源)在 script/sql/ai-terminal-mvp.sql 中,首次启动前执行