# 回调事件接入

# 回调配置

  1. 管理员登录到圈量SCRM平台 -> 企业设置 -> API接入
  2. 配置系统事件接收URLTokenEncodingAesKey
    • 系统事件接收URL,用于接收回调事件的地址,回调服务需要支持POST方法
    • Token,由接收方提供,用于计算签名,校验数据是否安全
    • EncodingAesKey(32位),用于消息内容加密(详情见回调示例代码示例

# 回调说明

示例:企业在圈量SCRM后台配置系统事件接收URL为http://test.com/callback。

当用户请求触发回调时,圈量会发送回调消息到http://test.com/callback,请求内容如下:

请求方式: POST

请求地址: http://test.com/callback

接收数据格式:

{
    "app_key": "co23e51cc5cac543a9",
    "token": "123456",
    "nonce": "b005326e823f46ecb0ad1902da32316a",
    "timestamp": "1623068882",
    "encoding_content": "P0+6MsQNLetL0k7aQrzJVANUp2YmZTWbYCMkK7rYCgKv1zwwVa3kWWSiQsHrrq7DUJY+4wi+k/X3LWHODTj+iw==",
    "signature": "f12826d7ff6fb212d6f18e6727b18977"
}

参数说明:

字段 类型 说明
app_key string 圈量SCRM提供的AppKey
token string 圈量SCRM上填写的Token
nonce string 随机字符串
timestamp string 回调时间戳
encoding_content string 加密后的回调内容(解密后是一个json结构的字符串)
signature string 签名

企业收到消息后,需要作如下处理:

  1. 对signature进行校验,确保数据安全性
  2. 解密encoding_content,得到明文的消息结构体
  3. 正确响应本次请求
    • 圈量服务器在五秒内收不到响应会断掉连接
    • 当接收成功后,http返回success(不区分大小写)表示接收ok

# 回调示例

以下demo使用伪代码进行说明

假设在SCRM后台有如下配置

AppKey = co23e51cc5cac543a9
Token = 123456
EncodingAESKey = 949001b2d67745328ffa5320feb1950e

收到来自圈量的回调数据

{
    "app_key": "co23e51cc5cac543a9",
    "token": "123456",
    "nonce": "2f5acc3956c3459a8bafc18a97f6db3c",
    "timestamp": "1623139834",
    "encoding_content": "TDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ==",
    "signature": "7c5775857b111581483998b545502da6"
}
  1. 从回调数据获取相关参数

    app_key = "co23e51cc5cac543a9"
    token = "123456"
    nonce = "2f5acc3956c3459a8bafc18a97f6db3c"
    timestamp = "1623139834"
    encoding_content = "TDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ=="
    
  2. 校验签名

    (1) app_key,token,nonce,timestamp,encoding_content这五个参数按照字典序排序

    1. "123456"
    2. "1623139834"
    3. "2f5acc3956c3459a8bafc18a97f6db3c"
    4. "TDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ=="
    5. "co23e51cc5cac543a9"
    

    (2)拼接成一个字符串

    sort_str = "12345616231398342f5acc3956c3459a8bafc18a97f6db3cTDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ==co23e51cc5cac543a9"
    

    (3) 对该字符串做MD5加密

    md5(sort_str) = "7c5775857b111581483998b545502da6"
    

    (4) 对比接收的signature,发现两者一致,签名校验通过,说明数据没被篡改,是安全的

  3. 解密encoding_content数据

    (1) 先对encoding_content进行base64解码

    msg_encrypt = base64_decode(encoding_content)
    

    (2) 使用EncodingAESKey解密,获取到真实回调数据

    data = aes_decrypt(msg_encrypt, EncodingAESKey) // 具体解密流程请参考代码demo
    

    (3) 解密后得到真实数据

    {"event_type": 40027, "msg":"这是一段测试数据"}
    

# 代码示例

签名生成规则,go示例

package utils

import (
	"crypto/md5"
)

func GenMd5Signature(data []string) string {
    // 1. 字典序排序
	sort.Strings(data) 
    
    // 2. 拼接成一个字符串
	str := ""
	for _, v := range data {
		str += v
	}

    // 3. md5加密
	h := md5.New()
	h.Write([]byte(str))

	return hex.EncodeToString(h.Sum(nil))
}

func main() {
    
    // 1. 接收到圈量数据
    data := `
        {
            "app_key": "co23e51cc5cac543a9",
            "token": "123456",
            "nonce": "2f5acc3956c3459a8bafc18a97f6db3c",
            "timestamp": "1623139834",
            "encoding_content": "TDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ==",
            "signature": "7c5775857b111581483998b545502da6"
        }
    `
    var dataMap = make(map[string]string)
    _ = json.Unmarshal([]byte(data), &dataMap)

    // 2. 从回调中获取数据
    appKey := dataMap["app_key"]
    token := dataMap["token"]
    nonce := dataMap["nonce"]
    timestamp := dataMap["timestamp"]
    encodingContent := dataMap["encoding_content"]
    
    // 生成signature
    sign := GenMd5Signature([]string{
        appKey,
        token,
        nonce,
        timestamp,
        encodingContent,
    })
    
    if sign != dataMap["signature"] {
        // 数据不安全
    }
    
}

签名生成规则,java示例

package utils

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class Md5Tools {
    public static String GenMd5Signature(String[] data) throws NoSuchAlgorithmException {
        // 1. 字典序排序
        Arrays.sort(data);
        
        // 2. 拼接成一个字符串
        StringBuilder encryStr = new StringBuilder();
        for (String v : data) {
            encryStr.append(v);
        }

        // 3. md5加密
        MessageDigest m = MessageDigest.getInstance("MD5");
        m.update(encryStr.toString().getBytes());
        byte[] s = m.digest();
        StringBuilder result = new StringBuilder();

        for (byte b : s) {
            result.append(Integer.toHexString((0x000000FF & b) | 0xFFFFFF00).substring(6));
        }
        return result.toString();
    }
    
    public static void main(String[] args) throws NoSuchAlgorithmException {
        String[] dataArr = new String[5];
        dataArr[0] = "co23e51cc5cac543a9";
        dataArr[1] = "123456";
        dataArr[2] = "1623139834";
        dataArr[3] = "2f5acc3956c3459a8bafc18a97f6db3c";
        dataArr[4] = "TDys3S8Q4/jc1YhppJqcX00bCTJZ0vKTiLsKRvYUHBT6+X/Y3M864fidTzucEBtyFlJv3Gw2r/PWPQVjT0vitQ==";

        String a = GenMd5Signature(dataArr);
        System.out.println(a);
    }
}

encoding_content解密算法,go示例

package utils

import (
     "bytes"
     "crypto/aes"
     "crypto/cipher"
     "encoding/base64"
)

// 解密主流程函数
func Decrypt(encodingContent, encodingAesKey string) (string, error) {
    // 1. base64解码
    base64DeContent, err := base64.StdEncoding.DecodeString(encodingContent)
    if err != nil {
      return "", err
    }

    // 2. aes解密
    deContent := AesDecryptCBC(base64DeContent, []byte(encodingAesKey))
    
    return string(deContent), nil
}

// Aes解密
func AesDecryptCBC(encrypted []byte, key []byte) (decrypted []byte) {
     block, _ := aes.NewCipher(key)                              // 分组秘钥
     blockSize := block.BlockSize()                              // 获取秘钥块的长度
     blockMode := cipher.NewCBCDecrypter(block, key[:blockSize]) // 加密模式
     decrypted = make([]byte, len(encrypted))                    // 创建数组
     blockMode.CryptBlocks(decrypted, encrypted)                 // 解密
     decrypted = pkcs5UnPadding(decrypted)                       // 去除补全码
     return decrypted
}

func pkcs5UnPadding(originData []byte) []byte {
     length := len(originData)
     if length == 0 {
        return nil
     }
     unpadding := int(originData[length-1])
     return originData[:(length - unpadding)]
}

func main() {
    aesKey := "949001b2d67745328ffa5320feb1950e"
    content := "P0+6MsQNLetL0k7aQrzJVANUp2YmZTWbYCMkK7rYCgKv1zwwVa3kWWSiQsHrrq7DUJY+4wi+k/X3LWHODTj+iw=="
    
    decryptedContent, err := Decrypt(content, aesKey)
    if err != nil {
        // 错误处理
    }
    
    // 解密后的数据
    // {"event_type": 40027, "msg":"这是一段测试数据"}
    println(decryptedContent)
    
}

encoding_content解密算法,java示例

public class AesTools {

    public static byte[] str2ByteArr(String hexStr) {
        return hexStr.getBytes();
    }

    public static String DecryptCBC(String sSrc, String encodingAESKey) {
        try {
            int base = 16;
            byte[] raw = str2ByteArr(encodingAESKey);
            byte[] ivRaw = new byte[base];
            System.arraycopy(raw, 0, ivRaw, 0, base);

            SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            IvParameterSpec iv = new IvParameterSpec(ivRaw);
            cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);

            // 1. 先用base64解密
            byte[] encrypted = Base64.getDecoder().decode(sSrc); 
            // 2. AES解密
            try {
                byte[] original = cipher.doFinal(encrypted);
                return new String(original);
            } catch (Exception e) {
                System.out.println(e.toString());
                return null;
            }
        } catch (Exception ex) {
            System.out.println(ex.toString());
            return null;
        }
    }

    public static void main(String[] args) throws Exception {
        // 后台配置的aes key
        String encodingAESKey = "588bc7cfb5a34507ba132cc75b6df005";
        String content = "Kuf/0j1nvhFMxVGadSBd6E9xgZqvEvAOA1BuO514GUJK/GjnfzccnUP0c0VZ/T5E6E+1ASiLqc8KTEYqoUsKqVBLUtCC1VW/qKY46snL/uGCI3VWP86Uk74q3EoMzzBHSWZXqxOACoNi+ETnktWEwbe6lP34nQweuULqrw5d9ft/mlyOEYeRODObLFJhqLdXAQll/335sXrMKCtIQV/Pw+PCncG6/gtEMscdH3T6PnBHrhoM0dPXZtdrCE819h4DaQ+OLnCXHTTpj8ljX/Ap/rOLOfBMOvJjt3Bm1bq14i+3QgpP5nPJxv50YrdE9mX3ksP1UgjbUjZxffQCNsJYob7/W83O6Mo36MwzCse6er8kqHwaBUQlcSc8S7Jly68Te+vZKJ+7C0j57T6/3VYnWoP5VJVQEfHvdaqbTL72QG89W8XuPVz5DttvUv34htX/bFEW813NOoWsrg1piXyXeFNZ9oFshShgcDN7Gymjqz0eC0cH4re934sk3m5LgFrb";

        String a = DecryptCBC(content, encodingAESKey);
        System.out.println(a);
    }
}

