1. 业务说明
用户在DY店铺购买会员商品(虚拟商品),平台即时发送短信给用户下发业务系统的会员兑换码(即发放卡券),用户拿到兑换码后可以在我们自己的APP上兑换会员(即注销卡券),整个流程简单概括为:D店购买商品-->短信获取兑换码--> APP使用兑换码,整体架构如下:
2. 术语
【虚拟商品】: DY商品类型的一种,区分于【普通商品】,D店创建商品时可选择
【第三方卡券】: 指的是我们自己业务系统生成的商品唯一标识,如会员兑换码,D店创建商品时可选择,用户购买了虚拟商品第三方卡券后,通过回调消息通知到业务平台,业务平台将生成的卡券(如会员兑换码)通过D店API进行发放
【测试店铺】:开发者对接D店开放平台接口的测试环境,可以创建第三方卡券虚拟商品、在DY APP购买商品,前期对接接口时需要准备;
【D店】登录时选择店铺(如测试店铺),可以对商品、订单等进行管理,一般是运营人员使用,前期我们需要使用测试店铺创建自己的虚拟商品进行功能调试;
【D店开放平台】需要创建应用,后面对接的所有D店API都基于此应用,如给订单发放兑换码(业务系统和D店关联); 应用绑定D店店铺(应用和店铺的关联); 在应用中需要配置业务系统的回调地址来接收DY平台消息(D店和业务系统关联);
3. 接入准备
3.1 D店开放平台创建应用(以下简称“D店应用”):
- D店开放平台地址:抖店开放平台
- 创建应用前需要完成企业认证
- 创建【自用型应用】
- 应用创建好之后授权D店店铺(前期开发阶段只用测试店铺即可),这就打通了D店开放平台和D店店铺的关联,之后用户在D店店铺购买商品才能通过D店应用消息推送回调到业务平台。
- 消息推送,将D店应用接收到的消息回调给业务平台,文档地址: 抖店开放平台
PS:开发阶段建议通过内网穿透配置推送地址,这样消息就可以直接推送到我们本地运行的项目中了,方便调试代码。内网穿透工具可以用ngrok:会员登陆
3.2 D店应用关联测试店铺
- 按照自用型应用接入测试店铺,接入文档: 抖店开放平台
- 在测试店铺可以直接创建自己的虚拟第三方卡券商品,注意测试店铺是共用的,业务系统可能会接收到其他人的回调消息,但是只能对自己商品的消息进行处理(如同步卡券),所以不用担心回调消息会被其他人消费的问题。
4. 代码 (基于JAVA)
4.1 控制台下载SDK代码后上传到本地仓库,然后通过maven引入
SDK下载地址: 抖店开放平台
<!--DYSDK,控制台下载后上传到本地仓库-->
<dependency>
<groupId>com.douyin</groupId>
<artifactId>doudiansdk</artifactId>
<version>1.0.0</version>
</dependency>
4.2 接收回调消息
/**
* DY回调相关
*/
@RestController
@Slf4j
@Component
@RequestMapping("douyin/")
public class DouYinController {
private static final String FAIL = "fail";
@Resource
private DouYinUtil douYinUtil;
@Resource
private RabbitSender rabbitSender;
/**
* DY消息回调通知处理
* https://op.jinritemai.com/docs/guide-docs/10/99
* return java.lang.Object
**/
@PostMapping("callback")
public Object callBackMessage(HttpServletRequest request, @RequestBody JSONArray requestBody) throws Exception {
String sign = request.getHeader("event-sign");
String appKey = request.getHeader("app-id");
log.info("接收到的回调信息sign:{},appkey:{},requestBody:{}", sign, appKey, requestBody);
// 校验应用ID和店铺ID
if ($.isNotEqual(appKey, douYinUtil.getAppKey())) {
log.info("忽略非自己应用的回调消息,requestBody:{}", requestBody);
return FAIL;
}
// 校验签名
String checkSign = douYinUtil.getCallBackEventSign(JSON.toJSONString(requestBody));
if ($.isNotEqual(sign, checkSign)) {
log.warn("DY回调事件签名校验失败,sign:{},requestBody:{}, checkSign:{}", sign, requestBody, checkSign);
return FAIL;
}
JSONObject responseSuccess = new JSONObject();
responseSuccess.put("code", 0);
responseSuccess.put("msg", "success");
String tag = requestBody.getJSONObject(0).getString("tag");
if ($.equals(tag, "0")) {
log.warn("DY消息推送地址配置成功响应");
return responseSuccess;
}
// 校验店铺ID
JSONObject data = requestBody.getJSONObject(0).getJSONObject("data");
Long shopId = data.getLong("shop_id");
if (shopId != null && $.isNotEqual(shopId, douYinUtil.getShopId())) {
log.warn("DY回调事件店铺ID校验失败requestBody:{}", requestBody);
return FAIL;
}
// TODO 发送MQ异步处理回调事件
// 响应回调成功消息,如果不响应默认回调失败,最多重推3次,推送的时间间隔分别为30s、5min、1h
return responseSuccess;
}
}
4.3 DY工具类(核心代码)
/**
* DY工具类
*/
@Component
@Slf4j
public class DouYinUtil {
/**
* D店应用key
*/
@Value("${appkey:}")
private String appKey;
/**
* D店应用密钥
*/
@Value("${appsecret:}")
private String appSecret;
/**
* D店店铺ID
*/
@Value("${shopid:}")
private Long shopId;
/**
* D店应用API访问Token
*/
private AccessToken localAccessToken;
@PostConstruct
public void init() {
// 初始化配置
// 超时时间
GlobalConfig.initHttpClientReadTimeout(10000);
// 连接时间
GlobalConfig.initHttpClientConnectTimeout(5000);
//设置appKey和appSecret,全局设置一次
GlobalConfig.initAppKey(appKey);
GlobalConfig.initAppSecret(appSecret);
// 创建访问token
this.getAccessToken();
}
public String getAppKey() {
return appKey;
}
public Long getShopId() {
return shopId;
}
/**
* 生成或刷新DYToken
* 注意点:
* 1、在 access_token 过期前1h之前,ISV使用 refresh_token 刷新时,会返回原来的 access_token 和 refresh_token,但是二者有效期不会变;
* 2、在 access_token 过期前1h之内,ISV使用 refresh_token 刷新时,会返回新的 access_token 和 refresh_token,但是原来的 access_token 和 refresh_token 继续有效一个小时;
* 3、在 access_token 过期后,ISV使用 refresh_token 刷新时,将获得新的 acces_token 和 refresh_token,同时原来的 acces_token 和 refresh_token 失效;
* return void
* @author better
**/
public AccessToken getAccessToken() {
// 优先从缓存获取token
String tokenKey = "你的redis缓存key";
String tokenCache = jedisClient.get(tokenKey);
if ($.isNotBlank(tokenCache)) {
log.info("DYtoken生效中,无需刷新token");
localAccessToken = JSONObject.parseObject(tokenCache,AccessToken.class);
return localAccessToken;
}
// 缓存token没有token,说明token即将过期或已过期,如果本地有上次获取的token则掉刷新接口,否则调创建接口
AccessToken newAccessToken;
if (localAccessToken == null) {
newAccessToken = AccessTokenBuilder.build(shopId);
}else {
newAccessToken = AccessTokenBuilder.refresh(localAccessToken);
}
log.info("获取到DY的Token:{}" , JSONObject.toJSONString(newAccessToken));
if (!newAccessToken.isSuccess()) {
return null;
}
// 保存本地变量
localAccessToken = newAccessToken;
// 过期时间(秒级时间戳)
Long expireIn = newAccessToken.getExpireIn();
// redis的过期时间在token过期前50分钟,也就是下次刷新token时要么在token过期前1h之内,要么在过期后,这2种情况都会拿到新的token
expireIn = expireIn - 3000;
if (expireIn > 0) {
jedisClient.setex(tokenKey, expireIn.intValue(),JSONObject.toJSONString(newAccessToken));
}
return localAccessToken;
}
/**
* 获取DY回调事件数据的签名
* @param requestBody 订阅回调事件的数据
* return boolean
* @author better
**/
public String getCallBackEventSign(String requestBody) {
String signParams = appKey + requestBody + appSecret;
log.info("参与签名参数:{}",signParams);
return HMACUtil.genHmacSHA256String(appSecret,signParams);
}
/**
* 获取DY订单详情
* @param dyOrderId
* return com.doudian.open.api.order_orderDetail.data.OrderOrderDetailData
* @author better
**/
public ShopOrderDetail getOrderDetail(String dyOrderId) {
if ($.isBlank(dyOrderId)) {
log.warn("获取DY订单失败,参数缺失");
return null;
}
// 构建请求参数
OrderOrderDetailRequest request = new OrderOrderDetailRequest();
OrderOrderDetailParam param = request.getParam();
param.setShopOrderId(dyOrderId);
//调用API
AccessToken accessToken = this.getAccessToken();
OrderOrderDetailResponse response = request.execute(accessToken);
log.info("获取到DY订单详情:{}",JSONObject.toJSONString(response));
if (response == null || $.isNotEqual(response.getCode(), "10000") ) {
return null;
}
return response.getData().getShopOrderDetail();
}
/**
* 卡券同步
* PS: 用户在DY下单后(支付完成),需要同步会员兑换码到DY订单详情中,以更新DY状态
* @param dyOrderId DY订单ID
* @param redeemCodeList 会员兑换码
* return boolean 同步结果 true-成功 false-失败
* @author better
**/
public CouponsSyncV2Response couponsSync2(String dyOrderId, List<String> redeemCodeList){
CouponsSyncV2Request request = new CouponsSyncV2Request();
CouponsSyncV2Param param = request.getParam();
// DY订单ID
param.setOrderId(dyOrderId);
Date now = new Date();
// 组装兑换码列表,一笔订单购买多少个商品就发多少个兑换码
List<CertListItem> certList = new ArrayList<>();
for (String redeemCode : redeemCodeList) {
CertListItem certItem = new CertListItem();
certItem.setCertNo(redeemCode);
certItem.setGrantTime(DateUtils.format(now, DatePattern.NORM_DATETIME_PATTERN));
certList.add(certItem);
}
param.setCertList(certList);
AccessToken accessToken = this.getAccessToken();
CouponsSyncV2Response response = request.execute(accessToken);
log.info("同步卡券到DY结果:{}",JSONObject.toJSONString(response));
return response;
}
/**
* 券码核销
* PS: 用户兑换了会员兑换码之后,需要同步到DY以更新订单状态
* @param redeemCode 待核销的券码 (即会员兑换码)
* return com.doudian.open.api.coupons_verifyV2.CouponsVerifyV2Response
* @author better
**/
public CouponsVerifyV2Response couponsVerifyV2(String redeemCode){
CouponsVerifyV2Request request = new CouponsVerifyV2Request();
CouponsVerifyV2Param param = request.getParam();
param.setCertNo(redeemCode);
CouponsVerifyV2Response response = request.execute(getAccessToken());
return response;
}
/**
* 同意售后并退款
* @param dyAftersaleId DY售后订单
* return boolean 执行成功-true 执行失败-false
* @author better
**/
public boolean agreeAfterSaleAndRefund(String dyAftersaleId) {
AfterSaleOperateRequest request = new AfterSaleOperateRequest();
AfterSaleOperateParam param = request.getParam();
// 售后详情
List<ItemsItem> itemList = new ArrayList<>();
ItemsItem item = new ItemsItem();
item.setAftersaleId(dyAftersaleId);
itemList.add(item);
param.setItems(itemList);
// 售后类型:201同意仅退款; 202拒绝仅退款; 其他参考https://op.jinritemai.com/docs/question-docs/93/2752
param.setType(201);
AfterSaleOperateResponse response = request.execute(getAccessToken());
if (response == null || $.isNotEqual(response.getCode(),"10000")) {
log.warn("同意售后并退款操作失败,dyAftersaleId:{},response:{}",dyAftersaleId,JSONObject.toJSONString(response));
return false;
}
return true;
}
/**
* 拒绝售后
* @param dyAftersaleId
* return boolean 执行成功-true 执行失败-false
* @author better
**/
public boolean rejectAfterSale(String dyAftersaleId) {
AfterSaleOperateRequest request = new AfterSaleOperateRequest();
AfterSaleOperateParam param = request.getParam();
// 售后详情
List<ItemsItem> itemList = new ArrayList<>();
ItemsItem item = new ItemsItem();
item.setAftersaleId(dyAftersaleId);
// 拒绝编码
item.setRejectReasonCode(23L);
// 拒绝举证
List<EvidenceItem> evidenceList = new ArrayList<>();
EvidenceItem evidence = new EvidenceItem();
evidence.setType(4);
evidence.setDesc("卡券已核销");
item.setEvidence(evidenceList);
itemList.add(item);
param.setItems(itemList);
// 售后类型:201同意仅退款; 202拒绝仅退款; 其他参考https://op.jinritemai.com/docs/question-docs/93/2752
param.setType(202);
AfterSaleOperateResponse response = request.execute(getAccessToken());
if (response == null || $.isNotEqual(response.getCode(),"10000")) {
log.warn("拒绝售后操作失败,dyAftersaleId:{},response:{}",dyAftersaleId,JSONObject.toJSONString(response));
return false;
}
return true;
}
}
4.4 业务代码,这里仅展示订单的处理流程, 其他流程可参考自行扩展
@Service
@Slf4j
public class DouYinService {
/**
* 订单支付/确认消息处理
* @param param DY回调参数
* return void
* @author better
**/
@Transactional(rollbackFor = Exception.class)
public void handleTradePaid(JSONObject param) {
log.info("DY订单支付/确认消息处理,param:{}",param.toJSONString());
// 消息记录ID
String msgId = param.getString("msg_id");
JSONObject data = param.getJSONObject("data");
String dyOrderId = data.getString("p_id");
// 订单类型: 0: 实物 2: 普通虚拟 4: poi核销 5: 三方核销 6: 服务市场
Integer orderType = data.getInteger("order_type");
if ($.isNotEqual(orderType,5)) {
log.info("非三方卡券商品,无需处理,dyOrderId:{}",dyOrderId);
return;
}
// TODO 分布式锁,资源竞争上锁
try {
// 获取DY订单信息(付费接口,需保证有足够充值金额)
ShopOrderDetail orderDetail = douYinUtil.getOrderDetail(dyOrderId);
if (orderDetail == null) {
log.warn("获取不到DY订单详情,dyOrderId:{},msgId:{},data:{]",dyOrderId,msgId,data.toJSONString());
// 抛出异常,触发MQ重试机制
throw new Exception("获取DY订单详情异常");
}
// 订单状态 : 1-待确认/待支付(订单创建完毕);105-已支付;2-备货中;101-部分发货 ;3-已发货(全部发货)4-已取消;5-已完成(已收货)
Long orderStatus = orderDetail.getOrderStatus();
if (orderStatus !=2 && orderStatus != 101) {
log.info("DY订单非备货中(2)且非部分发货(101),忽略当前同步会员兑换码操作,msgId:{},orderDetail:{]",msgId,JSONObject.toJSONString(orderDetail));
return;
}
// 到这一步需要发放兑换码,如果发放失败需要记录下来后面通过定时任务进行补发
// TODO 订单商品数量(这里我们D店只配置1个单sku会员虚拟商品,如果要配置多个虚拟商品或多个sku,这里需要根据业务改造!)
Long itemNum = orderDetail.getSkuOrderList().get(0).getItemNum();
// TODO 获取兑换码,数量和订单商品数量必须一致,没有异常表示成功获取到兑换码
List<String> redeemCodeList = "根据实际业务获取兑换码";
// 同步会员兑换码
CouponsSyncV2Response response = douYinUtil.couponsSync2(dyOrderId, redeemCodeList);
if (response == null || $.isNotEqual(response.getCode(),"1000")) {
log.warn("DY订单会员兑换码同步失败,msgId:{},data:{},response:{}",msgId,data,JSONObject.toJSONString(response));
throw new AppException("DY订单会员兑换码同步失败");
}
}catch (Exception e ) {
// TODO 记录获取兑换码失败的记录,通过定时任务进行补发
// 抛出异常触发MQ重试
throw e;
} finally {
// TODO 释放分布式锁
}
}
}
五、其他