做了这个项目之后我才发现,把家里那些"随手记在手机备忘录、写在便利贴上、发在家庭群里"的菜谱整理成一个能方便存储、查找、交流和沉淀的系统,工作量完全不低于做一个正经的社交产品。去年我抽空把这个基于SpringBoot + Vue + Android的家庭食谱烹饪菜谱分享交流系统完整做了一遍,从需求梳理、数据库设计、后端接口、Android端到Vue管理后台全部自己搞定。整个过程踩了不少坑,也沉淀出一些很实在的经验。如果你正在做一个类似的"平台型"全栈练习项目,或者想给家里搭建一套真正能用的菜谱分享工具,这篇文里的技术选型思路、表结构设计、联调细节和踩坑记录,应该能帮你省下不少时间。下面我会按我实际开发的顺序来讲,从立项拆解到最终打包部署,全程都是可以直接落地的内容。1. 立项拆解:三端各管什么,以及为什么不用小程序搞定一切1.1 先把"家庭食谱"这件事的真实需求捋清楚所谓"家庭食谱分享交流系统",最容易犯的错误是把它当成"菜谱大全App"来做——从网上爬五千个菜谱、做几个漂亮分类、再放个搜索框,看上去功能很全,实际上并不解决家庭场景的问题。我观察自家和身边朋友的使用习惯,发现家庭食谱的真实痛点有三个。第一是"记录分散"。同一个红烧肉,可能爸爸记在自己的备忘录里,妈妈存在手机相册里,照片上还没有步骤说明。系统首先要解决的是"让内容有一个集中沉淀的地方"。第二是"做法有版本差异"。每个家庭都有自己的做法习惯:用冰糖还是白糖、炖四十分钟还是一个小时、要不要先焯水。这意味着菜谱不应该是一份冷冰冰的标准答案,而是可以收藏、评论、交流讨论的内容。第三是"使用人群年龄跨度大"。家里不只有年轻人,还有长辈。长辈可能用不惯复杂交互,所以移动端的核心操作必须极其简单自然:进首页浏览、点开菜谱、收藏、评论,五步以内完成。想清楚这三点之后,功能范围就自然收敛了:用户注册登录、菜谱的增删改查、分类和标签、搜索、收藏、点赞、评论交流、个人主页,以及后台对内容的审核和用户管理。1.2 三个技术栈各自承担什么职责这个系统的标题里同时出现了SpringBoot、Vue、Android,一个常见误区是"后端用SpringBoot,前端用Vue,Android是另一套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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;菜谱表是所有业务的核心,设计时一定要考虑字段的扩展性: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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;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 '父评论ID,0表示一级评论', `reply_to_user_id` bigint(20) DEFAULT NULL COMMENT '回复目标用户ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_recipe` (`recipe_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;注意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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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: 0serverTimezone=Asia/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的条件构造器:LambdaQueryWrapper<Recipe> 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和Vue,Vue承担的职责是管理后台,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? ): ApiResponse<PageResult<Recipe>> @GET("recipe/{id}") suspend fun getRecipeDetail(@Path("id") id: Long): ApiResponse<RecipeDetail> @POST("recipe/collect/{id}") suspend fun collectRecipe(@Path("id") id: Long, @Body body: Any): ApiResponse<Void> }使用协程+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:usesCleartextTraffic="true"这适用于测试环境。其二,正式一点的做法是配置network security config,只允许特定域名走明文:<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">192.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 数据看板:用最轻的方式看社区活跃度后台的首页我放了一个数据概览,展示总用户数、总菜谱数、今日新增菜谱、今日新增评论,以及近七天的菜谱发布趋势和分类分布饼图。图表用ECharts,Vue3配合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连接串里我加了serverTimezone=Asia/Shanghai,但Android端展示时间又出问题了,详情页的发布时间比实际晚了8小时。本质原因是后端接口返回的时间字段是2024-06-01T12:00:00这种带时区的ISO格式,Android端的SimpleDateFormat解析默认用的是UTC时区。我最后的统一方案是:后端返回时间全部用时间戳(long型毫秒数),前端各自负责转换成当地时区展示。这样Android端和Vue后台解析逻辑都简单,也不会有时区混乱问题。这个修改看起来小,但在多端项目里,一次时间格式不一致就能让所有动态内容看起来都错乱了。6.4 分页重复与元素丢失:列表页的前端幂等处理自动化测试连续翻页时,出现过第二页数据和第一页有重复、或者每页不满一屏内容的情况。原因是滚动加载触发时,上一次请求还没返回,用户又滚了一下,同时发出两个page=2的请求,加载逻辑把两份相同数据都追加进去了。解决方式是加一个isLoading标志位,加载期间锁住重复触发;同时每次加载前记录当前列表最大ID,如果新返回数据里有已存在的ID就做去重过滤:val existingIds = mutableSetOf<Long>() 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 LambdaQueryWrapper<Recipe>() .select(Recipe::getId, Recipe::getTitle, Recipe::getCover, Recipe::getCategoryId, Recipe::getCollectCount)这一步做完,列表接口响应体从几百KB降到几十KB,分页加载速度快一倍不止。前端再配一个简单的LRU内存缓存,返回详情页再退回来时列表不会重新闪烁加载。做完整套系统之后,我最大的感受是:这类"三端全家桶"项目的难度,从来不在任何一个单点技术,而在模块之间的衔接缝隙。每个单端教程都只告诉你"这个页面怎么写",但真正决定项目成败的是API字段设计是否统一、时间格式是否一致、文件路径能否互相访问、登录态能不能在三个端口之间共用。把一个家庭场景的真实需求拆清楚,再用一套统一规范把三端串起来,最终这个系统是真的能被家里人日常使用的——每天晚上打开App看看今天吃什么,看到喜欢的菜谱收藏起来,做完发现味道不错再回来补一条评论。这种让技术落到真实生活里的感觉,比单纯跑通一个Demo要有意思得多。
做了这个项目之后我才发现,把家里那些"随手记在手机备忘录、写在便利贴上、发在家庭群里"的菜谱整理成一个能方便存储、查找、交流和沉淀的系统,工作量完全不低于做一个正经的社交产品。去年我抽空把这个基于SpringBoot + Vue + Android的家庭食谱烹饪菜谱分享交流系统完整做了一遍,从需求梳理、数据库设计、后端接口、Android端到Vue管理后台全部自己搞定。整个过程踩了不少坑,也沉淀出一些很实在的经验。如果你正在做一个类似的"平台型"全栈练习项目,或者想给家里搭建一套真正能用的菜谱分享工具,这篇文里的技术选型思路、表结构设计、联调细节和踩坑记录,应该能帮你省下不少时间。下面我会按我实际开发的顺序来讲,从立项拆解到最终打包部署,全程都是可以直接落地的内容。1. 立项拆解:三端各管什么,以及为什么不用小程序搞定一切1.1 先把"家庭食谱"这件事的真实需求捋清楚所谓"家庭食谱分享交流系统",最容易犯的错误是把它当成"菜谱大全App"来做——从网上爬五千个菜谱、做几个漂亮分类、再放个搜索框,看上去功能很全,实际上并不解决家庭场景的问题。我观察自家和身边朋友的使用习惯,发现家庭食谱的真实痛点有三个。第一是"记录分散"。同一个红烧肉,可能爸爸记在自己的备忘录里,妈妈存在手机相册里,照片上还没有步骤说明。系统首先要解决的是"让内容有一个集中沉淀的地方"。第二是"做法有版本差异"。每个家庭都有自己的做法习惯:用冰糖还是白糖、炖四十分钟还是一个小时、要不要先焯水。这意味着菜谱不应该是一份冷冰冰的标准答案,而是可以收藏、评论、交流讨论的内容。第三是"使用人群年龄跨度大"。家里不只有年轻人,还有长辈。长辈可能用不惯复杂交互,所以移动端的核心操作必须极其简单自然:进首页浏览、点开菜谱、收藏、评论,五步以内完成。想清楚这三点之后,功能范围就自然收敛了:用户注册登录、菜谱的增删改查、分类和标签、搜索、收藏、点赞、评论交流、个人主页,以及后台对内容的审核和用户管理。1.2 三个技术栈各自承担什么职责这个系统的标题里同时出现了SpringBoot、Vue、Android,一个常见误区是"后端用SpringBoot,前端用Vue,Android是另一套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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;菜谱表是所有业务的核心,设计时一定要考虑字段的扩展性: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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;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 '父评论ID,0表示一级评论', `reply_to_user_id` bigint(20) DEFAULT NULL COMMENT '回复目标用户ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_recipe` (`recipe_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;注意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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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: 0serverTimezone=Asia/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的条件构造器:LambdaQueryWrapper<Recipe> 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和Vue,Vue承担的职责是管理后台,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? ): ApiResponse<PageResult<Recipe>> @GET("recipe/{id}") suspend fun getRecipeDetail(@Path("id") id: Long): ApiResponse<RecipeDetail> @POST("recipe/collect/{id}") suspend fun collectRecipe(@Path("id") id: Long, @Body body: Any): ApiResponse<Void> }使用协程+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:usesCleartextTraffic="true"这适用于测试环境。其二,正式一点的做法是配置network security config,只允许特定域名走明文:<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">192.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 数据看板:用最轻的方式看社区活跃度后台的首页我放了一个数据概览,展示总用户数、总菜谱数、今日新增菜谱、今日新增评论,以及近七天的菜谱发布趋势和分类分布饼图。图表用ECharts,Vue3配合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连接串里我加了serverTimezone=Asia/Shanghai,但Android端展示时间又出问题了,详情页的发布时间比实际晚了8小时。本质原因是后端接口返回的时间字段是2024-06-01T12:00:00这种带时区的ISO格式,Android端的SimpleDateFormat解析默认用的是UTC时区。我最后的统一方案是:后端返回时间全部用时间戳(long型毫秒数),前端各自负责转换成当地时区展示。这样Android端和Vue后台解析逻辑都简单,也不会有时区混乱问题。这个修改看起来小,但在多端项目里,一次时间格式不一致就能让所有动态内容看起来都错乱了。6.4 分页重复与元素丢失:列表页的前端幂等处理自动化测试连续翻页时,出现过第二页数据和第一页有重复、或者每页不满一屏内容的情况。原因是滚动加载触发时,上一次请求还没返回,用户又滚了一下,同时发出两个page=2的请求,加载逻辑把两份相同数据都追加进去了。解决方式是加一个isLoading标志位,加载期间锁住重复触发;同时每次加载前记录当前列表最大ID,如果新返回数据里有已存在的ID就做去重过滤:val existingIds = mutableSetOf<Long>() 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 LambdaQueryWrapper<Recipe>() .select(Recipe::getId, Recipe::getTitle, Recipe::getCover, Recipe::getCategoryId, Recipe::getCollectCount)这一步做完,列表接口响应体从几百KB降到几十KB,分页加载速度快一倍不止。前端再配一个简单的LRU内存缓存,返回详情页再退回来时列表不会重新闪烁加载。做完整套系统之后,我最大的感受是:这类"三端全家桶"项目的难度,从来不在任何一个单点技术,而在模块之间的衔接缝隙。每个单端教程都只告诉你"这个页面怎么写",但真正决定项目成败的是API字段设计是否统一、时间格式是否一致、文件路径能否互相访问、登录态能不能在三个端口之间共用。把一个家庭场景的真实需求拆清楚,再用一套统一规范把三端串起来,最终这个系统是真的能被家里人日常使用的——每天晚上打开App看看今天吃什么,看到喜欢的菜谱收藏起来,做完发现味道不错再回来补一条评论。这种让技术落到真实生活里的感觉,比单纯跑通一个Demo要有意思得多。