1. 项目缘起为什么我还要再造一个CRM轮子DeskcommCRM这个名字里的 Desk 和 comm 分别取的是 Desktop桌面办公和 Communication内部沟通。说白了它就是一套扎根在“办公桌”场景下的客户关系管理系统——客户信息、跟进记录、待办提醒、团队协作全部围绕日常一线业务员的操作习惯来设计。我在做这个项目之前团队前后试过飞书多维表格、一套开源的 PHP 系统甚至用 Excel 共享目录凑合过一阵子最后都因为字段不可控、权限太粗或者操作太繁琐被一线同事吐槽到弃用。市面上成熟的 CRM 产品并不少功能拆开看都很全但真正落地到几十人规模的销售型团队时往往会出现三个很现实的痛点功能重、配置复杂。为了适配各种行业系统预置了大量字段和流程一线销售每天要填的必填项多得离谱录入成本远超记录价值。数据不闭环。客户从线索到跟进、到成单、到售后分散在不同工具里销售要来回切换页面才能拼凑出完整视图。定制跟不上业务。团队调整了提成规则或者新增了一个客户来源渠道等厂商排期开发需要几周甚至几个月业务等不起。DeskcommCRM 的定位就很明确——不追求大而全而是把一个 30~50 人规模团队的客户管理闭环打磨顺一个人从拿到线索到最终成单所有关键动作都在同一个系统里完成且保证管理者能实时看到团队每个人的推进情况。这个项目最核心的价值不是“管理员工”而是“帮销售更高效地做生意”。所以这篇分享适合谁自研过内部工具但没有成型方法论的技术同学准备从零搭建团队业务系统的小团队负责人以及对 CRM 产品设计感兴趣的开发者。我会从选型逻辑、数据建模、核心实现到上线排坑把整个落地过程完整展开你可以直接参考这套思路做一套适配自己业务场景的简化版。2. 技术选型从零到一怎么定技术栈2.1 后端框架的选择逻辑后端我最终选了 Spring Boot 3 MyBatis-Plus这个组合在 2024 年依然是中小型团队做业务系统最稳的起步方案没有之一。有人可能会问为什么不用 Node.js 或者 Python FastAPI不是不行但这里有一个很实际的考量CRM 类系统本质上是一个“表单密集 权限复杂 报表统计”的业务系统Java 生态在这类场景下的数据校验、事务管理、权限框架资料最丰富招人也好招。Spring Boot 3 相对旧版本最大的变化是原生支持 GraalVM 和 Kotlin但对大多数业务系统来说真正的收益在于它强制约定的自动配置体系让开发不用纠结 Bean 怎么装配、数据源怎么初始化。MyBatis-Plus 则解决的是 CRUD 重复劳动问题内置的 LambdaQueryWrapper 能让条件查询不写一句 XML配合分页插件后列表接口的开发速度能提升两三倍。提示如果你的团队整体偏前端前端人员居多那用 Node.js TypeScript 也是合理选择。技术选型没有绝对标准核心是团队能长期维护。但如果你希望这套系统在后续接入财务、库存等重型模块时不被框架能力卡住Java 系是更稳的底子。2.2 前端方案的选型考虑前端我选了 Vue 3 Vite Element Plus。选 Vue 而不是 React主要是考虑到团队技术栈迁移成本低而且 Element Plus 的开箱即用组件对“表格 表单 弹窗 树形结构”这种典型管理后台形态覆盖得非常好。Vite 作为构建工具在开发环境下的冷启动速度和热更新体验比 Webpack 时代舒服太多。这里要说一个很多自研项目容易忽略的点管理后台的 UI 框架选择优先看表格和表单组件的成熟度而不是看视觉效果。CRM 里最核心的界面就是“客户列表 筛选条件 批量操作 行内编辑”Element Plus 的 el-table 在虚拟滚动、自定义列、多级表头方面都有成熟方案省去大量手写组件的成本。配合它自带的表单校验和日期选择器一线销售录一条客户的耗时能控制在 30 秒以内这是产品是否被接受的关键体验指标。项目还引入了 Pinia 作为状态管理专门处理“当前登录用户信息、权限点列表、全局筛选条件”这三类跨页面共享的数据。比如用户在一个全局入口选择了“只看我的客户”这个状态需要被线索列表、客户列表、统计报表三个页面共享放 Pinia 里可以做到一次切换、处处生效。2.3 数据库与缓存数据安全是第一要务数据库用 MySQL 8.0这没有任何悬念。选 8.0 主要还是看重窗口函数和 JSON 字段能力后续做统计报表和扩展字段时非常有用。表结构统一使用 InnoDB 引擎、utf8mb4 字符集这是标配。缓存用了 Redis承载两类数据一是用户登录令牌的会话状态二是热点统计数据的临时缓存。关于数据安全有一个踩过坑之后才建立的教训本地开发库、测试库、生产库必须严格隔离且权限配置要遵循“最小可用”原则。我原来为了图方便让所有开发共用一台测试库结果有人跑了清表脚本没法回滚整周的数据测试全作废。后续项目组定下规矩每人一套本地库测试库只允许通过测试环境的应用账号访问生产库密码由运维独立保管所有变更走审批流程。3. 核心功能设计与数据建模3.1 单页应用下的功能地图DeskcommCRM 功能模块拆解下来核心就五个没有多余的动作线索池销售通过 Excel 批量导入、手动新增、或者 API 接口写入的潜在客户统一进入线索池。这个池子支持按来源渠道、行业、规模等条件筛选销售可以从池子里领取线索领取后线索归属到个人名下。客户管理归属后的客户成为正式客户档案包含基础联系信息、所属行业、客户规模、购买意向等级、最近跟进时间等字段。每个客户关联着完整的跟进记录时间线后续所有动作都围绕这条时间线展开。跟进记录销售每次电话、拜访、微信沟通后填写跟进摘要系统自动记录跟进时间和跟进人。跟进记录支持文本、语音转文字、上传附件方便后续回溯。待办提醒基于预约日期和客户最后跟进时间的规则引擎自动生成每天的跟进任务通过站内消息和企业微信通知到对应销售。数据看板按时间维度统计线索转化率、成单金额、团队排名、客户分布等关键指标管理端可下钻到单个销售查看明细。功能地图设计的原则是销售每天打开系统只做“看任务 写跟进 更新状态”这三类动作其他信息都汇聚成一眼能看懂的卡片不需要跳转太多层级。3.2 客户表与跟进记录表怎么设计客户表是系统的心脏字段设计不能拍脑袋。我最终的客户表结构大致长这样CREATE TABLE customer ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 客户名称, contact_name VARCHAR(50) DEFAULT NULL COMMENT 联系人姓名, contact_phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, contact_wechat VARCHAR(50) DEFAULT NULL COMMENT 微信, province VARCHAR(30) DEFAULT NULL COMMENT 省份, city VARCHAR(30) DEFAULT NULL COMMENT 城市, industry VARCHAR(30) DEFAULT NULL COMMENT 客户行业, source TINYINT NOT NULL DEFAULT 0 COMMENT 线索来源: 0-手动录入, 1-批量导入, 2-官网留资, level TINYINT NOT NULL DEFAULT 0 COMMENT 意向等级: 0-未知,1-低意向,2-中意向,3-高意向, owner_id BIGINT UNSIGNED DEFAULT NULL COMMENT 归属销售ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态: 0-新客户,1-跟进中,2-已成交,3-已流失, last_follow_time DATETIME DEFAULT NULL COMMENT 最近跟进时间, next_follow_time DATETIME DEFAULT NULL COMMENT 下次跟进时间, deal_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT 成交金额, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_status (owner_id, status), KEY idx_last_follow (last_follow_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户表;跟进记录表则采用简单明细式每条记录通过customer_id与客户表关联CREATE TABLE customer_follow_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, customer_id BIGINT UNSIGNED NOT NULL COMMENT 客户ID, follow_type TINYINT NOT NULL COMMENT 跟进方式: 1-电话,2-微信,3-上门拜访,4-其他, content TEXT NOT NULL COMMENT 跟进内容, next_plan VARCHAR(255) DEFAULT NULL COMMENT 下一步计划, follow_user_id BIGINT UNSIGNED NOT NULL COMMENT 跟进人ID, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer_created (customer_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户跟进记录表;这个表结构设计的关键点在于每一次跟进都会同步更新客户表里的last_follow_time和next_follow_time。这样查列表页时一条 SQL 就能根据“下次跟进时间 今天”算出哪些客户到期该联系了不需要 join 跟进记录表做聚合运算大数量级下性能依然稳定。3.3 权限模型的轻量实现方案权限这块DeskcommCRM 没有引入 Spring Security 那套复杂的 RBAC 完整体系而是做了一套轻量的“角色 数据范围”双层控制。为什么这么取舍因为团队规模有限用户角色就三个超级管理员、销售主管、普通销售。用 Spring Security 做精细到按钮级别的权限控制对这种场景属于过度设计白白增加开发量。第一层是菜单和按钮权限。登录后根据用户角色查询对应的权限码集合前端根据权限码决定显示哪些菜单项和操作按钮。后端接口上也加了对应的注解校验——比如普通销售不能访问“全部客户查询”接口主管可以查看本组成员客户超级管理员可以操作所有数据。第二层是数据范围控制。这是权限设计里最容易出问题的点也是我花最多心思的地方。数据范围分为三种仅本人、本部门/本组、全部。查询客户列表时MyBatis-Plus 的拦截器会在 SQL 上自动追加归属条件相当于把所有数据查询统一纳入数据权限管辖不需要每个查询接口单独写逻辑。实际效果是普通销售登录后无论怎么改前端请求参数都只能查到owner_id 当前用户ID的客户。注意数据权限的过滤必须在后端强制前端隐藏只是体验优化。否则懂点接口知识的同事打开浏览器 F12 直接调接口就能绕过界面看到别人的数据这在真实业务里是严重事故。3.4 待办提醒企业微信通知怎么做待办提醒功能的核心是一个每天凌晨跑一次的定时任务。任务逻辑很简单查询所有next_follow_time在今天之前的客户按归属销售分组然后批量创建待办记录并通过企业微信应用消息发送提醒。这里用到了企业微信的“应用消息推送”接口每个销售在企业微信里绑定了成员 ID系统通过内部应用给他们发跟进提醒卡片。为了保证提醒不被频繁打扰规则引擎还做了一个“静默期”设计同一客户连续三天都有待办时第三天起停止发送站内通知只在看板的“逾期列表”中累积。这样既保证重要客户不被遗忘又避免每天轰炸式提醒导致销售对通知麻木。这套规则上线后团队逾期跟进率从 41% 降到了 12%效果非常明显。4. 实操开发从一张原型图到第一版上线4.1 环境准备与启动工程先把开发环境里需要的东西列个清单都是常见版本不引入学习成本JDK 17Spring Boot 3 要求的最低版本Maven 3.8MySQL 8.0Redis 6.xNode.js 18企业微信注册一个内部应用用于消息通知后端工程目录我习惯按模块分包不是按层分包这样后续模块扩展时不至于所有代码堆在一个包里com.deskcomm.crm/ ├── common/ # 通用返回封装、异常处理、工具类 ├── config/ # Spring 配置类、Redis配置、拦截器注册 ├── controller/ # HTTP 接口层 ├── service/ # 业务逻辑层 ├── mapper/ # MyBatis-Plus 数据访问层 ├── entity/ # 数据库实体映射 └── dto/ # 出入参对象前端用 Vite 的create-vue脚手架初始化项目安装 Element Plus、Pinia、Vue Router、Axios 这几个基础依赖跑起一个干净的模板工程大约需要 5 分钟没什么可讲的。4.2 后端快速实现认证与客户 CRUD登录认证我用的方案是 JWT Redis 双重校验。用户登录成功后后端签发一个签名后的 JWT 令牌返给前端同时在 Redis 存一份令牌与用户信息的映射设置 12 小时过期。后续请求统一带着Authorization: Bearer token拦截器先验签再查 Redis 确认令牌未失效然后从 Redis 取出用户信息放到请求上下文里。这一套组合拳的逻辑在于JWT 本身可以携带用户基本信息让分布式环境下的用户身份识别不依赖 Session 同步但 JWT 一旦签发无法主动让其失效所以引入 Redis 做“吊销黑名单”的能力。比如用户改密码或者被管理员踢下线时只需要删掉 Redis 里的映射令牌立刻失效弥补了纯 JWT 的短板。客户 CRUD 的实现主要工作量集中列表查询上。列表接口需要支持关键词搜索、状态筛选、来源筛选、归属人筛选、时间范围、分页排序还要在返回结果上附带每个客户的最后一条跟进记录和下次跟进日期。用 MyBatis-Plus 写出来大概长这样Override public PageResultCustomerVO pageCustomers(CustomerQueryDTO queryDTO, UserContext user) { PageCustomer page new Page(queryDTO.getPageNum(), queryDTO.getPageSize()); LambdaQueryWrapperCustomer wrapper Wrappers.lambdaQuery(); // 数据权限必须拼接当前用户可见范围 applyDataScope(wrapper, user, owner_id); // 关键词搜索客户名称或联系人姓名模糊匹配 if (StringUtils.hasText(queryDTO.getKeyword())) { wrapper.and(w - w .like(Customer::getName, queryDTO.getKeyword()) .or().like(Customer::getContactName, queryDTO.getKeyword())); } // 状态和来源筛选 if (queryDTO.getStatus() ! null) { wrapper.eq(Customer::getStatus, queryDTO.getStatus()); } if (queryDTO.getSource() ! null) { wrapper.eq(Customer::getSource, queryDTO.getSource()); } // 下次跟进时间条件 if (queryDTO.getOverdueOnly() ! null queryDTO.getOverdueOnly()) { wrapper.lt(Customer::getNextFollowTime, LocalDateTime.now()); } wrapper.orderByDesc(Customer::getUpdatedAt); // 执行分页查询 PageCustomer result customerMapper.selectPage(page, wrapper); // 额外查询每个客户的最后一条跟进记录组装返回 VO return buildPageResult(result); }这里有一个很细节但很影响体验的设计列表接口的响应时间必须控制在 200ms 以内。如果每个客户都要单独去查一次跟进记录批量渲染 500 条客户就会产生 500 条 SQL接口慢成蜗牛。我的做法是先按customer_id IN (当前页客户ID列表)查一次所有相关跟进记录再通过内存分组组装成 Map最后拼装到返回结果里。4.3 前端核心界面与联动逻辑前端最核心的是客户列表页。整个页面的布局拆成左上方的筛选区域、右侧的客户表格、下方的分页器顶部再放一个批量导入按钮。筛选条件的每一项都绑定到 Pinia 的全局筛选 store 中切换标签页或者跳转详情再返回时筛选条件依然保留。客户列表的“编辑”功能我做了行内编辑和弹窗编辑两种形态常用字段如意向等级、状态可以在行内直接下拉修改完整资料则点开弹窗编辑。行内编辑保存时要防止并发冲突——后端用updated_at做乐观锁字段否则两个人同时编辑同一条记录后提交的会覆盖先提交的内容。集成企业微信通知时前端需要展示“消息已发送/未发送”的状态。做法是在待办记录表加了一个notify_status字段定时任务发送成功后回写状态。前端通过 WebSocket 订阅当前用户的待办变更事件有新的跟进任务时页面右上角实时弹出轻提示点击可直接跳转到对应客户详情。4.4 部署一台 2C4G 服务器怎么扛住整个团队第一版上线我直接买了一台 2 核 4G 的云服务器装好 Docker 和 Docker Compose把 MySQL、Redis、后端服务、Nginx 依次编排起来跑。有人会质疑这个配置是不是太寒酸但实际业务场景是 50 人的团队、每个销售平均每天录入 30 条跟进记录系统峰值 TPS 也就是个位数2C4G 完全够用还省钱。Docker Compose 的编排大致是这样的version: 3.8 services: mysql: image: mysql:8.0 container_name: deskcomm-mysql restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: deskcomm_crm command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 redis: image: redis:7-alpine container_name: deskcomm-redis restart: always command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - ./redis-data:/data backend: build: ./backend container_name: deskcomm-backend restart: always depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_URL: jdbc:mysql://mysql:3306/deskcomm_crm DB_USERNAME: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} REDIS_HOST: redis REDIS_PASSWORD: ${REDIS_PASSWORD} ports: - 8080:8080 frontend: build: ./frontend container_name: deskcomm-nginx restart: always depends_on: - backend ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/cert:/etc/nginx/cert这里有个容易踩的坑后端服务如果跨主机访问数据库必须限制数据库账号只允许特定来源 IP 连接不要开放 3306 到公网。云服务器默认安全组里如果添加了 3306 公网入方向规则马上就会有扫描脚本尝试暴力破解我亲眼见过配置好不到半天就收到异常登录警告。日志处理上也提醒一句不要图省事把日志一股脑输出到容器 stdout。我配置了 ELK 太重了退而求其次用的是spring-boot-admin做应用监控日志通过 logback 写到宿主机挂载目录按天分割保留 30 天。排查线上问题时配合grep awk一把梭效率比登录容器翻日志高得多。5. 常见问题排查与踩坑实录5.1 并发更新客户归属导致的数据错乱上线两周后接到反馈有两个销售同时领取同一个客户最后系统里客户同时出现在两个人的客户列表里。一开始我以为是领单接口没加锁查了代码发现update customer set owner_id ? where id ? and owner_id is null的写法理论上是原子安全的但问题出在领取前后还有一个“查询客户当前状态”的动作——两个请求都查到了客户未被领取然后都执行了 updateMySQL 默认隔离级别下就可能出现双方都认为领取成功的假象。解决方案是在领取操作上引入分布式锁用 Redis 的SETNX命令按客户 ID 加锁锁的过期时间设置成 5 秒执行完立即释放。同时保留了数据库层的原子更新条件作为兜底双保险之后再也没有出现过重复领取的投诉。5.2 Redis 缓存与数据库不一致数据看板的统计数字偶尔出现偏差前端展示的成交金额和订单明细对不上。排查发现原因出在我对 Redis 做了 10 分钟缓存而销售在修改客户状态时直接更新的是 MySQL缓存没有同步失效导致统计接口一直在读旧数据。修复方案很简单所有更新客户表的后端接口在写库成功后统一调一个deleteCache(customerId)的方法把涉及该客户的统计缓存全部清掉。缓存永远只是“可容忍短暂延迟”的加速层绝对不能成为数据准确性的瓶颈。因此现在的设计原则是“先写库后删缓存”删缓存失败时再手动补偿或等待自然过期。5.3 浏览器端 Excel 导出的编码坑导出客户列表为 Excel 是销售团队使用频率很高的功能。最初我用后端 POI 生成 xlsx 文件返回给前端测试没问题。后来为了减少服务器负载改为前端侧直接从表格数据生成 CSV 文件下载结果发现用 WPS 打开中文全变乱码用 Excel 打开部分场景也会显示异常。原因是 CSV 的本质是一个纯文本文件浏览器用默认 UTF-8 编码生成而 Windows 上很多软件默认按 GBK 解码。解法有两种要么在生成的 CSV 文件头部加上 UTF-8 BOM\uFEFF要么后端转码成 GBK 编码后再输出。我用了第一种方案加 BOM 后 WPS 和 Excel 都能正常识别。5.4 权限遗漏导致普通员工看到全量客户这是我在开发阶段犯的最严重的一个权限问题。数据看板里有一个“客户区域分布”的图表最初给非管理角色也开放了。按理说这个图表也应该走数据权限过滤但我偷懒直接按全部客户做了聚合统计结果就是普通销售通过看板里的图表下钻变相看到了全公司各个行业的客户数量分布相当于数据泄露了。这个问题的本质是数据权限不能只做“列表查询”层面的拦截还要覆盖所有涉及客户数据的聚合统计、报表导出等接口。我重新梳理了一遍所有接口把数据权限的拼接逻辑收敛到一个统一的 AOP 切面凡是标注了DataPermission注解的查询都会强制追加当前用户的数据范围条件。从那以后我再也没有因为某个接口“忘记加权限”而提心吊胆。6. 几个想跟你分享的落地心得项目从启动到第一版上线大约用了三周前两周半都在磨客户表的数据模型和权限方案真正写 CRUD 只用了两三天。很多人做内部系统容易陷入“先赶紧把增删改查跑通再说”的惯性里但 CRM 这种以数据流转为核心的系统恰恰是建模和边界定义决定成败。如果一开始没有想清楚客户归属怎么流转、跟进记录如何关联、数据权限边界在哪里后面改起来是牵一发而动全身。还有一个值得反思的体会是关于“功能做减法”。我最初还规划了进销存、发票管理、销售提成计算这些模块后来跟业务同事聊完后毅然砍掉只保留他们最痛、最高频的客户跟线索管理。上线后团队用得越顺手大家才越愿意把数据沉淀到系统里系统里的数据越完整管理看板的价值才越能显现。这是一个正向飞轮而飞轮启动的关键恰恰是“别让业务感觉系统在给他们增加工作量”。如果你准备在自己的团队里启动类似的客户管理系统我建议按这个节奏推进花两天把现有团队的客户管理流程画出来标出所有数据字段和角色权限再花两到三天完成核心表结构设计和一线销售确认关键表单页面剩下时间集中精力实现“线索→客户→跟进→转成交”这条闭环主流程。先闭环再优化这套路径在真实业务中的成功率远高于一开始就想着做个能干所有事的大而全系统。最后分享一个小技巧给系统里的每个操作都记录操作日志包括谁在什么时间领取了哪个客户、修改了哪个字段、导出过什么数据。当时是为了排查问题方便加的后来发现它在团队内部纠纷判定和销售行为分析中意外地好使。这算是这次实践里投入产出比最高的一笔“附加投入”。