ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue客户管理系统实战:从数据库设计到部署全解析

SpringBoot+Vue客户管理系统实战:从数据库设计到部署全解析

最近把一个基于SpringBoot+Vue的企业客户管理系统完整跑通了,从数据库设计到前后端联调,再到打包部署,踩了不少坑。这篇文章我就以这套系统的源码实现为主线,把这个项目的整体架构、核心模块、关键代码和部署细节一次说完。

项目本身不复杂,但胜在"五脏俱全"——客户信息维护、联系人管理、跟进记录、数据看板、用户登录与权限控制全都有,技术栈正好是2025年中小型团队依然最主流的组合:后端SpringBoot + MyBatis + MySQL,前端Vue + Element Plus,前后端完全分离。适合正在做毕业设计、刚开始学前后端分离开发,或者准备接一个内网CRM项目的开发者参考。

1. 系统定位与整体设计思路

1.1 客户管理系统到底在解决什么问题

很多刚开始做企业项目的人会低估客户管理系统的价值,觉得不就是"增删改查"嘛。真去企业里看一眼就明白了:客户信息散落在Excel表格、微信聊天记录、纸质名片和售前人员的脑子里,销售离职带走一片客户,管理层想知道这个月新增了多少有效线索、哪个销售跟进最勤、哪些客户快流失了,根本拿不出统一数据。

这套系统要解决的核心问题就是三个:第一,把客户资产集中管理起来,统一存档、统一查看、统一维护;第二,把销售跟进过程记录下来,形成可追溯的时间线;第三,把数据汇总成看板,让管理者一眼看清客户分布、跟进频率和状态变化。

所以你会发现,这类系统的核心并不在"界面多漂亮",而在于数据模型是不是清晰、流程是不是闭环。一个客户从"线索"到"成交"再到"售后回访",状态流转必须完整记录下来,这才是有业务价值的"企业管理"系统,而不是一张单纯的信息登记表。

1.2 这套技术栈为什么到2025年还是主流

先说我为什么在2025年依然推荐SpringBoot+Vue+MyBatis+MySQL这套组合。

SpringBoot的优势不用多说,内嵌Tomcat、自动配置、起步依赖管理,一个jar包就能把后端服务跑起来。相比SpringMVC时代写一堆XML配置,SpringBoot把开发者的精力从"配置"解放到了"业务"上,这对中小型项目来说,开发效率的提升是质变的。

Vue作为前端框架,单文件组件、响应式数据绑定、Vue Router做路由管理、Pinia或Vuex做状态管理,配合Element Plus组件库,能在很短时间内搭出风格统一、交互完善的页面。尤其是Element Plus的表格、表单、弹窗、分页组件,几乎是企业后台管理系统的标配,开箱即用,省去大量UI打磨时间。

MyBatis在这套组合里经常被质疑"为什么不直接用JPA"。我的看法是:客户管理系统天然面临多条件组合查询、复杂统计SQL,MyBatis的半自动SQL控制让它成为最合适的选择。你写一条动态SQL,<where>标签里塞几个<if>判断,多条件筛选就完成了,SQL是你自己写的,性能可控,调优起来心里有数。

MySQL就不多说了,成熟稳定、社区资料丰富、部署运维成本低,支撑这种规模的企业级系统绰绰有余。

这么一套组合下来,从后端接口到前端页面,一个人花两周左右就能完整交付,对于毕设、小企业内网系统、外包项目来说,性价比非常高。

1.3 功能模块划分与业务链路

这套系统的功能模块,我按照企业内部真实的业务链路来拆:

  • 登录认证:账号密码登录,JWT生成token,前端携带token访问受保护接口。
  • 客户管理:客户信息的增删改查、分页搜索、状态流转、分配归属。
  • 联系人管理:一个客户下挂多个联系人,维护联系人姓名、职位、电话、微信。
  • 跟进记录:销售每次和客户沟通后,在系统里写一条跟进记录,记录跟进方式、结果和下次跟进时间。
  • 数据看板:用图表展示客户总数、新增客户趋势、客户状态分布、跟进次数排行。
  • 用户管理:管理员维护系统用户账号,分配角色,控制不同模块的查看权限。

一条完整业务链路是:销售登录系统 → 在客户列表里新增客户或领取公共池客户 → 给客户新增联系人 → 每次电话或拜访后填写跟进记录 → 客户状态从"线索"变为"跟进中",最终变为"已成交"或"已流失" → 管理层打开看板看整体数据。

