SpringBoot+Vue销售系统:订单库存与状态机设计实战
简介基于SpringbootVue的东北特产销售系统完整项目代码适合用于毕业设计、课程设计或前后端分离开发入门。系统覆盖用户信息、图片素材、视频素材等管理模块采用Spring Boot、Vue、MyBatisPlus、MySQL等主流技术附带项目文档与目录结构可帮助理解B/S架构下电商系统的实现流程。资源共937个文件压缩包约30.62MB主要包含151个Java后端文件、89个Vue前端组件、161个JavaScript脚本以及CSS、HTML、图片等静态资源和数据库配置整体目录清晰便于快速导入运行已有190人学习下载。资料包提供全套源代码、前端页面、数据库表结构与配置说明并包含论文目录章节方便对照学习系统分析、设计及实现过程既可以作为Spring Boot与Vue整合开发的实战案例也能够在此基础上扩展功能或二次开发。1. 东北特产销售系统为什么选 SpringBootVue 这套组合把一个卖人参、木耳、榛蘑和蓝莓干的小店搬上线最棘手的问题通常不是页面样式而是订单状态。界面显示有货却发不出货顾客付了款管理员看不到后台改完价格前台还在按旧价算账——这些毛病几乎都发生在数据一致性和接口边界上。基于SpringBootVue的东北特产销售系统本质就是让SpringBoot负责交易纪律库存、金额、订单状态让Vue负责挑选与下单的交互体验。这套方案之所以常见是因为它把后端稳定性和前端迭代效率分开商品上下架、订单流转、库存扣减都收敛在 Java 服务里前端只需要关心接口返回什么。对课程设计、毕业设计或者小团队想快速上线一个垂直品类电商来说这是投入产出比最高的选择。2. 特产商品与订单的 SpringBoot 数据模型怎么定2.1 先规划四张核心表再谈代码做销售系统最容易犯的错是一上来就写接口觉得表结构后面再补。实际上商品怎么描述、订单怎么记、价格按什么单位算全在表结构里定死。东北特产这类非标品和标准 3C 商品不一样人参可以按「棵」卖也可以按「盒」卖木耳有 250g 袋装也有散称斤价。所以价格必须绑定单位不能只在商品表里放一个 price 字段。CREATE TABLE product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL COMMENT 特产名称如长白山人参, category VARCHAR(50) NOT NULL COMMENT 分类滋补/山珍/果干/菌类, unit VARCHAR(20) NOT NULL COMMENT 计价单位袋/盒/斤/棵, price DECIMAL(10,2) NOT NULL COMMENT 当前售价单位由 unit 指定, stock INT NOT NULL DEFAULT 0 COMMENT 库存数量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, origin VARCHAR(100) COMMENT 产地如吉林抚松、黑龙江五常, cold_chain TINYINT NOT NULL DEFAULT 0 COMMENT 是否需冷链影响运费模板, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL COMMENT BCrypt 密文不存明文, nickname VARCHAR(50) ); CREATE TABLE t_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_sn VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号前端用, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL COMMENT 下单时快照总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), receiver_address VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user (user_id) ); CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL COMMENT 商品名冗余防止商品改名, price DECIMAL(10,2) NOT NULL COMMENT 成交价快照不受后续调价影响, quantity INT NOT NULL, unit VARCHAR(20) NOT NULL, INDEX idx_order (order_id) );商品表和订单明细表是这套系统的地基。product 管「现在有什么可卖」order_item 管「这笔订单成交时到底卖了什么」。两个表之间通过 product_id 关联但订单明细里的 product_name、price、unit 都是冗余快照不能再去 join product 表实时取。原因不难理解店铺三个月后把长白山人参从 168 涨到 198三个月前的订单金额不应该跟着变。任何销售系统的对账、退款、统计都必须以订单明细里的快照为准。2.2 MyBatis-Plus 实体类的字段映射怎么写数据访问层我一般直接选 MyBatis-Plus而不是 Spring Data JPA。原因很实际这个系统的查询大量是「商品名模糊搜 分类过滤 分页」这类动态条件MyBatis-Plus 的 LambdaQueryWrapper 可以让条件拼装留在 Java 代码里不写 XML分页插件也是内置的一个 Page 对象传进去就回来。JPA 在简单表关联上体验好但一旦要手写动态 SQL 或分页优化反而要绕 JPQL 和 Specification代码量并不少。Data TableName(product) public class Product { TableId(type IdType.AUTO) private Long id; private String name; private String category; private String unit; private BigDecimal price; private Integer stock; private Integer status; private String origin; private Boolean coldChain; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }说明几个关键点。TableName 指定表名解决类名和表名不一致的问题TableId 声明主键策略为数据库自增createTime 和 updateTime 用 TableField(fill ...) 配合 MetaObjectHandler 自动填充避免每个 insert 和 update 手动 set 时间。Boolean 类型对应数据库的 TINYINTMyBatis-Plus 会自动转换。这里有一点要特意避坑金额属性类型必须用 BigDecimal不能用 Double 或 Float。二进制浮点数在计算 0.1 0.2 时会出现精度误差订单金额算错一个分财务对账就是事故。2.3 容易被忽略的字段设计取舍下面这几个字段设计决策是「能跑」和「能上线」的分水岭。设计点推荐做法不推荐的做法和原因金额BigDecimal 数据库 DECIMAL(10,2)Double 存金额精度丢失累计对账对不上订单金额下单时计算并写入 t_order.total_amount前端传 totalAmount 到后端任何人都能改价格商品名/单价冗余到 order_item下单后商品改名或调价旧订单展示错乱库存单位product.unit 字段价格绑定单位只有 price 没有 unit一斤和一盒价格混在一起订单号order_sn 唯一索引约束无唯一约束前端双击按钮会生成两笔订单下架状态status 字段不做物理删除DELETE 删除会导致历史订单关联不到商品最后再提醒一个容易踩的点如果后续要支持「250g 装木耳」和「500g 装木耳」作为两个规格不要急着加规格表先在 product 表里用 name 区分比如「秋木耳 250g 袋装」。东北特产这个场景下规格数量有限一张商品表撑得住等真出现规格组合促销再拆 product_sku 也不迟。把表结构搞得太抽象反而是给第一个版本增加负担。3. SpringBoot 后端接口商品分页与订单生成的关键代码3.1 /api/product/page 的分页查询与关键词搜索用 IDEA 创建 Spring Boot 项目时依赖项选 Spring Web、MySQL Driver、Lombok 三个就够起步。MyBatis-Plus 需要手动引入 starter注意 Spring Boot 3.x 对应引入mybatis-plus-spring-boot3-starter如果用 2.7.x 就用传统 starter。很多新手遇到 springboot 版本太高导致依赖不兼容多半是没对上这个版本矩阵先降到主流教程常用的 2.7.x 反而更省事。分页接口是整站浏览量最大的接口首页、搜索页、分类页全走它。Controller 层只做参数接收业务逻辑全部下沉到 ServiceRestController RequestMapping(/api/product) public class ProductController { Autowired private ProductService productService; GetMapping(/page) public RIPageProduct page( RequestParam(defaultValue 1) long pageNum, RequestParam(defaultValue 10) long pageSize, RequestParam(required false) String keyword, RequestParam(required false) String category ) { PageProduct page new Page(pageNum, pageSize); LambdaQueryWrapperProduct wrapper Wrappers.lambdaQuery(); wrapper.eq(Product::getStatus, 1); wrapper.like(StringUtils.hasText(keyword), Product::getName, keyword); wrapper.eq(StringUtils.hasText(category), Product::getCategory, category); wrapper.orderByDesc(Product::getCreateTime); return R.ok(productService.page(page, wrapper)); } }逻辑说明pageNum 和 pageSize 从请求参数拿defaultValue1 和 10 保证缺省时也能正常分页keyword 用于商品名模糊查询category 用于分类过滤。LambdaQueryWrapper 的 eq 和 like 第一个参数是 boolean condition这里用 StringUtils.hasText 判断keyword 为空串时整条条件不参与 SQL 拼接避免出现WHERE name LIKE %%这种无效查询。最后按创建时间倒序让新上架的特产排前面。这里要提醒一个分页参数的边界问题pageSize 不能无限制放大。有人传 pageSize100000 直接拖垮数据库。生产环境里我一般会在 Service 层加pageSize Math.min(pageSize, 100)做兜底这行代码成本极低但能挡住最粗糙的滥用请求。3.2 创建订单的 Service 代码校验、落库、扣库存一事务订单生成是销售系统的核心事务必须保证「订单明细写入」「库存扣减」「金额计算」要么全部成功要么全部回滚。用 Transactional 把整个方法包起来事务边界就是方法边界。Service RequiredArgsConstructor public class OrderService { private final ProductMapper productMapper; private final TOrderMapper orderMapper; private final OrderItemMapper orderItemMapper; Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, ListCartItemDTO items) { TOrder order new TOrder(); order.setOrderSn(generateOrderSn()); order.setUserId(userId); order.setStatus(0); orderMapper.insert(order); BigDecimal total BigDecimal.ZERO; for (CartItemDTO item : items) { // 关键条件更新将“校验库存充足”和“扣减库存”合并为一次原子操作 int updated productMapper.deductStock(item.getProductId(), item.getQuantity()); if (updated 0) { throw new BusinessException(库存不足或商品已下架); } Product product productMapper.selectById(item.getProductId()); OrderItem orderItem new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setUnit(product.getUnit()); orderItemMapper.insert(orderItem); total total.add(product.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } order.setTotalAmount(total); orderMapper.updateById(order); return order.getId(); } private String generateOrderSn() { return SN System.currentTimeMillis() ThreadLocalRandom.current().nextInt(1000, 9999); } }逻辑说明先插订单主表拿到自增 id再循环处理每个商品。每次循环先扣库存扣减影响的函数返回值是 0 说明条件不满足直接抛 BusinessException 让事务回滚已经扣掉的其他商品库存也会一并恢复。最后把计算出的 totalAmount 写回订单保证订单金额是服务端算出来的而不是前端传过来的。generateOrderSn 用时间戳加随机数保证不冲突同时 t_order 表对 order_sn 有唯一索引双保险防重复下单。3.3 库存扣减为什么必须用条件更新这是整个 springboot 项目里最容易在面试中被追问的细节库存超卖怎么解决。最朴素的写法是先 select 查库存Java 里判断 stock quantity再 update 减库存。这个写法在单线程测试时没问题但两个请求同时查到 stock1同时通过校验各自执行 update最后库存变成 -1超卖就发生了。Update(UPDATE product SET stock stock - #{count} WHERE id #{id} AND stock #{count} AND status 1) int deductStock(Param(id) Long id, Param(count) Integer count);这段 SQL 把「查库存、校验、扣减」压成一条 UPDATE。数据库的行锁保证同一时刻只有一个事务能更新这一行的 stock所以不会出现扣成负数的情况。affected rows 返回 0 就说明要么库存不够要么商品已下架。代码里stock stock - #{count}是原地计算不需要先查出旧值再 set条件里status 1顺便把下架商品挡在门外。有同学会问能不能用乐观锁 version 字段也可以但那是为「更新不频繁、冲突少」的场景准备的。电商下单是典型的高冲突写操作用乐观锁会导致大量请求因 version 不一致而失败重试用户看到的就是「下单失败请重试」。条件更新是此处侵入性最小、效率最高的方案。至于分布式锁单库单应用用不上等真到了多实例部署再考虑也不迟。4. Vue 前端把特产列表、购物车和下单流程串起来4.1 用 Vite 创建 Vue3 项目并安装依赖Vue 侧的工程化现在统一走 Vite创建项目只需要一条命令。Node 环境建议 18 以上太低版本安装依赖时容易报错这也是 vue 安装及环境配置里最常见的问题来源。npm create vitelatest speciality-shop -- --template vue cd speciality-shop npm install npm install axios vue-router4 pinia说明create vite 生成的是 Vue3 Vite 的基础骨架只带一个 HelloWorld 组件。vue-router 用第 4 版配合 Vue3 的 createRouter APIpinia 是 Vue3 官方推荐的状态管理库比 Vuex 的 mutations 写法简洁购物车这种跨页面共享数据用它最合适。axios 负责请求后端接口。安装完依赖后把 src 下按 api、stores、views 三个目录整理别把接口请求写在组件里不然页面一多维护成本直接翻倍。4.2 axios 封装统一 baseURL 和错误提示前后端分离后前端所有请求都走 axios 实例不要在组件里一个个axios.get(url)。统一封装的意义在于接口地址改一处就好后端返回的 code 非 0 时可以集中弹错误提示登录过期可以在这里统一跳转。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || http://localhost:8080/api, timeout: 10000 }) request.interceptors.response.use( response response.data, error { ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default request逻辑说明baseURL 从环境变量VITE_API_BASE_URL读取本地开发没有配置时默认指向http://localhost:8080/api后端 Controller 的 RequestMapping 恰好是 /api 开头路径直接衔接。响应拦截器里把 response.data 直接返回给业务代码组件里拿到的就是后端统一的{ code, message, data }结构不用每次.then(res res.data.data)。跨域问题在后端解决Spring Boot 里加一个 CorsFilter 配置允许本地前端端口访问即可不需要在前端做额外的处理。4.3 商品列表页与 Pinia 购物车状态商品列表页是用户进入系统看到的第一个页面。用 Pinia 管理购物车加购、改数量、清空这些操作都不需要和后端交互商品信息在点击加购那一刻就快照进了前端状态里。import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ items: JSON.parse(localStorage.getItem(cart) || []) }), actions: { add(product) { const found this.items.find(i i.productId product.id) if (found) { found.quantity } else { this.items.push({ productId: product.id, name: product.name, price: product.price, unit: product.unit, quantity: 1 }) } this.save() }, save() { localStorage.setItem(cart, JSON.stringify(this.items)) }, clear() { this.items [] this.save() } } })购物车 items 在 state 初始化时直接从 localStorage 读取刷新页面购物车不丢。add 方法先按 productId 找有没有已加购的同款商品存在就数量加一不存在就 push 新条目最后 save 持久化。price 和 name 在这里也做了快照购物车阶段显示的是加购时价格真正下单金额以第 3 章的后端计算为准前端购物车金额只是展示。商品卡片组件的核心逻辑很短script setup import { ref, onMounted } from vue import { useCartStore } from ../stores/cart import { getProductPage } from ../api/product const store useCartStore() const products ref([]) async function load() { const res await getProductPage({ pageNum: 1, pageSize: 12, keyword: }) products.value res.data.records } function add(item) { store.add(item) } onMounted(load) /script template div classproduct-grid div v-foritem in products :keyitem.id classproduct-card h3{{ item.name }}/h3 p产地{{ item.origin }}单位{{ item.unit }}/p p classprice{{ item.price }} / {{ item.unit }}/p button clickadd(item)加入购物车/button /div /div /templateload 函数调用 api/product 模块里的 getProductPage把返回的 records 绑定到 products 数组onMounted 在组件挂载后自动加载数据。加入购物车按钮直接把整个 item 对象传给 store.addstore 内部自己管理快照和 LocalStorage。4.4 提交订单与 vue 路由参数跳转购物车页点「去结算」前端只做两件事把购物车数据组装成后端约定的 CartItemDTO 数组调创建订单接口成功后清空购物车并跳转到订单页。import { useRouter } from vue-router const router useRouter() async function submitOrder() { const items store.items.map(i ({ productId: i.productId, quantity: i.quantity })) const res await createOrder({ userId: 1, items }) store.clear() router.push({ path: /orders, query: { orderSn: res.data.orderSn } }) }提交时只传 productId 和 quantity商品价格由后端从数据库读取并计算前端不传价格字段。后端返回的 orderSn 是业务订单号通过 vue 路由参数 query 传到订单详情页。订单页在 onMounted 里通过route.query.orderSn取参数再调查询接口展示支付状态和物流信息。要注意query 参数在刷新后依然保留在 URL 上比用 Pinia 传参会更稳这也是我通常推荐用 query 传订单号而不是存 store 的原因。前端项目打包后如果出现布局异常或图片 404先检查vite.config.js里的 base 配置是否相对路径以及后端是否把 dist 目录配成了静态资源映射这两个点是 vue 打包后布局异常的高发原因。5. 订单状态流转的校验边界与手动验证5.1 状态机的合法流转表订单状态是销售系统里最容易出逻辑漏洞的地方。很多实现只在 controller 层调 update 语句改 status状态想怎么跳就怎么跳结果出现「已取消的订单发货了」「未支付订单显示已完成」这类数据事故。正确做法是先定义一张合法流转表再在 Service 层强制校验。当前状态允许执行的操作目标状态0 待支付用户支付 / 用户取消1 已支付 / 4 已取消1 已支付管理员发货 / 用户退款2 已发货 / 4 已取消2 已发货用户确认收货3 已完成3 已完成 / 4 已取消不允许任何操作终态流转表看起来简单但它是订单模块的需求说明书。支付回调、取消订单、发货操作、售后流程全都围着这张表转。后端实现时把表抽象成 Map代码里直接查字典判断比散落的 if else 清晰得多。5.2 状态机校验代码private static final MapInteger, SetInteger ALLOWED_TRANSITIONS Map.of( 0, Set.of(1, 4), 1, Set.of(2, 4), 2, Set.of(3) ); Transactional(rollbackFor Exception.class) public void transition(Long orderId, int targetStatus) { TOrder order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } SetInteger allowed ALLOWED_TRANSITIONS.get(order.getStatus()); if (allowed null || !allowed.contains(targetStatus)) { throw new BusinessException( 非法状态流转 order.getStatus() - targetStatus); } order.setStatus(targetStatus); orderMapper.updateById(order); }这段逻辑的核心是 ALLOWED_TRANSITIONSkey 是当前状态value 是允许跳转到的状态集合。待支付订单只能去已支付或已取消已发货订单只能去已完成。任何不在集合里的目标状态直接抛异常。这套校验同样覆盖了一个业务场景支付回调如果因为网络延迟重复推送第二次回调时订单已经是已支付再往已支付跳就是非法流转直接拒绝。这么做比用状态字段加唯一约束更贴合业务。5.3 用 curl 手动验证一条完整交易链路写完接口不要着急写页面联调先用 curl 把后端接口链跑通能省大量排查时间。数据库已经有测试数据的前提下按下面顺序执行# 1. 搜索商品确认人参商品 id curl http://localhost:8080/api/product/page?pageNum1pageSize5keyword%E4%BA%BA%E5%8F%82 # 2. 创建订单传商品 id 和数量 curl -X POST http://localhost:8080/api/order/create \ -H Content-Type: application/json \ -d {userId:1,items:[{productId:3,quantity:2}]} # 3. 支付该订单模拟用户点击付款 curl -X POST http://localhost:8080/api/order/pay \ -H Content-Type: application/json \ -d {orderId:1} # 4. 尝试把待支付订单直接发货应该返回非法状态流转 curl -X POST http://localhost:8080/api/order/ship \ -H Content-Type: application/json \ -d {orderId:1}第 4 步是关键验证新建的订单处于 0 待支付直接调 ship 接口会命中 ALLOWED_TRANSITIONS 校验返回「非法状态流转0 - 2」证明状态机在正常工作。如果这里返回了成功说明状态校验没生效要回头检查代码。手动验证时最省事的做法是把这张流转表直接写成单元测试的参数化用例每个状态跳转组合断言成功或抛异常比每次手工刷新页面更能抓住回归问题。本文还有配套的精品资源点击获取