简介:一套面向企业级CRM与全渠道营销的Odoo社交集成模块,覆盖微信服务号、微信小程序、企业微信、抖音号、抖音小程序、飞书H5、钉钉等主流社交渠道,并融合AI智能体提供智能问答、客户数据分析与自动化营销能力。包内共267个文件、3.12MB,内容包括96个Python业务代码文件、49个XML视图与权限数据、35个PNG及26个JPG界面素材、16个Markdown说明文档、11个JavaScript交互脚本、8个HTML页面、6个CSS样式和8个CSV配置,目录按功能模块划分,便于理解代码结构和二次开发。目前已有50人学习/下载,适合Odoo实施工程师、企业数字化团队以及希望打通多平台社交客户关系的开发者使用。压缩包中同时提供完整的社交渠道接入代码、前端模板、权限配置和AI交互示例,能够显著降低统一管理客户、执行全渠道自动化营销的落地成本,是一份实践性很强的社交SCRM参考资料。
1. 把多平台社交会话收口到 Odoo:一个消息就是一个潜在客户
做过私域运营的人都有体会:客户在微信服务号里问一句价格,在抖音小店里留一条私信,又在钉钉群里艾特了客服,而你需要在三个后台之间反复切换,才能拼出这个人完整的咨询轨迹。真正的问题是——渠道越多,客户画像越碎,营销动作越难自动化。这个 Odoo 社交集成模块就是把微信服务号、微信小程序、企业微信、抖音号、抖音小程序、飞书、H5、钉钉这些社交渠道的消息统一收口到 Odoo 的客户关系管理体系中,再叠加 AI 智能体做意图识别和自动应答,实现从「渠道消息」到「可运营客户」再到「自动化营销」的完整闭环。它适合已经在用 Odoo 做内部管理、又想对外打通社交渠道的企业信息化团队,也适合做私域代运营的服务商。
2. 渠道接入层:统一数据模型与 Webhook 验签的落地设计
2.1 消息模型的统一抽象:为什么不能每个渠道建一张表
先看一个最常见的翻车设计:微信消息建一张表,抖音消息建一张表,钉钉再建一张表,表结构各写各的,字段名都不统一。短期能跑,等到要做全渠道客户视图的时候,光写数据清洗脚本就能耗掉一周。
这个模块的做法是定义一个统一的社交消息模型,所有渠道的消息进来后都转换成同一套字段。我在接手此类改造时,一般会先建四个基础模型:
class SocialChannel(models.Model): _name = 'social.channel' _description = '社交渠道配置' name = fields.Char(string='渠道名称', required=True) platform = fields.Selection([ ('wechat_service', '微信服务号'), ('wechat_mini', '微信小程序'), ('wechat_work', '企业微信'), ('douyin', '抖音号'), ('douyin_mini', '抖音小程序'), ('feishu', '飞书'), ('dingtalk', '钉钉'), ('h5', 'H5网页') ], string='平台类型', required=True) app_id = fields.Char(string='AppID/CorpID') app_secret = fields.Char(string='AppSecret/CorpSecret') webhook_token = fields.Char(string='Webhook Token') encoding_aes_key = fields.Char(string='编码密钥') is_active = fields.Boolean(string='启用', default=True)这段代码的核心思路是把不同平台的凭证信息统一收进一张渠道表,platform字段用 Selection 来限定枚举值,后续无论是做消息路由还是做渠道维度分析,都可以直接按这个字段筛选。
参数说明里重点看webhook_token和encoding_aes_key。微信服务号和企业微信的服务器配置需要用到 Token 做验证,而encoding_aes_key是消息加解密的安全密钥,长度固定 43 位。很多人在配置时把这个密钥漏填或者填错,导致消息推送一直失败,后面避坑章节会专门讲。is_active这个字段是给自己留后门用的——某渠道出问题需要临时断流时,直接在界面上关掉开关,不需要改代码重启服务。
联系人模型需要在 Odoo 原生的res.partner上做扩展,而不是另建一张独立表。我会加上这些字段:
class ResPartner(models.Model): _inherit = 'res.partner' social_openid = fields.Char(string='OpenID') social_unionid = fields.Char(string='UnionID') social_platform = fields.Char(string='来源平台') social_app_id = fields.Char(string='所属应用')这里要强调social_unionid的价值。微信生态里,同一个用户在不同公众号、小程序、企业微信下的 openid 都不相同,但 unionid 是唯一的。模块通过 unionid 把服务号粉丝和小程序用户关联起来,抖音和钉钉侧再用手机号或用户 ID 做二次匹配。耦合逻辑虽然放在业务层,但数据模型上必须预留这些字段,否则后续做客户去重时只能望着一张满是重复记录的数据库发呆。
2.2 Webhook 回调的端点设计与验签流程
渠道接消息的核心是 Webhook——第三方平台把用户消息推送到你的服务器,你的系统接收后解析并存储。Odoo 里实现 Webhook 端点就是在控制器里写一个路由:
from odoo import http import hashlib import hmac class SocialWebhookController(http.Controller): @http.route('/social/webhook/<string:webhook_token>', type='json', auth='public', methods=['POST'], csrf=False) def social_webhook(self, webhook_token, **kwargs): # 通过 URL 中的 token 定位渠道 channel = http.request.env['social.channel'].sudo().search([ ('webhook_token', '=', webhook_token), ('is_active', '=', True) ]) if not channel: return {'code': 404, 'message': 'channel not found'} # 从请求头中取签名 platform_signature = http.request.httprequest.headers.get('X-Signature', '') raw_data = http.request.httprequest.data # 按平台规则重建签名串 signature_payload = webhook_token + raw_data.decode('utf-8') expected_signature = hashlib.sha256(signature_payload.encode('utf-8')).hexdigest() if platform_signature != expected_signature: return {'code': 401, 'message': 'invalid signature'} # 解析消息并入库(具体解析逻辑按平台分派) message_data = http.request.jsonrequest http.request.env['social.message'].sudo().create_from_platform_payload(channel, message_data) return {'code': 0, 'message': 'success'}这段路由有两个关键点。第一,auth='public'是必须的——第三方平台的服务器没有 Odoo 的登录会话,如果使用默认的auth='user',回调会被重定向到登录页,导致验证永远失败。第二,csrf=False是因为回调请求不带 CSRF Token,Odoo 默认会拒绝这类 POST 请求,这个问题在接入微信时最容易踩到。
验签逻辑各平台有差异。微信的签名机制是 Token、时间戳、随机数按字典序拼接后做 SHA1 加密,抖音则是把所有请求参数按字段名排序后编码拼接再做加密。我在写这类验签时有一条原则:先把平台文档核实清楚,再按「平台要求的最小签名串」来构造,不要为了省事把整个请求体都塞进去,多一个字段都会导致验签失败。
消息入库时,我会在create_from_platform_payload方法里做统一处理——提取消息 ID、发送者标识、消息类型(文本/图片/视频/卡片)、内容正文、时间戳,存进统一的social.message模型。这样前端统一收件箱只需要查一张表,按渠道字段过滤就够。
2.3 双写策略与数据补偿:消息不丢的底线
Webhook 推送有一个天然缺陷——不可靠。第三方平台可能因为网络抖动推送失败,也可能重复推送同一条消息。模块里需要实现两个机制:幂等去重和失败补偿。
class SocialMessage(models.Model): _name = 'social.message' _description = '社交消息' platform_msg_id = fields.Char(string='平台消息ID') channel_id = fields.Many2one('social.channel', string='渠道') conversation_id = fields.Many2one('social.conversation', string='会话') direction = fields.Selection([ ('inbound', '用户发送'), ('outbound', '平台回复') ], string='方向') content = fields.Text(string='内容') msg_type = fields.Selection([ ('text', '文本'), ('image', '图片'), ('video', '视频'), ('link', '链接'), ('event', '事件') ], string='消息类型') raw_payload = fields.Text(string='原始报文')幂等去重的做法是给platform_msg_id建唯一索引,入库前先检查是否已经存在同一条消息。为什么需要保留raw_payload?因为平台升级消息格式或你需要排查解析 bug 时,原始报文是最好的排查依据。我见过很多团队把原始报文丢弃只留解析后的字段,等线上出问题再找平台帮忙查日志,对方一句「我们只保留最近三天」就直接卡死。
双写策略的具体做法是:本地入库后再往 Redis 或队列里推一份完整报文,如果入库逻辑发生异常,定时任务可以从队列里捞出原始数据重新入一次。这个补偿窗口通常保留 24 小时,足以覆盖大部分网络抖动场景。
3. 部署配置:从模块安装到全渠道消息进 Odoo
3.1 环境准备与模块安装的完整流程
在正式安装模块之前,先确认 Odoo 本体能正常运行。这个模块基于 Odoo 的现有框架开发,对版本的要求跟随 Odoo 主版本走,建议使用较新的稳定版本。部署路径可以独立创建一个custom_addons目录,把模块解压进去:
mkdir -p /opt/odoo/custom_addons cd /opt/odoo/custom_addons unzip /data/downloads/odoo_social_integration_module.zip # 检查目录结构是否正确 ls -la /opt/odoo/custom_addons/ # 期望看到模块目录,里面包含 __manifest__.py 和 models/、controllers/ 等子目录模块目录的结构检查非常关键。我遇到过多次解压后多套了一层目录的情况——压缩包内是先有一个外层文件夹再套模块本体,Odoo 启动时扫描不到模块,排查半天才发现路径层级不对。正确做法是确认__manifest__.py文件所在的目录名就是模块名,且该目录直接位于custom_addons之下。
安装模块用命令行参数:
cd /opt/odoo python3 odoo-bin -c /etc/odoo.conf -u all -d yourdbname --addons-path=/opt/odoo/addons,/opt/odoo/custom_addons --stop-after-init-u all表示升级全部模块并应用新模块的数据库变更,首次安装时可以只升级这个模块的依赖项,但实际操作中我习惯直接-u all避免依赖冲突。--stop-after-init让初始化完成后自动退出,不会驻留后台进程。
安装完成后到 Odoo 后台的应用列表里确认模块显示为「已安装」状态,再检查数据库层面是否创建了social_channel、social_message等表。这一步用 SQL 验证最直观:
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'social_%';预期应返回至少三张表。如果发现social_trigger或social_ai_rule等营销自动化相关的表缺失,说明模块的可选依赖没有完整安装,需要回到后台把所有依赖项一并勾选安装。
3.2 微信服务号接入:服务器配置验证失败的三种场景
微信服务号是所有渠道里最常用但也最容易卡住的。接入前需要准备 AppID、AppSecret、服务器 Token 和 EncodingAESKey。在公众平台后台的「设置与开发-基本配置-服务器配置」里填写回调 URL 并启用。
回调 URL 的格式为:
https://yourdomain.com/social/webhook/your_webhook_token_valueURL 中的your_webhook_token_value要和渠道配置里的webhook_token保持一致。启用时微信会向这个 URL 发送一次 GET 请求并携带signature、timestamp、nonce、echostr参数,验证通过后才会正式启用。
我习惯把 GET 验证的验签逻辑也放在同一个控制器里做兼容:
@http.route('/social/webhook/<string:webhook_token>', type='http', auth='public', methods=['GET'], csrf=False) def wechat_verify(self, webhook_token, **kwargs): # 微信服务器配置验证时的 GET 请求,需要原样返回 echostr signature = kwargs.get('signature', '') timestamp = kwargs.get('timestamp', '') nonce = kwargs.get('nonce', '') echostr = kwargs.get('echostr', '') channel = http.request.env['social.channel'].sudo().search([ ('webhook_token', '=', webhook_token) ]) if not channel: return 'error' token = channel.webhook_token # 按微信规则:token、timestamp、nonce 字典序排序后做 SHA1 sort_list = sorted([token, timestamp, nonce]) sort_str = ''.join(sort_list) hash_str = hashlib.sha1(sort_str.encode('utf-8')).hexdigest() if hash_str == signature: return echostr return 'error'这三个参数——signature、timestamp、nonce——是微信验签的核心。如果 Token 填错,hash_str 永远对不上;如果服务器时间不同步,timestamp 偏差超过一定范围也会导致验签失败。前者检查渠道配置里的 Token 是否与公众平台一致,后者用date命令确认服务器时间。
启用服务器配置后,需要在公众平台后台把「服务器配置」开关打开,同时设置「IP 白名单」为 Odoo 所在服务器的公网 IP。不设白名单时,两个后果很典型:一是调用https://api.weixin.qq.com/cgi-bin/token获取 access_token 时报 40164 错误,二是后续回复消息时获取不到access_token。
3.3 企业微信、小程序、抖音小程序的差异化配置要点
各平台验证方式差异较大,我把常用几个渠道的关键参数整理成表方便对照。
| 平台 | 接入凭证 | 回调/验签方式 | 特有配置 |
|---|---|---|---|
| 微信服务号 | AppID + AppSecret | Token + EncodingAESKey,SHA1 验签 | IP 白名单,服务器域名备案 |
| 微信小程序 | AppID + AppSecret | 调用 code2Session 换取 openid,无服务器回调 | 消息推送需订阅消息模板 |
| 企业微信 | CorpID + CorpSecret + AgentId | Token + EncodingAESKey,加解密模式 | 可信域名和接收消息服务器配置 |
| 抖音小程序 | ClientKey + ClientSecret | 请求参数排序 + HMAC-SHA256 | 消息推送需在后台开启主动订阅 |
| 飞书 | AppID + AppSecret | VerToken 验签 | 事件订阅需配置加密策略 |
| 钉钉 | AppKey + AppSecret | 签名计算 + 时间戳防重放 | 企业内部应用需单独添加权限 |
企业微信的服务商模式接入时,corp_id和agent_id是两个独立的凭证,很多团队把agent_id当成corp_id的子字段直接拼接进 URL,导致回调请求 401。正确做法是:corp_id在企业管理后台「我的企业」里查,agent_id在「应用管理」里查,两者分开配置。
小程序的消息类型里,用户主动发送消息在code2Session中就能拿到openid,模块内部通过 session 保持连接,不需要额外的 Webhook 验签流程。这里有个细节常被忽略——小程序端每次需要wx.login()重新拿 code,后端再换取 openid,所以模块的会话保持需要和前端配合,不能只靠前端缓存。
抖音小程序的订阅消息机制比较特殊,用户下单或提交表单后,需要拿到订阅授权才能主动推送。模块的处理方式是把抖音消息主动推送的授权状态记录在social_contact的扩展字段里,发送前检查授权状态,没有授权则降级为静默记录。
3.4 H5 与钉钉飞书的特殊处理:从「被动收消息」到「主动推消息」
H5 渠道没有原生消息推送机制,模块的 H5 接入本质上是一个嵌入 Odoo 的网页会话窗口。用户在前端页面发起咨询,请求打到模块的公共路由上,模块拿到访客 IP、浏览器指纹、来源页面 URL 后创建一个匿名联系人并建立会话。等到用户填了手机号或完成注册,再把这个匿名会话合并到正式联系人的会话列表中。
钉钉和飞书类似,都支持企业内部应用的事件订阅。钉钉的回调验签使用的是签名计算方式,需要在请求头里带timestamp和sign。飞书则是在后台设置 Encrypt Key 后,所有事件回调的请求体都会经过 AES 加密。模块的控制器里对这两个平台专门做了分支处理,加密报文先解密再解析为统一消息对象。
钉钉有一个坑需要特别注意:企业内部应用请求钉钉的 API 需要添加对应的权限点,单独申请某个科目的数据权限没有用,必须把「通讯录只读」和「消息发送」这两个权限点都加上,否则调用sendMessage接口时一直返回「没有操作权限」。权限点在钉钉开放平台的「权限管理」里全量勾选后重新发布应用才会生效。
4. AI 智能体与营销自动化:意图识别先于人工介入
4.1 会话生命周期的划分:从首次响应到线索成熟
渠道接通,消息开始进入 Odoo,下一个问题就是如何把这些零散消息变成可运营的客户生命周期。模块把每个会话划分成几个阶段:新建(new)、待分配(pending)、处理中(processing)、已解决(resolved)、已关闭(closed)。
class SocialConversation(models.Model): _name = 'social.conversation' _description = '社交会话' partner_id = fields.Many2one('res.partner', string='关联客户') channel_id = fields.Many2one('social.channel', string='来源渠道') stage = fields.Selection([ ('new', '新建'), ('pending', '待分配'), ('processing', '处理中'), ('resolved', '已解决'), ('closed', '已关闭') ], string='会话阶段', default='new') assignee_id = fields.Many2one('res.users', string='处理人') priority = fields.Selection([ ('low', '低'), ('medium', '中'), ('high', '高'), ('urgent', '紧急') ], string='优先级', default='medium')阶段的推进逻辑是在模型里通过状态机控制的。新建会话自动进入待分配队列,AI 智能体判断是否需要人工介入:能解答的常见问题直接回复并推进到「已解决」;识别到复杂的投诉或咨询,才会打上高优先级标记并推送给在线客服。
首次响应时长这个指标在会话模型里通过计算字段自动统计:从会话创建时间到首条人工回复时间之间的间隔。我在实际运营中会把这个指标单独拉成看板视图,按渠道分组展示。微信和 H5 渠道的首次响应普遍在 30 秒以内,企业微信由于有内部员工在线,可以提供接近实时的响应能力。
4.2 规则触发器:基于事件和客户标签的自动化操作
有了会话和客户数据,营销自动化就顺理成章。模块内置了触发器机制——按事件类型(收到关键词、进入会话、完成下单)和客户属性(标签、渠道、历史消费)匹配规则,执行一系列动作(自动回复、修改标签、创建工单、推送群发消息)。
class SocialTrigger(models.Model): _name = 'social.trigger' _description = '自动化触发器' name = fields.Char(string='规则名称', required=True) event_type = fields.Selection([ ('keyword', '关键词触发'), ('conversation_start', '会话开始'), ('tag_added', '标签变更'), ('order_paid', '订单支付完成') ], string='事件类型', required=True) keyword = fields.Char(string='匹配关键词') match_mode = fields.Selection([ ('exact', '精确匹配'), ('fuzzy', '模糊匹配'), ('regex', '正则匹配') ], string='匹配方式', default='fuzzy') action_type = fields.Selection([ ('reply_text', '自动回复文本'), ('add_tag', '添加标签'), ('create_task', '创建任务'), ('change_stage', '变更会话阶段'), ('send_marketing', '发送营销消息') ], string='动作类型', required=True) action_params = fields.Text(string='动作参数(JSON)') is_active = fields.Boolean(string='启用', default=True)写规则时,match_mode的选择直接影响命中率。精确匹配适合「退款」这类明确指令;模糊匹配适合「怎么开发票」「发票怎么开」这类变形表达;正则匹配则用于复杂的模式判断,比如「订单号 ORD-2025- 开头后面跟 6 位数字」。我建议把高频业务场景各建一条规则,配合 AI 意图识别做组合,比只靠单一关键词触发要稳得多。
action_params字段存 JSON 格式的动作参数。自动回复文本时,这个 JSON 是{"content": "您的订单已受理,退款将在1-3个工作日内原路返回"};发送营销消息时,则是{"template_id": "xxx", "data": {"product_url": "https://..."}}。我把动作参数统一用 JSON 存储而不是单独建关联表,省去大量字段扩展,代价是配置时需要手写 JSON,对运营人员有一定门槛。
4.3 AI 智能体的接入:意图识别模型与人工兜底
AI 智能体是这个模块区别于普通 SCRM 的核心。它的职责有三层:意图识别、自动应答、线索评分。接入方式采用可配置的模型服务地址,模块通过 HTTP 调用大模型接口或者其他推理服务。
import json import requests class SocialAiAgent(models.Model): _name = 'social.ai.agent' _description = 'AI智能体配置' name = fields.Char(string='智能体名称') model_provider = fields.Selection([ ('openai_compatible', 'OpenAI兼容接口'), ('local_http', '本地HTTP服务'), ('internal_knlg', '内置知识库引擎') ], string='模型服务类型') api_base = fields.Char(string='API地址') api_key = fields.Char(string='API密钥') model_name = fields.Char(string='模型名称') confidence_threshold = fields.Float(string='置信度阈值', default=0.7) fallback_user_id = fields.Many2one('res.users', string='兜底人工客服') def call_ai_intent(self, message_text): # 调 AI 服务识别用户意图 headers = { 'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json' } payload = { 'model': self.model_name, 'messages': [ {'role': 'system', 'content': '你是企业社交客户关系管理的意图识别助手,请判断用户意图'}, {'role': 'user', 'content': message_text} ], 'temperature': 0.1 } try: resp = requests.post( f'{self.api_base}/chat/completions', headers=headers, json=payload, timeout=5 ) resp.raise_for_status() result = resp.json() intent_text = result['choices'][0]['message']['content'] # 简单解析:返回 JSON 结构 { "intent": "after_sales", "confidence": 0.85 } intent_data = json.loads(intent_text) if intent_data['confidence'] < self.confidence_threshold: # 置信度不达标,直接转人工 return { 'action': 'transfer_to_human', 'assignee_id': self.fallback_user_id.id, 'intent': intent_data.get('intent', 'unknown'), 'confidence': intent_data['confidence'] } return intent_data except Exception: # 超时或服务不可用时的降级策略:不阻塞消息流转 return { 'action': 'transfer_to_human', 'assignee_id': self.fallback_user_id.id, 'intent': 'unknown', 'confidence': 0 }参数上有几个点需要现场调整:temperature我固定设置 0.1 而不是默认值 1.0——意图识别任务需要确定性输出,过高的 temperature 会让同一个问题在不同时间返回不同意图。timeout设 5 秒,超过 5 秒直接降级转人工,避免用户等太久。
confidence_threshold的默认值是 0.7,实际运营时建议从 0.85 起步观察一周,准确率高再逐步下调。这个值是动态调参的关键——调太低,AI 会把无关问题当常见问题答;调太高,AI 能解决的重复咨询又都转给了人工,渠道成本降不下来。
AI 应答链路在模块里是这样走的:用户消息进来 → 在social.message中入库 → 调用 AI 意图识别 → 命中知识库直接回复 → 未命中转人工分配。如果企业已经有训练好的内部知识库,可以在internal_knlg模式下直接把问题映射到知识库条目,不经过外部大模型服务,响应速度会快很多且不需要 API 调用费用。
4.4 全渠道营销自动化的执行链路
营销自动化的执行链路在模块中按"触发 — 筛选 — 执行 — 回收"四步走。触发由social_trigger完成,筛选通过客户标签和渠道条件过滤受众,执行包括消息群发、标签修改、工单创建等动作,回收则是统计发送成功率、打开率、回复率回写到营销活动记录。
群发消息时有一个重要约束——各平台对主动推送的频率限制差异较大。微信服务号每月只能群发 4 次,企业微信每个客户每天最多收到 1 条群发消息,抖音私信对未关注用户只能发送 3 条。模块提供的频率控制机制按平台和联系人的粒度做计数,超过限额的消息写入social_marketing_message的状态为throttled,等待下一个窗口期补发。
我这里会刻意把营销活动的目标受众圈定在「过去 30 天有交互」的联系人上。全渠道消息收进来了,客户画像比以往任何时候都完整,但也意味着你要控制打扰频次。把一个低意向线索从「沉默」状态直接推到群发名单里,退订率上升几乎是必然的。
5. 避坑排查:验签失败、消息重复、AI 误答的三类现场
5.1 微信验签总是失败
现象:公众平台服务器配置点击提交时,一直提示「Token 验证失败」。
原因排查:第一反应是检查 Odoo 日志,发现根本没有收到 GET 请求;或者收到了请求但返回的echostr跟微信期望的不一致。后者的常见原因是控制器的type='json'参数导致 GET 请求被强制按 JSON 解析,echostr没有原样返回。
解决:GET 验证路由必须使用type='http'而不是type='json',并且返回体用纯字符串而不是字典。在控制器里同时保留 GET 和 POST 两个方法体,通过methods参数区分。
5.2 文本消息收得到,图片消息一直丢
现象:服务号配置成功后,用户发文字正常入库,但发图片时 Odoo 侧没有任何记录。
原因:微信的图片消息在推送的 XML 里只有media_id,没有图片 URL。模块要下载图片必须调用media/get接口换取临时链接,这一步需要有效的access_token。而access_token的获取受 IP 白名单限制,服务器 IP 不在白名单里就静默失败。
解决:先把服务器公网 IP 加到微信公众平台「基本配置-IP 白名单」里,再在模块日志里确认调access_token接口是否返回了errcode字段。access_token的有效期是 7200 秒,模块里要有缓存,避免每次下载图片都重新换取。
5.3 同一条消息被重复创建 N 次
现象:排查数据库时发现social_message表里同一条用户消息出现了多条记录,platform_msg_id字段的值完全相同。
原因:第三方平台在回调失败后会按退避策略重试推送,重试次数可能多达 3 到 5 次。如果入库逻辑没有检查platform_msg_id的唯一性,每次重试都会新增一条记录。
解决:给platform_msg_id和channel_id建联合唯一索引。入库前先查询该platform_msg_id是否已存在,存在直接丢弃。注意唯一索引只能在两者一起使用时才能准确去重,因为不同渠道的消息 ID 可能重复。
ALTER TABLE social_message ADD CONSTRAINT unique_platform_msg_per_channel UNIQUE (platform_msg_id, channel_id);SQL 能保证数据库层面的唯一性,但应用层也应该先查重再插入,避免直接抛唯一约束异常返回给平台。
5.4 AI 智能体把「我要退款」答成「订单查询」
现象:用户发送「这个质量太差了我要退款」,AI 自动回复了一段订单查询指引,没有触发售后流程。
原因:意图识别模型的置信度高于阈值,但意图判断不准确。问题出在 prompt 设计上——系统提示词里没有强调「退款、退货、投诉、质量问题」必须归入after_sales意图,模型把文本里的「订单」当成了查询意图的触发词。
解决:重写系统提示词,把高优意图的触发条件写清楚,并让模型在无法确定时降低置信度输出而不是猜一个。另外设置一个「意图映射表」配置界面,把平台高频问题手工映射到正确的意图标签上,AI 识别结果回来后再经过映射表二次修正。
5.5 钉钉群消息回调时区差异导致时间错乱
现象:钉钉群里用户发消息,Odoo 消息记录里的时间比实际时间早了 8 小时。
原因:钉钉回调的时间戳用的是毫秒级 Unix 时间,有些渠道用的是秒级。模块统一按秒级处理,钉钉的时间戳没有除以 1000,导致解析后的时间变成 1970 年左右或者提前 8 小时。
解决:在平台解析代码里单独处理时间字段,钉钉的timestamp字段先除以 1000 再转成datetime。顺手在模型里加一条 SQL 检查所有消息的时间分布,如果出现大量早于 2020 年的记录,基本可以断定是时间单位转换问题。
5.6 消息时序错乱和幂等处理
多平台并发推送时会出现跨渠道的消息乱序。比如用户先在企业微信问了问题,又在抖音留了言,两条消息的入库时间可能因为网络延迟发生颠倒。模块里用received_at字段记录第三方平台推送的原生时间戳,而不是用 Odoo 的create_date来排序。展示会话时按received_at升序排列,这样即使入库顺序有偏差,前端展示仍然是正确的时间线。
幂等处理除了唯一索引外,还有一个边界:抖音的私信事件和普通消息事件会在同一回调里推送,模块需要分别处理事件和消息两类 payload,各自独立去重。如果只做消息表去重而忽略事件表,用户进入直播间这类事件会被重复记录,后续营销自动化的触发条件就可能被错误激活。
5.7 日志定位:从 traceback 到 message_id 的全链路追踪
排查问题时我习惯给每条入站消息分配一个内部消息 ID,格式是SOC-{channel_id}-{platform_msg_id},在日志里输出这个 ID 后,整个链路的任何异常都可以通过它串联。
# 查看模块相关日志 grep 'social_message' /var/log/odoo/odoo.log | tail -n 100 # 按内部消息 ID 查全链路 grep 'SOC-3-478293847' /var/log/odoo/odoo.log # 查看 Webhook 控制器调用记录 grep 'social/webhook' /var/log/odoo/odoo.log | tail -n 20日志追查有个原则:Odoo 原生日志只能看到控制器入口和 SQL 语句,平台回调和模块内部的解析细节必须自己在代码里加_logger.info()打印。我通常会在验签入口、消息解析成功后、AI 调用前后各加一条日志,记录关键字段的摘要。线上运行稳定后再按日志级别把 debug 信息关掉,避免日志量过大。
6. 进阶技巧:会话路由优先级的动态控制与灰度验证
渠道越接越多,路由策略就不是「有用就行」,而是要精确管控。我给这个模块做扩展时常加一个会话路由权重表,控制不同渠道、不同时段下的消息分配策略。
class SocialRoutingStrategy(models.Model): _name = 'social.routing.strategy' _description = '路由策略' channel_id = fields.Many2one('social.channel', string='渠道') time_start = fields.Float(string='开始时间(小时)') time_end = fields.Float(string='结束时间(小时)') ai_ratio = fields.Float(string='AI应答比例', default=0.6) human_priority = fields.Boolean(string='人工优先', default=False) max_queue_length = fields.Integer(string='排队上限', default=10) def get_route_strategy(self, conversation): # 按当前时间和渠道取匹配的路由策略 hour = datetime.now().hour + datetime.now().minute / 60.0 channel = conversation.channel_id strategies = self.env['social.routing.strategy'].sudo().search([ ('channel_id', '=', channel.id), ('time_start', '<=', hour), ('time_end', '>=', hour) ], order='time_start desc', limit=1) if not strategies: # 默认策略:AI 应答 60%,人工 40% return { 'ai_ratio': 0.6, 'human_priority': False, 'max_queue_length': 10 } return { 'ai_ratio': strategies.ai_ratio, 'human_priority': strategies.human_priority, 'max_queue_length': strategies.max_queue_length }动态路由的核心逻辑就是这条get_route_strategy函数。同一天里,凌晨消息少,把ai_ratio调高到 0.8,让 AI 兜住绝大部分咨询;工作日上午十点是咨询高峰期,ai_ratio调到 0.4、human_priority打开,优先保证人工客服能处理高价值线索。
灰度验证的流程是接新渠道时先在抖音号做小流量测试——只接入 5% 的会话进入 AI 应答,观察一周人工介入率和首响时长指标。指标达标再逐步放大到 20%、50%、100%。这里有一个我反复踩坑后形成的习惯:每次改路由策略参数,先在后台界面确认策略生效再重启服务,避免出现配置改了但实际跑的还是上一版策略的情况。某次我在测试环境调了一版ai_ratio但忘了保存,结果线上流量直接按旧策略跑了三天,AI 应答率飙升到 95%,客服团队差点以为系统出了故障。
从那以后我每次改路由参数都强制走一遍:改参数 → 停服务 → 查数据库确认策略记录 → 重启 → 看前 100 条消息的分配日志。这套流程虽然多花两分钟,但能挡掉不少配置类的低级事故。渠道接入的路还长,AI 的阈值、消息的幂等、日志的追踪这些细节做到了,Odoo 社交集成模块才能真正从「能连上」变成「能运转起来」,希望这些思路和踩坑记录能帮你在接入过程中少走几段弯路。
本文还有配套的精品资源,点击获取