能力授权&门店绑定SDK
概述
- 1.通过第三方业务授权,技术服务商可以在取得商家授权后,获取授权范围的商家数据用以完成相关的业务处理或应用开发(如商家门店订单数据,以实现财务入账等)。
- 2.业务授权需要技术服务商发起,或由技术服务商在自己的应用中添加对应功能并由商家操作完成授权行为;业务授权时商家就具体业务的服务场景及授权资源进行选择,授权同意后,技术服务商方能使用API请求到商家相关数据。同时此链路还支持门店绑定功能,通过接入授权绑定webhook,获取绑定信息。
- 3.在第三方业务授 权时,技术服务商需拼装授权URL,每次授权成功后技术服务商会获得授权的商家信息和已经授权的具体解决方案和能力
注意:
- 1.对于无需授权的API,技术服务商无需进行第三方业务授权,即可调用API请求数据;
- 2.本能力仅生活服务应用可以使用;
- 3.授权页面不支持 iframe 内嵌的方式打开。
授权流程
快速接入
第一步:入驻开放平台并申请业务权限
在完成上述申请操作后,技术服务商才可以进行开放平台API的调用。
注:调用UISDK前,应用必须开通解决方案中的商户授权能力、门店管理能力
第二步:业务授权URL拼装
业务授权URL: https://auth.dylk.com/auth-isv/
在URL后加上下表参数并且计算签名:
字段 | 含义 | 是否必须 | 是否参与签名 |
client_key | 服务商应用标示 | 是 | 是 |
timestamp | url开始生效的秒时间戳 | 是 | 是 |
solution_key | 解决方案:
单次请求仅能授权一个解决方案下的能力 | 是 | 是 |
permission_keys | 能力列表,可传递多个: 参考下方附录 “各解决方案能力枚举” 枚举1 、16 必传 | 是 | 是 |
charset | 交互数据的编码 固定值:UTF-8 | 是 | 是 |
extra | 服务商自定义字段,授权完成时,此值会回值给服务商 | 否 | 是 |
sign | 根据参数内容生成的签名 生成方式参考 | 是 | 是 |
out_shop_id | 绑定抖音门店的外部门店ID(不影响授权范围),一次请求只能传一个外部门店id,不可传递多个 | 否 | 是 |
new_host | 固定值:1 如果在微信环境遇到授权链接被封禁无法直接打开的情况,可以拼接new_host=1 来解决,则移动端将跳转至新域名 auth.dylk.com/h5/isv 该字段不参与签名 | 否 | 否 |
授权url示例:
https://auth.dylk.com/auth-isv/?client_key=awxxxxxxxx×tamp=1677686399&sign=xxxxxxxxxxxxxx&solution_key=1&permission_keys=1,16&out_shop_id=shop_id&charset=UTF-8&extra=aaaaaaaaaa
注意:授权URL有效时间为24小时,生成链接之后,请尽快让商家登录授权。
第三步:使用场景
商家通过技术服务商发起的授权URL,用抖音来客账号进行登录。
商家登录成功后,对选定的门店进行绑定,并完成业务授权操作。
第四步:获取已授权的商户信息、授权详情、门店绑定信息
通过此方式完成授权后,抖音将向服务商推送Webhook消息
该消息在商家发起授权-服务商统一授权的链路中不会发送,只会用于新商服关系链路授权后,向服务商推送授权了的解决方案。
- •对应申请能力
商户授权
- •请求头字段
字段 | 说明 |
Msg-Id | 同一实体下同一action的msg_id相同,服务商可根据msg_id对于消息去重 |
X-Douyin-Signature | 抖音侧签名,服务商可根据签名判断该消息是否来自抖音开放平台 |
Content-Type | 固定值application/json |
消息可能重复推送,请使用Msg_Id进行去重处理!
- •请求体字段
字段 | 类型 | 说明 |
event | string | 消息类型,用于区分各类消息 |
client_key | string | 对应服务商平台或开发者平台中的APPID,应用ID |
content | string | 消息内容,根据需要解析消息内容,不同类型的消息内容不同 |
log_id | string | 抖音内部日志id,可提供给抖音方便排查问题 |
- •event:life_saas_cooperate_auth_with_bind
- •content内容
字段 | 类型 | 说明 | 是否必传 |
account_id | string | 商家id | 是 |
solution_key | string | 解决方案key | 是 |
permission_keys | list<string> | 新增授权的能力列表 | 是 |
out_shop_id | string | 外部门店id(门店绑定场景返回) | 否 |
poi_id | string | 抖音门店Id(门店绑定场景返回) | 否 |
extra | string | 服务商链接中的参数原样透传回去 | 否 看服务商是否传 |
请求示例:
{ "event": "life_saas_cooperate_auth_with_bind", "client_key": "axxxxxxxxxxxxx", "content": "{\"account_id\": \"7187258584315758632\", \"solution_key\": \"1\",\"permission_key\":[\"1\",\"16\"], \"out_shop_id\":\"out_id_1\", \"poi_id\":\"7264432090391775270\",\"extra\":\"123\"}", "log_id": "202210101930530102281180650970B5AF" }
附录:
商服授权Url生成SDK
各sdk调用GenAuthWithBindValidUrlFast即可快速生成可用url,或者调用GenAuthWithBindValidUrl指定各参数的内容,也可调用SignUtil中的SignV2只获取签名内容。
Golang
auth_url.go
package open import ( "life_service/life_open_sdk/sign" urll "net/url" "strconv" "strings" "time" ) const AuthWithBindUrl string = "https://auth.dylk.com/auth-isv/" // 请求应用ck TODO 更换成开发者自己的ck const ClientKey string = "" // clientKey对应的secret 切勿泄漏 TODO 更换成开发者自己的secret const ClientSecret string = "" func GenAuthWithBindValidUrlFast(solutionKey string, outShopId string,extra string, permissionKeys []string, ) (result string, err error) { dto := &GenAuthWithBindValidUrlDto{ Timestamp: strconv.FormatInt(time.Now().Unix(), 10), Extra: extra, SolutionKey: solutionKey, OutShopId: outShopId, PermissionKey: permissionKeys, } return GenAuthValidUrl(dto) } // GenAuthValidUrl 生成一个合法的签名url func GenAuthWithBindValidUrl(dto *GenAuthValidUrlDto) (result string, err error) { parsedUrl, err := urll.Parse(AuthUrl) if err != nil { return "", err } query := map[string]string{} query["client_key"] = ClientKey query["timestamp"] = dto.Timestamp query["charset"] = "UTF-8" query["solution_key"] = dto.SolutionKey query["permission_keys"] = strings.Join(dto.PermissionKeys, ",") query["out_shop_id"] = dto.OutShopId if dto.Extra != "" { query["extra"] = dto.Extra } signResult := sign.SignV2(ClientSecret, "", query) // set final url params parsedQuery := parsedUrl.Query() parsedQuery.Add("sign", signResult) for k, v := range query { parsedQuery.Add(k, v) } parsedUrl.RawQuery = parsedQuery.Encode() return parsedUrl.String(), nil } type GenAuthWithBindValidUrlDto struct { // url开始生效时间 Timestamp string // 服务商自定义字符串,长度不可超过1000字节 Extra string // 解决方案 具体值参照 SolutionKey string // 授权能力列表 PermissionKeys []string // 外部门店ID OutShopId string }
sign.go
package sign import ( "crypto/md5" "crypto/sha256" "fmt" "sort" ) // SignV2 用于生活服务应用计算header中的签名(sha256) 新申请应用请使用该方法计算 func SignV2(clientSecret, body string, query map[string]string) string { keys := make([]string, 0) for k, _ := range query { if k == "sign" { continue } keys = append(keys, k) } sort.Sort(stringSlice(keys)) str := clientSecret for _, k := range keys { val := query[k] str = fmt.Sprintf("%s&%s", str, fmt.Sprintf("%s=%s", k, val)) } if body != "" { str = fmt.Sprintf("%s&http_body=%s", str, body) } result := Sha256(str) fmt.Printf("[Sign] v2 str:%s, result:%s\n", str, result) return result } // Sign 用于生活服务网站应用计算url签名(md5) 不再使用 func Sign(clientSecret, body string, query map[string]string) string { keys := make([]string, 0) for k, _ := range query { if k == "sign" { continue } keys = append(keys, k) } sort.Sort(stringSlice(keys)) str := clientSecret for _, k := range keys { val := query[k] str = fmt.Sprintf("%s&%s", str, fmt.Sprintf("%s=%s", k, val)) } if body != "" { str = fmt.Sprintf("%s&http_body=%s", str, body) } fmt.Printf("[Sign] str:%s\n", str) return MD5(str) } func MD5(str string) string { data := []byte(str) // 切片 hash := md5.Sum(data) md5str := fmt.Sprintf("%x", hash) // 将[]byte转成16进制 return md5str } func Sha256(str string) string { sum256 := sha256.Sum256([]byte(str)) shaStr := fmt.Sprintf("%x", sum256) // 将[]byte转成16进制 return shaStr } type stringSlice []string func (p stringSlice) Len() int { return len(p) } func (p stringSlice) Less(i, j int) bool { return p[i] < p[j] } func (p stringSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
Java
GenAuthWithBindUrlUtil.java
package com.douyin.open.goodlife; import java.util.*; import java.util.stream.Collectors; public class GenAuthWithBindUrlUtil { private final static String AuthUrl = "https://auth.dylk.com/auth-isv/"; /** * 请求应用ck TODO 更换成开发者自己的ck */ private final static String ClientKey = ""; /** * clientKey对应的secret 切勿泄漏 TODO 更换成开发者自己的secret */ private final static String ClientSecret = ""; public static String GenAuthWithBindValidUrlFast(List<String> permissionKeys, String outShopId, String solutionKey, String extra) { GenAuthWithBindValidUrlDto dto = new GenAuthWithBindValidUrlDto(); dto.setTimestamp(String.valueOf(System.currentTimeMillis() / 1000)); dto.setSolutionKey(solutionKey); dto.setExtra(extra); dto.setPermissionKeys(permissionKeys); dto.setOutShopId(outShopId); return GenAuthWithBindValidUrl(dto); } /** * 生成一个合法的签名url * * @param dto * @return */ public static String GenAuthWithBindValidUrl(GenAuthWithBindValidUrlDto dto) { Map<String, String> query = new HashMap<>(); query.put("client_key", ClientKey); query.put("timestamp", dto.getTimestamp()); query.put("charset", "UTF-8"); query.put("solution_key", dto.getSolutionKey()); query.put("out_shop_id", dto.getOutShopId()); query.put("permission_keys", String.join(",", dto.getPermissionKeys())); if (dto.getExtra() != null && dto.getExtra() != "") { query.put("extra", dto.getExtra()); } String signResult = SignUtil.SignV2(ClientSecret, "", query); query.put("sign", signResult); String queryStr = query.entrySet().stream() .map(entry -> new StringBuilder().append(entry.getKey()).append("=").append(entry.getValue())) .collect(Collectors.joining("&")); StringBuilder resultSb = new StringBuilder(AuthUrl).append("?").append(queryStr); return resultSb.toString(); } }
SignUtil.java
package com.douyin.open.goodlife; import java.util.Map; public class SignUtil { /** * SignV2 用于生活服务应用计算header中的签名(sha256) 新申请应用请使用该方法计算 *@param clientSecret 应用secret * @param body post请求/spi请求中的body参数json字符串 * @param query url参数 * @return 签名 */ public static String SignV2(String clientSecret, String body, Map<String, String> query) { StringBuilder str = new StringBuilder(clientSecret); query.keySet().stream() .filter(a -> !"sign".equals(a)) .sorted() .forEach(k -> { String val = query.get(k); str.append("&"); str.append(k).append("=").append(val); }); if (body != null && !"".equals(body)) { str.append("&"); str.append("body").append("=").append(body); } String result = EncryptUtil.SHA256(str.toString()); System.out.printf("[Sign] v2 str:%s, result:%s\n", str.toString(), result); return result; } }
EncryptUtil.java
package com.douyin.open.goodlife; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; public class EncryptUtil { /* * appid/client_key对应的client_secret * TODO: 更换成开发者自己的secret */ private static final String secret = ""; private static final String key; private static final String iv; static { key = parseSecret(secret); iv = secret.substring(16); } /** * @description AES加密 * @param data 明文数据 * @return 密文 */ public static String encryptAES(String data) throws Exception { try { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); int blockSize = cipher.getBlockSize(); byte[] dataBytes = data.getBytes(); int plaintextLength = dataBytes.length; if (plaintextLength % blockSize != 0) { plaintextLength = plaintextLength + (blockSize - (plaintextLength % blockSize)); } byte[] plaintext = new byte[plaintextLength]; System.arraycopy(dataBytes, 0, plaintext, 0, dataBytes.length); SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES"); IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes()); // CBC模式,需要一个向量iv,可增加加密算法的强度 cipher.init(Cipher.ENCRYPT_MODE, keyspec, ivspec); byte[] encrypted = cipher.doFinal(plaintext); System.out.println(encrypted); return encode(encrypted).trim(); // BASE64做转码。 } catch (Exception e) { e.printStackTrace(); return null; } } /** * @Description AES解密 * @param data base64后的密文 * @return 明文 */ public static String decryptAES(String data) throws Exception { try { byte[] encrypted1 = decode(data);//先用base64解密 Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES"); IvParameterSpec ivspec = new IvParameterSpec(iv.getBytes()); cipher.init(Cipher.DECRYPT_MODE, keyspec, ivspec); byte[] original = cipher.doFinal(encrypted1); String originalString = new String(original); return originalString.trim(); } catch (Exception e) { e.printStackTrace(); return null; } } /** * SHA-256加密 * @param input 明文 * @return 密文 */ public static String SHA256(String input) { String result = ""; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(input.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(); result = bytes2Hex(digest); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return result; } /** * 将字节数组转换为十六进制字符串 */ public static String bytes2Hex(byte[] bts) { StringBuilder hexSb = new StringBuilder(); for (byte b : bts) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) hexSb.append('0'); hexSb.append(hex); } return hexSb.toString(); } /** * base64编码 * @param byteArray * @return */ public static String encode(byte[] byteArray) { return new String(Base64.getEncoder().encode(byteArray)); } /** * base64解码 * @param base64EncodedString * @return */ public static byte[] decode(String base64EncodedString) { return Base64.getDecoder().decode(base64EncodedString); } private static String parseSecret(String secret) { secret = fillSecret(secret); secret = cutSecret(secret); return secret; } private static String cutSecret(String secret) { if (secret.length() <= 32) { return secret; } int rightCnt = (secret.length() - 32) / 2; int leftCnt = secret.length() - 32 - rightCnt; return secret.substring(leftCnt, 32 + leftCnt); } private static String fillSecret(String secret) { if (secret.length() >= 32) { return secret; } int rightCnt = (32 - secret.length()) / 2; int leftCnt = 32 - secret.length() - rightCnt; StringBuilder sb = new StringBuilder(""); for (int i = 0; i < leftCnt; i++) { sb.append('#'); } sb.append(secret); for (int i = 0; i < rightCnt; i++) { sb.append('#'); } return sb.toString(); } }
GenAuthWithBindValidUrlDto.java
package com.douyin.open.goodlife.dto; import java.util.List; public class GenAuthWithBindValidUrlDto { // url开始生效时间 private String Timestamp; // 透传信息 private String Extra; // 解决方案key 根据接口文档填充值 private String SolutionKey; // 授权能力列表 private List<String> PermissionKeys; // 外部门店ID private String OutShopId; public String getTimestamp() { return Timestamp; } public void setTimestamp(String timestamp) { Timestamp = timestamp; } // public String getExtra() { return Extra; } public void setExtra(String extra) { Extra = extra; } // public String getSolutionKey() { return SolutionKey; } public void setSolutionKey(String solutionKey) { SolutionKey = solutionKey; } // public List<String> getPermissionKeys() { return PermissionKeys; } public void setPermissionKeys(List<String> permissionKeys) { PermissionKeys = permissionKeys; } // public String getOutShopId() { return OutShopId; } public void setOutShopId(String outShopId) { OutShopId = outShopId; } }
各解决方案能力枚举
到店餐饮解决方案 solution_key=1
能力枚举permission_keys | 语义 |
1 | 门店管理 |
2 | 订单查询 |
3 | 门店基础信息更新 |
4 | 门店任务查询 |
5 | 同步门店户 |
6 | 门店匹配 |
7 | 门店装修 |
8 | 门店亮照 |
9 | 会员管理 |
10 |