最近帮一所高校的实践基地做了一个校园志愿者管理系统,技术栈选了 SpringBoot + Vue 这套经典组合。做完之后我最大的感受是:这类系统的需求非常明确——志愿者注册登录、管理员发布活动、志愿者报名、活动签到、时长认定、数据统计,功能边界清晰,数据流转直观,特别适合作为前后端分离项目的完整实战。
但真正做起来会发现,难点从来不是 CRUD,而是怎么把业务状态管清楚,怎么处理并发报名,怎么让前端权限和后端权限对齐。这篇文章就把整套系统的设计思路、数据库结构、核心实现、部署过程以及我踩过的坑完整拆开讲一遍,希望能给正在做类似项目或者准备把 SpringBoot + Vue 练熟的同学一些参考。
1. 项目整体设计:为什么是 SpringBoot + Vue 这一套
1.1 需求拆解:先把“志愿者管理”说清楚
任何一个系统在动手之前,最重要的就是先把“究竟要管理什么”想明白。我当时拿到需求之后,没有直接开写代码,而是先画了一条业务主线,把所有的功能点都挂到这条线上。
这个系统里一共有两类角色:志愿者和管理员。志愿者的核心诉求是找到活动、报名活动、按时签到、在个人中心看到自己的累计时长和排名。管理员的核心诉求是发布活动、审核报名、认定时长、查看统计报表。围绕这条主线,系统拆成了下面几个模块:
- 用户模块:注册、登录、个人信息维护、角色区分;
- 活动模块:活动发布、活动列表、活动详情、状态自动切换;
- 报名模块:志愿者报名、取消报名、管理员查看报名名单;
- 时长模块:签到记录、时长认定、审核流转;
- 统计模块:个人时长汇总、活动参与统计、志愿者时长排行。
可能很多人会想,一个校园志愿者系统而已,做成这样是不是太复杂了?我实际做完的感受是:每个模块单看都不难,难在模块之间数据怎么衔接。比如活动状态变化之后,报名入口要不要关闭?活动结束之后,时长记录从哪里来?这些问题必须在动手之前就想清楚,否则写代码的时候会反复返工。
1.2 技术选型背后的理由
选型这件事,网上有无数种争论,但落到这个项目上,我当时的判断很简单:用最主流、最稳妥、社区资料最丰富的组合。
前端用 Vue 3 + Vite + Element Plus,后端用 Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0,认证用 JWT。这套组合的好处有几点:Vue 3 的 Composition API 写起来比 Options API 清爽很多,组件复用逻辑更自然;Element Plus 的表格、表单、弹窗组件很完善,后台管理界面基本就是“拼组件”的过程;Spring Boot 的生态成熟,遇到问题搜一下基本都有答案,对初学者足够友好。
我没有过早引入 Redis 之类的中间件。token 存内存、统计直接查 MySQL 对于这个项目的规模完全够用。引入中间件的前提是“当前业务真的需要”,而不是“用起来显得高级”,这一点对学习阶段的人来说尤其重要。
1.3 目录结构与模块划分
前端目录我用了比较标准的划分方式:
src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # 状态管理(Pinia) ├── views/ # 页面 │ ├── admin/ # 管理员端页面 │ └── volunteer/ # 志愿者端页面 └── utils/ # 工具函数后端目录按 Controller -> Service -> Mapper 经典三层结构组织,Entity 对应数据库表,DTO 用于接收前端参数,VO 用于返回给前端的数据。这里有一个容易被忽略的点:前端传过来的参数不应该直接绑到 Entity 上,比如注册接口的用户密码需要加密,活动发布接口需要校验时间是否合法,这些逻辑放在 Service 层里处理更合适,Controller 保持薄薄一层只做参数接收和结果返回。
2. 数据库设计:先把表结构想明白
2.1 核心表与字段设计
数据库设计是整个项目的基石。我常常跟朋友说,如果表结构设计得好,后面的代码怎么写都顺;如果表结构一开始就挖了坑,后面全都是填坑的活。这个项目我设计了以下核心表:
用户表(sys_user):主键、用户名、密码、昵称、手机号、头像、角色、状态、创建时间。
志愿者扩展表(volunteer_profile):主键、用户 ID、学号、学院、专业、年级、累计时长。为什么不直接把这些字段塞到 sys_user 里?因为管理员不需要学号、学院这些信息,把扩展字段单独拆出来,可以避免用户表字段过多,也符合“字段归属其真实业务”的原则。
活动表(activity):主键、标题、内容、地点、开始时间、结束时间、最大人数、当前报名人数、状态、发布人、创建时间。
报名表(activity_signup):主键、活动 ID、用户 ID、报名时间、签到时间、状态。
时长认定表(service_hours):主键、用户 ID、活动 ID、时长值、状态、审核人、审核时间、备注。
关键字段给出一些实际设计参考:
| 字段名 | 类型 | 说明 |
|---|---|---|
| activity.status | tinyint | 0 未开始,1 进行中,2 已结束,3 已取消 |
| activity_signup.status | tinyint | 0 已报名,1 已签到,2 已取消 |
| service_hours.status | tinyint | 0 待审核,1 已通过,2 已驳回 |
| service_hours.hours | decimal(4,1) | 支持 1.5 小时这类数据 |
2.2 状态机设计:让数据流转可控
数据库里最值钱的设计,其实是状态字段的状态流转。如果不做状态控制,业务逻辑会出现很多“脏数据”,而排查脏数据的成本远高于一开始设计状态机的成本。
活动状态的设计我用了最简单的四态模型。当管理员创建一个活动时,状态默认为“未开始”。系统里用一个定时任务,每分钟扫描一次活动表,把当前时间大于开始时间且小于结束时间的活动置为“进行中”,把当前时间大于结束时间的活动置为“已结束”。同时管理员也可以手动取消活动。
报名状态则根据活动状态联动。只有“未开始”的活动允许报名,活动一旦进入“进行中”状态,报名入口关闭。这个逻辑前端要控制,比如按钮置灰提示“活动已开始,无法报名”;后端接口也要控制,Service 层先查活动状态,不满足条件直接抛业务异常。
时长认定这里我走了审核流。志愿者签到之后,并不是立刻获得时长,而是生成一条待审核记录,管理员确认服务属实之后才把时长写入志愿者的累计字段。这么做看起来多了一步,但实际很有必要——线下活动经常有人报了名但不来、签到之后提前走,审核机制给管理员留了把关的空间。
2.3 时长与统计字段的埋点
志愿时长字段我用的是 decimal(4,1),也就是支持 0.5 这样的精度。为什么不用 int?因为实际场景里经常出现“服务了 3.5 小时”的情况;为什么不用 float?因为浮点数在数据库里存储会有精度问题,统计求和时容易出现 3.499999 这种尴尬结果。decimal 能精确到小数点后一位,对本项目足够。
统计逻辑我没有单独建统计表,而是直接通过 SQL 聚合查询实现。个人累计时长在志愿者扩展表里维护一个冗余字段,每次时长认定通过时同步累加。这样个人中心展示时不需要临时聚合计算,查询效率极高。排行榜则用一条 GROUP BY 查询完成。
3. 后端实现:SpringBoot 的核心业务逻辑
3.1 登录认证与权限控制
登录认证这块,我选了 JWT + Spring Security 的方案。流程是这样的:用户提交用户名和密码,后端校验通过后生成一个 token,token 里面包含用户 ID 和角色信息,返回给前端。前端后续请求在 Header 里带上这个 token,后端过滤器解析 token、识别用户身份和角色,再决定这个请求能不能访问。
选择 JWT 而不是传统的 Session,核心原因是前后端分离之后,后端不再维护会话状态。Session 依赖 Cookie 和浏览器,前后端分离后接口可能被小程序、移动端等不同客户端调用,JWT 这种无状态方案天然适配这种场景。而且 JWT 里直接携带角色信息,权限判断不需要每次都查数据库。
实现上我写了一个 JwtAuthenticationFilter,继承 OncePerRequestFilter,在 doFilterInternal 里读取请求头中的 token,解析成功之后把用户信息放入 SecurityContext。为了方便权限控制,我自定义了一个要求管理员权限的注解,在 Controller 的方法上用注解标记,再用 Spring Security 的 @PreAuthorize 校验角色。
有一个容易被坑的细节:Spring Security 默认会把所有请求拦截下来,必须显式放行登录接口、注册接口和一些静态资源,否则前端调登录接口会直接 401。
3.2 活动的发布与报名
活动发布接口逻辑相对直接:接收前端传的活动参数,先做合法性校验,比如开始时间必须晚于当前时间、结束时间必须晚于开始时间。校验通过之后插入数据。这里我对时间参数做了一次统一处理,要求前端传“开始时间”和“结束时间”两个字段,后端再根据这两个时间计算出活动时长,不需要前端额外传。
报名接口是这个系统里最值得仔细写的接口。我当时的实现逻辑是这样:
- 根据活动 ID 查出活动,判断状态是否为“未开始”;
- 判断当前报名人数是否小于最大人数;
- 判断当前用户是否已经报名过这个活动;
- 校验通过后插入报名记录,同时把活动表的已报名人数加一。
这几个步骤里有明显的并发风险,我在后面的排查章节会细讲。
取消报名这个操作,我要求只有在活动“未开始”的状态下才允许取消。取消时把报名记录的状态改成“已取消”,同时把活动的已报名人数减一。这里同样需要保证数据一致性。
3.3 时长认定与定时任务
时长认定的流程是:志愿者在活动进行中点击“签到”,系统检查当前时间是否在活动的时间范围内,然后生成一条待审核时长记录。管理员在后台看到待审核列表,确认无误后点击通过,系统把时长累加到志愿者的累计时长里,同时把时长记录状态改为“已通过”。
为了把活动状态自动化,我写了一个定时任务:
@Scheduled(fixedRate = 60000) public void updateActivityStatus() { LocalDateTime now = LocalDateTime.now(); // 将已开始的活动置为进行中 activityMapper.updateStatusByStartTime(now, 1); // 将已结束的活动置为已结束 activityMapper.updateStatusByEndTime(now, 2); }用 @Scheduled 注解加 fixedRate = 60000 表示每 60 秒执行一次。这个任务本身逻辑不复杂,但实现了“系统自动感知时间”的核心能力,不需要管理员每天手动去改活动状态。
统计模块我用了几条 SQL 来实现。个人时长直接查志愿者扩展表;活动参与统计按活动分组统计报名人数;时长排行榜按用户分组后对时长求和再倒序排列。索引设计上要注意给报名表的 activity_id 和 user_id 建联合索引,给时长表的 user_id 建普通索引,否则数据量上来之后查询会慢。
4. 前端实现:Vue3 + Element Plus 的几个关键点
4.1 路由与权限控制
前端路由我分成了两块,一块是志愿者端页面,一块是管理员端页面。志愿者端包括活动列表、活动详情、我的报名、个人中心;管理员端包括活动管理、报名审核、时长管理、数据统计。
权限控制靠的是路由守卫加角色判断。前端在用户登录成功之后,把 token 和角色信息存到 localStorage 和 Pinia 里。路由配置时给每个需要权限的路由加上 meta.roles 字段,然后在全局前置守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path === '/login') { next() } else if (!token) { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(role)) { next('/403') } else { next() } })这个逻辑很直白:没登录跳登录页,登录了但不是对应角色就跳 403 页面。前端做权限控制属于体验层面的优化,真正的安全校验必须依赖后端。前端权限只决定“看不看得到入口”,后端权限决定“能不能调通接口”。
4.2 axios 请求封装
接口请求封装我统一放在 src/api 目录下,用 axios 实例统一配置 baseURL 和超时时间。请求拦截器负责每次请求自动带上 token,响应拦截器负责统一处理错误码。
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') localStorage.removeItem('role') router.push('/login') } return Promise.reject(error) } )这里的 401 处理是一个很容易被忽略的细节。token 过期之后,后端会返回 401,前端如果不在拦截器里统一处理,用户就会一直停留在当前页面,所有接口都报错,体验非常差。统一跳转登录页之后,问题会清晰很多,但我自己在实际测试中也遇到了一个跳转死循环的问题,后面排查章节会专门说。
4.3 后台管理的表格与表单
管理员端的页面大量使用了 Element Plus 的 el-table 和 el-form。活动管理页面的做法是:页面加载时调用列表接口,拿到活动数据后渲染到表格里,提供搜索框、状态筛选、分页组件。
比较值得说的是编辑弹窗。管理员点击编辑按钮时,需要把当前行的数据回填到表单里。但数据库里的时间字段是 LocalDateTime,传到前端变成字符串,Element Plus 的日期时间选择器需要的是 Date 对象。这里我加了一步转换,把字符串解析成 Date,提交时再转回字符串,避免提交时格式化报错。
表单校验也做了不少。活动表单里的开始时间和结束时间需要联动校验:开始时间必须晚于当前时间,结束时间必须晚于开始时间。Element Plus 的表单校验支持自定义 validator,我写了两个自定义校验函数,实测下来效果不错。
5. 联调与部署:从本地到线上
5.1 跨域与联调
前后端分离开发必然遇到跨域问题。本地开发时前端服务跑在 5173 端口,后端接口在 8080 端口,浏览器默认禁止跨域请求。我在后端写了一个全局 CORS 配置类,允许指定来源、指定请求方法,同时允许携带凭证。
不过开发阶段的 CORS 配置,在生产环境通常不会直接用。生产环境我建议用 Nginx 做反向代理,把前端请求统一转发到后端服务,这样浏览器请求的是同一个域名,不会产生跨域。前后端分离部署其实是“物理分离、逻辑同源”,前端静态资源由 Nginx 托管,接口请求通过 /api 路径转发给后端服务。
5.2 打包部署
部署流程我整理成了标准的步骤。前端执行 npm run build,构建产物生成到 dist 目录,把 dist 目录里的内容复制到服务器 Nginx 的 html 目录下。后端执行 mvn clean package 打成 jar 包,用 java -jar 启动。
生产环境的 Nginx 配置重点关注一个地方:前端路由如果是 history 模式,刷新页面时 Nginx 会尝试按路径去找对应的静态文件,找不到就返回 404。解决办法是配置 try_files:
location / { try_files $uri $uri/ /index.html; }/api 路径则单独配置反向代理。部署时我建议区分开发配置和生产配置,通过 Spring Boot 的 profile 机制切换。这样数据库地址、日志级别、文件上传路径这些配置不至于写到代码里。
6. 常见问题与排查实录
6.1 数据库与时间的那些坑
第一个遇到的问题是 JSON 序列化之后时间差了 8 个小时。后端返回的 LocalDateTime 是北京时间,前端显示的却是 UTC 时间。排查之后发现是 Jackson 的时区默认值不是东八区。解决方式是在配置里设置:
spring: jackson: time-zone: GMT+8数据库连接也要注意时区参数,连接 MySQL 时 URL 上加 serverTimezone=Asia/Shanghai,否则会报警告甚至读出来时间不对。
第二个坑是数据库字段命名。我在设计表的时候一开始用了 description、status 这种词当字段名,虽然这些不是 MySQL 的保留字,但某些版本会有隐式冲突。建议字段名统一用下划线风格,比如 activity_desc、signup_status,避免踩坑。
6.2 并发报名与超卖问题
活动报名这里有一个几乎每个做这类系统的人都会遇到的经典问题:先查人数再插入,两个请求同时进来,查询时都发现人数未满,然后都插入了报名记录,最后人数超了。
我当时的解决方案是双保险。第一重保险:在报名表上建一个联合唯一索引,字段是活动 ID 加用户 ID,同一个用户对一个活动只能有一条有效记录。第二重保险:用一条带条件的更新语句来控制人数:
UPDATE activity SET signed_count = signed_count + 1 WHERE id = #{activityId} AND signed_count < max_people这条 SQL 的影响行数如果是 0,说明人数已满,直接返回“活动名额已满”。配合事务一起使用,能够最大程度避免超卖问题。
这里要记住一点:事务要放在 Service 层方法上,并且方法要被外部调用,否则 Spring 的代理机制不会生效。自己调自己类的另一个方法时,事务注解是不生效的,这个问题我排查过很久。
6.3 前端登录死循环
还有一个很典型的问题:token 过期之后,前端拦截器检测到 401 就跳转登录页,但跳转触发路由守卫,路由守卫发现没有 token 又跳 /login,登录页本身再次触发接口请求,接口返回 401,拦截器又跳 /login,形成死循环。
解决方式是给路由守卫加一个判断:如果当前已经在登录页,就不再跳转。另外 /login、/register 这类免登录页面应该放进白名单,在拦截器里单独放行。
6.4 密码安全与接口幂等
密码存储必须用加密算法,明文存库是绝对不允许的。我用的 BCrypt 加密,Spring Security 自带 BCryptPasswordEncoder,每次加密生成的哈希值不同,但校验时可以匹配,安全性比 MD5 高得多。
接口幂等问题也是实际开发中会遇到的。比如用户连续点击两次报名按钮,同时发出两个请求,第一次报名成功,第二次会因为唯一索引和状态判断被拦截。但如果活动状态已经变成“进行中”,后端就应该保证这种请求一定报错,不会产生脏数据。
7. 踩过几次坑之后的个人体会
这套系统从设计到部署我前前后后改了三个版本,最深的体会是:表结构设计决定系统的天花板,状态机设计决定系统的稳定性,权限设计决定系统的安全性,而这三者在动手写代码之前就必须全部想清楚。
我在第一版里把活动状态完全丢给前端控制,后端接口只做增删改查,结果上线测试时发现活动已经开始还能报名,管理员手动改状态还容易改错,数据一乱排查起来非常痛苦。后来把状态机引入到后端,加了一个定时任务自动切换状态,这类问题才算根治。
第二版开始重视并发和幂等,给报名表加了唯一索引,控制了活动名额更新方式,虽然这个系统的并发量不大,但养成这个习惯之后,做其他项目时也少踩了很多开发后期才会发现的坑。
给正在做类似项目的同学一个建议:先用一天时间把表结构设计好,把所有状态流转画成表格贴在旁边;再动手写代码,每天保持前后端同步推进。如果遇到拿不准的需求,优先找业务方确认,而不是猜。这套思路不仅适用于志愿者管理系统,换成一个课程管理系统、社团管理系统,核心设计方法都是一样的。