encoding_content解密算法,php示例

<?php

$key = "949001b2d67745328ffa5320feb1950e";

$content = "P0+6MsQNLetL0k7aQrzJVANUp2YmZTWbYCMkK7rYCgKv1zwwVa3kWWSiQsHrrq7DUJY+4wi+k/X3LWHODTj+iw==";

function stripPKSC5Padding($source) {
    $num = ord(substr($source, - 1));
    if ($num == 125) {
        return $source;
    }
    return substr($source, 0, -$num);
}

// 加密过程
function encrypt($origData, $key)
{
    $cipher = "AES-256-CBC";
    $length = openssl_cipher_iv_length($cipher);
    $iv = substr($key, 0, $length);
    $ciphertext_raw = openssl_encrypt($origData, $cipher, $key, OPENSSL_RAW_DATA, $iv);
    return base64_encode($ciphertext_raw);
}

// 解密过程
function decrypt($data, $key)
{
    $cipher = "AES-256-CBC";
    $c = base64_decode($data);
    $length = openssl_cipher_iv_length($cipher);
    $iv = substr($key, 0, $length);
    $decodeData = openssl_decrypt($data, $cipher, $key, OPENSSL_ZERO_PADDING, $iv);

    // 替换换行符等
    $ret =  str_replace(["\n"],"",$decodeData);

    return stripPKSC5Padding($ret);
}

print_r(decrypt($content, $key));