抖音第三方卡券虚拟商品业务接入

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店应用接收到的消息回调给业务平台,文档地址: 抖店开放平台

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 释放分布式锁
        }
    }
}

 

五、其他

  • 注意D店商铺要创建第三方虚拟商品,需要向D店平台提交申请,申请地址为: 申请地址

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值