1. 项目概述与需求分析
做奶茶销售系统这个题目,我在去年底到今年初花了不少心思。市面上现成的开源商城很多,但真正贴近“奶茶店”这种场景的反而很少——既要支持小程序或H5点单,又要兼顾收银端、后厨端和管理后台,还得算上会员营销、库存损耗、外卖平台对账这些门店日常运营里绕不开的环节。所以当我把目标锁定在“Spring Boot + Vue”这套组合上时,心里其实已经有了完整的产品蓝图:一套标准的前后端分离架构,后端负责业务规则、数据持久化和接口安全,前端负责交互体验和运营后台的可视化操作。2026年了,这个技术栈依然稳定、生态成熟,无论是做毕业设计、个人项目还是小团队的自研系统,都是性价比极高的选择。
我见过太多人拿到这类项目标题就开始盲目堆功能,结果数据库建了十几张表、页面做了几十个,最后核心的“点单—支付—出杯—统计”链路却跑不通。这篇文章我会从需求分析、架构设计、数据库建模、核心模块实现、前后端联调和生产部署一条线讲透,重点解释每个关键选择背后的原因,顺带分享我在这个项目里踩过的坑。无论你是准备拿它做毕业设计,还是打算真正落地一家奶茶店的数字化系统,照着这个思路走,都能省下大量试错时间。
1.1 奶茶销售系统到底要解决什么问题
很多同学在开始写代码前习惯先画用例图,但我觉得更重要的是想清楚“谁在什么场景下用这个系统”。奶茶店的业务角色比普通电商要复杂一些,至少包含顾客、收银员/店长、后厨制作人员、系统管理员和老板这几类。顾客需要快速浏览菜单、自定义糖度冰量、在线支付并看到取单进度;收银员需要处理线下点单、折扣和退款;后厨需要看到按时间排序的制作队列;老板则需要实时营业额、热门单品、会员增长和库存预警。
这些角色诉求叠加在一起,决定了系统绝不能只是一个简单的商品CRUD。我最终把系统划分成几个核心域:商品域、订单域、会员域、库存域、支付域和统计报表域。每一域都有自己的数据边界和服务逻辑,而不是把所有操作混杂在一张大表里。举个典型的例子,奶茶的“规格”不是传统电商的SKU那样简单绑定价格,而是“中杯/大杯 + 少冰/去冰 + 三分糖/七分糖 + 加珍珠/加椰果”这种组合式定制。如果按照传统电商的做法给每个组合生成一个SKU,数据库会瞬间膨胀几十上百倍,而且维护成本极高。所以我的设计是把规格因子拆成独立的选项分组,下单时用JSON保存“顾客选择快照”,既不影响历史订单的查询,也不用提前枚举所有组合。
1.2 为什么选Spring Boot + Vue这套组合
先聊后端。Spring Boot 在2026年依然是Java后端开发的绝对主流,原因很简单:自动配置机制让“零配置启动”成为可能,生态里几乎能找得到任何你需要的第三方集成,而且大厂招聘和部署环境对这种技术栈的接受度最高。我在这个项目里选了 Spring Boot 3.x + Java 17/21 的组合。Spring Boot 3 基于 Spring Framework 6,全面拥抱 Jakarta EE 规范,性能比老版本更好,而且对 GraalVM 原生镜像的支持也成熟了,后续如果想优化启动速度、压到几十毫秒级,不用改任何业务代码。
再聊前端。Vue 3 已经稳坐国内中小型前端项目的头把交椅,组件化思维配合 Composition API 让代码复用变得很顺畅。相比 React 的生态选择焦虑,Vue 的官方全家桶工具链更统一:Vite 做构建、Vue Router 做路由、Pinia 做全局状态。我用 Vue 3 + Vite + Pinia + Element Plus 组合搭建前端,开发体验非常顺滑,Vite 的按需编译让冷启动时间比 Webpack 时代快一个量级,这在频繁调试奶茶点单界面时尤其重要。
选择前后端分离还有一个很实际的理由:奶茶店经常需要换主题皮肤、加节日活动页,分离架构下前端可以独立迭代发布,不需要因为改一个按钮颜色就把后厨端的服务也重启一遍。再加上微信小程序端、店长App端后续都要复用同一套后端接口,前后端分离是这套系统能自然演进的基础。
1.3 需求拆解:从点单到数据分析
落到具体功能上,我把整个系统拆成了五个功能模块,每个模块都能独立验收:
第一是用户与权限模块。顾客端用手机号+验证码登录,管理后台用账号密码登录,并通过 JWT 进行身份认证。权限模型我没有做过重的设计,只分了“顾客、店员、店长、管理员”四种角色,用 Spring Security 的注解控制接口访问即可,毕竟一家门店的服务端后台用户不会超过几十人。
第二是商品与分类模块。支持分类树、商品上下架、每日限量与售罄标记,还有奶茶特有的规格定制配置。注意这里的“规格”不仅仅是口味选项,还关系到库存扣减逻辑和价格计算,我会在数据库设计部分细讲。
第三是订单与支付模块。用户可以选择自取或外卖(第三方配送),下单时同时创建订单主表、订单明细表和规格快照,然后调用支付接口生成支付二维码。由于沙箱支付环境需要申请,我在这套系统里做了一个可配置的支付策略接口,默认实现是模拟支付,方便本地演示,正式接入微信支付只需新增一个适配类。
第四是门店运营模块。包括后厨的制作队列、叫号取餐,以及库存管理。库存消耗不是简单的下单减库存,因为一杯奶茶的物料包含茶底、奶、糖浆、小料等,实际门店更多是基于“物料配比表”做库存换算。为了控制项目复杂度,我做的是“按周简单扣减商品销量对应的备料数量”,但数据库表设计时预留了 BOM(物料清单)结构,后面要细化也很容易扩展。
第五是数据统计模块。老板最关心的是今日营业额、订单量、复购率、热门单品排行和低库存预警。我用简单的定时聚合任务,每晚把当天的订单数据汇总到统计表里,查询时直接读聚合结果,避免大范围扫描订单明细。这个思路对单店量级完全够用,而且代码写起来很直观。
需求拆解完成之后,再回头看技术选型,每一块都能找到对应的实现路径。下面我按项目推进顺序,从环境准备、工程创建、数据库建模到核心代码,把整个开发过程完整过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境准备与工程初始化
2.1 后端环境:JDK、Maven 与 IDEA 建项目的那些事
先准备后端工具链。这个项目我用的 JDK 17,不是最新但对绝大多数场景最稳:Spring Boot 3.x 要求 JDK 17 起步,而 JDK 21 功能更新更多但部分第三方依赖的兼容性还有小坑,没必要现在冒险。Maven 用 3.9+,IDEA 用 2024 以后版本,自带的 Spring Initializr 可以直接生成项目骨架。如果你习惯命令行,也可以直接访问 start.spring.io 生成压缩包再导入。
实际创建项目时,我建议在 Spring Initializr 里把依赖一次性选好,省得后面手动补。我的选择是:Spring Web、Spring Security、Spring Data JPA 或 MyBatis-Plus、MySQL Driver、Lombok、Validation。这里稍微多说一句,新手经常在 MyBatis-Plus 和 Spring Data JPA 之间纠结。奶茶销售系统的查询需求偏定制化(比如多条件组合筛选订单、分组统计销量),用 MyBatis-Plus 写动态 SQL 更直观,而且国内相关博客和资料更多,出问题时搜解决方案容易。所以我最后选了 MyBatis-Plus。
项目依赖里还有一个容易被忽略的东西:knife4j 接口文档工具。它基于 OpenAPI 3,启动后访问 http://localhost:8080/doc.html 就能看到所有接口的定义,可以直接在线调试。在前后端分离模式下,联调之前先让接口文档定稿再开发,能极大减少返工。我是把接口文档作为前后端约定的一部分提交到仓库,前后端各看各的文档开发,效率提升非常明显。
2.2 前端环境:Vite + Vue 3 的安装配置细节
前端这边,第一次装 Vue 环境的人问得最多的三个问题是:要不要用 Vite、Node 装哪个版本、npm 老报错怎么办。我的答案是:新项目一律用 Vite,Node 装 20 LTS(Vite 6/7 都要求 Node 18+,20 LTS 既稳定又满足要求),npm 镜像换成国内镜像后基本不会再遇到网络问题。
创建项目的命令很简单:
bash复制npm create vite@latest milk-tea-admin -- --template vue
cd milk-tea-admin
npm install
npm run dev
如果你还需要一个顾客点单端,可以再建一个 milk-tea-shop 的 Vue 项目,管理后台和点单端拆开,部署时通过不同目录或不同端口提供服务。这样做的原因在于,点单端追求的是快速、移动优化的轻交互,而后台管理是重表格、重表单的桌面体验,二者风格和组件侧重明显不同,放同一个项目里会让代码变得混乱。
装依赖时有个很容易踩的坑:Element Plus 的完整导入会让打包体积飙到 1MB 以上,所以尽量按需引入。Vite 下用 unplugin-vue-components 和 unplugin-auto-import 这两个插件能自动按需加载组件和 API,写完组件名不用手动 import,非常爽。配置代码放在 vite.config.js 里:
javascript复制import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] })
]
})
2.3 版本管理:从乱码到可复现构建
工程初始化好之后,我做的第一件事不是写代码,而是把 Git 仓库整理干净。.gitignore 里必须排除 node_modules、target、*.log、.idea、dist 等目录,这些文件一旦提交,后续协作者拉下来要么依赖冲突,要么把本地的本地配置覆盖掉,特别恶心。
另外提醒一句,前后端最好分两个仓库管理,或者至少是同一个仓库下的两个独立目录。我见过把 Spring Boot 和 Vue 代码混在同一个 src 目录下的项目,那种结构可维护性极差。分开以后,后端的版本发布节奏可以独立控制,前端某次紧急改活动页也不影响后端接口。如果你用的是 IDEA 自带的前后端一体化项目模板,我建议手动调整为多模块或双目录结构,长期看更干净。
3. 数据库设计与核心后端模块实现
3.1 表结构设计的两个关键点
奶茶系统最核心的表有:用户表 t_user、商品分类表 t_category、商品表 t_product、规格选项组表 t_spec_group、规格选项表 t_spec_item、订单主表 t_order、订单明细表 t_order_item、购物车表 t_cart 和库存表 t_stock。其中最容易设计错的就是“商品规格”和“订单状态流转”。
商品规格表结构
我在前面提过,奶茶规格是多个维度选项的组合。表结构上,我把规格设计成两层:第一层是规格组,比如“温度”“糖度”“大小杯”“小料”;第二层是具体选项,比如温度组下是“热/温/去冰/少冰/多冰”。商品通过中间表关联规格组,一个商品可以拥有多个规格组,规格组之间是“或”的关系而不是“且”,因为顾客可以在温度组选一项、糖度组选一项,同一组内不能多选。下单时,前端提交的是一个包含多个 specGroupId + specItemId 的数组,后端以 JSON 形式整体存入订单明细表的 spec_snapshot 字段。这样既能灵活扩展新的规格维度,又不会把查询逻辑复杂化。
订单状态流转
奶茶订单比普通电商多出几个状态:已下单、已支付、制作中、已出杯、已取餐/已配送、已完成、已取消。我用了整数类型存储状态码,并在代码里用枚举类统一管理,例如 0待支付、1已支付、2制作中、3已出杯、4已完成、-1已取消。为什么要用整数而不是字符串?因为整数在数据库索引和比较上性能更好,而且代码枚举可以保证业务含义唯一。状态流转通过状态机集中控制,不允许随意跳转。比如一个“已取消”的订单绝对不能变成“制作中”,否则后厨看到单子做了半天才发现已经退款,这在实际门店里是会引发客诉的严重问题。
订单号生成也有讲究。直接用数据库自增ID当订单号暴露给用户,很容易被枚举猜到门店销量。我这里做了一个 t_order 的独立单号字段,规则是 yyyyMMddHHmmss + 门店编号 + 4位随机数,生成时加了唯一索引防止并发冲突。这个单号同时用于支付回调对账和用户查询,体验比赤裸裸的自增ID好很多。
3.2 登录鉴权:从Session到JWT的取舍
奶茶系统有两个端需要登录,顾客端和后台管理端。如果沿用传统的 Session 登录,在前后端分离和未来小程序复用接口的场景下会遇到跨域和分布式会话共享的问题。所以我选择 JWT 做无状态认证,核心逻辑分三步:
用户提交账号密码(或手机号验证码)到 /api/auth/login,后端校验成功后生成 JWT,Token 里只放必要信息:用户ID、角色、过期时间,再用 HMAC-SHA256 签名防止伪造。前端把 Token 存在 localStorage 里,每次请求在 Axios 拦截器里带上 Authorization: Bearer <token>。后端用一个 OncePerRequestFilter 解析 Token,并把它放入 SecurityContext。
很多人会问:JWT 过期了怎么办?我的处理是签发一个有效期 7 天的长期 Token,后端不主动维护刷新机制。对于奶茶店系统这种低并发、低攻击面的场景,够用也简单。如果你要做得更安全,可以加 Refresh Token 双 Token 机制,但那是另一个复杂度层次,现阶段没必要。
权限控制上,Spring Security 的注解最简单:
java复制@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/products")
public Result<?> pageProducts(@RequestParam Integer page, @RequestParam Integer size) { ... }
缺点是注解需要写在每个接口上,容易漏。我更推荐在 SecurityConfig 里集中配置 URL 规则:/api/admin/** 需要 ADMIN 或 SHOP_OWNER 角色,/api/shop/** 任意登录用户可访问,/api/public/** 不鉴权。这样新增接口时不需要额外配置,也避免漏加权限造成越权。
3.3 购物车、下单与库存扣减的事务细节
购物车本身不复杂,无非是“查列表”“加项”“改数量”“删除”“清空”。真正容易出问题的是“加购时校验商品是否下架”“下单前重新检查价格”。我吃过一次亏:商品调价后,用户在购物车页看到的价格是旧价格,但提交订单时后端如果没有重新计算价格,就会出现一笔订单按旧价成交的情况。所以我的接口约定是:购物车只保存商品ID、规格快照和数量,任何价格都不能信前端传上来的值,后端在下单时重新从数据库读取最新价格计算订单总价。这就是后端永远不可信的最小原则。
下订单和扣库存必须是一个原子操作。我用 @Transactional 同时操作订单表和库存表,并且在库存表更新时加了条件判断:
sql复制UPDATE t_stock SET stock = stock - #{quantity}
WHERE product_id = #{productId} AND stock >= #{quantity}
这样写可以在数据库层面避免超卖,而不需要在代码里先 SELECT 再 UPDATE(那两步中间存在竞态窗口)。同时要留意 MySQL 默认的 REPEATABLE READ 隔离级别下,直接在事务里执行 UPDATE 会锁定相关行,不会出现其他并发事务读到旧值的问题。对奶茶店单店几百单/天的量级来说,这个方案绰绰有余。
库存扣减成功后,我还会向消息队列发送一个“下单成功”事件。热词里有人问过 springboot 整合 activemq,我这套项目其实没有引入完整消息队列,因为单店场景用不上。但为了给后续多门店、对接第三方配送预留扩展点,我用 Spring 的事件监听器先实现了内部解耦,等系统规模变大后,把 ApplicationEventPublisher 替换成 ActiveMQ 或 RocketMQ 也就是改一个适配类的事。
3.4 接口文档规范与统一返回体
后端接口需要一套统一的返回结构,我定义了一个 Result<T> 对象,核心字段是 code、message、data。成功时 code=200,业务异常时返回 code=500 或自定义错误码。统一返回体的意义在于前端可以封装一套通用的响应处理逻辑,Axios 拦截器看到 code 非 200 时可以统一弹错误提示,不需要接口各自为政。
HTTP 状态码我不会用得太花哨。正常请求返回 200,未登录返回 401,无权访问返回 403,资源不存在返回 404,系统异常返回 500。把业务错误码放在 body 里,而不是滥用 HTTP 状态码表达业务逻辑,这是前后端团队最容易达成一致的做法。
4. 前端核心页面与交互实现
4.1 路由结构:动态路由与权限控制
点单前端和管理后台的路由设计差别很大。管理后台是固定多页面结构,包括登录页、工作台、订单管理、商品管理、会员管理、库存管理、统计分析等,我直接在路由表里静态声明完事。但你如果希望不同角色的管理员登录后只看到自己有权访问的菜单,就需要动态路由:登录成功后,后端根据角色返回一个菜单列表,前端通过 router.addRoute() 动态挂载。
热词里有人问 vue动态路由,这里说个实操要点:动态路由的坑在于刷新页面后路由表会被清空,因为 Pinia/Vuex 里的状态都是内存数据。所以应用加载时,如果当前用户已经登录,必须先异步获取菜单信息并重新注册路由,再执行首屏渲染逻辑。我建议把“初始化路由”这个动作放在路由守卫里做:
javascript复制router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (to.path === '/login') return next()
if (!userStore.token) return next('/login')
if (!userStore.menuLoaded) {
const menus = await userStore.fetchMenus()
menus.forEach(item => router.addRoute(item))
userStore.menuLoaded = true
return next({ ...to, replace: true })
}
next()
})
直接 addRoute 后,如果当前访问的地址是刚添加的路由,需要 next({ ...to, replace: true }) 重进一次,否则会进入死循环或者白屏。这个问题我排查过一次,印象很深。
4.2 点单页与购物车交互:组合式API的实战写法
点单页面是整个顾客端的门面,用户体验要求很高。奶茶点单的交互特征是:用户先选商品,进入详情页后设置糖度冰量小料,加入购物车;也可以直接在当前分类页面连续加购,再统一去购物车结算。为了保持界面顺畅,我把点单页的状态都收敛到了 Pinia 的 cartStore 里,这样多个页面组件都能同时访问购物车数量,不需要一层层传 props 和 emit。
前端在收集规格选择时,我建议用响应式对象保存每个规格组的选中项:
javascript复制const selectedSpec = reactive({})
function selectSpec(groupId, itemId) {
selectedSpec[groupId] = itemId
}
这里要注意一点:不同规格组的选项之间可能存在互斥或依赖关系,比如选了“热”就不能选“加冰”,但这种规则不应该只靠前端控制,后端在下单接口里也要做合法性校验,否则用户绕过前端直接调接口就会产生非法组合。我的实现是后端有一个 SpecValidator,针对每种商品类型配置允许的规格规则,下单选完规格后统一验证,验证失败直接返回业务错误码。
4.3 管理后台:列表查询、表单校验与图片上传
管理后台主要做的事情是增删改查,技术上没什么特别,但有两个细节值得展开:一是列表页的查询条件组件化,二是图片上传的工程化处理。
商品列表页通常带多个筛选条件:分类、上下架状态、创建时间范围、名称模糊搜索。如果每个列表页都写一套查询状态和 URL 同步逻辑,代码会无比冗余。我封装了一个 usePageQuery 组合式函数,把分页参数、筛选参数、加载列表函数、重置条件全部收拢起来,前端代码量能少一半。
图片上传这里,热词里提到了 minio加入到springboot,我确实在项目里用了 MinIO 做商品图片存储,而不是直接存到后端本地磁盘。原因很简单:Docker 部署后本地磁盘路径可能会丢,而且多个前端实例共用同一套本地图片目录会出问题。MinIO 是开源的 S3 兼容对象存储,单机部署很轻量。后端的图片上传接口先把文件传到 MinIO,然后返回文件的 URL。生产环境前端的图片域名再通过 Nginx 转发,实现动静分离。前端上传组件需要做的额外事情只有一步:把文件表单提交到 /api/upload 后,把返回的 URL 写入表单字段,避免直接把 Base64 字符串存数据库——那会让数据库体积迅速膨胀。
4.4 路由参数透传与跨页面联动
顾客点单时经常是“从首页活动 Banner 跳转到某个分类的某款商品”,例如从“新品上市”活动图进到商品详情页。这个过程需要把分类ID、商品ID、甚至选好的规格ID通过 URL 拼接传给商品详情页。热词里提到 vue路由参数,我这里推荐 query 方式而不是 params 方式,因为 query 参数刷新后不会丢失,用户复制 URL 分享给朋友也能直接打开同一个商品页面。
另外,前端页面之间传递复杂对象时,不要绞尽脑汁把对象塞进路由参数。正确做法是用 Pinia 做一个临时暂存空间,路由里只传ID,详情页根据ID从接口拉最新数据。这样用户即使分享一个旧链接,也能看到商品的最新数据和最新价格。购物车条数红点显示也可以通过 Pinia 持久化到 localStorage,刷新后不会闪回空车。
5. 联调、打包、部署与高频排坑
5.1 本地联调:跨域与代理配置
前后端分离后,第一个拦路虎就是跨域。本地开发时 Vue 跑在 5173 端口,Spring Boot 跑在 8080 端口,浏览器直接发请求会被 CORS 拦截。最简单的解决方式不是在后端配 @CrossOrigin 或全局 CORS 过滤器,而是在 Vite 配置里把 /api 开头的请求代理到后端:
javascript复制server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这样前端代码里的请求路径统一写成 /api/...,浏览器看到的是同源的 5173 地址,Vite 开发服务器再把流量转发给后端,开发阶段完全不用处理跨域。为什么要加 changeOrigin: true?因为后端如果校验 Host 头或做了 IP 白名单,代理不改 Host 可能被拒。
正式生产环境则用 Nginx 做反向代理,把同域下的 / 指向前端静态资源目录,/api 指向后端服务:
nginx复制server {
listen 80;
location / {
root /opt/milk-tea-admin/dist;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}
try_files $uri $uri/ /index.html 这行是 Vue Router 的 history 模式必需配置,否则用户直接刷新 /admin/orders 页面会返回 404。这个问题几乎每个用 history 模式的人都会遇到,建议直接记进自己的部署清单里。
5.2 打包构建与Docker部署
后端打包我用 Maven 的 mvn clean package -DskipTests,生成一个可执行 JAR 包。这里有个常见的疑问:为什么要在宿主机上装 JDK 而不是直接打进镜像?我的经验是编译阶段和运行阶段分离,Dockerfile 用多阶段构建,编译阶段用 maven:3.9-eclipse-temurin-17 镜像,运行阶段用 eclipse-temurin:17-jre 精简镜像。这样最终镜像体积比塞一个完整 JDK 小很多,推送到私有仓库和拉取都能省时间。
再往前一点,热词里有人问 怎么将springboot jar反编译成项目,这通常发生在接手别人的项目、但对方没给源码的情况下。用 IDEA 自带的反编译功能或 javap、CFR 之类的工具可以查看 JAR 包里的 class 内容,也能还原出大部分代码结构,但反编译产物一般没有注释、泛型和编译期优化过的方法细节,只能作为应急参考。我个人的建议是:学习别人的 JAR 包结构没问题,但线上排障时优先看日志和配置,别依赖反编译去改别人的包,改坏了连回滚依据都没有。
前端构建则执行 npm run build,产物输出到 dist 目录。构建前有一个非常容易忽略的点:接口地址不能写死成 http://localhost:8080,否则打出来的包换环境就废。正确做法是用环境变量:
bash复制# .env.production
VITE_API_BASE_URL=/api
代码里通过 import.meta.env.VITE_API_BASE_URL 读取,这样开发环境指向代理的 /api,生产环境由 Nginx 把 /api 转发给后端,整套部署可以做到零硬编码。
5.3 高频问题排查:Vue安装、依赖与运行环境
先聊 vscode保姆级安装vue 和 vue devtools插件下载 这类前端的问题。VS Code 装 Vue 支持,需要装官方推荐的 Vue - Official 插件(以前叫 Vetur,已经停止维护了)。Vue DevTools 插件新版本以独立扩展的形式提供,浏览器扩展商店直接搜 Vue.js devtools 即可。创建项目后如果 npm install 一直报错,大概率是网络源问题,换成 registry.npmmirror.com 基本就能解决。
另外我踩过一次印象很深的坑是不同 Node 版本引起的 TypeError: Request failed with status code 404。原因是同事的 Node 版本是 16,而项目依赖里的某个包要求 Node 18+,安装时虽然成功了,但运行时会报一些莫名其妙的错。后来项目里加了一个 .nvmrc 文件固定 Node 版本,新机器 nvm use 一下就能对齐环境,这个习惯值得推广。
后端这边,Spring Boot 相关的高频问题更多。springboot自动装配原理 经常被面试官问,但在实操中对应的现象也很多:你引入了一个依赖,启动后却发现它没生效。自动装配的本质是 @EnableAutoConfiguration 会根据 classpath 下的类自动注册一批 @Bean,生效与否可以通过 @ConditionalOnClass、@ConditionalOnProperty 等条件控制。遇到“我明明加了依赖为什么不生效”时,先看看自动配置类的条件注解是否满足,再看是不是被自定义配置覆盖了,思路会比瞎猜快很多。
还有 springboot banner生成器 这种小但提升幸福感的问题。在 src/main/resources 下放一个 banner.txt,Spring Boot 启动时就会显示自定义字符画。网上有在线生成器,把品牌名和带颜色的字体贴进去即可。虽然没有实际功能,但项目启动那一瞬间的观感,对演示评分还是很加分的。
5.4 数据一致性:事务失效与缓存穿透
订单模块最容易犯的毛病是事务注解失效。@Transactional 失效的常见原因有三个:方法被 private 修饰、同一个类里内部方法调用、异常被吞掉了。特别说一下内部方法调用,很多人写 Controller 调 Service,然后在 Service 的公开方法上加事务,结果这个方法里又调了同类的另一个方法,事务就没了。原因是 Spring 事务默认基于代理机制,内部调用不会经过代理对象。我的习惯是事务方法都放在独立的 Service 类里,或者通过 AopContext.currentProxy() 获取代理对象再调内部方法。
缓存穿透也是高频话题。如果你把商品列表做了 Redis 缓存,查询一个不存在的商品ID时,请求会直接打到数据库。正常做法是先查缓存,缓存为空时查数据库;如果数据库也没有,就把一个空对象写入缓存并设置很短的过期时间(比如5分钟),避免恶意遍历ID把数据库拖垮。像奶茶店这种ID往往是自增或有序单号,只要有人想爬系统里的商品数,缓存空值策略是必须的。热词里还提到 springboot yml 随机端口,这个在本地调试多个服务实例时很好用:配置 server.port: ${random.int(8000,9000)} 启动多个实例不会冲突,但生产环境不要用,因为Nginx和注册中心需要知道确切端口。
6. 最后的经验汇总
奶茶销售系统做完以后,我最大的体会是:技术选型永远没有绝对的“最新”,只有“适合当下场景”。Spring Boot + Vue 的组合不算惊艳,但它让一个人也能在短时间内完成一套前后端完整闭环的产品,这本身就是效率。你完全可以用 Go 写后端、用 React 写前端,甚至上个低代码平台拖拖拽拽,但当你需要深度控制订单状态机、库存扣减、权限模型这些核心业务规则时,一个自己完全掌控的技术栈才是睡得着觉的保障。
再分享一个实际心得:第一次做这类系统,别急着加功能,先把“点单—支付—出杯—统计”这条主链路跑通。奶茶店的本质是“把原料变成一杯稳定出品的饮品”,系统的本质就是记录并优化这个过程的每一步。我见过很多项目,购物车、会员积分、营销券一大堆,结果最基本的订单退款逻辑都靠手动改数据库,那样系统上线反而是负担。
最后留一个扩展方向:这套系统的接口层已经和前端完全分离,后续要接微信小程序、做门店多租户、对接外卖平台自动接单,都只需要在现有架构上添加适配层。我在设计表结构时已经预留了 store_id 字段,未来同一套代码部署多家门店时,只要在鉴权阶段解析门店参数即可。这个坑我提前填了,希望你做的时候也别等到上线前再改表。
做技术项目这件事,动手永远比纠结重要。拿这篇设计思路去搭你的第一个 Spring Boot + Vue 项目吧,把奶茶店的订单流转跑通那一刻,你会觉得一切都值。
