☰
Java CCO客服工单服务:Spring Boot多模块与坐席分配实战
2026/10/23 1:46:52 网站建设 项目流程

简介:面向具备一定Java基础、希望了解企业级客户服务与运营系统实现思路的开发者,这份CCO服务入门源码包提供了一套可导入IDE浏览和运行的主分支工程,适合作为学习项目结构、配置方式与基础业务代码的实践参考。压缩包共62个文件,约71KB,其中52个Java源码文件覆盖核心业务与测试代码,4个XML和3个YAML文件承担工程构建及运行配置,另有少量工程元数据与说明文档,整体体量轻便,便于快速解压查阅。目前已有269人学习,说明其在入门级Java项目参考方面具有一定关注度。读者可据此观察Maven工程的目录组织、主代码与测试代码的边界、依赖声明与配置项的对应关系,并进一步理解服务端基础模块的编写方式,为本地调试、功能扩展或对照自身项目查漏补缺提供直观样本。

1. 先说清楚:CCO 服务在业务链路里卡的是哪个位置

拿到这个 Java 包,我先翻启动类和 controller,确认它不是一套大而全的客服系统,而是一段被切出来的服务:渠道进线、工单创建、坐席分配、SLA 计时、关单归档,五件事串成一条链。外面无论 App、小程序还是电话呼入,报文先进来收敛成统一结构,再往下走工单状态机。适合两类人:一类想找一个完整 Spring Boot 业务服务当脚手架的中高级开发,另一类刚接手客服工单类需求、要照着改路由和 SLA 规则的同学。它不含前端页面,也不含坐席工作台,纯后端。这点先记住,后面很多“为什么没有 XX 页面”的疑问都从这来。

2. 技术栈与工程结构:一个 Java 服务被切成几块才对

拆包第一件事别急着看业务,先看它怎么分层。分层乱了,后面改任何一个需求都会像拆炸弹。这套工程是典型的多模块 Maven 结构,依赖方向是单向的,谁依赖谁一眼能看出来。

2.1 依赖清单与技术选型:为什么是这套组合

Spring Boot 主线(2.x 和 3.x 都能跑,差别主要在javax和jakarta包名,迁移时全局替换即可),持久层用 MyBatis-Plus,缓存 Redis,消息队列走 RocketMQ 或 Kafka 二选一,靠一个适配类隔开。这个组合在工单类业务里几乎是默认答案:工单表字段多、单表查询多,MyBatis-Plus 省掉大量样板 CRUD;坐席在线状态变化频繁,放 Redis 比放库合适;超时提醒和归档需要削峰,交给 MQ。

<dependencies> <!-- Web 层:对外进线接口 + 坐席工作台回调 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:工单表宽、单表查询占比高,Plus 能省掉大量手写 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!-- 坐席状态、分布式锁、SLA 计时都用它 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 参数校验,进线报文一律在 controller 层拦住 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

这段依赖里有两个点值得说。第一,spring-boot-starter-validation不能省。渠道进来的报文脏得超出想象,缺字段、长度超限、枚举传了个拼音,全都出现过。校验放在 controller 入口,比在 service 里到处写 if 靠谱得多。第二,消息队列的客户端依赖不要直接写进cco-domain模块,domain 只放接口,具体实现放 infra,否则以后换 MQ 要动业务代码,这是血泪经验。

常见做法是把 MQ 客户端和 Redis 客户端都收在 infra 模块里,domain 层只认EventPublisher这种自定义接口。我第一次拆这种工程时就吃过亏:domain 里直接@Autowired RocketMQTemplate,后来要换消息中间件,业务代码跟着改了三天。

2.2 目录分层:每个包守什么边界

cco-service ├── cco-api # 对外 DTO、枚举、Feign 接口,给其他服务依赖 ├── cco-domain # 领域模型、状态机、路由策略接口,不依赖 Web ├── cco-infra # Mapper、Redis、MQ、渠道适配实现 ├── cco-bootstrap # 启动类、controller、全局异常、配置 └── cco-job # 定时任务:SLA 超时扫描、工单归档

依赖方向是bootstrap → infra → domain,api谁都能依赖但它不依赖别人。这个约束听起来教条,实际非常有用:状态机放在 domain,不引 Spring Web 的包,意味着它可以直接写单元测试,不用起容器。工单状态流转是整个服务里最容易写错的地方,能脱离容器跑测试,价值很大。

