☰
基于 Spring Boot + Vue3 的奶茶店自助点单与配方管理系统设计与实现
2026/10/29 1:52:55 网站建设 项目流程

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系名片!

一、项目背景与意义

奶茶行业近年来保持高速增长,门店数量快速扩张,但传统门店的点单与出品流程仍高度依赖人工:顾客排队点单、店员手写记录、配方靠记忆或纸质卡片、库存靠经验估算。这种模式在高峰期容易出现点单效率低、配方出错、原料浪费、数据无法沉淀等问题。

本项目的目标是构建一套奶茶店自助点单与配方管理系统,通过前后端分离架构,把「顾客自助点单」「门店订单处理」「配方标准化管理」「原料与库存联动」四个环节打通,帮助门店降低人力成本、提升出品一致性,并为后续的经营数据分析提供基础。

1.1 核心解决的问题

  • 点单效率:顾客通过移动端或门店终端自助下单,减少排队与人工录入。
  • 配方标准化:每款饮品绑定结构化配方,出品时按配方自动计算原料用量,避免「凭手感」导致口味不一致。
  • 库存联动:订单完成后按配方自动扣减原料库存,低于阈值时预警。
  • 数据可追溯:订单、配方、库存变更均有记录,便于复盘与对账。

二、技术选型与整体架构

2.1 技术栈

层次技术选型说明
前端框架Vue 3 + Vite组合式 API,构建速度快,开发体验好
UI 组件库Element Plus后台管理界面快速搭建
状态管理Pinia管理用户信息、购物车、订单状态
路由Vue Router 4前台点单与后台管理路由分离
后端框架Spring Boot 3.x自动装配、内嵌容器、生态完善
持久层MyBatis-Plus简化单表 CRUD,支持条件构造器
数据库MySQL 8.0存储订单、配方、原料等业务数据
缓存Redis缓存菜单、会话、库存扣减防并发
鉴权Spring Security + JWT前后端分离下的无状态认证
接口文档Knife4j / SpringDoc自动生成在线接口文档

2.2 系统架构

flowchart TD A[顾客端 Vue3 点单页] --> B[Nginx 静态资源] C[管理端 Vue3 后台] --> B B --> D[Spring Boot 应用] D --> E[Controller 层] E --> F[Service 层] F --> G[Mapper 层 MyBatis-Plus] G --> H[(MySQL)] F --> I[(Redis)] F --> J[配方计算引擎] J --> K[库存扣减服务]

整体采用前后端分离架构:前端通过 Nginx 部署静态资源,后端以 RESTful 接口提供服务,Redis 承担缓存与并发控制,配方计算与库存扣减在 Service 层内聚实现。

三、数据库设计

系统核心表围绕「商品—配方—原料—订单」四条主线设计,关键表结构如下。

3.1 商品与配方表

CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '饮品名称', category_id BIGINT NOT NULL COMMENT '分类ID', price DECIMAL(10,2) NOT NULL COMMENT '售价', image_url VARCHAR(255) COMMENT '图片地址', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE recipe ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '关联商品', version INT DEFAULT 1 COMMENT '配方版本', remark VARCHAR(255) COMMENT '配方说明', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE recipe_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, recipe_id BIGINT NOT NULL COMMENT '关联配方', material_id BIGINT NOT NULL COMMENT '关联原料', quantity DECIMAL(10,3) NOT NULL COMMENT '单杯用量', unit VARCHAR(16) NOT NULL COMMENT '单位: g/ml/个' );

3.2 原料与库存表

CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '原料名称', unit VARCHAR(16) NOT NULL COMMENT '计量单位', stock DECIMAL(12,3) DEFAULT 0 COMMENT '当前库存', warn_threshold DECIMAL(12,3) DEFAULT 0 COMMENT '预警阈值' ); CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_id BIGINT NOT NULL, change_amount DECIMAL(12,3) NOT NULL COMMENT '正数入库 负数出库', biz_type VARCHAR(32) NOT NULL COMMENT 'ORDER/REPLENISH/ADJUST', biz_id BIGINT COMMENT '关联业务ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

3.3 订单表

CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', user_id BIGINT COMMENT '下单用户', total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待支付 1制作中 2已完成 3已取消', pickup_type TINYINT DEFAULT 1 COMMENT '1自取 2堂食', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(64) NOT NULL COMMENT '下单快照', price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, sugar_level VARCHAR(16) COMMENT '糖度', ice_level VARCHAR(16) COMMENT '冰量', toppings VARCHAR(255) COMMENT '加料JSON' );

设计要点:订单明细保存商品名称与价格快照,避免商品改价后历史订单金额失真;配方采用版本号管理,商品可绑定最新版本配方,历史订单仍可追溯当时的配方版本。

四、后端核心功能实现

4.1 项目结构

milk-tea-server ├── src/main/java/com/example/milktea │ ├── common # 统一响应、异常、工具类 │ ├── config # Security、Redis、MyBatis-Plus 配置 │ ├── controller # 接口层 │ ├── service # 业务层 │ ├── mapper # 持久层 │ ├── entity # 实体类 │ └── dto # 请求/响应对象 └── src/main/resources ├── application.yml └── mapper # XML 映射文件

4.2 统一响应与全局异常处理

@Data public class Result<T> { private int code; private String message; private T data; public static &lt;T&gt; Result&lt;T&gt; success(T data) { Result&lt;T&gt; r = new Result&lt;&gt;(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static &lt;T&gt; Result&lt;T&gt; fail(int code, String message) { Result&lt;T&gt; r = new Result&lt;&gt;(); r.setCode(code); r.setMessage(message); return r; } } @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result&lt;Void&gt; handleBiz(BizException e) { return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result&lt;Void&gt; handleOther(Exception e) { log.error("系统异常", e); return Result.fail(500, "系统繁忙,请稍后重试"); } }

4.3 配方计算引擎

配方计算是系统的核心逻辑:下单时根据商品绑定的配方,计算所需原料用量,并在库存充足的前提下完成扣减。

@Service @RequiredArgsConstructor public class RecipeService { private final RecipeMapper recipeMapper; private final RecipeItemMapper recipeItemMapper; private final MaterialMapper materialMapper; /** 计算某商品指定数量所需的原料用量 */ public Map&lt;Long, BigDecimal&gt; calculateMaterials(Long productId, int quantity) { Recipe recipe = recipeMapper.selectLatestByProductId(productId); if (recipe == null) { throw new BizException(4001, "该商品尚未配置配方"); } List&lt;RecipeItem&gt; items = recipeItemMapper.selectByRecipeId(recipe.getId()); Map&lt;Long, BigDecimal&gt; need = new HashMap&lt;&gt;(); for (RecipeItem item : items) { BigDecimal total = item.getQuantity() .multiply(BigDecimal.valueOf(quantity)); need.merge(item.getMaterialId(), total, BigDecimal::add); } return need; } /** 校验库存是否充足 */ public void checkStock(Map&lt;Long, BigDecimal&gt; need) { for (Map.Entry&lt;Long, BigDecimal&gt; entry : need.entrySet()) { Material material = materialMapper.selectById(entry.getKey()); if (material == null || material.getStock().compareTo(entry.getValue()) &lt; 0) { throw new BizException(4002, "原料不足:" + (material == null ? entry.getKey() : material.getName())); } } } }

4.4 下单与库存扣减

下单流程需要保证「订单创建」与「库存扣减」的原子性,这里使用本地事务配合乐观锁更新库存。

@Service @RequiredArgsConstructor public class OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final RecipeService recipeService; private final MaterialMapper materialMapper; private final StockLogMapper stockLogMapper; @Transactional(rollbackFor = Exception.class) public String createOrder(OrderCreateDTO dto, Long userId) { String orderNo = "MT" + System.currentTimeMillis(); Orders order = new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(0); order.setPickupType(dto.getPickupType()); order.setTotalAmount(dto.getTotalAmount()); orderMapper.insert(order); for (OrderItemDTO item : dto.getItems()) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setProductId(item.getProductId()); oi.setProductName(item.getProductName()); oi.setPrice(item.getPrice()); oi.setQuantity(item.getQuantity()); oi.setSugarLevel(item.getSugarLevel()); oi.setIceLevel(item.getIceLevel()); orderItemMapper.insert(oi); Map&amp;lt;Long, BigDecimal&amp;gt; need = recipeService.calculateMaterials( item.getProductId(), item.getQuantity()); recipeService.checkStock(need); for (Map.Entry&amp;lt;Long, BigDecimal&amp;gt; e : need.entrySet()) { int rows = materialMapper.deductStock(e.getKey(), e.getValue()); if (rows == 0) { throw new BizException(4003, "库存扣减失败,请重试"); } StockLog log = new StockLog(); log.setMaterialId(e.getKey()); log.setChangeAmount(e.getValue().negate()); log.setBizType("ORDER"); log.setBizId(order.getId()); stockLogMapper.insert(log); } } return orderNo; } }
<update id="deductStock"> UPDATE material SET stock = stock - #{amount} WHERE id = #{materialId} AND stock >= #{amount} </update>

