ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue3全栈实战:美食互动平台从设计到部署

SpringBoot+Vue3全栈实战:美食互动平台从设计到部署

做一个能真正跑起来的全栈项目,是我提升自己的主要方式。这次我选择的方向是美食烹饪互动平台:Java SpringBoot负责后端服务、Vue3构建前端界面、MyBatis做数据持久化、MySQL存储业务数据,典型的前后端分离架构。从最初的表结构设计到最后的服务器部署,整个过程走完后,我对这套技术栈的理解比看十遍文档都深刻。

这篇文章我不会去复述那些官方文档里已经写得很清楚的东西,而是把设计思路、表结构决策、接口约定、前端对接细节、部署时的坑,按照实际开发顺序完整记录下来。无论你是准备做类似项目的在校学生,还是想通过一个完整实战来巩固全栈能力的开发者,这篇文章都能给你一些参考。

1. 从需求到落地:美食互动平台到底该做什么

1.1 平台的核心用户与典型使用场景

动手写代码之前,我先把用户场景想清楚了。美食烹饪互动平台面向两类人:一类是愿意分享做菜经验的人,另一类是找菜谱、学做菜的人。前者需要方便地发布菜谱,包含详细的食材清单、步骤说明、成品图;后者需要快速找到感兴趣的菜谱,并且能对菜谱进行点赞、收藏、评论互动。

我设计了一套完整的功能清单,覆盖了用户从注册到日常使用的主链路:

  • 用户注册与登录(手机号/用户名 + 密码,JWT 鉴权)
  • 菜谱发布与管理(图文混排、多步骤、草稿状态)
  • 菜谱浏览与搜索(关键词搜索、分类筛选、最新/最热排序)
  • 互动功能(点赞、收藏、评论)
  • 个人中心(查看自己发布的菜谱、收藏列表、点赞记录)

这个场景与传统的内容社区系统非常相似,业务核心就三个字:内容流。搞清楚这一点之后,后续的技术选型和表结构设计就有了明确方向。

1.2 功能优先级排序

功能不是一下子全做完的。我给功能排了两个优先级,第一版只做 P0,P1 放到后续迭代。

优先级功能模块说明
P0用户体系注册/登录、JWT 鉴权、基本个人信息
P0菜谱模块发布、编辑、列表、详情、删除
P0互动模块点赞、收藏、评论
P1搜索与筛选关键词搜索、按分类/难度筛选
P1关注与动态关注其他用户,首页聚合推荐
P1管理后台内容审核、用户管理、数据统计

如果一上来就想把所有功能都做完,项目大概率会烂尾。先保证核心链路跑通,再填充周边功能,这是做全栈项目最务实的策略。

2. 技术选型的逻辑:SpringBoot+Vue3+MyBatis这套组合好用在哪

2.1 为什么不用 JSP,非要前后端分离

十年前的 JavaWeb 项目大多是 SpringMVC 加 JSP,前端页面由服务端渲染,数据藏在 HTML 里面。这种模式在简单场景下够用,但做到美食社区这种交互密集的平台,问题会很明显:页面局部刷新要依赖 AJAX 拼字符串,前后端开发必须共用一套工程,上个线都提心吊胆。

前后端分离把渲染责任完全交给了前端,后端只负责提供 JSON 数据接口。前端用 Vue3 构建单页应用,后端用 SpringBoot 提供 RESTful API,两边通过 HTTP 通信。这样做最直接的好处是:

  • 前后端可以并行开发,我只需要提前约定接口文档
  • 前端通过 Axios 统一管理请求,页面渲染逻辑完全收归前端,交互体验更流畅
  • 后端接口可以被小程序、App 等多端复用,不需要重新开发

2.2 MyBatis与MyBatis-Plus的取舍

