
年初帮A同学做毕设评审的时候她拿出来的这套“校园光盘行动”平台让我印象很深。Spring Boot Vue做一套反浪费管理系统再叠一个AI图像识别判定“有没有光盘”最后用排行榜做社交激励——功能不算多难技术点覆盖却相当完整从后端接口、数据库设计到模型接入、前端交互全走了一遍。这篇文章就用这个项目当例子聊聊这类“毕设型全栈应用”到底怎么落地哪些环节是重中之重哪些坑是前人踩过、你最好绕开的。1. 项目背景与整体设计为什么用“AI识别排行榜”来反浪费1.1 真实场景里的痛点分析校园食堂的浪费问题比想象中严重得多。我观察过几个高校食堂的收餐台高峰期十分钟内收上来的餐盘里至少有三分之一还留着大量剩菜。更麻烦的是这种浪费没人管、也没法管——食堂阿姨不可能盯着每个学生把饭吃完后勤部门只能贴标语、放宣传视频但效果有限。这个项目要解决的就是把“光盘”这件事从道德号召变成可量化、可激励的行为。核心思路是学生在食堂吃完饭后用手机拍一张餐盘照片AI自动判断是不是光盘。如果是就获得积分积分进入排行榜排行榜和动态分享形成社交压力与荣誉感。这样一来反浪费就有了数据抓手和激励机制。1.2 技术选型为什么是Spring Boot Vue选技术栈之前我先问了自己三个问题谁在开发部署在哪里演示给谁看答案是开发者是学生部署环境是一台普通实验室服务器演示对象是答辩老师。这就决定了技术栈必须满足三个条件——成熟稳定、资料多、上手快。Spring Boot Vue恰好是当下最主流的学生全栈组合之一网上案例铺天盖地出了问题搜索就能找到答案。Spring Boot的价值在于它把后端开发的配置工作量降到极低。以前做SSH项目光配置XML就要半天现在一个启动类就能把Web服务跑起来集成的数据校验、JSON处理、异常处理等能力都是开箱即用特别适合开发周期只有两三个月的毕设项目。Vue这边响应式数据绑定和组件化把前端代码组织得很清晰排行榜列表、动态卡片、上传按钮这些UI元素拆成组件之后后续加功能不费劲。我在实际评估中还对比过其他方案Django React、Flask 原生JS、Node.js Vue。各有优势但Django在国内社区资料不如Spring Boot丰富Flask太轻导致很多东西得自己拼装Node.js在部署阶段对新手不够友好。Spring Boot Vue是那个“综合翻车概率最低”的选择。1.3 系统整体架构和模块划分整个平台分成三个端用户端学生用手机访问、管理端食堂或学生会运营、AI识别服务独立的模型推理模块。用户端的关键页面有四个拍照上传页、识别结果页、排行榜页、个人中心。管理端主要做数据看板和用户管理。AI识别服务单独拆出来不跟Spring Boot直接耦合通过HTTP接口调用这点很重要——训练好的模型可以换识别逻辑可以升级不影响主业务代码。后端按“业务域”划分包结构我建议这样分controller接收前端请求做参数校验返回统一结果service核心业务逻辑比如积分计算、排行榜查询mapper数据库访问用MyBatis就够不必上太重的东西ai负责与AI识别服务交互调用接口、处理返回结果entity数据实体类数据库我设计了五张核心表用户表、识别记录表、积分流水表、动态表和评论表。排行榜不需要单独建表直接用积分流水表实时汇总这也是后面要聊的一个关键设计点。2. 核心功能模块拆解AI识别、积分体系与排行榜2.1 AI识别模块从“人眼看”到“机器判”“光盘”识别的本质是一个二分类图像任务给一张餐盘照片判断是“已光盘”还是“有剩余”。听起来简单实际落地有三个环节要处理模型选型、服务封装、阈值设定。模型我选的是轻量级分类网络MobileNetV3输入尺寸224×224。为什么不用ResNet这种大模型因为校园里有几百个学生同时上传GPU资源有限MobileNet单张推理只要几十毫秒CPU也能跑准确率在控制得当的前提下能到90%以上够用。做演示和答辩的时候轻量模型还有一个隐形优势——不依赖高端显卡用自己笔记本就能现场演示不会出现“只有服务器能跑”的尴尬。模型训练数据的来源要特别说一句别自己傻乎乎去食堂拍几百张照片费时不说光照和环境都很难统一。更务实的做法是搭建一个小规模采集流程用手机在不同光线、不同餐盘颜色、不同菜品残渣的情况下各拍三十到五十张然后用开源数据集做预训练、用自己的数据做微调。微调阶段只需要改最后一层全连接把原来的1000类输出改成2类训练大概二十个epoch就能收敛。识别服务不用跟Spring Boot写在一起。我用Python封装了一个Flask服务加载训练好的模型对外暴露一个POST /predict接口接收图片字节流返回“光盘概率”。Spring Boot端用RestTemplate调用这个服务。这里有个容易踩坑的点模型权重文件加载很慢第一次调用可能要等两三秒。我当时的处理方式是服务启动时就预加载模型到内存后续请求直接走推理不再重新加载。还有监控模型推理耗时特别重要如果单次推理超过1.5秒前端就会觉得卡。实际优化下来GPU上MobileNet推理在30毫秒以内CPU上大概100到200毫秒完全可接受。2.2 判定逻辑与防作弊设计光有模型还不够业务判定逻辑才是真正的门槛。上传一张照片AI返回“光盘概率0.87”这算不算光盘我的判定策略是这样的概率大于0.85直接判定光盘概率在0.5到0.85之间进入人工复核池概率小于0.5直接判定未光盘不给积分这个三层判定能有效避免模型“自信过头”。我测试过某些深色汤汁溅在白色餐盘上模型会误判成“有菜”概率在0.7左右。直接拒绝会误伤用户直接放行又会让刷分变得容易人工复核是最稳妥的过渡方案。防作弊设计这块我踩过两次坑才补全。第一次上线有人拿着以前的照片反复上传照样拿积分。后来加了两个约束上传时调用后端获取当前时间戳前端携带这个时间戳一起提交并且限制同一个用户每天的识别次数为6次上传照片时必须带食堂位置信息后端记录经纬度范围防止在校外乱传照片刷分。第二个坑是照片被PS改过这个无法完全杜绝但作为毕设项目控制到“不能轻易钻空子”这个程度就够了。2.3 积分与排行榜激励机制的算法设计积分规则是整个项目的“心脏”规则设计不好用户就没有动力持续使用。我最后敲定的规则是一次有效光盘判定10分连续7天每天至少完成一次光盘记录额外20分每日首次上传2分点赞别人动态1分每日上限5次这套规则的精妙之处在于基础积分鼓励“每餐都光盘”连续打卡奖励“持续坚持”每日首次上传激励“活跃使用”。设计的时候要小心积分通胀问题如果积分太好拿排行榜就没有含金量了。排行榜我做了三个维度日榜、周榜、总榜。实现上有两种方案一种是直接查数据库动态求和另一种是用Redis的有序集合ZSet。我后来选择了Redis ZSet原因很简单当记录数超过几万条数据库实时聚合排行会让接口响应变慢而ZSet的ZREVRANGE命令是O(logN M)复杂度实时排行毫秒级返回。日榜的key带日期比如rank:20241201每个人当天第一次获得积分时用ZINCRBY累加一整天结束后旧key自然过期。排行榜返回给前端时我做了三层过滤只看自己所在学院的排行需要用户在个人资料里填学院、只看真实用户ID管理员账号过滤掉、只展示排名前100。这样既保护了隐私又让榜单不至于被“僵尸号”刷满。2.4 社交分享模块的克制设计社交模块我没有做太重。毕设项目最怕的就是在社交功能上无限加需求——评论、点赞、转发、私信、好友关系每加一个功能就多一堆表、一堆接口、一堆测试用例。我只做了三个功能发布光盘动态、给别人的动态点赞、评论。动态发布的时候用户可以把AI识别成功的照片加上一句感想分享出去后端审核一下文字内容是否合法有敏感词就拦截。这里的“拦截”只是简单的关键词匹配不需要接第三方审核服务。点赞和评论都用的是最朴素的表结构点赞表(user_id, post_id, create_time)、评论表(post_id, user_id, content, create_time)。数据库加索引查询动态列表时一次性带出点赞数和评论数不做实时通知推送。为什么要克制核心原因是毕设项目的评判标准是“完整度”而不是“复杂度”。评委老师看到的是一个能演示、逻辑自洽、代码规范的系统而不是一个看起来无限膨胀但哪哪都不靠谱的半成品。你把点赞、评论、排行榜做扎实比塞一堆花哨但脆弱的社交功能得分更高。3. 实操复盘从后端接口到前端页面的完整实现3.1 后端核心接口设计与代码示范整个后端我设计了大概20个接口其中最关键的是这三个上传识别接口POST /api/record/upload Content-Type: multipart/form-data 参数 - file: 餐盘照片文件 - location: 食堂位置坐标 - timestamp: 前端获取的时间戳这个接口的处理流程是接收文件→保存到临时目录→计算文件MD5防止重复上传→调用AI识别服务→根据判定逻辑返回结果→如果判定为光盘发放积分并记录日志。积分流水查询接口GET /api/points/history?page1size20返回当前用户的积分明细列表包括时间、来源、数值变化。我的页面会显示“今日已得积分”和“累计积分”两个数字前者直接计算当天积分总和后者从用户表字段读取。排行榜接口GET /api/rank/daily?date20241201schoolcomputer服务端先从Redis中读取指定key的ZSet数据然后按排名顺序返回前100名包含用户名、学院、头像URL、今日积分。因为要展示学院缩写所以返回前还要联表查一下用户表把ID转成昵称和学院。代码层面我用了比较规范的统一返回结构public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } }这样前端不需要为每个接口单独做错误处理统一判断code字段即可。3.2 前端Vue页面搭建与状态管理前端我用Vue 3 Vite Element Plus这套组合。Vite的启动速度比Webpack快太多开发体验极佳Element Plus提供现成的按钮、卡片、表格、弹窗组件做后台管理页面很顺手。用户端页面因为要适配手机浏览器我额外加了viewport适配和响应式布局让页面在手机上不至于变形。三个核心页面拍照上传页是用户最常用的。手机浏览器访问后点击拍照按钮打开摄像头或者从相册选择照片。拿到照片后先做一次前端预览压缩——我用Canvas把图片压缩到宽度800像素再上传这样能明显减少上传流量和AI识别耗时。压缩代码大致是function compressImage(file, maxWidth 800) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload (e) { const img new Image(); img.onload () { const canvas document.createElement(canvas); const scale Math.min(1, maxWidth / img.width); canvas.width img.width * scale; canvas.height img.height * scale; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) resolve(blob), image/jpeg, 0.85); }; img.src e.target.result; }; reader.readAsDataURL(file); }); }压缩到800像素既能保证识别准确率不掉又能让上传体积控制在200KB左右实测效果非常好。识别结果页会把返回结果展示成一张卡片绿色背景表示“光盘成功”显示获得的积分黄色表示“待人工复核”灰色表示“未识别为光盘”。识别结果的展示逻辑直接映射后端判定代码简单直观。排行榜页用了Tabs组件切换日榜、周榜、总榜。每行显示排名、头像、昵称、积分、一个“关注”按钮。我这里只做了前端展示没有做关注功能保持克制。Vue的状态管理我用了Pinia只维护两个全局状态当前用户信息和最近一次识别结果。页面跳转时让识别结果不丢失体验会比全部用localStorage好很多。3.3 AI服务集成细节与调试经验Spring Boot调用AI识别服务的代码是核心链路我把超时设置单独拎出来说。默认的RestTemplate超时是无限等待如果AI服务挂了用户端会一直转圈。我设置了连接超时3秒、读取超时10秒并且在调用失败时返回一个“识别服务暂不可用”的提示不会让整个上传流程卡死。这个细节很容易被忽略但答辩演示的时候特别关键——一旦网络抖动页面不会白屏还能优雅地给出提示。还有一个调试技巧我在后端接入了简单的日志切面对每一个上传识别请求记录文件大小、识别耗时、返回的概率值、最终判定结果。这样当用户投诉“我明明光盘了你不给我积分”的时候打开日志一眼就能看到当时的原始数据排查效率高很多。AI服务的Python代码我贴一个核心片段def predict(image_bytes): image Image.open(BytesIO(image_bytes)).convert(RGB) image image.resize((224, 224)) image_array np.array(image) / 255.0 image_array np.expand_dims(image_array, axis0) prediction model.predict(image_array, verbose0) clean_prob float(prediction[0][0]) return {clean_probability: clean_prob}注意这里clean_prob指的是“光盘”这一类别的概率。因为我做的是二分类模型输出两个值把第一个类标定为“光盘”第二个标定为“有剩余”读取第一个值就是光盘概率。如果你的模型输出顺序反了返回就会混乱所以训练完之后要跑一次测试集确认类别顺序不要想当然。3.4 数据库表怎么设计才经得起追问答辩的时候评委最喜欢问的就是数据库设计。我核心用了五张表每张表都对应一个“评审提问点”user表用户ID、昵称、学号、学院ID、积分余额、创建时间record表识别记录ID、用户ID、照片URL、光盘概率、判定结果、上传时间、食堂位置points_log表积分流水ID、用户ID、变动积分、来源类型上传/点赞/连续打卡、时间戳post表动态ID、用户ID、照片URL、文字内容、发布时间comment表评论ID、动态ID、用户ID、内容、时间关键设计点在于记录表和积分流水表是分离的。这样做的理由是“识别结果”和“积分变化”虽然有关联但它们是两个独立的事实。如果以后业务调整了积分规则比如光盘从10分变成15分不需要修改历史识别记录只需要在积分流水表里追加一条15分的记录即可。历史数据永远不变未来规则随便改这是一张好的业务表设计应该具备的灵活性。4. 毕设避坑指南与优化实录那些文档里不会写明的事4.1 图片识别不准的根源排查第一个让人头疼的问题是识别准确率不够。一开始我拿训练集测试准确率95%感觉稳了。上了真实场景立刻翻车——食堂灯光是暖黄色的餐盘的材质反光菜汤泼洒后油腻腻一片识别率直接掉到75%。这个落差让我意识到训练集和真实环境差距太大。解决办法有两个方向。第一个是数据增强在训练时对图片做随机亮度调整、对比度调整、模糊模拟、随机裁剪。第二个是收集真实环境数据微调我让五个同学在不同食堂窗口、不同时段、不同餐盘颜色下各拍了100张照片用LabelImg标好类别做二次微调。两项措施加一起识别率回到了90%以上。这个坑的教训是做AI相关毕设一定要提前预留至少一周时间做真实环境验证和数据补充不要以为训练集效果好就万事大吉。4.2 积分刷单与数据异常处理上线没多久我发现有人在一分钟之内上传了6张一模一样的大米饭照片连续拿到了60分。排查发现是同一个账号在短时间内重复请求图片MD5居然一样。这显然是被试出了规则漏洞。修复方案分两层。第一层上传时检查图片MD5如果该用户当天已经上传过相同MD5的图片直接拒绝。第二层单日识别次数限制为6次超过次数提示“今日已用完”。这两层防住之后刷单问题基本绝迹。但后来想想本质上是因为我加了“记录表里每天最多6条且MD5不重复”的约束是数据库层面和业务层双重控制比单纯改某一个环节都可靠。数据异常处理上我还遇到过一次Redis缓存和数据库数据不一致。某天排行榜和数据库查询结果对不上排查发现是Redis里的ZSet因为过期时间设置错误在晚上十二点前就清掉了。所以我后来把排行榜的过期时间设置为“当天23:59:59”用计算出来的秒数精确调过期时间确保一整天内数据都在缓存重建不会导致数据丢失。4.3 并发场景下积分发放的原子性问题排行榜并发写的问题发生在一次演示预演之后。当时特意开了10个账号疯狂上传想测试系统稳定性结果发现积分流水表里出现了负数记录。追查原因是用户点击上传到后端后端在同一条逻辑里做了“查用户当前积分→加上本次积分→写回”当两个请求并发进来后者读到的积分是前者还没加完的旧值。修复方案是在积分变更时引入行级锁用数据库的UPDATE user SET points points ? WHERE id ?这样一条SQL更新语句本身就是原子操作不需要先查询再更新。如果用的是MyBatis直接在mapper接口里写一句更新参数即可。这种“让数据库帮我们做原子操作”的思路比自己在代码里加锁要稳健得多。4.4 部署时模型加载慢与内存占用过高答辩前一天的鸡飞狗跳很多人见过我的那次也很有代表性。把AI服务、Spring Boot、MySQL、Redis全部署到一台仅2G内存的云服务器上之后模型加载用了12秒导致第一次上传直接超时。我用top一看内存已经被吃掉了75%再跑完模型推理整个系统几乎要挂掉。优化方向是给AI服务一个单独的轻量级进程并且用Tornado替代Flask——Tornado是非阻塞的并发处理能力和Flask不是一个量级。如果服务器内存实在吃紧还有一个备选方案完全不跑真实模型识别阶段用本地规则模拟返回比如依据图片的HSV颜色统计判断盘底是否大面积露出瓷白色。这个方法听起来“不AI”但在毕设答辩时它保证系统能流畅演示模型可以后续再接回来。我用这个方案应急过一次效果出人意料地稳定。这里我建议正式答辩的演示环境不要用云服务器直接用演示者自己的笔记本电脑跑配置32G以上内存或者有NVIDIA显卡最好。自备电脑演示永远比远程服务器稳这是无数人用血泪总结出的经验。4.5 前端图片上传在手机上的兼容性问题手机浏览器的兼容性比PC端复杂得多。我的测试中出现过iOS的Safari点击拍照后文件类型是image/heic格式后端保存图片后AI服务读取失败。这个问题花了我一个晚上排查最终解决方案是在前端把图片统一转成JPEG再上传Safari的HEIC格式在Canvas绘制时会自动转成可绘制的位图导出再转成JPEG就绕开了后端解析问题。另一个兼容性坑是部分安卓机型的input[typefile]不支持capturecamera属性要么不弹出相机要么调用的是相册而不是相机。我的处理方案是同时提供“拍照”和“从相册选择”两个按钮让用户自己选体验最稳。4.6 答辩演示时的高频追问与应答思路最后说一条关于答辩准备的实在建议。评委大概率不会细看你的代码但会问三个方向的问题业务逻辑、技术选型、系统瓶颈。我的准备思路是把每个功能模块的“为什么这么设计”提前梳理成三句话的答案。“为什么用Redis做排行榜”——因为排行榜是对时间敏感的高频读取场景Redis的ZSet天然支持按分值排序而且读写性能远高于MySQL的实时聚合。“为什么模型选MobileNet”——因为精准度和性能的平衡MobileNet能在CPU上达到实时推理适合学生项目的硬件条件。“为什么判定阈值是0.85”——因为通过真实环境测试0.85能最好地平衡漏判和误判低于0.85会有过多的模糊样本进入人工复核池高于0.9又会漏掉很多有效光盘记录。把这些“所以然”想清楚答辩的深度就和别人拉开了。很多人的项目做完了但讲不清楚不是因为没做而是因为没提前梳理设计决策的依据。这块花半天时间整理性价比极高。5. 写在最后的几点体会这个项目做下来我最深的感触是一个毕设项目是否出彩关键不在用了多牛的技术而在于能否把一条完整的技术链路从头到尾打通。Spring Boot、Vue、AI识别、排行榜、社交分享每一块单独拎出来都不算难难的是让它们无缝协作还要应对真实使用中的各种异常情况。A同学的这套系统最终验收效果不错评委给的核心评价是“业务逻辑完整、技术栈匹配、有真实落地思考”。如果你正准备做类似的项目我的建议是先把核心链路跑通——用户上传照片、AI识别判定、积分变动、排行榜更新这条线必须在第一周打通。然后再补社交功能和细节优化。别一上来就想着把每个按钮、每个页面都做好看后端的健壮性和前端的完整链路才是评价的主心骨。最后哪怕时间再紧也一定要在真实手机和真实环境里多做几轮测试那种“实验室里一切正常、演示现场一塌糊涂”的翻车我见过太多次了。