☰
社区智能垃圾管理系统:SpringBoot+Vue全栈设计与避坑指南
2026/10/7 11:16:17 网站建设 项目流程

简介:一份基于SpringBoot与Vue前后端分离实现的社区智能垃圾管理系统源码,面向准备毕业设计、课程设计或工程实训的Java全栈学习者。系统覆盖垃圾回收、分类监管等社区业务,后端SpringBoot整合主流生态,前端Vue.js构建交互界面,配合MySQL5.7与配套SQL文件可快速运行。资源共797个文件、约40.02MB,含186个Java后端逻辑、127个Vue前端组件、SQL初始化脚本及文档,并附带bat一键启动脚本;开发环境涉及JDK8、Tomcat7与Maven3.3.9。已有98人学习下载。整体结构清晰,便于二次开发或扩展智能分类、数据统计等模块。

1. 社区智能垃圾管理系统:从“满溢报警”到“积分激励”,SpringBoot+Vue 能撑起什么

社区智能垃圾管理系统听起来像给垃圾桶装个摄像头做自动识别,但真正落地时,它更像一套“运营管理后台 + 居民服务端”的多端业务系统:社区管理员盯桶位状态、清运工单和分类数据,居民则通过小程序或网页查分类、攒积分、兑换物品。很多团队一上来就钻研图像识别,结果项目烂在了“积分规则”“桶状态判定”“数据统计”这些不起眼的逻辑上。

这套系统用 SpringBoot 做后端服务,Vue 做前端界面,核心价值是把设备上报数据、居民投放行为和社区管理规则串成一条可运转的闭环。它适合做毕设、中小社区的智慧化改造,也适合想快速验证“分类 + 奖励”模式的团队。下面按我从零搭这类系统的顺序讲,不绕弯子,直接说设计和踩坑。

2. 先拆业务模型再写代码:社区垃圾管理系统的表设计与接口边界

2.1 核心实体:垃圾桶、垃圾类型、投放记录、用户积分

做管理系统的第一件事不是写 Controller,而是画清楚谁在操作什么。社区智能垃圾管理系统里,最少得有五个基础实体:社区小区、垃圾桶点位、垃圾类型、投放记录、用户积分账户。如果涉及多社区运营,还要再加一个“社区管理员”到“社区”的关联表。

我一般这样建表,字段能省则省,但要留出扩展位。以下是系统里最核心的两张表,一张管设备,一张管居民行为:

CREATE TABLE `trash_bin` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `community_id` bigint(20) DEFAULT NULL COMMENT '社区ID', `position` varchar(64) DEFAULT NULL COMMENT '点位名称,如东门3号', `category` varchar(16) DEFAULT NULL COMMENT '可回收/厨余/有害/其他', `full_level` int(11) DEFAULT 0 COMMENT '满溢百分比 0-100', `status` tinyint(4) DEFAULT 0 COMMENT '0正常 1满溢 2离线', `last_report_time` datetime DEFAULT NULL COMMENT '最近上报时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='垃圾桶点位表'; CREATE TABLE `recycle_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `bin_id` bigint(20) NOT NULL COMMENT '垃圾桶ID', `category` varchar(16) NOT NULL COMMENT '投放分类', `weight` decimal(10,2) DEFAULT 0.00 COMMENT '投放重量kg', `score` int(11) DEFAULT 0 COMMENT '本次获得积分', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投放记录表';

这里full_level我建议存百分比而不是布尔“满/不满”。因为后面要做趋势图,而且不同社区对“满”的阈值不一样,有的 80% 就叫满,有的 90% 才安排清运,存具体百分比灵活得多。category直接用中文字符串比用数字字典更直白,接口返回省一次翻译,做数据报表时也方便排查。

2.2 SpringBoot 项目结构怎么摆:controller/service/mapper 与配置

不少人照着教程把项目分成controller/service/mapper三层,但做带设备上报的管理系统时,还缺一个不可忽视的dto/vo层。我的标准结构是这样的:

src/main/java/com/community/garbage ├── config # 跨域、定时任务、Jackson 配置 ├── controller # REST 接口 ├── service # 业务逻辑、积分规则、设备上报处理 ├── dao # MyBatis-Plus Mapper ├── entity # 数据库实体 ├── dto # 入参对象,比如设备上报 DTO └── vo # 返回给前端的对象,比如垃圾桶状态 VO

配置上,我劝你不要用太高的 SpringBoot 版本。SpringBoot 3.x 把javax包换成了jakarta,很多老教程和开源代码直接跑不起来,尤其是 MyBatis-Plus 的旧版 starter 在 3.x 下经常出兼容问题。如果你照着网上的例子做,先锁2.7.x最稳。pom.xml里这样写:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>

