02-在芋道上盖楼:技术选型的得与失

在芋道上盖楼:技术选型的得与失

先亮底牌:后端基座是芋道 ruoyi-vue-pro(JDK 25 分支,当然我们也有go的版本)深度改造,前端 Vue 3 + TypeScript + Element Plus,数据库 PostgreSQL 16,全部跑在 Docker Compose 里。每次跟同行说起,对方反应通常是"又一个 ruoyi 二开"。是,也不是。这篇就把选型时想清楚的和没想到的都摊开讲。

为什么不自研基座

呼叫中心的业务代码其实不难写,难的是底下那层每个 SaaS 都要的东西:多租户隔离、RBAC 权限到按钮级、菜单体系、操作日志、数据权限、登录防爆破、代码生成器。这些玩意儿自研一遍,两三个人写三个月起步,而且都是"写完没成就感、出错全是事故"的部分。

芋道这套基座恰好把这些都做完了。多租户靠 MyBatis 拦截器自动拼 tenant_id 条件,我们的全部 cc_* 表天然继承;菜单驱动的路由让"新功能上线=插几行菜单数据";权限串 callcenter:xxx:yyy 直接对齐 @PreAuthorize。评估阶段也看过直接用 FreeSWITCH 生态的现成呼叫中心方案,但那些项目的基本盘是"单套系统给一个公司用",多租户、按租户计费、套餐授权这些 SaaS 骨架还是得自己从头搭——算下来不如在业务框架上盖楼。

代价也很明确,后文细说。

激进的版本,保守的架构

技术栈上有个反差挺有意思:版本选得很激进,架构却很保守。

激进的是 JDK 25 + Spring Boot 4.1 + MyBatis-Plus 3.5。踩着最新 LTS 走,代价是不少三方库要等社区适配,排查问题时搜索引擎里冷冷清清,最后经常得自己翻源码。前端同样,Node 20.19+、pnpm、Vite,全仓库 TypeScript,提交前强制 ts:check && lint。

保守的是模块化单体,不拆微服务。全部业务收在一个 yudao-module-callcenter 模块里,唯一启动模块 yudao-server,端口 48080。这条是架构决策 ADR-001 定死的,当时的原话是:"直接上微服务——复杂度守恒,会把复杂度从代码搬到基础设施,当前团队无法承担。"我们团队就这么几个人,微服务带来的注册中心、链路追踪、分布式事务、二十个服务的发布编排,每一样都比它解决的问题多。

唯一的"拆"发生在部署层:控制面(cc-backend,Spring Boot 全家桶)和媒体面(Kamailio/FreeSWITCH/RTPEngine)分成两个 compose 栈。原因不是流行词,而是两者的故障模式完全不同——媒体面吃 CPU 和网络 IO、有状态、重启会拆掉在途通话;控制面无状态、随时可以灰度重启不影响通话。混在一起的话,每次发后端版本都在赌通话掉线。

另外全仓库立了一条规矩:MyBatis-Plus 零 Mapper XML,一律注解加构造器。起初有人不适应,半年下来公认这是做得最对的约定之一——SQL 和 Java 在一个文件里,review 时不用来回跳,也没有"XML 改了忘了打包"这种陈年事故。

PostgreSQL,而不是 MySQL

传统呼叫中心开源栈几乎清一色 MySQL,我们选了 PG 16,事后证明对这个业务是降维打击:

  • JSONB + GIN 索引:客户自定义字段(文本/数字/日期/单选/多选)的值直接存 cc_client_info.ext jsonb。当初否掉 EAV 模型(一行一属性值)就是不想为了查一个字段 join 五张表。
  • 分区表:话单表 cc_cdr 按 start_time 单列 RANGE 分区,每日 Job 预建未来 3 个月分区,6 个月热数据 DETACH 归档。年 1500 万行的表,查询永远只扫近期分区。
  • 部分唯一索引:CREATE UNIQUE INDEX ... WHERE cdr_id IS NOT NULL 这种写法,在幂等防重上是关键武器——当然,它也坑过我们一次,那个 WHERE 子句里的盲区后来造成过 52 笔双扣,详见计费复盘那篇。

消息中间件用 RabbitMQ(架构文档早期写的是 RocketMQ,实现时换了,文档口径已纠正),Exchange cc.event 一个 topic,每域一个 quorum 队列。FreeSWITCH 每通电话吐 20~40 条事件,全部先进 MQ 削峰,消费侧解耦——否掉"ESL 直写数据库"方案的理由很简单:呼叫风暴来的时候,数据库永远是最先倒下的那个。

前端与移动端

管理后台 Vue 3 + Element Plus + Pinia,软电话用 sip.js。没太多可说的,除了两条教训:

一是 mock 即契约(ADR-008)。前端本地 _mock 数据的接口形状必须和后端一致,联调期前后端并行开发靠这个撑着,接口形状漂移在 mock 层就会被抓到。

二是 keep-alive 多页签的坑:页面被缓存后 onMounted 只执行一次,下拉选项就再也不刷新了。规范最后定为"打开新增/编辑弹窗前无条件刷新选项",全模块统一改掉。

移动端只做 Android,Flutter + riverpod + go_router + sip_ua。不做 iOS 不是技术原因,是客户群体几乎清一色安卓工作机。Flutter 里跑 WebRTC 软电话这块至今没完全通,属于已知欠账。

代价清单:在别人的楼里施工

在芋道上盖楼,楼不是你的,施工时处处要小心。真实付出过的代价:

代码生成器漂移。 基座自带的生成器跑第一遍很爽,55 个 Controller、63 张表的地基半天就出来了。但 call 域的 DO/VO 手工演进几轮之后,生成脚本再也重跑不了——重跑会把手工改动全部覆盖。结论很早就定了:加字段一律手改 DO/VO 再同步规格文档,生成器只在新起一个域时用一次。

跨模块 Bean 冲突。 我们自己的 SmsTemplateMapper 和基座 system 模块撞名,Spring 启动直接报 bean 冲突。改名解决,但这提醒了命名空间纪律:业务表/类一律 cc_ 前缀,别跟基座抢名字。

依赖误删。 有次整理依赖把 spring-boot-starter-amqp 当没用的删了,编译照常通过,运行时 RabbitTemplate 缺失才炸。从此删依赖必须本地起一遍全链路。

定时任务切面陷阱。 基座的租户上下文是靠切面注入的,@Scheduled 方法上切面不生效,多租户的定时任务会静默跳过或查错租户。最终 Job 全部统一走 Quartz JobHandler + 自定义 @TenantJob 注解,把租户上下文的获取从"框架帮你"变成"必须显式声明"。

还有一条通用体会:在成熟基座上二开,最大的风险不是功能写不出来,而是你不了解的那部分基座行为。我们的应对是把踩过的坑全部沉淀成 docs/callcenter-backend-conventions.md 作为唯一权威约定,新人入职先读它,别再靠口口相传。

一句话总结

选型这件事没有银弹,只有交换。我们用"接受基座的约束"换掉了"三个月基础设施开发",用"单体架构"换掉了"团队能力之外的运维复杂度",用"激进版本"换来一些冷清的搜索结果和不少源码阅读。事后看,这些交换都划算——前提是,代价要被诚实地记录下来,而不是假装不存在。


本文是「云呼叫中心技术内幕」系列第 2 篇。

posted @ 2026-10-25 20:50  cliffdev  阅读(3)  评论(0)    收藏  举报