
先说个结论这套系统的难点从来不在某个单一功能而在于“定制”这两个字。市面上现成的点餐系统、外卖系统一抓一大把但那些基本都是标准菜品、标准下单逻辑换成私房菜定制加厨师上门服务整个业务模型全变了。用户不是选一个菜然后等配送而是要把口味偏辣、忌口海鲜、家里来6个人、希望周六晚上6点上门的这些场景化需求全部表达出来再由厨师端去接收、报价、接单、备餐、上门服务。这种带强交互、强状态管理、又有人工撮合属性的业务才是值得用一套完整的前后端分离系统去落地的典型场景。我做的这套私房菜定制上门服务系统技术栈是SpringBoot Vue MyBatis MySQL。说到底它既是一个练手级的前后端分离项目又是一条能讲清楚电商核心逻辑的业务线。如果你想找一套能写进简历、能用来学项目架构、又不用被复杂的分布式中间件绕晕的完整源码并且想搞懂从需求分析到数据库设计再到部署上线的全链路那这篇文章应该能帮上忙。下面我不跳步骤从业务模型、后端设计、前端实现、订单状态机、部署踩坑一直讲到实测数据全部按我实际开发时的思路来写。1. 私房菜上门服务要解决的核心问题角色分工与业务链路设计很多第一次做这类项目的人上来就先建表、先写接口结果写到一半发现逻辑根本理不顺。原因很简单他对“这个系统到底服务谁、每一步谁操作、字段该存在哪张表”没概念。所以我建议所有做业务系统的人动代码之前先花一天把业务流程用文字写清楚。1.1 三个角色的诉求差异这套系统里有三类使用者普通用户、私厨服务者、平台管理员每一类的诉求完全不一样。普通用户要的是“省事地把需求讲清楚”。他不关心技术细节只想选一位会做菜的师傅告诉他时间、人数、口味偏好、忌口然后等师傅接单上门。私厨服务者要的是“高效地接单和备餐”。他需要看到平台推过来的定制需求判断自己能不能做报价然后跑这一单。这里有个细节很多人会忽略厨师同样有“拒单”和“跟用户沟通确认”的需求所以订单不能一上来就是铁板钉钉必须留出协商空间。平台管理员要的是“审核和干预能力”。厨师入驻要审核订单异常要处理评价纠纷要有据可查。这三者的关系理清之后你再看代码里的角色权限、页面路由、接口设计就全部顺了。用户端和服务端是面向C端的管理端是面向运营的所以前端必须要做三端合一用角色去控制可见功能和页面入口。1.2 一条订单从下单到完结的完整流程我最终整理的这条业务链路在需求文档里是这样写的用户注册登录完善个人地址和常用用餐偏好。用户浏览厨师列表、菜谱或者直接提交一条定制需求。用户填写定制表单内容包括期望上门时间、用餐人数、预算范围、口味偏好、忌口、服务地址。系统生成待受理订单订单进入到“待派单/待接单”状态。厨师端看到新订单根据自身档期和能力决定接单或拒绝。厨师接单后填写实际报价和可服务时间订单进入“待用户确认”状态。用户确认后订单锁定厨师进入备餐阶段。厨师上门服务服务完成后标记订单完成。双方互评用户可对口味、服务、卫生三个维度打分。你看这里面每一步都不是单纯的增删改查。订单状态跟着双方交互走不同状态下能执行的操作完全不一样。只做四个CRUD控制层是没法把这类系统做完整的你必须从状态机的角度去建模这部分我在第4节单独展开。先把流程想明白你后面建的表、写的接口才是有根有据的。2. 后端技术栈落地SpringBoot、MyBatis与MySQL的协作方式后端我没有引入太复杂的东西整套基于SpringBoot 2.7、JDK 1.8、MyBatis、MySQL 5.7/8.0配合Maven做依赖管理。之所以选这个组合是因为它真实、可控、也足够经典。SpringBoot负责把Web层、依赖注入、配置管理整个串起来MyBatis负责SQL和对象映射MySQL存数据对外完全是一套标准RESTful API。2.1 工程结构与分层我采用的是最常见的分层结构controller、service、mapper、pojo、config、common六块。com.privatechef ├── controller // 接收HTTP请求做参数校验 ├── service // 业务逻辑层接口实现类拆分 │ └── impl ├── mapper // MyBatis的Mapper接口 ├── pojo │ ├── entity // 数据库表对应的实体 │ ├── dto // 接收前端参数的模型 │ └── vo // 返回给前端的视图模型 ├── config // 跨域、拦截器、文件上传等配置 └── common // 统一返回体、异常处理、常量类controller层我规定只做三件事接参数、调service、做基础校验。所有状态判断、金额计算、权限校验都丢到service里。有人会觉得那controller不就成了壳子确实就是壳子但这层壳子能保证你把业务逻辑控制在一个地方后面回看你写的代码会轻松很多。统一返回体这里我直接写了一个Result类泛型结构code代表状态码data放实际数据msg放异常提示。前端axios封装时也依赖这套规则去统一处理错误。这样做的收益在后期特别明显因为前后端分离项目最大的痛点就是两边数据结构约定不一致话传得越多越容易扯皮。2.2 数据库核心表设计数据库设计是这类系统的地基我梳理出六张核心表用下面的SQL可以一次性建好。CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, phone varchar(20) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint(4) DEFAULT 0 COMMENT 0-普通用户 1-厨师 2-管理员, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE chef ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 关联user表, intro varchar(500) COMMENT 个人简介, cuisine_type varchar(100) COMMENT 擅长菜系, service_area varchar(100) COMMENT 服务区域, price decimal(10,2) COMMENT 起步价, status tinyint(4) DEFAULT 0 COMMENT 0-待审核 1-正常 2-停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dish ( id bigint(20) NOT NULL AUTO_INCREMENT, chef_id bigint(20) NOT NULL, name varchar(100) NOT NULL, image varchar(255) DEFAULT NULL, price decimal(10,2) DEFAULT 0, category varchar(50) DEFAULT NULL, monthly_sales int(11) DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务单号, user_id bigint(20) NOT NULL, chef_id bigint(20) DEFAULT NULL, custom_req varchar(2000) COMMENT 定制需求JSON, meal_time datetime DEFAULT NULL COMMENT 期望上门时间, address varchar(255) DEFAULT NULL, status tinyint(4) DEFAULT 0 COMMENT 订单状态, total_amount decimal(10,2) DEFAULT 0, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE custom_requirement ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, meal_time datetime DEFAULT NULL, people_count int(11) DEFAULT 1, budget varchar(50) DEFAULT NULL, taste varchar(100) DEFAULT NULL COMMENT 口味偏好, avoid varchar(200) DEFAULT NULL COMMENT 忌口, remark varchar(500) DEFAULT NULL COMMENT 其他备注, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE evaluation ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, chef_id bigint(20) NOT NULL, taste_score tinyint(4) DEFAULT 5, service_score tinyint(4) DEFAULT 5, hygiene_score tinyint(4) DEFAULT 5, content varchar(500) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个我专门做的设计决策。用户表和厨师表拆开而不是把厨师字段全部塞进user表是因为让一个普通用户申请成为厨师时只需要往chef表里插一条记录而不用把user表的结构改动一遍后期可扩展性要好得多。订单号和自增id分开自增id对内部用order_no对用户和业务用。业务单号可以做成日期加随机数比如20250328001查问题、跟客服对单都方便。custom_requirement这张表单独拆出来是因为定制内容字段实在太多了直接塞进订单表会显得臃肿。这里我采用的方案是把常用结构化字段独立成列比如用餐人数、预算、口味、忌口这样后台运营想统计“大家最爱什么口味”时直接SQL分组就能查出来。2.3 MyBatis映射与动态SQL实践MyBatis在订单列表这类场景里是最好用的。一个订单要关联厨师名、用户地址、评价状态一条SQL查完再用resultMap做映射比用JPA那种自动管理实体的方案更直观也好排查问题。展示一个我从实际项目里保留下来的Mapper片段查询订单列表时关联厨师信息select idselectOrderDetailList resultMapOrderDetailMap SELECT o.id, o.order_no, o.status, o.meal_time, o.total_amount, c.id AS chef_id, c.cuisine_type, u.username FROM orders o LEFT JOIN chef c ON o.chef_id c.id LEFT JOIN user u ON c.user_id u.id WHERE o.user_id #{userId} ORDER BY o.create_time DESC /select动态SQL最大用处是那种“搜索条件不固定”的列表。比如管理员后台筛选订单可能按状态、按时间范围、按厨师区域也可能不填任何条件。我用where加if标签拼接它就能自动适配各种组合而不用为每种查询组合单独写一条SQL。另外金额只要涉及计算就必须用Java的BigDecimal不能再用double或float这一点我在电商类的项目里多次强调。数据库里用decimal(10,2)Java对象也用BigDecimal前端序列化的时候再统一转成字符串不然前端拿到精度丢失后的金额会直接出现后期对账的不可逆错误。2.4 身份认证JWT拦截器为什么够用很多初学者问为什么不上Spring Security或Sa-Token。我的答案是这套系统的角色只有用户、厨师、管理员三种接口权限只用判断“登录了没有”“是不是这个角色”就够了引入重框架反而增加了学习成本和配置复杂度。我最终用JWT生成令牌配合一个自定义拦截器完成身份识别。登录成功后后端把用户id和角色放进JWT的payload签名后返回token。前端每次请求在请求头带上Authorization: Bearer token后端拦截器解析、验签再把用户信息放到ThreadLocal里供service层使用。只有管理员接口才额外校验角色普通接口只保证登录状态。整个过程清晰代码量也不大而且非常贴合“前后端分离”场景因为JWT天然无状态服务器不需要存session以后想加Redis做会话管理也只是顺手的事。密码存库我强制要求BCrypt加密。SpringBoot自带的BCryptPasswordEncoder就够了同一密码每次加密结果都不同即使数据库泄露也不会被彩虹表直接还原。登录时用matches方法校验这一步不要省。3. Vue前端三端合一用户端、服务端、管理端的实现要点前端我用的Vue 3 Vite Element Plus Vue Router Pinia。之所以能用一套代码做三端是因为业务功能可以切片用户端是浏览、下单、评价厨师端是订单池、接单、报价管理端是厨师审核、订单管理、数据统计。界面形态不一样但底层都是同一套API。3.1 前端工程组织与路由权限Vue Router我用的是动态路由加路由守卫的混合方案。先只注册登录和公共页面用户登录后根据角色标签动态添加对应端的功能路由。这么做的原因是为了安全性和体验的双重保证路由守卫里如果没有token就直接踢到登录页如果角色不匹配就停留在当前端首页避免用户手输URL越权访问。路由守卫核心逻辑大概长这样router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } const role localStorage.getItem(role) if (to.meta.role to.meta.role ! Number(role)) { next(/) return } next() })页面结构上我做了三个布局组件UserLayout、ChefLayout、AdminLayout。每个Layout都有不同的侧边栏菜单和顶栏内容区用router-view渲染。这样分开写的好处是管理端的菜单项、操作按钮不会跟用户端混在一起后面加功能时也能定位到对应Layout不会越改越乱。3.2 定制表单与订单流程的交互设计定制表单是整个前端交互的核心。我把它拆成了四个步骤时间地点、用餐信息、口味偏好、确认提交。这样做是刻意降低用户的填写压力四个大问题比长表单更直观。口味偏好那一栏我做成了一个标签多选组件可以选“偏辣”“偏淡”“少油”“无香菜”等预设项也可以自由输入。这些标签选项的背后其实对应custom_requirement表的taste字段提交时用逗号拼接字符串存进去。好处是用户端操作简单后台统计也方便。订单详情页我用Element Plus的Steps步骤条展示订单当前状态待受理、待确认、待服务、服务中、已完成、已取消。每一步对应一个状态码前端根据状态码高亮到对应节点用户一眼知道自己处在哪个环节。这个交互虽然简单但实际体验比一堆状态文字好很多。厨师端有一个新订单池页面厨师能看到未接单的定制需求卡片卡片上突出显示四项信息上门时间、人数、预算范围、口味忌口再附一个“实际地址仅接单后可见”的提示。这是为了隐私保护做的设计因为如果未接单就能看到完整地址会有被骚扰的风险。3.3 axios封装与联调细节axios封装我做了三层创建实例、请求拦截器、响应拦截器。const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) return res.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.msg || 请求失败)) }, error { return Promise.reject(error) } )baseURL用/api开发环境由Vite proxy转发到后端8080端口生产环境由Nginx转发。这一点特别重要开发和生产你要保证前端代码里不需要再改接口地址不然部署一次改一堆文件早晚出事。联调阶段我踩过最深的一个坑是时间格式。后端返回LocalDateTime默认带T比如2025-03-28T18:00:00前端Element Plus的日期时间选择器不认这个格式。解决方案很粗暴也有效在application.yml里配置Jackson全局格式化输出成yyyy-MM-dd HH:mm:ss前端统一字符串展示。4. 状态机与并发定制订单最复杂的部分如果单纯CRUD这套系统可能三天就能写出来但那只是一个空壳。私房菜定制业务里的订单状态流转才是真正需要动脑子设计的地方。4.1 订单状态机设计我用数字状态码去表达订单表结构里已经写了。这里详细展开状态流转规则0 待受理用户提交定制需求订单刚生成此时还没有厨师接手。1 待确认厨师接单并报价等待用户接受或拒绝。2 待服务用户确认报价订单锁定厨师备餐等待上门。3 服务中厨师在上门服务时间范围内开始服务。4 已完成服务结束等待双方评价。5 已取消任意一方在服务开始前取消或超时未确认系统自动取消。我更建议你在代码里把这些状态做成枚举类而不是散落的魔法数字。因为每个状态节点都对应一组“允许执行的操作”集中管理的好处是可以把校验逻辑收拢。比如状态为0时只有“厨师接单”和“用户取消”两个操作合法如果有人调用了“用户确认”直接抛异常。我在service层里统一封装了“订单状态变更”的方法每次变更都会校验当前状态和期望状态是否匹配。这个设计在后面排查问题时帮了大忙日志里能清晰地看到订单从哪一个状态经过哪一个操作变成了哪个状态不会出现乱跳。4.2 定制需求的存储JSON字段还是关联表当时我纠结过一个问题定制需求那么多字段是用JSON塞进一个大字段还是老老实实建关联表。最终我两个方案都用了。custom_requirement表存结构化字段用于查询和统计同时orders表里的custom_req字段存一份完整的JSON快照用于详情展示。为什么做双写因为订单生成后用户的需求是不能被后续修改覆盖的。如果只存关联表哪天用户改了口味偏好那历史订单的展示就会跟着变对商品类订单是可接受的但服务类订单直接涉及履约必须要留快照。这个“快照”思想在电商领域非常重要订单快照、价格快照、需求快照本质都是为了对账和纠纷追溯。JSON快照的格式大概长这样{ mealTime: 2025-03-28 18:00:00, peopleCount: 6, budget: 人均200, taste: 偏辣,少油, avoid: 海鲜过敏, remark: 需要自带餐盘 }读取的时候直接JSON.parse展示前端的步骤条和详情页。这里我用了Hutool的JSON工具解析简单不易出错。4.3 厨师时间冲突的并发控制同一个厨师同一时间段原则上只能接一单这是正式版本里必须处理的问题。但如果只用代码判断“当前有没有重叠订单”在高并发场景下是会出问题的两个请求同时读到无冲突然后同时写入就超卖了。我的处理办法是三重保险第一数据库层面加唯一索引。chef表可以加一个(chef_id, meal_time)唯一索引从数据库层面保证同一个厨师同一个期望时间只能存在一条待服务订单。不过这个方案要求meal_time精度一致所以我把时间段精确到10分钟比如18:00、18:10、18:20统一按槽位保存。第二接单操作使用数据库乐观锁。在orders表加一个version字段更新时带上WHERE version #{oldVersion}如果影响行数为0说明有其他请求已经改过直接提示“该时段已被占用”。第三service层加分布式锁。这里我用的是MySQL的SELECT ... FOR UPDATE单机部署下完全够用事务里先锁住厨师记录再校验时间不会出现并发插入重叠订单的情况。等以后真的做了分库分表再替换成Redis分布式锁也不迟。这三种方案如果仔细看本质是数据库兜底加应用层校验的组合任何单独一层都有漏洞但合起来能保证业务不出问题。5. 完整部署过程与踩坑实录源码全部跑通这是所有拿到这套系统的人最关心的一步。下面我把本地启动和服务器上线两部分都写清楚步骤是完整的。5.1 本地环境快速启动本地开发环境需要JDK 1.8、Maven 3.6、MySQL 5.7、Node.js 16这四个装好基本就够了。第一步创建数据库并导入初始数据mysql -uroot -p CREATE DATABASE private_chef DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后修改后端的application-dev.yml把数据源地址、账号密码改成你自己的。这里我特别提醒localhost后面的serverTimezone一定要写Asia/Shanghai否则数据库时间会显示成UTC标准时间跟中国本地时间差8小时这个坑几乎每个人都会踩。第二步启动后端mvn clean package -DskipTests java -jar target/private-chef-0.0.1-SNAPSHOT.jar --spring.profiles.activedev后端默认端口8080。启动成功后访问http://localhost:8080/api/ping能返回pong就说明基本服务起来了。第三步启动前端cd frontend npm install npm run devVite默认端口5173浏览器打开http://localhost:5173Vite会把/api代理到8080。到这里本地开发环境就跑通了。5.2 服务器上线步骤服务器我用的比较常规的Ubuntu Nginx方案后端用nohup挂守护进程前端静态文件交给Nginx。打包后端mvn clean package -DskipTests把打好的jar包上传到服务器比如放到/home/app/private-chef/目录下然后启动nohup java -jar private-chef-0.0.1-SNAPSHOT.jar \ --spring.profiles.activeprod \ --server.port8080 \ app.log 21 这里我把spring.profiles.active切到prod所以还要准备一份application-prod.yml数据源指向服务器MySQL配置改成生产环境参数。生产环境我建议把MySQL账号新建一个专用账号只给它业务库的所有权限不要直接用root安全第一位。前端打包npm run build生成dist目录把dist里的所有文件上传到/usr/share/nginx/html然后配置Nginx。我的生产配置是这样的server { listen 80; server_name your_domain_or_ip; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /home/app/private-chef/uploads/; } }注意四个细节。location里的try_files $uri $uri/ /index.html是给前端路由用的不然浏览器直接访问/user/orders刷新一次就到Nginx找真实路径找不到就404。proxy_pass http://127.0.0.1:8080/;这里末尾的斜杠很有讲究它会把/api/前缀剥掉所以后端接口就不需要再处理/api前缀。uploads目录单独映射出来是为了菜品图片这类上传文件应用重启也不会丢。配置完重载Nginxnginx -t nginx -s reload5.3 三个值得记录的坑第一个坑是MySQL 8.x的驱动和时区问题。用MySQL 8必须引入com.mysql:mysql-connector-j这个驱动坐标而且在JDBC URL里加上useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。否则启动直接报时区错误。第二个坑是前端刷新404。这个不只在部署时出现我在本地用Nginx模拟生产环境的时候也遇到过。原因就是Vue Router默认的hash模式如果改成history模式就必须由Nginx把所有非文件路径重写到index.html。上面配置里的try_files就是干这个的。第三个坑是文件上传后访问不到图片。我们的菜品图片上传接口把文件写到了服务器本地目录/home/app/private-chef/uploads/但nginx配置里如果没有映射这个目录前端图片就会403。排查方法是直接访问http://服务器IP/uploads/xxx.jpg如果404就看Nginx error.log如果403就看目录权限大概率是权限不足。后来我把uploads目录设为755文件设为644问题就解决了。6. 上线后的实测数据与几个可扩展方向项目跑通之后我在一台4核8G的云服务器上做了简单压测并用真实数据跑了几天记录一些数据供参考。6.1 压测数据与瓶颈分析我用了Apache Bench对订单列表接口和菜品搜索接口做了测试。单机MySQL、连接池默认配置下100个并发请求持续20秒订单列表接口平均响应时间在180ms左右P95约350ms没有出现连接池耗尽。菜品搜索接口因为有索引平均在60ms以内。整体来看这种体量的系统在单机部署下完全够用。真正的瓶颈反而不是接口而是数据库连接数和图片存储。如果后期用户量上来第一步应该升级MySQL连接池到合理的100以内开启慢查询日志再考虑引入Redis缓存菜品列表和厨师信息。缓存这个东西没有实际流量前不要盲目加加了反而多一层复杂度。6.2 未来值得扩展的几个方向第一是支付环节。目前订单确认后直接进入待服务状态没有接微信支付或支付宝。引入支付后状态机需要新增“已支付”“退款中”“已退款”同时要处理支付回调的幂等性这一类内容我会单独写一篇。第二是消息通知。厨师接单、用户确认这些关键节点现在都靠轮询。以后可以接WebSocket做实时推送或者先用短信模板通知做饭的人不一定一直盯着页面短信提醒比站内信更靠谱。第三是智能推荐。现在系统有了用户口味偏好、下单时间、所在地址这些数据完全可以做一个简单的“帮你挑厨师”推荐逻辑按评分、接单率、距离排序。推荐逻辑不放太重的算法模型SQL加权排序就够了。第四是多城市运营。现在服务区域只是一个字符串字段真跑起来以后需要按城市、区域维护厨师的覆盖范围并在地图上表达。这一步牵扯到订单分配和路线规划数据模型要重新设计一遍。最后分享一点我自己的实操体会。做这种带定制属性的业务系统最忌讳的就是拿到需求就埋头写代码。你先把“用户到底怎么用这个系统”走一遍把角色、流程、状态、异常情况全列出来再动手效率反而最高。这套私房菜定制系统目前源码完整部署流程也是我在一台全新服务器上一步步试过的按照上面的步骤走基本不会出大问题。如果你在本地启动或者部署过程中卡在某个细节可以重点回头看两个地方数据库连接配置里的时区参数以及Nginx的try_files和proxy_pass斜杠问题90%的报错都出在这两处。