Spring Boot + Vue3实战:二手交易平台全栈开发与部署指南

📅 发布时间:2026/10/9 3:11:24
Spring Boot + Vue3实战:二手交易平台全栈开发与部署指南
我当年第一次接到二手交易平台这类需求时心里想的是这不就是个带图片的CRUD吗等真正把一个能用的版本交付出去才发现商品上下架、订单状态流转、图片存储、用户登录鉴权每一个环节拆开都能写一篇排坑记录。这个项目作为Spring Boot Vue 3的全栈练手或者毕业设计非常典型但也正因为典型很多人做出来的东西只是能跑离能用差得很远。这篇文章我会按照自己实际做过的方案来讲从需求边界、数据库设计、后端接口、前端页面到联调部署每个部分都带上当时踩过的坑和最终保留的代码写法。如果你正准备动手做类似的项目可以直接把这套思路搬过去比从零瞎试要省很多时间。1. 先从需求说起什么样的二手交易平台才算能用很多人一想到电商系统就恨不得把淘宝全部功能塞进去购物车、优惠券、秒杀、评价体系全上。二手交易平台如果按这个思路来半个月能做完表结构就不错了。我建议把需求收敛成三个核心角色的最小闭环买家、卖家、管理员。买家的操作路径是逛、看、买。逛就是首页和分类页的商品列表看是进入商品详情页买则是下单并且能查到自己买过什么。卖家的操作路径是发、改、卖。发是发布新商品改是编辑商品信息或调整上下架状态卖是能看到订单被谁拍下。管理员做的事情更偏向审核和治理比如把违规商品下架、把恶意用户禁用这部分做简单一点能操作就行。这样一条链路走下来数据库里最少需要五张核心表用户表、分类表、商品表、订单表、收藏表外加一张轮播图表做首页运营位。登录注册用JWT做无状态鉴权图片上传存本地磁盘搜索引擎都不需要上直接靠MySQL的LIKE查询加分类筛选就能满足九成场景。我这里还要强调一个经常被忽略的点交易状态。二手商品的订单不是标准电商那种待付款-待发货-待收货-已完成因为二手交易往往会在线下沟通比如买家先咨询再付款或者先验货再走平台。所以订单状态不要设计成一套固定的线性流程要在订单表里放一个status字段取值包括待付款、待发货、待收货、已完成、已取消给后续的协商退款留出空间。需求范围一旦定死后面每一步开发都有明确的收口标准。那些额外的东西比如站内信、举报系统、数据统计大屏全部放到二期再做先确保从注册到交易完成这条路畅通。2. 环境与脚手架Spring Boot版本选择和数据访问方案二手交易平台这种项目后端框架选型上最容易纠结的两件事Spring Boot用2.x还是3.x数据持久层用Spring Data JPA还是MyBatis-Plus。先给结论。如果没有特别强烈的理由毕业设计和练手项目优先选Spring Boot 2.7.18配合JDK 1.8。原因很实际市面上绝大多数教程、开源代码、问题解决方案都基于这套组合遇到报错搜一下基本都有答案。Spring Boot 3.x虽然已经不算新东西但它强制要求JDK 17及以上一些老版本的依赖组件和新版本之间会有兼容性坑对于以跑通项目为核心目标的场景来说没有必要冒这个险。当然如果你打算用Spring Boot 3走更前沿的技术栈那就把JDK、IDE、Maven仓库源一次性检查好不要等项目写到一半才升级。数据访问层我强烈推荐MyBatis-Plus。JPA上手轻快但在二手交易平台这种需要写多表联查、动态筛选的场景里JPA的规范方法名和复杂查询之间需要一个适应的过程很多人写着写着就跑了原生SQL。MyBatis-Plus的BaseMapper提供的selectPage、selectList能覆盖八成简单操作复杂查询直接写XML里的SQL语句可控性高得多也符合国内多数企业的技术栈习惯。创建Spring Boot项目时有个细节依赖不要在创建时一把梭全勾上只需要勾Spring Web、MySQL Driver、Lombok这三个其他依赖比如MyBatis-Plus、JWT工具、Hutool都通过手动添加依赖坐标的方式引入。这样做的原因是Maven的依赖传递经常产生版本冲突手动管理反而更容易定位问题。下面这份是我的pom.xml核心依赖清单直接抄也行dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.18/version /dependency版本号我故意写成了你顺手就能用的具体版本不要upgrade到最新。尤其是Hutool和MyBatis-Plus新版本偶尔会把某些工具方法标记为废弃照着老教程抄代码时会编译报错没必要为了追新把自己绕进去。3. 数据库设计五张核心表的结构和状态字段怎么定数据库建模是这种项目最关键、也最容易被追着返工的部分。以我踩过的坑来看表结构设计失误导致的返工是整个项目所有返工里成本最高的所以我建议哪怕你是先写代码再补库的流派也先把表结构画出个草稿。用户表的核心字段不算多id、username、passwordBCrypt加密后的结果、nickname、avatar、phone、role用户/管理员、status正常/禁用、create_time。这里有个容易被忽视的点用户头像默认值要处理好前端很多人会把空字符串当成无效图片导致img显示裂图后端可以在插入用户时直接给一个默认头像路径。分类表更简单id、name、parent_id、sort_order。支持两级分类就够用一级是数码/服饰/图书/生活二级是手机/电脑/耳机这种粒度。不要设计成无限极分类一来管理后台选择麻烦二来查询性能和非必要的递归逻辑没有收益。商品表是整个系统的绝对核心我需要重点说它的设计。首先是必须包含的字段id、user_id卖家、category_id、title、description、price、original_price、images、status0在售/1已售/2下架/3违规、view_count、create_time、update_time。图片字段存什么格式是个细节问题自己测的时候用JSON数组字符串最方便比如[/images/upload/xxx.jpg,/images/upload/yyy.jpg]不要用逗号拼接字符串后端解析JSON比split逗号可靠得多。商品表的索引设置也非常关键。实际使用中的高频查询是按分类和关键字筛选、按时间排序、按销量排序。所以至少要建立(category_id, status)联合索引以及create_time单列索引。数据量小的时候索引感知不强等列表里塞了上千条商品数据加了索引和没加索引的响应时间差别非常明显。订单表我踩过一个印象深刻的坑。最初设计时只有一个订单号、买家ID、商品ID、金额、状态结果买家拍下多个商品时因为每个商品是独立订单快递费、批量沟通就全乱套了。二手交易平台的订单设计其实分成两种路径单一商品即时拍下想用一个订单号关联多个商品的话就需要中间表。我的建议是主订单表和订单项表分开设计主订单表存total_price、status、create_time、consignee信息订单项表存order_id、product_id、product_title、product_price。这套结构后续扩展购物车、收藏夹合并下单都很自然。收藏表相对独立id、user_id、product_id、create_time然后加唯一索引(user_id, product_id)避免同一个人重复收藏同一件商品。这个唯一索引在MyBatis-Plus里配合INSERT IGNORE或者先查后插都很好用。像图片上传这种功能本地存磁盘路径就足够支撑学习和演示了。正式商用或云部署时可以考虑对象存储但业务代码里只要把存储逻辑封装成独立的FileStorageService接口后续替换存储供应商只需要改一个实现类不影响Controller和Service层。我给这个项目留的接口大概是这样的思路Controller接收MultipartFile交给FileStorageService返回URL字符串Service层把URL拼进商品images字段。后面文章讲到文件上传时会给出具体的代码写法。4. Spring Boot分层实现Controller要薄、Service要厚、事务要跟对方法后端代码的组织方式我见过太多人把整个项目的核心逻辑堆在Controller里一个方法写完100多行看着热闹后面想改一个细节就牵一发动全身。二手交易平台这种规模的企业级分层应该是Controller只做参数接收和结果包装、Service做业务逻辑、Mapper做数据操作。先展示一个典型的商品发布接口看Controller的缩水版PostMapping public ApiResultLong publish(RequestBody Validated ProductPublishDTO dto, RequestAttribute(userId) Long userId) { Long productId productService.publishProduct(userId, dto); return ApiResult.success(productId); }Controller里不出现任何关于商品价格校验、状态流转、图片列表解析的逻辑。这些全部下沉到Service层。有人可能觉得这样多此一举但等你要给发布接口加一个用户当天发布商品数量限制或者敏感词过滤时就明白Service层多点扩展点的好处了。真正容易出问题的是Service层的事务控制。商品上架加订单创建就是一个典型的多表操作场景。买家点击购买后端要做的事至少有两件查询商品是否存在且status等于在售把商品status改成已售并插入一条订单记录。这两步必须在同一个事务里执行否则可能出现商品被改成已售但订单没生成的脏数据。正确姿势是在Service方法上打Transactional注解注意这个注解要打在public方法上并且不能从同类内部绕过代理调用。Transactional(rollbackFor Exception.class) public Long createOrder(Long buyerId, Long productId) { Product product productMapper.selectById(productId); if (product null || product.getStatus() ! ProductStatus.ON_SALE.getValue()) { throw new BusinessException(商品不存在或已下架); } product.setStatus(ProductStatus.SOLD.getValue()); productMapper.updateById(product); Order order new Order(); order.setBuyerId(buyerId); order.setSellerId(product.getUserId()); order.setProductId(productId); order.setAmount(product.getPrice()); order.setStatus(OrderStatus.PENDING_PAYMENT.getValue()); orderMapper.insert(order); return order.getId(); }这里rollbackFor Exception.class值得多说一句。Spring的事务默认只在RuntimeException下回滚如果你手滑抛了Exception类型的异常事务不会回滚数据就出了大问题。显式指定rollbackFor算是防御性编程的惯例。再讲一个关于接口返回的通用规范。大部分项目会统一用一个Result类包装返回数据但很多新手会把状态码返回写得很随意前端对接时只能对着文档一个一个猜。我习惯定义成三件套code200成功400业务失败401未登录500系统异常、message可读提示文本、data载荷数据。前端axios拦截器里判断code不等于200时直接弹错误提示语这套模式在前后端分离项目里复用性极高。商品列表接口的分页查询也是需要展开细说的点。MyBatis-Plus的selectPage配合LambdaQueryWrapper可以非常优雅地完成条件拼接public PageResultProductVO pageProducts(int page, int size, Long categoryId, String keyword) { PageProduct p new Page(page, size); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getStatus, ProductStatus.ON_SALE.getValue()) .eq(categoryId ! null, Product::getCategoryId, categoryId) .and(StringUtils.hasText(keyword), w - w.like(Product::getTitle, keyword).or().like(Product::getDescription, keyword)) .orderByDesc(Product::getCreateTime); IPageProduct result productMapper.selectPage(p, wrapper); return PageResult.of(result.getRecords(), result.getTotal(), page, size); }这段代码里有两个细节值得注意。第一个是eq方法的condition重载categoryId为null时条件自动失效避免NullPointerException。第二个是keyword为空时整个and块不拼接这个用condition参数实现比动态拼SQL字符串安全得多也符合MyBatis-Plus的官方推荐用法。后端最后还要提一下JWT登录鉴权的落地方式。很多项目用的方案是写一个拦截器继承HandlerInterceptorAdapter在preHandle里解析Header中的token把userId放进Request attribute供Controller取用。注册、登录接口用AnonymousAccess注解跳过拦截器或者干脆在拦截器里维护一个白名单路径集合。这个方案虽然比Spring Security轻量但并发和安全性上对毕业设计和练手项目完全够用还不用被Security的过滤器链绕晕。5. 商品发布里的两个隐藏难点图片上传和富文本信息商品发布是买家和卖家都直接接触的核心操作如果做不好整个平台的使用体验会打折。先讲图片上传。前端用一个Element Plus的Upload组件就能搞定配置action属性指向后端接口。后端的Controller接收文件并落盘代码写起来不长PostMapping(/upload) public ApiResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(上传文件为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID().toString().replace(-, ) ext; String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(UPLOAD_DIR dateDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return ApiResult.success(/uploads/ dateDir / filename); }这里面有几个坑我实际都踩过。文件扩展名必须通过lastIndexOf截取完整后缀不要用用户上传的原始文件名防止路径穿越。文件名用UUID重命名可以避免同名文件互相覆盖还能顺手抹掉一些非法字符。按日期归档的好处是后续维护磁盘时按天清理很方便。还有一个大家几乎都会忽略的坑Spring Boot默认上传单文件大小限制是1MB商品主图稍微拍得清晰一点就超了。在application.yml里必须显式改掉spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB另一个坑是上传目录和静态资源映射。默认情况下Spring Boot不会把你上传到项目磁盘的文件路径暴露成可访问的URL前端拿到/uploads/20250604/xxx.jpg根本加载不出来跨域问题还在其次。正确做法是配置一个WebMvcConfigurer把本地磁盘目录映射成虚拟路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadDir /); } }配置完之后还要做一次自测直接在前端地址栏输入图片URL如果能打开图片说明映射生效否则排查路径末尾的斜杠和绝对路径写法。富文本信息这个点很多二手交易项目用Textarea草草了事。但实际上二手商品的卖点高度依赖详细的描述信息比如瑕疵、转手原因、购买渠道。我的方案是用一个极简的markdown编辑器或者纯文本多行输入框前端存markdown文本详情页面再用markdown渲染组件转换成HTML展示。这样数据库存的是文本、体积小、可检索页面渲染效果也够精致。不要为了高级感引入重型富文本编辑器编辑器生成的HTML嵌入到Vue页面里还有XSS风险还需要额外的消毒处理性价比不高。6. Vue 3前端组织方式从页面拆解到接口封装前端脚手架推荐Vite Vue 3 Pinia Vue Router Element Plus这组合是当前Vue 3生态里最省心的一套。Vite相比Webpack的启动速度和热更新体验好得一截Element Plus作为UI组件库表单、表格、弹窗这种后台系统的基础件它都有不用自己造轮子。文件目录建议这样组织src ├── api │ ├── product.js │ ├── order.js │ └── user.js ├── views │ ├── home │ │ └── Home.vue │ ├── product │ │ ├── Detail.vue │ │ ├── Publish.vue │ │ └── List.vue │ ├── order │ │ └── OrderList.vue │ ├── user │ │ ├── Login.vue │ │ ├── Register.vue │ │ └── Center.vue │ └── admin │ ├── UserManage.vue │ └── ProductAudit.vue ├── router │ └── index.js ├── stores │ └── user.js └── utils ├── request.js └── auth.js这个目录结构的好处是页面视图、接口层、状态管理彼此隔离改接口字段只动api目录改页面布局只动views目录互不干扰。项目规模不大不需要引入过多的模块深层次抽象。接口封装这一步我最想让新手注意的是一次性把axios实例配置到位。通常我们会在utils/request.js里创建axios实例统一设置baseURL、请求头加请求拦截器和响应拦截器。响应拦截器里要做的事情包括业务状态码判断、401统一跳转登录页、错误message自动弹出。这块代码写一次能省后面几十次重复的错误处理。我分享一下核心部分// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router import { getToken, clearToken } from ./auth const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { clearToken() router.push(/login) ElMessage.error(登录状态已失效请重新登录) return Promise.reject(new Error(Unauthorized)) } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || Error)) } return res.data }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request使用这个封装时业务代码里调用接口拿到的是响应拦截器处理后的data数据比如const res await productApi.pageProducts(params)res里直接就是后端返回的业务数据对象省去了res.data.data这种垃圾代码。Vue 3的组件写法我想强调的是用script setup这个语法糖。它把组件内代码收敛得非常干净不用再纠结export default { setup() {} }的缩进层级问题。下面是一个商品列表卡片组件的核心部分script setup import { ref, onMounted } from vue import { getProducts } from /api/product const products ref([]) const loading ref(false) onMounted(async () { loading.value true try { const res await getProducts({ page: 1, size: 12 }) products.value res.records } finally { loading.value false } }) /script这种写法对新手非常友好状态用ref和reactive页面的响应式逻辑都在setup里平铺不需要记忆大量生命周期API。如果对TypeScript也熟悉可以把const products refProductItem[]([])加上泛型能增强团队协作的健壮性。不过纯JavaScript写法也完全没问题。页面级的路由守卫也值得花心思设计。Vue Router提供的beforeEach全局守卫里要做两件事检查目标路由是否需要登录检查token是否存在。不需要鉴权的页面白名单建议在路由meta里标记一次router.beforeEach((to, from, next) { if (to.meta.requiresAuth !getToken()) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })等你把物流信息、买家留言这些后续功能加进来时路由守卫的代码不用改动扩展性很好。7. 联调阶段的高频坑位跨域、日期格式和会话状态前后端分开开发的最大痛点就是联调。这一步踩的坑往往能消耗掉比写代码更多的时间。跨域问题是最先冒出来的。前端运行在localhost:5173后端运行在localhost:8080端口不同就会被浏览器的同源策略拦截。后端加一个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); } }加完之后如果用axios调用还报跨域不要慌先确认是否存在Access-Control-Allow-Origin响应头。另外注意使用了allowCredentials(true)后allowedOrigins不能写*所以用allowedOriginPatterns传递匹配模式否则会被浏览器拦下来。这是很细节的坑我见到好几个人改完配置依然报错都是栽在这一行。日期格式是第二个高频坑。前端拿到的时间戳往往没有格式化好页面上显示出一长串数字。最简单可靠的方案是后端实体类的时间字段上加JsonFormat注解统一输出格式。Jackson默认的日期序列化格式不是我们可读的显式指定后就稳定了JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;还有一个相关但容易被忽略的问题两种情况下的数据库时区和应用时区不一致会出现时间差8小时。解决方法是连接字符串里加serverTimezoneAsia/Shanghai并且JVM启动参数带上-Duser.timezoneAsia/Shanghai。上线前哪怕费点事也要检查一遍这个坑排起来更隐蔽。会话状态的坑则集中在登录态上。前端登录成功后保存token到localStorageVue Router把token通过请求头传给后端后端用拦截器去验证token有效性。这整套闭环如果中间哪一环断了最典型的表现是登录成功了但一刷新页面就回到登录页。要排查的地方集中在utils/auth.js的getToken取值逻辑和axios请求头是否正确传递这两处定位清楚基本能解决九成问题。联调阶段我自己最推荐的做法是准备一份接口文档表。把所有接口的路径、方法、请求参数、返回结构写清楚前后端各拿一份推进效率会高很多。这不算花架子因为两边同时开发时接口字段变动如果没有同步记录半天时间就浪费在扯皮上了。8. 本地跑通全流程的验证清单和部署备忘项目开发到一个可交付的状态后一定要按真实用户的操作路径从头到尾走一遍。我梳理了一个验证清单照着过一遍基本能撑起项目的完整性注册一个新用户用同一个用户名再注册一次确认有重复用户名提示。登录后进入个人中心看到默认头像正常加载。发布一件商品带三张图片确认详情页图片左右切换正常、缩略图无裂图。商品列表按分类筛选确认分页加载正常翻页后数据不重复、不丢失。商品详情页点击立即购买确认商品状态变为已售卖家的卖出的宝贝列表出现这笔订单。订单列表里能看到买家和卖家的订单记录状态流转符合预期。收藏一件商品取消收藏再重复收藏确认没有重复数据。管理员后台禁用某个用户被禁用用户再次调用需要登录的接口时返回401。所有涉及金额的页面确认数字只保留两位小数没有精度问题。这套流程走完前端报错、后端异常、数据库脏数据基本都会被揪出来。边跑边修效果比写完就交差了事好太多。部署方面如果只是本地演示或答辩后端可以打包成Jar直接java -jar xxx.jar前端打包后把dist目录交给Nginx托管再通过nginx反向代理/api到后端服务端口。配置文件里把前端目录指到dist反向代理的路径记得加个斜杠替换server { listen 80; server_name localhost; root /opt/fe/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; } location /uploads/ { alias /opt/be/uploads/; } }Nginx这里有两个细节要核对。第一proxy_pass http://127.0.0.1:8080/;末尾的斜杠会让路径中的/api前缀被剥掉请求转到后端就是干净的接口路径否则会出现404。第二商品图片如果上传在后端本地目录Nginx的location /uploads/必须能映射到那个目录否则列表页和数据详情页的图片全部加载不出来。我在生产环境调整这个配置就花了半个下午因为路径里一个斜杠的问题图片裂了一整屏。如果上云服务器建议把数据库单独部署或用阿里云RDS这类托管服务图片对象存储是后续的优化方向不着急一开始用本地磁盘完全没问题。宝塔面板在单人运维场景下挺省事的可视化地管理Nginx配置和进程守护部署门槛能低不少。9. 几个后续值得加的功能点以及我的扩展顺序建议一个基础可用的二手交易平台跑通后往哪个方向加功能最能提升完整度我的个人顺序是第一个值得加的是消息通知。买家对商品有疑问卖家想要议价如果没有站内信就只能靠手机号联系平台价值会大打折扣。简易实现方案是在数据库加一张message表买卖双方发消息时各插入一条记录列表页按会话维度查询页面轮询刷新。等后续数据量大了再升级成WebSocket推送。第二个是搜索功能的加强。MySQL的LIKE查询在商品标题和描述字段量级较小的时候够用。如果商品数据量上来了可以考虑引入Elasticsearch或者轻量的方案。但毕业设计和练手项目在数据量有限的情况下用数据库索引加全文索引就能撑住不建议为了炫技把搜索中间件一上来就纳入架构复杂度会成倍增加。第三个是管理后台的数据统计。在后台首页放一组简单的运营数字今日新增用户、今日发布商品、总成交量。SQL里写几个COUNT函数就行配合ECharts画两条趋势折线整个后台的观感立刻不一样。我见过很多二手平台的管理后台就是数据表格堆砌给用户看的数据维度不够直观。这些扩展点的共同特点是不改变现有表结构和业务闭环属于在主干上添加枝叶每多一个功能都能让项目的完整性和面试/答辩时的讲稿厚度增加一节。做完这个项目后我最大的体会是二手交易平台听起来平凡但它把用户认证、文件存储、状态机设计、前后端分离联调、部署运维全部串起来了是那种能逼着你把常规工程问题过一遍的好项目。跟着这套方案走一次踩过其中的坑之后再遇到同类业务需求的把握度会高很多。最后分享一个实操建议开发前先把Postman里的接口集合建好。每写完一个后端接口就顺手把请求用例保存进去前端联调时拿真实的响应数据做页面效果比纯前端mock数据要稳。我见过太多人项目写完接口没有测试记录改了个字段都不知道哪些前端页面要跟着改。接口集合这个好习惯能让整个联调过程从容不少。