基于Spring Boot和Vue的二手车交易管理系统全栈开发实战
2026/9/9 1:27:09 网站建设 项目流程

二手车交易这种事,需求方一开始找我的时候,就带着很典型的期望:要有用户端、要有管理后台、要能发车、要能看车、最好还能下订单。听起来不复杂,但真正做起来,从数据库到前后端联调,每一步都有坑。这个项目我是基于 Spring Boot + Vue 做的全栈分离实现,核心走的是经典也是最稳的一套技术栈:后端 Spring Boot + MyBatis-Plus + MySQL,前端 Vue + Element UI,鉴权用 JWT。折腾下来一个完整的二手车交易管理系统是能跑通的,这篇文章我就把整个设计思路、关键实现、还有踩过的坑都整理出来,给正在做类似管理系统或毕设项目的朋友一个参考。

1. 项目整体设计与技术选型思路

1.1 这个系统到底要解决什么问题

做系统之前,先把业务边界想清楚很重要。二手车交易管理系统不是简单的“车辆信息展示”页面,它至少要覆盖三类角色的协同工作:普通游客或买家要看车、搜车、收藏、下单;卖家或管理员要发布车辆、管理车源状态、处理订单;平台运营方要能看到所有交易数据、管理用户和车辆上架下架。

我设计的时候把系统分成前台和后台两块。前台是面向买家的,包括注册登录、车源列表、车辆详情、条件搜索、预约看车、下单购买、订单查询这些功能。后台是面向管理员或车商的,包括车辆管理、订单审核、用户管理、数据看板等。前后台共用一套后端接口,前端用两个不同的路由模块来承载,这样复用性高,也省去维护两套后端的成本。

这个系统适合谁来参考?如果你是正在做 Java 全栈项目、或者准备毕业设计、又或者想接一个小型管理系统外包,这套划分思路和代码组织方式都值得参考。它不炫技,但胜在结构清晰、流程完整、该有的业务闭环都有。

1.2 为什么偏偏选 Spring Boot + Vue 这套组合

技术选型上,我没有去追新,直接选了最稳定的组合:后端 Spring Boot 2.7.x,前端 Vue 2 + Element UI,数据库 MySQL 5.7+,持久层 MyBatis-Plus,鉴权用 JWT + Redis 可选。有人会问,为什么不用 Spring Boot 3.x 或者 Vue 3?这里说下我的实际考量。

Spring Boot 2.7 目前生态最成熟,网上资料多,遇到问题最容易搜到答案,而且对 JDK 8 的兼容性最好。很多服务器环境还停留在 JDK 8,用 Spring Boot 3 要求 JDK 17,虽说新版性能更好,但这个项目里没必要冒兼容性的风险。Vue 2 同理,Element UI 和 Vue 2 的配合已经非常成熟,任何表格、表单、弹窗需求都能快速找到组件方案,开发效率高,对于管理系统这类重交互但轻渲染的业务场景完全够用。如果是新学 Vue 3 的人,也可以把这里的 Vue 2 写法无障碍迁移到组合式 API,核心思路是一致的。

数据库选 MySQL 就不用多说了,开源免费、生态好、运维简单,应付这种体量的系统绰绰有余。持久层用 MyBatis-Plus,最大好处是不用写大量简单的 CRUD SQL,内置的BaseMapper直接帮忙搞定单表操作,复杂查询再自定义 XML SQL,开发效率提升非常多。

2. 数据库设计与核心表结构

2.1 车源表:整个系统的数据基石

车辆信息表是整个系统的核心,几乎所有业务都围绕它展开。设计这张表时,我不仅考虑了展示字段,还考虑了搜索效率和后期的扩展。

表名我定义为car_info,核心字段包括:车辆标题、品牌、车系、车型、上牌日期、行驶里程、排量、变速箱类型(手动/自动)、排放标准、车身颜色、新车指导价、售价、车辆所在地、车况描述、图片URL、上架状态(0下架 1上架 2已售)、审核状态、卖家ID、发布时间等。

字段类型上特别注意几点:价格用DECIMAL(10,2),别用FLOAT,否则会有精度问题;里程用INT存公里数,方便区间查询;状态字段用TINYINT存数字,别用字符串,索引效率更高;描述类字段用TEXT;图片存的是 URL 字符串,一张主图加多张详情图可以单独建一张car_image表,也可以用逗号分隔存在一个字段里。

这里特别说下搜索相关的设计。品牌和车系是高频筛选条件,所以必须建索引。我是这样建的组合索引:

ALTER TABLE car_info ADD INDEX idx_brand_series (brand, series); ALTER TABLE car_info ADD INDEX idx_status_price (status, price);

这样处理之后,最常出现的“按品牌和价格区间筛选在售车辆”查询就能走索引,性能非常理想。

2.2 用户、订单和收藏:构建业务闭环

只有一个车源表远远不够,交易系统必须有围绕人的数据模型。我总共建了 8 张表:

用户表sys_user存放账号、密码、昵称、手机号、角色(1管理员 2车商 3买家)、头像、创建时间。密码必须加密存储,我用的是 BCrypt,天然带盐值,每次加密结果都不同,安全性比 MD5 高一个量级。注册时后端统一加密,登录时用BCryptPasswordEncodermatches方法校验。

订单表order_info是交易闭环的关键,字段包括订单编号、买家ID、车辆ID、成交价格、订单状态(0待支付 1已支付待过户 2已完成 3已取消)、下单时间、支付时间、完成时间。订单编号我建议用时间戳加随机数生成,保证唯一性,比如:

String orderNo = "ORD" + System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000));

收藏表user_favorite记录用户收藏的车辆,联合唯一索引(user_id, car_id)防止重复收藏。预约看车表test_drive_appointment存储用户预约的看车时间和状态。车源图片表car_image存车辆多张图片的 URL 和排序值。公告表、反馈表这类辅助表就不细说了,按业务需求加即可。

设计数据库时,有一个关键心得:不要在初期把表设计得太死,一定要预留扩展余地。比如车源表我一开始没加“是否支持分期”,后来业务方提需求要加,好在表结构还算宽松,加字段成本不高。另外,所有业务表我都添加了create_timeupdate_time两个字段,这在排查数据和做分页排序时非常有用。

3. 后端核心模块实现

3.1 工程分层与统一返回格式

后端工程我按经典的三层结构来分包:controller、service、mapper、entity、config、common、dto。实体类对应数据库表,DTO 用来接收前端入参,VO 用来返回前端数据,避免直接把数据库实体暴露给前端,这样后期字段调整影响面小。

一个很重要的设计是一开始就要定好统一返回格式。我封装了一个Result类:

public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

所有接口都返回Result包装的对象,前端统一做解包处理。这样做的最大好处是,前端 axios 的响应拦截器里只要判断code === 200就能进入正常流程,异常信息统一弹提示,代码写起来极度舒服。如果没有统一格式,每个接口各返回各的,前端联调时会被逼疯。

3.2 JWT登录认证与权限拦截

登录模块看起来简单,但它是整个系统的安全大门。我用的方案是 JWT + 拦截器。

用户登录成功后,后端用jjwt库生成 token,里面放用户ID和角色信息,设置 24 小时过期:

String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

前端拿到 token 后存储在 localStorage,每次请求时在 axios 请求拦截器里把 token 放到 header:

service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; });

后端写一个拦截器统一解析 token,放行登录、注册、车源列表等公开接口,其他接口必须验证身份,再根据角色做权限控制:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token != null && !token.isEmpty()) { try { Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); request.setAttribute("userId", Long.parseLong(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; }

这里有个很重要的细节是 CORS 预检请求 OPTIONS 必须直接放行,否则前端请求会在浏览器层面被拦截,报 CORS 错误。这是我调试时最容易忽略的地方,后面专门写一节讲。

3.3 车辆发布与图片上传

车辆发布是车商端最核心的操作。前端表单提交会带着车辆基本信息和多张图片,后端接收时要分两步处理:先把图片上传到服务器指定目录,再把图片 URL 和车辆信息一起写入数据库。

图片上传我实现了一个通用接口,用MultipartFile接收文件,保存到服务器的/upload/car/目录,然后用 UUID 重命名文件,防止文件名冲突和路径注入问题:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; String uploadPath = System.getProperty("user.dir") + "/upload/car/"; File dir = new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(uploadPath + fileName)); } catch (IOException e) { return Result.error("文件上传失败"); } return Result.success("/car/" + fileName); }

