CRM工单系统开发实战:分支流程引擎与全链路追踪的设计与实现
前言
做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) { List<WfEdge> 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, Map<String, 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, List<SuspendPeriod> 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, Map<String, 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格式:
// 字段修改 Map<String, Object> detail = Map.of( "type", "field_change", "field", "priority", "old_value", "普通", "new_value", "紧急" ); // 转交 Map<String, Object> detail = Map.of( "type", "transfer", "from_user", "张三", "to_user", "李四", "reason", "需要技术专家处理" ); // 退回 Map<String, 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; List<TicketAcceptDetail> 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引用 Map<Long, 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产品方法论,值得借鉴。
五、FAQ
Q1:自动判断条件支持多复杂?支持字段值比较(equals/contains/gt/lt)和AND/OR逻辑组合。复杂场景建议走人工判断。
Q2:受理明细的存储增长?单条工单10-50条明细。大规模场景建议按月分表 + 自动归档。
Q3:流程定义修改后运行中的工单?版本控制:运行中工单用创建时版本,新工单用最新版本。
Q4:时长计算是否考虑节假日?是,通过工作日历配置实现,支持自定义工作日、工作时间和节假日。
Q5:权限如何设计?三级:个人(看自己的)、部门经理(看本部门)、管理员(看全部)。
总结
工单系统的两个核心开发难题:
- 动态分支流程:从线性状态机到有向图,核心是分支节点的执行逻辑和条件引擎
- 全链路过程追踪:从状态记录到操作明细,核心是工作日历时长计算和流式数据导出
企客宝CRM的实践方案在灵活性和性能之间做了合理权衡,可供SaaS开发者参考。
企客宝CRM——专注中小企业客户关系管理。
