抖音开放平台接入
抖音开放平台接入
准备工作
注册抖音开放平台 https://developer.open-douyin.com/ 并且进行企业认证。内网穿透工具(需支持https),推荐ngrok。
相关网站
- 抖音开放平台: https://developer.open-douyin.com/console?type=1
- 官网开发文档:https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/list
- 抖音提供的h5发布视频演示页面:https://open.douyin.com/web_apps/h5_share
需要资料
- 应用对应:Client Key 和 Client Secret
- 白名单管理:增加白名单抖音账号
注意事项
- 抖音权登录抖音号需要加入白名单 控制台-->设置-->白名单管理
- 授权登录地址权限
scope
需要配置已有权限,就是抖音对应的能力管理 - 授权回调和订阅事件都需要在抖音配置才可以生效,控制台-->设置-->开发配置
授权登录
第一步 授权登录地址拼接
授权登录和微信授权登录类似,拼接授权登录地址。包含回调地址(必须https)携带code
以及自定义参数 state
。回调地址可以是到指定前端页面可以是接口,我这里是通过接口去接收。
public String access(String uid, String redirectUri) {
List<String> scope = Lists.newArrayList();
scope.add("data.external.item");//视频数据
scope.add("user_info");// 用户信息
scope.add("h5.share");// 支持H5场景的内容可以分享发布到抖音,且可携带指定话题、小程序等内容
scope.add("open.get.ticket");// 用于h5链接拉起抖音发布器分享视频时对开发者身份进行验签
scope.add("trial.whitelist");// 测试应用白名单权限
scope.add("poi.cps.common");// CPS佣金设置与查询
scope.add("open.get.ticket");// 获取openTicket
URLBuildUtil url = new URLBuildUtil("https://open.douyin.com/platform/oauth/connect/");
url.putParams("client_key", '抖音应用信息 client key');
url.putParams("response_type", "code");
url.putParams("redirect_uri", "回调地址");
url.putParams("scope", String.join(",", scope));
url.putParams("state", uid);
String build = url.build();
log.debug("url:{}", build);
return build;
}
最终访问地址
https://open.douyin.com/platform/oauth/connect?client_key=1234563&redirect_uri=https://d2c2-120-238-70-9.ngrok-free.app/tiktok/callBack&response_type=code&scope=data.external.item,user_info,h5.share,open.get.ticket,trial.whitelist,poi.cps.common,open.get.ticket&state=1662039377758420993
直接浏览器访问
注意
1、生成的地址可以直接通过浏览器打开,抖音进行扫码授权。
2、将上面生成的地址再次作为二维码内容生成一个二维码,用户直接抖音扫码也是可以完成授权登录。
第二步 配置授权回调
如果是开发环境请使用内网穿透地址,可以配置多个回调地址。
第三步 回调接口处理
授权成功抖音会重定向到回调地址,GET请求,并且携带 code
和 state
参数如果有
@ApiOperation(value = "授权回调")
@GetMapping(value = "callBack")
public Result callback(String code, String state) {
// 获取 token 和 open id
Map<String, Object> params = new HashMap<>();
params.put("client_secret","client_secret");
params.put("client_key", "client_key");
params.put("code", code);
params.put("grant_type", "authorization_code");
Map map = HttpClientUtils.getInstance()
.setContentType("application/json")
.putParams(params)
.doPost("https://open.douyin.com/oauth/access_token/").toMap();
//... 自己的业务逻辑
return Result.success("授权成功");
}
订阅事件
第一步 配置Webhooks
抖音是以POST
请求进行验证配置的,后续所有事件都会发送到改接口上。和微信公众号开发服务器验证类似。
第二步 订阅接口处理
该接口接收抖音验证消息,验证通过才能配置成功。请求头类型是 application/json
内容是在body中所以我的接口用 map
来接收,消息体有返回openid
抖音用户唯一标识
抖音事件如下
- create_video 视频(通过h5分享发布视频触发该事件)
- unauthorize 取消授权(设置->账号与安全->授权管理-解绑触发)
- authorize 授权(扫码授权登录触发)
- verify_webhook 服务器验证(抖音开发配置添加URL触发)
注意
抖音签名是
headers
里面获取,签名验证是应用秘钥+body字符
进行sha1
加密结果比对相对表示没问题。返回给抖音的是content
内容,响应头必须是application/json
@ApiOperation("订阅消息")
@PostMapping(value = "subscribe")
public void subscribeEvent(@RequestBody Map<String, Object> map, HttpServletRequest request, HttpServletResponse response) {
log.info("订阅消息:{}", JSONUtil.objToStr(map));
// 签名字符串
String signature = request.getHeader("x-douyin-signature");
log.info("signature:{}", signature);
// 消息id
String msgId = request.getHeader("msg-id");
log.info("msgId:{}", msgId);
// 消息内容
Map content = (Map) map.get("content");
// 事件
String event = map.get("event").toString();
// verify_webhook 服务器验证
if (event.equals("verify_webhook")) {
// 秘钥+body字符串
String data = "client secret " + JSONUtil.objToStr(map);
// sha1 签名
String sign = DigestUtils.sha1Hex(data);
log.info("sign:{}", sign);
if (Objects.equals(sign, signature)) {
this.responseText(JSONUtil.objToStr(content), response);
return; // 提前结束
}
}
// ... 其他业务逻辑
}
// 处理响应结果
private void responseText(String text, HttpServletResponse response) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter writer = null;
try {
writer = response.getWriter();
} catch (IOException e) {
e.printStackTrace();
}
assert writer != null;
writer.write(text);
writer.flush();
writer.close();
}
官网代码示例
import org.apache.commons.codec.digest.DigestUtils; // sha1算法库
// 获取消息中body
String str, wholeStr = "";
try{
BufferedReader br = re.getReader();
while((str = br.readLine()) != null){
wholeStr += str;
}
} catch (Exception e){
log.warn("获取请求内容失败");
}
// 获取请求头中的加签信息
String signature = re.getHeader("X-Douyin-Signature");
String data = appSecret + wholeStr;
String sign = DigestUtils.sha1Hex(data);
if(!sign.equals(signature)){
log.error("验签失败");
}
第三步 事件消息体
消息内容做了脱敏处理,实际返回内容以接口为准
h5发布视频
{
"event": "create_video",
"client_key": "11111",
"from_user_id": "234234234ldoQ2UFeflKR33333eLYTNVTs",
"content": {
"share_id": "111111",
"item_id": "@9VxX1111111zoA+lLFUWbfL+60z333333EBbHec9qLXdMCWaQQYTUnzwg==",
"has_default_hashtag": null,
"video_id": "111111"
},
"log_id": "2023060117095922440FA7ABD95228B8D0",
"event_id": ""
}
取消授权
{
"event": "unauthorize",
"client_key": "11111",
"from_user_id": "11111dU7BKTbvqPiLPts8KxFUDKS",
"content": {
"scopes": [
"user_info",
"trial.whitelist",
"data.external.item"
],
"code": 1,
"description": "用户取消授权"
},
"log_id": "20230601165505000000000000596F91E",
"event_id": ""
}
授权登录
{
"event": "authorize",
"client_key": "11111",
"from_user_id": "11111BKTbvqPiLPts8KxFUDKS",
"content": {
"scopes": [
"user_info",
"trial.whitelist",
"data.external.item"
]
},
"log_id": "20230601165602172018000003663DCE3",
"event_id": ""
}
服务器验证
{
"event": "verify_webhook",
"client_key": "11111",
"from_user_id": "",
"content": {
"challenge": 19655498
},
"log_id": "2023060116524902BF3D205E5EBE86AAE4",
"event_id": ""
}
H5 发布 Schema 生成示例
https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/h5/share-to-h5
H5 分享是指第三方应用通过接入该功能,让用户可以从网页或者外部应用分享在线视频或图片等信息到抖音。
说白就是通过h5可以做视频批量分发,平台可以检测这些视频数据。
步骤:
- 首先获取
client_token
- 其次通过
client_token
获取ticket
- 最后 票据
ticket
、随机字符串nonce_str
、 时间戳timestamp
进行签名 share id
这个参数可选,用来跟踪用户发布h5视频状态,是否发布成功,以及后续业务处理。
第一步 client_token
Map<String, Object> params = new HashMap<>();
params.put("client_secret", "client_secret");
params.put("client_key", "client_key");
params.put("grant_type", "client_credential");
Map map = HttpClientUtils.getInstance()
.putParams(params)
.doPost("https://open.douyin.com/oauth/client_token/").toMap();
log.debug("clientToken:{}", map);
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(map.get("message").toString());
}
String access_token = data.get("access_token").toString();
第二步 ticket
票据有效期 2 小时,自行缓存
Map map = HttpClientUtils.getInstance()
.putHeader("access-token", this.clientToken())
.doGet("https://open.douyin.com/open/getticket/").toMap();
log.debug("clientToken:{}", map);
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(data.get("description").toString());
}
String ticket = data.get("ticket").toString();
第三步 签名
这里使用了 TreeMap 因为官网要求要 ASCII 码从小到大排序(字典序) 注意时间戳字段是秒不是毫秒
long timestamp = System.currentTimeMillis() / 1000; // 秒
TreeMap<String, String> map = new TreeMap<>();
map.put("nonce_str", '随机字符串');
map.put("ticket", '票据');
map.put("timestamp", '时间戳' + "");
log.debug("参数:{}", JSONUtil.objToStr(map));
List<String> list = new ArrayList<>();
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
list.add(String.format("%s=%s", key, value));
}
String join = String.join("&", list);
String signature = Md5Util.md5Encode(join, "UTF-8");
log.debug("签名:{}", signature);
第四步 share id(可选)
public String shareId() {
Map<String, Object> params = new HashMap<>();
params.put("need_callback", true); //
Map map = HttpClientUtils.getInstance()
.putParams(params)
.putHeader("access-token", 'client_token')
.setContentType("application/json")
.doGet("https://open.douyin.com/share-id/").toMap();
log.debug("shareId:{}", map);
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(data.get("description").toString());
}
return data.get("share_id").toString();
}
第五步 schema
这里需要注意的是我们生成并不是一个 URL 地址,他是一个 schema 地址,特殊URL地址和苹果URL Schemes差不多意思。就是可以通过浏览器访问这个链接唤起对应APP。
具体参数参考官网:https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/h5/share-to-h5
注意
- 提供的素材必须是http地址的可以下载的素材,像有些资源有防盗链等就是不行的
- 生成 schema 地址可以复制在浏览器直接打开,会自动跳转抖音
- 也可以生成二维码使用抖音扫码,原则上这个分享链接有效期2小时
- 苹果手机请使用 Safari 浏览器打开,其他浏览器可能有问题
// 构建 schema
URLBuildUtil url = new URLBuildUtil("snssdk1128://openplatform/share");
url.putParams("share_type", "h5");
url.putParams("client_key",'client_key');
url.putParams("title", 'title');
url.putParams("nonce_str", 'nonce_str');
url.putParams("timestamp", 'timestamp' );
url.putParams("signature", 'signature');
url.putParams("image_path",URLEncoder.encode("https://js.ibaotu.com/act/23/04/20/6441198db0ef0.jpg", "UTF-8"););
// 分享 id
url.putParams("state", "shareId");
schema 地址
snssdk1128://openplatform/share?client_key=1111111&image_path=https%3A%2F%2Fjs.ibaotu.com%2Fact%2F23%2F04%2F20%2F6441198db0ef0.jpg&nonce_str=29649247100043823967999435052604&share_type=h5&signature=efb371d33b0deefb6e471ded1724faef&state=1767391659575207×tamp=1685516015&title=哈哈哈
完整代码示例
import com.github.chenlijia1111.utils.core.RandomUtil;
import com.github.chenlijia1111.utils.http.HttpClientUtils;
import com.github.chenlijia1111.utils.http.URLBuildUtil;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.*;
class Tiktok {
/**
* description: 客户端 token
* create by: Mr.Fang
*
* @return: java.lang.String
* @date: 2023/6/2 16:11
*/
public static String clientToken() {
Map<String, Object> params = new HashMap<>();
params.put("client_secret", Constants.TIKTOK_CLIENT_SECRET); //
params.put("client_key", Constants.TIKTOK_CLIENT_KEY); //
params.put("grant_type", "client_credential"); // 回调地址
Map map = HttpClientUtils.getInstance()
.putParams(params)
.doPost("https://open.douyin.com/oauth/client_token/").toMap();
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(map.get("message").toString());
}
String access_token = data.get("access_token").toString();
return access_token;
}
/**
* description: 票据
* create by: Mr.Fang
*
* @return: java.lang.String
* @date: 2023/6/2 16:11
*/
public static String ticket() {
Map map = HttpClientUtils.getInstance()
.putHeader("access-token", clientToken())
.doGet("https://open.douyin.com/open/getticket/").toMap();
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(data.get("description").toString());
}
String ticket = data.get("ticket").toString();
return ticket;
}
/**
* description: 分享 id
* create by: Mr.Fang
*
* @return: java.lang.String
* @date: 2023/6/2 16:11
*/
public static String shareId() {
Map<String, Object> params = new HashMap<>();
params.put("need_callback", true); //
Map map = HttpClientUtils.getInstance()
.putParams(params)
.putHeader("access-token", clientToken())
.setContentType("application/json")
.doGet("https://open.douyin.com/share-id/").toMap();
Map data = (Map) map.get("data");
if (!Objects.equals(data.get("error_code"), 0)) {
throw new RuntimeException(data.get("description").toString());
}
return data.get("share_id").toString();
}
/**
* description: schema
* create by: Mr.Fang
*
* @return: java.lang.String
* @date: 2023/6/2 16:11
*/
public static String schemaH5() {
String randomCode = RandomUtil.createRandomCode(32); // 随机字符
long timestamp = System.currentTimeMillis() / 1000; // 秒
String ticket = ticket(); // 票据
TreeMap<String, String> map = new TreeMap<>();
map.put("nonce_str", randomCode);
map.put("ticket", ticket);
map.put("timestamp", timestamp + "");
List<String> list = new ArrayList<>();
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
list.add(String.format("%s=%s", key, value));
}
String join = String.join("&", list);
String signature = Md5Util.md5Encode(join, "UTF-8");
// 构建 URL
URLBuildUtil url = new URLBuildUtil("snssdk1128://openplatform/share");
url.putParams("share_type", "h5");
url.putParams("client_key", Constants.TIKTOK_CLIENT_KEY);
url.putParams("title", "标题");
url.putParams("nonce_str", randomCode);
url.putParams("timestamp", timestamp + "");
url.putParams("signature", signature);
url.putParams("state", shareId());
url.putParams("image_path", urlEncode("https://js.ibaotu.com/act/23/04/20/6441198db0ef0.jpg"));
return url.build();
}
private static String urlEncode(String url) {
try {
return URLEncoder.encode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
throw new RuntimeException(url);
}
}
}
API请求工具类型使用了第三方工具包,在此鸣谢 chenlijia1111 该作者
<dependency>
<groupId>com.github.chenlijia1111</groupId>
<artifactId>utils</artifactId>
<version>1.2.0-RELEASE</version>
</dependency>