尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Vue和SpringBoot的二手车交易平台全栈实战开发指南

基于Vue和SpringBoot的二手车交易平台全栈实战开发指南 先说个我自己的感受如果你想找个能覆盖注册、登录、信息流、搜索筛选、交易状态流转还带后台管理的全栈练手项目二手车交易平台这个选题几乎是把前端和后端的高频知识点都串起来了。用户用 Vue 负责页面交互管理员和买家卖家都在同一套系统里操作SpringBoot 这层把车辆、订单、用户、审核这些业务逻辑收拢成标准接口前端能调用就能完成从发布一辆车到下单成交的完整闭环。这篇文章按我实际搭这套系统的顺序来从需求边界怎么划、表结构怎么建、核心流程怎么走到打包部署时那些很容易卡壳的细节一条线讲完适合正在做毕设或者想完整走一遍前后端分离项目的朋友直接参考。1. 需求边界先划清楚别让功能列表把自己拖垮很多新手拿到二手车交易平台第一反应就是参考某二手车App恨不得把在线估价、金融分期、线下检测合作都塞进去结果页面写了一堆后端接口全是空的最后答辩也讲不清楚。我的建议是先让业务闭环跑通再考虑亮点。1.1 用户角色只有三类别一开始就搞复杂这个系统的核心角色就是游客/买家、卖家个人用户、管理员。游客可以浏览车辆、按条件搜索注册登录后变成用户既能收藏车辆、发起购买也能发布自己的车源管理员负责审核车源、管理用户和车辆上下架。这里面不需要引入商家入驻、经纪公司这种多租户概念因为一旦引入权限模型和数据隔离的复杂度会直接翻倍不适合作为第一版。角色明确之后后端在用户表里用一个role字段区分就足够了0或1分别表示普通用户和管理员不用上Spring Security那一套复杂的RBAC。前端路由守卫里判断一下角色决定能不能进后台管理页面这样权限的粒度对这个项目来说刚刚好。1.2 核心功能清单六个模块闭环即可我实际落地时把功能收敛成六块每块对应一套页面和一组接口用户管理注册、登录、个人信息查看与修改。车辆浏览首页车辆列表、车辆详情、按品牌车系/价格区间/里程/排放标准筛选。车源发布用户填写车辆信息上传多张图片提交后进入待审核状态。交易流程买家对在售车辆发起购买生成订单订单状态跟随流程推进。收藏与预约买家收藏意向车辆也可以预约看车卖家或管理员能看到预约记录。后台管理管理员审核车源、管理用户状态、查看订单和预约。对比真实二手车平台你可能会觉得少了支付、在线合同、车况检测报告。没关系毕设或练手项目完全可以做模拟支付——点击支付按钮直接标记成功但订单状态机一定不能在支付这里断掉要把待支付 - 已完成这个流转保留将来要接真的支付网关也不用改表结构。1.3 为什么要先定边界再动手写代码我见过太多人第一天就建工程写登录写到一半才想起来哦我还没想好订单状态有哪些然后回头改表。二手车这个业务的特殊之处在于一辆车从发布到成交要经过多个状态而且用户、车辆、订单三者强关联表结构一旦设计错后面重构的成本远远大于一开始花半天做需求梳理。先定边界还有一个好处你会清楚哪些地方可以留给后期扩展比如搜索功能初期就用 MySQL 的 LIKE 查询等数据量大了再考虑 Elasticsearch而不是一开始就把搜索引擎、消息队列全配上把项目复杂度抬高一大截。2. 技术栈选型的决策逻辑每个依赖都得有存在的理由在热门技术词里Vue 和 SpringBoot 是始终绕不开的两个主角。围绕这两个核心前后的配套选型我按资料好查、版本稳定、出现问题能快速定位的原则来定不追新但也不排斥一些能提升效率的库。2.1 前端Vue 3 Vite Element Plus Pinia前端选 Vue 3 而不是 Vue 2不是因为 Vue 2 不能用而是现在社区和网上能找到的示例、组件库、踩坑记录基本都迁移到 Vue 3 了你遇到问题更容易搜到答案。脚手架我选 Vite主要原因是冷启动快改代码热更新也快这在联调阶段特别舒服。Vue CLI 虽然也很稳定但 Vite 已经是当前新建项目的默认选择。配套的组件库是 Element Plus表单校验、表格、弹窗这些后台管理页面常用的组件都有现成的。状态管理用 Pinia它是 Vue 3 官方推荐的状态库比 Vuex 的 TypeScript 支持和模块化设计更自然。路由就用 Vue Router 4这没什么悬念。有个实际细节提醒一下Vite 对 Node 版本有要求如果你用的是比较老的 Node比如 12.xnpm install或者npm run dev大概率会直接报错。装依赖之前先执行node -v看一眼不够版本就先升级 Node能省掉后面很多莫名的报错。2.2 后端Spring Boot 2.7.x 是最稳的落点后端我选了 Spring Boot 2.7.x而不是 3.x。原因很现实2.7.x 配合 JDK 1.8不管是你本地开发环境还是网上搜到的教程、视频兼容性都最好。Spring Boot 3.x 要求 JDK 17 起步很多老一点的教程和依赖配置都不适配对新手来说遇到报错都不知道去哪查。ORM 用 MyBatis-Plus它最大的价值是不用写大量单表 CRUD 的 SQL提供的QueryWrapper可以很方便地拼接动态查询条件非常适合车辆列表这种筛选条件不固定的场景。数据库用 MySQL 8.0连接驱动、时区设置这些都成熟。鉴权这块我用 JWTJSON Web Token登录成功返回 token前端放在请求头里带上后端用拦截器校验Redis 不是必须的后面我会专门讲为什么初期可以不上 Redis。2.3 图片存储本地目录加静态映射先不碰对象存储车辆发布需要上传图片这个功能很多人第一反应是接阿里云 OSS 或腾讯云 COS。但我强烈建议初期用本地文件存储后端接收 MultipartFile保存到服务器指定目录然后通过 Spring Boot 的静态资源映射把目录映射成 URL 访问路径。原因就两条第一对象存储需要开通服务、配密钥可能还需要备案域名对本地开发和毕设来说成本太大第二项目迁移到别的环境时本地存储只要改一个配置路径就行不会增加额外变量。等系统真正上线再考虑把这里替换成 OSS 也不迟因为只需要改一个文件存储工具类的实现业务逻辑完全不受影响。3. 数据库表设计车辆表是一切业务的根表结构设计是整个项目里最值得花时间的部分。车辆这个核心实体牵扯到品牌车系、用户、订单、收藏、图片等多张表关系理不顺后面写接口会处处别扭。3.1 用户表和车辆表核心字段一次定型用户表t_user的字段相对固定id主键MyBatis-Plus 默认 ASSIGN_ID 雪花算法生成。username和password密码用 BCrypt 加密保存绝对不能明文入库。phone、avatar、role、create_time这些常规字段。车辆表t_vehicle是核心中的核心我列出的字段都是踩过坑之后沉淀下来的字段名类型说明idbigint主键雪花IDuser_idbigint发布者ID关联用户表brand_id / series_idbigint品牌ID / 车系ID关联品牌车系表titlevarchar车辆标题类似2020款 大众迈腾 330TSIcover_imagevarchar封面图URL列表页直接展示避免查询多图pricedecimal(10,2)售价单位万元decimal可以避免浮点误差mileageint表显里程单位万公里license_datevarchar上牌日期保存2020-06只精确到月gearbox / emissionvarchar变速箱类型 / 排放标准statustinyint状态0草稿 / 1待审核 / 2在售 / 3已售 / 4下架 / 5审核驳回versionint乐观锁版本号处理并发下单用这里面有两个设计细节值得展开讲。第一价格用decimal(10,2)单位是万元不是元。很多二手车平台的展示习惯就是12.88万存万元在页面直接展示就行不用再做单位换算。第二cover_image字段是冗余设计车辆详情页要多图但列表页只需要一张封面如果列表也去查图片表就会产生 N1 查询问题。发布车辆时把第一张图存到封面字段列表页直接用详情页再查图片表这是性能上的常规做法。3.2 品牌车系表搜索筛选的地基二手车搜索框里最常见的筛选条件就是品牌和车系。如果直接在车辆表里存一个brand_name字符串确实省事但后患无穷不同用户可能写成大众和一汽-大众筛选就乱了。所以我把品牌车系拆成两张表t_brand品牌表字段id、brand_name、logo_url。t_series车系表字段id、brand_id、series_name。车辆表里只存brand_id和series_id查询时关联这两张表拿名称。数据初始化时把常见品牌和车系写进 SQL 脚本或者用数据初始化接口插入前端车系下拉框可以根据选中的品牌动态加载。3.3 订单、收藏、图片表把关联关系做干净订单表是交易链路的核心字段要能完整描述一次购买行为order_no订单号用时间戳加随机数生成作为展示给用户的业务编号。vehicle_id车辆ID。buyer_id/seller_id买家ID / 卖家ID同样关联用户表。amount成交金额下单时从车辆表冗余过来。status订单状态后面专门讲状态机。create_time、pay_time、update_time时间流水。收藏表t_favorite比较简单核心就是user_id和vehicle_id两个外键再加一个create_time。为了避免同一个用户重复收藏同一辆车要给这两个字段建一个联合唯一索引。车辆图片表t_vehicle_image存车辆的多个图片 URL按vehicle_id和排序字段查询。提示所有表都建议带上create_time和update_time两个通用字段。MyBatis-Plus 里有自动填充机制插入和更新时自动写入后期排查数据问题会舒服很多。4. 核心业务链路不等于写增删改查状态机才是重点框架搭好、表建完之后最考验设计能力的不是 CRUD 接口而是业务状态怎么流转。二手车平台里车辆状态和订单状态就是两条最容易写乱的状态机。4.1 车辆发布与审核链路车辆发布不是用户提交完就直接上架展示的。为了避免系统里全是垃圾车源业务流程里加了审核环节用户登录后进入发布车源页面填写车辆信息并上传图片。后端接口接收数据状态设为1待审核车辆对游客和买家不可见。管理员在后台审核列表看到待审核车辆可以查看详情。点击通过车辆状态变为2在售前台列表立即可见。点击驳回状态变为5审核驳回用户端可以看到驳回原因修改后重新提交。这个审核动作看起来简单但实现时有个细节管理员点击通过这个动作后端不能用简单的updateById而应该用带条件的状态更新boolean success vehicleService.update( new LambdaUpdateWrapperVehicle() .eq(Vehicle::getId, vehicleId) .eq(Vehicle::getStatus, 1) .set(Vehicle::getStatus, 2) ); if (!success) { // 说明车辆状态已经变了比如另一个管理员已经审核过 throw new BizException(车辆状态已变化请刷新后重试); }这个写法的本质是一个乐观锁更新时带上status 1条件如果影响行数为 0说明并发或者状态被改了就不能再盲目更新。这个思路可以复用到后面所有的状态变更场景。4.2 购买流程与订单状态机买家看到一辆在售车辆点击立即购买后端要做的事是按顺序走完三件事校验车辆状态为在售、创建订单、把车辆状态改为已售。这里最怕的就是两个买家同时下单如果都用updateById改车辆状态就可能出现一辆车被卖两次的情况。我实际用的是一组原子操作// 1. 前置检查 Vehicle vehicle vehicleService.getById(vehicleId); if (vehicle null || vehicle.getStatus() ! 2) { throw new BizException(车辆已下架或已售出); } // 2. 原子更新车辆状态只在“在售”时才能变为“已售” boolean sold vehicleService.update( new LambdaUpdateWrapperVehicle() .eq(Vehicle::getId, vehicleId) .eq(Vehicle::getStatus, 2) .set(Vehicle::getStatus, 3) ); if (!sold) { throw new BizException(手慢了车辆已被下单); } // 3. 创建订单 Order order new Order(); // ... 填充订单字段 orderService.save(order);订单状态我设计了四个流转关系用一个表格就能说明状态含义可操作角色下一个状态0待支付买家支付后 - 11待过户买家/卖家双方确认 - 22已完成无终态3已取消买家/管理员终态这套状态机的止损点在于每一步状态变更都只能由上一个状态推进不允许跳变。比如0不能直接变成2必须经过1。我在后端写了一个专门的状态流转更新方法前端任何页面都不能直接改订单状态只能调用接口这样就避免了很多脏数据。4.3 搜索筛选MyBatis-Plus 动态条件拼接二手车列表页的筛选条件非常多品牌、车系、价格区间、里程、变速箱、排放标准、关键词这些条件用户可以不选也可以交叉选。最理想的实现方式就是用 MyBatis-Plus 的 QueryWrapper 动态拼接LambdaQueryWrapperVehicle wrapper Wrappers.lambdaQuery(); wrapper.eq(Vehicle::getStatus, 2); // 只查在售 if (StringUtils.hasText(brandId)) { wrapper.eq(Vehicle::getBrandId, brandId); } if (StringUtils.hasText(seriesId)) { wrapper.eq(Vehicle::getSeriesId, seriesId); } if (minPrice ! null maxPrice ! null) { wrapper.between(Vehicle::getPrice, minPrice, maxPrice); } if (StringUtils.hasText(keyword)) { wrapper.like(Vehicle::getTitle, keyword).or().like(Vehicle::getDescription, keyword); } wrapper.orderByDesc(Vehicle::getCreateTime);这里要注意一个细节如果关键词要同时匹配标题和描述用and包裹or不然拼接出来的 SQL 条件优先级会出错。用wrapper.and(w - w.like(...).or().like(...))才能保证关键词子句的括号正确。5. 前端工程化与后端接口的配合路由守卫和请求封装前后端分离项目里光有后端接口和前端页面还不够两者之间的胶水层决定了开发体验和系统安全性。这个胶水层包括请求工具封装、路由守卫、统一返回结构和跨域配置。5.1 前端请求封装与路由守卫前端所有的接口调用我都封装在一个request.js里基于 Axios。核心逻辑很简单请求拦截器里从 localStorage 取出 token并设置到请求头Authorization字段响应拦截器里判断后端返回的 code如果 code 表示未登录比如 401就跳转登录页。request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) request.interceptors.response.use(res { const { code, msg } res.data if (code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(msg)) } if (code ! 200) { ElementPlus.Message.error(msg) return Promise.reject(new Error(msg)) } return res.data })路由守卫分两层一层是未登录跳转另一层是管理员页面权限校验。Vue Router 的beforeEach里判断meta.requiresAuth和meta.requiresAdmin没有 token 就重定向登录页不是管理员就跳回首页。这样用户手动在地址栏输入/admin也进不去后台前后端双重控制才算靠谱。5.2 后端统一返回结构前后端联调省心一大半后端接口的返回值我统一封装成了一个ResultT类包含三个字段code、msg、data。所有接口方法都是返回这个对象前端请求封装里只判断最外层的code不再跟具体的实体类型耦合。Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }异常处理用RestControllerAdvice全局拦截业务异常统一抛BizException拦截器里捕获后返回带错误码的 Result。这样前端一旦收到非 200 的 code就能直接弹出错误信息不用在前端再解析各种不同的错误格式。5.3 跨域问题一个细节就能卡半天前端启动在http://localhost:5173后端在http://localhost:8080端口不同浏览器就会发起跨域请求。后端配置 CORS 时有个很经典的坑如果接口需要携带 Cookie 或者某些认证信息allowCredentials(true)就不能和allowedOrigins(*)同时使用浏览器会直接拦截。正确做法是用allowedOriginPatterns(*)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); } }我们这里用的是 JWT请求头里带 token理论上不依赖 Cookie但把这个配置做对可以在将来接入其他需要 Cookie 的功能时少踩一个坑。如果你发现前端明明发了请求后端逻辑也执行了但浏览器里报跨域错误先检查这两行的配置。5.4 文件上传后端本地保存加 URL 映射图片上传的接口实现不复杂真正容易漏的是访问映射。后端接收文件PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { // 校验文件大小和类型 String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString() suffix; // 保存到 application.yml 里配置的 upload.path 目录 File dest new File(uploadPath, fileName); file.transferTo(dest); // 返回可访问的 URL String url /upload/ fileName; return Result.success(url); }然后必须加一个静态资源映射让/upload/**这个路径能映射到真实磁盘目录Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath file: uploadConfig.getPath(); registry.addResourceHandler(/upload/**) .addResourceLocations(uploadPath); }很多人的项目前端能上传成功但图片显示 404原因就是只写了保存逻辑没写这个映射。另外file.transferTo(dest)要求目标目录必须存在否则会抛IOException建议先dir.mkdirs()再保存。还要记得在application.yml里调大上传大小限制spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB6. JWT 鉴权与前后端分离下的登录态管理登录是任何平台都绕不开的功能二手车平台也不例外。在前后端分离的架构下登录态用 JWT 是最简单直接的方式。6.1 后端 JWT 结构与拦截器用户登录成功后后端生成一个 token 返回token 里带上用户ID和角色。我用的 JWT 工具类是io.jsonwebtoken这个库生成时设置过期时间一般 24 小时就够用。关键不是生成 token而是校验。我写了一个 Spring MVC 拦截器在preHandle里从请求头取出 token解析成功就把用户信息放到request的 attribute 里解析失败直接返回 401。对于登录、注册、首页车辆列表这些无需登录就能访问的接口用excludePathPatterns排除掉。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token) JwtUtil.verify(token)) { request.setAttribute(userId, JwtUtil.parseUserId(token)); return true; } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } }6.2 前端 token 保存与登录态恢复前端把 token 存到 localStorage 里路由守卫从 localStorage 读取。但有一个小细节刷新页面时Vue 的状态管理库 Pinia 里的用户信息会丢失需要重新调用一个获取当前用户信息的接口来恢复登录态。所以在App.vue的初始化逻辑里如果检测到有 token就调用/user/info接口拉取用户信息存到 Pinia 里。这个接口在拦截器里已经通过 attribute 注入了用户ID所以后端不需要前端传用户ID直接从 token 里解析就行。有些教程喜欢把用户信息也加密存到 token 里然后前端解析 Base64 拿用户信息省一次请求。我建议不要这么做用户昵称、头像这些信息可能随时修改token 里存了旧值就会造成页面展示不一致。token 里只存用户ID和角色其余信息都走接口查是更稳妥的做法。6.3 密码安全和退出登录密码存储用 BCrypt 加密Spring Security 里有BCryptPasswordEncoder单独抽出来用就行不需要引入完整的安全框架。用户登录时后端用matches方法比对明文密码和加密字符串。这样做的好处就是即使数据库被脱库拿到的也是不可逆的密文用户在其他平台的同密码账号也不会受到牵连。退出登录的逻辑很简单前端清除 localStorage 里的 token 并跳转登录页即可。因为 JWT 是无状态的后端没有会话可销毁除非你引入 Redis 做黑名单否则这个方案对当前项目来说已经完全够用了。等将来有强制的踢人下线需求再考虑把 token 存到 Redis 白名单里也不迟。7. 从本地跑通到部署上线配置与避坑记录项目写完最后一步是打包部署。这一步绝大多数新手都会遇到问题因为本地开发环境和服务器环境的差异往往就藏在几个配置细节里。7.1 后端打包与关键配置后端项目用 Maven 打包执行mvn clean package -DskipTests生成 jar 包。但打包之前application.yml里的配置要检查三处第一数据库连接串一定要加上时区参数否则 MySQL 8.0 会报时区错误spring: datasource: url: jdbc:mysql://localhost:3306/car_trading?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码第二上传文件的保存路径要填绝对路径不要用相对路径。相对路径在本地可能指向项目根目录在服务器上可能指向 jar 包所在目录很不稳定upload: path: /data/car-trading/upload/第三JWT 密钥不要用默认值至少改成一段足够长的随机字符串防止 token 被伪造jwt: secret: 一串足够长的随机字符串 expire-hours: 247.2 前端构建与部署策略前端执行npm run build后会在dist目录生成静态文件。部署方式有两种可选。方式一把dist里的文件用 Nginx 托管再把/api开头的请求反向代理到后端server { listen 80; server_name your-domain.com; location / { root /var/www/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }注意try_files这行很重要它解决的是 Vue Router 的 history 模式下刷新某个子路由页面时 Nginx 返回 404 的问题。方式二如果不想装 Nginx可以把dist目录放进后端的static目录然后重新打 jar 包Spring Boot 会直接托管前端页面。这种方式部署简单但静态资源和后端接口混在一起后续前端更新要重新打整个 jar。我个人的建议是如果你有多余的服务器资源尽量用 Nginx 方案前后端完全独立迭代时只替换对应的产物就行。7.3 我必须提醒的几个常见坑第一个坑是雪花 ID 的精度丢失。MyBatis-Plus 默认主键策略ASSIGN_ID生成的是 19 位 Long这种数字在前端 JavaScript 里精度会丢失导致修改车辆时 ID 变了的诡异问题。解决办法是在主键字段上加上JsonSerialize(using ToStringSerializer.class) private Long id;或者在后端配置全局的 Long 转 String 序列化否则所有返回给前端的主键字段都可能出现问题。第二个坑是接口日期格式。默认情况下后端返回的LocalDateTime会被序列化成数组或者 ISO 格式前端拿到根本没法直接展示。统一在实体类时间字段上标注JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;第三个坑是接口的动态 SQL 条件拼接尤其是多表查询时。MyBatis-Plus 的 QueryWrapper 在关联查询时不够灵活我建议在车辆列表带品牌名这类场景直接用自定义 XML 写 SQL接口返回值用一个 VO 对象接收而不是硬把复杂查询塞进 Wrapper。这样 SQL 的可读性和可维护性都会好很多。7.4 最后分享一个我自己的习惯在项目彻底跑通之前我一直会用一套小的演示数据来验证整个链路注册两个账户一个当买家一个当卖家卖家发布一辆车管理员审核通过买家搜索到、下单、支付卖家确认过户订单变成完成态全程走一遍。这套数据能帮你快速发现接口里隐藏的字段错误和状态跳转问题比写单元测试更直观。我每次接到类似的全栈项目都会保留这套流程它既是给答辩老师演示的素材也是验证自己改动的回归用例。做这个项目最核心的收获是你能完整地理解一个前后端分离系统里数据是怎么从数据库一路流到页面的业务状态是怎么被协议约束住的以及部署时那些让人抓狂的小问题又各自对应哪一段配置。这些经验在换任何技术栈的时候都不会失效。
返回列表