1. 项目背景与整体架构思路
标题里的 DeskcommCRM 拆开看就很有意思:Desk 是桌面办公场景,Comm 是 Communication(通讯/协作),合在一起就是一套以"桌面端日常沟通 + 客户关系管理"为核心的企业级 CRM 系统。我最初接到这个项目需求时,对方只提了一个非常朴素的要求——"我们就想把销售每天跟客户聊的东西、跟进的进度、签的单子全部串起来,别再各记各的了"。
但真等我在纸面上把需求捋完,才发现事情没那么简单。市面上现成的 CRM 产品多如牛毛,从轻量级的电子表格到重型的全域营销自动化平台,选择太多反而容易陷入"功能堆砌"的陷阱。DeskcommCRM 真正要解决的,是三个被大多数通用 CRM 忽略的痛点:
第一,沟通留痕的碎片化。销售每天跟客户的交互渠道太多了——电话、企业微信、邮件、线下会议记录,如果这些信息不能归并到同一个客户时间轴上,那客户资产的沉淀就是空话。第二,销售流程的执行不可控。管理者想知道"这批线索到底跟到什么阶段了",不能只靠销售每周交一份周报,必须由系统用流程节点把过程数据自动带出来。第三,数据报表的滞后性。传统的月度统计在实际业务里几乎没有调整价值,真正的管理需求是"今天有多少商机可能本周签约、哪些客户已经超过 7 天没跟进",这些动态视图才是活的。
基于这三个痛点,DeskcommCRM 的整体定位就很清楚了:它不是那种大而全的通用型 CRM,而是面向销售团队协作场景的"沟通 + 过程管理"型系统,核心价值是把客户全生命周期内的每一次互动都变成可查询、可统计、可跟进的结构化数据。
1.1 技术选型:为什么用这套组合
技术栈的选型我直接说结论,然后解释理由。后端采用 Spring Boot 3.x + MyBatis-Plus + MySQL 8.0,前端使用 Vue 3 + Element Plus,权限模型选用 Spring Security + JWT,缓存用 Redis,定时任务用 XXL-Job,部署采用 Docker Compose 编排 Nginx + 应用镜像 + 数据库。这套组合看起来没什么新意,但恰恰是这种"平庸"才最适合业务型系统。
先说 Spring Boot 3.x。CRM 这类系统的核心诉求是稳定和快,不是技术新潮。Spring Boot 3 基于 Jakarta EE 规范,内置的自动配置和 Starter 机制能极大减少样板代码,团队里即使有新人也能快速上手。MyBatis-Plus 则是在 MyBatis 基础上的增强,分页、条件构造器、逻辑删除这些功能开箱即用,对于 CRM 里大量存在的"多条件组合筛选客户列表"这种需求,QueryWrapper 写起来比手写 SQL 快太多,而且不容易出错。
前端选 Vue 3 + Element Plus 没什么好犹豫的。CRM 的管理后台天然就是 Table + Form + Dialog 这三种元素的密集组合,Element Plus 的表格组件支持自定义列、懒加载、多级表头,表单组件有完整的校验机制,这些能力正好命中客户列表页和商机编辑页的核心需求。加上 Vue 3 的组合式 API 让逻辑复用更干净,比如客户列表的筛选逻辑和分页逻辑可以抽成独立的 composable,供不同业务页面复用。
Redis 在系统里承担了四个职责:登录令牌的存储(替代传统的 Session 共享)、客户公海池的锁机制(防止多销售同时领取同一条线索)、热门客户标签的缓存(减少数据库压力)、以及销售日报中"今日待办"的临时计数。Xxl-Job 则是为了处理定时任务——比如每晚自动把"超过 30 天未跟进的客户"转池、每天上午 9 点给销售推送"即将过期的商机提醒"。
1.2 架构分层:从接口到数据的解耦思路
代码层面我采用标准的四层架构:Controller 层负责参数接收和响应包装,Service 层承载业务逻辑,Mapper 层做数据持久化,外加一个独立的 DTO/VO 转换层。这里特别想强调的是 DTO/VO 的转换,很多项目规模一大就乱,本质上是 Entity、DTO、VO 混着用,导致字段含义在层与层之间漂移。
DeskcommCRM 里我定了一条规矩:数据库表对应的 Entity 类不允许出现在 Controller 层的返回结果中,必须转换成 VO(View Object)再输出。比如客户实体类里有"负责人 ID"和"创建人 ID"两个字段,前端页面要展示的是"负责人姓名"和"创建人姓名",这两个名字存在用户表里。如果直接把 Entity 返回给前端,要么前端拿到 ID 后查一次用户表(产生 N+1 请求),要么后端在 Service 层做主从表关联后组装成 VO,后者才是正确做法。
ServiceImpl 里我会按业务聚合的方式组织方法,而不是简单地把 Mapper 的 CRUD 透传一层。举个例子,客户列表页需要同时支持按跟进时间筛选、按负责人筛选、按客户来源筛选,还要在每条记录后面带上"最近一次跟进记录"和"未读消息数"。这个查询如果用循环查数据库的方式实现,列表页一页 20 条数据就可能产生 40 条以上的 SQL,性能完全不可接受。实际方案是先用主查询把客户主数据查出来(条件在 SQL 里过滤),然后把客户 ID 集合作为参数一次性查出所有客户的最近跟进记录和未读消息数,最后在 Java 代码里做 Map 匹配拼装。这样一个列表页只需要三条 SQL,响应时间从原来的 3 秒降到了 300 毫秒以内。
2. 数据模型设计:CRM 的核心是实体关系
实体关系设计是 CRM 系统最重要的地基,没有之一。我见过太多 CRM 项目"死"在数据模型上——要么是客户、联系人、商机揉在一张表里,导致字段冗余到 60 多个;要么是父子关系没理清,后来想加一个"公司下有多个联系人"的需求就得改表结构。DeskcommCRM 的数据模型按照客户主数据、销售过程数据、协作沟通数据三大主题域来划分。
客户主数据包括客户表(customer)、联系人表(contact)、客户标签表(tag)、客户来源表(source)。这里有个关键设计决策:客户表和联系人表分开,而不是把多个联系人塞进客户表的一个 JSON 字段里。分开的好处很直接——后续加"联系人独立跟进""给联系人发邮件""按联系人维度统计业绩"这些需求时,表结构不用动,扩展性完全不受限。
销售过程数据是线索表(lead)、商机表(opportunity)、合同表(contract)、跟进记录表(follow_up_record)。这四张表构成一条完整的销售漏斗链路:线索 → 转换为客户 → 建立商机 → 推进赢单 → 签订合同。业务状态机就挂在链路上,比如线索有"新分配、跟进中、已转换、已流失"四种状态,商机有"初步沟通、需求确认、方案报价、商务谈判、赢单、输单"六种状态。
协作沟通数据是消息表(message)、待办任务表(task)、操作日志表(operation_log)。这一块是 DeskcommCRM 区别于普通报表型 CRM 的核心——每一次客户沟通、每一次状态变更、每一个待办任务的产生和完成,都会在这三张表里留下结构化记录。
2.1 客户表和线索表的字段设计要点
客户表(customer)的核心字段我列一下:id、customer_name、customer_type(1-企业客户,2-个人客户)、industry、source_type、owner_id(负责人)、creator_id、created_time、updated_time、deleted_flag。这里有两个容易忽略的细节。
第一个是 owner_id 和 creator_id 一定要分开。很多系统里创建客户的人就是负责人,但实际业务中经常出现"销售 A 录入了客户,之后转给了销售 B 跟进"的情况。如果不分开,后续查"这个客户是谁创建的""现在归谁负责"会产生语义混乱,审计跟踪就断了。
第二个是 deleted_flag 用逻辑删除而不是物理删除。CRM 的客户数据往往关联着合同、跟进记录、审批流程,一旦物理删除,关联数据要么跟着删(风险极高),要么变成孤儿数据。逻辑删除虽然会让查询条件里多一个 where deleted_flag = 0,但换来的是数据可追溯、可恢复,对于企业系统来说这个取舍是必须的。
线索表(lead)在客户表的基础上多了几个关键字段:lead_status(状态)、convert_customer_id(转换后的客户 ID)、assigned_at(分配时间)。线索转客户是 CRM 系统里一个典型的分布式事务场景:要把线索表的状态改成"已转换",要在客户表插入一条新客户记录,要建立线索和客户的关联关系,还要把线索下的跟进记录迁移到客户的时间轴下。这就是第 3 节要讲的"流程实现"里的重头戏。
2.2 商机和合同如何挂靠业务链路
商机表(opportunity)是销售漏斗分析的数据源,字段设计上要特别注重"金额"和"预计成交时间"这两个维度的准确性。核心字段包括:opportunity_name、customer_id、expected_amount(预计金额)、actual_amount(实际成交金额)、stage(当前阶段)、probability(赢单概率)、expected_deal_date(预计成交日期)、owner_id。
这里我特意没有把金额字段设计成"一版定终身",而是引入了一个商机阶段变更记录表(opportunity_log)。每次商机从"需求确认"推进到"方案报价",系统都往这个日志表里写入一条记录,记录当时的阶段、金额、操作人。这样做有两个好处:一是管理层可以回放某个商机的"推进历史",二是做商机阶段转化率报表时有真实数据支撑,而不是只能看到当前状态。
合同表(contract)相对简单,核心字段是 contract_no(合同编号)、customer_id、opportunity_id、amount、sign_date、start_date、end_date、status。合同必须挂到商机下,而不是直接挂到客户下,这样才能打通"从线索到回款"的完整链路。后续如果要扩展回款计划、开票管理,就围绕 contract_id 再建子表,天然清晰。
2.3 跟进记录的设计:时间轴还是独立表
跟进记录(follow_up_record)是销售日常使用频率最高的功能,设计上我选了独立表而不是时间轴 JSON 字段。原因很简单:时间轴展示需要"在客户详情页把沟通记录、待办变化、状态变更按时间倒序混排",这是一个动态查询,如果用 JSON 字段存,每加一种新的事件类型就要改解析代码,维护成本会递增。
独立表的设计如下:id、customer_id、record_type(1-电话,2-微信,3-上门拜访,4-邮件)、content(沟通内容)、contact_id(关联的联系人)、creator_id、next_follow_time(下次跟进时间)、created_time。每次销售录完跟进记录,系统自动做两件事:一是更新客户表的 last_follow_time 字段(客户列表页按这个字段排序,就能实现"最近跟进靠前");二是在任务表里生成一条 next_follow_time 对应的待办任务,到这个时间点系统会推送提醒。
三张表(跟进记录、任务、客户主数据)的联动是整套系统里我觉得最有实用价值的设计,它让"跟进客户"不再依赖销售的自觉性,而是变成系统驱动的工作流。
3. 核心功能模块的实操实现
光有数据模型还跑不起来业务,这一节我挑三个最有代表性的功能模块拆开讲:销售漏斗的自定义配置与权限控制、线索转客户的事务一致性方案、以及客户公海池的自动流转规则。这三个模块分别是流程引擎、数据一致性、任务调度三个方向的典型实现,做完这三个,CRM 的主体框架就立住了。
3.1 销售阶段配置与权限管控
销售阶段(Sales Stage)不能写死在前端页面上,必须做成可配置的。因为不同业务团队的销售流程长度不一样——有的团队只要"初步沟通 → 报价 → 签单"三步,有的团队要拆到七八步。DeskcommCRM 里我用一张销售阶段配置表(sales_stage_config)存储,字段包括:stage_name、stage_order、probability(该阶段默认赢单概率)、is_final(是否终态)、tenant_id(租户 ID)。
运行时的商机推进操作会读取这张配置表,前端按 stage_order 排序渲染成 Steps 组件。每当商机状态变更为新的阶段,系统自动把该阶段对应的默认概率写入 opportunity 表的 probability 字段,同时插入一条操作日志。这样销售推进商机时只需要选"下一步阶段",系统自动带上赢单概率,无需销售手动填数字,既减少了录入负担,也保证了数据统计口径统一。
权限控制方面,我的设计原则是"资源归属优先,角色授权兜底"。实现的是一套基于数据范围(Data Scope)的权限模型,分为四个级别:全部数据、本部门数据、本人数据、指定人数据。具体到接口层面,就是在 MyBatis-Plus 的 QueryWrapper 上动态拼接数据权限条件。比如销售角色默认只看到 owner_id 等于当前用户 ID 的数据,部门主管能看到 owner_id 属于本部门的,管理员能看到全部。这个逻辑我封装成了一个自定义注解 @DataScope,标注在 Service 方法上,通过 AOP 切面自动追加权限条件,省掉了在每个方法里手写权限判断的重复劳动。
3.2 线索转客户的事务性方案
线索转客户是 CRM 里最典型的多表单写操作,我用一个实际场景说明:销售在"线索池"里点到一条数据,点击"转为客户",系统需要完成五件事:
- 把线索表(lead)的 lead_status 更新为"已转换";
- 在客户表(customer)插入一条新客户,把线索的名称、行业、来源等字段迁移过去;
- 建立客户主数据下的默认联系人(如果有联系人信息的话);
- 把线索下的所有跟进记录(follow_up_record)的 customer_id 从 0 更新为新客户的 ID,这里线索在转换前的跟进记录 customer_id 存的是关联的临时线索编号,转换后必须回填成正式客户 ID;
- 在操作日志表(operation_log)里写入一条"由线索转换创建客户"的审计记录。
这五步如果只靠单个方法逐条执行,任何一步失败都会留下脏数据——最典型的就是客户表插入成功、跟进记录回填失败,导致客户详情页里看不到任何历史跟进。我的做法是使用 Spring 的 @Transactional 注解包裹整个方法,并设置回滚规则为 RuntimeException 时整体回滚。有些场景下数据库 InnoDB 引擎是支持跨表事务的,但需要确保所有表都是同一个数据源,且事务隔离级别设置正确。实操中我建议生产环境用默认的 REPEATABLE_READ 级别即可,不要随便调成 READ_COMMITTED,除非你非常清楚并发场景下的后果。
另外还有一个并发问题值得提醒:如果两个销售同时点击"转换"同一条线索,可能产生两条客户记录。我的处理方案是在线索表上加一个 version 字段(乐观锁),执行转换前先执行 update lead set version = version + 1 where id = ? and version = ?,更新行数为 0 则说明已经被其他人处理,直接返回"该线索已被转换"的提示。
3.3 客户公海池的自动流转规则
公海池(Public Pool)是解决"销售离职/低活跃导致客户资源闲置"的标准方案。规则是:客户超过 N 天未跟进,自动流转到公海池;销售可以从公海池领取客户,领取后进入自己的私有列表。
在 DeskcommCRM 里,我把这个逻辑封装为一个独立的策略接口 PublicPoolStrategy,默认实现是"30 天未跟进进入公海池"。但实际上不同等级的客户天数不一样——A 类客户 14 天没跟进就该收回,C 类客户 60 天没跟进也没关系。所以我把客户分级字段(customer_level)和跟进时间字段(last_follow_time)组合成规则引擎的入参。规则配置放在一张配置表里,运营人员可以在后台维护"什么等级的客户超过多少天未跟进自动进公海池",而不用改代码。
定时任务用 XXL-Job 的调度平台来触发,每天早上 2 点执行一次。任务逻辑是:查出所有满足"当前时间 - last_follow_time > 阈值"且 owner_id 不为空且 deleted_flag = 0 的客户,批量把 owner_id 置空(代表进入公海),同时给原负责人发送一条站内消息提醒"客户 X 已超期未跟进,自动进入公海池"。执行完成后记一条调度日志,方便复查。
这个实现里最核心的优化是批量操作而不是单条循环。假设有 5000 条待流转数据,如果用 for 循环逐条 update,每条约 1 毫秒,总耗时 5 秒,加上每条消息的推送可能还要更久。实际我用的方案是:先查出待流转数据的 ID 列表,然后执行一条 SQL 批量更新(update customer set owner_id = null where id in (...)),再统一插入消息记录。5000 条数据的处理时间从秒级降到了毫秒级,这个差异在数据量上来后非常明显。
4. 前端关键页面的实现方案
前端部分我挑三个核心页面细说:客户列表页、客户详情时间轴、商机看板。这三个页面是销售每天沉浸时间最长的界面,也是前端性能优化和交互设计的重心。
4.1 客户列表页:筛选、分页与性能优化
客户列表页看起来只是个表格,但一旦数据量过万,各种问题都会浮出来。我踩过最大的坑是"筛选条件联动导致的重复请求"——页面上有负责人下拉框、客户来源下拉框、客户等级下拉框、创建时间范围选择器,以前的做法是任何一个筛选值变化就重新请求一次列表接口,结果是用户操作过快时,前面的请求还没返回,后面的请求又发出去了,页面数据乱闪。
现在的做法是防抖 + 统一查询参数对象。页面里所有的筛选器绑定到同一个 reactive 对象 filterParams 上,使用 watch 监听 filterParams 的变化,并用 lodash 的 debounce 包一层(延迟 500 毫秒)。这样用户连续切换多个筛选条件时,只会发出最后一次的请求。分页方面使用的是"总条数 + 当前页 + 页面大小"模式,每次查询带上 pageNum 和 pageSize,后端用 MyBatis-Plus 的 Page 对象完成分页。
表格渲染的优化我用了两个 Vue 3 的特性:一是按行进行自定义列渲染时使用 shallowRef,只做浅层响应式,避免大对象深响应式带来的性能开销;二是对跟进时间、金额这类字段使用 Element Plus 的 formatter 函数而不是在模板里写三元表达式,减少渲染时的方法调用。
4.2 客户详情页的时间轴实现
时间轴是 DeskcommCRM 最受用户欢迎的模块。销售点开一个客户详情,能按时间倒序看到:跟进记录、操作日志、商机阶段变更、合同签订事件,所有事件混排成一个可滚动的 timeline。这个界面的数据来源是前文提到的多张表,后端提供一个聚合接口 /customer/{id}/timeline,用 UNION 查询把四项数据合并。
SQL 的写法上,我是用 UNION ALL 而不是在 Java 里做多次查询再合并。原因是排序逻辑在 SQL 层完成最直接——四条子查询各自查出对应的记录,带上统一的 event_time 字段,最后按 event_time desc 排序。因为子查询之间有重复字段名的问题,我会给每个子查询加一个 type 字段标识事件类型,前端根据 type 渲染不同的图标和颜色。
前端时间轴的交互上有一个细节:默认只展示最近 20 条,点击"加载更多"时用平铺追加而不是翻页,这样更符合用户"滚动阅读"的心理模型。后面的数据通过增量接口传递 lastId + size 参数,后端用游标分页查询,比传统 page/pageSize 的 offset 分页在深分页场景下性能好得多。
4.3 商机看板:拖拽变更阶段
商机看板(Pipeline Board)是把销售漏斗可视化的关键页面。我用的是 Vue 3 + SortableJS 实现横向的看板列(每个列代表一个销售阶段),卡片(商机)可以在列之间拖拽。拖拽完成后,前端把商机 ID 和目标阶段 ID 发送到后端接口,后端校验该阶段变更是否符合业务规则(比如"不能从初步沟通直接跳到签订合同"),通过则更新商机阶段并写日志,不通过则回滚拖拽位置并提示原因。
这个实现有两个要点:第一,拖拽要使用"乐观更新"模式,即拖拽后立即在本地把卡片移动到目标列,同时发起请求,请求成功则保持现状,失败则回滚。这种方式让用户感觉"操作即时生效",避免了等待请求完成期间卡片的"粘滞感"。第二,需要在拖拽开始时记录卡片原来的列位置,以便失败时准确回滚。
看板数据量性能方面,如果某个阶段有超过 1000 张商机卡片,一次性渲染会变卡。我在前端做了虚拟滚动,只渲染可视区域内的卡片。后端接口也做了对应的分页设计,拖拽滚动到底部时动态加载下一页数据。
5. 常见问题与排查技巧实录
系统上线半年,生产环境踩过的坑值得记一笔速查表,帮助后来者少走弯路。
5.1 排查过的三个典型问题
第一个是"客户列表页偶发超时"。排查时发现是 MySQL 的深分页问题,当用户点击列表底部的第 100 页时,SQL 会写成 limit 1980, 20,数据库需要扫描前 1980 条记录再丢弃,数据量越大越慢。解决方式是将传统 offset 分页改为游标分页:把排序字段(比如主键 id)带到查询条件里,用 where id > 上次最后一条的 id 实现翻页。配合前端"加载更多"而不是跳页,体验更好。
第二个是"公海池任务执行后部分客户没有进入公海"。最后定位到原因:客户表里部分记录的 last_follow_time 字段为 NULL。定时任务的 SQL 条件是 last_follow_time < 当前时间 - 30 天,NULL 值不满足该条件,所以这些客户永远不会被自动流转。修复方案有两个:在 SQL 里加上 OR last_follow_time IS NULL 的条件,或者在表设计阶段给该字段设置默认值(比如创建时间)。推荐后者,因为它在数据源头就堵住了空洞。
第三个是"并发领取同一条公海客户导致报错"。公海池客户被销售 A 点击领取时,系统先查询该客户是否仍属于公海,再执行 update 更新负责人。两个请求同时进入时,双方都查询到"属于公海",随后都执行更新,产生了竞争条件。解决方式是使用 Redis 分布式锁,key 为 customer_claim_{customerId},拿到锁的销售才能执行领取逻辑,执行完释放锁。这个方案在高并发场景下能保证同一时刻只有一个请求能通过。
这些问题整理成一个速查表:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 列表翻页到深层时响应变慢 | MySQL offset 深分页 | 改为游标分页/加载更多 |
| 定时任务部分客户未流转 | 时间字段为 NULL | 设置默认值或补充空值条件 |
| 并发领取同一条公海客户 | 缺少互斥锁机制 | Redis 分布式锁 |
| 大列表渲染卡顿 | 一次性渲染 DOM 过多 | 虚拟滚动 + 按需加载 |
| 字段含义在层间漂移 | Entity/DTO/VO 混用 | 分层隔离并强制转换 |
5.2 数据一致性与权限越权的坑
权限越权是 CRM 系统里最危险的问题,一旦出现,不仅是业务数据泄露,更可能引发信任危机。我在上线前做了一次完整的越权测试,覆盖了三个典型场景:普通销售是否可以修改他人的客户、是否可以删除部门数据、是否可以在报表接口里通过改 user_id 参数查看其他人的商机。
排查发现,问题最常出现在报表和统计接口。很多开发在做列表页时都会带上数据权限,但到了导出 Excel、统计图表这种"附属功能",容易忽略权限条件的拼装。一个典型的例子是导出接口复用了 Service 层的查询逻辑,但查询逻辑里没有追加 @DataScope 注解的权限条件,导致销售可以导出全量客户数据。我的建议是:权限校验必须在查询边界做,而不是在展示层做。写一个数据权限切面,把所有查询方法统一接管,无论接口是列表、详情、导出还是统计,都强制追加数据权限条件。
数据一致性方面,特别要注意跨服务的操作不能只靠本地事务。比如"创建客户 + 推送企业微信通知"这个操作,如果把通知推送放在本地事务里,推送接口网络超时会导致整个事务回滚,客户创建失败。正确的做法是先把客户数据落库(本地事务),然后通过消息队列发送通知,推送失败后由消费者重试。这就要求在设计表时给通知任务表(task)增加一个 retry_count 字段,消费失败后重试次数加 1,超过 3 次则进入死信队列人工处理。
6. 从开发到上线的部署实践经验
这一节聊一聊项目从开发环境到生产环境的部署实践,特别是容器化编排和数据库初始化带来的实际问题。
6.1 Docker Compose 编排多个服务
我在本地开发用的是 Docker Compose 起整套环境,包含四个容器:MySQL 8.0、Redis 7、后端应用(Spring Boot 打成 JAR 包)、前端 Nginx(Vue 3 构建产物)。Compose 文件里需要注意几个点:数据卷一定要挂载到宿主机指定目录,不然 docker compose down 的时候数据就没了;后端应用的环境变量通过 environment 传入数据库地址和密码,不要硬编码在配置里;Nginx 要做 SPA 的 history 路由回退,配置 try_files $uri $uri/ /index.html。
给一段精简过的 compose 文件参考:
version: "3.8" services: mysql: image: mysql:8.0 container_name: deskcomm-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: deskcomm ports: - "3306:3306" volumes: - ./data/mysql:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password redis: image: redis:7-alpine container_name: deskcomm-redis ports: - "6379:6379" volumes: - ./data/redis:/data backend: build: context: ./server dockerfile: Dockerfile container_name: deskcomm-backend depends_on: - mysql - redis environment: DB_HOST: mysql DB_PORT: 3306 DB_USERNAME: root DB_PASSWORD: root123456 REDIS_HOST: redis ports: - "8080:8080" nginx: image: nginx:1.24-alpine container_name: deskcomm-nginx depends_on: - backend ports: - "80:80" volumes: - ./web/dist:/usr/share/nginx/html - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf线上环境我会把 compose 里的 MySQL 和 Redis 换成云数据库实例,应用容器单独部署在云主机上,Nginx 做负载均衡。后端应用如果是多实例部署,需要保证 Redis 里存 JWT 令牌的命名空间不冲突,同时定时任务只在一个实例上开启,避免多个实例同时跑公海池流转造成重复更新。
6.2 数据库迁移脚本的管理
CRM 这类业务系统需求变更频繁,表结构改动的频次很高。我强烈建议从项目一开始就用 Flyway 管理数据库迁移脚本,而不是"手工修改数据库 + 导出 SQL 给同事执行"。Flyway 的好处在于:每个版本的 SQL 脚本单独命名,按版本号顺序执行,数据库的 schema 版本与代码版本始终保持一致,避免"我本地库是新结构、生产库是老结构"这种错位。
实际操作中我会为每个迭代建一个 V 系列脚本,例如 V1__init_schema.sql、V2__add_customer_level.sql、V3__create_contract_table.sql。脚本只能追加,不能修改已执行的脚本——如果发现之前的脚本有误,新建一个 V 系列脚本去做修正,而不是直接改动旧的。因为 Flyway 会记录已经执行过的脚本的 checksum,改旧脚本会导致校验不一致,启动直接报错。这个习惯虽然前期麻烦一点,但对于多人协作和线上稳定非常有价值。
持续集成方面我把测试、构建、镜像打包、推送、部署串成一条流水线。每次代码合并到主分支后,自动跑单元测试和接口测试,通过后构建 Docker 镜像并推送镜像仓库,然后在测试环境触发更新。线上环境的发布选择在低峰期手动执行,因为考虑到用户数据安全,全自动发布在业务流程系统里风险偏好还是保守一些更好。
7. 后续扩展:从 CRM 到客户全生命周期管理
DeskcommCRM 的基础框架搭好之后,它的可扩展空间其实非常大。我从实际业务出发,列出三个我认为最有价值的发展方向。
第一个是增加工单管理模块。CRM 天然贴近客户,如果能把售后的工单请求也纳入系统,客户从"线索被跟进"到"成为客户"再到"提交售后工单"的全旅程就都在一套系统里了。数据模型上只需要新增工单表(ticket),关联客户表和合同表,再提供一个客户服务看板,就能让管理层看到服务响应时长、工单解决率等关键指标。
第二个是引入商业智能(BI)报表。CRM 里沉淀的大量数据如果只是躺在数据库里,价值非常有限。可以用 Cube.js 或者直接写 SQL 生成报表——包括销售漏斗转化率、商机赢单率、客户来源 TOP 渠道、团队业绩排行等。核心思路是预先定义好指标和维度,用物化视图或汇总表加速查询,避免前端做多维度自助分析时无限 GROUP BY 拖垮主库。
第三个是智能提醒和自动化。比如商机到了预计成交日期还没有进入赢单阶段,系统可以自动提醒销售负责人;客户等级是 A 类但最近 7 天都没有跟进记录的,自动推送给主管介入。这些逻辑用 XXL-Job 加规则引擎就能实现,不需要上复杂的 BI 工具,性价比很高。
我在实际开发中的体会是:像 DeskcommCRM 这样的业务系统,技术难点往往不在某个高深算法或中间件上,而在于业务理解是否透彻、数据模型是否健壮、异常分支是否考虑周全。把这些基础功夫做扎实,系统自然稳定好用。如果后续有团队打算从零做一套类似的系统,我建议一定先把数据模型设计拿出来反复评审几轮,这块省下的时间,后面十倍的开发量都换不回来。