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——专注中小企业客户关系管理。

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)