开发者文档

API 与 SDK 接入

浏览器嵌入见 接入示例页。本页讲的是直接调 HTTP API—— 桌面应用、移动 App、以及任何非浏览器客户端。

OpenAPI 规格

由契约自动生成,有防漂移门禁。可直接导入 Postman / Insomnia,或用于代码生成。

client-v1.openapi.json client-v2.openapi.json admin-v1.openapi.json agent-v1.openapi.json server-v1.openapi.json

client-v1/v2 是访客面(自动/人工对话),agent-v1 是人工座席面, server-v1 是客户后端换取短期访客令牌的服务端入口,admin-v1 是管理面(应用、Origin、审计)。server/admin 均不返回任何 CORS 头,长期 Key 绝不能进入浏览器。

先读这一段,否则会卡住

两个端点校验的是两份不同的 Origin 白名单。

用同一个 Origin 走完全程,第二步就会拿到 origin_not_allowed。这是浏览器里的真实结构—— Loader 在宿主页换票,Shell 在 iframe 里换会话。

完整调用流程

下面每一步都已在生产环境实测通过。Origin 头是必填,不带直接 400。

1 · 换取 embed ticket

POST https://api.aiservice.shunwanpay.com/client/v1/embed-tickets
Origin: 
Content-Type: application/json

{"app_id": "app_E1B7MYTKM7YE44AD19HX9S7P03"}

→ 201  {"data": {"embed_ticket": "...", "expires_at": "..."}}

2 · 用 ticket 换会话(注意 Origin 变了)

POST /client/v1/sessions
Origin: https://widget.aiservice.shunwanpay.com
Content-Type: application/json

{
  "embed_ticket": "上一步拿到的",
  "client": {"type": "widget", "version": "0.1.9"}
}

→ 201  {"data": {"access_token", "session_id", "expires_at", "capabilities"}}

client 是必填对象,type 目前只接受字面量 "widget"。

3 · 建会话

POST /client/v1/conversations
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <16-128 字符,[A-Za-z0-9._:-]>
Content-Type: application/json

{}

→ 201  {"data": {"conversation_id", "state", "state_version", "owner_epoch"}}

4 · 发消息

POST /client/v1/conversations/<conversation_id>/messages
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <每条消息一个新的>
Content-Type: application/json

5 · 轮询事件

GET /client/v1/conversations/<conversation_id>/events
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>

→ 200  事件页(带游标,按序号连续)

回复是异步的:发消息返回后回复尚未生成,要靠轮询事件拿到。没有 WebSocket。

端点与必需请求头

方法 路径 必需头
POST /client/v1/embed-tickets Origin
POST /client/v1/sessions Origin
POST /client/v1/visitor-sessions Origin · Idempotency-Key
POST /client/v1/sessions/refresh Origin · Idempotency-Key
POST /client/v1/sessions/remember Origin · Idempotency-Key
POST /client/v1/sessions/forget Origin · Idempotency-Key
POST /client/v1/conversations Origin · Idempotency-Key
POST /client/v1/conversations/{id}/messages Origin · Idempotency-Key
POST /client/v1/conversations/{id}/handoff-requests Origin · Idempotency-Key
POST /client/v1/realtime-tokens Origin · Idempotency-Key
GET /client/v1/conversations/{id}/events Origin

Idempotency-Key:16–128 字符,只允许 [A-Za-z0-9._:-]。 同一个键重放同一请求是安全的;同键不同请求体会失败关闭。

已登录用户:客户后端签发短期访客令牌

sk_server_* 只属于客户后端。不要把它写进 HTML、Widget、游戏客户端、 移动 App、日志或仓库。请在管理台“服务端 API”页创建,并立即保存到服务端秘密管理器;原始 Key 只显示一次。

1 · 客户后端换取短期 visitor token

POST https://api.aiservice.shunwanpay.com/server/v1/visitor-tokens
Authorization: Bearer <sk_server_*>
Idempotency-Key: <由客户后端稳定生成的 16–128 字符键>
Content-Type: application/json

{
  "app_id": "app_...",
  "external_user_ref": "你方系统中的稳定用户标识",
  "expires_in": 300
}

→ 201  {"data":{"visitor_token":"vst_...","token_type":"Bearer","expires_at":"..."}}

平台不保存 external_user_ref 原文,只保存用途隔离、带版本的 HMAC。相同 Key、 相同请求体与相同 Idempotency-Key 会恢复同一令牌;同键不同正文返回 409。

2 · Widget 同时提交短期令牌与一次性 embed ticket

POST /client/v1/visitor-sessions
Origin: https://widget.aiservice.shunwanpay.com
Idempotency-Key: <当前浏览器会话的稳定键>
Content-Type: application/json

{
  "visitor_token": "vst_...",
  "embed_ticket": "emb_...",
  "client": {"type":"widget","version":"0.1.9"}
}

→ 201  {"data":{"access_token","session_id","expires_at","capabilities"}}

visitor_token 只能消费一次,并与 embed ticket 的应用、允许宿主页 Origin 和平台 Widget Origin 同时核对。成功后继续使用上面的 Conversation → Message → Events → Handoff 协议,不另造一套消息流。

Node.js / TypeScript SDK

