一、需求与"翻车"现场
业务需求听起来很简单:市场活动方案在选择市场活动计划时,只能选到"申请人自己名下"的计划,选别人的要拦下来。
第一版实现也很"标准":给 lookup 控件挂addPreSearch,拼 FetchXml 按申请人过滤:
var fetchXml = "<fetch version='1.0' mapping='logical'>" + "<entity name='new_campain'>" + "<filter type='and'>" + "<condition attribute='new_applyid' operator='eq' value='" + applyUserId + "' />" + "<condition attribute='statecode' operator='eq' value='0' />" + "</filter></entity></fetch>"; compainCtrl.addPreSearch(function () { compainCtrl.addCustomFilter(fetchXml); });测试却反馈:还能选到别人的计划。排查后发现,问题出在 lookup 弹窗里一个很容易被忽略的区块。
二、坑一:MRU"最近的记录"不走过滤
D365 旧版 Web 客户端(Xrm.Page 时代)的 lookup 弹窗,结构上分两个区块:
"查找记录"区:用户点放大镜搜索,结果走服务端 FetchXml 查询——这个区域受 addPreSearch 过滤;
"最近的记录"(MRU,Most Recently Used)区:客户端本地缓存的快选区,显示操作人最近操作过的记录——这个区域直接渲染缓存,不经过任何过滤链。
所以操作人最近刚操作过别人的计划,那条记录就躺在 MRU 里,一点就选上了,preSearch 形同虚设。更麻烦的是:旧版客户端没有禁用 MRU 的官方 API,这个口子堵不上。
解法只能是双保险——字段 onChange 时反查校验,不通过就清空 + 提示:
// onChange 反查校验:不通过就清空 + 提示 function validatePlanApplicant(planId) { return Xrm.WebApi.retrieveRecord("new_campain", planId, "?$select=_new_applyid_value") .then(function (plan) { var planApplyUserId = plan._new_applyid_value; if (String(planApplyUserId).toLowerCase() !== String(applyUserId).toLowerCase()) { setVal(formContext, "new_compainid", null); // 清空选择 alertMsg("只能选择申请人名下的市场活动计划。"); return false; } return true; }, function () { setVal(formContext, "new_compainid", null); }); }(清空会再次触发 onChange,加一个 lookupId 为 null 直接 return 的守卫即可避免递归。)
三、坑二:$select 查 lookup 字段要写"引用名"
在写上面这个反查时,踩了第二个坑。一开始按 schema 名查:
// ✗ 写 schema 名 → 旧版客户端可能不返回对应属性,取值恒为空 Xrm.WebApi.retrieveRecord("new_campain", planId, "?$select=new_applyid")返回体里就是没有_new_applyid_value,取值恒为空,导致校验误报"只能选择申请人名下的市场活动计划"——明明选的是对的。
原理:D365 Web API 遵循 OData 协议,lookup 字段本质是导航属性,它的标量值以"引用名"形式暴露:_<字段名>_value。所以正确写法是:
// ✓ 写引用名 → 返回体里取 _new_applyid_value 才是引用记录的 GUID Xrm.WebApi.retrieveRecord("new_campain", planId, "?$select=_new_applyid_value") .then(function (plan) { var applyUserId = plan._new_applyid_value; });新旧客户端对"$select 写 schema 名"的容错行为还不一致(新版有的会顺手把引用名也带出来),所以写引用名是唯一稳的写法。
四、三层防线:前端只是体验,后端才是安全
回头看这个需求,最终落地是三层同口径校验:
addPreSearch 过滤(体验层):让大部分用户"看不到"不该选的记录;
onChange 反查(体验兜底):拦 MRU 和任何过滤漏网的选择,立刻清空 + 提示;
插件 Create/Update Pre 校验(安全层):同口径再查一遍,不匹配直接抛
InvalidPluginExecutionException,保存被拒。
// 插件兜底(Create Pre),Update 侧 PreValidation 同口径 EntityReference applyUserRef = target.GetAttributeValue<EntityReference>("new_applyuser"); EntityReference selectedPlanRef = target.GetAttributeValue<EntityReference>("new_compainid"); if (applyUserRef != null && selectedPlanRef != null) { Entity plan = svc.Retrieve("new_campain", selectedPlanRef.Id, new ColumnSet("new_applyid")); EntityReference planApplyRef = plan.GetAttributeValue<EntityReference>("new_applyid"); if (planApplyRef == null || planApplyRef.Id != applyUserRef.Id) throw new InvalidPluginExecutionException("只能选择申请人名下的市场活动计划"); }核心思想:界面过滤只能当体验优化,不能当规则。前端负责不让用户犯错,后端负责用户犯错也进不来——控制台里绕 JS 太容易了。
五、总结
MRU 是客户端缓存直接渲染,不过服务端过滤链,旧版客户端无官方禁用 API——业务规则类校验必须后端兜底;
OData 里 lookup 的值在
_字段_value,$select写 schema 名拿不到值是经典坑,写引用名才稳;前端拦截 + 后端兜底,是安全规则的标准双防线。