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:
受众: 前端工程师、终端厂商联调人员
前置阅读:MVP接口契约.md(接口契约)、详细设计.md(架构设计)
目标: 从零开始,完成终端接入后端的完整联调
┌─────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────┘
核心原则:前端不决定业务逻辑。 前端只发送用户输入和设备上下文,后端决定路由到哪个智能体、创建什么卡片、调用什么工具。
所有终端接口使用 HMAC-SHA256 签名,输出为 Base64 编码:
canonical = METHOD + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + SHA256(body)
signature = Base64(HMAC-SHA256(secretKey, canonical))
X-Emoon-Access-Key: pk-xxxx
X-Emoon-Timestamp: 1717000000000
X-Emoon-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-Emoon-Signature: <base64-output>
密钥不得放入 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('');
}
| HTTP 状态 | 错误码 | 原因 | 前端处理 |
|---|---|---|---|
| 401 | AUTH_TIMESTAMP_EXPIRED |
时间戳偏差 > 5 分钟 | 检查终端系统时间 |
| 401 | AUTH_NONCE_REUSED |
重放攻击检测 | 生成新 nonce 重试 |
| 401 | AUTH_SIGNATURE_INVALID |
签名错误 | 检查密钥和 canonical 构造 |
| 401 | PROJECT_NOT_EXISTS |
accessKey 无效 | 检查配置 |
终端启动后必须按以下顺序调用:
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 更新
请求:
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
}
}
请求:
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);
请求:
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();
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 根据设备类型、场景配置、用户输入、任务状态自动决定路由。
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", ...)
| 事件名 | 何时触发 | 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 用于断线恢复 |
// 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;
}
// 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);
}
}
这是 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 标识)
curl https://api.demo.emoon.local/api/v1/cards/card_001
响应中的关键字段:
| 字段 | 用途 |
|---|---|
cardKey |
决定渲染哪个卡片组件 |
cardData |
卡片渲染数据(科室列表、医生列表等) |
actions |
该卡片支持的动作列表 |
status |
active=可操作,expired/completed=只读 |
expiresAt |
过期时间,超时后不能再提交 |
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);
}
| 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 |
curl -X POST https://api.demo.emoon.local/api/v1/files/upload \
-d '{"businessType":"TONGUE_IMAGE","sha256":"abc123...","deviceId":"EMOON-KIOSK-001"}'
| 值 | 说明 | 后续使用方式 |
|---|---|---|
ID_CARD |
身份证图片 | fileId 传入 chat attachments |
TONGUE_IMAGE |
舌象采集 | fileId 传入卡片动作 submit_tongue_image |
REPORT_IMAGE |
报告图片 | fileId 传入 chat attachments |
AUDIO |
语音文件 | fileId 传入 chat attachments |
FileController.upload()
→ 校验 businessType 是否在允许列表
→ FileService.upload() → 生成 fileId + local-dev:// 路径(MVP)
→ 写入 ai_file_object
→ 返回 {fileId, businessType, retentionPolicy, sensitiveLevel}
// 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 },
});
机器人端需要定时轮询后端下发的导航/操作命令:
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}]
curl -X POST https://api.demo.emoon.local/api/v1/devices/EMOON-ROBOT-001/commands/cmd_001/ack \
-d '{"status":"success","result":{}}'
// 每 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);
当终端发生硬件事件(扫码、读卡、打印完成等)时,上报到后端用于审计和监控:
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}
| 场景 | 机器人端 | 自助机端 | 导诊大屏 |
|---|---|---|---|
| FAQ 问答 | ✅ | ✅ | ✅ |
| 路线导航 | ✅ + 机器人 SDK 移动 | ✅ 展示路线 | ✅ 展示路线 |
| 科室推荐 | ✅ | ✅ | ✅ |
| 挂号流程 | ❌ 引导到自助机 | ✅ Mock 挂号 | ❌ |
| 舌象采集 | ❌ | ✅ | ❌ |
| 实名建档 | ❌ | ✅ | ❌ |
| 报告解读 | ❌ | ❌ | ❌ |
| 命令轮询 | ✅ 导航/语音 | ❌ | ❌ |
| 读卡/打印 | ❌ | ✅ 硬件桥接 | ❌ |
| 错误码 | 含义 | 前端处理 |
|---|---|---|
DEVICE_NOT_FOUND |
设备未注册或已过期 | 回到注册流程 |
CARD_NOT_FOUND |
卡片实例不存在 | 刷新对话或重新发起 |
CARD_EXPIRED |
卡片已过期 | 提示用户重新操作 |
CARD_ACTION_NOT_ALLOWED |
动作不被允许 | 检查卡片定义 |
PATIENT_CONFIRM_REQUIRED |
需要二次确认 | 展示确认弹窗 |
INVALID_BUSINESS_TYPE |
文件类型不支持 | 检查 businessType |
FILE_NOT_FOUND |
文件不存在 | 重新上传 |
| 禁止行为 | 原因 |
|---|---|
请求中传 agentId |
后端 AgentRouter 决定路由,前端不应写死 |
卡片动作不传 idempotencyKey |
写操作必须幂等,防止重复挂号/扣费 |
| 直连 Dify API | 前端不掌握 Dify Key,也不应决定流程编排 |
| 直连舌诊底层接口 | 舌象图片必须先通过 /files/upload + 卡片动作闭环 |
隐藏 mock:true 标识 |
后端返回 mock 结果时,前端必须明确标注"联调演示" |
| 把签名密钥放进 web bundle | 使用终端壳签名,不放在 VITE_* 环境变量 |
| 自行构造医疗文本 | 只展示后端返回的文本和卡片 |
从 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 解析。
# 后端
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
本地联调要点:
dev profile 下,resolveAllowedAgents 在无设备 ID 时有宽松 fallbackemoon-openplatform 的 OpenPlatformAuthInterceptorTest 中的工具方法作为参考script/sql/ai-terminal-mvp.sql 中,首次启动前执行