这套链路闭合了客户从进入系统到成交流失的全过程,业务上立得住,技术上也不复杂,非常适合作为练手或交付项目。

2. 数据库设计与后端核心实现

2.1 数据库表结构设计

数据库设计是这类系统最关键的一步,表设计没做好,后面写代码全是补丁。我这个项目一共设计了5张核心表:

  • sys_user:用户表,存储账号、密码(MD5加盐加密)、姓名、角色、状态。
  • customer:客户表,存储客户名称、所属行业、客户级别、来源、状态、负责人、联系方式、地址。
  • contact:联系人表,存储联系人姓名、职位、电话、微信、邮箱,通过customer_id关联客户。
  • follow_record:跟进记录表,存储跟进内容、跟进方式、下次跟进时间,同样关联客户。
  • customer_change_log:客户状态变更日志表,记录状态从什么变成什么、操作人、操作时间。

这里有几个设计细节可以说一下。第一个是客户状态和级别字段,我用int类型配合数据字典解释,比如客户状态:0-线索、1-跟进中、2-已成交、3-已流失。为什么不直接用varchar存"跟进中"?因为int存储更省空间、查询更快,而且后续扩展状态机时,改数据字典就行,不用动表结构。

第二个是逻辑删除标志。客户数据是企业的核心资产,物理删除风险太大,一不留神把历史客户删了就找不回来了。所以我在每张业务表都加了deleted字段,默认0,删除操作执行的是UPDATE ... SET deleted = 1,查询时统一过滤deleted = 0。

第三个是时间字段全部用datetime类型,create_time、update_time由后端统一处理,保证格式一致。

建表SQL大致长这样:

CREATE TABLE `customer` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(100) NOT NULL COMMENT '客户名称', `industry` varchar(50) DEFAULT NULL COMMENT '所属行业', `level` tinyint DEFAULT 1 COMMENT '客户级别 1-普通 2-重要 3-核心', `source` varchar(50) DEFAULT NULL COMMENT '客户来源', `status` tinyint DEFAULT 0 COMMENT '状态 0-线索 1-跟进中 2-已成交 3-已流失', `owner_id` int DEFAULT NULL COMMENT '负责人ID', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `address` varchar(200) DEFAULT NULL COMMENT '地址', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `deleted` tinyint DEFAULT 0 COMMENT '逻辑删除 0-未删 1-已删', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_owner_id` (`owner_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';

注意两个索引:owner_id和status。系统里最频繁的查询是"我的客户"和"按状态筛选客户",这两个字段加索引后,数据量大的时候查询性能差距非常明显。

2.2 MyBatis动态SQL与多条件分页搜索

客户列表页是这个系统使用频率最高的页面,必然要做多条件组合搜索:客户名称模糊查询、行业精确匹配、状态筛选、负责人筛选,再加分页。如果用普通的@Select注解一个个拼SQL,能写到你怀疑人生。这时候MyBatis的动态SQL就派上用场了。

我在Mapper层配置了一个动态查询语句:

<select id="selectCustomerPage" resultType="com.example.entity.Customer"> SELECT c.*, u.real_name AS ownerName FROM customer c LEFT JOIN sys_user u ON c.owner_id = u.id <where> c.deleted = 0 <if test="name != null and name != ''"> AND c.name LIKE CONCAT('%', #{name}, '%') </if> <if test="industry != null and industry != ''"> AND c.industry = #{industry} </if> <if test="status != null"> AND c.status = #{status} </if> <if test="ownerId != null"> AND c.owner_id = #{ownerId} </if> </where> ORDER BY c.update_time DESC LIMIT #{offset}, #{pageSize} </select>

这里有几个关键点。<where>标签会自动处理"多余"的AND,当前面所有<if>都不成立时,<where>不会生成WHERE关键字;当有多个条件时,它也会把最前面那个多余的AND去掉。这个细节能省掉很多容易出错的字符串拼接逻辑。

LEFT JOIN sys_user是为了在一次查询里直接把负责人的真实姓名查出来,前端表格展示时不用再对用户ID做翻译。这是非常典型的"查询时冗余关联"写法,比先查客户再循环查用户要高效得多。

分页我直接用LIMIT # {offset}, #{pageSize}实现,配合前端传过来的currentPage和pageSize,在Service层计算offset = (currentPage - 1) * pageSize。这套方案写出来一目了然,不需要额外引入PageHelper插件,也避免了插件拦截SQL带来的一些隐性坑。

2.3 JWT登录鉴权与拦截器设计

客户管理系统里的客户数据是敏感的商业信息,接口必须要有权限控制。我用了JWT方案,对比传统的Session方案,它天然适合前后端分离场景:后端不需要存储会话状态,token自包含用户信息,每次请求带上token就行。

登录流程是这样的:

  1. 用户提交账号密码,后端校验通过后生成JWT字符串返回给前端。
  2. 前端把token存到localStorage里,每次请求在拦截器中加上Authorization: Bearer <token>请求头。
  3. 后端写一个拦截器,拦截所有/api/**请求,从请求头取token,解析并校验,通过则放行。

拦截器核心代码:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录状态已失效,请重新登录\"}"); return false; } } }

解析成功之后,我把userId和role放进request属性里,Controller层直接取,不用再查一遍用户表。这里有个实用技巧:下一步可以在Service层把用户角色拿出来做更细粒度的权限判断,比如普通销售只能看自己的客户,管理员能看全部。这个"数据权限"跟"接口权限"是两层概念,很多新手容易忽略,在客户管理系统里,数据权限才是刚需。

JWT生成工具类也很简单,我用jjwt库,密钥写死在配置文件中,有效期设24小时。

public static String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }

3. 前端Vue实现与页面交互

3.1 前端工程结构与路由守卫

前端我用Vue 3 + Vite + Element Plus + Axios + Pinia这套组合。Vite比Webpack快太多,开发时热更新基本秒开,对于迭代频繁的管理后台,开发体验提升很明显。

项目结构上,我按模块拆目录:

src/ ├── api/ # 接口请求封装 ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── views/ │ ├── login/ # 登录页 │ ├── layout/ # 主布局 │ ├── customer/ # 客户管理 │ ├── contact/ # 联系人管理 │ ├── follow/ # 跟进记录 │ └── dashboard/ # 数据看板 ├── components/ # 公共组件 └── utils/ # 工具函数

路由配置我设置了登录页作为公开路由,其余页面全部挂在主布局下,通过路由守卫做登录验证。这里用一个前置守卫判断localStorage里有没有token,没有就强制跳转登录页。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })

一个容易忽略的小坑是:如果token过期,前端路由守卫是发现不了的(因为token还在localStorage里),页面会白屏。所以我在Axios响应拦截器里统一处理401状态码,收到401就清除本地token并跳转登录页,保证用户体验不卡壳。

3.2 客户列表页的核心交互实现

客户列表页是前端的重头戏,承载了最多的业务操作。我拆成两个组件:一个搜索表单区,一个表格区。

搜索表单用Element Plus的el-form配合el-select、el-input、el-date-picker,点击"查询"按钮时把表单数据传到父组件,父组件更新查询参数并调接口。这里我踩过一个坑:表单绑定的是响应式对象的引用,如果不做深拷贝,修改表单值会直接污染当前查询条件。所以每次查询前先JSON.parse(JSON.stringify(searchForm))做一个拷贝再传给接口。

表格区用el-table展示客户数据,列包括客户名称、所属行业、客户级别、状态、负责人、更新时间。状态列我用el-tag配合不同的type来展示,不同颜色区分状态:

<el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="statusMap[row.status].type">{{ statusMap[row.status].label }}</el-tag> </template> </el-table-column>

statusMap放在一个常量文件里统一维护。

分页用el-pagination,配置好current-page、page-size、total三个属性,监听current-change和size-change事件重新拉数据。这里要注意:分页组件绑定的值必须是数字,不能是字符串,否则切换页码时会出诡异的bug,这是Element Plus最常见的坑。

新增和编辑共用同一个弹窗表单组件,根据传入的是不是row来判断是新增还是编辑。表单校验规则每个字段都配上,name必填、phone用正则校验手机号格式,这样能把脏数据挡在接口之前,比后端校验响应更快、体验更好。

3.3 跟进记录时间线与状态联动

跟进记录这个模块,我用的是客户详情抽屉的方式实现:点击客户列表的"跟进"按钮,右侧弹出一个el-drawer,上半部分展示客户基本信息,下半部分展示时间线式的跟进记录。

时间线用Element Plus的el-timeline组件,每条记录显示跟进内容、跟进方式、跟进人和时间。新增跟进记录时,表单里有一个"跟进后客户状态"下拉框,这是业务联动的关键点:销售填完跟进记录后,顺手更新客户状态,一条操作同时完成两件事。

实现方式是:新增跟进记录的接口,后端同时接收followRecord和customerStatus两个参数,事务里先插入跟进记录,再更新客户状态和update_time。如果更新状态失败,插入的跟进记录也会跟着回滚,不会出现"写了跟进但状态没变"的数据不一致情况。这个逻辑非常简单,但很实用——很多系统做一个跟进操作要开两个页面,数据还总是对不上,就是没有做联动设计。

4. 项目启动与环境配置

4.1 数据库初始化与配置文件模板

拿到源码后第一步是初始化数据库。我把建表SQL和初始数据统一放在项目根目录的sql/init.sql文件里,用Navicat或命令行执行一遍就好。初始数据里包含一个管理员账号(admin)和一个普通销售账号(user01),密码统一是123456,登录后可以自己改。

后端配置集中在application.yml,核心配置模板如下:

server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/crm_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key-here expire-hours: 24

重点说几个配置项。map-underscore-to-camel-case这个必须设为true,这样数据库的owner_id字段能自动映射到实体的ownerId属性,否则你会看到一堆查询结果为null的诡异问题。

serverTimezone=Asia/Shanghai必须要加,不然后端通过JDBC连MySQL时,会因为时区不一致报错或者时间错8个小时。这个问题在不同版本MySQL上都有出现过,属于JDBC连库的标配参数。

4.2 后端启动要点与常见报错

后端启动前要确认三个东西:JDK版本(我用JDK 11及以上)、Maven配置(确保能正常下载依赖)、MySQL服务已启动。

启动命令没什么好说的,mvn spring-boot:run或者打包成jar后java -jar target/crm-system.jar都行。第一次启动会下载大量依赖,需要耐心等。

我整理了几个启动阶段一定概率遇到的报错:

第一个是Invalid bound statement (not found)。这个错误90%的原因是MyBatis的mapper接口和XML文件没有匹配上。检查三处:Mapper接口的包路径和XML文件里的namespace是否一致;mapper接口的方法名和XML里的id是否一致;application.yml里的mapper-locations能否匹配到XML文件路径。

第二个是Failed to configure a DataSource。这通常是数据库配置没生效,要么是url写错,要么是MySQL没启动。先把localhost:3306在命令行里ping通再启动项目。

第三个是端口占用。8080被占了就改server.port,或者直接杀掉占用的进程。

4.3 前端启动要点与代理配置

前端启动相对简单,但有一件事必须做:配置Vite的代理,解决开发环境的跨域问题。

后端接口地址是http://localhost:8080/api,前端开发服务器是http://localhost:5173。如果前端直接请求后端接口,会触发浏览器的CORS跨域限制。虽然可以在后端写@CrossOrigin注解或者全局CORS配置来放行,但更好的方案是在vite.config.js里配置代理,让前端开发服务器帮忙转发请求:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端代码里请求/api/customer/list,开发服务器会转发到http://localhost:8080/api/customer/list。配置代理的好处是:前端代码不用写死完整的后端地址,等上线部署时,只需要调整Nginx的转发规则,前端代码一行都不用改。

前端启动步骤:

npm install npm run dev

npm install阶段如果卡住或者报错,大概率是网络问题,换成国内镜像源再试。Node版本建议用16以上,Vite 5要求Node 18+,太老的版本跑不起来。

5. 常见问题与排查技巧实录

5.1 跨域问题的三种解法怎么选

跨域问题在前后端分离项目里绕不开。除了我在前面提到的Vite代理方案,还有两种常见解法。

第一种是后端全局CORS配置,在SpringBoot里写一个配置类:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(Registry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

CORS配置适合开发阶段临时联调,生成环境不建议完全放开,因为allowCredentials(true)配合allowedOriginPatterns("*")在某些浏览器版本上有安全隐患。

第二种是部署阶段用Nginx做反向代理,前端静态资源和后端接口都挂在同一个域下,从根本上消除跨域。生产环境推荐这种方式,因为Nginx还能顺手做静态资源缓存、gzip压缩、负载均衡。有一说一,Nginx这一层真的是前后端分离项目的标准配置,值得多花点时间搞定。

5.2 前端页面白屏与接口401循环跳转

项目跑起来后经常遇到一个现象:登录成功后,用户刷新页面,接口报401,Axios拦截器跳登录页,登录成功后回到首页,再刷新又跳。这种"登录死循环"很让人抓狂。

根因是:用户刷新后,前端没有检测token有效性就直接进首页,首页发起一堆后台接口请求,此时token过期或缺失,收到401就跳到登录页。问题在于,跳转登录页的时候,如果当前路由正好是登录页,就会形成死循环。

我采用的解决办法是在路由守卫里增加一个标记判断:如果是401导致的跳转,跳转前把token清除并加上redirect参数,登录成功后再带redirect参数回到原来页面,同时给登录页一个特殊处理——如果localStorage里有token且能通过一个校验接口,就直接跳首页。

更彻底的做法是在Axios响应拦截器里加一个"防抖"逻辑:多个接口同时返回401时,只执行一次跳转,避免反复跳转和接口重复请求。设置一个isRedirecting标志位,为true时直接return,跳转完成后重置。

let isRedirecting = false service.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { if (!isRedirecting) { isRedirecting = true localStorage.removeItem('token') router.push('/login') setTimeout(() => { isRedirecting = false }, 1000) } } return Promise.reject(error) } )

这个方案实测下来可以很好地消除登录死循环和重复跳转,值得直接抄。

5.3 数据库时间字段相差8小时的坑

这是一个让很多人排查到崩溃的问题:数据库里存的时间是正常的,接口返回给前端的时间却差了8个小时。

原因大概率出在时区配置上。JDBC连接串里虽然已经设置了serverTimezone=Asia/Shanghai,但Jackson序列化时间时用的还是JVM默认时区,如果JVM运行环境使用的是UTC或GMT时区,就会导致输出时间比数据库里的时间早8小时。

最快的解决办法是在application.yml里设置Spring Boot的时间格式和时区:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

如果接口返回的是一个自定义对象里的LocalDateTime类型,Jackson需要额外引入jackson-datatype-jsr310依赖并注册JavaTimeModule,否则LocalDateTime序列化会直接报错。

5.4 打包部署后静态资源404

开发环境一切正常,打包部署后却发现访问页面显示404,或者样式、JS文件找不到。这个问题我遇到不止一次,原因通常是打包路径配置不对。

Vite默认的base路径是/,意味着打包后的静态资源引用路径是/assets/xxx.js。如果项目部署在一个子路径下,比如http://ip:8080/crm/,那么/assets就会指向根目录,自然找不到文件。

解决办法是在vite.config.js里设置base: './',让资源引用使用相对路径。这样打包后的index.html里引用资源就会是./assets/xxx.js,无论部署在哪个子路径下都能正常访问。

后端SpringBoot打包部署时,如果想把前端静态资源和后端打成同一个jar包,需要把前端打包后的dist目录内容复制到src/main/resources/static下。注意:如果后端有context-path: /api,那么前端接口请求地址会变成/api/xxx,但静态资源访问地址还是根路径/,两者不冲突,Nginx配置时需要分别处理。

经验总结

这套系统跑通之后,我个人比较深的体会有几个。

一是设计先行确实比写代码更重要。这个项目如果一上来就写Controller和页面,大概率写到一半就要推翻。但先把表结构、状态流转、模块链路想清楚,后面每一步都是顺着业务逻辑走,效率反而高。

二是技术栈不追求新,追求稳。SpringBoot+Vue+MyBatis+MySQL这套组合,2025年依然是无数企业内网系统的真实选择。你把这套吃透,去公司做业务系统、接外包项目,碰到的基本都是类似的架构。

三是前后端联调阶段是踩坑重灾区。跨域、token、时间格式、打包路径,这些问题的出现频率远超业务逻辑本身。把这些"工程化"问题理清楚,比多写一百行CRUD代码有价值得多。

如果后续要扩展,这套系统的方向也很明确:可以加上客户批量导入导出(EasyExcel)、数据权限按部门细分、客户查重机制、跟进计划提醒,甚至可以引入消息队列做操作日志异步记录。基础架子没歪,往上加功能不费劲。

最后再分享一个写代码之外的技巧:给这个项目配好一个README文档,把数据库初始化步骤、前后端启动命令、账号密码说明写清楚。别小看这件事,一个项目能不能"用起来",取决于文档清不清楚。我见过太多项目代码写得不错,结果别人接手时卡在环境搭建上白白浪费一天时间。把这半小时花掉,后面省下的时间远不止半小时。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进