SpringBoot+Vue+Android家庭食谱系统:全栈项目实战与踩坑记录

📅 发布时间:2026/10/21 5:05:13
SpringBoot+Vue+Android家庭食谱系统:全栈项目实战与踩坑记录
做了这个项目之后我才发现把家里那些随手记在手机备忘录、写在便利贴上、发在家庭群里的菜谱整理成一个能方便存储、查找、交流和沉淀的系统工作量完全不低于做一个正经的社交产品。去年我抽空把这个基于SpringBoot Vue Android的家庭食谱烹饪菜谱分享交流系统完整做了一遍从需求梳理、数据库设计、后端接口、Android端到Vue管理后台全部自己搞定。整个过程踩了不少坑也沉淀出一些很实在的经验。如果你正在做一个类似的平台型全栈练习项目或者想给家里搭建一套真正能用的菜谱分享工具这篇文里的技术选型思路、表结构设计、联调细节和踩坑记录应该能帮你省下不少时间。下面我会按我实际开发的顺序来讲从立项拆解到最终打包部署全程都是可以直接落地的内容。1. 立项拆解三端各管什么以及为什么不用小程序搞定一切1.1 先把家庭食谱这件事的真实需求捋清楚所谓家庭食谱分享交流系统最容易犯的错误是把它当成菜谱大全App来做——从网上爬五千个菜谱、做几个漂亮分类、再放个搜索框看上去功能很全实际上并不解决家庭场景的问题。我观察自家和身边朋友的使用习惯发现家庭食谱的真实痛点有三个。第一是记录分散。同一个红烧肉可能爸爸记在自己的备忘录里妈妈存在手机相册里照片上还没有步骤说明。系统首先要解决的是让内容有一个集中沉淀的地方。第二是做法有版本差异。每个家庭都有自己的做法习惯用冰糖还是白糖、炖四十分钟还是一个小时、要不要先焯水。这意味着菜谱不应该是一份冷冰冰的标准答案而是可以收藏、评论、交流讨论的内容。第三是使用人群年龄跨度大。家里不只有年轻人还有长辈。长辈可能用不惯复杂交互所以移动端的核心操作必须极其简单自然进首页浏览、点开菜谱、收藏、评论五步以内完成。想清楚这三点之后功能范围就自然收敛了用户注册登录、菜谱的增删改查、分类和标签、搜索、收藏、点赞、评论交流、个人主页以及后台对内容的审核和用户管理。1.2 三个技术栈各自承担什么职责这个系统的标题里同时出现了SpringBoot、Vue、Android一个常见误区是后端用SpringBoot前端用VueAndroid是另一套App——三者各自孤立各做各的。我实际开发时把三端的边界划分得很明确。后端SpringBoot负责的是纯粹的API服务用户认证、菜谱管理、评论和收藏等业务逻辑、数据持久化、文件上传存储。它不关心请求来自哪个端只要按接口规范返回JSON。Vue在这一架构里的定位不是普通用户使用的H5页面而是管理后台和内容运营入口。管理员用它完成菜谱审核、分类管理、用户状态管理、基础的数据看板。这么做的好处很明显Android端能保持轻量干净只服务家庭成员的高频使用场景后台则集中处理低频的、权限要求高的操作。Android端是真正的用户主阵地。首页信息流、菜谱详情、分类浏览、搜索、个人中心、收藏列表全部在手机App里实现。整个项目跑起来的链路是Android端和Vue后台都请求SpringBoot提供的RESTful API共用同一套数据。1.3 顺带聊一下为什么我用三端方案而不是小程序很多人会问家庭内部使用场景做一个微信小程序不就行了开发成本低、不用装App、分享也方便。我测过之后没采用原因有三点。第一是内容审核和平台合规。小程序对用户生成内容类目有很严格的要求食品类内容往往需要额外的资质或承诺家庭内部工具没必要背这个负担。自建Android应用安装包只要自己负责内部使用场景限制明显少得多。第二是菜谱数据量积累之后本地化的收藏和离线浏览体验很重要。做菜的时候厨房网络信号往往不好App可以做到收藏菜谱离线可看小程序在弱网环境下表现受平台限制。第三学习价值不一样。SpringBoot、Vue、Android三个栈完整走一遍能同时把后端服务、BS管理端、CS移动端的开发模式都锻炼到这是小程序替代不了的。2. 数据模型设计一张菜谱表如何装下分类、食材、步骤和多人交流2.1 核心表结构和字段设计思路数据库我用的MySQL 8默认utf8mb4编码——这个一定不要省否则中文emoji字符会出现乱码。整个系统核心就围绕六张表展开用户表、菜谱表、分类表、标签表、收藏表、评论表。用户表字段如下CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称默认显示用户名, avatar varchar(200) DEFAULT NULL COMMENT 头像URL, role tinyint(4) NOT NULL DEFAULT 1 COMMENT 1普通用户 2管理员, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;菜谱表是所有业务的核心设计时一定要考虑字段的扩展性CREATE TABLE recipe ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 发布者ID, title varchar(100) NOT NULL COMMENT 菜名, cover varchar(300) NOT NULL COMMENT 封面图URL, category_id bigint(20) NOT NULL COMMENT 分类ID, description varchar(500) DEFAULT NULL COMMENT 一道菜的背景故事或简介, ingredients text COMMENT 食材清单JSON数组字符串, steps longtext COMMENT 步骤列表JSON数组字符串, difficulty tinyint(4) DEFAULT 1 COMMENT 难度 1简单 2中等 3困难, cook_time int(11) DEFAULT 30 COMMENT 预计烹饪时间单位分钟, view_count int(11) NOT NULL DEFAULT 0, like_count int(11) NOT NULL DEFAULT 0, collect_count int(11) NOT NULL DEFAULT 0, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上架 0下架 2待审核, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_user (user_id), KEY idx_status (status) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;ingredients和steps我用JSON字符串存储一开始有同事觉得不规范但实际用下来才发现这个设计在家庭菜谱场景里非常合适——因为食材和步骤的结构是灵活变化的有的菜分三组食材有的菜步骤里还裹着图片强行设计成关联表反而会让CRUD变得极其繁琐。JSON字段在MySQL 8里可以直接用函数查询对于中小型应用性能完全够。2.2 分类、标签和多版本做法的设计选择分类表很简单id、分类名、排序值、图标URL。我预置了家常菜、汤粥、烘焙、主食、凉菜、饮品等常见分类。标签表同样独立另加一张recipe_tag关联表。这里我特别建议加标签而不是只靠分类因为家庭场景下爸爸拿手菜减脂期孩子爱吃这类交叉属性用标签表达更灵活。关于一个菜谱多个版本做法的需求我在设计时纠结过要不要做成菜谱版本表。最后我选择了更轻的方式同一道菜的不同做法就是多条独立菜谱通过标题相似搜索把他们关联起来或者用户在评论里互相讨论我加了一勺黄豆酱味道更好。这个方案在数据模型上最简单也最符合社区交流的语义——不同家庭各有做法本来就是不同内容大家各自发布再互相讨论反而更热闹。2.3 交流互动体系收藏、点赞、评论不是简单的计数收藏表设计为user_id recipe_id唯一索引同时统计字段冗余在菜谱表上。这里我特意说明一下冗余计数的用法查询列表页时每次实时count收藏表在数据量大之后会产生明显性能压力我在菜谱表上直接维护collect_count字段收藏和取消收藏时事务里同步更新。这样列表页和详情页的展示性能好很多。评论表要考虑的是楼层关系CREATE TABLE comment ( id bigint(20) NOT NULL AUTO_INCREMENT, recipe_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, content varchar(500) NOT NULL, parent_id bigint(20) DEFAULT 0 COMMENT 父评论ID0表示一级评论, reply_to_user_id bigint(20) DEFAULT NULL COMMENT 回复目标用户ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_recipe (recipe_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;注意parent_id让评论支持两层嵌套就够用了家庭场景不需要搞复杂的回复的回复树形结构。评论列表查询时一次性取出某菜谱的全部评论在内存里组装成树连表查询反而麻烦。3. SpringBoot服务端JWT鉴权、图片上传和搜索排序的落地细节3.1 工程结构和基础依赖选型后端工程我按标准分层controller、service、mapper、entity、config、common。依赖选Spring Boot 2.7.x搭配MyBatis-Plus、Spring Security、JWT、Knife4j接口文档。选MyBatis-Plus而不是纯MyBatis主要看中它的条件构造器和分页插件能让CRUD代码量压缩一半以上。Spring Security配合JWT做无状态鉴权——移动端和Vue管理后台共用同一套登录接口JWT的天然优势就是不依赖Session。application.yml里几个容易忽略的配置值得单独说server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/family_recipe?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0serverTimezoneAsia/Shanghai必须写上否则连接MySQL 8会报时区错误max-file-size限制很重要不然用户随手拍一张4K照片直接让你的接口超时。3.2 基于JWT的登录态设计以及Spring Security配置的坑JWT流程很简单登录成功返回token字符串客户端每次请求在Header里带上Authorization: Bearer token后端过滤器解析token并注入当前用户上下文。关键实现我拆成三步。第一步自定义一个拦截器而不是直接上Spring Security的复杂配置。对于这个项目我只用Spring Security做密码加密和过滤链其余认证逻辑自己写。核心过滤器public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims JwtUtil.parseToken(token); Integer userId Integer.valueOf(claims.get(userId).toString()); UserContext.set(userId); } catch (Exception e) { // token过期或非法不直接报错放行后由具体接口校验 } } chain.doFilter(request, response); } }这里我踩过一个坑一开始在过滤器里直接返回401结果发现登录过期请重新登录的提示安到了所有页面上包括登录页自身因为匿名访问也是走这个过滤器。后来改成非法token不拦截交给接口层处理反而更灵活。第二步用户密码存库必须用BCrypt。new BCryptPasswordEncoder().encode(明文密码)以后校验用matches方法。这一步不解释凡是做过的应该都知道明文密码存库是多恐怖的事。第三步注册接口里要做用户名唯一校验同时给一个默认头像。真实场景里我发现很多注册失败都是因为用户名已被占用不友好这里把错误信息返回得具体一点。3.3 图片上传我选本地存储而不是OSS的原因图片存储我最终选了本地磁盘存储 Nginx反向代理访问的方案而不是一开始想的对象存储。原因是家庭内部系统的访问量有限对象存储的SDK接入、桶策略、外链域名配置对这样一个项目来说有点杀鸡用牛刀。实现是这样的上传接口接收MultipartFile按日期目录存储public String upload(MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); String fileName UUID.randomUUID().toString().replace(-, ) ext; String relativePath /uploads/ dateDir / fileName; // 绝对路径写到配置项里例如 D:/recipe-upload/ file.transferTo(new File(uploadPath relativePath)); return relativePath; }然后配置文件里加一个静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceHandler(file: uploadPath); } }这里我特别要提醒前端后端联调时图片地址如果返回/uploads/xxx.jpg这种相对路径Android端和Vue端都要拼上完整的服务器地址才能访问。我踩的坑是Android端Glide加载图片时直接用了相对路径结果全部加载失败日志报Failed to load resource。最后在API响应的数据结构里专门返回一个imageBaseUrl字段统一拼接或者干脆在存储时存完整URL这种小细节决定了App到底能不能跑起来像样。3.4 搜索怎么做MySQL模糊查询到底能撑多大家庭食谱系统里菜谱量级大概在几千到几万条我用MySQL的LIKE查询就足够了不需要上Elasticsearch。搜索实现我用了MyBatis-Plus的条件构造器LambdaQueryWrapperRecipe wrapper new LambdaQueryWrapper(); wrapper.like(Recipe::getTitle, keyword); // 菜品名匹配优先级最高 wrapper.orderByDesc(Recipe::getCollectCount);排序策略上我默认按收藏数降序而不是按时间降序——家庭菜谱场景里大家真正想看的是大家做得最多、收藏最多的菜而不是最新发的。另外标题匹配和描述匹配的权重不同如果要更精细可以在SQL里加ORDER BY CASE WHEN title keyword THEN 0 WHEN title LIKE keyword% THEN 1 ELSE 2 END让完全匹配的菜排最前。搜索冷启动问题也要提前想新菜谱没有收藏数排序会靠后。我的做法是热度权重里混合一个时间衰减因子ORDER BY (collect_count * 3 like_count * 2 view_count * 0.5) / (TIMESTAMPDIFF(DAY, create_time, NOW()) 1) DESC这个公式逻辑很直白内容越新、互动越高排名越靠前老内容的权重也不会完全清零。4. Android端实践网络层、多状态页面与离线收藏4.1 为什么我选了Kotlin Retrofit Coil这套组合Android端我没有用Vue直接打包成WebView方案。标题里提到SpringBoot和VueVue承担的职责是管理后台Android端是原生Kotlin开发。原因很简单原生App交互流畅度、列表滑动性能、离线缓存能力都远强于Web容器而且Kotlin开发效率在Android里已经算很高的了。依赖核心就三个Retrofit2做网络请求、OkHttp做网络层、Coil做图片加载。协程做异步任务Room做本地数据库。Retrofit的Service接口定义是整个App网络层的骨架interface RecipeApi { GET(recipe/list) suspend fun getRecipeList( Query(page) page: Int, Query(size) size: Int, Query(categoryId) categoryId: Long? ): ApiResponsePageResultRecipe GET(recipe/{id}) suspend fun getRecipeDetail(Path(id) id: Long): ApiResponseRecipeDetail POST(recipe/collect/{id}) suspend fun collectRecipe(Path(id) id: Long, Body body: Any): ApiResponseVoid }使用协程suspend函数做异步代码比回调嵌套舒服太多。4.2 列表页的多状态处理加载中、空数据、失败重试家庭用户用App时最怕的就是卡在某页不知道发生了什么。所以我在列表页做了统一的多状态管理——加载中Loading、空数据Empty、失败Error、正常Content四种状态用一个状态切换控件统一管理。这里有一个很有价值的开发习惯ApI返回的分页数据结构里除了data以外一定要带上total和hasMore字段。Android端在滚动加载时要根据hasMore决定是否显示没有更多了的底栏。我在开发初期偷懒只返回了list结果上滑加载时永远不知道还能不能加载下一页只能发请求试白白浪费用户流量。4.3 离线收藏用Room保存收藏菜谱的完整数据离线收藏用于菜谱详情缓存——收藏过的菜谱在没有网络时也能打开看到完整步骤和食材这是App体验里很打动人的细节。Room实体直接映射菜谱详情收藏动作发生时先从服务端拉一次完整详情存进本地表取消收藏时删掉。读取时优先查本地查不到再走网络。Entity(tableName favorite_recipe) data class FavoriteRecipe( PrimaryKey val id: Long, val title: String, val cover: String, val ingredients: String, val steps: String, val collectTime: Long )注意到没有我保存的是整个菜谱内容不是仅保存一个ID。这个设计使得离线收藏的打开率极高家里人做饭前翻收藏列表完全无需联网就能看到步骤。4.4 一个让很多新手崩溃的问题Android默认禁止明文HTTP流量Android 9及更高版本默认不允许明文HTTP请求如果你后端的接口地址是http://192.168.x.x:8080直接请求会抛CLEARTEXT communication not permitted异常。解决方式有两个。其一在AndroidManifest.xml里给application节点加android:usesCleartextTraffictrue这适用于测试环境。其二正式一点的做法是配置network security config只允许特定域名走明文?xml version1.0 encodingutf-8? network-security-config domain-config cleartextTrafficPermittedtrue domain includeSubdomainstrue192.168.1.10/domain /domain-config /network-security-config我调试时两种都试过最后测试环境直接用usesCleartextTraffic快。这个配置遗漏的话App首页永远白屏但看日志才发现问题在网络层。5. Vue管理后台审核、统计和三端协同的那条隐藏链路5.1 为什么一个家庭食谱系统也需要后台可能有人觉得家庭内部使用的工具不需要后台但实际内容只要开放注册和发布就必然出现三类问题广告垃圾内容、不合规图片、垃圾评论。哪怕只是朋友之间使用也需要一个能下架内容、禁用账号的入口。管理后台我用Vue 3 Element Plus Axios Vue Router Pinia搭建整个后台只解决三个核心问题内容审核、用户管理、数据概览。5.2 菜谱审核列表的实现要点菜谱表里我设计了status字段1上架、0下架、2待审核。普通用户发布时status默认2后台管理员审核通过才置为1。后台的菜谱列表就是一个带条件筛选的表格页面筛选条件有状态、分类、标题关键字、发布时间区间操作有通过下架删除。Element Plus的el-table el-pagination组合做这个非常顺手。有一个细节后台接口必须支持批量操作否则审核几十条低质量内容要逐条点开人会被逼疯。我后端加了POST /admin/recipe/batch/status接口接收ID数组和状态值前端表格加checkbox列再放一个批量下拉按钮操作效率大幅提高。权限方面后台接口统一加管理员权限校验非admin角色访问任何/admin/**接口一律返回403。前端路由也做一层守卫未登录跳登录页非管理员跳首页。双重校验不能省前端守卫只是体验问题后端校验才是安全底线。5.3 数据看板用最轻的方式看社区活跃度后台的首页我放了一个数据概览展示总用户数、总菜谱数、今日新增菜谱、今日新增评论以及近七天的菜谱发布趋势和分类分布饼图。图表用EChartsVue3配合echarts包可以直接用。统计接口也是写在SpringBoot里的写几个聚合SQL就能搞定-- 近七天每日新增菜谱数 SELECT DATE(create_time) as date, COUNT(*) as cnt FROM recipe WHERE create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time);这个页面做成看一眼就知道社区健不健康的程度就够了不需要上复杂的实时大数据。实际运营中我发现分类分布饼图能直观看出系统里哪类菜谱内容太稀缺方便引导用户补充。5.4 后台和Android端联调时的一个隐蔽坑后台和Android端共享同一个后端子服务所以它们必须遵守完全相同的接口规范。我用Knife4j维护API文档后台前端开发时按文档对接。但真实项目里我犯过一个错误后台需要返回的菜谱列表里包含了审核状态字段Android端希望列表接口不返回无用字段于是我在后台和App端各写了一个列表接口。结果导致两边的排序、分页逻辑出现差异同一条数据在两端展示顺序不一致折腾了一天才发现是两套接口细节对不上。后续我改成统一接口列表一律返回完整字段App端自己只读取需要的字段后台也一样。接口不是越定制越好对于这种中小型系统一个标准接口喂两端才是省心方案。6. 打包部署与联调最容易翻车又最影响体验的四个环节6.1 跨域配置、端口开放和局域网访问开发过程中Vue后台跑在5173端口SpringBoot跑在8080端口Vue必须配置代理// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这是开发环境的做法。生产环境我直接把Vue后台打包后放进SpringBoot的static资源目录或者用独立的Nginx服务。如果Nginx做前端静态托管同时反向代理/api到SpringBoot这里要专门配置跨域响应头server { listen 80; server_name 你的服务器IP或域名; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }还有一个非常隐蔽的问题云服务器默认安全组只开放个别端口很多人后端接口写好了但外网始终访问不通检查半天发现是8000端口没在安全组规则里放行。Android端实机测试时连接的是局域网或公网IP一定要确保网络环境能通到后端。6.2 文件上传路径与Nginx的映射不一致本地开发时图片能显示部署到服务器上图片全部裂了这个问题十有八九是上传路径配置不一致导致的。我自己的解决方式是把上传路径抽成一个配置项后端代码里完全不用绝对路径统一用${file.upload-path}占位。部署脚本里确保目录存在mkdir -p /data/recipe-upload/uploads chown -R www-data:www-data /data/recipe-upload然后Nginx增加location /uploads/ { alias /data/recipe-upload/uploads/; }这里踩坑的点在于SpringBoot内嵌静态映射和Nginx的反向代理如果都配置了两个路径一旦不一致就会出现一会儿能显示一会儿不能显示的灵异现象。我现在统一原则生产环境一切静态文件访问由Nginx负责SpringBoot只处理动态接口职责单一问题就清楚了。6.3 时间时区一致性为什么评论时间显示快了8小时MySQL连接串里我加了serverTimezoneAsia/Shanghai但Android端展示时间又出问题了详情页的发布时间比实际晚了8小时。本质原因是后端接口返回的时间字段是2024-06-01T12:00:00这种带时区的ISO格式Android端的SimpleDateFormat解析默认用的是UTC时区。我最后的统一方案是后端返回时间全部用时间戳long型毫秒数前端各自负责转换成当地时区展示。这样Android端和Vue后台解析逻辑都简单也不会有时区混乱问题。这个修改看起来小但在多端项目里一次时间格式不一致就能让所有动态内容看起来都错乱了。6.4 分页重复与元素丢失列表页的前端幂等处理自动化测试连续翻页时出现过第二页数据和第一页有重复、或者每页不满一屏内容的情况。原因是滚动加载触发时上一次请求还没返回用户又滚了一下同时发出两个page2的请求加载逻辑把两份相同数据都追加进去了。解决方式是加一个isLoading标志位加载期间锁住重复触发同时每次加载前记录当前列表最大ID如果新返回数据里有已存在的ID就做去重过滤val existingIds mutableSetOfLong() currentList.forEach { existingIds.add(it.id) } newList.filter { it.id !in existingIds }.let { loadMoreItems.addAll(it) }这个去重逻辑虽然简单但实际体验差异极大没有它滑动加载就会给人一种数据错乱的廉价感。6.5 几条性能优化经验整个项目性能优化的核心不在数据库而在图片。菜谱封面如果直接上传原图一个列表页加载30道菜每张照片2MB流量消耗不可接受。我在后端上传时用Thumbnailator做了压缩处理封面统一生成800px宽度的缩略图步骤图压缩到1280px宽度同时后端列表接口只返回缩略图地址详情页再返回原图地址。用Coil加载时再配合placeholder和error占位图弱网之下的列表滑动流畅度明显上了一个台阶。另外一个很容易被忽视的点是列表接口的字段裁剪。详情页需要steps全文但列表页根本不需要。MyBatis-Plus的select方法可以只查指定字段recipeMapper.selectList(new LambdaQueryWrapperRecipe() .select(Recipe::getId, Recipe::getTitle, Recipe::getCover, Recipe::getCategoryId, Recipe::getCollectCount)这一步做完列表接口响应体从几百KB降到几十KB分页加载速度快一倍不止。前端再配一个简单的LRU内存缓存返回详情页再退回来时列表不会重新闪烁加载。做完整套系统之后我最大的感受是这类三端全家桶项目的难度从来不在任何一个单点技术而在模块之间的衔接缝隙。每个单端教程都只告诉你这个页面怎么写但真正决定项目成败的是API字段设计是否统一、时间格式是否一致、文件路径能否互相访问、登录态能不能在三个端口之间共用。把一个家庭场景的真实需求拆清楚再用一套统一规范把三端串起来最终这个系统是真的能被家里人日常使用的——每天晚上打开App看看今天吃什么看到喜欢的菜谱收藏起来做完发现味道不错再回来补一条评论。这种让技术落到真实生活里的感觉比单纯跑通一个Demo要有意思得多。