
做这个二手车交易系统已经有段时间了从需求梳理、数据库设计到前后端联调上线基本把SpringBootVue这套组合拳踩了一遍。先说结论如果你想快速落地一个业务流清晰、能演示能上线的管理类系统SpringBootVueMyBatisMySQL是相当稳妥的选择。二手车交易本身就是典型的信息展示业务流转后台管理场景技术栈不需要多么花哨关键在于把交易状态流转设计清楚、把字段和权限想明白。这篇文章我把整个项目的设计思路、核心实现和踩坑过程完整记录下来内容包括数据库表设计、后端接口开发、前端页面搭建、以及联调部署阶段的常见问题适合正在做毕设、想转Java全栈、或者准备接私活的同学参考。1. 项目背景与整体设计思路1.1 二手车交易业务的真实需求很多刚接触这个项目的人会以为二手车交易系统就是个车辆信息发布网站其实真实业务比这复杂得多。线下二手车买卖的痛点集中在信息不透明、车况描述全靠中介一张嘴、价格没有参照、看车试车流程混乱。数字化之后至少得解决几件事车辆信息能不能标准化录入和展示、买卖双方能不能快速建立联系、交易状态怎么跟踪、后台怎么审核管理。所以我最终把系统拆成了前台和后台两部分。前端面向普通用户买车方和卖车方主要功能包括用户注册登录、车辆浏览搜索、车辆详情查看、收藏车辆、发布卖车信息、提交购车意向后台面向管理员负责车辆审核、用户管理、订单管理、数据统计。前台和后台共用一套前端工程靠路由和权限做区分后台独立一套管理布局这样结构清晰也方便后期扩展。1.2 为什么选SpringBootVue这套组合选技术栈的时候我对比过几个方案。用纯JSPServlet能跑但前后端耦合严重维护成本高而且现在几乎没有新项目这么写了。用若依这种现成框架上手快但如果不做二次开发答辩或者面试的时候很容易被问到哪些是你自己写的反而尴尬。自主搭建SpringBootVue虽然前期要多写一些配置代码但每个环节都能讲清楚原理后期扩展也灵活。SpringBoot负责提供RESTful API内置Tomcat打jar包就能跑Vue负责前端交互和页面渲染开发时用Node服务器热更新上线时构建成静态文件扔进SpringBoot的static目录MyBatis负责数据库访问层手写SQL控制力强动态SQL在处理车辆多条件组合筛选时特别好用MySQL存业务数据。这套组合最大的优点就是各司其职、边界清楚遇到问题能快速定位在哪一层。1.3 功能模块梳理我最终的功能清单是这样的用户模块注册、登录、个人信息维护、身份切换个人用户/车商车辆模块车辆发布、车辆列表、多条件搜索品牌、价格区间、车龄、里程、排放标准、车辆详情、车辆上下架交易模块购车意向单提交、订单状态流转待联系、已看车、已成交、已取消、成交记录收藏模块收藏/取消收藏、我的收藏列表后台管理模块车辆审核、用户管理、订单管理、基础数据统计每日发布量、成交量、成交均价留言模块车辆详情页留言咨询其中订单状态流转是整个系统的核心我把它单独拎出来仔细设计。状态字段用一个整数表示1买家已提交意向2卖家已联系买家3买家已看车4已成交5已取消。每个状态变更都在接口层做校验不允许跳状态比如订单不能从1直接跳到4必须先经过联系和看车。这样虽然代码多写了一点但数据库里不会出现乱七八糟的脏数据。2. MySQL数据库设计把表结构想清楚再动手2.1 核心表结构设计数据库设计是这个项目里最值得花时间的部分表结构没设计好后面写SQL和接口会非常痛苦。我建了5张核心业务表用户表、车辆表、订单表、收藏表、留言表。这里拿出了核心字段大家可以对照参考。-- 用户表 CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT 加密密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, role int(11) NOT NULL DEFAULT 0 COMMENT 角色 0普通用户 1车商 2管理员, phone varchar(20) DEFAULT NULL COMMENT 联系方式, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, status int(11) NOT NULL DEFAULT 1 COMMENT 状态 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;-- 车辆表 CREATE TABLE car_info ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 车辆ID, user_id bigint(20) NOT NULL COMMENT 发布者ID, title varchar(100) NOT NULL COMMENT 标题, brand varchar(30) DEFAULT NULL COMMENT 品牌, model varchar(50) DEFAULT NULL COMMENT 车型, year int(11) DEFAULT NULL COMMENT 上牌年份, mileage decimal(10,2) DEFAULT NULL COMMENT 行驶里程(万公里), price decimal(12,2) NOT NULL COMMENT 售价(元), gearbox varchar(20) DEFAULT NULL COMMENT 变速箱类型, emission varchar(20) DEFAULT NULL COMMENT 排放标准, color varchar(20) DEFAULT NULL COMMENT 颜色, description text COMMENT 车况描述, cover_image varchar(255) DEFAULT NULL COMMENT 封面图, status int(11) NOT NULL DEFAULT 0 COMMENT 状态 0待审核 1在售 2已下架 3已售出, view_count int(11) DEFAULT 0 COMMENT 浏览次数, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_brand_price (brand, price), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车辆信息表;2.2 关键字段设计细节关于几个关键字段的设计我有几点经验。第一密码字段用varchar(100)不要用varchar(50)因为BCrypt加密后的密文长度是60个字符左右当初用50直接导致入库时报Data too long后来重置字段才解决。第二价格字段用decimal(12,2)不要用float或者double浮点数在MySQL里做比较运算会出现精度问题尤其是后台要统计成交均价的时候用decimal才能保证结果准确。第三状态字段都用int类型加注释不要用枚举字符串用1、2、3比用待联系、已联系要节省空间同时Java代码里定义常量即可。第四所有表的字符集必须统一为utf8mb4否则emoj或者其他特殊字符在车辆备注里保存不了。车辆表里的索引我特意加了idx_brand_price这个组合索引对应前端品牌价格区间的筛选场景。组合索引的字段顺序不能乱写最左前缀原则决定了第一个字段必须是等值查询的字段所以品牌放前面、价格放后面实际压测下来这条查询SQL走了索引全表扫描被避免掉了。2.3 初始化数据与自增主键策略项目启动时数据库要预置几个测试账号方便前台和后台联调。我在init.sql里写好了管理员账号admin、测试车商账号和普通用户账号密码统一用BCrypt加密后的密文。因为每次create语句里带上的是密文所以不用再写一段Java代码去初始化密码直接INSERT执行就行。自增主键ID我保持了默认的AUTO_INCREMENT策略没有做分库分表这种体量的项目用雪花算法属于过度设计。但要注意的是车辆图片表如果后期要独立出来主键就不能只依赖自增了开发的时候得有意识地给业务主键字段留好空间。2.4 MySQL安装与配置的几个坑开发环境我用的是MySQL 5.7.44安装的时候踩了SSL连接报错的坑。用Navicat和SpringBoot连接都提示SSL错误最后排查原因有两个一个是驱动版本和MySQL版本不兼容另一个是连接串没配useSSL参数。在application.yml的连接URL里加上useSSLfalse就解决了其实本地开发完全不需要SSL加密。如果你的JDK版本比较高MySQL驱动要选择8.x版本8.0.33之前有个时区问题必须加serverTimezoneAsia/Shanghai否则日期字段全部慢8个小时。这个问题我前后排查了半天最后发现是驱动升级后时区参数没跟上。3. 后端实现SpringBootMyBatis核心代码解析3.1 SpringBoot项目骨架搭建SpringBoot版本选型是个容易被忽略的点。我一开始用3.x版本结果和内部框架的兼容性出了不少问题后来稳定在2.7.x版本配合JDK 1.8。这里提醒一句如果你的JDK是8SpringBoot 2.7.x是最稳的JDK 17以上的环境再考虑SpringBoot 3.x。Maven依赖里我用的关键包如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web层 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis整合 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- 密码加密 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT令牌 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency /dependencies引入spring-boot-starter-security只是用它的BCryptPasswordEncoder做密码加密不启用它的默认认证链。因为一旦引入Security默认情况下所有请求都会被Spring Security拦一道所以我又写了一个配置类放行所有请求只保留PasswordEncoder这个Bean。如果你想省事也可以单独引入spring-security-crypto这个轻量级包只拿加密工具类不会触发默认拦截链。3.2 MyBatis配置与Mapper实现MyBatis的配置在application.yml里直接声明。这里有两个配置特别关键一个是开启下划线转驼峰一个是控制台打印SQL日志。map-underscore-to-camel-case我建议必须开Java的命名规范是camelCase数据库字段是snake_case没有这行配置你就要在每一个resultMap里手动映射字段工作量翻倍。SQL日志默认为关开发环境下打开能让你直接看到MyBatis生成的SQL语句排查参数传递问题很快。mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.cartrade.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl以车辆列表查询为例Mapper接口与XML文件这样配合。这个接口要支持品牌、价格区间、车龄和状态五个条件的组合筛选用MyBatis的动态SQL做条件拼接比在Java代码里拼字符串要清爽得多。public interface CarInfoMapper { ListCarInfoVO selectCarList(Param(query) CarQueryDTO query); int updateStatus(Param(id) Long id, Param(status) Integer status); }select idselectCarList resultTypecom.example.cartrade.vo.CarInfoVO SELECT id, title, brand, model, year, mileage, price, cover_image, gearbox, emission, color, status, view_count FROM car_info WHERE 11 if testquery.brand ! null and query.brand ! AND brand #{query.brand} /if if testquery.minPrice ! null AND price gt; #{query.minPrice} /if if testquery.maxPrice ! null AND price lt; #{query.maxPrice} /if if testquery.maxAge ! null AND (YEAR(CURDATE()) - year) lt; #{query.maxAge} /if if testquery.status ! null AND status #{query.status} /if ORDER BY create_time DESC /select动态SQL里有个容易写错的地方大于小于号。XML文件里出现的和会被XML解析器当成标签符号所以必须用和转义或者用 包起来。我两次在车辆价格区间筛选时忘了转义SQL直接报错排查下来都是语法错误。另外where 11这个写法虽然看起来有点丑但配合 标签其实是最稳妥的动态拼接方案否则每个条件都要考虑要不要加WHERE关键字。3.3 核心接口实现车辆发布与购车意向车辆发布接口需要注意图片上传的处理。我是这样设计的前端先把图片上传到服务器返回图片访问URL发布车辆时再把URL序列化成JSON字符串存入car_info表的cover_image字段。图片上传单独拎出一个接口好处是发布表单可以做成草稿模式用户先传图再填信息也不会丢数据。上传接口限制单张5MB用UUID重命名文件避免中文名乱码前端预览的时候直接拼接服务器地址访问。RestController RequestMapping(/api/car) public class CarController { Autowired private CarService carService; PostMapping(/publish) public Result publish(RequestBody Validated CarPublishDTO dto, RequestAttribute(userId) Long userId) { // 从拦截器放入的userId获取当前登录用户 dto.setUserId(userId); Long carId carService.publishCar(dto); return Result.success(carId); } PostMapping(/order) public Result createOrder(RequestBody Validated OrderCreateDTO dto, RequestAttribute(userId) Long userId) { return Result.success(carService.createBuyOrder(dto, userId)); } }车辆发布后的状态是0待审核只有管理员在后台审核通过后状态变为1在售前台列表页默认只查询status1的车辆。这个审核动作是二手车交易系统里必须有的它保证了前台展示的车辆信息是经过人工确认的也正好体现后台模块存在的价值。订单创建时我在Service层做了一件事检查车辆状态是否还是在售如果已经被别人下单或者管理员下架了直接抛出业务异常提示该车辆暂不可购买。这里涉及到了并发问题如果不用锁两个用户同时下单就有可能把同一辆车买入两个订单。我采用在订单表上建唯一索引car_id status1的条件用数据库约束兜底这个方案简单可靠在高并发场景不适用但本项目足够。3.4 登录鉴权与拦截器设计登录鉴权我用的JWT Token方案。用户登录成功后后端生成一个Token返回给前端前端每次请求在header里带上Token后端拦截器校验合法性。流程看起来简单但实现的时候有几个细节要注意。Token生成时把userId和角色塞进Claims设置24小时过期时间。拦截器里只做三件事取出Authorization头、解析Token、把userId和role放到Request属性里供Controller使用。这样Controller不需要每个方法都写一遍获取当前登录用户逻辑直接用RequestAttribute(userId)就能拿当前用户ID代码非常干净。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }Interceptor写完后要在WebMvcConfigurer里注册并且指定排除路径。比如登录接口、车辆列表、车辆详情这些是所有人都能访问的就不要拦截后台管理的接口要拦截还要在Controller层再校验一次角色双保险。我遇到过的情况是注册了拦截器但忘了把静态资源路径排除掉导致前端访问已上传的图片时报401排查了不少时间最后才想起来静态资源也会走拦截器。3.5 MyBatis缓存机制与生产环境建议MyBatis的一级缓存是默认开启的作用范围是同一个SqlSession内。在SpringBoot整合环境下每个Mapper操作默认会开启新的SqlSession所以一级缓存的作用很有限而且如果配置不当会出现缓存脏读的隐患。二级缓存需要手动开启配置在Mapper XML里。我在车辆信息表这个高频查询场景测试过二级缓存列表查询的响应时间确实明显下降但我最终还是关闭了二级缓存原因很简单车辆数据的更新频率很高发布、审核、下架都会触发状态变化如果缓存刷新机制处理不精细用户前台看到的车辆列表就是脏数据。线上环境我建议在Service层做缓存改用一个Redis或者局部缓存Caffeine精确控制失效时机。4. Vue前端实现从环境搭建到页面组件4.1 Vue环境配置与项目初始化Vue开发环境我建议直接上Vue CLI或者Vite。开始用Vue CLI创建的项目后来为了构建速度切到了Vite实测冷启动和热更新速度提升非常明显。用Vite创建项目的命令很简单但要注意Node版本Vite 4以上需要Node 14.18如果你还在用Node 12创建过程中会直接报错。我本机Node升到了16.20整个开发过程没有出现前端工具链的问题。创建项目后一个重点工作是安装路由、状态管理和HTTP库。这套组合就是标准的Vue全家桶。axios需要单独封装因为要统一处理Token注入和错误响应。封装之后的效果是每个页面的请求代码就一两行状态码401自动跳登录页弹窗错误提示统一处理业务代码里几乎看不到重复的try-catch。// src/utils/api.js import axios from axios import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带Token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }) // 响应拦截器统一处理错误 service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )4.2 路由配置与权限控制Vue Router的路由配置是整个前端工程的重点之一。我拆成了公共路由、用户路由和管理员路由三块。公共路由包括首页、车辆列表、车辆详情、登录注册页用户路由在登录后可见包括我的收藏、我的发布和购车订单管理员路由则包含车辆审核、用户管理、订单管理、数据统计。登录状态下通过Router的beforeEach守卫判断角色如果当前用户是普通用户又强行访问管理后台页面就重定向回首页并提示没有权限。const router new VueRouter({ routes: [ { path: /, component: HomePage }, { path: /car/list, component: CarList }, { path: /car/detail/:id, component: CarDetail }, { path: /login, component: Login }, { path: /user, component: UserLayout, children: [ { path: favorites, component: Favorites }, { path: publish, component: PublishCar }, { path: orders, component: MyOrders } ], meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, children: [ { path: audit, component: AuditCars }, { path: users, component: UserManage }, { path: orders, component: OrderManage } ], meta: { requiresAuth: true, role: admin } } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else if (to.meta.role admin) { const role localStorage.getItem(role) role admin ? next() : next(/) } else { next() } })前端路由守卫只是控制页面能不能访问真正的安全边界在后端接口的拦截器和权限校验。前端控制角色是体验优化后端校验才是数据安全这个原则大家一定要记住。4.3 车辆列表页与多条件搜索实现车辆列表页是整个系统最核心的展示页面我把它分成了三个区域顶部的筛选条件栏、中间的车辆卡片网格、右下角的排序切换。筛选条件包括品牌下拉框、价格区间输入框、车龄下拉框以及排放标准选择。这个组件用element-ui的form和select组合每一个筛选条件的值都双向绑定到一个searchForm对象里点击搜索按钮时调用API传参。这里还有一个细节分页逻辑是前端传页码page和每页条数pageSize后端返回总条数total。分页组件切换页码时重新请求接口而不是把全量数据拿回来在前端分页因为车辆图片和描述字段都比较占网络带宽全量拉取一次性能比较差。template div classcar-list el-form :modelsearchForm inline el-select v-modelsearchForm.brand placeholder品牌 el-option v-forb in brands :keyb :labelb :valueb / /el-select el-input v-modelsearchForm.minPrice placeholder最低价 / el-input v-modelsearchForm.maxPrice placeholder最高价 / el-select v-modelsearchForm.maxAge placeholder车龄 el-option label3年内 :value3 / el-option label5年内 :value5 / el-option label不限 :value10 / /el-select el-button typeprimary clickloadCars搜索/el-button /el-form /div /template4.4 车辆图片轮播与富文本展示车辆详情页需要展示多张车辆图片我用的element-ui的el-carousel轮播组件。前端从后端返回的图片URL数组中取数据v-for渲染成轮播项。这里有一个真实的坑el-carousel默认的image组件用的是原生的img标签图片高度如果没固定不同比例的图片会把轮播区域撑出不同的高度视觉上很难看。我的解决方案是不直接用image而是用div给固定高度背景图用background-size: cover填充这样无论图片比例是什么轮播区域都稳定。车况描述这块我用的是普通的textarea存储文本没有上富文本编辑器。如果后期想上富文本需要特别注意XSS注入问题Vue的v-html渲染富文本内容时脚本标签可能会被执行必须做过滤。4.5 前端打包与路由模式选择Vue Router有两种模式hash模式和history模式。hash模式URL里会带个#号刷新页面不会404部署最简单。history模式URL更干净但是后端必须要做路径回退因为你在浏览器直接输入 /admin 地址时后端发现这个路径没有对应的接口就会返回404必须配置SpringBoot的forward转发到index.html。我最后选择了hash模式开发阶段省心部署也没有多余的配置。最典型的是用户从列表页进入详情页然后在详情页按F5刷新如果后端没配好history回退页面直接就是白屏。5. 前后端联调与打包部署5.1 联调阶段的跨域处理开发阶段前后端分开跑前端在localhost:5173后端在localhost:8080端口不同就必然产生跨域问题。解决跨域有三层方案我最终用了后端CORS全局配置因为最简单直观。代码是写一个WebMvcConfigurer里的addCorsMappings方法允许localhost:5173访问允许所有请求头。这个方案只适合开发环境线上部署时前后端同域就不用担心跨域了。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }CORS配置有几点要特别注意allowCredentials(true)和allowedOriginPatterns(*)在SpringBoot 2.4版本之后可以共存但在老版本里会互相冲突要写明确的具体域名。还有一个常见问题前端请求会先发OPTIONS预检请求如果后端没有正确响应OPTIONS浏览器就会报CORS错误。你需要在拦截器里明确放行OPTIONS请求SpringMVC的拦截器默认是会拦截OPTIONS的。5.2 前端构建物放进SpringBoot线上部署我采用的是最经典的方案Vue项目先执行npm run build把生成的dist目录里的文件拷贝到SpringBoot项目的src/main/resources/static目录下然后整个SpringBoot打包为一个jar。启动jar之后浏览器输入IP和端口就能直接访问系统不需要单独部署Nginx。这个方案很适合单体应用前端静态文件和后端API在一个服务里省去了反向代理的配置。具体操作步骤是这样先进入前端项目目录执行npm run build构建成功后dist目录下会有index.html和static文件夹。然后清空后端resources/static中原有内容把dist下的所有文件复制进去。SpringBoot会自动将static下的文件作为静态资源对外提供访问。这里有一个注意点Vite默认的base配置是/这意味着打包后的index.html里引用的JS和CSS路径都以/开头。如果SpringBoot访问路径不在根路径需要把base改成相对路径./否则静态资源引用会404。我一开始就遇到这个坑因为我把系统挂在端口8080且没有配置context-path所以根路径没问题但如果你设置了server.servlet.context-path就必须处理base路径。5.3 配置环境变量与启动脚本打包部署时我新建了application-prod.yml作为生产配置和开发环境分开管理。生产配置里主要改三个地方数据库连接地址换成云服务器内网IP不要用localhost因为服务部署后localhost指向的可能是容器或云主机本身数据库账号密码用强密码参数化配置通过环境变量注入开启MySQL的SSL连接。实际部署时我写了一个简单的start.sh脚本用nohup后台启动jar包启动时通过--spring.profiles.activeprod选择生产配置。日志输出到logs目录下方便排查问题。如果你是第一次部署SpringBoot服务记得先看8080端口有没有被占用用netstat -tlnp检查一下。6. 常见问题与排查技巧实录6.1 MyBatis相关问题的排查清单MyBatis开发中遇到最多的问题集中在SQL日志、参数类型和动态SQL上面。我整理了一份排查速查表现象可能原因解决方式控制台不打印SQLlog-impl未配置或配置错误配置log-impl为StdOutImpl查询结果全部为null下划线转驼峰未开启配置map-underscore-to-camel-case: true参数传递报错Mapper接口和XML的Param不一致统一Param注解名和SQL中#{}名称动态SQL拼接错误大于小于号未转义使用 或CDATA同一个查询返回旧数据二级缓存未清理关闭二级缓存或手动刷新6.2 MySQL连接常见报错MySQL相关的报错最能消耗开发时间。我遇到过Public Key Retrieval is not allowed这个报错原因是MySQL 8.0默认使用caching_sha2_password认证连接串需要加allowPublicKeyRetrievaltrue。还有前面提到过的SSL连接错误和时区问题这些都可以通过一条完整的连接URL解决。我把常用的JDBC连接串粘贴出来遇到类似问题对照着改就行。spring: datasource: url: jdbc:mysql://localhost:3306/car_trade?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueallowMultiQueriestrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverallowMultiQueries这个参数容易被忽略它的作用是允许一次执行多条SQL语句以分号分隔。如果你的Mapper里写多条SQL一起执行必须打开。但生产环境我不建议开启容易导致SQL注入面扩大。另外从MySQL 5.x升级到8.x后驱动类名从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver不修改driver-class-name会直接启动失败。6.3 Vue项目的构建异常定位思路前端构建报错一般分两个阶段。npm install阶段的错误九成是依赖版本冲突处理办法是删除node_modules目录和package-lock.json重新install。构建阶段的错误通常是ESLint检查不过或者Vite打包内存溢出。如果报内存溢出在package.json的build脚本里增加NODE_OPTIONS--max-old-space-size4096就好。还有一个高频场景本地npm run dev能跑但build就报路由懒加载的chunk大小告警这是正常的Vite在构建时会提示chunk大于500KB不影响运行想根治得做代码分割把element-ui按需引入能显著减小包体积。6.4 SpringBoot版本过高引起的兼容问题这部分必须单独拿出来讲因为我身边至少有三位同事在SpringBoot版本上栽过跟头。SpringBoot 3.x发布后很多人新建项目默认就选了3.x结果发现第二行配置就报错了javax.servlet包变成了jakarta.servletMyBatis-Spring-Starter的旧版本完全不兼容3.x必须升级对应版本。我当时的建议是没有特殊需求别盲目追新SpringBoot 2.7.x依然是中小型项目最稳定的选择。版本选型不是越新越好生态兼容性和文档丰富程度更重要尤其对于一个需要快速交付上线的业务系统稳定压倒一切。7. 一些优化扩展想说的系统虽然已经上线运行但扩展空间还很大我给几个方向供参考。第一个是车辆图片走对象存储服务目前图片是存在服务器本地的如果图片量大了磁盘IO会成为瓶颈改成阿里云OSS或者MinIO访问速度和安全性能都上一个台阶。第二个是加入车辆估值模块对接市场行情数据做一个估价接口这个功能在真实二手车平台非常受欢迎。第三个是引入消息通知机制买家提交购车意向后系统给卖家发一条站内信或者短信通知可以用Spring的事件机制实现代码不复杂但业务体验提升明显。第四个是做数据看板目前后台仅有简单统计如果每天有大量访问用定时任务把核心指标预处理到报表表里前端用图表组件展示车辆价格分布、成交趋势会更有说服力。我个人在实际开发中的体会是这个系统的难点不在某个单独的技术点而在把所有模块串成一条完整的业务线。很多同学做项目时容易陷入一个误区急着先把框架搭起来写CRUD结果做到第三步就发现字段少了、状态对不上、权限绕来绕去。我的建议是先把业务场景在纸上完整走一遍把每个页面的原型画出来把表结构和状态流转定下来再动手写代码。顺序反了大概率会推倒重来。最后再分享一个小技巧写前端接口封装时多花十分钟设计一个统一返回体。我的Result类固定是{ code, message, data }三件套code200表示成功code401表示未登录code500表示业务异常。这个返回体一定要从前端到后端保持一致页面里不需要每个请求单独判断成功失败拦截器统一处理。看起来是个很小的设计但后期调整权限、增加异常处理时你会庆幸当初做了这个决定。