ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3+MyBatis二手车交易系统全栈开发实战解析

SpringBoot+Vue3+MyBatis二手车交易系统全栈开发实战解析 1. 项目整体设计与技术选型思路1.1 这套系统到底解决什么问题这几年我一直在做 Java 方向的业务系统开发二手车交易系统算是电商类项目里比较典型的一类。它跟普通商城不太一样核心是车源信息的流转、买卖双方的撮合、以及线下看车和过户流程的衔接所以系统里既要有标准化的商品管理逻辑又要有围绕真实交易场景的定制化设计比如车辆状态的变更、价格调整历史、预约看车记录等等。标题里已经把这套系统的技术底牌亮得很清楚SpringBoot Vue3 MyBatis MySQL前后端分离架构。对于想学习 Java 全栈开发、或者正在准备毕设/求职项目的人来说这套组合是当前国内中小型业务系统的真实缩影。你出去面试简历上写 SpringBoot 全家桶是标配能把这个项目讲透比堆砌一堆框架名词有用得多。我最初接触这类项目时有个误区以为系统复杂度越高越好上来就想设计十几个微服务。后来做了几个实际项目才明白二手车交易系统的核心不在架构花哨而在业务闭环是否完整——用户能不能发布车源、能不能搜索筛选、能不能下单联系卖家、管理员能不能审核下架。这些环节的连贯性决定了这套系统是演示 Demo还是真正能跑起来的项目。1.2 为什么选 SpringBoot Vue3 MyBatis 这套组合选技术栈不是越新越好而是要看匹配度。SpringBoot 的优势在于快速搭建、约定优于配置内嵌 Tomcat一个 jar 包就能启动这对前后端分离的开发模式特别友好——后端只负责提供 RESTful 接口不关心页面渲染开发效率非常高。Vue3 这边Composition API 的引入让逻辑复用变得更干净。二手车系统里的车辆列表页、筛选条件栏、分页组件、详情弹窗这些交互逻辑如果用 Options API 写一个组件会堆满 methods、data、computed多个页面复制粘贴维护起来很痛苦。用 Composition API 把筛选逻辑、分页逻辑、收藏逻辑各抽成一个函数代码清爽得多。MyBatis 就更不用说了它在国内 Java 生态里几乎是半官方地位。相比 JPAMyBatis 对 SQL 的控制力更强复杂查询——比如车辆列表的多条件组合筛选、按价格区间统计、报表聚合——写 XML SQL 比拼装 JPQL 直观得多。对于二手车这类查询条件多变的业务MyBatis 是更务实的选择。MySQL 作为存储层没什么争议车源信息、用户信息、订单记录都是典型的结构化数据用 MySQL 的 InnoDB 引擎事务支持可靠非常适合这套系统的数据模型。1.3 前后端分离架构下的工程规划前后端分离意味着前端工程和后端工程是两个独立项目通过 HTTP 接口通信。这套架构下我建议把工程拆成两大部分工程目录职责技术要点car-trade-backend后端 API 服务SpringBoot MyBatis MySQL提供 RESTful 接口car-trade-frontend前端 SPA 应用Vue3 Vite Element Plus负责页面渲染和交互car-trade-sql数据库脚本建库建表语句、初始化数据后端不碰页面前端不碰数据库两边只通过 JSON 格式的 API 契约对接。这样分工明确调试也简单——前端用 Vite 开发服务器跑在 5173 端口后端 SpringBoot 跑在 8080 端口通过 Vite 的代理配置把/api开头的请求转发到后端解决了开发阶段的跨域问题。这种架构还有个大好处将来如果要做移动端 H5 或者小程序后端接口完全可以复用只需要新开一个前端工程对接同一套 API。这就是前后端分离最大的价值所在。2. 核心模块拆解与数据库设计2.1 用户体系与权限设计二手车交易系统至少要有两类角色普通用户和管理员。普通用户可以注册登录、发布车源、浏览车辆、发起询价管理员负责审核车源、管理用户、查看平台数据。我不想在这种系统里引入 Spring Security JWT 的复杂组合——不是说它不好而是对初学者来说太重了。我采用的是拦截器 JWT 的方案用户登录成功后后端签发一个带 userId 和 role 的 token前端存到 localStorage每次请求在 header 里带上Authorization: Bearer xxx后端用一个拦截器统一校验把 userId 塞进 ThreadLocal业务层直接取。这种设计的好处是逻辑清晰你能完全掌控认证流程的每个细节。面试时被问到token 过期怎么处理怎么防止 token 被篡改你能答出 JWT 签名机制和有效期校验这比背 Spring Security 配置有用得多。用户相关的表我设计了三张sys_user用户主表存用户名、密码BCrypt 加密、手机号、角色、状态sys_user_profile用户扩展信息比如头像、个人简介和主表拆开是防止每次查询用户列表都带大字段user_favorite用户收藏车辆记录2.2 车源信息表——整个系统的核心车源表是这套系统的重中之重。二手车的属性跟新房电商差别很大同样的品牌可能有不同年款、不同里程、不同排放标准、不同过户次数这些都得用字段精确描述。我的car_info表核心字段设计如下CREATE TABLE car_info ( id bigint NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 车源标题, brand_id int NOT NULL COMMENT 品牌ID, series_id int NOT NULL COMMENT 车系ID, model_year varchar(10) DEFAULT NULL COMMENT 年款如 2020款, mileage decimal(10,2) NOT NULL DEFAULT 0 COMMENT 表显里程(万公里), gearbox tinyint DEFAULT NULL COMMENT 变速箱1手动 2自动, displacement varchar(20) DEFAULT NULL COMMENT 排量如 2.0T, emission_standard varchar(10) DEFAULT NULL COMMENT 排放标准如国VI, transfer_count tinyint DEFAULT 0 COMMENT 过户次数, price decimal(12,2) NOT NULL COMMENT 售价(万元), original_price decimal(12,2) DEFAULT NULL COMMENT 新车指导价, color varchar(20) DEFAULT NULL COMMENT 车身颜色, publish_user_id bigint NOT NULL COMMENT 发布用户ID, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待审核 1在售 2已下架 3已售出, verify_status tinyint DEFAULT 0 COMMENT 审核状态0未审核 1通过 2驳回, description text COMMENT 车况描述, cover_image varchar(255) DEFAULT NULL COMMENT 封面图URL, view_count int DEFAULT 0 COMMENT 浏览量, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_brand_series (brand_id, series_id), KEY idx_status_price (status, price) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我特别想强调的是status和verify_status分开设计。一开始我图省事只留了一个status字段结果发现待审核和已下架完全不是一个维度的问题——审核是管理员操作下架可能是卖家主动撤下或者违规强制下架。两个字段分开状态机的转换逻辑才清晰。另一个注意点是价格字段。二手车报价通常以万元为单位但数据库里我不建议直接存12.5这种带小数的万单位数字用decimal直接存万前端展示时加个万字就行。我习惯存实际金额元这样统计报表时不用做单位换算避免精度问题。2.3 订单、预约与浏览记录——交易闭环的支撑光有车源展示还不够交易系统必须支撑买卖双方的业务动作。我设计了两个关键表order_info订单主表记录用户对某辆车的购买意向订单包含车辆ID、买家ID、卖家ID、订单金额、状态待支付/已支付/已取消/已完成appointment_record看车预约表记录用户预约线下看车的时间、地点、联系方式很多初学的人会问买二手车为什么还要订单表直接线下交易不就行了。但你要想系统要做交易闭环就必须有个载体记录买卖双方的行为轨迹。哪怕最终交易在线下完成订单表里买家发起 - 卖家接受 - 买家到店看车 - 成交/取消的状态流转也是平台做数据统计和纠纷处理的重要依据。浏览记录我单独建了一张user_view_log只记录用户 ID、车辆 ID、浏览时间。这表的主要用途是后期做推荐系统——根据浏览历史给用户推荐同品牌同价位的车源。当然这是扩展功能但表先建好后面接推荐算法时就不用改表结构了。3. 后端核心实现与实操解析3.1 工程结构与启动流程后端工程的包结构我按照表现层 - 业务层 - 数据访问层三层架构来组织com.cartrade ├── controller // RESTful 接口层 ├── service // 业务逻辑层接口 实现 ├── mapper // MyBatis 数据访问层 ├── entity // 数据库实体类 ├── dto // 前端交互的数据传输对象 ├── vo // 视图对象如车辆列表VO ├── common // 通用返回结果、异常处理、工具类 ├── config // 配置类如拦截器注册、跨域配置 └── interceptor // 登录拦截器启动流程很简单SpringBoot 项目通过main方法启动但要注意几个关键点第一SpringBootApplication注解默认扫描的是启动类所在包及其子包所以所有组件必须放在com.cartrade包下面一旦放错位置就会出现找不到 Bean的经典报错。第二MyBatis 的 Mapper 接口需要额外配置。我习惯在启动类上加MapperScan(com.cartrade.mapper)这样不用每个 Mapper 接口都写Mapper注解。第三数据库连接配置里spring.datasource.url必须加时区参数spring.datasource.urljdbc:mysql://localhost:3306/car_trade?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue这个serverTimezoneAsia/Shanghai不加MySQL 8.0 驱动会报时区错误allowPublicKeyRetrievaltrue是 MySQL 8.0 用 caching_sha2_password 认证时需要的参数。这两个坑我当初都踩过每次启动必报错查了半天才发现是连接串的问题。3.2 MyBatis 多条件动态查询的实战写法车辆列表页是查询最复杂的部分。用户通过前端传过来的筛选条件包括品牌、车系、价格区间、变速箱类型、排放标准、是否只看在售车辆。这些条件是可选的、组合的所以 SQL 必须是动态拼接的。MyBatis 的whereif标签就是干这个的select idselectCarList parameterTypecom.cartrade.dto.CarQueryDTO resultTypecom.cartrade.vo.CarListVO SELECT c.id, c.title, c.cover_image, c.mileage, c.price, c.model_year, b.brand_name, s.series_name FROM car_info c LEFT JOIN car_brand b ON c.brand_id b.id LEFT JOIN car_series s ON c.series_id s.id where if testbrandId ! null and brandId ! 0 AND c.brand_id #{brandId} /if if testseriesId ! null and seriesId ! 0 AND c.series_id #{seriesId} /if if testminPrice ! null AND c.price gt; #{minPrice} /if if testmaxPrice ! null AND c.price lt; #{maxPrice} /if if testgearbox ! null AND c.gearbox #{gearbox} /if AND c.status 1 /where ORDER BY c.update_time DESC /select这里有两个细节一是if里布尔判断和test里有特殊字符的处理。比如 小于等于 在 XML 直接写会解析报错必须转义成lt;。不经常写手写 SQL 的人很容易漏掉这个。二是AND c.status 1放在了where标签内部且不带if。因为查询列表时默认只让看在售车辆如果放在where外面一旦前面筛选条件为空SQL 就变成FROM car_info WHERE AND c.status 1——语法直接错。where标签会自动去掉第一个多余的 AND/OR这是它的重要特性。3.3 分页查询与 PageHelper 的配置细节列表页必须分页我选的是 PageHelper。用法确实简单PageHelper.startPage(pageNum, pageSize)下一行紧跟的查询就会被拦截并自动拼上 LIMIT。但坑也隐藏在这里PageHelper 的拦截原理是通过 ThreadLocal 保存分页参数然后拦截下一行执行的 SQL。如果你在startPage和查询之间夹了其他 SQL 操作分页参数就会串到错误的查询上导致数据错乱。所以正确姿势是startPage后面必须紧跟你要分页的那条查询中间不能做任何其他数据库操作。分页返回给前端时我习惯封装一个通用的分页结果对象public class PageResultT { private ListT list; // 当前页数据 private Long total; // 总记录数 private Integer pageNum; // 当前页码 private Integer pageSize; // 每页大小 private Integer pages; // 总页数 }用new PageInfo(list)可以直接拿到 total、pages 这些信息再手动组装成PageResult前端拿到这个结构后分页组件就能渲染出总页数和当前页码了。3.4 JWT 登录认证与拦截器实现用户登录接口的逻辑不复杂根据用户名查出用户用 BCrypt 校验密码成功后生成 JWT 令牌。生成 token 我用的是jjwt库代码大概是String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 24 * 7)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact();claim里可以塞用户的自定义信息前端拿到 token 后可以解析 payload 里的字段跳过获取用户详情的接口。但要注意JWT 的 payload 是 Base64 编码不是加密所以敏感信息比如密码绝对不能往里面放。拦截器侧的实现核心是HandlerInterceptor的preHandle方法Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录注册和车辆查询等公开接口 String uri request.getRequestURI(); if (uri.startsWith(/api/auth) || uri.startsWith(/api/car/list) || uri.startsWith(/api/car/detail)) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { try { Claims claims Jwts.parser().setSigningKey(SECRET_KEY) .parseClaimsJws(authHeader.substring(7)).getBody(); UserContext.set(claims); return true; } catch (Exception e) { // token 过期或非法 } } response.setStatus(401); return false; }这里我特别想提醒一个细节拦截器里别只校验 token 有效性必须把解析出来的 Claims 存到一个可访问的地方。我用的UserContext是个基于 ThreadLocal 的工具类这样后续业务层可以直接通过UserContext.getUserId()拿到当前登录用户不用每个接口都手动传 userId 参数。3.5 图片上传与静态资源映射二手车源必须有图片图片上传也是后端必做的功能。我的方案是最简单的本地磁盘存储上传的文件保存到服务器的/data/car-trade/images/目录文件名用 UUID 重新生成避免文件名冲突返回给前端的是通过一个/files/**路径映射出去的访问 URL。SpringBoot 里配置资源映射就一行Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceHandler(file:/data/car-trade/images/); } }上传接口的单文件上限默认是 1MB不够用得调大spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size20MB这个配置不加前端传一张大图就直接报MaxUploadSizeExceededException初学者经常在这卡住。4. 前端 Vue3 Element Plus 界面开发与接口联调4.1 Vite 工程搭建与代理配置前端我用 Vite 来构建相比 Vue CLI 的 Webpack 方案Vite 的冷启动速度真的快得多——改完代码保存浏览器里几乎瞬间更新开发体验完全不是一个量级。用npm create vitelatest car-trade-frontend -- --template vue就能拉出一个干净的 Vue3 工程。这里有个概念需要明确一下Vite 开发服务器在本地跑SpringBoot 后端也在本地跑两个服务端口不同前端直接发请求会被浏览器拦截跨域。解决办法有两个方向一是后端开 CORS 配置二是前端把请求代理到后端。我推荐前端代理方案因为后端接口以后部署到生产环境时前端经过 Nginx 反代接口本身就是同域的开发和生产的一致性更好。Vite 的vite.config.js关键配置如下export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/car/list时Vite 会自动转发到http://localhost:8080/api/car/list开发阶段完全没有跨域烦恼。4.2 车辆列表页Composition API 组合式逻辑车辆列表页是整个前端最核心的页面。筛选栏在左侧车辆卡片列表在右侧顶部有搜索框底部有分页器。这个页面如果用 Options API 写data里要放筛选表单、列表数据、分页参数、加载状态methods里要有加载列表方法、筛选重置方法watch还得监听筛选条件变化——代码会越来越胖。用 Composition API 就好很多。我把筛选和列表逻辑抽成了可复用的组合式函数// useCarList.js export function useCarList() { const loading ref(false) const carList ref([]) const total ref(0) const queryParams reactive({ pageNum: 1, pageSize: 12, brandId: null, seriesId: null, minPrice: null, maxPrice: null, gearbox: null }) const fetchList async () { loading.value true try { const res await getCarList(queryParams) carList.value res.data.list total.value res.data.total } finally { loading.value false } } const resetQuery () { queryParams.pageNum 1 queryParams.brandId null queryParams.seriesId null queryParams.minPrice null queryParams.maxPrice null queryParams.gearbox null fetchList() } return { loading, carList, total, queryParams, fetchList, resetQuery } }组件里一行const { loading, carList, total, queryParams, fetchList, resetQuery } useCarList()就能拿到全部状态和方法。这里的reactive对象是响应式的模板里直接绑定queryParams.brandId用户选择了品牌值自动更新再触发fetchList就行。有一个 Vue3 的坑我得特别提一下reactive对象如果用解构赋值拿出来的属性会失去响应式。所以上面代码里queryParams本身是 reactive 的不能写成const { brandId } queryParams然后在模板里直接绑定 brandId——那是个普通变量改了不会触发页面更新。如果要解构必须用toRefs包裹。4.3 车辆详情的图片轮播与预约弹窗车辆详情页展示的是单车源的完整信息。前端用 Element Plus 的el-carousel做图片轮播车辆关键参数里程、排量、变速箱、过户次数用el-descriptions描述列表展示。页面上有两个核心按钮一个是立即询价弹出一个表单让买家留手机号另一个是预约看车选择日期和时间段。这里的交互逻辑其实挺有讲究的用户点预约看车前前端要先检查 localStorage 里有没有 token。没有就跳转到登录页登录成功后再跳回来。这个未登录拦截的逻辑我封装在路由守卫router.beforeEach里router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这样任何一个需要登录的页面只要能进入路由就代表用户已登录业务组件里就无需再重复判断登录态了。4.4 Axios 拦截器与接口封装前后端分离项目网络请求必须封装一层。我的 Axios 实例配置里做了两件核心的事第一请求拦截器里自动带上 tokenservice.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })第二响应拦截器里统一处理业务错误码和 HTTP 401service.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 }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )这么做的价值在于业务代码里不需要每个请求都写一遍错误处理接口调用成功就直接取res.data干净利落。token 过期时任何接口的 401 都会自动把用户踢回登录页这个全局兜底逻辑非常省心。5. 常见问题与排查技巧实录5.1 MyBatis 常见报错与解决办法我整理一下项目实操中最高频的几个问题用速查表列出来每个都是踩过的坑。报错信息原因解决方案Invalid bound statement (not found)Mapper 接口和 XML 没有对应上或 XML 不在 mapper 扫描路径检查 namespace 是否为接口全限定名确保 XML 放在 resources/mapper 目录下mybatis.mapper-locations配置classpath:mapper/*.xmlCause: org.apache.ibatis.builder.BuilderException: Error parsing SQL Mapper ConfigurationXML 文件风格问题或 SQL 语法错误检查是否有未闭合标签、转义字符是否处理转义Parameter xxx not foundMapper 接口方法传多个参数时XML 里直接用参数名取不到使用Param(xxx)注解给参数命名或者封装成对象传递Table car_trade.car_info doesnt exist数据库没建表或库名不匹配先执行 SQL 脚本检查连接串里的库名是否拼写正确第一条Invalid bound statement是新手绝对会遇到的。这个报错翻译过来是找不到映射语句意思是接口方法存在但 MyBatis 没找到对应的 XML 里的 SQL。最常见的原因是 XML 文件没有在application.yml里配置扫描路径。mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.cartrade.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true也特别重要它能自动把数据库的update_time转成实体的updateTime不需要手写 ResultMap能省大量配置。5.2 前端跨域与端口占用问题开发环境前端代理配好了跨域基本不会再遇到。但生产环境或者别人直接访问你的后端接口时会遇到 CORS 问题浏览器控制台会报No Access-Control-Allow-Origin header is present on the requested resource。解决办法是后端加 CORS 配置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); } }注意如果用了拦截器校验 tokenOPTIONS 预检请求必须放行——因为浏览器跨域请求前会先发一个 OPTIONS 请求试探服务器允不允许跨域这个请求不带 Authorization 头拦截器直接返回 401前端真实请求就会失败。所以拦截器里务必加上如果请求方法是 OPTIONS直接放行。端口占用也很常见。SpringBoot 的 8080 被占启动日志会报Port 8080 was already in use。解决方案要么杀掉占用进程要么换端口。我习惯在后端配置文件里设一个备用端口注释方便切换server.port8080 # server.port8081前端 Vite 的 5173 被占有时Vite 会自动切换到 5174 并在终端提示一般不用管。5.3 数据库层面的典型坑MySQL 8.0 是现在的主流版本但它跟 5.7 的认证方式不同。如果你拿 5.7 时代的连接配置连 8.0可能会报Public Key Retrieval is not allowed上面已经说过连接串里加allowPublicKeyRetrievaltrue才能解决。这个问题的根源是 MySQL 8.0 默认的caching_sha2_password插件在 SSL 加密连接时会要求客户端先检索公钥而驱动默认不允许这种操作。另外5.7 的utf8和 8.0 的utf8mb4也有混淆。我的建议是无脑用utf8mb4因为它兼容utf8而且能存 Emoji 表情字符。如果你的表里有用户的个性签名用户输入了一个 Emoji而表是utf8插入时直接报Incorrect string value。建表语句里写DEFAULT CHARSETutf8mb4把字符集一次配好省得后面改表结构。5.4 代码层面的一些隐蔽逻辑错误有一些问题不是报错而是业务逻辑不对调试起来更花时间。第一个是 MyBatis 的PageHelper分页串线问题。我在 3.3 提过。再补充一个场景如果你的 Service 方法里先执行了一条别的 SQL 统计再执行列表查询分页参数就会作用在统计 SQL 上导致条数统计错乱。所以务必要把startPage和真正查询的代码放在紧挨着的两行。第二个是前端reactive对象直接赋值的问题。上面代码里queryParams是reactive包住的但如果你从后端返回的数据里拿了一个新对象直接赋值给queryParams比如queryParams {...someData}那是不行的——reactive 对象不能整个替换这样会丢失响应式。正确做法是Object.assign(queryParams, someData)逐字段覆盖。第三个是日期时间字段的时区问题。如果你在插入记录时发现数据库里的时间比现在慢了 8 个小时八成是连接串里没配serverTimezoneAsia/Shanghai或者 Java 服务本身运行的机器时区不对。前一种情况改连接串后一种情况启动时加-Duser.timezoneAsia/Shanghai。5.5 排查问题的方法论日志断点二分最后分享一个排查问题的通用思路。遇到报错我的习惯是先看堆栈信息的第一行那通常是真正的错误原因而不是看最后面的Caused by。SpringBoot 日志打印得很详细CtrlF 搜ERROR关键字再往上看异常类型和消息基本能定位 80% 的问题。如果是逻辑错误但不报异常那就用二分法从前端请求发出去开始一步步确认数据走到了哪一层。用浏览器的 Network 面板看请求是否发出、后端是否返回、返回状态码是什么如果请求发出了但后端没执行到业务逻辑就在 Controller 方法入口打个断点如果 Controller 收到了但数据不对就检查 Service 层如果 Service 逻辑正确但查询结果异常就去数据库客户端手动执行那条 SQL 看结果。这套方法论做下来几乎所有问题都能在半小时内定位。怕的不是报错报错至少给出了线索怕的是功能静默失败那才需要系统性排查。6. 打包部署与项目扩展方向6.1 后端打包与运行后端打包非常简单SpringBoot 集成 Maven执行mvn clean package -DskipTests完成后target目录下会生成一个car-trade-backend.jar。部署时直接执行java -jar car-trade-backend.jar --spring.profiles.activeprod生产环境的数据库地址、文件上传路径这些配置我放在application-prod.yml里独立维护避免把本地开发配置带到生产环境。用--spring.profiles.active参数动态切换本地用dev线上用prod非常清晰。6.2 前端打包与 Nginx 部署前端打包执行npm run build生成dist目录里面全是静态文件。我把这些静态文件交给 Nginx 托管同时把/api开头的请求反向代理到后端服务再配置前端路由的 history 模式回退到 index.html不然刷新页面会 404server { listen 80; server_name your-domain.com; root /var/www/car-trade-frontend/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /files/ { proxy_pass http://127.0.0.1:8080/files/; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这行是 Vue Router 的 history 模式必须的。它的意思是请求的路径如果找不到对应文件就回退到index.html让前端路由自己决定渲染哪个页面。6.3 项目扩展方向从 Demo 到真实产品这套车交易系统跑通后我给它规划过几条扩展路线按优先级排序第一车辆推荐。基于user_view_log和用户收藏数据做一个简单的协同过滤推荐——用户看过的车型品牌、价格区间作为特征推荐相似车源。接一个实时推荐接口放到首页猜你喜欢区域产品价值立竿见影。第二车辆评估工具。输入品牌、车龄、里程、车况等级后台用估价模型算出一个参考价范围。这功能能提升用户粘性因为买家不知道二手车水多深给一个市场参考价能建立信任。第三管理后台的数据看板。用 ECharts 展示每日新增车源量、在售车辆价格分布、成交转化率等指标。管理员最需要的不是冷冰冰的表格而是几张能看出业务趋势的图表。这套系统从数据库设计到前后端联调从认证拦截到部署上线已经是一个完整的全栈项目。我个人的经验是做这类项目不要急着加功能先把主链路走通——发布车源、浏览筛选、询价预约、后台审核每一步都要真实可操作。主链路通了系统就活了主链路不通边角功能再多也只是个演示壳子。最后再分享一个我积累下来的小习惯每完成一个模块就在项目的README.md里记录下当时遇到的坑和解决办法。半年后回看这些记录比任何技术书都有价值——它们是你自己真实踩过的路也是面试时最独特的谈资。二手车交易系统这个项目做好以后如果你再去做其他电商类系统会发现核心思路完全相通这就是全栈项目带给你的底气。
返回列表