前阵子在社区里帮朋友做了一套城市垃圾分类管理系统,技术栈选的是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,前后端分离,代码加文档全部整理好交付。说实话,垃圾分类管理这种系统看起来不复杂,真正动手才发现处处都是细节:垃圾分类怎么动态维护、投放点如何在地图上打点、积分规则怎么设计、后台数据怎么统计,再加上前端权限控制和后端接口配合,一套完整流程跑通并稳定上线,并不是简单把框架搭起来就完事的事情。
这篇就当作我自己的项目复盘帖,把我踩过的坑和最终落地的方案完整写一遍。如果你正准备做一个管理类系统的毕业设计、课设,或者想看看 SpringBoot2 + Vue3 + MyBatis-Plus 这套组合在实际业务中怎么配合,这篇文章应该能给你一套可以照抄的答案。里面所有核心实现、配置代码、数据库表设计,还有问题排查记录,我都会贴出来。
1. 垃圾分类系统到底要做成什么样
1.1 业务场景和用户角色梳理
垃圾分类管理系统的核心使用场景在街道、物业、社区和环卫部门。居民端需要知道某个垃圾属于哪一类、附近哪里有投放点、怎么参与分类获得激励;管理端需要维护垃圾类别信息、投放点信息、积分商品,查看居民反馈和各类统计数据。
所以在动手写代码之前,一定要先把角色理清楚。我把它分成两类用户:普通用户(居民)和系统管理员。居民通过前端页面查看分类知识、查询投放点、提交分类反馈、查看积分;管理员在后台维护所有基础数据、审核反馈、管理兑换商品、查看统计报表。如果后续要扩展上门回收业务,还可以增加回收员角色,但核心一期先做好这两类角色就足够支撑项目跑起来了。
1.2 为什么选 SpringBoot2 + Vue3 这套组合
技术选型是最容易纠结的地方。写这套系统前我对比过几个方案:SpringBoot3 + Vue3、SpringBoot2 + Vue2,甚至考虑过若依这种现成脚手架改一改。
最终我坚持用了 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,理由很实在。第一,SpringBoot2.x 目前在企业项目里存量最大,JDK8 支持完美,各种中间件兼容性文档丰富,遇到问题基本都能搜到答案;SpringBoot3 虽然新,但强制 JDK17,很多老项目依赖和数据库驱动的兼容问题会多一些,对业务系统来说没有必要冒这个险。第二,很多高校和企业面试环节重点问的也是 SpringBoot2,做这套系统拿来学习和面试复习一鱼两吃。第三,Vue3 已经是当前前端主流,组合式 API 写业务代码比 Vue2 选项式更直观,配合 Vite 开发体验也好很多。
MyBatis-Plus 就更不用说了,主流的通用 CRUD 方案,几乎不用写 SQL 就能完成单表操作,项目里基础增删改查全走 ServiceImpl + BaseMapper,省下来的时间全投入到业务逻辑上,效率翻倍。MySQL8.0 是当前主流版本,支持窗口函数、JSON 字段这些新特性,团队里新人也更容易上手。
1.3 功能模块一共拆成几块
先把需求拉成一张模块清单,开发的时候按模块切分,前后端对应着做,心里才有底。
| 模块 | 用户端功能 | 管理端功能 |
|---|---|---|
| 分类管理 | 查看垃圾所属分类、按关键词查询 | 垃圾类别维护、垃圾条目 CRUD |
| 投放点管理 | 地图查看投放点、地址详情 | 投放点新增、坐标维护、状态管理 |
| 积分系统 | 查看积分明细、参与分类反馈 | 积分规则设置、积分审核、兑换商品管理 |
| 反馈管理 | 提交文字/图片反馈 | 反馈列表查看、处理状态更新 |
| 数据统计 | 个人分类行为统计 | 分类比例、投放点覆盖、反馈趋势图表 |
这套模块划分覆盖了垃圾分类业务从“居民分类”到“后台运营”的完整闭环。实际写代码时,我会把系统拆成 user、category、point、points、feedback、statistics 几个业务包,每个包内做 controller、service、mapper、entity 四层,逻辑清晰一眼就能看懂。
2. 数据库设计:MySQL8.0 下的表结构与关键细节
2.1 核心表设计一览
数据库是整个系统最不能将就的部分。分类、投放点、积分、反馈这四个业务域我分别设计了一组表。下面这几张是实际项目里跑起来的核心表。
用户表 sys_user,主要存用户名、密码、昵称、手机号、头像、积分余额、状态字段。密码不存明文,用 BCrypt 加密后存储。垃圾类别表 waste_category,字段包括类别名(可回收物、有害垃圾、厨余垃圾、其他垃圾)、图标、颜色标识、排序号、状态。垃圾明细表 waste_item 是核心表,存具体垃圾名称、所属类别 ID、分类依据说明、投放建议,用户搜索垃圾时主要查这张表。
投放点表 recycle_point 存投放点名称、地址、经纬度、负责人、联系电话、开放时间段、状态。积分记录表 points_record 存用户 ID、变动积分、变动类型(签到、反馈奖励、兑换扣减)、关联业务 ID、创建时间。兑换商品表 points_goods 存商品名称、所需积分、库存、状态。兑换记录表 exchange_record 存用户 ID、商品 ID、兑换积分、收货信息、兑换时间。反馈表 feedback 存用户 ID、垃圾类型、描述、图片地址、处理状态。
2.2 MySQL8.0 建表需要注意的三个点
MySQL8.0 建表时我踩过不少坑,这里专门说一下。字符集一定要在建库时指定 utf8mb4,不然默认字符集在 Linux 环境下可能是 latin1,存中文直接乱码。建库语句我用的是:
CREATE DATABASE waste_management DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;timestamp 和 datetime 的选择上,业务表我统一用 datetime,因为涉及积分、反馈这些时间记录,datetime 的范围足够用,不涉及时区换算,显示也更直观。如果涉及多时区业务才需要用 timestamp。
第三个要特别注意的地方是 MySQL8.0 的认证插件。我开发环境用 Navicat 链接时遇到过 1251 错误,这是因为 MySQL8.0 默认加密规则是 caching_sha2_password,老客户端不支持。解决办法有两个:要么升级客户端,要么在 MySQL 里执行下面这条:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '密码'; FLUSH PRIVILEGES;我实际部署时为了方便维护,直接统一改成了 mysql_native_password,配合新版驱动 com.mysql.cj.jdbc.Driver,从没再出过认证问题。
2.3 积分和兑换业务为什么单独建表
很多新手会把积分余额直接存在用户表一个字段里,消费积分时直接 update 用户表。我第一版也是这么做的,测试一段时间后发现完全不行,因为一旦积分扣错了,根本没有历史记录可查,也不知道这笔积分是签到来的还是反馈奖励来的。
后来我改成积分流水表设计:用户表只冗余一个积分余额字段用于展示,所有积分变动全部落 points_record 表。查询用户积分总流水时直接查流水表,统计某段时间积分产出时也走这张表,数据可追溯性大大提高。这算是我做这个系统最大的重构之一,也符合真实业务系统的设计习惯。
3. 后端实现:SpringBoot2 + MyBatis-Plus 从工程结构到通用 CRUD
3.1 初始化 SpringBoot 工程和依赖配置
工程我直接用 Spring Initializr 生成,JDK 版本选的 1.8,打包方式 jar。核心依赖包括 spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok、hutool 工具包、jwt 认证相关依赖。MyBatis-Plus 我用的 3.5.3.1 版本,不要再往下用老版本,3.5.x 对 SpringBoot2 适配得很好,分页插件 API 也更规范。
配置文件里最重要的还是数据源和 MyBatis-Plus 的设置,实际效果很简单明了:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/waste_management?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root123 mybatis-plus: configuration: map-underscore-to-camel-case: true 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你看到 URL 里我加了 serverTimezone=Asia/Shanghai 和 useSSL=false。不加 timezone 后端插入时间会差 8 个小时,这个八小时时区问题几乎是每个做 MySQL 的人都会遇到一次。useSSL=false 是因为本地开发环境没有配置 SSL 证书,不加它启动时一堆警告日志刷屏。
3.2 基于 MyBatis-Plus 的通用 CRUD 到底怎么写
之前看到过一段描述,说 MyBatis-Plus 提供的通用 CRUD 服务是“基于工具类实现无状态增删改查”,实际用起来就是这个感觉。对单表来说,你不需要写一行 SQL,只需要定义实体类,然后让 Mapper 继承 BaseMapper,Service 继承 IService,实现类继承 ServiceImpl,增删改查接口就全部齐了。
以垃圾类别管理为例,核心代码量少得惊人:
@Data @TableName("waste_category") public class WasteCategory { @TableId(type = IdType.AUTO) private Long id; private String name; private String icon; private String color; private Integer sort; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }Mapper 接口:
@Mapper public interface WasteCategoryMapper extends BaseMapper<WasteCategory> { }Service 接口和实现类也一样简洁:
public interface WasteCategoryService extends IService<WasteCategory> { } @Service public class WasteCategoryServiceImpl extends ServiceImpl<WasteCategoryMapper, WasteCategory> implements WasteCategoryService { }从 Controller 层调用的时候,新增、修改、分页查询、删除全都能直接走 IService 暴露的方法,单表业务几乎不用写 SQL。通用 CRUD 省下的时间,全花在业务规则上,这才是 MyBatis-Plus 存在的意义。
3.3 分页、自动填充和逻辑删除的细节配置
MyBatis-Plus 很多功能不是加了依赖就能用,必须在代码里显式配置。首先是分页插件。如果只引入依赖不配置 PaginationInnerInterceptor,调用 page 方法返回的数据里 total 是 0,我最初就栽在这里。完整配置类如下:
@Configuration @MapperScan("com.waste.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }我同时还加了 OptimisticLockerInnerInterceptor,给积分流水表设了一个 version 字段。因为积分增减是高并发场景,两个请求同时改同一个用户积分,不加乐观锁版本号极容易把余额覆盖成旧值。加上之后,更新时自动带上 version 条件,失败就重试,数据一致性有了保障。
自动填充 createTime 和 updateTime 也建议统一处理。我写了一个 MetaObjectHandler 实现类,在 insert 和 update 时自动给实体类的 createTime、updateTime 注入值,这样每张表都不用手动维护时间字段。
逻辑删除我用了全局配置里的 logic-delete-field 配置,实体类里加一个 deleted 字段,删除操作自动变成 update 语句。这个对投放点、垃圾类别这种需要保留历史数据的表特别重要,用户误删后管理员还能恢复。
3.4 非单表查询:垃圾名称模糊搜索和投放点附近查询
虽然通用 CRUD 能覆盖大部分操作,但真正业务里还是有几个场景需要自定义 SQL。第一个场景是用户端垃圾查询。用户在搜索框输入“纸箱”“玻璃瓶”“过期药品”,后端要带分页、支持模糊查询、关联垃圾类别显示名称。我直接用 MyBatis-Plus 的 LambdaQueryWrapper 实现:
LambdaQueryWrapper<WasteItem> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), WasteItem::getName, keyword) .eq(WasteItem::getStatus, 1); IPage<WasteItem> page = wasteItemService.page(new Page<>(current, size), wrapper);第二个场景是投放点按距离排序。MySQL8.0 可以直接用 ST_Distance_Sphere 函数计算经纬度距离,SQL 里直接按距离排序,省去后端逐条计算的开销:
SELECT id, name, address, longitude, latitude, ST_Distance_Sphere(point(longitude, latitude), point(#{lng}, #{lat})) AS distance FROM recycle_point WHERE status = 1 ORDER BY distance ASC LIMIT 0, 20;这个函数返回的是米,配合前端地图打点,用户能直接看到离自己最近的 5 个投放点,体验比那种全量返回让前端算距离的方案好太多。
3.5 接口设计:统一返回结构 + JWT 登录认证
前端调用后端接口时,最忌讳每个接口返回结构不一样。我定义了一个 R 类统一返回 code、message、data。所有 Controller 返回类型都写成 R,这样前端 axios 拦截器只用判断 code 是否为 200,就能统一处理成功和失败。
登录认证这套系统我用的是 JWT。用户登录成功后,后端返回 token,前端存到 localStorage 并放入请求头。后端写一个 JwtInterceptor 拦截器,校验 token 中携带的用户 ID 是否正确、是否过期。管理员和普通用户的路由权限在前端控制,后端接口只做最基础的登录校验。如果要更严谨,可以在接口上增加 @RequireRole 注解做角色校验,这个后续扩展再加也不迟。
4. 前端实现:Vue3 + Vite + Element Plus 从搭项目到页面交互
4.1 Vue3 工程初始化和配套依赖选择
前端我用的 Vite 构建,创建命令很简单:
npm create vite@latest waste-web -- --template vue cd waste-web npm install接下来按模块装依赖。UI 组件库用的 Element Plus,状态管理用的 Pinia(Vue3 里 Vuex 已经不是最佳选择了),路由用的是 Vue Router 4,HTTP 请求用的 axios。地图展示我接的是高德地图 JSAPI,按投放点经纬度初始化地图然后批量打点即可,这个对接逻辑不算复杂。
装依赖的时候提醒一句,Element Plus 全量引入虽然打包体积大一些,但开发省心。如果按需引入,需要额外配置 unplugin-auto-import 和 unplugin-vue-components 插件,配置不当组件样式会丢失。我项目里为了稳妥,直接用了全量引入,演示和课设场景完全够用。
4.2 状态管理和接口封装:告别到处写 fetch
Vue3 开发管理系统,如果把用户信息、token 散落在各个组件里,维护起来就是灾难。我统一放到 Pinia 的 store 里管理。store 里存了 token、用户信息、侧边栏折叠状态。登录成功后调接口拿到用户信息,setUser 存进 store;退出登录时清空 store 和 localStorage 里的数据。
axios 封装我单独成 request.js 文件,设置了 baseURL、超时时间、请求拦截器和响应拦截器。请求拦截器里从 localStorage 取出 token 并放到 Authorization 头里;响应拦截器里统一处理后端返回的 code,如果不是 200,直接弹出 ElMessage 提示。这样每个页面调用接口时,两行代码就能拿到数据,不会出现一处一个样的问题。
4.3 几个核心页面:垃圾查询、地图打点、积分商城
垃圾查询页面是用户端使用频率最高的一页。顶部放搜索输入框,下面是分页列表,卡片展示垃圾名称、类别标识、分类依据说明。搜索时防抖处理,用户输入停顿 500 毫秒再发请求,避免每敲一个字母就请求一次后端。
投放点地图页用的是高德地图。页面初始化时通过 navigator.geolocation 获取浏览器定位,然后请求后端接口拿到投放点列表,循环用 AMap.Marker 打点。点开标记弹窗显示投放点名称、地址、开放时间。这里有个细节,地图组件初始化一定要等 DOM 渲染完成后再执行,我在 onMounted 里用了 nextTick,不然有时地图容器宽度是 0,地图渲染出来一片灰。
积分商城页面分两块:左侧展示用户当前积分余额,右侧是商品卡片列表。兑换按钮点击后调后端兑换接口,后端判断积分是否足够、库存是否充足,通过校验就扣积分、减库存、写兑换记录。前端收到成功结果后重新拉取用户积分和商品列表。
4.4 前端路由权限控制怎么做
路由我分成两类:公共路由和需要登录的路由。登录验证用路由守卫判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })需要登录才能访问的页面在路由配置里加 meta: { requiresAuth: true }。管理员页面额外加 meta: { roles: ['admin'] },打开页面时校验当前用户的角色,没有权限直接跳转到首页并提示无权限。这套方案虽然不能防住真正的安全攻击,但对业务系统来说足够清晰、易扩展。
5. 部署联调与常见问题排查实录
5.1 本地开发和前端联调时的跨域问题
我在前端开发环境用的 Vite dev server,默认端口是 5173,后端跑在 8080,跨域问题根本绕不开。前端 vite.config.js 里配置一下 proxy 就能解决:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }这样就相当于把前端发起的 /api 请求代理到后端,不带 CORS 配置也能联调。如果你非要用跨域而不是代理,那就在后端加一个 CorsFilter 允许所有来源,但生产环境建议用代理方式,更安全也更快。
5.2 上线部署环境搭建与打包配置
服务器环境我用的是最常规的方案:后端 jar 包 + Nginx 静态站点。先在服务器装好 JDK8 和 MySQL8,导入数据库脚本后修改数据源连接,然后打包:
mvn clean package -DskipTests nohup java -jar waste-server.jar > server.log 2>&1 &前端打包:
npm run build打包产物在 dist 目录,把它拷到 Nginx 的 html 目录下,再加一个反向代理指向后端接口:
server { listen 80; server_name yourdomain.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/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意 try_files 这一行必须写。因为前端用的是 history 路由,不配 try_files 时刷新首页没问题,但刷新二级页面会直接 404,这是 Vue3 部署最经典的坑。
5.3 实际运行中的高频问题速查表
我把项目开发和部署过程中遇到的高频问题整理成了一张表,方便你以后排查。这些问题基本每个 SpringBoot2 + Vue3 项目都会撞见。
| 问题现象 | 原因分析 | 解决方案 |
|---|---|---|
| MySQL 连接失败,报 SSL 错误 | URL 未设置 useSSL=false | 连接串加 useSSL=false |
| 后端时间比实际早/晚 8 小时 | 未设置时区或设置不一致 | URL 加 serverTimezone=Asia/Shanghai |
| MySQL 认证失败 1251 | MySQL8 默认认证插件不兼容 | 改 mysql_native_password 或升级客户端 |
| MyBatis-Plus 分页 total 为 0 | 未配置分页插件 | 注册 PaginationInnerInterceptor |
| 前端打包后刷新 404 | 未配置 try_files | Nginx 加 try_files 回退 |
| Vue3 响应式数据页面不更新 | 用了赋值方式替换整个 reactive 对象 | 改用 ref 或对对象逐字段赋值 |
| 前后端联调跨域请求失败 | 端口不同未做代理 | Vite proxy 或后端加 CORS |
| 上传图片后访问 404 | 上传目录未做静态资源映射 | 自定义 WebMvcConfigurer 映射上传路径 |
5.4 两个容易忽略的编码细节
Vue3 写业务时最常被坑的是响应式。reactive 对象如果用Object.assign或直接给整个对象赋值,响应式会丢失。解决办法是推荐用 ref 来存整体对象,取值时加 .value,修改时直接替换 value。我在写用户信息更新接口时就改了好几次才彻底弄清楚这个边界。
后端还有一个容易被忽略的点是 Jackson 时间序列化。默认情况下 LocalDateTime 会被序列化成一大串数组,前端接收后没法直接展示。我统一在配置文件里加了 Jackson 时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8加完之后前后端传参和显示就完全一致,不会再出现前端看到一堆数字的情况。
6. 从这套系统还能延伸到哪些方向
做完这套系统之后,我最大的体会是:一个完整的管理系统,真正的难点不在单点技术,而在把这些技术有机组合起来,保证每个环节都能正常运行。垃圾类别管理让 MyBatis-Plus 帮你省事,地图打点考验组件的生命周期处理,积分流水让你重新审视数据模型设计,部署上线又逼着你去理解 Nginx 和网络问题。这套系统做完,你对 SpringBoot2 和 Vue3 的理解会进入另一个层次。
如果你想继续扩展,我建议优先做这几个方向:第一,对接 uni-app 写一个微信小程序版本,地图打点和垃圾查询逻辑基本可以复用,一套代码多端运行;第二,把积分商城扩展成完整的 签到 + 任务 + 兑换 闭环,加入定时任务统计每日分类数据;第三,给后端接入 Redis 做缓存,热点垃圾类别查询不走数据库,性能能明显提升;第四,用 WebSocket 做实时通知,反馈处理完成后主动推送消息给居民端,而不是让用户反复刷新页面。
我实际做这套系统时,个人最深刻的教训就是:不要小看数据库设计,更不要为了省事把积分这种东西直接存一个字段。前期多花点时间把表结构、中间表、流水表设计合理,后面代码少写一半,bug 少一大半。这套系统核心围绕“分类数据 + 投放点 + 积分激励”打转,把这几个链路跑顺了,后续加什么功能都顺手。