项目标题里写的是 MyBatis,但我实际在项目里做了一次过渡。第一版我用纯 MyBatis,XML 文件手动写 SQL。做到用户列表、菜谱分页这些常见操作时,我发现单表 CRUD 的 XML 代码极其重复:同样的 insert、selectById、updateById,换个表结构就再写一遍,非常消耗时间。

后来我在项目中引入了 MyBatis-Plus,在保留 MyBatis 核心能力的前提下,用内置的 BaseMapper 简化单表操作。像菜谱列表的分页查询这样复杂的 SQL,仍然通过自定义 XML 实现。这样做的思路是:复杂 SQL 要完全可控,简单 CRUD 则不要浪费时间。

技术选型的一个重要原则:框架是为业务效率服务的,而不是为了证明某种技术偏好。MyBatis-Plus 在团队中普及度已经很高,引入它并不会破坏 MyBatis 的生态,反而能显著减少样板代码。

2.3 工程目录怎么组织才不会后期失控

前后端分离意味着我要维护两个工程目录。后端按职责分包,前端按业务模块划分。一个良好的目录结构让你后期维护时能快速定位代码。

food-platform/ # 根目录 ├── backend/ # SpringBoot 后端工程 │ ├── src/main/java/ │ │ └── com/example/food/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务层 │ │ ├── mapper/ # MyBatis Mapper接口 │ │ ├── entity/ # 数据库实体 │ │ ├── dto/ # 传输对象 │ │ ├── config/ # 配置类(跨域、JWT、拦截器) │ │ └── common/ # 统一返回结构、异常处理 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML映射文件 │ └── application.yml └── frontend/ # Vue3 前端工程 ├── src/ │ ├── api/ # 接口请求封装 │ ├── views/ # 页面组件 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态管理 │ └── utils/ # 工具函数(request封装等) ├── vite.config.js └── package.json

分包依据是"职责边界",不是"功能层级"。controller 层只做参数接收和结果返回,service 层写业务逻辑,mapper 层只碰数据库。你如果发现自己为了图省事在 controller 里直接写 SQL,后面测试和排错的时候一定会后悔。

3. 数据库设计是这类系统的命门:表结构、索引与搜索

3.1 用户、菜谱、互动三块核心表设计

美食烹饪互动平台的数据库设计,我归纳成三大块:用户表、菜谱表、互动表。全部使用 InnoDB 引擎,字符集使用 utf8mb4(为了兼容 emoji 表情,评论区经常会用到)。

用户表是最基础的一张表,字段尽量精简但每个字段都有自己的用途:

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密密码', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像地址', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `bio` VARCHAR(255) DEFAULT NULL COMMENT '个人简介', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

密码字段我是重点考虑的。开发阶段图省事直接存明文,联调的时候被别人扫了一眼数据库,才意识到一旦上线后果多严重。后来改用 BCrypt 加密,加盐逻辑由框架内置,不需要自己在代码里拼随机字符串。这是所有用户系统的底线,不能省。

菜谱表是整个平台的核心内容表,我把它拆成主表和详情表两张:

