简介:这份资源是一套基于Odoo的企业级社交渠道集成方案,面向企业CRM、全渠道营销实施人员及Odoo二次开发工程师。方案聚焦微信服务号、微信小程序、企业微信、抖音号、抖音小程序、飞书H5、钉钉等多平台客户入口分散的痛点,将各渠道会话、客户与订单数据统一收口到Odoo后台,并引入AI智能体完成客户分群、行为预测、智能问答和自动化营销执行,形成从引流、互动到转化的闭环。压缩包共267个文件,大小3.12MB;其中96个py实现后端业务逻辑与多平台接入适配,49个xml搭建视图、菜单和权限模型,11个js与8个html支撑前端交互和页面展示,8个csv用于初始化数据和访问控制,16个markdown文档便于快速上手,其余png/jpg截图和css样式辅助界面调试与二次开发。当前已有50人浏览学习。通过包内源码与文档,可了解多平台社交渠道的Odoo模块设计思路、AI智能体融合方案,并能直接参考源码和文档进行企业内部门户集成、营销流程自动化部署或二次功能扩展。
1. 多平台社交渠道集成:Odoo 这件“小事”到底卡在哪?
做企业级 SCRM 选型时,很多团队第一反应是先对比各家营销平台,最后发现卡点根本不在营销,而在“渠道接入”:微信服务号、微信小程序、企业微信、抖音号、抖音小程序、飞书、钉钉、H5 都有各自的登录态、消息回调、开放接口,想在一套后台里统一管理,先得把这些渠道各自的 API 拼起来,再搭出统一的会话和线索模型,最后才谈得上营销自动化和 AI 智能体融合。这个标题指向的正是 Odoo 生态里做这件事的集成方案:以 Odoo 为中心,把多平台社交渠道收进来,用一套 CRM 数据模型承载全渠道客户关系,再用自动化规则和 AI 智能体补上运营人力缺口。适合正在评估 Odoo 做 SCRM 底座的中小企业、实施顾问和独立开发者。值不值得投入,取决于你愿不愿意接受一个前提:渠道接入只是骨架,真正的资产是沉淀下来的统一客户视图。
2. 渠道接入前先定架构:令牌统一存储、定时刷新与适配器设计
2.1 所有渠道的命门都是同一个:access_token 的统一存储
接入任何社交平台,第一步都不是写消息接口,而是管理令牌。微信服务号和小程序的 access_token 有效期约 7200 秒,企业微信的 corp access_token 同样只有两小时,抖音、飞书、钉钉的令牌也多在 7200 秒上下,少数渠道能到 24 小时。这意味着你的 Odoo 实例每隔一两个小时就要主动去换一次令牌,换晚了,所有消息拉取、发送、用户信息查询全部报 401。
各平台的授权机制差异很大,但落到数据模型上是同一件事:存渠道、存账号、存令牌、存过期时间。我一般会单独建两张表,渠道表和令牌表分开,因为一个企业主体下可能有多个微信小程序、多个抖音号,一个渠道账号也可能挂在多个开放应用下,混在一张表里后期改权限会非常痛苦。
# odoo/addons/social_channel/models/social_token.py from odoo import models, fields class SocialToken(models.Model): _name = 'social.token' _description = '渠道授权令牌' channel_id = fields.Many2one('social.channel', string='渠道', required=True) account_id = fields.Many2one('social.account', string='渠道账号', required=True) access_token = fields.Text(string='访问令牌') refresh_token = fields.Text(string='刷新令牌') expires_in = fields.Integer(string='有效期(秒)', default=7200) expires_at = fields.Datetime(string='过期时间点', index=True) last_refresh_at = fields.Datetime(string='最近刷新时间') refresh_count = fields.Integer(string='刷新次数', default=0) last_error = fields.Text(string='最近错误')这段模型设计的核心是把令牌从业务数据里拆出来。令牌刷新是高频写操作,消息收发也是高频写操作,如果放在同一张表,事务锁会互相拖累;拆开后令牌表可以单独做缓存和重试策略,业务表只关心 token 对象是否还有效。
参数说明:expires_at 不要简单存「当前时间 + expires_in」,要留 300 秒的安全余量,也就是按now + expires_in - 300写入。原因是各平台返回的 expires_in 是服务端视角,你的服务器时钟和平台可能有偏差,消息发到一半令牌失效是最尴尬的现场事故。
提示:多账号池轮换场景下,每个账号的令牌必须独立存储、独立刷新。共用一张令牌记录会直接导致渠道 A 的刷新把渠道 B 的令牌顶掉,这类问题非常难排查。
2.2 定时刷新任务:把“被动过期”变成“主动续期”
令牌不能等用的时候发现过期了再去刷新,而是要用 Odoo 的 ir.cron 定时任务提前扫一遍。常见的做法是每 5 分钟扫一次令牌表,把所有即将在 5 分钟内过期的令牌主动刷新掉。刷新失败要留痕迹,不能只写日志,因为渠道账号一般在运营手里,运维看不到 Odoo 日志。
# odoo/addons/social_channel/models/social_account.py from odoo import models, api from datetime import datetime, timedelta class SocialAccount(models.Model): _inherit = 'social.account' @api.model def _cron_refresh_tokens(self): tokens = self.env['social.token'].search([ ('expires_at', '<', datetime.now() + timedelta(minutes=5)), ('refresh_count', '<', 3) ]) for token in tokens: try: token._refresh() # 各渠道子类实现具体刷新逻辑 token.write({ 'last_refresh_at': datetime.now(), 'refresh_count': 0, 'last_error': False, }) except Exception as exc: token.write({ 'last_error': str(exc), 'refresh_count': token.refresh_count + 1, })这段代码的逻辑是:只处理「5 分钟内即将过期」的令牌,避免全表刷一遍触发各平台的频率限制;连续失败 3 次的令牌不再自动重试,而是留给人工处理。在 ir.cron 里创建一条周期任务,执行间隔设 5 分钟,绑定到这个方法即可。
参数说明:为什么失败 3 次就停?因为连续失败大概率不是临时网络问题,而是 AppSecret 被重置、平台侧封禁、或者 IP 白名单变更,继续刷只会浪费调用额度,还可能在平台侧触发风控。
2.3 渠道适配器:抖音、飞书、钉钉和 H5 靠同一套接口接入
令牌管住了,下一步是消息收发。不要为每个渠道写一套业务代码,而是抽象出一个适配器基类,每个渠道一个子类,Odoo 的业务层只依赖基类接口。这样后续加一个新渠道,只是新增一个 Python 文件,不用动客服工作台、不用动自动化营销模块。
# odoo/addons/social_channel/adapter/base.py class SocialAdapter: """渠道适配器基类,所有渠道必须实现三个方法""" def __init__(self, account): self.account = account self.token = account._get_valid_token() def send_message(self, user, content, msg_type='text'): raise NotImplementedError def fetch_messages(self, since=None): raise NotImplementedError def get_user_info(self, external_uid): raise NotImplementedError # odoo/addons/social_channel/adapter/douyin.py class DouyinAdapter(SocialAdapter): def send_message(self, user, content, msg_type='text'): # 抖音私信发送接口,需要用户之前主动发过消息才能回 url = "https://open.douyin.com/api/douyin/v1/im/message/send/" payload = { "access_token": self.token.access_token, "open_id": user.external_uid, "content": content, } return self._post(url, payload)适配器模式的价值在于把渠道差异隔离在子类里。Odoo 的客服工作台只要调用adapter.send_message(...)就可以向任意渠道发消息,营销自动化模块也不用关心这个消息是发到抖音私信还是飞书群聊。
H5 渠道的适配器最特殊。所谓“H5 随意玩”,意思是同一个 H5 页面会被嵌到微信公众号、抖音小程序 web-view、飞书内嵌浏览器、普通浏览器里。每个容器下的登录态完全不一样:微信公众号里走网页授权拿 openid,抖音小程序里要用抖音的 code 换 token,飞书里要拿临时授权码换 user_access_token,而普通浏览器只有 URL 参数。我的做法是 H5 适配器只处理「签名 + 渠道来源参数」:生成链接时带上channel_source和user_id,后端用 AppSecret 做签名校验,页面打开时先校验签名再自动登录,这样用户不用手输账号,运营也不怕参数被伪造。
3. 微信生态三件套:服务号、小程序、企业微信的接入细节
3.1 服务号:消息回调验证与网页授权,两步走
微信服务号是所有渠道里最基础也最容易配置错的一个。配置回调 URL 时,微信服务器会先发一个 GET 请求验证 URL 有效性,你必须在 GET 请求里正确校验 signature 并把 echostr 原样返回,否则配置按钮会直接提示「验证失败」。
# odoo/addons/social_channel/controllers/wechat.py import hashlib from odoo import http class WechatController(http.Controller): @http.route('/social/wechat/callback', type='http', auth='public', methods=['GET', 'POST']) def wechat_callback(self, **kw): signature = kw.get('signature') timestamp = kw.get('timestamp') nonce = kw.get('nonce') echostr = kw.get('echostr') token = self._get_wechat_token() # 微信要求把 token、timestamp、nonce 排序后做 sha1 tmp_list = sorted([token, timestamp, nonce]) tmp_str = ''.join(tmp_list) if hashlib.sha1(tmp_str.encode()).hexdigest() == signature: return echostr # GET 验证通过,原样返回 return 'invalid'这段代码里的_get_wechat_token是你在公众平台后台填写的 Token 值,不是 access_token,很多新手在这里被绕晕。校验顺序错一个字符都过不去,常见翻车是忘记 sorted,直接把三个参数按原顺序拼接。
网页授权走的是另一条链路:用户点击菜单或链接时跳转到微信授权 URL,微信回调时带 code,后端用 code 换 openid。公众号后台的「网页授权域名」只填域名,不要带 http 前缀,这个填错会直接报redirect_uri参数错误。
3.2 小程序登录与手机号获取:wx.login 到 decrypt 的完整链路
小程序渠道里最常被问的就是「怎么拿用户手机号」。这里先明确一个原则:手机号必须用户主动点击授权按钮才能拿到,小程序端不可能静默获取。用户点击授权后,前端会拿到一个code,后端拿这个 code 加上 appid、secret 去微信接口换 session_key,再解密加密数据取出手机号。
// 小程序前端(原生或 uni-app 均可) wx.login({ success(res) { // 第一步:先 login 拿 code const loginCode = res.code // 第二步:用户点击授权按钮后触发 wx.getPhoneNumber({ success(phoneRes) { // 把 loginCode 和 phoneRes.code 一起发给后端 wx.request({ url: 'https://your-odoo-domain/social/mini/phone', data: { code: loginCode, phone_code: phoneRes.code } }) } }) } })Odoo 后端拿到两个 code 后,先用 loginCode 调code2session接口换 openid 和 session_key,再用 phone_code 配合 session_key 解密。这个概念要分清:wx.login换出来的 code 是登录凭证,wx.getPhoneNumber返回的 code 是手机号授权凭证,两个不能混用。
# odoo/addons/social_channel/controllers/mini_program.py import requests, json, base64 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes def decrypt_phone(session_key, encrypted_data, iv): # AES-128-CBC 解密,session_key 来自 code2session 接口 key = base64.b64decode(session_key) encrypted = base64.b64decode(encrypted_data) iv_bytes = base64.b64decode(iv) cipher = Cipher(algorithms.AES(key), modes.CBC(iv_bytes)) ctx = cipher.decryptor() plain = ctx.update(encrypted) + ctx.finalize() # 解密结果是一个 JSON,包含 phoneNumber 和 purePhoneNumber return json.loads(_pkcs7_unpad(plain).decode()) # 注意:phone_code 换 session_key 的接口是 # https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN解密报错 -41003 是高频坑,原因是 phone_code 和 session_key 不匹配。用户点授权后你又调了一次 wx.login,旧 session_key 就失效了。正确做法是:拿到 phone_code 后立刻解密,整个流程中间不要插任何wx.login调用。
提示:调试小程序回调时,先用抓包工具确认两个 code 是否真的发出、后端是否真的收到。很多「解密失败」其实是前端把 code 传丢了,而不是算法问题。
3.3 企业微信:自建应用与客户联系的权限配置
企业微信的接入比服务号多一层:需要先建自建应用,拿到 AgentId 和 Secret,再配置可信 IP 和可信域名。这里的「提示无权限」高发点是可信 IP。企业微信要求调用接口的服务器公网 IP 必须在应用的「企业可信IP」列表里,而且这个列表是按应用维度配置的,不是按企业维度。
常见做法是先用gettoken接口验证 Secret 是否正确,再用一个简单的「获取部门列表」接口验证 IP 白名单是否生效。如果gettoken成功但业务接口报60011无权限,基本可以断定是 IP 白名单或应用权限范围没配好。另外,企业微信的「客户联系」功能需要在管理后台开启,自建应用默认没有客户联系接口权限,要去「客户联系->客户」里把 API 权限授权给对应应用,这一步漏掉的概率非常高。
3.4 飞书与 H5 免登录:一次性 code 的闭环
飞书内嵌 H5 的免登录方案设计得比较干净:前端通过飞书 SDK 拿到一个临时授权码,传给后端,后端拿这个授权码换user_access_token。但授权码是一次性的,而且只有几分钟有效期。H5 页面如果刷新一次,前端又拿新 code 去换,后端必须幂等处理,不能重复入库。
// 飞书 H5 页面内获取临时授权码 import { auth } from '@lark-cloud-dev/feishu-jsapi' auth.getAuthCode().then((res) => { // 拿到的 code 传给 Odoo 后端 /social/feishu/login fetch('/social/feishu/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: res.code }) }) })后端换取成功后,要给前端返回一个 Odoo 侧的会话 token,后续请求都带这个 token,不要再走飞书授权。钉钉的 H5 免登录逻辑类似,但授权码换出来的是用户 unionId,绑定逻辑要根据企业通讯录的 userId 来映射。
这里要特别提醒:H5 渠道的「随意玩」是有代价的。既然是 H5,前端代码就是完全暴露的,渠道来源参数可以被任意伪造。我一般会在生成 H5 链接时拼一个sign=md5(channel_source + user_id + secret),后端每次请求都重新校验签名和时间戳,超过 10 分钟的链接直接拒绝。
4. 从渠道消息到 SCRM 资产:全渠道收件箱、线索合并与营销自动化
4.1 消息标准化:把各渠道消息归一成一条 social_message
渠道接进来之后,最怕的是客服工作台上每个渠道一套字段:微信有 MsgId,抖音有 message_id,飞书有 event_id,钉钉有自己的 messageId。如果不做标准化,后面写营销自动化规则就是噩梦。首先要建一张统一消息表,把各渠道消息归一成/social/message记录。
# odoo/addons/social_channel/models/social_message.py from odoo import models, fields class SocialMessage(models.Model): _name = 'social.message' _description = '全渠道消息' channel_type = fields.Selection([ ('wechat_mp', '微信服务号'), ('wechat_mini', '微信小程序'), ('wechat_work', '企业微信'), ('douyin', '抖音'), ('feishu', '飞书'), ('dingtalk', '钉钉'), ('h5', 'H5'), ], string='渠道类型', required=True) external_id = fields.Char(string='渠道原始消息ID', required=True, index=True) partner_id = fields.Many2one('res.partner', string='关联客户') direction = fields.Selection([('in', '客户进线'), ('out', '客服出线')], string='消息方向') content = fields.Text(string='消息内容') raw_data = fields.Json(string='渠道原始数据') _sql_constraints = [ ('channel_msg_unique', 'unique(channel_type, external_id)', '渠道消息ID不能重复') ]external_id加唯一约束是整个消息模块的第一个保命设计。渠道的 webhook 经常重推事件,微信的客服消息在某些情况下会重复推送,没有这个唯一约束,收件箱会出现双条记录,客服点进去以为是两条不同消息,实际上同一句话回了两遍。
raw_data字段保存渠道原始 JSON,这个字段平时没人看,但排错时是唯一的黑匣子入口。某条消息内容对不上时,把 raw_data 翻出来对照平台文档,比看 Log 快得多。
4.2 客服收件箱的 3 个必调参数:合并窗口、分配轮询、超时提醒
消息标准化完成后,客服工作台要处理的主要是三个策略参数,这套参数我建议上线前就调好,不然后面每天都在改配置。
| 参数 | 常见默认值 | 调整建议 |
|---|---|---|
| 会话合并窗口 | 15 分钟 | 同一位客户在 15 分钟内多个渠道的消息合并成一个会话;电商售后期建议缩到 10 分钟,活动期放大到 30 分钟 |
| 分配轮询间隔 | 30 秒 | 客户进线后每 30 秒检测一次客服在线状态;客服人多时缩短到 10 秒,人少时不建议调,会频繁触发重分配 |
| 超时提醒阈值 | 首响 5 分钟 | 首响指客户发消息后多久必须有客服接;周期响应指会话进行中多久必须回一句;活动大促时首响放宽到 15 分钟,避免客服被提醒淹没 |
合并逻辑本质上是一个时间窗口判断:同一 partner 在窗口内发来的消息归属同一会话,超过窗口且中间没有客服响应,就开新会话。注意这里的 partner 是 Odoores.partner模型,多渠道的客户身份统一靠它关联,微信 openid、抖音 open_id、飞书 user_id 都是挂在同一个 partner 下的不同账号记录。
分配轮询别做得太重。有些团队想自己写一套复杂的「智能分配」,我见过因为分配逻辑太复杂导致消息在队列里躺了 20 分钟没人接的现场。客服工作台这种场景,简单的「在线客服里轮流派给最近回复最少的人」就够用,先跑稳定再谈智能。
4.3 自动化营销触发:不要在模型层写死规则
营销自动化的核心是把「触发条件」和「执行动作」解耦。在 Odoo 里最合适的载体不是 Python 业务代码,而是 Automated Action(自动化动作)加 Server Action。理由是业务人员要能自己改规则,每次改触发条件都提开发单,这个项目就废了。
常见的触发场景这么配置:
| 触发事件 | 判断条件 | 执行动作 |
|---|---|---|
| 客户在小程序提交表单 | 表单类型 = 售后 | 48 小时后未成交则发企微消息 |
| 客户在抖音私信里提到「价格」 | 消息内容包含价格关键词 | 打标签「高意向」,推送销售跟进任务 |
| 飞书 H5 页面停留超 3 分钟 | 页面访问时长 > 180 秒 | 自动推送一张限时优惠券 |
| 全渠道消息 5 分钟未响应 | 最近消息方向 = in 且无客服回复 | 企业微信群通知值班客服 |
Server Action 里写执行逻辑,Automated Action 负责监听。model 层只保留最基础的消息入库和状态变更,任何营销判断都进规则引擎。上线后要监控两类数据:触发成功率(条件命中后动作是否真执行了)和动作成功率(消息是否真发送成功),这两个数对不上,基本就是规则的过滤条件写错了或者渠道令牌又过期了。
5. 避坑:多平台集成里「提示无权限」的 5 个高频根因
5.1 服务号接口报「无权限」:先查权限、白名单、令牌三件事
现象:微信服务号调用接口返回48001或直接提示「api unauthorized」,但网页能打开、消息能收到一部分。
原因:三种情况最多——公众号后台的接口权限没开通(尤其是客服消息和网页授权)、IP 白名单没加你的服务器出口 IP、AppID 与 Secret 不匹配。
解决:先去公众平台「接口权限」页面对照报错接口查权限状态;再把服务器公网 IP 加进「基本配置 -> IP 白名单」;最后用 AppID + Secret 调一次gettoken验证凭证本身。按这个顺序排查,五分钟内能定位。
5.2 access_token 被并发刷新:多 worker 下的令牌竞态
现象:刚刷新过令牌,过两分钟接口又报 401,日志里能看到多个 worker 几乎同时调刷新接口。
原因:Odoo 多 worker 模式下,两个 worker 同时触发 cron 刷新同一个令牌,后刷新者拿到的令牌覆盖了先刷新者的,而客服工作台用的还是旧令牌缓存。
解决:给刷新逻辑加锁。最简单的做法是在social.token表上加一个refreshing布尔字段,刷新前先抢锁,刷新完释放;更稳的是用 Redis 锁配合 expiry。不要依赖 cron 单次执行,除非你明确知道 Odoo 实例是单 worker 部署。
5.3 飞书 / 钉钉免登录的「一次性 code」被重复消费
现象:飞书 H5 页面打开正常,用户刷新一次或从其他入口进入后,后端报invalid code或code already used。
原因:授权码只能换一次 token。前端页面刷新时又触发了一次getAuthCode,而后端没有记住旧 code 已消费,重复换 token 直接失败。
解决:后端加一张social_oauth_code表,记录所有消费过的 code;换 token 前先查状态,已消费则直接返回前端已有的会话 token。同时前端要判断是否已登录,已登录就不再走授权流程。
提示:这里特别容易掉进一个思维陷阱——「换个 code 不就行了吗」。一次授权换一次 token 是平台铁律,开发者要处理的是状态管理,不是「再换一次」。
5.4 小程序手机号解密报 -41003:session_key 过期
现象:前端拿到的手机号加密数据在后端解密时报-41003 invalid aes key,但同一套代码换个测试号又能解密。
原因:session_key 与 phone_code 不是同一次登录产出的。常见操作是用户点授权后前端又调了一次wx.login,导致后端拿到的 session_key 是新的,加密数据是旧的。
解决:手机号授权流程里,严格保持「一次登录 -> 一次授权 -> 一次解密」的顺序。拿到 phone_code 后就不要碰wx.login了,数据到手再考虑刷新登录态。前端代码里把wx.login的调用收敛到一个工具函数里,避免业务组件各自触发。
5.5 企业微信「客户联系」只看到新消息,历史会话是空的
现象:企微接入上线后,客服工作台只能看到新消息,客户之前的聊天记录一条都没有。
原因:企业微信的「客户联系」API 只能拉取接口调用之后产生的新消息,接入前的历史消息不在开放接口的范围内,这是平台侧的数据边界。
解决:上线当天就启动定时拉取任务,把游标存到 Odoo 侧,之后每 5 分钟增量拉一次,这样从接入日起的消息都不会丢。历史消息如果想要,要么走企业微信管理后台导出,要么让客服手动补录,不要指望代码能拉回来,这是平台接口的物理限制,不是你的代码问题。
6. 把 AI 智能体接进全渠道营销编排:意图识别、话术推荐与验收清单
渠道和 SCRM 打通后,AI 智能体放在哪个位置最值钱?我的答案是三个动作:意图识别、话术推荐、客户标签建议。先把历史消息做一个回放测试,把消息文本丢给 LLM,让模型输出意图标签和推荐回复,人工核对命中率。
# 调用 LLM 网关做意图识别与话术推荐 import requests def ai_analyze(message: str) -> dict: resp = requests.post( "https://your-llm-gateway/v1/chat/completions", headers={"Authorization": "Bearer sk-xxx"}, json={ "model": "qwen-plus", "messages": [ {"role": "system", "content": "你是SCRM智能体,输出意图标签、客户情绪、推荐话术,格式为JSON"}, {"role": "user", "content": message} ], "temperature": 0.3, "response_format": {"type": "json_object"} }, timeout=10, ) return resp.json()["choices"][0]["message"]["content"]这个调用里最关键的参数是temperature=0.3。意图识别和话术推荐不是创意写作,温度调高会让模型在相同输入下给出不同的答案,客服没法信任一个「这次说价格敏感、下次说不敏感」的智能体。response_format强制 JSON 输出,是为了直接落库和驱动规则引擎。
上线验证清单就三条:用 1000 条历史会话回放算意图识别命中率,命中率低于 80% 就调 prompt 而不是加规则;上线后先跑「人审 + AI 建议」模式,客服采纳 AI 话术的比例过 60% 再放开一键发送;每周看两个指标——话术采纳率和人工拦截率,采纳率低说明推荐不贴场景,拦截率高说明 AI 越权了。
我第一次把这个智能体放在自动回复位,翻车翻得很惨:意图识别率看着有 87%,但客户根本不买账——因为客户要的不是「识别得准」,而是「回复得像人话」。后来改成「AI 建议、人确认」的模式,客服按得心甘情愿,运营也愿意往里投数据。现在每接入一个新渠道,我做的第一件事不是看接口文档,而是问客服一句话:你们最希望 AI 替你们省下哪一分钟?答案往往比架构设计更决定这个模块的生死。希望帮到你。
本文还有配套的精品资源,点击获取