前言做CRM SaaS开发的同学应该都有体会工单系统看着简单做到后面全是坑。最近看到企客宝CRM对工单系统做了两项升级——动态分支流程和全链路过程追踪正好是我之前踩过的两个大坑。本文结合企客宝的实现方案聊聊这两个功能的设计思路和代码实现。一、线性流程到分支流程状态机的升级1.1 线性状态机的局限标准工单流程是一个线性状态机public enum TicketStatus { CREATED, ACCEPTED, PROCESSING, REVIEWING, CLOSED }每个状态只有一个后继状态适合简单场景。但实际业务中流程路径往往在中间环节才能确定。比如客户投诉工单受理后才知道该走技术处理还是销售处理。1.2 有向图模型解决方案是将线性链表升级为有向图。核心数据模型-- 流程节点 CREATE TABLE wf_node ( id BIGINT PRIMARY KEY, template_id BIGINT, node_type ENUM(start, normal, branch, end), node_name VARCHAR(100), config JSON, sort_order INT ); -- 流程边连接关系 CREATE TABLE wf_edge ( id BIGINT PRIMARY KEY, from_node_id BIGINT, to_node_id BIGINT, edge_type ENUM(normal, branch), branch_label VARCHAR(100), condition JSON, -- 自动判断条件 sort_order INT ); -- 工单实例 CREATE TABLE ticket ( id BIGINT PRIMARY KEY, template_id BIGINT, current_node_id BIGINT, current_branch_id BIGINT, status ENUM(open, in_progress, closed) );1.3 分支节点的执行逻辑public class BranchNodeHandler { /** * 处理分支节点 * param ticket 工单实例 * param branchNode 分支节点 * param selectedBranchId 手动选择的分支ID可为空 */ public void handleBranch(Ticket ticket, WfNode branchNode, Long selectedBranchId) { ListWfEdge branches edgeDao.findByFromNode(branchNode.getId()); Long targetBranchId null; // 1. 优先尝试自动判断 if (selectedBranchId null) { for (WfEdge branch : branches) { if (branch.getCondition() ! null evaluateCondition(branch.getCondition(), ticket)) { targetBranchId branch.getId(); break; } } } // 2. 自动判断失败或未配置使用手动选择 if (targetBranchId null) { targetBranchId selectedBranchId; } // 3. 验证分支有效性 WfEdge selectedEdge branches.stream() .filter(e - e.getId().equals(targetBranchId)) .findFirst() .orElseThrow(() - new BusinessException(无效的分支选择)); // 4. 更新工单状态 ticket.setCurrentNodeId(selectedEdge.getToNodeId()); ticket.setCurrentBranchId(targetBranchId); // 5. 记录分支选择 ticketAcceptDetailDao.save(buildBranchRecord(ticket, branchNode, selectedEdge)); } /** * 评估条件表达式 */ private boolean evaluateCondition(String conditionJson, Ticket ticket) { Condition condition JsonUtils.parse(conditionJson, Condition.class); // 递归评估 AND/OR 组合条件 return ConditionEvaluator.evaluate(condition, ticket.getFormData()); } }1.4 条件引擎设计JSON格式的规则定义支持字段比较和逻辑组合{ operator: OR, conditions: [ { field: fault_type, operator: equals, value: 硬件 }, { operator: AND, conditions: [ {field: fault_type, operator: equals, value: 软件}, {field: severity, operator: equals, value: 紧急} ] } ] }条件评估器public class ConditionEvaluator { public static boolean evaluate(Condition cond, MapString, Object formData) { if (cond.getField() ! null) { // 叶子条件字段比较 Object actual formData.get(cond.getField()); return compare(actual, cond.getOperator(), cond.getValue()); } // 组合条件AND/OR boolean result AND.equals(cond.getOperator()); for (Condition child : cond.getConditions()) { boolean childResult evaluate(child, formData); if (AND.equals(cond.getOperator())) { result result childResult; } else { result result || childResult; } } return result; } private static boolean compare(Object actual, String operator, Object expected) { switch (operator) { case equals: return String.valueOf(actual).equals(expected); case contains: return String.valueOf(actual).contains(String.valueOf(expected)); case gt: return Double.parseDouble(String.valueOf(actual)) Double.parseDouble(String.valueOf(expected)); case lt: return Double.parseDouble(String.valueOf(actual)) Double.parseDouble(String.valueOf(expected)); default: return false; } } }1.5 多级分支分支内部可以再包含分支节点执行逻辑是递归的。但建议限制层级2-3级避免流程图过于复杂。1.6 流程版本管理// 发布新版本时不修改现有版本而是创建新版本 public void publishTemplate(Long templateId) { WfTemplate current templateDao.findById(templateId); current.setIsActive(false); // 旧版本标记为不活跃 WfTemplate newVersion current.clone(); newVersion.setVersion(current.getVersion() 1); newVersion.setIsActive(true); templateDao.save(newVersion); }运行中的工单使用创建时的版本新工单使用最新版本。二、全链路过程追踪受理明细的设计与实现2.1 数据模型CREATE TABLE ticket_accept_detail ( id BIGINT PRIMARY KEY, ticket_id BIGINT, node_name VARCHAR(100), handler_id BIGINT, accept_time DATETIME, complete_time DATETIME, duration_seconds INT, -- 自动计算 action_type VARCHAR(50), -- accept/transfer/return/complete action_detail JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_ticket_id (ticket_id), INDEX idx_handler_time (handler_id, accept_time) );2.2 时长计算的核心逻辑直接用complete_time - accept_time不行因为需要排除非工作时间和挂起时间。public class DurationCalculator { /** * 计算实际处理时长秒 */ public int calculateDuration(LocalDateTime acceptTime, LocalDateTime completeTime, WorkCalendar calendar, ListSuspendPeriod suspends) { int totalSeconds 0; LocalDateTime current acceptTime; while (current.isBefore(completeTime)) { // 跳过非工作日 if (!calendar.isWorkDay(current.toLocalDate())) { current nextWorkDayStart(current, calendar); continue; } // 跳过非工作时间 if (current.toLocalTime().isBefore(calendar.getWorkStart())) { current LocalDateTime.of(current.toLocalDate(), calendar.getWorkStart()); continue; } if (current.toLocalTime().isAfter(calendar.getWorkEnd())) { current nextWorkDayStart(current, calendar); continue; } // 跳过挂起时段 LocalDateTime segmentEnd getSegmentEnd(current, completeTime, calendar, suspends); totalSeconds Duration.between(current, segmentEnd).getSeconds(); current segmentEnd; } return totalSeconds; } }2.3 操作记录的细粒度public class TicketActionRecorder { /** * 记录操作 */ public void recordAction(Ticket ticket, ActionType type, MapString, Object detail) { TicketAcceptDetail record new TicketAcceptDetail(); record.setTicketId(ticket.getId()); record.setNodeName(ticket.getCurrentNodeName()); record.setHandlerId(getCurrentUserId()); record.setActionType(type.name()); record.setActionDetail(JsonUtils.toJson(detail)); record.setCreatedAt(LocalDateTime.now()); // 如果是受理操作记录受理时间 if (type ActionType.ACCEPT) { record.setAcceptTime(LocalDateTime.now()); } // 如果是完成操作记录完成时间并计算时长 if (type ActionType.COMPLETE) { record.setCompleteTime(LocalDateTime.now()); TicketAcceptDetail acceptRecord findLastAcceptRecord(ticket.getId()); if (acceptRecord ! null acceptRecord.getAcceptTime() ! null) { int duration durationCalculator.calculateDuration( acceptRecord.getAcceptTime(), record.getCompleteTime(), getWorkCalendar(), getSuspendPeriods(ticket.getId()) ); record.setDurationSeconds(duration); } } detailDao.save(record); } }操作详情的JSON格式// 字段修改 MapString, Object detail Map.of( type, field_change, field, priority, old_value, 普通, new_value, 紧急 ); // 转交 MapString, Object detail Map.of( type, transfer, from_user, 张三, to_user, 李四, reason, 需要技术专家处理 ); // 退回 MapString, Object detail Map.of( type, return, from_node, 技术处理, to_node, 受理, reason, 信息不完整需要补充 );2.4 Excel导出大数据量导出使用流式写入public void exportDetails(ExportCriteria criteria, OutputStream out) { Workbook workbook new SXSSFWorkbook(100); // 保留100行在内存 Sheet sheet workbook.createSheet(工单受理明细); // 写表头 Row header sheet.createRow(0); String[] headers {工单ID, 环节名称, 受理人, 受理时间, 完成时间, 处理时长(分), 操作类型, 操作详情}; for (int i 0; i headers.length; i) { header.createCell(i).setCellValue(headers[i]); } // 分页查询流式写入 int page 0; ListTicketAcceptDetail records; do { records detailDao.findByCriteria(criteria, page, 1000); for (TicketAcceptDetail record : records) { Row row sheet.createRow(sheet.getLastRowNum() 1); row.createCell(0).setCellValue(record.getTicketId()); row.createCell(1).setCellValue(record.getNodeName()); // ... 其他字段 row.createCell(5).setCellValue(record.getDurationSeconds() / 60); } } while (!records.isEmpty()); workbook.write(out); workbook.dispose(); // 清理临时文件 }2.5 性能优化场景优化方案按ticket_id查询明细B树索引单次查询10ms按handler_id时间范围统计复合索引 预聚合大数据量导出SXSSFWorkbook流式写入历史数据归档按月分表 冷热分离三、SaaS多租户设计3.1 数据隔离// MyBatis拦截器自动注入租户条件 Intercepts(Signature(type Executor.class, method query, args {...})) public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) { // 自动在SQL中添加 tenant_id 条件 // ... } }3.2 流程模板的租户隔离每个租户有独立的流程模板定义。流程模板的导入导出功能支持跨租户复制public void importTemplate(Long targetTenantId, String templateJson) { WfTemplate template JsonUtils.parse(templateJson, WfTemplate.class); template.setTenantId(targetTenantId); template.setId(null); // 清除原ID template.setVersion(1); template.setIsActive(true); // 重建节点和边的ID引用 MapLong, Long idMapping new HashMap(); for (WfNode node : template.getNodes()) { Long oldId node.getId(); node.setId(null); nodeDao.save(node); idMapping.put(oldId, node.getId()); } // 重建边的引用... }四、实际案例企客宝CRM的这两项功能分别来自真实客户需求分支流程某制造企业设备故障工单需要按故障类型分流过程追踪某服务企业需要按处理明细核算绩效企客宝CRM将个性化需求提炼为通用方案面向所有租户开放。这种需求驱动 通用设计的SaaS产品方法论值得借鉴。五、FAQQ1自动判断条件支持多复杂支持字段值比较equals/contains/gt/lt和AND/OR逻辑组合。复杂场景建议走人工判断。Q2受理明细的存储增长单条工单10-50条明细。大规模场景建议按月分表 自动归档。Q3流程定义修改后运行中的工单版本控制运行中工单用创建时版本新工单用最新版本。Q4时长计算是否考虑节假日是通过工作日历配置实现支持自定义工作日、工作时间和节假日。Q5权限如何设计三级个人看自己的、部门经理看本部门、管理员看全部。总结工单系统的两个核心开发难题动态分支流程从线性状态机到有向图核心是分支节点的执行逻辑和条件引擎全链路过程追踪从状态记录到操作明细核心是工作日历时长计算和流式数据导出企客宝CRM的实践方案在灵活性和性能之间做了合理权衡可供SaaS开发者参考。企客宝CRM——专注中小企业客户关系管理。