资讯详情 抖音私信自动回复卡片开发实战:服务端API接入指南
📅 2026/10/18 17:29:50
简介本资源是一套面向抖音企业号开发者的私信自动回复卡片功能源码实现适用于具备Python/Go基础的后端或全栈工程师解决企业号高频咨询场景下的即时响应与交互升级问题。压缩包共17个文件含3个核心Python脚本app.py、main.py、get_token.py负责业务逻辑与接口对接1个Go主程序main.go支撑高性能服务2个SQLite数据库douyin_cards.db、douyin_messages.db管理卡片配置与消息记录2个HTML模板与DEPLOYMENT_GUIDE.md等文档构成可快速部署的完整工程结构整体8.37MB。已有713人学习下载提供从Token获取、卡片结构体AutoReplyCard定义、JumpLink白名单跳转适配、关键词触发到自动撤回的全链路代码支持目录模块划分清晰含.env.example环境配置、deploy.sh一键部署脚本及详细README开箱即用显著提升用户互动效率与企业服务专业度。1. 抖音私信自动回复卡片代码不是「机器人」而是「结构化消息响应引擎」抖音私信场景里大量用户咨询集中在「价格」「发货时间」「是否支持定制」「有没有教程」这几类高频问题上。但纯文字自动回复容易被判定为营销号、触发限流且信息密度低、跳失率高——用户看完还得手动点链接、翻图片、查文档。而「自动回复卡片」是抖音开放平台明确支持的交互形态它能在私信流中渲染出带标题、描述、按钮、缩略图的富媒体卡片点击后可直接跳转小程序、H5页或唤起客服既合规又提升转化。所谓「抖音私信自动回复卡片代码」指的是一套基于抖音开放平台服务端 API非客户端模拟点击实现的、可部署在自有服务器上的轻量级响应服务核心能力是接收抖音推送的私信事件 → 解析用户提问意图 → 匹配预设规则或调用简单逻辑 → 组装符合抖音卡片 Schema 的 JSON 数据 → 调用message/send_card接口完成发送。它不依赖第三方平台托管不涉及任何客户端自动化工具适合中小商家、知识博主、SaaS 工具方快速接入把重复咨询变成一次点击就能解决的闭环动作。如果你正被私信咨询淹没又不想被封号、不想写一堆 if-else 文字回复这套代码就是你该立刻验证的最小可行路径。2. 搭建服务端响应骨架从注册应用到接收第一条私信事件抖音私信自动回复卡片的落地本质是「事件驱动 结构化响应」。它不走前端埋点或浏览器插件路线而是依托抖音开放平台的服务端能力你注册一个企业/个体工商户认证的应用配置服务器地址接收 Webhook 事件再通过服务端调用接口发卡。整个链路干净、可控、可审计。下面从零开始搭起这个骨架。2.1 在抖音开放平台创建应用并获取凭证进入抖音开放平台developer.open-douyin.com使用已认证的企业/个体工商户账号登录。新建应用时选择「服务号应用」类型注意个人开发者无法开通私信相关权限。填写应用名称如「客服卡片助手」、简介、Logo建议用品牌色简洁图标关键一步是「配置服务器地址」填写你将要部署服务的公网域名如https://card.yourdomain.com并设置 Token任意 32 位字符串如douyin_card_2024_token和 EncodingAESKey自动生成的 43 位随机字符串用于消息体加解密。提交后平台会下发AppID和AppSecret—— 这两个是后续所有 API 调用的身份凭据务必安全存储不要硬编码进代码用环境变量管理。提示Token 和 EncodingAESKey 仅在创建时显示一次丢失需重置。重置会导致 Webhook 中断务必截图保存。2.2 验证服务器地址并启用私信事件订阅抖音要求首次配置 Webhook 地址时必须通过 GET 请求完成验证。你的服务需监听/或/verify路径接收抖音传来的echostr参数并原样返回不加空格、不加换行。Python Flask 示例from flask import Flask, request, make_response import os app Flask(__name__) TOKEN os.getenv(DOUYIN_TOKEN, douyin_card_2024_token) # 从环境变量读取 app.route(/, methods[GET]) def verify(): echostr request.args.get(echostr) if echostr and request.args.get(signature) and request.args.get(timestamp): # 此处可做简单签名校验非必须抖音只要 echostr 回显 return echostr return Invalid request, 400部署后在开放平台「Webhook 配置」页点击「验证」若返回success即表示通路打通。接着进入「事件订阅」模块勾选message类型下的text_msg文本消息和card_click卡片点击保存。此时用户向你的抖音号发送私信抖音就会以 POST 请求将原始消息 JSON 推送到你的服务器。2.3 解析抖音推送的私信事件结构抖音推送的私信事件是加密的AES-256-CBC需先解密才能读取内容。解密逻辑官方 SDK 已封装但为避免引入黑盒依赖我们手写核心解密函数基于pycryptodome库from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 import json import os def decrypt_douyin_message(encrypted_data: str, encoding_aes_key: str) - dict: 解密抖音推送的加密消息体 :param encrypted_data: Base64 编码的密文来自 POST body :param encoding_aes_key: 开放平台配置的 EncodingAESKey43位末尾含 :return: 解密后的原始 JSON 字典 key base64.b64decode(encoding_aes_key) cipher AES.new(key, AES.MODE_CBC, key[:16]) decrypted unpad(cipher.decrypt(base64.b64decode(encrypted_data)), AES.block_size) # 解密后数据格式4字节msg_len msg 8字节random_string appid # 我们只取中间 msg 部分去掉头尾固定长度 msg_len int.from_bytes(decrypted[:4], big) msg_content decrypted[4:4 msg_len] return json.loads(msg_content.decode(utf-8)) # 使用示例在 Flask POST 路由中 app.route(/, methods[POST]) def handle_message(): data request.get_data() encoding_aes_key os.getenv(DOUYIN_AES_KEY, ) try: plain_msg decrypt_douyin_message(data, encoding_aes_key) print(解密成功:, plain_msg) # plain_msg 结构示例 # { # ToUserName: gh_xxx, # FromUserName: oxxx, # CreateTime: 1712345678, # MsgType: text, # Content: 怎么发货, # MsgId: 1234567890123456 # } return success except Exception as e: print(解密失败:, e) return fail, 400这段代码的关键在于抖音加密消息体是「明文长度明文随机串appid」拼接后加密解密后需按协议截取有效载荷。很多初学者卡在这一步直接json.loads(data)报错就是因为没解密。参数说明encrypted_data是原始 POST body二进制encoding_aes_key必须与开放平台配置完全一致含符号否则解密失败。3. 构建卡片生成逻辑从关键词匹配到动态数据注入收到解密后的用户消息下一步是决定「回什么卡片」。这不是简单的 if-else 文本映射而是围绕「卡片 Schema」设计的结构化响应策略。抖音卡片message/send_card要求严格 JSON 格式包含card_type目前仅支持link类型、title、description、button_list最多 3 个按钮、thumbnail_media_id图片 ID等字段。我们采用「规则引擎 模板填充」双层设计先用关键词/正则匹配意图再用 Jinja2 模板注入动态数据如用户昵称、订单号保证复用性与可维护性。3.1 定义卡片模板与数据模型我们不把卡片 JSON 硬写在代码里而是用 YAML 管理模板库cards.yaml便于运营人员修改# cards.yaml shipping_info: title: 发货说明 description: 我们承诺下单后24小时内发货江浙沪次日达全国3-5天。 button_list: - name: 查看物流规则 type: webview url: https://yourdomain.com/shipping-rules - name: 联系客服 type: customer_service thumbnail_media_id: media_abc123 price_query: title: 价格明细 description: 基础版 ¥199/年专业版 ¥399/年含定制开发。 button_list: - name: 立即购买 type: miniprogram app_id: wx1234567890 page_path: /pages/pay/index?planbasic - name: 对比版本 type: webview url: https://yourdomain.com/pricing thumbnail_media_id: media_def456对应 Python 加载逻辑import yaml from jinja2 import Template # 全局加载卡片模板 with open(cards.yaml, r, encodingutf-8) as f: CARD_TEMPLATES yaml.safe_load(f) def render_card(template_name: str, **context) - dict: 渲染指定模板支持 Jinja2 变量注入 :param template_name: cards.yaml 中的键名 :param context: 动态上下文如 user_nickname张三 :return: 符合抖音卡片 Schema 的 dict if template_name not in CARD_TEMPLATES: raise ValueError(fCard template {template_name} not found) template_data CARD_TEMPLATES[template_name] # 对 title/description/button.name 做模板渲染 rendered {} for key in [title, description]: if key in template_data: rendered[key] Template(template_data[key]).render(**context) # 渲染按钮列表 if button_list in template_data: rendered[button_list] [] for btn in template_data[button_list]: rendered_btn {k: v for k, v in btn.items() if k ! name} rendered_btn[name] Template(btn[name]).render(**context) rendered[button_list].append(rendered_btn) # 其他字段直传 for key in [thumbnail_media_id, card_type]: if key in template_data: rendered[key] template_data[key] rendered[card_type] link # 抖音当前仅支持 link 类型 return rendered # 使用示例 # card render_card(shipping_info, user_nickname李四) # print(card[title]) # 发货说明此设计让「业务逻辑」匹配规则与「展示逻辑」卡片样式彻底分离。运营改文案不用动代码只需改 YAML开发新增卡片类型只需加 YAML 条目。3.2 实现意图识别与卡片路由意图识别不追求 NLP 大模型用「关键词白名单 正则兜底」足够覆盖 90% 场景。我们定义一个IntentRouter类支持多级匹配import re from typing import List, Dict, Optional class IntentRouter: def __init__(self): # 关键词匹配规则优先级从高到低 self.rules [ (r(发货|快递|物流|什么时候到), shipping_info), (r(价格|多少钱|贵吗|便宜吗), price_query), (r(教程|怎么用|不会用|操作步骤), tutorial), (r(定制|私人|专属|能不能改), customization), (r(退款|退货|不想要了), refund_policy), ] def route(self, content: str) - Optional[str]: 根据用户消息内容返回匹配的卡片模板名 :param content: 用户发送的纯文本已去空格、转小写 :return: 模板名如 shipping_info未匹配返回 None clean_content re.sub(r[^\w\u4e00-\u9fff], , content.lower()) for pattern, template_name in self.rules: if re.search(pattern, clean_content): return template_name return None # 全局单例 ROUTER IntentRouter() # 在消息处理路由中调用 app.route(/, methods[POST]) def handle_message(): # ... 解密逻辑见 2.3... plain_msg decrypt_douyin_message(data, encoding_aes_key) content plain_msg.get(Content, ).strip() if not content: return no content, 200 # 路由匹配 template_name ROUTER.route(content) if not template_name: # 未匹配时返回默认兜底卡片如「请描述具体问题」 card render_card(default_fallback, user_nicknameplain_msg.get(FromUserName, 用户)) else: # 动态注入上下文如用户ID用于埋点 card render_card(template_name, user_nicknameplain_msg.get(FromUserName, 用户), msg_idplain_msg.get(MsgId)) # 后续调用 send_card 接口发送见 4.1 return success这里的关键经验是正则模式要「宽进严出」。比如(发货|快递|物流)比(发货时间|快递多久)更鲁棒因为用户输入千奇百怪“啥时候发”“发不发”“能发顺丰吗”过度限定反而漏匹配。血泪经验上线前用真实私信语料跑一遍正则把漏掉的 case 补进规则列表。4. 调用抖音卡片发送接口鉴权、重试与错误归因解密消息、识别意图、生成卡片 JSON 后最后一步是调用抖音开放平台的message/send_card接口完成发送。这步看似简单却是线上最常翻车的环节——token 过期、签名错误、字段缺失、频率超限任何一个都导致卡片发不出用户以为你「已读不回」。我们必须把这步做成「高可靠、可追溯、可降级」的服务。4.1 获取 access_token 并构造签名请求抖音所有服务端 API 都需access_token它由AppIDAppSecret换取有效期 2 小时。不能每次请求都去换必须本地缓存并自动刷新。我们用 Redis 或内存字典做简易缓存生产环境务必用 Redisimport time import requests from urllib.parse import urlencode # 全局缓存 access_token _ACCESS_TOKEN_CACHE {token: , expires_at: 0} def get_access_token(app_id: str, app_secret: str) - str: 获取或复用 access_token :return: 有效的 access_token 字符串 now time.time() if now _ACCESS_TOKEN_CACHE[expires_at] - 60: # 提前60秒刷新 return _ACCESS_TOKEN_CACHE[token] # 调用 token 接口 url fhttps://open.douyin.com/oauth/access_token?{urlencode({ app_id: app_id, app_secret: app_secret, grant_type: client_credential })} resp requests.get(url, timeout5) data resp.json() if access_token not in data: raise RuntimeError(fFailed to get access_token: {data}) _ACCESS_TOKEN_CACHE[token] data[access_token] _ACCESS_TOKEN_CACHE[expires_at] now data[expires_in] return _ACCESS_TOKEN_CACHE[token] def build_signed_params(params: dict, app_secret: str) - dict: 为抖音 API 构造签名参数非 OAuth 签名是消息体签名 抖音要求对所有请求参数除 signature 外按 key 字典序排序后拼接成字符串 再与 app_secret 拼接后计算 sha256 sorted_keys sorted(params.keys()) sign_str .join([f{k}{params[k]} for k in sorted_keys]) app_secret import hashlib signature hashlib.sha256(sign_str.encode()).hexdigest() params[signature] signature return params4.2 发送卡片并实现指数退避重试message/send_card接口要求 POST JSON且必须携带access_token和open_id即用户的FromUserName。我们封装一个健壮的发送函数内置重试与日志import logging from tenacity import retry, stop_after_attempt, wait_exponential logger logging.getLogger(__name__) retry( stopstop_after_attempt(3), waitwait_exponential(multiplier1, min1, max10), reraiseTrue ) def send_douyin_card( app_id: str, app_secret: str, open_id: str, card_data: dict, msg_id: str ) - dict: 发送抖音私信卡片 :param app_id: 抖音应用 AppID :param app_secret: 抖音应用 AppSecret :param open_id: 用户唯一标识FromUserName :param card_data: render_card() 返回的卡片字典 :param msg_id: 原始消息 ID用于日志追踪 :return: 抖音 API 响应 JSON access_token get_access_token(app_id, app_secret) # 构造请求体 payload { open_id: open_id, card: card_data, msg_id: msg_id # 透传原始消息ID便于排查 } # 构造签名抖音要求对 payload 所有字段签名 signed_payload build_signed_params(payload.copy(), app_secret) url fhttps://open.douyin.com/message/send_card?access_token{access_token} headers {Content-Type: application/json} try: resp requests.post( url, jsonsigned_payload, headersheaders, timeout(5, 15) # connect5s, read15s ) result resp.json() if resp.status_code ! 200: logger.error(fHTTP {resp.status_code} from send_card: {result}) raise Exception(fHTTP {resp.status_code}) if result.get(err_no) ! 0: # 抖音业务错误码如 10001access_token无效10002参数错误 logger.error(fAPI error {result[err_no]}: {result[err_msg]} | msg_id{msg_id}) if result[err_no] in [10001, 10002]: # token过期或参数错不重试 raise Exception(fAPI biz error {result[err_no]}) logger.info(fCard sent successfully to {open_id} | msg_id{msg_id}) return result except requests.exceptions.RequestException as e: logger.error(fNetwork error sending card: {e} | msg_id{msg_id}) raise except Exception as e: logger.error(fUnexpected error: {e} | msg_id{msg_id}) raise # 在消息处理中调用 app.route(/, methods[POST]) def handle_message(): # ... 解密、路由、渲染逻辑 ... try: result send_douyin_card( app_idos.getenv(DOUYIN_APP_ID), app_secretos.getenv(DOUYIN_APP_SECRET), open_idplain_msg[FromUserName], card_datacard, msg_idplain_msg[MsgId] ) return success except Exception as e: logger.error(fFailed to send card: {e}) return fail, 500关键参数说明timeout(5,15)是经过压测的经验值——连接超时 5 秒防 DNS 挂读取超时 15 秒防抖音后端慢tenacity重试策略用指数退避1s→2s→4s避免雪崩msg_id全链路透传是后续排查「某条消息为何没发卡」的唯一线索。5. 避坑指南上线前必须验证的 5 个致命细节这套方案看似清晰但我在多个项目中踩过太多坑有些错误抖音不报错、不提示只是静默失败导致你以为功能正常实际用户一条卡片都没收到。以下是上线前必须逐条验证的 5 个致命细节每一条都附带真实翻车现场和血泪修复方案。5.1 现象Webhook 收不到任何推送开放平台显示「验证成功」但无流量原因服务器防火墙或反向代理如 Nginx拦截了抖音 IP 段。抖音 Webhook 的源 IP 不固定但主要来自119.29.0.0/16、121.51.0.0/16、223.252.192.0/18等段官方未完全公开需实测收集。很多云服务商默认只放行常见爬虫 IP抖音 IP 被误杀。解决在服务器安全组和 Nginx 配置中显式放行上述网段。Nginx 示例# 在 server 块内添加 if ($remote_addr !~ ^(119\.29\.\d\.\d|121\.51\.\d\.\d|223\.252\.(192|193|194|195|196|197|198|199|200|201|202|203|204|205|206|207)\.\d)$) { return 403; }注意不要用deny all; allow xxxNginx 的 deny/allow 顺序易出错用if 正则更可控。5.2 现象解密后 JSON 解析失败报JSONDecodeError: Expecting value原因抖音推送的加密消息体是二进制但部分框架如 Flask默认将request.get_data()当作字符串处理导致base64.b64decode()输入乱码。更隐蔽的是EncodingAESKey末尾的符号在某些环境如 Docker env会被截断造成密钥变短解密结果全乱。解决强制request.get_data()为 bytes并校验EncodingAESKey长度app.route(/, methods[POST]) def handle_message(): data request.get_data() # 确保是 bytes不是 str if not isinstance(data, bytes): data data.encode(utf-8) aes_key os.getenv(DOUYIN_AES_KEY, ) if len(aes_key) ! 43: # 抖音要求 43 位含 logger.error(fAES Key length wrong: {len(aes_key)} ! 43) return key error, 400 plain_msg decrypt_douyin_message(data, aes_key) # ...5.3 现象卡片发送成功API 返回err_no0但用户手机端看不到卡片只看到空白或「消息异常」原因card字段中thumbnail_media_id是无效 ID。抖音要求缩略图必须提前通过material/upload接口上传返回的media_id才能用于卡片。很多人直接填了自己服务器图片 URL或用了微信/其他平台的 media_id抖音无法识别。解决上传图片必须走抖音素材接口且图片需满足PNG/JPG 格式、尺寸 ≥ 300×300px、大小 ≤ 5MB。上传后缓存media_idYAML 中只填这个 IDdef upload_thumbnail(image_path: str, app_id: str, app_secret: str) - str: 上传缩略图并返回 media_id access_token get_access_token(app_id, app_secret) with open(image_path, rb) as f: files {image: f} data {type: image} resp requests.post( fhttps://open.douyin.com/material/upload?access_token{access_token}, datadata, filesfiles ) return resp.json()[media_id] # 如 media_abc1235.4 现象用户点击卡片按钮无反应或跳转到错误页面原因按钮url或page_path中含有中文或特殊字符如?、未做 URL 编码。抖音解析时截断导致 H5 页面 404 或小程序路径错误。解决所有url和page_path字段必须urllib.parse.quote()编码from urllib.parse import quote # 在 render_card() 中处理按钮 URL if url in btn: rendered_btn[url] quote(btn[url], safe/:?) if page_path in btn: rendered_btn[page_path] quote(btn[page_path], safe/:?)5.5 现象服务运行几天后突然大量失败日志显示access_token invalid原因access_token缓存未做进程间同步。当服务部署多实例如 Kubernetes 多 Pod每个实例都独立缓存 token但抖音 token 是全局唯一的一个实例刷新后其他实例的旧 token 立即失效导致 50% 请求失败。解决必须用外部存储Redis做分布式缓存。简易 Redis 版本import redis r redis.Redis(hostlocalhost, port6379, db0) def get_access_token_redis(app_id: str, app_secret: str) - str: key fdouyin:token:{app_id} token r.get(key) if token: return token.decode() # 调用接口获取新 token url fhttps://open.douyin.com/oauth/access_token?{urlencode({...})} data requests.get(url).json() r.setex(key, data[expires_in] - 60, data[access_token]) return data[access_token]6. 进阶技巧用「卡片点击事件」反哺用户画像与效果归因自动回复卡片的价值远不止于「省事」。抖音会将用户点击卡片按钮的行为作为独立事件card_click推送到你的 Webhook。这意味着你可以精准知道「哪个卡片被点了」「点了哪个按钮」「谁点的」从而构建比单纯阅读率更真实的用户兴趣图谱。我一般会把这步做成「轻量级埋点管道」不依赖大数据平台用几行代码就跑通闭环。6.1 解析卡片点击事件并打标用户当用户点击卡片按钮抖音推送的card_click事件结构如下{ ToUserName: gh_xxx, FromUserName: oxxx, CreateTime: 1712345678, MsgType: card_click, CardClick: { card_id: card_abc123, button_index: 0, button_name: 立即购买 } }我们在 Webhook 路由中增加对该事件的处理app.route(/, methods[POST]) def handle_message(): data request.get_data() # ... 解密逻辑 ... plain_msg decrypt_douyin_message(data, encoding_aes_key) if plain_msg.get(MsgType) card_click: click_data plain_msg.get(CardClick, {}) user_id plain_msg[FromUserName] button_name click_data.get(button_name, unknown) # 记录到本地日志或写入数据库 logger.info(fCard click: user{user_id} | button{button_name} | card_id{click_data.get(card_id)}) # 关键技巧用 Redis Set 给用户打标签如点击过「立即购买」的用户标记为 high_intent if 立即购买 in button_name: r.sadd(douyin:high_intent_users, user_id) elif 查看物流规则 in button_name: r.sadd(douyin:logistics_users, user_id) return success # ... 原有文本消息处理逻辑 ...6.2 构建「卡片效果看板」三张表搞定归因分析有了点击数据我们不需要 BI 工具用三个 Redis Hash 就能实时统计效果表名用途示例 key-valuedouyin:card_stats卡片总曝光与点击数shipping_info:impression→1247,shipping_info:click→382douyin:button_stats按钮级点击热力shipping_info:btn_0_click→215,shipping_info:btn_1_click→167douyin:user_journey用户行为序列最近3次oxxx:last_3_actions→[price_query, card_click:btn_0, order_submit]更新逻辑嵌入发送和点击处理中def record_card_impression(card_template: str, user_id: str): 记录卡片曝光每次 send_card 调用时触发 pipe r.pipeline() pipe.hincrby(douyin:card_stats, f{card_template}:impression, 1) pipe.hset(douyin:user_journey, user_id, f{card_template}:impression) pipe.execute() def record_button_click(card_template: str, button_index: int): 记录按钮点击 pipe r.pipeline() pipe.hincrby(douyin:card_stats, f{card_template}:click, 1) pipe.hincrby(douyin:button_stats, f{card_template}:btn_{button_index}_click, 1) pipe.execute()每天凌晨用脚本导出douyin:card_stats到 CSV导入 Excel 做简单透视哪张卡片 CTR点击率最低哪个按钮没人点结合用户标签还能做「高意向用户召回」——比如给sadd(douyin:high_intent_users)里的用户下次私信自动发「限时优惠」卡片而不是泛泛的「欢迎咨询」。最后说句实在话这套代码我最早在模拟项目X里验证时只花了半天就跑通首条卡片后来在某高校实验室的招生咨询号上部署把人工回复压力降低了 70%现在它是我给客户交付的 SaaS 工具的标准模块之一。它不炫技但极其扎实——没有一行代码是「为了看起来高级」而写的每一行都对应一个真实场景里的「必须解决的问题」。如果你也正被私信淹没别犹豫就从flask pycryptodome redis这个最小技术栈开始把第一张卡片发出去。那声「叮」就是效率革命的起点。希望帮到你。本文还有配套的精品资源点击获取