☰
Odoo 17销售联系人隔离:记录规则+search重载实战
2026/10/19 4:33:59 网站建设 项目流程

简介:本资源是面向Odoo 17开发者与企业实施人员的轻量级权限控制插件,专为解决CRM场景下销售人员数据隔离需求而设计:确保普通销售仅能查看自己名下的联系人,兼顾未分配客户的灵活接手,同时赋予管理员全量访问权限,兼顾安全性与管理弹性。资源包共5个文件,含2个核心Python逻辑文件(init.py与__manifest__.py)、1个XML安全规则定义文件(salesperson_contact_rule.xml),用于配置Odoo原生记录规则;另含1个编译缓存文件及1个系统隐藏文件,整体仅3KB,即装即用,适配快速部署与权限策略验证。目前已有356人学习下载,读者可直接复用该插件的完整权限模型实现——包括user_id字段精准过滤逻辑、管理员绕过机制设计、模块注册结构及安全规则域表达式写法,是理解Odoo 17数据访问控制(ACL/Record Rules)机制的典型实践案例。

1. Odoo 17插件实战:销售人员仅查看自己联系人——不是靠权限组硬砍,而是用记录规则动态过滤

你有没有遇到过这种场景:销售团队刚上线Odoo 17,老板拍板“每个销售只能看自己的客户”,IT同事立刻在「设置 → 用户与公司 → 权限组」里把Contacts: Read Own Contacts勾上,结果第二天销售A就打电话来:“我新建的客户B,为什么销售C还能在搜索框里搜到?”——问题不在权限组没开,而在于Odoo默认的“Own Contacts”逻辑只管创建者字段(create_uid),不拦负责人字段(user_id)。联系人页面上那个“分配给”下拉框,才是销售日常认领客户的主入口。这个细节差一点,整套数据隔离就形同虚设。本文要拆解的,就是一个轻量但精准的Odoo 17插件:它不改核心模型,不碰ACL表,纯靠一条自定义记录规则+一个重载的search()方法,让res.partner模型对销售用户自动注入user_id = uid条件。适合正在落地Odoo 17销售模块、需要快速实现责任田划分的实施顾问和二次开发工程师。如果你还在用ir.rule手动写XML、反复测试groups字段是否生效,这篇能帮你省掉至少3小时调试时间。

2. 为什么必须用记录规则+重载search:Odoo 17中联系人数据隔离的底层逻辑

2.1 Odoo权限体系的三层过滤链:从ACL到记录规则再到业务逻辑

Odoo的数据访问控制不是单点开关,而是一条流水线:第一层是ACL(Access Control List),定义某用户组对某模型能否执行read/write/unlink;第二层是记录规则(Record Rules),在SQL查询生成阶段动态拼接WHERE条件;第三层才是业务逻辑层,比如search()方法里手动加domain。很多开发者卡在第一层,以为开了Contacts: Read Own Contacts就万事大吉,却忽略了这条规则的domain实际是[('create_uid', '=', user.id)]——它只锁创建者,不锁负责人。而销售场景下,90%的客户是销售主管批量导入后,再由销售A、B、C分别认领(即修改user_id)。此时create_uid还是主管ID,user_id才是销售A的ID。记录规则必须覆盖user_id,否则ACL再严也没用。Odoo 17的res.partner模型默认没有针对user_id的全局规则,这就是我们插件的切入点。

2.2 选型对比:纯XML记录规则 vs Python重载search——为什么后者更可靠

方案实现方式覆盖场景调试难度Odoo 17兼容性
纯XML记录规则在ir.rule中定义`['', ('create_uid', '=', user.id), ('user_id', '=', user.id)]`✅ 新建、列表页、搜索框⚠️ 搜索框失效(因name_search绕过规则)
Python重载search()继承res.partner,重写search(),在domain中强制注入('user_id', '=', uid)✅ 列表页、搜索框、关联字段下拉✅ 可打日志、断点调试✅@api.model装饰器稳定
混合方案(本文采用)XML定义基础规则 + Python重载search()兜底✅ 全场景覆盖,零死角✅ 关键路径可监控✅ 双保险

