今年我把之前沉淀的一套高校社团管理系统完整复盘了一遍。技术选型不算花哨,后端 Java + SpringBoot,数据层用 MyBatis-Plus,前端采用微信小程序原生,覆盖了社团档案、成员管理、活动审批、活动报名、签到核销这些典型场景。说实话,这套题的难点并不在 CRUD,而在三点:表结构能否支撑并发报名,小程序登录态和身份绑定怎么设计,以及你拿到一份源码后能不能快速跑起来。这篇我就围绕高校社团管理系统里真正容易出问题的地方,讲一段按业务倒推设计、再落代码的完整过程,也适合正在做 SpringBoot 毕设、又想弄懂完整业务闭环的同学参考。
1. 需求拆解决定开发顺序:社团档案只是背景,活动流才是 C位
很多同学拿到“高校社团管理系统”这个题目,第一反应是把社团增删改查做得很重,社团名称、简介、logo、所属学院一应俱全,然后草草加一个活动表。做完发现系统很“空”,因为用户打开小程序的目的是看活动、报名活动,而不是看一个静态社团主页。
1.1 从“管理社团信息”到“活动全流程审批”:系统到底在管什么
我把系统拆成两层:基础资料层与业务流程层。基础资料层是用户、社团、成员关系;业务流程层是活动从创建、审核、发布、报名、签到到归档的完整状态推进。后者才是用户高频使用的部分。
活动流中至少有几个必须落地的控制点:
- 活动创建后不能直接对所有人可见,要经过管理员审核;
- 审核通过的活动也只能在报名时间段内开放;
- 报名时对活动容量做校验,不能出现显示“已报 120/120”后还能继续报的情况;
- 参与者的“已报名”状态不能只靠前端一个按钮来控制,后端必须能识别;
- 活动结束之后,报名列表和签到列表可以作为数据沉淀,方便后续统计。
1.2 管理员、社长、普通同学的权限边界必须按场景定义
我强烈建议不要在 user 表里只存一个“社长”的全局角色字符串就完事,因为现实里一个学生可以同时加入多个社团,也可能同时担任两个社团的负责人。更合理的做法是分两层权限:
- 系统级角色:用于后台管理端,比如 STUDENT、ADMIN;
- 社团内身份:放在“用户-社团关系表”里,比如 president、manager、member。
这样在查询某个社团活动列表时,只需要判断当前用户在该社团内的身份,就能决定他能不能编辑活动、能不能查看报名名单。权限判断落在具体业务接口上,而不是根据全局角色做粗暴拦截,后面扩展会舒服很多。
1.3 四条必须闭环的业务主链路
我梳理需求时列了四条链路,开发时所有接口都围绕这四条来排优先级:
第一条是“加入社团”。学生浏览社团详情,提交加入申请,社长审批通过后成为正式成员。
第二条是“活动发布”。社长在社团内发起活动,填写容量、时间、地点、报名起止时间,管理员审核通过后活动进入公开列表。
第三条是“报名与取消”。学生看到活动,在报名窗口内点击报名,系统扣减剩余名额;若临时有事可在未签到前取消,名额释放。
第四条是“签到与归档”。活动现场由负责人核销报名记录,活动结束后保留签到记录,支持按活动或社团导出参与名单。
一旦按这四条链路来设计接口,页面怎么跳、接口返回什么字段就都很清晰了,而不是今天加一个接口,明天又改一个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表结构设计:把报名、退报和人数上限都交给数据库来兜底
表结构是整个项目的地基。尤其是报名这种带有并发写场景的功能,如果只靠 Java 代码里先查再算,数据一定会漂。我最终是按这几张核心表来组织数据模型的。
2.1 一张用户表和一张社团成员表,负责“身份”而不是“角色字符串”
用户表保存基础身份信息,包括 openid、学号、姓名、手机号、头像、系统级角色、状态等。真正体现“这个人在某个社团里是什么身份”的,是社团成员表。
社团成员表不要设计成只记录一条“属于哪个社团”的简单结构,还需要带状态和岗位语义。常见的字段可以这样安排:
- club_id:社团 ID;
- user_id:用户 ID;
- role:president、manager、member;
- status:0 待审核,1 已加入,2 已退出。
这里需要特别注意:一张表一旦出现 status 为已退出的历史数据,就不能简单地用 (club_id, user_id) 做唯一索引去拦截重复申请,否则用户退出后再次申请同一个社团时,会因唯一索引冲突而插入失败。我采用的方式是:保留一条有效关系,退出不是 insert 一条新记录,而是把原有记录 status 改成已退出;再次申请时先把旧记录 status 改回待审核,再更新 join_time 和 role。这样既保留唯一约束,又不会导致关系数据爆炸。
2.2 活动主表的字段设计:状态字段和时间字段要分开
活动表把“人工审核状态”和“报名时间段”设计为两套维度,而不是混在一个字段里拍脑袋。
常用的字段有:
| 字段 | 说明 |
|---|---|
| title | 活动标题 |
| club_id | 发起社团 |
| cover | 活动封面 |
| capacity | 最大报名人数 |
| enrolled_count | 当前报名人数 |
| enroll_start_time | 报名开始时间 |
| enroll_end_time | 报名截止时间 |
| start_time | 活动开始时间 |
| end_time | 活动结束时间 |
| status | 0 待审核,1 已发布,2 已结束,3 已驳回,4 已取消 |
| audit_comment | 审核不通过时的驳回原因 |
为什么 status 和报名时间要分开?因为实际业务里,管理员审核通过后,活动并不一定是立即开放报名,它可能在下周一才进入报名窗口。如果只用一个“是否可报名”的状态,就必须定时任务去改状态,很麻烦。更简单的做法是:接口在判断可不可以报名时,用一条 SQL 同时校验状态、时间、人数。
2.3 报名表与并发扣减:为什么不能用“先查人数再 +1”
先给出一版可用建表脚本,核心是报名记录表:
sql复制CREATE TABLE `activity_enroll` (
`id` bigint NOT NULL AUTO_INCREMENT,
`activity_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`student_no` varchar(32) NOT NULL,
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1已报名 2已签到 3已取消',
`enroll_time` datetime NOT NULL,
`signin_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
同一个人针对同一场活动只能有一条报名记录,所以唯一索引非常关键。取消报名时不是删除记录,而是把 status 改成 3;如果后续用户想再次报名,再把状态改回 1 并刷新 enroll_time。这样同一活动不会出现多行有效报名记录。
人数扣减则使用数据库的原子更新:
sql复制UPDATE club_activity
SET enrolled_count = enrolled_count + 1
WHERE id = #{activityId}
AND status = 1
AND enrolled_count < capacity
AND NOW() BETWEEN enroll_start_time AND enroll_end_time;
这条 SQL 返回 1,说明扣减成功;返回 0,说明活动不存在、没有到报名时间、名额已满或者活动已被取消。把它和报名表插入放在同一个事务里,才能保证“人数扣了但报名记录没插上”这种事故不会发生。
3. SpringBoot 端真正需要你手写的核心逻辑
有了表结构,后端代码不能只是机械地生成 Controller、Service、Mapper。在项目里,我认为最值得花时间雕琢的是分包方式、登录鉴权、统一响应体和报名事务。
3.1 按业务模块分包,但是把“能力底座”单独抽出来
很多教学项目喜欢按 controller、service、mapper 这种“技术层分包”,代码少时问题不大,但页面和接口一多,你会发现在 user 模块里既找用户接口又找社团接口,跳来跳去非常消耗耐心。我建议改成“能力底座 + 业务模块”的组织方式:
text复制com.example.club
├── common
│ ├── Result.java
│ ├── BusinessException.java
│ └── GlobalExceptionHandler.java
├── config
│ ├── WebMvcConfig.java
│ └── MybatisPlusConfig.java
├── interceptor
│ └── AuthInterceptor.java
├── module
│ ├── auth
│ ├── user
│ ├── club
│ ├── member
│ ├── activity
│ └── enroll
common 放所有模块共用能力,比如统一返回体、业务异常、全局异常处理;module 下面每个业务包内部再放 controller、service、mapper、entity。这样别人拿到工程后,想看报名功能就只看 enroll 包,想加功能也不会把公共代码改乱。
3.2 小程序登录不是把 openid 直接返回给前端
微信小程序登录的正确流程是:
- 小程序端调用
wx.login获取临时 code; - 小程序把 code 提交到自己的后端接口;
- 后端拿 code、appid、secret 去微信接口换取 openid 和 session_key;
- 后端用 openid 找到用户并签发自己的 Token,返回给小程序;
- 后续请求在请求头里带 Token,后端通过拦截器解析身份。
后端代码骨架大概是这样的:
java复制public Result<LoginVO> wxLogin(@RequestBody WxLoginDTO dto) {
// 1. code 换 openid
String openid = wxService.code2Session(dto.getCode());
// 2. 查用户是否存在
ClubUser user = userMapper.selectOne(new LambdaQueryWrapper<ClubUser>()
.eq(ClubUser::getOpenid, openid));
if (user == null) {
// 返回业务码,小程序端跳转绑定学号页面
return Result.fail(1001, "用户不存在,请先绑定学号");
}
// 3. 生成自定义 Token
String token = JwtUtil.createToken(user.getId(), user.getRole());
return Result.success(new LoginVO(token, user));
}
前端拿到的 Token 默认有效期建议 7 天左右,时间太长有安全风险,太短则会让用户频繁重新登录。这里有个细节:不要把微信下发的 session_key 返回前端,它是用来解密手机号等敏感信息的,业务上用不到,应该在后端使用后立即丢弃。
3.3 统一响应体、业务异常与登录拦截:三个习惯一次到位
如果每个 Controller 都返回不同的 Map 结构,小程序端会写得很痛苦。项目一开始就约定统一响应体:
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
}
业务上的错误不要通过返回 null 或抛通用 Exception 表达,而是定义 BusinessException,并让全局异常处理器把里面的 message 转成统一响应。登录状态失效时,后端直接返回 401,小程序端请求层拦截到 401 后统一跳转登录页。
拦截器里只需要解析 Token,把用户 ID 放入 ThreadLocal,业务方法里随时可以取当前登录人:
java复制public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new BusinessException(401, "登录已失效");
}
Claims claims = JwtUtil.parse(token);
UserContext.set(claims);
return true;
}
3.4 报名的代码顺序不能乱,否则人数会变负数
报名接口的代码顺序必须严谨:先校验活动基本状态,再原子扣减名额,最后插入报名记录,并且整个过程在一个事务里。伪代码如下:
java复制@Transactional(rollbackFor = Exception.class)
public void enroll(EnrollDTO dto) {
// 1. 判断当前用户是否已经报名
Long count = enrollMapper.selectCount(...);
if (count > 0) {
throw new BusinessException("你已报名过该活动");
}
// 2. 原子扣减名额
int updated = activityMapper.deductCapacity(dto.getActivityId());
if (updated != 1) {
throw new BusinessException("活动不存在、未到报名时间或名额已满");
}
// 3. 插入报名记录
enrollMapper.insert(...);
}
这里不要用“先查 enrolled_count,然后在内存里 +1,再 update”的方式。两个用户同时读到 119 人时,都会认为自己可以报名,最终人数变成 121。数据库行级原子更新才是唯一可靠方案。
