
这套二手车交易管理系统是我最近完整做完并跑通的一个全栈项目。从需求梳理到数据库设计再到SpringBoot Vue MyBatis MySQL这套组合的落地实现中间踩了不少坑也积累了一些比较实用的经验。如果你正打算做毕业设计、个人全栈项目或者想拿二手车业务练手这篇文章会非常合适。我不打算讲一堆空泛的理论而是直接告诉你这个系统怎么拆、表怎么建、接口怎么写、前端怎么对接、最后怎么打包成可运行的jar同时把容易出问题的地方全都标注清楚。读完你收获的不只是一份源码而是一套可以自己动手复现的思路。1. 这套二手车系统要解决什么问题为什么选这套技术栈1.1 业务场景与功能范围二手车交易和普通电商不一样它的商品是非标的。每台车的车况、里程、排放标准、变速箱类型都不同而且交易过程里还有线下看车、过户、付款这些环节单纯套用一个商城系统肯定别扭。管理系统的核心目标是把车源信息数字化、把订单流转管起来、把买卖双方的沟通留痕减少“信息全部靠人工”导致的差错。从我实际梳理的需求看这个系统至少要覆盖三类角色普通买家、车商/卖家、平台管理员。买家有注册登录、浏览车辆、多条件筛选、查看详情、收藏车辆、下订并跟踪订单状态这些操作卖家需要发布车源、管理自己名下的车辆上下架管理员则要审核并管理车源、处理用户和订单、维护品牌数据。项目一开始如果不把权限边界理清楚后面写角色判断时会非常痛苦。而车辆信息的筛选是二手车系统里最核心的功能之一。买家通常会按品牌、价格区间、车龄、里程、变速箱、排放标准来过滤车源前后的排序还可能包含发布时间、价格高低。改造成本最低的做法就是前端传查询条件后端用动态SQL拼过滤条件既不牺牲灵活性也不会因为场景简单而去过度设计。1.2 技术选型每一样东西为什么出现在这里SpringBoot说白了就是把Spring繁琐的配置自动化起步依赖帮你把常用的组件拉齐内嵌Tomcat也省去了单独部署Servlet容器的麻烦。Vue负责页面交互组件化以后车辆的列表页、详情页、发布表单都能拆成独立文件改起来不连累其他页面。MyBatis是一个轻量级持久层框架它的XML配置方式让我能精确控制SQL二手车这种筛选条件多、查询动态变化大的场景写动态SQL非常灵活。MySQL则是免费、稳定、社区资料极多的关系型数据库对于中小规模的管理系统完全够用。这套组合选型的基本逻辑是前端要快速迭代后端要易于维护SQL要可控。如果换成JPA虽然实体映射写起来省事但复杂查询和多表关联反而要多写“绕路”代码如果换成MyBatis-Plus也完全可行它封装了很多单表CRUD适合快速开发但定制化SQL能力仍然需要依赖XML。对于我这种要求“每个查询逻辑都看得明白”的人原生MyBatis反而用得最顺手。版本方面建议直接使用Spring Boot 2.7系列或3.x系列JDK对应17以上。新手要注意的是Spring Boot 3.x和Spring Boot 2.x在依赖坐标、javax到jakarta命名上都有差异照着旧教程配置容易报错。个人项目我推荐Spring Boot 2.7 JDK8/11资料最多踩坑成本最低如果你的机器已经是JDK17或者想用更新特性再上Spring Boot 3。2. 从表结构开始先把数据模型铺平后面实现才不别扭2.1 核心数据表与字段设计数据库设计是整个系统最容易返工的环节。我在第一次做的时候直接按照页面原型建表结果订单状态一变发现很多字段设计成固定列根本撑不住业务变化。后来我把核心表控制在六张用户表、品牌表、车辆表、订单表、收藏表、公告表把角色字段和状态字段都设计成可扩展的int类型后面新增状态时只需要加数字约定不需要改表。车辆表是信息最密集的一张表大致字段可以这样规划字段名类型说明idbigint主键自增titlevarchar车源标题brand_idbigint关联品牌表model_namevarchar车型名称pricedecimal(10,2)售价mileagedecimal(10,1)表显里程单位万公里year_of_registrationint上牌年份gearboxtinyint变速箱1手动 2自动colorvarchar车身颜色descriptiontext车况描述cover_imgvarchar封面图路径imagestext图片地址JSON数组或逗号分隔statustinyint0下架 1上架 2已售 3锁定owner_idbigint所属用户/卖家create_timedatetime发布时间订单表我特意把amount单独拎出来不直接读车辆表的price。这样即使卖家中途改价已生成的订单仍然保留了下单那一刻的成交价格历史数据不会跟着翻。status用0待支付、1已完成、2已取消以后需要增加“交易中”“已退款”状态直接加数字就行不用改字段结构。外键我在实际项目里用得比较少更多是保留逻辑关联字段比如order表里有user_id和car_id但我不在数据库层面强制建外键约束。原因很简单项目迭代过程中逻辑外键足够保证开发效率也避免了外键约束带来的插入、删除顺序限制。真正保障一致性的地方放在service层事务里处理这也更符合现在Spring Boot项目的常见做法。2.2 下划线字段与驼峰映射一篇配置省下大半麻烦MySQL的表字段我习惯用snake_case命名比如create_time、owner_id而Java实体类用驼峰命名比如createTime、ownerId。MyBatis里开启一项配置后就能自动完成下划线到驼峰的映射不用每个字段都写resultMap代码会干净很多。在application.yml里这个配置长这样mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.secondhand.entity有了map-underscore-to-camel-case: true之后select * from car查询出来的create_time就能直接赋给Car实体里的createTime属性。mapper-locations则告诉Spring Boot去哪找XML文件这个路径一旦写错启动就会报Invalid bound statement (not found)是我见过最高频的启动报错之一。实体类我强烈建议加Lombok用Data注解省略getter/setter。这套系统里面实体字段多手写getter/setter真是白费时间而且一旦改动字段漏改某个getter的问题也不好查。有了Lombok实体类瞬间缩减到字段注释可读性高很多。2.3 状态字段与索引前期约定后面少踩坑车辆表和订单表都有status字段这里有一个习惯值得养成状态字段不要用字符串存中文比如“上架”“已售”而应该用数字映射。原因有两个一是中文受字符集影响换库容易乱码二是数字做查询和索引都比字符串更快。项目里甚至可以专门写一个枚举类把1、2、3映射成业务含义在代码里写清楚别人接手也看得懂。索引也不能马虎。我实际查询最频繁的字段是status、brand_id、price和create_time。单表数据量还小的时候全表扫描没什么感觉但车源一旦上千条每次筛选都扫全表就会拖慢时间。我给status和brand_id建了普通索引给price和create_time建了组合索引查询速度明显提升。这里不必过度设计对二手车系统来说两三个有效索引已经足够。还有个细节很容易忽略car表里images字段我用TEXT存储多张图片地址查询详情时前端拿到逗号分隔字符串再split成数组渲染即可。数据库本身不建议存JSON但在小型管理系统里把图片列表这种不常参与查询的数据聚合存储反而减少了大量关联表查询是实用取向的做法。3. 后端实现SpringBoot MyBatis 如何写业务接口与事务3.1 SpringBoot工程结构与关键配置后端我按标准的controller、service、mapper、entity四层去组织。Controller只负责接收参数、校验参数格式、返回统一结构Service里放业务规则Mapper和XML管SQL。有的项目喜欢再包一层DTO和VO但对于二手车管理系统把请求参数用Map或者简单POJO接收就能解决问题强行加中途层反而增加理解成本。统一返回结构这一点一定要在一开始就定好。我定义了一个Result类包含code、message、data三个字段所有接口都返回这个结构。前端一起封装的axios拦截器只需要判断code是否等于200即可异常信息通过message展示给用户。这个结构看起来简单但真的能避免前后端联调时各写各的格式是我强烈建议保留的做法。application.yml里还要写数据源配置。MySQL 8.x版本下我推荐这样配spring: datasource: url: jdbc:mysql://localhost:3306/secondhand?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverurl里的serverTimezone和allowPublicKeyRetrieval是最容易出问题的两个参数。时区不写连接MySQL 8会报server time zone的异常allowPublicKeyRetrieval不写某些MySQL 8连接方式会报Public Key Retrieval is not allowed。这两条都是无数新手卡的经典问题先写上去能省很多排查时间。3.2 MyBatis XML里最常用的查询写法筛选条件多的时候MyBatis的XML动态SQL是我最喜欢的一部分。比如车辆列表接口前端可能传来brandId、minPrice、maxPrice、keyword、gearbox这些参数每一次用户选择的组合都不同。如果用Java代码拼SQL不仅代码烦琐还可能遗漏判断而XML里用 和 组合逻辑一目了然。下面这段是我在实际项目里的典型写法select idselectCarList resultTypecom.example.secondhand.entity.Car select * from car where if testbrandId ! null and brand_id #{brandId} /if if testkeyword ! null and keyword ! and title like concat(%, #{keyword}, %) /if if testminPrice ! null and price gt; #{minPrice} /if if testmaxPrice ! null and price lt; #{maxPrice} /if if testgearbox ! null and gearbox #{gearbox} /if and status 1 /where order by create_time desc limit #{offset}, #{pageSize} /select这里有几个值得注意的点like查询记得用concat拼接百分号不要自己在前端拼好再传过来否则容易引发SQL注入price的“”和“”在XML中要写成和否则XML解析不过手动分页用limit offset, pageSize简单可控。分页插件PageHelper我也用过确实是好用的工具但个人项目里手写分页更透明也不会遇到插件和Spring Boot版本不兼容的烦恼。如果查询结果需要返回两个id字段名完全一样的关联数据比如车辆表和品牌表都有name这时候就别偷懒了老老实实写 或者给字段起别名否则MyBatis映射时必然混淆。很多“车源列表品牌名称显示不对”的问题根源就在这。3.3 下订单接口事务和并发控制一起说下订单是系统里业务逻辑最重的接口也是必须加事务的地方。完整流程是用户请求下单接口后端先判断车辆是否存在且status等于1然后把status改成3锁定避免别人再下订接着生成订单号并计算成交价格最后把订单插入order表。为什么要用Transactional因为“改车辆状态”和“插入订单”是两个独立的数据库操作中间任何一个失败都可能出现车被锁了但订单没创建的尴尬情况。加上事务之后两步操作要么都成功要么都回滚数据一致性才有保障。我在Service方法上这么写Transactional(rollbackFor Exception.class) public void createOrder(Long userId, Long carId) { Car car carMapper.selectById(carId); if (car null || car.getStatus() ! 1) { throw new BusinessException(车辆不存在或已下架); } int updated carMapper.lockCar(carId); if (updated 0) { throw new BusinessException(该车已被预订请看看其他车源); } Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setCarId(carId); order.setAmount(car.getPrice()); order.setStatus(0); orderMapper.insertOrder(order); }这里的carMapper.lockCar方法在SQL层面执行update car set status 3 where id #{id} and status 1。靠where status 1来保证更新的行数是0还是1是一种最简单也最可靠的行级并发控制手段。两个用户同时下单同一辆车时数据库的行锁会保证只有一个update执行成功另一个updated为0自然就抛异常了。这比先查再改的“检查再操作”模式安全得多后者在并发下很容易出现超卖。订单号我直接用时间戳加随机数生成比如yyyyMMddHHmmss加四位随机数够用且不会太复杂。生成前要记得判断重复虽然概率极低但订单号重复会造成业务上的严重事故。3.4 登录鉴权与密码处理登录鉴权我没有引入复杂的Spring Security而是用一个轻量方案JWT加拦截器。用户登录成功后后端生成一个携带用户id和角色的token返回给前端前端保存到localStorage之后的每个请求都在请求头带上Authorization。后端写一个拦截器解析token并把用户信息放进请求上下文HandlerInterceptor大几百行就能写完足够支撑这个系统的权限需求。密码存储必须用BCrypt加密绝对不要明文存库。开发库和生产库一旦泄露明文密码就是整个系统的灾难。Spring Boot里引入spring-security-crypto这个依赖只使用里面的BCryptPasswordEncoder来加密和校验密码不需要启动完整的Security过滤链使用成本很低。我以前为了省事存过MD5后来发现撞库太容易后悔得很。角色区分用user表里的role字段0表示管理员1表示普通用户。管理员接口上用一个RequireAdmin注解配合拦截器判断角色没有权限直接返回code 403。这套过滤逻辑虽然不如Spring Security完善但胜在容易理解对于相关的教学和个人项目够用且好维护。4. 前端页面与接口对接Vue组件、路由和请求封装4.1 Vue工程搭建与路由设计前端我用Vue 3加Vite构建。Vite的启动速度比Webpack快太多改代码热更新几乎是即时的开发体验对提高效率很有帮助。组件库选择Element Plus表格、表单、对话框这些后台管理常用组件都有现成的能节省大量写CSS的时间。创建工程很简单npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router axios element-plus路由我用createWebHistory模式页面路径比较干净。但history模式有一个典型代价用户在前端路由里刷新页面时如果部署环境没有做“所有路径都指向index.html”的兼容会返回404。我在开发时更推荐hash模式也就是createWebHashHistoryURL带#号但绝不会刷新404适合个人项目和教学演示。如果你坚持要漂亮的路径那生产环境一定要配合后端或者Nginx做好fallback后面部署章节会重点提。路由守卫是权限控制的前端入口router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) } else { next() } })这段逻辑让未登录用户不管怎么跳转最终都会被带到登录页。后端拦截器再兜底一次前端路由守卫主要是提升交互体验真正验证token是否有效还是后端说了算。4.2 axios封装与跨域调试axios如果不封装每个页面里都直接写请求路径和错误处理代码会迅速失控。我一般建一个request.js文件实例化一个axios对象设置baseURL为/api再添加请求拦截器和响应拦截器。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络请求失败请稍后再试) return Promise.reject(error) } ) export default request开发阶段前后端端口不同跨域问题几乎必然出现。最常见最省心的办法是让Vite把请求代理到后端端口在vite.config.js里做如下配置server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这里的前缀必须和后端Controller的统一路径前缀保持一致。我在后端所有接口都加了/api前缀比如PostMapping(/api/auth/login)这样前端请求/api/login会由代理转到后端地址又不会和页面路由冲突。代理的好处是浏览器看到的请求是同源的根本没有跨域报错的机会比后端配CORS更加省心。4.3 几个典型页面的实现思路车辆列表页是前端最典型的一个页面整体结构是筛选栏加卡片列表加分页。筛选栏绑定一个searchForm对象点击搜索时重新请求第一页数据。车辆卡片用Element Plus的el-card展示封面图、标题、价格、里程、上牌年份信息每张卡片下方放“查看详情”和“收藏”按钮。数据的获取全部通过上面封装好的request.get(/car/page, { params: searchForm })后端返回的数据结构是{total, list}前端塞进el-pagination即可。车辆发布页和后端的上传接口配合紧密。上传图片我用el-upload组件action指向后端/api/upload接口上传成功后把返回的图片路径存到表单的coverImg或images字段里。表单提交时把el-upload里的fileList转换成字符串再传给后端后端存入TEXT字段。这里有个常见坑提交时如果直接提交fileList对象后端只会收到一堆无用的临时文件名因为Vue的fileList是组件内部维护的对象数组不是最终的存储路径。车辆详情页的逻辑重点是订单按钮的交互。页面加载时先根据路由参数里的id获取车辆详情点击“立即下订”时确认当前车辆status是否为1。如果后端已经在这种状态下返回“已锁定”或“已售”前端要正确展示对应按钮的禁用态。这个状态判断最好在后端做因为用户完全可能绕过前端直接调接口。5. 联调、打包与部署把前端放进SpringBoot生成可运行jar5.1 两种部署方式静态资源托管和反向代理后端开发完成后本地联调已经通畅接下来要考虑怎么部署。最省事的一种方式是把前端构建出的dist目录复制到后端项目的src/main/resources/static下让SpringBoot同时充当API服务和静态Web服务器。这样最终只需要部署一个jar包不存在跨域也不存在两个进程分别维护的问题。实际操作步骤并不复杂前端执行npm run build生成dist目录把里面的index.html和assets、favicon等文件一并复制到后端的resources/static目录下然后执行mvn clean package打包最后java -jar运行。启动完成后直接访问localhost:8080看到的就是前端首页。请求/api路径时SpringBoot返回接口数据请求其他路径时从static里找静态资源。这种单jar部署非常适合个人项目和小团队但是有个细节要特别留意如果前端用了history模式路由刷新某个子页面比如/home时后端static目录里只有index.html没有home.html这样的物理文件就会404。解决办法是让后端把所有找不到的路径都转发到index.html交给前端路由接管Controller public class ViewController { GetMapping(value {/, /home, /car/**, /user/**}) public String index() { return forward:/index.html; } }注意这个转发不能覆盖/api前缀的接口路径否则会拦截掉正常请求。所以后端接口统一加/api前缀在这里就显得相当重要。如果不想折腾这个直接把前端路由改成hash模式这个问题天然就不存在。另一种方式是前端独立部署到NginxNginx监听80/443端口把/api下的请求反向代理到后端的8080端口。Nginx配置核心就一段location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }try_files保证了history模式下刷新子路径不会404proxy_pass把API请求转发给后端。这种部署方式更适合前后端需要各自扩展的场景缺点是服务器上多一个Nginx进程运维成本稍高一些。我个人觉得小项目用第一种方式更舒服端口少、管理简单、问题少。5.2 初始化数据库与环境检查部署前数据库初始化是不可跳过的一步。我用一个schema.sql加上几个测试数据通过Navicat或者命令行执行。执行前要确认数据库版本和字符集推荐使用utf8mb4字符集它能完整显示生僻汉字和一些特殊符号utf8在这点上是不够全的。字符集检查结果往往在部署后才暴露问题中文乱码、问号或者出现乱码符号多数都是连接串没指定characterEncoding或者数据库字符集不是utf8mb4。我在Spring Boot连接串里已经写了characterEncodingutf8实际上这个值在MySQL驱动里会自动映射为utf8mb4但数据库建库时还是建议显式声明CREATE DATABASE IF NOT EXISTS secondhand DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;数据库导入完成后建议先写个最简单的接口测试连通性比如查品牌表返回一个JSON数组。如果这一步报错优先检查账号密码、网络端口和防火墙而不是急着去跑完整前端页面。5.3 Maven打包中容易忽略的细节Maven打包时前端文件已经在static目录下正常执行mvn clean package就能打出一个包含前端资源的jar。但如果你的构建顺序是先打包后端、再把前端复制进来要格外注意复制文件的时机和路径。很多人习惯直接在IDEA里点package结果打包出来的jar没有页面就是因为前端dist没有在打包前放到正确位置。有一种更自动化但稍微复杂的做法是借助Maven的frontend-maven-plugin在构建后端前自动执行npm install和npm run build。这个插件能把前端构建流程纳入Maven生命周期以后打包一次就全做完了。但对初学者来说手动复制dist是最直白的也能让你理解最终jar里到底有什么。打包完成后在命令行运行java -jar target/app-name.jar。遇到jar无法启动可以先看日志里有没有Invalid bound statement、端口被占用、数据库连不上三类经典问题这些我都会放在最后的速查表里。检查无误后浏览器访问http://localhost:8080整个二手车系统就上线了。6. 常见问题速查与踩坑记录6.1 高频报错速查表我把自己在实际开发过程中遇到的高频问题整理成一张表方便你遇到对应报错时迅速定位。问题现象根本原因解决办法Invalid bound statement (not found)MyBatis XML文件路径配置不对或方法id找不到检查yml里mapper-locations路径和XML namespace、id是否匹配Access denied for user rootlocalhost数据库账号密码或权限不对确认MySQL账号密码必要时执行grant授权Public Key Retrieval is not allowedMySQL 8加密连接默认行为改变连接串增加allowPublicKeyRetrievaltrueuseSSLfalseTable doesnt exist数据库没初始化和实体对不上执行建库建表脚本确认表名大小写一致前端刷新404history模式路由没有fallback后端转发到index.html或用Nginx try_files后端接口返回中文乱码字符集配置不完整数据库使用utf8mb4连接串加characterEncodingutf8java.net.BindException: Address already in use端口被其他进程占用换一个server.port端口或查出占用进程kill掉车辆列表接口能通但品牌名称没显示resultMap没有处理关联字段给SQL取别名或编写resultMap映射下订单接口偶发“车辆不存在”但列表里明明有车商品被并发下单锁定用update where status1做行锁而不是先查再改npm install 报网络错误npm默认镜像访问较慢使用registry镜像地址安装依赖这些坑里面Invalid bound statement和前端刷新404是我见过出现频率最高的。它们其实都不是复杂问题但错误信息一开始看不懂往往会卡住很长时间。建议碰到之后先检查路径和拼写再检查版本兼容性往往比漫无目的地搜博客更快。6.2 我再补充几个隐蔽的坑第一个隐蔽问题是MySQL驱动版本。如果你是Spring Boot 2.7加MySQL 8.x项目里默认引入的是com.mysql.cj.jdbc.Driver而不是老教程里的com.mysql.jdbc.Driver。如果复制了旧配置启动时会连驱动类都找不到。直接用com.mysql.cj.jdbc.Driver适配MySQL 5.7和8.x都能稳定运行。第二个隐蔽问题是MyBatis的多参数方法。Mapper接口如果写的是List selectList(String keyword, Integer brandId);XML里直接引用#{keyword}和#{brandId}会报参数无法解析的错误。这是因为MyBatis无法直接判断参数名解决方法是加Param注解或者在编译时加上-parameters参数才会被自动识别。建议所有Mapper方法都规范地写Param不要偷懒。第三个和MyBatis缓存相关的问题我一直放到最后说。默认情况下MyBatis会在同一个SqlSession里开启一级缓存二级缓存默认关闭。个人项目里不建议开启二级缓存因为开启后要处理实体序列化还要应对缓存和数据库不一致的问题。除非你已经深刻理解缓存失效场景否则老老实实每次查询数据库对二手车管理系统这个数据量来说完全够快。缓存优化应该是出现性能瓶颈之后再做的事而不是项目一开始就要去折腾的炫技点。回到这个系统本身一套完整跑通的二手车交易系统难点很少在某个单独技术上更多是业务链路的闭环用户发车、别人看到、下订单、状态流转、后台管理。你用第二部分提到的方案把表和状态约定好再用第三部分的接口设计把每个环节串起来前端只是把这些能力变成一个可点击可操作的门面。先让链路闭合再谈优化外观和性能这个顺序千万别搞反。最后再分享一个小技巧。我在给车辆列表写排序时如果搜索条件里加了一个sortBy参数直接用参数去匹配固定的排序字段白名单比如只允许createTime、price、mileage这几个值而绝不直接拼接用户传来的字符串。这样既支持列表排序又不会留下SQL注入的口子。很多隐藏得很深的安全问题其实就是在这种看似不起眼的小地方埋下的。把这类细节养成本能比修完一个Bug更有价值。