Spring Boot美食分享网站源码解析:从分层架构到部署避坑实战指南
简介:面向地方美食分享类网站的设计与实现需求,这份基于SpringBoot和Vue的Java Web源码,适用于高校毕业设计、课程实训及初级开发者学习完整的前后端项目搭建。系统包含用户信息、图片素材、视频素材等核心模块,覆盖用户管理、美食内容展示与后端接口开发,技术栈涉及MySQL数据库、MyBatisPlus持久层、Maven构建与AJAX交互,能够帮助理解B/S架构下的业务实现流程。项目文档同步包含选题动因、背景与意义,以及MySQL、Vue、B/S架构等章节,便于对照代码理解整体设计思路。压缩包共812个文件,以Java源码、Vue组件、JavaScript脚本为主,辅以CSS样式、HTML页面、SQL脚本、XML配置及项目说明文档,整体大小约21.23MB,目录结构清晰,便于按模块定位代码。前端组件、控制器与Mapper接口均可直接复用,能够显著减少同类型网站从零搭建环境的时间成本。目前已有195人学习下载,适合需要快速上手SpringBoot整合Vue实际项目的读者参考。
1. 拿到地方美食分享网站源码先做什么:Spring Boot 项目两百字导读
这份地方美食分享网站源码,是用 Spring Boot 搭起来的一套 Java Web 单体项目。和常见的图书借阅、商品管理课设相比,它更接近真实内容社区的最小闭环:用户注册登录、发布美食帖子、上传图片、分类浏览、关键词搜索、收藏评论,外加帖子管理后台。我拿到后先做的不是翻代码,而是导入 SQL、改数据库配置、把项目跑起来,再注册两个账号互发带图帖子,走完整链路。走完才确认它不是演示壳子,分页、热度排序、拦截器、图片映射都有落地。适合要写毕业设计的学生、从 SSM 过渡到 Spring Boot 的学习者,以及想快速搭地方内容社区的开发者。
2. 项目骨架与核心链路:从目录结构到一条美食帖子的完整请求流
2.1 分层结构与启动入口:controller / service / mapper 三条线怎么切
拿到源码第一步,先把项目结构扫一遍。这套项目采用的是最标准的 controller / service / mapper 三层分包,实体类单独放 entity,公共返回包装放在 common。这样切的好处是请求只能往下一层走,controller 不碰 SQL,mapper 不写业务判断,出问题时能顺着调用链直接定位到具体层,而不是在一堆互相调用的代码里找头绪。
src/main/java/com/foodshare ├── FoodShareApplication.java # Spring Boot 启动入口 ├── controller/ # 接收请求、做轻量校验、包装返回 │ ├── UserController.java │ ├── FoodController.java │ ├── CategoryController.java │ ├── CollectController.java │ └── AdminController.java ├── service/ # 业务逻辑,事务边界在这一层 ├── mapper/ # MyBatis 数据访问接口 ├── entity/ # 数据库表对应的实体类 ├── config/ # 拦截器、静态资源映射等配置 └── common/ ├── Result.java # 统一返回体 └── PageResult.java # 分页结果包装controller 按业务对象分文件,User 管登录注册,Food 管帖子和列表,Collect 管收藏,Admin 管后台操作。service 层是事务边界,比如收藏操作要同时更新收藏表和热度计数,事务注解就加在 service 方法上,一个方法内的所有数据库操作要么全成功、要么全回滚。mapper 层只留接口和 XML,SQL 全部集中在 resources/mapper 目录,方便在不改动 Java 代码的前提下调 SQL。
启动入口就是标准的 Spring Boot 主类,代码量很少,但两个注解决定了整个项目的装配方式:
@SpringBootApplication @MapperScan("com.foodshare.mapper") public class FoodShareApplication { public static void main(String[] args) { SpringApplication.run(FoodShareApplication.class, args); } }@SpringBootApplication 包含组件扫描和自动配置,@MapperScan 把 mapper 包下的接口批量注册成 MyBatis 代理,省去在每个接口上写 @Mapper 注解的重复工作。改包名时必须同步改这个扫描路径,否则启动会直接报找不到 mapper bean,这是 Spring Boot 打包后最容易踩的第一类环境问题。
2.2 核心表设计:user / food / category / collect / comment 五张表怎么关联
一个美食分享网站的数据库不复杂,关键是表之间关系要顺。user 表存用户,food 表存帖子,category 表存分类,collect 表是用户和帖子的多对多关联,comment 表挂帖子 ID。看源码里 food 表的建表语句,能直接看出设计者的取舍:
CREATE TABLE food ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', title VARCHAR(100) NOT NULL COMMENT '美食标题', description TEXT COMMENT '描述', cover_image VARCHAR(255) COMMENT '封面图路径', images VARCHAR(500) COMMENT '多图路径,逗号分隔', category_id BIGINT COMMENT '分类ID', user_id BIGINT COMMENT '发布用户ID', view_count INT DEFAULT 0 COMMENT '浏览量', collect_count INT DEFAULT 0 COMMENT '收藏数', status TINYINT DEFAULT 1 COMMENT '1正常 0下架', create_time DATETIME COMMENT '创建时间', update_time DATETIME COMMENT '更新时间' );view_count 和 collect_count 是冗余字段,值由浏览和收藏操作实时更新。选冗余而不是实时 COUNT 两张表,是为了列表页排序时直接 ORDER BY collect_count DESC 就能拿到热度排名,不用在多表关联里做聚合。代价是每次收藏操作都必须同时改 collect 表和这个计数,少一步就会出现收藏数和实际记录对不上的问题,这一点在后文翻车记录里会细说。
collect 表的建表语句是理解收藏功能的关键:
CREATE TABLE collect ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, food_id BIGINT NOT NULL, create_time DATETIME COMMENT '收藏时间', UNIQUE KEY uk_user_food (user_id, food_id) );user_id 和 food_id 上的联合唯一约束是整套收藏逻辑的地基。它从数据库层面挡住了重复收藏,后面判断"是否已收藏"就是查这条唯一索引存不存在。comment 表结构同理,只是约束条件从唯一变成普通索引,按 food_id 查评论列表。category 表字段最简单,id 加 name,food 表通过 category_id 关联它,做分类筛选时直接当筛选条件用。
2.3 发布美食帖子的请求链路:Controller 到 SQL 的完整落库流程
以发布帖子为例,看一条请求怎么走完三层。前端把表单数据用 JSON 提交到 /food/publish,FoodController 先从 Session 里取登录用户,把 user_id 补进去,再交给 service 层校验和入库:
@PostMapping("/publish") public Result publish(@RequestBody Food food, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { return Result.error("请先登录"); } food.setUserId(loginUser.getId()); food.setStatus(1); return foodService.publish(food); }@RequestBody 要求前端传 JSON,参数名和 Food 实体字段对应。这里刻意不信任前端传的 userId,而是从 Session 里取,这是防止用户伪造他人发帖的常见手段。controller 只做两件事:取登录态、补基础字段,真正校验和落库往下放。status 默认置 1,意思是发布即上架,后台管理可以通过改这个值实现下架。
service 层的方法长这样:
@Transactional public Result publish(Food food) { if (food.getTitle() == null || food.getTitle().trim().length() < 2) { return Result.error("标题至少2个字符"); } food.setCreateTime(new Date()); food.setUpdateTime(new Date()); foodMapper.insert(food); return Result.success(food.getId()); }@Transactional 保证 insert 失败时不会留下半截数据。校验只拦标题长度,description 和 images 为空也允许发布,这是刻意留的宽容度,避免造测试数据时被参数校验卡住。insert 使用 MyBatis 的自动回填,主键 id 从数据库生成后回填到 Food 对象,返回给前端方便跳转详情页。
对应 Mapper XML 里的插入语句:
<insert id="insert" parameterType="com.foodshare.entity.Food" useGeneratedKeys="true" keyProperty="id"> INSERT INTO food (title, description, cover_image, images, category_id, user_id, view_count, collect_count, status, create_time, update_time) VALUES (#{title}, #{description}, #{coverImage}, #{images}, #{categoryId}, #{userId}, 0, 0, #{status}, #{createTime}, #{updateTime}) </insert>view_count 和 collect_count 不从前端接收,插入时直接写死 0,这是对代码审核比较友好的处理。useGeneratedKeys 让 MySQL 生成的主键回填到 Food 对象的 id 属性,keyProperty 明确告诉 MyBatis 回填到哪个字段。整条链路走完,一次发帖从 JSON 请求变成 food 表里的一行数据,从浏览器 Network 面板到数据库客户端都能观察到完整过程。
3. 登录、权限与图片上传:Java Web 用户模块的三个翻车点
3.1 Session 登录态与拦截器:白名单里多一个路径就是多一个洞
登录模块决定了整个项目对"当前用户是谁"的判断方式。这份源码用的是 Session 方案:登录成功后把 user 对象放进程内 Session,后续请求从 Session 取,取不到就拦截。相比 JWT,Session 方案代码量小、天然支持服务端主动失效,单体项目用起来最省心。拦截器是实现登录校验的标准件,preHandle 返回 false 时请求直接中断,不会进入 controller:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } return true; } }手动设置 401 状态码并写 JSON 响应体,这一步很关键。如果只 return false 不写响应体,普通表单请求会看到一个空白页,Ajax 请求会收到空响应,前端判断不出发生了什么。把响应码和提示信息写清楚,前端才能统一跳转登录页。
注册拦截器在 WebConfig 里完成,这是访问控制的全部入口:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/food/publish", "/collect/**", "/user/info") .excludePathPatterns("/user/login", "/user/register", "/food/list", "/food/detail/**", "/category/list"); } }addPathPatterns 是要保护的路径,excludePathPatterns 是放行路径。源码里放行的思路很明确:读操作全部放行,写操作拦截。游客能看列表、看详情、看分类,只有发帖、收藏、改资料这些动作才要求登录。这里最容易翻车的是少写一个放行路径,漏掉 /food/detail 的话,分享出去的帖子链接在未登录时直接 401;反过来多写了 /food/publish 的话,没登录也能发帖,权限等于白做。改这一节时建议顺手写一个路径对照表,逐条核对。
3.2 密码加盐与 MD5:为什么盐必须跟着用户走
用户表里专门留了 salt 字段,这是处理密码明文的关键。注册时生成随机盐,把密码和盐拼接后再做 MD5,数据库里只存摘要结果和盐本身。这样即使数据库泄露,攻击者拿到的也不是明文密码,想批量反推还得对每个用户单独跑彩虹表,成本高得多:
public User register(String username, String password) { User user = new User(); user.setUsername(username); String salt = UUID.randomUUID().toString().substring(0, 6); user.setSalt(salt); user.setPassword(md5WithSalt(password, salt)); userMapper.insert(user); return user; } private String md5WithSalt(String password, String salt) { return DigestUtils.md5DigestAsHex( (password + salt).getBytes(StandardCharsets.UTF_8) ); }UUID 的随机性保证了每个用户的盐不同,同一个密码在不同用户账号下生成的摘要完全不一样。salt 取前 6 位,对课设和内部项目够用,生产环境我更建议直接换 BCrypt,密码长度和安全性都更可控。登录校验时从库里查出用户,用用户自己的盐重新计算摘要再比对:
public User login(String username, String password) { User user = userMapper.selectByUsername(username); if (user != null && user.getPassword().equals(md5WithSalt(password, user.getSalt()))) { return user; } return null; }这里有个高频坑:改造时新增字段后忘了把 salt 写入数据库,登录时拿到的盐是 null,拼接出来的摘要永远和库里不一致,表现为注册成功但登录永远失败。我一般会在注册方法后面加一段日志打印 user 的完整字段,确认盐真的落库再继续测登录,能省掉至少半个小时的排查时间。
3.3 图片上传与本地存储:访问 404 大概率是虚拟路径映射没配
美食帖子最重要的内容就是图片,图片能不能稳定显示决定整个项目的真实感。源码采用本地磁盘存储方案:上传时把 MultipartFile 写到服务器目录,再通过静态资源映射把 /upload/** 这个 URL 指向那个目录,请求由 Spring MVC 直接从文件系统读文件,不经过 controller:
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf('.')); String fileName = System.currentTimeMillis() + "_" + new Random().nextInt(1000) + ext; String basePath = "D:/foodshare/upload/"; File dir = new File(basePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(basePath + fileName)); return Result.success("/upload/" + fileName); }文件名用时间戳加随机数重命名,避免中文文件名乱码和重名文件互相覆盖。ext 从原始文件名取后缀,这里源码没有做白名单校验,实际部署建议补上,只允许 jpg、png、webp,防止有人传 jsp 或 exe。basePath 直接写死在代码里是课设常见做法,缺点是换机器就得改代码。我习惯把它挪到 application.yml,通过 @Value 注入,部署时只改配置不动代码。
目录能写文件不代表 URL 能访问,还要在 WebConfig 里注册虚拟路径映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/foodshare/upload/"); }addResourceHandler 是浏览器访问的 URL 前缀,addResourceLocations 是磁盘物理路径,两个都必须以 / 结尾。Windows 下写 file:D:/foodshare/upload/,Linux 下必须改成 file:/home/app/upload/,斜杠方向和多出来的斜杠是图片在服务器上 404 的头号原因。上传接口返回成功但浏览器打不开图片时,先检查这一行的路径格式。
4. 检索与排序:美食列表的分类筛选、关键词搜索和热度排序
4.1 动态 SQL 组合查询:where 和 if 如何拼出干净的条件
列表页是项目流量最大的接口,既要支持分类筛选,又要支持关键词搜索,还要能按热度或时间排序。如果每个组合都写一条静态 SQL,条件一多代码就爆炸。源码用的是 MyBatis 动态 SQL,在 XML 里用 where 加 if 组合条件,有值才拼条件,没值直接跳过:
<select id="selectByCondition" parameterType="map" resultType="com.foodshare.entity.Food"> SELECT * FROM food <where> <if test="categoryId != null"> category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY <choose> <when test="sort == 'hot'"> collect_count DESC, view_count DESC </when> <otherwise> create_time DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>where 标签会自动去掉第一个条件前多余的 AND,所以第一个 if 不用写 AND,后面的都写。keyword 同时匹配标题和描述,LIKE 用 CONCAT 拼接而不是直接写 '%#{keyword}%',因为后一种写法在预编译时会出问题,% 被当成参数的一部分导致匹配失效。choose 是互斥分支,sort 不是 hot 就默认按创建时间降序。这套组合写完之后,新增筛选条件只要在 where 里加一个 if,不需要动 Java 代码。
4.2 分页参数与边界:offset 换算不能省,page 越界要有兜底
分页接口最容易出错的点在页码计算。前端传的 page 从 1 开始,SQL 里的 LIMIT 第一个参数是偏移量,两者之间差一个 pageSize 的倍数关系。Service 层负责换算,mapper 只管接收结果:
public PageResult pageList(int page, int pageSize, Long categoryId, String keyword, String sort) { int offset = (page - 1) * pageSize; List<Food> list = foodMapper.selectByCondition( categoryId, keyword, sort, offset, pageSize); int total = foodMapper.countByCondition(categoryId, keyword); return PageResult.build(list, total, page); }配套的 count 查询和列表查询共用同一套动态条件,只是把 SELECT 列换成 COUNT(*),这样分页组件才能算出总页数。这里能看到的接口参数可以整理成一张表,改前端对接时直接用:
| 参数 | 类型 | 说明 |
|---|---|---|
| page | int | 当前页码,从 1 开始 |
| pageSize | int | 每页条数,一般限制在 20 以内 |
| categoryId | Long | 分类 ID,为空时查全部分类 |
| keyword | String | 搜索关键词,为空时跳过 LIKE 条件 |
| sort | String | hot 按收藏量和浏览量降序,其他值按时间降序 |
page 越界时,比如总共 5 页请求第 6 页,list 返回空列表但 total 不变,前端按 total 除以 pageSize 算总页数并禁用翻页按钮。这种设计的好处是后端不参与"修正页码"这种业务判断,超过范围就展示空页面,用户自然会退回上一页。常见误用是有人直接在 LIMIT 里写 page 而不做换算,结果第一页跳过了第一条数据,第二页跳过前 20 条,列表永远错位。看到列表第一条对不上时,先查 Service 层 offset 换算有没有写。
4.3 热度排序与收藏统计:冗余字段和事务缺一不可
热度排序的数据来源是 food 表的 collect_count 和 view_count。收藏接口在做收藏和取消收藏时,同时维护 collect 表和 food 表的计数。这两个操作必须放在同一个事务里,否则可能出现收藏记录插进去了、计数没更新,或者取消收藏后计数还挂着的情况:
@Transactional public Result toggleCollect(Long foodId, Long userId) { if (collectMapper.checkExists(foodId, userId) > 0) { collectMapper.deleteByFoodIdAndUserId(foodId, userId); foodMapper.decreaseCollectCount(foodId); return Result.success("已取消收藏"); } collectMapper.insert(foodId, userId); foodMapper.increaseCollectCount(foodId); return Result.success("收藏成功"); }checkExists 依赖 collect 表上 user_id 和 food_id 的联合唯一索引,先查再插。没有唯一索引时,并发请求可能插入两条重复收藏记录,之后再取消收藏只会删一条,数据就对不上了。increase 和 decrease 在 SQL 里写成 collect_count = collect_count + 1 和 collect_count = collect_count - 1,是数据库层面的原子操作,比先 SELECT 再在 Java 里计算后 UPDATE 安全,因为行锁会串行化并发更新。
view_count 的更新更简单,详情页接口被访问一次就自增一次,不需要事务。这个方案有一个必须接受的代价:冗余字段和真实数据之间存在短暂不一致窗口,比如收藏事务还没提交时,列表页看到的计数就是旧的。对内容社区来说,排行榜差一个数不影响体验,但如果是电商库存这类强一致场景,就不能用这种同步冗余方案,改成消息队列异步更新更稳妥。
5. 部署与避坑:从本地跑通到上线的四类问题排查记录
5.1 本地启动三步走:建库、改配置、跑 main 方法
拿到源码后最快的验证路径是先让项目在本机跑通,再谈改代码。第一步是建库导表,SQL 脚本在 resources/db 目录下,用命令行导入后确认表结构完整:
mysql -uroot -p < foodshare.sql第二步改数据源配置,把用户名密码换成自己本机的。MySQL 8 以上版本时区必须显式指定,否则启动会报 time zone 相关的 SQLException。还要注意 URL 里的 & 符号在 YAML 里会被解析成特殊字符,所以整个 URL 要用引号包起来:
spring: datasource: url: "jdbc:mysql://localhost:3306/foodshare?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" username: root password: "你的数据库密码"characterEncoding=utf8 解决中文乱码,serverTimezone=Asia/Shanghai 解决数据库时间差 8 小时的问题。这两个参数是本地启动最常见的环境坑,少任何一个,轻则列表页中文乱码,重则启动直接失败。
第三步运行 FoodShareApplication 的 main 方法,控制台出现 Tomcat started 后访问 http://localhost:8080 验证首页。这里建议注册一个账号、发一条带图的帖子再关掉,把登录、上传、发布三个最核心的接口都确认到位,项目才算真正跑通。
提示:YAML 里多行拼接 URL 在 Spring Boot 里很容易踩坑,一行写完整条 URL 最省事。本地调试时如果出现数据源解析错误,先检查有没有引号。
5.2 避坑记录:四个在我手上真实发生过的部署问题
以下四类问题在跑 Spring Boot 课设项目时反复出现,按现象、原因、解决三步排列,对照排查即可。
中文乱码:页面和数据库里显示的内容全是问号。 现象:通过表单提交中文标题后,列表页展示的标题、描述变成一串问号,数据库里存的也是问号。 原因:建库时没指定 utf8mb4,或者 JDBC URL 缺少 characterEncoding 参数,个别情况下是源码文件被编辑器存成了 GBK 编码。 解决:建库语句写成 CREATE DATABASE foodshare DEFAULT CHARACTER SET utf8mb4,URL 带上 characterEncoding=utf8,IDEA 右下角把项目文件编码统一改为 UTF-8 后再重新编译。
图片上传返回成功但访问 404:上传接口返回了 /upload/xxx.jpg,浏览器直接打不开。 原因:Spring Boot 默认不映射本地磁盘目录,少了 addResourceHandlers 虚拟路径配置;或者本地用 Windows 路径,部署到 Linux 后没改成 file:/app/upload/ 格式。 解决:确认 WebConfig 里 addResourceHandler("/upload/**") 和 addResourceLocations("file:D:/foodshare/upload/") 成对存在,服务器上改成 file:/app/foodshare-upload/,并在上传代码里加目录不存在则自动创建的判断。
登录后请求接口还是返回未登录:登录接口调用成功,紧接着发帖却被 401 拦下。 现象:浏览器里能正常访问登录接口,拿到成功响应,但再调 /food/publish 时返回未登录。 原因:拦截器把 /user/login 也拦了,或者登录时存入 Session 的 key 和拦截器取出的 key 不一致。 解决:把 /user/login 和 /user/register 加进 excludePathPatterns,全局搜索 loginUser 这个字符串,确保 login 和拦截器两处写的完全一致。
启动报 8080 端口被占用:Tomcat 起不来,控制台提示 Port 8080 was already in use。 现象:本地有别的 Java 进程或开发工具占用了 8080,比如之前没关掉的旧项目实例。 原因:端口冲突,Spring Boot 默认端口就是 8080,多个项目同时跑必然撞车。 解决:用 netstat -ano | findstr 8080 找到占用进程的 PID,在任务管理器里结束,或者在 application.yml 里把 server.port 改成 8081,开发期我习惯直接换端口,省得每次都要杀进程。
5.3 打包部署:profile 切换与 jar 包运行时的注意点
本地跑通后部署到服务器,推荐 Maven 打包成 jar 直接运行,比 war 包省去外部 Tomcat 的版本匹配问题。打包时跳过测试能省时间,但前提是你已经本地验证过核心流程:
mvn clean package -DskipTests打包产物在 target 目录下,文件名是 artifactId 加版本号。启动时用 --spring.profiles.active 指定环境配置,比如新建 application-prod.yml 放生产环境数据源,然后这样启动:
java -jar target/foodshare-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod把环境差异收敛到配置文件,是本阶段最值得做的事。本地配置连本机 MySQL,prod 配置连云数据库,上传目录从 D:/foodshare 换成 /app/foodshare-upload,jar 包里只留一套业务代码。部署时数据库连不上,第一件事看 prod 配置里的 IP 和端口是不是数据库服务器的真实地址,很多人把 localhost 原样带到了服务器上,库当然连不通。
6. 把项目改造成自己的:收藏推荐与定时导出的两个低成本扩展
6.1 用收藏记录做简化版偏好推荐
收藏功能最容易被当成"存进去就完事",其实它是最便宜的推荐数据源。不需要引入算法框架,统计用户收藏过的帖子在哪些分类,然后用分类做过滤,就能做出一个"你可能还喜欢"的推荐位:
public List<Food> recommendForUser(Long userId) { List<Long> categoryIds = collectMapper.selectCategoryIdsByUserId(userId); if (categoryIds == null || categoryIds.isEmpty()) { return foodMapper.selectHotList(10); } return foodMapper.selectByCategoryIdsAndExcludeCollected(categoryIds, userId, 10); }逻辑分红两条路:新用户没有任何收藏记录,直接返回热度榜前 10,这是冷启动的回退方案;老用户有收藏,就按收藏过的分类取同类帖子,同时排除已经收藏过的。selectCategoryIdsByUserId 从 collect 表关联 food 表拿到分类 ID 列表,再用 IN 查询捞食品。这个改动只涉及一个 service 方法和两句 SQL,却能让详情页侧边栏从死数据变成动态内容。验证方法很直接:新注册账号看推荐位,显示的是热度榜;收藏几条川菜帖子后再看,推荐位里川菜比例明显上升。
6.2 定时导出帖子数据做后端备份
另一个值得加的小功能是每天凌晨把 food 表全量导出成文件。这只需要一个定时任务类,用 Spring 自带的 @Scheduled 就能跑,不需要引入 Quartz:
@Component @EnableScheduling public class BackupTask { @Scheduled(cron = "0 0 2 * * ?") public void exportFood() { List<Food> list = foodMapper.selectAll(); String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String fileName = "/backup/food_" + date + ".json"; // 用 Jackson 把 list 序列化后写入 fileName } }cron 表达式0 0 2 * * ?表示每天凌晨 2 点执行。@EnableScheduling 要加在启动类或任意配置类上,否则 @Scheduled 不会生效。顺带提醒:定时任务默认单线程阻塞执行,如果以后加了导出评论、导出用户的多个任务,记得单独配置线程池,避免一个任务卡住把后面的全部阻塞。
说到这我忍不住提一句自己的习惯:拿到任何 Spring Boot 源码,我不追求先理解每一行,而是强制走一遍"建库、启动、注册、发帖、传图、打包"这条完整链路。每次走都能暴露出别人代码里至少一个隐含假设,比如上传目录写死、数据库密码留在配置文件、时区没指定。把这些假设全部记下来,项目才算真正属于你。这套地方美食分享源码在课设项目里完整度算中上,推荐和备份这两个点补上之后,代码会从"能跑"变成"像认真设计过",希望帮到你。
本文还有配套的精品资源,点击获取