抖音开放平台Logo
开发者文档
“/”唤起搜索
控制台
  • 接入前准备
  • 通用接口
  • 代运营
  • 订单查询
  • 团购核销
  • 三方码
  • 团购退款
  • 团购对账
  • 商品发布
  • 门店相关接口
  • 商品查询
  • 会员接入
  • 会员入会&退会
  • 会员数据更新
  • 会员信息变更(抖音->商家)
  • 招商入驻
  • 组合券包
  • KA核销对账
  • 餐饮
  • 大交通
  • 酒旅
  • 综合
  • 历史版本文档(不推荐)
  • 会员信息变更(抖音->商家)

    收藏
    我的收藏
    用户在抖音端内发起会员信息变更(当前支持:换绑手机号),同步通知到商家侧。

    使用限制

    接口说明

      1.用户在抖音端内发起会员信息变更(当前支持:换绑手机号),同步通知到商家侧,商家需返回变更成功或失败结果。
      2.使用抖音开放平台的 SPI 机制接入。该机制需要开发者在抖音开放平台或者服务商平台配置 SPI 回调 URL。

    基本信息

    HTTP URL
    地址由服务商提供
    HTTP Method
    POST
    权限申请
    会员管理
    权限要求
      需要申请权限 ,路径:抖音开放平台-服务商平台 > 控制台 > 应用详情 > 解决方案
      需要 URL 配置,路径:参考SPI 配置
      需要商家授权,路径:抖音来客 > 店铺管理 > 服务应用授权

    SPI 配置

      1.入口:抖音开放平台-开发者平台/抖音开放平台-服务商平台 → 控制台 → 第三方应用 → 应用详情页 → 开发设置 →SPI 回调
      2.配置 URL 监听会员信息变更消息:抖音会员-会员信息变更(抖音->商家)

    签名规则

    请求验签请参考SPI 签名机制

    请求参数

    字段名
    类型
    解释
    open_id
    string
    加密后的uid,用户的唯一标志
    account_id
    string
    商家抖音来客品牌户 id
    update_time
    string
    变更时间(unix timestamp, s)
    info
    json
    会员信息变更内容
      手机号(新旧手机号),为加密后的手机号,商家可使用解密sdk(见附录)进行解密
    .mobile
    json
    手机号结构体
    .old_mobile
    string
    用户旧手机号
    .new_mobile
    string
    用户新手机号

    请求示例

    content: { "open_id":"f6e35c98-1e53-4943-ad6d-f476f869deab", "account_id":"17371731", "update_time":"1709705974", "info":{ "mobile":{ "old_mobile":"2yxFypQ39OrifivISATEqA==", //加密后的手机号 "new_mobile":"2yxFypQ39OrifivISAadfj==" } } }

    响应参数

    字段名
    类型
    是否必填
    解释
    data
    struct
    .error_code
    int
    业务错误码
      0 表示成功
      100 表示系统内部处理异常
      201 表示新手机号在商家侧已有其他会员账号,此时不支持变更
    说明
    商家返回不为 0 的错误码,信息变更失败,同步返回给用户
    .description
    string
    错误消息描述

    响应示例

    正常示例

    // 成功示例 { "data": { "error_code": 0, "description": "success", } }

    异常示例

    // 错误示例 { "data": { "error_code": 11, "description": "业务错误 错误代码11" } }

    注意事项

      1.新手机号在商家侧已有其他会员账号,此时不支持变更
      2.接口应支持幂等,如抖音多次请求变更为相同的新手机号,商家侧已完成变更,此时商家侧应返回请求成功
      3.手机号换绑不应影响全渠道新会员判断。Example:用户使用手机号A入会,为全渠道新会员,变更至手机号B后退会;再次从抖音入会时,无论用户使用手机号A还是手机号B都应是全渠道新会员

    附录

    解密SDK

    Golang SDK

    package utils import ( "bytes" "crypto/aes" "crypto/cipher" "encoding/base64" ) // AesDecrypt 解密函数 // encryptedStr:base64后的密文 // secret:appid/client_key对应的client_secret // return: []byte 明文 func AesDecrypt(encryptedStr string, secret string) ([]byte, error) { // 加密字符串进行base64解码 decodeBytes, err := base64.StdEncoding.DecodeString(encryptedStr) if err != nil { return nil, err } key, iv := parseSecret(secret) block, err := aes.NewCipher(key) if err != nil { return nil, err } blockSize := block.BlockSize() blockMode := cipher.NewCBCDecrypter(block, iv[:blockSize]) origData := make([]byte, len(decodeBytes)) blockMode.CryptBlocks(origData, decodeBytes) origData = PKCS5UnPadding(origData) return origData, nil } // parseSecret 将secret解析为key和iv func parseSecret(secret string) ([]byte, []byte) { // secret对齐为32位 secret = cutSecret(secret) secret = fillSecret(secret) key, iv := secret, secret[16:] return []byte(key), []byte(iv) } func fillSecret(secret string) string { if len(secret) >= 32 { return secret } rightCnt := (32 - len(secret)) / 2 leftCnt := 32 - len(secret) - rightCnt var byt bytes.Buffer byt.Write(bytes.Repeat([]byte("#"), leftCnt)) byt.WriteString(secret) byt.Write(bytes.Repeat([]byte("#"), rightCnt)) return byt.String() } func cutSecret(secret string) string { if len(secret) <= 32 { return secret } rightCnt := (len(secret) - 32) / 2 leftCnt := len(secret) - 32 - rightCnt return secret[leftCnt: 32+leftCnt] } func PKCS5UnPadding(origData []byte) []byte { length := len(origData) unpadding := int(origData[length-1]) return origData[:(length - unpadding)] }

    Java SDK

    package com.douyin.open.goodlife; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class SignUtil { /* * appid/client_key对应的client_secret TODO 这里换成服务商的appsecret */ private static final String secret = "12345678901234566543210987654321"; private static final String key; private static final String iv; static { key = parseSecret(secret); iv = key.substring(16); } /** * @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; } } /** * base64编码 */ public static String encode(byte[] byteArray) { return new String(Base64.getEncoder().encode(byteArray)); } /** * base64解码 */ 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(); } }