SpringBoot+Vue.js客户关系管理系统实现全解析:从设计到部署

发布时间:2026/10/12 20:50:33
SpringBoot+Vue.js客户关系管理系统实现全解析:从设计到部署
做企业管理系统的这些年我见过太多客户关系管理CRM项目用Excel凑合、用纸质台账硬扛最后客户数据散落在不同销售手里老板要个汇总报表能等三天。今天要聊的这个基于SpringBootVue.js的客户关系管理系统就是一个典型的标准企业级开发练手落地项目技术栈是JavaMySQLMyBatis前后端分离源码完整可跑。我按自己复现这个项目的过程把设计思路、核心代码逻辑、踩坑点一次说透准备做毕业设计、接外包单子、或者想搭一套内部CRM做二次开发的朋友这篇都能直接当参考。这个系统的价值不在于功能多花哨而在于它把客户信息管理、跟进记录、合同审批、数据统计这些CRM最核心的业务闭环做完整了。前后端分离的架构、RESTful接口规范、JWT鉴权、RBAC权限模型这些关键词背后的工程经验才是源码里真正值钱的东西。下面我从技术选型开始一层层拆。1. 项目整体设计与技术栈选型思路1.1 为什么是 SpringBoot Vue 这套组合先回答一个很多人纠结的问题这套技术栈是不是过时了我的看法是对绝大多数中小型管理系统来说SpringBoot Vue的组合依然是性价比最高的方案没有之一。从后端看SpringBoot把Spring繁琐的XML配置全部干掉内嵌Tomcat打成一个jar包就能跑部署成本极低。配合MyBatis做持久层SQL完全由自己掌控业务复杂的查询条件拼接特别灵活——CRM系统里按客户名称模糊查询按等级筛选按最近跟进时间排序这种组合条件查询是家常便饭用MyBatis写动态SQL比JPA舒服得多至少排查问题的时候你能直接看到SQL语句。从前端看Vue2的生态成熟稳定Element UI组件库把表格、表单、弹窗、树形控件全给好了做后台管理系统基本就是拼积木。Vue的响应式数据绑定让客户列表的筛选、排序、分页交互非常顺滑单页面应用SPA的体验比传统的JSP页面高出两个档次。1.2 项目模块划分背后的业务逻辑完整的CRM系统至少要有这几个模块系统管理用户、角色、权限、客户管理客户信息CRUD、跟进管理跟进记录、下次跟进提醒、合同管理合同录入、审批流转、数据统计客户来源分析、销售漏斗。这套源码在模块划分上走的是高内聚低耦合的路子。每个模块独立成包比如controller、service、mapper、entity、vo层与层之间通过接口交互。controller层只做参数接收和结果封装service层写业务逻辑mapper层怼SQLentity对应数据库表结构vo专门用来向前端返回展示数据。我在实际复现时发现一个值得点赞的细节返回给前端的对象用的是VO而不是直接扔Entity比如客户实体里有创建人ID但前端需要显示创建人姓名源码里通过VO字段冗余的方式解决而不是让前端拿着ID再调一次接口。这种细节很多新手容易忽略但恰恰是前后端分离项目里必须养成的好习惯。1.3 项目结构一览与运行环境准备拿到源码后第一步不是急着跑起来而是先看清楚目录结构。标准的Maven工程src/main/java下按业务分包resources里放着application.yml、mapper的XML文件、静态资源。前端是独立的Vue工程src下分api、assets、components、router、store、views。运行前需要准备的环境我列一下都是基础版本踩过的版本坑我在后面章节专门说环境项推荐版本说明JDK1.8当前绝大多数企业项目的稳定选择MySQL5.7 或 8.05.7兼容性最好8.0注意驱动和时区配置Node.js14.x 或 16.x对应Vue2和Element UI的构建要求Maven3.6配置阿里云镜像加快依赖下载IDEIDEA 2021装好Lombok插件否则编译报错注意如果本机同时装了多个JDK版本务必在IDEA的Project Structure里把Project SDK和Modules SDK都指到JDK1.8否则可能出现编译通过但运行时报UnsupportedClassVersionError的尴尬情况。2. 核心功能模块拆解与数据库设计2.1 功能权限模型是怎么设计的CRM系统的权限设计直接决定这个项目能不能在真实业务场景里用起来。这套源码采用的是RBAC基于角色的访问控制模型核心就五张表用户表、角色表、菜单/权限表、用户角色关联表、角色菜单关联表。为什么用RBAC而不是简单地在用户表里放一个角色字段因为真实企业里角色是会变的今天你是销售明天可能升职成销售主管权限边界完全不同。如果把角色和权限的关系硬编码在用户表里业务调整时改代码的成本太高。RBAC把用户-角色-权限三者拆开加一个角色、给角色勾几个菜单权限都是在页面上点几下的事不需要动一行代码。我用这套模型的通用做法是登录成功后查询当前用户所拥有的菜单权限集合前端根据权限码来控制按钮和菜单的显隐后端在接口层面再用拦截器校验。前端控制的是用户体验后端控制的是安全底线两边都得做缺一不可。这套源码在拦截器里校验了每个请求的权限标识比如添加客户对应customer:add没有这个权限码的用户即使破解接口也操作不了。2.2 核心数据表结构与字段设计要点数据库是整个CRM系统的地基表设计好了后面写代码顺风顺水。下面这几张核心表的设计思路值得重点看客户表customer是整个系统的主动脉字段包括客户名称、联系人、联系电话、客户等级重点/普通/潜在、客户来源转介绍/网络推广/线下活动、所属销售ID、创建时间、更新时间。这里有个设计细节客户等级和来源这类固定选项值源码里用了int类型存储配合数据字典表翻译成对应的中文显示。为什么要这么干因为重点客户和普通客户这些词在业务调整后可能改名用数字存前端展示的时候查字典表替换文案就行不需要动数据库里几百条历史数据。跟进记录表follow_record记录销售对客户的每一次跟进行为字段有客户ID、跟进方式电话/拜访/微信、跟进内容、下次跟进时间、跟进人ID。这张表是CRM系统里数据量增长最快的设计时一定要给customerId和followTime建联合索引否则数据量上来后查询会明显变慢。合同表contract包含合同编号、客户ID、合同金额、签约日期、生效状态。合同编号建议用业务前缀年月日随机数的格式生成避免直接用数据库自增ID当合同编号——客户看到编号是3、4、5会直觉觉得公司业务少这点在真实项目里很重要。数据字典表dict是容易被忽视但极其有用的表它存储所有下拉选项的键值对类型分为customer_level、customer_source、follow_type等。前端做下拉框时统一从后端接口读取字典数据而不是硬编码在页面上。这样做的好处是业务人员改选项值时不需要发版直接在系统管理里维护字典即可。2.3 逻辑删除与时间字段的约定这套源码在所有业务表里都加了del_flag字段0未删除1已删除统一采用逻辑删除而非物理删除。这个设计背后的考虑是客户数据是企业的核心资产误删一条客户记录可能导致整个销售线索断掉物理删除无法恢复风险太大。逻辑删除虽然多一条update语句但换来的是随时可恢复的安全感。时间字段统一用datetime类型插入和更新时间由MyBatis的insert和update语句里直接写NOW()或者通过Java代码在service层统一设置。这里我建议所有表的创建时间和更新时间放在一个BaseEntity里让实体类继承代码能少写很多。这套源码没有用MyBatis-Plus的自动填充注解而是手动在service层赋值虽然啰嗦一点但对新手理解什么是审计字段很有帮助。3. 后端核心实现从登录鉴权到业务闭环3.1 JWT登录鉴权与拦截器的完整链路登录鉴权是每个管理系统的第一道门。这套源码用的是JWTJSON Web Token方案整个流程是这样的用户提交用户名密码后端校验通过后用SecretKey生成一个带过期时间的token返回给前端前端把token存在localStorage里每次请求在axios拦截器中把token放到请求头Authorization字段后端自定义一个拦截器在请求进入controller之前校验token的合法性和有效期。这里有个重要的工程细节拦截器放行了登录接口和静态资源路径其余所有接口都要经过token校验。源码里处理token解析后的用户信息是这样做的从token里取出用户ID通过ThreadLocal在当前线程内暂存后续controller和service层需要知道当前操作人是谁时直接从ThreadLocal拿不用每次都在请求参数里传用户ID。我复现的时候觉得这个设计很巧妙——它避免了在业务代码里反复查询当前登录用户也方便权限操作时记录操作人。JWT本身有三个部分Header、Payload、Signature。Header声明加密算法Payload放用户ID、用户名、过期时间等非敏感信息Signature用密钥对前两部分签名。贾穿其中的要点是密钥不能泄露源码里密钥写在了配置文件里真实项目应该放在环境变量或配置中心。token过期时间一般设置为2小时到24小时之间太短用户频繁重新登录体验差太长安全性下降我一般设置12小时配合前端在token过期时自动跳转登录页。3.2 MyBatis的使用技巧与SQL优化细节MyBatis在这套CRM系统里的使用有几个值得学习的点。第一所有的mapper接口和XML文件通过namespace一一对应XML文件放在resources/mapper目录下在application.yml里配置mapper-locations扫描路径。第二动态SQL用了if标签做条件拼接比如客户列表查询有客户名称就按名称模糊查有客户等级就按等级精确查两个条件同时存在就用AND连接这是MyBatis最核心的用法。看一段典型的动态查询XMLselect idselectCustomerList resultTypecom.example.crm.vo.CustomerVO SELECT c.id, c.customer_name, c.level, c.source, u.nick_name AS ownerName FROM customer c LEFT JOIN sys_user u ON c.owner_id u.id where if testcustomerName ! null and customerName ! AND c.customer_name LIKE CONCAT(%, #{customerName}, %) /if if testlevel ! null AND c.level #{level} /if if testownerId ! null AND c.owner_id #{ownerId} /if /where ORDER BY c.update_time DESC /selectwhere标签会自动去掉多余的AND/OR前缀这个细节能避免很多多一个条件就SQL报错的问题。另外注意客户名称的模糊查询用CONCAT(%, #{customerName}, %)而不是直接在Java代码里拼接好字符串传进去。前者是参数化查询能有效防止SQL注入后者虽然也能查出结果但等于把SQL注入的大门敞开了。这套源码在防SQL注入方面做得很规矩所有用户输入都走#{}占位符。另一个实用点是把查询结果映射成VO时用LEFT JOIN关联用户表查出销售姓名而不是查出ownerId后在Java代码里循环再查一次用户表。前者一条SQL搞定后者会产生经典的N1查询问题数据量大的时候性能直接崩掉。我在Review这套源码时特意确认过客户列表、跟进记录列表、合同列表全部用的是JOIN查询这一点务必保持。3.3 分页查询与导出功能的实现方案凡是管理列表必然有分页。这套源码的分页用的是PageHelper插件配置只需要在pom里引入依赖然后在application.yml里加一行helper-dialect: mysql以及合理设置reasonable: true——这个参数的作用是当页码超出范围时自动回退到第一页或最后一页而不是报错体验好很多。使用PageHelper的规范是PageHelper.startPage(pageNum, pageSize)之后的第一条查询语句会被自动分页分页信息封装在PageInfo里包含总记录数、总页数、当前页、是否有下一页等字段。这里有个隐蔽的坑startPage和查询之间不能插入其他SQL操作否则分页会作用到错误的语句上。我在初次使用时就踩过service层里先查了一下客户等级字典再查客户列表结果分页完全失效查出来的还是全部数据。Excel导出是CRM系统的高频功能。源码用的是Apache POI实现思路是查出符合条件的所有数据不走分页然后在内存里创建Workbook和Sheet逐行填充数据最后通过response.getOutputStream()把文件流写给前端。导出这里有三个细节要注意第一导出的数据量要加一个上限限制比如最多导出5万条否则大数据量导出会把内存撑爆第二单元格内容如果是电话号码这类长数字要设置单元格格式为文本否则会被Excel自动转成科学计数法显示第三文件下载需要前端配合用responseType: blob接收并且从响应的Content-Disposition头里解析文件名否则中文文件名会乱码。4. 前端Vue实现页面如何串起整个系统4.1 Vue项目结构与路由权限控制前端工程的标准Vue2项目结构main.js入口文件、App.vue根组件、router目录放路由配置、store目录放Vuex状态管理、api目录放接口请求、views目录放页面组件、components目录放公共组件。路由设计上采用了动态路由方案用户登录成功后前端根据后端返回的菜单权限动态添加路由而不是在路由表里把所有页面全部写死。这么做的好处很明显——权限低的操作员压根看不到他没权限访问的页面路径即使他手输URL也进不去。实现方式是在路由守卫beforeEach里做判断没有token就跳登录页有token但没有动态路由就调用接口拉取菜单再router.addRoutes然后next({ ...to, replace: true })重新进入目标页。这套源码在前端权限控制上做得比较完整按钮级别的权限用v-permission自定义指令实现。指令里比较当前用户的权限码数组里是否包含所需权限码不包含就把按钮DOM移除。这样添加客户按钮只在有customer:add权限码的用户页面上出现从交互层面把权限控制做到了颗粒度极细的级别。4.2 Axios封装与请求拦截器前端网络层是管理系统的命脉所有接口请求都走axios。源码在api目录下对axios做了统一封装核心是request.js文件里面做了三件重要的事第一请求拦截器。每次请求发出去之前从localStorage取出token设置到headers.Authorization。如果没有token直接跳转登录页避免请求打到后端被拦截器打回来浪费一次网络往返。第二响应拦截器。后端返回的数据结构统一是{ code: 200, message: success, data: {...} }这个格式响应拦截器里先判断code为200就返回data给业务页面使用非200就弹出ElMessage提示错误信息。特别要处理code为401的情况——这是登录过期或token无效的标志前端要做的是清除本地token、跳转登录页而不是任由一堆报错弹窗轰炸用户。第三统一错误处理。网络超时、服务器500这类异常在拦截器里统一拦截弹出网络异常请稍后重试的提示。业务页面里就不用每个接口都写try catch了页面代码干净很多。看一个封装的片段import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } Message.error(res.message) return Promise.reject(new Error(res.message)) }, error { Message.error(网络异常请稍后重试) return Promise.reject(error) } ) export default service这里面的baseURL: /api配合开发环境的代理配置是解决跨域的关键后面章节会详细说。4.3 核心页面实现客户列表与跟进时间线客户列表页是整个CRM用得最多的页面实现上有几个参考价值很高的点。表格用的是Element UI的el-table数据通过created生命周期里调用getCustomerList接口获取分页用el-pagination组件页码变化时重新请求数据。搜索区域用el-form做行内表单点击搜索按钮时把查询条件重置页码为1再查询点击重置时清空条件并恢复默认列表。这里有一个交互细节很多人做不好搜索条件和分页状态的一致性。如果当前在第3页用户执行一次新的搜索还停留在第3页的话看到的可能是空数据因为符合条件的记录可能只有2页。所以搜索时必须把pageNum重置为1这是CRM系统交互的基本常识。跟进记录页用的是时间线Timeline组件按时间倒序展示每次和客户沟通的内容。每次跟进完成后系统会提示录入下次跟进时间这块业务逻辑值得细看创建跟进记录时通过更新客户表里的nextFollowTime字段让客户列表页能按最近需要跟进的客户排序。这一下子就把跟进从单纯的记录变成了销售行动驱动力是CRM系统里最能体现业务洞察的地方。数据看板页用ECharts画图表包括客户来源饼图、销售业绩柱状图、客户等级分布图。前端从后端接口拿统计数据后option配置好塞进ECharts实例就行。这部分逻辑不复杂但它是管理系统的面子——老板打开系统第一眼看的就是图表做得好不好直接影响系统的口碑。5. 前后端联调与源码部署实录5.1 跨域问题的两种解决方式前后端分离项目绕不开跨域。前端跑在localhost:8080后端跑在localhost:8081浏览器的同源策略会拦截跨域请求报CORS错误。这套源码给了两种方案我实测都有效。开发环境的方案是前端配置Vue CLI的代理。在vue.config.js里配置devServer.proxy把所有/api开头的请求代理到后端地址。这样浏览器看到的请求是同源的自然不存在跨域问题而且后端代码一行不用改。module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }生产环境的方案是后端网关层处理通常用Nginx把前端静态资源请求和后端接口请求统一转发。更保险的做法是在后端加一个CORS全局配置类允许指定来源的跨域请求。需要注意配置里的allowCredentials(true)和allowedOrigins不能同时用通配符*否则浏览器会拒绝携带凭证的跨域请求这个坑我在联调时折腾了很久。5.2 从源码到上线的完整部署流程我把这套系统的上线部署流程完整走了一遍先构建后端在项目根目录执行mvn clean package -DskipTests生成target目录下的jar包。然后准备好生产环境的MySQL把源码里自带的SQL脚本导入初始化完整的表结构和基础数据包括默认管理员账号。启动时用nohup java -jar crm.jar --spring.profiles.activeprod让服务在后台运行再用tail -f logs/crm.log确认启动日志里出现Started Application in xx seconds就说明启动成功。前端部署要执行npm run build打包后dist目录下是纯静态文件丢到Nginx的html目录下。Nginx配置里需要做三件事第一root指向dist目录第二所有/api前缀的请求反向代理到后端服务第三配置try_files $uri $uri/ /index.html解决SPA应用刷新页面404的问题。server { listen 80; server_name your-domain.com; root /usr/share/nginx/html/crm; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }5.3 生产环境必须调整的几个配置源码默认的配置是开发环境参数生产上线前有几项必须改。数据库连接从localhost:3306/crm改成生产库地址密码不要明文写在application.yml里用${DB_PASSWORD}占位符配合环境变量注入。MySQL8.0的驱动要确认是com.mysql.cj.jdbc.Driver连接串里加上serverTimezoneAsia/Shanghai否则会出现时间差8小时的问题。MyBatis在生产环境建议把map-underscore-to-camel-case设置为true这个配置能把数据库字段的customer_name自动映射成Java属性的customerName省掉大量resultMap手写映射的工作。但要注意如果某个查询用了SELECT *这种自动映射可能因为字段不在实体类里而抛异常所以生产环境的SQL要尽量显式列出需要的字段。另外日志级别从DEBUG调整为INFO避免生产环境打印大量SQL语句拖慢性能。慢SQL的日志要单独开通过log4jdbc或者直接通过MySQL的slow_query_log监控查询超过1秒的语句CRM系统的列表页最容易出慢SQL一定要提前监控。6. 常见问题速查与避坑经验6.1 高频问题排查手册我复现这个项目的过程中整理了一份高频问题排查表基本上你照着顺序排查大部分问题都能在半小时内定位问题现象排查方向解决方案后端启动报数据库连接失败检查MySQL服务是否启动、账号密码是否正确、数据库是否已导入用Navicat手动连一下数据库确认连接串前端页面打不开后端接口控制台看请求URL和跨域报错信息确认代理配置的/api前缀是否和后端context-path一致登录接口报401确认用户名密码、确认用户状态启用看后端日志的认证异常信息多半是密码加密方式不一致列表数据不显示F12看Network里接口返回结构确认后端返回的code是否为200data是否包了一层list字段刷新页面404Nginx未配置try_files按上文Nginx配置补上try_files $uri $uri/ /index.html中文乱码JVM编码和数据库编码两端不一致数据库连接串加characterEncodingutf8服务启动加-Dfile.encodingUTF-8导出Excel文件名乱码响应头编码问题对Content-Disposition里的文件名做URLEncoder.encode时间显示差8小时时区未配置确认serverTimezoneAsia/Shanghai前端格式化时设置好时区这里重点说下登录报401的问题这是新手最容易卡住的点之一。如果后端返回401大概率是JWT解析失败原因可能是token过期、密钥不匹配前后端拿到的不是同一个token、或者拦截器里放行路径没配置登录接口。我在源码里发现一种写法是把登录接口路径硬编码在拦截器配置里如果你自己改了登录接口的路径却忘了改拦截器配置就会出现怎么登录都登不上的诡异问题。6.2 几条越早明白越好的实操心得第一日志能救命。我在联调阶段遇到过一个非常隐蔽的问题客户列表接口偶尔会返回500但本地复现不了。后来加了完整的请求日志和异常堆栈输出才发现是某个客户名称里带了特殊字符%导致LIKE CONCAT(%, %, %)匹配了所有记录大数据量下直接内存溢出。这个问题不看日志根本想不到所以要养成每个接口入口打印请求参数、出口打印响应耗时的习惯。第二数据库设计阶段多想一步后面省两周。CRM系统看起来简单但客户和跟进记录是一对多的关系合同和客户是多对一的关系如果一开始没想清楚外键字段怎么设计后面写JOIN查询的时候会到处找补。我建议在做任何管理系统之前先花半天时间把所有实体间的关联关系画清楚确认每张表的主键外键再动手写代码。第三前端组件化不是越碎越好。这套源码的页面组件粒度控制得不错——搜索表单、表格、分页没有拆成一个个单独组件而是在页面内作为完整模块存在。过度的组件化会导致props和events满天飞维护起来反而痛苦。组件拆分的边界应该是是否会被两个以上页面复用不复用的部分留在页面内就好。第四版本兼容性要提前锁定。我刚开始复现时用的SpringBoot 2.7搭配MyBatis Spring Boot Starter 2.2启动时报循环依赖错误。排查后发现是JDK版本不对导致Spring的PostConstruct初始化顺序异常。后来统一按源码的版本编号来一杯茶的时间就启动成功了。做技术选型时pom里的版本号之间是有隐式兼容关系的别轻易升级某个依赖的版本容易引入莫名其妙的坑。这个系统做完之后我自己又往里加了一个客户公海模块超过30天未跟进的客户自动掉入公海其他销售可以领取领取后重新计算跟进时限。这个功能在真实企业里非常常见本质就是对nextFollowTime字段的自动化利用。如果你拿到这套源码想二次开发客户公海、拜访签到、销售目标管理都是不错的方向核心逻辑都不复杂基于现有代码扩展起来很顺。最后说一句跑通这套系统只是第一步把每一步的实现逻辑吃透你收获的才是真正的工程能力。