☰
SpringBoot+Vue前后端分离二手车交易系统开发与部署实践
2026/10/7 12:35:27 网站建设 项目流程

聊到二手车交易系统,很多人第一反应是:这不就是个增删改查吗?把车辆信息塞进数据库,前端表格一展示,完事。真上手做一套前后端分离的二手车交易系统后会发现,它比普通后台管理系统多出来的东西不少:车辆审核、多条件检索、图片上传、订单流转、三角色权限,每一项都牵扯到具体的技术决策。这套基于SpringBoot+Vue+MyBatis+MySQL的完整项目,正好把这些点全部串了起来。

这篇文章不打算只贴一份部署命令,而是把从业务建模、数据库设计、核心代码实现,到部署上线的真实过程拆开讲。适合刚学完SpringBoot和Vue、想拿一个完整项目练手的人,也适合需要做课程设计或者打算快速搭建二手车交易网站做二次开发的开发者。看完之后,你至少能回答清楚三个问题:这套系统为什么这么设计?核心代码到底解决了什么问题?部署上线时常见的坑在哪里?

1. 先把业务理清楚:二手车交易系统到底在管什么

很多项目做不下去,不是代码写不出来,而是业务边界没划清楚。二手车交易不是一个简单的“卖车”动作,它至少包含三件事:车辆信息的发布与审核、买家检索与询价、交易订单的状态流转。这三个环节对应后台、前台、交易三个模块,把模块之间的数据流理顺,代码写起来就顺了。

1.1 三个角色与一条完整交易链路

这套系统里一共有三个角色:管理员、卖家、买家。别小看角色划分,它直接决定了路由权限、接口鉴权和页面菜单怎么写。

  • 管理员:负责车辆审核、用户管理、基础数据维护。二手车平台必须有人工审核环节,否则假车源、重复车源会迅速拉低平台可信度。所以车辆发布后不能直接上架,要先进“待审核”状态。
  • 卖家:可以发布车源、编辑自己的车辆信息、下架车辆、查看买家订单。卖家只能操作自己名下的车辆,接口层面要做数据归属校验。
  • 买家:可以浏览车辆列表、按条件筛选、收藏、询价、下单购买。买家看不到“待审核”和“已售出”的车辆。

一条完整的交易链路是这样的:

卖家发布车辆 → 车辆进入待审核 → 管理员审核通过 → 车辆上架展示 → 买家浏览检索 → 买家下单 → 车辆标记已售出 → 卖家确认订单 → 交易完成。

这个链路里有两个状态机。一个是车辆状态:待审核、已上架、已下架、已售出。另一个是订单状态:待支付、待确认、已完成、已取消。很多初学者会把这两个状态混在一起,比如给车辆表直接加一个“订单状态”字段,这是错误的设计。车辆状态描述的是车辆本身能否被售卖,订单状态描述的是某一次交易走到哪一步,两者必须分开。一辆车被下单后,车辆状态变成“已售出”,但订单可能还在“待确认”。如果合并成一个字段,后面统计车辆库存和订单数据都会乱。

1.2 数据库表设计的核心取舍

数据库设计我建议尽量精简,但该拆的表一定要拆。这套项目核心表可以控制在六张左右:

  • user:用户表,包含用户名、密码、手机号、角色类型、状态。角色用类型字段区分,比单独建角色表简单直接。
  • car:车辆表,包含标题、品牌、车系、上牌日期、行驶里程、变速箱、排量、排放标准、颜色、城市、售价、车况描述、卖家ID、审核状态、浏览量。
  • car_image:车辆图片表,包含车辆ID、图片URL、排序号。图片单独拆表,不要用逗号拼接存在car表里。
  • orders:订单表,包含订单编号、车辆ID、买家ID、卖家ID、成交金额、订单状态、创建时间。
  • favorite:收藏表,买家ID、车辆ID、创建时间。
  • message:询价/留言表,车辆ID、发送方ID、接收方ID、内容、发送时间。

