资讯详情

基于SpringBoot+Vue的CRM系统实战:从数据库设计到部署全流程

📅 2026/10/12 20:50:40 | 华诺云谱 👁 阅读
基于SpringBoot+Vue的CRM系统实战:从数据库设计到部署全流程
做客户关系管理系统的朋友多少都经历过业务数据散落在一堆Excel表格里的日子。销售要查客户信息得翻好几个文件老板要统计业绩得等月底手工汇总跨部门协作全靠微信群接龙。今天分享的这个项目就是针对这类痛点做的一套完整方案基于SpringBootVue的客户关系管理系统CRM后端用Java生态里最常见的SpringBoot和MyBatis前端用Vue.js数据放MySQL从客户管理、跟进记录到商机漏斗和合同回款一套流程全部打通。无论你是刚学完Java想做点拿得出手的实战项目还是小团队想快速把客户资源管起来这篇文章都值得你花十分钟读完我会把项目的设计思路、核心代码实现、数据库表结构和容易踩的坑全部拆开讲清楚。1. 项目整体设计与技术选型思路1.1 为什么是SpringBootVueMySQL这套组合先说技术栈。市面上做业务管理系统的方案很多有Python的Django、PHP的Laravel甚至Node.js全栈但如果说哪个组合在国内中小企业里覆盖面最广SpringBootVue绝对排得上号。原因很实际招人好招生态成熟出了问题用搜索引擎一搜就是一堆解决方案。SpringBoot解决的痛点非常直接它没有繁琐的XML配置没有复杂的部署流程一个可执行的Jar包就能把后端服务跑起来。对于CRM这种以CRUD为主、报表统计为辅的业务系统SpringBoot的自动配置机制能省掉大量体力活。MyBatis则是跟国内开发者的习惯贴合得比较好SQL自己写执行的每一步都看得懂遇到慢查询可以精准优化不像某些ORM框架需要理解复杂的对象关系映射规则。前端选Vue.js核心原因是响应式数据绑定很舒服。CRM系统里有大量的表格、表单、筛选条件和联动逻辑传统jQuery那套手动操作DOM的方式写多了容易乱Vue的声明式渲染让页面状态和UI自动同步开发效率高了不少。配合Element UI组件库像客户列表、分页表格、多选下拉、日期范围这些常用组件开箱即用不用从零造轮子。数据库选MySQL就一个理由数据量和并发量在这个项目场景下MySQL完全够用而且部署、备份、监控的工具链非常成熟。真要到了单表千万级、并发上万的阶段那得考虑MySQL调优、读写分离甚至分布式方案但那是后话做CRM系统先把基本功练好。1.2 系统核心业务边界怎么划分CRM系统听起来范围很大但从企业实际使用角度来说痛点往往集中在六个业务模块用户权限、客户管理、联系人管理、跟进记录、商机管理、合同与回款。我在设计方案时把这六个模块作为核心每个模块都是独立的业务闭环又通过外键关联形成完整链路。客户管理是地基。系统里必须搞清楚的场景包括客户的基础资料怎么维护、客户来源是哪些渠道、公海池和私海池怎么划分、销售离职后客户怎么回收分配。联系人管理则是把客户的对接人和客户资料分开存因为一个客户可能对应多个联系人但联系人始终归属于某一家企业。跟进记录可以说是CRM的灵魂销售拜访客户的时间、内容和下一步计划全都要留痕做得好这层数据就是老板做管理决策的依据。商机管理是另外一条核心链。一个客户可能对应多个商机每个商机处于初步接触、需求确认、方案报价、商务谈判、赢单、输单等不同阶段。统计各个阶段的金额和数量就能算出销售漏斗转化率。合同与回款模块则是把商机固化下来生成合同编号、记录回款计划让财务和销售都能看到应收账款的进度。这套东西做完前台业务线和后台数据统计线就都打通了。1.3 项目目录结构与代码组织方式代码组织得好不好直接影响后续维护心态。我采用的是经典的分层架构controller层负责接收请求和参数校验service层处理业务逻辑和事务mapper层跟数据库交互entity和dto区分数据库实体和前端传输对象。前端工程则按页面模块划分views下面每个子目录对应一个业务页面api目录统一管理请求接口方便集中改请求地址和拦截器。后端源码的核心目录大概长这样src/main/java/com/example/crm ├── controller // 接口入口只做请求分发和参数校验 ├── service // 业务逻辑事务控制都在这一层 ├── mapper // MyBatis的Mapper接口对应XML文件 ├── entity // 数据库表实体 ├── dto // 前端请求/响应对象避免直接暴露实体 ├── config // 跨域、拦截器、静态资源配置 ├── common // 统一返回结果、异常处理 └── utils // 工具类比如JWT工具、Excel导入导出前端目录大致是src ├── api // 封装axios请求 ├── router // 前端路由和导航守卫 ├── store // Vuex状态管理 ├── views │ ├── customer // 客户管理页面 │ ├── follow // 跟进记录页面 │ ├── business // 商机管理页面 │ ├── contract // 合同页面 │ ├── dashboard // 数据看板 │ └── system // 用户和角色管理 └── utils // 请求工具、日期格式化工具这种组织方式的好处是每一个业务域内聚改一个模块的前后端代码基本不用翻太远。团队协作的时候多人并行开发各自负责不同的模块目录冲突也能降到最低。2. 数据库设计与核心表结构拆解2.1 六张核心业务表的功能定义数据库设计直接决定系统的上限。如果表结构乱七八糟后面统计报表会写得欲哭无泪。我最终定的核心表包括sys_user用户表、sys_role角色表、customer客户表、contact联系人表、follow_record跟进记录表、business_opportunity商机表、contract合同表。用户表关键是区分角色和部门我用role_id和dept_id关联这样在权限控制时可以做到“角色部门”两级控制。比如普通销售只能看到自己和本部门的客户销售总监能看到整个部门的数据管理员则拥有全部权限。客户表是整个CRM的主表字段包括客户名称、客户级别、客户状态、所属行业、来源渠道、负责人、地址和备注。有一点特别注意客户表不直接存联系人联系人单独放一张表因为一个客户往往对接多个人如果塞在客户表里要么冗余字段要么只能记一个联系人后续做对接会比较吃力。商机表和合同表我单独拆出来理由是这个字段的结构跟客户差异太大。商机表需要记录预计成交金额、预计成交日期、当前阶段和赢单率合同表需要记录合同金额、已回款金额、合同状态和签单日期。表设计的原则是“高频查询的字段建立索引非高频的大文本字段独立存放”。2.2 表关系设计外键关联与数据一致性客户、联系人、跟进记录、商机、合同这五张表之间的关系可以这样理解一条客户数据可以对应多条联系人记录、多条跟进记录、多条商机和多条合同。一条商机记录在赢单之后可以生成一条合同记录关联字段是business_id和contract_id。所有业务表都要有customer_id和owner_idcustomer_id用于页面钻取客户详情owner_id用于权限过滤和数据归集。数据一致性我用了一个很容易忽略的技巧在数据库层不做级联删除全部在Service层控制。比如删除客户之前先检查该客户下有没有未结的合同、有没有正在进行的商机有的话就提示“当前客户存在关联数据不可删除”。这种业务规则放在Service层比数据库外键更可控也更容易写清楚错误信息。2.3 索引、更新时间和权限字段的设计细节索引方面核心是两套组合索引。第一套针对“按负责人查客户列表”这个高频操作建了owner_id status的组合索引第二套针对“按客户查跟进记录”的查询建了customer_id follow_time组合索引。商机表的阶段统计也很高频建了stage expected_amount的组合索引。实测下来列表页查询从几百毫秒降到几十毫秒。客户表字段中有个next_follow_date下次跟进时间这个字段虽然不起眼但实用价值很高。销售首页要展示“今日待跟进客户”直接查这个字段即可不需要通过跟进记录表做子查询。每次新增跟进记录时同步更新客户的next_follow_date让一眼看到当前最应该联系谁。另外每个业务表都加了create_time、update_time、deleted字段deleted是逻辑删除标志防止用户误删后没法恢复。3. 后端核心模块实现与细节剖析3.1 项目搭建与基础配置后端项目我是用Spring Initializr快速生成的核心依赖包括spring-boot-starter-web、spring-boot-starter-validation、mybatis-spring-boot-starter、mysql-connector-java、lombok和jjwt。下面这份application.yml的配置是我反复调过的直接可以参考server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/crm_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.crm.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里特别提一下时区配置。MySQL连接串里必须加serverTimezoneAsia/Shanghai否则本地时区跟数据库时区不一致时间字段查出来会差8个小时。之前我就是漏了这个配置结果页面上的时间全部错位查了半天才发现是连接参数的问题。MyBatis的map-underscore-to-camel-case自动把create_time映射成createTime省掉大量手写resultMap的工作。登录模块我用的是JWT的方案。流程是用户登录时校验用户名和密码密码用BCrypt加密存储Spring Security的加密工具生成随机盐暴力破解成本高校验通过后生成一个有效期两小时的Token返回给前端前端存到localStorage里之后每次请求都带上Authorization请求头。后端的拦截器会从请求头取出Token解析出用户ID和角色放入ThreadLocal上下文供Service层做数据权限判断。3.2 客户管理模块事务控制和动态SQL客户管理模块是整个系统最复杂的CRUD因为它牵扯公海池、分配、回收和导入导出。新增客户是基础操作但有一个隐藏逻辑普通销售新增客户后系统会先判断这个客户名是否已存在统一社会信用代码一致的记录如果存在就不允许重复添加避免重复数据。另外新增客户时要给客户设置初始状态比如“潜在客户”负责人默认就是当前登录用户。客户分配和回收要写成一个事务方法。分配客户的实现逻辑包括三个步骤把客户负责人改成目标用户、记录一条操作日志、给目标用户生成一条待办提醒。回收机制则是系统定时任务每天凌晨扫描如果客户的next_follow_date超过三天且无新跟进记录就把负责人置为空客户退回公海池。这个功能听起来简单但写起来要注意用Transactional把三个数据库操作包在一起否则中途报错会导致数据不一致。代码上我用了MyBatis的动态SQL处理筛选条件。客户列表往往有名称模糊搜索、来源下拉筛选、客户级别下拉筛选、负责人下拉筛选如果这些条件全部为空就查全部如果非空就拼接对应SQL。XML里用where标签加if判断能避免where 11这种不太好维护的写法。核心片段可以参考select idselectCustomerPage resultTypecom.example.crm.entity.Customer select * from customer where if testcustomerName ! null and customerName ! and customer_name like concat(%, #{customerName}, %) /if if testsource ! null and source ! and source #{source} /if if testlevel ! null and level ! and level #{level} /if if testownerId ! null and owner_id #{ownerId} /if and deleted 0 /where order by update_time desc /select前端传筛选参数时只传非空的字段后端用if判断后拼接条件。这个方案灵活SQL性能也能接受。3.3 数据权限与服务层设计数据权限是CRM系统绕不开的话题不能一个查询把所有客户数据都拉出来那谁也管不了。我设计了一个基于用户角色的权限拦截器在Service层统一注入数据权限条件。具体说来systemUserService.getAllChildrenDeptIds(userId)返回当前用户所在部门的所有子部门ID集合然后不同的角色走不同的查询逻辑超级管理员不拼接任何过滤条件查全量数据。销售总监查询本部门及其子部门的所有客户数据。普通销售只允许查询自己是负责人的客户数据。实现方式是在Service层根据当前角色把isAdmin、deptIds、userId这些值传给Mapper的查询条件。这样前端根本不知道数据权限逻辑因为即使有人恶意调接口后端也不会返回无权数据。3.4 商机漏斗与数据看板的统计类查询数据看板是CRM里给管理层看的东西通常包括销售漏斗、客户状态分布、月度签单趋势和销售排行榜。这些统计SQL一开始我用Java代码在内存里做了筛选结果数据一多性能惨不忍睹后来重写成SQL聚合效果立竿见影。销售漏斗的核心SQL是按商机阶段分组统计金额和数量select stage, count(*) as opportunityCount, sum(expected_amount) as totalAmount from business_opportunity where deleted 0 and owner_id #{userId} group by stage order by field(stage, 初步接触, 需求确认, 方案报价, 商务谈判, 赢单)用field函数强制排序让漏斗阶段按照业务顺序输出。月度签单趋势则是按月份分组聚合合同金额SQL配合date_format函数处理。这些查询写好之后前端用ECharts渲染成柱状图和漏斗图整个页面一下子就有了看头。4. 前端Vue页面设计与交互实现4.1 前端工程结构与核心依赖前端工程用Vue CLI创建选用Vue 2版本加Element UI组件库。为什么不用Vue 3说实话Vue 3的功能很强大但考虑到市面上大量的成型项目、Element UI插件的兼容性以及招聘市场的熟悉度Vue 2 Element UI对业务系统来说依然是低风险的选择。核心依赖包括{ vue: ^2.6.14, vue-router: ^3.5.1, vuex: ^3.6.2, axios: ^0.21.1, element-ui: ^2.15.6, echarts: ^5.1.2 }登录后的布局框架用的是Element UI的el-container组件左侧是折叠菜单顶部是用户信息和退出按钮右侧是主内容区域。路由配置挂载了全局前置守卫——如果没有Token就跳到登录页登录状态下访问登录页则跳回首页。拦截器则是axios的请求拦截器统一加Token响应拦截器统一处理401过期跳转。4.2 客户列表页与跟进记录的表格实现客户列表页是访问量最大的页面交互设计上我踩过几个坑也优化过几轮。表格用el-table带多选框表头的筛选区域是单独的el-form组件操作区包括新增、编辑、批量分配、导入、导出的按钮。分页组件用el-pagination注意它的current-page和page-size是数值类型不是字符串类型传参数给后端前要转成数字。新增编辑客户我用了Dialog弹窗方案。弹窗里是一个表单表单字段跟后端DTO字段一一对应提交前做前置校验客户名称必填、手机号格式校验。常规做法是this.$refs.form.validate()走Element的表单验证如果有错误就直接返回不调接口。跟进记录页的核心是一个时间线Timeline我用的el-timeline组件把该客户的所有跟进动态按时间倒序排列。每次新增跟进记录调用后端接口后刷新时间线同时更新客户列表里的“最近跟进时间”和“下次跟进时间”。这个联动逻辑值得注意前端不能只傻傻地重新拉区客户列表而是要在新增成功的回调里单独调一次客户详情接口保证页面数据是最新状态。4.3 ECharts数据看板与前端性能优化数据看板页面用了ECharts的四个图表销售漏斗图、客户来源饼图、月度签单收入柱状图、销量排行横向条形图。图表渲染要注意一个坑容器初始化时如果页面或标签页没有显示宽高是0ECharts会渲染空白。解决方案是在mounted之后用nextTick重置图表尺寸或者用echarts.init的时候先判断容器宽度。前端性能优化方面有几点实际操作。表格数据量控制在100条以内筛选条件不同时用后端分页不要一次拉取全部数据。图片资源直接交给对象存储项目的静态资源加版本号实现缓存更新。接口请求耗时超过300毫秒的在页面顶部加Loading状态既提升感知速度也避免用户重复点击提交。5. 实际开发中遇到的坑与排查实录5.1 MyBatis动态SQL与JSON序列化的隐蔽问题第一个坑来自MyBatis的if判断。客户名称的模糊查询参数是customerName如果前端传空字符串if testcustomerName ! null and customerName ! 确实会跳过拼接。但是当参数是0时条件就失效了。我们有个客户级别的字段如果筛选条件是level0if testlevel ! null没问题if testlevel ! 也没问题但如果你年轻时写过if testlevel ! null and level ! 一旦前端传过来的是数字0这个判断会判定为不为空字符串而拼接但0本身会被当作有效条件。其实真正隐蔽的问题是MyBatis的OGNL表达式在判断字符串和数字时的类型转换数字0和空字符串做不等比较时会有诡异行为。我的建议是字符串类型做不为空判断数字类型只做不为null判断两者分开写。第二个坑是Long类型ID在前端精度丢失。数据库表的主键我用了自增Long类型但后端通过JSON序列化传给前端后JavaScript的Number类型对超过15位的数字会丢失精度。实际表现是删除或编辑一条记录时ID变成错误的数字接口返回404或者更新了错误数据。解决方案是对所有的主键字段在JSON序列化时把Long转换为StringJackson配置加一句ToStringSerializer即可。5.2 跨域配置和前端代理的环节问题本地开发时前端运行在localhost:3000后端运行在localhost:8080浏览器默认会拦截跨域请求。我用的方案是开发环境配Vue CLI的proxy代理把/api前缀的请求代理到后端生产环境上Nginx做反向代理。有同事一开始不愿意配置proxy直接用JSONP绕过跨域但在POST请求和自定义Header面前根本不管用。正确的解法是后端配置一个WebMvcConfigurer重写addCorsMappings方法允许本地开发域名访问这个配置上生产环境反而要移除或加白名单。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }5.3 部署环境和数据库初始化的问题项目说要在Linux服务器上部署最容易翻车的是数据库编码和时区设置。如果MySQL没有设置utf8mb4字符集插入生僻字或表情符号会报错。我的做法是在建库时就指定字符集CREATE DATABASE crm_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。时区则通过application.yml里的连接参数统一设置。Jar包部署还有一个坑SpringBoot的内嵌Tomcat默认上传文件大小限制是1MB客户导入Excel文件经常超过这个大小导致报错。需要在配置里加spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB另外如果服务器上的内存比较小启动Jar时建议指定JVM参数nohup java -Xms256m -Xmx512m -jar crm-system.jar crm.log 21 不加参数也能跑但GC频繁会导致接口响应突然变慢加了之后整个系统稳定很多。5.4 常见问题速查表现象原因解决方案前端时间比数据库少8小时连接串未指定serverTimezone加serverTimezoneAsia/Shanghai删除客户报“有关联数据”Service层做了关联检查先删除或转移关联的合同、商机数据表格显示ID是14后面变5Long转String未处理Jackson Long序列化为字符串批量导入Excel超时一次导入行数过大每次导入限制2000行异步处理接口报401但Token有效拦截器Token解析失败检查请求头名称和Token前缀是否一致数据看板图表空白容器尺寸未初始化mounted中用nextTick重置图表6. 本地运行与打包部署全流程6.1 本地启动的完整步骤项目到手之后本地跑起来需要依次做六件事。第一步准备MySQL 5.7或8.0新建数据库并执行项目里的crm_db.sql脚本初始化表结构和测试数据。第二步修改后端application.yml的数据库账号密码。第三步在代码仓库根目录执行mvn spring-boot:run或者先mvn package打包再执行Jar。第四步在前端目录执行npm install安装依赖。第五步启动前端项目npm run serve浏览器打开localhost:3000。第六步用项目预置的账号登录比如管理员账号admin、测试销售账号sales1。这个流程里最容易卡住的是npm install。如果你在中国大陆网络环境建议先配置npm国内镜像源不然装依赖可能要等很久。执行一次npm config set registry https://registry.npmmirror.com再执行安装速度提了一倍不止。6.2 Linux服务器上的打包部署记录生产环境部署我推荐使用Nginx做静态资源服务配合后端Jar包。前端代码先执行npm run build打包出dist目录把dist里的内容上传到服务器。Nginx配置的核心是server { listen 80; server_name your-domain.com; # 前端静态资源 root /opt/crm/frontend; index index.html; # 解决Vue Router history模式的刷新404问题 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有几个要点。第一点是try_files $uri $uri/ /index.html;这行必须配置不然用户刷新页面时会404。第二点是后端接口的前缀要跟Nginx的location匹配我后端统一是/api开头。前端axios请求的baseURL就写成/api这样部署后浏览器请求走向是“前端域名/api/xxx”到NginxNginx再代理给后端服务的8080端口。第三点是Nginx配置了proxy_set_header之后后端通过request.getRemoteAddr()拿到的会是真实客户端IP记录登录日志和审计时比较有用。后端Jar包部署我用了一个启动脚本日常重启会方便很多。脚本里先检查进程是否存在存在就kill然后再启动新的Jar包。日志文件按天轮转避免单个文件无限增大。6.3 部署后的烟囱测试部署完成之后不要急着收工至少要做一次烟囱测试。用管理员账号登录在客户管理模块新增一条数据确认能保存。在跟进记录里给这个客户加一条沟通记录然后去客户列表看“最近跟进时间”有没有刷新。在商机模块新增一条商机把它从“初步接触”改到“方案报价”再去数据看板看漏斗图的数据有没有变化。最后用销售账号登录确认他看不到别的人的客户数据权限隔离生效。这五步走完系统的核心链路就都验证过了。7. 这个项目后续还能怎么扩展系统上线使用之后其实只是开始。客户数据会越积越多销售的使用习惯也会暴露更多优化空间。我在自己维护的过程中比较推荐三个扩展方向。第一个方向是自动化和提醒机制。目前跟进提醒是靠定时任务扫描后续可以引入消息中间件把“今日待跟进客户”的提醒推到销售的小程序或者企业微信上。客户生日、合同到期、回款逾期这些场景都可以做成定时通知减少销售的人工盯数据成本。第二个方向是数据分析深度。当前的看板只统计到结果数据要想真正提升销售效率可以按客户来源分析转化率按商机阶段分析平均停留天数按销售分析垂线漏斗。这些功能本质上是多写几条统计SQL然后在前端多画几个图表但带来的管理价值非常大。第三个方向是权限模型的精细化。如果公司组织结构很复杂有区域、小组多级权限目前的“部门角色”模型可能不够用可以引入数据范围字段支持自定义客户共享和交接审批。随着团队规模扩大权限模型不做精细化后面会不断产生数据越权的问题。这些扩展方向每一步都是独立的不用动系统根基。所以架构设计上保持模块清晰比堆功能更重要。就我自己这段时间做这个系统的感受而言客户关系管理系统看着是个常见的业务系统但真正从零开始把它做得顺手需要操心的事儿远比想象中多。数据库的表关系设计要经得起时间检验权限控制要够细且不能影响性能前端交互要照顾销售的使用习惯部署运维要让人省心。这些模块叠加起来项目就从一个简单的Web应用变成一个真正能帮业务团队提升效率的工具。如果你现在也在做类似的系统希望这篇经验总结能让你少走一些弯路至少把那些我在代码里踩过的坑直接绕过去。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