教培SaaS线索分配系统的状态机设计从人工抢单到智能路由的演进
背景教培机构的招生线索管理是一个典型的状态流转场景。一条线索从进入系统到最终成交或流失会经历分配、跟进、试听、转化等多个状态。我们最初用status字段加if-else实现随着业务复杂度增加代码变成了面条式的条件判断维护噩梦。后来重构为状态机模式今天分享这个过程。一、原始实现的问题最初的线索模型很简单class Lead:status models.CharField(max_length20, defaultnew)assigned_to models.ForeignKey(User, nullTrue)last_followup models.DateTimeField(nullTrue)# 分配逻辑散落在各处def assign_lead(lead, user):if lead.status new:lead.assigned_to userlead.status assignedlead.save()elif lead.status assigned:raise Exception(already assigned)elif lead.status lost:raise Exception(cannot assign lost lead)def followup_lead(lead, user, note):if lead.status assigned and lead.assigned_to user:lead.status followinglead.last_followup timezone.now()lead.save()elif lead.status following:lead.last_followup timezone.now()lead.save()else:raise Exception(invalid state transition)这种写法的问题很明显状态流转逻辑散落在十几个函数里每加一个状态就要改十几处代码。而且没有防止非法状态转换比如有人把lost状态的线索改成following也不会报错。二、状态机建模首先梳理线索的完整状态流转图new - assigned - following - trial_scheduled - trial_completed - won/lost还有几条特殊路径assigned - recycled超时未跟进回收following - recycled长时间未推进回收trial_completed - following试听后继续跟进任何状态 - invalid线索无效用状态机模式重构from enum import Enumfrom transitions import Machineclass LeadState(Enum):NEW newASSIGNED assignedFOLLOWING followingTRIAL_SCHEDULED trial_scheduledTRIAL_COMPLETED trial_completedWON wonLOST lostRECYCLED recycledINVALID invalidclass LeadStateMachine:transitions [# trigger, source, dest, conditions, before, after[assign, new, assigned, can_assign, before_assign, after_assign],[assign, recycled, assigned, can_assign, before_assign, after_assign],[start_followup, assigned, following, None, None, notify_assignee],[schedule_trial, following, trial_scheduled, None, validate_trial_slot, notify_parent],[complete_trial, trial_scheduled, trial_completed, None, record_trial_feedback, None],[continue_followup, trial_completed, following, None, None, notify_assignee],[win, [following, trial_completed], won, None, create_contract, notify_manager],[lose, [following, trial_completed], lost, None, record_loss_reason, None],[recycle, [assigned, following], recycled, None, clear_assignee, add_to_pool],[invalidate, *, invalid, None, record_invalid_reason, None],]def __init__(self, lead):self.lead leadself.machine Machine(modelself,states[s.value for s in LeadState],transitionsself.transitions,initiallead.status,send_eventTrue)def can_assign(self, event):# 检查当前用户是否有分配权限user event.kwargs.get(user)return user.has_perm(assign_lead)def before_assign(self, event):user event.kwargs.get(user)self.lead.assigned_to userself.lead.assigned_at timezone.now()def after_assign(self, event):# 发送通知给被分配人send_notification.delay(user_idself.lead.assigned_to_id,title新线索分配,contentf您收到一条新线索{self.lead.student_name})使用transitions库后状态流转逻辑集中在一处定义非法转换会被自动拦截新增状态只需在transitions列表里加一条。三、智能分配路由状态机解决了怎么转的问题但没解决转给谁的问题。最初是人工抢单后来客户要求智能分配。分配策略需要考虑多个因素销售老师的转化率、当前待跟进线索数、线索来源渠道与销售老师的匹配度、时间段有些老师晚上效率高。class LeadRouter:def __init__(self, config):self.weights {conversion_rate: 0.35,workload: 0.25,channel_match: 0.20,response_speed: 0.20}self.config configasync def route(self, lead, candidates):scores []for user in candidates:score await self._calculate_score(lead, user)scores.append((user, score))scores.sort(keylambda x: x[1], reverseTrue)# 如果最高分和第二高分差距小于阈值随机选一个避免总是分给同一个人if len(scores) 2 and (scores[0][1] - scores[1][1]) 0.05:top_two scores[:2]chosen random.choice(top_two)[0]else:chosen scores[0][0]return chosenasync def _calculate_score(self, lead, user):# 转化率得分conv_rate await self._get_conversion_rate(user)conv_score min(conv_rate / 0.3, 1.0) # 30%转化率得满分# 工作量得分待跟进越少分越高pending await self._get_pending_count(user)workload_score max(1 - pending / 30, 0) # 30条待跟进得0分# 渠道匹配度channel_match await self._get_channel_match(user, lead.channel)# 响应速度avg_response await self._get_avg_response_time(user)response_score max(1 - avg_response / 3600, 0) # 1小时响应得0分total (self.weights[conversion_rate] * conv_score self.weights[workload] * workload_score self.weights[channel_match] * channel_match self.weights[response_speed] * response_score)return total这里踩了一个坑最初把转化率权重设为0.6结果导致所有高转化率的销售老师线索堆积而新老师一直分不到线索能力无法提升。后来把转化率权重降到0.35加了随机选择机制线索分配更均衡了。四、超时回收机制线索分配后如果销售老师不及时跟进需要在超时后自动回收。这需要一个定时任务扫描超时线索class LeadRecycler:ASSIGN_TIMEOUT 2 * 3600 # 分配后2小时未跟进FOLLOWUP_TIMEOUT 48 * 3600 # 跟进后48小时未推进async def run(self):while True:await self._check_assignment_timeout()await self._check_followup_timeout()await asyncio.sleep(300) # 每5分钟检查一次async def _check_assignment_timeout(self):cutoff timezone.now() - timedelta(secondsself.ASSIGN_TIMEOUT)leads Lead.objects.filter(statusassigned,assigned_at__ltcutoff)for lead in leads:fsm LeadStateMachine(lead)try:fsm.recycle()lead.status fsm.statelead.assigned_to Nonelead.save()await self._notify_recycle(lead, assignment_timeout)except MachineError:pass # 状态转换失败跳过五、总结从面条式if-else到状态机加智能路由的演进核心收获1. 状态机模式把散落的状态流转逻辑集中管理新增状态和转换规则只需改一处配置。2. transitions库的send_event模式可以在转换前后插入钩子函数做通知、校验、日志等操作。3. 智能分配需要多维度加权打分不能只看单一指标权重要跟业务方一起调。4. 超时回收是线索管理容易被忽略的一环不回收就等于线索浪费。5. 状态机要有完整的日志记录每次状态转换都记录who、when、from、to、reason方便后续分析转化漏斗。