注意这里有个关键点:上传后返回的是相对路径,不是绝对路径。前端拿到路径后拼上服务器地址就能访问图片。同时,我还配置了静态资源映射,把本地的/upload/目录映射到 URL 的/image/**路径上:

registry.addResourceHandler("/image/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/");

这个映射如果不配置,前端<img>标签访问上传的图片就会 404。很多新手在这里踩坑,以为图片路径写错了,其实是后端没把磁盘路径暴露成 URL。

3.4 订单流程与状态管理

订单流程是整个交易系统的业务核心,也是最容易出现逻辑漏洞的地方。买家在车辆详情页点击“立即购买”,后端要先校验车辆状态是否为“在售”,防止买家拍下已下架或已售出的车。校验通过后创建订单,车辆状态暂时不变,等买家支付成功后才把车辆状态改成“已售”。

我的状态流转设计是这样的:

状态码含义触发动作
0待支付用户下单成功
1已支付待过户用户模拟支付成功
2已完成管理员确认过户完成
3已取消用户取消或超时未支付

这里我加了一个比较实用的操作:用户下单时,后端会把该车辆暂时标记为“锁定”,避免其他用户同时购买同一辆车。锁定状态有 30 分钟过期时间,到期后自动释放,前端定时轮询订单状态来判断是继续支付还是订单已取消。

模拟支付环节不用真的接支付宝或微信支付,前端弹一个支付确认框,后端把订单状态更新为已支付即可。如果后续需求要接入真实支付,只需要在支付接口里替换为第三方支付 SDK 的调用,订单处理逻辑不受影响。

车辆下架和删除也需要特殊处理。删除不是物理删除,而是逻辑删除,也就是把is_deleted字段置为 1。这样做的好处是保留全部历史数据,避免误操作导致订单关联的车辆信息查不到。MyBatis-Plus 提供了@TableLogic注解,一步就能实现逻辑删除,非常方便。

4. 前端 Vue 页面实现与联调

4.1 前端工程结构与路由设计

前端我用 Vue CLI 搭建,目录结构清晰分模块。views下面按业务划分:首页、车源列表、车辆详情、用户中心、订单中心、后台管理。组件抽到components里,接口请求统一放api文件夹。

路由的设计上,前台和后台共用基础布局,通过嵌套路由实现不同页面区域的切换。用户中心需要登录后才能访问,所以在路由守卫里做了拦截:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });

这样处理之后,用户没登录时点击“我的订单”会被自动带到登录页,登录完成后再跳回原来的页面,体验很顺滑。后台管理页面除了要求登录,还要求角色是管理员或车商,所以在路由守卫里再加一层角色判断。

4.2 Axios 封装与错误拦截

axios 封装是整个前端工程质量的关键。我做了两层处理:请求拦截器加 token,响应拦截器统一处理业务码、401 跳转、错误提示。

service.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res; } if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('未授权')); } Message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); }, error => { Message.error(error.message || '网络异常'); return Promise.reject(error); } );

这样每个页面里调用接口就非常简洁,不用每次写成功失败的重复逻辑,比如获取车源列表就是一个方法:

getCarList(params).then(res => { this.carList = res.data.records; });

4.3 车源列表,搜索筛选与分页

车源列表页是整个前台最核心的页面,用户体验好不好就看这里。我用 Element UI 的el-card做车辆卡片展示,配合el-pagination做分页,筛选栏用el-selectel-input-number

搜索功能我做了品牌下拉、价格区间、车型、排量等多个筛选条件,每个条件都是一个可选项。实现上,我需要把前端的筛选条件拼成对象传给后端:

const params = { current: this.currentPage, size: this.pageSize, brand: this.filterForm.brand || undefined, series: this.filterForm.series || undefined, minPrice: this.filterForm.minPrice || undefined, maxPrice: this.filterForm.maxPrice || undefined };

后端接口里我用 MyBatis-Plus 的LambdaQueryWrapper动态拼接条件,只有条件不为空才加入查询,这样不用写一长串的 if 判断 SQL:

LambdaQueryWrapper<CarInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(CarInfo::getStatus, 1); wrapper.eq(!StringUtils.isEmpty(brand), CarInfo::getBrand, brand); wrapper.ge(minPrice != null, CarInfo::getPrice, minPrice); wrapper.le(maxPrice != null, CarInfo::getPrice, maxPrice); wrapper.orderByDesc(CarInfo::getCreateTime);

分页返回给前端的数据结构是:总记录数、总页数、当前页数据列表。前面提到的Result包装类再套一层IPage即可。

4.4 车辆详情与下单交互

车辆详情页展示车辆完整信息、多张图片轮播、车况描述,右侧放一个价格卡片,包含“预约看车”和“立即购买”按钮。这里我做了两个实用的交互细节。

第一个是图片轮播。车辆可以有 1 到 6 张图片,用 Element UI 的el-carousel组件可以无缝轮播,前端不用额外写 JS 逻辑。

第二个是购买按钮的防重复点击。下单接口是写操作,用户如果双击按钮,会产生两条订单,业务上完全不可接受。前端我用了一个isSubmitting标志位,请求期间按钮禁用并显示 loading,请求结束后再恢复。后端在创建订单的接口里,我加了车辆 ID 和用户 ID 的唯一索引来兜底防止重复下单,虽然这种方式不是最优雅的,但这种双保险在后端审核时是加分项。

预约看车功能我也做了,用户选择日期时间和联系方式提交,后台能看到预约列表,车商可以联系买家确认时间。这里用 Element UI 的el-date-picker就能搞定日期选择,数据类型用时间戳传到后端,入库前再格式化。

5. 常见问题排查与踩坑实录

5.1 前端跨域请求与代理配置

前后端分离项目最容易遇到的就是跨域问题。开发环境下我推荐直接用 Vue CLI 的代理解决,不用后端配置 CORS,改动最灵活。在vue.config.js里配置:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };

这样前端请求/api/car/list会自动转发到后端http://localhost:8080/api/car/list。浏览器看到的请求同源,就不会有跨域报错。

生产部署时通常用 Nginx 做反向代理,把前端的/api请求转发到后端服务,同样避开跨域。不过如果后端接口需要被多个不同域名的前端调用,或者你在调试时直接改后端的接口地址访问,那就必须在后端也配置 CORS。两种方案我都实测过,总结一下:开发期用 Vue 代理最省心;部署期用 Nginx 代理最稳;后端 CORS 作为兜底方案,三者组合基本不会出问题。

5.2 图片上传后的路径无法访问

图片上传成功但页面展示不出来,是贼多新手会踩的坑。通常有三层问题:第一层是后端没配置静态资源映射,接口返回了/car/xxxx.jpg但 URL 访问 404,这个前面已经讲了,配addResourceHandler即可。第二层是前端拼 URL 的方式不对,我这里是相对路径,组件里要用process.env.VUE_APP_BASE_URL + '/image/' + car.coverUrl拼接完整地址。第三层是上传目录不存在导致文件写失败,我已经在代码里做了mkdirs()处理,但如果你是从代码库里直接拷贝代码,记得检查你的服务器运行目录是不是有磁盘写权限。

还有一个易踩坑的点:Windows 和 Linux 路径分隔符不一致,我代码里写的是/upload/car/,在 Windows 上运行没问题,但是如果你用 String 直接拼路径,在 Linux 上会出现路径少斜杠的情况,建议直接用File.separator或者统一用/拼接,然后交给 Java 处理。

5.3 查询条件为空时查出所有数据

搜索功能开发完联调时,我遇到一个很典型的 bug:用户不选任何条件直接点击搜索,理论上应该返回所有在售车辆,但实际返回结果却是空列表。排查后发现,是我的LambdaQueryWrapper条件没写对,空字符串拼了进去。

这里有一个很重要的经验:MyBatis-Plus 的条件构造器里,字符串类型判断空要用!StringUtils.isEmpty(),而不能用!= null,因为前端传过来的空字段是空字符串 "",不是 null。前端能传 undefined 不传字段最好,但很多情况下表单会自动传空字符串,后端不做处理就出问题。我统一处理前端请求参数,在 axios 请求拦截器里删掉所有为空的字段:

const params = {}; Object.keys(data).forEach(key => { if (data[key] !== '' && data[key] !== null && data[key] !== undefined) { params[key] = data[key]; } });

这样后端拿到的参数要么有值要么没有值,条件拼接简单很多,凭空少了一半 bug。

5.4 分页数据量变大后的性能问题

系统上线跑了一段时间后,车源表数据量上来了,发现列表页明显变慢。排查下来有两个原因:第一是全表扫描,没有走索引;第二是查询返回了大量不需要的字段。

第一个问题的解决方案前面已经提到,给常用筛选字段建索引。第二个问题是我们在查询列表时用 MyBatis-Plus 的select方法把整张表的所有字段都查出来了,包括car_description这种大字段,这在列表页完全没必要。优化的办法是查询时只查列表需要的字段:

wrapper.select(CarInfo::getId, CarInfo::getTitle, CarInfo::getCoverUrl, CarInfo::getBrand, CarInfo::getSeries, CarInfo::getPrice, CarInfo::getMileage, CarInfo::getRegDate, CarInfo::getStatus);

这样数据库的 IO 和网络传输量都大大减少,分页查询返回速度能提升一个档次。注意必须保留 id 和 coverUrl,点击卡片跳到详情页时要用。

真实场景中,如果你的车源表数据量非常大,还可以考虑把品牌和车系拆成字典表,用冗余字段存储编码,关联字典表查询名称,这样即使品牌调整,也不需要批量更新车源表。我这次项目早期就把品牌车系存成了纯字符串,后续如果要维护品牌列表就得改表,这是一个值得提前规划的点。

6. 项目部署与后续扩展建议

本地开发环境下,后端我用 IDEA 直接运行Application.java,前端用npm run serve启动开发服务器。但项目交付的时候,不能让人家一直跑开发模式,我整理了一套生产部署流程。

前端执行npm run build,打包出来的dist目录丢到 Nginx 的 html 目录,配置一个location块将/api转发到后端服务的8080端口。后端执行mvn clean package,打成 jar 包后用nohup java -jar xxx.jar在后台运行。数据库初始化脚本用 springboot 的schema.sqlalways模式自动执行。

部署过程中,数据库连接信息、上传目录、token 密钥这些不能写死在代码里,用application.yml配合环境变量管理:

spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:car_db} username: ${DB_USER:root} password: ${DB_PASSWORD:root}

这样换服务器部署时,只需要改环境变量,不用改代码重新打包。我吃过教训,第一次部署因为数据库密码直接写死,客户换环境后忘了提醒改配置,折腾了半天。

后面如果要扩展,建议优先做这几个方向:接入真正的支付网关而不是模拟支付,增加车辆估值功能,加一个简单的推荐系统,或者做成包含聊天功能方便买卖双方直接沟通。技术栈完全不用换,Spring Boot 生态可以无缝扩展 Redis 做缓存、Elasticsearch 做车源全文搜索。我这个项目里 Redis 只在 token 存储上简单使用,如果数据量上来,车源搜索从 MySQL 改成 ES 能明显提升体验。

微信支付的对接流程我也研究过,核心是后端统一下单、前端发起支付、后端接收回调。接支付时注意回调地址必须是公网可访问的地址,而且回调验签一定要做,防伪造成交通知。不过对于管理类课程项目,模拟支付已经满足业务演示需求,真接支付是为了商业场景。

做这类系统最大的体会是:技术本身并不复杂,难的是把业务流程梳理清楚,并且提前预判各种边界情况。比如同一个用户重复下单怎么办、买家下单后车辆被管理员下架怎么办、卖家把已售车辆再次上架怎么办,这些问题如果不提前在代码层面处理,联调阶段就会连环爆雷。我在 controller 层对关键状态做了防护,即使前端传了不合理的参数,后端也能拦截下来返回明确的错误提示。开发时多想想用户会怎么乱操作,这个系统才能真正从 Demo 变成一个可交付的项目。

如果你也在开发类似系统,建议第一步先别急着写代码,花半天时间把核心业务的状态流转图画清楚,数据表之间的关系定义好。表结构定了,代码写起来就是流水线工作,后面的调试时间能省下一大半。线上出问题后你要能快速定位是前端参数不对还是后端逻辑有 bug,现在我养成的习惯是在关键接口入口打印日志,把入参和出参都记录下来,排查问题的时候效率高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询