客户继承看着像后台点两下的事,接进系统才发现坑全在异步上:发起成功不等于已完成,你库里归属已经改了,客户那边还挂在老销售名下;老销售的三百个客户一次性倒给一个人,第二天客户收到一条「你好,很高兴认识你」。这篇讲离职继承和在职转接在工程上到底差在哪、中间态怎么建模,以及分配规则怎么写成三个月后还能改的代码。执行链路按 wecomapi 的接入方式来讲。
三个场景,别用一套逻辑
「客户继承」在需求文档里通常是一句话,落到实现是三件事,触发条件、可操作窗口和失败语义都不一样。
- 离职继承:成员已经从企业里移除,名下存量客户需要重新找归属。触发点是成员状态变更,处理对象是一个批次而不是单个客户,而且没有「原主人配合」这个选项。
- 在职转接:成员还在,业务上主动移交(换区域、休长假、客户升级到大客户团队)。由人发起、量小、可协商、应当可回滚。
- 群的转接:外部群的归属变更和客户的归属变更是两条线。一个成员名下既有客户也有群,转客户不等于群跟着走,反过来也一样。
这三件事共用一个「分配决策」模块是合理的,共用一个「执行」模块通常不合理。执行侧的批量策略、失败重试语义和对客户可见的影响差得太远,硬写成一个函数,最后一定长出一堆场景分支。把决策和执行拆开,是这个模块唯一值得提前做的抽象。
转接是异步的,把中间态建成状态机
最容易翻车的假设是「调用返回成功 = 客户已经归新人了」。实际链路里,通过 wecomapi 发起之后,客户会进入一段待接收的中间状态,完成时间取决于对侧,可能几秒,也可能拖到接收人做了某个动作之后。这段时间你要是已经把归属改掉,一线看到的就是「客户已经是我的了,但我看不到聊天记录」。
处理方式是把归属拆成两个字段:一个是当前实际归属,只有收到完成确认才改;另一个是转接目标,只给转接流程自己用。所有下游逻辑 —— 再分配、自动触达、报表、工作量统计 —— 一律只读前者。这一条能挡掉后面一半的数据不一致问题。
- 1待分配:批次已建立,决策未完成。
- 2已发起:请求已提交,等待完成确认。这个状态必须带超时。
- 3已完成:收到确认,改写实际归属,清空转接目标。
- 4失败待重试:可重试的失败(接收人已满、临时错误)回到待分配重新决策,而不是死等原接收人。
- 5人工介入:重试耗尽,或接收人本身已停用。这个出口必须有,否则客户会静默卡在中间态里。
「已发起」的超时宁可设长(按小时算),也不要短到把还在正常进行的转接判成失败又发起一次。重复发起造成的二次转接,比多等两小时难收拾得多。
事件驱动做时效,定时对账做正确性
这两件事要同时做,只做一件都会被打脸。
事件驱动负责时效。成员状态变更、转接完成、归属变化这些事件从 wecomapi 的回调推过来,你才能在分钟级完成重新分配。靠定时扫全量成员列表去发现某人离职了,延迟按小时计,期间进来的客户消息没人接。
定时对账负责正确性。事件会漏投、会重复、会乱序,乱序尤其致命:「转接完成」比「转接发起」先到,如果代码写的是「状态为已发起才处理完成事件」,这条完成事件就被丢了,客户永久卡在中间态。所以每天要有一次全量比对,拉取当前真实归属和自己库里的实际归属做差集,不一致的进人工队列。
- 事件处理一律以事件唯一 ID 做幂等键,先落库再处理。
- 状态机允许补写:收到晚到的前置事件时,不回退已经推进的状态。
- 对账发现的差异不要自动修复,先进队列 —— 自动修复一个你还没搞懂的差异,通常会造出第二个差异。
分配规则怎么落到代码
分配规则最后写成一条 if-else 长链,是这个模块的默认结局。避免的方法是先承认它其实是四个独立阶段,然后就按四段写,规则改动只会落进其中一段。
- 1筛选:从在职成员里筛出合法候选人。硬条件,不打分 —— 在职、有接待权限、不在休假、不是原归属人。
- 2打分:对候选人排序。软条件 —— 属地匹配、行业匹配、历史成交率,权重外置成配置。
- 3约束:容量校验。在手客户数上限、本批次单人接收上限、当日新增上限。这一段在打分之后过滤,不要揉进分数里。
- 4兜底:候选人全被约束挡掉时怎么办。轮询兜底、进公海还是转给主管,必须显式指定,不能让函数返回空。
// 示意结构,精确字段、接口与频率约束以 wecomapi 文档为准
async function decideOwner(customer, ctx) {
// 1. 筛选:硬条件,命中即淘汰
const candidates = ctx.members.filter(
(m) => m.active && m.canServe && !m.onLeave && m.id !== customer.fromOwnerId,
);
if (!candidates.length) return ctx.fallback("no_candidate");
// 2. 打分:软条件排序,权重来自配置而不是写死
const ranked = candidates
.map((m) => ({ m, score: score(m, customer, ctx.weights) }))
.sort((a, b) => b.score - a.score);
// 3. 约束:容量在打分之后过滤,占位必须原子
for (const { m } of ranked) {
if (await quota.reserve(m.id, ctx.batchId)) return m.id;
}
// 4. 兜底:显式出口
return ctx.fallback("all_over_quota");
}这里有个容易漏掉的并发问题:一个批次并行处理时,多个客户会同时看到同一个人还有余量,于是全分给他。所以容量校验和占位必须是同一个原子操作,reserve 成功才算分配成功,批次失败时要释放占位。
四种分配策略,默认该选哪个
策略选型上的判断很直接:在有可靠的历史转化数据之前,不要上打分模型。
- 轮询:实现最简单、绝对公平,完全不看能力和匹配度。适合客户同质化高、单价低的场景。
- 按在手客户数负载均衡:比轮询好一点,但在手客户数不等于工作量 —— 手上五十个沉默客户的人会被判成「忙」,手上十个天天聊的反而被判成「闲」。要用就用近期活跃会话数,别用总数。
- 规则匹配(属地、行业、语言、客户等级):转化效果通常最好,代价是分布容易极度不均,某个区域的人被压死。必须配容量上限。
- 抢单:分配延迟最低,但会挑肥拣瘦,优质客户秒抢、难啃的没人碰。要用就限定在小批量,并给未被抢走的客户设兜底自动分配。
默认建议是「规则筛选 + 容量上限 + 轮询兜底」。它最大的优点是可解释 —— 销售问「为什么这个客户没给我」,你能一句话答清楚。在落地阶段,这比多几个百分点的匹配度重要得多。打分模型等积累了三到六个月的归属与转化数据再上,而且第一版只用来排序,不用来决定给不给。
上线前必查的五条
- 1转接后的客户不是新客户。归属变更事件不能触发新客欢迎语序列,否则跟了半年的老客户会收到一条「你好,很高兴认识你」。这是这个模块最常见的线上事故。
- 2接收人状态要在分配的那一刻再校验一次。批次是几分钟前算的,期间接收人可能已经停用,客户会直接进黑洞。
- 3大批量要分片、要限速。一个离职成员几百个客户一次性推完,既影响对方体验,也是典型的集中高频操作,按人分片、按时段摊平。
- 4跟进记录不跟着人走。历史跟进、标签、来源归因属于客户,不属于销售,变的只有负责人这一个字段。这条要在数据模型里保证,不能靠写代码时自觉。
- 5保留回滚能力。在职转接尤其需要 —— 转错了要能一步退回,而不是反向再转一次,反向转接会在客户侧留下两次痕迹。
本文讲的是链路顺序、状态建模与工程取舍。继承与转接相关接口的精确字段、可操作条件与频率限制以 wecomapi 线上接口文档为准。
常见问题
- 离职继承和在职转接在工程上有什么区别?
- 触发方式和批量特征不同。离职继承由成员状态变更触发,处理的是一个批次,没有原归属人配合,基本不可回滚;在职转接由人发起、量小、可协商、应当支持回滚。共用同一套分配决策没问题,执行层建议分开,否则会长出一堆场景分支。
- 转接发起后一直没完成怎么办?
- 把「已发起」做成带超时的状态。超时后不要原地重发,先判断是接收人侧的问题还是链路问题:可重试的回到待分配重新决策(接收人可能已满或已停用),重试耗尽的进人工队列。最忌讳死等或盲目重发,前者让客户永久卡住,后者造成二次转接。另外别拿发起时的返回值当完成信号,以 wecomapi 推来的完成事件为准,再配每日对账兜底。
- 一次转几百个客户会有什么问题?
- 三类问题。集中高频操作本身不稳妥,需要分片限速;全压给一个人的话,接下来一周他谁都跟不过来,等于把客户放置了;容量校验如果不是原子的,并发分配会超配。做法是按接收人分片、给单人单批设上限、分配时先占位再执行,调 wecomapi 时按账号维度排队限速,别全局一把梭。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
