基于Spring Boot的智能垃圾分类系统设计与实现
做垃圾分类这块其实是我自己小区垃圾桶旁边站出来的想法。当时垃圾督导员阿姨拿着夹子对着我手里拎的袋子反复问“这是什么垃圾”我低头看了半天也没敢确定。那一刻我就琢磨能不能做一个系统拍照就能告诉你这是什么垃圾、应该扔哪个桶这个念头后来演变成了一个完整的项目基于Spring Boot的智能垃圾分类系统。整套系统包含前端小程序/移动端H5、后端管理平台、图像识别服务和积分激励体系核心解决三个问题——用户不知道垃圾怎么分、管理者不知道分类效果怎么样、居民参与积极性起不来。这篇文章把我从需求拆解到部署上线的全过程写透包括数据库怎么设计、识别接口怎么调、积分怎么防刷、部署踩了哪些坑尽量让拿到源码的同学能直接跑起来而不是对着代码干瞪眼。1. 项目需求与业务场景拆解1.1 垃圾分类这个场景到底要解决什么问题垃圾分类看起来是个“识别”问题本质上是个“行为引导”问题。一个居民站在垃圾桶前他要的不是一个多复杂的算法而是“这个东西到底扔哪”的确定性答案。所以系统的第一优先级不是技术炫技而是准确、快速、低门槛。从用户侧看真实使用场景主要有三个不确定某样东西属于哪类垃圾想拍照问一下知道分类但记不准例如“脏塑料袋”和“干净塑料袋”是不是一个扔法想参与分类但缺少动力需要一些激励手段从管理侧看社区或物业关心的是分类参与度高不高、准确率高不高、哪些垃圾类别容易分错、积分活动有没有人玩。这些都需要数据支撑而不是靠人工抽查。所以我把系统拆成了四个核心模块用户端小程序/H5、识别服务、管理后台、积分激励。这四个模块缺一个都不完整。很多同类项目只做了“拍照识别”就收工了但实际用起来你会发现没有积分和后台统计整个系统就是一个“一次性玩具”用户用完就走运营毫无抓手。1.2 核心角色与功能清单系统涉及三类角色权限并不复杂但边界要清楚。普通用户注册登录手机号验证码或第三方登录拍照/上传图片识别垃圾类别查看识别结果与分类详情包括投放指导积分查询、签到、兑换误判反馈管理员/运营登录后台垃圾类别与物品库管理识别记录与用户行为统计积分规则配置误判反馈处理、知识库维护系统层面统一鉴权与数据权限定时任务积分结算、统计报表日志与异常监控这里有个容易被忽略的点垃圾知识库是持续更新的。今天居民问“玉米皮是什么垃圾”明天可能问“口红管是什么垃圾”。如果知识库不能由运营在线维护系统上线三个月后就废了。所以后台的可配置能力比识别能力本身更影响项目寿命。1.3 技术选型为什么是Spring Boot而不是别的后台技术选型让我犹豫过一轮核心候选是Spring Boot和PythonFastAPI/Django。最后选了Spring Boot理由很实际第一生态成熟且招人好招。国内做这类管理系统Java技术栈的人才储备和开源组件生态明显更适合团队协作。我见过很多用Python快速搭起来的后台后期加需求时被GIL和异步模型折腾到怀疑人生。Spring Boot在这方面的上限要高得多。第二开箱即用的东西多。Spring Boot对Spring生态的整合能力不用我吹Spring Security做登录、MyBatis-Plus操作数据库、Redis做缓存和分布式锁、XXL-JOB做定时任务这些都是现成的轮子。我这次还把Spring Boot Admin接进来了用来监控应用的健康状态和内存指标省掉了自建监控面板的功夫。第三部署形态对中小项目友好。一个可执行JAR包搞定所有不依赖外部容器内存控制在512MB以内也能跑。配合Docker做镜像部署一台2核4G的云服务器完全够支撑一个小区的使用量。版本上我用了Spring Boot 2.6.x。不选3.0的原因很朴素稳定性优先。3.x虽然已经出了很久但部分第三方组件和自动配置的兼容性在2.6.x上更稳。网上那些“Spring Boot 2.3.x 2.6.x”的版本对比贴我基本都翻过2.6.x处于一个非常微妙的平衡点——既没有2.3那么老又不像3.x那样大面积改动底层。2. 系统总体架构与核心流程设计2.1 前后端分离的整体拓扑这个项目我采用了前后端完全分离的架构后端只提供RESTful API前端分两个独立工程一个面向居民的H5/小程序端一个面向管理员的Vue后台。两者的入口不同但共用同一套鉴权体系只是角色权限不同。整体交互链路是这样的用户在小程序端上传一张垃圾照片前端先把图片传到一个临时存储我用的是MinIO本地也能起拿到文件地址后再调用后台的识别接口。后台接收图片地址后调用图像识别服务拿到一组带置信度的识别结果再结合本地垃圾物品库做二次映射最终把“物品名称、所属类别、投放建议、常见误判提醒”返回给前端。整个过程大约3秒以内用户体感是“拍一下马上知道扔哪个桶”。这里我建议把图片上传和识别拆成两步而不是把图片二进制直接塞进识别接口。好处是识别服务挂了的话图片还在存储里可以后端重试不至于让用户白白传一次。2.2 模块划分与核心流程后端我把项目拆成了五个子模块用Maven多模块管理waste-classification ├── waste-common // 通用工具类、统一返回结果、全局异常 ├── waste-system // 用户、权限、菜单等基础模块 ├── waste-api // 对外API接口层 ├── waste-service // 业务逻辑层识别、积分、统计等 └── waste-admin // 后台管理接口识别流程的完整串联是这样的用户上传图片后端校验图片格式和大小超过10MB直接拦截图片存储到MinIO返回URL调用识别服务获取物品标签和置信度通过标签匹配本地垃圾物品库得到分类结果如果是“可回收物”额外返回对应的投放要求比如“请清空内容物并压扁”保存识别记录同步更新用户积分返回完整结果给前端这个链路里最微妙的一环是第5步。第三方图像识别返回的标签往往不是垃圾名而是“塑料瓶”“易拉罐”这种物品名如果识别模型不够准可能返回“玻璃杯碎片”这种非常细的标签。所以本地物品库必须做同义词和别名映射比如“矿泉水瓶”“PET瓶”“塑料瓶”全部映射到同一个物品ID上否则知识库会越维护越乱。2.3 图像识别方案第三方API还是自建模型识别这块是很多初学者最纠结的地方。我的建议是现阶段别自建模型先接成熟API跑通业务后再考虑私有化。我自己对比了多个方案最终选了成熟API方案。原因有三个自建分类模型需要大量标注数据垃圾种类上千种标注成本高到离谱通用图像识别API对常见物品的识别准确率已经足够先跑通MVP才是关键后期数据积累多了可以用识别记录里的“高置信度用户确认”数据做增量训练再考虑本地化实现上我封装了一层IdentifyService接口底层默认实现是调第三方API但这层封装的价值在于以后想换成自建模型只需要新增一个实现类业务层一行都不用改。这就是面向接口编程的意义。public interface IdentifyService { IdentifyResult identify(String imageUrl); }这里提一句不要迷信识别结果置信度低于0.6的返回结果我会在业务层做二次确认让用户看到一个“可能识别有误请确认”的提示。这种设计比直接甩一个错误答案体验好得多。3. 数据库设计与核心表结构3.1 核心数据表逻辑垃圾分类系统的数据量不会特别大但表的设计要有扩展余地。我最终设计了8张核心表这里挑最重要的几张说。用户表t_user用户表必须跟居民信息打通以后才能做“这个小区的参与率是多少”这类统计。字段上除了基本资料我特意加了bound_address绑定地址字段虽然前期不一定录入但以后按小区/街道维度做统计时这个字段就是切片维度。垃圾物品表t_garbage_item这是整个知识库的核心。字段设计如下字段类型说明idbigint主键item_namevarchar物品标准名称aliasesjson别名/同义词列表category_codevarchar分类编码recyclable/kitchen/harmful/otherconfidence_thresholddecimal识别置信度阈值instructiontext投放指导说明statustinyint启用状态aliases字段用JSON存储这个设计很实用。比如标准名称是“一次性纸杯”别名可以是“纸杯”“一次性杯子”“咖啡纸杯”。识别服务返回的标签会跟这些别名做模糊匹配匹配成功率大幅提升。识别记录表t_recognize_recordCREATE TABLE t_recognize_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, image_url VARCHAR(255) NOT NULL COMMENT 图片地址, item_id BIGINT COMMENT 匹配到的物品ID, result_label VARCHAR(100) COMMENT 识别原始标签, confidence DECIMAL(5,4) COMMENT 置信度, category_code VARCHAR(20) COMMENT 最终分类, is_correct TINYINT DEFAULT NULL COMMENT 用户是否确认识别正确, create_time DATETIME NOT NULL ) COMMENT 识别记录表;这张表是系统的“金矿”。is_correct字段是用户对识别结果的二次确认攒到一定量以后这些数据可以用来评估识别准确率、优化物品库映射关系甚至做后续的模型微调。很多项目不太在意这个字段我强烈建议保留。3.2 积分体系和防刷设计积分表的设计关系到整个激励体系能不能跑起来。我用了积分流水表来记录每一次积分变动而不是在用户表里只存一个总分。原因很简单运营会查“这个用户上周签到给了多少分”“今天哪个时段积分发放最多”只有流水表才能支撑这种查询。积分的来源目前有三种注册奖励、每日签到、识别成功奖励。针对防刷我做了几个非常关键的限制同一用户每日识别最高计分次数设为30次超过后识别功能正常但不再累加积分图片识别接口必须携带用户Token匿名用户不奖励签到积分用Redis的SETNX做“每日一签”校验防止通过切换会话绕过多端签到兑换奖品前校验用户积分的“可兑换”余额排除冻结部分这个防刷设计执行后后台的积分异常增长趋势明显消停了。有人可能觉得30次有点少但正常用户一天扔垃圾根本到不了这个量级而对刷分党来说30次基本断了念想。3.3 数据库索引与性能设计一开始我把索引建得很随意后期数据量上来后识别记录表的查询明显变慢。我后来做了一次索引优化ALTER TABLE t_recognize_record ADD INDEX idx_user_time (user_id, create_time); ALTER TABLE t_recognize_record ADD INDEX idx_category_time (category_code, create_time); ALTER TABLE t_integral_flow ADD INDEX idx_user_time (user_id, create_time);第一个索引支撑“我的历史记录”分页查询第二个支撑管理后台“某个分类的识别趋势”统计。这里给个提醒统计类SQL尽量走日期范围单维度分组那种“双维度GROUP BY再排序”的报表SQL数据量一大必挂。实在扛不住就上定时任务提前算汇总表别硬查。4. 核心功能实现与代码解读4.1 识别接口的实现与参数细节先看最核心的识别接口。我在WasteIdentifyController里暴露了一个POST /api/waste/identify接收imageUrl参数内部串联存储、识别、匹配、积分四个环节PostMapping(/identify) ApiOperation(垃圾识别) public ResultIdentifyVO identify(RequestBody IdentifyRequest request, RequestHeader(token) String token) { Long userId userService.getUserIdByToken(token); // 1. 检查今日识别积分次数 int todayCount recognizeRecordService.countTodayByUser(userId); if (todayCount MAX_SCORE_COUNT) { // 超过阈值正常返回结果但标记不计分 request.setScored(false); } // 2. 识别 IdentifyResult raw identifyService.identify(request.getImageUrl()); // 3. 匹配物品库 GarbageItem item garbageItemService.matchItem(raw.getLabel()); // 4. 保存记录 RecognizeRecord record buildRecord(userId, request.getImageUrl(), raw, item); recognizeRecordService.save(record); // 5. 积分奖励如果允许 if (request.isScored()) { integralService.reward(userId, IntegralType.RECOGNIZE, record.getId()); } return Result.success(buildVO(item, raw)); }这里Step 1有个小设计先查次数再决定是否计分而不是识别失败后再查。因为识别接口本身有延迟大量无效请求会白白消耗识别API额度。先拦截计分资格可以顺带减少无意义调用。matchItem的实现我用的是“精确匹配 - 别名匹配 - 模糊匹配”三级降级策略public GarbageItem matchItem(String label) { // 1. 精确匹配标准名称 GarbageItem item itemMapper.selectByItemName(label); if (item ! null) return item; // 2. 别名匹配JSON字段里的任意一个别名 item itemMapper.selectByAlias(label); if (item ! null) return item; // 3. 模糊匹配LIKE %label% item itemMapper.selectByFuzzyName(label); if (item ! null) return item; // 4. 匹配不到返回默认的“其他垃圾”兜底 return itemMapper.selectByDefaultOther(); }这套三级策略实测下来命中率提升非常明显。识别API偶尔返回的品牌名、口语化表达都可以通过别名表兜住。4.2 积分发放与流水记录的幂等设计积分发放必须保证幂等否则重复提交会导致用户积分暴涨。我采用的是“业务单号去重”策略每个识别记录ID天然可以作为幂等键。Transactional public void reward(Long userId, IntegralType type, Long bizId) { // 利用数据库唯一索引防止同一次奖励被重复发放 try { integralFlowMapper.insert(userId, type, bizId, SCORE); } catch (DuplicateKeyException e) { // 已经发放过直接忽略 log.warn(duplicate integral reward: userId{}, bizId{}, userId, bizId); return; } userMapper.increaseIntegral(userId, SCORE); }t_integral_flow表在(user_id, type, biz_id)上建了唯一索引这一步是整个积分体系的基石。注释里的逻辑很简单但真上线后你会感谢这个设计。Redis分布式锁也是一种方案但用唯一索引更轻量也不会有锁过期之类的问题。4.3 管理后台的统计报表实现管理后台我用了Vue ECharts做数据可视化后端接口只返回原始数据图表交给前端渲染。这里分享一个我踩过的坑统计接口一定要做数据脱敏和访问权限控制。后台数据比用户端敏感得多一定要单独走一套登录鉴权体系并且对用户手机号、地址等敏感信息做脱敏脱敏处理比如后台列表里手机号只显示138****1234。统计维度上我做了三个固定的按天识别量趋势、按垃圾类别占比、按用户活跃度排行。这三个指标能覆盖90%的运营场景。至于更复杂的“各小区分类准确率对比”我放在二期规划里因为涉及到小区维度的数据完善前期先不做。GetMapping(/api/v1/stats/category) public ResultListCategoryStatVO categoryStats(RequestParam(required false) String startDate, RequestParam(required false) String endDate) { String start (startDate null ? LocalDate.now().minusDays(30) : LocalDate.parse(startDate)).toString(); String end (endDate null ? LocalDate.now() : LocalDate.parse(endDate)).toString(); ListCategoryStatVO list recognizeRecordMapper.selectCategoryStats(start, end); return Result.success(list); }这里注意一点日期参数我建议始终用字符串传递不要用时间戳。字符串可读性强、能直接拼SQL、也方便前端传参。时间戳在跨时区场景下容易出幺蛾子这里完全没必要引入这种复杂度。5. 部署实操与避坑指南5.1 本地开发环境的搭建要点先解决一个问题IntelliJ IDEA社区版能不能开发Spring Boot项目答案是完全能。社区版免费虽然没有Spring Initializr向导和Spring Boot的专属Run Dashboard但你可以直接去Spring官网的 start.spring.io 生成项目压缩包解压后导入IDEA社区版照样开发运行。我平时很多Demo项目就是这么干的没有明显卡顿。环境清单如下组件版本备注JDK1.8 / 11Spring Boot 2.6.x两者皆可Maven3.6简化为内置Maven也行MySQL5.7 / 8.0推荐8.0字符集utf8mb4Redis3.x以上用于Token缓存、签到锁MinIO最新稳定版图片存储可替换为OSSNode.js14前端Vue项目用这里有个坑要提醒JDK版本别乱升。我见过有人用JDK 17跑Spring Boot 2.6.x虽然能启动但部分反射和代理相关的操作会报InaccessibleObjectException。要么老老实实JDK 8/11要么直接升级Spring Boot 3.x配JDK 17别混搭。5.2 配置文件中的关键项核心配置文件application.yml里有几个配置项我单独强调一下spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB datasource: url: jdbc:mysql://localhost:3306/waste_classification?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: root123 hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket: waste-imageserverTimezoneAsia/Shanghai一定要显式配置否则MySQL驱动按UTC处理时间数据库里的时间跟本地时间差了8个小时排查起来非常痛苦。5.3 Docker部署与上线注意事项我项目的部署形态是后端JAR包打成一个Docker镜像前端打完包通过Nginx托管MySQL和Redis用Docker Compose管理MinIO单独一台。Dockerfile很简单FROM openjdk:8-jre-alpine RUN adduser -D -u 1000 app WORKDIR /app COPY target/waste-classification.jar app.jar USER app EXPOSE 8080 ENTRYPOINT [java,-jar,-Xms256m,-Xmx512m,app.jar,--spring.profiles.activeprod]这里有个容易被忽视的参数Docker容器里JVM的堆内存设置。如果不显式设置-XmxJVM在容器里会按宿主机内存的1/4自动分配可能直接撑爆容器配额导致OOM被Kill。我在2核4G的云服务器上设了-Xmx512m既够用又不抢占宿主机内存。Nginx那边核心配置是前端静态文件托管 后端API反代server { listen 80; server_name waste.example.com; location / { root /usr/share/nginx/html; index index.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; } client_max_body_size 20m; }client_max_body_size 20m这行容易被忽略但如果没有它上传稍大点的图片直接返413排查半天才发现是Nginx的默认1MB限制在拦截。6. 常见问题与排查技巧实录6.1 我真实遇到过的5个问题这里整理一下我开发部署过程中遇到过的典型问题写成速查表大概率你也会撞上问题现象根本原因解决办法上传图片后Nginx返回413Nginx默认限制请求体1MB在server块加client_max_body_size 20m数据库时间比北京时间晚8小时JDBC连接参数未指定时区使用serverTimezoneAsia/ShanghaiDocker容器启动后不久被KillJVM自动按宿主机内存分配堆内存显式设置-Xmx512m积分重复发放多端并发请求同一个识别回执在流水表建(user_id,type,biz_id)唯一索引识别接口偶尔挂掉导致核心功能不可用第三方识别API不稳定接口降级识别失败时用关键词本地匹配兜底第五个问题值得展开说。识别API是外部依赖这堵墙倒了系统其他功能不能跟着瘫。我在设计时加了兜底如果识别服务超时或报错可以根据图片文件名里的用户手动输入关键词走本地知识库简单匹配。虽然准确率会下降但至少用户还能拿到一个“参考分类”而不是直接看到“系统繁忙”。6.2 处理接口超时的经验识别服务我设置了超时时间连接超时5秒、读取超时15秒。为什么读取要15秒因为我用的是同步调用而第三方识别服务的响应时间在2-8秒之间波动太短容易误杀正常请求太长又会拖垮线程池。另外一个经验教训是千万别在主线程同步调识别服务。用户点击识别后前端会一直转圈等待如果识别服务响应慢用户直接就退出了。我最终的方案是识别接口同步返回前端3秒内拿到结果超过3秒轮询一次识别结果异步查询接口。但这样增加了不少开发量。如果是MVP阶段直接同步返回也能接受只是要对用户体验的损失有心理准备。说到底这是个取舍不是技术能力问题。6.3 一套我私藏的排查流程碰到问题时我习惯按以下顺序排查基本能定位90%的问题特别适合刚接触Spring Boot的新手参照先看日志tail -fn 200 application.log抓最新的异常栈再确认端口netstat -tunlp | grep 8080排除端口占用确认数据库连接redis-cli ping、mysql -uroot -p -e select 1排除基础中间件故障用Postman直接调接口绕过前端定位是前端问题还是后端问题看Nginx日志error.log里有大量有用的线索排错最忌讳上来就改代码。先确认环境、网络、依赖这些基础项往往能省下大量时间。7. 源码结构与获取方式说明7.1 源码目录和运行说明老规矩说说源码怎么跑。项目源码我已经整理好放在文末联系即可获取包含了后端完整代码、前端管理后台代码、数据库初始化脚本和部署文档。结构说明如下后端Java Spring Boot 2.6.x ├── controller/ // 接口层只做参数接收和结果包装 ├── service/ // 业务逻辑层核心逻辑都在这里 ├── mapper/ // MyBatis-Plus的Mapper接口 ├── entity/ // 数据库实体类 ├── config/ // 各种配置类Redis、MinIO、WebMvc等 └── resources/ ├── mapper/ // XML文件复杂SQL写在这里 └── application.yml 前端Vue 3 Element Plus ├── views/ // 页面组件 ├── api/ // 统一API封装 └── router/ // 路由配置 数据库 ├── schema.sql // 建表脚本 └── init_data.sql // 垃圾物品库种子数据运行顺序导入数据库脚本 - 启动Redis和MinIO - 修改application.yml为本地环境 - 启动Spring Boot工程 - 启动前端工程 - 访问后台登录页。全程文档里都写了照着做就行。7.2 获取方式与二次开发建议源码获取方式直接文末联系即可。我这边会把完整的项目压缩包发给你。另外给拿到源码的同学几个实在的建议第一**这个项目天然适合做毕业设计或课程设计。需求明确而且有真实社会价值技术栈主流不偏门能讲的亮点很多——智能识别、积分激励、统计分析每一个模块都能在答辩时拿出实际效果来展示。第二**拿到源码别直接把别人的名字改成自己的就交差**这是最蠢的做法。我的建议是至少替换掉其中两个模块的逻辑——比如把积分规则改造一下或者把识别服务换成另一个API——这样既能跟老师解释清楚又不浪费这个项目本身的价值。第三上线部署前记得改掉所有默认密码和密钥**。开发环境里用的是minioadmin/minioadmin这种默认值上线环境一定得换成复杂密码数据库账号同理。这是最基本的安全习惯很多初学者容易忽略。我在实际开发这个系统的过程中最深的体会是技术难点从来不在怎么写一个“Hello World”级别的小接口而是怎么把一个带真实业务场景的系统从需求理解、表设计到部署运维完整地串起来。垃圾分类这个项目恰好把这种复杂度浓缩得刚刚好——不会过于庞大让人失去信心又方方面面都涉及到了。希望这篇分享能帮你在做类似项目的路上少踩几个坑跑通之后回头再看你是真的能学到东西的。要源码直接文末找我顺手的事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →