1. 二手车交易系统到底要解决什么:从需求倒推项目边界
做这个项目之前,我一直觉得"二手车交易系统"是个挺成熟的品类,随便找一个开源项目改改就能用。直到自己真去跑了一遍业务,才发现没那么简单。先说个背景:去年家里换车,我把旧车挂在几个平台上卖,结果一天能接七八个电话,约看车的时间、地点全乱套,中间还差点被车贩子压价套路。后来我索性自己写一套系统,专门管"车辆信息发布—买家浏览预约—线下成交后归档"这一条完整的交易链路。
这套系统最终做出来长这样:车主注册登录后可以发布车辆信息,包括品牌、型号、上牌时间、行驶里程、排放标准、售价、实拍图;买家用关键词、价格区间、车龄、里程这些条件筛选车辆,看到合适的车提交预约看车申请;管理员在后台审核车辆是否真实、状态是否合规、价格是否合理,还可以管理用户、处理交易记录。整个流程走下来,解决了三个核心痛点:一是所有车辆信息有统一录入规范,不会像在论坛发帖那样格式混乱;二是预约看车和交易状态有系统记录,谁预约了、看到什么程度、最后谈没谈拢,都有迹可循;三是管理员能从后台看全局数据,哪些车卖得快、哪些价格虚高,一目了然。
这个项目适合两类人参考:一类是正在做JavaWeb毕设或课程设计的学生,另一类是刚工作不久、想完整掌握"前后端分离+联表查询+权限控制"这套技能的开发者。它的代码量不大,但麻雀虽小五脏俱全,能把SpringBoot、Vue、MyBatis、MySQL这几样东西怎么配合干活讲清楚。
我在给系统划边界的时候,特意砍掉了几个看起来热门但实际会拖垮进度的功能,比如在线支付、实时聊天、AI估价。原因很简单:交易系统的核心价值是"信息撮合+状态管理",支付属于金融级业务,需要资质和复杂的对账逻辑;实时聊天要引入WebSocket,复杂度翻倍;AI估价依赖大量市场数据,个人项目根本喂不饱模型。先把核心主链路做扎实,比堆一堆半成品功能有意义得多。
1.1 交易场景里的角色划分与权限诉求
做系统第一步不是写代码,而是把角色想清楚。我在这个项目里划分了三种角色:普通用户(包含买家和卖家两种身份)、系统管理员。
普通用户注册后,可以发布车辆、编辑自己发布的车辆、上下架自己的车辆,同时也能浏览所有车辆、提交预约看车。管理员则负责审核车辆、管理用户状态、查看交易记录、统计核心数据。这里有个容易踩坑的细节:如果"发布车辆"和"浏览车辆"属于同一个角色,在权限上其实是有冲突的——比如用户A发布的车辆,用户B能不能编辑?显然不能。所以后端做权限控制时,不能只靠一个角色标识一刀切,还必须加入"数据归属校验",也就是判断当前登录用户是否是这条车辆记录的所有者。我后面在实现章节会详细说这个校验怎么写。
1.2 业务闭环怎么串起来
我一开始画业务流程图时特别容易乱,后来抽象成一条主链路才清晰起来:用户发布车辆 → 管理员审核 → 车辆上架可被检索 → 买家提交预约 → 卖家确认 → 线下看车成交 → 管理员归档交易记录。
这条链路里最容易被忽略的是"审核"这一环。不少同学做类似系统时会把车辆发布和上架当成同一个操作,用户提交完直接就显示在列表里了。但现实中二手车平台一定有人工审核的,否则虚假车源、盗图凑数的信息会泛滥。我在表设计里给车辆表加了一个status字段,用数字区分草稿、待审核、已上架、已下架、已售出五种状态,每次状态流转都由后端接口严格校验。这个设计虽然多写了很多判断逻辑,但后续做管理端统计时特别顺手。
2. 技术选型不是跟风:SpringBoot+Vue+MySQL+MyBatis这套组合到底赢在哪
很多人拿到一个项目标题,第一反应是"用SpringBoot+Vue就完事了",但问一句"为什么不用SSM?为什么不用JPA?"就答不上来了。我做选型时考虑过好几套方案,最后定下来的组合里每个组件都是经得起推敲的。
2.1 后端框架:SpringBoot解决的是"配置地狱"
早期的SSM项目,光配置文件就有数据源、MyBatis工厂、事务管理器、SpringMVC视图解析器、web.xml等六七份,新手配置一小时还没开始写业务逻辑。SpringBoot最大的价值是"约定优于配置",它通过自动配置把常见组件的初始化过程接管了。我只需要在pom.xml里引入依赖,写一个application.yml,再用@SpringBootApplication注解启动,一个可运行的Web服务就成了。这对快速验证业务逻辑非常有帮助。
当然,SpringBoot不是把配置消灭了,而是把"通用配置"变成默认值,把"个性配置"留在配置文件里。比如我在application.yml里配置了数据源、MyBatis的Mapper扫描路径、文件上传大小限制,这些都是项目需要自定义的部分。如果你想把自动配置的细节摸透,可以启动时加--debug参数看自动配置报告,这个技巧后面部署部分会提到。
2.2 数据层选MyBatis而不是JPA:控制SQL的主动权
二手车交易系统里最难写的是多条件筛选查询。买家可能同时按品牌、价格区间、车龄、里程、排量、变速箱类型筛选,而且每个条件都可选可不选。这种场景我用MyBatis的动态SQL处理起来非常舒服——写一个<where>标签,配合<if>标签按需拼接条件,代码清晰且性能可控。
JPA抽象层次更高,但遇到复杂查询时要么写JPQL,要么用Specification,要么退回去用原生SQL,反而多了一层转换成本。MyBatis直接面向SQL,联表查询、子查询、聚合统计都按数据库原生语法写,出了问题也容易排查。唯一要注意的是Mapper接口和XML文件一定要对应好,我后文会专门讲这个坑。
2.3 前端Vue:组件化让管理后台开发效率翻倍
管理后台这种页面千篇一律、逻辑重复率高的项目,用Vue的组件化开发非常合适。我把侧边栏、头部导航、表格、表单弹窗都封装成独立组件,页面之间通过Vue Router切换路由,数据状态用Vuex(4.x版本对应Pinia,但我当时用的是Vue2生态,所以还是Vuex)管理。列表页和详情页分别对应路由/cars和/cars/:id,组件复用率相当高。
选择Vue还因为它的生态对国内开发者太友好了,Element UI组件库拿来即用,表格分页、日期选择、上传组件全都现成,能节省大量样式和交互的编码时间。如果你用的是Vue3,建议搭配Element Plus,API基本兼容,文档也齐全。
2.4 MySQL版本选择的一个提醒
开发环境我用的是MySQL 5.7,生产环境用的MySQL 8.0。如果你是刚接触,建议直接从8.0开始。一方面8.0是长期支持版本,另一方面默认的字符集是utf8mb4,对中文和表情符号支持更好。MySQL 5.7默认字符集经常是latin1,如果建库时没注意指定utf8mb4,插入中文会出现乱码,而且数据进去后再改字符集非常麻烦。
我建库时的统一操作是:
CREATE DATABASE car_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这样建出来的库,从根上杜绝了中文乱码问题。
3. 数据库设计:交易系统的表结构是骨架,字段设计决定开发顺不顺利
数据库设计是整个项目里最不能急的环节。我见过很多半途返工的项目,问题基本都出在表结构没想清楚。二手车交易系统的核心表可以归纳为四张:用户表、车辆表、预约表、交易记录表。下面挨个说设计思路和关键字段。
3.1 用户表:角色和状态必须分开存
用户表字段其实很简单:id、username、password(存的是BCrypt加密后的哈希值,不是明文)、phone、role(0表示普通用户,1表示管理员)、status(0正常,1禁用)、create_time。
这里要说两个细节。第一,密码加密用BCrypt而不是MD5。MD5是摘要算法,被彩虹表攻击的风险很大;BCrypt是专门为密码哈希设计的算法,自带随机盐,同样的密码每次生成的哈希都不同。Spring Security里直接有BCryptPasswordEncoder可以用。第二,role字段没有单独建角色表,因为业务里只有两种角色,建表反而复杂化了。如果以后要扩展多角色、多权限,再拆也不迟。
3.2 车辆表:状态机字段是业务逻辑的核心
车辆表是最重要的表,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| user_id | bigint | 发布人ID,关联用户表 |
| brand | varchar(50) | 品牌,如宝马、奥迪 |
| model | varchar(50) | 车型,如3系、A4L |
| license_date | date | 上牌日期 |
| mileage | decimal(10,2) | 行驶里程(万公里) |
| price | decimal(10,2) | 售价(万元) |
| gearbox | varchar(20) | 变速箱:手动/自动 |
| displacement | varchar(20) | 排量,如1.5T、2.0L |
| emission_standard | varchar(20) | 排放标准:国五/国六 |
| color | varchar(20) | 颜色 |
| description | text | 车况描述 |
| cover_image | varchar(255) | 封面图地址 |
| images | text | 图片地址,逗号分隔 |
| status | tinyint | 0草稿,1待审核,2已上架,3已下架,4已售出 |
| view_count | int | 浏览次数 |
| create_time | datetime | 发布时间 |
| update_time | datetime | 更新时间 |
status字段是整个系统最核心的业务字段。我在实现时定义了一个状态机:用户发布后是1待审核,管理员通过后变2已上架,卖家手动下架变3已下架,买家预约后交易成功变4已售出。每个状态之间的跳转都是有权限和条件的,比如已售出只能从已上架状态流转,且必须由管理员确认。
3.3 预约表和交易记录表:关联关系要设计成"快照"而不只是外键
预约表的字段是:id、car_id、buyer_id、seller_id、appoint_time(期望看车时间)、status(0待确认、1已确认、2已取消、3已完成)、remark(备注)、create_time。
交易记录表字段是:id、car_id、buyer_id、seller_id、deal_price(实际成交价)、deal_time、remark。
这里有一个很多初学者想不到的设计细节:交易记录里的deal_price不能直接关联车辆表的price,而要单独存一个值。原因是车辆的标价和最终成交价往往不一样,如果只存一个关联ID,回头查历史记录时价格早就变了。这种"把交易发生时的信息复制一份存下来"的思路叫快照设计,在订单、账单、合同这类强历史追溯需求的表里是基本操作。同理,交易记录里也冗余存储了buyer_id和seller_id,而不是通过预约表间接查,减少一次联表。
3.4 索引设计:查询慢很多时候是没建对索引
车辆列表页是流量最大的页面,搜索条件集中在brand、price、mileage、license_date这几个字段上。我在建表时给这些字段建了联合索引:
ALTER TABLE car ADD INDEX idx_brand_price (brand, price); ALTER TABLE car ADD INDEX idx_mileage (mileage); ALTER TABLE car ADD INDEX idx_status_create (status, create_time);idx_status_create这个索引是给管理员后台用的——后台列表通常要按时间倒序查某个状态下的车辆,两条查询条件正好命中联合索引。如果只建了status的单列索引,虽然也能用,但回表次数更多。这里的原则是:把查询频率最高的两列组合成联合索引,比给每个列单独建索引更高效。
4. 后端核心模块实现:登录鉴权、车辆审核、动态筛选一个都不能少
后端我用标准的Controller、Service、Mapper三层结构。下面挑几个技术含量比较高的模块详细展开,会贴关键代码片断。
4.1 登录认证与接口拦截
登录这块我用JWT(JSON Web Token)做无状态认证。用户登录成功后,后端生成一个带过期时间的Token返回给前端,前端存到本地,每次请求在请求头里带Authorization: Bearer <token>。后端写一个拦截器,对除登录、注册、车辆列表查询之外的接口做Token校验。
拦截器核心逻辑如下:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { response.setStatus(401); return false; } String token = authHeader.substring(7); try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); request.setAttribute("userId", claims.get("userId", Integer.class)); request.setAttribute("role", claims.get("role", Integer.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } }解析出来的userId和role放到request属性里,后面的Controller就能直接用了。管理端接口还要再做一层角色校验,判断role == 1才放行。
这里要提醒一个安全细节:JWT的密钥不能硬编码在代码里,至少也要放到配置文件中,最好用环境变量注入。我见过直接把密钥写在常量类里的项目,一旦源码泄露,任何人都能伪造Token登录管理员账号。
4.2 车辆发布与审核状态流转
发布车辆接口看起来只是把表单数据插入车辆表,但有几个细节必须处理:第一,必须从登录拦截器里拿当前用户的ID作为user_id,不能用前端传的参数;第二,车辆初始状态固定为1(待审核),不能让用户自己选;第三,封面图和详情图要分开存储。
审核接口则是管理员的专属操作。管理员把车辆从待审核改为已上架时,本质上是一个状态更新操作。我的实现是这样的:
@PutMapping("/admin/car/audit") public Result auditCar(@RequestBody AuditRequest req) { // 校验当前用户是管理员,这个在拦截器或AOP里做 Car car = carMapper.selectById(req.getCarId()); if (car == null) return Result.error("车辆不存在"); if (car.getStatus() != 1) return Result.error("只有待审核状态的车辆才能审核"); car.setStatus(req.getPass() ? 2 : 3); if (!req.getPass()) car.setAuditRemark(req.getRemark()); carMapper.updateById(car); return Result.success(); }这个接口的关键在"先查状态,再改状态"。如果不加状态判断,管理员对同一辆车重复点击审核,状态会被覆盖成不同值,造成逻辑混乱。这就是状态机设计对业务规则的约束。
4.3 多条件筛选查询:MyBatis动态SQL的主场
车辆列表页的筛选条件我刚才说过了,前端把选中的条件拼成GET参数传给后端,后端用MyBatis的动态SQL组装查询。核心Mapper如下:
<select id="searchCars" resultType="com.example.entity.Car"> SELECT * FROM car <where> <if test="brand != null and brand != ''"> AND brand = #{brand} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> <if test="minMileage != null"> AND mileage >= #{minMileage} </if> <if test="maxMileage != null"> AND mileage <= #{maxMileage} </if> <if test="gearbox != null and gearbox != ''"> AND gearbox = #{gearbox} </if> AND status = 2 </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>注意几个细节。第一,gt是>在XML中的转义写法,直接用>会解析报错;第二,AND status = 2写在<where>内部但不在<if>里,这样无论前端选了什么条件,都只能查出已上架车辆,避免待审核车辆泄露到公网列表;第三,分页是用LIMIT #{offset}, #{pageSize}实现的,offset = (pageNum - 1) * pageSize在Service层算好再传入。
4.4 图片上传:别把文件存进数据库
图片上传是个经常被做错的功能。我见过有人把图片转成Base64字符串直接存MySQL的TEXT字段,结果一张图几十万字符,数据库很快就被撑爆,查询也慢得离谱。正确做法是:图片文件存到服务器磁盘(或OSS对象存储),数据库里只存图片的访问URL。
我本地开发的实现是配置了一个静态资源映射目录:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB然后写一个上传接口,把文件保存到D:/upload/目录,返回可访问的URL。为了让Vue能访问到这个目录,我在SpringBoot里加了映射配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:D:/upload/"); } }这样前端访问http://localhost:8080/upload/xxx.jpg就能看到图片了。部署到Linux服务器时,把路径改成绝对路径,比如/home/app/upload/,其余逻辑不变。
5. 前端实现思路:Vue负责交互体验,Element UI负责管理端效率
后端接口都通了,前端就是把这些接口"翻译"成用户能看懂的界面。我前端的整体目录结构大概是:src/views放页面组件,src/api放接口请求方法,src/router放路由配置,src/store放用户状态管理。下面挑三个前端开发中比较关键的模块说说。
5.1 Vue Router动态路由设计
前端访问控制的核心在路由。我的路由分两部分:静态路由和动态路由。
静态路由包括登录页/login、注册页/register、首页车辆列表/cars、车辆详情/cars/:id,这些不需要登录就能访问。
动态路由则是后台管理页面,包括用户管理、车辆审核、交易记录、数据统计。用户在登录成功后,前端拿到角色信息,如果是管理员就动态添加管理路由,如果是普通用户就不添加。Vue Router有一个addRoutes方法(Vue Router 4 中改成了addRoute)可以动态注册路由。
有一点要提醒:前端路由隐藏不等于权限安全。即使前端不显示管理入口,用户手动访问管理页面URL,请求还是会发到后端。所以真正的权限控制一定在后端接口上,前端的动态路由只是提升用户体验的手段。我在项目里把这两层都做了,后端拦截是安全底线,前端动态路由是交互优化。
5.2 车辆列表页的状态管理
车辆列表页要维护很多筛选条件,如果每个条件都用组件内部data存储,组件销毁再重建时状态就丢了。我的做法是把筛选条件放到Vuex中,即使用户从列表页跳到详情页再返回,筛选条件依然保留。
具体流程是:列表页mounted时检查Vuex里有没有筛选条件,有就恢复,没有就请求全量数据。筛选条件变化时,通过watch触发重新查询。这个交互细节很影响体验——很多初学者做出来的是"返回列表页就回到第一页,条件全没了",用户翻了几页后不小心点进去一个车,回退还得从头筛选,非常烦躁。
5.3 后台管理表格:分页、筛选、状态操作一条龙
管理端我用Element UI的el-table展示车辆数据,配合el-pagination做分页,el-tag展示状态标签。审核操作直接用弹出框,管理员可以写上/下架理由。
表格加载时我会传给后端两个必要参数:pageNum和pageSize,把筛选条件也一并传过去。后端返回的是{ total: 100, list: [...] }这样的结构,前端拿到后渲染表格并更新分页器。这里有个习惯对调试很有帮助:在api目录里把每个后端接口封装成独立函数,比如searchCars(params)、auditCar(data)、getUserList(params)。这样后端的接口变动只影响一个文件,业务组件里不会直接出现axios.get('/car/search?page=1')这种裸请求,可维护性好很多。
6. 编译、打包与部署:前端如何塞进SpringBoot里一起跑
开发调试时前后端分离跑两个服务,前端占8080端口(Vite/WebpackDevServer),后端占8081端口。但最终交付给用户时,不可能要求人家同时启动两个服务。最常见也是最稳妥的方案:前端构建完生成静态资源文件,SpringBoot把它当作静态资源一并加载。这样用户只需要java -jar一条命令就能启动整个系统。
6.1 前端构建并复制到后端
前端项目构建:
npm run build构建完成后会在dist目录生成index.html和一堆css、js资源。我的做法是把dist目录下的所有文件复制到SpringBoot项目的src/main/resources/static目录下,重新打包即可。
但这里有个大坑。前端用Vue Router的History模式时,访问/cars这样的路由,SpringBoot默认返回404,因为它没有这个接口。解决办法有两个:
第一种,把Vue Router改成Hash模式(URL带#,如/#/cars),请求路径始终是index.html,不会出现404。这种模式部署简单,兼容性也好,缺点是不美观。
第二种,在SpringBoot里配置一个兜底路由,所有非/api开头的路径都转发到index.html:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}").setViewName("forward:/index.html"); } }我实际项目里用的是第二种,因为URL更清爽。这个配置要注意正则表达式要排除带点号的路径,否则图片、JS、CSS等静态资源也会转发到index.html。
6.2 后端打包的运行细节
SpringBoot打包用的是Maven插件:
mvn clean package -DskipTests生成的jar包在target目录下,运行指令:
java -jar car-trade-system.jar --server.port=8080注意,整个项目如果只有SpringBoot自己跑,跨域问题基本不存在,因为前端页面和后端接口是同一台服务器同端口。只有开发调试时才需要后端开启CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8080") .allowedMethods("GET", "POST", "PUT", "DELETE"); } }这个配置只需要在开发环境开启,部署集成后建议把它注释掉,减少不必要的请求放行。
7. 真实验证环节:完整跑通一个交易闭环要多久
我拿到完整源码后,花了一整个晚上把系统在本地跑通。这期间遇到不少坑,也排查了不少问题,挑几个有代表性的记录一下,大家复现时能少走弯路。
7.1 第一个坑:MySQL时区问题导致日期错乱
后端启动时报错The server time zone value ... is unrecognized,SpringBoot默认使用的连接字符串里没有时区参数。我的解决方法是修改application.yml中的JDBC连接串配置:
url: jdbc:mysql://localhost:3306/car_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/ShanghaiserverTimezone=Asia/Shanghai这个参数必须显式指定,否则不仅是启动报错,就算能连上,查出来的时间也可能和北京时间差8小时。
7.2 第二个坑:MyBatis的Mapper接口和XML路径不对
项目跑起来后,只要一调用车辆查询接口就报Invalid bound statement (not found)。排查路径是:先确认application.yml里mapper-locations配置的是否指到了所有的XML文件;再确认XML文件的namespace是否和Mapper接口的全限定名完全一致;最后确认Mapper接口的方法名和XML里id是否一致。这三层有一层不匹配都会触发这个报错。
7.3 第三个坑:图片上传成功但前端访问404
图片上传后,浏览器直接访问URL报404。这个问题的关键定位是SpringBoot静态资源映射路径没生效。新版本SpringBoot对静态资源的拦截规则有调整,如果和@RequestMapping冲突,需要像前面那样单独实现WebMvcConfigurer配置映射。另外,路径里的文件分隔符也要注意,Windows和Linux的写法不同,配置跨平台部署时要留意。
7.4 交易闭环的实测流程与数据结果
系统跑通后,我用测试数据走了一遍完整流程:
- 注册一个普通账号,登录后发布一辆"2020款宝马3系,2.0T,行驶3.2万公里,售价22.5万",提交后状态变成待审核。
- 切换管理员账号进入后台,看到该车相关信息,点击审核通过。
- 回到普通用户视角,在首页按品牌"宝马"和价格区间"20-25万"筛选,能搜到刚才那辆车。
- 点击进入详情页,提交预约看车申请,填写期望时间。
- 之后管理员在后台能看到这条预约记录,标记为已确认。
- 管理员将该车辆状态更新为已售出,系统生成一条交易记录。
全程走下来,分页正常、筛选正常、权限拦截正常,状态流转没有跳级或重复提交的问题。这就是一个核心闭环,也是判断这套系统是否完整的最直接标准。
8. 个人总结与后续可扩展的方向
把整个项目做完再回头看,我个人最大的体会是:这种"管理系统"类项目的难度不在某一个技术点,而在把一条业务链路用代码完整表达出来。发布、审核、筛选、预约、交易、归档,每一步都有对应的表结构、接口逻辑和界面交互,任何一个环节偷懒,整个系统就会显得"缺一块"。
这个项目后续还可以往三个方向扩展:一是引入图片延迟加载和压缩图,降低列表页流量消耗;二是增加车辆浏览记录的推荐逻辑,让买家更容易发现自己关注的车源;三是把交易数据做成可视化报表,让管理员能看到近半年的成交趋势。这三个方向都不需要动现有架构,属于增量迭代。
如果大家要复现这套项目,我的建议是自己先把car表的状态机画清楚,再动手写代码。状态机理清了,后端接口怎么写、前端按钮怎么置灰、管理员操作权限怎么控制,全都顺理成章。最后留个小技巧:开发时把所有SQL打印开关打开,MyBatis配置里加一句log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,调试动态SQL拼接是否正确会直观很多。