“旧时光咖啡厅管理系统”这个项目,就是那种典型的Spring Boot + Vue前后端分离后台管理系统——但在开发层面又比纯教学项目多了一层“真实门店业务”的味道。刚拿到这个需求的时候,我的第一反应是:这不就是一个带界面的CRUD吗?真正动手做下来才发现,难点全在订单流程、会员积分、库存联动这些业务细节上。这套系统的核心不是“管理数据”,而是把咖啡厅每天发生的那点事——点单、结账、进货、算账——全部搬到线上,让老板不用再靠Excel和纸质小票过日子。
如果你是正在做Java课程设计、毕业设计,或者打算用一套完整技术栈练手的同学,这篇内容会非常有参考价值。我会从需求拆分、数据库设计、后端业务模块、前端页面搭建,到部署上线和常见坑点,完完整整讲一遍。文中用到的是我实际测试过的一组稳定组合:Spring Boot 2.7 + MyBatis-Plus + MySQL 5.7 + Vue 2 + Element UI,这套组合基本覆盖了目前市面上大多数中小型管理系统的真实形态。
1. 项目整体设计与技术选型
1.1 系统定位与核心需求拆解
“旧时光”是一家开在老街区的咖啡厅,主打安静氛围和手冲咖啡。老板最头疼的事大概有这几件:客人到店后靠纸笔点单,高峰期容易漏记错记;会员信息和充值记录全在Excel里,月底对账眼睛都快看花;库存全凭感觉,到了周末才发现牛奶不够用;每天卖了多少、哪个品类最受欢迎,只能靠一张张翻小票统计。
所以这个系统的定位不是简单的一个“后台增删改查”,而是围绕门店日常运营的轻量级管理系统。目标用户主要有两类:店长(admin)和店员(staff)。店长可以管理员工、查看统计报表、调整商品库存;店员负责日常点单、结账、会员登记。从功能模块的拆分来看,我最终确定了这些核心模块:
- 登录与权限模块:JWT登录、区分admin/staff角色。
- 员工管理模块:新增、禁用员工账号。
- 商品分类与商品管理:咖啡、茶饮、甜品、轻食等分类,商品上下架、价格调整。
- 桌台管理:模拟门店桌号,配合点单流程。
- 点单与订单管理:选桌台、选商品、生成订单、结账、查看历史订单。
- 会员管理:注册会员、充值、积分记录、会员价。
- 库存管理:入库、出库,与商品销售联动扣减。
- 销售统计:按天、按周的营业额统计,品类占比。
这里有一个值得注意的设计判断:我把“点单”和“管理后台”放在同一个系统里,而不是做成两个独立系统。原因是咖啡厅规模不大,一套系统里用菜单区分入口,开发成本最低,老板也只用维护一套账号密码。如果你的场景是连锁门店,那才需要考虑独立的点单端和总店管理端。
1.2 为什么选Spring Boot + Vue而不是其他方案
做系统选型,最重要的不是追新,而是看团队的技术掌控力和项目规模。我当时为什么没有用传统的JSP+Servlet单体方案?最核心的原因是前后端分离后,改前端页面不再需要重启后端服务,联调效率高很多。像这种管理后台,界面改动非常频繁,今天老板说“列表要多一列”,明天说“按钮换个颜色”,前后端分离模式下前端用热更新,秒级生效,体验完全不同。
Spring Boot的优势在于“开箱即用”。内嵌Tomcat、自动配置、起步依赖,一个mvn spring-boot:run就能把服务跑起来。对比早年Spring MVC那套繁琐的XML配置,Spring Boot把复杂度吃掉了不少,让开发者能更专注于业务代码。对课程设计或者小团队来说,这是性价比极高的选择。
Vue这边,我用的是Vue 2 + Element UI。你可能会问,现在Vue 3都这么成熟了,为什么还用Vue 2?我的理由很实在:Element UI生态稳定,网上的踩坑案例最多,遇到问题一搜就有答案;而且Vue 2的API对新手更友好,Options API写起来直白。如果你的项目是新起盘且团队没人用过Vue 2,直接上Vue 3 + Element Plus也没问题,只是部分组件用法略有差异,整体思路完全一致。
顺便说下为什么没上微服务、也没用若依那类脚手架。这个系统的并发量就是一家店几十个人同时下单的水平,单机MySQL加一台服务器绰绰有余,微服务纯属给自己找麻烦。至于脚手架,它能让你快速出一个后台,但如果你是为了学习或者答辩,建议至少把登录鉴权、订单主流程自己写一遍,不然面试官问你JWT怎么实现的,你什么都答不上来。
2. 数据库设计与核心模块实现
2.1 核心表结构设计
数据库设计是整个项目的地基。我在设计表结构上花了一天时间,后面写代码明显顺很多。这个系统我最终设计了8张核心表,下面用表格列一下:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 员工账号(店长/店员) | id, username, password, real_name, role, status, create_time |
| product_category | 商品分类 | id, name, sort, status |
| product | 商品 | id, category_id, name, price, cost_price, stock, image, status, sales_count |
| table_info | 桌台 | id, table_no, seats, status(空闲/使用中) |
| orders | 订单主表 | id, order_no, table_id, member_id, total_amount, discount_amount, actual_amount, pay_type, status, create_time |
| order_item | 订单明细 | id, order_id, product_id, product_name, price, quantity, subtotal |
| member | 会员 | id, phone, name, balance, points, level, create_time |
| recharge_record | 充值记录 | id, member_id, amount, gift_amount, create_time |
| inventory_log | 库存变动日志 | id, product_id, change_type(in/out/sale), change_qty, remark, create_time |
有几个设计要点需要展开讲。首先是订单主表和明细表为什么要分开?这是典型的“一父多子”关系:一个订单对应多个商品。如果不拆表,一个订单如果有三个商品,就要在orders表里插三行,那订单号、会员、金额都会被重复存三次,不仅冗余,而且没法在订单级别做扩展(比如一次订单统一用一个备注)。拆开后,orders存订单级信息,order_item存每个商品的快照,逻辑非常清晰。
第二个要点:订单明细里为什么把product_name和price冗余存储,而不是通过product_id去关联商品表查询?因为商品价格和名称会变。今天这个咖啡卖28,明天搞活动卖20,如果订单明细只存product_id,一个月后你查历史订单,显示的会是当前价格,而不是顾客当时实际支付的价格。这种“历史快照”的思想在很多业务系统里都用得到,比如电商订单快照、审批流快照。
第三个要点:金额字段必须用decimal(10,2),不要用double或float。浮点数在计算机里是二进制近似的,0.1 + 0.2 不等于 0.3,这在货币计算里是绝对不能接受的。decimal虽然性能略低,但胜在精确。另外,订单金额相关字段建议都带默认值0,避免空指针。
还有一点,时间字段统一用datetime。字符类型时间戳虽然也能存日期,但没法直接做SQL排序和范围查询,维护起来很反人类。MySQL连接串记得加上serverTimezone=Asia/Shanghai,否则你会被时区问题虐一遍(后面我会专门讲)。
2.2 后端分层架构与关键代码实现
后端我采用的是经典的controller -> service -> mapper三层结构,再加一层VO/DTO做数据隔离。所谓VO就是给前端看的对象,比如登录接口返回的userInfo里绝对不能有password字段,这一步可以在Service里手动set,也可以用BeanUtils.copyProperties处理,但注意别把敏感字段拷进去了。
依赖清单里,几个核心依赖如下:
- spring-boot-starter-web:Web基础
- mybatis-plus-boot-starter:MyBatis-Plus,单表CRUD不用写SQL
- mysql-connector-java:MySQL驱动
- lombok:简化实体类getter/setter
- jjwt(io.jsonwebtoken):JWT生成与解析
- hutool-all:工具库,处理字符串、日期、Excel导出都很方便
- spring-boot-starter-validation:参数校验
我用一个实际例子来讲清楚后端核心业务是怎么实现的。以“创建订单”为例,这个操作涉及多个表,必须放在一个事务里。我贴一段简化后的核心逻辑:
@Service public class OrderServiceImpl extends ServiceImpl<OrderMapper, Orders> implements OrderService { @Override @Transactional(rollbackFor = Exception.class) public Result createOrder(OrderCreateDTO dto) { // 1. 校验桌台和商品 TableInfo table = tableService.getById(dto.getTableId()); if (table == null || !"空闲".equals(table.getStatus())) { return Result.error("桌台不可用"); } List<OrderItemCreateDTO> items = dto.getItems(); if (items == null || items.isEmpty()) { return Result.error("订单商品不能为空"); } // 2. 计算金额 BigDecimal totalAmount = BigDecimal.ZERO; for (OrderItemCreateDTO item : items) { Product product = productService.getById(item.getProductId()); if (product == null || product.getStatus() != 1) { return Result.error("商品不存在或已下架:" + item.getProductId()); } totalAmount = totalAmount.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 生成订单号并保存主表 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setTableId(dto.getTableId()); order.setMemberId(dto.getMemberId()); order.setTotalAmount(totalAmount); order.setDiscountAmount(BigDecimal.ZERO); order.setActualAmount(totalAmount); order.setStatus(1); // 1待支付 2已支付 3已退款 order.setCreateTime(LocalDateTime.now()); this.save(order); // 4. 保存明细并扣减库存 for (OrderItemCreateDTO item : dto.getItems()) { Product product = productService.getById(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.setSubtotal(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemService.save(orderItem); // 扣减库存 product.setStock(product.getStock() - item.getQuantity()); if (product.getStock() < 0) { throw new RuntimeException("库存不足:" + product.getName()); } productService.updateById(product); } // 5. 占用桌台 table.setStatus("使用中"); tableService.updateById(table); return Result.success(order.getId()); } }这段代码充分体现了事务的意义。如果第4步扣库存失败抛出异常,第3步保存的订单主表必须回滚,否则就会出现“有订单明细但没扣库存”的脏数据。@Transactional(rollbackFor = Exception.class)里rollbackFor=Exception.class很关键,因为Spring默认只对运行时异常回滚,如果你抛的是自定义的受检异常,不加这个参数很可能不会回滚,这就是一个典型的坑点。
另外要注意一个工程量问题:很多人喜欢把业务逻辑全写在Controller里,Controller里直接调Mapper。小项目当时看着爽,一旦订单、会员、库存之间有关联,你就会陷入“一个方法里嵌套五个Mapper”的泥潭。我的建议是哪怕项目再小,也要保持Controller薄、Service厚的习惯。Controller只做参数接收和结果返回,Service里放业务规则,这样维护和答辩讲逻辑都会清晰很多。
2.3 登录鉴权与权限控制的落地细节
登录鉴权我选的是JWT方案。为什么不用Session?因为前后端分离之后,前端可能部署在Nginx,后端跑在8080端口,Session依赖Cookie,跨域环境下Cookie的维护是最麻烦的一环。JWT把用户身份信息(比如用户ID、角色)加密成一个token字符串,前端放在请求头Authorization里,后端拦截器每次校验token解密,不依赖服务端存储,天然适合这种架构。
我的实现是这样组织的:
- 登录接口:用户输入用户名密码,我用BCrypt加密存储的password去校验,比对成功生成token。
- 全局过滤器:写一个JwtAuthenticationFilter继承OncePerRequestFilter,放行/login和静态资源,其余请求都从Authorization头里取token并解析,把用户信息放到ThreadLocal或Request域中。
- 角色控制:sys_user表里有role字段,admin和staff。JWT里也会带role,但真正做权限判断时,要以数据库实时状态为准,不能盲信token里的声明,因为用户可能在登录后被禁用。
密码加密一定要用BCrypt,而不是MD5。MD5已经被彩虹表打烂了,同样的密码加密结果永远相同,毫无安全性可言。BCrypt每次加密结果都带随机盐,同一个“123456”加密出来的字符串都不一样,而且自带强度参数,暴力破解成本极高。Spring Security里自带BCryptPasswordEncoder,但如果你不想引入整个Spring Security,单独引一个spring-security-crypto依赖,也能用这个工具类。
还有一个容易忽视的权限问题:前端菜单要按角色显示,但后端接口必须也做校验。比如店员角色不应该能调用员工管理的接口。如果只藏前端按钮,那你就是给黑客留了一扇门。最简单的做法是在后端的员工管理Controller上加角色校验,所有需要店长权限的接口,统一走一个注解或拦截器判断,避免每个方法都写一遍if。
3. 前端页面搭建与接口联调
3.1 Vue项目初始化与工程化配置
前端我用的是Vue 2 + Element UI,脚手架创建项目很简单。装依赖的时候有个小建议:npm install如果特别慢或者报错,可以切换淘宝镜像源,但不能只图快,注意要看版本兼容性。Element UI要装2.x版本,配Vue 2;如果你装了Element Plus,那就要Vue 3,混用基本跑不起来。
工程目录我做了这样的划分:
src/ ├── api/ // 每个模块的接口请求封装 │ ├── auth.js │ ├── product.js │ └── order.js ├── assets/ ├── components/ // 公共组件 ├── router/ // 路由配置 ├── store/ // Vuex状态管理 ├── utils/ │ └── request.js // axios封装 ├── views/ │ ├── Login.vue │ ├── Dashboard.vue │ ├── ProductList.vue │ ├── OrderPage.vue │ └── Statistics.vue └── main.js这里核心是把axios封装好。我在utils/request.js里做了两件事:请求拦截器自动加上Authorization头,从本地存储里取出token注入进去;响应拦截器统一处理返回结构。代码大致这样:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => Promise.reject(error)) service.interceptors.response.use( response => { const res = response.data // 后端统一返回 { code, msg, data } if (res.code !== 200) { Message.error(res.msg || '请求错误') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.msg)) } return res }, error => { Message.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default service为什么一定要统一封装?因为如果不封装,每个页面都要自己判断接口返回状态、自己处理401跳登录,代码写到最后全是重复的逻辑,而且一旦后端改了提示语格式,前端几十个文件你要逐个改。封装之后,业务页面只需要拿到res.data直接渲染就行。
3.2 核心页面实现思路与关键代码
整个系统里我花时间最多的页面其实是点单页。咖啡厅使用频率最高的就是它,柜台点单、服务员下单、确认结账,都要在同一个页面完成。页面的布局是左右结构:左侧按分类展示商品卡片,点击或点击“+”加入购物车;右侧是当前订单区,展示已选商品、数量、小计,底部是桌台选择和结账按钮。
点单页的购物车状态管理要重点设计。我用Vuex存了一个cart数组,每个元素是{productId, name, price, quantity},对应后端订单明细DTO。加入商品时判断是否已存在,存在就加数量,不存在就push一条。这个逻辑看起来简单,但涉及到对象引用和响应式更新,如果直接用this.cart.push可能没问题,但如果直接修改数组里某个对象的属性,Vue 2的响应式系统可能检测不到,这时候需要用Vue.set或者整体替换数组。这是Vue 2一个非常经典的坑。
结账逻辑的前端代码如下:
methods: { async submitOrder() { if (!this.currentTableId) { this.$message.warning('请先选择桌台') return } if (this.cart.length === 0) { this.$message.warning('购物车不能为空') return } const payload = { tableId: this.currentTableId, memberId: this.currentMemberId || null, items: this.cart.map(item => ({ productId: item.productId, quantity: item.quantity })) } const res = await createOrder(payload) if (res.code === 200) { this.$message.success('下单成功') this.cart = [] this.loadTables() this.loadTodayOrders() } } }商品管理页相对简单,就是Element UI的el-table展示商品列表,配合el-dialog弹窗表单做新增和编辑。如果商品有图片,上传图片这块要注意:图片不能传给后端把二进制内容存进MySQL,那会把数据库撑爆。正确做法是把图片上传到服务器指定的upload目录,数据库只存文件路径(比如/upload/coffee1.jpg),前端直接用这个相对路径拼接完整URL展示。
统计报表页是店长角色最看重的页面。我用ECharts做了一个近7天营业额折线图和一个品类销售占比饼图。后端提供一个统计接口,返回最近7天的日期和营业额;前端拿到数据塞进ECharts配置。这里有一个数据处理技巧:后端返回的日期格式是2024-05-20T10:30:00这种带T的ISO格式,前端要展示成“05/20”,需要在JavaScript里做格式化,否则界面会很难看。
3.3 前后端联调与跨域处理
开发阶段最烦的问题就是跨域。前端跑在本地8080(Vue CLI默认端口),后端跑在9090,不同端口就算跨域。我的解决办法是在vue.config.js里配置代理:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样做的好处是开发时前端页面请求的地址是/api/login,代理服务器会把请求转发到后端的/login,浏览器看到的始终是同源的8080端口,自然没有跨域问题。
生产环境怎么办?用Nginx做反向代理。前端打包后的dist目录放到Nginx的html目录,后端jar包跑在9090,Nginx配置里把/api开头的请求转发到后端。核心配置如下:
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9090/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里try_files那一行非常重要。Vue Router默认是history模式,路由路径比如/admin/product,刷新页面时Nginx会去服务器找这个路径对应的文件,找不到就404。try_files会把请求回退到index.html,由前端路由重新解析,问题才能解决。如果你不想配Nginx也行,把Vue Router改成hash模式,URL里带个#号,刷新也没问题,但界面不太好看。
联调过程中还有一个容易被忽略的坑:日期格式。后端返回的LocalDateTime默认序列化成“2024-05-20T14:30:00”,中间有个T。前端要么手动replace掉,写正则replace('T',' '),要么后端加一个Jackson配置,全局把LocalDateTime序列化成“yyyy-MM-dd HH:mm:ss”。我建议在application.yml里配置spring.jackson.date-format和time-zone,一劳永逸。
4. 系统安全、性能优化与常见问题排查
4.1 安全防护与基础性能优化
一个管理系统挂在公网上,最基本的安全措施不能省。我的做法是三层防护。
第一层,SQL注入防护。MyBatis-Plus的Wrapper在绝大多数情况下都用参数绑定方式生成SQL,基本免疫SQL注入。但如果你写了原生XML的SQL,一定要用#{}而不是${},${}是字符串拼接,用户输入拼进SQL就直接完蛋。比如按关键字搜索商品,order by这种动态字段确实只能用${},这时要校验字段名是否在白名单里,比如只允许列表里的固定字段名传入。
第二层,XSS过滤。用户输入的商品名称、备注,都可能携带