
前阵子有个做毕设的同学来问我能不能把一套“基于SpringBootVue的美食推荐商城”做成真正能跑起来的项目而不是网上那种只有登录注册的教学Demo。我干脆自己完整做了一遍从数据库建模到推荐算法落地从前端路由到Docker部署一路踩了不少坑。今天这篇就把整套系统的设计与实现过程拆开来讲内容包括技术选型思路、MySQL表结构设计、MyBatis动态SQL和批量插入的真实问题、JWT登录拦截、基于用户行为的推荐策略、Vue3路由与请求封装以及最后从Eclipse报错到Docker部署的完整避坑记录。无论你是拿它做毕业设计、课程项目还是想给中小型餐饮商家做一个独立站MVP这篇都能给你一份可以直接复刻的参考路径。1. 项目定位美食推荐商城到底要解决什么问题很多人在动手写商城系统前会把需求想得特别大——秒杀、优惠券、分销、直播带货全都塞进去。等你真把表建完、接口写了一半就会发现项目推进不下去。我这次做美食推荐商城第一件事就是给产品边界做减法。1.1 典型使用场景这套系统的核心使用场景有三个用户场景访客浏览推荐首页通过“今日热门”“猜你喜欢”发现美食搜索菜品关键词查看详情、加购物车、下单、评价。商家/运营场景后台维护菜品分类、上下架菜品、处理订单、查看销售统计。技术演示场景作为前后端分离架构、主流Java技术栈的完整示例项目展示从数据层到展示层的全链路实现。我最终确定的功能模块如下模块功能点说明用户模块注册、登录、个人中心JWT无状态登录区分普通用户与管理员推荐模块热门榜单、猜你喜欢、基于标签的推荐热度加权 简化协同过滤菜品模块分类浏览、关键词搜索、详情展示支持按价格、销量、评分排序购物车模块加购、改数量、批量结算批量插入场景订单模块下单、订单状态流转、订单列表事务与库存扣减评价模块打分、评论内容影响菜品评分管理后台菜品/分类/订单管理、数据统计与用户端同源部署1.2 为什么不做成企业级中台明确一点这个定位是轻量级商城系统的标准形态目标不是替代企业级电商中台而是把业务主链路跑通。秒杀这类高并发场景需要消息队列、分布式锁、库存分片对毕设或者中小型项目来说属于过度设计。我个人的建议是先把“商品→购物车→订单→支付回调模拟→评价”这条闭环做扎实再谈扩展。1.3 角色与权限设计的简化思路我用了一张role字段区分管理员和普通用户没有引入Spring Security的完整RBAC模型。原因很简单项目规模不大引入过重框架会产生大量配置样板代码反而掩盖了核心业务逻辑。但我在接口层面做了拦截器校验管理员接口与用户接口分开这样既保证安全又不失简洁。2. 技术选型与版本组合2025年还这么配吗技术选型不是越新越好而是要兼顾稳定性、生态成熟度和可维护性。这套系统选型非常经典后端SpringBoot MyBatis MySQL前端Vue3 Element Plus Pinia。2.1 为什么是SpringBoot MyBatis MySQL这三件套在JavaWeb领域能屹立不倒核心原因在于“可靠 资料多 招人容易找”。SpringBoot简化了Spring的配置内嵌Tomcat一套java -jar就能启动天然适合前后端分离项目。MyBatis相比JPAMyBatis最大的优势是SQL完全可控。商城系统的列表筛选、报表统计、多表关联都是SQL强项场景用MyBatis写动态SQL非常顺手。MySQL轻量、部署简单、生态完善对这个量级的项目完全够用。如果你问我现在2025年还有没有更优选择我的回答是这个组合依然是最稳妥的。除非你明确需要强一致性事务、复杂聚合查询或者ORM自动建表否则不需要引入PostgreSQL或MyBatis-Plus之外的偏重方案。2.2 前后端分离与Vue3的选择前后端分离是这个项目的基本形态。后端只提供JSON接口前端独立部署。Vue3 Vite Vue Router 4 Pinia是当前Vue生态的标准组合。组件库我在Element Plus和Ant Design Vue之间最终选了Element Plus主要原因是它文档全、社区活跃、表单和表格组件对后台管理场景支持很好。2.3 版本兼容性SpringBoot 2.7还是3.x这是整个项目里第一个大坑。标题写“2025最新”但“最新”不等于“盲目升版”。SpringBoot 3.x要求JDK17如果你的开发环境是JDK8强行上3.x会导致启动报错、依赖冲突甚至javax和jakarta包名迁移问题——3.x中javax.servlet全部变成了jakarta.servlet老教程里的代码直接编译不过。我的建议学习/毕设/中小型项目使用SpringBoot 2.7.x JDK8生态最稳网上资料最多。新项目且团队已统一JDK17可以用SpringBoot 3.x但要做好包名迁移和依赖版本检查。MyBatis对应使用mybatis-spring-boot-starter2.3.x或MyBatis-Plus 3.5.x注意与SpringBoot大版本匹配。3. 数据库设计推荐、订单与商品数据模型数据库设计是商城系统的地基。表结构不合理后面所有接口都会写得别扭。我这次按“从业务反推表结构”的方式来设计优先考虑推荐策略和订单闭环需要哪些数据。3.1 核心表结构一览表名用途关键字段user用户表id, username, password, nickname, avatar, rolecategory菜品分类表id, parent_id, name, sortdish菜品表id, category_id, name, description, price, image, sales_count, rating, favorite_count, deletedtag标签表id, namedish_tag菜品-标签关联表dish_id, tag_iduser_behavior用户行为表id, user_id, dish_id, behavior_type, tag_id, weight, create_timecart_item购物车表id, user_id, dish_id, quantity, checkedorders订单主表id, order_no, user_id, total_amount, status, create_timeorder_item订单明细表id, order_id, dish_id, price, quantitycomment评价表id, user_id, dish_id, rating, content, create_time3.2 分类树与int(5)误区分类表用parent_id自关联就能实现无限级分类。一级分类如“川菜”“粤菜”二级分类如“火锅”“烧烤”。后台维护时只需操作parent_id字段前端用递归组件渲染树形菜单。这里要特别说一个MySQL高频误区int(5)不代表“只能存5位数”。int类型无论括号里写几存储范围永远是int的4字节范围-2147483648到2147483647括号里的数字只是“显示宽度”且只有配合zerofill时才有效果。我看到很多人建表时写int(11)其实没毛病但别误以为它能限制数据长度。真正限制长度的是varchar。3.3 价格与金额字段的类型选择价格一定要用decimal不能用float或double。原因很简单浮点数在二进制中无法精确表示算钱会出现0.10.2不等于0.3的问题。我用的是decimal(10,2)也就是整数部分最多8位、小数2位对绝大多数餐饮场景足够。3.4 索引与逻辑删除设计查询频率最高的场景是“推荐首页”和“菜品列表页”所以我在dish表的category_id、sales_count、rating、create_time上建了组合索引。组合索引的生效遵循最左前缀原则因此查询条件里必须包含最左字段否则索引失效。查询条件按category_id sales_count组合时索引顺序是(category_id, sales_count, rating)。逻辑删除字段deleted是MyBatis-Plus生态里很常用的设计。默认查询时自动拼上deleted 0防止用户误删数据。但要注意如果你的菜品名称建了唯一索引逻辑删除后再插入同名菜品会报唯一索引冲突。我当时的处理方案是不做名字级唯一约束而是用category_id name维度在业务层判断重复如果你的项目必须保留历史记录且要重复插入可以引入“删除标记名称”的联合唯一索引例如deleted字段存自增ID而不是固定的1不过会增加复杂度小项目不建议。4. 后端实现从登录认证到推荐算法的落地后端部分我会拆开讲项目结构设计、JWT认证、动态SQL、批量插入、推荐策略与定时任务。每个环节都有对应的“为什么这么做”。4.1 项目分层与统一返回结构后端包结构如下com.example.foodmall ├── common // 统一返回结果、全局异常、常量 ├── config // 拦截器、跨域配置、MyBatis配置 ├── controller // 接口层 ├── service // 业务层 ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体 ├── dto // 入参对象 ├── vo // 出参对象 └── util // JWT、分词等工具类统一返回结构我用了一个简单的ResultT类Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(success); r.setData(data); return r; } public static T ResultT error(Integer code, String msg) { ResultT r new Result(); r.setCode(code); r.setMsg(msg); return r; } }配合RestControllerAdvice全局异常处理器接口层会干净很多。业务异常、参数校验异常、未知异常全部统一返回JSON结构前端拦截器只需要判断code即可。4.2 JWT登录与安全拦截登录流程用户提交用户名密码。后端用BCrypt验证密码不要用MD5存密码。验证通过后生成JWTtoken里放userId和role设置过期时间我设的是24小时。前端把token存localStorage请求时放在Authorization: Bearer token头里。后端拦截器解析token把用户信息放进ThreadLocal供后续接口直接取用。拦截器核心代码Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册等白名单接口 if (request.getRequestURI().contains(/auth/login)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录或token已过期); } Claims claims JwtUtil.parseToken(token.replace(Bearer , )); UserContext.set(claims); return true; } }这里有个容易被忽略的细节Authorization头不是所有前端请求都会带因此白名单放行必须在改CORS配置之前确认好路径匹配逻辑。我在配置文件里用addPathPatterns(/api/**).excludePathPatterns(/api/auth/**)来管理。如果要做更严格的接口安全可以参考API Key Signature方案调用方申请apikey请求参数加上时间戳和随机串用HMAC-SHA256计算签名服务端校验签名并拒绝超过5分钟的请求。商城后端如果要开放接口给第三方这一步建议加上。4.3 MyBatis动态SQL条件搜索与排序菜品列表页最常见的需求是“多条件筛选排序”这正是MyBatis动态SQL的主场。不要用Java代码去拼接SQL字符串那样既难维护又容易有SQL注入风险。正确做法是在XML里用where、if、choose标签select idselectByCondition resultTypecom.example.foodmall.entity.Dish SELECT * FROM dish where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testtagId ! null AND id IN (SELECT dish_id FROM dish_tag WHERE tag_id #{tagId}) /if /where ORDER BY choose when testorderBy salessales_count DESC/when when testorderBy ratingrating DESC/when when testorderBy price_ascprice ASC/when otherwisecreate_time DESC/otherwise /choose /select注意两个坑if testname ! null and name ! 里字符串比较常被写成name ! 注意引号必须配对。XML里小于号会被解析成标签开始所以要用lt;。如果name参数传了空白字符串! 判断会失效建议在Service层先trim()再传参。4.4 批量插入与逻辑删除的坑购物车结算时需要把多条购物车记录写入订单明细表。如果不做任何处理一条条insert会产生大量网络往返性能很差。批量插入的优化方案是使用foreach拼一条SQLinsert idbatchInsert INSERT INTO order_item (order_id, dish_id, price, quantity) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.dishId}, #{item.price}, #{item.quantity}) /foreach /insertJava侧控制批量大小public void batchInsertOrderItems(ListOrderItem items) { int batchSize 500; for (int i 0; i items.size(); i batchSize) { ListOrderItem subList items.subList(i, Math.min(i batchSize, items.size())); orderItemMapper.batchInsert(subList); } }批量插入还有一个隐藏优化点MySQL连接串上加rewriteBatchedStatementstrue会让JDBC驱动把批量语句重写为真正的多值插入性能提升非常明显。再说一个我从热搜词里看到的高频问题MyBatis-Plus insert数据没有写成功但也不报错。我当时调试时也遇到过最终原因是实体类主键策略没配对。TableId(type IdType.AUTO)对应数据库自增主键如果不写这个注解MyBatis-Plus会用默认的雪花ID策略而如果数据库主键字段是int类型雪花ID远超int范围数据插不进去还被静默吞了。建议主键字段统一用Long类型或者显式声明主键策略。4.5 推荐策略热门榜与协同过滤简化版推荐模块是“美食推荐”的点题之处。但这个项目里的推荐不需要也不会用上深度学习模型。我的实现分两套第一套是热度榜直接按加权评分排序SELECT id, name, price, sales_count, rating, ROUND(0.5 * sales_count 0.3 * rating * 10 0.2 * favorite_count, 2) AS heat_score FROM dish WHERE deleted 0 ORDER BY heat_score DESC LIMIT 10这个公式把销量、评分0-5分乘以10变成0-50分、收藏数统一到一个量纲里。初始数据量小时手动给部分菜品增加一些基础销量避免榜单全是0。第二套是基于用户行为的“猜你喜欢”简化协同过滤。核心思想在user_behavior表里记录用户对菜品标签的偏好浏览/收藏/下单分别记权重然后找到行为最相似的用户把这些用户喜欢的菜品推荐给当前用户。Java侧计算用户相似度可以用余弦相似度public double cosineSimilarity(MapLong, Integer userVector, MapLong, Integer otherVector) { SetLong commonKeys new HashSet(userVector.keySet()); commonKeys.retainAll(otherVector.keySet()); double dotProduct 0; double userNorm 0; double otherNorm 0; for (double v : userVector.values()) { userNorm Math.pow(v, 2); } for (double v : otherVector.values()) { otherNorm Math.pow(v, 2); } for (Long key : commonKeys) { dotProduct userVector.get(key) * otherVector.get(key); } if (userNorm 0 || otherNorm 0) { return 0; } return dotProduct / (Math.sqrt(userNorm) * Math.sqrt(otherNorm)); }这段代码不难理解核心就是把每位用户映射成一个“标签偏好向量”两个用户之间的相似度就是两个向量的夹角余弦值取值范围在-1到1之间越接近1说明越相似。冷启动阶段用户行为数据少可以加一个兜底逻辑如果相似用户太少就回退到热度榜。4.6 定时榜单与全文分词搜索每日榜单用Spring的Scheduled实现Component public class HotRankTask { Scheduled(cron 0 0 3 * * ?) public void refreshDailyRank() { ListDish hotList dishMapper.selectHotList(50); hotRankService.saveDailyRank(hotList); } }cron 0 0 3 * * ?表示每天凌晨3点执行。凌晨跑定时任务的好处是避开高峰期不影响用户查询。搜索模块我接入了HanLP分词解决中文菜名的分词问题。默认分词器会把“红烧肉”切分成“红烧”和“肉”导致搜索结果不够精确。接入HanLP后在自定义词典里维护菜品名称和常见食材词比如“红烧肉”“水煮鱼”“宫保鸡丁”搜索结果会准确很多。我在此只想说一点关键词联想和文本搜索是两回事直接用LIKE %xx%照样能用但分词的搜索体验会明显好一个档次。5. 前端工程化Vue路由、请求封装与页面实现前端部分我会按“从脚手架到页面”的顺序讲重点放在路由守卫、请求封装和几个核心页面的实现细节上。5.1 项目初始化与组件库选择用Vite创建Vue3项目npm create vitelatest food-mall-web -- --template vue cd food-mall-web npm install npm install vue-router4 pinia element-plus axios sassElement Plus按需引入比全量引入打包体积小很多。我在main.js里使用官方推荐的unplugin-vue-components插件自动按需导入不用手动注册组件。Vue Devtools是调试Vue3项目的必备工具装上后可以直接查看组件树和Pinia状态排查数据流问题效率翻倍。5.2 路由守卫与权限控制路由配置const routes [ { path: /, name: Home, component: Home, meta: { title: 首页 } }, { path: /category/:id, name: Category, component: Category, meta: { title: 分类 } }, { path: /dish/:id, name: DishDetail, component: DishDetail, meta: { title: 详情 } }, { path: /cart, name: Cart, component: Cart, meta: { requiresAuth: true, title: 购物车 } }, { path: /orders, name: Orders, component: Orders, meta: { requiresAuth: true, title: 我的订单 } }, { path: /login, name: Login, component: Login, meta: { title: 登录 } }, ];路由守卫router.beforeEach((to, from, next) { document.title to.meta.title ? ${to.meta.title} - 美食商城 : 美食商城; const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });有同学会纠结要不要用动态路由根据角色动态添加路由。我的建议是如果不是特别复杂的权限模型静态路由 按钮级v-if控制就足够了。动态路由增加了很多复杂度例如刷新页面路由重新注册、权限变更后旧路由清理等对小型项目收益很低。5.3 Axios封装与接口调试Axios封装是所有前端项目里的标准动作。统一处理token、统一处理响应码、统一错误提示。import axios from axios; import { ElMessage } from element-plus; import router from ../router; 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); } ElMessage.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );开发环境要解决跨域可以在vite.config.js里配置代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, },这样前端请求/api/dish/list就会转发到后端的http://localhost:8080/api/dish/list开发时不需要后端开启CORS。5.4 推荐首页与详情页实现首页结构分三块顶部轮播图、今日热门榜、猜你喜欢列表。轮播图直接用Element Plus的el-carousel。热门榜是请求/api/recommend/hot接口拿到榜单数据后用el-card展示。猜你喜欢由/api/recommend/guess接口返回。这里要重点说computed的使用场景榜单数据的筛选、购物车总价计算这类“依赖响应式数据的派生值”用computed最合适。例如购物车页的总价const cartItems ref([]); const totalPrice computed(() { return cartItems.value .filter(item item.checked) .reduce((sum, item) sum item.price * item.quantity, 0); });computed的特点是基于依赖缓存只有依赖的响应式数据变化时才重新计算比在模板里每次渲染时都执行方法高效。5.5 购物车状态管理与m3u8视频播放购物车状态我用Pinia管理并加上持久化插件保证刷新页面后购物车数据不丢失export const useCartStore defineStore(cart, { state: () ({ items: [], }), getters: { totalCount: state state.items.reduce((sum, item) sum item.quantity, 0), totalPrice: state state.items.reduce((sum, item) sum item.price * item.quantity, 0), }, actions: { addToCart(dish) { const exist this.items.find(item item.dishId dish.id); if (exist) { exist.quantity; } else { this.items.push({ dishId: dish.id, name: dish.name, price: dish.price, quantity: 1 }); } }, }, persist: true, });菜品详情页如果展示制作过程视频我用的是m3u8格式的视频流。原生video标签对m3u8的支持有限Chrome浏览器播放m3u8需要借助hls.jsimport Hls from hls.js; function playVideo(videoUrl) { const video document.getElementById(video); if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(video); } else if (video.canPlayType(application/vnd.apple.mpegurl)) { video.src videoUrl; } }注意hls.js只在浏览器环境可用服务端渲染或者SSG场景需要判断window是否存在。m3u8这个格式在直播和长视频点播场景很常见商城菜品演示视频如果来自云点播服务大概率就是这种格式前端适配一次以后都能复用。6. 环境、测试与部署从本机到Docker的避坑记录这一章是全文信息量最密集的部分。我回头看了一遍整个开发过程真正浪费时间的不是业务代码而是环境配置和部署环节。6.1 MySQL安装配置与Workbench使用MySQL 8.x安装时最容易出问题的就是认证插件。8.0默认使用caching_sha2_password而很多老版本驱动和工具不兼容导致连接报错。解决方案有两种升级JDBC驱动使用com.mysql.cj.jdbc.Driver并在连接串中明确指定useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。创建用户时指定old认证插件CREATE USER root% IDENTIFIED WITH mysql_native_password BY 123456;我推荐用方案一因为mysql_native_password在新版本里已经标记为不推荐。日常开发用MySQL Workbench建表、跑SQL脚本、看ER图很方便。但要注意Workbench生成的INSERT语句对中文字符集比较敏感连接时确认数据库字符集是utf8mb4否则菜品描述里存emoji或特殊字符会报Incorrect string value错误。6.2 Eclipse中SpringBoot集成MyBatis一直报错的排查链路这是一个典型的“照视频敲代码但跑不起来”的场景。排查顺序应该固定下来按下面这条链路走看Maven依赖mybatis-spring-boot-starter版本是否和SpringBoot版本兼容。如果是SpringBoot 2.7.x用mybatis-spring-boot-starter 2.3.2没问题SpringBoot 3.x要换mybatis-spring-boot-starter 3.0.3。看启动类有没有加MapperScan(com.example.mapper)。没加这个注解所有Mapper接口都不会被扫描到注入时报错。看application.ymlspring.datasource.url是否正确写了jdbc:mysql://localhost:3306/foodmall?useSSLfalseserverTimezoneAsia/Shanghaidriver-class-name是不是com.mysql.cj.jdbc.DriverMySQL 8必须带cjmybatis.mapper-locations是否指向classpath:mapper/*.xml。看target目录有时XML文件没被编译进classes目录。在pom.xml中排除resources文件时如果配置了**/*.xml排除规则就会把mapper XML一起排掉。此时只报“Invalid bound statement (not found)”但代码层面看不出来。看依赖下载状态如果Eclipse一直停留在downloading...说明Maven中央仓库访问不稳定。在settings.xml里配置阿里云镜像仓库mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror配置完成后强制更新Maven项目AltF5基本能解决大部分Eclipse集成问题。SpringBoot版本太高的问题在这条链路里也容易踩SpringBoot 3.x项目里如果沿用了老教程的javax.*包名import会出现大量编译错误。判断方法很简单——看报错信息里的包名javax.servlet要全部改为jakarta.servlet。这个迁移在2.7升3.x时是最大的隐性成本。6.3 单元测试MockMvc写接口测试单元测试这块很多人会跳过但如果你把项目交给别人评审或者用于毕设答辩测试代码是很好的加分项。SpringBoot Test MockMvc的测试方式SpringBootTest AutoConfigureMockMvc class AuthControllerTest { Autowired private MockMvc mockMvc; Test void login_success() throws Exception { String json {\username\:\admin\,\password\:\123456\}; mockMvc.perform(post(/api/auth/login) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(200)) .andExpect(jsonPath($.data.token).isNotEmpty()); } Test void login_wrong_password() throws Exception { String json {\username\:\admin\,\password\:\wrong\}; mockMvc.perform(post(/api/auth/login) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(500)); } }测试方法的命名建议直接表达业务期望比如login_success、login_wrong_password比test1、test2可读性好得多。Service层的测试如果不想污染数据库可以在测试类上标注Transactional测试结束自动回滚。6.4 Docker打包与Nginx部署Docker部署是我最后做的也踩了最多坑。先说JDK8项目的DockerfileFROM openjdk:8-jdk-alpine ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone COPY target/food-mall.jar app.jar ENTRYPOINT [java, -jar, /app.jar]时区配置必加否则后端返回的时间比北京时间少8小时。openjdk:8-jdk-alpine镜像体积小但alpine的glibc问题偶尔会导致字体或加密模块异常如果遇到NoSuchAlgorithmException换用openjdk:8-jre即可。前端用Nginx部署关键配置是history路由模式下的try_files和API反向代理server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files的作用是当用户直接访问/dish/12这样的前端路由时如果静态目录里没有这个文件就回退到index.html由Vue Router接管页面渲染。没有这行配置刷新页面就会404。6.5 上线后性能优化项目上线后我重点做了三处优化列表接口分页所有后台列表接口统一接入MyBatis分页插件PageHelper或MyBatis-Plus分页插件避免一次查全表。热点数据缓存把推荐首页的榜单数据缓存到Redis设置5分钟过期。因为榜单由定时任务每天凌晨计算短时间内不会有变化每次请求都查MySQL很浪费。SQL慢查询检查开启MySQL慢查询日志long_query_time1一周后看日志把执行超过1秒的查询都拎出来绝大部分问题出在漏加索引和全表扫描。数据库连接池我用的是默认的HikariCP没做额外调优。maximum-pool-size默认10对中小型项目够用。如果线上并发上来了再根据实际压测结果调整。最后提醒一点开发环境、测试环境、生产环境的数据库配置千万不要写在同一个application.yml里。用application-dev.yml、application-prod.yml区分启动时通过--spring.profiles.activeprod指定环境。这能避免很多“我本地跑得好好的怎么上服务器就报错”的问题。这个项目从我搭骨架到完整跑通前后花了两周多。最大的感受是技术栈本身并不难难点全在细节——MyBatis的一个XML路径、MySQL连接串的一个参数、Nginx的一行try_files任何一个地方出错都会消耗大量时间。但正是这些坑才是项目经验真正沉淀下来的部分。你如果也要做类似的商城系统建议按文中的顺序推进先表结构、再后端接口、再前端页面、最后部署不要跳步。每个阶段都留下一份测试记录后面返工的成本会低很多。