图片单独建表是很多新手不愿意做的,觉得多一张表麻烦。实际上二手车一辆车至少五六张图片,如果直接存到car表里,要么加一堆image1、image2字段,要么用逗号拼接。加字段的方式完全没法扩展,逗号拼接的方式每次展示都要拆分字符串。拆成子表之后,加图片、删图片、设置主图都非常灵活,后续如果想做图片懒加载或者分图集展示,直接查这张子表就行。

车辆表有几个字段要注意。价格字段用decimal(10,2),不要用varchar,否则价格区间排序会变成字符串排序,10万会排在9万前面。里程字段用int,单位是公里。浏览量用于列表排序和热门车源推荐,每次详情访问加1即可,不需要精确到防刷,个人项目没那么高要求。

查询频繁的字段要建索引,尤其品牌、状态、价格、上牌时间。下面这段SQL可以直接用在初始化脚本里:

ALTER TABLE car ADD INDEX idx_car_brand (brand); ALTER TABLE car ADD INDEX idx_car_status (status); ALTER TABLE car ADD INDEX idx_car_price (price); ALTER TABLE car ADD INDEX idx_car_create_time (create_time);

索引不是越多越好,但它对业务查询的提升立竿见影。二手车列表页最常见的操作就是按品牌筛选、按价格排序、按发布时间倒序,这三个索引基本覆盖了核心查询路径。

2. 技术栈选型:为什么是SpringBoot、Vue、MyBatis、MySQL这一套

这套组合不是最炫的,但一定是最不缺教程、最容易找人问、踩坑成本最低的。选技术栈不是选秀,要考虑维护成本和熟悉度。

2.1 后端选型的现实考量

SpringBoot 2.7 + JDK8 是我在这类项目上比较推荐的组合。SpringBoot 3.x 虽然已经普及,但它要求JDK17,并且把javax包迁移到了jakarta,很多老版本的依赖和网上教程会直接报ClassNotFoundException。如果你拿到的是一套成熟源码,跑不起来的第一原因往往是JDK版本和SpringBoot版本不匹配。先用SpringBoot 2.7.18 + JDK8把项目跑通,后续再考虑升级,这是最务实的路线。

MyBatis 对比 JPA,最核心的优势是SQL完全可控。二手车交易系统的查询条件非常不固定,品牌、价格区间、里程区间、变速箱类型、排放标准,用户可能任意组合条件。这种场景如果用JPQL或者Spring Data JPA的Specification,写起来远不如MyBatis的动态SQL直观。你直接在XML里写<where>加<if>,每个条件一眼就能看懂,出了问题也好排查。

MySQL 在这个量级下没什么可挑剔的。单表几十万条数据,配合索引和分页查询完全扛得住。存储过程、视图这些尽量别用,逻辑放在Java代码里,后续维护和迁移都更方便。数据库字符集统一用 utf8mb4,别用 utf8,因为utf8在MySQL里存不了emoji和一些生僻字,虽然车辆描述里不一定有,但统一用utf8mb4能少一个隐患。

2.2 前端Vue与接口约定

前端我建议Vue2 + Element UI + Vue Router + Axios。这套组合虽然老,但生态成熟稳定,坑基本都被前人踩平了。Vue3的Composition API确实更好,但如果你是配合源码二次开发,尽量别在选型上折腾,先跑通再重构。

前后端分离不只是“前端一个项目、后端一个项目”这么简单,真正关键的是接口约定。如果前后端各写各的,联调阶段会非常痛苦。我习惯先约定一套统一响应结构:

{ "code": 200, "message": "操作成功", "data": {} }

后端所有接口都返回这个结构,前端Axios响应拦截器统一处理。code为200时取data,code为401时跳登录页,code为500时弹出错误提示。这样前端业务代码里不会到处散落try-catch和状态判断。

接口路径要遵守RESTful风格,比如:

  • GET /api/car/list 车辆分页列表
  • GET /api/car/detail/{id} 车辆详情
  • POST /api/car 发布车辆
  • PUT /api/car/{id} 编辑车辆
  • PUT /api/car/{id}/audit 管理员审核车辆
  • POST /api/order 买家下单

统一加 /api 前缀是为了部署时的反向代理。前端Nginx可以把这个前缀直接转发到后端服务,后面部署章节会专门讲。

