| 方向 | 发起方 | 接收方 | 用途 |
|---|---|---|---|
| 出站 | chatdoing | 合作方平台 | 发送 消息、转人工、查询访客 |
| 回调 | 合作方平台 | chatdoing | 推送客服事件 |
{
"appid": "partner-app-id",
"host": "https://partner.example.com",
"key_id": "open-kf-202608",
"key": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
}| 字段 | 要求 | 说明 |
|---|---|---|
appid | 非空字符串 | 合作方的平台应用标识,写入 X-OpenKF-App-ID |
host | HTTPS 绝对 URL,无 userinfo | chatdoing 调用合作方的基础地址 |
key_id | 非空字符串 | 当前 active 加密密钥版本 |
key | 标准 Base64 编码的 32 字节随机值 | 当前 active AES-256 密钥 |
Content-Type: application/octet-stream
Body: AES-256-GCM ciphertext || tagkey_id,回调接口会以 HTTP 400 返回最小明文 JSON 错误。其他已完成头和密钥校验后的回调响应均为加密 body。| Header | 值与格式 |
|---|---|
X-OpenKF-Version | 固定 1 |
X-OpenKF-Direction | 请求为 request,响应或 ACK 为 response |
X-OpenKF-App-ID | 合作方 appid |
X-OpenKF-Key-ID | 本次使用的密钥版本 |
X-OpenKF-Algorithm | 固定 AES-256-GCM |
X-OpenKF-Timestamp | Unix 毫秒时间戳字符串,与接收方时钟差不得超过 5 分钟 |
X-OpenKF-Nonce | 标准 Base64 编码的 16 字节随机值,用于防重放 |
X-OpenKF-IV | 标准 Base64 编码的 12 字节随机 GCM nonce |
X-OpenKF-Request-ID | 一次逻辑请求的稳定唯一标识,建议 UUID v4 |
timestamp、nonce 和 IV。同一次逻辑调用的自动重试必须保持同一个 request_id。\n 连接,UTF-8 编码后作为 GCM Additional Authenticated Data:method\npath\nversion\ndirection\nappID\nkeyID\nalgorithm\ntimestamp\nnonce\niv\nrequestIDmethod 是大写的 POST,path 仅为 URL path,不包含 scheme、host、query string。例如出站消息接口为:POST
/open-kf/spi/v1/messages/send
1
request
partner-app-id
open-kf-202608
AES-256-GCM
1700000000000
QUFBQUFBQUFBQUFBQUFBQQ==
AQIDBAUGBwgJCgsM
req-001host 下的三个固定端点。合作方应解密 body、处理业务、以相同 request_id 返回加密的统一响应。POST {host}/open-kf/spi/v1/messages/send{
"msg_id": "msg-20260812-001",
"chat_id": "chat-001",
"send_time": 1786492800123,
"msg_type": 0,
"content": "您好,请问有什么可以帮您?"
}msg_type 如下。消息类型值与本服务内部枚举一致。msg_type | 类型 | 必填业务字段 |
|---|---|---|
0 | 文本 | content |
1 | 图片 | file_url |
2 | 语音 | file_url |
3 | 文件 | file_url |
4 | 视频 | file_url |
7 | 位置 | location |
9 | 链接 | link |
{
"location": {
"name": "办公园区",
"address": "广东省广州市...",
"latitude": 23.1291,
"longitude": 113.2644
},
"link": {
"title": "帮助中心",
"desc": "常见问题",
"url": "https://partner.example.com/help",
"thumb_url": "https://partner.example.com/help.png"
}
}POST {host}/open-kf/spi/v1/chats/transfer{"chat_id":"chat-001"}request_id 或自身持久化的会话业务键保证重复调用不会重复转人工。POST {host}/open-kf/spi/v1/contacts/get{"chat_id":"chat-001","sender_id":"customer-001"}data 应为:{
"sender_id": "customer-001",
"nickname": "张三",
"avatar": "https://partner.example.com/avatar.png",
"gender": 1,
"unionid": "optional-unionid"
}data 的成功响应。{
"code": 0,
"message": "",
"request_id": "d2b9a0ad-9ac1-4a21-98cc-436be1e37701",
"duplicate": false,
"data": null
}direction 必须为 response,其余 header 重新生成;响应 app_id、key_id 和 body 中的 request_id 必须与请求对应。成功时 code=0;重复成功可设置 duplicate=true 并返回原结果。POST /go-im-center/open_customer_service/callback/v1/events/{localAppID}X-OpenKF-Direction 必须为 request,X-OpenKF-App-ID 必须等于该本地 Application 配置的合作方 appid。event_type="event.msg"。解密后 body 示例:{
"event_type": "event.msg",
"msg_id": "partner-msg-001",
"chat_id": "chat-001",
"chat_type": 0,
"chat_status": 1,
"sender_id": "customer-001",
"sender_type": 1,
"send_time": 1786492800123,
"msg_type": 0,
"content": "我想咨询产品价格"
}| 字段 | 说明 |
|---|---|
chat_type | 0 单聊,1 群聊 |
chat_status | 1 人工服务,2 AI 托管 |
sender_type | 0 客服/员工,1 客户,2 AI |
msg_type | 取值及载荷字段见 4.1;媒体消息使用 file_url,位置使用 location,链接使用 link |
msg_id 或 request_id 已成功处理时,ACK 的 code=0 且 duplicate=true,不会再次发布消息事件。code | 含义 | 是否自动重试 |
|---|---|---|
40001 | 信封或 header 非法 | 否 |
40002 | GCM 解密或认证失败 | 否 |
40003 | nonce 重放 | 否,重新构造新请求才可能重试 |
40004 | 时间戳超过 5 分钟窗口 | 否,先校准时钟后重新构造请求 |
40005 | 协议版本、方向或算法不支持 | 否 |
40006 | 事件、动作、发送者或消息类型不支持 | 否 |
40101 | key_id 缺失、未知或已 retired | 否,更新密钥 配置后重新构造请求 |
40901 | 该请求正在处理,幂等占用冲突 | 由调用方按退避策略重试 |
42900 | 业务限流 | 是 |
50000 | 服务端内部错误 | 是 |
429 或任意 5xx,以及业务码 42900、50000 都会触发该重试。request_id 在一次逻辑操作和其传输重试中保持不变。nonce、12 字节 IV 和当前毫秒时间戳。appid + nonce 维度原子去重,避免重放。appid + request_id 及消息 appid + msg_id 维度保存处理结果,重复请求返回原结果或 duplicate=true,不重复产生业务副作用。crypt、Headers 和 AAD 规则即可。区别仅在于:URL path 为 /go-im-center/open_customer_service/callback/v1/events/{localAppID},请求 body 为第 5 节的事件 JSON;收到响应后按响应 headers 的 direction=response 解密并检查 request_id、code 与 duplicate。Headers、Response、crypt、headersFromHTTP 和 validateHeaders,展示合作方调用 本服务 POST https://im.example.com/go-im-center/open_customer_service/callback/v1/events/12345 的完整流程。// openkfspi.mjs — 合作方向 go-im-center 推送 event.msg 回调的示例。
// 使用 Node.js 内置模块,无需 npm install。
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import https from 'node:https';
const CONTENT_TYPE = 'application/octet-stream';
const CALLBACK_URL = 'https://im.example.com/go-im-center/open_customer_service/callback/v1/events/12345';
const CALLBACK_PATH = '/go-im-center/open_customer_service/callback/v1/events/12345';
const TAG_LENGTH = 16;
/**
* 按第 3.3 节固定顺序构造 AAD,以 \n 连接,UTF-8 编码。
*/
function buildAad(method, path, version, direction, appId, keyId,
algorithm, timestamp, nonce, iv, requestId) {
return Buffer.from(
[method, path, version, direction, appId, keyId, algorithm,
timestamp, nonce, iv, requestId].join('\n'),
'utf-8'
);
}
/**
* AES-256-GCM 加密,返回 ciphertext || tag 原始 Buffer。
*/
function encrypt(key, iv, aad, plaintext) {
const cipher = createCipheriv('aes-256-gcm', key, iv);
cipher.setAAD(aad);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag(); // 16 字节
return Buffer.concat([encrypted, tag]);
}
/**
* AES-256-GCM 解密,输入为 ciphertext || tag 原始 Buffer。
*/
function decrypt(key, iv, aad, input) {
if (input.length < TAG_LENGTH) {
throw new Error('Ciphertext too short');
}
const ciphertext = input.subarray(0, input.length - TAG_LENGTH);
const tag = input.subarray(input.length - TAG_LENGTH);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAAD(aad);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/**
* 向 go-im-center 推送一条 event.msg 回调并解密 ACK。
*
* @param {Buffer} key 32 字节 AES 密钥
* @param {string} appId 合作方 appid
* @param {string} keyId 当前密钥版本
* @returns {Promise<object>} 解密后的 ACK
*/
function pushEvent(key, appId, keyId) {
return new Promise((resolve, reject) => {
// 生成 16 字节 nonce、12 字节 IV,Base64 编码
const nonceB64 = randomBytes(16).toString('base64');
const ivB64 = randomBytes(12).toString('base64');
const requestId = randomBytes(16).toString('hex');
const timestamp = String(Date.now());
const version = '1';
const direction = 'request';
const algorithm = 'AES-256-GCM';
// 构造 AAD
const aad = buildAad('POST', CALLBACK_PATH, version, direction,
appId, keyId, algorithm, timestamp,
nonceB64, ivB64, requestId);
// 构造 event.msg body
const event = {
event_type: 'event.msg',
msg_id: 'partner-msg-001',
chat_id: 'chat-001',
chat_type: 0,
chat_status: 1,
sender_id: 'customer-001',
sender_type: 1,
send_time: Date.now(),
msg_type: 0,
content: '我想咨询产品价格',
};
const plaintext = Buffer.from(JSON.stringify(event), 'utf-8');
const ivBytes = Buffer.from(ivB64, 'base64');
const ciphertext = encrypt(key, ivBytes, aad, plaintext);
const headers = {
'Content-Type': CONTENT_TYPE,
'X-OpenKF-Version': version,
'X-OpenKF-Direction': direction,
'X-OpenKF-App-ID': appId,
'X-OpenKF-Key-ID': keyId,
'X-OpenKF-Algorithm': algorithm,
'X-OpenKF-Timestamp': timestamp,
'X-OpenKF-Nonce': nonceB64,
'X-OpenKF-IV': ivB64,
'X-OpenKF-Request-ID': requestId,
};
const url = new URL(CALLBACK_URL);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers,
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
if (res.statusCode < 200 || res.statusCode >= 300) {
// 非 2xx 可能是明文错误 JSON
return reject(new Error(`Callback HTTP status: ${res.statusCode}`));
}
const respContentType = res.headers['content-type'] || '';
if (respContentType !== CONTENT_TYPE) {
return reject(new Error(`Unexpected response Content-Type: ${respContentType}`));
}
const respDirection = res.headers['x-openkf-direction'] || '';
const respRequestId = res.headers['x-openkf-request-id'] || '';
if (respDirection !== 'response') {
return reject(new Error(`Unexpected response direction: ${respDirection}`));
}
if (respRequestId !== requestId) {
return reject(new Error('Response request_id mismatch'));
}
// 用响应的全部 header 构造 AAD 并解密 ACK
const respIvB64 = res.headers['x-openkf-iv'] || '';
const respIv = Buffer.from(respIvB64, 'base64');
const respAad = buildAad('POST', CALLBACK_PATH,
res.headers['x-openkf-version'] || '',
respDirection,
res.headers['x-openkf-app-id'] || '',
res.headers['x-openkf-key-id'] || '',
res.headers['x-openkf-algorithm'] || '',
res.headers['x-openkf-timestamp'] || '',
res.headers['x-openkf-nonce'] || '',
respIvB64, respRequestId);
const respBody = Buffer.concat(chunks);
const ackPlaintext = decrypt(key, respIv, respAad, respBody);
const ack = JSON.parse(ackPlaintext.toString('utf-8'));
resolve(ack);
} catch (err) {
reject(err);
}
});
});
req.on('error', reject);
req.write(ciphertext); // 原始二进制 body
req.end();
});
}
// 使用示例
const key = Buffer.from('MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', 'base64');
pushEvent(key, 'partner-app-id', 'open-kf-202608')
.then((ack) => console.log('ACK:', JSON.stringify(ack, null, 2)))
.catch((err) => console.error('Error:', err));// OpenKFCallbackExample.cs — 合作方向 go-im-center 推送 event.msg 回调的示例。
// 依赖 .NET 6+,无需 NuGet 额外包。
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OpenKF.SPI;
public static class OpenKFCallbackExample
{
private const string ContentType = "application/octet-stream";
private const string CallbackUrl = "https://im.example.com/go-im-center/open_customer_service/callback/v1/events/12345";
private const string CallbackPath = "/go-im-center/open_customer_service/callback/v1/events/12345";
private const int TagLength = 16;
private const int NonceLength = 16; // header nonce
private const int GcmIvLength = 12; // GCM IV
// ========== AAD 与加密工具 ==========
/// <summary>
/// 按第 3.3 节固定顺序构造 AAD,以 \n 连接,UTF-8 编码。
/// </summary>
private static byte[] BuildAad(
string method, string path, string version, string direction,
string appId, string keyId, string algorithm,
string timestamp, string nonce, string iv, string requestId)
{
string joined = string.Join("\n",
method, path, version, direction,
appId, keyId, algorithm,
timestamp, nonce, iv, requestId);
return Encoding.UTF8.GetBytes(joined);
}
/// <summary>
/// AES-256-GCM 加密,返回 ciphertext || tag 原始字节。
/// </summary>
private static byte[] Encrypt(byte[] key, byte[] nonce, byte[] aad, byte[] plaintext)
{
// AesGcm.Encrypt 输出 ciphertext,tag 由 tag 参数填充
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[TagLength];
using var aes = new AesGcm(key);
aes.Encrypt(nonce, plaintext, ciphertext, tag, aad);
// 拼接 ciphertext || tag
return ciphertext.Concat(tag).ToArray();
}
/// <summary>
/// AES-256-GCM 解密,输入为 ciphertext || tag 原始字节。
/// </summary>
private static byte[] Decrypt(byte[] key, byte[] nonce, byte[] aad, byte[] data)
{
if (data.Length < TagLength)
throw new ArgumentException("Ciphertext too short");
byte[] ciphertext = data[..^TagLength];
byte[] tag = data[^TagLength..];
byte[] plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key);
aes.Decrypt(nonce, ciphertext, tag, plaintext, aad);
return plaintext;
}
// ========== 回调发送示例 ==========
/// <summary>
/// 向 go-im-center 推送一条 event.msg 回调并解密 ACK。
/// </summary>
public static async Task<string> PushEventAsync(
byte[] key, string appId, string keyId, HttpClient client)
{
// 生成 16 字节 nonce、12 字节 IV,Base64 编码
byte[] nonceBytes = RandomNumberGenerator.GetBytes(NonceLength);
byte[] ivBytes = RandomNumberGenerator.GetBytes(GcmIvLength);
string nonceB64 = Convert.ToBase64String(nonceBytes);
string ivB64 = Convert.ToBase64String(ivBytes);
string requestId = Guid.NewGuid().ToString();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
string version = "1";
string direction = "request";
string algorithm = "AES-256-GCM";
// 构造 AAD
byte[] aad = BuildAad("POST", CallbackPath, version, direction,
appId, keyId, algorithm, timestamp, nonceB64, ivB64, requestId);
// 构造 event.msg body
var evt = new
{
event_type = "event.msg",
msg_id = "partner-msg-001",
chat_id = "chat-001",
chat_type = 0,
chat_status = 1,
sender_id = "customer-001",
sender_type = 1,
send_time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
msg_type = 0,
content = "我想咨询产品价格",
};
byte[] plaintext = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(evt));
byte[] ciphertext = Encrypt(key, ivBytes, aad, plaintext);
// 构造 HTTP 请求,body 为原始二进制
var request = new HttpRequestMessage(HttpMethod.Post, CallbackUrl)
{
Content = new ByteArrayContent(ciphertext)
};
request.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue(ContentType);
request.Headers.Add("X-OpenKF-Version", version);
request.Headers.Add("X-OpenKF-Direction", direction);
request.Headers.Add("X-OpenKF-App-ID", appId);
request.Headers.Add("X-OpenKF-Key-ID", keyId);
request.Headers.Add("X-OpenKF-Algorithm", algorithm);
request.Headers.Add("X-OpenKF-Timestamp", timestamp);
request.Headers.Add("X-OpenKF-Nonce", nonceB64);
request.Headers.Add("X-OpenKF-IV", ivB64);
request.Headers.Add("X-OpenKF-Request-ID", requestId);
HttpResponseMessage resp = await client.SendAsync(request);
if (resp.StatusCode < HttpStatusCode.OK ||
resp.StatusCode >= HttpStatusCode.MultipleChoices)
{
// 非 2xx 可能是明文错误 JSON,按 code 处理
throw new InvalidOperationException(
$"Callback HTTP status: {(int)resp.StatusCode}");
}
string? respContentType = resp.Content.Headers.ContentType?.MediaType;
if (respContentType != ContentType)
{
throw new InvalidOperationException(
$"Unexpected response Content-Type: {respContentType}");
}
// 校验响应 header
string? respDirection = resp.Headers.TryGetValues("X-OpenKF-Direction", out var d)
? d.FirstOrDefault() : null;
string? respRequestId = resp.Headers.TryGetValues("X-OpenKF-Request-ID", out var r)
? r.FirstOrDefault() : null;
if (respDirection != "response")
throw new InvalidOperationException($"Unexpected response direction: {respDirection}");
if (respRequestId != requestId)
throw new InvalidOperationException("Response request_id mismatch");
// 用响应的全部 header 构造 AAD 并解密 ACK
string? respIvB64 = resp.Headers.TryGetValues("X-OpenKF-IV", out var vi)
? vi.FirstOrDefault() : null;
byte[] respIv = Convert.FromBase64String(respIvB64 ?? "");
byte[] respAad = BuildAad("POST", CallbackPath,
resp.Headers.TryGetValues("X-OpenKF-Version", out var rv)
? rv.FirstOrDefault() ?? "" : "",
respDirection ?? "",
resp.Headers.TryGetValues("X-OpenKF-App-ID", out var ra)
? ra.FirstOrDefault() ?? "" : "",
resp.Headers.TryGetValues("X-OpenKF-Key-ID", out var rk)
? rk.FirstOrDefault() ?? "" : "",
resp.Headers.TryGetValues("X-OpenKF-Algorithm", out var rl)
? rl.FirstOrDefault() ?? "" : "",
resp.Headers.TryGetValues("X-OpenKF-Timestamp", out var rt)
? rt.FirstOrDefault() ?? "" : "",
resp.Headers.TryGetValues("X-OpenKF-Nonce", out var rn)
? rn.FirstOrDefault() ?? "" : "",
respIvB64 ?? "", respRequestId ?? "");
byte[] respBody = await resp.Content.ReadAsByteArrayAsync();
byte[] ackPlaintext = Decrypt(key, respIv, respAad, respBody);
return Encoding.UTF8.GetString(ackPlaintext);
}
public static async Task Main(string[] args)
{
byte[] key = Convert.FromBase64String(
"MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=");
using var client = new HttpClient();
string ackJson = await PushEventAsync(key, "partner-app-id", "open-kf-202608", client);
Console.WriteLine($"ACK: {ackJson}");
}
}429/5xx 或 ACK 的业务码为 42900、50000 时,应保持 requestID 不变,重新生成 Timestamp、Nonce、IV,使用新 header 和新密文重发;不可复用同一个 nonce 或 IV。