温馨提示:本人主页置顶文章(点我)开头有 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 <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(int code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } } @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result<Void> handleBiz(BizException e) { return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<Void> 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<Long, BigDecimal> calculateMaterials(Long productId, int quantity) { Recipe recipe = recipeMapper.selectLatestByProductId(productId); if (recipe == null) { throw new BizException(4001, "该商品尚未配置配方"); } List<RecipeItem> items = recipeItemMapper.selectByRecipeId(recipe.getId()); Map<Long, BigDecimal> need = new HashMap<>(); 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<Long, BigDecimal> need) { for (Map.Entry<Long, BigDecimal> entry : need.entrySet()) { Material material = materialMapper.selectById(entry.getKey()); if (material == null || material.getStock().compareTo(entry.getValue()) < 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&lt;Long, BigDecimal&gt; need = recipeService.calculateMaterials( item.getProductId(), item.getQuantity()); recipeService.checkStock(need); for (Map.Entry&lt;Long, BigDecimal&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 && 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.js5.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 request5.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> <div class="product-list"> <div v-for="p in currentProducts" :key="p.id" class="product-card"> <img :src="p.imageUrl" :alt="p.name" /> <div class="info"> <h4>{{ p.name }}</h4> <p class="price">¥{{ p.price }}</p> </div> <el-button type="primary" size="small" @click="openSpec(p)">选规格</el-button> </div> </div> <el-dialog v-model="specVisible" :title="currentProduct?.name" width="360px"> <el-form label-width="60px"> <el-form-item label="糖度"> <el-radio-group v-model="spec.sugar"> <el-radio value="无糖">无糖</el-radio> <el-radio value="少糖">少糖</el-radio> <el-radio value="标准糖">标准糖</el-radio> </el-radio-group> </el-form-item> <el-form-item label="冰量"> <el-radio-group v-model="spec.ice"> <el-radio value="去冰">去冰</el-radio> <el-radio value="少冰">少冰</el-radio> <el-radio value="正常冰">正常冰</el-radio> </el-radio-group> </el-form-item> </el-form> <template #footer> <el-button @click="specVisible = false">取消</el-button> <el-button type="primary" @click="confirmAdd">加入购物车</el-button> </template> </el-dialog> </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 配方编辑流程
- 选择目标商品,系统加载当前生效配方。
- 添加原料行,选择原料并填写单杯用量与单位。
- 保存时生成新版本配方,旧版本置为历史状态。
- 商品默认绑定最新版本,历史订单仍关联下单时的配方版本。
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 前后端分离为基础,重点解决了配方标准化与库存联动两个业务难点:通过配方版本化管理保证出品一致性,通过数据库条件更新实现库存扣减的并发安全。
后续可在此基础上扩展会员体系、优惠券、多门店管理与经营报表,逐步演进为完整的门店数字化解决方案。