AI 客服系统开放平台对接文档
版本: v2.0.0
更新日期: 2026-01-14
📋 目录
1. 概述
1.1 介绍
AI 客服系统开放平台为第三方应用提供智能客服集成能力,支持实时对话、知识库问答、会话管理等功能。通过集成开放平台API,您可以快速将AI客服能力嵌入到自己的应用中。
1.2 核心特性
- ✅ 双重验证机制:签名验证 + Token验证,确保安全性
- ✅ WebSocket实时通信:支持长连接推送,实时接收客服消息
- ✅ 多场景支持:Web、App、小程序全覆盖
- ✅ 用户身份关联:支持匿名访客和登录用户两种模式
- ✅ 会话管理:完整的会话生命周期管理
- ✅ 智能知识库:AI自动问答,支持常见问题快速响应
1.3 接口域名
| 环境 | 域名 |
|---|---|
| 测试环境 | https://test-api.example.com |
| 生产环境 | https://api.example.com |
⚠️ 请将域名替换为实际部署的域名
1.4 通信协议
- 协议:HTTPS(推荐) / HTTP
- 请求方式:POST / GET
- 字符编码:UTF-8
- 数据格式:JSON
- WebSocket协议:WSS / WS
2. 认证机制
2.1 双重验证说明
开放平台采用两层验证机制,确保接口安全:
验证层级
┌─────────────────────────────────────────────┐
│ 第1层:签名验证(Signature Verification) │
│ 验证商户身份合法性 │
│ 使用:open_app_key + open_secret_key │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 第2层:Token验证(Token Verification) │
│ 验证访问权限和会话有效性 │
│ 使用:access_token │
└─────────────────────────────────────────────┘
接口验证规则
| 接口类型 | 第1层签名验证 | 第2层Token验证 | 说明 |
|---|---|---|---|
| 获取Token | ✅ 必需 | ❌ 不需要 | 用签名换取Token |
| HTTP业务接口 | ✅ 必需 | ✅ 必需 | 双重验证 |
| WebSocket连接 | ❌ 不需要 | ✅ 必需 | Token已通过签名获取 |
安全机制
- 防重放攻击:时间戳 + nonce 机制,签名5分钟内有效
- 跨商户防护:Token与商户ID绑定,禁止跨商户访问
- Token隔离:开放平台Token与前端Token分离,互不干扰
2.2 获取开放平台凭证
请联系平台管理员获取您的商户凭证:
{
"open_app_key": "MERCHANT_123_abc456def789",
"open_secret_key": "SECRET_xyz789uvw456rst123",
"merchant_id": 123
}
⚠️ 安全提示:
open_secret_key用于签名计算,切勿泄露或提交到代码仓库- 建议使用环境变量或配置中心管理密钥
- 定期轮换密钥以提高安全性
2.3 签名算法
2.3.1 公共请求头参数
所有需要签名验证的接口都需要在HTTP头中包含以下参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| open_app_key | string | ✅ | 商户密钥(公开) |
| timestamp | string | ✅ | Unix时间戳(秒),如:1703567890 |
| nonce | string | ✅ | 随机字符串,建议16-32位 |
| sign | string | ✅ | 请求签名 |
| sign_type | string | ❌ | 签名类型:MD5 或 SHA256,默认MD5 |
2.3.2 签名计算步骤
步骤1:参数收集
- 收集所有请求参数(包括 body 中的 JSON 参数)
- 排除
sign字段本身 - 添加
open_secret_key参数
步骤2:参数排序
按照参数名的字典序(ASCII码从小到大)排序
步骤3:拼接字符串
格式:key1=value1&key2=value2&...&open_secret_key=YOUR_SECRET
步骤4:计算签名
- MD5方式:
sign = MD5(拼接字符串) - SHA256方式:
sign = SHA256(拼接字符串)
2.3.3 签名示例
请求示例:
POST /api/open/visitor/token HTTP/1.1
Host: api.example.com
Content-Type: application/json
open_app_key: MERCHANT_123_abc456
timestamp: 1703567890
nonce: abc123def456
sign_type: MD5
sign: 3a5f8c9d2e1b4f7a8c6e2d9f1a3b5c7d
{
"app_key": "app_abc123",
"scene": "app",
"device_type": "mobile",
"user_info": {
"user_id": "customer_001",
"phone": "13800138000"
}
}
签名计算过程:
// 1. 参数收集
const params = {
"app_key": "app_abc123",
"scene": "app",
"device_type": "mobile",
"user_info": {"user_id":"customer_001","phone":"13800138000"}, // JSON序列化
"timestamp": "1703567890",
"nonce": "abc123def456",
"open_secret_key": "SECRET_xyz789uvw456rst123" // 添加密钥
};
// 2. 字典序排序
// app_key, device_type, nonce, open_secret_key, scene, timestamp, user_info
// 3. 拼接字符串
const signStr = 'app_key=app_abc123&device_type=mobile&nonce=abc123def456&open_secret_key=SECRET_xyz789uvw456rst123&scene=app×tamp=1703567890&user_info={"user_id":"customer_001","phone":"13800138000"}';
// 4. 计算MD5
const sign = MD5(signStr); // 结果:3a5f8c9d2e1b4f7a8c6e2d9f1a3b5c7d
2.3.4 注意事项
⚠️ 重要提示:
1. 时间戳有效期:服务器时间 ± 5分钟,超时则拒绝请求
2. nonce唯一性:建议使用UUID或随机字符串,防止重放攻击
3. 参数序列化:
- 对象类型参数需要先 JSON 序列化(紧凑格式,无空格)
- 空值参数不参与签名
4. 字符编码:统一使用 UTF-8 编码
5. 大小写敏感:参数名区分大小写
2.4 Token获取
接口说明
获取访客Token是使用开放平台的第一步,所有业务接口都需要先获取Token。
接口: POST /api/open/visitor/token
验证方式: 仅需签名验证(第1层)
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| app_key | string | ✅ | 应用密钥(由后台管理员分配,不同于open_app_key) |
| visitor_id | string | ❌ | 访客ID(设备唯一标识),首次调用可不传,服务端会生成 |
| scene | string | ❌ | 访问场景:web/app/miniapp/h5 |
| device_type | string | ❌ | 设备类型:mobile/pc/tablet |
| user_info | object | ❌ | 用户信息(登录用户模式必传) |
| user_info.user_id | string | ⚠️ | 商户系统的用户ID,传user_info时必填 |
| user_info.phone | string | ⚠️ | 用户手机号,传user_info时必填 |
| user_info.name | string | ❌ | 用户姓名 |
| user_info.email | string | ❌ | 用户邮箱 |
| user_info.avatar | string | ❌ | 用户头像URL |
| user_info.gender | int | ❌ | 用户性别:0-未知 1-男 2-女 |
请求示例
场景1:匿名访客模式
curl -X POST 'https://api.example.com/api/open/visitor/token' \
-H 'Content-Type: application/json' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: abc123def456' \
-H 'sign: 3a5f8c9d2e1b4f7a8c6e2d9f1a3b5c7d' \
-H 'sign_type: MD5' \
-d '{
"app_key": "app_abc123",
"scene": "web",
"device_type": "pc"
}'
场景2:登录用户模式
curl -X POST 'https://api.example.com/api/open/visitor/token' \
-H 'Content-Type: application/json' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: abc123def456' \
-H 'sign: 4b6g9d0f3f2c5g8b9d7f3e0a2b4c6d8e' \
-H 'sign_type: MD5' \
-d '{
"app_key": "app_abc123",
"scene": "app",
"device_type": "mobile",
"user_info": {
"user_id": "customer_001",
"phone": "13800138000",
"name": "张三",
"avatar": "https://example.com/avatar.jpg"
}
}'
响应示例
{
"code": 200,
"msg": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDM1Njk2OTAsImlhdCI6MTcwMzU2Nzg5MCwidmlzaXRvcl9pZCI6InZpc2l0b3JfMTcwMzU2Nzg5MF9hYmMxMjMifQ.xxx",
"refresh_token": "550e8400-e29b-41d4-a716-446655440000",
"visitor_id": "visitor_1703567890_abc123",
"expires_in": 600,
"user_info": {
"user_id": "customer_001",
"phone": "13800138000",
"name": "张三",
"avatar": "https://example.com/avatar.jpg"
}
}
}
响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| access_token | string | 访问令牌,有效期10分钟,用于后续接口调用 |
| refresh_token | string | 刷新令牌,有效期30天(暂未启用) |
| visitor_id | string | 访客唯一标识,客户端应持久化保存 |
| expires_in | int | access_token过期时间(秒) |
| user_info | object | 用户信息(仅登录用户模式返回) |
注意事项
⚠️ 重要提示:
1. visitor_id 管理:
- 首次调用可不传,服务端会生成
- 客户端应将 visitor_id 持久化保存(localStorage/数据库)
- 后续调用传递相同的 visitor_id 可关联会话历史
2. Token 有效期:
- access_token 有效期为10分钟
- Token 即将过期时应重新调用此接口获取新Token
- 建议在有效期过半时刷新Token
3. 用户信息绑定:
- 传递 user_info 时,user_id 和 phone 必须同时传递
- 同一 user_id 的 phone 必须保持一致,否则返回错误
- 用户信息会关联到会话记录,支持用户画像分析
4. 跨商户验证:
- app_key 必须属于签名的商户
- 禁止商户A使用商户B的app_key获取Token
3. API 接口参考
以下所有接口均需要双重验证(签名 + Token)。
通用请求头
除了签名相关的头部参数外,还需要携带Token:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| Authorization | string | ✅ | 格式:Bearer {access_token} |
或者通过 URL 参数传递:
?access_token={access_token}
3.1 结束会话
主动结束客服会话。
接口: POST /api/open/chat/session/end
验证方式: 签名验证 + Token验证(双重)
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | ✅ | 会话ID |
请求示例
curl -X POST 'https://api.example.com/api/open/chat/session/end' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: abc123def456' \
-H 'sign: 5c7h0e1g4g3d6h9c0e8g4f1b3c5d7e9f' \
-H 'sign_type: MD5' \
-d '{
"session_id": "session_1703567890_xyz"
}'
响应示例
{
"code": 200,
"msg": "会话已结束",
"data": null
}
3.2 设置会话已解决
标记会话问题已解决。
接口: POST /api/open/chat/session/solve
验证方式: 签名验证 + Token验证(双重)
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | ✅ | 会话ID |
| is_solve | int | ✅ | 是否解决:1-已解决 0-未解决 |
请求示例
curl -X POST 'https://api.example.com/api/open/chat/session/solve' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: def456ghi789' \
-H 'sign: 6d8i1f2h5h4e7i0d1f9h5g2c4d6e8f0g' \
-H 'sign_type: MD5' \
-d '{
"session_id": "session_1703567890_xyz",
"is_solve": 1
}'
响应示例
{
"code": 200,
"msg": "操作成功",
"data": null
}
3.3 会话评价
用户对会话进行评价。
接口: POST /api/open/chat/session/rating
验证方式: 签名验证 + Token验证(双重)
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| session_id | string | ✅ | 会话ID |
| rating | int | ✅ | 评分:1-5星 |
| comment | string | ❌ | 评价内容 |
| tags | array | ❌ | 评价标签,如:["响应及时","态度好"] |
请求示例
curl -X POST 'https://api.example.com/api/open/chat/session/rating' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: ghi789jkl012' \
-H 'sign: 7e9j2g3i6i5f8j1e2g0i6h3d5e7f9g1h' \
-H 'sign_type: MD5' \
-d '{
"session_id": "session_1703567890_xyz",
"rating": 5,
"comment": "客服态度很好,问题解决及时",
"tags": ["响应及时", "态度好", "专业"]
}'
响应示例
{
"code": 200,
"msg": "评价成功",
"data": null
}
3.4 标记消息已读
标记指定消息为已读状态。
接口: POST /api/open/chat/mark_read
验证方式: 签名验证 + Token验证(双重)
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| message_id | string | ✅ | 消息ID |
请求示例
curl -X POST 'https://api.example.com/api/open/chat/mark_read' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: jkl012mno345' \
-H 'sign: 8f0k3h4j7j6g9k2f3h1j7i4e6f8g0h2i' \
-H 'sign_type: MD5' \
-d '{
"message_id": "msg_1703567890_abc"
}'
响应示例
{
"code": 200,
"msg": "已标记为已读",
"data": null
}
3.5 获取常见问题
获取配置的常见问题列表。
接口: GET /api/open/chat/questions
验证方式: 签名验证 + Token验证(双重)
请求参数
无需 body 参数,Token 和签名通过请求头传递。
请求示例
curl -X GET 'https://api.example.com/api/open/chat/questions?access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
-H 'open_app_key: MERCHANT_123_abc456' \
-H 'timestamp: 1703567890' \
-H 'nonce: mno345pqr678' \
-H 'sign: 9g1l4i5k8k7h0l3g4i2k8j5f7g9h1i3j' \
-H 'sign_type: MD5'
响应示例
{
"code": 200,
"msg": "success",
"data": {
"list": [
{
"id": 1,
"question": "如何退款?",
"answer": "您可以在订单详情页面点击退款按钮...",
"category": "售后服务",
"sort": 1
},
{
"id": 2,
"question": "配送需要多久?",
"answer": "正常情况下3-5个工作日送达...",
"category": "物流配送",
"sort": 2
}
],
"total": 2
}
}
4. WebSocket 通信
4.1 建立连接
WebSocket用于实时接收客服消息和系统通知,仅需Token验证,无需签名。
接口: GET /api/open/chat/customer/ws
验证方式: 仅Token验证(第2层)
连接URL格式:
wss://api.example.com/api/open/chat/customer/ws?access_token={access_token}
或者通过请求头传递Token:
const ws = new WebSocket('wss://api.example.com/api/open/chat/customer/ws', {
headers: {
'Authorization': 'Bearer ' + access_token
}
});
连接示例
JavaScript (浏览器)
// 1. 先获取Token
const tokenData = await fetch('https://api.example.com/api/open/visitor/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'open_app_key': 'MERCHANT_123_abc456',
'timestamp': '1703567890',
'nonce': 'abc123def456',
'sign': '3a5f8c9d2e1b4f7a8c6e2d9f1a3b5c7d',
'sign_type': 'MD5'
},
body: JSON.stringify({
app_key: 'app_abc123',
scene: 'web',
device_type: 'pc'
})
}).then(res => res.json());
// 2. 建立WebSocket连接
const ws = new WebSocket(
`wss://api.example.com/api/open/chat/customer/ws?access_token=${tokenData.data.access_token}`
);
ws.onopen = () => {
console.log('WebSocket连接成功');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('收到消息:', message);
};
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
};
ws.onclose = (event) => {
console.log('WebSocket关闭:', event.code, event.reason);
};
Node.js
const WebSocket = require('ws');
// 1. 先获取Token (省略,同上)
// 2. 建立WebSocket连接
const ws = new WebSocket(
`wss://api.example.com/api/open/chat/customer/ws?access_token=${access_token}`
);
ws.on('open', () => {
console.log('WebSocket连接成功');
});
ws.on('message', (data) => {
const message = JSON.parse(data);
console.log('收到消息:', message);
});
ws.on('error', (error) => {
console.error('WebSocket错误:', error);
});
ws.on('close', (code, reason) => {
console.log('WebSocket关闭:', code, reason);
});
4.2 消息格式
4.2.1 发送消息(客户端→服务端)
文本消息
{
"type": "text",
"content": "您好,我想咨询一下退款问题"
}
图片消息
{
"type": "image",
"content": "https://example.com/upload/image.jpg"
}
文件消息
{
"type": "file",
"content": "https://example.com/upload/document.pdf",
"filename": "合同文件.pdf",
"filesize": 1024000
}
4.2.2 接收消息(服务端→客户端)
普通消息
{
"type": "message",
"data": {
"message_id": "msg_1703567890_abc",
"session_id": "session_1703567890_xyz",
"sender_type": "robot",
"sender_id": "robot_001",
"sender_name": "智能助手",
"content_type": "text",
"content": "您好,关于退款问题,您可以...",
"timestamp": 1703567890,
"created_at": "2026-01-14 10:30:00"
}
}
系统通知
{
"type": "system",
"event": "session_timeout",
"data": {
"message": "会话即将超时,请尽快完成咨询",
"remaining_seconds": 60
}
}
客服转接通知
{
"type": "system",
"event": "agent_transfer",
"data": {
"message": "正在为您转接人工客服...",
"agent_name": "客服小王",
"agent_avatar": "https://example.com/avatar/agent001.jpg"
}
}
会话结束通知
{
"type": "system",
"event": "session_end",
"data": {
"session_id": "session_1703567890_xyz",
"message": "会话已结束,感谢您的咨询",
"end_reason": "user_close"
}
}
4.2.3 消息类型说明
sender_type 发送者类型:
| 值 | 说明 |
|---|---|
| robot | 机器人客服 |
| agent | 人工客服 |
| system | 系统消息 |
content_type 内容类型:
| 值 | 说明 |
|---|---|
| text | 文本消息 |
| image | 图片消息 |
| file | 文件消息 |
| rich_text | 富文本消息(HTML) |
| card | 卡片消息 |
event 事件类型:
| 值 | 说明 |
|---|---|
| session_start | 会话开始 |
| session_end | 会话结束 |
| session_timeout | 会话超时提醒 |
| agent_transfer | 转接人工 |
| agent_online | 客服上线 |
| agent_offline | 客服离线 |
| message_read | 消息已读 |
4.3 心跳机制
为保持WebSocket连接活跃,客户端应定期发送心跳包。
心跳包格式
客户端发送:
{
"type": "ping"
}
服务端响应:
{
"type": "pong",
"timestamp": 1703567890
}
心跳示例
// 每30秒发送一次心跳
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
// 接收心跳响应
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'pong') {
console.log('心跳正常');
}
};
4.4 断线重连
网络波动可能导致WebSocket断开,建议实现自动重连机制。
重连策略
1. 指数退避算法: 1s → 2s → 4s → 8s → 16s → 最大30s
2. 最大重连次数: 建议10次
3. Token过期处理: 重连前检查Token是否过期,过期则重新获取
重连示例
class WebSocketClient {
constructor(url, getToken) {
this.url = url;
this.getToken = getToken; // 获取Token的函数
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000; // 初始1秒
this.maxReconnectDelay = 30000; // 最大30秒
}
async connect() {
try {
// 获取最新Token
const token = await this.getToken();
this.ws = new WebSocket(`${this.url}?access_token=${token}`);
this.ws.on('open', () => {
console.log('WebSocket连接成功');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
});
this.ws.on('message', (data) => {
const message = JSON.parse(data);
this.handleMessage(message);
});
this.ws.on('error', (error) => {
console.error('WebSocket错误:', error);
});
this.ws.on('close', (code, reason) => {
console.log('WebSocket关闭:', code, reason);
this.reconnect();
});
} catch (error) {
console.error('连接失败:', error);
this.reconnect();
}
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('达到最大重连次数,停止重连');
return;
}
this.reconnectAttempts++;
console.log(`${this.reconnectDelay}ms后进行第${this.reconnectAttempts}次重连...`);
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
// 指数退避
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay
);
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.error('WebSocket未连接');
}
}
handleMessage(message) {
// 处理接收到的消息
console.log('收到消息:', message);
}
close() {
if (this.ws) {
this.ws.close();
}
}
}
// 使用示例
const client = new WebSocketClient(
'wss://api.example.com/api/open/chat/customer/ws',
async () => {
// Token获取逻辑
const tokenData = await getVisitorToken();
return tokenData.access_token;
}
);
client.connect();
4.5 完整通信流程
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 客户端 │ │ 服务端 │ │ AI │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 1. 获取Token(带签名) │ │
├──────────────────────────>│ │
│ │ │
│ 2. 返回access_token │ │
│<──────────────────────────┤ │
│ │ │
│ 3. WebSocket连接(带Token) │ │
├──────────────────────────>│ │
│ │ │
│ 4. 连接成功 │ │
│<──────────────────────────┤ │
│ │ │
│ 5. 发送消息 │ │
├──────────────────────────>│ 6. 转发给AI │
│ ├──────────────────────────>│
│ │ │
│ │ 7. AI回复 │
│ 8. 推送AI消息 │<──────────────────────────┤
│<──────────────────────────┤ │
│ │ │
│ 9. 心跳ping │ │
├──────────────────────────>│ │
│ │ │
│ 10. 心跳pong │ │
│<──────────────────────────┤ │
│ │ │
5. 错误码说明
5.1 HTTP状态码
| 状态码 | 说明 |
|---|---|
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 未授权(签名或Token验证失败) |
| 403 | 禁止访问(跨商户访问等) |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
5.2 业务错误码
| 错误码 | 说明 | 解决方案 |
|---|---|---|
| 10001 | 参数缺失 | 检查必填参数是否传递 |
| 10002 | 参数格式错误 | 检查参数类型和格式 |
| 20001 | 签名验证失败 | 检查签名算法和密钥是否正确 |
| 20002 | 签名已过期 | 检查timestamp是否在5分钟内 |
| 20003 | open_app_key无效 | 检查商户凭证是否正确 |
| 20004 | nonce重复 | 使用新的随机字符串 |
| 30001 | Token无效或已过期 | 重新获取Token |
| 30002 | Token商户不匹配 | 使用正确商户的Token |
| 30003 | 访客信息不存在 | 检查visitor_id是否有效 |
| 40001 | app_key无效 | 检查应用密钥是否正确 |
| 40002 | app_key不属于当前商户 | 使用本商户的app_key |
| 40003 | 用户信息验证失败 | user_id和phone必须同时传递 |
| 40004 | 用户信息不一致 | 同一user_id的phone必须保持一致 |
| 50001 | 会话不存在 | 检查session_id是否有效 |
| 50002 | 会话已结束 | 该会话已关闭,无法继续操作 |
| 60001 | 系统繁忙 | 稍后重试 |
5.3 错误响应格式
{
"code": 20001,
"msg": "签名验证失败: 签名不匹配",
"data": null
}
6. 完整 SDK 示例
6.1 Java SDK
6.1.1 签名工具类
package com.example.openapi;
import com.alibaba.fastjson.JSON;
import org.apache.commons.codec.digest.DigestUtils;
import java.util.*;
public class OpenAPISignUtil {
/**
* 生成签名
* @param params 请求参数
* @param secretKey 商户密钥
* @param signType 签名类型(MD5/SHA256)
* @return 签名字符串
*/
public static String generateSign(Map<String, Object> params, String secretKey, String signType) {
// 1. 添加密钥
Map<String, Object> signParams = new TreeMap<>(params);
signParams.put("open_secret_key", secretKey);
// 2. 按字典序排序并拼接
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> entry : signParams.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 跳过sign字段
if ("sign".equals(key) || value == null) {
continue;
}
// 对象类型需要序列化
String valueStr = (value instanceof String)
? (String) value
: JSON.toJSONString(value);
if (sb.length() > 0) {
sb.append("&");
}
sb.append(key).append("=").append(valueStr);
}
String signStr = sb.toString();
// 3. 计算签名
if ("SHA256".equalsIgnoreCase(signType)) {
return DigestUtils.sha256Hex(signStr);
} else {
return DigestUtils.md5Hex(signStr);
}
}
/**
* 生成随机nonce
*/
public static String generateNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 获取当前时间戳(秒)
*/
public static String getTimestamp() {
return String.valueOf(System.currentTimeMillis() / 1000);
}
}
6.1.2 开放平台客户端
package com.example.openapi;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import okhttp3.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class OpenAPIClient {
private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8");
private String baseUrl;
private String openAppKey;
private String openSecretKey;
private OkHttpClient httpClient;
public OpenAPIClient(String baseUrl, String openAppKey, String openSecretKey) {
this.baseUrl = baseUrl;
this.openAppKey = openAppKey;
this.openSecretKey = openSecretKey;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
}
/**
* 获取访客Token
*/
public JSONObject getVisitorToken(String appKey, String scene, String deviceType,
Map<String, Object> userInfo) throws IOException {
String url = baseUrl + "/api/open/visitor/token";
Map<String, Object> params = new HashMap<>();
params.put("app_key", appKey);
params.put("scene", scene);
params.put("device_type", deviceType);
if (userInfo != null) {
params.put("user_info", userInfo);
}
return postWithSign(url, params, null);
}
/**
* 结束会话
*/
public JSONObject endSession(String sessionId, String accessToken) throws IOException {
String url = baseUrl + "/api/open/chat/session/end";
Map<String, Object> params = new HashMap<>();
params.put("session_id", sessionId);
return postWithSign(url, params, accessToken);
}
/**
* 设置会话已解决
*/
public JSONObject setSessionSolve(String sessionId, int isSolve, String accessToken) throws IOException {
String url = baseUrl + "/api/open/chat/session/solve";
Map<String, Object> params = new HashMap<>();
params.put("session_id", sessionId);
params.put("is_solve", isSolve);
return postWithSign(url, params, accessToken);
}
/**
* 会话评价
*/
public JSONObject rateSession(String sessionId, int rating, String comment,
String[] tags, String accessToken) throws IOException {
String url = baseUrl + "/api/open/chat/session/rating";
Map<String, Object> params = new HashMap<>();
params.put("session_id", sessionId);
params.put("rating", rating);
if (comment != null) {
params.put("comment", comment);
}
if (tags != null) {
params.put("tags", tags);
}
return postWithSign(url, params, accessToken);
}
/**
* 标记消息已读
*/
public JSONObject markMessageRead(String messageId, String accessToken) throws IOException {
String url = baseUrl + "/api/open/chat/mark_read";
Map<String, Object> params = new HashMap<>();
params.put("message_id", messageId);
return postWithSign(url, params, accessToken);
}
/**
* 获取常见问题
*/
public JSONObject getCommonQuestions(String accessToken) throws IOException {
String url = baseUrl + "/api/open/chat/questions?access_token=" + accessToken;
Map<String, Object> params = new HashMap<>();
return getWithSign(url, params);
}
/**
* POST请求(带签名)
*/
private JSONObject postWithSign(String url, Map<String, Object> params, String accessToken) throws IOException {
// 生成签名参数
String timestamp = OpenAPISignUtil.getTimestamp();
String nonce = OpenAPISignUtil.generateNonce();
// 签名计算(包含body参数)
Map<String, Object> signParams = new HashMap<>(params);
signParams.put("timestamp", timestamp);
signParams.put("nonce", nonce);
String sign = OpenAPISignUtil.generateSign(signParams, openSecretKey, "MD5");
// 构建请求
RequestBody body = RequestBody.create(JSON.toJSONString(params), JSON_TYPE);
Request.Builder builder = new Request.Builder()
.url(url)
.post(body)
.addHeader("Content-Type", "application/json")
.addHeader("open_app_key", openAppKey)
.addHeader("timestamp", timestamp)
.addHeader("nonce", nonce)
.addHeader("sign", sign)
.addHeader("sign_type", "MD5");
if (accessToken != null) {
builder.addHeader("Authorization", "Bearer " + accessToken);
}
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
String responseBody = response.body().string();
return JSON.parseObject(responseBody);
}
}
/**
* GET请求(带签名)
*/
private JSONObject getWithSign(String url, Map<String, Object> params) throws IOException {
// 生成签名参数
String timestamp = OpenAPISignUtil.getTimestamp();
String nonce = OpenAPISignUtil.generateNonce();
Map<String, Object> signParams = new HashMap<>(params);
signParams.put("timestamp", timestamp);
signParams.put("nonce", nonce);
String sign = OpenAPISignUtil.generateSign(signParams, openSecretKey, "MD5");
// 构建请求
Request request = new Request.Builder()
.url(url)
.get()
.addHeader("open_app_key", openAppKey)
.addHeader("timestamp", timestamp)
.addHeader("nonce", nonce)
.addHeader("sign", sign)
.addHeader("sign_type", "MD5")
.build();
try (Response response = httpClient.newCall(request).execute()) {
String responseBody = response.body().string();
return JSON.parseObject(responseBody);
}
}
}
6.1.3 使用示例
public class Main {
public static void main(String[] args) {
// 初始化客户端
OpenAPIClient client = new OpenAPIClient(
"https://api.example.com",
"MERCHANT_123_abc456",
"SECRET_xyz789uvw456rst123"
);
try {
// 1. 获取Token(匿名模式)
JSONObject tokenData = client.getVisitorToken(
"app_abc123",
"web",
"pc",
null
);
System.out.println("Token: " + tokenData);
String accessToken = tokenData.getJSONObject("data").getString("access_token");
// 2. 获取常见问题
JSONObject questions = client.getCommonQuestions(accessToken);
System.out.println("常见问题: " + questions);
// 3. 结束会话
JSONObject endResult = client.endSession("session_123", accessToken);
System.out.println("结束会话: " + endResult);
// 4. 会话评价
JSONObject rateResult = client.rateSession(
"session_123",
5,
"服务很好",
new String[]{"响应及时", "专业"},
accessToken
);
System.out.println("评价结果: " + rateResult);
} catch (IOException e) {
e.printStackTrace();
}
}
}
6.2 PHP SDK
6.2.1 签名工具类
<?php
class OpenAPISignUtil {
/**
* 生成签名
* @param array $params 请求参数
* @param string $secretKey 商户密钥
* @param string $signType 签名类型(MD5/SHA256)
* @return string 签名字符串
*/
public static function generateSign($params, $secretKey, $signType = 'MD5') {
// 1. 添加密钥
$params['open_secret_key'] = $secretKey;
// 2. 移除sign字段和空值
unset($params['sign']);
$params = array_filter($params, function($value) {
return $value !== null && $value !== '';
});
// 3. 按key排序
ksort($params);
// 4. 拼接字符串
$signStr = '';
foreach ($params as $key => $value) {
// 数组或对象需要JSON序列化
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$signStr .= $key . '=' . $value . '&';
}
$signStr = rtrim($signStr, '&');
// 5. 计算签名
if (strtoupper($signType) === 'SHA256') {
return hash('sha256', $signStr);
} else {
return md5($signStr);
}
}
/**
* 生成随机nonce
*/
public static function generateNonce() {
return md5(uniqid(mt_rand(), true));
}
/**
* 获取当前时间戳(秒)
*/
public static function getTimestamp() {
return (string)time();
}
}
6.2.2 开放平台客户端
<?php
class OpenAPIClient {
private $baseUrl;
private $openAppKey;
private $openSecretKey;
public function __construct($baseUrl, $openAppKey, $openSecretKey) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->openAppKey = $openAppKey;
$this->openSecretKey = $openSecretKey;
}
/**
* 获取访客Token
*/
public function getVisitorToken($appKey, $scene = 'web', $deviceType = 'pc', $userInfo = null) {
$url = $this->baseUrl . '/api/open/visitor/token';
$params = [
'app_key' => $appKey,
'scene' => $scene,
'device_type' => $deviceType,
];
if ($userInfo !== null) {
$params['user_info'] = $userInfo;
}
return $this->postWithSign($url, $params);
}
/**
* 结束会话
*/
public function endSession($sessionId, $accessToken) {
$url = $this->baseUrl . '/api/open/chat/session/end';
$params = [
'session_id' => $sessionId,
];
return $this->postWithSign($url, $params, $accessToken);
}
/**
* 设置会话已解决
*/
public function setSessionSolve($sessionId, $isSolve, $accessToken) {
$url = $this->baseUrl . '/api/open/chat/session/solve';
$params = [
'session_id' => $sessionId,
'is_solve' => $isSolve,
];
return $this->postWithSign($url, $params, $accessToken);
}
/**
* 会话评价
*/
public function rateSession($sessionId, $rating, $comment = null, $tags = null, $accessToken) {
$url = $this->baseUrl . '/api/open/chat/session/rating';
$params = [
'session_id' => $sessionId,
'rating' => $rating,
];
if ($comment !== null) {
$params['comment'] = $comment;
}
if ($tags !== null) {
$params['tags'] = $tags;
}
return $this->postWithSign($url, $params, $accessToken);
}
/**
* 标记消息已读
*/
public function markMessageRead($messageId, $accessToken) {
$url = $this->baseUrl . '/api/open/chat/mark_read';
$params = [
'message_id' => $messageId,
];
return $this->postWithSign($url, $params, $accessToken);
}
/**
* 获取常见问题
*/
public function getCommonQuestions($accessToken) {
$url = $this->baseUrl . '/api/open/chat/questions?access_token=' . $accessToken;
return $this->getWithSign($url, []);
}
/**
* POST请求(带签名)
*/
private function postWithSign($url, $params, $accessToken = null) {
// 生成签名参数
$timestamp = OpenAPISignUtil::getTimestamp();
$nonce = OpenAPISignUtil::generateNonce();
// 签名计算(包含body参数)
$signParams = array_merge($params, [
'timestamp' => $timestamp,
'nonce' => $nonce,
]);
$sign = OpenAPISignUtil::generateSign($signParams, $this->openSecretKey, 'MD5');
// 构建请求头
$headers = [
'Content-Type' => 'application/json',
'open_app_key' => $this->openAppKey,
'timestamp' => $timestamp,
'nonce' => $nonce,
'sign' => $sign,
'sign_type' => 'MD5',
];
if ($accessToken !== null) {
$headers['Authorization'] = 'Bearer ' . $accessToken;
}
// 发送请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("HTTP Error: " . $httpCode);
}
return json_decode($response, true);
}
/**
* GET请求(带签名)
*/
private function getWithSign($url, $params) {
// 生成签名参数
$timestamp = OpenAPISignUtil::getTimestamp();
$nonce = OpenAPISignUtil::generateNonce();
$signParams = array_merge($params, [
'timestamp' => $timestamp,
'nonce' => $nonce,
]);
$sign = OpenAPISignUtil::generateSign($signParams, $this->openSecretKey, 'MD5');
// 构建请求头
$headers = [
'open_app_key' => $this->openAppKey,
'timestamp' => $timestamp,
'nonce' => $nonce,
'sign' => $sign,
'sign_type' => 'MD5',
];
// 发送请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("HTTP Error: " . $httpCode);
}
return json_decode($response, true);
}
}
6.2.3 使用示例
<?php
// 初始化客户端
$client = new OpenAPIClient(
'https://api.example.com',
'MERCHANT_123_abc456',
'SECRET_xyz789uvw456rst123'
);
try {
// 1. 获取Token(登录用户模式)
$tokenData = $client->getVisitorToken(
'app_abc123',
'web',
'pc',
[
'user_id' => 'customer_001',
'phone' => '13800138000',
'name' => '张三',
]
);
echo "Token: " . json_encode($tokenData, JSON_UNESCAPED_UNICODE) . "\n";
$accessToken = $tokenData['data']['access_token'];
// 2. 获取常见问题
$questions = $client->getCommonQuestions($accessToken);
echo "常见问题: " . json_encode($questions, JSON_UNESCAPED_UNICODE) . "\n";
// 3. 结束会话
$endResult = $client->endSession('session_123', $accessToken);
echo "结束会话: " . json_encode($endResult, JSON_UNESCAPED_UNICODE) . "\n";
// 4. 会话评价
$rateResult = $client->rateSession(
'session_123',
5,
'服务很好',
['响应及时', '专业'],
$accessToken
);
echo "评价结果: " . json_encode($rateResult, JSON_UNESCAPED_UNICODE) . "\n";
} catch (Exception $e) {
echo "错误: " . $e->getMessage() . "\n";
}
6.3 Python SDK
6.3.1 完整SDK实现
import hashlib
import json
import time
import uuid
import requests
from typing import Dict, Any, Optional, List
class OpenAPISignUtil:
"""签名工具类"""
@staticmethod
def generate_sign(params: Dict[str, Any], secret_key: str, sign_type: str = 'MD5') -> str:
"""生成签名"""
# 1. 添加密钥
sign_params = dict(params)
sign_params['open_secret_key'] = secret_key
# 2. 移除sign字段和空值
sign_params.pop('sign', None)
sign_params = {k: v for k, v in sign_params.items() if v is not None}
# 3. 按key排序
sorted_keys = sorted(sign_params.keys())
# 4. 拼接字符串
sign_str = '&'.join([
f"{k}={json.dumps(sign_params[k], ensure_ascii=False, separators=(',', ':')) if isinstance(sign_params[k], (dict, list)) else sign_params[k]}"
for k in sorted_keys
])
sign_str += f"&key={secret_key}"
print(f"待签名字符串: {sign_str}")
# 5. MD5加密并转大写
sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
return sign
@staticmethod
def generate_nonce() -> str:
"""生成随机nonce"""
return uuid.uuid4().hex
@staticmethod
def get_timestamp() -> str:
"""获取当前时间戳(秒)"""
return str(int(time.time()))
class OpenAPIClient:
"""开放平台客户端"""
def __init__(self, base_url: str, open_app_key: str, open_secret_key: str):
self.base_url = base_url.rstrip('/')
self.open_app_key = open_app_key
self.open_secret_key = open_secret_key
self.session = requests.Session()
self.session.headers.update({'Content-Type': 'application/json'})
def get_visitor_token(self, app_key: str, scene: str = 'web',
device_type: str = 'pc', user_info: Optional[Dict] = None) -> Dict:
"""获取访客Token"""
url = f"{self.base_url}/api/open/visitor/token"
params = {
'app_key': app_key,
'scene': scene,
'device_type': device_type,
}
if user_info:
params['user_info'] = user_info
return self._post_with_sign(url, params)
def end_session(self, session_id: str, access_token: str) -> Dict:
"""结束会话"""
url = f"{self.base_url}/api/open/chat/session/end"
params = {'session_id': session_id}
return self._post_with_sign(url, params, access_token)
def set_session_solve(self, session_id: str, is_solve: int, access_token: str) -> Dict:
"""设置会话已解决"""
url = f"{self.base_url}/api/open/chat/session/solve"
params = {
'session_id' => session_id,
'is_solve' => is_solve,
}
return self._post_with_sign(url, params, access_token)
def rate_session(self, session_id: str, rating: int, comment: Optional[str] = None,
tags: Optional[List[str]] = None, access_token: str = None) -> Dict:
"""会话评价"""
url = f"{self.base_url}/api/open/chat/session/rating"
params = {
'session_id' => session_id,
'rating' => rating,
}
if comment:
params['comment'] = comment
if tags:
params['tags'] = tags
return self._post_with_sign(url, params, access_token)
def mark_message_read(self, message_id: str, access_token: str) -> Dict:
"""标记消息已读"""
url = f"{self.base_url}/api/open/chat/mark_read"
params = {'message_id' => messageId}
return self._post_with_sign(url, params, access_token)
def get_common_questions(self, access_token: str) -> Dict:
"""获取常见问题"""
url = f"{self.base_url}/api/open/chat/questions?access_token={access_token}"
return self._get_with_sign(url, {})
def _post_with_sign(self, url: str, params: Dict, access_token: Optional[str] = None) -> Dict:
"""POST请求(带签名)"""
# 生成签名参数
timestamp = OpenAPISignUtil.get_timestamp()
nonce = OpenAPISignUtil.generate_nonce()
# 签名计算(包含body参数)
sign_params = dict(params)
sign_params.update({
'timestamp' => timestamp,
'nonce' => nonce,
})
sign = OpenAPISignUtil.generate_sign(sign_params, $this->openSecretKey, 'MD5');
# 构建请求头
headers = {
'Content-Type': 'application/json',
'open_app_key': $this->openAppKey,
'timestamp': timestamp,
'nonce': nonce,
'sign': sign,
'sign_type': 'MD5',
};
if (access_token !== null) {
headers['Authorization'] = 'Bearer ' . access_token;
}
# 发送请求
response = await this.httpClient.post(url, { headers });
return response.data;
}
}
// 使用示例
async function main() {
// 初始化客户端
const client = new OpenAPIClient(
'https://api.example.com',
'MERCHANT_123_abc456',
'SECRET_xyz789uvw456rst123'
);
try {
// 1. 获取Token(登录用户模式)
const tokenData = await client.getVisitorToken(
'app_abc123',
'web',
'pc',
{
user_id: 'customer_001',
phone: '13800138000',
name: '张三',
}
);
console.log('Token:', JSON.stringify(tokenData, null, 2));
const accessToken = tokenData.data.access_token;
// 2. 获取常见问题
const questions = await client.getCommonQuestions(accessToken);
console.log('常见问题:', JSON.stringify(questions, null, 2));
// 3. 结束会话
const endResult = await client.endSession('session_123', accessToken);
console.log('结束会话:', JSON.stringify(endResult, null, 2));
// 4. 会话评价
const rateResult = await client.rateSession(
'session_123',
5,
'服务很好',
['响应及时', '专业'],
accessToken
);
console.log('评价结果:', JSON.stringify(rateResult, null, 2));
} catch (error) {
console.error('错误:', error.message);
}
}
// 导出模块
module.exports = {
OpenAPISignUtil,
OpenAPIClient,
};
// 如果直接运行此文件
if (require.main === module) {
main();
}
6.4 Node.js SDK
6.4.1 完整SDK实现
const crypto = require('crypto');
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
class OpenAPISignUtil {
/**
* 生成签名
*/
static generateSign(params, secretKey, signType = 'MD5') {
// 1. 添加密钥
const signParams = { ...params, open_secret_key: secretKey };
// 2. 移除sign字段和空值
delete signParams.sign;
Object.keys(signParams).forEach(key => {
if (signParams[key] === null || signParams[key] === undefined) {
delete signParams[key];
}
});
// 3. 按key排序
const sortedKeys = Object.keys(signParams).sort();
// 4. 拼接字符串
const signStr = sortedKeys.map(key => {
const value = signParams[key];
const valueStr = (typeof value === 'object')
? JSON.stringify(value)
: String(value);
return `${key}=${valueStr}`;
}).join('&');
// 5. 计算签名
if (signType.toUpperCase() === 'SHA256') {
return crypto.createHash('sha256').update(signStr).digest('hex');
} else {
return crypto.createHash('md5').update(signStr).digest('hex');
}
}
/**
* 生成随机nonce
*/
static generateNonce() {
return uuidv4().replace(/-/g, '');
}
/**
* 获取当前时间戳(秒)
*/
static getTimestamp() {
return String(Math.floor(Date.now() / 1000));
}
}
class OpenAPIClient {
constructor(baseUrl, openAppKey, openSecretKey) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.openAppKey = openAppKey;
this.openSecretKey = openSecretKey;
this.httpClient = axios.create({
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
}
/**
* 获取访客Token
*/
async getVisitorToken(appKey, scene = 'web', deviceType = 'pc', userInfo = null) {
const url = `${this.baseUrl}/api/open/visitor/token`;
const params = {
app_key: appKey,
scene,
device_type: deviceType,
};
if (userInfo) {
params.user_info = userInfo;
}
return await this._postWithSign(url, params);
}
/**
* 结束会话
*/
async endSession(sessionId, accessToken) {
const url = `${this.baseUrl}/api/open/chat/session/end`;
const params = { session_id: sessionId };
return await this._postWithSign(url, params, accessToken);
}
/**
* 设置会话已解决
*/
async setSessionSolve(sessionId, isSolve, accessToken) {
const url = `${this.baseUrl}/api/open/chat/session/solve`;
const params = {
session_id: sessionId,
is_solve: isSolve,
};
return await this._postWithSign(url, params, accessToken);
}
/**
* 会话评价
*/
async rateSession(sessionId, rating, comment = null, tags = null, accessToken) {
const url = `${this.baseUrl}/api/open/chat/session/rating`;
const params = {
session_id: sessionId,
rating,
};
if (comment) params.comment = comment;
if (tags) params.tags = tags;
return await this._postWithSign(url, params, accessToken);
}
/**
* 标记消息已读
*/
async markMessageRead(messageId, accessToken) {
const url = `${this.baseUrl}/api/open/chat/mark_read`;
const params = { message_id: messageId };
return await this._postWithSign(url, params, accessToken);
}
/**
* 获取常见问题
*/
async getCommonQuestions(accessToken) {
const url = `${this.baseUrl}/api/open/chat/questions?access_token=${accessToken}`;
return await this._getWithSign(url, {});
}
/**
* POST请求(带签名)
*/
async _postWithSign(url, params, accessToken = null) {
// 生成签名参数
const timestamp = OpenAPISignUtil.getTimestamp();
const nonce = OpenAPISignUtil.generateNonce();
// 签名计算(包含body参数)
const signParams = {
...params,
timestamp,
nonce,
};
const sign = OpenAPISignUtil.generateSign(signParams, this.openSecretKey, 'MD5');
// 构建请求头
const headers = {
'Content-Type': 'application/json',
'open_app_key': this.openAppKey,
'timestamp': timestamp,
'nonce': nonce,
'sign': sign,
'sign_type': 'MD5',
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
// 发送请求
const response = await this.httpClient.post(url, params, { headers });
return response.data;
}
/**
* GET请求(带签名)
*/
async _getWithSign(url, params) {
// 生成签名参数
const timestamp = OpenAPISignUtil.getTimestamp();
const nonce = OpenAPISignUtil.generateNonce();
const signParams = {
...params,
timestamp,
nonce,
};
const sign = OpenAPISignUtil.generateSign(signParams, this.openSecretKey, 'MD5');
// 构建请求头
const headers = {
'open_app_key': this.openAppKey,
'timestamp': timestamp,
'nonce': nonce,
'sign': sign,
'sign_type': 'MD5',
};
// 发送请求
const response = await this.httpClient.get(url, { headers });
return response.data;
}
}
// 使用示例
async function main() {
// 初始化客户端
const client = new OpenAPIClient(
'https://api.example.com',
'MERCHANT_123_abc456',
'SECRET_xyz789uvw456rst123'
);
try {
// 1. 获取Token(登录用户模式)
const tokenData = await client.getVisitorToken(
'app_abc123',
'web',
'pc',
{
user_id: 'customer_001',
phone: '13800138000',
name: '张三',
}
);
console.log('Token:', JSON.stringify(tokenData, null, 2));
const accessToken = tokenData.data.access_token;
// 2. 获取常见问题
const questions = await client.getCommonQuestions(accessToken);
console.log('常见问题:', JSON.stringify(questions, null, 2));
// 3. 结束会话
const endResult = await client.endSession('session_123', accessToken);
console.log('结束会话:', JSON.stringify(endResult, null, 2));
// 4. 会话评价
const rateResult = await client.rateSession(
'session_123',
5,
'服务很好',
['响应及时', '专业'],
accessToken
);
console.log('评价结果:', JSON.stringify(rateResult, null, 2));
} catch (error) {
console.error('错误:', error.message);
}
}
// 导出模块
module.exports = {
OpenAPISignUtil,
OpenAPIClient,
};
// 如果直接运行此文件
if (require.main === module) {
main();
}
7. 最佳实践
7.1 安全建议
7.1.1 密钥管理
// ❌ 错误示例:硬编码密钥
const client = new OpenAPIClient(
'https://api.example.com',
'MERCHANT_123_abc456',
'SECRET_xyz789uvw456rst123' // 危险!
);
// ✅ 正确示例:从环境变量读取
const client = new OpenAPIClient(
process.env.API_BASE_URL,
process.env.OPEN_APP_KEY,
process.env.OPEN_SECRET_KEY
);
密钥安全原则:
- ✅ 使用环境变量或配置中心管理密钥
- ✅ 不要将密钥提交到代码仓库
- ✅ 定期轮换密钥(建议每3-6个月)
- ✅ 不同环境使用不同的密钥
- ✅ 限制密钥的访问权限
7.1.2 HTTPS通信
// ✅ 生产环境必须使用HTTPS
const client = new OpenAPIClient(
'https://api.example.com', // 使用HTTPS
openAppKey,
openSecretKey
);
通信安全原则:
- ✅ 生产环境强制使用HTTPS
- ✅ 验证SSL证书有效性
- ✅ 避免在URL中传递敏感信息
- ✅ 使用最新的TLS版本(TLS 1.2+)
7.1.3 签名验证
# ✅ 每次请求都生成新的nonce和timestamp
def make_request():
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex # 每次都是新的随机值
# 签名计算...
签名安全原则:
- ✅ 每次请求使用新的nonce(防重放攻击)
- ✅ 检查时间戳有效性(±5分钟)
- ✅ 签名计算在服务端完成,不要在客户端暴露密钥
- ✅ 记录签名失败日志,及时发现异常
7.2 性能优化
7.2.1 Token复用
class TokenManager {
constructor(client, appKey) {
this.client = client;
this.appKey = appKey;
this.tokenCache = null;
this.expireTime = 0;
}
async getToken() {
const now = Date.now() / 1000;
// Token未过期,直接返回
if (this.tokenCache && now < this.expireTime - 60) {
return this.tokenCache;
}
// Token过期或不存在,重新获取
const tokenData = await this.client.getVisitorToken(this.appKey);
this.tokenCache = tokenData.data.access_token;
this.expireTime = now + tokenData.data.expires_in;
return this.tokenCache;
}
}
// 使用示例
const tokenManager = new TokenManager(client, 'app_abc123');
// 多次调用会复用Token
const token1 = await tokenManager.getToken(); // 首次获取
const token2 = await tokenManager.getToken(); // 复用缓存
const token3 = await tokenManager.getToken(); // 复用缓存
Token管理原则:
- ✅ Token有效期内复用,避免频繁请求
- ✅ 提前刷新Token(过期前60秒刷新)
- ✅ 缓存Token到内存或Redis
- ✅ 多进程/多线程共享Token缓存
7.2.2 连接池复用
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
class OpenAPIClient:
def __init__(self, base_url, open_app_key, open_secret_key):
self.base_url = base_url
self.open_app_key = open_app_key
self.open_secret_key = open_secret_key
# 配置连接池和重试策略
self.session = requests.Session()
retry_strategy = Retry(
total=3, # 最多重试3次
backoff_factor=1, # 重试间隔:1s, 2s, 4s
status_forcelist=[500, 502, 503, 504] # 这些状态码自动重试
)
adapter = HTTPAdapter(
pool_connections=10, # 连接池大小
pool_maxsize=20, # 最大连接数
max_retries=retry_strategy
)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
连接优化原则:
- ✅ 使用HTTP连接池,避免频繁建立连接
- ✅ 配置合理的超时时间(连接超时10s,读取超时30s)
- ✅ 实现自动重试机制(5xx错误重试)
- ✅ 使用Keep-Alive保持长连接
7.2.3 批量操作
// ✅ 批量标记消息已读(如果接口支持)
async function markMultipleMessagesRead(messageIds, accessToken) {
const promises = messageIds.map(msgId =>
client.markMessageRead(msgId, accessToken)
);
// 并发执行,但控制并发数量
const results = await Promise.all(promises);
return results;
}
// 控制并发数量的版本
async function markMessagesWithLimit(messageIds, accessToken, limit = 5) {
const results = [];
for (let i = 0; i < messageIds.length; i += limit) {
const batch = messageIds.slice(i, i + limit);
const batchResults = await Promise.all(
batch.map(msgId => client.markMessageRead(msgId, accessToken))
);
results.push(...batchResults);
}
return results;
}
批量操作原则:
- ✅ 合并多个请求,减少网络开销
- ✅ 控制并发数量,避免服务端过载
- ✅ 实现请求队列,平滑流量
- ✅ 使用异步处理,提高吞吐量
7.3 错误处理
7.3.1 错误分类处理
async function callAPIWithErrorHandling(apiFunc) {
try {
const result = await apiFunc();
return { success: true, data: result };
} catch (error) {
// 网络错误
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
console.error('网络连接失败,请检查网络或稍后重试');
return { success: false, error: 'NETWORK_ERROR', retry: true };
}
// HTTP错误
if (error.response) {
const { status, data } = error.response;
// 401 未授权:Token过期或签名错误
if (status === 401) {
console.error('认证失败:', data.msg);
// 清除Token缓存,重新获取
tokenManager.clearToken();
return { success: false, error: 'AUTH_ERROR', retry: true };
}
// 403 禁止访问:跨商户访问等
if (status === 403) {
console.error('禁止访问:', data.msg);
return { success: false, error: 'FORBIDDEN', retry: false };
}
// 500 服务器错误
if (status >= 500) {
console.error('服务器错误:', data.msg);
return { success: false, error: 'SERVER_ERROR', retry: true };
}
}
// 业务错误
if (error.response && error.response.data) {
const { code, msg } = error.response.data;
console.error(`业务错误[${code}]: ${msg}`);
return { success: false, error: 'BUSINESS_ERROR', code, msg };
}
// 未知错误
console.error('未知错误:', error);
return { success: false, error: 'UNKNOWN_ERROR' };
}
}
// 使用示例
const result = await callAPIWithErrorHandling(async () => {
return await client.getCommonQuestions(accessToken);
});
if (result.success) {
console.log('成功:', result.data);
} else if (result.retry) {
console.log('错误可重试:', result.error);
// 实现重试逻辑...
} else {
console.log('错误不可重试:', result.error);
}
7.3.2 自动重试机制
import time
from functools import wraps
def retry_on_error(max_retries=3, delay=1, backoff=2):
"""错误重试装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
retries = 0
current_delay = delay
while retries < max_retries:
try:
return func(*args, **kwargs)
except Exception as e:
retries += 1
# 判断是否可重试
if not is_retryable_error(e):
raise
if retries >= max_retries:
print(f"重试{max_retries}次后仍失败")
raise
print(f"请求失败,{current_delay}秒后重试({retries}/{max_retries})")
time.sleep(current_delay)
current_delay *= backoff # 指数退避
return wrapper
return decorator
def is_retryable_error(error):
"""判断错误是否可重试"""
# 网络错误可重试
if isinstance(error, (ConnectionError, TimeoutError)):
return True
# HTTP 5xx错误可重试
if hasattr(error, 'response') and error.response:
status = error.response.status_code
if 500 <= status < 600:
return True
# 401错误(Token过期)可重试
if hasattr(error, 'response') and error.response:
if error.response.status_code == 401:
return True
return False
# 使用示例
@retry_on_error(max_retries=3, delay=1, backoff=2)
def call_api():
return client.get_common_questions(access_token)
# 调用会自动重试
result = call_api()
重试策略原则:
- ✅ 只重试可恢复的错误(网络错误、5xx错误、401错误)
- ✅ 使用指数退避算法(1s, 2s, 4s, 8s...)
- ✅ 设置最大重试次数(建议3-5次)
- ✅ 记录重试日志,便于问题排查
7.4 监控与日志
7.4.1 请求日志记录
class OpenAPIClient {
async request(method, url, data, headers) {
const requestId = generateRequestId();
const startTime = Date.now();
// 记录请求日志
console.log(`[${requestId}] 请求开始`, {
method,
url,
headers: this.maskSensitiveData(headers),
body: this.maskSensitiveData(data),
timestamp: new Date().toISOString()
});
try {
const response = await axios({ method, url, data, headers });
const duration = Date.now() - startTime;
// 记录响应日志
console.log(`[${requestId}] 请求成功`, {
status: response.status,
duration: `${duration}ms`,
dataSize: JSON.stringify(response.data).length
});
return response;
} catch (error) {
const duration = Date.now() - startTime;
// 记录错误日志
console.error(`[${requestId}] 请求失败`, {
status: error.response?.status,
duration: `${duration}ms`,
error: error.message,
stack: error.stack
});
throw error;
}
}
maskSensitiveData(data) {
// 脱敏处理:隐藏密钥、Token等敏感信息
const masked = { ...data };
if (masked.open_secret_key) masked.open_secret_key = '***';
if (masked.access_token) masked.access_token = masked.access_token.substring(0, 10) + '...';
return masked;
}
}
日志记录原则:
- ✅ 记录每个请求的关键信息(URL、参数、响应时间)
- ✅ 敏感信息脱敏(密钥、Token等)
- ✅ 使用唯一请求ID关联日志
- ✅ 记录错误堆栈,便于排查问题
- ✅ 使用结构化日志格式(JSON)
7.4.2 性能监控
import time
from functools import wraps
class PerformanceMonitor:
def __init__(self):
self.metrics = {
'total_requests': 0,
'success_requests': 0,
'failed_requests': 0,
'total_duration': 0,
'api_stats': {}
}
def record_request(self, api_name, duration, success):
self.metrics['total_requests'] += 1
self.metrics['total_duration'] += duration
if success:
self.metrics['success_requests'] += 1
else:
self.metrics['failed_requests'] += 1
# 记录各API的统计
if api_name not in self.metrics['api_stats']:
self.metrics['api_stats'][api_name] = {
'count': 0,
'total_duration': 0,
'success': 0,
'failed': 0
}
stats = self.metrics['api_stats'][api_name]
stats['count'] += 1
stats['total_duration'] += duration
if success:
stats['success'] += 1
else:
stats['failed'] += 1
def get_report(self):
total = self.metrics['total_requests']
if total == 0:
return "暂无数据"
avg_duration = self.metrics['total_duration'] / total
success_rate = self.metrics['success_requests'] / total * 100;
report = f"""
性能监控报告
========================
总请求数: {total}
成功请求: {self.metrics['success_requests']}
失败请求: {self.metrics['failed_requests']}
成功率: {success_rate:.2f}%
平均响应时间: {avg_duration:.2f}ms
各API统计:
"""
for api_name, stats in self.metrics['api_stats'].items():
avg = stats['total_duration'] / stats['count']
rate = stats['success'] / stats['count'] * 100
report += f"\n {api_name}:":
report += f"\n 调用次数: {stats['count']}"
report += f"\n 平均耗时: {avg:.2f}ms"
report += f"\n 成功率: {rate:.2f}%"
return report
# 全局监控器
monitor = PerformanceMonitor()
def monitor_performance(api_name):
"""性能监控装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
success = False
try:
result = func(*args, **kwargs)
success = True
return result
finally:
duration = (time.time() - start_time) * 1000 # 毫秒
monitor.record_request(api_name, duration, success)
return wrapper
return decorator
# 使用示例
@monitor_performance('get_visitor_token')
def get_visitor_token(app_key):
return client.get_visitor_token(app_key)
# 定期输出监控报告
print(monitor.get_report())
监控指标建议:
- ✅ 请求总数、成功数、失败数
- ✅ 平均响应时间、P95、P99响应时间
- ✅ 各API的调用频率和成功率
- ✅ 错误类型分布
- ✅ Token刷新频率
8. 常见问题
8.1 认证相关
Q1: 签名验证失败,提示"签名不匹配"?
原因分析:
- 签名算法实现错误
- 参数编码问题(UTF-8)
- 参数排序错误
- 密钥配置错误
解决方案:
// ✅ 正确的签名计算步骤
function generateSign(params, secretKey) {
// 1. 移除sign字段
delete params.sign;
// 2. 按key升序排序
const sortedKeys = Object.keys(params).sort();
// 3. 拼接字符串: key1=value1&key2=value2&key=secretKey
let signStr = sortedKeys
.map(key => `${key}=${params[key]}`)
.join('&') + `&key=${secretKey}`;
console.log('待签名字符串:', signStr);
// 4. MD5加密并转大写
const sign = crypto.createHash('md5')
.update(signStr, 'utf8')
.digest('hex')
.toUpperCase();
return sign;
}
// 调试技巧:打印待签名字符串
console.log('待签名字符串:', signStr);
console.log('计算的签名:', sign);
排查步骤:
1. 对比服务端和客户端的待签名字符串是否一致
2. 检查密钥是否正确(注意首尾空格)
3. 确认时间戳格式(10位秒级时间戳)
4. 验证nonce是否为有效的随机字符串
Q2: 提示"Token已过期"怎么办?
原因:
- access_token有效期为30分钟
- 超时未使用导致过期
解决方案:
class TokenManager {
async getValidToken() {
const now = Date.now() / 1000;
// Token即将过期(提前60秒刷新)
if (!this.token || now >= this.expireTime - 60) {
await this.refreshToken();
}
return this.token;
}
async refreshToken() {
const tokenData = await client.getVisitorToken(appKey);
this.token = tokenData.data.access_token;
this.expireTime = Date.now() / 1000 + tokenData.data.expires_in;
}
}
最佳实践:
- ✅ 提前刷新Token(过期前60秒)
- ✅ 捕获401错误自动重试
- ✅ 使用refresh_token刷新机制
Q3: 提示"Token商户与签名商户不匹配"?
原因:
- 使用了其他商户的Token
- Token和签名来自不同的商户账号
解决方案:
- 确保Token和签名使用同一个商户的凭证
- 检查是否混用了测试环境和生产环境的凭证
// ✅ 正确:使用同一商户凭证
const merchantAClient = new OpenAPIClient(
API_URL,
'MERCHANT_A_KEY',
'MERCHANT_A_SECRET'
);
const tokenA = await merchantAClient.getVisitorToken('app_a');
await merchantAClient.endSession(sessionId, tokenA); // ✅ 正确
// ❌ 错误:跨商户使用Token
const merchantBClient = new OpenAPIClient(
API_URL,
'MERCHANT_B_KEY',
'MERCHANT_B_SECRET'
);
await merchantBClient.endSession(sessionId, tokenA); // ❌ 错误:tokenA是商户A的
8.2 WebSocket相关
Q4: WebSocket连接失败怎么办?
常见原因:
1. Token无效或过期
2. 网络问题
3. 协议错误(ws vs wss)
4. 防火墙拦截
解决方案:
// ✅ 健壮的WebSocket连接
class RobustWebSocket {
connect(url, token) {
// 1. 确保使用正确的协议
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${API_DOMAIN}/api/open/chat/customer/ws?access_token=${token}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('✅ WebSocket连接成功');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onerror = (error) => {
console.error('❌ WebSocket错误:', error);
};
this.ws.onclose = (event) => {
console.log('WebSocket关闭:', event.code, event.reason);
this.reconnect();
};
}
reconnect() {
if (this.reconnectAttempts >= 5) {
console.error('重连失败次数过多,停止重连');
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`${delay}ms后尝试重连(${this.reconnectAttempts}/5)`);
setTimeout(() => {
this.connect(this.url, this.token);
}, delay);
}
}
排查步骤:
1. 检查Token是否有效:console.log(access_token)
2. 检查WebSocket URL格式是否正确
3. 查看浏览器控制台Network标签的WS连接状态
4. 测试网络连通性:ping api.example.com
Q5: WebSocket消息丢失怎么办?
原因:
- 网络波动导致连接断开
- 消息发送时连接未就绪
- 服务端消息积压
解决方案:
class MessageQueue {
constructor(ws) {
this.ws = ws;
this.queue = [];
this.isReady = false;
ws.onopen = () => {
this.isReady = true;
this.flushQueue();
};
ws.onclose = () => {
this.isReady = false;
};
}
send(message) {
if (this.isReady && this.ws.readyState === WebSocket.OPEN) {
// 连接正常,直接发送
this.ws.send(JSON.stringify(message));
} else {
// 连接未就绪,加入队列
this.queue.push(message);
console.log('消息已加入队列,等待连接就绪');
}
}
flushQueue() {
// 连接就绪后,发送队列中的消息
while (this.queue.length > 0) {
const message = this.queue.shift();
this.ws.send(JSON.stringify(message));
}
}
}
// 使用示例
const messageQueue = new MessageQueue(ws);
messageQueue.send({ type: 'text', content: '你好' });
最佳实践:
- ✅ 检查WebSocket连接状态再发送
- ✅ 实现消息队列机制
- ✅ 记录发送失败的消息,待重连后重发
- ✅ 实现消息确认机制(ACK)
8.3 业务相关
Q6: 如何区分匿名访客和登录用户?
说明:
// 场景1:匿名访客(不传user_info)
const anonymousToken = await client.getVisitorToken({
app_key: 'app_abc123',
scene: 'web',
device_type: 'pc'
// 不传user_info
});
// 系统会生成visitor_id,用于跟踪同一设备
// 场景2:登录用户(传user_info)
const userToken = await client.getVisitorToken({
app_key: 'app_abc123',
scene: 'app',
device_type: 'mobile',
user_info: {
user_id: 'customer_001', // ⚠️ 必填
phone: '13800138000', // ⚠️ 必填
name: '张三'
}
});
// 系统会关联真实用户,支持历史查询
区别:
| 特性 | 匿名访客 | 登录用户 |
|---|---|---|
| user_info | 不传 | 必传(user_id+phone) |
| 用户识别 | visitor_id(设备) | user_id(真实用户) |
| 会话历史 | 按设备查询 | 按用户查询 |
| 用户画像 | 不支持 | 支持 |
| CRM集成 | 不支持 | 支持 |
Q7: visitor_id 需要客户端保存吗?
答案: 建议保存
// ✅ 首次获取Token时保存visitor_id
const tokenData = await client.getVisitorToken({ app_key });
const visitorId = tokenData.data.visitor_id;
// 保存到本地存储
localStorage.setItem('visitor_id', visitorId);
// ✅ 下次请求时传递visitor_id
const savedVisitorId = localStorage.getItem('visitor_id');
const newTokenData = await client.getVisitorToken({
app_key,
visitor_id: savedVisitorId // 传递保存的visitor_id
});
好处:
- ✅ 同一设备的会话历史可关联
- ✅ 用户体验更好(保留咨询记录)
- ✅ 数据统计更准确(去重)
Q8: 如何处理会话超时?
场景: 用户长时间未操作,会话可能超时
解决方案:
// 1. 监听会话超时消息
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'session_timeout') {
alert('会话已超时,请重新开始咨询');
// 关闭连接
ws.close();
// 重新获取Token
const newToken = await client.getVisitorToken(appKey);
// 重新建立连接
connectWebSocket(newToken.data.access_token);
}
};
// 2. 实现心跳保活
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // 每30秒发送一次心跳
最佳实践:
- ✅ 监听超时消息,及时提示用户
- ✅ 实现心跳机制,保持连接活跃
- ✅ 记录会话状态,超时后可恢复
Q9: 如何实现多轮对话?
说明: WebSocket连接保持期间,所有消息自动关联到同一会话
// 建立连接后,持续发送消息即可
ws.onopen = () => {
// 第1轮对话
ws.send(JSON.stringify({
type: 'text',
content: '我想咨询产品价格'
}));
// 收到回复后,继续第2轮
setTimeout(() => {
ws.send(JSON.stringify({
type: 'text',
content: '有优惠活动吗?'
}));
}, 5000);
// 第3轮...
};
// 服务端会自动识别同一会话的多轮对话
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('客服回复:', message.content);
// 显示消息...
};
要点:
- ✅ 保持WebSocket连接不断开
- ✅ 多次send()会自动关联到同一会话
- ✅ 不需要手动传递session_id
- ✅ 会话结束时主动调用结束接口
8.4 调试技巧
Q10: 如何调试签名问题?
步骤:
# 1. 打印待签名字符串
function debugSign(params, secretKey) {
delete params.sign;
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys
.map(key => `${key}=${params[key]}`)
.join('&') + `&key=${secretKey}`;
console.log('=== 签名调试信息 ===');
console.log('1. 原始参数:', JSON.stringify(params, null, 2));
console.log('2. 排序后的key:', sortedKeys);
console.log('3. 待签名字符串:', signStr);
const sign = crypto.createHash('md5')
.update(signStr, 'utf8')
.digest('hex')
.toUpperCase();
console.log('4. 计算的签名:', sign);
console.log('==================');
return sign;
}
// 2. 使用在线工具验证
// 访问: https://tool.oschina.net/encrypt (MD5加密)
// 输入待签名字符串,选择MD5,对比结果
// 3. 联系技术支持
// 提供调试信息给技术支持,协助排查
Q11: 如何测试WebSocket连接?
方法1: 使用浏览器控制台
// 在浏览器控制台直接测试
const ws = new WebSocket('wss://api.example.com/api/open/chat/customer/ws?access_token=YOUR_TOKEN');
ws.onopen = () => console.log('✅ 连接成功');
ws.onerror = (err) => console.error('❌ 连接错误:', err);
ws.onmessage = (event) => console.log('📩 收到消息:', event.data);
// 发送测试消息
ws.send(JSON.stringify({ type: 'text', content: '测试消息' }));
方法2: 使用Postman
1. 创建WebSocket Request
2. 输入URL: wss://api.example.com/api/open/chat/customer/ws?access_token=YOUR_TOKEN
3. 点击Connect
4. 发送JSON消息测试
方法3: 使用在线工具
- WebSocket测试工具: http://www.websocket.org/echo.html
- 或使用: https://websocketking.com/
Q12: 生产环境如何排查问题?
排查清单:
## 1. 检查凭证配置
- [ ] open_app_key是否正确
- [ ] open_secret_key是否正确
- [ ] 环境(测试/生产)是否匹配
## 2. 检查网络连通性
- [ ] 能否访问API域名: `curl https://api.example.com`
- [ ] 防火墙是否拦截
- [ ] 是否配置了IP白名单
## 3. 检查日志
- [ ] 查看客户端请求日志
- [ ] 查看服务端错误日志
- [ ] 查看网络层日志(Nginx/LB)
## 4. 对比测试环境
- [ ] 测试环境是否正常
- [ ] 对比测试和生产的差异
- [ ] 切换到测试环境验证
## 5. 联系技术支持
提供以下信息:
- 商户ID或open_app_key
- 接口名称和调用时间
- 完整的错误信息和请求日志
- 可复现的测试代码
附录
A. 签名算法完整实现
A.1 Java版本
import java.security.MessageDigest;
import java.util.*;
public class SignUtils {
public static String generateSign(Map<String, String> params, String secretKey) {
// 1. 移除sign字段
params.remove("sign");
// 2. 按key升序排序
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
// 3. 拼接字符串
StringBuilder sb = new StringBuilder();
for (String key : keys) {
sb.append(key).append("=").append(params.get(key)).append("&");
}
sb.append("key=").append(secretKey);
String signStr = sb.toString();
System.out.println("待签名字符串: " + signStr);
// 4. MD5加密并转大写
return md5(signStr).toUpperCase();
}
private static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] bytes = md.digest(input.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
A.2 PHP版本
<?php
function generateSign($params, $secretKey) {
// 1. 移除sign字段
unset($params['sign']);
// 2. 按key升序排序
ksort($params);
// 3. 拼接字符串
$signStr = '';
foreach ($params as $key => $value) {
$signStr .= $key . '=' . $value . '&';
}
$signStr .= 'key=' . $secretKey;
echo "待签名字符串: " . $signStr . "\n";
// 4. MD5加密并转大写
return strtoupper(md5($signStr));
}
?>
A.3 Python版本
import hashlib
import json
def generate_sign(params, secret_key):
"""生成签名"""
# 1. 移除sign字段
if 'sign' in params:
del params['sign']
# 2. 按key升序排序
sorted_keys = sorted(params.keys())
# 3. 拼接字符串
sign_str = '&'.join([f"{key}={params[key]}" for key in sorted_keys])
sign_str += f"&key={secret_key}"
print(f"待签名字符串: {sign_str}")
# 4. MD5加密并转大写
sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
return sign
B. 状态码对照表
| HTTP状态码 | 业务code | 说明 | 处理建议 |
|---|---|---|---|
| 200 | 200 | 成功 | 正常处理 |
| 401 | 401 | 签名验证失败 | 检查签名算法和密钥 |
| 401 | 402 | Token无效或过期 | 刷新Token |
| 403 | 403 | 跨商户访问 | 检查Token和签名的商户是否一致 |
| 400 | 400 | 参数错误 | 检查请求参数 |
| 404 | 404 | 资源不存在 | 检查URL路径 |
| 500 | 500 | 服务器错误 | 稍后重试或联系技术支持 |
C. 联系我们
如有技术问题,请联系:
- 技术支持邮箱: support@example.com
- 技术支持电话: 400-xxx-xxxx
- 工作时间: 周一至周五 9:00-18:00
提交问题时请提供:
1. 商户ID或open_app_key
2. 接口名称和调用时间
3. 完整的错误信息和请求日志
4. 可复现的测试代码
文档版本: v2.0.0
最后更新: 2026-01-14
维护团队: AI客服系统开发组