CREATE TABLE `recipe` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '发布者ID', `title` VARCHAR(100) NOT NULL COMMENT '菜谱标题', `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图', `summary` VARCHAR(500) DEFAULT NULL COMMENT '简介', `category` VARCHAR(50) DEFAULT NULL COMMENT '分类:川菜/粤菜/烘焙/饮品等', `difficulty` TINYINT DEFAULT 2 COMMENT '难度:1简单 2中等 3困难', `cook_time` INT DEFAULT 0 COMMENT '预计耗时(分钟)', `status` TINYINT DEFAULT 1 COMMENT '状态:0草稿 1已发布 2下架', `view_count` INT DEFAULT 0 COMMENT '浏览量', `like_count` INT DEFAULT 0 COMMENT '点赞数', `favorite_count` INT DEFAULT 0 COMMENT '收藏数', `comment_count` INT DEFAULT 0 COMMENT '评论数', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_category` (`category`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜谱主表';

菜谱正文内容可能很长,包括食材列表、每个步骤的图文描述。为了不让主表行变得臃肿,我单独建了一张recipe_detail表,通过recipe_id和主表一对一关联。查询详情页时先查主表拿基础信息,再拿详情内容;列表页只查主表,效率明显高很多。

3.2 点赞收藏这类高频操作的细节

点赞、收藏和评论是三张独立的表。一开始我想的是把点赞和收藏合并成一张"用户互动表",用一个 type 字段区分。后来细想发现,点赞和收藏的语义差别很大:点赞是轻量行为,用户可能今天点了明天就取消;收藏是重量级行为,会长期保留且要展示在个人中心。合并之后索引和查询逻辑互相干扰,得不偿失。

具体表结构以收藏表为例:

CREATE TABLE `favorite` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `recipe_id` BIGINT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_recipe` (`user_id`, `recipe_id`), KEY `idx_recipe_id` (`recipe_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏记录表';

联合唯一索引uk_user_recipe有两个作用:一是防止同一个人对同一篇菜谱重复收藏,这是数据库层面的兜底;二是在查询"我是否收藏过这个菜谱"时,这个索引可以直接命中,不需要额外回表扫描。业务层再配合 Redis 存热门菜谱的点赞数,数据库压力会小很多。

数据库设计阶段最容易被忽略的是索引。一张表建好之后,不要急着写代码,先把业务查询列出来,对着查询条件设计索引。等数据量上来之后再补索引,代价就大了。

3.3 菜谱搜索的关键词匹配方案

第一版做搜索,我直接在 recipe 表上用 LIKE 查询:

SELECT * FROM recipe WHERE title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%') ORDER BY create_time DESC;

数据量几千条时完全没压力,等测试环境导入了十万条模拟数据,这个查询直接让接口超时。LIKE '%keyword%' 无法走索引,全表扫描的代价在这个量级已经不可接受了。

我优化后的方案是拆词查匹配,把搜索词按空格或标点拆成多个关键词,然后逐一匹配标题和简介,再按命中次数做相关性排序,匹配出来的结果用索引列参与排序:

SELECT id, title, cover_image, summary, like_count FROM recipe WHERE status = 1 AND ( title LIKE CONCAT('%', #{keyword1}, '%') OR summary LIKE CONCAT('%', #{keyword1}, '%') OR title LIKE CONCAT('%', #{keyword2}, '%') OR summary LIKE CONCAT('%', #{keyword2}, '%') ) ORDER BY create_time DESC LIMIT 20;

这套方案在十万级数据下性能勉强够用。如果数据规模继续增长,就要引入完整的全文检索引擎(例如 Elasticsearch 或者 MySQL 全文索引),那属于另一个量级的话题。关于搜索这块我的建议是:中小项目不要一上来就上重型搜索引擎,先用简单方案扛住第一版,等业务量大了再迁移,避免过度设计。

4. 后端核心模块实现:认证、菜谱发布与互动逻辑

4.1 JWT登录认证与登录状态保持

用户登录成功后,后端生成一个 JWT 令牌返回给前端。这个令牌包含 userId、username 等信息,由服务端密钥签名,前端后续每次请求在请求头里携带Authorization: Bearer <token>。

我在后端用拦截器统一处理令牌校验:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析并校验 Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token.replace("Bearer ", "")) .getBody(); request.setAttribute("userId", claims.get("userId", Integer.class)); return true; } }

这里有两个实际问题要处理。第一个是令牌过期。我设置 token 有效期是 2 小时,前端 Pinia 里存了登录状态和过期时间,每次接口返回 401 时自动跳转到登录页重新登录。第二个是防止未登录用户访问受保护接口,所以需要在 Spring 配置类里注册拦截器,并配置放行路径(登录接口、注册接口、菜谱列表和详情接口可以不用登录访问,但是发布、点赞、收藏、评论必须登录)。