import { createServerClient } from "@aiservice/sdk-typescript";

const serverApiKey = process.env.AISERVICE_SERVER_API_KEY;
if (!serverApiKey) throw new Error(\"AISERVICE_SERVER_API_KEY 未配置\");

const client = createServerClient({
  apiBaseUrl: "https://api.aiservice.shunwanpay.com/server/v1",
  serverApiKey,
  fetch
});

const token = await client.createVisitorToken({
  appId: "app_...",
  externalUserRef: authenticatedUser.id,
  idempotencyKey: requestScopedStableKey,
  expiresInSeconds: 300
});

SDK 是 Node-only;检测到浏览器运行环境会拒绝实例化。它不会自动生成幂等键,也不会把服务端 错误正文、Key 或外部用户标识放进异常对象。

桌面应用与移动 App

上面的流程在原生客户端里同样可用——直接发 HTTP 即可,把 Origin 当成一个普通请求头填上。

要理解 Origin 在这里意味着什么。浏览器会强制页面不能伪造自己的 Origin,所以白名单能挡住「未授权站点嵌入 Widget」。但原生客户端可以填任意值—— Origin 对原生客户端不是安全边界。当前访客面没有为原生客户端设计的独立凭据机制, 请据此评估你的威胁模型。

现有 SDK 能做什么、不能做什么

仓库里有 C++17 与 C#/.NET 两个 SDK,但它们是 Protocol Core,不是完整客户端:

提供 不提供
C# / .NET
netstandard2.1 · net10.0
事件页解析与校验、会话游标、序号连续性、重复事件识别;可注入的读取 Transport 与取消 不保存 Access / Embed / Cookie
C++17
CMake 静态库
对已解析的 typed DTO 做协议校验 网络 Transport、取消、线程模型全部由宿主实现

换句话说:HTTP 那一层要你自己写,SDK 负责的是「拿到事件页之后别读错」—— 序号断裂、重复事件、游标推进这些容易写错的部分。

传输层、Origin、凭据签发、Realtime、Unity / Unreal 适配均未标记 GA。 SDK 版本与服务协议独立演进。

实时推送(WebSocket)

事件除了轮询,也可以通过 WebSocket 推送。推送与轮询走完全同一条读取路径, 因此两者在同一游标下产出的事件序列逐字节相同——这一点有门禁强制。

1 · 换取实时令牌

POST /client/v1/realtime-tokens
Origin: https://widget.aiservice.shunwanpay.com
Authorization: Bearer <access_token>
Idempotency-Key: <16-128 字符>

→ 201  {"data": {"realtime_token", "realtime_url", "realtime_expires_at"}}
→ 404  本部署未启用实时通道

404 是稳定事实,不是暂时故障——重试多少次结果都一样,请回落到轮询。

2 · 建立连接

令牌走 Sec-WebSocket-Protocol,不走查询串——查询串会进入 反向代理与服务端的访问日志,而实时令牌本该只存在于内存。

new WebSocket(realtime_url, ["aiservice.realtime.v1", realtime_token])

3 · 订阅并携带游标

{"type":"subscribe","conversation_id":"conv_...","after_sequence":"0"}

游标由客户端携带,服务端不记「上次推到哪」。断线重连时带上你收到的 最后一个 sequence,既不会丢事件也不会重复。

4 · 服务端帧

{"type":"event","conversation_id","event_id","sequence","event_type","payload","occurred_at"}
{"type":"heartbeat"}

关闭码

码 含义 该怎么做
4001 令牌到期 重新换令牌再连——连接内不续期
4002 你消费不过来 降低处理延迟后重连,并从游标续传
4003 握手或帧不合法 检查令牌与帧格式;原因不会回显

站点有 CSP 时,connect-src 需同时放行 https:// 与 wss://——前者不会自动覆盖后者,漏了会让握手在浏览器里被拦下, 而服务端一条记录都没有。

SSE 降级通道

受限网络里 WebSocket 的 Upgrade 握手常被中间层拦掉。此时可以改用 text/event-stream——协议逻辑完全相同,订阅、游标、补读、 背压、到期断开都来自同一份实现,只有传输不一样。

GET /realtime/v1/events?conversation_id=conv_...&after_sequence=0
Authorization: Bearer <realtime_token>
Origin: https://widget.aiservice.shunwanpay.com

→ 200  text/event-stream

不能用 EventSource:它不允许设置请求头,而令牌必须走 Authorization。放进查询串会留在访问日志里,放进 Cookie 则要引入一份新的持久凭据——两者都更差。请用流式 fetch 读响应体。

帧格式

id: 7
data: {"type":"event","sequence":"7",...}

: ping

event: close
data: {"code":4001,"reason":"token expired"}

id: 就是事件序号,因此标准的 Last-Event-ID 续传语义可直接使用,且它优先于查询串里的 after_sequence。 心跳是注释行(: ping),不占 id——否则重连游标会被心跳污染。

SSE 没有关闭码,所以结束前会发一帧 event: close 说明原因。 没有它,客户端无法区分「令牌到期该换一张」与「网络抖动直接重连」。

SSE 是单向的:订阅参数只能来自请求本身,发消息等写操作仍走 HTTP POST。

目前做不到的