
去年帮一位学弟审毕设题目就是基于B/S的美食网站设计与实现。他最初的设想很轻松做几个漂亮页面把菜品图片摆上去能登录、能下单就算完成。真正动手后才发现页面反而是最不花时间的部分评分老师真正看重的是需求是否完整、业务流程是否闭环、数据库有没有合理冗余、项目能不能在一台干净的服务器上跑起来。为了让他少走弯路我把这个项目的完整实现过程整理成了一套可复现的方案配套需求文档、数据库设计文档、部署文档和分层源码全部对应。这篇文章就是从需求分析开始到表结构、核心模块、前端交互再到服务器部署和实测踩坑的完整复盘。如果你正在找一套可以照着做的美食网站源码和配套文档照着这个思路走能省掉不少弯路。1. 美食网站项目的需求画像不是做个页面那么简单1.1 这类项目真正的评分点在哪里我见过很多来问美食网站怎么做的同学第一版方案几乎都是照着外卖App画页面。等到答辩的时候老师问三个问题就露馅了购物车下单以后库存怎么减订单状态怎么流转管理员能不能下架一个已经被人下单的菜品这三个问题任何一个没想清楚代码写得再漂亮也拿不到高分。课程设计或者毕业设计的评分逻辑其实和公司里的项目验收很像。页面美观度只是第一档真正拉开差距的是三个维度功能完整性、业务闭环、数据库设计合理性。所谓功能完整性不是说你有十个页面而是前台从注册登录到下单评论后台从菜品维护到订单处理两条业务线都能走通业务闭环则体现在库存、订单、支付状态这些数据能互相咬合数据库设计合理性看的是有没有冗余字段、有没有该建的索引、有没有把状态字段设计成能支撑业务流转的整形状态机。所以做这个项目前我先把需求文件写成了用例清单。这份文档不是摆设后面每一张表、每一个Controller都能对应上答辩的时候老师指着需求文档问这个功能在哪三分钟就能定位到代码和SQL脚本。1.2 前台展示与后台管理的功能清单美食网站表面上是一个展示平台拆开看其实是两条业务主线前台是食客的浏览和交易流程后台是管理员的运营和维护流程。我最初整理的功能清单如下。端功能模块说明前台用户注册登录用户名密码注册BCrypt加密存储登录后Session保存会话前台菜品分类浏览按川菜、粤菜、家常菜、甜点饮品等分类筛选前台菜品搜索支持菜名关键字模糊查询分页展示前台菜品详情展示图片、价格、库存、描述以及用户评论列表前台购物车加入、修改数量、删除、清空实时合计金额前台下单结算填写收货人信息生成订单并扣减库存前台我的订单按状态查看订单支持取消待支付订单前台评论与收藏已登录用户可对菜品评论可收藏喜欢的菜品后台分类管理新增、编辑、删除菜品分类后台菜品管理菜品CRUD、图片上传、上下架切换后台订单管理查看所有订单发货、完成、取消操作后台用户管理查看用户列表禁用异常账号后台数据概览统计用户数、菜品数、订单数、销售额非功能需求容易被忽略但也很关键。我要求页面兼容Chrome和Edge移动端至少能正常浏览所有表单都做前端校验和后端校验密码不能明文入库图片上传要做类型和大小限制。这些点不需要写多少代码但文档里写上、代码里落实评语里就会多一句考虑得比较全面。2. 技术选型为什么B/S架构是这类项目的最优解2.1 B/S架构的本质与三层结构先明确一个最基本的概念。B/S架构就是Browser/Server架构用户不需要安装任何客户端只要打开浏览器输入网址就能访问服务器上部署的应用。美食网站面向的人群是分散的食客如果用传统C/SClient/Server架构意味着每个用户都要下载安装包升级一次还要全网重新安装这在互联网场景下是不可接受的。B/S架构天然分成了三层表现层就是浏览器里看到的页面业务逻辑层处理注册、下单、库存判断这些规则数据访问层负责和数据库打交道。分层最大的好处是改起来不牵连。比如我想把菜品搜索从模糊查询升级成全文检索只需要改Service层和对应的Mapper页面和数据库连接都不用动再比如前台页面从Thymeleaf换成Vue只要Controller把数据返回成JSON表现层整体替换业务层和数据层可以原封不动。我做这个项目的时候把代码分层固定成了controller、service、mapper、entity四层。Controller只做参数接收和视图转发Service写业务逻辑Mapper写SQLEntity对应数据库表。很多课设代码把业务逻辑写在Controller里一个方法几百行看着省事后面调试和加功能的时候就知道苦头了。2.2 技术栈怎么选Java、Python还是PHP技术栈选择要结合这是课程设计/毕设这个场景来考虑。不是越新越好也不是越简单越好而是资料多、好验证、能说清楚原理最好。我做了个对比表格纯个人经验。技术栈优点缺点适合场景Spring Boot MyBatis-Plus MySQL生态成熟中文资料极多结构化好启动重概念多课设/毕设首选答辩好讲Flask / Django SQLite/MySQL轻量上手快代码量小大型项目规范需要自己把控偏Python方向的项目PHP MySQL部署简单老项目多工程化体验一般快速原型Node.js Express MongoDB前后端统一JS开发快事务和关系型数据场景不如MySQL直观偏后端的Web方向我最终选的是Spring Boot 3 MyBatis-Plus MySQL 8 Thymeleaf Bootstrap 5。理由是这套组合非常适合B/S架构美食网站这个题目Spring Boot让配置变得极简MyBatis-Plus原生支持分页插件和Lambda条件构造器省掉大量手写SQLThymeleaf做服务端页面渲染正好符合B/S架构服务端生成页面的教学点Bootstrap 5可以快速把后台管理页面做得像模像样不需要花太多时间在前端调样式上。这里说明一下如果你更熟悉Python用Flask或Django搭出来的系统结构完全一样路由对应ControllerORM模型对应Entity模板对应前端页面。后面的部署部分我也会给出Python项目的启动方式思路是互通的。3. 数据库设计菜品、分类、订单与评论的表结构落地3.1 核心表结构设计数据库是这类项目的命根子。表设计得好Service层写起来像填空表设计得烂后面每个查询都是地狱。我最终定了七张核心表sys_user用户表、category菜品分类表、dish菜品表、cart购物车表、orders订单表、order_item订单明细表、comment评论表。收藏功能我单独建了一张favorite表避免在用户表里堆一个逗号分隔的字段。先看用户表和菜品表这两个是最基础的实体。CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), avatar VARCHAR(255), role TINYINT DEFAULT 0 COMMENT 0-普通用户 1-管理员, status TINYINT DEFAULT 1 COMMENT 1-正常 0-禁用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE dish ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(255), status TINYINT DEFAULT 1 COMMENT 1-在售 0-下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;特别注意把金额字段设计成DECIMAL(10,2)而不是FLOAT或DOUBLE。浮点数在累加运算时会有精度误差订单金额算错了是要出事的。菜品库存字段用INT下单扣库存必须放在事务里防止并发情况下库存变负数这个在第4章展开。订单表和订单明细表是核心中的核心。CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待付款 1-待发货 2-待收货 3-已完成 4-已取消, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), receiver_address VARCHAR(255), remark VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, dish_id INT NOT NULL, dish_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么订单表里不直接存一份购物车JSON为什么order_item要单独一张表原因有两个。第一一个订单包含多个菜品一张表存不下这种一对多关系第二下单之后菜品可能改名、改价格订单明细里如果不做快照用户回看历史订单时看到的可能就是另一个价格了。所以我在order_item表里冗余了dish_name和price字段这叫历史快照是订单系统设计里很基本的思维。3.2 外键、索引与初始化数据我没在表之间建物理外键约束只保留了逻辑外键。原因很实际MyBatis-Plus做连表查询时物理外键还会限制删除顺序给课程设计增加不必要的麻烦。逻辑外键的意思是dish.category_id指向category.id这种关系由代码去保证数据库层面不设FOREIGN KEY。这样删数据灵活也不影响你写SQL时用JOIN。索引设计上面已经体现了dish表的category_id和nameorders表的user_id和statusorder_item表的order_id。这些索引不是乱加的都是实际查询里WHERE和ORDER BY经常用到的字段。比如后台按状态查订单、前台按分类查菜品没有索引的表数据量一大就会全表扫描。初始化数据我准备了一个food.sql里面包含一个管理员账号用户名admin、密码加密后存储、几个默认分类以及二十道左右的示例菜品。这里有个易错点管理员密码不能像普通数据一样用明文插入必须是BCrypt加密后的字符串否则注册时加密、登录时比对的行为不一致管理端永远登不进去。初始化数据不要贪多够演示和测试就行重点是每个菜品的分类ID要和category表对得上。订单状态字段我用的是TINYINT数字状态机。0到4分别代表待付款、待发货、待收货、已完成、已取消。这样的好处是数据库体积小、查询快前端再用th:switch把数字映射成中文标签显示。比直接存字符串待发货要规范也比搞一套完整的工作流引擎要简单得多对课设来说刚好踩在正确的位置上。4. 核心模块实现从登录到购物车的完整链路4.1 注册登录与权限控制用户模块看起来简单但安全细节不能省。密码我用的BCrypt加密不是MD5也不是SHA。BCrypt每加密一次会生成随机的盐同密码两次加密结果不同数据库中即使被拖库反查概率也很低。Spring Security里可以直接用BCryptPasswordEncoder不加整个Security框架也行单独引一个spring-security-crypto依赖就够了。登录成功之后我把用户对象放进Session。为什么不用JWT因为这个项目是服务端渲染的B/S架构页面跳转靠Session天然合理服务端可以随时让会话失效。JWT更适合前后端分离、移动端App那种场景放在这里徒增复杂度答辩时还要花精力解释为什么用JWT不用Session纯属自己给自己挖坑。权限控制我写了一个拦截器拦截除登录页、注册页、菜品浏览页之外的所有路径。管理员路径/admin/**额外校验role字段不是管理员直接返回403。public class AuthInterceptor implements HandlerInterceptor { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user (User) request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } if (request.getRequestURI().startsWith(/admin/) !Integer.valueOf(1).equals(user.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }这里有个新手常犯的错user.getRole()如果是Integer类型用比较的是引用地址哪怕值相同也可能为false。所以要用Integer.valueOf(1).equals(...)或者把角色字段定义成int基本类型。我见过不下三次这种Bug页面权限莫名失效最后都是栽在包装类型比较上。4.2 菜品分类、分页与搜索菜品列表页是前台访问量最大的页面。我用MyBatis-Plus的分页插件配合Lambda条件构造器一次性解决分类筛选、关键字搜索、分页三个问题。GetMapping(/dish/list) public String list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 12) Integer size, RequestParam(required false) Integer categoryId, RequestParam(required false) String keyword, Model model) { PageDish p new Page(page, size); LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.eq(categoryId ! null, Dish::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Dish::getName, keyword) .eq(Dish::getStatus, 1); dishService.page(p, wrapper); model.addAttribute(page, p); return dish/list; }这段代码里容易忽略的是前台的菜品查询永远要带status 1条件。不然管理员下架的菜品照样出现在食客端逻辑上就穿帮了。后台菜品管理页面查菜时则要覆盖所有状态所以后台的查询接口需要单独写不能直接复用前台的Service方法。分页插件需要在配置类里注册MybatisPlusInterceptor并添加PaginationInnerInterceptor这一步漏掉的话Page参数会被当成普通数据传入查出来的数据不分页这是MyBatis-Plus最经典的配置问题之一。4.3 购物车与下单的事务处理下单是整个项目最考验逻辑的部分。购物车表cart只存user_id、dish_id、quantity真正下单的时候不能只把购物车数据扔给订单表就完事必须重新查一次菜品价格和库存。下单流程我设计成五步第一步遍历购物车查出每个菜品的当前价格和库存第二步校验菜品是否在售、库存是否够第三步创建订单主记录算总金额第四步批量插入订单明细同时扣减库存第五步清空购物车。这五步必须在一个事务里完成任何一步失败前面的插入和扣减都要回滚。Transactional public Order createOrder(Long userId, ListCartItem items, OrderAddress address) { BigDecimal total BigDecimal.ZERO; for (CartItem item : items) { Dish dish dishMapper.selectById(item.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BusinessException(菜品已下架 item.getDishId()); } if (dish.getStock() item.getQuantity()) { throw new BusinessException(库存不足 dish.getName()); } total total.add(dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); order.setReceiverInfo(address); orderMapper.insert(order); for (CartItem item : items) { Dish dish dishMapper.selectById(item.getDishId()); OrderItem oi new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(dish.getId()); oi.setDishName(dish.getName()); oi.setPrice(dish.getPrice()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); // 注意扣库存语句本身要带 stock quantity 条件防止超卖 int rows dishMapper.decreaseStock(dish.getId(), item.getQuantity()); if (rows 0) { throw new BusinessException(库存不足 dish.getName()); } } cartMapper.clearByUserId(userId); return order; }这里有一个很多人会踩的坑Transactional默认情况下只有RuntimeException才会触发回滚。如果我在校验失败时抛的是普通的ExceptionSpring不会回滚前面插入的订单记录就会残留在数据库里。所以业务异常要么继承RuntimeException要么在Transactional上显式声明rollbackFor Exception.class。另外一个并发细节是decreaseStock方法。最稳妥的做法不是先SELECT stock再UPDATE而是直接用一条带条件的更新语句UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}如果更新影响行数为0说明库存不够立即抛异常。这样可以避免两个用户同时抢最后一份菜品时都读到库存够、然后都扣减成功的问题。这也是我第一次做的时候没注意后来实测用两个浏览器同时下单才暴露出来的Bug。4.4 评论与收藏模块评论和收藏是两个相对独立的小功能但设计上要注意关联关系。评论表comment记录了user_id和dish_id在下单完成后才允许评论吗我采取的是宽松策略只要用户登录且购买了该菜品就可以评论。代码里会校验订单里是否包含这个菜品防止没买过的人乱刷评论。收藏功能用favorite表核心是给user_id和dish_id加唯一约束防止重复收藏。页面上的收藏按钮要做成可切换状态已经收藏显示红心点击后取消收藏没收藏显示空心点击后写入收藏。这个状态的判断在菜品详情页渲染时通过当前用户ID去收藏表里查一次即可数据量不大效率上没问题。5. 前端页面与交互Bootstrap Thymeleaf 的快速实现5.1 前台页面结构与交互前端我用Thymeleaf做服务端渲染没有单独拆Vue工程。原因很直接这个项目的核心是B/S架构的教学演示服务端生成页面本身就是B/S的一部分用Thymeleaf可以让Controller返回数据后直接在模板里循环渲染不用额外处理跨域、不用写一堆Ajax调用。一套Vue分离项目做下来前端代码量比后端还大课设的时间成本会失控。页面布局用Bootstrap 5的栅格系统。首页是导航栏加轮播图下面是分类标签和菜品卡片列表。一个简单的菜品卡片模板div classcol th:eachdish : ${page.records} div classcard h-100 img th:src${dish.image} classcard-img-top alt菜品图片 div classcard-body h5 classcard-title th:text${dish.name}菜名/h5 p classcard-text th:text${dish.description}菜品描述/p span classtext-danger fw-bold th:text¥ ${dish.price}价格/span a th:href{/dish/detail/{id}(id${dish.id})} classbtn btn-primary btn-sm float-end查看详情/a /div /div /div这里有个实用的细节菜品图片如果没有上传数据库里存的是空字符串前端渲染的img就是个裂图。我在页面上加了th:if${dish.image ! null dish.image ! }判断没有图片就显示一张默认图这种小细节在演示的时候观感差别很大。菜品详情页除了基础信息还会显示评论列表。我直接复用分页组件评论列表单独分页。详情页里加入购物车按钮需要用户登录才能操作未登录时点击会跳转登录页这个逻辑写在Controller里同时也在页面上用th:if判断当前Session是否为空来切换按钮文案。购物车页面是一个表格每行有菜品名、单价、数量输入框、小计和删除按钮。数量修改用一个简单的加减按钮配上隐藏的菜品ID点击后提交到后端更新购物车。购物车的合计金额我用JavaScript在前端实时计算后端在下单时重新计算一次以第二次为准防止用户篡改页面金额。5.2 后台管理页面与权限隔离后台页面我放在了/admin/**路径下和前台完全隔离。管理员登录后导航栏会多出后台管理入口这个入口同样用th:if判断当前用户角色普通用户看不到。后台管理页面主要三个大块仪表盘、菜品管理、订单管理。仪表盘我做了四个统计卡片用户总数、菜品总数、订单总数、营收总额数据通过一个DashboardController查询后填充。菜品管理是一个表格每行有图片缩略图、名称、分类、价格、库存、状态操作按钮包含编辑、上下架、删除。图片上传我用MultipartFile接文件保存到服务器本地的/uploads目录数据库只存相对路径。订单管理表格的每一行会根据订单状态显示不同的操作按钮。待发货可以点发货待收货可以点完成待付款可以点取消。这些状态流转按钮背后就是更新orders表的status字段同时往操作记录里写一条日志。为了演示方便我没有做完整的操作日志表而是在代码里用System.out打印关键操作答辩时看控制台输出就能讲清楚流程。5.3 响应式适配与表单校验Bootstrap的栅格系统天然支持响应式。桌面端卡片一行显示四列平板两列手机上单列。关键是把col-lg-3 col-md-4 col-sm-6 col-12这些类加到卡片外层容器上页面不需要额外写媒体查询。表单校验我做了前后端两层。前端用Bootstrap自带的required、maxlength这些属性做基础校验比如手机号格式、库存数字范围。后端在Controller里用Valid配合参数校验注解Spring Boot 3里可以直接用jakarta.validation.constraints包下的注解。有一点必须提醒前端校验永远只是体验优化后端校验才是安全底线。因为请求可以被绕过浏览器直接拿Postman发如果后端不校验非法数据就会进数据库。6. 部署到服务器的完整流程从本地跑通到外网可访问6.1 环境准备与打包很多人做课设习惯只在本地IDEA里跑通答辩前一周才发现服务器上跑不起来那是很被动的。部署这件事至少要在提交前完整走一遍。我的部署环境用的是最低配的云服务器2核2G就够系统是Ubuntu 20.04。需要安装的东西就三样JDK 17、MySQL 8、以及打包用的Maven也可以本地打包好再把jar传上去服务器上不装Maven。本地先执行打包命令mvn clean package -DskipTests打包完成后在target目录下会生成food-sys-1.0.0.jar。这里有个细节打包前一定要把测试类的SpringBootTest注释掉或者跳过不然打包时会尝试连接本地数据库连不上直接构建失败。用-DskipTests只是跳过测试执行比注释测试类靠谱。上传jar包可以用scp命令也可以直接用宝塔面板的文件管理器拖拽上传传到一个固定目录比如/www/app/。Windows和Linux之间的文件传输没有太多坑唯一要注意的是配置文件别用记事本编辑后直接上传UTF-8编码容易变成带BOM头Spring Boot读取配置可能报错。6.2 数据库导入与配置修改服务器上数据库需要手动创建注意字符集。直接执行CREATE DATABASE food CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后导入SQL文件mysql -uroot -p food food.sql接着修改application-prod.yml把数据库地址从localhost:3306改成服务器的内网地址或者直接保持localhost数据库账号密码换成服务器上实际的账号密码。另外要改两处地方一是MySQL连接字符串里的时区参数建议写serverTimezoneAsia/Shanghai不然数据库和Java程序之间容易出现8小时的时差导致订单创建时间显示成凌晨。二是文件上传路径本地开发我用的D:/upload/Linux上要改成绝对路径/www/uploads/并且确保目录存在、运行Java程序的用户有写权限。6.3 启动、防火墙与反向代理启动命令我用的是nohup加后台运行nohup java -Xms256m -Xmx512m -jar /www/app/food-sys-1.0.0.jar --spring.profiles.activeprod /www/app/nohup.out 21 -Xms256m -Xmx512m是限制JVM的堆内存。2G内存的服务器如果不限制堆Spring Boot默认可能吃掉一大半系统别的进程就会很卡。启动后立刻看一下日志tail -f /www/app/nohup.out看到Started FoodSysApplication基本就成功了。如果端口没起用netstat -tlnp | grep 8080确认。让外网用户通过80端口访问最省事的方式是装一个Nginx做反向代理。配置核心片段如下server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads/ { alias /www/uploads/; } }如果你用的是Python Flask实现部署逻辑大体一致只是启动进程从java -jar换成Gunicornpip install gunicorn nohup gunicorn -w 4 -b 0.0.0.0:8080 app:app gunicorn.log 21 最后不要忘了在云服务器的安全组里放行SSH端口、80端口和443端口。如果配置了Nginx8080端口只需要在内网监听不用对公网开放这样更安全。6.4 几个部署侧边问题部署阶段还有一个常见问题jar包更新了但进程还是旧版。原因是没杀掉旧进程就重新启动8080端口被占用新进程起不来。这时候先找进程再杀ps -ef | grep food-sys.jar kill -9 进程号另外一个容易被忽略的是上传目录权限。Java进程如果是一个专门的服务用户启动的而上传目录是root创建的写入就会失败。我会把/www/uploads的属主改成启动进程的用户改完再验证一次上传功能。7. 实测踩坑记录这些细节文档里不会写7.1 图片裂图与保存路径的坑我最初把上传的图片保存到项目源码的static/uploads目录下本地运行一切正常打包部署后访问图片全部裂图。原因很简单jar包运行起来以后项目内部路径是只读的临时目录不可能往里写文件。正确做法是把上传目录独立出来比如Linux下放/www/uploadsNginx配置一个/uploads/别名指向它数据库里存/uploads/xx.jpg这种相对路径页面直接拼接访问。这个问题我花了半天才排查清楚写下来希望后面的人直接避开。7.2 MySQL 8驱动名和时区问题我用的MySQL 8驱动类名是com.mysql.cj.jdbc.Driver不少旧教程还在用com.mysql.jdbc.Driver直接启动报错。连接字符串里的serverTimezoneAsia/Shanghai也不能省否则时间字段整体偏8个小时。这个问题在本地开发时往往发现不了因为开发机和数据库在同一时区部署到云服务器后就会冒出来。7.3 Session失效与事务不回滚有两个问题我是在演示当天才发现的。第一个是Session超时时间默认30分钟演示时讲解拖长了回头刷新页面发现登录态没了站在台上很尴尬。我最后把Session超时时间调整到了2小时虽然不推荐生产环境这么做但课设演示确实有需要。第二个是Transactional失效问题。我在同一个类里写了一个createOrder方法在里面直接调用this.sendSms方法结果sendSms抛异常订单数据也没回滚。原因是Transactional是基于代理实现的同一个类内部的this调用不会经过代理对象注解就失效了。解决方式是拆分Bean或者把需要事务保护的方法单独放到一个Service类里从外部注入调用。7.4 Windows本地与Linux服务器的路径分隔差异本地开发是Windows服务器是Linux最容易踩的就是路径。我写文件保存路径时一开始用了File.separator这只能保证平台自适应但配置文件里的路径分隔符还是要注意。比如Linux的绝对路径是/www/uploads/Windows里如果配成D:\upload\反斜杠在配置里需要转义。更省心的做法是配置里统一用正斜杠/Java在Windows上也能识别/分隔符不会出问题。7.5 懒加载与JSON序列化这个坑是我后期扩展接口时遇到的菜品和分类之间有关联关系我图省事在实体类里加了一个Category category字段查询时用ManyToOne懒加载。结果在前端渲染或者返回JSON的时候Jackson序列化触发懒加载一旦Session关闭就报LazyInitializationException。这个问题的常规解法是用JsonIgnore忽略不需要序列化的关联字段或者把关联查询做成显式DTO只返回必要字段。课程设计里不要为了省事把关联对象直接序列化给前端极容易踩坑。整体做完这个项目我最大的体会是代码反而是最不值钱的部分真正花时间的是把业务逻辑想清楚、把表设计好、把部署验证完。如果你照着这个思路去复现哪怕不用我这套源码自己用Flask或者Django搭一套流程和坑基本也都是这些。最后再提一个小建议拿到源码先别急着跑把food.sql导入数据库后先把配置文件的账号密码改了再启动项目否则排查问题时会混入环境因素。祝一次通过。