放行路径的设计是别遗漏的一个点。我第一版把收藏接口也放行了,结果用户在未登录状态下前端能正常请求到后端,后端拿到 userId 为空,直接空指针异常。后来统一做了鉴权校验,所有需要登录的接口都确认了 userId 的有效性。

4.2 菜谱发布与图片上传的处理链路

菜谱发布表单包含基本信息(标题、分类、难度、耗时)和图文步骤。前端的做法是分两个接口:先上传图片拿到 URL,再提交菜谱主表信息。

图片上传的实现方案:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { // 校验文件类型和大小 if (file.getSize() > 5 * 1024 * 1024) { return Result.error("图片大小不能超过5MB"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFilename = UUID.randomUUID().toString().replace("-", "") + suffix; // 按日期分目录存储 String datePath = LocalDate.now().toString(); File dir = new File(uploadDir + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath(), newFilename)); return Result.ok("/uploads/" + datePath + "/" + newFilename); }

文件名一定要用 UUID 重命名。用户上传的原始文件名可能包含中文、空格、特殊符号,直接存储会造成 URL 编码混乱;更严重的是如果用户上传的目标文件名和后缀与已有文件同名,会发生覆盖。按日期分目录存储,一是避免单目录文件过多,二是方便后续做过期清理。

图片存储路径和静态资源映射是联调时最容易踩的坑。后端把图片写到/uploads/目录下,前端访问http://localhost:8080/uploads/2025/06/xxx.jpg,却发现 404。原因就是 SpringBoot 默认不把外部目录映射为静态资源路径,需要在配置类里手动注册:

public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceHandler(filePath + "/uploads/"); }

4.3 点赞、收藏、评论的并发与事务处理

以点赞为例,最直观的实现方式是一个接口里做两件事:插入点赞记录,同时更新 recipe 表的 like_count 字段。这两个操作必须放在同一个事务里,不然会出现点赞记录插入了但计数没增加的情况。

这里真正的问题是并发场景下的计数器一致性。假设两个用户同时点赞,都读到 like_count=100,各自加一写回,结果变成了 101,而不是 102。我采用的最简单有效的方案:数据库原子更新,不先读后写。

@Transactional public void likeRecipe(Long recipeId, Long userId) { // 插入点赞记录,唯一索引保证不重复 likeMapper.insert(userId, recipeId); // 原子自增 recipeMapper.increaseLikeCount(recipeId); }

对应的 SQL 是:

UPDATE recipe SET like_count = like_count + 1 WHERE id = #{recipeId}

这样根本不需要先查询出来再计算,数据库引擎本身保证原子性。取消点赞就是 decrease,JAVA 代码也不需要处理分布式锁这种东西。

关于评论,我额外加了一层敏感词过滤。虽然这个项目只是演示性质,但评论区如果不做基本的内容控制,上线后的风险很大。过滤有两种力度:简单的方式是维护一个敏感词列表,用字符串替换处理;更完善的方式是集成开源敏感词库做前缀树匹配。第一版我选择了简单替换,规则虽粗糙,但能挡住大部分明显问题。

5. Vue3前端实现:页面、路由、状态管理与接口对接

5.1 Vite项目初始化与目录规范

前端工程我使用 Vite 构建,相比 Webpack 最大的优势是开发服务器启动快,改代码之后的冷热更新几乎是秒级生效。组件库选了 Element Plus,样式方面加了一些自定义覆盖,让页面看起来不像"纯后台模板"。

路由设计采用的是嵌套路由,整体结构如下:

  • /首页:菜谱信息流、分类导航
  • /recipe/:id菜谱详情页:图文步骤、互动区
  • /publish发布菜谱
  • /login登录页
  • /register注册页
  • /profile/:userId个人主页:发布的菜谱、收藏列表

项目结构在早期就按views下的业务模块分好了目录,每个页面目录里放着页面主组件和该页面专属的子组件,公共的组件(比如菜谱卡片、分页组件、空白提示)放在components下。这样做的好处是改某个页面的样式不用翻遍整个项目,组件复用也清晰。

5.2 路由守卫与登录状态处理

前端路由守卫的核心逻辑是:需要登录才能访问的页面,必须检查本地是否有有效的登录状态。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('access_token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });

登录成功后,我会把 token 存到 localStorage,同时把用户基本信息放到 Pinia 的 user store 里。这里有一个需要特别注意的问题:localStorage 存 token 这个方案本身有 XSS 风险,所以在做内容渲染的时候要避免直接使用v-html渲染用户输入内容,防止脚本注入。前端的 XSS 防御,不要依赖某一个组件,要在所有渲染用户内容的地方都保持警惕。

5.3 Axios封装与Pinia状态管理

我封装了一个统一的 request 模块,职责包括:设置基础 URL、自动携带 token、统一处理错误码、401 时跳转登录页。基础配置如下:

import axios from 'axios'; import router from '@/router'; import { useUserStore } from '@/stores/user'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { const userStore = useUserStore(); userStore.clearLoginState(); router.push({ path: '/login' }); } return Promise.reject(error); } );

