拿到金蝶云苍穹插件开发的第一个需求,十有八九离不开“加载数据”这四个字。比如销售订单上选了客户,想自动带出这个客户最近一次成交的单价;采购申请审核通过后,想回写供应商的信用额度;列表页面上想加一个自定义筛选按钮,按条件重新拉取单据。第一次接触苍穹二开的人,往往以为插件就是挂一个 Java 类、配一个事件,剩下的事平台都帮你干了。真正动手才发现,光是“把数据查出来”就能走出好几条完全不同的路线,选错了路,轻则列表卡上几秒,重则明明数据库里有记录,界面上却什么都查不出来。
这篇文章就围绕“加载数据”这个高频场景,把我自己在苍穹插件开发里的完整思路写下来。适合刚接触苍穹插件开发的同学参考,也适合那些已经在表单插件、列表插件里写过几段逻辑、但还没系统性梳理过取数方式的人。我会按“什么时候加载数据、用什么 API 加载、怎么写最稳、遇到问题怎么排查”的顺序展开。代码基于常见版本的苍穹 Java 插件模型,细节 API 以你手上的版本为准。
1. 插件到底在哪个环节处理“加载数据”
1.1 插件的本质:在标准流程上开的口子
很多第一次写苍穹插件的人,容易把插件理解成“一段独立跑的程序”。实际上完全不是这么回事。苍穹的插件不是独立运行的程序,它挂在业务对象的生命周期上。表单、列表、单据操作、服务流程,在苍穹内部都被实现成一个个业务对象,每个对象在创建、加载、绑定、保存、操作前和操作后这些节点上,都会向外部暴露钩子方法。插件要做的事情,就是找到对的钩子,把代码放进去。
可以把它想象成把一个临时员工安排到流水线上。他不是老板,不能决定流水线什么时候启动,但可以在流水线的固定工序位上插入自己的动作。你重写了哪个方法,就相当于把自己安插到了哪个工序位上。这也解释了为什么很多新手会觉得“插件不生效”——不是代码错了,而是重写的方法根本没有在预期的时机被调用。
拿“加载数据”来说,同样的查询代码写在afterBindData里、写在按钮点击里、写在setFilter里,执行时机和效果完全不一样。写插件的第一步,不是急着写查询,而是确认你到底要挂哪个生命周期节点。
1.2 表单、列表、服务插件:三个最常取数的入口
在苍穹里做二开,接触最多的就是三类插件:表单插件、列表插件、服务插件。它们加载数据的时机和用途有明确分工。
| 插件类型 | 基类 | 典型时机 | 加载数据干什么 |
|---|---|---|---|
| 表单插件 | AbstractFormPlugin | afterBindData、click、字段值变更 | 表单显示后自动带入关联数据,或用户点按钮时触发查询回填 |
| 列表插件 | AbstractListPlugin | setFilter、afterLoadData | 列表查询前追加过滤条件,或查询后修正/补充返回数据 |
| 服务插件 | AbstractServicePlugin 或实现 IActionService | executeAction | 在服务端执行一段业务逻辑,通常是批处理、操作链扩展、定时任务 |
这些时机归纳起来其实就是两类:“界面要数据”和“业务要数据”。
界面要数据,典型场景是一个页面打开时需要显示另外一张单据的信息,或者列表页需要根据入口参数自动筛选。这时候关注的是参数传递、刷新方式、页面状态。业务要数据,典型场景是审核操作、保存操作、计划任务里需要查询一批数据做判断或计算,用户根本看不到中间的查询过程。这时候关注的是查询 API 选型、空结果处理、性能和事务。
多数新手栽跟头,就是把“业务要数据”的逻辑写成了“界面要数据”的写法,比如在表单插件里弹个列表让用户去选,结果后台任务根本没法这样交互。
1.3 一个判断准则:先确认你要的是界面数据还是业务数据
我自己写插件时有个习惯:动手前先问一句话,“这批数据是给人看的,还是给业务逻辑用的?”
如果答案是“给人看的”,那就要考虑数据的展示通道,比如ListShowParameter、FormShowParameter这种页面参数对象,核心思路是“把参数带过去,让目标页面自己查”。如果答案是“给业务逻辑用的”,那就要忘掉界面,直接走服务端查询 API,核心思路是“查得准、查得快、处理全”。
后面的两个实战案例,正好分别对应这两种思路。第一个案例是表单插件里查单张单据并回填,属于业务取数;第二个案例是列表插件按条件重新加载,属于界面取数。把这两个场景吃透,苍穹插件加载数据的骨架基本就搭起来了。
2. 查数据的三条路线:QueryService、BillQueryService、DBUtils 怎么选
2.1 QueryService:最通用的数据查询入口
如果你只想记住一个取数 API,那就是QueryService。它是苍穹后端最通用的数据查询入口,适合列表查询、分页查询、按条件过滤查询。它面向的是“业务对象”的数据模型,你不需要知道底层物理表长什么样,只要知道业务对象的表单标识和字段标识就行。
一个典型的查询代码是这样:
// 构造查询服务 QueryService queryService = new QueryService(); // 指定要查询的业务对象,这里是销售订单 queryService.setFormId("sal_saleorder"); // 只查询需要的字段,不要一把梭查全字段 queryService.setSelectFields("id,billno,customer,totalamount"); // 构造过滤条件 QueryFilter filter = new QueryFilter(); filter.eq("auditstatus", "A"); // 已审核 filter.ge("totalamount", new BigDecimal("100")); // 金额大于等于100 queryService.setQueryFilter(filter); // 分页 queryService.setPageIndex(1); queryService.setPageSize(50); // 执行查询 QueryResult result = queryService.executeQuery(); if (result != null && result.getData() != null) { for (DynamicObject row : result.getData()) { String billNo = row.getString("billno"); // 逐行处理 } }这里有两个容易踩的细节。第一,setSelectFields里的字段标识不是界面上看到的中文名称,而是设计器里的英文字段标识,写错了直接报“字段不存在”。第二,过滤条件里的参数类型必须和字段类型匹配,比如金额字段用BigDecimal,字符串字段用String,类型不匹配时查询结果会为空,甚至报 SQL 类型错误。
2.2 BillQueryService:面向单据模型,查询单据头与单据体
BillQueryService和QueryService的区别,一句话概括:QueryService是通用查询,适合列表和批量场景;BillQueryService更贴近单据模型,适合在表单插件里按单号、按条件查一张或一组单据。
BillQueryService bqs = new BillQueryService(); bqs.setBillFormId("sal_saleorder"); bqs.addSelectField("billno"); bqs.addSelectField("customer"); bqs.addSelectField("totalamount"); bqs.addFilterString("billno = ?"); bqs.getQueryParameter().put("billno", billNo); DynamicObject[] rows = bqs.queryBillRunTime(); if (rows != null && rows.length > 0) { DynamicObject order = rows[0]; String billNo = order.getString("billno"); BigDecimal amount = order.getBigDecimal("totalamount"); }从使用体感来说,BillQueryService在“按单号精确查单张单据”这种场景下更顺手,返回的是DynamicObject数组,直接取第一条就行。它支持查询单据头和单据体的数据,并且会带出一些运行时状态。要注意的是,不同版本里这个方法名可能不同,有的版本是queryBillRunTime,有的版本是executeQuery,写代码前先看一眼依赖 jar 包里实际的方法签名。
2.3 DBUtils/JDBC:能不用就不用
还有一条路线是直接用DBUtils或者 JDBC 连数据库查。我见过不少从传统 Java 开发转过来的同事,一上来就写SELECT * FROM T_SAL_SALEORDER WHERE ...,理由是“这样我熟”。但这在苍穹插件开发里是下下策。
直接用 JDBC 查物理表有几个致命问题。第一,绕过苍穹的缓存、数据权限、字段翻译、组织隔离,同样的 SQL 在不同用户、不同组织下结果完全一样,这在企业应用里是很危险的。第二,物理表的表名和字段名是数据库命名规范,跟业务对象的标识完全不同,可读性极差。第三,苍穹版本升级时物理表结构很可能调整,你的 SQL 就报废了。
但也不是完全不能用。我自己的经验是,只有在这几类场景才考虑 JDBC:复杂的聚合统计 SQL、跨库查询、QueryService 确实难以表达的性能敏感查询。用的时候必须自己处理数据权限和组织隔离,并且在代码里加清晰注释,说明为什么不用标准 API。
三条路线用一张表对比一下:
| 维度 | QueryService | BillQueryService | DBUtils/JDBC |
|---|---|---|---|
| 适用场景 | 列表查询、分页、条件过滤 | 按单号查单据、单据体联查 | 复杂统计 SQL、跨库查询 |
| 返回结构 | QueryResult / 分页集合 | DynamicObject 数组 | ResultSet |
| 数据权限 | 自动带出 | 自动带出 | 不带,需自己控制 |
| 缓存 | 支持 | 支持 | 不支持 |
| 易用程度 | 高 | 高 | 低,需维护 SQL |
我给你的建议很直接:90% 的加载数据需求,用QueryService就能解决;剩下的 9% 用BillQueryService;最后那 1% 才轮到 JDBC。别一上来就奔着底层的路去。
3. 实战:表单插件按单号加载关联单据并回填
3.1 需求拆解:从“点一下按钮”到“完成回填”
现在写一个完整的例子。假设采购订单上有个“关联销售订单号”字段,用户点一个“同步数据”按钮,系统按这个单号去销售订单里查客户和总金额,回填到当前采购订单的客户字段,并在备注里追加一行“同步自销售订单 XXX”。
这个需求很典型,拆解下来有四个动作:
- 获取界面上的来源单号
- 按单号查询销售订单数据
- 拿到查询结果,写成采购订单的字段值
- 处理查不到、重复单号、重复点击这些边界情况
插件类型是表单插件,挂在采购订单表单上。触发时机是按钮点击,不是afterBindData。为什么不是表单打开就自动同步?因为用户需要先录入销售订单号,单号可能被修改,如果每次字段变化都触发查询回填,会干扰用户录入。手动按钮触发是最可控的交互方式。
3.2 注册按钮事件与点击处理
先看插件骨架:
public class SyncFromSaleOrderPlugin extends AbstractFormPlugin { private static final String SYNC_BTN = "btnsync"; @Override public void registerListener(EventObject e) { super.registerListener(e); // 给按钮注册点击事件 this.addClickListeners(SYNC_BTN); } @Override public void click(EventObject e) { super.click(e); // 判断点击的控件是不是我们关心的按钮 if (e.getSource() instanceof Control) { Control control = (Control) e.getSource(); if (SYNC_BTN.equals(control.getControlId())) { doSync(); } } } private void doSync() { String sourceBillNo = (String) this.getModel().getValue("srcbillno"); if (sourceBillNo == null || sourceBillNo.trim().isEmpty()) { this.getView().showTipNotification("请先填写关联销售订单号"); return; } // 查询和回填逻辑见下文 } }这里有一个非常实际的细节:addClickListeners传入的标识必须和元数据里按钮的标识完全一致,包括大小写。一旦不一致,按钮点击后插件根本没有反应,而且平台不报任何错误。排查方法就是在click方法第一行打日志,看方法有没有被调用。
3.3 按单号查询销售订单
查询部分建议单独抽一个方法,方便复用和测试:
private DynamicObject[] querySaleOrder(String billNo) { BillQueryService bqs = new BillQueryService(); bqs.setBillFormId("sal_saleorder"); bqs.addSelectField("billno"); bqs.addSelectField("customer"); bqs.addSelectField("totalamount"); bqs.addFilterString("billno = ?"); bqs.getQueryParameter().put("billno", billNo); // 防止脏数据导致查询结果过多,限制最大返回条数 bqs.setTop(20); return bqs.queryBillRunTime(); }为什么这里选BillQueryService而不是QueryService?因为这是典型的“按单号查单据”场景,返回DynamicObject数组,直接判断长度即可。QueryService返回的是分页结构,还得额外处理QueryResult.getData(),反而啰嗦。
注意setTop(20)这个细节。理论上单号是唯一的,但企业系统里难免有脏数据,万一存在重复单号,限制了条数至少不会把内存打爆。这也是我在实际项目里吃过亏才养成的习惯——所有查询都设置最大返回条数。
3.4 回填字段时最容易踩的类型坑
查到数据后,回填字段这一步坑最多。写出下面的代码:
private void doSync() { String sourceBillNo = (String) this.getModel().getValue("srcbillno"); if (sourceBillNo == null || sourceBillNo.trim().isEmpty()) { this.getView().showTipNotification("请先填写关联销售订单号"); return; } DynamicObject[] saleOrders = querySaleOrder(sourceBillNo.trim()); if (saleOrders == null || saleOrders.length == 0) { this.getView().showTipNotification("未找到销售订单:" + sourceBillNo); return; } DynamicObject saleOrder = saleOrders[0]; // 基础资料字段,需要传基础资料标识 String customerId = saleOrder.getString("customer"); this.getModel().setValue("customer", customerId); // 数值字段,直接传 BigDecimal BigDecimal totalAmount = saleOrder.getBigDecimal("totalamount"); this.getModel().setValue("totalamount", totalAmount); // 备注字段,追加同步来源信息 String oldRemark = (String) this.getModel().getValue("remark"); String newRemark = "同步自销售订单 " + sourceBillNo; if (oldRemark != null && !oldRemark.isEmpty()) { newRemark = oldRemark + ";" + newRemark; } this.getModel().setValue("remark", newRemark); this.getView().showTipNotification("同步完成"); }三个类型坑说一下。
第一,基础资料字段。saleOrder.getString("customer")拿到的是基础资料的标识,不是名称。界面上显示的是基础资料的名称,但插件里setValue必须传标识,传了名称会导致字段显示异常或者保存报错。
第二,数值字段。totalamount是金额,查询出来是BigDecimal,回填时直接传BigDecimal。如果你先toString()再传,可能会碰到精度问题,或者平台类型转换报错。
第三,日期字段。如果涉及日期回填,最好直接传Date或Timestamp对象,不要传字符串。字符串格式和平台日期格式不匹配时,界面会显示乱掉。
3.5 多结果、重复点击和单据已保存的边界处理
多结果的情况,上面代码直接取了saleOrders[0],这是合理的,但最好加一条日志。
重复点击的问题更隐蔽。用户双击按钮,会触发两次同步,虽然结果一样,但会弹两次提示,体验很差。我的处理方式是加一个状态标志:
private boolean syncing = false; private void doSync() { if (syncing) { return; } syncing = true; try { // 原有逻辑 } finally { syncing = false; } }还有一个边界:如果采购订单已经保存过,再次修改来源单号并同步,新数据覆盖旧数据是没有问题的。但如果采购订单已经审核了,此时字段还允许修改吗?这就涉及到单据状态控制,一般要在同步前判断当前单据的审核状态,已审核单据应该禁止同步并提示用户。这块逻辑不同企业要求不同,但你要有这个意识。字段回填不是简单地 setValue,还要考虑单据所处的生命周期。
4. 实战:列表插件按条件动态加载与刷新
4.1 场景:从客户列表跳转到销售订单列表并自动过滤
第二个案例是界面取数的典型。客户列表页上有一个“查看全部订单”按钮,点击后跳转到销售订单列表,并且列表只显示这个客户的订单。
这个需求用ListShowParameter实现。跳转的核心不是查询数据,而是把参数传递过去:
ListShowParameter param = new ListShowParameter(); param.setFormId("sal_saleorder"); param.setPageSize(50); param.setCustomParam("customerId", customerId); this.getView().showForm(param);setCustomParam可以往参数里塞任意对象,目标列表页打开时,在列表插件里能原样取出来。这是苍穹页面间传参最常用的方式之一。
4.2 在列表插件里取参数并追加过滤条件
销售订单列表挂一个列表插件,在setFilter阶段读取参数并追加过滤:
public class SaleOrderListPlugin extends AbstractListPlugin { @Override public void setFilter(EventObject e) { super.setFilter(e); // 从跳转参数中获取客户标识 Object customerId = this.getView().getFormShowParameter() .getCustomParam("customerId"); if (customerId == null) { return; } // 在原有过滤条件上追加客户过滤 if (e.getData() instanceof FilterParameter) { FilterParameter fp = (FilterParameter) e.getData(); // 不同版本 API 略有差异,核心思路是追加过滤条件 fp.getCustomFilter().addFilter("customerid = ?", customerId); } } }为什么在setFilter里做,而不是查询完成后再过滤数据?因为setFilter是列表查询的必经之路,平台每次发起查询都会先进这个方法。在这里追加条件,能让查询直接落到数据库层,效率最高。如果等数据查回来再在内存里过滤,列表页几十上百条数据可能还好,一旦分页查询,每页只查一页数据,内存里根本拿不到全部客户的订单,过滤就失效了。
这个逻辑说白了:能下推到数据库的过滤,就不要在内存里做。
4.3 自定义按钮触发重新加载:重置过滤条件
列表页还可能有一个“重置”按钮,点击后清空客户过滤,显示全部订单。
实现方式很简单,在列表插件的click方法里处理按钮点击,清掉自定义参数后刷新列表:
@Override public void click(EventObject e) { super.click(e); if (e.getSource() instanceof Control) { Control control = (Control) e.getSource(); if ("btnreset".equals(control.getControlId())) { // 清空自定义参数 this.getView().getFormShowParameter().setCustomParam("customerId", null); // 重新加载列表数据 this.getModel().refresh(); } } }refresh()会重新触发查询流程,包括重新走一遍setFilter,所以清掉参数后刷新,列表自然就恢复成全部数据了。这是一个很实用的小技巧,不用手动去拼 SQL,也不用重新构造列表对象。
4.4 刷新时的高频坑:页码状态和过滤条件残留
列表刷新这步,我踩过的坑可以列一个清单。
页码未复位。用户翻到第三页后点“重置”,刷新后数据变成了全部订单,但页码还在第三页,如果第三页已经没有数据,列表就显示空白。解决办法是刷新前把页码重置到第一页。大部分版本里getModel().refresh()默认会回到第一页,但如果你手动调了页码相关的方法,就要特别注意。
过滤条件残留。这是更隐蔽的坑。用户从客户列表跳转到销售订单列表,列表按客户过滤了。用户手动把列表关闭,下次直接从销售订单菜单打开列表,如果customerId参数没有清掉,列表依然会按那个客户过滤,看起来就像“列表坏了,永远只显示一个客户的数据”。所以跳转过去的时候,一定要保证参数生命周期可控,或者在列表插件的afterLoadData里判断当前入口,决定是否需要清理参数。
事件重复触发。在setFilter里追加过滤条件时,千万不要顺手调用this.getModel().refresh()。refresh()会再次触发setFilter,于是形成“刷新 -> setFilter -> 刷新”的循环。正确的做法是:setFilter只负责组装条件,不要在里面触发查询。
这些坑都不是 API 不会用,而是对“生命周期”和“事件流”理解不够。我建议你自己在环境里打断点,把一次列表刷新从click到setFilter到afterLoadData的完整调用链走一遍,比看十篇文档都管用。
5. 加载数据时四个最容易翻车的边界问题
5.1 数据权限和数据隔离:查不到数据先别怀疑 SQL
在苍穹里,通过QueryService和BillQueryService查询数据时,默认会带着当前用户的数据权限和组织隔离。也就是说,同一个查询,管理员可能看到 100 条数据,普通用户可能只看到 20 条,这不是 SQL 的问题,是权限在起作用。
很多新手遇到“查不到数据”,第一反应是把过滤条件反复改来改去,折腾半天才发现是当前用户没有那个组织的数据权限。我的建议是排查问题时,把“当前用户是谁、当前组织是什么、有没有数据权限”这三个问题放在最前面。
还有一种特殊情况:插件运行在没有用户上下文的场景里,比如定时任务、消息订阅、第三方接口回调。这时候你用QueryService查数据,可能因为上下文为空,一条都查不出来。解决方案是在代码里显式设置操作人、操作组织,或者使用系统管理员身份上下文,具体看你的业务场景。
这里特别提醒:不要为了图省事,在插件里用 JDBC 绕过权限控制。权限控制是企业系统的底线,绕过权限写出来的功能,一旦上线就是事故。谁也不想自己写的插件成为数据泄露的入口。
5.2 只查需要的字段,不要一把梭
写QueryService的时候,setSelectFields不设置的话,有些版本会返回业务对象的全字段。一张销售订单,单据头几十个字段,单据体可能还有几十个字段,查出来全部塞到内存里,列表不卡才怪。
我的习惯是:凡是查询,必写selectFields,只查当前逻辑需要的字段。这既减少了网络传输量,也减少了对象初始化的开销。特别是在循环里查询数据的场景,字段少一个,性能可能差一倍。
5.3 缓存倒灌:查出来的是旧数据
苍穹的查询 API 是带缓存的。同一张单据,短时间内被反复查询,第二次查询很可能直接命中缓存,速度很快,但拿到的可能是修改前的旧数据。
有一个我实际遇到的场景:审核操作里改完单据的金额状态,紧接着在同一个服务里用QueryService去查这个单据的最新金额,结果拿到的还是旧值。一开始我以为事务没提交,后来才发现是查询缓存的问题。
解决办法也很简单,对实时性要求高的查询,要么在查询参数里显式关闭缓存,要么先清理对应单据的缓存再查询。不同版本的 API 略有差异,你要在自己的环境里找到对应的关闭缓存的方法。
5.4 事件循环:setValue 引发的死循环
表单插件里有个典型场景:监听字段“客户”的值改变事件,每当客户变化时就查询这个客户的默认业务员,回填到“业务员”字段。代码写起来很自然,但这里藏着一个大坑。
事件流是这样的:用户修改客户 -> 触发值改变事件 -> 插件查询默认业务员 ->setValue("owner", 业务员)-> 业务员字段值变化 -> 如果业务员字段也有值改变监听,又触发事件 -> 继续查询、继续回填……一旦链路里有环,就会形成死循环,轻则页面卡死,重则后台持续跑查询。
我的解决方案有三种,按优先级排:
- 回填前判断值是否真的变化了,如果新旧值相同,就不执行
setValue。 - 引入一个 boolean 类型的标志位,在批量回填期间置为 true,回填结束后置回 false,事件处理开头判断标志位直接 return。
- 不用字段值改变事件,改成手动按钮触发,从交互源头杜绝循环。
这三个方案里,第一个最轻量,第二个最通用,第三个最保险。实际项目里我一般先用第一个,如果逻辑复杂再上第二个。
6. 高频报错与调试经验:数据加载不上时的排查链路
6.1 插件没生效:先查这三件事
“插件写了但没反应”是新手问得最多的问题,通常原因有三个:
第一,插件类没有部署到正确的位置。开发环境里改了代码,要重新编译并且部署到苍穹的运行目录,不是在 IDE 里点一下 Run 就完事了。第二,插件类没有在元数据里配置,或者配置的类名和实际类名不一致。第三,方法名拼写错误。registerListener写成了registerListenner,click写成了onClick,这类错误不会报编译错,但方法永远不会被调用。
排查方法很简单:在重写的方法第一行加一句日志输出,比如System.out.println("SyncFromSaleOrderPlugin.click invoked");,然后触发对应动作,看控制台有没有输出。没有输出,说明插件根本没被挂上或者时机不对;有输出,说明插件挂载成功,问题在后面。
6.2 “字段不存在”类错误:标识符别手打
QueryService执行时如果报“未找到标识符为 XXX 的字段”,百分之百是字段标识写错了。这个字段标识不是界面上看到的中文名称,而是表单设计器里的英文字段标识。比如“客户”在界面是中文,在数据模型里可能是customer或customerid,要以设计器里为准。
我的习惯是:从设计器或元数据里直接复制字段标识,绝不手打。手打容易打错大小写,而字段标识是区分大小写的。这个习惯帮我省了无数次排查时间。
6.3 查不到数据与查到错误数据的排查方向
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 查不到数据 | 数据权限、过滤条件类型不匹配、组织隔离、状态条件配置错误 | 1. 确认当前用户权限;2. 打印过滤参数;3. 用相同条件在查询分析器里执行 |
| 查到数据但不对 | 过滤条件没生效、缓存、表单标识填错、被其他插件改了参数 | 1. 检查 setFilter 是否被覆盖;2. 排除缓存;3. 确认 formId 正确 |
查不到数据时,最快的方式是把过滤条件里的参数用日志打印出来,单独在数据库客户端里执行一遍同样的 SQL。能查到,说明 SQL 逻辑没问题,问题在权限或缓存;查不到,直接看 SQL 本身。
查到错误数据时,先怀疑过滤条件是否真的传到数据库了。有一种情况是你在插件里追加了过滤条件,但平台本身的查询参数把你的条件覆盖了,导致条件“没生效”。这时候可以用afterLoadData里打印返回的第一条数据的单号,反推过滤条件是否生效。
6.4 调试加载顺序的完整链路
最后分享一下我自己排查数据加载问题时的操作顺序,照着走基本能定位 80% 的问题:
- 本地起调试环境,断点打在
registerListener,确认插件被加载。 - 断点打在事件方法第一行,确认事件被触发。
- 把查询入参打印出来,包括表单标识、字段列表、过滤条件、参数值。
- 把打印出来的 SQL 条件和参数,直接拿到数据库客户端里执行。
- 数据库能查出数据,回到代码里检查 API 版本差异;数据库查不出数据,检查权限、组织、过滤条件。
这套链路的核心思想就一句话:先确认代码真的走到了,再确认参数真的对了,最后确认数据真的存在。大多数排查工作,其实是在第一步和第二步之间浪费的时间最多。
写到这里,第一篇基本把“加载数据”的骨架讲完了。说句实在话,金蝶云苍穹的插件开发并不难,难点全在“时机”和“边界条件”。把事件时机理清楚,把查询 API 的特性记牢,数据加载这块就算拿下了一半。我个人建议你先别急着往后看,把文中的两个实战例子在自己的环境里跑一遍,尤其是表单回填和列表过滤这两个场景,跑通了再往下学,会有一种豁然开朗的感觉。下一篇我打算写插件里的数据写回和事务控制,那里面还有一批更隐蔽的坑等着填。