☰
SpringBoot+Vue城市垃圾分类管理系统开发实战教程
2026/10/10 14:42:10 网站建设 项目流程

前阵子帮一位同学调试基于SpringBoot+Vue的城市垃圾分类管理系统,这个项目算是典型Java全栈方向的中型练习项目:后端SpringBoot+MyBatis,数据库MySQL,前端Vue全家桶,还配了一套完整源码。很多人做类似题目时,最容易卡住的不是需求,而是从零到一落地过程中的各种小细节——表怎么建、登录鉴权怎么拦截、Vue的axios怎么统一管理、部署起来总报错。这篇我把这类系统从需求边界、数据库设计到前后端实现、部署排查的整条链路完整过一遍,给准备做毕业设计或练手项目的读者一条可以直接复用的路。

说明:文中的所有工程、案例均为模拟项目,涉及的人物均为虚构代称。代码片段基于Spring Boot 2.7.x和Vue 2.x/Element UI,这套组合在同类项目里最稳定,教程也最多。

1. 先搞清楚这个系统到底要管什么:需求边界与功能模块拆解

做管理系统之前,最忌讳上来就建表写代码。城市垃圾分类管理系统听起来就是“登录、增删改查、统计”三件事,但一旦拆开,不同角色看到的功能差异非常大。我一般先画业务边界,再列功能清单,最后才开始设计数据库。

1.1 两类角色:普通居民与系统管理员

用户侧(居民端)要尽量轻:核心场景是“我不知道这个东西算什么垃圾,去查一下”。所以居民端必须有一个好用的垃圾条目查询,支持按名称搜索、按四分类浏览;其次是要能提交投放预约或回收记录,比如大件垃圾预约上门;再次是积分体系,查询积分明细、兑换科普小奖励。积分不是必选,但很多评分标准里它属于加分功能,而且能串起整条业务链。

管理端则完全相反,那是一个标准的中后台:垃圾类别管理(可回收物、有害垃圾、厨余垃圾、其他垃圾)、垃圾条目管理(给每一条垃圾写分类说明和处理提示)、公告管理、居民投放记录的审核与状态流转、积分发放与兑换记录查询,以及按周/月/类别维度的统计报表。这几块做出来,系统从功能上就完整了。

值得注意的是,真实项目里“管理端”往往还有二级划分,比如超级管理员和普通审核员,但作为课程设计或毕业设计,两类角色够用了。角色的区分在数据库里就是user表的一个role字段,不用单独建角色表。

1.2 核心业务流程:从居民查询到积分发放的闭环

我把系统的核心业务流梳理成一条主链:

居民登录/注册 → 查询垃圾类别或条目 → 提交投放记录(预约大件垃圾回收) → 管理员审核 → 审核通过后自动发放积分 → 居民查看积分明细并兑换奖励。

加入积分闭环后,这个系统就不是单纯的增删改查了,它有了“状态”的概念:投放记录从“待审核”到“已通过”或“已驳回”,可以由状态字段驱动。这里有两个关键点必须提前想清楚:

第一个是状态字段的设计。我用status字段,0待审核,1已通过,2已驳回,同时配create_time和audit_time,审核时间在管理员操作时更新。不要用字符串存状态,用tinyint最方便,后续统计SQL也简单。

第二个是积分发放的幂等控制。审核接口如果被前端重复点击,或者后端没有做校验,会出现一次审核发两次积分。所以发放积分的动作必须和状态更新放在同一事务里,并且通过UPDATE语句的条件判断来兜底,比如UPDATE disposal_record SET status=1 WHERE id=#{id} AND status=0,影响行数为0说明已经审核过,不能再发积分。

1.3 模块与页面映射:建功能清单表

为了避免后面开发时想到哪写哪,我在开工前会把功能模块和页面路径列成一张映射表,这也是给评分老师看的很好用的文档素材。