说明:WHERE stock >= #{amount}让数据库层面完成「判断 + 扣减」的原子操作,返回影响行数为 0 即代表库存不足,避免先查后改带来的并发超卖问题。

4.5 JWT 鉴权

@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null &amp;&amp; header.startsWith("Bearer ")) { String token = header.substring(7); if (jwtUtil.validate(token)) { String username = jwtUtil.getUsername(token); String role = jwtUtil.getRole(token); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))); SecurityContextHolder.getContext().setAuthentication(auth); } } chain.doFilter(request, response); } }

五、前端核心功能实现

5.1 项目结构

milk-tea-web ├── src │ ├── api # 接口封装 │ ├── assets # 静态资源 │ ├── components # 通用组件 │ ├── router # 路由配置 │ ├── stores # Pinia 状态 │ ├── views │ │ ├── client # 顾客点单端 │ │ └── admin # 后台管理端 │ └── main.js

5.2 请求封装

import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = Bearer ${userStore.token} } return config }) request.interceptors.response.use( res => { const { code, message, data } = res.data if (code === 200) return data ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) }, err => { if (err.response?.status === 401) { useUserStore().logout() window.location.href = '/login' } ElMessage.error(err.message) return Promise.reject(err) } ) export default request

5.3 购物车状态管理

import { defineStore } from 'pinia' import { computed, ref } from 'vue' export const useCartStore = defineStore('cart', () => { const items = ref([]) const totalCount = computed(() => items.value.reduce((sum, i) => sum + i.quantity, 0)) const totalAmount = computed(() => items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)) function addItem(product, options = {}) { const key = ${product.id}-${options.sugar || ''}-${options.ice || ''} const exist = items.value.find(i => i.key === key) if (exist) { exist.quantity++ } else { items.value.push({ key, productId: product.id, productName: product.name, price: product.price, quantity: 1, sugarLevel: options.sugar || '标准糖', iceLevel: options.ice || '正常冰' }) } } function clear() { items.value = [] } return { items, totalCount, totalAmount, addItem, clear } })

5.4 点单页核心交互

<template> <div class="menu"> <div class="category-tabs"> <span v-for="c in categories" :key="c.id" :class="{ active: c.id === activeCategory }" @click="activeCategory = c.id" >{{ c.name }}</span> </div> &lt;div class="product-list"&gt; &lt;div v-for="p in currentProducts" :key="p.id" class="product-card"&gt; &lt;img :src="p.imageUrl" :alt="p.name" /&gt; &lt;div class="info"&gt; &lt;h4&gt;{{ p.name }}&lt;/h4&gt; &lt;p class="price"&gt;¥{{ p.price }}&lt;/p&gt; &lt;/div&gt; &lt;el-button type="primary" size="small" @click="openSpec(p)"&gt;选规格&lt;/el-button&gt; &lt;/div&gt; &lt;/div&gt; &lt;el-dialog v-model="specVisible" :title="currentProduct?.name" width="360px"&gt; &lt;el-form label-width="60px"&gt; &lt;el-form-item label="糖度"&gt; &lt;el-radio-group v-model="spec.sugar"&gt; &lt;el-radio value="无糖"&gt;无糖&lt;/el-radio&gt; &lt;el-radio value="少糖"&gt;少糖&lt;/el-radio&gt; &lt;el-radio value="标准糖"&gt;标准糖&lt;/el-radio&gt; &lt;/el-radio-group&gt; &lt;/el-form-item&gt; &lt;el-form-item label="冰量"&gt; &lt;el-radio-group v-model="spec.ice"&gt; &lt;el-radio value="去冰"&gt;去冰&lt;/el-radio&gt; &lt;el-radio value="少冰"&gt;少冰&lt;/el-radio&gt; &lt;el-radio value="正常冰"&gt;正常冰&lt;/el-radio&gt; &lt;/el-radio-group&gt; &lt;/el-form-item&gt; &lt;/el-form&gt; &lt;template #footer&gt; &lt;el-button @click="specVisible = false"&gt;取消&lt;/el-button&gt; &lt;el-button type="primary" @click="confirmAdd"&gt;加入购物车&lt;/el-button&gt; &lt;/template&gt; &lt;/el-dialog&gt; </div> </template> <script setup> import { ref, computed } from 'vue' import { useCartStore } from '@/stores/cart' const cartStore = useCartStore() const categories = ref([]) const products = ref([]) const activeCategory = ref(null) const specVisible = ref(false) const currentProduct = ref(null) const spec = ref({ sugar: '标准糖', ice: '正常冰' }) const currentProducts = computed(() => products.value.filter(p => p.categoryId === activeCategory.value)) function openSpec(product) { currentProduct.value = product specVisible.value = true } function confirmAdd() { cartStore.addItem(currentProduct.value, spec.value) specVisible.value = false } </script>

