简介:一套面向高校计算机专业期末大作业场景的校园奶茶店线上点单系统项目,基于Spring Boot与Vue技术栈,整合后端接口、前端页面、数据库脚本及课程报告文档。项目适合正在完成课程设计、毕业设计或需要项目实战练习的学生,可帮助理解前后端分离开发、点单流程设计与订单管理等核心环节,整体难度适中,代码结构清晰,适合作为全栈入门参考。资源包共2000个文件,包含1192个JS、204个CSS、150个JSON、143个Markdown、132个TypeScript、49个Java和16个Vue等类型,涵盖前端依赖与页面、后端业务代码、配置说明、数据库SQL脚本以及报告文档;其中Markdown文档便于阅读学习,SQL脚本可直接初始化数据,压缩包整体约17.02MB。目前已有20人学习使用。该设计曾获导师认可并给出98分评审成绩,源码经过本地编译和严格调试,下载后可直接运行;配套报告文档可辅助完成期末材料撰写,适合作为优质参考模板,能显著节省课程设计或大作业的搭建时间。
1. 校园奶茶店线上点单,这套 Springboot+Vue 项目到底解决什么问题?
期末接到奶茶店点单系统这种大作业,最让人头疼的不是功能多,而是“看起来简单,一写全是细节”。菜单要按分类展示,商品要能加进购物车,下单要扣库存,订单状态要跟着流程走,后端要能登录鉴权,前端还要把页面对接上——这一套要是全靠自己从头搭,光跨域和打包就能卡你一整天。拆完这套基于 Springboot+Vue 的校园奶茶店线上点单系统,我的体会是:它最值钱的地方不是某个算法或炫技功能,而是把数据库设计、后端接口、前端页面和报告文档怎么配套讲清楚了。数据库用 MySQL,后端走 Spring Boot 经典三层,前端用 Vue 做单页应用,覆盖面刚好卡在期末大作业最常考的区间。适合正在找 Java 期末大作业源码参考的同学,也适合想快速厘清前后端分离项目结构、准备把代码变成可部署产物的入门开发者。
2. 数据库先立住:七张表拆出点单系统的“骨架”
2.1 从用例反推表:用户端和管理端都要什么字段
点单系统看着功能不少,但把它拆成用例后,表结构就清晰了。用户端要选奶茶、加购物车、下订单、看订单状态;管理端要维护商品分类、商品上下架、订单处理、查看简单流水。围绕这两类操作,七张表基本是常见组合:category(分类)、product(商品)、user(用户)、cart(购物车)、orders(订单)、order_item(订单明细)、operation_log(操作日志)。
为什么要拆成七张而不是三张表硬塞?核心原因是“订单”和“订单明细”必须分开。一张订单里会包含多杯不同口味的奶茶,如果只在 orders 里存商品名称和数量,后续改价格、做退款、按商品统计销量都很麻烦。拆出 order_item 后,订单主表只负责金额、状态、支付时间这类汇总信息,订单明细表负责每一杯奶茶的快照。这里强调“快照”是因为商品价格随时会变,下单时刻的价格要被存进明细表,而不是下单后再去关联 product 表反查。
用户和管理端共用一个 user 表还是分开?我的做法是拆成 user 和 staff 两张。用户端表存微信昵称、手机号、头像等;员工表存后台账号、密码、角色。虽然期末阶段用一个表加 type 字段也能交差,但拆开后,前台用户注册和后台管理员登录的字段校验逻辑各管各的,报告文档里画角色权限也更好解释。
表结构定好后,典型字段如下:
- category:id、name、sort、status、create_time、update_time
- product:id、category_id、name、description、price、image、stock、status、create_time、update_time
- user:id、username、password、nickname、phone、avatar、create_time、update_time
- staff:id、username、password、real_name、role、status
- cart:id、user_id、product_id、quantity、create_time、update_time
- orders:id、order_no、user_id、total_amount、status、pay_time、remark、create_time
- order_item:id、order_id、product_id、product_name、product_image、price、quantity、subtotal
这个结构在答辩时常被问到的一句话是:“为什么订单明细里要冗余 product_name 和 product_image?”答案很简单:商品改名或删除后,历史订单仍然能显示下单时的奶茶名和图片,这就是冗余字段换业务稳定性。
2.2 一张能跑的建表脚本:字段类型、默认值和索引怎么定
直接看核心建表 SQL,下面的脚本覆盖 category、product、orders、order_item 四张表,是项目里最容易被问到的部分。
CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT '分类名称', sort INT NOT NULL DEFAULT 0 COMMENT '排序值', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_category_name (name) ) COMMENT '奶茶分类表'; CREATE TABLE product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL COMMENT '分类id', name VARCHAR(100) NOT NULL COMMENT '奶茶名称', description VARCHAR(255) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT '售价', image VARCHAR(255) DEFAULT NULL COMMENT '图片路径', stock INT NOT NULL DEFAULT 0 COMMENT '库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id) ) COMMENT '商品表'; CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单编号', user_id BIGINT NOT NULL COMMENT '下单用户id', total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2制作中 3已取餐 4已取消', pay_time DATETIME DEFAULT NULL, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) COMMENT '订单主表'; CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL COMMENT '订单id', product_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL COMMENT '商品快照名称', product_image VARCHAR(255) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT '下单时单价', quantity INT NOT NULL COMMENT '数量', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计', KEY idx_order (order_id) ) COMMENT '订单明细表';这段脚本里几个设计点值得对着报告文档聊:第一,库存是 INT,不是 DECIMAL,别跟着感觉写成浮点数;第二,金额用 DECIMAL(10,2),避免后端用 Double 算账出现 0.1 + 0.2 的精度问题;第三,order_no 建唯一索引,因为订单号要被接口查询、被用户凭单取餐,重复是事故;第四,order_item 的 price 是“下单时单价”,不是后一次查出来的商品现价,这是做历史订单统计的关键。
user、cart、operation_log 的建表逻辑同理,业务里常用的检索字段(如 user_id、product_id、order_id)都加上普通索引。字段类型要尽量“抠门”,因为期末大作业的电脑配置普遍不会太高。状态字段用 TINYINT 就够了,描述性长文本用 VARCHAR(255),不差那点空间,但要避免把整个页面介绍塞进单个字段的坏习惯。
2.3 预置数据:奶茶分类和商品数据的初始化姿势
表建完后,资源包里通常会给一份预置好的 SQL 数据。我一般会检查三个点:分类是否带正常排序值、商品是否每个都挂上了分类 id、图片字段是否填的是相对路径。常见做法是插入六到八个分类,比如“热销原味”“芝士奶盖”“鲜果茶”“冰淇淋”“小料”,每个分类下放三到五款商品,价格保留两位小数,库存初始给 50 到 200 之间的数。
预置数据时有一个容易翻车的地方:不要在 SQL 里写死图片完整 URL,比如 192.168.x.x 这种局域网地址。项目一旦换个网段,前端所有图片全部裂掉。正确姿势是存相对路径,比如 /upload/tea_milk_01.png,由后端通过接口或静态资源映射补全域名前缀。后续你会感谢这个习惯,因为答辩时换电脑连 WiFi,图片不会直接崩掉。
初始化数据还有一个容易被忽略的字段:product 的 status。预置时如果某些商品不想第一屏就露出,可以直接置为 0,前端列表接口只查 status=1,这样管理端上下架功能在演示时就有了数据基础。你不需要为了演示“下架”功能临时去库里改数据,这是很多新手答辩时卡壳的原因。
3. 后端 Spring Boot:接口分层、登录鉴权与下单事务的实现
3.1 三级结构:Controller / Service / Mapper 各管哪一段
Spring Boot 项目拿到手,不要急着看代码,先看包结构。常见做法是把它拆成三层:Controller 负责接收前端请求、做基础参数校验、返回统一结果;Service 负责业务规则,比如库存判断、订单状态流转、金额计算;Mapper(数据访问层)只做数据库增删改查。源码里的 controller、service、mapper、entity、vo、common 这些包名,基本就对应这个套路。
以“用户查看商品列表”为例,完整链路是:Vue 页面调用 /api/product/list 接口,进入 ProductController 的 list 方法;Controller 把查询条件交给 ProductService;Service 判断是否需要按分类过滤、是否需要排除下架商品,然后调用 ProductMapper 执行 SQL;结果回传后包装成统一响应体。这样的好处是答辩时导师问“如果我要加一个按价格排序,你改哪里”,你至少能说出要动 Service 和 Mapper,而不是把 SQL 散落在 Controller 和 Vue 请求里。
三层结构里最容易被新手写坏的是 Service 里嵌 Controller 返回值,比如让 Service 直接返回 Result 对象。短期能跑,但后续如果想写单元测试、做定时任务,整个逻辑就被绑死在 HTTP 层了。我拿到源码后通常会检查一个点:Service 里是否出现了 HttpServletRequest 或 Result 这类 HTTP 专属对象。如果出现了,说明分层不够干净,要把参数传进去、把业务结果抛出来,由 Controller 统一包装。
3.2 下单接口的核心代码:扣库存与订单明细怎么在一个事务里
下单是整个项目业务逻辑最集中、也是最常被答辩老师追问的地方。先看 Service 层的核心逻辑:
@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, String remark) { // 1. 查出用户购物车中勾选的商品 List<Cart> cartList = cartMapper.selectByUserId(userId); if (cartList.isEmpty()) { throw new ServiceException("购物车为空,不能下单"); } // 2. 遍历购物车,校验库存并组合订单明细快照 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (Cart cart : cartList) { Product product = productMapper.selectById(cart.getProductId()); if (product == null || product.getStatus() != 1) { throw new ServiceException("商品已下架:" + cart.getProductId()); } if (product.getStock() < cart.getQuantity()) { throw new ServiceException("库存不足:" + product.getName()); } BigDecimal subtotal = product.getPrice() .multiply(BigDecimal.valueOf(cart.getQuantity())); totalAmount = totalAmount.add(subtotal); OrderItem item = new OrderItem(); item.setOrderId(null); // 订单主表生成后回填 item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductImage(product.getImage()); item.setPrice(product.getPrice()); item.setQuantity(cart.getQuantity()); item.setSubtotal(subtotal); orderItems.add(item); } // 3. 生成订单主表 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(0); // 待支付 order.setRemark(remark); orderMapper.insert(order); // 4. 回填订单明细的订单id,批量插入 orderItems.forEach(item -> item.setOrderId(order.getId())); orderItemMapper.batchInsert(orderItems); // 5. 扣减库存并清空购物车 for (Cart cart : cartList) { productMapper.decreaseStock(cart.getProductId(), cart.getQuantity()); } cartMapper.deleteByUserId(userId); return order.getId(); }这里有几个必须讲清楚的关键点。第一,方法上加了@Transactional(rollbackFor = Exception.class),作用是让订单主表插入、明细插入、库存扣减这三个操作处于同一个数据库事务里。如果第 5 步扣库存失败,前面已插入的订单会自动回滚,不会出现“订单建了但库存没变”的数据不一致。第二,所有金额计算都用BigDecimal,在报表中不会出现浮点误差。第三,购物车数据在扣减库存之后清理,顺序很重要——先扣库存再清购物车,如果清购物车失败,事务回滚后库存还有恢复机会。
decreaseStock建议写成带条件的 SQL 更新,而不是先查后改:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这种写法在执行层面就拦住了超卖,避免两个用户同时下单时读到同一份库存。如果你在源码里只看到 select 再 update,那这段代码在并发场景下是脆弱的,答辩时建议主动提一句“我用了预扣减 SQL 避免超卖”。前端调用时,入参是用户 id、备注,购物车内容由后端自己查,而不是由前端传商品列表。原因是购物车数据以服务端为准,防止有人绕过前端直接传一个并不便宜的订单金额。
3.3 登录鉴权:JWT 的简单实现与答辩时的回答提纲
奶茶店点单系统权限划分很自然:用户端要登录后才能下单,管理端要登录后才能操作后台。源码里一般会用到 JWT 做鉴权。简单流程是:用户登录成功后,后端用密钥生成一个带着 userId 和 role 的 token,前端把 token 存到 localStorage;后续发起请求时在请求头里带上 Authorization;后端拦截器解析 token,再决定放行还是拒绝。
实现上分三步。第一步,生成 token:
public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .signWith(secretKey) .compact(); }第二步,写一个拦截器校验:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new ServiceException("未登录"); } try { Claims claims = Jwts.parserBuilder() .setSigningKey(secretKey).build() .parseClaimsJws(token.substring(7)).getBody(); request.setAttribute("userId", Long.parseLong(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; } catch (ExpiredJwtException e) { throw new ServiceException("登录过期"); } }第三步,在配置类里注册拦截器,并且把/api/user/login、/api/product/list这些公开接口排除掉。答辩时被问到“为什么不直接把用户 id 塞在请求参数里”,标准回答是:参数传 id 可以被伪造,JWT 有签名,篡改会立刻校验失败。另一个常被追问的问题是“token 过期时间设多长”,我习惯设两小时,管理端可以放宽到八小时。你不需要把 JWT 讲得特别深,只要说清生成、校验、过期三个闭环,已经比大部分只调接口的同学强了。
4. 前端常见问题排查:跨域、路由刷新404与图片回显
4.1 现象一:前端一直跨域,后端请求直接被拦住
现象:Vue 页面调用后端接口时,浏览器 console 报Access to XMLHttpRequest ... has been blocked by CORS policy,No 'Access-Control-Allow-Origin' header is present。
原因:前后端分离项目里,前端开发服务器(通常跑在 8080 端口)和后端 Spring Boot(通常跑在 8081 或 9090)端口不一致,浏览器基于同源策略拦截了跨源请求。这本身不是 Bug,是浏览器安全机制,但开发时必须解决。
解决:最省事的方式是让后端开启 CORS。在 Spring Boot 里创建一个配置类:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return new CorsFilter(source); } }同时前端不要写死完整域名,而是把 baseURL 配成/api,开发时靠 Vue 脚手架代理转发:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }这个组合是前后端联调最常见且不会翻车的配置。注意allowCredentials(true)和allowedOriginPattern("*")要配合使用,否则部分浏览器会报“cannot use wildcard with credentials”错误。
4.2 现象二:开发环境一切正常,打包部署后一刷新页面就 404
现象:本地npm run dev能正常跳转,前端打包后用nginx部署,首页能打开,但点进详情页后按 F5 刷新,页面变白,返回 404。
原因:Vue Router 默认用 history 模式,页面路由像是/detail/1。刷新时浏览器直接向服务器请求这个路径,而后端没有/detail/1对应的资源,自然 404。这和后端接口没配对无关,是服务端不知道要把请求回退到 index.html。
解决:如果用的是 Nginx,加一行 try_files:
location / { try_files $uri $uri/ /index.html; }如果按我后面第 5 章的做法把 Vue 打包后的 dist 放进 Spring Boot 的 static 目录,则要在 Spring Boot 里加一个手动回退逻辑,让所有非/api/的 GET 请求返回 index.html。教学里常见做法是:
@Controller public class WebController { @RequestMapping(value = {"/", "/detail/**", "/admin/**", "/order/**"}) public String index() { return "forward:/index.html"; } }不要小看这个 404,很多同学把前后端功能跑通后以为自己能交大作业了,结果部署完打开就白屏,最后只能改成 hash 路由。hash 路由虽然能用,但地址里会多个#,体验一般,答辩时也会被问一句“为什么不用 history”。所以我建议直接配置回退。
4.3 现象三:商品加载出来了,但图片碎了一地说
现象:首页商品列表有文字、价格、库存,但图片全部显示成破碎的小图标。F12 打开 Network 一看,图片请求地址是/upload/xxx.png,后端返回 404。
原因:图片没有随项目一起部署。开发时前端直接访问http://localhost:8081/upload/xxx.png能通,因为你可能把 upload 目录放在了后端本地磁盘,或者做了静态资源映射。打包部署后,upload 目录不在服务器上,自然 404。
解决:最直接的办法是让 Spring Boot 把本地磁盘目录暴露成静态资源。在application.yml里配置:
spring: web: resources: static-locations: classpath:/static/,file:${upload.dir}代码里通过System.getProperty("user.dir") + "/upload/"动态获取上传目录,避免硬编码D:/...。如果你只是做期末演示,更简单的方案是把图片放到前端项目本地的public/upload/下,打包后会被复制进 dist 根目录,图片路径写成相对路径./upload/xxx.png。答辩时导师大概率会问“图片是存在数据库吗”,这时解释清楚“库里面只存路径,实际文件在磁盘目录”是很加分的。
5. 把 Vue 打包放进 Spring Boot 的部署实践与答辩演示
5.1 前端 build 后放 static/,实现单个 jar 运行
期末大作业最体面的交付形态,是一个 Spring Boot 后端包着前端页面,最终打出一个可执行 jar,直接java -jar就能跑。这样导师拿到代码也不需要额外启动前端服务,一张localhost:8081就能看到完整效果。
先在前端目录执行构建:
npm run build构建完成后,dist 目录里会出现 index.html 和若干带 hash 的 js/css 文件。把整个 dist 目录里的内容复制到:
后端项目/src/main/resources/static/然后重新打包:
mvn clean package -DskipTests拿到target下的 jar 文件后,直接运行:
java -jar campus-milk-tea-0.0.1-SNAPSHOT.jar这里有个关键点:前端构建时接口地址要写成相对路径,或者统一以/api开头,让请求打到当前域名的 /api,再由后端把 /api 路由进 Controller。如果前端代码里的 baseURL 写的是http://localhost:8081,打包后很多浏览器会因为域名不匹配直接拒绝请求。所以开发环境写代理,生产环境写相对前缀,两条线要分开。
5.2 报告文档怎么和代码对应:答辩前建议背熟的三条主线
这个资源里带了报告文档,它本质上是给答辩用的“话术稿”。我通常建议把报告和代码互相对照着看三条线。
第一条是数据流:用户打开页面 → 前端请求商品列表 → 后端从 MySQL 的 product 表查数据 → 返回 JSON → 前端渲染到卡片。报告里对应的诊断是“系统功能模块”和“数据库表设计”。你要能指着报告里的 ER 图说出 product 和 order_item 的外键关系。
第二条是业务流程:用户选奶茶 → 购物车 → 下单 → 后端开启事务 → 插入 orders 和 order_item → 扣库存 → 清空购物车 → 前端跳转到订单页。报告里对应的章节是“系统设计”和“核心功能实现”。答辩时把你写的下单代码里每个步骤对应到报告的三级标题,比单纯背 PPT 有效得多。
第三条是部署验证:jar 包在本地启动后,访问登录页,输入管理员账号,进入商品管理页,修改一条商品价格,再到用户端页面看最新价格。这条线要在演示前完整走一遍,确保接口和页面没有随着打包而断开。
还有几个演示加分项:提前准备好一杯奶茶的完整下单流程,从选品到支付成功后订单状态从“待支付”变成“已支付”;把库存改成 1,再去下单 2 杯,弹出“库存不足”的提示;到数据库里 show tables 给导师看七张表结构。这套动作下来,老师基本能确认项目是你自己跑通过的。
我第一次拿到类似资源时,只顾着把前后端分别跑起来,没做合并打包,结果老师让现场演示时,我发现 localhost 两个端口一开,页面能看但接口全部跨域,最后急得满头汗。从那以后我每次接手这种带前端和后端的项目,都会强制把前端 dist 先拷进后端 static,再打成一个 jar 从编译到启动完整走一遍,确认单端口可跑才算验收。这既是对自己代码的确认,也是让答辩演示不翻车的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取