YAOTU INSIGHTS

社团管理系统开发:权限模型、状态机与并发控制的工程实践

社团管理系统开发:权限模型、状态机与并发控制的工程实践
简介一份基于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.xORM 选 MyBatis-Plus权限用 Sa-Token数据库 MySQL 8.x。前端用 Vue 3 Element Plus 做管理后台移动端直接用适配好的网页不必单独开发 App。这套组合的好处是招人容易、排错链路短一台 4 核 8G 的服务器就能带起一个万人规模的学校使用。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot3-starter/artifactId version1.39.0/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批记录表;这张表平时写多读少但到了期末核对“谁批准了这场活动”时是唯一的证据链。开发时顺手建了它能在后期省掉很多扯皮。4. 核心业务接口实现入社申请、活动发布、签到考勤这条主链路怎么落地4.1 登录与角色鉴权Sa-Token 的注解要用对位置社团管理系统的登录和普通管理系统登录差别不大但有一个细节登录返回的 token 里不要存角色列表角色是动态变化的存进去以后改角色还要重新登录才能生效。正确做法是登录只认用户 ID每次请求鉴权时去数据库读该用户在当前社团的角色。PostMapping(/login) public ResultString login(RequestBody Valid LoginDTO dto) { LambdaQueryWrapperSysUser 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 ResultVoid 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(); LambdaQueryWrapperClubMember 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(); // 先判断是否已报名 LambdaQueryWrapperActivityEnroll checkWrapper new LambdaQueryWrapper(); checkWrapper.eq(ActivityEnroll::getActivityId, activityId) .eq(ActivityEnroll::getUserId, userId); if (enrollMapper.selectCount(checkWrapper) 0) { throw new ServiceException(你已报名该活动); } // 原子扣减名额 UpdateWrapperClubActivity 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 还是 N1 查询现象查询活动列表时活动关联的社团信息在 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 连接串缺少serverTimezoneAsia/Shanghai参数数据库连接的默认时区跟应用服务器不一致LocalDateTime 转出来凭空少 8 小时。解决连接串统一加?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8同时前端传时间统一要求 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_status0的记录把这些“挂起未审”的数据导出给老师看再跑一次综测加分汇总和人工 Excel 台账比对差值超过一定阈值就查日志。这类业务系统最怕的不是主流程报错而是看不到的静默数据错误。最后给一句我的个人习惯状态变更的接口我在上线后都会保留近 30 天的请求日志出事能回查这相当于给线上数据留了一颗后悔药。社团管理系统不难难在你想没想清楚那些状态流转的边界条件。希望帮到你。本文还有配套的精品资源点击获取