ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Vue二手商城系统全栈实战:从数据库设计到部署排坑

Spring Boot+Vue二手商城系统全栈实战:从数据库设计到部署排坑 最近聊得最多的一个项目就是这套 Spring Boot Vue 的二手商城系统。二手商城基本属于毕设、课设里的常青树题材但你别觉得它“老套”恰恰是因为它业务链路完整、角色清晰、技术点覆盖广反而最适合用来把前后端分离、登录鉴权、商品流转、订单状态管理这些全栈核心问题一次性串起来。这套系统带源码、数据库脚本和配套文档拿来直接跑、照着改、或者二次开发都很顺手。这篇文章我不打算只贴几个截图应付了事而是把整个项目从技术选型、数据库设计到后端接口实现、前端页面搭建、最后部署运行和排坑按我实际做项目的顺序完整拆一遍。无论你是准备拿它交毕业设计还是想快速入门全栈开发都能在里面找到可以直接抄作业的部分。1. 项目整体设计与技术选型思路1.1 为什么偏偏是 Spring Boot Vue先说结论这套组合是目前中小型管理系统、电商平台、课程设计项目里最稳妥、资料最全、面试最好聊的搭配。后端用 Spring Boot核心优势是“零配置启动”。它内置了 Tomcat依赖管理靠 Maven 一个 pom 文件就能拉齐不需要像传统 SSM 那样花大量时间在 XML 配置上。Spring Boot 2.7.x 现在依然是企业里的主力版本稳定、社区资料多遇到问题基本都能搜到现成答案。配合 MyBatis-Plus单表 CRUD 几乎不用写 SQL对于二手商城这种以单表操作为主的业务来说开发效率提升非常明显。前端用 Vue核心优势是组件化开发。商品卡片、分页组件、表单弹窗、订单状态标签这些都可以抽成独立组件哪个页面需要就直接引用。再加上 Element UI 这套现成的后台管理组件库页面的视觉效果和交互逻辑能快速到位。Vue 的响应式数据绑定也让购物车数量、订单总价、筛选条件这些联动的数据状态维护变得非常简单。我见过很多人纠结要不要用 JSP、Thymeleaf 做服务端渲染或者用 React 替换 Vue。我的看法是如果目标是把一个能跑的商城系统快速做出来并且想强调前后端分离的架构思想Spring Boot Vue 就是性价比最高的选择。前后端分离意味着前端可以独立开发和调试后端接口也可以单独用 Postman 测试团队协作时分工明确答辩时也能讲出东西来。1.2 二手商城的业务模型怎么拆二手商城和普通电商最核心的区别是 C2C也就是个人卖家直接对个人买家。没有平台自营没有复杂的促销引擎也没有库存管理的概念——商品只有一个“在售、已售、下架”的状态交易双方都是普通注册用户。基于这个特点业务模块可以拆成两块。前台用户端用户注册登录、浏览商品列表、按分类筛选、关键词搜索、查看商品详情、收藏商品、加入购物车、提交订单、查看订单状态、发布闲置商品、个人中心管理自己的商品和订单。后台管理端管理员登录、用户管理禁用/启用、商品审核上架/下架、分类管理、订单管理、基础数据统计。这两块业务看起来多实际落到数据表上就那么几张用户表、商品表、分类表、购物车表、订单表、订单明细表、收藏表、轮播图表。接口数量也就是三四十个完全在一个可控制的范围内。这也是为什么二手商城适合做课设的原因业务规模不大不小但该有的核心环节都有把每一环做扎实比做一个“大而全”但每个功能都粗糙的项目要有价值得多。1.3 前后端协作的关键接口先约定好我做这套项目的时候有一个体会特别深前后端分离项目最容易出问题的不是技术而是接口对不上。后端返回的字段名是 createTime前端拿的是 create_time页面就白屏报错。所以开工之前最好先把接口文档的骨架定下来。写清楚每个接口的请求方式、请求路径、参数类型、返回示例。不需要做得很重一张接口清单表格就够用。比如这样约定{ code: 200, message: success, data: { list: [], total: 0 } }然后后端把统一的返回结构做成一个通用类所有 Controller 都返回这个结构。前端 Axios 拦截器统一解析拿到 code 不等于 200 就直接提示错误。这样一来联调时大部分问题都能在拦截器这一层被统一拦截排查效率高很多。2. 数据库设计与核心表结构2.1 从业务反推表结构开始建表之前先别急着写 SQL。我习惯先把业务里涉及的名词都列出来用户、商品、分类、购物车、订单、订单项、收藏、轮播图、管理员。每个名词对应一张表名词之间的关系就是表与表之间的外键关联。对于二手商城表结构大致是这样表名说明关键字段user用户表id, username, password, nickname, avatar, phone, statuscategory商品分类表id, name, sortgoods商品表id, seller_id, category_id, name, description, price, original_price, images, status, create_timecart购物车表id, user_id, goods_id, quantity, checked, create_timeorders订单表id, order_no, user_id, total_price, status, create_time, pay_timeorder_item订单明细表id, order_id, goods_id, goods_name, goods_image, price, quantityfavorite收藏表id, user_id, goods_id, create_timebanner轮播图表id, image, url, sort2.2 商品表的设计细节goods 表是整个系统的核心字段设计直接影响后续查询和展示。images 字段我建议存多张图片的 URL用逗号分隔。这样查询详情时直接按逗号 split 成一个数组前端轮播图组件就能直接用不需要额外建一张商品图片表。如果你要做的项目图片不多这种方式最省事。price 和 original_price 用 decimal(10,2) 存储不要用 float 或 double。这一点在涉及金额的系统中是铁律浮点数会丢失精度对账时容易出问题。后台管理页面展示时可以用“一口价”“原价”两个字段对比让买家直观感受到折扣力度。status 字段是商品状态的开关一般用整数表示0 表示下架1 表示上架在售2 表示已售出3 表示审核中。不同的状态在前端用不同的标签颜色展示逻辑统一在后端控制前端只负责渲染。seller_id 关联用户表查询商品列表时如果只展示卖家昵称和头像可以单独写一个 SQL 关联查询或者在商品表冗余一个 seller_name 字段。对于小项目冗余字段反而能省掉很多联表操作查询性能更好。2.3 订单拆分为什么需要订单明细表很多新手在设计订单表时会把所有商品信息直接塞进 orders 表下单买了三件商品就存三行订单记录。这个设计在展示和统计时会非常痛苦。正确做法是拆成两层orders 表存订单主信息比如订单号、总金额、支付时间、订单状态order_item 表存这个订单下的每个商品项包括商品 ID、快照名称、快照图片、成交单价、购买数量。为什么说是“快照”因为商品信息是可能变化的。卖家改了商品标题、换了商品图片甚至把商品下架了买家已下单的历史订单不应该跟着变。所以下单那一刻把商品名称、图片、价格复制一份到 order_item 表里后续无论商品怎么变订单数据都保持稳定。订单状态我用整数表示0 待付款1 待发货2 待收货3 已完成4 已取消。状态流转在后端服务里控制比如点击“确认收货”接口里先判断当前状态是否是 2是则改成 3否则返回“订单状态异常”。前端根据状态码渲染对应的按钮组。数据库脚本里我会写上初始化数据包括测试账号、商品分类、几件示例商品、轮播图链接。这样项目一跑起来页面就是有内容的不用自己再去造数据。3. 后端搭建与核心代码实现3.1 Spring Boot 项目初始化与依赖引入后端工程结构我习惯按模块分包config、controller、service、mapper、entity、common或 utils。Controller 只做参数接收和结果封装业务逻辑全部下沉到 ServiceMapper 层用 MyBatis-Plus 的 BaseMapper 继承接口。核心依赖就这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency如果做登录鉴权需要额外引入 JJWT。文件上传功能如果需要做可以用 Spring Boot 自带的 MultipartFile只要配置一个本地存储路径就够了不必须引入第三方存储服务。application.yml 里重点配置三块数据源、MyBatis-Plus、自定义的 JWT 配置项。spring: datasource: url: jdbc:mysql://localhost:3306/second_hand?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key expire: 604800map-underscore-to-camel-case 这个配置非常关键它可以把数据库里的 seller_id 自动映射成实体类的 sellerId省掉一大堆 TableField 注解。3.2 统一返回结构与全局异常处理接口返回格式不统一是前后端联调的第一大坑。我一开始写接口时每个 Controller 都自己拼 Map 返回后来发现前端同学每接一个接口都要看一遍返回格式效率极低。后来抽了个 Result 类public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }配合 RestControllerAdvice 做全局异常处理。业务上主动抛出的异常比如“商品已下架”“余额不足”“登录已过期”通过自定义 BusinessException 抛出统一转换成 Result 返回。这样 Controller 里就不会出现大段 try-catch代码清爽很多。3.3 登录鉴权JWT 拦截器二手商城有两个角色普通用户和管理员。我这里用的是 JWT 方案用户登录成功后生成一个 token 返回给前端前端每次请求在 Header 里带上 token后端拦截器解析 token 识别用户身份。JWT 的好处是服务端无状态不需要把登录信息存在 Session 里接口天然支持跨域、适合前后端分离架构。核心逻辑分三步。第一步登录接口校验用户名密码成功后生成 tokenString token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact();第二步拦截器里解析 token把用户信息放到 ThreadLocal 里String token request.getHeader(Authorization); Claims claims Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); UserContext.setUserId(Long.parseLong(claims.getSubject()));第三步注册拦截器并配置放行路径。登录、注册、商品列表、商品详情这些接口不需要登录不拦截下单、发布商品、购物车操作这些必须登录拦截。需要注意一个细节token 过期了怎么处理我是直接返回 401 状态码前端 Axios 拦截器统一监听跳转到登录页。这样可以保证前端每个页面在 token 失效时都有一致的表现。3.4 商品模块分页、搜索、发布商品列表接口是前端访问量最大的接口需要支持关键词搜索、分类筛选、价格排序、分页。MyBatis-Plus 的 LambdaQueryWrapper 写这种条件查询很方便不需要手写 XML。核心代码大概长这样LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.eq(Goods::getStatus, 1); if (StringUtils.isNotBlank(keyword)) { wrapper.like(Goods::getName, keyword); } if (categoryId ! null) { wrapper.eq(Goods::getCategoryId, categoryId); } wrapper.orderByDesc(Goods::getCreateTime); PageGoods page goodsMapper.selectPage(new Page(pageNum, pageSize), wrapper);需要注意like 拼接时要用%通配符吗MyBatis-Plus 的 like 方法是自动帮你拼好的。如果你要自己手写 SQL一定记得like %${keyword}%要改成concat(%, #{keyword}, %)防止 SQL 注入。发布商品接口在保存 goods 数据的同时要设置 sellerId 为当前登录用户status 默认 1 或者 0 看业务需求。如果是后台需要审核status 就设为 3 审核中如果不需要审核流程直接设为 1 上架。图片上传接口我单独写了一个接收 MultipartFile保存到本地磁盘目录返回访问 URL。注意要在 Spring Boot 里配置静态资源映射否则浏览器无法访问到上传的图片。3.5 订单模块事务是必须的下单接口是整个项目里最容易出 bug 的地方。用户从购物车勾选商品点击提交订单后端要做的事包括查询勾选的购物车项、校验商品是否还在售、计算总金额、生成订单主表记录、生成订单明细记录、清空购物车对应项、把对应商品状态改为已售。这七步操作中任何一步失败都会导致数据不一致。所以下单接口必须加 Transactional 事务注解任何一步抛异常之前的数据库操作全部回滚。我曾经遇到过一个情况用户下单成功了但购物车没清空导致用户重复下单。排查后发现问题出在事务没有生效。原因是同类内部方法调用导致事务失效——UserService 里的方法调用了同类里的另一个方法Spring 的 AOP 代理绕过了事务注解。后来把下单逻辑拆到独立 Service 里问题才解决。订单号生成我建议不用数据库自增 ID而是用时间戳加随机数拼一个业务订单号。比如yyyyMMddHHmmss 6位随机数既方便排序又不容易重复。4. 前端 Vue 项目搭建与页面实现4.1 Vue 项目初始化和工程结构前端我推荐用 Vue CLI 创建项目。如果是 Vue 2 项目执行vue create second-hand-mall安装 vue-router、vuex、axios、element-ui。如果是 Vue 3 项目把 element-ui 换成 element-plus思路是一样的。工程目录按页面和组件拆src/ api/ // 接口请求模块 assets/ // 静态资源 components/ // 公共组件 router/ // 路由配置 store/ // 状态管理 views/ home/ // 首页 goods/ // 商品详情 cart/ // 购物车 order/ // 订单相关 user/ // 个人中心 admin/ // 后台管理4.2 Axios 封装和跨域代理Axios 如果不做封装每个页面自己写请求维护成本非常高。我在 utils/request.js 里统一封装import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.message || 请求失败) return Promise.reject(error) } ) export default request这里需要注意 baseURL 设置为/api然后在 vue.config.js 里配置 devServer 代理devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } }这样开发环境下前端请求/api/goods/list会被代理到后端http://localhost:8080/goods/list完美解决跨域问题而且不需要后端配 CORS。4.3 核心页面怎么实现首页是一个典型的信息流页面。顶部搜索框下面分类标签再往下是商品卡片瀑布流。商品卡片我会抽成一个组件接收一个 goods 对象作为 props点击跳转到详情页。列表数据通过分页接口加载滚动到底部自动加载下一页或者用 Element UI 的 Pagination 组件做手动分页。商品详情页分三块左侧图片轮播右侧商品信息和价格底部下单操作区。加入购物车按钮调用购物车接口立即购买按钮直接跳转到确认订单页面。购物车页面的核心是选中状态和总价的计算。我用一个计算属性搞定computed: { totalPrice() { return this.cartList .filter(item item.checked) .reduce((sum, item) sum item.price * item.quantity, 0) } }这样只要勾选状态变化总价自动更新不需要手动维护一份“已选列表”。发布商品页面是一个表单页包含商品名称、分类选择、价格、原价、描述、图片上传。图片上传组件我直接用的 Element UI 的 el-upload配置 action 指向后端的上传接口on-success 回调里把返回的图片 URL 存进表单数组里。后台管理页面相对机械用户管理用 el-table 展示列表配合 el-switch 切换启用/禁用状态商品管理在 el-table 的基础上加“上架”“下架”“删除”操作按钮订单管理展示订单列表支持按状态筛选操作按钮动态控制。4.4 状态管理和路由守卫登录状态我存在 localStorage 里前端 store 里也做一份用户信息缓存。路由守卫检查用户是否登录未登录跳转登录页。有些页面还需要管理员权限比如 /admin 开头的路由守卫里再判断用户角色。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.requiresAdmin localStorage.getItem(role) ! ADMIN) { next(/) } else { next() } })这套逻辑虽然简单但在实际项目中非常实用。它把前端层面大部分“不该访问的页面”挡住了后端接口依然做防护形成双保险。5. 从源码到跑起来部署运行与数据库初始化5.1 数据库脚本怎么用拿到整套源码之后第一步不是打开 IDEA而是先初始化数据库。项目里会带一个 db_second_hand.sql 文件用 Navicat 或者命令行执行mysql -u root -p db_second_hand.sql执行完会创建一个名为 second_hand 的数据库以及全部表和初始化数据。这里我想提醒一点如果 SQL 文件里包含建库语句注意别把默认的 root 密码或者生产环境的账号信息漏出来。文档里如果要写数据库配置密码最好用 xxxxxx 代替。5.2 本地开发流程完整走一遍后端启动IDEA 打开 backend 目录等待 Maven 下载依赖完成后修改 application.yml 里的数据库账号密码然后运行主类。前端启动终端进入 frontend 目录执行npm install npm run servenpm install 有时候会因为网络问题失败或者某些依赖版本冲突。我建议用npm install --registryhttps://registry.npmmirror.com指定国内镜像源安装速度会快很多。启动成功后浏览器访问http://localhost:8081Vue 的默认端口是 8080如果被占用会自动升到 8081页面就能正常渲染。用 SQL 初始化数据里的测试账号登录就能体验完整的交易流程。由于前端配置了跨域代理开发环境下前后端不需要任何额外的跨域配置就能联调。这也是我推荐开发阶段用代理而不是后端开启 CORS 的原因部署阶段更灵活不会因为 CORS 配置误放行了不该访问的路径。5.3 上线部署时的几个关注点如果要把项目部署到服务器有几个点需要提前处理。前端执行npm run build生成 dist 目录用 Nginx 托管再把接口请求通过 Nginx 反向代理到后端服务。server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端用mvn clean package打成 jar 包配合nohup java -jar second-hand-mall.jar 后台运行。项目里上传的文件应该放在固定目录做好定期备份。如果使用 MySQL 8.x驱动的 driver-class-name 是com.mysql.cj.jdbc.Driver5.x 则是com.mysql.jdbc.Driver。这个细节经常导致数据库启动直接报错排查时先看一眼配置文件。6. 常见问题与排查技巧实录6.1 前后端联调阶段的经典报错联调时最常遇到的是跨域问题、接口 404、token 不通过这三类。跨域的表现是浏览器控制台打印 CORS errorNetwork 面板里请求状态是 blocked。如果后端配置了 CORS检查 Access-Control-Allow-Origin 是否包含当前前端域名如果用了代理检查 vue.config.js 的 target 是否指向正确端口。接口 404 大多不是真不存在而是路径拼错了。客户端请求的是/api/goods/list后端 Controller 映射的是/goods/list代理层如果没正确重写路径请求就打不到后端。我在开发时习惯在 Controller 类上统一加RequestMapping(/goods)类里方法只写二级路径这样前后端对照接口文档就能减少这一类的低级错误。token 不通过先看请求头里 Authorization 有没有带对。前端在 request 拦截器里从 localStorage 取 token如果存的时候 key 不同后端就永远收不到。遇到这种问题我建议后端先单独用 Postman 测一遍接口确认接口本身没问题再从前端看请求头。6.2 后端启动和数据库连接排查后端启动报数据库连接失败先检查三件事MySQL 服务有没有启动、application.yml 里的账号密码是否正确、URL 里有没有配置 serverTimezone。MySQL 8.x 乱码、时区报错基本都是 URL 参数缺失引起的。还有一种是端口占用。Spring Boot 默认 8080如果被其他程序占用了端口冲突直接写server.port: 8081或者换一个端口号。MyBatis-Plus 查询结果一直是 null大概率是实体类和表字段映射没对上。优先检查数据库表字段名和实体属性名是否一致。只要开启了 map-underscore-to-camel-case数据库里 seller_id 会自动映射到 sellerId但如果实体属性叫 sellerID就映射不上了Spring Boot 不会主动报错只会返回 null。6.3 前端构建和页面显示问题npm install 阶段最常见的坑是 node-sass 安装失败。新版本 Node.js 对 node-sass 兼容性很差建议直接用 sassdart-sass替代Element UI 完全兼容。如果项目里已经锁定了 node-sass可以先执行npm uninstall node-sass再安装sass。启动项目端口自动跳号也是一个常见现象。Vue CLI 默认占用了 8080如果这个端口被后端或其他页面占用它会自动跳 8081。我在开发时给前端单独配了 8081 端口devServer: { port: 8081 }页面白屏或者图片不显示先看控制台有没有报错再看图片 URL 能不能在浏览器里直接访问。本地开发时上传的图片路径是http://localhost:8080/upload/xxx.jpg如果前端端口是 8081图片 URL 可能是绝对路径直接访问 8080 是没问题的。但如果上线后图片路径写死了 localhost就会全部失效。所以图片存储路径和访问 URL 最好是相对路径或者可配置的。现象可能原因解决办法跨域 CORS 报错后端未配置 CORS 或代理未生效优先用 devServer 代理后端统一允许来源设置接口 404前端路径与后端 RequestMapping 不匹配对照接口文档检查路径和请求方法401 登录过期token 缺失或已过期检查拦截器放行路径前端添加 401 统一跳转数据库连接失败密码错误 / 时区未配置检查连接 URL 是否含 serverTimezone图片无法访问缺失静态资源映射配置注册 ResourceHandler 映射本地 upload 目录npm 安装报错node-sass 版本兼容问题替换为 sass使用国内镜像源我实际跑这套项目的时候踩过最隐蔽的一个坑是下单接口事务没有生效。当时用相同 Service 里另一个方法调下单方法Transactional 注解形同虚设出现了“订单生成了但购物车没清”的数据不一致问题。后来把下单逻辑拆分出去还在删除购物车和更新商品状态之间故意留了一个运行时异常验证事务能整体回滚才算真正搞定。做完这一步整个下单流程才让我放心。最后再分享一个我自己的习惯拿到任何一套开源项目或者源码第一件事不是急着跑而是先花半小时把目录结构、数据库表、接口清单浏览一遍。看懂代码的组织方式再动手改业务逻辑往往能少走很多弯路。这套二手商城项目后续扩展空间也很大比如接入微信支付、加入管理员数据看板、把图片上传切到云存储都是很自然的下一步。需要源码和配套文档的话直接按文中步骤把环境和数据准备起来边改边跑比只看不练要快得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进