前言:
因快手文档混乱,官方社区技术交流仍有很多未解之谜,下面3种文档的定义先区分。
代码中的JSON相关工具均用hutool工具包
1.快手 移动双端 原生SDK 文档https://mp.kuaishou.com/platformDocs/develop/mobile-app/ios.html
2.快手 Api 开放接口 文档https://mp.kuaishou.com/platformDocs/openAbility/contentManagement/createAVideo.html
3.快手 Java 服务端SDK maven 依赖 文档https://open.kuaishou.com/platform/openApi?menu=55
一、引入依赖
根据 3号 文档,虽然快手在JavaSDK中,封装了授权、用户信息、发布作品、直播等相关能力,但本次业务只涉及用户授权、发布视频,并且,SDK版的发布能力,不具备挂载小黄车的能力,所以只用到SDK中的授权能力。
<dependency>
<groupId>com.github.kwaiopen</groupId>
<artifactId>kwai-open-sdk</artifactId>
<version>1.0.6</version>
</dependency>
二、信息配置
1.注册应用
快手有两个开放平台
①:快手开放平台——只涉及小程序
②:快手开放平台——5端统管
从 ② 进入创建开发者账户,并创建移动应用后提交审核。填写好ios和andriod信息,申请需要的权限
2.后端配置
yml中自定义参数
快手配置类
import com.github.kwai.open.api.KwaiOpenLiveApi;
import com.github.kwai.open.api.KwaiOpenOauthApi;
import com.github.kwai.open.api.KwaiOpenUserApi;
import com.github.kwai.open.api.KwaiOpenVideoApi;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* 快手配置类
*/
@Data
@Component
public class KuaishouConfig {
/**
* App
*/
@Value("${kuaishou.appId}")
private String appId;
@Value("${kuaishou.appSecret}")
private String appSecret;
/**
* 小程序
*/
@Value("${kuaishou.appletId}")
private String appletId;
@Value("${kuaishou.appletSecret}")
private String appletSecret;
//快手服务端SDK接入- java版本
//https://open.kuaishou.com/platform/openApi?menu=55
//快手开放Api
//https://mp.kuaishou.com/platformDocs/openAbility/contentManagement/createAVideo.html
//发起上传Api
private final String startUploadApi = "https://open.kuaishou.com/openapi/photo/start_upload";
//上传视频Api
private final String uploadApi = "http://{endpoint}/api/upload";
public String getUploadApi(String endpoint) {
return uploadApi.replace("{endpoint}", endpoint);
}
//发布视频Api
private final String publishApi = "https://open.kuaishou.com/openapi/photo/publish";
/**
* oauth2.0协议的接口封装
*/
private KwaiOpenOauthApi kwaiOpenOauthApi;
/**
* 获取用户信息的相关接口封装
*/
private KwaiOpenUserApi kwaiOpenUserApi;
/**
* 发布内容能力的相关接口封装
*/
private KwaiOpenVideoApi kwaiOpenVideoApi;
/**
* 直播能力的相关接口封装
*/
private KwaiOpenLiveApi kwaiOpenLiveApi;
/**
* 初始化API接口实例,只执行一次,保证单例
*/
@PostConstruct
public void init() {
this.kwaiOpenOauthApi = KwaiOpenOauthApi.init(appId);
this.kwaiOpenUserApi = KwaiOpenUserApi.init(appId);
this.kwaiOpenVideoApi = KwaiOpenVideoApi.init(appId);
this.kwaiOpenLiveApi = KwaiOpenLiveApi.init(appId);
}
}
三、实现
1.授权
前端部分跳转快手,指定scope权限,获取授权码自行实现
绑定第三方Controller
/**
* 绑定第三方
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/bound")
public class BoundThirdPartController extends BaseController {
private final ISysUserService userService;
/**
* 绑定快手
* @param bound
* @return
*/
@PostMapping("/kuaishou")
public R<Void> boundKuaishou(@Validated @RequestBody KuaishouBound bound){
SysUser user = userService.selectUserById(getUserId());
if (StringUtils.isNotEmpty(user.getKuaishouOpenId())) {
return R.fail("您已绑定过快手账号");
}
return toAjax(userService.boundKuaishou(bound, getUserId()));
}
}
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class KuaishouBound {
/**
* 快手授权码
*/
@NotBlank(message = "快手授权码不能为空")
private String kuaishouCode;
}
用户Service
@Slf4j
@RequiredArgsConstructor
@Service
public class SysUserServiceImpl implements ISysUserService{
private final SysUserMapper baseMapper;
private final IKuaishouService kuaishouService;
@Override
public boolean boundKuaishou(KuaishouBound bound, Long userId) {
AccessTokenResponse response = kuaishouService.getKuaishouAccessToken(bound.getKuaishouCode());
String openId = response.getOpenId();
String accessToken = response.getAccessToken();
Long expiresIn = response.getExpiresIn();
//查看此openid是否有被绑定过
SysUser old = baseMapper.selectOne(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getKuaishouOpenId, openId));
if (ObjectUtil.isNotNull(old)) {
//自己绑定过
if (old.getUserId().equals(userId)) {
throw new ServiceException("您已绑定该快手账户,请勿重复绑定!");
}
//别人绑定过
throw new ServiceException("该快手已绑定到其他用户!");
}
RedisUtils.setCacheObject(CacheConstants.KUAISHOU_ACCESS_TOKEN + userId, accessToken, Duration.ofSeconds(expiresIn));
//更新用户数据
SysUser user = new SysUser();
user.setUserId(userId);
user.setKuaishouOpenId(openId);
return baseMapper.updateById(user) > 0;
}
}
快手Service
@Slf4j
@RequiredArgsConstructor
@Service
public class IKuaishouServiceImpl implements IKuaishouService {
private final KuaishouConfig kuaishouConfig;
/**
* 获取快手AccessToken
*
* @param kuaishouCode 授权码
*/
@Override
public AccessTokenResponse getKuaishouAccessToken(String kuaishouCode) {
try {
AccessTokenRequest tokenRequest = new AccessTokenRequest(kuaishouCode, kuaishouConfig.getAppSecret());
return kuaishouConfig.getKwaiOpenOauthApi().getAccessToken(tokenRequest);
} catch (KwaiOpenException e) {
throw new RuntimeException(e);
}
}
}
2.发布视频
文章开头说到的三种文档,都有各自的发布视频实现,这里选择第2种,Api的文档,因为只有Api接口中,可以带上小黄车的商品id。
但是! 不要高兴的太早!
这里的商品id,只能是发布视频的账号下的橱窗自建商品。
附上我与快手社区官方的交流
接受了这点,就可以看接下来的代码了。或者你不需要挂载小黄车的功能,可以考虑更方便的3号文档中的实现方式
业务Service
//快手创建一个视频需要执行 发起上传、上传视频、发布视频 三个步骤
//1.发起上传
JSONObject startResult = kuaishouService.startUpload(userId);
//2.上传视频
String endpoint = startResult.get("endpoint", String.class);
String uploadToken = startResult.get("upload_token", String.class);
Boolean uploadResult = kuaishouService.uploadMp4(endpoint,
uploadToken,
"mp4短视频 http url 地址");
//3.发布视频
JSONObject publishResult = kuaishouService.publishVideo(userId,
"封面图 http url 地址",
uploadToken,
"短视频标题 (示例#话题)",
"NOT_SPHERICAL_VIDEO",
"快手账户 快手小店 中 商品id");
// 4.得到的publishResult 结果,进行业务处理
…………
…………
快手Service
@Override
public JSONObject startUpload(Long userId) {
//获取用户授权的快手token
String accessToken = RedisUtils.getCacheObject(CacheConstants.KUAISHOU_ACCESS_TOKEN + userId);
if (StringUtils.isEmpty(accessToken)) {
throw new ServiceException("快手授权过期");
}
String result = HttpRequest.post(kuaishouConfig.getStartUploadApi() + "?access_token=" + accessToken + "&app_id=" + kuaishouConfig.getAppId())
.execute().body();
/*结果示例
{
"result": 1
}
*/
JSONObject json = JSONUtil.parseObj(result);
if (json.get("result", Integer.class) != 1) {
throw new ServiceException("向快手发起上传请求失败,请稍后再试");
}
return json;
}
@Override
public Boolean uploadMp4(String endpoint, String uploadToken, String fileUrl) {
//此接口的视频上传,只接受二进制,url转二进制
byte[] bytes = FileUtils.urlToByteArray(fileUrl);
String result = HttpRequest.post(kuaishouConfig.getUploadApi(endpoint) + "?upload_token=" + uploadToken)
.header("Content-Type", "video/mp4")
.body(bytes)
.execute().body();
/*结果示例
{
"result": 1
}
*/
if (!JSONUtil.isTypeJSON(result)) {
log.error("快手上传视频失败,{}", result);
throw new ServiceException("上传视频失败");
}
return JSONUtil.parseObj(result).get("result", Integer.class) == 1;
}
@Override
public JSONObject publishVideo(Long userId, String coverImg, String uploadToken, String skitsTitle, String panoramicParams, Integer productId) {
//获取用户授权的快手token
String accessToken = RedisUtils.getCacheObject(CacheConstants.KUAISHOU_ACCESS_TOKEN + userId);
if (StringUtils.isEmpty(accessToken)) {
throw new ServiceException("快手授权过期");
}
//上传封面又只接受File文件,主打一个混乱🤬🤬🤬
File file = FileUtils.urlToFile(coverImg, "jpg");
String body = HttpRequest.post(kuaishouConfig.getPublishApi() + "?access_token=" + accessToken + "&app_id=" + kuaishouConfig.getAppId() + "&upload_token=" + uploadToken)
.header("Content-Type", "multipart/form-data")
.form("cover", file)//封面图(10MB内)
.form("caption", skitsTitle)//标题
//.form("stereo_type", panoramicParams)//全景视频参数
//.form("merchant_product_id", productId)//需要挂载小黄车的商品ID
.execute().body();
/* 结果示例
{
"result": 1,
"video_info": {
//pending代表作品还在处理中,true时没有下面的play_url等参数
"pending": true,
"caption": "#测试1 #测试2 #测试3",
"view_count": 0,
"comment_count": 0,
"like_count": 0,
"cover": "",
"play_url": "",
"photo_id": "3xf4z2c8d9awkgg",
"create_time": 1728634351884
}
}*/
JSONObject result = JSONUtil.parseObj(body);
if (result.get("result", Integer.class) != 1) {
log.error("快手发布视频失败,{}", body);
throw new ServiceException("视频分享失败,请稍后再试");
}
return JSONUtil.parseObj(result.get("video_info", JSONObject.class));
}
FileUtils工具类
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 文件处理工具类
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class FileUtils extends FileUtil {
/**
* url转二进制
*
* @param url
* @return
*/
public static byte[] urlToByteArray(String url) {
//通过URL 流 下载 文件的二进制数据
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try {
HttpURLConnection urlConnection = (HttpURLConnection) new URL(url).openConnection();
urlConnection.setConnectTimeout(5000);
urlConnection.setRequestMethod("GET");
InputStream inputStream = urlConnection.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outStream.write(buffer, 0, len);
}
//关闭输入流
inputStream.close();
} catch (Exception e) {
log.error("短剧资源转二进制异常:{}", e.getMessage());
}
byte[] data = new byte[0];
data = outStream.toByteArray();
if (data.length == 0) {
log.error("短剧资源二进制数据大小为0");
throw new ServiceException("短剧资源异常");
}
return data;
}
/**
* url转File
*
* @param coverImg
* @param fileType
* @return
*/
public static File urlToFile(String coverImg, String fileType) {
File file = new File("temp/" + IdUtil.fastSimpleUUID() + "." + fileType);
try {
URL url = new URL(coverImg);
org.apache.commons.io.FileUtils.copyURLToFile(url, file);
} catch (Exception e) {
log.error("文件转换异常:{}", e.getMessage());
throw new ServiceException("文件转换异常");
}
return file;
}
}
为了弄清混乱的快手开发,和根本没有官方技术回答,整理不易。