☰
Springboot二手车交易系统实战:从表结构设计到并发下单避坑
2026/9/26 12:46:18 网站建设 项目流程

简介:一份基于SpringBoot框架开发的二手车交易系统源码,采用B/S架构,覆盖二手车信息发布、浏览与交易管理等典型业务场景,适合计算机相关专业学生用于毕业设计、课程项目或框架实战参考。代码含中文注释且经过运行测试,整体功能完整可靠。压缩包共782个文件,约31.75MB,以Java源码、Vue前端页面、JavaScript脚本及样式文件为主,同时包含构建运行所需的bat脚本、配置文件和演示视频,方便快速搭建环境、查看效果。已有128人学习下载。该资源可作为学习借鉴的完整样例,读者能从中了解前后端分离项目的结构组织、SpringBoot接口实现方式及常见功能模块划分,也可在此基础上自行扩展和修改,用于毕业设计或项目演练。

1. 二手车交易系统为什么值得用 Springboot 重写一遍:先把这套方案的底细看清

拿到一个二手车交易系统的需求,最容易被拖垮的往往不是「车」这个领域有多复杂,而是交易链路本身:上架、询价、锁单、过户、尾款,每个角色都有自己要看的页面和数据权限,稍不注意就把项目做成一张大表到处查。用 Springboot 做二手车交易系统之所以是当前毕设和企业内部演示系统最常见的选型,是因为 Springboot 的自动装配帮我们把数据源、Web 容器、事务管理器一次配好,中文注释的代码又能直接当业务文档读。这套方案适合三类人:手里有 Springboot 基础、想找个完整项目练手的应届生;需要快速给客户做二手车业务原型的开发;以及做毕设但不想从零搭环境的同学。下面前四章按「先看懂设计,再动手改代码,最后避坑」的顺序展开,第五章给出验证清单,照着做就能知道这套代码的水位在哪。

2. 先把交易链路拆开:Springboot 二手车交易系统的模块划分与表结构设计

设计一个二手车交易系统,最忌讳一上来就写车辆 CRUD。业务上真正绕不开的是四件事:车源发布和审核、买家咨询和下单、订单状态流转、以及车辆上下架与成交后的数据联动。这一章我会按 Springboot 常见的 controller-service-mapper 三层来拆模块,把每张核心表的主键策略、关键字段、状态字段给出可直接执行的建表 SQL,然后再讲为什么这样设计索引和状态机。

2.1 模块划分:车辆、订单、用户、后台管理四块怎么切

二手车交易系统在功能上可以切成四个大模块:前台门户(注册登录、车辆浏览、搜索筛选、收藏咨询)、车辆模块(发布、审核、上下架、图片管理)、订单模块(创建订单、支付回调占位、取消、确认成交)、后台管理(用户管理、车辆审核、订单管理、数据统计)。

我一般会把车辆模块和订单模块分开建表,而不是混在一张业务表里硬加 type 字段。原因是车辆的生命周期和订单的生命周期完全是两套状态:车辆只有「草稿、待审核、在售、已下架、已售出、违规下架」六个状态,而订单有「待付款、已付款待过户、过户中、已完成、已取消、退款中」六个状态,两者在业务上是一对多的关系(一台车可以产生多笔意向订单,但只有一笔能成交)。混在一起会让后面加「同类车推荐」或者「车辆热度统计」时无从下手。

Springboot 的分层在这里的价值是:controller 只做参数接收和简单校验,service 层持有业务逻辑,mapper 层只碰 SQL。一个常见的错误是把「查询车辆列表」的过滤逻辑写在 controller 里用 if 拼接,等条件多到四个以上就完全没法维护。正确做法是把筛选条件封装成一个 Query 对象传入 service,由 service 决定怎么拼 MyBatis 动态 SQL。

2.2 核心建表脚本:用户表、车辆表、订单表的关键字段与索引

直接给可以执行的 MySQL 建表脚本。这套脚本我在本地 MySQL 8.0 上验证过,注意字符集必须写成 utf8mb4,不然车辆描述里出现 emoji 会直接报错。