这个版本的spring-boot-starter-web和mybatis-plus-boot-starter兼容性最好。application.yml里最要紧的三组配置是数据源、MyBatis-Plus 的下划线转驼峰、以及 Jackson 的时间格式。数据库字段是community_id,Java 属性是communityId,如果不开map-underscore-to-camel-case,查询结果会把communityId映射成null,这是新手最容易忽略的暗坑。

2.3 用 MyBatis-Plus 生成基础 CRUD 的最小代码

基础 CRUD 没什么技术含量,直接用 MyBatis-Plus 的BaseMapper和ServiceImpl,能省下大量手写 SQL 的时间。先定义实体类和 Mapper:

@Data @TableName("trash_bin") public class TrashBin { @TableId(type = IdType.AUTO) private Long id; private Long communityId; private String position; private String category; private Integer fullLevel; private Integer status; private LocalDateTime lastReportTime; }
public interface TrashBinMapper extends BaseMapper<TrashBin> { }
@Service public class TrashBinService extends ServiceImpl<TrashBinMapper, TrashBin> { public Page<TrashBin> pageByCommunity(Long communityId, int page, int size) { LambdaQueryWrapper<TrashBin> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(TrashBin::getCommunityId, communityId) .orderByDesc(TrashBin::getLastReportTime); return page(new Page<>(page, size), wrapper); } }

LambdaQueryWrapper的好处是编译期能检查字段名,避免手写"community_id"字符串写错。注意ServiceImpl里自带的page方法是无条件的,所以这里要自己写一个带条件查询的方法。返回的Page对象里带total总数,前端分页组件直接拿就行。

Controller 层我给一个最小模板:

