CRM外部数据接入指南

前置说明

本文档仅供开通CRM的企业,将外部数据传入CRM而使用。要使用此功能,需要联系客户经理开通配置相应的密钥。

接入说明

接口接入域名:https://api.xiaolanben.com

接口功能开通后获得签名密钥,每次调用接口时需要传入签名信息,签名参数如下:

参数参数名传输方式备注
h_t签名生成的时间戳query如:当前时间为2024-01-01 12:30:35.345时,应精确毫秒
则该值为1704083435345
注意:该值必须为调用接口时生成,接口服务器端限制10秒内有效
h_sign请求签名结果query数据签名结果字符串;
注意:同一签名数据10秒内不允许重复调用,防止数据重放

签名方式

在接口调用前需要对调用的数据生成签名,并将签名结果传入接口。签名示例代码见文档最后的代码示例

生成待签名字符串

1、请求URL中的query部份数据按key的顺序进行key1=value1&key2=value2的方式进行组装(h_t/h_sign不参与组装),得到待签名字符串A,注意当value为非URLEncoder的原文内容,空字符串时,不需要参与组装

2、如果当前请求为POST/PUT时,获取请求body块中的字符串原文为待签名字符串B

3、将 待签名字符串A、待签名字符串B、h_t、签名密钥 直接连接,生成待签名完整字符串

签名

将待签名完整字符串,直接md5获取到32为的签名结果串,并将该结果传入至h_sign中

错误响应

本文档所有接口如果出错时,系统将直接响应非2XX的http响应码,具体错误原因可在响应body中获取,如:

{
  "trackingId": "12b505a5-ff57-43ac-9571-5a1993a49c9e",
  "errors": [
    {
      "code": 403,
      "message": "请求不允许重复执行"
    }
  ]
}
http statuscodemessage
403403签名参数错误等原因,如:
请求未包含签名信息
请求不允许重复执行
401401签名错误或未开通接口,如:
请求未经过认证
400400请求参数错误,如:
有必传参数未传,或参数类型错误等
500500服务端异常错误

接口

单客户及联系人导入接口

接口说明

该接口仅用于每次导入一个客户及其联系人信息

接口路径:/blue-crm/api/v1/open/customer/import/customerWithContact

调用方式:POST application/json; charset=utf-8 body块数据使用json进行组装

请求参数

参数名类型位置备注
companyIdLongheader必传
当前企业的标识ID,开通时由客户经理提供
不参与签名
checkKeepLimitBoolquery可空
当传入true,如果请求体中指定了staffId时,用于判断是否校验该员工的客户保有量。
默认为true
ownerConflictUpdateBoolquery可空
当传入true,如果请求体中指定了staffId时,请求的客户在库中归属于其它人时,是否强制更新客户信息(不会更新归属人信息)
默认为false
autoUnlockBoolquery可空
当传入true,如果添加的客户匹配到企业的时候,是否自动解锁该企业。
默认为false
autoDrawBoolquery可空
当传入true,并且如果指定了staffId,及库中已有客户在公海中,则自动领取该客户
默认false
duplicateControlStringquery可空
客户入库时查重检测控制
SYSTEM :系统配置查重规则,如果命中则直接响应失败
CUSTOMER_PHONE_DUPLICATE_FAIL :客户归属与手机号归属冲突时直接响应失败
CUSTOMER_PHONE_DUPLICATE_IGNORE :客户归属与手机号归属冲突时则忽略该手机号联系人
默认为CRM系统查重控制逻辑
entNameStringbody可空
企业名称 长度介于0~200
用于标识该客户对应企业的唯一性,如未传入则使用customerName
如客户之前不存在,则会新建客户
如指定了staffId,则会归属到该员工下的私海
如指定了publicSeaId
同时指定了staffId,则该客户在掉保时会掉入该公海
否则该客户新建时,直接进入至该公海
如客户之前已存在,则
该客户是公海客户或指定的staffId客户,则会更新该客户信息
否则直接报错
customerNameStringbody必传
客户名称 长度介于1~200
如同时指定了entName,并该entName记录已经存在,则会更新该客户的客户名称字段
staffIdLongbody可空
客户归属人ID
员工标识参考其它接口(待提供)
如指定该值,则
如客户之前不存在,则新建客户并分配给该员工
如客户之前已存在,则该值自动无效
customerSourceStringbody可空
客户来源
来源列表参考其它接口(待提供)
如之前客户已存在,则该值自动无效
publicSeaIdStringbody可空
客户公海标识
如客户已经存在,则该值自动无效
tagStringbody可空
客户分组
如之前未创建该分组,则系统会自动创建
customizeField.xxxxbody可空
自定义字段设置,见 字段标识
如果有多个自定义字段的,输入多个字段
tagIdsList<String>body可空
客户自定义标签
自定义标签参见其它接口(待提供)
如果提供了该值,则会自动保存或更新客户的自定义标签
contactsListbody可空
客户联系人列表
如果是是更新客户及联系人的,则会更新原phone的记录
–thirdDataIdString必传
本次传输时联系人数据唯一标识
用于响应体生成时返回联系人的ID
–nameString可空
联系人姓名 长度<=30
–positionString可空
联系人职位 长度<=30
–deptString可空
联系人部门 长度<=30
–phoneString可空
联系人手机号 长度<=30
如果新建客户时,系统会按phone查询客户归属人,如果有则会将客户归属至该员工
如果更新客户添加联系人时,会查询该phone归属人,如果有冲突则会直接响应失败
–telString可空
联系人电话 长度<=30
–emailString可空
联系人邮箱 长度<=100
–addrString可空
联系人联系地址 长度<=200
–logoString可空
联系人头像地址 长度<=400
–wechatString可空
联系人微信号 长度<=30
–qqString可空
联系人QQ 长度<=30
–genderString可空
联系人性别 男/女/未知;长度<=2
–birthdayString可空
联系人生日 建议为yyyy-MM-dd格式;长度<=10
customizeField.xxxx可空
联系人自定义字段设置,见 字段标识
如果有多个自定义字段的,输入多个字段
–remarkString可空
联系人备注

响应参数

参数名类型备注
customerIdLong客户ID
staffIdLong客户归属人ID
publicSeaIdString公海标识
isNewBool是新建/更新客户
contactIdMapMap<String,Long>客户联系人唯一标识与联系人ID对
如果是忽略的联系人,则不会返回

多客户及联系人导入

接口说明

用于一次性导入多个客户及联系人;出于对接口响应速度的考虑,建议每次客户数不要超过10条,每客户联系人不超过10个

接口路径:/blue-crm/api/v1/open/customer/import/customersWithContact

调用方式:POST application/json; charset=utf-8 body块数据使用json进行组装

请求入参

参数名类型位置备注
companyIdLongheader必传
当前企业的标识ID,开通时由客户经理提供
不参与签名
checkKeepLimitBoolquery可空
当传入true,如果请求体中指定了staffId时,用于判断是否校验该员工的客户保有量。
默认为true
ownerConflictUpdateBoolquery可空
当传入true,如果请求体中指定了staffId时,请求的客户在库中归属于其它人时,是否强制更新客户信息(不会更新归属人信息)
默认为false
autoUnlockBoolquery可空
当传入true,如果添加的客户匹配到企业的时候,是否自动解锁该企业。
默认为false
autoDrawBoolquery可空
当传入true,并且如果指定了staffId,及库中已有客户在公海中,则自动领取该客户
默认false
duplicateControlStringquery可空
客户入库时查重检测控制
SYSTEM :系统配置查重规则,如果命中则直接响应失败
CUSTOMER_PHONE_DUPLICATE_FAIL :客户归属与手机号归属冲突时直接响应失败
CUSTOMER_PHONE_DUPLICATE_IGNORE :客户归属与手机号归属冲突时则忽略该手机号联系人
默认为CRM系统查重控制逻辑
entNameStringbody可空
企业名称 长度介于0~200
用于标识该客户对应企业的唯一性,如未传入则使用customerName
如客户之前不存在,则会新建客户
如指定了staffId,则会归属到该员工下的私海
如指定了publicSeaId
同时指定了staffId,则该客户在掉保时会掉入该公海
否则该客户新建时,直接进入至该公海
如客户之前已存在,则
该客户是公海客户或指定的staffId客户,则会更新该客户信息
否则直接报错
Listbody多个客户的数据列表对象,每个客户信息参照下面的请求体
customerNameStringbody必传
客户名称 长度介于1~200
如同时指定了entName,并该entName记录已经存在,则会更新该客户的客户名称字段
staffIdLongbody可空
客户归属人ID
员工标识参考其它接口(待提供)
如指定该值,则
如客户之前不存在,则新建客户并分配给该员工
如客户之前已存在,则该值自动无效
customerSourceStringbody可空
客户来源
来源列表参考其它接口(待提供)
如之前客户已存在,则该值自动无效
publicSeaIdStringbody可空
客户公海标识
如客户已经存在,则该值自动无效
tagStringbody可空
客户分组
如之前未创建该分组,则系统会自动创建
customizeField.xxxxbody可空
自定义字段设置,见 字段标识
如果有多个自定义字段的,输入多个字段
tagIdsList<String>body可空
客户自定义标签
自定义标签参见其它接口(待提供)
如果提供了该值,则会自动保存或更新客户的自定义标签
contactsListbody可空
客户联系人列表
如果是是更新客户及联系人的,则会更新原phone的记录
–thirdDataIdString必传
本次传输时联系人数据唯一标识
用于响应体生成时返回联系人的ID
–nameString可空
联系人姓名 长度<=30
–positionString可空
联系人职位 长度<=30
–deptString可空
联系人部门 长度<=30
–phoneString可空
联系人手机号 长度<=30
如果新建客户时,系统会按phone查询客户归属人,如果有则会将客户归属至该员工
如果更新客户添加联系人时,会查询该phone归属人,如果有冲突则会直接响应失败
–telString可空
联系人电话 长度<=30
–emailString可空
联系人邮箱 长度<=100
–addrString可空
联系人联系地址 长度<=200
–logoString可空
联系人头像地址 长度<=400
–wechatString可空
联系人微信号 长度<=30
–qqString可空
联系人QQ 长度<=30
–genderString可空
联系人性别 男/女/未知;长度<=2
–birthdayString可空
联系人生日 建议为yyyy-MM-dd格式;长度<=10
customizeField.xxxx可空
联系人自定义字段设置,见 字段标识
如果有多个自定义字段的,输入多个字段
–remarkString可空
联系人备注

响应参数

参数名类型备注
Map响应客户名与入库结果响应对
{“客户名”:{“success”:true,”customerId”:xxx…}}
successBool导入是否成功,如果false则原因见failedReason
customerIdLong客户ID
staffIdLong客户归属人ID
publicSeaIdString公海标识
isNewBool是新建/更新客户
contactIdMapMap<String,Long>客户联系人唯一标识与联系人ID对
如果是忽略的联系人,则不会返回
failedReasonString失败原因

分页获取员工信息

该接口仅用于获取部门员工列表

接口路径:/blue-crm/api/v1/open/companycenter/staffs

调用方式:GET

请求入参

参数名类型位置备注
companyIdLongheader必传
当前企业的标识ID,开通时由客户经理提供
不参与签名
pageIndexIntquery必传
请求页码,从1开始
pageSizeIntquery必传
请求每页大小,介于2~100之间
searchStringquery非必传
按姓名搜索(包含)/按手机号搜索完全一至

响应参数

参数名类型备注
List响应为数据列表
如请求是10,但响应体小于10,则表示没有下一页数据
–staffIdLong员工ID
–realNameString员工姓名
–mobileString员工手机号
–departmentString所属部门
–roleIdLong角色标识
–roleString角色名称

示例代码

签名工具类

import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang.StringUtils;

/**
 * 签名验签工具
 */
@Slf4j
public class SignUtil {

    public static final String KEY_SIGN = "h_sign";
    public static final String KEY_T = "h_t";
    private static final List<String> IGNORE_KEYS = Lists.newArrayList(KEY_SIGN, KEY_T);

    /**
     * 生成签名
     *
     * @param key      签名的密钥
     * @param paramMap 请求的URL参数
     * @param body     请求的body,如果是get,则传null
     * @param time     请求发出的时间
     * @return 返回签名
     */
    public static String sign(String key, Map<String, Object> paramMap, String body, long time) {
        String params = getParamStr(paramMap);
        StringBuilder strb = new StringBuilder();
        if (StringUtils.isNotBlank(params)) {
            strb.append(params);
        }
        if (StringUtils.isNotBlank(body)) {
            strb.append(body);
        }
        strb.append(time);
        strb.append(key);
        String str = strb.toString();
        log.info("待生成签名的字符串{}", str);
        return DigestUtils.md5Hex(str);
    }

    /**
     * 生成请求的参数待签名串
     */
    private static String getParamStr(Map<String, Object> paramMap) {
        if (MapUtils.isEmpty(paramMap)) {
            return StringUtils.EMPTY;
        }
        TreeMap<String, Object> treeMap = Maps.newTreeMap();
        treeMap.putAll(paramMap);
        List<String> params = Lists.newArrayList();
        for (Entry<String, Object> entry : treeMap.entrySet()) {
            String key = entry.getKey();
            Object objVal = entry.getValue();
            if (objVal == null) {
                continue;
            }
            List<String> values = Lists.newArrayList();
            if (objVal instanceof String) {
                values.add((String) objVal);
            } else if (objVal.getClass().isArray()) {
                values.addAll(Arrays.asList((String[]) objVal));
            } else {
                values.add(objVal.toString());
            }
            if (IGNORE_KEYS.contains(key) || CollectionUtils.isEmpty(values)) {
                continue;
            }
            String value = values.stream().filter(StringUtils::isNotEmpty).sorted()
                .collect(Collectors.joining(","));
            params.add(key + "=" + value);
        }
        return String.join("&", params);
    }
}

导入客户接口示例

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.net.ssl.HttpsURLConnection;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.junit.Test;

public class CustomerApiTest {

    private final ObjectMapper objectMapper = new ObjectMapper();

//    @Test
    public void importCustomerWithContact() throws IOException {
        Map<String, Object> req = this.getRequestData();
        //导入客户的接口地址
        final String baseUrl = "https://api.xiaolanben.com/blue-crm/api/v1/open/customer/import/customerWithContact";
        final String companyId = "xxx"; //开通后由小蓝本提供的公司id
        final String signKey = "xxxxxx-xxxxxx-xxxxxx"; //开通后由小蓝本提供的签名key
        //导入客户时的请求参数
        Map<String, Object> params = new HashMap<>();
        params.put("checkKeepLimit", "true");
        params.put("autoUnlock", "false");
        params.put("duplicateControl", "CUSTOMER_PHONE_DUPLICATE_FAIL");

        String body = objectMapper.writeValueAsString(req); //将对象转为json字符串 请求的body
        long timestamp = System.currentTimeMillis(); //接口请求时生成的时间戳
        //签名
        String sign = SignUtil.sign(signKey, params, body, timestamp);
        //加签名参数加入到参数中
        params.put("h_t", timestamp);
        params.put("h_sign", sign);
        //组装完成的参数
        String queryString = params.entrySet().stream()
            .filter(it -> Objects.nonNull(it.getValue()) && StringUtils.isNoneEmpty(it.getValue().toString()))
            .map(it -> {
                try {
                    return URLEncoder.encode(it.getKey(), "UTF-8") + "=" + URLEncoder.encode(it.getValue().toString(),
                        "UTF-8");
                } catch (UnsupportedEncodingException e) {
                    throw new RuntimeException(e);
                }
            }).collect(Collectors.joining("&"));

        String fullUrl = baseUrl + "?" + queryString;
        System.out.println(fullUrl);
        URL url = new URL(fullUrl);
        HttpsURLConnection httpsURLConnection = null;
        try {
            httpsURLConnection = (HttpsURLConnection) url.openConnection();
            httpsURLConnection.setRequestMethod("POST");
            httpsURLConnection.setDoOutput(true);
            httpsURLConnection.setRequestProperty("companyId", companyId);
            httpsURLConnection.setRequestProperty("Content-Type", "application/json");
            try (OutputStream outputStream = httpsURLConnection.getOutputStream()) {
                outputStream.write(body.getBytes());
            }
            int statusCode = httpsURLConnection.getResponseCode();
            System.out.println(statusCode);
            System.out.println(httpsURLConnection.getResponseMessage());

            try (InputStream inputStream =
                (statusCode == 200 ? httpsURLConnection.getInputStream() : httpsURLConnection.getErrorStream())) {
                String resp = String.join("", IOUtils.readLines(inputStream, StandardCharsets.UTF_8));
                System.out.println(resp);
            }
        } finally {
            if (httpsURLConnection != null) {
                httpsURLConnection.disconnect();
            }
        }
    }

    private Map<String, Object> getRequestData() {
        Map<String, Object> contact1 = new HashMap<>();
        contact1.put("thirdDataId", "u1");
        contact1.put("name", "联系人1");
        contact1.put("position", "职位1");
        contact1.put("dept", "部门1");
        contact1.put("phone", "13909876543");
        contact1.put("tel", "");
        contact1.put("email", "");
        contact1.put("addr", "");
        contact1.put("logo", "");
        contact1.put("wechat", "");
        contact1.put("qq", "");
        contact1.put("gender", "男");
        contact1.put("birthday", "");
        contact1.put("customizeField.text_2", "测试2");
        contact1.put("remark", "备注");

        Map<String, Object> contact2 = new HashMap<>();
        contact2.put("thirdDataId", "u2");
        contact2.put("name", "联系人2");
        contact2.put("position", "职位2");
        contact2.put("dept", "部门2");
        contact2.put("phone", "13909876544");
        contact2.put("tel", "");
        contact2.put("email", "");
        contact2.put("addr", "");
        contact2.put("logo", "");
        contact2.put("wechat", "");
        contact2.put("qq", "");
        contact2.put("gender", "男");
        contact2.put("birthday", "");
        contact2.put("customizeField.text_2", "测试2");
        contact2.put("remark", "备注");

        Map<String, Object> req = new HashMap<>();
        req.put("staffId", null);
        req.put("entName", "");
        req.put("customerName", "鲍哥2024测试1");
        req.put("customerSource", "");
        req.put("publicSeaId", "");
        req.put("tag", "未分组");
        req.put("customizeField.text_13", "测试13");
        req.put("customizeField.bool_5", true);
        req.put("tagIds", new ArrayList<String>());
        req.put("contacts", new ArrayList<Map<String, Object>>() {{
            add(contact1);
            add(contact2);
        }});
        return req;
    }
}