# 第三方服务接口

# 调用方式

仅支持通过 https 协议的 post 方式进行调用,不支持 IP 加端口的方式调用

支持的接口形式:

https://example.com

# 签名

如果在配置的时候,开启了签名校验,我们会将签名的 app_id 放到 http 请求的链接中,并使用对应的 token 进行签名,用于校验请求来源合法性。

具体的签名方式为:

str = token + time.Now.Unix + skill_name + intent_name + query
signature = MD5(str)

服务收到请求后,可以重新计算这组参数,对比签名来确认合法性。tokenapp_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"
        }
      }
    ]
  }
}

注意

  1. 你的接口应该在 2 秒内返回;超时异步回复,可以通过另外一个客服消息推送接口实现。
  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