2.3 用户认证:Session、JWT还是Redis

前后端分离项目,我不推荐用传统Session。Session依赖Cookie,Cookie存在浏览器里,跨域、跨端口、移动端调用都会遇到麻烦。用JWT(JSON Web Token)更符合前后端分离的架构。

JWT的核心思路是:用户登录成功后,后端把用户ID、角色、过期时间等信息签名生成一个token字符串返回前端。前端把token存在localStorage,每次请求在请求头里带上Authorization: Bearer token。后端写一个拦截器,解析token、校验过期时间、把用户信息放到ThreadLocal或请求上下文里。

如果不想引入额外组件,JWT不需要Redis,项目部署时少一个依赖,更稳。但JWT也有缺点:服务器端无法主动让token失效。如果用户被封禁,或者改了密码,JWT在过期前仍然有效。解决这个问题的简单做法是token有效期设短一些,比如2小时,前端在拦截器里检测到401时自动重新登录。对个人项目来说完全够用。

如果后续要做“记住登录状态7天”“强制下线”等功能,再引入Redis也不迟。Redis存token可以随时删除,管理起来更灵活,代价是多一个中间件要维护。教学项目和中小型系统,JWT足够。

3. 源码实现中最容易卡住的五个点

无论你是自己写还是拿源码二次开发,下面这几个地方是最容易出问题的。我把它们单独挑出来讲,因为这些都是我在实际项目里真正排查过的坑。

3.1 车辆多条件检索的SQL怎么写

车辆列表页的筛选条件很多:品牌、价格区间、里程区间、变速箱、排放标准、城市、排序方式。这些条件组合起来可能是一二三四五六七八个,但用户不会一次全选。MyBatis动态SQL就是为这种场景设计的。

核心思路是:用一个<where>标签包住所有<if>,MyBatis会自动去掉第一个AND/OR前缀,同时没有条件时不会拼出语法错误的WHERE子句。

<select id="selectCarList" resultMap="CarResultMap"> SELECT * FROM car <where> <if test="brand != null and brand != ''"> AND brand = #{brand} </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> <if test="mileage != null"> AND mileage &lt;= #{mileage} </if> <if test="gearbox != null and gearbox != ''"> AND gearbox = #{gearbox} </if> <if test="city != null and city != ''"> AND city = #{city} </if> AND status = '已上架' </where> ORDER BY ${orderByColumn} ${orderByType} </select>

注意两点。

第一,price &gt;=和price &lt;=中间的空格不能少,否则MyBatis解析XML时会误判成标签的开始或结束。这个错误非常隐蔽,报错时你会看到一堆莫名其妙的SQL语法异常。

第二,ORDER BY ${orderByColumn}使用了字符串拼接,而不是预编译的#{},这里必须做白名单校验。排序字段只允许传入create_time、price、mileage、view_count这几个固定值,排序方向只允许asc和desc。如果直接把前端参数拼接进去,会产生SQL注入风险。我在代码里通常先校验一遍:

String[] allowOrderColumns = {"create_time", "price", "mileage", "view_count"}; if (!Arrays.asList(allowOrderColumns).contains(orderByColumn)) { orderByColumn = "create_time"; }

3.2 图片上传与静态资源映射

二手车图片是刚需,一个车源至少五六张图。上传流程看起来简单,实际部署时出问题最多的就是图片。

后端接收文件的接口大致是这样的逻辑:校验文件大小和类型,然后生成UUID文件名,保存到本地磁盘目录,把文件相对路径存到car_image表,返回给前端拼接后的访问URL。