@RestController @RequestMapping("/api/bin") public class TrashBinController { @Autowired private TrashBinService binService; @GetMapping("/page") public Result<Page<TrashBin>> page( @RequestParam Long communityId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { return Result.ok(binService.pageByCommunity(communityId, page, size)); } }

Result是自己封装的统一返回体,里面至少包含code、msg、data三个字段。前端 axios 拦截器只判断code,比返回裸对象省事得多。@RequestParam(defaultValue = "1")一定要加,不然前端漏传参数直接 400,联调时容易误判成后端错误。

3. 后端落地:SpringBoot 里做满溢检测、积分规则与定时任务

3.1 满溢状态判定:用传感器数值还是手动上报

先说结论:不要真的依赖“智能识别”来做核心状态判断。市面上所谓智能垃圾桶,大多通过超声波传感器测距换算成满溢百分比,或者通过投递门计数估算。这套系统要对接真实设备,最稳妥的做法是接受设备上报的原始值,再在后端做判定和过滤。

我给设备预留的接口是这样的:

@PostMapping("/api/report") public Result<?> report(@RequestBody DeviceReportDTO dto) { TrashBin bin = binService.getById(dto.getBinId()); if (bin == null) { return Result.error("bin not found"); } Integer fullLevel = calcFullLevel(dto.getSensorDistance(), bin.getHeight()); bin.setFullLevel(fullLevel); bin.setStatus(fullLevel >= fullThreshold ? 1 : 0); bin.setLastReportTime(LocalDateTime.now()); binService.updateById(bin); // 满溢时往消息表插一条待清运工单,这里省略 return Result.ok(); }

calcFullLevel的换算逻辑很简单:(1 - distance / height) * 100。但注意设备上报可能会有瞬时跳变,比如垃圾袋突然挡住传感器,导致一秒钟内数值从 20% 跳到 95%。我会加一个简单的一阶滤波:current = last * 0.7 + newValue * 0.3,避免垃圾桶盖被掀动一下就直接报满。满溢阈值fullThreshold放到application.yml,不要写死在代码里,不同社区的标准不一样。

3.2 积分规则引擎:用策略模式代替 if-else

积分是社区运营的重点,不同垃圾类型给不同积分。可回收垃圾按重量计分,厨余按次计分,有害垃圾可能单次分高但限制每天只能投一次。如果直接在 Service 里写if (category.equals("可回收")),规则多了会变成一团乱麻。

我用策略模式来组织。先定义一个策略接口:

public interface ScoreStrategy { /** 对应垃圾类型 */ String category(); /** 计算本次积分 */ int score(RecycleRecord record); }

再写两个具体策略:

@Component public class RecyclableScoreStrategy implements ScoreStrategy { @Override public String category() { return "可回收"; } @Override public int score(RecycleRecord record) { // 可回收按重量给分,1kg = 10 分 return (int) (record.getWeight() * 10); } } @Component public class HazardousScoreStrategy implements ScoreStrategy { @Override public String category() { return "有害"; } @Override public int score(RecycleRecord record) { // 有害垃圾不按重量,按次给 20 分 return 20; } }

然后在 Service 里把一个List<ScoreStrategy>转成Map,让 Spring 注入所有策略实现:

@Service public class ScoreService { private final Map<String, ScoreStrategy> strategyMap; public ScoreService(List<ScoreStrategy> strategies) { this.strategyMap = strategies.stream() .collect(Collectors.toMap(ScoreStrategy::category, s -> s)); } public int grant(RecycleRecord record) { ScoreStrategy strategy = strategyMap.get(record.getCategory()); if (strategy == null) { return 0; // 未知类型不积分 } return strategy.score(record); } }

以后新增一个“玻璃制品”分类,只要加一个@Component类实现ScoreStrategy,Service 层不用改。要注意category()返回的 key 必须和数据库里存的值完全一致,否则策略匹配不上直接返回 0,用户会以为系统吞了积分。

3.3 SpringBoot 定时任务扫描垃圾桶状态

设备可能长时间不上报,但满溢状态不能一直挂着旧值。我会用@Scheduled定时扫描所有超过一定时间没上报的桶,把它置为“离线”,同时把满溢超过 12 小时的桶自动生成清运工单。

下面是离线扫描的核心代码:

@Component public class BinStatusTask { @Autowired private TrashBinService binService; @Scheduled(fixedDelay = 60000, initialDelay = 5000) public void scanOfflineBins() { List<TrashBin> bins = binService.list(); for (TrashBin bin : bins) { if (bin.getLastReportTime() == null) { continue; } long mins = ChronoUnit.MINUTES.between(bin.getLastReportTime(), LocalDateTime.now()); if (mins > 30 && bin.getStatus() != 2) { binService.lambdaUpdate() .eq(TrashBin::getId, bin.getId()) .set(TrashBin::getStatus, 2) .update(); } } } }

记得在启动类上加@EnableScheduling,这是一个特别容易漏的坑:定时任务写好了,但就是不执行,控制台也没报错,就是因为少了这个注解。fixedDelay = 60000表示任务结束 60 秒后再跑下一次,initialDelay = 5000让服务先起来等 5 秒再跑第一轮,防止启动期间数据库连接池还没就绪。这里的扫描逻辑比较简单,桶数量超过几千个时建议改成 SQL 批量更新,别在内存里全表循环。

4. 前端落地:Vue 3 + Element Plus 搭出管理后台和居民端

4.1 Vue 项目结构与路由配置:动态路由怎么加

前端我选用 Vue 3 + Vite + Element Plus,用npm create vue@latest初始化。项目里src/views按角色拆目录,避免管理端和居民端代码混在一起:

src/views/ ├── admin/ # 管理端:垃圾桶管理、清运工单、数据报表 ├── resident/ # 居民端:投递记录、积分商城 └── login/

热词里经常有人搜“vue路由”和“vue动态路由”,这个系统的权限模型很典型:管理员和居民看到的菜单不一样,所以不能全写死在router/index.js里。我的做法是登录后从后端拿菜单权限,再动态addRoute:

// router/index.js import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/login/Index.vue') } ] }) export function setupAdminRoutes(router) { const adminRoutes = [ { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), children: [ { path: 'bins', component: () => import('@/views/admin/BinList.vue') }, { path: 'orders', component: () => import('@/views/admin/OrderList.vue') }, { path: 'report', component: () => import('@/views/admin/Report.vue') } ] } ] adminRoutes.forEach(route => router.addRoute(route)) }

注意我用了createWebHistory(),这是 HTML5 History 模式,URL 干净,但打包放到 SpringBoot 后会有刷新 404 的问题,具体解决方案在第 5 章。如果不想处理那个问题,可以临时改用createWebHashHistory(),代价是 URL 里会多一个#。

4.2 垃圾分类识别页:调后端接口 + 本地缓存

居民端的核心功能是输入垃圾名称或拍照,让系统返回分类结果。我不建议真的在 Vue 里跑图像模型,成本高且移动端兼容麻烦。常见做法是前端把垃圾名词发给后端,后端用分词映射返回分类结果,前端只负责展示。

前端先封装 axios 实例,统一处理请求路径和错误码:

// src/utils/request.js import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.response.use( response => { const res = response.data // 后端 Result.code 约定 0 为成功 if (res.code !== 0) { return Promise.reject(new Error(res.msg)) } return res }, error => Promise.reject(error) ) export default request

然后在页面里调用:

async function checkGarbage(name) { const res = await request.post('/garbage/classify', { name }) answer.value = `“${name}”属于 ${res.data.category},投递可获 ${res.data.score} 积分` }

这里有个容易被忽略的点:baseURL设成/api,开发环境由 Vite 代理转发到localhost:8080,生产环境 SpringBoot 的前端页面和后端接口同域,就不存在跨域了。很多新手直接写死http://localhost:8080,开发能通,打包上线后 IP 或端口一变就废,还要到处找替换。

4.3 用 ECharts 做垃圾量趋势图

管理报表最常用的图是“近 7 天各分类垃圾投放重量趋势”。ECharts 在 Vue 里可以配合vue-echarts使用,也可以用原生 echarts 配合onMounted,我习惯用原生,因为可控性强,也方便后续做图表联动。

// src/views/admin/Report.vue import * as echarts from 'echarts' import { onMounted, ref, onBeforeUnmount } from 'vue' import request from '@/utils/request' const chartRef = ref(null) let chart = null onMounted(async () => { const res = await request.get('/stats/weight-trend', { params: { days: 7 } }) chart = echarts.init(chartRef.value) chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: res.data.dates }, yAxis: { type: 'value', name: '重量(kg)' }, series: [{ name: '可回收', type: 'line', data: res.data.recyclable }, { name: '厨余', type: 'line', data: res.data.kitchenWaste }] }) }) onBeforeUnmount(() => { if (chart) { chart.dispose() } })

注意echarts.init(chartRef.value)必须等 DOM 渲染完成后再调用,否则chartRef.value是null。如果页面用v-if控制图表容器,还得配合nextTick。另外组件销毁时要chart.dispose(),不然从管理报表页切到其他页面会内存泄漏,时间长了页面会越来越卡。

5. 避坑:SpringBoot 版本、Vue 打包跨域、时间字段等 4 个常见问题

5.1 现象:后端接口通了前端却拿不到数据

现象:前端请求http://localhost:8080/api/bin/page,浏览器 Network 里能看到后端返回了,但 axios 报跨域错误,或者 SpringBoot 控制台频繁出现OPTIONS请求且返回 403。

原因:浏览器跨域限制。前端地址是http://localhost:5173,后端是http://localhost:8080,端口不同。浏览器会先发一个OPTIONS预检请求,后端没有允许跨域的响应头,预检直接失败,真实请求也不会发出。

解决:开发环境用 Vite 代理,前端请求写/api,在vite.config.js里配置:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

如果坚持要用后端解决,就在 SpringBoot 里加一个全局CorsFilter:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

生产环境我建议去掉这个跨域配置,让前后端同域部署,少一层暴露面。这个坑几乎每个 SpringBoot + Vue 项目都会遇到,越早统一代理方案越省心。

5.2 现象:LocalDateTime 序列化出来是数组

现象:接口返回的createTime变成[2025, 5, 20, 14, 30, 0]数组,前端拿到后没法直接展示日期。

原因:SpringBoot 默认用 Jackson 序列化。Jackson 对LocalDateTime默认处理的输出格式不是字符串,而是对象或数组。不同版本表现不一致,在 SpringBoot 2.7 下如果不做配置,很容易出现数组。

解决:在application.yml里配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

但要注意,spring.jackson.date-format只对java.util.Date生效,对LocalDateTime不一定有效。最保险的方式是加一个 Jackson 全局配置:

@Configuration public class JacksonConfig { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer(DATE_TIME_FORMATTER)); builder.deserializers(new LocalDateTimeDeserializer(DATE_TIME_FORMATTER)); }; } }

这样所有接口返回的时间字段都是yyyy-MM-dd HH:mm:ss字符串,前端不用再做转换。否则每写一个查询接口都要留意时间字段,太容易翻车。

5.3 现象:Vue 打包放进 SpringBoot 后刷新 404

现象:前端npm run build后把dist目录拷到 SpringBoot 的src/main/resources/static,启动后访问首页正常,但路由进到/admin/bins之后按 F5 刷新,页面 404。

原因:Vue Router 用的createWebHistory()让浏览器地址栏变成了真实路径/admin/bins。刷新时浏览器向 SpringBoot 发送/admin/bins的请求,后端确实没有这个路由,就返回 404。

解决:两种方案。第一,改用createWebHashHistory(),URL 变成/#/admin/bins,刷新始终请求/index.html,不会 404。第二,保留createWebHistory(),在 SpringBoot 加一个 ViewController 转发规则:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{spring:[^\\.]*}") .setViewName("forward:/index.html"); } }

这个转发会把所有不带点的路径转发给index.html,刷新 404 就解决了。但一定要把/api/**排除在外,否则前端调接口也会被转发到首页。更严格的做法是加一个拦截器,或者把前端静态资源托管到 Nginx,由 Nginx 的try_files来处理,SpringBoot 只负责 API。我用过几次 Nginx 方案,比改后端转发干净,但小项目图省事还是用 ViewController 转发。

5.4 现象:定时任务不执行

现象:写了@Scheduled方法,启动项目后没有任何输出,也没有报错,感觉像代码没跑。

原因:最常见的是忘在启动类加@EnableScheduling;另一个原因是@Scheduled方法所在的类包路径没被扫到;还有可能是fixedDelay时间太长,第一轮间隔还没到。

解决:先在启动类加上:

@EnableScheduling

再把定时任务类放到主类所在的包或子包下。排查时在方法里加一行日志,看启动后 5 到 10 秒内有没有打印。如果你在定时任务里调用了Thread.sleep,会导致任务阻塞,下一轮也跟着延迟。这时候建议把fixedDelay调小,或者把耗时操作委托给线程池。

我用过一个更稳的写法:单机场景下用fixedDelay而不是fixedRate,因为fixedRate在任务执行时间超过周期时,会造成下一个任务挤进来,两个任务同时跑同一批数据,容易产生脏更新。fixedDelay保证上一轮跑完再等 60 秒,适合这种全表扫描的维护型任务。

6. 让系统更“智能”:对接设备上报与垃圾分类识别的两个进阶方向

6.1 不接硬件也能验证满溢链路:用脚本模拟设备上报

没有真实垃圾桶设备时,不要干等硬件联调。我一般用 Python 脚本模拟 HTTP 上报,验证满溢判定、积分策略和定时任务是否联动。接口按第 3 章的/api/report设计,脚本每 5 秒上报一组随机距离值:

import requests import time import random url = "http://localhost:8080/api/report" for bin_id in range(1, 11): payload = { "binId": bin_id, "distance": random.randint(10, 80) } resp = requests.post(url, json=payload) print(bin_id, resp.status_code, resp.text) time.sleep(5)

跑起来后,去数据库里看trash_bin表的full_level和status变化,再把fullThreshold调成 80,观察满溢工单是否生成。这一步能帮你提前发现两个问题:一是calcFullLevel的换算方向反没反,二是设备字段名和后端 DTO 对没对上。如果对接真实设备,上报协议很可能是 MQTT 或 TCP 私有协议,后端要单独做协议适配层,不要把/api/report直接暴露给公网。

6.2 垃圾名称匹配:用 HanLP 分词才是性价比之王

垃圾分类识别的核心是判断用户输入的“塑料瓶”“铝罐”“易拉罐”属于可回收。维护关键词映射表最简单,但同义词一多就漏。用 HanLP 做分词,再和垃圾类型关键词表做交集,能覆盖大多数口语输入。

先在pom.xml引入 HanLP 的 portable 版,不用下载完整模型:

<dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.8.4</version> </dependency>

然后写一个简单匹配 Service:

@Service public class GarbageClassifyService { private final Map<String, String> keywordCategoryMap; public GarbageClassifyService() { keywordCategoryMap = new HashMap<>(); keywordCategoryMap.put("电池", "有害"); keywordCategoryMap.put("过期药品", "有害"); keywordCategoryMap.put("塑料瓶", "可回收"); keywordCategoryMap.put("纸箱", "可回收"); keywordCategoryMap.put("剩饭", "厨余"); } public String classify(String garbageName) { List<String> terms = HanLP.segment(garbageName) .stream() .map(term -> term.word) .collect(Collectors.toList()); for (String term : terms) { String category = keywordCategoryMap.get(term); if (category != null) { return category; } } return "其他"; } }

这个方案我实测过,对“电池”“过期药品”“剩饭剩菜”这类名词识别非常准,对“这个瓶子能扔吗”也能抽取出“瓶子”再用词表命中。但要注意 HanLP 的 portable 包第一次加载会初始化词典,启动时稍慢,建议做成单例 Bean,不要每次请求都重新创建分词器。

我自己做这套系统时,最大的教训就是先定义好数据字典和接口契约再动手写前后端。很多项目做到一半烂掉,不是技术难题,而是“桶的状态”“积分规则”“清运工单”这些业务概念没对齐。如果你正在规划这个方向,可以从垃圾桶点位表和积分规则表开始设计,这两张表稳定了,前后端自然就顺畅了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询