VITE_API_BASE_URL是环境变量,开发环境指向http://localhost:8080/api,生产环境指向服务器域名。前后端分离项目一定要把环境变量独立出来,不然换环境部署时,前端要改代码重新打包,非常麻烦。

Pinia 我用它来管理用户状态和主题偏好,相比 Vuex 的好处是它的写法更接近 Vue3 的组合式 API,心智负担小很多。只要记住一点:Pinia 中的 state 是响应式的,但不要在组件之外随意修改 store,保持单向数据流。

5.4 首页与菜谱详情页的实现要点

首页是典型的信息流页面。第一版我用onMounted里直接调用接口请求数据,这个方案简单,但返回上级页面时会重新请求,体验差。后来我引入了 keep-alive 缓存首页组件,切换 Tab 再回来不会重新拉取数据,滚动位置也保留住了。

菜谱详情页是互动率最高的页面,点赞、收藏、评论都在这里发生。核心实现是:页面加载时,除了请求菜谱详情,再额外请求"当前用户对这篇菜谱的点赞状态和收藏状态"接口,根据返回值决定按钮的高亮样式。如果用户点击收藏,前端立即更新按钮状态,再调用后端接口,接口失败则回滚状态。

const isFavorite = ref(false); async function toggleFavorite() { if (!userStore.isLogin) { router.push({ path: '/login' }); return; } const prevState = isFavorite.value; isFavorite.value = !prevState; try { await favoriteApi.toggle(route.params.id); } catch (e) { isFavorite.value = prevState; message.error('操作失败,请重试'); } }

先更新界面再调用接口,如果失败回滚。这个策略叫乐观更新,用户在弱网环境下也不会觉得界面卡顿。实测下来这个交互体验比那些先转圈再出结果的方案好一截。

6. 联调部署时踩过的坑:从端口占用到图片404

6.1 跨域配置的正确打开方式

前后端分离开发,前后端端口不同,必然遇到跨域问题。我本地开发时后端跑在 8080,前端 Vite 跑在 5173,前端请求后端接口时浏览器会发出 CORS 预检请求。

配置跨域的正确位置是后端 Security 配置类或者 WebMvc 配置类。我用的方案是自定义 WebMvcConfigurer:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

需要注意allowCredentials(true)和allowedOriginPatterns("*")的组合。早期我用的是allowedOrigins("*"),结果发现一旦开启 credentials,Spring 就拒绝这种配置,报错信息提示得也不够直观,排查了很久。后来改成allowedOriginPatterns,问题就解决了。另外maxAge(3600)可以缓存预检结果,减少 OPTIONS 请求次数,别忽略。

还有一种开发调试的方法是 Vite 代理:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

