做公司内部管理系统,客户关系管理(CRM)是我接触最多的一类需求。业务上要管线索、管客户、管跟进、管商机合同,技术上要支撑多人协作、权限隔离、数据统计,还要让销售愿意用、老板看得到数据。前前后后我做过好几个版本,从早期的JSP+Servlet,到SSH,再到后来前后端分离,最顺手、也最适合中小团队快速交付的组合就是SpringBoot+Vue。
这篇内容围绕一个真实可复现的“基于SpringBoot+Vue公司客户关系管理信息系统”展开。我会把当时设计的思路、表结构怎么拆、权限怎么做、前后端怎么联调、部署时踩过哪些坑,全部梳理出来。适合正在做毕业设计、刚转Java全栈、或者公司里要快速搭一套内部CRM的朋友参考。我不会讲太多虚的架构理论,尽量给可以直接抄走的代码片段、配置和避坑清单。你把它当成一个做过同类项目的人,坐在你旁边跟你讲他怎么落地就行。
1. 整体设计与技术选型思路
1.1 为什么选SpringBoot而不是传统SSM
早几年做CRM,流行的是SSM(Spring+SpringMVC+MyBatis)加JSP。SSM的问题是配置繁琐:XML配置、扫描配置、视图解析器、事务管理器,光搭环境就要半天。SpringBoot把大部分配置都自动化了,内嵌Tomcat,一个main方法就能启动项目,开发调试效率完全不在一个量级。做客户关系管理系统这种典型的CRUD密集型业务,SpringBoot的自动配置、starter机制、监控体系,能让我把主要精力放在业务逻辑上,而不是环境折腾上。
你可能担心SpringBoot是不是“太新”“不保险”?实际上SpringBoot已经是非常成熟的技术,2.x版本在企业里大面积落地,文档全、社区活跃、招聘需求也多。做毕设或者公司内部系统,选SpringBoot不仅开发快,答辩或评审时也有得聊。我在这个项目里用的是SpringBoot 2.7.x,搭配JDK8,稳定优先,不追新。
1.2 为什么前端选Vue而不是传统jQuery
CRM系统页面交互复杂:表格筛选、弹窗编辑、多标签页、图表统计。如果用jQuery+模板引擎,代码会迅速膨胀,维护成本很高。Vue的核心优势是响应式数据绑定和组件化:页面被拆成客户列表、客户表单、跟进时间线、数据看板等独立组件,每个组件的逻辑内聚,改一处不影响别处。
选Vue还有一个现实原因:生态成熟。Element Plus提供了现成的表格、表单、弹窗、日期选择器,配合Vue Router和Pinia/Vuex,可以很快搭出后台管理界面。你要说React行不行?当然也行,但Vue的上手曲线更平缓,中文资料也多,对中小团队更友好。
1.3 前后端分离的整体架构
这个CRM系统采用前后端分离架构:SpringBoot只提供RESTful API,返回JSON数据;Vue通过Axios调用接口,渲染页面。前端项目和后端项目完全独立,可以分开开发、分开部署。
前端由Vue CLI或Vite创建工程,开发时通过代理转发请求到后端,避免跨域问题;生产环境打包成静态文件,交给Nginx托管,Nginx再把/api开头的请求反向代理到后端服务。后端就是标准的SpringBoot应用,打包成jar运行在服务器上。
选这个架构最大的好处是职责清晰:后端同学只管接口,前端同学只管页面。就算你是一个人做全栈,也能明显感觉到调试效率的提升——前端改样式不用重启后端,后端改接口不用等前端编译。
1.4 功能模块划分:CRM到底做哪些功能
很多刚接触CRM的同学最容易犯的错,是一上来就把客户表设计得特别复杂,结果页面做不完,逻辑还绕。我的建议是抓住CRM最核心的销售管理闭环:线索→客户→跟进→商机→合同→回款→统计。围绕这个闭环,系统分成六个核心模块:
- 线索管理:记录原始线索来源,支持分配和转换。
- 客户管理:维护企业客户/个人客户信息,支持公海池和私有客户。
- 跟进记录:每次和客户沟通的时间、内容、下次跟进计划。
- 商机管理:把有购买意向的客户转化为商机,跟踪阶段与金额。
- 合同管理:记录成交合同,关联客户和商机,管理回款计划。
- 数据统计:按销售、时间、阶段等维度统计客户数量、成交金额。
此外还需要系统管理模块:用户管理、角色管理、菜单权限、操作日志。这些模块组合起来,才能算一个完整的公司客户关系管理信息系统。
2. 核心细节解析与实操要点
2.1 认证与权限:用JWT + 拦截器实现登录状态
刚开始做的时候我也用过Session,但前后端分离之后Session的跨域和共享问题很麻烦。后来我统一用JWT做认证:用户登录成功后,后端生成一个Token返回给前端,前端存到localStorage或者Pinia里,每次请求在请求头加上Authorization: Bearer <token>,后端用一个拦截器校验Token。
Token里我一般只放userId和username,不放心的话可以加expireTime。权限校验不能只靠Token里有没有,还要配合角色菜单。我的实现方式是在拦截器里解析出用户ID,然后查询该用户拥有的权限标识集合,通过自定义注解@RequiresPermission("customer:add")做接口级别的校验。这样做的好处是接口权限和前端菜单权限共用同一套权限标识,避免前端“隐藏了按钮但接口还能调用”的问题。
2.2 客户数据模型:一张表还是多条表
客户是CRM的核心主数据。第一个人版本我图省事,把客户、联系人、地址、行业都塞到一张表里,看起来简单,实际用起来全是问题:一个客户多个联系人怎么办?客户被重新分配后跟进记录怎么处理?
后来我按这个思路拆表:
customer:客户主表,存公司名称、客户等级、所属行业、来源、状态(私有/公海)、所属销售ID。customer_contact:联系人表,一个客户多个联系人,存姓名、电话、职位、是否主要联系人。follow_record:跟进记录表,关联客户ID、跟进内容、下次跟进时间、创建人。business_opportunity:商机表,关联客户ID、预计金额、阶段。contract:合同表,关联商机ID、合同金额、签约时间。
客户主表不存联系人信息,而是通过外键关联。这样看起来多查了几次表,但数据模型清晰,后续做统计、做权限隔离都方便。实际建表时我习惯用逻辑外键,不物理建外键约束,避免高并发下锁表。
这里有一个很多新手会掉进去的坑:客户的“所属销售”到底存什么?答案不是存销售姓名,而是存用户ID。姓名是冗余字段,可以查出来展示,但表里只存ID,否则做数据权限的时候会非常痛苦。
2.3 跟进记录与客户列表的联动
跟进记录是销售最常用的功能,也是CRM系统的价值所在。销售每天要记“今天跟客户聊了什么,下次什么时候联系”。我在设计时让跟进记录做成了时间线组件:进入客户详情页,能看到该客户所有跟进记录按时间倒序排列,每条记录有跟进方式(电话/微信/拜访)、跟进内容、下次跟进时间。
由于一个客户可能被多个销售跟进过(比如客户先被A录入,后被B接手),跟进记录表里需要同时保存customer_id和create_by,查询时按customer_id过滤,再通过create_by关联用户表显示操作人姓名。这个逻辑并不复杂,但一旦遗漏了create_by,以后做“某销售名下客户的跟进历史”就查不出来。
2.4 文件上传与Excel导入导出
CRM系统里经常出现Excel导入客户名单、导出客户列表的需求。Excel导入导出的主力工具是Apache POI,但POI的API偏底层,我一般用EasyExcel,阿里开源的,内存占用小,API简单。
导入时要处理两个问题:第一,模板字段校验,比如手机号格式、重复客户判断,我建议用@ExcelProperty加自定义校验器,读取时逐行校验,并把错误信息收集起来返回给前端;第二,大数据量导入,几百行用同步导入没问题,几千行以上建议异步导入并写导入日志,避免前端请求超时。
导出功能要注意字段权限:不同角色可导出字段不同,比如普通销售不能导出全公司客户,只能导出自己名下的。所以导出接口一定要校验数据权限,在SQL里就加好条件,而不是导出来再过滤。
2.5 前端动态菜单与路由控制
前端菜单不应该写死,应该根据当前登录用户的角色动态生成。后端在登录接口中同时返回用户信息和权限码列表,前端根据权限码动态生成菜单,并使用router.addRoute动态注册路由。
这里需要注意一个细节:如果刷新页面时Pinia里的数据丢失,需要重新请求用户信息和菜单。我当时的处理是在路由守卫beforeEach里加一个全局判断:如果Pinia中没有用户信息,先调用后端接口获取用户信息,再动态添加路由,最后next()放行。这个过程一定要处理好,否则会出现“登录成功后刷新页面就404”的经典问题。
3. 实操过程与核心环节实现
3.1 后端项目搭建与依赖配置
创建SpringBoot项目我推荐直接用Spring Initializr(IDEA内置或者start.spring.io都行)。关键依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>application.yml里注意配置MyBatis-Plus的驼峰映射和逻辑删除:
mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 03.2 统一返回结果与全局异常处理
接口返回格式如果不统一,前端Axios的响应拦截器就很难写。我定义了一个ApiResponse类,包含code、message、data三个字段,所有接口都返回它。同时写了一个@RestControllerAdvice全局异常处理器,把业务异常、参数校验异常、未知异常统一转成ApiResponse返回。
这里的关键点是业务异常不要用RuntimeException裸抛,我定义了一个BusinessException,里面携带错误码。比如客户已经被删除时,抛出BusinessException(ResultCode.CUSTOMER_NOT_FOUND)。这样做的好处是前端可以通过统一的code字段判断业务错误,而不是解析message字符串。
3.3 客户管理模块的核心实现
客户管理的核心是分页查询和权限过滤。分页用的是MyBatis-Plus的分页插件,在配置类里注册一下就行:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }查询接口示例:
@GetMapping("/customer/page") public ApiResponse<Page<CustomerVO>> page( @RequestParam(required = false) Integer pageNum, @RequestParam(required = false) Integer pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Boolean onlyMine) { Page<Customer> page = new Page<>(pageNum == null ? 1 : pageNum, pageSize == null ? 10 : pageSize); LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Customer::getCustomerName, keyword); } // 数据权限:onlyMine为true时只查当前用户 if (Boolean.TRUE.equals(onlyMine)) { wrapper.eq(Customer::getOwnerUserId, LoginUtil.getUserId()); } Page<Customer> result = customerService.page(page, wrapper); return ApiResponse.success(result); }新增和修改接口要注意参数校验。客户名称必须非空,手机号格式要校验,联系人电话可以允许为空。我用的@Validated加@NotBlank等注解,减少手写if-else。
删除客户建议走逻辑删除,也就是MyBatis-Plus的@TableLogic字段。为什么?因为客户一旦被删除,关联的跟进记录、商机、合同都不能查了,但历史数据不能丢,尤其是财务报表可能需要追溯。逻辑删除只是在SQL上自动加deleted = 0条件,对外表现跟删除一样,但对数据保全非常重要。
3.4 前端Vue项目结构与环境配置
前端我使用的是Vue 3 + Vite + Element Plus + Pinia + Vue Router + Axios这套组合。项目结构大致这样:
src ├── api │ ├── customer.js │ ├── auth.js │ └── dashboard.js ├── assets ├── components │ ├── CustomerForm.vue │ ├── FollowTimeline.vue │ └── PaginationTable.vue ├── layout │ └── MainLayout.vue ├── router │ └── index.js ├── store │ ├── user.js │ └── app.js ├── views │ ├── login/index.vue │ ├── customer/index.vue │ ├── customer/detail.vue │ ├── opportunity/index.vue │ └── dashboard/index.vue ├── utils │ └── request.js └── main.jsAxios封装我放在utils/request.js里,主要做三件事:请求时带Token、响应时统一处理code、401时跳转登录页。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/store/user' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response && error.response.status === 401) { const userStore = useUserStore() userStore.resetAuth() router.push('/login') } ElMessage.error(error.message || '网络错误') return Promise.reject(error) } )这里有一个细节:响应拦截器里我直接返回了res.data,所以后续调接口拿到的不是整个响应体,而是data字段。这一点要和后端约定好,避免团队成员理解不一致。
3.5 前端客户管理页面的核心代码
客户列表页面就是典型的“搜索区+表格+分页+弹窗表单”。搜索区包括关键字、客户状态、所属销售(管理员可见)。表格列包括客户名称、等级、来源、联系人、电话、下次跟进时间、状态、操作按钮。操作按钮有“编辑”“跟进”“转为商机”“删除”。
弹窗表单用了Element Plus的el-dialog加el-form,表单校验规则写在rules里。添加和编辑共用一个表单组件,通过props传入初始数据,内部使用深拷贝避免直接修改父组件数据。
跟进时间线组件是客户详情页的核心,我用el-timeline展示:
<el-timeline> <el-timeline-item v-for="record in recordList" :key="record.id" :timestamp="record.createTime" placement="top" > <div class="follow-content"> <p>{{ record.content }}</p> <span>{{ record.creatorName }} · {{ record.type }}</span> </div> </el-timeline-item> </el-timeline>添加跟进时,除了填内容,还要选“下次跟进时间”。这个时间会被后端解析后更新客户主表的next_follow_time字段,这样销售首页就能展示“今天需要跟进的客户列表”。
3.6 前后端联调与代理配置
开发阶段前后端分离,最烦的是跨域。简单粗暴的解决方案是后端加@CrossOrigin,但只能做开发环境。规范做法是在后端写一个CorsFilter,允许的来源放到配置中心:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }生产环境则不用后端开跨域,因为前后端最终同源。前端开发时通过Vite代理解决:
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, } } } })注意,后端接口路径如果是/api/customer/page,Vite代理会把整个/api前缀转发到后端,后端Controller里要定义@RequestMapping("/api/customer/page"),或者把前缀统一放到server.servlet.context-path里。我习惯前端代理去前缀,后端Controller只写业务路径。
3.7 项目部署:Docker与Nginx结合
部署SpringBoot+Vue项目不难,但细节多。我的部署方案是后端打包成jar,用Docker跑;前端打包成静态文件,用Nginx跑。Dockerfile如下:
FROM openjdk:8-jre WORKDIR /app COPY target/crm-server.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]前端打包后,把dist目录拷贝到服务器的/usr/share/nginx/html下。Nginx配置:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index 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; } location / { try_files $uri $uri/ /index.html; } }try_files那行很关键,它保证前端路由在history模式下刷新页面不会404。如果你用hash路由,不配置也能工作,但地址会带个#,不美观。我建议用history模式,同时配好Nginx的回退。
数据库初始化我用了Sql脚本加Docker容器里的MySQL。首次部署时手动执行建库脚本,之后靠程序里的Flyway做版本管理。这里踩过一个坑:如果两个模块都要建表,可能会出现“table already exists”,所以我统一用Flyway管理,不再手写CREATE TABLE IF NOT EXISTS。
4. 常见问题与排查技巧实录
4.1 跨域问题拦住了所有请求
前后端联调时最常见的问题就是跨域。表现是前端控制台报CORS policy错误,请求根本没到后端。排查分两步:先看浏览器Network里请求是否发出去,如果OPTIONS预检请求401,多半是拦截器把预请求拦了。
我的解决方法是,在JWT拦截器里放行OPTIONS请求:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }这个细节能救很多人的命。我之前因为没放行OPTIONS,前端所有GET请求都报跨域,折腾了一下午。
4.2 Token过期后页面“卡死”了
Token过期之后,接口返回401,前端响应拦截器应该跳转登录页。但如果响应拦截器里使用了router.push('/login'),而调用接口的页面正在加载路由,就会报“NavigationDuplicated”或者“Redirected when going from /login to /login”。
我的解决方式是,在拦截器里判断当前路由不是/login才跳转,并且用window.location.href做强制跳转也可以。另外,Token过期后不要只提示一个错误,最好弹个“登录已过期,请重新登录”的提示,避免用户不知道发生了什么。
4.3 MyBatis-Plus分页不生效
分页插件的使用顺序有讲究。MyBatis-Plus的PaginationInnerInterceptor必须放到拦截器链的最后,否则可能导致分页参数失效。另一个坑是分页插件没有注册时,page查询会查出全部数据且不分页,这个错误在日志里很容易被忽略。
验证分页是否生效,看日志里的SQL是否包含LIMIT。如果没包含,八成是插件没注册成功。还有一个细节,使用Page对象时,页码从1开始,前端分页组件如果从0开始,需要对页码做转换。
4.4 数据库字段下划线映射到Java驼峰失败
如果MySQL字段叫customer_name,Java属性叫customerName,MyBatis-Plus默认会开启下划线转驼峰。但如果你在application.yml里覆盖了configuration项,可能会把默认配置搞丢。所以我建议在配置里显式写map-underscore-to-camel-case: true。
同时,查询SQL里如果用了as别名,别名的下划线也要注意。比如select customer_name as customerName,这种写法反而能避免映射问题,但显得啰嗦。我一般写好实体后,直接用MyBatis-Plus的LambdaQueryWrapper,不手写繁琐SQL,就很少遇到映射问题了。
4.5 逻辑删除字段导致唯一索引失效
客户表为了防重,我给customer_name加了唯一索引。但加上逻辑删除后,问题来了:客户A被逻辑删除后,deleted变成1,再录入同名客户B,唯一索引会挡住插入,因为居然允许唯一索引不包含deleted字段。
解决方法是把唯一索引改成联合唯一索引:(customer_name, deleted)。这样同一个客户名下可以有一条未删除记录和任意多条已删除记录,不会冲突。这个细节虽然不起眼,但实际项目里真能卡你一整天。
4.6 前端Element Plus表单校验不通过但不提示
有时候点击提交,后端没请求,前端也没提示,原因是表单校验规则绑定的prop必须是model对象里的字段路径,比如form.name对应prop="name"。如果你在el-form-item上写的prop和校验规则里的name不一致,校验就不生效,也不会弹错误消息。
遇到这种问题,我一般直接在浏览器React/组件面板里看表单的validateState是否是error,或者临时在提交方法里调用this.$refs.form.validate看返回。排查速度会快很多。
4.7 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 前端报CORS错误 | 拦截器未放行OPTIONS | 在JWT拦截器放行OPTIONS |
| 登录后刷新页面404 | 动态路由未重新加载 | 路由守卫获取用户信息后再addRoute |
| 分页查询返回全部数据 | 分页插件未注册 | 注册PaginationInnerInterceptor |
| 客户名重名后无法新增 | 逻辑删除与唯一索引冲突 | 唯一索引改为(customer_name, deleted) |
| 表单校验不提示 | prop与校验规则不匹配 | 检查el-form-item的prop绑定 |
| 文件上传超时 | 后端同步处理耗时太长 | 改为异步导入,引入消息提示和导入日志 |
| 客户列表数据混乱 | 数据权限过滤遗漏 | 查询SQL增加owner_user_id条件 |
5. 数据库设计补充与性能优化建议
5.1 核心表的建表SQL参考
客户主表:
CREATE TABLE `customer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_name` varchar(100) NOT NULL COMMENT '客户名称', `customer_level` varchar(20) DEFAULT NULL COMMENT '客户等级', `industry` varchar(50) DEFAULT NULL COMMENT '所属行业', `source` varchar(50) DEFAULT NULL COMMENT '客户来源', `status` tinyint(1) DEFAULT '0' COMMENT '0-私有 1-公海', `owner_user_id` bigint(20) DEFAULT NULL COMMENT '负责人用户ID', `next_follow_time` datetime DEFAULT NULL COMMENT '下次跟进时间', `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除', `create_by` bigint(20) DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_owner_user_id` (`owner_user_id`), UNIQUE KEY `uk_customer_name_deleted` (`customer_name`, `deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户主表';跟进记录表:
CREATE TABLE `follow_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_id` bigint(20) NOT NULL, `content` text COMMENT '跟进内容', `follow_type` varchar(20) DEFAULT NULL COMMENT '电话/微信/拜访', `next_follow_time` datetime DEFAULT NULL, `create_by` bigint(20) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_customer_id` (`customer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟进记录表';这里不建外键约束是刻意为之。业务上删除客户时逻辑删除,实际数据还在,外键约束会碍手碍脚。所有关联关系靠应用层保证,配合定时任务清理孤儿数据即可。
5.2 索引优化经验
客户表查询最频繁的条件是“当前登录用户ID + 客户名称模糊查询 + 下次跟进时间排序”。我给owner_user_id和next_follow_time建了联合索引,查询效率提升明显。注意,模糊查询如果写成%keyword%,就算有索引也用不上,会全表扫描。客户量超过十万后,这种查询尽量改成keyword%前缀匹配,或者引入ES,但我们一般规模用不到,合理设计索引就够了。
跟进时间线查询是按customer_id过滤,所以在follow_record表的customer_id上建索引后,即使跟进记录有几千条,查询也在毫秒级。
5.3 数据权限的三种实现方式
CRM系统里的数据权限是核心需求。常见三种方式:
- 最简方式:在Service层手动加
where owner_user_id = 当前用户ID,适合单人开发、快速上线。 - 注解方式:自定义
@DataScope注解,通过AOP自动拼接数据权限SQL,适合中小团队、有多个角色。 - 框架方式:引入若依(RuoYi)等后台框架,自带数据权限。如果项目不是从零定制,我会优先考虑框架,省去造轮子。
这个项目里我选择了第二种方式的简化版:在查询逻辑里写一个DataScopeHelper工具类,根据当前角色判断是否只查本人数据。管理员角色不加条件,普通销售加owner_user_id条件,老板角色可以看全部但不允许修改,通过角色编码判断即可。
5.4 缓存预热与性能优化
CRM系统里客户详情页要展示客户信息、联系人列表、跟进记录、商机列表,如果每次打开都实时查数据库,体验会很差。我用Spring Cache + Caffeine做了一级缓存:
@Cacheable(cacheNames = "customer:detail", key = "#id") public CustomerDetailVO getDetail(Long id) { // 查询客户、联系人、跟进、商机 }缓存策略是:读取时缓存,修改客户时@CacheEvict清除缓存。这个策略对客户详情这种读多写少的场景非常合适。但是要注意,涉及权限隔离的数据不能随便缓存,比如不同角色看到的客户字段可能不同,缓存前先确认数据不存在跨角色差异。
6. 项目落地后的经验复盘
6.1 从零到一开发时,先做能跑通的主流程
如果你是自己一个人做这个项目,不要先追求把所有模块做完,而是先打通主线:登录→客户列表→新增客户→编辑客户→删除客户→跟进记录→数据统计。主线通了以后,再逐步加商机、合同、权限细节。这个顺序能让你快速看到系统成型,避免前期陷入权限和界面细节里出不来。
我第二次做CRM时,提前把主线走通,只用了一周。后面三周都在优化权限、补校验、做统计报表。如果主线都没跑通就开始做花哨功能,大概率要到答辩或交付前一天还在焦头烂额。
6.2 权限设计一定要提前想清楚
很多项目前期只做了登录,觉得权限后面再说。等客户、合同都做完,再回头加数据权限,改动范围就会非常大:所有查询接口都要加条件,所有菜单都要动态渲染,遗漏一个就数据泄露。所以哪怕是最简单的角色,也要在架构设计阶段就把权限模型定下来。
我的模型是三张核心表:用户表、角色表、菜单表,再配用户角色关联表和角色菜单关联表。用户登录后加载菜单和权限码,前端控制页面和按钮,后端控制接口。后面要加“部门数据权限”,只需要在用户表加部门ID,再在数据权限工具里加一个部门维度即可。
6.3 沟通成本往往高于编码成本
我做了几个CRM项目后发现,最难的部分不是写代码,而是把需求问清楚。比如“客户”到底指公司还是联系人?公海客户几天没有跟进要回收?“商机阶段”有哪几个?“合同金额”是否含税?这些问题如果不在一开始对齐,后面返工成本很高。我的经验是先画一张业务流程图给需求方看,确认后再建表。流程图画清了,表结构基本就不会大改。
如果你做的是毕业设计,也同样需要把业务故事讲完整。答辩老师最常问的就是“为什么这样设计表”“这个权限怎么控制”“你的系统解决了什么问题”。把业务逻辑想透,比堆砌再多的技术名词都有用。
6.4 备份和日志别偷懒
内部管理系统也要有备份意识。我在部署MySQL时,每天凌晨用cron任务做一次全量备份,保留最近7天,备份文件存到独立目录。操作日志模块记录登录日志和关键操作日志,包括谁在什么时间改了什么字段。这个设计看似简单,但上线后排查数据问题时非常有价值。
有一次销售反馈“客户归属被改了”,我通过操作日志很快就定位到是管理员手动转移客户的操作,没有走批量分配逻辑。如果没有日志,这个问题根本查不出来。
6.5 最后分享一个小技巧
前端表格里展示客户下次跟进时间时,我的处理是超过当前时间且当天到期的高亮,超过时间未跟进的标红。这个需求后端接口只需要返回一个字段followStatus,前端根据状态渲染样式。就这么一个小功能,真正用起来的时候销售反馈“好用,一眼就知道今天要联系谁”。做管理系统,尤其是CRM,这些细节比花哨的图表更能提升用户黏性。
如果你准备自己动手做这套SpringBoot+Vue的客户关系管理系统,我的建议是不要去找那种“开箱即用”的完整源码直接跑。哪怕你参照这个思路从零写一遍,哪怕写得粗糙一点,收获也远比复制粘贴大得多。先把用户、客户、跟进这三张表打通,你就已经掌握了这类业务系统的核心脉络,剩下的模块都是在这个骨架上长肉。