目录里最容易被忽视的是cco-job。SLA 超时扫描、工单自动归档、坐席状态心跳清理,都是定时任务。单独拆一个模块的好处是它可以独立部署,扫描频率和主服务解耦。我一般会把扫描任务做成幂等的,按时间窗口分段扫,而不是一次select *全表捞。

另一个细节是cco-api里的 DTO 和 domain 里的实体不要共用。共用一开始省事,后面接口字段一改,数据库实体跟着变,改动范围失控。这种耦合在小项目里不算事,在工单这种字段经常加的业务里就是灾难。

2.3 配置项:真正会改的那几个键

配置文件不用全看,看几个真正常改的就行。SLA 时限、路由策略、坐席最大负载,这三个是运营和产品天天要调的,必须放配置里而不是写死在代码里。

cco: sla: first-response-minutes: 15 # 首次响应时限,超时升级 resolve-minutes: 240 # 解决时限,超时进升级队列 route: strategy: weighted # weighted / round-robin / least-busy max-load-per-agent: 6 # 单坐席同时处理上限 channel: dedup-window-seconds: 300 # 同渠道同用户的重复进线去重窗口 spring: datasource: url: jdbc:mysql://localhost:3306/cco?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai hikari: maximum-pool-size: 20

serverTimezone=Asia/Shanghai这个参数看着不起眼,它是后面 SLA 计时错 8 小时的直接原因之一,第 5 章会细说。max-load-per-agent控制的是分配算法里的硬上限,超过这个数的坐席直接不进候选集,比在算法里做软性权重靠谱。dedup-window-seconds是同一个用户在 5 分钟内重复进线时不新建工单,而是挂到已有工单上,这个逻辑在渠道适配层做,别放到工单创建里,否则每个渠道都要重复写一遍。

3. 核心链路拆解:一个工单从进线到关闭要经过哪些代码

这条链路是整个服务的主干。拆的时候我建议按时间顺序读:报文进来 → 适配成统一命令 → 建单 → 选坐席 → 状态流转 → 关单。每一段都有它的坑,逐段看。

3.1 渠道适配:用策略模式把脏报文收敛成一条命令

不同渠道的报文结构差别极大。App 传 JSON,电话呼入传的是 CTI 事件,邮件渠道可能是一整段 HTML。但往下走只需要一种结构,所以适配层要做的事情就是“翻译”。

/** * 渠道适配器:一个渠道一个实现,泛型 T 是原始报文类型 */ public interface ChannelAdapter<T> { /** 渠道编码,和配置里的 channel 对应 */ String channelCode(); /** 把原始报文翻译成统一的建单命令 */ TicketCreateCmd adapt(T raw); /** 该渠道的用户唯一标识,用于去重 */ String userKey(T raw); }

适配器的注册用一个 Map 收口,Spring 启动时把所有实现塞进去,调用方按渠道编码取,取不到就抛明确的业务异常,而不是返回 null 让上层猜。