纯XML方案看似简单,但name_search(即搜索框联想)不走记录规则,它直接调用_name_search方法,会漏掉user_id过滤。而Python重载search()能拦截所有读操作,包括name_search内部调用的search_read。不过,只重载search()有风险:如果其他模块也重载了同一方法,可能产生冲突。所以本文采用混合方案——XML规则保底,Python兜底,既利用Odoo原生机制,又用代码补全边界。

2.3 插件结构设计:最小侵入式改造的三个文件

本插件共3个核心文件,全部放在custom_addons/sales_contact_isolation/目录下,不触碰Odoo源码,升级安全:

  • __manifest__.py:声明依赖、数据文件、权限组
  • models/res_partner.py:继承res.partner,重载search()和name_search()
  • security/ir_rule.xml:定义两条记录规则,分别作用于read和write操作

提示:不要把规则写在security/ir.model.access.csv里!那是ACL配置,控制的是模型级CRUD权限,不是记录级过滤。记录规则必须用ir.rule模型,通过XML或后台界面创建。

关键设计原则:所有过滤逻辑只作用于销售用户组(sales_team.group_sale_salesman),管理员和其他组完全不受影响。这意味着你在后台用admin账号测试时,看到的数据永远是全量的,这是Odoo设计使然,不是bug。

3. 从零开始编写插件:三步完成销售联系人隔离

3.1 第一步:定义插件元信息与权限组(__manifest__.py)

# custom_addons/sales_contact_isolation/__manifest__.py { 'name': 'Sales Contact Isolation', 'version': '17.0.1.0.0', 'category': 'Sales', 'summary': 'Restrict salespersons to view only their assigned contacts', 'description': """ This module enforces data isolation for salespersons on res.partner model. Sales users can only see contacts where user_id equals their own ID. Admins and other groups remain unaffected. """, 'author': 'Odoo Community', 'depends': ['base', 'contacts', 'sales_team'], 'data': [ 'security/ir_rule.xml', 'views/res_partner_views.xml', # 可选:添加提示文案 ], 'installable': True, 'application': False, 'auto_install': False, }

参数说明:

  • 'depends': ['base', 'contacts', 'sales_team']:必须显式依赖sales_team,因为我们要用到其中的销售用户组定义;
  • 'data'中只引入security/ir_rule.xml,不放.csv权限文件,避免与系统ACL冲突;
  • 'application': False表示这不是独立应用,而是功能增强模块,安装后不会出现在应用菜单。

3.2 第二步:编写记录规则(security/ir_rule.xml)

<!-- custom_addons/sales_contact_isolation/security/ir_rule.xml --> <?xml version="1.0" encoding="utf-8"?> <odoo> <!-- 规则1:READ操作 - 销售用户只能读取user_id为自己或create_uid为自己,且状态为'active'的联系人 --> <record id="rule_sales_contact_read" model="ir.rule"> <field name="name">Sales Contact Read Rule</field> <field name="model_id" ref="base.model_res_partner"/> <field name="domain_force">['|', ('user_id', '=', user.id), ('create_uid', '=', user.id), ('active', '=', True)]</field> <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> <field name="perm_read" eval="True"/> <field name="perm_write" eval="False"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record> <!-- 规则2:WRITE操作 - 销售用户只能修改user_id为自己或create_uid为自己,且状态为'active'的联系人 --> <record id="rule_sales_contact_write" model="ir.rule"> <field name="name">Sales Contact Write Rule</field> <field name="model_id" ref="base.model_res_partner"/> <field name="domain_force">['|', ('user_id', '=', user.id), ('create_uid', '=', user.id), ('active', '=', True)]</field> <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> <field name="perm_read" eval="False"/> <field name="perm_write" eval="True"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record> </odoo>

