做美食分享交流平台这个项目前前后后折腾了大概一个月。当时需求很直接把大家分散在朋友圈、短视频评论区里的菜谱、家常菜记录、探店心得集中到一个地方能发图、能评论、能点赞、能搜菜名还能互相关注。技术栈定了 SpringBoot MyBatis-Plus Vue整套下来就是一个非常典型的前后端分离项目。这篇博客就把完整的实现过程、踩过的坑、以及那些“文档里不会细讲”的细节一次性讲清楚。如果你是正在做毕设、准备项目作品或刚工作想独立把一个 SpringBoot 项目跑通的开发者这篇文章可以直接当成一份操作手册来用。里面涉及了自动装配原理、JWT 登录、MinIO 图片存储、Redis 点赞、中文分词搜索、Vue 打包整合 SpringBoot、IDEA 启动配置等一系列高频问题也都是面试里爱问的点。1. 项目定位与整体架构设计1.1 这种平台到底解决什么问题功能怎么拆美食分享交流平台本质上是一个 UGC 社区系统用户生产内容发布菜谱其他人消费内容浏览、点赞、收藏、评论再加上社交关系关注。想清楚这一点后功能边界就不会乱。我一开始犯过设计过度的毛病恨不得把美食电商、订单、配送全塞进去。实际做下来MVP 就被缩减成了这几个核心模块用户模块注册、登录、个人信息维护、个人主页发布列表 收藏列表 粉丝/关注列表。菜谱模块菜谱发布标题、封面图、多图、正文、标签、菜谱列表、菜谱详情、菜谱删除。互动模块点赞、收藏、评论、关注。搜索模块按菜名、标签搜索。后台管理简单的分类管理、用户禁用可视作可选项。我在设计时给功能划了优先级MVP 阶段只做 P0P1 排到第二期P2 干脆砍掉。优先级功能说明P0注册登录、菜谱发布/列表/详情、多图上传、点赞、评论没有这些平台跑不起来P1收藏、关注、粉丝列表、搜索、个人主页提升社区感的功能P2热门排行榜、消息通知、管理后台、定时统计加分项有余力再做这样拆完之后数据库表和接口设计就有了明确的依据。给还在学习阶段的读者一个建议前期一定要写一份这样的功能拆分表不然做到一半很容易被需求牵着走。1.2 技术选型SpringBoot MyBatis-Plus Vue 这套组合的逻辑技术选型这件事结论看起来很简单但背后的取舍要说清楚才行。我选的是 SpringBoot 2.7.6 JDK 8 MyBatis-Plus 3.5.x MySQL 8 Redis MinIO前端用 Vue 2 Element UI构建工具用 Maven。为什么不是 SpringBoot 3.x当时考虑两个原因一是 SpringBoot 3 要求 JDK 17团队本地方便性和服务器环境不统一二是不少第三方 starter 在 3.x 下包名从 javax 换成了 jakarta集成过程中容易莫名踩坑。结合“版本太高”带来的兼容性问题我个人的项目选型原则是稳定优先能力覆盖需求就够了。SpringBoot 在这个项目里最大的价值是它的自动装配能力。你引入一个spring-boot-starter-web依赖就能直接写 Controller 跑起来内置 Tomcat不用手动配置一堆 XML。相比以前 SSH/SSM 时代写配置写到手软SpringBoot 把“约定优于配置”做到了极致。MyBatis-Plus 则解决了我在 MyBatis 使用中最大的痛点单表 CRUD 不用手写 SQL。菜谱、用户、评论这些表的增删改查大部分直接用 BaseMapper 搞定复杂查询再写 XML 里的自定义 SQL开发效率提升非常明显。Redis 的引入主要为了解决两个问题点赞计数的高并发写入、热度榜单的实时排序。图片存储没有用本地磁盘而是用 MinIO既可以私有化部署又能将来平滑替换成云存储。前端选 Vue纯粹是因为前后端分离模式下好用生态成熟Element UI 拿来做管理后台和展示型页面非常顺手。整体架构可以概括为浏览器请求 - Nginx或直接访问 SpringBoot - Vue 静态资源 - /api 接口 - SpringBoot Controller - Service - MyBatis-Plus - MySQL/Redis/MinIO。理想情况下前端资源也部署在 Nginx 里工程化一点但小项目要省事也可以把 Vue 打包产物塞进 SpringBoot 的 static 目录一台机器一个进程全部搞定这个细节我在第 4 章专门讲。2. 数据库建模与核心接口开发2.1 数据库表设计8张表跑通 MVP这个项目我最终用了 8 张表用户表、菜谱表、菜谱图片表、评论表、点赞记录表、收藏表、关注表、标签表。如果时间充足标签可以设计成多对多关联表但 MVP 阶段我直接用一个tags字符串字段存逗号分隔的标签代码简单查询时用LIKE即可。用户表是最基础的表字段设计如下字段类型说明idbigint主键自增usernamevarchar(50)登录账号唯一passwordvarchar(100)BCrypt 加密后的密码nicknamevarchar(50)昵称avatarvarchar(255)头像地址gendertinyint性别0 未知 1 男 2 女biovarchar(255)个人简介statustinyint0 正常 1 禁用create_timedatetime创建时间菜谱表是内容核心字段类型说明idbigint主键user_idbigint发布人titlevarchar(100)菜名summaryvarchar(255)一句话简介contenttext详细做法/图文详情cover_imagevarchar(255)封面图tagsvarchar(255)逗号分隔的标签view_countint浏览数like_countint点赞数冗余字段favorite_countint收藏数冗余字段comment_countint评论数冗余字段statustinyint0 草稿 1 已发布 2 已删除create_timedatetime发布时间update_timedatetime更新时间like_count、favorite_count、comment_count为什么要冗余存因为每次查询列表都去COUNT评论表和点赞表数据量上来后会慢而且 SQL 会写得很复杂。用 Redis 做实时计数再异步同步到 MySQL 的冗余字段列表页直接查字段就行。互动关系表都差不多点赞记录表结构如下字段类型说明idbigint主键user_idbigint点赞人recipe_idbigint被点赞的菜谱create_timedatetime点赞时间deletedtinyint逻辑删除取消点赞每次点赞先查是否已存在存在则不做重复插入不存在则插入并 Redis 加一。这种设计应对小规模社区完全足够。数据库的字符集建议统一utf8mb4排序规则用utf8mb4_general_ci不然用户存 emoji 表情会出现Incorrect string value的报错美食平台里用户发“好吃”太常见了这一步别省。2.2 项目整合从创建工程到跑通注册接口SpringBoot 和 MyBatis 整合在 2025 年几乎已经是标准答案了直接加mybatis-plus-boot-starter不用再手写 SqlSessionFactory 的 XML 配置。它不仅能省掉大量样板代码还自带了分页插件、逻辑删除、字段自动填充这些常用能力。pom.xml 里核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.6/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.31/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependenciesapplication.yml 里的配置也很关键server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_share?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意serverTimezone必须配置否则 MySQL 8 的驱动会报时区错误。主键策略用id-type: auto对应数据库自增主键。启动类加MapperScan(com.foodshare.mapper)Mapper 接口上再加Mapper注解兜底双保险保证 MyBatis 能扫到。这一步做不到位容器启动时会报Consider defining a bean排查起来会绕圈子。注册功能的实现就非常直接了核心逻辑如下PostMapping(/register) public ResultString register(RequestBody Valid RegisterDTO dto) { // 1. 校验两次密码一致 if (!dto.getPassword().equals(dto.getConfirmPassword())) { return Result.error(两次输入密码不一致); } // 2. 判断用户名是否存在 Long count userMapper.selectCount( new LambdaQueryWrapperUser().eq(User::getUsername, dto.getUsername()) ); if (count 0) { return Result.error(用户名已被占用); } // 3. 密码加密注意是 BCrypt不是 MD5 User user new User(); user.setUsername(dto.getUsername()); user.setPassword(new BCryptPasswordEncoder().encode(dto.getPassword())); user.setNickname(dto.getNickname()); user.setAvatar(default_avatar.png); user.setStatus(0); userMapper.insert(user); return Result.success(注册成功); }代码虽然短但每一步都别省。用户名唯一校验是必须的否则数据库唯一索引会直接抛异常体验很糟糕。密码加密用 BCrypt它内置随机盐同一个密码每次加密结果都不同比 MD5 靠谱太多。2.3 登录鉴权JWT 拦截器的落地写法登录鉴权我选了 JWT而不是传统的 Session。主要原因是前后端分离后后端接口是无状态的JWT 天然适合分布在多实例上不需要做 Session 同步。登录接口的设计思路是这样的用户传入账号密码校验通过后生成一个 token 返回给前端。前端把 token 存到 localStorage每次请求在请求头里带上Authorization: Bearer token。JWT 的生成代码如下private String createToken(User user) { Date expireDate new Date(System.currentTimeMillis() 7 * 24 * 60 * 60 * 1000); return JWT.create() .withClaim(userId, user.getId()) .withClaim(username, user.getUsername()) .withExpiresAt(expireDate) .sign(Algorithm.HMAC256(jwtSecret)); }jwtSecret这个密钥要放在配置里不能硬编码在代码中。密钥长度尽量 32 位以上防止被暴力破解。校验 token 我用的是一个拦截器实现HandlerInterceptor接口在preHandle里读取请求头解析 token拿到 userId 放到ThreadLocal里供后续业务直接取用。遇到 token 无效或过期直接返回 401 JSON。这里分享一个我的处理细节拦截器放行名单用配置文件维护。像/api/user/login、/api/user/register、/api/recipe/list这些公开接口就不校验其余接口都拦截。开发时最容易犯的错是忘了放行注册接口导致前端注册都进不了系统接口文档里第一步就对不上。再说一下登录拦截的一个常见隐患如果 Controller 里需要当前登录用户的信息不要从前端传 userId一定要从 token 里解析。否则用户改一下请求参数就能操作别人的数据这是非常严重的越权漏洞。3. 自动装配原理与工程配置避坑3.1 自动装配原理为什么 SpringBoot 能“开箱即用”这个项目的技术底座是 SpringBoot如果不理解自动装配原理后面遇到莫名其妙的 bean 冲突、配置不生效排查起来会非常痛苦。这里用大白话拆解一下。SpringBoot 的启动类上有SpringBootApplication其实它是个组合注解核心是EnableAutoConfiguration。这个注解通过Import引入了AutoConfigurationImportSelector它做的事听起来很玄乎本质上就是启动时扫描所有依赖 jar 包里的自动配置类再根据当前项目的依赖情况和配置条件决定哪些 bean 要创建、哪些不创建。具体来说SpringBoot 把自动配置信息写在 jar 包内的META-INF/spring.factories文件里SpringBoot 2.7 之后改为META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。比如你引入了mybatis-plus-boot-starter它的包内就有一个MybatisPlusAutoConfiguration标记了ConditionalOnClass(SqlSessionFactory.class)。如果你确实引入了 MyBatis 相关依赖这个条件就成立自动配置类开始工作帮你把 SqlSessionFactory、SqlSessionTemplate 创建好。每个自动配置类内部还大量使用了ConditionalOnMissingBean意思是“如果容器里已经有用户自定义的 Bean就不重复装配”。这就是为什么你可以覆盖默认配置的原因。理解了这套机制再看常见的启动报错就明白多了。比如项目启动报Failed to configure a DataSource大概率是你没有配置数据源但却又引入了spring-boot-starter-data-jpa或 MyBatis 相关依赖导致自动配置类要创建 DataSource 时找不到 Bean。解决办法很简单要么补全spring.datasource.url配置要么把用不到的自动配置排除掉。项目里还做了一个小优化自定义一个GlobalWebMvcConfigurer在自动配置基础上增加拦截器注册。这是因为 WebMvc 的自动配置类会检测容器里是否存在WebMvcConfigurer类型的 Bean如果有就融合生效。这种扩展方式比直接继承WebMvcConfigurationSupport要安全得多后者会完全屏蔽掉 SpringBoot 的默认 MVC 配置导致静态资源映射全部失效。3.2 多环境配置与 IDEA 启动参数设置做项目时开发环境、测试环境、生产环境的数据库和 Redis 地址肯定不一样。我最开始把所有配置都写在application.yml里每次切换环境都要改文件再重启后来实在受不了了才认真搞了多环境配置。SpringBoot 多环境的做法很简单定义多个配置文件命名规则是application-{profile}.yml比如application-dev.yml、application-prod.yml然后通过属性spring.profiles.active指定当前激活哪个。我习惯在启动命令里指定环境而不是写在application.yml里因为这样同一份打包产物可以在不同环境稳定复用java -jar food-share.jar --spring.profiles.activeprod配置优先级还要搞清楚命令行参数 Java 系统属性 application.yml 配置文件。所以你在 IDEA 里调试时想临时换端口直接在 Program arguments 里写上--server.port8081就能覆盖 yml 里的值。重点说一下 IDEA 运行配置。很多初学者在 IDEA 里启动 SpringBoot会遇到“改了端口没生效”“改了环境变量没生效”这类问题。你得打开 Run/Debug Configurations 面板找到启动类对应的 Spring Boot 配置把参数填对Program arguments填--server.port8081 --spring.profiles.activedev这是优先级最高的一层。VM options填 JVM 参数比如-Dfile.encodingUTF-8 -Xms256m -Xmx512m。Environment variables填环境变量比如JAVA_HOME等。另一个容易忽略的操作IDEA 有时会缓存旧编译产物导致修改没生效。执行mvn clean再去掉 target 目录里的旧 class或者直接重启 IDEA基本都能解决。3.3 事务与动态代理CGLIB 带来的坑SpringBoot 的Transactional是项目里处理菜谱发布、点赞同步这类写操作时经常用的注解。但事务注解底层是 AOP 代理实现的这个机制带来的坑我一个一个讲。第一个坑是this调用问题。当一个类里的方法 A 调用同类中的方法 B而 B 上标注了Transactional事务是不生效的。原因很简单通过this调用的是原始对象的方法而不是代理对象的方法代理逻辑根本没机会介入。解决办法是把功能拆到另一个 Service 类里或者注入AopContext.currentProxy()用代理对象再调用一次。第二个坑是 SpringBoot 默认使用 CGLIB 代理。SpringBoot 2.0 之后默认将spring.aop.proxy-target-class设为 true也就是说就算你的类实现了接口也用 CGLIB而不是 JDK 动态代理。这样做的好处是普通类也能被代理你不用为了 AOP 刻意写接口。但代价是 CGLIB 是基于继承创建子类的所以被代理的类不能用final修饰final方法也无法被代理拦截。我在项目里用这个机制做了一个接口耗时日志切面记录每个接口的耗时排查慢接口时很有用。切面代码如下Aspect Component public class ApiLogAspect { Around(execution(* com.foodshare.controller..*.*(..))) public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; // 自己封装一个日志工具输出或直接 log.info return result; } }切面里千万记得调用joinPoint.proceed()如果不调用接口直接不执行了返回值还会是 null。这种低级错误调试起来非常容易让人怀疑人生。4. 进阶功能落地图片、点赞、搜索与前后端合体4.1 SpringBoot 集成 MinIO美食图片上传与访问安全美食平台最大的流量消耗就是图片。一开始我图省事把图片直接传到服务器本地某个目录结果遇到两个麻烦一是磁盘空间很快被打满二是项目重新部署时图片丢失。后来把图片存储切到了 MinIO一劳永逸。MinIO 是一个开源对象存储服务兼容 Amazon S3 API可以部署在私有服务器上。把它集成进 SpringBoot 的完整步骤第一步用 Docker 启动 MinIOdocker run -p 9000:9000 -p 9001:9001 \ -e MINIO_ROOT_USERadmin \ -e MINIO_ROOT_PASSWORDyour_secret \ -v /data/minio:/data \ minio/minio:latest server /data --console-address :90019000 端口是 API 端口9001 是 Web 管理界面。生产环境一定要修改默认账号密码并开启 TLS这些都是基本要求。第二步在 SpringBoot 里加入 MinIO 依赖dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency第三步编写配置类。我用ConfigurationProperties绑定配置项ConfigurationProperties(prefix minio) Component public class MinioProperties { private String endpoint http://localhost:9000; private String accessKey; private String secretKey; private String bucket; // getter/setter 省略 }第四步注册 MinioClient BeanBean public MinioClient minioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .build(); }第五步封装上传和访问逻辑。上传时我用 UUID 重命名文件避免重名覆盖和路径穿越问题public String upload(MultipartFile file) throws Exception { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String objectName recipe/ UUID.randomUUID() ext; minioClient.putObject( PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); return objectName; }关于图片访问的安全这里有个非常关键的经验MinIO 的 bucket 不要设置成公开读。你应该用“私有 bucket 预签名 URL”的方案。预签名 URL 是 MinIO 生成的一个带有效期的临时链接比如生成一个 7 天内有效的图片地址过期后链接自动失效。这样即使图片 URL 被人截图扩散有效期过了也无法访问能很好防止图片资源被盗链。获取预签名 URL 的代码String presignedUrl minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(objectName) .expiry(7, TimeUnit.DAYS) .build() );在实际项目里我会在前端展示图片时统一走一个/api/file/presigned接口后端根据业务需要生成临时 URL 返回前端拿到后加载图片。开发起来多写一点代码但安全性高了一整个档次。4.2 用 Redis 做点赞计数器与热度排行榜点赞功能是社区产品的灵魂但点赞操作有典型的写多读少特点。如果每次点赞都直接更新 MySQL一条菜谱点击上百次赞数据库的压力会非常明显。我这里用 Redis 做了缓冲。点赞操作的流程设计为三层第一层Redis 的 Set 保存“用户点赞记录”。key 设计为like:recipe:{recipeId}value 是 userId。用户点赞时执行SADD取消赞时执行SREM判断是否点过赞则执行SISMEMBER。这样的好处是天然去重且查询都是内存操作非常快。第二层Redis 的 Hash 保存“点赞计数的增量”。key 为like:count:recipefield 是 recipeIdvalue 是待同步的增量。点赞加一取消赞减一。第三层定时任务或主动触发时把增量同步到 MySQL 的like_count字段。同步逻辑不能全量覆盖否则会丢掉并发期间的增量。热度排行榜我用的是 Redis 的 ZSet。每当菜谱有浏览、点赞、收藏、评论动作时往hot:recipe这个 ZSet 里累加对应的分值。比如一次点赞加 3 分一次收藏加 5 分一次浏览加 1 分。前端直接调用SetZSetOperations.TypedTupleObject result redisTemplate.opsForZSet() .reverseRangeWithScores(hot:recipe, 0, 9);取 Top10 的效率是 O(log N M)非常厉害。而且 ZSet 天然按分值排序不用额外写排序逻辑。这里要提一个很大的坑Redis 的过期时间。如果给like:recipe:{recipeId}设置了过期时间那过期的 Set 会被 Redis 删除导致用户“点赞记录”丢失但 MySQL 的计数已经加上去了用户还是可以重复点赞。我的处理方案是热点数据不设过期时间非热点数据在用户取消全部点赞后再清理 key。简单点说Set 数据我只删除不自动过期避免计数与记录不一致。4.3 中文搜索先分词再查询搜索体验翻倍最初搜索功能我用的是 MySQL 的LIKE %关键字%。用户搜“红烧肉”时SQL 写成WHERE title LIKE %红烧肉%体验尚可。但用户搜“肉”时结果里混杂了大量不相关的内容而用户搜“红烧 肉”时带空格的查询更是一塌糊涂。这就是没有做中文分词的问题。我的方案是在 SpringBoot 里集成 HanLP 分词工具。HanLP 是开源的中文 NLP 工具包Java 版本可以通过 Maven 引入对菜谱名称、标签进行分词非常方便。做法分成两步第一步在发布菜谱时把标题和标签分词结果存到一个 keyword 表里比如“红烧肉”分词后得到“红烧肉”“肉”把每个词和菜谱 id 关联起来。这一步相当于提前建好了索引。第二步搜索时同样对用户输入进行分词再把每个词去匹配 keyword 表查出匹配的菜谱 id 集合再回表查询完整菜谱信息。这样搜索“红烧 肉”分词结果是“红烧”“肉”能同时命中红烧类和带肉类的菜谱召回率显著提升。HanLP 在 SpringBoot 里的使用示例import com.hankcs.hanlp.HanLP; import com.hankcs.hanlp.seg.common.Term; ListString words HanLP.segment(红烧肉) .stream() .map(Term::word) .collect(Collectors.toList());输出结果是[红烧肉, 肉]这样的词列表。之后再拼接查询条件。这不是一个搜索引擎级别的解决方案但胜在轻量、无额外依赖、可以离线运行。等到数据量真正大到 LIKE 都扛不住了再替换成 Elasticsearch中间体的思路完全一致你只需要把 keyword 表的来源从 MySQL 改成 ES 的倒排索引即可。4.4 把 Vue 打包放进 SpringBoot一台服务器跑整个站常规的前后端分离是前端部署一份 Nginx后端部署一个 SpringBoot Jar 包。但对于小项目、毕设或临时演示用 Nginx 反而成了多余环节。我在这项目里尝试了“Vue 打包放进 SpringBoot”的方案。操作步骤其实很简单。前端项目执行npm run build生成dist目录把里面的static资源和index.html全部复制到 SpringBoot 的src/main/resources/static目录下。重新打包之后直接访问http://localhost:8080/就能看到前端页面接口是/api开头由同一个 SpringBoot 进程处理。但这里有一个坑必须处理Vue Router 使用 history 模式时页面路径是/recipe/123这样的多级路由。如果用户直接刷新/recipe/123SpringBoot 默认会去找static/recipe/123这个资源找不到就返回 404。正确的做法是写一个兜底控制器把所有非/api的 URL 都转发到index.htmlController public class PageForwardController { RequestMapping(value {/, /recipe/**, /user/**, /search/**}) public String forward() { return forward:/index.html; } }这样一个 jar 包就能承载整个应用日常环境一台 2C4G 的服务器完全够用。Nginx 那套等真的有并发需求了再上也不迟。不过我也要说清楚这个方案的缺点前端资源的更新会和后端代码耦合在一起每次前端改版都要重新打包后端。如果你迭代频率很高还是尽早拆成独立部署更舒服。这属于权衡取舍没有绝对的对错。5. 常见问题排查与避坑速查表5.1 启动与运行期报错速查开发过程中把能踩的坑基本踩了个遍。这里整理一个核心报错速查表每个都是实际案例。报错/现象常见原因解决措施Failed to configure a DataSource引入了持久层依赖但没配置数据源补齐spring.datasource.url/username/password或者排除自动配置Consider defining a bean of type xxxMapper 接口没被扫描到启动类加MapperScan或 Mapper 接口加MapperInvalid bound statement (not found)Mapper XML 的 namespace 或方法 id 不匹配检查 XML 文件和 Mapper 接口的对应关系确认mapper-locations配置正确Access denied for user rootlocalhostMySQL 账号密码或权限不对检查数据库账号授权localhost和远程连接的 host 差异端口被占用启动时端口冲突换端口--server.port8081或找到并终止占用进程前端跨域报错前端端口和后端端口不一致开发环境配代理生产环境用同端口 兜底转发解析不了 LocalDateTime缺少 Jackson 时间序列化配置在配置类注册JavaTimeModule或设置全局时间格式最容易卡住初学者的其实是 MyBatis XML 映射问题。mapper-locations: classpath:/mapper/**/*.xml这个配置一旦少了/**XML 就加载不上。我这里还遇到过把 XML 放在src/main/resources下但没编译进去的情况执行mvn clean package时注意 target 目录里有没有对应 XML 文件。5.2 几个容易被忽略的配置细节一些看上去很小的细节实际排查时很要命单独提出来说说。第一个MySQL 连接串一定带上characterEncodingutf8和serverTimezoneAsia/Shanghai。前者保证中文不乱码后者避免 JDBC 驱动和数据库时区不一致导致时间错 8 小时。这个报错在开发环境不明显一旦部署到海外服务器时间全乱套。第二个Redis 连接不上时SpringBoot 默认会直接报启动失败。如果你不确定 Redis 是否在环境里可以先给 Redis 相关配置加一个临时开关或者在配置里关闭spring.redis.timeout的强校验。当然更稳的方式是确保 Redis 环境就绪再启动毕竟不装 Redis 就不该启动项目。第三个文件上传大小限制。SpringBoot 默认单个文件最大 1MB整体 10MB。美食平台的封面图动辄几 MB默认限制会导致上传直接失败。需要显式配置spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB第四个Controller 接收 MultipartFile 时如果前端传的是 JSON 加文件混合格式后端要用RequestPart分别接收普通RequestBody是处理不了文件流的。这个在网上有一堆MultipartFile传参失败的问题基本都是没分清RequestParam和RequestPart的区别。5.3 面试复盘项目里值得提的3个亮点做完这个项目我把里面能拿出来讲的技术亮点梳理了一遍。以后面试或答辩不要只会说“我做了注册登录”要说设计思路和取舍过程。第一个亮点Redis MySQL 的双写一致性方案。点赞、收藏这些高频操作用 Redis 做计数器异步同步到 MySQL 冗余字段。面试官最喜欢追问“数据不一致怎么办”我这边的回答是允许最终一致性高峰期容忍短暂不一致通过定时任务和查漏补缺机制保证最终一致。这在大多数社区类产品里都是合理方案。第二个亮点JWT 无状态鉴权。从 Session 到 JWT 的演进讲清 token 的组成如何防篡改如何设计拦截器以及 token 过期后如何续签。如果能补一句“JWT 不适合做强制注销要加 Redis 黑名单”面试官会觉得你对这个东西有真实理解。第三个亮点自动装配源码层面的理解。不要背概念直接在 IDE 里展开AutoConfigurationImportSelector讲清楚它通过Import机制加载装配类的流程。面试官问“SpringBoot 和 SpringMVC 区别是什么”你从自动装配、内置容器、约定优于配置三个角度回答基本能超出他的预期。项目做到这里我已经不太纠结“功能有多全”了。一个 SpringBoot 项目真正的价值不在于你写了多少行代码而在于你是否把每个选择背后的原因想清楚了。这个美食分享交流平台从技术选型、数据库建模到特色功能、部署方式完整走了一遍能沉淀下来的经验远比代码本身更宝贵。