相关新闻

查找场景中所有丢失脚本(Mono Script)

查找场景中所有丢失脚本(Mono Script)

脚本放在 Editor 文件夹&#xff1b; &#xff08;测试版本Unity 2019&#xff09; using System.Collections.Generic; using UnityEngine; using UnityEditor;public class FindAllMissingScripts : EditorWindow {private List<GameObject> m_missingObjs new List&l…

2026/10/19 23:54:40 阅读更多 →
RTSP拉流失败排查完整流程:解决摄像头账号密码正确但平台无法稳定取流的实战指南

RTSP拉流失败排查完整流程:解决摄像头账号密码正确但平台无法稳定取流的实战指南

1. 环境假设在参考本文的排查步骤前&#xff0c;请确认您的生产或测试环境满足以下假设条件&#xff1a;硬件设备&#xff1a; 海康威视&#xff08;Hikvision&#xff09;、大华&#xff08;Dahua&#xff09;、宇视&#xff08;Uniview&#xff09;等标准 IP 摄像头&#xff…

2026/10/19 1:43:52 阅读更多 →
基于Kimi K3与MCP协议构建本地化AI量化交易智能体实战

基于Kimi K3与MCP协议构建本地化AI量化交易智能体实战

在量化交易领域&#xff0c;如何将前沿的大语言模型&#xff08;LLM&#xff09;与标准化的工具调用协议结合&#xff0c;构建一个既能理解复杂市场逻辑、又能精准执行交易指令的智能体&#xff0c;是许多开发者和机构探索的方向。近期&#xff0c;深度求索公司开源的 Kimi K3 …