六、配方管理模块设计

配方管理是区别于普通点单系统的关键模块,后台需要支持配方的新增、编辑、版本迭代与原料用量维护。

6.1 配方编辑流程

  1. 选择目标商品,系统加载当前生效配方。
  2. 添加原料行,选择原料并填写单杯用量与单位。
  3. 保存时生成新版本配方,旧版本置为历史状态。
  4. 商品默认绑定最新版本,历史订单仍关联下单时的配方版本。

6.2 配方保存接口

@PostMapping("/recipes") @PreAuthorize("hasRole('ADMIN')") public Result<Long> saveRecipe(@RequestBody @Valid RecipeSaveDTO dto) { return Result.success(recipeService.saveNewVersion(dto)); }
@Transactional(rollbackFor = Exception.class) public Long saveNewVersion(RecipeSaveDTO dto) { Integer maxVersion = recipeMapper.selectMaxVersion(dto.getProductId()); Recipe recipe = new Recipe(); recipe.setProductId(dto.getProductId()); recipe.setVersion(maxVersion == null ? 1 : maxVersion + 1); recipe.setRemark(dto.getRemark()); recipeMapper.insert(recipe); for (RecipeItemDTO item : dto.getItems()) { RecipeItem ri = new RecipeItem(); ri.setRecipeId(recipe.getId()); ri.setMaterialId(item.getMaterialId()); ri.setQuantity(item.getQuantity()); ri.setUnit(item.getUnit()); recipeItemMapper.insert(ri); } return recipe.getId(); }

七、系统测试与效果

7.1 测试要点

测试项测试方法预期结果
并发下单库存JMeter 100 并发下单同一商品库存不出现负数,超卖订单返回失败提示
配方计算准确性单杯用量 × 数量与手工计算比对结果一致,精度保留三位小数
权限控制普通用户访问后台接口返回 403,无法操作配方与库存
订单状态流转待支付 → 制作中 → 已完成状态按规则流转,非法跳转被拒绝

7.2 优化方向

  • 缓存热点菜单:菜单数据读多写少,使用 Redis 缓存并设置合理过期时间。
  • 库存预扣:高并发场景可引入 Redis 预扣 + 异步落库,进一步降低数据库压力。
  • 消息通知:订单状态变更通过 WebSocket 推送至顾客端,减少轮询。
  • 数据分析:基于订单与配方数据统计畅销商品、原料消耗趋势,辅助采购决策。

八、总结

本文围绕奶茶店自助点单与配方管理系统,完成了从需求分析、技术选型、数据库设计到核心功能实现的完整梳理。系统以 Spring Boot 3 + Vue 3 前后端分离为基础,重点解决了配方标准化与库存联动两个业务难点:通过配方版本化管理保证出品一致性,通过数据库条件更新实现库存扣减的并发安全。

后续可在此基础上扩展会员体系、优惠券、多门店管理与经营报表,逐步演进为完整的门店数字化解决方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询