前端请求写/api/xxx,Vite 开发服务器把请求转发给后端。生产部署时用 Nginx 做反向代理,效果一样,这里不再展开。建议两种方案都掌握,本地联调用代理更整洁,后端也不用为了本地开发特意开全域名跨域。

6.2 图片上传的大小限制与路径映射

前后端联调时最先发现的问题是图片传不上去。前端上传一张手机拍摄的照片,大概 3~8MB,后端直接报 MaxUploadSizeExceededException。SpringBoot 的默认单文件上传上限是 1MB,需要在配置文件中调大:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB

我还踩过一个更隐蔽的坑:图片上传成功,数据库里存的 URL 也能访问,但把项目打成 jar 包部署到服务器后,新上传的图片全部 404。原因是我用${user.dir}作为存储根目录,加上打包后工作目录发生变化,存储路径和静态资源映射路径不一致了。后来我统一把存储路径配置为一个绝对路径:

upload: dir: /data/food-platform/uploads

同时注册静态资源映射时,把硬盘上的这个目录映射到/uploads/**。部署的时候提前创建好目录,并保证运行应用的系统用户对该目录有读写权限。很多部署环境用的是低权限用户,没有提前授权的话,上传接口会报 IOException,看似是代码问题,其实只是权限问题。

6.3 数据库连接池参数与上线配置

本地开发时用的默认 HikariCP 参数没出过问题,但部署到服务器上,就遇到了连接不稳定的情况。主要原因是线上 MySQL 和本地的配置差异,还有并发请求量增大之后连接池的分配策略。

我在 application.yml 里显式定义了连接池参数:

spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

max-lifetime必须小于 MySQL 服务端的wait_timeout,这个是血泪教训。MySQL 默认空闲 8 小时断连,如果连接池 max-lifetime 大于这个值,连接会被 MySQL 服务端关闭,而客户端不知道,下一次请求会拿到一个失效连接,抛 Communications link failure。把 max-lifetime 设成 1800000 毫秒(30分钟),就避开了这个问题。

数据库连接配置上还有个容易忽略的点:服务器数据库账号要单独创建,只授权应用所用库的增删改查权限,不要直接用 root 账号。这样即使后续应用被攻破,影响范围也受控。

6.4 前后端打包部署的完整流程

后端打包是标准的 Maven 生命周期,先执行测试跳过,再打包:

mvn clean package -DskipTests

得到的 jar 包通过java -jar food-platform.jar运行。因为服务器上还有 Nginx 和 MySQL 在跑,我用 systemd 管理这个后端服务,配置了开机自启和崩溃重启。实际部署时我给 jvm 设置了-Xms256m -Xmx512m,如果服务器内存有限,堆内存不要给太大,避免 OOM。

前端打包更简单:

npm run build

产物会输出在dist目录。然后把dist里的文件复制到 Nginx 静态目录,同时在 Nginx 中配置反向代理规则,把/api和/uploads路径转发到后端服务:

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 /data/food-platform/uploads/; }

前端路由用的 history 模式,所以 Nginx 还要加一个 fallback 配置,让所有未匹配的路径都回到 index.html,否则刷新首页下方的二级页面时会出现 404:

location / { try_files $uri $uri/ /index.html; }

部署完这些配置,一个完整的美食烹饪互动平台就算真正落地了。浏览器访问域名,能注册、能发布菜谱、能点赞收藏评论,整个前后端分离闭环算是完整跑通了。

从表结构设计到 JWT 鉴权,从图片上传到 Nginx 反向代理,这个项目把全栈开发的主干链路都覆盖了。我自己的体会是:做这类项目,技术难点不在于某个框架用得多熟练,而在于你能否把用户认证、数据一致性、文件存储、部署环境这四件事串起来,任何一环断裂,整个系统都不算真正完成。如果你也在做类似的全栈练手项目,建议不要停留在"能跑起来"这个层面,试着把它部署到真实的服务器环境里,再观察日志、处理异常,收获会大很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进