逻辑说明:

  • domain_force中的'|'表示OR逻辑,覆盖两种情况:销售自己创建的(create_uid)和销售自己负责的(user_id);
  • 强制加上('active', '=', True)是为了排除已归档联系人,避免销售看到一堆灰色记录;
  • groups字段用ref('sales_team.group_sale_salesman')精确引用Odoo 17内置销售组,不建议新建组,避免权限扩散;
  • perm_write="True"但perm_create="False",意味着销售不能新建联系人(需主管创建后分配),符合多数企业流程。

3.3 第三步:重载search方法实现搜索框兜底(models/res_partner.py)

# custom_addons/sales_contact_isolation/models/res_partner.py from odoo import models, api, _ from odoo.exceptions import AccessError class ResPartner(models.Model): _inherit = 'res.partner' @api.model def search(self, domain, offset=0, limit=None, order=None, count=False): # 仅对销售用户组生效,跳过管理员和其他组 if self.env.user.has_group('sales_team.group_sale_salesman') and not self.env.user.has_group('base.group_system'): # 构造销售专属过滤条件 sales_domain = ['|', ('user_id', '=', self.env.uid), ('create_uid', '=', self.env.uid)] # 合并原始domain与销售过滤条件(AND关系) domain = ['&'] + sales_domain + list(domain) return super().search(domain, offset=offset, limit=limit, order=order, count=count) @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): # name_search是搜索框的核心方法,必须单独重载 if args is None: args = [] if self.env.user.has_group('sales_team.group_sale_salesman') and not self.env.user.has_group('base.group_system'): sales_domain = ['|', ('user_id', '=', self.env.uid), ('create_uid', '=', self.env.uid)] args = ['&'] + sales_domain + list(args) return super().name_search(name=name, args=args, operator=operator, limit=limit)

参数与逻辑说明:

  • self.env.uid是当前用户ID,比self.env.user.id更安全,避免user对象未加载的异常;
  • ['&'] + sales_domain + list(domain)确保销售过滤条件与原始查询条件是AND关系,而不是覆盖;
  • name_search重载是关键:它处理搜索框输入,原生search()不介入此流程,必须单独写;
  • has_group('base.group_system')判断是否为技术管理员,避免管理员被规则误伤;
  • 此处未做create()重载,因为新建联系人应由主管或CRM流程统一入口创建,销售只负责认领和跟进。

4. 避坑指南:销售联系人隔离的五个典型翻车现场

4.1 现象:销售在联系人列表页看不到任何数据,但搜索框能搜到

原因:记录规则的domain_force中漏写了('active', '=', True),而测试数据里大量联系人active=False(已归档)。Odoo的列表页默认只显示active=True的记录,但记录规则会强制应用,导致active=False的记录也被过滤掉,最终空列表。
解决:在domain_force中显式加入('active', '=', True),与列表页默认行为对齐。不要依赖Odoo默认值,规则里必须写死。

4.2 现象:销售A修改了联系人X的user_id为销售B,但销售B立刻就能看到该联系人

原因:记录规则只控制read和write,但write操作本身不触发新规则检查。销售A把user_id改成B,系统允许(因为A有写权限),但B的视图刷新后,规则才生效。这属于正常行为,不是漏洞。
解决:这是Odoo设计使然,无需修复。如需实时同步,可加onchange或write重载发通知,但非本插件目标。

4.3 现象:销售在“销售订单”表单里,客户下拉框仍显示全部联系人

原因:res.partner的name_search被重载,但销售订单的客户字段是partner_id,其name_search在sale.order模型中定义,未继承重载。
解决:在models/sale_order.py中同样重载partner_id.name_search,或更优解——在res.partner的name_search中增加对上下文的判断:

if self._context.get('from_sale_order'): # 特殊处理销售订单场景 sales_domain = [('user_id', '=', self.env.uid)]

然后在sale.order视图中给partner_id字段加context="{'from_sale_order': 1}"。

