点狮OA:集团级多租户办公系统的落地实践
简介点狮OA是一套面向集团级企业及多租户SaaS服务场景的现代化办公系统专为需要独立流程管控与权限隔离的中大型组织设计解决跨子公司协同审批、个性化组织架构管理及异构系统集成等核心问题。资源包共2000个文件主体为485个Java后端逻辑文件、850个HTML/HTM前端页面、414个JS交互脚本及107个CSS样式资源辅以Flowable流程引擎配置、权限控制模块与多端适配代码整体压缩包37.51MB结构完整、模块解耦清晰。目前已有175人学习下载适合Java全栈开发者、企业级OA系统实施工程师及SaaS平台二次开发人员参考。读者可直接获取基于点狮后台管理框架的可运行OA系统源码包含全流程设计器、并行/会签/回退等复杂审批逻辑实现、租户级菜单与单据权限隔离方案以及与HRM、CRM、ERP等点狮生态系统的标准对接接口。1. 点狮OA不是“又一个OA”而是集团级多租户办公系统的落地骨架它把租户隔离、流程可编排、系统可拼装这三件事真正做进了生产环境的数据库和API里你见过这样的场景吗某集团下属7家子公司每家都要独立配置请假流程——A公司要求部门负责人HRBP双签B公司走线上会签纸质归档C公司则必须触发ERP薪资模块校验。传统OA要么硬编码改三次要么买三套SaaS付三份年费。点狮OA的解法很直接一个后台七个租户库或单库多schema每个租户在Web界面拖拽流程节点、绑定岗位而非人员、上传自定义表单字段保存即生效。它不靠“租户概念”画饼而是用Flowable引擎的tenantId字段贯穿任务创建、审批路由、历史查询全链路不靠“可扩展”喊口号而是把HRM/AM/CRM等模块设计成标准Spring Boot Starter依赖声明即集成连数据库连接池都支持按租户动态切换。适合正在从单体OA向集团化数字底座演进的技术团队——尤其当你已踩过“租户数据混查”“流程版本回滚失败”“小程序登录态跨租户失效”这三类坑时点狮OA的源码结构和配置清单就是你下一次架构评审的对照表。2. 多租户架构落地从数据库隔离到API网关路由看点狮OA如何让七个子公司互不看见对方的数据点狮OA的多租户能力不是配置开关而是从数据层到应用层的纵深防御。它提供三种隔离模式共享数据库独立Schema推荐用于中小集团、共享数据库租户ID字段硬过滤适配老旧MySQL 5.7、完全独立数据库实例金融级合规场景。无论选哪种核心逻辑都锚定在TenantContext这个线程局部变量上——所有MyBatis拦截器、JPA实体监听器、Flowable事件处理器都通过它获取当前请求所属租户标识。下面以最常用的Schema模式为例拆解关键实现。2.1 数据源动态路由AbstractRoutingDataSource的实战改造点狮OA没有用ShardingSphere这类重量级中间件而是基于Spring原生AbstractRoutingDataSource做了轻量封装。关键代码在com.dianshi.oa.tenant.datasource.TenantRoutingDataSource中public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 从ThreadLocal取租户标识若为空则取默认租户如平台管理租户 String tenantId TenantContext.getCurrentTenantId(); return StringUtils.hasText(tenantId) ? tenantId : default; } }提示TenantContext的注入时机非常关键。点狮OA在TenantFilter中完成初始化该Filter必须排在Spring Security FilterChain之前否则SecurityContext中的用户信息可能先于租户上下文加载导致权限校验错乱。这个设计的精妙在于它把租户路由从“SQL拼接”升级为“连接池级隔离”。每个租户Schema对应一个独立的HikariCP连接池连接池名格式为hikari-tenant-{tenantId}。当A公司用户发起审批请求时整个事务链路包括Flowable的ACT_RU_EXECUTION表操作全部落在tenant_aSchema内物理层面杜绝了数据越界。2.2 MyBatis拦截器自动注入tenant_id字段的隐形手对于共享数据库字段过滤模式点狮OA用MyBatis插件实现无感租户过滤。TenantInterceptor在Executor.update()和Executor.query()方法执行前自动解析SQL并注入WHERE条件Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}), Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 只处理非SELECT COUNT查询避免影响分页总数统计 if (!ms.getSqlCommandType().equals(SqlCommandType.SELECT) || !ms.getBoundSql(parameter).getSql().contains(COUNT)) { BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql(); // 在WHERE后插入tenant_id ?若无WHERE则追加 String newSql injectTenantCondition(sql); // 构造新BoundSql将tenantId参数注入parameterMap BoundSql newBoundSql new BoundSql(ms.getConfiguration(), newSql, getParameterMappingWithTenant(boundSql.getParameterMappings()), parameter); // 替换MappedStatement MappedStatement newMs copyFromMappedStatement(ms, new BoundSqlSqlSource(newBoundSql)); args[0] newMs; } return invocation.proceed(); } }这段代码的血泪经验是不能简单用正则替换WHERE。真实业务SQL常含子查询、UNION、WITH CTE等复杂结构点狮OA采用ANTLR4解析SQL语法树确保tenant_id ?只注入到主查询的WHERE子句不污染子查询逻辑。这也是为什么它的租户过滤能兼容98%的动态SQL包括foreach生成的IN语句。2.3 API网关层租户识别从Header到JWT Claim的三级解析策略前端调用点狮OA API时租户标识通过三种方式传递系统按优先级降序解析HeaderX-Tenant-ID最高优先级用于调试和灰度发布JWT Token的tenant_idClaim生产环境主通道Token由统一认证中心签发域名子路径/t/{tenantCode}/api/...兼容老版小程序Nginx反向代理时剥离网关过滤器TenantResolveFilter的核心逻辑如下public class TenantResolveFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String tenantId resolveTenantId(request); if (StringUtils.hasText(tenantId)) { TenantContext.setCurrentTenantId(tenantId); // 将租户ID写入MDC方便日志追踪 MDC.put(tenantId, tenantId); } filterChain.doFilter(request, response); } private String resolveTenantId(HttpServletRequest request) { // 1. 检查Header String headerTenant request.getHeader(X-Tenant-ID); if (StringUtils.hasText(headerTenant)) return headerTenant; // 2. 解析JWT需验证签名有效性 String authHeader request.getHeader(Authorization); if (StringUtils.startsWithIgnoreCase(authHeader, Bearer )) { String token authHeader.substring(7); try { JwsClaims claimsJws Jwts.parser() .setSigningKey(jwtSecretKey) .parseClaimsJws(token); return (String) claimsJws.getBody().get(tenant_id); } catch (Exception e) { log.warn(JWT解析失败跳过租户识别, e); } } // 3. 解析路径 String path request.getRequestURI(); Matcher matcher PATH_TENANT_PATTERN.matcher(path); if (matcher.find()) { return matcher.group(1); // 提取/t/abc/中的abc } return null; } }注意JWT解析必须做签名验证否则攻击者可伪造任意tenant_id。点狮OA强制要求所有生产环境JWT使用RSA256算法公钥由认证中心通过/auth/public-key端点动态提供避免硬编码密钥。3. Flowable流程引擎深度集成从并行审批到抄送转办看可视化设计器如何生成可审计的BPMN 2.0文件点狮OA没把Flowable当黑匣子用而是把它拆解成“设计器→BPMN文件→运行时引擎→审计日志”四段式流水线。所有流程变更新增、修改、停用都生成标准BPMN 2.0 XML文件存入act_re_procdef表的同时同步备份到MinIO对象存储文件名格式为{processKey}_{version}_{tenantId}.bpmn20.xml。这种设计让流程回滚变成文件版本切换比数据库脚本迁移可靠十倍。3.1 流程设计器的三个关键约束保证BPMN可执行性点狮OA的Web流程设计器基于bpmn-js定制并非自由画布而是内置三重校验规则节点命名强制规范用户任务UserTaskID必须为usertask_{roleCode}_{stepSeq}格式如usertask_hr_manager_01确保后端能根据ID解析出角色编码和步骤序号连线标签必填且唯一每条SequenceFlow必须填写name属性如“同意”“驳回”且同一网关出口的标签不能重复避免Flowable解析时因分支条件歧义导致死循环全局变量预声明设计器右侧面板强制填写流程级变量如applyAmount:BigDecimal、applicantDept:String这些变量在部署时自动注入ProcessEngineConfiguration的globalVariables避免运行时null异常。这些约束看似限制自由实则是把“流程开发”从“画图”拉回“编程”维度。某次客户要求增加“财务复核超24小时自动升级至CFO”的需求我们没改一行Java代码只在设计器中拖入一个TimerEvent设置timeDurationPT24H再连到CFO审批节点——Flowable原生支持零开发成本。3.2 并行审批的租户安全实现如何让A公司采购流程不卡住B公司报销流程并行审批Parallel Gateway是Flowable高频但易翻车的功能。点狮OA的解决方案是用租户ID作为流程实例ID的前缀。当启动流程时RuntimeService.startProcessInstanceByKey()传入的businessKey被重构为{tenantId}_{originalKey}_{uuid}// 启动采购申请流程 String businessKey String.format(%s_purchase_%s, TenantContext.getCurrentTenantId(), UUID.randomUUID().toString().replace(-, )); ProcessInstance instance runtimeService.startProcessInstanceByKey( purchase-process, businessKey, variables);这样做的好处是数据库索引友好act_ru_execution.PROC_INST_ID_字段天然按租户分片SELECT * FROM act_ru_execution WHERE PROC_INST_ID_ LIKE tenant_a%能命中索引运维排查直观查看act_hi_procinst表时PROC_INST_ID_列直接显示租户前缀DBA不用查关联表就能定位问题租户避免ID冲突不同租户可能同时启动同名流程如都叫leave-process前缀确保全局唯一。更关键的是点狮OA重写了ParallelMultiInstanceBehavior在创建并行分支时为每个分支Execution注入租户上下文public class TenantAwareParallelMultiInstanceBehavior extends ParallelMultiInstanceBehavior { Override protected void createInstances(DelegateExecution execution, int nrOfInstances) { super.createInstances(execution, nrOfInstances); // 为每个分支Execution设置租户ID ListExecutionEntity executions execution.getExecutions(); for (ExecutionEntity exec : executions) { exec.setVariable(tenantId, TenantContext.getCurrentTenantId()); } } }这保证了即使并行分支调用外部系统如调用ERP接口也能携带正确的租户凭证。3.3 抄送、转办、委托的权限穿透机制为什么被委托人能看到原始申请人信息点狮OA的“抄送”CC、“转办”Reassign、“委托”Delegate功能表面是UI按钮底层是Flowable的TaskService深度定制。以转办为例普通taskService.setAssignee(taskId, newAssignee)只能改办理人但点狮OA要求原始办理人记录在act_hi_taskinst.OWNER_字段不可见转办操作生成一条act_hi_comment记录类型为REASSIGN新办理人的待办列表中任务详情页显示“由【张三】转办”。实现的关键在ReassignTaskCmd命令类public class ReassignTaskCmd implements CommandVoid { private final String taskId; private final String newAssignee; Override public Void execute(CommandContext commandContext) { TaskEntity task commandContext.getTaskEntityManager().findById(taskId); String originalOwner task.getOwner(); // 保存原始办理人 // 执行转办 task.setAssignee(newAssignee); task.setOwner(originalOwner); // owner不变保证历史追溯 // 记录转办日志 Comment comment CommentEntity.create(); comment.setType(REASSIGN); comment.setTime(new Date()); comment.setTaskId(taskId); comment.setUserId(commandContext.getAuthenticatedUserId()); comment.setMessage(String.format(转办给 %s, newAssignee)); commandContext.getCommentEntityManager().insert(comment); // 关键更新act_hi_taskinst.HISTORIC_TASK_INST_ID_字段使其指向原始任务ID // 这样在历史任务查询时能关联到同一业务单据 HistoricTaskInstanceEntity historicTask commandContext.getHistoricTaskInstanceEntityManager() .findById(task.getId()); if (historicTask ! null) { historicTask.setHistoricTaskInstanceId(task.getId()); } return null; } }这套机制让审计变得极其简单查act_hi_comment表TYPE_REASSIGN的记录就是所有转办行为查act_hi_taskinst表OWNER_字段就是流程责任链。4. 租户个性化配置落地菜单权限、单据字段、流程节点如何用同一套代码支撑七种不同UI点狮OA的“租户可配置”不是指后台改几个JSON而是把配置项拆解为元数据驱动的三层结构基础层数据库字典表如sys_menu、sys_role_menu存储租户无关的通用菜单配置层tenant_menu_config、tenant_form_field等表存储租户特有配置渲染层前端Vue组件根据tenantId动态请求配置生成最终UI。这种设计让A公司隐藏“档案借阅”菜单、B公司给报销单增加“汇率”字段、C公司要求合同审批流必须包含“法务终审”节点全部无需发版。4.1 菜单权限的动态组装从静态路由到租户专属导航栏点狮OA前端使用Vue Router的addRoute()动态注册菜单。后端MenuController返回的菜单数据结构如下{ code: 0, data: [ { id: menu_oa_home, name: 首页, path: /home, component: Layout, children: [ { id: menu_oa_leave, name: 请假申请, path: leave, component: OALeaveApply, icon: icon-leave, permission: [leave:apply, leave:query] } ] } ] }关键点在于permission字段——它不是字符串数组而是权限表达式。点狮OA的权限校验器PermissionEvaluator支持SpEL表达式// PreAuthorize(permissionEvaluator.hasPermission(#menuId, view)) public boolean hasPermission(String menuId, String action) { // 1. 根据menuId查租户配置表获取该菜单的权限规则 MenuConfig config menuConfigMapper.selectByMenuIdAndTenant(menuId, TenantContext.getCurrentTenantId()); if (config null) return false; // 2. 解析SpEL表达式如 hasRole(hr) and #user.dept finance StandardEvaluationContext context new StandardEvaluationContext(); context.setVariable(user, getCurrentUser()); context.setVariable(tenant, getCurrentTenant()); Boolean result parser.parseExpression(config.getPermissionExpr()) .getValue(context, Boolean.class); return Boolean.TRUE.equals(result); }这样A公司可配置hasRole(admin) or #user.isGroupLeader trueB公司则用#user.dept in {hr,finance}——同一套Java代码不同租户不同规则。4.2 单据表单的字段级配置JSON Schema驱动的动态渲染引擎点狮OA的单据如《费用报销单》不写死HTML而是用JSON Schema描述字段{ title: 费用报销单, type: object, properties: { amount: { title: 报销金额, type: number, format: currency, minimum: 0.01 }, currency: { title: 币种, type: string, enum: [CNY, USD, EUR], default: CNY }, receipts: { title: 附件, type: array, items: { type: string, format: uri } } } }前端使用vue-json-schema库渲染后端FormSchemaService负责根据租户ID查tenant_form_schema表获取Schema对enum值进行租户级翻译如A公司CNY显示为“人民币”B公司显示为“RMB”对minimum等校验规则注入租户阈值如A公司最低报销100元B公司为50元。避坑JSON Schema的$ref引用必须绝对路径。点狮OA禁用相对引用所有$ref指向/schema/{tenantId}/{formKey}.json由Nginx统一代理到MinIO避免跨域和缓存问题。4.3 流程节点的租户化绑定岗位码如何映射到真实用户点狮OA流程设计器中“指定岗位办理”节点的配置项是岗位编码如hr_manager而非具体用户。运行时通过PositionService动态解析Service public class PositionService { public ListString resolveUsersByPosition(String positionCode) { // 1. 查租户岗位表获取该岗位的组织架构路径 Position position positionMapper.selectByCodeAndTenant(positionCode, TenantContext.getCurrentTenantId()); // 2. 根据组织路径查用户支持多级继承 // 如岗位hr_manager对应组织路径/集团/人力资源部/经理 // 则返回该路径下所有在职用户 return userMapper.selectByOrgPathAndStatus( position.getOrgPath(), UserStatus.ACTIVE.name() ).stream() .map(User::getUsername) .collect(Collectors.toList()); } }这个设计让组织架构调整如HR经理离职自动生效——无需重新部署流程只要在sys_position表更新org_path字段下次流程启动即用新配置。5. 避坑指南点狮OA上线前必须验证的五个致命陷阱附诊断脚本点狮OA的文档很厚但真正让项目延期的往往是那些文档里没写的“隐性依赖”。以下是我在三个集团客户上线时踩过的坑按严重程度排序每条都附可直接执行的诊断脚本。5.1 现象流程审批后历史任务查询返回空但act_ru_task表有数据原因Flowable的HistoryLevel配置为NONE或ACTIVITY未开启FULL级别。点狮OA要求historyLevelfull否则act_hi_taskinst表不记录完整任务链。解决检查application.yml中spring.flowable.history-levelfull并确认数据库act_hi_*表存在且可写。诊断脚本MySQL-- 检查历史表是否为空 SELECT COUNT(*) FROM act_hi_taskinst WHERE START_TIME_ IS NOT NULL LIMIT 1; -- 检查Flowable配置是否生效查运行时属性 SELECT * FROM act_ge_property WHERE NAME_ historyLevel;5.2 现象A公司用户登录后菜单栏显示B公司配置的菜单原因TenantContext未在Spring Security的SecurityContextPersistenceFilter之后清除导致线程复用时残留旧租户ID。解决在TenantFilter中添加finally块强制清理try { filterChain.doFilter(request, response); } finally { TenantContext.clear(); // 关键 }诊断脚本Java线程栈# 查看Tomcat线程中是否有残留TenantContext jstack pid | grep -A 5 -B 5 TenantContext5.3 现象并行审批中某个分支卡住act_ru_execution表出现IS_ACTIVE_false但IS_CONCURRENT_true的僵尸执行流原因Flowable的ParallelMultiInstanceBehavior在异常中断时未正确清理子执行流。点狮OA打了补丁在CustomParallelMultiInstanceBehavior中重写leave()方法Override public void leave(DelegateExecution execution) { try { super.leave(execution); } catch (Exception e) { // 强制清理所有子执行流 execution.getExecutions().forEach(ExecutionEntity::deleteCascade); throw e; } }诊断脚本查僵尸执行流-- 查找IS_ACTIVEfalse但IS_CONCURRENTtrue的执行流 SELECT ID_, PROC_INST_ID_, ACT_ID_, IS_CONCURRENT_, IS_ACTIVE_ FROM act_ru_execution WHERE IS_ACTIVE_ 0 AND IS_CONCURRENT_ 1;5.4 现象小程序调用/t/abc/api/leave接口返回401但Web端正常原因小程序JWT的audience受众字段未包含oa-mobile而网关TenantResolveFilter的JWT解析逻辑中JwsParser默认校验aud。解决在application.yml中配置Flowable JWT解析器忽略aud校验或让认证中心签发时添加aud: [oa-web, oa-mobile]。诊断脚本解析JWT# 使用jwt.io在线工具或以下命令解析token echo your-jwt-token | cut -d . -f 1,2 | base64 -d | jq . # 检查输出中是否有aud字段及值5.5 现象租户切换后Flowable的ProcessEngine仍使用旧租户的数据库连接原因ProcessEngineConfiguration是单例其dataSource属性在Spring容器启动时已注入未随租户动态切换。解决点狮OA不切换ProcessEngine而是为每个租户维护独立的ProcessEngine实例通过ProcessEngineFactoryBean按需创建Bean Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public ProcessEngine processEngine(Qualifier(tenantDataSource) DataSource dataSource) { ProcessEngineConfiguration config new StandaloneProcessEngineConfiguration(); config.setDataSource(dataSource); config.setDatabaseSchemaUpdate(false); // 禁用自动建表 return config.buildProcessEngine(); }诊断脚本查连接池-- 查看HikariCP连接池名是否含租户ID SELECT pool_name, active_count, idle_count FROM information_schema.processlist WHERE user hikari;6. 生产环境验证技巧用三条SQL和一个curl命令5分钟确认点狮OA多租户是否真正就绪上线前最怕什么不是功能没做完而是“以为就绪其实没好”。我给自己立了个铁律每次部署后必须用以下四个原子操作验证缺一不可。这招帮我在某集团上线前揪出Flowable历史表权限问题避免了上线后审计日志缺失的灾难。6.1 验证租户数据隔离执行跨租户数据探针SQL在数据库客户端执行以下SQL它故意构造一个跨租户查询如果返回非空结果说明隔离失效-- 探针SQL查租户A的流程实例是否能在租户B的表中查到 SELECT tenant_a as source_tenant, tenant_b as target_tenant, COUNT(*) as cross_tenant_count FROM act_ru_execution a JOIN act_ru_execution b ON a.ID_ b.ID_ WHERE a.TENANT_ID_ tenant_a AND b.TENANT_ID_ tenant_b AND a.ID_ IS NOT NULL;预期结果cross_tenant_count 0。如果大于0立刻检查TenantRoutingDataSource的determineCurrentLookupKey()方法是否被绕过。6.2 验证流程引擎租户绑定调用Flowable REST API检查流程定义点狮OA的Flowable REST API默认启用用curl验证租户上下文是否透传到引擎层# 获取租户A的流程定义列表需带租户Header curl -X GET http://localhost:8080/flowable-rest/service/repository/process-definitions?tenantIdtenant_a \ -H Authorization: Basic YWRtaW46YWRtaW4 \ -H X-Tenant-ID: tenant_a \ -H Content-Type: application/json | jq .data[] | {key: .key_, version: .version_, tenantId: .tenantId_}预期结果返回的所有流程定义中tenantId_字段必须全为tenant_a。如果出现null或tenant_b说明ProcessEngineConfiguration未正确设置tenantId。6.3 验证菜单权限动态加载抓包分析前端菜单请求打开浏览器开发者工具登录A公司账号访问/menu接口检查响应体{ code: 0, data: [ { id: menu_oa_leave, name: 请假申请, path: leave, permission: [leave:apply, leave:query] } ] }重点看两点data数组长度是否与A公司配置的菜单数一致可在后台tenant_menu_config表查COUNT(*) WHERE tenant_idtenant_a验证permission字段是否为数组而非字符串曾有客户误配为leave:apply,leave:query导致权限校验永远失败。6.4 验证单据字段租户化用Postman提交带租户字段的表单创建一个Postman请求POST到/t/tenant_a/api/leave/submitBody为{ amount: 1500.00, currency: CNY, receipts: [https://minio/tenant_a/receipt1.jpg] }检查响应头X-Tenant-ID是否为tenant_a响应体是否包含tenantId:tenant_a字段。再用同样Body POST到/t/tenant_b/api/leave/submit对比响应中receipts字段的URL前缀是否变为https://minio/tenant_b/——这是字段级租户隔离的终极证明。从那以后我每次上线前都强制走一遍这四个验证。不是信不过测试同学而是信不过自己写的TenantContext.clear()有没有漏掉某个Filter。希望帮到你。本文还有配套的精品资源点击获取