每年毕业设计季我都要帮不少学弟学妹review各类管理系统源码。说实话SpringBootVue 这个组合几乎成了毕设和课设的默认配置但真正能让我给出“值得参考”评价的项目并不太多城市垃圾分类管理系统算一个。这个选题既有 JavaMySQL 的后端落点又有 Vue 前端的页面发挥空间最重要的是垃圾四分类的业务规则自带一套“可扩展的逻辑深度”不会像普通的增删改查那样空洞。这篇文章我会从一个实际评审者的角度把这个系统的功能边界、数据库设计、后端接口、前端交互和部署调试完整拆解一遍顺带把我见过的高频翻车点都点出来希望能成为你选型或照着实现的一份硬核参考。1. 为什么这个选题在毕设/课设中这么吃香1.1 一个系统串起Web开发全链路很多同学在选毕设题目时容易两极分化要么选纯工具类项目功能太少几天做完答辩时没有内容可讲要么选电商、社交这类大而全的题目结果数据库表几十张还没做完一半就到交稿时间了。城市垃圾分类管理系统刚好卡在中间。它表面是一个管理系统但拆开来看它的技术覆盖量非常完整用户注册登录、角色权限控制、垃圾名称查询识别、投放记录、积分流水、预约回收、后台管理、数据统计报表。这些功能几乎都是Web开发面试和课程考察的高频点尤其 JWT 无状态认证和RBAC权限模型是很多公司校招都会问到的内容。我见过一个很有意思的对比同样是学生项目有人做的是“SSM框架的新闻管理系统”功能就是后台发文章有人做的是“SpringBoot Vue的垃圾分类管理系统”既有前台用户的完整操作路径又有后台的管理闭环。这两个项目放在一起哪怕第二个的代码量只多三分之一答辩时的演示效果和可讲深度都是完全不同的量级。1.2 垃圾分类业务自带算法感纯粹的管理系统最大的问题就是“看起来像增删改查”评委一眼就能看穿。但垃圾分类系统的核心业务天然不是纯CRUD它有一个非常有辨识度的功能输入垃圾名称返回它属于哪一类以及对应的处理建议。这个功能听起来简单真要做得顺手需要处理精确匹配、包含匹配、别名匹配、冲突处理等一堆逻辑。比如用户输入“废旧电池”系统要知道它匹配的是“电池”这条记录并且电池属于有害垃圾而不是匹配到“干电池”或其他词条。这种匹配逻辑本身就是一个小型的规则引擎设计完全可以在答辩时作为亮点来讲。再加上积分兑换、预约回收订单的状态流转系统的业务链是从“查询”到“行为”再到“反馈”的完整闭环不是孤立的几个页面。1.3 选题背景天然契合垃圾分类是城市生活里的高频场景很多人每天都要面对“这是什么垃圾”的灵魂拷问。这个系统天然自带“便民服务”的属性前后端各个模块都能往这个主线上靠。相比“XX管理系统”“XX平台”这类空泛题目“城市垃圾分类管理系统”这个名字本身就更容易让评审老师觉得你有场景意识。我个人的建议是如果你决定用这个题目汇报PPT的第一页就放一个用户查询垃圾类别的截图配一句“这是一个能让普通居民快速获得分类答案的系统”比开场讲技术栈更能抓住注意力。2. 功能边界与数据模型先想清楚系统要管什么2.1 用户视角与管理员视角的功能清单动工写代码之前一定要先把功能边界画清楚。很多同学一上来就建表做着做着发现少个字段又要加列改来改去数据库结构一团乱。我把这个系统常见的功能边界整理成了一张矩阵你可以直接参考模块居民端前台用户管理端管理员账号体系注册、登录、修改密码登录、用户列表、禁用/启用用户分类知识库搜索垃圾名称、查看分类详情和投放建议维护垃圾类别、维护垃圾条目和别名、批量导入投放管理在线提交投放记录、查看历史投放审核投放记录、查看投放统计积分系统查看积分余额、查看积分流水、在线积分兑换配置积分规则、处理兑换订单预约回收提交预约订单、取消预约查看订单、指派回收状态、完成订单系统管理我的页面、意见反馈菜单权限配置、数据概览大屏这里要注意一个原则毕业设计项目的功能不是越多越好而是要让每个角色有一条完整可演示的操作路径。比如居民端必须是“注册 → 登录 → 查询垃圾 → 提交投放记录 → 获得积分 → 使用积分兑换”这条线走通光有一堆页面但流程断掉演示时很容易冷场。2.2 数据库核心表设计这个系统的核心表我建议划分成四组人员权限组、分类知识组、业务记录组、系统配置组。我先给几个最基本的建表语句示例。人员权限相关CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(128) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(64) DEFAULT NULL COMMENT 昵称, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户表; CREATE TABLE sys_role ( id BIGINT AUTO_INCREMENT PRIMARY KEY, role_code VARCHAR(32) NOT NULL UNIQUE COMMENT ROLE_USER / ROLE_ADMIN, role_name VARCHAR(64) NOT NULL COMMENT 角色名称 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 角色表; CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户角色关联表;分类知识相关CREATE TABLE garbage_category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL UNIQUE COMMENT 可回收垃圾/有害垃圾/厨余垃圾/其他垃圾, description VARCHAR(255) DEFAULT NULL COMMENT 分类说明, icon VARCHAR(255) DEFAULT NULL COMMENT 图标地址, sort_order INT DEFAULT 0 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 垃圾类别表; CREATE TABLE garbage_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL COMMENT 关联garbage_category.id, name VARCHAR(64) NOT NULL COMMENT 标准名称如电池, aliases VARCHAR(255) DEFAULT NULL COMMENT 逗号分隔的别名如废旧电池,5号电池, keyword VARCHAR(64) DEFAULT NULL COMMENT 匹配关键词用于模糊命中, recycle_desc VARCHAR(255) DEFAULT NULL COMMENT 投放建议, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 垃圾条目表;业务记录相关CREATE TABLE recycle_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 下单用户, address VARCHAR(255) NOT NULL COMMENT 上门回收地址, contact_phone VARCHAR(20) NOT NULL, appointment DATETIME NOT NULL COMMENT 预约时间段, status TINYINT DEFAULT 0 COMMENT 0待处理 1已接单 2已完成 3已取消, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 预约回收订单表; CREATE TABLE points_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, change_type VARCHAR(32) NOT NULL COMMENT INCREASE投放积分 / EXCHANGE兑换扣减, points INT NOT NULL COMMENT 正数为增加负数为扣减, description VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 积分流水表;2.3 表设计里的几个关键取舍垃圾条目表不要只建一个name字段。真实场景下同一件物品有太多叫法“废旧电池”和“5号电池”其实是同一个分类逻辑。单独用一个aliases字段存别名查询时做“name精确匹配 → aliases精确匹配 → keyword包含匹配”的阶梯式判断比只靠like %电池% 准确得多性能也更好。用户表和角色表是多对多关系必须建中间表。不少简化版本想在sys_user表里直接加一个role字段存“admin”或“user”短期能用但以后加第二个管理员角色或者给用户分配多个角色时就只能改表结构。用中间表是最稳妥的做法。积分余额千万不要做字段存储。我见过有人直接在user表里加一个points字段每次积分变动就update一次。这种方式并发操作时很容易出现扣减覆盖正确做法是只记录points_record流水需要余额时用SUM聚合实时计算。用户量级不大时这个查询毫无压力还能天然保留每一笔积分的历史来源。3. SpringBoot后端从接口设计到垃圾识别逻辑3.1 统一返回结构与全局异常处理我review过的学生项目里最影响观感的问题之一就是接口返回格式不统一有的接口返回Map有的返回String前端拿到数据后要到处判空。正规做法是定义一个统一的Result类public class ResultT { private Integer code; // 200成功 400参数错误 401未登录 500异常 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }再配一个全局异常处理器把业务异常、参数校验异常、兜底异常都统一转成Result格式。这样做的好处是前端axios拦截器只需要判断code为200就走正常流程其他情况统一弹出message前后端联调时沟通成本会低非常多。3.2 为什么用JWT而不是Session这个系统的用户端和管理端是前后端分离部署前端可能跑在8080端口后端跑在8081端口如果后端用Session存登录态就必须处理跨域携带Cookie的问题还要额外配置CORS的allowCredentials。JWT的方案就简单很多登录成功后后端签发一个token字符串前端存在localStorage或者Pinia/Vuex里之后每个请求在请求头带上Authorization字段后端写一个拦截器解析token并提取用户信息。核心拦截器逻辑可以这样写public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { writeUnauthorized(response); return false; } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); request.setAttribute(roleCode, claims.get(roleCode)); return true; } catch (Exception e) { writeUnauthorized(response); return false; } } }需要注意的是拦截器里要放行登录、注册、垃圾查询这几个匿名接口其他接口全部拦截。角色校验可以放在拦截器里判断请求路径前缀比如 /api/admin/** 必须roleCode为ROLE_ADMIN更精细的权限可以配合Spring Security或Sa-Token做但毕设阶段用拦截器注解基本就够用了。3.3 垃圾识别接口真正的核心逻辑垃圾识别是这套系统里最有亮点的地方也是体现你“不是只会写CRUD”的关键。它的接口大概长这样GET /api/garbage/search?keyword废旧电池。Service层的匹配逻辑我建议按下面的顺序做精确命中garbage_item.name 等于 keyword或者 aliases 字段拆分后完全等于 keyword。包含命中keyword 包含 garbage_item.name比如 keyword是“废旧电池”name是“电池”此时可以命中。关键词命中garbage_item.keyword 与 keyword 有包含关系用于处理“玻璃瓶”这类词。兜底逻辑如果以上都不命中返回“暂未收录该垃圾请参考其他垃圾”的引导提示。这里有一个很隐蔽的坑包含匹配的优先级问题。比如你同时有“干电池”和“锂电池”两条记录用户输入“电池”时应该优先匹配标准名完全等于“电池”的条目而不是同时返回两条。所以实现的时候要给每条命中记录打上一个匹配类型权重精确匹配权重最高包含匹配第二关键词匹配最低最后按权重排序取第一条返回复杂度可控而且逻辑清晰。我再给一个简化版Service代码片段public QueryResult search(String keyword) { // 1. 精确匹配 GarbageItem exact garbageItemMapper.findByNameOrAlias(keyword); if (exact ! null) { return buildResult(exact, 精确匹配); } // 2. 包含匹配 ListGarbageItem items garbageItemMapper.findByKeyword(keyword); if (!items.isEmpty()) { return buildResult(items.get(0), 包含匹配); } // 3. 兜底 return QueryResult.unknown(keyword); }生产级的匹配算法当然还会做同义词表、拼音匹配、词频统计但在毕设场景做到上面的三步已经能展示出完整的逻辑递进。3.4 积分与统计的SQL设计积分的聚合查询其实一个SQL就能把七天的投放趋势拉出来SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS putCount, SUM(points) AS totalPoints FROM points_record WHERE user_id #{userId} AND create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY day ORDER BY day;管理端的统计大屏也用同样的思路按category_id分组统计投放量能画出一张“四分类投放占比”的饼图演示效果比干巴巴的表格好很多。4. Vue前端从页面骨架到交互细节4.1 工程初始化的版本选择如果项目是从零开始我建议前端直接用Vite来创建Vue3项目配合Element Plus。Vite的启动速度确实比旧版Vue CLI快很多尤其在演示现场临时改代码的时候热更新几乎是秒级反应能避免答辩时的尴尬。路由组织上我倾向于拆成两个布局用户端一个主导航布局包含首页、垃圾查询、投放记录、积分中心、预约回收、我的。管理端一个独立的管理布局侧边栏菜单包含用户管理、分类管理、条目管理、订单管理、统计报表。路由懒加载是必须的使用const routes [{ path: /user, component: () import(/layouts/UserLayout.vue), children: [...] }] 这种写法首页首屏加载会快很多。4.2 axios封装与token处理前端最核心的基础设施就是axios实例。我见过太多项目每个页面单独import axios然后自己拼请求头结果后端一改token字段名所有页面都要改。正确做法是把axios实例封装到一个request.js里import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /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 { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(网络异常) return Promise.reject(error) } )这里有两个细节需要特别注意token过期后不要立刻跳登录页。如果当前页面有多个并发请求同时401可能触发多次路由跳转所以最好在响应拦截器里加一个标记变量isRedirecting避免重复跳转。baseURL写成/api后开发环境要配合Vite的proxy代理把/api开头的请求转发到后端地址。这一步很多人会忘导致请求发到前端自己的8080端口然后报404。4.3 核心页面拆解登录页是最容易被忽略但最重要的是页面之一。除了常规的表单校验登录成功后要做的三件事保存token、保存用户基本信息、根据角色跳转到不同首页。角色如果是ROLE_ADMIN就进管理端是ROLE_USER就进用户端。垃圾查询页是系统的门面。我建议做成一个居中的大搜索框下面展示分类卡片。输入时用watch加防抖函数用户停顿300毫秒后才去请求接口避免每敲一个字符就发一次请求。查询结果下方展示分类名称、投放建议、相关物品列表三个区块信息层次清晰。管理端的统计报表用ECharts实现需要按ECharts的按需引入方式配置不要全量引入所有图表那会让打包体积大很多。折线图和饼图是足够了饼图展示垃圾分类投放占比折线图展示近7天积分趋势两张图放在一个页面上就很完整。5. 跑通项目的完整步骤与关键配置5.1 开发环境适配清单很多项目跑不起来不是代码有问题而是环境不匹配。我把这套系统常用的环境组合整理成一张表照着配能省很多时间组件推荐版本说明JDK1.8 或 111.8兼容性最好11也可以17要注意部分旧依赖不兼容Maven3.6.3用IDEA内置的Maven也行MySQL5.7 或 8.08.0需要配置serverTimezone驱动类名也不一样Node.js16.x / 18.x / 20.xVite和Vue3支持都比较稳定后端框架Spring Boot 2.7.x很多教程和依赖基于2.x避坑前端框架Vue 3 Element Plus组件丰富样式成熟5.2 从零跑通后端第一步是创建数据库并导入初始化SQL。这里不要用root账号在公网环境跑项目本地开发倒是无所谓。第二步是修改application.ymlserver: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/garbage_sorting?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl启动类上别忘加MapperScan注解否则Mapper接口扫描不到启动时会一直报找不到bean。第三步就是执行mvn spring-boot:run或者在IDEA里直接运行启动类。后端启动成功的标志是控制台看到“Tomcat started on port(s): 8081”。5.3 从零跑通前端和跨域前端跑起来比较容易npm install之后npm run dev即可。关键在跨域配置。开发环境最省事的方案是Vite的proxy代理在vite.config.js里写server: { port: 5173, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } }注意如果后端接口路径本身就带/api前缀proxy配置里就不要写rewrite如果不带要加一个rewrite把/api去掉。前后端的路径设计最好提前约定好避免联调时来回改。5.4 最容易卡住的三个配置问题MySQL 8.0的驱动类名是com.mysql.cj.jdbc.DriverMySQL 5.7用com.mysql.jdbc.Driver。如果在pom.xml里引入了mysql-connector-java 8.x配置里还写旧驱动名启动会直接报ClassNotFoundException。端口被占用是本地启动的高频问题。Windows上可以用netstat -ano | findstr 8081查看进程PID然后在任务管理器里结束它。不要在IDEA里反复点重启问题一直存在不说你还不知道是谁占了端口。前端proxy和后端CORS不要同时放开。如果前端代理已经指向后端后端再配置允许跨域也没有坏处但一旦前端绕过proxy直接请求后端地址就必须依赖后端的CORS配置。我的建议是开发阶段用前端proxy部署阶段用Nginx转发后端的CORS配置保持默认关闭就好。6. 我在维护和扩展这类系统时踩过的坑6.1 权限拦截器放行路径写错引发的死循环这个坑我见过不止一次。有人写拦截器时只放行了/api/user/login忘记放行/api/user/register结果用户注册接口也被拦截。前端注册页面一提交就收到401跳转登录页登录成功后系统又想把用户带回注册页路径上再次触发401整个人被绕晕。定位这种问题其实有技巧先把后端控制台日志打开看每一个被拦截的请求路径然后再一个个对拦截器里的排除列表。我建议把所有匿名接口统一放到一个常量数组里用AntPathMatcher做路径匹配比在代码里写一长串if/else清晰得多。6.2 垃圾查询关键字排序不稳定刚开始做识别逻辑时我的实现是直接用List的contains匹配结果用户输入“旧电池”时同时命中了“电池”和“其他垃圾的旧衣服”两个条目返回结果飘忽不定。后来给每个匹配类型加了权重等级才彻底解决。这里分享一个经验垃圾识别的核心不是算法多高级而是分类规则足够稳定。你要保证同一个keyword无论调用多少次返回结果都一致。实现时给查询结果加一个match_type字段在SQL里用CASE WHEN计算权重并排序是成本最低的稳定方案。6.3 token过期后前端循环跳转管理端的用户登录状态失效后常见表现是用户正在操作token过期点击某个按钮后路由被守卫重定向到登录页登录成功后想跳回原页面但原页面里的数据请求再次401又被踢回登录页用户觉得系统在抽风。解决思路是登录页不要自动跳回原页面而是统一跳到角色对应的首页。这样即使原页面的请求失败也只是少看一次数据不会再陷入循环。具体实现时在axios响应拦截器里通过一个全局标记变量控制跳转频率同时把当前想要跳转的地址记录下来在登录成功后再消费。6.4 可以继续扩展的方向如果你做完这个基础版还有余力可以往几个方向扩展拍照识别垃圾前端调用摄像头或相册上传图片后端接一个图像分类接口把识别结果和文本查询结果合并展示。地图标注回收点在管理端维护回收点坐标用户端用地图组件展示最近回收点并提供导航。多端复用把后端接口原样保留单独做一个微信小程序页面技术上只多一层请求封装演示时能展示“多端适配”能力。批量导入知识库用EasyExcel或POI做一个Excel导入模板管理员可以一次导入几百条垃圾条目比一条条新增高效得多。在我个人实际体验里这类系统源码最大的价值不在于代码量有多少而在于它把你在学校里零散学到的技术串成一个能跑、能演示、能讲出逻辑的完整闭环。如果你准备拿它当毕设或课设我的建议是一开始不要急着加功能先把“居民查垃圾→投放→攒积分→兑换”这条主链路跑通再逐步做管理后台和统计。等你真正把垃圾识别接口从简单的like查询改造成带权重的阶梯匹配再配合管理端的图表报表答辩时评委想挑毛病都要先想一会儿。