模块角色核心功能前端路由建议后端接口前缀
登录注册居民/管理员用户名密码登录、JWT签发/login/api/auth
垃圾查询居民按名称搜索、按四分类浏览/query/api/garbage
投放记录居民提交预约、查看个人记录/records/api/record
积分中心居民积分明细、兑换记录/points/api/points
基础管理管理员类别维护、条目维护/admin/category/api/admin/garbage
审核管理管理员投放记录审核、积分发放/admin/audit/api/admin/record
统计报表管理员四分类占比、提交趋势/admin/statistics/api/admin/stats

这样一列,开发顺序立刻就出来了:先做认证,再做居民端查询,再做管理端审核,最后做统计。前后端联调时也可以按这个顺序走,避免同时铺开一堆接口导致问题不好定位。

2. 技术栈选型和数据库设计:为什么是SpringBoot+Vue+MyBatis,表要怎么建

2.1 选型逻辑:不是赶时髦,是匹配业务规模

SpringBoot是Java社区做Web服务最主流的框架,内嵌Tomcat,自动配置省掉大量XML,非常适合中小型管理系统。MyBatis的优势是SQL由开发者自己控制,特别适合这类需要条件查询、统计报表、复杂SQL的教务/环保/政务系统,调试SQL非常直观。Vue配合Element UI做中后台页面效率极高,组件现成,前后端分离也让前后端可以并行开工。

MySQL不必多说,免费、稳定、生态好,对于这种百人规模的模拟系统,完全没有性能压力。这套组合的唯一缺点是工程结构稍显传统,但作为课程设计和毕业设计,恰恰是老师最熟悉的,答辩时不容易被刁钻问题难住。

2.2 数据库设计:六张核心表搞定全模块

数据库是翻车率最高的地方。我见过有人建二十多张表,结果字段逻辑混乱;也有人只建两张表,后面统计接口根本写不下去。合理的做法是先建六张核心表,覆盖主体功能,后续按需再加表。

第一张是用户表user:字段包括id、username、password、role、phone、points、create_time。role用数值区分,1为管理员,2为普通居民。points存积分总额,方便展示,不需要实时SUM。

第二张是垃圾类别表garbage_category:id、name、code、description。code用字符串,比如recyclable、harmful、kitchen、other。注意“厨余垃圾”有些地方也写“湿垃圾”,具体命名按你所在城市的分类标准来。

第三张是垃圾条目表garbage_item:id、category_id、name、description、tips。category_id逻辑关联类别表,不用物理外键,省得增删数据时一堆外键约束。垃圾查询主要就是查这张表。

第四张是投放记录表disposal_record:id、user_id、garbage_item_id、record_type、status、create_time、audit_time。record_type记录是普通查询还是预约回收,status的含义上面说过。

第五张是积分明细表points_record:id、user_id、change_points、type、description。type取值例如1审核通过增加、2兑换减少。积分总额和明细分开存,是标准做法,避免每次都汇总明细表。

第六张是公告表announcement:id、title、content、create_time。面向居民端展示,简单直接。

核心建表SQL(节选最关键的三张表):

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt加密存储', `role` tinyint(4) DEFAULT '2' COMMENT '1管理员 2居民', `points` int(11) DEFAULT '0', `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `garbage_category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `code` varchar(20) NOT NULL, `description` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `garbage_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL, `name` varchar(100) NOT NULL, `description` varchar(500) DEFAULT NULL, `tips` varchar(500) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有一个大多数人不会在意但很重要的细节:用户名一定要加密存储。不要用明文密码,推荐Spring Security自带的BCryptPasswordEncoder。生成验证码时也不要存明文,否则数据库一旦泄露,所有账号都裸奔,这在答辩时也是一个被追问的高频点。

3. 后端从0到1:认证、业务接口和MyBatis映射的落地细节

3.1 基于JWT的登录鉴权怎么落地

这类前后端分离系统,登录态通常用JWT。思路是:用户登录成功后,后端生成一个token返回,前端在请求头里带Authorization: Bearer token,后端通过拦截器解析token得到userId和role。用JWT不用Session,主要是无状态、天然适配前后端分离。

我用jjwt来实现,先封装一个JwtUtil工具类,核心方法如下:

public class JwtUtil { private static final String SECRET = "your-secret-key"; private static final long EXPIRE = 1000 * 60 * 60 * 2; public static String generateToken(Integer userId, Integer role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }

然后是拦截器。注册WebMvcConfigurer时添加拦截器,放行登录注册接口,其他接口全部拦截:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(HttpMethod.OPTIONS.name())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }

开发环境调试时,CORS跨域配置也很关键。Vue跑在8080端口,后端跑在8081端口,如果不配跨域,浏览器会直接拦截响应。我是这样处理的,在配置类里实现WebMvcConfigurer的addCorsMappings方法,允许所有来源和常用请求头。

3.2 垃圾分类查询与投放记录接口的实现

垃圾分类查询是居民端点击率最高的接口,要求支持模糊搜索。搜索时用户可能输“塑料瓶”也可能输“瓶子”,不可能每次命中完全匹配,所以SQL采用LIKE匹配名称和描述。这里要小心一件事:MyBatis的#{}会自动转义用户输入,防SQL注入,但如果直接在XML里写LIKE '%${name}%',就会存在注入风险,且无法预编译。推荐用concat函数:

<select id="searchItems" resultMap="garbageItemMap"> SELECT gi.*, gc.name AS category_name FROM garbage_item gi LEFT JOIN garbage_category gc ON gi.category_id = gc.id <where> <if test="keyword != null and keyword != ''"> AND (gi.name LIKE CONCAT('%', #{keyword}, '%') OR gi.description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND gi.category_id = #{categoryId} </if> </where> ORDER BY gi.id DESC </select>

投放记录提交接口要注意防止重复提交。前端通过loading按钮只能挡掉一部分操作,后端还必须做校验:比如同一个用户对同一个垃圾条目,在当天已经提交过待审核记录,就直接拒绝。这个判断用一个count查询就能实现。

提交成功后,记录初始状态为0待审核。审核接口才需要事务,因为“更新状态”和“新增积分明细”必须一起成功或一起失败。我在Service方法上加@Transactional,先执行UPDATE,再用int类型接收影响行数,如果返回0说明记录状态已经被改过,主动抛出业务异常,保证不会重复发放积分。

3.3 MyBatis动态SQL与分页的常见坑

动态SQL是MyBatis的灵魂,但也是最容易掉坑的地方。第一坑是<where>标签搭配<if>时,很多初学者会在每个if里加AND,第一行写成AND name = #{name},如果前面没有条件,生成的SQL就是WHERE AND name = ...,直接语法错误。用了<where>标签后,MyBatis会自动去掉多余的AND/OR,所以放心地把AND写在每个if开头即可,但如果你手写WHERE 1=1,别忘了去掉多余AND。

第二坑是PageHelper分页插件。它的原理是在当前线程上下文中拦截下一条查询,如果你在PageHelper.startPage()和查询之间又执行了其他查询语句(比如先查一次用户信息再查列表),第一次查询会被分页插件误处理,导致结果错乱。正确写法是:

PageHelper.startPage(pageNum, pageSize); List<DisposalRecordVO> list = disposalRecordMapper.selectRecordPage(condition); PageInfo<DisposalRecordVO> pageInfo = new PageInfo<>(list);

在controller里返回pageInfo.getTotal()作为总条数,pageInfo.getList()作为当前页数据。注意PageHelper只对紧跟着的第一个查询生效,所以一定不要在中间插入其他数据库查询。

4. 前端Vue项目:页面结构、状态管理和对接口的几种姿势

4.1 页面路由与组件划分

前端我用Vue 2 + Element UI的组合。工程创建好之后,最先规划路由。我建议居民端和管理端用不同的layout,管理端有侧边栏菜单,居民端只有顶部导航,这样代码结构清晰。

居民端路由:登录页/login,首页/home,垃圾查询/query,我的记录/records,积分中心/points。管理端路由:垃圾类别管理/admin/category,垃圾条目管理/admin/item,投放审核/admin/audit,统计报表/admin/stats。所有管理端路由放在同一个父路由下,并设置一个前置守卫,判断当前用户的role是否等于1,不是就跳回首页。这个导航守卫写起来很简单:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); } else { next(); } });

不要在导航守卫里只判断token,因为token只是登录凭证,角色判断要在进入每个管理端页面时再校验一次。比较偷懒但也有效的做法是在后端管理接口的拦截器里校验role,前端即使误入也无权访问数据。

4.2 axios封装与token携带

每来一个请求都要手动加请求头,工作量大且容易漏。正确做法是统一封装一个request模块。我通常会建src/utils/request.js:

import axios from 'axios'; import { Message } from 'element-ui'; import router from '@/router'; const request = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || 'http://localhost:8081/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); Message.error('登录已过期,请重新登录'); } else { Message.error(error.response?.data?.message || '请求失败'); } return Promise.reject(error); } ); export default request;

这里有一个容易被忽略的点:response拦截器返回response.data后,所有API调用拿到的就是后端返回的数据体,不用再在业务代码里反复res.data.data。后端我统一封装了返回结构{code, message, data},前端这边直接解构data字段出来。

开发环境跨域问题推荐用proxy代理解决,在vue.config.js里配置devServer的proxy,把/api代理到后端地址,这样浏览器请求的是同源地址,不会有跨域报错,比后端开启cors配置更干净。

4.3 管理端表格与统计图表的实现要点

管理端表格是Element UI的绝活。后端分页接口返回{list, total, pageNum, pageSize},前端在el-table中用el-pagination绑定几个属性即可。注意翻页时必须把搜索表单中的keyword、status一并传给后端,否则会出现在第二页搜索条件丢失的问题。我通常把搜索表单放一个对象里,分页事件里把整个对象序列化传给后端。

统计图表推荐ECharts。比如要画“四分类提交记录占比饼图”,后端只需要返回一个原始数据组:

[ {"name": "可回收物", "value": 120}, {"name": "有害垃圾", "value": 8}, {"name": "厨余垃圾", "value": 46}, {"name": "其他垃圾", "value": 33} ]

前端直接:

chart.setOption({ series: [{ type: 'pie', data: res.data }] });

不用让后端拼好ECharts的完整option,因为后端返回原始数据最通用,前端可以随意换图表类型,以后加需求也不用改后端接口。这个设计思路很多人一开始想不明白,但做两三个项目后就会发现,后端只需要提供结构化数据,前端负责可视化,是效率最高的分工。

5. 从源码到运行:一键部署完整步骤与常见报错排查

5.1 环境配置清单:版本不匹配是最隐形的拦路虎

拿到一套源码,先别双击启动,先对照环境清单检查。最小可行环境如下:

组件推荐版本说明
JDK1.8多数毕设源码基于JDK8,Spring Boot 2.7完全支持
Maven3.6.x3.9也能用,但仓库源可能要配国内镜像
MySQL5.7或8.0注意8.0连接驱动名称与5.7略有差异
Node.js14.17+Vue 2 + Vue CLI 4建议Node 12~16
IDEA2023或2024社区版即可,支持Maven

最容易翻车的组合是:用了Spring Boot 3.x源码,却装了JDK8;或前端项目用Vite构建,需要Node 18,而你用的是Node 14。解决办法是在动手前看pom.xml里的 和package.json里的scripts,确认构建工具版本。

5.2 导入源码、建库、启动的详细步骤

第一步,新建数据库:在MySQL里执行CREATE DATABASE garbage CHARACTER SET utf8mb4;,然后选择该数据库,导入源码中提供的garbage.sql。如果源码里没有sql文件,这基本属于残缺源码,建议直接换一份。

第二步,修改后端配置文件。找到application.yml,把数据库地址、用户名、密码改为你自己的:

spring: datasource: url: jdbc:mysql://localhost:3306/garbage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your-password

第三步,启动后端。用IDEA以Maven项目导入后端文件夹,等依赖下载完,运行主类。如果不配置镜像,第一次下载依赖可能很慢,建议在Maven的settings.xml里配置阿里云镜像,但这一步属于基础操作,不展开。

第四步,启动前端。进入vue目录,依次执行:

npm install npm run serve

如果npm install报错,优先查看有没有package-lock.json,有就用npm ci代替。启动成功后,访问前端地址,用管理员预置账号登录。

5.3 部署后必看的几个报错与解决办法

我总结了一份高频报错对照表,都是帮朋友调试时实际遇见过的:

报错现象可能原因排查与解决
Access denied for user 'root'@'localhost'数据库密码错误检查application.yml中username/password
java.sql.SQLSyntaxErrorException: Unknown column表和源码版本不匹配重新执行最新sql脚本,或看有没有增量更新sql
Port 8080 was already in use8080端口被占用改application.yml中server.port,或kill占用进程
Failed to load resource: net::ERR_CONNECTION_REFUSED后端没启动或端口不一致确认后端启动;确认前端baseURL/proxy指向后端真实端口
Uncaught (in promise) invalid tokentoken过期或伪造清localStorage重新登录
npm ERR! Missing script: "serve"项目可能用Vite或工程不完整查看package.jsonscripts定义,改用npm run dev

这些坑绝大多数不是代码逻辑问题,而是环境一致性问题。所以我的习惯是:运行前先检查后端端口、前端代理端口、数据库密码三处,能省下至少一小时。

6. 这个项目还能怎么改:可扩展方向与实际开发建议

一套基础源码跑通之后,很多人会问“这样够了吗”。我的答案是:作为课程设计或毕业设计的基础版本,够;但如果你想让它从“普通管理系统”变成“有亮点的作品”,可以在下面几个方向里挑一两个加进去。

第一个方向是缓存优化。垃圾分类查询是高频只读操作,尤其是热门条目,可以引入Redis做一级缓存。每次查询先查Redis,没有再查MySQL并回写。这个改动量不大,却能在论文里加上“系统性能优化”章节。

第二个方向是大屏可视化。很多城市都在推垃圾分类示范小区,如果能做一个数据大屏首页,在地图上标记投放点,用ECharts展示各街道分类趋势,视觉效果会非常加分。前端工作量会变大,但技术难度不太高,适合想在答辩时抓眼球的人。

第三个方向是小程序端。把居民端查询和预约提交移植到微信小程序,后端接口不变,只需要新写一个前端。这也会明显增加工作量,建议量力而行,至少要留足两周。

我个人的开发和调试体会是:这类项目最花时间的从来不是CRUD功能,而是前后端联调时接口字段名对不上、状态码不统一、跨域拦截、token过期这类琐碎问题。如果后续有人给你一份所谓“完整源码”,别急着跑,先确认三件事——有没有数据库脚本、后端入口类是否明确、前端代理或baseURL配的是什么。这三件事确认完,百分之八十的启动问题都提前消掉了。

最后再分享一个小技巧:在所有Json响应里统一返回一个包装结构,比如{code:200, message:"ok", data:xxx},前端axios拦截器只处理code,不要直接把整个HttpResponse返回给页面。这能让前后端协作效率提高很多。这个项目我前后迭代过三个版本,第一版接口返回乱七八糟,第二版统一了后端结构,第三版加了积分闭环,越到后期越觉得,前期把规范和约束定好,比多加十个接口都值。照着上面这条路走一遍,你拿到源码后从导入到跑通也就一个下午的事。

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

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

立即咨询