CREATE TABLE `car` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `seller_id` BIGINT NOT NULL COMMENT '发布人用户ID', `brand` VARCHAR(50) NOT NULL COMMENT '品牌', `series` VARCHAR(50) NOT NULL COMMENT '车系', `model_year` SMALLINT NOT NULL COMMENT '上牌年份', `mileage_km` INT NOT NULL COMMENT '表显里程(公里)', `gearbox` TINYINT NOT NULL DEFAULT 1 COMMENT '变速箱 1自动 2手动', `displacement` VARCHAR(10) DEFAULT '1.5L' COMMENT '排量', `emission_std` VARCHAR(10) DEFAULT '国VI' COMMENT '排放标准', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `car_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待审核 2在售 3已下架 4已售出', `audit_remark` VARCHAR(255) DEFAULT NULL COMMENT '审核驳回原因', `cover_url` VARCHAR(255) DEFAULT NULL COMMENT '封面图URL', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_brand_series` (`brand`, `series`), KEY `idx_status_price` (`car_status`, `price`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手车车辆信息表';

这段 DDL 里有三个值得注意的设计决策。第一,price用DECIMAL(10,2)而不是DOUBLE,因为订单金额、过户费用都要做到两位小数精确累加,浮点类型在累计时会丢精度,这是二手车系统里最容易出现的隐形 bug。第二,car_status单独建了索引并和price组成联合索引,因为前台列表最常见的查询是「筛选在售车并按价格排序」,这样走覆盖索引就能减少回表。第三,把audit_remark直接放在车辆表里,而不是单独建审核记录表,对毕设和中小系统来说是合理的取舍,省一次联表查询,代价是审核历史不可追溯,等系统真上线再拆不迟。

接着是订单表,核心是状态字段和版本号。

CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `car_id` BIGINT NOT NULL COMMENT '车辆ID', `buyer_id` BIGINT NOT NULL COMMENT '买家用户ID', `seller_id` BIGINT NOT NULL COMMENT '卖家用户ID', `trade_price` DECIMAL(10,2) NOT NULL COMMENT '成交价', `deposit` DECIMAL(10,2) DEFAULT 0.00 COMMENT '定金', `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款待过户 2过户中 3已完成 4已取消 5退款中', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer_status` (`buyer_id`, `order_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

订单表这里我给了一个version乐观锁字段,在 Springboot 里配合 MyBatis-Plus 的@Version注解就能实现防重复下单。二手车订单的典型并发场景是:买家 A 和买家 B 同时看到同一台在售车,几乎同时点了「立即下单」。如果没有锁,两个订单都会创建成功,最后过户时发现车只有一台。常见做法是在创建订单的 service 方法里先执行一条带状态的更新语句UPDATE car SET car_status = 3 WHERE id = ? AND car_status = 2,影响行数为 0 就说明车已被别人锁单。订单表自身的version则用来防「重复确认过户」的并发操作。

2.3 状态机与事务边界:为什么车辆状态和订单状态不能放在一个事务里

二手车交易系统里最容易被忽视的设计是状态流转的原子性。我见过不少项目把「创建订单」和「更新车辆状态」写在同一个事务方法里,看起来没毛病,但实际埋了个雷:如果买家下单后迟迟不付款(比如超时 30 分钟),系统要自动释放车辆,这时候要把「车辆状态从已锁单改回在售」和「订单状态改成已取消」放在同一个事务里执行,但订单状态更新可能因为各种原因失败,比如订单已经被买家手动取消,那么事务回滚会把车辆状态也回滚成已锁单,导致车被莫名锁死。

正确的事务边界是:同一次请求里业务上必须同时成功或同时失败的状态变更才放同一个事务;跨定时任务、跨消息触发的状态变更,用「先改状态,再补偿」的方式做。比如超时释放订单,我会先执行UPDATE order_info SET order_status = 4 WHERE id = ? AND order_status = 0,如果影响行数为 1,再执行UPDATE car SET car_status = 2 WHERE id = ? AND car_status = 3;第二步失败就记录一条补偿日志,由定时任务重试。这样即使第二步失败,也不会把已取消的订单重新翻成待付款,数据一致性靠状态条件而不是靠事务回滚来兜底。

这章先到这里,下一章我们把车辆发布、订单创建和登录鉴权这几个核心接口用 Springboot 代码串起来,包括 MyBatis-Plus 的分页插件怎么在这里落地。

3. 把核心链路跑起来:Springboot 里登录鉴权、车辆发布、下单的落地代码

这一章进入动手环节。我会沿着一趟完整的买家交易路径来讲:注册登录拿到 token,卖家发布车源,买家筛选车辆并下单。每一步都给出在 Springboot 项目中可以直接使用的最小代码,并说明关键参数怎么调、失败时看什么日志。

3.1 登录鉴权:JWT 工具类、拦截器与登录接口的完整闭环

Springboot 做登录鉴权最常见的选择是 JWT + 拦截器,不用引入 Spring Security 那么重的框架。先看 JWT 工具类,这里我用的是 jjwt 0.9.1 版本,注意 0.9.x 和 1.x 的 API 差别很大,网上很多教程混着写,照着复制经常会编译不过。

@Component public class JwtUtil { // 实际项目中这个 secret 要放到配置中心或环境变量,不要直接写死在代码里 private static final String SECRET = "your-256-bit-secret-change-me"; private static final long EXPIRE_MS = 2 * 60 * 60 * 1000L; // 2小时过期 public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_MS)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET) .parseClaimsJws(token).getBody(); } }

逻辑说明:generateToken把用户 ID 和用户名放进 JWT 的 claim 里,parseToken负责校验签名和过期时间。EXPIRE_MS设成 2 小时,对后台管理端偏短,对前台用户端合适——二手车交易涉及支付和过户,token 有效期太长会有安全风险,太短则用户频繁登录体验差。如果不想让用户在会话中途被踢出,常见做法是在拦截器里判断剩余有效期不足半小时时自动续签一个新 token 放回响应头,由前端在下次请求时替换,这里不展开。

再写拦截器,这里先排除登录和浏览车辆这些公开接口:

@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equalsIgnoreCase("OPTIONS")) { return true; // 跨域预检请求直接放行 } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } try { Claims claims = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"登录已过期\"}"); return false; } } }

使用说明:拦截器解析 token 成功后,把userId放到 request attribute 里,这样后续 controller 里直接用request.getAttribute("userId")就能拿到当前登录人,不需要每次查库。要注意的是@Autowired注入的拦截器实例是单例的,不要在拦截器里保存任何与具体请求相关的状态变量,否则高并发下会串数据。

登录接口本身不复杂,但有个关键参数容易忽略——密码加密。标准做法是 BCrypt,密码存库时是 60 位字符串,任何情况下不要用 MD5 明文存储,二手车系统涉及买卖双方个人信息,密码泄露是非常严重的合规问题。

@PostMapping("/api/auth/login") public Result login(@RequestBody LoginRequest req) { // BCryptPasswordEncoder 是 spring-security-crypto 里的独立类, // 不引入完整 Spring Security 也能直接用 BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); User user = userMapper.selectByUsername(req.getUsername()); if (user == null || !encoder.matches(req.getPassword(), user.getPassword())) { return Result.fail(400, "用户名或密码错误"); } String token = jwtUtil.generateToken(user.getId(), user.getUsername()); return Result.ok(new LoginResp(token, user.getNickname())); }

这里有一个 Springboot 新手常犯的错:为了做 BCrypt 把整个spring-boot-starter-security引入进来,结果项目启动后所有接口都被默认登录页拦住了,排查半天。实际上只需要引入spring-security-crypto这一个依赖包,就能使用BCryptPasswordEncoder,完全不影响 Springboot 的自动配置。

3.2 车辆发布:MyBatis-Plus 分页插件配置与图片上传路径的取舍

Springboot 使用 MyBatis 做持久层是很常见的组合,我习惯用 MyBatis-Plus,因为内置的BaseMapper能省掉大部分单表 CRUD 的 XML。但用 MyBatis-Plus 必须显式配置分页插件,否则.selectPage()方法只会执行全表查询然后内存分页,车辆表数据过万后接口直接卡死。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // DbType.MYSQL 告诉插件用 MySQL 的 LIMIT 语法 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

参数说明:PaginationInnerInterceptor的maxLimit属性默认没有上限,这意味着有人传pageSize=99999时数据库会被一次拉全表。建议在配置里补一行paginationInnerInterceptor.setMaxLimit(100L),超过 100 条强制按 100 条返回,这是接口安全的底线。

车辆发布接口的核心逻辑是「插入车辆记录 + 保存图片关系」。图片这里有个常见取舍:本地磁盘存储还是对象存储。如果是毕设或内网演示系统,直接存在本地指定目录最省事;如果要上线,至少改成 MinIO 这类私有对象存储。Springboot 项目里引入 MinIO 的方式是直接加一个MinioClient的配置 Bean,上传时用putObject,和本地存储切换的成本只在一个上传 Service 的实现类上,这种设计我在前面写模块划分时特意留了 service 接口,就是为了后面换存储实现不碰 controller。

车辆发布代码只贴核心事务部分:

@Transactional(rollbackFor = Exception.class) @Override public Long publishCar(CarPublishRequest req, Long sellerId) { Car car = new Car(); BeanUtils.copyProperties(req, car); car.setSellerId(sellerId); car.setCarStatus(0); // 草稿态,提交审核后变为 1 carMapper.insert(car); // 图片表批量插入,carId 回填后关联 if (req.getImageUrls() != null && !req.getImageUrls().isEmpty()) { for (String url : req.getImageUrls()) { carImageMapper.insert(new CarImage(car.getId(), url)); } } return car.getId(); }

逻辑说明:@Transactional保证了车辆主记录和图片子记录要么都插入成功,要么都回滚。rollbackFor = Exception.class一定要写,因为 Spring 默认只在 RuntimeException 时回滚,捕获了异常后事务是不回滚的,这是事务失效的最高频原因之一。上传图片的接收逻辑里有一个细节:MultipartFile 的getOriginalFilename()在部分浏览器下会带全路径(比如 IE 返回C:\fakepath\1.jpg),保存文件时千万不要直接用这个原始文件名拼路径,必须用 UUID 重新生成文件名,否则会出现路径穿越和重名覆盖问题。

@PostMapping("/api/car/publish") public Result publish(@RequestParam("file") MultipartFile[] files, @RequestParam("data") String jsonData) throws Exception { // 处理文件 List<String> savedUrls = new ArrayList<>(); for (MultipartFile file : files) { String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext; File dest = new File(uploadDir, newName); file.transferTo(dest); savedUrls.add("/images/" + newName); } // jsonData 用 fastjson2/Jackson 解析成 CarPublishRequest CarPublishRequest req = JSON.parseObject(jsonData, CarPublishRequest.class); Long carId = publishCar(req, getCurrentUserId()); return Result.ok(carId); }

参数说明:uploadDir在application.yml里通过@Value("${custom.upload-dir}")注入,而不是写死绝对路径。Windows 本地开发可能配D:/tmp/upload,Linux 部署时配/data/car_images,用绝对路径还是相对路径以部署环境为准,但一定要确保这条路径对应用户有读写权限。另一个坑是transferTo(dest)目标文件的父目录必须存在,否则会抛 IOException,建议在保存前执行dest.getParentFile().mkdirs()。

3.3 下单与状态流转:乐观锁防重复下单的两种写法对比

下单是二手车交易系统里最容易产生并发 bug 的地方。第一种写法用 MyBatis-Plus 的乐观锁插件:实体类加@Version注解,更新时 MyBatis-Plus 会自动拼上WHERE version = ?,影响行数为 0 说明版本号对不上,需要重试或直接报错给用户。

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long carId, Long buyerId, BigDecimal tradePrice) { // 1. 校验车辆状态:加悲观锁或者乐观锁条件更新 int rows = carMapper.lockForSale(carId); // lockForSale 对应 SQL: UPDATE car SET car_status = 3 WHERE id = ? AND car_status = 2 if (rows == 0) { throw new BizException("车辆已下架或已被其他买家锁定"); } // 2. 创建订单 OrderInfo order = new OrderInfo(); order.setOrderNo(generateOrderNo()); // 时间戳 + 随机数 + userId 后缀 order.setCarId(carId); order.setBuyerId(buyerId); // tradePrice 取当前车辆表 price,不允许前端传,防止篡改价格 order.setTradePrice(tradePrice); order.setOrderStatus(0); orderMapper.insert(order); return order.getId(); }

逻辑说明:carMapper.lockForSale是关键,它对应的 SQL 用了「条件更新」而不是「先查再改」,这是防并发超卖的核心。先查再改的问题在于:查询拿到car_status=2(在售),还没来得及更新,另一个请求也查询到了同样的状态,两个请求后边的更新操作都会执行,车就被下单两次。条件更新把「检查状态」和「修改状态」合并成一条原子 SQL,数据库行锁保证同一时刻只有一个请求能改成功。tradePrice必须取车辆表当前售价而不是前端传值,这属于基本的接口安全设计——前端传的价格不可信。

关于订单号生成,generateOrderNo()不要用系统时间戳简单拼接,并发下会重复。我常用的是 18 位:yyyyMMddHHmmss + 6位随机数,主键用数据库自增,业务订单号做唯一索引,这样日志排查时一眼能看出下单时间,而且不依赖 Redis 也能保证足够低的碰撞概率。

4. Springboot 二手车交易系统的 5 个高发踩坑:现象、原因、解决

这一章是我做这个方向时真实遇到过的坑,按「现象 → 原因 → 解决」三条式写清。每个坑都不冷门,新手和做课程设计的人都容易踩。

4.1 车辆列表分页查不到最新数据:MyBatis-Plus 分页插件没配置的隐蔽后果

现象:调用分页接口返回的total始终是整张表的数据量,翻到第二页时数据还是第一页的内容,或者新增的车辆在列表里 10 分钟不出现。

原因:没配置PaginationInnerInterceptor,.selectPage()退化成全表查询后在内存里做分页。数据量小的时候看起来「正常」,数据量一大,total是按内存里 List 的 size 算的,自然和数据库真实记录数对不上。

解决:在配置类里注册 MyBatis-Plus 拦截器,并设置setMaxLimit(100L)做安全兜底。排查时可以打开 MyBatis SQL 日志,application.yml配mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,如果输出的 SQL 没有LIMIT关键字,就是分页插件没生效。

4.2 金额计算对不上账:Double 类型累加丢精度

现象:车辆价格加过户费(比如 0.1 + 0.2),页面上显示成 0.30000000000000004,订单总金额偶尔比手算多一分钱或少一分钱。

原因:Java 的double和float是二进制浮点数,无法精确表示 0.1/0.2 这样的十进制小数。Springboot 项目从 MySQLDECIMAL字段取值映射到BigDecimal是没问题的,但一旦 Java 实体里用Double接收,精度就开始丢了。

解决:Java 实体类全部用BigDecimal对应数据库DECIMAL字段,controller 接收前端金额也用BigDecimal而不是Double。计算时统一走BigDecimal.add(),不要用+运算符。如果项目中已有的订单表已经用 Double 存了数据,迁移时写个一次性脚本把金额乘 100 转成整数分存储,是最稳妥的后悔药。

4.3 图片上传后刷新页面 404:Springboot 没有做静态资源映射

现象:开发环境上传图片成功,访问http://localhost:8080/images/xxx.jpg返回 404,把图片保存路径配到项目目录里也不好使。

原因:Springboot 默认把classpath:/static/作为静态资源目录,上传到本地磁盘的目录并不在这个映射范围内。很多人为了省事把图片存到src/main/resources/static/images下,开发时能用,打包成 jar 部署后图片目录在 jar 包内部,重启一次就丢。

解决:在配置类里注册资源映射,或者直接在配置类加一个 WebMvcConfigurer:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${custom.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadDir + "/"); } }

参数说明:file:前缀是关键,告诉 Spring 这是文件系统路径而不是 classpath 路径。uploadDir记得以/结尾,比如/data/car_images/,否则映射拼接出来的路径会少一个分隔符。生产环境如果前端和后端分离部署,这个映射就不需要了,图片应该走独立的对象存储或 Nginx 静态服务器。

4.4 定时释放订单把已付款的订单取消掉了:状态条件更新用错

现象:定时任务每 30 分钟扫描一次「待付款」订单,把超时的订单取消并把车辆释放回在售。某天买家刚付完款,订单还是被取消了,用户在线下投诉。

原因:定时任务的 SQL 写的是WHERE order_status = 0,但没加时间条件,或者加了时间条件但用了<=而不是<,导致刚在边界时刻付款的订单被扫到。更隐蔽的是取消订单的代码里先查订单状态再更新,两步之间有并发窗口:买家在付款接口里已经UPDATE order_status = 1,定时任务此时查到的是旧状态0,随后执行UPDATE order_status = 4,把已付款订单覆盖了。

解决:定时任务和业务接口的状态更新全部改成「条件更新」,并且条件里带上状态判断。例如取消超时订单的 SQL 写成UPDATE order_info SET order_status = 4 WHERE id = ? AND order_status = 0 AND created_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)。这样买家付款时执行UPDATE order_info SET order_status = 1 WHERE id = ? AND order_status = 0,两条更新同一时刻只有一个能成功,从根本上避免覆盖。

4.5 二手车搜索一输入中文就乱码或查不到:连接串与前端编码不一致

现象:前端搜索「大众」查不到任何结果,但数据库里明显有对应记录;偶尔查询条件里的中文变成???。

原因:最典型的是 JDBC 连接字符串没带characterEncoding=utf8。MySQL 8.0 的驱动会将连接字符集与服务器端字符集协商,如果服务器的character_set_server不是 utf8mb4,而 tomcat 默认 URI 编码是 UTF-8,传到数据库就变成乱码。另一个原因是前端 fetch/axios 默认的Content-Type没有显式指定application/json;charset=UTF-8,请求体里的中文内容被按 ISO-8859-1 解析了。

解决:application.yml 里数据源 URL 显式加参数。

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/car_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false

参数说明:characterEncoding=utf8在 MySQL 8.0 中代表 utf8mb4,不需要写成utf8mb4。serverTimezone=Asia/Shanghai必须设置,否则 Springboot 连接 MySQL 8.0 会报时区错误。排查时可以在数据库执行SHOW VARIABLES LIKE 'character_set_%'确认库、表、列三级字符集都是 utf8mb4。

5. 一套马上能用的验证清单:把核心链路跑一遍再下结论

前面几章把设计和代码讲完了,最后给一份我在交付前必跑的验证路径。二手车交易系统的验收核心不在页面多漂亮,而在交易链路的数据一致性,按下单、付款、释放订单、成交四条线依次验证。

第一步准备测试数据:在数据库里准备两个测试用户和一个卖家账号,插入种子 SQL 时要注意车辆表的seller_id要和用户 ID 对得上。然后启动 Springboot 项目,在浏览器先访问http://localhost:8080/api/car/list确认接口返回 JSON 而不是 404。

第二步用 Postman 或 Apifox 测登录和鉴权。先调用登录接口拿token,然后不带 token 访问/api/order/create,预期返回 401;带上Authorization: Bearer <token>再访问,预期返回参数错误而不是未登录。这一步能同时验证拦截器放行规则和 JWT 解析链路是不是通的。

第三步走一遍订单全链路。卖家发布车 → 管理端审核通过 → 买家下单 → 确认车辆状态已变更(在售改锁单)→ 用第二条买家账号重复下单同一辆车,预期收到「车辆已被锁定」的错误提示。这是整个系统里最核心的并发验证,如果两条下单都能成功,先回 4.4 检查更新条件。

关注的重点参数有三个:分页接口的pageSize传 999 时是否被maxLimit拦截;订单金额计算用BigDecimal是否输出正确;定时任务取消订单后车辆是否回到「在售」状态。这三个点对应前面第四章节的坑,验证时逐笔记下日志里UPDATE语句的影响行数,影响行数为 0 的路径都是业务异常分支。

进阶方向我一般会建议加两个东西。第一个是 Redis 缓存车辆列表:把热门筛选条件下的列表页缓存 60 秒,车辆详情页缓存 5 分钟,能释放不少数据库压力,Springboot 整合 Redis 只需要引入spring-boot-starter-data-redis并在application.yml配spring.data.redis.host,然后给车辆列表 Service 加一层缓存即可。第二个是检索体验优化:当车辆数据量到十万级,MySQL 的LIKE '%关键词%'会很吃力,把标题、品牌、车系同步到 Elasticsearch,用 Springboot 的spring-boot-starter-data-elasticsearch做搜索接口,相关度排序和筛选都更专业。这两个方向都是在现有代码上加一层,不需要推翻主流程,适合作为进阶选型。

我自己的习惯是每个模块写完后先打开 MyBatis 的 SQL 日志看一眼真实语句:分页有没有 LIMIT、更新有没有带WHERE条件、字符集对不对。这套系统的代码最珍贵的地方就是中文注释把每个方法的意图讲清楚了,复制到项目里改了字段也能看懂原作者的思路。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询