@Component public class ChannelAdapterRegistry { private final Map<String, ChannelAdapter<?>> adapters = new ConcurrentHashMap<>(); // 构造注入所有实现,Spring 会自动把 List 填满 public ChannelAdapterRegistry(List<ChannelAdapter<?>> list) { for (ChannelAdapter<?> adapter : list) { adapters.put(adapter.channelCode(), adapter); } } @SuppressWarnings("unchecked") public <T> ChannelAdapter<T> get(String channelCode) { ChannelAdapter<T> adapter = (ChannelAdapter<T>) adapters.get(channelCode); if (adapter == null) { // 明确抛出,别让 null 流到下一层 throw new BizException("UNSUPPORTED_CHANNEL", "渠道未接入: " + channelCode); } return adapter; } }

adapt方法里要做三件事:字段映射、必填校验、优先级推导。优先级推导经常被漏掉,比如 VIP 用户进线自动标 P1,这个规则如果写死在各个适配器里,运营改一次规则要改五个类。我一般会把优先级规则抽成一个独立的PriorityResolver,适配器只负责把用户标签带出来,规则判断交给它。

去重逻辑放在适配层还是建单层?我的选择是建单层。适配层只负责给出userKey,真正的去重靠数据库唯一索引加时间窗口查询,因为同一渠道也可能有并发进线,内存判断挡不住。

3.2 坐席分配:路由策略要能换,而不是写死一个 if-else

分配是这类服务里最容易被改坏的地方。业务方今天要“技能优先”,明天要“负载均衡”,后天又说“老客户要分配给上次服务过的坐席”。如果这些都写成 if-else,恭喜你,这个类会变成一个两千行的黑洞。

接口先定死,实现可插拔:

public interface AgentRouter { /** 策略名,和配置 cco.route.strategy 对应 */ String name(); /** * @param cmd 建单命令,含技能组、优先级、渠道 * @param candidates 已过滤掉离线、满载、被禁用的坐席 * @return 选中的坐席,空表示当前无可用 */ Optional<Agent> select(TicketCreateCmd cmd, List<Agent> candidates); }

加权策略的实现大概是这个形状:

@Component public class WeightedRouter implements AgentRouter { @Override public String name() { return "weighted"; } @Override public Optional<Agent> select(TicketCreateCmd cmd, List<Agent> candidates) { return candidates.stream() // 技能匹配度:完全匹配 3 分,同类技能 2 分,无技能 1 分 .max(Comparator.comparingInt(a -> score(a, cmd))); } private int score(Agent agent, TicketCreateCmd cmd) { int skillScore = agent.matchSkill(cmd.getSkillGroup()) ? 3 : 1; // 负载越低分越高,用 6 减去当前负载做线性折算 int loadScore = Math.max(0, 6 - agent.getCurrentLoad()); // 上次分配时间越久越优先,避免总是同一个人接单 long idleScore = agent.getIdleSeconds() / 60; return skillScore * 10 + loadScore * 2 + (int) Math.min(idleScore, 20); } }

打分函数里的权重系数不是拍脑袋来的,skillScore * 10是为了保证技能匹配的优先级高于负载,否则忙的坐席全是新人,老手闲着。这个系数一旦定下来就别随便改,改之前先跟业务方对齐。idleSeconds用 Redis 里坐席最近一次接单时间算,不要用数据库里的更新时间,精度不够。

候选集的过滤在 router 之前做:离线剔除、达到max-load-per-agent剔除、被禁用的剔除、技能组不匹配的剔除。过滤逻辑单独一个AgentFilterChain,比塞进 router 里清晰得多。分配失败要有兜底:进排队池,而不是直接抛异常给用户。排队池我用 Redis 的有序集合,score 是进队时间,坐席空闲时按时间顺序取。

3.3 状态机:用枚举把状态流转锁死

工单状态乱飞是这类系统最常见的问题。PROCESSING直接跳到CLOSED,跳过RESOLVED,SLA 统计就少了一段耗时。用枚举加白名单把流转锁住:

public enum TicketStatus { CREATED, ASSIGNED, PROCESSING, PENDING_CUSTOMER, RESOLVED, CLOSED; // 允许的流转方向,白名单之外一律拒绝 private static final Map<TicketStatus, Set<TicketStatus>> ALLOWED = Map.of( CREATED, Set.of(ASSIGNED, CLOSED), ASSIGNED, Set.of(PROCESSING, PENDING_CUSTOMER, CLOSED), PROCESSING, Set.of(PENDING_CUSTOMER, RESOLVED, CLOSED), PENDING_CUSTOMER, Set.of(PROCESSING, RESOLVED, CLOSED), RESOLVED, Set.of(CLOSED, PROCESSING), // 客户不满意可重开 CLOSED, Set.of() ); public boolean canTransitTo(TicketStatus target) { return ALLOWED.getOrDefault(this, Set.of()).contains(target); } }

RESOLVED允许回到PROCESSING是真实业务需求,客户点了解决但其实没解决,得有重开通道。CLOSED是终态,不能再变,这点必须硬卡。

流转动作走一个统一的TicketStateMachine,里面做三件事:校验白名单、写流转日志、发领域事件。事件用 Spring 的ApplicationEventPublisher在进程内先发,再由监听器决定要不要发 MQ。这么做的好处是状态变更和后续动作解耦,加一个“关单后发满意度问卷”只需要加一个监听器,不用动状态机。

状态更新一定要带版本号做 CAS,别用“先查再改”:

// version 是乐观锁字段,更新失败说明状态已被别人改过 int updated = ticketMapper.updateStatus(ticketId, target, expectedVersion); if (updated == 0) { throw new BizException("CONCURRENT_MODIFY", "工单状态已变更,请刷新"); }

3.4 关单与归档:幂等是底线

关单接口最怕重复调用。前端网络抖动重试一次,坐席手快点了两下,都会打进来两次。关单本身要幂等,靠的是状态机的CLOSED终态判断加上数据库唯一约束。第一次关单成功,第二次进来状态已经是CLOSED,直接返回成功,不报错也不重复写日志。

归档动作异步做,走本地消息表:关单事务里往cco_ticket_event插一条待发送记录,事务提交后再由定时任务扫表投递 MQ。这个模式比“事务里直接发 MQ”可靠,因为发消息失败不影响关单,消息表里那条记录还在,下一轮接着发。

@Transactional(rollbackFor = Exception.class) public void close(Long ticketId, String operator, String reason) { Ticket ticket = ticketMapper.selectById(ticketId); if (ticket.getStatus() == TicketStatus.CLOSED) { return; // 幂等:已关单直接返回,不抛异常 } if (!ticket.getStatus().canTransitTo(TicketStatus.CLOSED)) { throw new BizException("ILLEGAL_TRANSIT", "当前状态不允许关单"); } ticketMapper.updateStatus(ticketId, TicketStatus.CLOSED, ticket.getVersion()); // 同一事务内落事件,保证关单成功则事件一定在 eventMapper.insert(TicketEvent.of(ticketId, "TICKET_CLOSED", reason)); }

参数说明:operator是操作人,坐席 ID 或系统标识;reason关单原因,后面做归因分析要用。事件表插入失败会让整个事务回滚,这是有意为之,宁可关单失败也不能出现“关了单但没有事件”的黑匣子状态。

4. 数据层与本地跑通:建库、起服务、验证接口

架构看完,接下来得让它跑起来。这一章把表结构、缓存用法和启动步骤放在一起,因为本地起服务时这几件事是连着做的。

4.1 表结构:主表加日志表,索引别乱建

工单主表字段多,但核心就那几个。索引也不用贪多,按查询场景建。

CREATE TABLE cco_ticket ( id BIGINT NOT NULL AUTO_INCREMENT, ticket_no VARCHAR(32) NOT NULL COMMENT '业务单号,对外暴露', channel_code VARCHAR(16) NOT NULL COMMENT '来源渠道', user_key VARCHAR(64) NOT NULL COMMENT '渠道内用户标识', skill_group VARCHAR(32) NOT NULL COMMENT '技能组', priority TINYINT NOT NULL DEFAULT 3 COMMENT '1最高 3普通', status VARCHAR(24) NOT NULL COMMENT '状态机枚举名', assignee_id BIGINT NULL COMMENT '当前坐席', sla_deadline DATETIME NULL COMMENT '解决时限', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_ticket_no (ticket_no), KEY idx_assignee_status (assignee_id, status), KEY idx_status_sla (status, sla_deadline) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

三个索引各有用途:uk_ticket_no保证幂等和外部查询;idx_assignee_status支撑坐席工作台的“我的待办”列表;idx_status_sla给超时扫描任务用。别在user_key上单独建索引,它的查询永远带渠道和时间范围,单独建大概率用不上。

流转日志表单独一张,只追加不更新:

CREATE TABLE cco_ticket_log ( id BIGINT NOT NULL AUTO_INCREMENT, ticket_id BIGINT NOT NULL, from_status VARCHAR(24) NOT NULL, to_status VARCHAR(24) NOT NULL, operator VARCHAR(64) NOT NULL, remark VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_ticket_created (ticket_id, created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

这张表是排查问题的关键。工单状态莫名其妙变了,先查这张表,谁在什么时间改的、从什么状态到什么状态,一目了然。它比在业务表上加一堆时间字段清晰得多。

4.2 Redis 与 MQ:坐席状态和超时消息怎么放

坐席状态放 Redis Hash,不要一个坐席一个 key。一个 key 一个坐席的话,要统计“在线坐席数”就得keys扫,生产环境直接起飞。

// key: cco:agent:status field: agentId value: 状态码|负载|最近接单时间戳 public void updateAgentStatus(Long agentId, AgentStatus status, int load) { String value = status.getCode() + "|" + load + "|" + System.currentTimeMillis(); redisTemplate.opsForHash().put(AGENT_STATUS_KEY, String.valueOf(agentId), value); // 状态需要过期,避免坐席异常掉线后一直显示在线 redisTemplate.expire(AGENT_STATUS_KEY, Duration.ofHours(12)); } public List<Agent> onlineAgents() { Map<Object, Object> raw = redisTemplate.opsForHash().entries(AGENT_STATUS_KEY); return raw.entrySet().stream() .map(e -> Agent.parse(String.valueOf(e.getKey()), String.valueOf(e.getValue()))) .filter(Agent::isOnline) .toList(); }

12 小时的过期时间是兜底,真正的下线靠坐席主动上报和心跳超时检测。心跳我一般 30 秒一次,90 秒没上报就判离线,这个阈值要跟前端约定好,前端切后台时心跳会停,阈值太短会误判。

SLA 超时不用延迟消息,用定时扫描更稳。延迟消息在消息积压时会延迟触发,而 SLA 是硬性指标,不能飘。扫描任务按status + sla_deadline走索引,每次扫过去一分钟内到期且未处理的,加分布式锁防重复。

// 每 60 秒扫一次,窗口取未来 1 分钟,避免漏掉 @Scheduled(fixedDelay = 60_000) public void scanSlaTimeout() { LocalDateTime now = LocalDateTime.now(); List<Ticket> due = ticketMapper.selectDue(now, now.plusMinutes(1)); for (Ticket t : due) { // 幂等:已经推进过的不再处理 if (t.getStatus() == TicketStatus.RESOLVED || t.getStatus() == TicketStatus.CLOSED) { continue; } slaService.escalate(t); } }

4.3 本地启动:四步跑通

依赖起起来,库建好,服务就能跑。我用 docker 起中间件,不折腾本机安装。

# 1. 起 MySQL,库名 cco,字符集务必 utf8mb4 docker run -d --name cco-mysql -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=cco \ mysql:8.0 --character-set-server=utf8mb4 # 2. 起 Redis,坐席状态和锁都在这 docker run -d --name cco-redis -p 6379:6379 redis:7 # 3. 执行建表脚本(放在 cco-bootstrap/src/main/resources/db 下) mysql -h127.0.0.1 -uroot -proot cco < db/schema.sql # 4. 启动服务,只跑 bootstrap 模块,-am 会带上它依赖的模块 mvn -pl cco-bootstrap -am spring-boot:run -Dspring-boot.run.profiles=local

-pl指定模块,-am表示同时构建依赖模块,不加这个参数会报找不到cco-domain的类。启动 profile 用local,本地配置里把 MQ 的发送开关关掉,不然一建单就报连不上 broker,新手很容易卡在这。

4.4 接口验证:用 curl 打一条完整链路

服务起来后别急着开页面,先用 curl 打一遍。

# 1. 模拟 App 渠道进线,创建工单 curl -X POST http://localhost:8080/api/ticket/create \ -H 'Content-Type: application/json' \ -d '{"channelCode":"app","userKey":"u_1001","skillGroup":"after-sale","content":"订单没收到"}' # 2. 查工单详情,返回体里应该有 ticketNo 和 status=ASSIGNED curl http://localhost:8080/api/ticket/{ticketNo} # 3. 坐席接单,状态应该流转到 PROCESSING curl -X POST http://localhost:8080/api/ticket/{ticketNo}/accept \ -H 'Content-Type: application/json' -d '{"agentId":10086}'

三个请求依次成功,说明进线、建单、分配、状态机、日志这条链是通的。查详情时重点看status和assigneeId,如果status停在CREATED,说明分配失败进了排队池,去查坐席是否在线、技能组是否匹配。

5. 排查避坑:5 条从启动到联调的踩坑记录

下面这五条,基本覆盖了这个服务从零到跑通最容易卡住的地方。每条按现象、原因、解决写,照着对号入座。

5.1 启动阶段:配置覆盖和数据源

现象:启动报Failed to configure a DataSource: 'url' attribute is not specified,但application.yml里明明有 url。

原因:profile 配置覆盖。application-local.yml里只写了一部分数据源字段,Spring 合并配置时按 profile 优先,导致url被空值覆盖。或者 Maven 打包时资源过滤把 yml 里的占位符替换成了空字符串。

解决:先看启动日志里打印的 active profile 是不是你预期的那个,再用/actuator/env确认最终生效值。本地配置我习惯写完整的连接串,不做跨文件继承,宁可重复也不留合并的玄学。

5.2 SLA 计时:重复触发和提前触发

现象:同一张工单被升级了三次,或者明明还有半小时才到期,提前触发了超时。

原因:定时任务多实例部署时没有加锁,每个实例都扫到了同一批工单;或者时间比较用的是数据库时间,而sla_deadline存的是应用服务器时间,两边时区不一致。

解决:扫描任务加 Redis 分布式锁,锁的过期时间要大于单次扫描耗时;时间统一在应用侧生成,数据库只存不生成,连接串上带serverTimezone,并且服务器时区统一设为东八区。升级动作本身再加一次状态 CAS,双保险。

5.3 重复建单:消息重投和用户手抖

现象:用户点了一次提交,库里出现两条工单。

原因:前端防抖没做,或者 MQ 消费失败重投,消费端没有幂等判断。

解决:ticket_no用“渠道 + 用户 key + 时间窗口”生成,加唯一索引,插入冲突时捕获异常返回已存在的工单。消费端用消息 ID 做去重表,处理过直接 ack。别指望前端防抖,服务端幂等是底线。

5.4 分布式锁:释放了别人的锁

现象:坐席分配偶尔出现两个人同时被分配给同一张工单。

原因:锁的 value 用固定字符串,A 的锁超时释放后,B 拿到锁,A 执行完把自己的del把 B 的锁删了。经典的锁误删。

解决:value 用 UUID,释放时用 Lua 脚本比对 value 再删。更省事的做法是直接用 Redisson 的RLock,它内部就是这个逻辑,还带看门狗续期。加锁的粒度也要注意,锁坐席而不是锁工单,并发度会好很多。

5.5 列表查询:深分页把库拖垮

现象:坐席工作台翻到后面几页,接口响应从 200ms 涨到 5 秒。

原因:limit 100000, 20这种深分页,MySQL 要扫掉前面十万行再丢弃。

解决:改成游标分页,用id > lastId order by id limit 20,前端记住上一页最后一个 id。工单列表按创建时间倒序,游标用created_at + id组合,避免时间重复导致漏数据。这个改动前端要配合,提前沟通好。

6. 二次开发:换路由策略和加渠道的正确姿势

改造这套服务,最高频的两个需求是换分配策略和接新渠道。两者的套路一样:实现接口、注册 Bean、改配置,不碰已有代码。

新增一个“技能优先、负载兜底”的路由:

@Component public class SkillFirstRouter implements AgentRouter { @Override public String name() { return "skill-first"; } @Override public Optional<Agent> select(TicketCreateCmd cmd, List<Agent> candidates) { // 先按技能精确匹配筛一遍,筛完为空再退回全量候选 List<Agent> matched = candidates.stream() .filter(a -> a.matchSkill(cmd.getSkillGroup())) .toList(); List<Agent> pool = matched.isEmpty() ? candidates : matched; return pool.stream().min(Comparator.comparingInt(Agent::getCurrentLoad)); } }

注册靠 Spring 自动扫描,只要实现类在cco-infra的扫描路径下,name()返回值和配置cco.route.strategy对上就能生效。改完不要直接上线,先写一个单测把候选集造出来验证:

@Test void skillFirstShouldPreferMatchedAgent() { TicketCreateCmd cmd = TicketCreateCmd.builder().skillGroup("after-sale").build(); List<Agent> agents = List.of( Agent.of(1L, "refund", 1), // 技能不匹配,负载低 Agent.of(2L, "after-sale", 5) // 技能匹配,负载高 ); Optional<Agent> picked = new SkillFirstRouter().select(cmd, agents); assertTrue(picked.isPresent()); assertEquals(2L, picked.get().getId()); }

接新渠道同理,实现ChannelAdapter,重点是把userKey取对,它决定去重是否准确。上线前用影子流量验证:新渠道的报文复制一份打到预发,比对生成的工单字段和旧渠道是否一致,跑两天没问题再切。

有一条我踩过的坑值得单独说:改路由策略时别只看平均值。加权策略下新坐席接单量会明显偏高,因为他们的负载分一直很高。上线前拿过去一周的真实工单回放一遍,看每个坐席的接单分布,比看接口通不通有用得多。

从那以后我每次改分配相关的代码,都强制走一遍“造候选集 → 回放历史单 → 看分布”的流程,哪怕只改了一个权重系数。分配算法这种地方,直觉基本不准,数据才说话。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询