2026/10/20 10:47:38 阅读更多 →

最新新闻

2026网络安全就业全景:岗位趋势、薪酬地图与技能风向

2026网络安全就业全景:岗位趋势、薪酬地图与技能风向

1. 为什么我会在年底啃这份招聘数据&#xff1a;调研背景与样本口径每年四季度都是观察次年就业市场最好的窗口期&#xff0c;因为各大公司的HC预算、校招指标、社招编制基本都在这个阶段定调。我做了近十年的安全行业职业发展相关调研&#xff0c;今年选了2025年10月到2026年1…

2026/10/20 12:47:34 阅读更多 →
AI论文写作全攻略:四款实用工具与高效降重、文献综述实操指南

AI论文写作全攻略:四款实用工具与高效降重、文献综述实操指南

毕业季又到了&#xff0c;我后台收到最多的私信就是“论文写不出来怎么办”。说实话&#xff0c;毕业论文这事儿&#xff0c;卡在最后一步的滋味确实难受。今年我实测了市面上主流的几款AI论文写作工具&#xff0c;也拉了几个刚答辩完的同学做了深度反馈&#xff0c;今天直接把…

2026/10/20 12:47:34 阅读更多 →
圆的性质第二讲:垂径定理、圆周角定理与圆内接四边形实战要点

圆的性质第二讲:垂径定理、圆周角定理与圆内接四边形实战要点

圆的性质是初中几何里最"有看头"的一块内容——结论密集、模型多样&#xff0c;而且和代数、三角都能搭上关系。云藏山鹰代数信息系统的几何模块里&#xff0c;圆的性质系列课程我已经带过好几轮了&#xff0c;这次把第二讲的教学手记整理成文&#xff1a;垂径定理怎…

2026/10/20 12:47:34 阅读更多 →
AI编码新王炸Augment实测:大项目重构、对比Cursor与避坑指南

AI编码新王炸Augment实测:大项目重构、对比Cursor与避坑指南

1. 为什么“AI 写代码”最近突然又换了一个主角1.1 先认识一下这位“新王炸”是谁这几年 AI 编码工具的更新频率&#xff0c;说实话已经快到让人麻木的程度。今天这个大模型说自己的代码能力第一&#xff0c;明天那个编辑器说自己能自动改完一个项目&#xff0c;用户嘴上说“好…

2026/10/20 12:47:34 阅读更多 →
配电网重构MISOCP建模:二阶锥松弛与MATLAB实战

配电网重构MISOCP建模:二阶锥松弛与MATLAB实战

简介&#xff1a;本资源是一套基于混合整数二阶锥规划&#xff08;MISOCP&#xff09;求解配电网重构问题的完整MATLAB实现代码&#xff0c;面向电力系统优化、智能配电网研究方向的研究生、科研人员及工程技术人员。针对传统启发式或智能算法在全局最优性与收敛性上的局限&…

2026/10/20 12:47:34 阅读更多 →
提示词模板化:从基础到高级的Prompt Engineering实战指南

提示词模板化:从基础到高级的Prompt Engineering实战指南

1. 为什么要把提示词做成模板&#xff1a;从重复劳动到可复用资产先讲一个我自己的经历。早期用对话式AI干活的时候&#xff0c;每次都是临时想提示词&#xff0c;想到哪写到哪&#xff1a;“帮我总结一下这段文字”“写一个产品介绍&#xff0c;要专业一点”。这种随手写的做法…

2026/10/20 12:46:33 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →