# 第三方服务接口
# 调用方式
仅支持通过 https 协议的 post 方式进行调用,不支持 IP 加端口的方式调用
支持的接口形式:
https://example.com
# 签名
如果在配置的时候,开启了签名校验,我们会将签名的 app_id
放到 http 请求的链接中,并使用对应的 token
进行签名,用于校验请求来源合法性。
具体的签名方式为:
str = token + time.Now.Unix + skill_name + intent_name + query
signature = MD5(str)
服务收到请求后,可以重新计算这组参数,对比签名来确认合法性。token
和 app_id
由对话平台生成并一一对应,需要自行保存,如果发生泄露,可以重置。
# 加密
如果在配置的时候,开启了数据加密,我们会将 body 的数据进行 AES 对称加密后,进行 Base64 编码后进行传递,加密算法为 AES CBC。开启加密后,响应的 body 也需要用同一个秘钥进行对称加密,并进行 Base64 编码。
为了方便展示,AESKey 是 32 位的字节进行 Base64 并去除最后一个=
。所以 AESKey 需要加上=
后,进行 Base64 解码后获得真正的 32 位 Key。
# Golang 示例
// 调用 aes 算法加密并编码
func encrypt(encodingAesKey, msg string) (string, error) {
// aes 加密
encryptedMsg, err := AesCBCEncrypt(encodingAesKey, []byte(msg))
if err != nil {
return "", err
}
// base64 编码
base64Msg := make([]byte, base64.StdEncoding.EncodedLen(len(encryptedMsg)))
base64.StdEncoding.Encode(base64Msg, encryptedMsg)
return string(base64Msg), nil
}
// 解码后调用 aes 算法解密
func decrypt(encodingAesKey, msg string) (string, error) {
// base64 解码
cipherText, err := base64.StdEncoding.DecodeString(msg)
if err != nil {
return "", err
}
// aes 解密
plainText, err := AesCBCDecrypt(encodingAesKey, []byte(cipherText))
if err != nil {
return "", err
}
return string(plainText), nil
}
func AesCBCEncrypt(encodingKey string, plaintextMsg []byte) ([]byte, error) {
block, aesKey, err := decodeAesKey(encodingKey)
if err != nil {
return []byte{}, err
}
plaintextMsg = pkcs5Padding(plaintextMsg, block.BlockSize())
cipherText := make([]byte, len(plaintextMsg))
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(cipherText, plaintextMsg)
return cipherText, nil
}
func AesCBCDecrypt(encodingKey string, encryptedMsg []byte) ([]byte, error) {
block, aesKey, err := decodeAesKey(encodingKey)
if err != nil {
return []byte{}, err
}
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
decryptedMsg := make([]byte, len(encryptedMsg))
mode.CryptBlocks(decryptedMsg, encryptedMsg)
decryptedMsg = pkcs5UnPadding(decryptedMsg)
return decryptedMsg, nil
}
func decodeAesKey(encodingAesKey string) (cipher.Block, []byte, error) {
aesKey, err := base64.StdEncoding.DecodeString(encodingAesKey + "=")
if err != nil {
return nil, aesKey, err
}
block, e := aes.NewCipher(aesKey)
if e != nil {
return nil, aesKey, err
}
return block, aesKey, nil
}
func pkcs5Padding(cipherText []byte, blockSize int) []byte {
padding := blockSize - len(cipherText)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(cipherText, padText...)
}
func pkcs5UnPadding(origData []byte) []byte {
length := len(origData)
unpadding := int(origData[length-1])
return origData[:(length - unpadding)]
}
# JavaScript 示例
const crypto = require('crypto');
//example only, do not recommend hardcode in code base.
const config = {
appid: 'Gg8HejYTkUsEIlG',
token: 'YV78Pyj1VvqdNGpMJ1pHic0bIBOWMv',
encodingAESKey: 'q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz'
}
function pkcs5UnPadding (text) {
let pad = text[text.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return text.slice(0, text.length - pad);
};
function pkcs5Padding (text) {
const blockSize = 32;
const textLength = text.length;
const amountToPad = blockSize - (textLength % blockSize);
const result = Buffer.alloc(amountToPad);
result.fill(amountToPad);
return Buffer.concat([text, result]);
};
function decrypt(text) {
const { AESKey, iv } = getAESKey();
const decipher = crypto.createDecipheriv('aes-256-cbc', AESKey, iv);
decipher.setAutoPadding(false);
const deciphered = Buffer.concat([decipher.update(text, 'base64'), decipher.final()]);
return pkcs5UnPadding(deciphered).toString();
}
function encrypt(text) {
const { AESKey, iv } = getAESKey();
const msg = Buffer.from(text);
const encoded = pkcs5Padding(msg);
var cipher = crypto.createCipheriv('aes-256-cbc', AESKey, iv);
cipher.setAutoPadding(false);
var cipheredMsg = Buffer.concat([cipher.update(encoded), cipher.final()]);
return cipheredMsg.toString('base64');
}
//
function getAESKey() {
const { encodingAESKey } = config;
const AESKey = Buffer.from(encodingAESKey + '=', 'base64');
if (AESKey.length !== 32) {
throw new Error('encodingAESKey invalid');
}
return {
AESKey,
iv: AESKey.slice(0, 16)
}
}
function requestHandler(request) {
//get full body from your http request context
const { body } = request;
/**
* decrypted data
* {
RequestId: '123123456456789789123456789',
SessionId: '12345678901234567_12345678909876543',
Query: '北京限行尾号是多少',
SkillName: '限行',
IntentName: '查限行尾号',
Slots: [
{
SlotName: 'from_loc',
SlotValue: '北京',
NormalizeValue: '{"type":"LOC_CHINA_CITY","city":"北京市","city_simple":"北京","loc_ori":"北京"}'
}
],
Timestamp: 1704135845,
Signature: '96f439043e1f7d2bb38162e35406f173',
ThirdApiId: 1234,
ThirdApiName: '车辆限行',
UserId: '97f7e892'
}
*/
const data = JSON.parse((decrypt(body)));
//your product logic...
return encrypt(JSON.stringify(data));
}
//test only
requestHandler({
body: 'rUWkvTY9vRPOeVDSH/IdNXHmvgsUQtPkp7QtBQjSS1tcuTHGPWv8O3PlxbnsjCogsM7+EY+As4yF2kp4yxXpP2U7RmbDsU/luRO/EqkpFFsoxMZZArz2XH1YeSdnDyHYPWzjiicBYjNiqqpTMX8ekrqooN0cCEH7JBcbEe6btmiK8hZkysKTUJfG1DTpbONxON5+YuVPelVpzW5ry9sRYLDcqhImMb9FqI+BlIVAIXt5g+e70rheSqpeXz98pEROx7yPeRi3tXPAibuwg+vKDhoN6LuM0hzvyNzPjwK2gMmQB5yVuBZUalYIIZTVaMNGu4H6RK6MovLyM2cKfMUTphKaBBKpAvsV0o4/QRY0MvxeRYvZAQXEzOG3dJ7BRB2KEqBKttT7jMK8MO5HEXDE0CJxtNI4Rjww9XYmPhBM7lOZSF97YNEg1NhwcXvUc3YcrR334PhWJeu2dZCHaJzBqVXFxq/WprNHM0Gw06o6p5oWb4/nzXKYbpJWDyqTN/aztwo5sppHwlYrzNzF7gERP691qoabTHiCd0H+Ea3t65gTyNo2+ssvS1RVsKubApS4BkbZb/EaZCTKP20pcvDBoJk3QLi8ObyBq8sIcLwVjzelLMUgCDa059gBuao+S9qdHXebEZyS49BqAxngMWjHU5uCRO/x2b9w8nwfCCT8b0Q='
});
# Java 示例
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
class AESUtil {
public static Base64.Encoder encoder = Base64.getEncoder();
public static Base64.Decoder decoder = Base64.getDecoder();
public static final String aesKey = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz=";// 注意平台分配的 key 需要加一个=结尾
// 将 json 字符串 str,用 aesKey 加密,然后 Base64
public static String encrypt(String str) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
byte[] secretKey = decoder.decode(aesKey.getBytes());
SecretKey keySpec = new SecretKeySpec(secretKey, "AES");
byte[] iv = new byte[16];
System.arraycopy(secretKey, 0, iv, 0, 16);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv));
byte[] aesData = cipher.doFinal(str.getBytes());
return new String(encoder.encode(aesData), "UTF-8");
}
// 将 Base64 后的加密 str,解开并解密,得到 json 字符串
public static String decrypt(String str) throws Exception {
byte[] secretKey = decoder.decode(aesKey.getBytes());
byte[] iv = new byte[16];
System.arraycopy(secretKey, 0, iv, 0, 16);
byte[] data = decoder.decode(str.getBytes());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKey keySpec = new SecretKeySpec(secretKey, "AES");
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
return new String(cipher.doFinal(data), "UTF-8");
}
}
public class Main {
public static void main(String[] args) throws Exception {
String resp = "{\n" +
" \"answer_type\": \"text\",\n" +
" \"text_info\": {\n" +
" \"short_answer\": \"answer\"\n" +
" }\n" +
"}\n";
String aesResp = AESUtil.encrypt(resp);
System.out.println(aesResp);
String body = AESUtil.decrypt(aesResp);
System.out.println(body);
String demo = AESUtil.decrypt("rUWkvTY9vRPOeVDSH/IdNXHmvgsUQtPkp7QtBQjSS1tcuTHGPWv8O3PlxbnsjCogsM7+EY+As4yF2kp4yxXpP2U7RmbDsU/luRO/EqkpFFsoxMZZArz2XH1YeSdnDyHYPWzjiicBYjNiqqpTMX8ekrqooN0cCEH7JBcbEe6btmiK8hZkysKTUJfG1DTpbONxON5+YuVPelVpzW5ry9sRYLDcqhImMb9FqI+BlIVAIXt5g+e70rheSqpeXz98pEROx7yPeRi3tXPAibuwg+vKDhoN6LuM0hzvyNzPjwK2gMmQB5yVuBZUalYIIZTVaMNGu4H6RK6MovLyM2cKfMUTphKaBBKpAvsV0o4/QRY0MvxeRYvZAQXEzOG3dJ7BRB2KEqBKttT7jMK8MO5HEXDE0CJxtNI4Rjww9XYmPhBM7lOZSF97YNEg1NhwcXvUc3YcrR334PhWJeu2dZCHaJzBqVXFxq/WprNHM0Gw06o6p5oWb4/nzXKYbpJWDyqTN/aztwo5sppHwlYrzNzF7gERP691qoabTHiCd0H+Ea3t65gTyNo2+ssvS1RVsKubApS4BkbZb/EaZCTKP20pcvDBoJk3QLi8ObyBq8sIcLwVjzelLMUgCDa059gBuao+S9qdHXebEZyS49BqAxngMWjHU5uCRO/x2b9w8nwfCCT8b0Q=");
System.out.println(demo);
}
}
# 输入
# 请求类型
POST
# 请求参数
# query
在 url query 中,会明文携带如下参数:
app_id
如果有需要传入其他参数来区别请求来源、或实现其他需要解密数据前实现的逻辑,可自行在 url 中配置。
示例:
用户配置接口为 https://api.example.com/app
,服务器实际收到的 url https://api.example.com/app?app_id=Gg8HejYTkUsEIlG
用户配置的接口中携带自定义参数: https://api.example.com/app?bot_id=123abc&key=value
,服务器实际收到的 url https://api.example.com/app?bot_id=123abc&key=value&app_id=Gg8HejYTkUsEIlG
# body
在 body 中,会加密传入如下数据:
type XwBrainThirdApiReqInfo struct {
RequestId string
SessionId string
Query string
SkillName string
IntentName string
Slots []*MatchSlotInfo
Timestamp int64
Signature string
ThirdApiId uint64
ThirdApiName string
UserId string
}
type MatchSlotInfo struct {
SlotName string
SlotValue string
NormalizeValue string
}
json 示例:
{
"RequestId": "123123456456789789123456789",
"SessionId": "12345678901234567_12345678909876543",
"Query": "北京限行尾号是多少",
"SkillName": "限行",
"IntentName": "查限行尾号",
"Slots": [
{
"SlotName": "from_loc",
"SlotValue": "北京",
"NormalizeValue": "{\"type\":\"LOC_CHINA_CITY\",\"city\":\"北京市\",\"city_simple\":\"北京\",\"loc_ori\":\"北京\"}"
}
],
"Timestamp": 1704135845,
"Signature": "96f439043e1f7d2bb38162e35406f173",
"ThirdApiId": 1234,
"ThirdApiName": "车辆限行",
"UserId": "97f7e892"
}
字段说明:
字段 | 说明 |
---|---|
RequestId | 请求唯一标识 |
SessionId | 请求 session id |
Query | 用户提问 |
SkillName | 技能名 |
IntentName | 意图名 |
Slots | 语意槽数据 |
Timestamp | Unix 时间戳 |
Signature | 签名 |
ThirdApiId | 第三方服务接口 id |
ThirdApiName | 服务接口名称 |
UserId | 用户标识 |
Slots
字段说明:
字段 | 说明 |
---|---|
SlotName | 语意槽名 |
SlotValue | 语意槽值 |
NormalizeValue | Norm 后的语意槽值 |
# 输出
服务接口处理完请求之后,返回的 body 数据应为 json 格式,如果选择加密,则必须是这个 json 数据加密之后的 base64 形式。同时 json 数据必须符合如下规定格式之一:
# 1. 文本
当需要返回简单的文本数据时,使用下面的数据格式:
{
"answer_type": "text",
"text_info": {
"short_answer": "answer"
}
}
# 2. 复合消息
支持一次性返回最多 3 条消息。
{
"answer_type": "complex",
"complex_info": {
"view_type": "multi",
"multi": [
{
"view_type": "text",
"text_info": {
"short_answer": "answer 1"
}
},
{
"view_type": "text",
"text_info": {
"short_answer": "answer 2"
}
},
{
"view_type": "text",
"text_info": {
"short_answer": "answer 3"
}
}
]
}
}
注意:
- 你的接口应该在 2 秒内返回;超时异步回复,可以通过另外一个客服消息推送接口实现。
- 接口返回的数据不应大于 2M。
# 示例
- appid:
Gg8HejYTkUsEIlG
- token:
YV78Pyj1VvqdNGpMJ1pHic0bIBOWMv
- aeskey:
q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz
- url:
http://example.com/
- method:
POST
假如命中了意图:
- SkillName: 限行
- IntentName: 查限行尾号
这时候你的接口 http://example.com 将收到:
http://example.com?app_id=Gg8HejYTkUsEIlG
body 数据为
rUWkvTY9vRPOeVDSH/IdNXHmvgsUQtPkp7QtBQjSS1tcuTHGPWv8O3PlxbnsjCogsM7+EY+As4yF2kp4yxXpP2U7RmbDsU/luRO/EqkpFFsoxMZZArz2XH1YeSdnDyHYPWzjiicBYjNiqqpTMX8ekrqooN0cCEH7JBcbEe6btmiK8hZkysKTUJfG1DTpbONxON5+YuVPelVpzW5ry9sRYLDcqhImMb9FqI+BlIVAIXt5g+e70rheSqpeXz98pEROx7yPeRi3tXPAibuwg+vKDhoN6LuM0hzvyNzPjwK2gMmQB5yVuBZUalYIIZTVaMNGu4H6RK6MovLyM2cKfMUTphKaBBKpAvsV0o4/QRY0MvxeRYvZAQXEzOG3dJ7BRB2KEqBKttT7jMK8MO5HEXDE0CJxtNI4Rjww9XYmPhBM7lOZSF97YNEg1NhwcXvUc3YcrR334PhWJeu2dZCHaJzBqVXFxq/WprNHM0Gw06o6p5oWb4/nzXKYbpJWDyqTN/aztwo5sppHwlYrzNzF7gERP691qoabTHiCd0H+Ea3t65gTyNo2+ssvS1RVsKubApS4BkbZb/EaZCTKP20pcvDBoJk3QLi8ObyBq8sIcLwVjzelLMUgCDa059gBuao+S9qdHXebEZyS49BqAxngMWjHU5uCRO/x2b9w8nwfCCT8b0Q=
body 数据通过 base64 反解并解密后为:
{
"RequestId": "123123456456789789123456789",
"SessionId": "12345678901234567_12345678909876543",
"Query": "北京限行尾号是多少",
"SkillName": "限行",
"IntentName": "查限行尾号",
"Slots": [
{
"SlotName": "from_loc",
"SlotValue": "北京",
"NormalizeValue": "{\"type\":\"LOC_CHINA_CITY\",\"city\":\"北京市\",\"city_simple\":\"北京\",\"loc_ori\":\"北京\"}"
}
],
"Timestamp": 1704135845,
"Signature": "96f439043e1f7d2bb38162e35406f173",
"ThirdApiId": 1234,
"ThirdApiName": "车辆限行",
"UserId": "97f7e892"
}
签名数据由 md5(“YV78Pyj1VvqdNGpMJ1pHic0bIBOWMv1704135845限行查限行尾号北京限行尾号是多少”)
生成,为 96f439043e1f7d2bb38162e35406f173