社团管理系统开发:权限模型、状态机与并发控制的工程实践
简介:一份基于SpringBoot构建的社团管理系统完整源码包,采用B/S与MVC分层架构,运行于Windows环境,面向计算机相关专业的在校学生、教师或企业开发者,可作为毕业设计、课程项目或SpringBoot入门进阶的参考案例。压缩包共827个文件、约15.95MB,涵盖138个Java后端逻辑、50个Vue组件、50个HTML页面、153个JavaScript文件、44个CSS样式、162个SVG图标以及XML配置、工程构建脚本等,前端界面与后端服务分离,目录结构清晰。目前已有477人学习过。源码经过实际运行测试,功能可用,覆盖登录、成员管理、活动发布等典型模块的前后端实现;内含一键构建与启动脚本,方便搭建本地调试环境,适合在此基础上理解代码、自行修改功能并扩展模块,作为学习参考而非直接照搬。
1. 社团管理系统不是堆 CRUD:你首先要定的三个边界问题
社团管理系统这类项目,很多团队一开始会当成“会员登记 + 活动发公告”来做,结果上线后被退社、换届、负责人权限交接、活动学分认定这些真实流程反复折腾。它真正要解决的不是信息登记,而是三类边界问题:谁是社团数据的拥有者,活动审批到什么粒度,以及成员身份在“申请—入社—退社—换届”里如何只保留一条有效记录。这篇笔记适合正在规划这类系统的开发者和做毕业设计的学生,我会从数据库模型、权限状态机到具体踩坑,把一套能跑完一整个学年运转的方案讲清楚。
2. 技术选型与整体架构:为什么这套体量不需要微服务
2.1 单体还是微服务:先看清楚你的用户量和运维能力
社团管理系统的并发特征非常明显:平时在线人数几十人,百团大战纳新那几天可能涌进来上千人同时注册和申请。这种尖峰压力,用单体应用加数据库连接池完全能扛,真正要防的是“架构撑场面”带来的部署复杂度。
我一般会建议选单体应用,按包结构拆模块就够了。后端用 Spring Boot 3.x,ORM 选 MyBatis-Plus,权限用 Sa-Token,数据库 MySQL 8.x。前端用 Vue 3 + Element Plus 做管理后台,移动端直接用适配好的网页,不必单独开发 App。这套组合的好处是招人容易、排错链路短,一台 4 核 8G 的服务器就能带起一个万人规模的学校使用。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot3-starter</artifactId> <version>1.39.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>依赖里最需要留意的是 MyBatis-Plus 和 Spring Boot 3 的兼容性,必须用 3.5.3 以上版本,否则分页插件会直接失效。Sa-Token 用官方提供的 spring-boot3 starter,不要自己去拼旧版拦截器。
2.2 为什么不用 Spring Security:权限模型决定的
社团管理系统的角色体系跟企业级系统不一样:学生既是普通用户,又可能是某个社团的干事,同时还能跨社团参加活动。你要管理的不是简单的 ROLE_USER、ROLE_ADMIN 二元结构,而是“用户—社团—角色”三元组。
Spring Security 在这类场景下能写,但要为每个社团动态生成权限标识符,配置粒度很别扭。Shiro 又太久没大版本更新。Sa-Token 的注解鉴权和持久层会话扩展在这里更贴合:登录后拿到一个 token,后端用@SaCheckPermission("club:activity:approve")就能完成每个社团独立权限校验,权限数据从数据库动态加载,不需要写一堆 configurer。
2.3 工程目录与请求链路
club-system ├── controller // 只做参数接收和响应封装 ├── service // 业务逻辑、事务边界 ├── mapper // MyBatis-Plus 接口 ├── entity // 数据库实体 ├── dto // 入参对象,不做数据库字段透传 ├── vo // 出参对象,避免把密码等字段漏给前端 └── config // Sa-Token 拦截器、MyBatis-Plus 分页插件一个我坚持的做法:Controller 层禁止直接调用 Mapper,所有跨表操作必须在 Service 层完成并用@Transactional包住。社团管理系统里大量业务是“创建活动 + 生成通知 + 记录日志”这种组合动作,Service 层不守住事务边界,后面数据对不上时哭都来不及。
请求链路是前端拿到 Sa-Token 返回的 token 后,每次请求放在Authorization请求头里。Sa-Token 拦截器统一校验登录态,注解检查角色权限。业务异常统一抛ServiceException,由全局异常处理器转成统一 JSON 结构。这条链路顺手治好了我之前在一个旧项目里看到的“每个接口自己 try-catch 返回 Map”的毛病。
3. 数据库设计:先把 12 张核心表的关系理清,再说写接口
3.1 用户、社团、成员三者的关系模型
社团管理系统的核心关系是:一个用户属于多个社团,在一个社团里只有一个有效身份。如果你只做 user 和 club 两张表加一个中间表 club_member,很快会发现两个问题:同一用户在不同社团里的角色不同(A 社社长、B 社普通成员),以及用户退出 A 社后加入 B 社的历史记录不能被删掉。
所以最关键的切入点是把“用户与社团的关系”视作一条带状态的生命周期记录,而不是一个静态的多对多连接。我的做法是把 club_member 表加四个核心字段:member_role、join_status、apply_time、quit_time。join_status用 0 待审核、1 已通过、2 已拒绝、3 已退出四种状态表示,退社不删记录,只把状态改为 3 并写入退出时间。这样后续做社团荣誉档案、任职证明导出时都有依据。
3.2 核心表结构:从建表 SQL 看字段设计取舍
CREATE TABLE `club` ( `id` bigint NOT NULL AUTO_INCREMENT, `club_name` varchar(50) NOT NULL COMMENT '社团名称', `category` varchar(20) DEFAULT NULL COMMENT '社团分类:学术/文艺/体育', `president_user_id` bigint NOT NULL COMMENT '当前社长用户ID', `max_member_count` int DEFAULT 100 COMMENT '成员上限', `intro` varchar(500) DEFAULT NULL COMMENT '社团简介', `audit_status` tinyint NOT NULL DEFAULT 0 COMMENT '社团创建审核:0待审 1通过 2拒绝', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社团表';CREATE TABLE `club_member` ( `id` bigint NOT NULL AUTO_INCREMENT, `club_id` bigint NOT NULL, `user_id` bigint NOT NULL, `member_role` tinyint NOT NULL DEFAULT 1 COMMENT '角色:1成员 2干事 3负责人', `join_status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待审 1已通过 2已拒绝 3已退出', `apply_reason` varchar(200) DEFAULT NULL COMMENT '申请理由', `reject_reason` varchar(200) DEFAULT NULL COMMENT '拒绝原因', `apply_time` datetime DEFAULT NULL, `approve_time` datetime DEFAULT NULL, `quit_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_club_user` (`club_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社团成员关系表';CREATE TABLE `club_activity` ( `id` bigint NOT NULL AUTO_INCREMENT, `club_id` bigint NOT NULL, `title` varchar(100) NOT NULL, `content` text, `activity_time` datetime NOT NULL COMMENT '活动开始时间', `end_time` datetime NOT NULL COMMENT '活动结束时间', `location` varchar(100) DEFAULT NULL, `max_enroll_count` int NOT NULL DEFAULT 50, `enroll_count` int NOT NULL DEFAULT 0 COMMENT '当前报名人数', `review_status` tinyint NOT NULL DEFAULT 0 COMMENT '审核:0待审 1通过 2拒绝 3已结束', `credit_value` decimal(4,1) DEFAULT 0.5 COMMENT '学分/综测加分', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社团活动表';这三张表是地基。club_member里的idx_club_user联合索引必建,因为“某个社团的所有成员列表”和“某个用户加入的社团列表”这两类高频查询都走这个索引。club_activity的activity_time要单独建索引,社团管理系统的列表页经常按时间范围筛选活动,全表扫描在数据量到五万条左右就开始拖慢接口。
3.3 状态字段到底用 tinyint 还是 varchar
这是社团管理系统设计里被问得最多的问题,我的建议是用 tinyint 加注释,不要用 varchar。原因不是省空间,而是状态值一旦进入业务逻辑,就会写成if (status == 1)这种硬编码判断。用 varchar 存“WAIT_APPROVE”看似可读性高,实际代码里全是魔法字符串,前后端协议还要多一层枚举转换,排查问题时多一道翻译工序。
但 tinyint 有一个配套要求:Java 实体类里必须用枚举而不是 Integer 接收。
@Getter @AllArgsConstructor public enum JoinStatus { PENDING(0, "待审核"), APPROVED(1, "已通过"), REJECTED(2, "已拒绝"), QUIT(3, "已退出"); private final Integer code; private final String desc; }MyBatis-Plus 默认枚举映射在 3.5.x 版本以后可以直接用@EnumValue注解指定,写查询条件时用枚举类型的实体属性,代码可读性能上去一个台阶。如果图省事全用 Integer,等业务复杂到你分不清 0、1、2 各代表什么,翻车就只是时间问题。
3.4 审批记录单独建表还是靠状态字段覆盖
社团运营里最磨人的是“活动审核被拒绝后修改再提交”,如果只靠一个review_status字段,审核意见只有一条,老师和社长都看不到历史审批轨迹。我的做法是加一张club_review_log表,每次审核变动都插入一条记录。
CREATE TABLE `club_review_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `biz_type` tinyint NOT NULL COMMENT '业务类型:1入社 2活动 3社团创建', `biz_id` bigint NOT NULL COMMENT '业务主键', `from_status` tinyint NOT NULL, `to_status` tinyint NOT NULL, `opinion` varchar(300) DEFAULT NULL COMMENT '审批意见', `reviewer_id` bigint NOT NULL COMMENT '审批人', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录表';这张表平时写多读少,但到了期末核对“谁批准了这场活动”时是唯一的证据链。开发时顺手建了它,能在后期省掉很多扯皮。
4. 核心业务接口实现:入社申请、活动发布、签到考勤这条主链路怎么落地
4.1 登录与角色鉴权:Sa-Token 的注解要用对位置
社团管理系统的登录和普通管理系统登录差别不大,但有一个细节:登录返回的 token 里不要存角色列表,角色是动态变化的,存进去以后改角色还要重新登录才能生效。正确做法是登录只认用户 ID,每次请求鉴权时去数据库读该用户在当前社团的角色。
@PostMapping("/login") public Result<String> login(@RequestBody @Valid LoginDTO dto) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getAccount, dto.getAccount()); SysUser user = userMapper.selectOne(wrapper); if (user == null || !BCrypt.matches(dto.getPassword(), user.getPassword())) { throw new ServiceException("账号或密码错误"); } StpUtil.login(user.getId()); return Result.ok(StpUtil.getTokenValue()); }登录接口只用密码加密校验和 token 签发两件事。BCrypt 是 Spring Security 里带出来的加密工具,单独引一个spring-security-crypto依赖就能用,不要自己写 md5 加盐,社团系统里学生密码普遍很弱,MD5 彩虹表一查就破。
鉴权注解要放在 Controller 方法上,不要放在 Service 接口上。放在 Service 上的问题是:一个 Service 方法可能被多个 Controller 复用,但不同入口的权限要求不一样。放在 Controller 层才能精确表达“谁能通过这个 URL 调这个动作”。
@SaCheckPermission("club:activity:approve") @PostMapping("/approve") public Result<Void> approve(@RequestBody ApproveDTO dto) { activityService.approve(dto); return Result.ok(); }Sa-Token 的权限码是从StpInterface实现里动态返回的。我实现时用社团 ID 拼权限码,比如club:12:activity:approve表示 12 号社团的活动审批权。这样同一个学生,在 A 社是干事可以审批,在 B 社是普通成员就不能审批,一套模型吃遍所有角色场景。
4.2 入社申请与审批状态机:幂等和事务缺一不可
入社申请最常见的坑是一个用户重复点击提交,生成多条 PENDING 记录,最后社长审批通过了两条,成员列表里出现重复身份。
@Transactional(rollbackFor = Exception.class) public void applyJoin(Long clubId, String reason) { Long userId = StpUtil.getLoginIdAsLong(); LambdaQueryWrapper<ClubMember> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ClubMember::getClubId, clubId) .eq(ClubMember::getUserId, userId) .in(ClubMember::getJoinStatus, 0, 1); Long count = memberMapper.selectCount(wrapper); if (count > 0) { throw new ServiceException("你已在申请或已在社团中"); } ClubMember member = new ClubMember(); member.setClubId(clubId); member.setUserId(userId); member.setJoinStatus(JoinStatus.PENDING.getCode()); member.setApplyReason(reason); member.setApplyTime(LocalDateTime.now()); memberMapper.insert(member); // 记录审批日志 reviewLogService.log(1, member.getId(), 0, 1, null, userId); }这段代码里两个关键点:查询状态时用in (0, 1)同时拦住“待审核”和“已通过”,因为对同一个人来说,这两种状态都算“已有身份”;事务加在方法上,防止日志写完但主记录插入失败造成脏数据。并发场景下 count 校验不能完全防住同一毫秒的两个请求,这点会在避坑章节专门展开。
审批接口需要处理的状态更多:社长只能审批 PENDING 的记录,审批后状态变为 APPROVED 或 REJECTED,但不能从 REJECTED 再次审批。我的处理是:先查一遍当前状态,再用 UpdateWrapper 带状态条件更新,返回更新行数判断是否被并发改掉。
4.3 活动报名与签到:并发扣减和防重复签到一起设计
活动报名的核心是enroll_count不能超过max_enroll_count,但用“先查 count 再 insert”的方式,并发下一定会超。这里直接用一条条件更新 SQL 来扣减是最稳妥的:
public void enroll(Long activityId) { Long userId = StpUtil.getLoginIdAsLong(); // 先判断是否已报名 LambdaQueryWrapper<ActivityEnroll> checkWrapper = new LambdaQueryWrapper<>(); checkWrapper.eq(ActivityEnroll::getActivityId, activityId) .eq(ActivityEnroll::getUserId, userId); if (enrollMapper.selectCount(checkWrapper) > 0) { throw new ServiceException("你已报名该活动"); } // 原子扣减名额 UpdateWrapper<ClubActivity> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("id", activityId) .apply("enroll_count < max_enroll_count") .setSql("enroll_count = enroll_count + 1"); int rows = activityMapper.update(null, updateWrapper); if (rows == 0) { throw new ServiceException("活动名额已满"); } ActivityEnroll enroll = new ActivityEnroll(); enroll.setActivityId(activityId); enroll.setUserId(userId); enroll.setEnrollTime(LocalDateTime.now()); enroll.setStatus(0); enrollMapper.insert(enroll); }apply("enroll_count < max_enroll_count")这行是把判断条件写进 UPDATE 语句,数据库行锁会保证同一时间只有一个事务能更新成功。但注意,如果先插入报名记录再更新活动人数,会留下“报名成功但人数没加”的脏数据;如果先更新人数再插入,又会留下“人数加了但没报名记录”。这两个操作必须放进同一个事务,并且把活动的 UPDATE 放在前面,插入记录放在后面,这样失败后整个事务回滚。
签到逻辑里最烦的是学生拿着别人的二维码截图代签到。从业者的常规方案是:签到码做成动态二维码,有效时间 30 秒,服务端用 Redis 存签到时生成的随机码,扫描时比对活动 ID、用户 ID、随机码三者是否匹配。严格一点要加 Geofence 校验,用高德 API 判断签到位置和活动地点的距离超过 500 米就拒绝,但这部分不是所有学校都接受,需要跟社团老师商量隐私边界。
5. 社团管理系统常见坑与排查:这五个问题我基本每次都会遇到
5.1 延迟加载报 LazyInitializationException 还是 N+1 查询
现象:查询活动列表时,活动关联的社团信息在 JSON 序列化阶段抛出LazyInitializationException,一度以为是前端传参问题,折腾了半天。
原因:JPA 或者 MyBatis-Plus 开启延迟加载后,Service 层事务已经提交,Controller 层访问懒加载属性时 Session 已关闭。另外一种情况是列表页循环查询社团信息,查 100 条活动发了 101 条 SQL。
解决:我的做法是查询列表时显式使用关联查询或者@TableField(exist = false)配合单独填充,不让 ORM 在序列化阶段偷偷触发懒加载。MyBatis-Plus 里可以写一个自定义查询方法,用 LEFT JOIN 一次性查出活动加社团名,返回 VO 而不是实体,从根上避开懒加载问题。
5.2 活动时间在签到环节对不上,差了 8 小时
现象:活动定在下午 14:00,学生扫码签到的时间戳却显示早上 06:00,打卡记录全部作废。
原因:MySQL 连接串缺少serverTimezone=Asia/Shanghai参数,数据库连接的默认时区跟应用服务器不一致,LocalDateTime 转出来凭空少 8 小时。
解决:连接串统一加?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8,同时前端传时间统一要求 ISO 格式字符串,后端在 DTO 里用@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")接住。JSON 序列化也统一配置 Jackson 的格式和时区,不要把格式转换交给前端。
5.3 审批状态被并发修改,两条审批请求同时进来都返回成功
现象:社长和副社长同时打开同一活动的审批页面,一个点通过,一个点拒绝,后提交的覆盖了先提交的,最终状态和第一个人的操作相反。
原因:review 接口里先查状态再判断修改,两个请求查到同一条 PENDING 记录,各自执行 update,最后一个提交的获胜。
解决:用乐观锁。给 activity 表加version字段,更新时条件里带上当前版本号,更新行数为 0 就说明被并发改过,直接提示“该审批已被其他人处理,请刷新后查看”。MyBatis-Plus 自带@Version注解,在实体类字段上加上,再配置分页插件里的乐观锁插件即可。
5.4 导出社团成员名单时内存直接溢出
现象:导出 Excel 时一个两万人的大型社团名单,后台接口卡了 30 秒后 OOM 崩溃。
原因:一次性把全量数据selectList查出来再塞给 EasyExcel 的write方法,List 对象驻留在内存里,加上 Excel 写入的缓冲,老年代直接被占满。
解决:用 EasyExcel 的write配合分批查询,每查 1000 条就write一次并clear缓存。查询侧用 MyBatis-Plus 的流式查询或者手动 LIMIT 分页,不要一次把全量数据 load 到内存再处理。这个经验和线上操作不同,内存溢出的现象经常延迟出现,祸根却在很久之前就已经埋下了。
5.5 学号被复用,老成员的数据串到了新生头上
现象:毕业年级的学号被学校回收重新分配给新生后,新生登录系统发现“自己”已经在三个社团多年,还挂着干事身份。
原因:系统的唯一标识用的是学号,而不是用户 ID。学号在学生生命周期里可能被回收复用,但用户记录应该是永久唯一的。
解决:建表时用自增主键作为用户 ID,学号只做登录账号,不参与任何关联查询。用户表加一个status字段,毕业后管理员将状态置为 2(已毕业),登录时直接拒绝;关联表里那些状态仍然是 1 的历史记录,写一个数据订正脚本,把毕业年级的成员关系全部置为 3(已退出)。
6. 上线前值得做的一组验证:用最小数据集模拟完整学年运转
在正式交给社团联合会使用前,我会先导入一份模拟数据,目标是让系统经历完整学年可能发生的所有状态变化。具体做法是写一个数据生成脚本,手动造 200 个学生、20 个社团、80 场活动,然后按下面这张表逐项跑:
| 验证项 | 操作方式 | 通过标准 |
|---|---|---|
| 入社申请全链路 | 20 人同时申请入社,社长逐个审批 | 拒绝后可重新申请,不能出现多条待审记录 |
| 状态闭环 | 对已退出的成员再次申请入社 | 能正常申请且生成新记录,不沿用旧身份 |
| 并发报名 | 用 JMeter 开 100 线程抢一个名额 50 的活动 | 最终报名名单数为 50,无超卖 |
| 签到码防重 | 同一二维码连续扫描两次 | 第二次扫码明确提示已签到 |
| 时间跨期末 | 手动把系统时间拨到第二学期结束 | 已结束活动不能报名签到,综测加分汇总准确 |
这套验证里最值得关注的是“状态闭环”。真实运营中,学生退社、复社、换社团是常态,每次操作留下的痕迹都要经得起追溯。我吃过一次亏,当年上线时没测“退社后再申请”这条路径,结果数据表里用唯一索引覆盖了俱乐部加会员两个字段,老记录占据名额,新生申请被卡住,第二天就有学生跑到办公室抱怨。
还有两个检查我建议放到验证脚本里:查一遍所有活动的review_status=0的记录,把这些“挂起未审”的数据导出给老师看;再跑一次综测加分汇总,和人工 Excel 台账比对,差值超过一定阈值就查日志。这类业务系统最怕的不是主流程报错,而是看不到的静默数据错误。
最后给一句我的个人习惯:状态变更的接口我在上线后都会保留近 30 天的请求日志,出事能回查,这相当于给线上数据留了一颗后悔药。社团管理系统不难,难在你想没想清楚那些状态流转的边界条件。希望帮到你。
本文还有配套的精品资源,点击获取