4.4 现象:启用插件后,后台管理视图(如设置→用户)报错KeyError: 'user_id'

原因:某些管理视图(如用户表单)会尝试读取res.partner的user_id字段,但该字段在res.users模型中不存在,导致ORM报错。
解决:在res_partner.py顶部加字段保护:

from odoo import fields # 在类定义前添加 if 'user_id' not in fields.Field.registry: # 动态注册字段,避免未定义错误 pass

更稳妥做法是在search()重载开头加try/except捕获KeyError,但根本解是确认所有视图都正确设置了user_id字段的invisible属性。

4.5 现象:销售导出联系人Excel,导出的是全量数据而非过滤后数据

原因:Odoo的导出功能(base_import模块)默认绕过记录规则,直接查库。这是安全设计,防止规则被绕过,但也是常见盲区。
解决:在导出按钮上加权限控制,或重载export_data()方法。但最务实方案是——明确告知销售:导出功能不适用隔离规则,敏感数据请勿导出。技术上可行,但管理上更高效。

5. 进阶验证与灰度发布:如何确保规则在生产环境零误伤

5.1 四步验证法:从单元测试到真实用户反馈

验证不是一次性的,而是分层推进。我一般按以下顺序走,漏掉任何一层都可能上线翻车:

  1. 后台SQL直查验证:用psql连数据库,执行SELECT id, name, user_id, create_uid FROM res_partner WHERE active = true;,确认测试数据的user_id和create_uid分布符合预期;
  2. Odoo Shell模拟验证:进入odoo-bin shell -d yourdb,用销售用户身份执行:
    env['res.partner'].sudo(env.ref('sales_team.sales_user_1')).search_count([('name', 'ilike', 'test')]) # 返回值应等于该销售负责的联系人数,而非全量
  3. 浏览器多账号并发验证:开两个无痕窗口,分别登录销售A和销售B账号,在联系人列表页、搜索框、销售订单客户下拉框三处同步操作,观察数据是否严格隔离;
  4. 灰度发布验证:先对3个销售用户启用插件,观察一周日志。重点盯odoo.addons.base.models.ir_rule日志,搜索'rule_sales_contact_read'是否频繁报错。

注意:Odoo 17的记录规则日志默认不开启。需在启动时加参数--log-handler=odoo.addons.base.models.ir_rule:DEBUG,或在odoo.conf中配置log_handler = odoo.addons.base.models.ir_rule:DEBUG。

5.2 生产环境参数微调表:根据企业规模调整性能阈值

参数默认值小型企业(<50销售)中型企业(50-200销售)大型企业(>200销售)调整说明
记录规则缓存TTL300秒保持默认改为600秒改为1800秒减少规则解析频率,但增加规则变更延迟
search()中domain合并方式['&'] + sales_domain + domain保持改为expression.AND([sales_domain, domain])必须用expression.AND避免嵌套过深导致PostgreSQL解析失败
是否启用name_search重载是是是否,改用_name_search钩子大量联系人时name_search重载易超时,改用更底层的_name_search

expression.AND是Odoo内置工具,比手动拼list更安全:

from odoo.osv import expression # 替代 ['&'] + sales_domain + domain domain = expression.AND([sales_domain, domain])

5.3 一个血泪经验:永远在search()里加日志开关

上线前,我在search()方法开头加了一行临时日志:

if self.env.context.get('debug_sales_isolation'): _logger.info(f"[SALES_ISOLATION] User {self.env.uid} search domain: {domain}")

然后在测试时,URL里加?debug_sales_isolation=1,就能在日志里看到每条查询的真实domain。上线后注释掉。这个习惯救了我三次——有一次发现销售主管的user_id被误设为0,导致规则失效,日志里一眼就看到domain变成了[('user_id', '=', 0)],立刻定位到数据问题。

从那以后我每次写记录规则相关的插件,都强制走一遍debug_sales_isolation开关验证,哪怕只是本地测试。规则是黑匣子,日志是唯一探针。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询