去年年底,一个做二手车门店的朋友找我说,店里还在用Excel管车辆、管客户,成交对账全靠人工,想上一套能在网页上操作的交易管理系统。我给他定的方案就是SpringBoot+Vue+MyBatis+MySQL这套主流组合,前后端分离,接口、页面、数据库一起落地。这篇我把整套系统的设计和实现过程完整拆一遍,从表结构到接口,从Vue路由到打包部署,覆盖开发中真正会踩的坑,适合正在做毕业设计、项目实战或者接私活搭管理后台的同学直接参考。
1. 系统整体设计与技术选型思路
1.1 前后端分离架构:一个后台两种角色
二手车交易系统本质上不是一个纯展示网站,而是“前台门户 + 后台管理”的组合体。买车的人要看车辆列表、车源详情、预约看车、收藏对比;门店管理员要做车辆入库、审核上下架、处理订单和客户信息。这两种角色的操作频率、页面复杂度和权限范围完全不同,硬塞在一个单体页面里会非常痛苦。
所以我把系统拆成前后端两层。Vue这一层只负责渲染和交互,不管数据从哪来,用户点按钮、翻页、填表单,都是发HTTP请求,拿到JSON后更新界面。SpringBoot这一层只提供接口,不关心页面长什么样,做参数校验、业务处理、数据库读写,最后统一返回Result对象。两边通过RESTful接口通信,联调时只要约定好code、msg、data三段式返回结构,前端就能独立开发,后端也能单独测试。
实际项目里我还会把前端分成两类路由:普通用户能访问的车辆浏览、预约、下单页面,和需要管理员权限的车辆管理、订单审核、数据统计页面。前端路由做一层跳转拦截,后端接口再做一层权限校验,两层都过了才算有权限,这也是前后端分离项目里比较标准的做法。
1.2 为什么选这套技术栈,不选更“新”的框架
很多人问我,现在新框架这么多,为什么还用SpringBoot+Vue这套老组合?其实选技术栈不是追新,是看成本。SpringBoot最值钱的地方是把Spring繁琐的XML配置全干掉了,起步依赖自动装配,写一个Controller就能直接跑起来,对中小型管理系统来说迭代效率极高。MyBatis则是专门为“复杂查询”设计的,二手车交易系统最核心的痛点就是条件筛选,品牌、价格区间、里程、变速箱、燃油类型、车况等级,这些条件排列组合非常多,MyBatis动态SQL能用if标签拼出任意组合的查询,比JPA那种先查全量再内存过滤的方案靠谱得多。
Vue就不用多说了,组件化开发很适合后台系统这种“表格+表单+弹窗”重复度极高的场景,配合Element Plus组件库,列表页、详情页、编辑弹窗基本是搭积木。MySQL做关系型数据存储稳定、免费、资料多,随便搜一下mysql安装教程都能跑起来,小团队自己维护完全没压力。
这套组合唯一要注意的是版本匹配。源项目如果用SpringBoot 2.7,就老老实实用JDK 8或11;如果你自己新建SpringBoot 3.x,JDK版本需要17以上,而且javax.*包名要改成jakarta.*,很多老代码不能直接搬。新手在springboot版本太高、项目启动直接报错这个问题上翻车的特别多,后面我会单独讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心功能模块与数据库设计
2.1 功能模块梳理
做管理系统的第一步不是写代码,是把功能边界画清楚。我按角色把整个系统拆成下面这些模块:
| 模块 | 面向角色 | 核心功能 |
|---|---|---|
| 用户认证 | 所有用户 | 注册、登录、Token校验、注销 |
| 车辆管理 | 管理员 | 新增车辆、编辑车源、图片上传、上下架、审核 |
| 车辆浏览 | 普通用户 | 车辆列表、品牌价格筛选、儿童锁关键字搜索、详情展示 |
| 收藏对比 | 普通用户 | 收藏车辆、查看收藏列表、取消收藏 |
| 预约看车 | 普通用户 | 提交预约、选择时间、管理员查看预约记录 |
| 订单交易 | 所有用户 | 创建订单、支付登记、成交状态流转 |
| 客户管理 | 管理员 | 客户档案、跟进记录、历史成交查询 |
| 数据统计 | 管理员 | 成交量、销售额、热门品牌排行 |
功能模块定清楚之后,前端Vue路由表、后端Controller层、数据库表三者的映射关系就很好设计了。每个模块对应一到两个表,每个表对应一套CRUD接口,页面有操作“新增”就对应POST接口,有操作“查询列表”就对应GET分页接口,这个思路在做的过程中能省很多沟通成本。
2.2 数据库表设计要点
数据库是整套系统的地基。二手车交易系统最核心的车辆表,我实际设计是这样的:
sql复制CREATE TABLE car_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '车辆ID',
car_no VARCHAR(32) NOT NULL COMMENT '车辆编号',
brand_name VARCHAR(32) NOT NULL COMMENT '品牌',
series_name VARCHAR(32) COMMENT '车系',
model_year VARCHAR(16) COMMENT '年款',
mileage DOUBLE COMMENT '里程(万公里)',
price DECIMAL(12, 2) COMMENT '售价',
gearbox VARCHAR(16) COMMENT '变速箱',
fuel_type VARCHAR(16) COMMENT '燃油类型',
color VARCHAR(16) COMMENT '车身颜色',
car_level TINYINT DEFAULT 3 COMMENT '车况等级',
status TINYINT DEFAULT 0 COMMENT '状态:0待审核 1在售 2已下架 3已成交',
description TEXT COMMENT '车辆描述',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这里有三个设计点是经常被忽略的。第一,status状态字段必须用TINYINT而不是字符串,状态流转用数字写起来简单,查询也快。第二,create_time一定要默认值,并且加索引,因为列表页最常用的排序就是order by create_time desc,没索引的表数据量一上去就慢。第三,价格用DECIMAL(12,2),不要用FLOAT或DOUBLE,浮点算钱容易出精度问题。
除了车辆表,还需要customer客户表存买车人信息,order_info订单表存成交记录,favorite收藏表存用户和车辆的关联关系,appointment预约表存看车预约,sys_user用户表存登录账号和角色。我的建议是订单表一定要单独设计order_no唯一订单号字段,并且加唯一索引,用数据库兜底防止并发下产生重复订单。
整个数据库建完之后,记得把初始化SQL脚本统一放到sql/init.sql文件里。项目给别人复现的时候,只需执行这一个脚本就能把表结构、初始管理员账号、测试数据全部建好,省掉一堆环境沟通。
3. 关键模块落地实操
3.1 后端核心接口:SpringBoot + MyBatis 动态SQL
后端代码我按Controller、Service、Mapper三层来组织。Controller层只管接收参数和返回结果,Service层做业务校验和事务控制,Mapper层只负责数据库操作。每个接口都返回统一的Result对象:
java复制public class Result<T> {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.code = 200;
r.msg = "success";
r.data = data;
return r;
}
public static <T> Result<T> error(String msg) {
Result<T> r = new Result<>();
r.code = 500;
r.msg = msg;
return r;
}
}
前端每次拿到响应先判断code是不是200,不是就直接弹错误提示。这样接口报错和正常返回走同一套逻辑,联调时基本不会出现“前端拿到一堆HTML错误页,不知道怎么处理”的情况。
车辆列表查询是整套系统的核心接口,我直接用MyBatis的动态SQL实现多条件筛选:
xml复制<select id="pageCars" resultType="com.cartrade.entity.Car">
select * from car_info
<where>
<if test="brandName != null and brandName != ''">
and brand_name like concat('%', #{brandName}, '%')
</if>
<if test="minPrice != null">
and price >= #{minPrice}
</if>
<if test="maxPrice != null">
and price <= #{maxPrice}
</if>
<if test="mileage != null">
and mileage <= #{mileage}
</if>
<if test="status != null">
and status = #{status}
</if>
</where>
order by create_time desc
limit #{pageNum}, #{pageSize}
</select>
这里为什么用<where>标签而不是直接在SQL里写where 1=1?因为<where>会自动处理第一个条件前面的and,多个条件同时为空时,它连where关键字都不会输出,能直接退化成全表查询。分页我这次没用PageHelper插件,而是手动算pageNum和pageSize,因为项目就一个分页场景,引插件有点重;当然你觉得麻烦,直接用PageHelper也没问题,看项目规模定。
动态SQL的用法,你是供应商那种固定条件多的查询,一定要配合好参数校验。比如minPrice和maxPrice同时传了,后端要校验minPrice <= maxPrice,否则查出来的数据就是空。这种逻辑放在Service层做,不要指望前端帮你挡,接口是给任何客户端用的,不能信任前端传参。
3.2 Vue前端:路由、Axios封装和列表页
前端我用Vue3 + Vue Router + Element Plus。Vue Router配置的核心是路由拦截,用户没登录的时候,所有需要权限的页面一律重定向到登录页:
javascript复制// router/index.js
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next({ path: '/login' })
} else {
next()
}
})
Axios请求封装是另一个关键点。我的习惯是统一配置baseURL和响应拦截器,拦截器里统一处理业务错误。前端所有请求都走这同一个封装,代码行数能砍掉一大半:
javascript复制import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = token
}
return config
})
request.interceptors.response.use(res => {
if (res.data.code === 200) {
return res.data
}
return Promise.reject(new Error(res.data.msg))
}, err => {
return Promise.reject(err)
})
export default request
列表页是后台系统最典型的页面,用Element Plus的el-table加el-pagination就能快速搞定:
vue复制<template>
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="carNo" label="车辆编号" />
<el-table-column prop="brandName" label="品牌" />
<el-table-column prop="price" label="售价" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
{{ statusMap[row.status] }}
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="pageNum"
:total="total"
@current-change="loadData"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import request from '@/utils/request'
const tableData = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const loadData = async () => {
loading.value = true
const res = await request.get('/car/page', {
params: { pageNum: pageNum.value, pageSize: pageSize.value }
})
tableData.value = res.data.rows
total.value = res.data.total
loading.value = false
}
onMounted(loadData)
</script>
这段代码里最容易出错的就是返回数据层级。后端返回Result对象时,data字段里才是真正的分页数据,如果你在后端把rows又包了一层,前端就很容易写成res.data.data.rows,联调阶段最频繁的问题基本都是这种层级对不上。
3.3 权限控制与订单事务处理
权限控制我用的JWT方案。用户登录成功后,后端生成一个Token返回给前端,前端存到localStorage,每次请求用Axios拦截器加上Authorization请求头。后端做一个HandlerInterceptor,对需要权限的接口校验Token合法性。
这里有一个很值得说的点:前端路由拦截只是体验优化,真正防不住人。懂点前端的人打开浏览器的开发者工具,改一下localStorage就能跳过后端路由拦截。所以后端接口必须再做一次Token校验,两层缺一不可。我的习惯是登录接口和注册接口放行,其余车辆查询接口也要放行,但订单创建、车辆审核这些操作才需要管理员权限。
订单提交接口涉及到两张表:订单表要插入一条记录,同时车辆表的状态要从“在售”改成“已成交”。这两个操作必须放在同一个事务里,否则就可能出现订单创建成功但车辆状态没改,或者状态改了订单丢了。用Spring的@Transactional注解,Service方法跑任何一个步骤抛异常,整个事务都会回滚:
java复制@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderForm form) {
// 生成订单号
String orderNo = "CT" + System.currentTimeMillis();
orderMapper.insert(...);
// 更新车辆状态
carMapper.updateStatus(form.getCarId(), 3);
return orderId;
}
事务回滚的前提是异常能被Spring感知。如果你在方法内部自己catch掉了异常,Spring是不知道的,事务照样不会回滚。这个坑我在代码review时见过很多次,大家记住一个原则:事务方法里不要自己吞异常,该抛就抛。
4. 环境搭建与部署:从零跑通项目
4.1 环境准备与版本匹配
拿到一份源码,第一件事不是导入,而是核对环境版本。整套系统的推荐环境如下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 / 11 | 对应SpringBoot 2.7 |
| Maven | 3.6+ | 后端依赖管理 |
| MySQL | 5.7 / 8.0 | 8.0需要新版JDBC驱动 |
| Node.js | 16.x | Vite3和Vue3工具链 |
| IDEA / VSCode | 任意 | 后端推荐IDEA |
最典型的问题就是springboot版本太高导致启动失败。如果项目POM文件里是SpringBoot 2.7.x,你必须用JDK8或11;如果POM是3.x,就要用JDK17,并且检查代码里有没有javax.servlet之类的旧包名。很多小白把项目从3.x降到2.7,却不换JDK版本,结果启动各种ClassNotFound,先骂代码后骂框架,其实是环境不对。
MySQL安装我多说一句。Windows平台直接下MySQL Installer,一路Next就行;Linux平台用rpm包安装然后初始化mysqld,记得设置root密码后建一个专门给项目的数据库账号,不要用root跑业务。数据库创建用UTF8MB4字符集,不然中文显示乱码。
4.2 初始化数据库与启动前后端
环境准备好之后,按下面顺序操作:
- 新建数据库
car_trade,执行项目里的sql/init.sql。 - 修改
application.yml里的数据库连接信息,用户名密码改成自己的。 - 在项目根目录执行
mvn spring-boot:run启动后端,看到Tomcat started on port 8080就成功了。 - 前端目录执行
npm install装依赖,再执行npm run dev启动开发服务器。 - 浏览器访问前端地址,登录测试。
npm install在国内经常卡住,网络慢或者依赖版本冲突时,建议先删掉node_modules和package-lock.json再重新装。如果你用Vite,默认端口是5173,后端是8080,本地开发必须解决跨域。最简单的方式是在Vite配置里加代理:
javascript复制// vite.config.js
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这样前端页面里所有/api开头的请求都会转发到后端,浏览器侧看不到跨域问题,联调体验和正式环境基本一致。
4.3 Vue打包放进SpringBoot的两种部署方式
项目做完之后要上线,常见两种部署方式。第一种是前后端分开部署,前端npm run build生成dist目录,扔到Nginx的HTML目录,Nginx再把/api请求代理到后端的8080端口。第二种是前端打包产物直接放进SpringBoot项目里,这样一个Java进程就能同时服务页面和接口,适合小项目或者演示环境。
第二种方式很多人搞错,以为把dist整个文件夹复制进SpringBoot就行。正确做法是把dist里面的index.html和static目录直接拷到src/main/resources/static目录下,然后重新打包:
bash复制npm run build
cp -r dist/* src/main/resources/static/
mvn clean package -DskipTests
java -jar target/car-trade.jar
启动后浏览器访问http://localhost:8080,Nginx都不需要,直接就能打开页面。但要注意,Vue Router如果用的是history模式,直接访问/car/list这种前端路由会404,因为刷新时请求发到了后端但没有对应的Controller处理。解决方式有两个:后端加一个forward到index.html的兜底Controller,或者前端改用hash模式,地址变成/#/car/list,更省事。小项目我建议直接用hash模式,少折腾。
5. 常见问题与排查技巧实录
5.1 启动与配置类问题
跑项目最痛苦的不是写功能,是环境起不来。我整理了这份项目里出现频率最高的问题和解决方案:
| 问题现象 | 根本原因 | 处理方案 |
|---|---|---|
| 启动报MySQL连接失败,时区错误 | JDBC连接串缺少serverTimezone |
URL加上serverTimezone=Asia/Shanghai |
| 端口被占用,Tomcat启动失败 | 8080被其他进程占用 | 改server.port,或用netstat查端口杀进程 |
| 找不到Mapper方法 | MyBatis没有扫描到Mapper接口 | 启动类加@MapperScan("com.cartrade.mapper") |
| 执行SQL报字段找不到 | 数据库没初始化或表名不对 | 执行sql/init.sql,检查实体类字段和表字段对应 |
| 中文乱码 | 数据库字符集问题 | 建库时用utf8mb4,连接串加characterEncoding=utf8 |
这里我想强调一下报错信息要看完整。很多人一看到红色报错就慌,直接复制第一行去搜,反而搜不到。SpringBoot的报错信息真正有用的通常在最后几行,比如“Caused by”后面的内容,那才是真正的根因。先看最后十行,再决定怎么处理,效率会高很多。
5.2 前后端联调与页面问题
后端跑起来了,前端页面却出不来数据,是联调阶段最头疼的。常见的坑有三个。
第一个是跨域。明明后端接口用Postman测试没问题,浏览器访问却报跨域错误,那是因为浏览器有同源策略。用我在4.2节说的Vite代理可以解决开发环境跨域,如果不用代理,就得在后端加CorsFilter配置允许跨域。
第二个是数据层级对不上。后端返回Result对象的时候,接口真正的数据在data字段里。前端如果直接用res.data拿分页列表,拿到的其实是Result对象而不是rows数组,渲染就空白。建议在后端写接口时就把返回结构固定好,返回分页就统一包成{ rows, total },前端封装里约定好层级,能减少大量无意义沟通。
第三个是Vue打包部署后的路由404。这个问题在4.3节提过,再补充一下:如果你部署到Nginx,history模式需要在Nginx配置里加try_files $uri $uri/ /index.html;,否则用户刷新子页面就报404。我见过很多项目本地开发好好的,一上线就白屏,八成就是这个原因。
5.3 MyBatis缓存与查询优化
项目跑通之后,很多人会问MyBatis缓存到底要不要开。MyBatis默认的一级缓存是SqlSession级别的,同一个SqlSession内执行两次相同的查询,第二次会命中缓存不查库。但Spring管理的事务里,SqlSession生命周期跟着事务走,这个缓存对实际项目的提升有限。
MyBatis二级缓存配置起来很简单,在Mapper XML里加一行<cache/>就行,缓存范围是整个Mapper命名空间。但我要提醒一句:二手车交易系统这种数据变更频繁的系统,不要盲目开二级缓存。因为如果两个Mapper都操作同一张表,缓存里的数据很可能变成脏数据。我在实际项目里的习惯是只对“品牌列表”“车况等级字典”这种几乎不变的基础数据开二级缓存,核心的车辆查询、订单查询都不开,靠MySQL索引和SQL优化来保障性能。
查询优化方面,最常用的三个手段:第一,列表查询只查需要的字段,不要select *全字段拉出来;第二,status、brand_name、create_time这些高频查询字段加索引;第三,价格筛选、里程筛选这种范围查询,尽量让MySQL走索引范围扫描。数据量到几十万条的时候,这三个优化比换什么框架都管用。
这套二手车交易系统做完之后,我又陆续加过图片上传、成交数据报表、预约看车提醒这些模块。整个流程走下来,最大的体会是:管理系统不怕功能多,怕的是表结构设计不合理,后面每一个改动都在填前面的坑。如果你也打算用SpringBoot+Vue这套组合做类似的管理系统,先把表字段和状态流转想清楚,再动手写接口,后面真的能少加很多班。
