Spring Boot + Vue打造CRM系统:从数据库设计到权限实战

发布时间:2026/9/16 1:14:52
Spring Boot + Vue打造CRM系统:从数据库设计到权限实战 简介一份基于Spring Boot和Vue的客户关系管理系统CRM毕业设计源码案例专为计算机相关专业学生与前后端分离开发者设计。该项目可作为课程设计或毕业设计的完整参考也可帮助快速理解企业级客户信息管理平台的搭建方式。压缩包共包含355个文件大小约20.17MB其中以89个Java后端源码、34个Vue前端组件和161个SVG图标资源为主体辅以数据库SQL脚本、YML配置文件及bat构建运行脚本便于本地部署与二次开发。目前已有178人学习下载。资源完整展现了后端RESTful API设计、Spring Security权限控制、前端Vuex状态管理及Element UI界面实现等关键环节并通过Controller控制器、Vue页面和mp4录屏直观呈现核心功能逻辑。下载后可直接对照源码梳理项目结构掌握前后端分离开发流程特别适合需要完成CRM类设计课题并希望快速提升实战能力的学习者。1. 为什么CRM系统选Spring Boot Vue而不去追微服务打开任何一张带客户关系管理字样的招聘要求要求的都是Spring Boot Vue这套组合。这个现象不是偶然客户关系管理系统的本质是一堆带着状态的表单在几个人之间流转压根没有高并发和大数据量的压力。把人员、客户、跟进记录、合同、回款这些都装进一张表里靠外键关联起来已经能覆盖绝大多数中小公司的销售管理需求。Spring Boot负责把后端接口按业务模块拆清楚Vue负责把页面交互做得像桌面软件一样顺手。两者通过RESTful JSON通信开发边界清晰一个人能独立搞定两个人协作也不容易打架。源码案例里最常见的结构是后端按controller/service/mapper三层分包前端按views/router/store组织页面配合JWT做登录态管理就构成了一个能直接上线的单体应用。这套方案的真正门槛不在会不会用某个注解而在字段关系怎么设计、权限边界怎么划、数据字典怎么维护。本次就按一个可运行的实现顺序从前到后把这些关键点逐一过一遍。2. 从模块划分到数据库设计CRM系统的字段与表结构怎么定2.1 先定业务边界CRM系统最少要几张表客户关系管理系统最常见的模块清单是系统管理、客户管理、跟进记录、合同管理、数据看板。系统管理衍生出用户、角色、菜单、部门四张表客户管理衍生出客户表跟进记录单独一张表合同管理由合同主表和回款记录表组成。常见做法是再加一张数据字典表用来维护客户来源、客户级别、合同状态这类可枚举的字段选项。我一般会建议把表拆到能独立统计这个粒度就收手。客户表里带一个last_follow_time字段能直接排序出超过7天没跟进的客户回款表带payment_date能按月汇总回款金额。这些字段看似冗余实际是为了把GROUP BY从多表关联里解放出来。2.1.1 客户表和跟进记录表的核心字段CREATE TABLE crm_customer ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, name varchar(100) NOT NULL COMMENT 客户名称, level tinyint(4) DEFAULT 1 COMMENT 客户级别1普通 2重要 3VIP, source varchar(50) DEFAULT NULL COMMENT 客户来源字典编码, owner_id bigint(20) DEFAULT NULL COMMENT 负责人ID关联sys_user.id, phone varchar(20) DEFAULT NULL COMMENT 联系电话, industry varchar(50) DEFAULT NULL COMMENT 所属行业, last_follow_time datetime DEFAULT NULL COMMENT 最后跟进时间, next_follow_time datetime DEFAULT NULL COMMENT 下次跟进时间, status tinyint(4) DEFAULT 1 COMMENT 状态1启用 0停用, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_owner_id (owner_id), KEY idx_last_follow_time (last_follow_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户表; CREATE TABLE crm_follow_record ( id bigint(20) NOT NULL AUTO_INCREMENT, customer_id bigint(20) NOT NULL COMMENT 客户ID, content text COMMENT 跟进内容, follow_type varchar(20) DEFAULT 1 COMMENT 跟进方式1电话 2拜访 3微信, follow_time datetime DEFAULT NULL COMMENT 跟进时间, creator_id bigint(20) DEFAULT NULL COMMENT 创建人, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer_id (customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT跟进记录表;这两张表的设计有四个关键点。第一level和source这类字段存的是编码值而非显示文本显示名称通过数据字典翻译这样将来调整枚举项不用改表结构。第二owner_id单独加索引因为我的客户列表是CRM系统里最频繁的查询条件。第三客户表冗余了last_follow_time更新跟进记录时用一条UPDATE同步维护避免了每次列出客户都要去子表里查MAX(follow_time)。第四所有表都带create_time和create_by字段审计时能直接溯源不会出现这个客户谁录进来的都查不到的尴尬局面。2.1.2 数据字典表宁可多建一张不要写死在代码里CREATE TABLE sys_dict_item ( id bigint(20) NOT NULL AUTO_INCREMENT, dict_type varchar(50) NOT NULL COMMENT 字典类型编码, item_label varchar(50) NOT NULL COMMENT 选项显示文本, item_value varchar(50) NOT NULL COMMENT 选项实际值, sort_order int(11) DEFAULT 0 COMMENT 排序号, status tinyint(4) DEFAULT 1 COMMENT 状态1启用 0停用, PRIMARY KEY (id), KEY idx_dict_type (dict_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT字典明细表;用字典表而不是Java枚举对CRM这种业务系统有两个实际好处前端下拉框可以直接请求/dict/item/list?dictTypesource动态渲染新增选项不用改后端代码重新部署。字典项崩溃不会连带主业务表出问题数据校验只需比对字典表里有没有这个值。唯一的代价是查询时多一次关联或缓存读取但这个开销对CRM的量级可以忽略。2.2 权限设计一张角色表搞定数据隔离CRM系统里最麻烦的不是客户表本身而是数据范围的控制。销售想看自己的客户销售总监想看部门的客户老板想看全公司的客户这是三个档位。我不建议把数据范围判断逻辑写到每个Mapper查询里而是统一在业务层处理。后端Service层里先通过RequiresPermissions注解做功能权限校验再根据当前登录用户的角色去数据字典查数据范围查询时给Mapper传一个owner_id条件。当角色是全部数据时传空值表示不加这个过滤条件否则传当前用户或当前部门下的所有用户ID列表。2.3 表结构设计阶段的常见误用很多新手拿着需求文档就开始建表做CRM系统时最容易踩的坑是字段值含义不统一。比如客户状态有人的表里写0和1有人的表里写未跟进和已跟进还有人在跟进记录表里直接存富文本HTML。这些做法在单机demo里看不出问题一旦要写统计SQL就会失控。2.3.1 字段命名的统一规范字段类型建议格式反例原因时间字段create_time、last_follow_timedate、time看清语义避免歧义状态字段status 注释说明含义flag、type状态不只有一个维度的场景金额字段amount、total_pricemoney金额永远用decimal类型逻辑删除deleted默认0直接物理DELETE审计和恢复数据用得上金额字段一定要用DECIMAL(10,2)不要用FLOAT或DOUBLE。CRM的合同金额和回款金额浮点数的二进制精度问题会导致对账不平这是已经验证过很多次的教训。Vue前端传金额时注意用字符串或* 100转成分单位避免JavaScript浮点数精度丢失在后端拦截时直接报错。3. Spring Boot侧用JWT 拦截器把登录鉴权和接口权限串起来3.1 为什么CRM系统推荐JWT而不是Session或Sa-TokenCRM系统通常是单体后端 前后端分离部署这种情况下Session方案需要额外处理跨域携带CookieSa-Token虽然开箱即用但引入额外依赖。自己用JWT写一套轻量鉴权能控制token的生成和校验逻辑排查问题更直接。JWT的核心是把用户ID、用户名、过期时间三样信息用密钥签名后交给前端保存。后端接口收到请求时从Authorization头里取token验证签名合法后把其中的用户ID放进ThreadLocal后续任何Service层代码都能拿到当前操作人。这个方案的无状态特性对集群部署也友好token粘滞问题天然不存在。3.1.1 引入JWT依赖并配置密钥dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependencyjwt: secret: crm-system-2024-secret-key-please-change-in-production # token过期时间毫秒这里配置为24小时 expire: 86400000 # 请求头名称 header: Authorizationsecret至少要32个字符jjwt0.11.x版本要求HS256算法的密钥长度不得少于256位否则启动时会报WeakKeyException。过期时间按照CRM的使用频率设置太短会让销售反复重新登录太长又会增加token被盗用的风险常见做法是24小时加一个记住我选项。3.2 登录接口校验用户名密码并签发tokenService public class LoginService { Autowired private SysUserMapper userMapper; Autowired private JwtUtil jwtUtil; public LoginResponse login(String username, String password) { SysUser user userMapper.selectByUsername(username); if (user null) { throw new RuntimeException(用户不存在); } // 这里用MD5或BCrypt校验密码推荐后者 if (!passwordEncoder.matches(password, user.getPassword())) { throw new RuntimeException(密码错误); } if (user.getStatus() ! 1) { throw new RuntimeException(账号已被停用); } // 生成token并指定一个标识 String token jwtUtil.createToken(user.getId(), user.getUsername()); return new LoginResponse(token, user); } }密码不要用明文存储也不要只用MD5。Spring Security的BCryptPasswordEncoder是目前最普及的方案每次加密自动加盐即使两个用户密码相同库里的密文也不一样。登录成功后返回token和用户基本信息。前端拿到token后一般会把它存到localStorage或pinia里之后每个请求的Authorization头都带上这个值。3.3 拦截器统一校验token并注入用户上下文Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Long userId jwtUtil.getUserId(token); // 存入ThreadLocal或request attribute UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\token已过期\}); return false; } } response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后清除上下文避免线程池复用导致的数据串号 UserContext.clear(); } }拦截器的关键点是两个一是OPTIONS请求要直接放行因为Vue开发环境跨域请求会先发一个预检请求不放行会导致前端报CORS错误。二是afterCompletion里必须清理UserContext否则tomcat线程池复用后下一个请求可能读到上一个登录用户的信息。3.3.1 拦截器注册和放行路径配置Configuration public class WebConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns(/auth/login, /auth/captcha, /error); } }放行路径要慎重。除了登录接口外验证码、静态资源、健康检查这几类要放行。其他接口全部进拦截器宁可前端报401也不要让未登录请求穿透到业务层。3.4 后端还会遇到的3个细节细节一是MyBatis的驼峰映射问题。数据库字段create_time要映射到Java属性createTime必须在application.yml里开启map-underscore-to-camel-case: true否则查出来全是null。细节二是时间字段的格式化统一在application.yml配置spring.jackson.date-format和time-zone避免前端看到一串数字时间戳。细节三是分页查询使用MyBatis-Plus的Page对象不要自己在SQL里拼LIMIT参数注入风险大且不好维护。Controller层接收参数时建议用DTO对象接收而不是散落的RequestParam。CRM的客户查询接口至少得传keyword、ownerId、level、pageNum、pageSize五个参数DTO对象到Service层的传递路径更清晰。4. Vue侧从动态路由到多角色菜单的权限页面落地4.1 前端工程初始化和环境配置创建Vue项目时选择Vue3 Vite TypeScript的组合比Vue2 Webpack的起步更利落。安装依赖时用npm install装Element Plus组件库、Axios、Pinia和Vue Router这几件套是CRM前端的基本盘。npm create vuelatest crm-web cd crm-web npm install element-plus axios pinia vue-router4router采用createWebHistory模式接口地址统一放到.env.development文件里VITE_API_BASE_URL /api本地开发时配置Vite的proxy代理到后端地址这样就绕开了前端代码里的硬编码地址。打包上线时Nginx再按同一路径反代到后端服务前后端环境保持一致。4.2 动态路由根据后端返回的菜单生成路由表CRM系统的菜单权限通常是后端根据用户角色计算出来的。登录成功后调用/sys/menu/list接口返回一个树形结构的菜单数据前端再把这棵树转换成Vue Router的路由表动态添加。这套机制的好处是权限调整即时生效不用重新部署前端。// 构造路由表的核心方法 function buildRoutes(menuTree) { const routes [] for (const menu of menuTree) { const route { path: menu.path, name: menu.name, component: () import(/views${menu.component}), meta: { title: menu.title, icon: menu.icon }, children: [] } if (menu.children menu.children.length 0) { route.children buildRoutes(menu.children) } routes.push(route) } return routes }其中menu.component对应views目录下的.vue文件路径比如customer/index。这一步容易踩的坑是import()里的路径不能完全动态拼接否则Webpack或Vite解析不到。解决方法是把组件路径预先映射好或者直接让后端返回组件文件的相对路径字符串把常量映射放在前端维护。4.3 Pinia Axios拦截器统一处理token和401// axios拦截器核心代码 service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }, error Promise.reject(error) ) service.interceptors.response.use( response { // 后端统一返回结构 { code, msg, data } if (response.data.code 200) { return response.data.data } ElMessage.error(response.data.msg || 请求失败) return Promise.reject(new Error(response.data.msg)) }, error { if (error.response) { if (error.response.status 401) { // token过期清除登录态并跳转登录页 localStorage.removeItem(token) router.push(/login) } else if (error.response.status 403) { ElMessage.error(没有操作权限) } } return Promise.reject(error) } )4.4 客户管理页的增删改查闭环客户列表页是CRM系统的门面。表格展示客户名称、级别、来源、负责人、最后跟进时间这几列操作列放跟进和编辑两个按钮。新增和编辑共用一个对话框组件内部用el-form加rules做校验比如客户名称必填、手机号格式校验。提交成功后刷新表格并弹出消息提示。推进时可以把客户详情做成抽屉组件左侧展示基本信息右侧是跟进记录的时间线。跟进记录用el-timeline组件展示每条记录显示跟进方式、跟进内容和跟进时间。点击新增跟进按钮弹出输入框提交后插入一条新记录并更新客户的last_follow_time和next_follow_time字段。4.5 前端权限控制的两种落地方式菜单级控制用动态路由已经覆盖了。按钮级的控制常见做法是自定义指令v-permission指令的值是权限编码数组比如[crm:customer:follow]。Vue指令在元素插入DOM时判断当前用户的权限列表没有权限就直接el.remove()。路由守卫里还需要处理刷新页面后动态路由丢失的问题。因为动态路由是登录后addRoute进去的刷新页面路由表会清空。常见做法是在router.beforeEach守卫里判断localStorage有没有token没有就跳登录页有token但路由表为空就先重新调菜单接口生成路由再next({ ...to, replace: true })走一次导航。这个细节不处理好刷新页面就会出现白屏或404的错觉。5. 最终检查点前后端联调和上线前的几个坑本章放在最后因为越接近运行时环境碰到的问题越不适合在IDE里调试。这里整理三大块每一块都是实际部署时反复踩过的典型问题。5.1 CORS跨域为什么前端打包后反而正常了开发环境Vite的proxy转发后端地址理论上浏览器看到的请求是同域的所以开发时很少报跨域。上线后如果前端和后端分别部署在不同域名下Nginx不统一反代浏览器就会拦截响应。Vue端检查Network面板看到CORS error就在Nginx层加反向代理配置location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }后端WebMvcConfigurer里也可以加addCorsMappings统一设置允许来源为前端域名两个端同时保障最稳妥。5.2 打包后布局异常的排查思路Vite默认的base配置是/如果部署在子路径下路由的history模式和静态资源路径都会找不到。控制台报404但F12看网络请求资源路径不对基本就是base没配置。Vue Router用createWebHistory(import.meta.env.VITE_ROUTER_BASE)可以灵活控制基础路径。还有一类布局问题是路由嵌套层级不对导致面包屑导航和菜单高亮不同步。检查el-menu的default-active值是否和当前路由的path完全一致meta里的title和面包屑的映射关系是否正确。5.3 最小可运行清单最后梳理一份部署完成后的验证序列按这个顺序排查新环境一小时能搞定第一步访问前端地址看页面能否正常加载。第二步用管理员账号登录看token是否签发成功。第三步打开客户列表看是否正常请求接口返回数据。第四步点击新增客户提交一条测试数据看数据库落库是否成功。第五步换个浏览器隐身窗口访问确认未登录时能否正确拦截跳转登录页。这套顺序从外到内每一层都建立在前一层可信的基础上问题定位非常快。做到这一步一套能跑、能改、能交付的Spring Boot Vue客户关系管理系统就真正成型了。本文还有配套的精品资源点击获取