项目:ERP-CRM | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇是逐段带码的源码走读:选取三个最能代表项目质量的代码对象——跨库审批编排核心 CrmApprovalServiceImpl(全项目最复杂模块,311 行)、开发期代码生成器 gen_business_modules.py(451 行,选 3 个关键模板函数逐函数解读)、以及一条典型业务的 Controller→Service→Mapper 完整链(客户转移/入公海的 restrictOwner 越权守卫链)。模块级的方法清单与职责划分见《07-模块详解》,流程图与步骤说明见《05-核心流程》,本篇直接贴代码逐段讲——目标是读完本篇后,你可以不看源码就预测这三段代码在任何输入下的行为。所有行号以 2026-10-06 检出状态为准。
文件:erp-crm/src/main/java/com/ec/crm/service/impl/CrmApprovalServiceImpl.java(311 行)。先读类头与字段,它决定了整个类的运行前提。
类注释三段话浓缩了全部设计(第 27-36 行):流程形态"contractApproval(开始 -> 审批人处理 -> 网关按 approved 变量分流 -> 结束)";租户隔离策略"引擎表统一落在 master 库,业务数据在租户库。businessKey 编码为 tenant:contractId,待办/已办按 businessKey 前缀过滤租户(引擎表单列谓词),避免按租户变量过滤的变量表 join";一致性策略"租户库回写先行,引擎操作最后执行;引擎失败则补偿回退租户库,避免'引擎已推进、业务数据卡在待审'且无法重试的死锁"。注入五个依赖:Flowable 的 RuntimeService/TaskService/HistoryService(引擎三入口)、CrmApprovalMapper(留痕表)、CrmContractMapper(合同表),外加 ErpMetrics 打点。唯一常量 PROCESS_KEY = "contractApproval" 与 BPMN 文件的 process key 对应(erp-crm/src/main/resources/processes/contract-approval.bpmn20.xml)。注意注入的是 mapper 而非 service——本类刻意不依赖其他业务 service,库切换的控制在自己手里,这避免了"被调用方法内部再切键"的失控。
第 134-141 行是全类的地基,两个三行方法:
private void useMaster() {
// Flowable 引擎表在 master 库
DynamicDataSourceContextHolder.setDataSourceKey("default_db");
}
private void useTenant() {
DynamicDataSourceContextHolder.setDataSourceKey(SecurityUtils.getLoginUser().getTenant());
}它们直接设置 ThreadLocal 数据源键(机制见《01-系统架构》第 4 节)。两条纪律从这里生出:其一,每次引擎操作前必须 useMaster,因为引擎表在主库而 ThreadLocal 默认停在租户键;其二,每次走 mapper 碰业务表前必须 useTenant。selectApprovalList 的循环体是纪律的集中展示——useMaster() 查任务、useTenant() 补合同、下一轮循环开头再 useMaster(),注释"上一轮 useTenant() 后 Flowable 查询必须切回 master 库"在循环里出现两次(第 78、111 行)。这种成对切换没有编译期保护,靠的是注释与评审纪律;也正因为没有事务包裹(方法上无 @Transactional),切换才是即时生效的——这反过来解释了为什么本类所有写操作都设计成"可回退"而非"靠事务回滚"。
方法签名 selectApprovalList(String status, String title, int pageNum, int pageSize)(第 60-132 行)按 status 分流。待办路径(status="0")走运行时查询:
useMaster();
ProcessInstanceQuery piq = runtimeService.createProcessInstanceQuery()
.processDefinitionKey(PROCESS_KEY)
.processInstanceBusinessKeyLike(SecurityUtils.getLoginUser().getTenant() + ":%")
.includeProcessVariables();
if (title != null && !title.isEmpty()) {
piq.variableValueLike("contractName", "%" + title + "%");
}
total = piq.count();
List<ProcessInstance> instances = piq
.orderByStartTime().desc()
.listPage((pageNum - 1) * pageSize, pageSize);三个细节值得逐个说。第一,租户过滤用 processInstanceBusinessKeyLike(tenant + ":%")——businessKey 是单列索引语义,前缀 like 不走通配开头,比按租户变量过滤(需要 join ACT_HI_VARINST 变量表)高效得多,这是类注释明示的取舍。第二,total = piq.count() 与 listPage(...) 是两次独立查询,Flowable 分页参数是 (offset, limit) 而非 MyBatis 的 (pageNum, pageSize),所以换算成 (pageNum-1)*pageSize——手写分页换算时这类 API 差异是高频出错点。第三,includeProcessVariables() 把流程变量一并带回,避免循环里再查一次变量。取到实例后逐个取 active 任务并组装 VO:任务不存在(实例已推进但历史未归档的边界态)直接 continue 跳过;baseVO 填 taskId 与标题("合同审批:" + contractName 变量);然后 useTenant() 切回租户库 fillContract 补客户名与合同金额。
已办路径(status=1/2)走历史查询 historyService.createHistoricProcessInstanceQuery()...finished().includeProcessVariables(),用 variableValueEquals("approved", "1".equals(status)) 过滤通过/驳回——approved 在引擎里是 Boolean 变量(后面 complete 时放入),这里用字符串比较后传入 Boolean,类型匹配靠 Flowable 的变量类型系统兜住。VO 组装时直接从历史变量取 opinion 与结束时间(第 119-123 行,用 SimpleDateFormat 格式化)。聚合出一个真实的性能事实:列表每行要做一次任务查询(待办)加一次合同查询(两路径),是《13-技术债务与演进建议》D11"审批列表 N+1"的出处。
第 188-245 行是全类最精巧的方法,按执行顺序走读。第一步预检(读主库前先把键切回租户库查合同,查完 finally 切回 master——try/finally 保证任何路径不残留错误键):
String tenant = SecurityUtils.getLoginUser().getTenant();
CrmContract contract;
useTenant();
try {
contract = contractMapper.selectCrmContractById(contractId);
} finally {
useMaster();
}
if (contract == null) {
throw new ServiceException("合同不存在");
}
if ("0".equals(contract.getReviewStatus())) {
throw new ServiceException("该合同已有待审批单,请勿重复提交");
}
if ("1".equals(contract.getReviewStatus())) {
throw new ServiceException("该合同已审批通过,无需再次提交");
}
String prevStatus = contract.getReviewStatus();prevStatus 在这里快照——它是后面补偿回退的目标值,注意允许的状态是 null(未提交)或 "2"(被驳回),驳回后可重新提交。第二步租户库先行(第 210-223 行):updateCrmContract 把 review_status 置 "0",insertCrmApproval 写留痕行(bizType="contract"、status="0")。第三步引擎启动(第 225-242 行),成败两分支:
try {
useMaster();
Map<String, Object> vars = new HashMap<>();
vars.put("approver", "admin");
vars.put("applicant", applicant);
vars.put("contractName", contract.getName());
vars.put("contractAmount", contract.getAmount());
vars.put("tenant", tenant);
runtimeService.startProcessInstanceByKey(PROCESS_KEY, tenant + ":" + contractId, vars);
} catch (RuntimeException e) {
useTenant();
contractMapper.revertReviewStatus(contractId, prevStatus);
approvalMapper.deleteCrmApprovalById(approval.getId());
erpMetrics.approval(tenant, "submit", "rolled_back");
throw new ServiceException("审批流程启动失败,已回滚业务数据,请重试");
}五个流程变量里 approver 硬编码 "admin"(当前流程定义是单人审批,网关只看 approved 变量);businessKey 用 tenant + ":" + contractId 编码租户。补偿分支按序做四件事:切回租户库、revertReviewStatus(幂等 SQL,直接把 review_status 写回快照值)、删留痕、打点 rolled_back、最后抛 ServiceException 把"已回滚"语义透传给前端。整个过程没有 @Transactional——两库写无法共用事务是硬约束,补偿式一致性就是为它设计的(详见《16-决策记录ADR》ADR-003)。方法尾 erpMetrics.approval(tenant, "submit", "ok") 打点成功。
approve(CrmApproval crmApproval, String approveBy)(第 247-310 行)与 submit 对称,多了两个校验:任务不存在时先 useTenant() 再抛错(保证抛错后线程键归位);驳回(status != "1")必填 opinion,否则抛"驳回时请填写审批意见"。然后从任务的流程实例反查 businessKey 解析 contractId(parseContractId 第 165-171 行,兼容历史无前缀数据:无冒号时整串当 contractId,这是 2026-09 加固前的存量流程实例容错)。租户库先行段(第 271-291 行)的关键是 syncApprovalStatus——一个定向 UPDATE(只更新该 biz 待审留痕行),返回 0 时抛"未找到待审批的留痕记录,请刷新后重试",这是把"重复审批"拦截在留痕表唯一性上。任何租户库写失败走 revertApprovalStatus 回滚留痕(合同更新失败时其本身未生效,无需回退)。引擎推进段失败的双回退(第 300-307 行)依次恢复合同快照、回滚留痕、切回 master(防止下一请求踩着租户键查询引擎)、打点、抛错。
注释里"若回写后引擎前崩溃,重试审批可自愈(回写幂等,引擎任务仍待处理)"这句话值得展开:两阶段之间 JVM 崩溃,留库状态是"合同已推进/留痕已更新、引擎任务还在待办"——用户在审批中心重试同一任务,syncApprovalStatus 幂等地重写同样的值,complete 正常推进,最终一致。崩溃窗口选择了"业务先行"方向:反过来(引擎先行)崩溃会留下"引擎已结束、业务卡待审"且 complete 无法重放(任务已消失)——那才是注释里说的"无法重试的死锁"。这个方向选择是全类设计的灵魂。
其一,GET /crm/approval/{id} 的 {id} 是 Flowable 任务 ID(UUID 字符串),selectCrmApprovalById 直接喂给 taskService.createTaskQuery().taskId(...),误传 crm_approval.id 得到空对象而非报错(《07-模块详解》2.2 已记录该坑)。其二,本类所有查询都依赖调用链上游(TenantInterceptor)设置的初始租户键与登录态(SecurityUtils.getLoginUser()),测试时必须模拟这两样——AbstractIntegrationTest 基类预置 SecurityContext 与租户键正是为此(《09-测试与质量保障》第 2 节),脱离基类直调本类会得到 NPE 而不是业务错误。
文件:erp-crm/gen_business_modules.py(451 行,Python 3 无第三方依赖)。整体结构:MODULES 数组声明式定义三个模块(每个含 entity/table/cn/route/cols/extra_transient),JOIN/JOIN_COLS/EXTRA_RESULT 三个字典按实体名声明关联查询三件套,六个 gen_* 函数各产一个文件,主循环逐模块写盘(第 435-451 行)。《07-模块详解》第 6 节已给过函数清单,本篇选三个最见功力的函数逐段解读。
gen_domain(m)(第 90-159 行)把列定义四元组 (java名, java类型, 中文注释, db列名) 翻译成 Java 实体。三个分支值得注意。主键分支(col is True,第 94-107 行):只生成字段与 getter/setter,不加 @Excel——主键不该出现在导出里。普通列分支(第 109-122 行)有一行是全函数的灵魂:
excel = f" @Excel(name = \"{comment}\")\n" if col and name != "remark" else ""每个非主键、非 remark 的列自动获得 @Excel(name=中文注释)——中文注释在这里承担双重职责(代码可读性 + 导出列头),这正是"生成产物里每个导出字段都有注解"的机制来源;remark 例外是因为 BaseEntity 已含 remark,导出时通常不需要。关联注入字段分支(第 123-137 行)为 extra_transient 生成带 @Transient 的字段——注意 import 的是 org.springframework.data.annotation.Transient(第 149 行),这是个容易较真的细节:语义上是"非表字段",用的是 Spring Data 的注解而非 javax.persistence,因为实体不与 JPA 绑定。import 段按类型去重排序生成(第 139-142 行,FIELD_TYPE_IMPORT 只登记 BigDecimal/Date),保证产物 import 区干净可 diff。最终模板(第 143-159 行)拼出 extends BaseEntity 的完整类。
gen_mapper_xml(m)(第 188-263 行)产出的 XML 是六个文件里结构最复杂的。先看它做的四段"列运算":
cols = [(n, t, c, col) for n, t, c, col in m["cols"] if col is not True]
dbcols = "o.id, " + ", ".join(f"o.{col}" for _, _, _, col in cols)
...
where = "\n".join(
f''' <if test="{n} != null{' and ' + n + " != ''" if t == "String" else ''}">
and o.{col} = #{{{n}}}
</if>'''
for n, t, c, col in cols
if col and n != "id"
)第一段过滤掉主键得到表列;第二段拼 select o.id, o.xxx ...(主键 id 硬编码在最前,别名统一用 o);第三段生成动态 where——类型感知是这里的关键:String 列的 <if> 追加 and x != ''(空串也算不过滤),非 String 列(Long/BigDecimal/Date)只判 null。第四段(第 204-208 行)为 insert/update 准备列清单,显式排除 id(自增)与 remark(BaseEntity 字段单独处理:insert 的 values 里 #{createBy}, sysdate(), #{remark},update 的 set 里 update_by, update_time, remark)。join 三件套的注入点在 <sql id="select{e}Vo"> 片段(第 219-222 行):
<sql id="select{e}Vo">
select {dbcols}, {JOIN_COLS[e]} from {table} o
{JOIN[e]}
</sql>回款 CrmReceivable 的二跳 join 直接写在 JOIN 字典里(left join crm_contract ct on ct.id = o.contract_id + 换行缩进 + left join crm_customer c on c.id = ct.customer_id),EXTRA_RESULT 为每个别名列补一行 <result property="contractNo" column="contract_no"/>——resultMap 与 select 别名必须一一对齐,这个不变量由生成器保证,也解释了《09-测试与质量保障》第 5 节为什么把"列表 join 列回填"列为生成代码最易漂移的测试点。其余四条 CRUD(selectById/insert/update/delete)全部参数化(#{}),insert 带 useGeneratedKeys="true" keyProperty="id" 回填主键,deleteByIds 用 <foreach> 展开 Long[]。通读该函数后再看产物 CrmReceivableMapper.xml,会发现产物 XML 与模板输出的每一行都能对应——生成器没有任何"隐藏后处理",所见即所得。
gen_controller(m)(第 345-432 行)产出 RuoYi 标准五端点 Controller。模板里的关键片段:
@PreAuthorize("@ss.hasPermi('crm:{route}:list')")
@GetMapping("/list")
public TableDataInfo list({e} {var}) {
startPage();
List<{e}> list = {var}Service.select{e}List({var});
return getDataTable(list);
}三个生成纪律固化在模板里:每个端点必带 @PreAuthorize(权限串 crm:{route}:{动作} 与路由参数联动,保证新模块权限串命名自动一致);写端点必带 @Log(title = "{cn}", businessType = BusinessType.INSERT/UPDATE/DELETE)(审计不遗漏);add/edit 分别注入 setCreateBy/setUpdateBy(getUsername())(操作人落库自动化)。同时它也固化了后续手工补课的边界:模板没有生成 restrictOwner 归属校验、没有动词端点(approve/submit 类)、remove 直接物理删除——这正对应 D03(生成器四模块无归属校验)与 D19 的出处,读模板就知道债从哪来。主循环(第 435-451 行)对每模块顺序写 6 个文件并打印 Xxx: 6 files written,结尾 ALL DONE。重跑即整文件覆盖(产物无哈希对比),这就是"生成后即固化"红线的机械原因(见《16-决策记录ADR》ADR-004 与《11-扩展开发指南》SOP-2 红线)。
第三走读选一条"横穿三层"的典型链:POST /crm/customer/transfer(转移客户)。它的价值在于三层各做一件事、且防越权逻辑横跨三层——这是本项目业务代码的标准形态。
erp-crm/src/main/java/com/ec/crm/controller/CrmCustomerController.java 第 141-146 行:
@PreAuthorize("@ss.hasPermi('crm:customer:transfer')")
@Log(title = "客户", businessType = BusinessType.UPDATE)
@PostMapping("/transfer")
public AjaxResult transfer(@RequestParam("ids") Long[] ids, @RequestParam("owner") String newOwner) {
return toAjax(crmCustomerService.transferCrmCustomerByIds(ids, newOwner, getUsername()));
}权限串 crm:customer:transfer 与前端 v-hasPermi 一字不差;@Log 落操作日志;旧负责人不是前端传的,而是服务端 getUsername() 取当前登录人——操作人永远以服务端会话为准是防越权的第一原则。toAjax 是 BaseController 的统一结果包装(影响行数 >0 即成功)。注意入参是 @RequestParam 数组(query 串 ids=1&ids=2&owner=xxx)而非 JSON body,与同 Controller 内 add/edit 的 @RequestBody 形态不同——对接时先看方法签名再定请求形态(《15-接口全量参考》4.1 的入参要点即按此记录)。
erp-crm/src/main/java/com/ec/crm/service/impl/CrmCustomerServiceImpl.java 第 94-99 行:
@Override
@Transactional
public int transferCrmCustomerByIds(Long[] ids, String newOwner, String oldOwner) {
boolean restrictOwner = !SecurityUtils.isAdmin(SecurityUtils.getUserId());
return crmCustomerMapper.transferCrmCustomerByIds(ids, newOwner, oldOwner, restrictOwner);
}Service 只做两件事:算出 restrictOwner(管理员判定在服务端,SecurityUtils.isAdmin 锚点 user_id=1 或权限串 *:*:*,普通用户恒为 true)、透传给 mapper。@Transactional 保证批量转移的原子性。设计上的微妙之处:越权防护不是在 Service 里"查出记录比对后拒绝",而是把一个布尔谓词下推给 SQL——非管理员操作他人记录的结果是影响 0 行(静默降级)而非报错。对比同类的删除方法(第 122-134 行 deleteCrmCustomerByIdsForOwner)则是逐条查记录、非本人名下抛 ServiceException("无权删除他人名下的记录")——两种风格并存且各有测试锚定(CrmCustomerOwnerGuardIntegrationTest),转移/入池选择"静默降级"是因为批量操作里混入他人记录时,部分成功比整体失败更符合公海业务直觉;删除不可逆所以选择"显式拒绝"。
erp-crm/src/main/resources/mapper/crm/CrmCustomerMapper.xml(transferCrmCustomerByIds 段,第 391-400 行附近):
<update id="transferCrmCustomerByIds">
<!-- pre_owner 记录原负责人(MySQL SET 从左到右求值,必须在 owner 赋值前);
restrictOwner 时仅允许转移自己名下的记录,防止越权改动他人客户/线索 -->
update crm_customer set pre_owner=owner, owner=#{newOwner} where id in
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
<if test="restrictOwner">
and owner = #{oldOwner}
</if>
</update>两个值得背下来的细节。第一,set pre_owner=owner, owner=#{newOwner} 的求值顺序依赖:MySQL 的 UPDATE SET 从左到右求值,pre_owner=owner 必须写在 owner= 赋值之前,否则 pre_owner 会拿到新值——注释明确记录了这条 MySQL 方言知识,XML 下方 customerToPoolByIds 的注释甚至写明"(顺序不可颠倒)"。第二,<if test="restrictOwner"> 拼 and owner = #{oldOwner}——谓词追加在 where id in (...) 之后,把"非本人名下"的行从更新范围里剔除;#{oldOwner} 参数化,restrictOwner 是布尔标志(非用户输入)不参与 SQL 拼接值,注入面为空。入公海的 customerToPoolByIds(第 402-412 行附近)多了两道约束:where status='1'(仅客户态可入池,线索与已是公海的行天然被排除)与 owner=null(公海行无主),再加 to_pool_time 时间戳;而公海领取 receiveCustomerById(第 414-417 行附近)则用 where id=#{id} and status='2' 保证只领公海行、并清 to_pool_time。三个 SQL 合起来就是"线索→客户→公海→再领取"全流程的数据语义(《05-核心流程》流程五的业务规则,在 SQL 层的最终落点)。
其一,横切三层各司其职:Controller 管协议与操作人注入、Service 管业务决策(谁可以做)、Mapper 管数据规则(怎么做),越权防护拆成"管理员判定(Service)+ SQL 谓词(Mapper)"两半,任何一层都不越俎代庖。其二,新模块抄这条链时,生成器产物(走读二)只覆盖了"没有归属校验的退化版",复刻本链要手工补三处:Service 的 isAdmin 判定、Mapper 的 <if> 谓词、以及(若涉及删除)逐条校验版本——这正是《13-技术债务与演进建议》D03 的偿还模板。其三,测试锚定方式:CrmCustomerOwnerGuardIntegrationTest 用非管理员 SecurityContext 调 transfer 断言 0 行、调 delete 断言异常消息——验证的是"行为语义"而非实现细节,这使两个风格的守卫都能安全重构。