恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot+Vue+MySQL鲜花商城毕设:从订单表设计到事务扣库存实战
首页
资讯中心
/
SpringBoot+Vue+MySQL鲜花商城毕设:从订单表设计到事务扣库存实战
SpringBoot+Vue+MySQL鲜花商城毕设:从订单表设计到事务扣库存实战
发布时间:2026/9/16 23:28:36
简介一款基于SpringBootVueMySql实现的前后端分离在线鲜花商城毕业设计项目适合计算机相关专业学生作为毕业设计参考也可供希望熟悉前后端分离开发的Java初学者学习。压缩包大小28.06MB共143个文件其中Java源码54个、Vue组件19个、JavaScript脚本15个并包含SQL数据库脚本、项目配置文件与多份论文文档便于对照源码理解系统结构。项目按用户、店家、管理员三种角色设计覆盖注册登录、鲜花分类浏览、多条件查询、商家管理、公告与轮播图配置、购物车及订单管理等典型电商业务场景。除可运行的完整源代码和数据库脚本外还附有支付流程设计图与毕业论文文档能帮助读者快速搭建环境并掌握从数据库设计到前后端联调的实现思路。目前已有257人学习下载。1. 毕业设计选在线鲜花商城先把 SpringBootVueMySQL 的项目边界划清楚选“在线鲜花商城”做毕业设计又能靠上 SpringBootVueMySQL 这条技术栈等于把 Java 后端、前端工程化和数据库设计在同一个项目里全部过一遍。项目标题里的“前后端分离”不是把代码分成前后两个文件夹而是 Vue 和 SpringBoot 各自启动、各自部署只通过 JSON 接口互相通信。这里有一个反直觉的点商城代码量不算大真正拉开差距的是订单扣库存会不会超卖、刷新页面后 token 是否还在、Vue 打包后路由会不会 404。下面按我复刻这类项目时实际会走的路线展开先立数据库脚本再写后端接口再接 Vue 页面最后把部署和验收一次跑通。2. 数据库脚本先行鲜花商城的表设计、订单状态机与扣库存索引SpringBoot 后端速度再快字段名对不上 Vue 页面项目就会卡在联调环节。我复刻这类商城项目时会先写数据库脚本而不是先建工程原因是表结构就是前后端接口的契约商品表决定列表页返回哪些字段订单表决定结算页提交哪些参数状态字段决定后端要做哪些条件分支。2.1 从下单流程倒推核心表用户路径是浏览分类、查看鲜花、加购物车、填收货地址、下单支付管理员则要维护商品和发货。按这个路径拆出来的核心表一共七张user、category、flower、cart、orders、order_item、address。表名作用与订单的关系user用户与管理员的登录账号订单归属方category鲜花分类商品列表按分类筛选flower鲜花商品与库存订单明细扣减 stockcart购物车下单前数据来源orders订单主表记录收货人、总价、状态order_item订单明细记录下单时的商品快照address收货地址结算时回填这七张表对应数据库脚本里的七个建表语句MySQL 统一用 InnoDB字符集用 utf8mb4这样表情符号或生僻花名不会乱码。下面这段 SQL 是整套项目的地基执行顺序不能乱先建 user、category再建 flower最后建 orders 和 order_item。CREATE DATABASE IF NOT EXISTS flower_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE flower_shop; CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的值, phone VARCHAR(20) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0 普通用户, 1 管理员, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE category ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT鲜花分类表; CREATE TABLE flower ( id INT NOT NULL AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, cover VARCHAR(255) DEFAULT NULL COMMENT 主图 URL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, description TEXT, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 上架, 0 下架, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT鲜花商品表; CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0 待付款, 1 已付款, 2 已发货, 3 已完成, 4 已取消, receiver_name VARCHAR(50) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(255) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, flower_id INT NOT NULL, flower_name VARCHAR(100) NOT NULL COMMENT 商品快照避免下单后改名称, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;这段脚本里有几个点要说明第一user 表密码长度给 100因为 BCrypt 加密结果通常是 60 个字符给 50 会不够用第二orders 和 order_item 用 BIGINT因为订单量增长后 INT 容易到上限而商品和分类用 INT 足够第三order_item 冗余了 flower_name 和 price这是故意为之鲜花改名改价后历史订单仍然能显示下单时的真实信息不需要回查商品表。提示九成毕设不需要建物理外键靠逻辑外键和索引即可。物理外键会让订单插入和商品更新产生额外锁竞争答辩时反而容易被追问性能问题。2.2 用状态字段表达订单生命周期订单状态用 TINYINT 而不是字符串一方面节省空间另一方面方便后端写status 1这样的范围判断。这里的状态机不是简单枚举而是每一步必须有明确出口否则订单模块会写出无限膨胀的 if 判断。status含义可进入的下一状态0待付款1、41已付款2、42已发货33已完成无4已取消无后端 Service 在修改状态前要先查原状态再迁移不允许从前端直接传一个 status 覆盖。常见做法是单独写一个updateStatus(orderNo, fromStatus, toStatus)方法SQL 里同时带status #{fromStatus}条件这样即使两个请求同时操作同一订单也只有一个能成功。这个思路和幻觉批次处理是一样的原理后面扣库存还会再次用到。2.3 扣库存的条件更新与索引取舍库存字段存在 flower.stock 上但它不负责展示“累计销量”销量由 sales 字段单独维护。扣库存最容易写错的是“先 SELECT 查库存再 UPDATE 扣减”两个请求同时查到 stock1各自判断库存充足结果都会执行扣减库存变成 -1。数据库脚本阶段就要把扣减姿势定下来用一条条件 UPDATE让 MySQL 的行锁帮我们挡住并发。UPDATE flower SET stock stock - 1 WHERE id 1 AND stock 1;这行 SQL 的巧妙之处在stock 1不是多余条件它让不满足条件的行不参与更新受影响行数为 0。后端拿到 0 就知道库存不足直接回滚整个订单。另外UPDATE 会命中 flower 的主键索引并对该行加锁并发请求会在这里排队不会出现两条请求同时把库存扣成负数。索引这里不需要额外处理但订单明细表一定要有 idx_order_id否则查询“这个订单买了哪些花”会走全表扫描。3. 后端 SpringBoot 实现JWT 登录、商品分页与事务扣库存数据库脚本确定后后端 SpringBoot 接口就变成了翻译工作。这个项目我建议不引入 Spring Security而是用 JWT 加 HandlerInterceptor 的轻量方案。Spring Security 的过滤器链和认证管理器对毕设来说配置成本太高面试官更想看到的是你清楚 token 怎么签发、怎么校验、怎么续期而不是背 Security 配置。3.1 JWT 签发与登录接口登录接口接收用户名和密码校验通过后签发 token。数据库里存的 password 是 BCrypt 加密后的字符串所以校验不能直接比字符串要用 BCrypt.checkpw。前端拿到的返回体统一是{ code: 200, message: ok, data: { token, role } }这个结构后面 Vue 的 axios 封装会依赖它。public class JwtUtil { private static final String SECRET flower-shop-secret-key-change-in-prod; private static final long EXPIRE_MS 1000L * 60 * 60 * 24; public static String createToken(Integer userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MS)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }这里用的 JWT API 属于 jjwt 0.9.x 的链式写法对应大多数教学项目的引入方式。如果你创建项目时拉到了 jjwt 0.11.x 版本签名和解析 API 会变成Key对象加parserBuilder()这是 SpringBoot 版本太高连带产生的常见坑换 API 时留意即可。claim(userId, userId)把用户 ID 塞进 token这样后端在拦截器里解析 token 后可以直接拿到当前操作人不需要前端每次把 userId 传过来。登录 Controller 里要做两件事查到用户后用 BCrypt 校验密码校验通过再调用JwtUtil.createToken。如果把 Token 生成放在校验之前接口就会暴露“用户名存在但密码错误”的信息不符合常规做法。3.2 商品分页PageHelper 的参数与前端对齐商品列表接口需要支持分页、按名称模糊查询、按分类筛选。数据库脚本阶段建了 idx_category查询和分页就走这个索引。GetMapping(/flowers) public Result page(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 8) Integer size, RequestParam(required false) String name, RequestParam(required false) Integer categoryId) { PageHelper.startPage(page, size); ListFlower list flowerMapper.search(name, categoryId); PageInfoFlower info new PageInfo(list); return Result.ok(info); }PageHelper.startPage(page, size)是分页插件的一行式做法它只对紧接着的一条查询生效所以必须直接放在 mapper 调用前。RequestParam里defaultValue和required的区别要分清required false表示可以不传 name 或 categoryId后端用 null 去接收defaultValue是前端没传 page 和 size 时兜底用的。PageInfo会额外返回 total、pages、hasNextPage 等字段前端 el-pagination 组件直接绑定 total 就能渲染总数。3.3 事务扣库存受影响行数是唯一的成功依据下单是最容易出现隐藏 bug 的接口因为它同时操作 flower、orders、order_item 三张表。我的推荐写法是 Service 方法上标注Transactional(rollbackFor Exception.class)方法内先扣库存全部成功后才插入订单和明细。Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateDTO dto) { ListOrderItem items new ArrayList(); BigDecimal total BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { int rows flowerMapper.deductStock(item.getFlowerId(), item.getQuantity()); if (rows 0) { throw new BizException(库存不足: item.getFlowerId()); } Flower flower flowerMapper.findById(item.getFlowerId()); OrderItem orderItem new OrderItem(); orderItem.setFlowerId(flower.getId()); orderItem.setFlowerName(flower.getName()); orderItem.setPrice(flower.getPrice()); orderItem.setQuantity(item.getQuantity()); items.add(orderItem); total total.add(flower.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } Order order buildOrder(dto, total); orderMapper.insert(order); for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return order; }对应的 Mapper XML 是事务成功的关键update iddeductStock UPDATE flower SET stock stock - #{quantity} WHERE id #{flowerId} AND stock #{quantity} /update逻辑上事务回滚的范围是整个方法而不是单条 SQL。循环里第二件商品库存不足抛异常第一件已经扣掉的库存会被Transactional回滚不会出现一个订单只扣了一个商品的库存。rows 0的判断依据是 MySQL 更新语句的受影响行数条件AND stock #{quantity}不成立时行不更新返回 0这正好作为库存不足的信号。注意Transactional默认只对 RuntimeException 回滚。这里抛出的是继承 RuntimeException 的 BizException所以不需要额外配置 rollbackFor如果自己定义检查异常就必须写rollbackFor Exception.class否则事务不会回滚。4. 前端 Vue 对接axios 统一 token、路由守卫与表单校验前端 Vue 部分决定项目答辩时能不能顺畅演示。很多毕设后端接口写得完整但一刷新页面就跳登录一跨域就白屏问题大都出在 token 处理和路由守卫上。下面按 Vue 3 加 Vite 加 Element Plus 的组合来讲这也是当前创建 SpringBoot 前后端分离项目时最常用的前端搭配。4.1 把 token 注入收敛到 request.js前端每个页面不建议单独写 axios因为 token 注入、401 跳转、错误提示完全是重复逻辑。常见做法是封装src/utils/request.js所有页面只 import 这个实例。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(flower_token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(flower_token) router.push(/login) } ElMessage.error(err.response?.data?.message || 请求失败) return Promise.reject(err) } ) export default request这段代码的请求拦截器负责从 localStorage 读 token并拼成标准 Bearer 格式。后端 LoginInterceptor 从Authorization头里截取 token 并解析两边的 key 必须完全一致。响应拦截器里res res.data是把 axios 的响应对象拆开后续代码拿到的直接是后端 Result 结构不用每次都写response.data.data。401 跳转要小心死循环如果登录接口本身返回 401会把用户从登录页再推到登录页所以后端登录失败应返回 HTTP 200 加业务 code而不是直接用 401 表达业务错误。4.2 路由守卫刷新页面后保持登录状态Vue Router 的路由守卫是刷新页面后的第一道关卡。token 放在 localStorage 里刷新不丢但如果你把 token 放内存变量里刷新就没了。守卫里只用 token 是否存在来判断是否登录token 是否过期交给后端拦截器判断。router.beforeEach((to, from, next) { const token localStorage.getItem(flower_token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.path /login token) { next(/) } else { next() } })路由表里要给受保护页面加 meta 标记例如购物车、结算、个人中心设置meta: { requiresAuth: true }首页和商品详情不加。to.path /login token的作用是登录过后再手动访问登录页直接送回首页避免重复登录。这个守卫没有做角色判断管理员页面需要再叠加一个meta.roles判断比如从 token 里解析 role然后和 to.meta.roles 做交集。token 存储方案刷新后是否保持适用场景localStorage保持毕设项目推荐代码简单sessionStorage浏览器关闭后失效安全性要求高但体验差Pinia 内存 store不保持不推荐单独使用必须搭配持久化4.3 表单校验要提交前做而不是提交后做登录页和结算页都要用 Element Plus 的 Form 组件校验。常见错误是只在点击登录时判断输入框是否为空这种“提交后提示”的体验在答辩时会显得不专业。正确做法是定义 rules 对象在提交按钮里调用formRef.validate()。const rules { username: [{ required: true, message: 请输入用户名, trigger: blur }], password: [{ required: true, message: 请输入密码, trigger: blur }] } const handleLogin () { formRef.value.validate(async valid { if (valid) { const res await request.post(/auth/login, form) localStorage.setItem(flower_token, res.data.token) router.push(/) } }) }trigger: blur表示输入框失焦时就校验用户还没点提交就知道哪里不对。登录成功后这里直接把 token 放进 localStorage然后跳转首页路由守卫会读取到这个 token 并放行。如果把 validate 里的 valid 判断写反会出现表单没填完也发请求的情况接口返回错误码后前端才弹提示这属于最容易犯但在场很难查出来的逻辑错误。5. 从本地到可演示跨域转发、Nginx 部署与 SpringBoot 版本坑前后端分离项目本地联调时前端跑在 5173 端口后端跑在 8080 端口浏览器会认为这是两个源。处理跨域有两条路后端加 CORS 配置或者前端开发服务器做转发。毕设项目我更推荐后者因为打包部署时还能复用同一套转发规则。5.1 Vite 的 server 转发配置在项目根目录的 vite.config.js 里加一段配置开发期所有/api开头的请求会被 Vite 开发服务器转发到后端 8080 端口。export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })changeOrigin: true会把请求头里的 Host 改成 target 的地址避免部分后端框架做域名校验时拦截。这里的 baseURL 是/api后端接口路径是/api/auth/login所以转发后完整路径不会丢失。如果后端没有统一加/api前缀需要在前端 baseURL 里补上或者在转发配置里用 rewrite 去掉前缀常见做法是后端 Context Path 配置成/api两个地方保持一致。5.2 Vue 打包与 Nginx 部署时的路由 404npm run build之后Vue 项目生成 dist 目录。历史路由模式下用户访问http://服务器IP/orders并刷新Nginx 会在磁盘上找orders这个文件找不到就返回 404这是“前后端分离部署后刷新页面白屏”的最常见原因也是热词“vue 打包后布局异常”的核心场景。server { listen 80; root /usr/share/nginx/html/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; } }try_files $uri $uri/ /index.html的含义先按请求路径找真实文件找不到就把请求回退到 index.html让 Vue Router 接管并渲染对应页面。这里的 root 必须指向 dist 目录否则 index.html 找不到加载后会是一片空白。有时打包后布局异常但不是 404 刷新而是图片和字体文件找不到多半是 Vite 的 base 路径问题需要在 vite.config.js 里把base: ./改成相对路径或者把资源放到 Nginx 对应的静态目录下。5.3 SpringBoot 版本太高javax 与 jakarta 的切换IDEA 里创建 SpringBoot 项目时初始化模板默认会勾选最新稳定版这直接带来 SpringBoot 版本太高的问题Spring Boot 3.x 把javax.servlet改成了jakarta.servlet之前教程里的import javax.servlet.http.HttpServletRequest全部报红。这个坑在拦截器、Filter、WebMvcConfigurer 三个位置最集中。对比项Spring Boot 2.7.xSpring Boot 3.xJDK 要求JDK 8 / 11JDK 17 及以上请求相关 APIjavax.servletjakarta.servlet数据库驱动大部分老驱动可用需要升级新驱动版本与 MyBatis Starter 兼容性稳定需选对应新版本如果已经建了 Boot 3.x 项目最快的方式是把 import 从javax.改成jakarta.并把 MyBatis Starter 换成 Boot 3 对应的版本。如果是找参考代码对不上版本时直接回退到 Spring Boot 2.7.x 加 JDK 11 是更顺的路线整套数据库脚本和代码不用改动。注意不要只改 import 不看依赖Tomcat 在 Boot 3 里也换了包路径依赖版本不对照样启动报错。5.4 数据库脚本的导入命令数据库脚本交付有两种形式SQL 文件和初始化数据。给评委演示时用命令行导入比用 Navicat 可视化导入更可信。mysql -u root -p flower_shop.sql如果你用的是 MySQL 免安装版需要先配置 my.ini 并执行mysqld --initialize-insecure初始化 data 目录然后启动 mysql 服务再执行上面这行导入命令。脚本第一行CREATE DATABASE IF NOT EXISTS已经处理了重复执行的问题但 orders 表里的测试订单数据每次导入会重复建议脚本里先清空数据表再插入或者用TRUNCATE保证演示环境干净。6. 交付前写好验收脚本并发扣库存、接口回归与简历描述项目写完不等于能交付前后端分离项目最怕接口回归时手工点页面。我一般会把验收写成 bash 脚本放到项目根目录的 verify 文件夹里每次改完代码跑一遍比打开浏览器一个个点靠谱。6.1 一条脚本验证登录与分页接口#!/bin/bash BASEhttp://localhost:8080 TOKEN$(curl -s -X POST $BASE/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} \ | sed s/.*token:\([^]*\).*/\1/) echo token$TOKEN curl -s $BASE/api/flowers?page1size5 \ -H Authorization: Bearer $TOKEN \ | head -c 500 echo sed 提取 token 依赖登录接口返回字段名为token如果后端改成了accessToken脚本要同步改。这里先声明 BASE 变量后边接口地址改动只需要改一行这个习惯在接口数量超过十个后能省很多事。6.2 并发抢购验证超卖问题不能靠肉眼观察要用并发请求压出真实行为。下面脚本对商品 1 发起 10 个并发下单请求每个都购买 1 件然后查库存for i in $(seq 1 10); do curl -s -X POST $BASE/api/orders \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {userId:1,items:[{flowerId:1,quantity:1}]} done wait mysql -u root -p -e SELECT id, name, stock FROM flower WHERE id1如果库存字段初始值是 5并发结束后应该是 0不可能出现负数。出现负数就说明条件更新没生效检查 Mapper XML 里是否少了AND stock #{quantity}。注意脚本里的 userId 必须是登录用户的真实 ID否则订单会创建到别人名下演示时要用哪个账号登录这里就传哪个 ID。6.3 简历上不要只写“实现增删改查”这个商城项目在简历上的标准描述应该围绕三个关键词展开前后端分离、JWT 认证、事务一致性。推荐写法是“基于 SpringBoot Vue MySQL 实现前后端分离的在线鲜花商城负责 JWT 登录认证、商品分页查询和订单事务扣库存使用条件更新解决并发超卖问题”。不要写“熟练掌握商城项目开发”这种结论式套话写清技术点、解决的问题、验证手段面试官才有继续追问的抓手。提示把 verify 脚本和数据库脚本一起放进项目源码的 docs 目录答辩现场直接运行整个项目从建库到接口回归都是可复现的这是最直观的交付证明。本文还有配套的精品资源点击获取