很多人第一次看 Odoo 的满意度功能,会把它理解成一件很轻的事:
- 发一封“请给服务打分”的邮件;
- 客户点一下笑脸或哭脸;
- 系统保存分数;
- 结束。
但如果你真的顺着 addons/rating/models/mail_thread.py、rating_mixin.py、rating.py、portal_rating/models/rating_rating.py 和 controllers/main.py 看一遍,会发现它并不是“随手发个表单链接”。
Odoo 实际上设计了一条比较完整的链路:
- 业务对象通过
mail.thread暴露评分能力; rating.mixin负责统计字段与展示语义;rating.rating存明细、token、反馈、父级对象引用;- 公共控制器用 token 找到目标评分记录;
- 最后再把结果回写到 chatter,形成业务可追踪的反馈事实。
这套设计值得讲透,因为它解释了几个日常高频问题:
- 为什么同一客户多次触发评分邮件,不一定会生成多条草稿评分;
- 为什么评分链接能公开访问,但又不等于任何人都能替别人评分;
- 为什么评分提交后,业务对象里会自动出现一条 chatter 消息;
- 为什么你看到的
rating_avg、rating_last_value这些字段,并不是“表里直接存好的最终答案”。
一、入口不在 rating.mixin,而在 mail.thread
很多人一看到 rating.mixin 就以为它负责整个评分流程。其实不是。
真正让业务对象“能发评分请求”的关键入口,在 addons/rating/models/mail_thread.py:
rating_ids = fields.One2many('rating.rating', 'res_id', ...)_rating_get_access_token()rating_send_request()rating_apply()
也就是说,评分是先挂在 mail.thread 的交互能力上,再由 rating.mixin 去补统计字段。
这很符合 Odoo 一贯的设计风格:
mail.thread负责消息、通知、chatter 语义;rating模块把“评分”当作一种与消息系统强相关的反馈动作;rating.mixin更像统计视图层,而不是流程入口层。
所以实战里如果你要排查“为什么评分邮件没发出去”或“为什么提交后 chatter 没动”,第一眼不该只盯 rating.mixin,而应该先看 mail_thread.py。
二、access token 不是每次都新建,它优先复用未消费评分
_rating_get_access_token() 这段逻辑很关键。
它先做一件安全上很重要的事:
self.check_access('read')
也就是说,哪怕后面很多动作会 sudo(),调用这个方法的人本身也得对业务记录有读取权限。这一步避免了模板、自动化或别的调用方越权把任意记录的评分链接生成出来。
然后它会:
- 找评分发起对象(通常是
partner_id); - 找被评分对象(通常是
user_id.partner_id); - 在当前记录的
rating_ids里找同一 partner 且consumed = False的草稿评分; - 找到就复用;
- 找不到才新建
rating.rating。
这解释了一个常见现象:
为什么同一张工单/任务/订阅记录,多次发评分邀请,token 看起来没变?
因为官方默认认为:同一个客户对同一个业务对象,如果之前的邀请还没真正提交,就没必要反复生成新草稿。
这不是偷懒,而是避免:
- 产生一堆没人消费的无效 rating 记录;
- 一个对象对应多个并行 token,最后难以判断哪条才是“真实反馈”;
- 邮件提醒频繁重发时把统计口径弄乱。
三、rating.rating 才是真正的事实表
rating.rating 模型里真正定义了评分的事实粒度。字段里最值得注意的有:
res_model/res_id:这条评分具体对应哪个业务对象;parent_res_model/parent_res_id:如果当前对象还要汇总到父对象,父对象是谁;partner_id:谁来打分;rated_partner_id:被谁服务、也就是谁被评分;access_token:公开链接识别用;rating:0 到 5;consumed:这条邀请是否真的被提交;feedback:文本评价;message_id:回写到哪条 chatter 消息。
这里最重要的设计,不是“有个 rating 字段”,而是 它把“邀请”和“已完成评分”放在同一张表里,用 consumed 区分状态。
这带来两个好处:
1)token 生命周期统一
链接先指向一条已存在但未消费的 rating 记录,提交后只是把它从草稿状态改成已消费状态,而不是“点完再临时创建一条新评分”。
2)统计可以天然过滤未完成邀请
无论 rating.mixin 还是 rating.parent.mixin,统计时都会显式要求 consumed = True。因此催评中的草稿不会污染平均分和满意度。
四、parent 数据不是手填,而是自动向上找
rating.rating.create() / write() 里如果看到 res_model_id 和 res_id,会调用 _find_parent_data()。
它会检查当前业务对象是否实现了:
_rating_get_parent_field_name()
如果实现了,系统就会自动把父对象信息写入:
parent_res_model_idparent_res_id
这意味着 Odoo 不是把评分只当成“当前单据的一次反馈”,而是允许它继续向上汇总到更高层级的业务实体。
例如:
- 某个消息线程上的评分,可能最终要汇总到工单;
- 某个子对象上的评分,可能要上卷到项目或服务请求。
这就是为什么评分体系里既有面向当前对象的 rating.mixin,又有面向父级汇总的 rating.parent.mixin。
五、提交评分时,控制器并不直接随便写数据库
公开评分入口在 addons/rating/controllers/main.py。
有两个关键路由:
/rate/<token>/<rate>:打开提交页;/rate/<token>/submit_feedback:真正提交反馈。
先看打开页这步。
控制器会先校验 rate 只能是 1、3、5 这三档。也就是说,前台入口默认不是任意小数星级,而是离散的 unhappy / neutral / happy。
然后 _get_rating_and_record(token) 做两件事:
- 用 token 查
rating.rating; - 再
sudo()找到对应业务记录,并确认记录还存在。
这里很重要的一点是:
- token 找不到 → 404;
- 业务记录已经没了 → 404。
所以公开链接并不是“只要拿到 token 就一定还能提交”。底层业务对象被删掉,这条评分链路也就终止了。
六、为什么公开路由是 public,但又不是完全裸奔
很多人会担心:auth="public" 不就等于任何人都能乱写?
其实官方做的是“令牌授权”,不是“登录授权”。
另外在 action_open_rating() 里还有一层补充校验:
如果当前访问者不是公共用户,而是一个已登录用户,那么它的商业伙伴必须和评分里的 partner_id 对得上;否则展示的是 invalid partner 页面,而不是正常提交页。
这代表 Odoo 在兼顾两种场景:
- 邮件收件人直接点公开链接,无需先登录;
- 如果用户已经登录到站点,也不能借这个页面替别的 partner 提交反馈。
换句话说,公开入口靠 token 放行,已登录态则还要补一层“你是不是这位客户”的约束。
七、真正落库的关键动作,是 rating_apply()
action_submit_rating() 在 POST 时不会自己手写一堆字段,而是调用业务对象上的:
record_sudo.rating_apply(...)
rating_apply() 做的事情很集中:
- 校验评分值必须在 0 到 5;
- 根据 token 或传入 record 找到
rating.rating; rating.write({'rating': rate, 'feedback': feedback, 'consumed': True});- 如果当前对象属于
mail.thread,再把反馈回写到 chatter。
这一步特别关键,因为它说明:
Odoo 不把评分看成“只存在于 rating 表里的孤立数字”,而是把它同步成消息历史的一部分。
所以你在界面上看到的效果,往往是:
- 数据层有一条 consumed rating;
- chatter 里出现一条带表情图片和评论的消息;
- 统计字段随之重算。
八、为什么 chatter 更新而不是重复发很多消息
rating_apply() 在回写时还考虑了 rating.message_id。
- 如果评分原本已经绑定了一条消息,就更新这条消息内容;
- 否则
message_post()新建一条消息。
这背后的好处很实际:
1)用户修改反馈时,不必不断制造重复消息
系统可以把同一条评分对应的消息内容刷新掉,而不是每次再插一条新的 chatter 记录。
2)评分与消息之间形成稳定引用
后续如果删掉 rating,unlink() 甚至还会把相关 chatter 消息一起清掉,保持业务历史一致。
这也是为什么 rating.rating 上要保存 message_id。
九、rating.mixin 主要解决的是“怎么展示统计”
回到很多人最先注意到的 rating.mixin。
它暴露了这些常见字段:
rating_last_valuerating_countrating_avgrating_avg_textrating_percentage_satisfactionrating_last_feedbackrating_last_text
但这些字段并不是简单 related。
rating_last_value 为什么单独跑 SQL
源码里这里直接用了 SQL 和 array_agg(order by write_date desc, id desc),本质上是为了高效地拿到“最近一条已消费评分”的值。
官方没有偷懒循环 Python,也没有随便 search(limit=1) 一条条取,而是一次性聚合。
rating_count 和 rating_avg 为什么一起算
_compute_rating_stats() 用 _read_group() 一次拿 count 和 avg,避免两个字段分开打两轮查询。
这说明官方默认判断:
- 页面上通常会同时显示平均分和评分数量;
- 它们应该作为一组统计结果来计算。
为什么满意度百分比单独算
rating_percentage_satisfaction 并不是平均分换算,而是把评分划到 great / okay / bad 三档后,再看 happy 占比。
这在业务上很重要,因为:
- 平均分适合看总体质量;
- 满意度更适合看“好评率”;
- 两者不是一回事。
十、实战里最容易踩的 4 个误区
误区 1:以为每次发邀请都会新建 rating
实际上官方优先复用同一 partner 的未消费 rating。排查“为什么 token 一样”时别先怀疑缓存,先看有没有草稿评分被复用了。
误区 2:以为 rating_avg 包含所有评分草稿
不会。统计口径都严格过滤 consumed = True,没提交的不算。
误区 3:以为评分提交只是改一张表
不止。标准链路还会更新或新增 chatter 消息,所以排错时既要看 rating.rating,也要看 mail.message。
误区 4:以为 token 链接公开,就没有权限边界
也不对。token 是第一层授权;已登录用户如果 partner 不匹配,官方仍会拦。
十一、给开发和实施的落地建议
如果你要在项目里扩展评分功能,我更建议这样理解它:
1)把 rating 当“反馈事件”,不是“一个字段”
不要只盯平均分。真正稳定的是:邀请 → token → 提交 → chatter → 聚合。
2)如果有父级 KPI 需求,优先复用 parent 机制
不要自己再造一张“汇总表”,先看能否通过 _rating_get_parent_field_name() 把上卷关系接起来。
3)调试时按链路排,不要只看 UI
建议顺序:
- 是否生成
rating.rating草稿; consumed是否变为 true;message_id是否建立;rating_avg/rating_count是否重算;- 前端模板是否正确读取统计字段。
结语
Odoo 的评分系统,表面上像一个“邮件打分小功能”,但源码其实把它做成了一条完整反馈链:
- 用
mail.thread接入业务对象; - 用 token 驱动公开访问;
- 用
rating.rating统一承载邀请与结果; - 用 chatter 把结果沉淀为业务历史;
- 用
rating.mixin把统计字段暴露给界面。
所以它真正解决的,不只是“怎么收一颗星”,而是:
怎样让一次客户反馈,既能安全提交、又能被业务对象追踪,还能被统计系统稳定消费。
DISCUSSION
评论区