SpringBoot+Vue客户关系管理系统开发实战:表设计、权限与部署避坑

发布时间:2026/9/28 12:26:33
SpringBoot+Vue客户关系管理系统开发实战:表设计、权限与部署避坑 做公司内部管理系统客户关系管理CRM是我接触最多的一类需求。业务上要管线索、管客户、管跟进、管商机合同技术上要支撑多人协作、权限隔离、数据统计还要让销售愿意用、老板看得到数据。前前后后我做过好几个版本从早期的JSPServlet到SSH再到后来前后端分离最顺手、也最适合中小团队快速交付的组合就是SpringBootVue。这篇内容围绕一个真实可复现的“基于SpringBootVue公司客户关系管理信息系统”展开。我会把当时设计的思路、表结构怎么拆、权限怎么做、前后端怎么联调、部署时踩过哪些坑全部梳理出来。适合正在做毕业设计、刚转Java全栈、或者公司里要快速搭一套内部CRM的朋友参考。我不会讲太多虚的架构理论尽量给可以直接抄走的代码片段、配置和避坑清单。你把它当成一个做过同类项目的人坐在你旁边跟你讲他怎么落地就行。1. 整体设计与技术选型思路1.1 为什么选SpringBoot而不是传统SSM早几年做CRM流行的是SSMSpringSpringMVCMyBatis加JSP。SSM的问题是配置繁琐XML配置、扫描配置、视图解析器、事务管理器光搭环境就要半天。SpringBoot把大部分配置都自动化了内嵌Tomcat一个main方法就能启动项目开发调试效率完全不在一个量级。做客户关系管理系统这种典型的CRUD密集型业务SpringBoot的自动配置、starter机制、监控体系能让我把主要精力放在业务逻辑上而不是环境折腾上。你可能担心SpringBoot是不是“太新”“不保险”实际上SpringBoot已经是非常成熟的技术2.x版本在企业里大面积落地文档全、社区活跃、招聘需求也多。做毕设或者公司内部系统选SpringBoot不仅开发快答辩或评审时也有得聊。我在这个项目里用的是SpringBoot 2.7.x搭配JDK8稳定优先不追新。1.2 为什么前端选Vue而不是传统jQueryCRM系统页面交互复杂表格筛选、弹窗编辑、多标签页、图表统计。如果用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 InitializrIDEA内置或者start.spring.io都行。关键依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.2/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependencyapplication.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 ApiResponsePageCustomerVO page( RequestParam(required false) Integer pageNum, RequestParam(required false) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Boolean onlyMine) { PageCustomer page new Page(pageNum null ? 1 : pageNum, pageSize null ? 10 : pageSize); LambdaQueryWrapperCustomer 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()); } PageCustomer 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-forrecord in recordList :keyrecord.id :timestamprecord.createTime placementtop div classfollow-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/pageVite代理会把整个/api前缀转发到后端后端Controller里要定义RequestMapping(/api/customer/page)或者把前缀统一放到server.servlet.context-path里。我习惯前端代理去前缀后端Controller只写业务路径。3.7 项目部署Docker与Nginx结合部署SpringBootVue项目不难但细节多。我的部署方案是后端打包成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_nameJava属性叫customerNameMyBatis-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对应propname。如果你在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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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这些细节比花哨的图表更能提升用户黏性。如果你准备自己动手做这套SpringBootVue的客户关系管理系统我的建议是不要去找那种“开箱即用”的完整源码直接跑。哪怕你参照这个思路从零写一遍哪怕写得粗糙一点收获也远比复制粘贴大得多。先把用户、客户、跟进这三张表打通你就已经掌握了这类业务系统的核心脉络剩下的模块都是在这个骨架上长肉。