@PostMapping("/api/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + ext; String dir = uploadPath + "/car/" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); } file.transferTo(new File(dir + "/" + fileName)); String url = "/api/upload/car/" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "/" + fileName; return Result.success(url); }

为什么文件名要用UUID?因为用户上传的照片可能叫“微信图片_20240101120000.jpg”,也可能两个人上传同名文件。不用唯一文件名,后上传的会覆盖先上传的,这是一个很经典的生产事故。

数据库里不要存图片的绝对路径,比如D:/project/upload/car/xxx.jpg。一是用户换机器部署时数据库里的路径全部失效,二是绝对路径会泄露服务器目录结构。正确做法是存URL路径,比如/api/upload/car/20240101/xxx.jpg,部署时通过Nginx将/api/upload/映射到磁盘目录,这样数据库的路径和部署环境解耦。

SpringBoot本地开发时,需要配置静态资源映射,让/api/upload/**请求能对应到本地磁盘目录。可以在WebMvcConfigurer里加:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/api/upload/**") .addResourceLocations("file:" + uploadPath + "/"); }

注意file:后面要跟带分隔符的路径,并且addResourceHandler的路径要以/结尾。如果图片404,大概率是这里的斜杠问题。

3.3 交易状态机与并发下单

在线教育项目里最常见的错误是先查询车辆状态,再执行创建订单和修改车辆状态。但在高并发场景下,两个买家同时看到一辆“已上架”的车,同时点击下单,两个请求都查询到“已上架”状态,然后各自创建订单,最终一辆车被卖了两次。

解决方式不要用同步锁,也不要用Redis分布式锁,单机场景下最简单可靠的是数据库乐观更新。创建订单前,先执行一条带条件的状态更新:

@Transactional public Result createOrder(OrderCreateParam param) { Car car = carMapper.selectByIdForUpdate(param.getCarId()); if (!"已上架".equals(car.getStatus())) { return Result.error("车辆已下架或已售出"); } // 创建订单 Order order = new Order(); order.setCarId(car.getId()); order.setBuyerId(currentUserId()); order.setSellerId(car.getSellerId()); order.setAmount(car.getPrice()); order.setStatus("待确认"); orderMapper.insert(order); // 原子更新车辆状态 int rows = carMapper.updateStatus(car.getId(), "已上架", "已售出"); if (rows == 0) { throw new RuntimeException("车辆已售出,订单回滚"); } return Result.success(true); }

这句update car set status = '已售出' where id = #{id} and status = '已上架'是核心。MySQL在同一时刻只允许一个事务把这条记录的status从“已上架”改成“已售出”,第二个请求的rows必然为0,然后抛异常让整个事务回滚。既不会出现超卖,也不用手写复杂的锁代码。

事务里有个容易被忽视的点:@Transactional默认只回滚RuntimeException和Error。如果你在事务里用try-catch把异常接住又没抛出,事务不会回滚,订单创建了,车辆状态回滚了或没改成功,数据就错了。所以在Service层做事务控制时,尽量别自己catch异常,让异常往上层抛,交给Spring事务管理器统一回滚。

3.4 分页查询和MyBatis字段映射的坑

分页我建议自己写limit,而不是依赖PageHelper。PageHelper用起来确实方便,但它在复杂动态SQL、多表联查、嵌套结果时偶尔会分页失效或者count语句生成错误。自己写分页其实不复杂,查询条件里带pageNum和pageSize,计算offset后拼上limit,再单独写一个count查询返回总条数。

int offset = (pageNum - 1) * pageSize; List<Car> list = carMapper.selectCarList(query, offset, pageSize); int total = carMapper.countCarList(query);

MyBatis里最坑的问题是下划线字段映射。如果数据库字段叫create_time,实体类属性叫createTime,而MyBatis配置里没有开启驼峰映射,查出来的createTime永远是null。这个问题的排查很折磨人,页面数据和SQL都能查到,就是Java对象里字段为空。

在application.yml里加上:

mybatis: configuration: map-underscore-to-camel-case: true

开启之后,create_time自动映射到createTime。如果你用的是多表联查,比如订单表关联车辆表,两个表都有id字段,那就不能靠自动映射,必须在XML里写resultMap,把每个字段的映射关系彻底明确。还有一个经验:resultType不要用Map。Map虽然写着方便,但下划线字段名会原样出现在Key里,前端拿到的JSON字段名全变成create_time这种风格,和实体类映射出来的createTime对不上。建议每个查询都定义一个VO类。

3.5 跨域问题:开发环境与生产环境的两种处理

前后端分离必然会遇到跨域。开发环境最简单的方式不是在后端开CORS,而是在Vue脚手架里配置devServer代理。前端页面跑在http://localhost:8080,后端跑在http://localhost:9090,让Vue的devServer把/api开头的请求代理到后端。

// vue.config.js module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } } }

这样前端代码里请求地址直接写/api/car/list,浏览器看到的所有请求都指向当前前端域名,没有跨域问题。

生产环境我一般直接用Nginx解决,让前端页面和后端接口在同一个域名下。比如访问https://yourdomain.com是前端页面,访问https://yourdomain.com/api时Nginx把请求反向代理到后端的http://localhost:9090。这样对外完全看不出前后端分离,也彻底规避了跨域。

如果非要后端单独开跨域,可以写一个全局CorsFilter,允许来源、方法和请求头。但要注意:跨域请求会先发一个OPTIONS预检请求,这个请求必须被后端正常响应,否则POST请求会失败。如果你在后端配置了登录拦截器,一定要把OPTIONS请求放行,否则前端会报CORS错误,但后端日志里看不到任何异常。

4. 部署教程:从裸机到可访问

部署是另一个大坑。很多项目本地跑得好好的,搬到服务器上就各种404、白屏、图片加载不出来。这一章我按顺序把从零到能访问的完整步骤写一遍。

4.1 部署前的版本与工具准备

先确认服务器上的基础环境。Linux服务器建议CentOS 7或者Ubuntu 20.04,Windows服务器也能跑,但生产环境还是Linux为主。

需要安装的东西有:

  • JDK 1.8
  • Maven 3.6以上,用于后端打包
  • Node.js 14或16,用于前端构建
  • MySQL 5.7或8.0
  • Nginx

注意Node版本。Vue2项目对Node版本敏感,Node 18以上的版本有时会在npm install阶段报OpenSSL错误。如果遇到这种问题,优先使用Node 16。前端构建前可以先看package.json里有没有node-sass这类老旧依赖,如果有,它的版本对Node版本有严格限制。

版本确认命令:

java -version mvn -version node -v npm -v mysql --version nginx -v

4.2 数据库初始化

如果源码保含SQL脚本,通常是一个.sql文件。建议先手动创建数据库,再导入脚本,这样编码和字符集可控。

mysql -uroot -p CREATE DATABASE car_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE car_system; SOURCE /usr/local/car_system.sql;

如果你用的是Navicat这类图形工具,导入时注意选择utf8mb4编码。SQL脚本里有中文注释或初始化数据时,编码错误会导致乱码或者导入失败。

导入成功后,要改后端配置文件里的数据库连接信息。重点看application.yml:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/car_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword

这里serverTimezone=Asia/Shanghai必加。MySQL 8.0和JDBC驱动版本不匹配时,不加时区参数会直接报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这个是乱码显示的中文时区名,不是MySQL真的坏了,在连接串里明确指定时区即可解决。

4.3 后端打包与启动

后端代码拉到服务器或者本地打包后传到服务器。在项目根目录执行:

mvn clean package -DskipTests

-DskipTests跳过测试,避免打包过程中因为测试环境不完整而失败。打包完成后,target目录下会生成一个jar包,名字类似car-system-0.0.1-SNAPSHOT.jar。

启动时不要直接java -jar xxx.jar,因为一旦关掉终端,进程就没了。用nohup放到后台:

nohup java -jar car-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > car.log 2>&1 &

查看启动日志:

tail -f car.log

看到Started CarApplication in x seconds说明启动成功。如果启动失败,99%是数据库连接问题,看日志里的Caused by部分,不要被前面的长堆栈吓到。

后期维护要用到停止和重启:

ps -ef | grep car-system kill -9 进程号

如果你想更规范一点,可以配置systemd服务,让SpringBoot作为系统服务托管,开机自启、崩溃自动重启。不过个人项目先用nohup就够。

4.4 前端构建与Nginx配置

前端构建前,先确认后端接口地址在哪里配置。如果开发环境的Axios基地址写的是http://localhost:9090/api,那打包前必须改成/api,让前端所有请求都发到当前域名下,由Nginx反代到后端,否则用户浏览器会直接向后端服务发起跨域请求。

安装依赖并构建:

npm install npm run build

构建完成后,前端项目下会出现dist目录。把dist目录上传到服务器,比如/usr/local/dist。然后配置Nginx。

server { listen 80; server_name yourdomain.com; root /usr/local/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/upload/ { alias /usr/local/upload/; } }

这里有几个关键点。

try_files $uri $uri/ /index.html;是为了适配Vue Router的history模式。如果前端路由是history模式,访问/detail/1时服务器找不到这个物理文件,就必须回退到index.html,由前端路由接管。如果不写这一行,刷新页面就会404。如果你的项目用的是hash模式,URL里带#,那不需要这个配置,但我建议用history模式,URL更干净。

location /api/upload/单独放在/api/外面,是为了让图片请求直接由Nginx读取磁盘文件,不经过Java后端。这样图片加载速度快,也不占用后端线程。注意Nginx的alias路径末尾一定要有/,否则/api/upload/car/1.jpg会匹配到/usr/local/uploadcar/1.jpg,这个错误非常隐蔽。

配置改完后,重载Nginx:

nginx -s reload

然后访问http://yourdomain.com,如果能看到首页,就说明前端部署成功。

4.5 部署中常见的坑

我把部署阶段最常见的四类问题整理一下,遇到时可以照着排查。

第一,端口被占用。后端启动失败,日志提示端口被占用时,先查端口:

lsof -i:9090 netstat -tlnp | grep 9090

要么杀掉占用进程,要么在application.yml里改端口。

第二,前端页面能打开但接口全部报404。先看浏览器Network,请求的实际URL是什么。如果请求的是http://localhost:9090/api/...,说明前端打包时没有把接口地址改成/api,需要重新配置Axios基地址并重新构建。如果请求的是https://yourdomain.com/api/...,再看Nginx是否配置了/api/的反向代理。

第三,图片上传成功但访问不了。上传接口返回了URL,但浏览器访问图片返回404。先确认后端配置的静态资源映射路径和实际磁盘路径是否一致,再看Nginx的alias路径是否正确。我遇到过很多次,图片存在了/usr/local/upload/,前端请求/api/upload/xxx,但Nginx里忘写alias,直接把请求代理到了Java后端,后端又没有对应的Controller,于是404。

第四,MySQL 8.0连接报错。如果数据库用的是MySQL 8.0,驱动类要写com.mysql.cj.jdbc.Driver,不是com.mysql.jdbc.Driver,后者是MySQL 5.x的驱动。同时pom.xml里的数据库驱动版本要跟着MySQL版本走:

<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

5. 拿到项目后怎么二次开发和排错

源码跑通只是第一步,后续你大概率要在这个基础上加功能、改逻辑。这一章聊一聊二次开发的切入方式,以及几个高频报错的具体定位思路。

5.1 新功能模块的切入方式

如果你想加一个“车辆年检到期提醒”之类的功能,不要一上来就写Controller。我的习惯是严格按照数据库 → Mapper → Service → Controller → Vue页面的顺序来加。

第一步,先设计表和字段。比如车辆表加一个inspection_date字段。第二步,在实体类加对应属性。第三步,在Mapper接口和XML里加对应的查询方法。第四步,写Service业务逻辑。第五步,Controller暴露接口。第六步,前端写API文件和页面。

如果直接先写Controller,你很快会发现:没有实体类,没有Mapper,接口根本没法落。逆向开发看起来效率高,但中间会反复补课。顺着数据层往上写,每一层都知道上一层需要什么,出错率最低。

前端新增一个页面时,有三个地方容易漏:路由配置、菜单配置、API文件。如果一个新页面点击后白屏,控制台报Failed to resolve component,十有八九是路由组件没注册。Element UI的侧边栏菜单如果是根据路由数组自动生成的,别忘了同步加菜单项。

5.2 性能优化与存储方案扩展

二手车列表页如果数据量上来了,第一件事是先看慢查询日志。MyBatis可以在配置里开启SQL日志:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

打开后每个查询SQL都会打印,耗时和实际语句一目了然。如果发现列表查询很慢,先explain看执行计划,重点检查有没有走索引、扫描行数有没有超过预期。

图片存储从本地上传到对象存储,是这类项目做大后的必经之路。本地磁盘存储有单机容量瓶颈,也没有CDN加速能力。改造思路不复杂:上传文件时传给对象存储,拿到一个URL直接存到car_image表,Nginx里那个upload映射就可以拆掉了。这个改动对前端完全透明,因为前端始终只认图片URL。

车辆检索量非常大的时候,MySQL组合索引也扛不住全文检索类的需求。这时可以考虑引入Elasticsearch,把车辆数据同步到ES,查询走ES,后端MySQL只作为权威数据源。但我要提醒一句:个人项目和中小企业项目,数据量没到几十万条之前,别引入这个复杂度。MySQL + 合理索引 + 分页完全能满足几千到几万条的数据量。

另外可以加个定时清理任务,用SpringBoot自带的 @Scheduled 注解,每天凌晨自动把超过30天未支付且状态为待确认的订单置为已取消,把车辆重新置为已上架。这类小功能对系统运营很重要,但很多课程设计项目会漏掉。

@Component public class OrderClearTask { @Scheduled(cron = "0 0 2 * * ?") public void clearExpiredOrders() { // 查询超过30天未支付的订单 // 更新订单状态为已取消 // 将对应车辆状态恢复为已上架 } }

别忘了在启动类或配置类加上@EnableScheduling,否则定时任务不会生效。这又是一个经常被忽略的配置项。

5.3 几个高频报错的处理思路

最后说几个我在跑这套项目时处理过的高频报错。

第一个,启动报Invalid bound statement (not found): xxxMapper.xxxMethod。这是MyBatis最常见的问题,意思是Mapper接口的方法在XML里找不到对应的SQL。排查顺序:XML文件里的namespace是否和接口全限定名一致;方法名是否和XML的id一致;XML文件是否被Maven打包到了classes目录;application.yml里mapper-locations是否配置了classpath:mapper/*.xml。个人项目里,还有一个很隐蔽的原因:XML文件放在了src/main/java目录下而不是resources目录,结果没被编译进去。把XML移到resources/mapper目录,问题一般就解决了。

第二个,前端点击登录后一直转圈,接口报401。先看请求头里有没有带Authorization。如果登录接口本身是401,说明认证拦截器把登录接口也拦截了。登录接口需要在拦截器配置里放行,还有图片访问、车辆列表等公开接口也要放行。我一般把接口分成两部分:不需要登录的公开接口(车辆列表、车辆详情、图片访问)和需要登录的业务接口(发布车辆、下单、收藏、后台管理),用路径匹配来区分。

第三个,Element UI表格数据加载正常,但时间显示成一串数字或者2024-01-01T00:00:00.000+08:00这种格式。这是JSON序列化时间格式的问题。后端在application.yml里统一配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这样传给前端的时间字符串就是人类可读的格式,前端不用再做二次转换。

第四个,页面能打开但是菜单和权限很乱。如果角色权限是前端控制的,通常逻辑是在登录接口返回用户角色,前端根据角色字段决定菜单显示项。排查这种问题,先打开浏览器Network看登录接口返回的JSON,确认role字段的值。然后再看路由守卫里对比的是不是这个字段。很多人会在后端返回role: "admin",前端路由守卫却判断== '管理员',两边不一致,导致菜单怎么都不显示。字段值是字符串,前后端必须约定一致,这是最简单的坑,也最容易因为改了一个地方忘了另一个地方而踩中。

四、这套项目跑通之后,我的一个核心建议是:不要急着往里面堆功能。先把你自己的业务疑问写在纸上,逐条对着代码找实现,弄明白每条数据是怎么从数据库流转到前端页面的。这个过程比单纯复制粘贴源码有价值得多。毕竟二手车交易系统的难点从来不在技术,而在于能不能把状态、权限、数据流之间的关系理清楚。理清楚了,后面加什么功能都是顺手的事。

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

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

立即咨询