本文完整拆解一个基于 Spring Boot 3.2.5 + Vue 3 + TypeScript 的校园社团管理平台,涵盖 17 张数据表、12 个控制器、双层权限模型、JWT 轻量级认证方案、数据范围控制等核心设计。项目不使用 Spring Security 框架,而是通过自定义注解 + 拦截器实现细粒度权限控制,适合中小型项目参考。 ==**本文源码:pan.quark.cn/s/3c08ec2e6...
一、项目概述
1.1 什么是校园社团管理平台
校园社团管理平台是一个面向高校社团全生命周期管理的 Web 应用系统,覆盖社团信息管理、成员管理、活动管理、申请审核、财务管理、通知公告、社团动态、指导老师管理、招新管理、数据统计等 11 个功能模块。系统采用前后端分离架构,后端基于 Spring Boot 3.2.5 提供 RESTful API,前端基于 Vue 3 + TypeScript + Element Plus 构建用户界面。
平台支持 6 种系统角色:SUPER_ADMIN(超级管理员)、ADMIN(管理员)、ADVISOR(指导老师)、PRESIDENT(社长)、MEMBER(普通成员)、GUEST(游客),角色之间通过数值层级(100 > 90 > 80 > 70 > 60 > 10)实现权限继承。
1.2 系统整体架构
系统采用前后端分离架构,前端通过 Vite 开发代理与后端通信,后端按 Controller → Service → Mapper 三层结构组织代码,安全层横切所有请求:

1.3 系统界面展示
首页概览 --- 登录后进入 Dashboard,顶部展示社团总数、成员总数、待审批、本月活动四项统计指标,下方展示近期活动列表与快捷操作入口:

社团管理 --- 管理员视角的社团列表,支持按名称、类别筛选,展示社团星级评级与状态,可执行新增、编辑、删除操作:

活动管理 --- 活动列表展示所属社团、参与范围(公开/内部)、报名时间、报名人数与活动状态(已批准/进行中/已结束),支持按社团和状态筛选:

财务管理 --- 顶部三张 KPI 卡片展示总收入、总支出、结余,下方为收支流水明细表,支持按社团、收支类型、日期范围筛选:

数据统计 --- 使用 ECharts 渲染三张图表:社团活跃度排名(柱状图)、成员增长趋势(面积图)、活动状态分布(环形图):

招新管理(管理员视角) --- 查看报名列表,展示申请人姓名、学号、专业、年级、自我介绍及录取状态:

招新管理(用户视角) --- 普通成员视角的招新动态页面,以卡片形式展示正在招新的社团信息,支持立即报名:

1.4 功能模块一览
| 模块 | 核心能力 | 关键技术点 |
|---|---|---|
| 社团信息管理 | CRUD、分页搜索、社团详情、类别管理、星级评级 | 数据范围控制 |
| 成员管理 | 加入/退出、角色设置、信息编辑、统计、考勤 | 社团内角色体系 |
| 活动管理 | CRUD、状态流转、报名签到、参与度统计 | 签到码生成、状态智能计算 |
| 申请审核 | 社团创建/活动/财务审批 | 审批联动机制 |
| 财务管理 | 流水记录、收支统计、财务审批、预算管理 | 大额支出阈值校验 |
| 通知公告 | 发布、已读统计、置顶 | 全校/社团内通知范围 |
| 社团动态 | 图文动态、点赞评论、审核 | 嵌套评论结构 |
| 指导老师 | 信息维护、分配、审批权限 | 角色自动刷新 |
| 招新管理 | 招新信息、在线报名、面试安排、录取 | 完整招新流程 |
| 数据统计 | 活跃度排名、增长趋势、财务报表 | ECharts 可视化 |
| 首页概览 | 数据看板、近期活动、消息通知 | Dashboard 聚合 |
1.5 项目目录结构
bash
校园社团管理平台/
├── backend/ # Spring Boot 后端
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/campus/club/
│ │ ├── ClubPlatformApplication.java
│ │ ├── common/ # Result, 异常, 常量, 角色枚举
│ │ ├── config/ # CORS, 拦截器注册, Knife4j, MyBatis-Plus
│ │ ├── controller/ # 12 个 REST 控制器
│ │ ├── service/ # 10 个业务模块
│ │ ├── mapper/ # 16 个 MyBatis-Plus Mapper
│ │ ├── entity/ # 15 个实体类
│ │ ├── dto/ # 14 个数据传输对象
│ │ ├── vo/ # 视图对象 (LoginVO)
│ │ └── security/ # JWT, 拦截器, @RequireRole, UserContext
│ └── resources/
│ ├── application.yml
│ └── schema.sql # 17 张表 + 初始化数据
├── frontend/ # Vue 3 前端
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── api/ # 13 个 API 模块
│ ├── views/ # 15 个页面视图
│ ├── layouts/ # MainLayout 主布局
│ ├── router/ # 路由 + 权限守卫
│ ├── stores/ # Pinia 用户状态
│ ├── utils/ # Axios 封装, 财务权限
│ └── types/ # TypeScript 类型定义
└── 需求.md
二、技术栈选型与版本说明
2.1 后端技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Spring Boot | 3.2.5 | 基础框架,使用 Jakarta EE 规范 |
| MyBatis-Plus | 3.5.6 | ORM 增强,对应 mybatis-plus-spring-boot3-starter |
| MySQL | 8.x | 数据库,字符集 utf8mb4 |
| jjwt | 0.12.5 | JWT 生成与解析(api + impl + jackson 三件套) |
| Knife4j | 4.5.0 | API 文档,对应 knife4j-openapi3-jakarta-spring-boot-starter |
| Hutool | 5.8.27 | Java 工具库 |
| spring-security-crypto | --- | 仅引入 BCrypt 加密,不引入完整 Spring Security |
| Lombok | --- | 实体类简化 |
2.2 前端技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.5.34 | 前端框架 |
| Vite | 8.0.12 | 构建工具 |
| TypeScript | ~6.0.2 | 类型安全 |
| Element Plus | 2.14.2 | UI 组件库 |
| Pinia | 3.0.4 | 状态管理 |
| Vue Router | 4.6.4 | 路由管理 |
| Axios | 1.18.1 | HTTP 请求 |
| ECharts | 6.1.0 | 数据可视化 |
2.3 为什么不使用 Spring Security
Spring Boot 3.x 默认集成的 Spring Security 功能强大但配置复杂,对于校园社团管理这类中小型项目存在过度设计的问题。本项目采用更轻量级的方案:
- 仅引入
spring-security-crypto做密码加密(BCrypt) - 自定义
@RequireRole注解声明接口所需角色 - 通过
AuthInterceptor拦截器解析 JWT 并校验权限 - 使用
PermissionService实现细粒度的社团级权限判断
这种方案的核心优势是:配置简单、易于调试、权限逻辑集中可追溯,整个安全模块仅 5 个类文件就完成了认证、授权、上下文管理的完整链路。

两种方案的核心差异在于:Spring Security 通过 Filter 链处理安全逻辑,需要配置十余个组件;本项目通过一个拦截器 + 一个注解 + 一个 Service 就实现了完整的认证授权链路,代码量减少约 70%。
三、数据库设计
3.1 数据库概览
数据库 campus_club 采用 utf8mb4 字符集,共 17 张业务表,所有表均包含 deleted 字段实现逻辑删除,create_time / update_time 字段由 MyBatis-Plus 自动填充。
核心表之间的关联关系如下:

3.2 核心表结构
用户表 (user)
sql
CREATE TABLE user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
real_name VARCHAR(50),
email VARCHAR(100),
phone VARCHAR(20),
avatar VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'GUEST',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用',
deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
社团表 (club)
sql
CREATE TABLE club (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL COMMENT '学术/体育/文艺/志愿/其他',
description TEXT,
logo VARCHAR(255),
president_id BIGINT,
advisor_id BIGINT,
member_count INT NOT NULL DEFAULT 0,
star_rating DECIMAL(2,1) NOT NULL DEFAULT 0.0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/ACTIVE/DISBANDED',
deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
社团成员表 (member)
sql
CREATE TABLE member (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
club_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
club_role VARCHAR(30) NOT NULL DEFAULT 'MEMBER'
COMMENT 'PRESIDENT/VICE_PRESIDENT/MINISTER/OFFICER/MEMBER',
join_time DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/INACTIVE',
deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
活动表 (activity)
sql
CREATE TABLE activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
club_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
location VARCHAR(200),
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
max_participants INT,
checkin_code VARCHAR(10) COMMENT '6位签到码',
scope VARCHAR(20) NOT NULL DEFAULT 'PUBLIC' COMMENT 'PUBLIC/INTERNAL',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
COMMENT 'PENDING/APPROVED/ONGOING/ENDED/CANCELLED',
deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
3.3 完整表清单
| 序号 | 表名 | 说明 | 关键字段 |
|---|---|---|---|
| 1 | user |
用户表 | role, status |
| 2 | club |
社团表 | category, star_rating, status |
| 3 | member |
社团成员表 | club_role, status |
| 4 | advisor |
指导老师-社团关联 | club_id, user_id |
| 5 | activity |
活动表 | checkin_code, scope |
| 6 | activity_registration |
活动报名表 | checkin_status, checkin_time |
| 7 | application |
申请审核表 | type, status, reviewer_id |
| 8 | finance |
财务记录表 | type(INCOME/EXPENSE), amount |
| 9 | finance_budget |
财务预算表 | club_id, term |
| 10 | notice |
通知公告表 | scope(ALL/CLUB) |
| 11 | notice_read |
通知已读记录 | notice_id, user_id |
| 12 | dynamic |
社团动态表 | like_count, comment_count |
| 13 | dynamic_comment |
动态评论表 | parent_id (嵌套) |
| 14 | dynamic_like |
动态点赞表 | dynamic_id, user_id |
| 15 | recruitment |
招新信息表 | status, deadline |
| 16 | recruitment_application |
招新报名表 | status |
| 17 | interview |
面试安排表 | time, location |
3.4 初始化数据
系统启动时自动插入默认管理员账户和测试社团数据:
- 管理员:
admin/admin123(BCrypt 加密存储) - 测试社团:计算机协会、篮球社、志愿者协会
四、后端核心实现
4.1 统一返回格式 Result<T>
所有 API 接口返回统一的 Result<T> 结构,包含状态码、消息和数据三个字段:
java
@Data
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
result.setData(data);
return result;
}
public static <T> Result<T> error(ResultCode resultCode) {
Result<T> result = new Result<>();
result.setCode(resultCode.getCode());
result.setMessage(resultCode.getMessage());
return result;
}
}
状态码采用分段设计,每个区间对应一类业务场景:

4.2 全局异常处理
使用 @RestControllerAdvice 统一捕获异常,避免将堆栈信息暴露给前端:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
ResultCode code = e.getResultCode();
HttpStatus httpStatus = switch (code.getCode()) {
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
default -> HttpStatus.OK;
};
return Result.error(code);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
}
}
4.3 JWT 认证方案
完整的认证流程从用户登录到请求拦截,涉及前端、后端拦截器、用户上下文和数据库的完整交互:

JWT 工具类 (JwtUtil)
项目使用 jjwt 0.12.5 实现 JWT 的生成与解析。Token 中包含 userId、username、role 三个自定义声明,过期时间 24 小时:
java
public class JwtUtil {
private final SecretKey key;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded;
}
this.key = Keys.hmacShaKeyFor(keyBytes);
this.expiration = expiration;
}
public String generateToken(Long userId, String username, String role) {
return Jwts.builder()
.claim("userId", userId)
.claim("username", username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(key)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
jjwt 0.12.x 的 API 与旧版本有显著差异:使用 Jwts.builder().signWith(key) 替代了 signWith(SignatureAlgorithm, key),使用 Jwts.parser().verifyWith(key).build() 替代了 setSigningKey(key).parseClaimsJws()。
权限注解 (@RequireRole)
java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value() default {};
boolean anyLoggedIn() default false;
}
该注解支持两种模式:value 指定所需角色(满足其一即可),anyLoggedIn = true 表示任意已登录用户即可访问。注解可放在类级别(对所有方法生效)或方法级别(覆盖类级别配置)。
认证拦截器 (AuthInterceptor)
拦截器核心流程分为 5 步:
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
// 1. 从 Authorization 头提取 Bearer Token
String token = extractToken(request);
// 2. 无 Token 时检查是否需要权限
RequireRole requireRole = getRequireRole(handlerMethod);
if (token == null) {
if (requireRole == null) return true;
throw new BusinessException(ResultCode.UNAUTHORIZED);
}
// 3. 校验 Token 有效性
if (!jwtUtil.validateToken(token)) {
throw new BusinessException(ResultCode.TOKEN_EXPIRED);
}
// 4. 解析用户信息,查询最新状态(检查是否被禁用)
Claims claims = jwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
User user = userService.getById(userId);
if (user == null || user.getStatus() == 0) {
throw new BusinessException(ResultCode.ACCOUNT_DISABLED);
}
// 5. 设置 UserContext + 角色权限校验
UserContext.set(user);
if (requireRole != null) {
permissionService.checkRole(requireRole, user.getRole());
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear(); // 清理 ThreadLocal,防止内存泄漏
}
}
用户上下文 (UserContext)
使用 ThreadLocal 存储当前请求的登录用户信息,请求结束后在 afterCompletion 中清理,避免线程池复用导致的内存泄漏:
java
public class UserContext {
private static final ThreadLocal<User> CONTEXT = new ThreadLocal<>();
public static void set(User user) { CONTEXT.set(user); }
public static User get() { return CONTEXT.get(); }
public static Long getUserId() {
User user = get();
return user != null ? user.getId() : null;
}
public static void clear() { CONTEXT.remove(); }
}
4.4 双层权限模型
本项目设计了系统角色 + 社团内角色的双层权限模型,这是整个权限体系的核心设计。
系统角色通过 level 数值实现层级继承:level 值越大的角色自动拥有低级别角色的所有权限。社团内角色独立运作,由 PermissionService 在运行时组合两层信息做出最终权限判断。
系统角色层级
scss
SUPER_ADMIN (100) > ADMIN (90) > ADVISOR (80) > PRESIDENT (70) > MEMBER (60) > GUEST (10)
RoleEnum 枚举通过 level 数值实现层级判断:高级别角色自动拥有低级别角色的所有权限。
java
public enum RoleEnum {
SUPER_ADMIN(100),
ADMIN(90),
ADVISOR(80),
PRESIDENT(70),
MEMBER(60),
GUEST(10);
private final int level;
public static boolean hasPermission(String currentRole, String requiredRole) {
int currentLevel = fromString(currentRole).level;
int requiredLevel = fromString(requiredRole).level;
return currentLevel >= requiredLevel;
}
}
社团内角色体系
社团内部有独立的角色体系,与系统角色相互独立:
| 社团角色 | 说明 | 典型权限 |
|---|---|---|
| PRESIDENT | 社长 | 管理社团所有事务 |
| VICE_PRESIDENT | 副社长 | 管理社团事务、查看财务 |
| MINISTER | 部长 | 管理本部门成员 |
| OFFICER | 干事 | 协助管理 |
| MEMBER | 普通成员 | 参与活动、查看动态 |
PermissionService 细粒度权限
PermissionService 是权限控制的核心服务,实现了社团级别的细粒度权限判断:
java
@Service
public class PermissionService {
// 系统级权限
public boolean isSuperAdmin() { ... }
public boolean isPlatformAdmin() { ... } // SUPER_ADMIN 或 ADMIN
public boolean isSelf(Long userId) { ... }
// 社团级权限
public boolean isClubPresident(Long clubId, Long userId) { ... }
public boolean isAdvisorOf(Long clubId, Long userId) { ... }
public boolean isFinanceOfficer(Long clubId, Long userId) { ... }
// 复合权限判断
public boolean canManageClub(Long clubId) {
return isPlatformAdmin() || isClubPresident(clubId, UserContext.getUserId());
}
public boolean canViewFinance(Long clubId) {
return isPlatformAdmin()
|| isClubPresident(clubId, UserContext.getUserId())
|| isVicePresident(clubId, UserContext.getUserId())
|| isAdvisorOf(clubId, UserContext.getUserId());
}
}
4.5 数据范围控制
数据范围控制是权限体系的关键延伸。不同角色看到的数据范围不同:管理员可以看到所有社团数据,社长只能看到自己管理的社团数据,普通成员只能看到自己所属社团的数据。
这一设计的精妙之处在于:同一套 Controller 和 Service 代码,无需任何 if-else 分支,仅通过 scopedClubIdsOrNull() 返回值是 null 还是 List 就实现了数据范围的动态控制。
PermissionService 提供了 scopedClubIdsOrNull() 方法,返回 null 表示无限制(管理员),返回 List<Long> 表示限定社团 ID 范围:
java
public List<Long> scopedClubIdsOrNull() {
if (isPlatformAdmin()) {
return null; // null 表示无限制
}
// 查询当前用户可见的社团ID列表
return memberMapper.selectClubIdsByUserId(UserContext.getUserId());
}
各 Service 层在查询时根据返回值动态构建查询条件:
java
// ClubServiceImpl 中的分页查询
public IPage<Club> getClubPage(ClubPageDTO dto) {
LambdaQueryWrapper<Club> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(dto.getName()), Club::getName, dto.getName());
List<Long> scopedIds = permissionService.scopedClubIdsOrNull();
if (scopedIds != null) {
if (scopedIds.isEmpty()) {
return new Page<>(dto.getPageNum(), dto.getPageSize()); // 空页
}
wrapper.in(Club::getId, scopedIds);
}
return clubMapper.selectPage(new Page<>(dto.getPageNum(), dto.getPageSize()), wrapper);
}
4.6 活动状态智能计算
活动状态不是简单的字段更新,而是基于当前时间与活动的 start_time / end_time 实时计算:
状态计算的核心逻辑:只有 APPROVED 状态的活动会根据时间动态计算为 ONGOING 或 ENDED,其他状态保持不变:
java
public String getEffectiveStatus(Activity activity) {
String status = activity.getStatus();
if (!"APPROVED".equals(status)) return status;
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(activity.getStartTime())) return "APPROVED";
if (now.isAfter(activity.getEndTime())) return "ENDED";
return "ONGOING";
}
活动创建时自动生成 6 位签到码,同时自动创建活动审批申请:
java
// 创建活动
activity.setCheckinCode(generateCheckinCode()); // 6位随机码
activity.setStatus("PENDING");
activityMapper.insert(activity);
// 自动创建审批申请
Application app = new Application();
app.setType("ACTIVITY");
app.setTargetId(activity.getId());
app.setClubId(activity.getClubId());
app.setApplicantId(UserContext.getUserId());
app.setStatus("PENDING");
applicationMapper.insert(app);
4.7 审批联动机制
审批通过后系统自动执行关联操作,这是业务逻辑设计的关键亮点:
审批联动的核心价值在于:一次审批操作触发后续多个关联实体的自动更新,避免人工逐条操作导致的数据不一致。审批处理代码如下:
java
// ApplicationServiceImpl 审批处理
public void reviewApplication(Long id, ApplicationReviewDTO dto) {
Application app = applicationMapper.selectById(id);
// 防自审批
if (app.getApplicantId().equals(UserContext.getUserId())) {
throw new BusinessException(ResultCode.PARAM_ERROR, "不能审批自己提交的申请");
}
app.setStatus(dto.getStatus());
app.setReviewerId(UserContext.getUserId());
app.setReviewTime(LocalDateTime.now());
applicationMapper.updateById(app);
// 审批通过后联动处理
if ("APPROVED".equals(dto.getStatus())) {
switch (app.getType()) {
case "ACTIVITY" ->
// 活动审批通过 → 更新活动状态为 APPROVED
activityMapper.updateStatus(app.getTargetId(), "APPROVED");
case "CLUB_CREATE" ->
// 社团创建通过 → 设置社长 + 添加成员记录 + 刷新角色
handleClubCreateApproval(app.getTargetId());
}
}
}
4.8 MyBatis-Plus 配置
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL)
);
return interceptor;
}
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime",
LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime",
LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime",
LocalDateTime.class, LocalDateTime.now());
}
};
}
}
4.9 实体类设计规范
所有实体类遵循统一的设计规范:
java
@Data
@TableName("club")
public class Club {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String category;
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@JsonIgnore
private Integer deleted;
@TableField(exist = false)
private String presidentName; // 非数据库字段,关联查询使用
}
4.10 CORS 与文件上传配置
java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login", "/auth/register", "/auth/captcha",
"/doc.html", "/swagger-resources/**",
"/webjars/**", "/v3/api-docs/**",
"/files/**", "/error"
);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowCredentials(true);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
}
}
五、前端核心实现
5.1 Axios 请求封装
前端通过 Axios 拦截器实现统一的 Token 注入和错误处理:
typescript
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 15000,
})
// 请求拦截器:自动添加 Token
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:统一错误处理
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.code === 200) return res
if (res.code === 401) {
ElMessage.error('登录已过期,请重新登录')
useUserStore().logout()
router.push('/login')
return Promise.reject(res)
}
if (res.code === 403) {
ElMessage.error('没有权限访问')
return Promise.reject(res)
}
ElMessage.error(res.message || '网络异常')
return Promise.reject(res)
},
(error) => {
ElMessage.error('网络连接异常')
return Promise.reject(error)
}
)
5.2 路由守卫与权限控制
路由守卫实现了三层权限校验:
路由守卫的核心代码如下:
typescript
router.beforeEach(async (to, from, next) => {
// 1. 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - 校园社团管理平台`
: '校园社团管理平台'
const userStore = useUserStore()
// 2. 公开页面直接放行
if (to.path === '/login' || to.path === '/404') {
if (userStore.isLoggedIn && to.path === '/login') {
return next('/')
}
return next()
}
// 3. 未登录跳转登录页
if (!userStore.isLoggedIn) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
// 4. 角色权限校验
if (to.meta.roles) {
// 层级角色判断:当前角色 level >= 所需角色 level
if (!userStore.hasRole(to.meta.roles as string[])) {
return next('/')
}
}
if (to.meta.exactRoles) {
// 精确角色匹配:必须是指定角色之一
if (!userStore.hasExactRole(to.meta.exactRoles as string[])) {
return next('/')
}
}
// 5. 财务权限异步检查
if (to.meta.financeAccess) {
const hasAccess = await checkFinanceAccess()
if (!hasAccess) return next('/')
}
next()
})
路由权限配置示例:
typescript
const routes = [
{ path: 'club', meta: { title: '社团中心', roles: ['GUEST'] } },
{ path: 'member', meta: { title: '成员管理',
exactRoles: ['SUPER_ADMIN', 'ADMIN', 'PRESIDENT'] } },
{ path: 'finance', meta: { title: '财务管理', financeAccess: true } },
{ path: 'statistics', meta: { title: '数据统计',
exactRoles: ['SUPER_ADMIN', 'ADMIN', 'ADVISOR'] } },
]
5.3 Pinia 状态管理
采用 Composition API 风格的 Pinia Store,实现 Token 和用户信息的管理:
typescript
export const useUserStore = defineStore('user', () => {
const token = ref<string>(localStorage.getItem('token') || '')
const userInfo = ref<User | null>(
JSON.parse(localStorage.getItem('userInfo') || 'null')
)
const roleLevelMap: Record<string, number> = {
SUPER_ADMIN: 100, ADMIN: 90, ADVISOR: 80,
PRESIDENT: 70, MEMBER: 60, GUEST: 10,
}
const isLoggedIn = computed(() => !!token.value)
const role = computed(() => userInfo.value?.role || 'GUEST')
const roleLevel = computed(() => roleLevelMap[role.value] || 0)
const isAdmin = computed(() =>
role.value === 'SUPER_ADMIN' || role.value === 'ADMIN'
)
// 层级角色判断
function hasRole(roles: string[]): boolean {
return roles.some(r => roleLevel.value >= (roleLevelMap[r] || 0))
}
// 精确角色匹配
function hasExactRole(roles: string[]): boolean {
return roles.includes(role.value)
}
function setLogin(data: LoginResult) {
token.value = data.token
userInfo.value = data.user
localStorage.setItem('token', data.token)
localStorage.setItem('userInfo', JSON.stringify(data.user))
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
return { token, userInfo, isLoggedIn, role, roleLevel, isAdmin,
hasRole, hasExactRole, setLogin, logout }
})
5.4 Vite 开发代理配置
typescript
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})
5.5 响应式布局设计
主布局 MainLayout.vue 采用侧边栏 + 顶栏 + 内容区的三栏结构,支持响应式适配:
- 桌面端:固定侧边栏(200px)+ 顶栏 + 内容区
- 移动端(< 768px):侧边栏变为固定定位 + 遮罩层,顶栏简化
侧边栏菜单根据用户角色动态过滤,社长看到"社团管理",普通成员看到"社团广场"。
六、关键技术亮点
6.1 轻量级权限方案
整个权限系统由 5 个核心类组成,不依赖 Spring Security 框架:
| 类 | 职责 |
|---|---|
JwtUtil |
JWT 生成、解析、校验 |
RequireRole |
权限注解,声明接口所需角色 |
AuthInterceptor |
拦截器,解析 Token + 角色校验 |
UserContext |
ThreadLocal 用户上下文 |
PermissionService |
细粒度社团级权限判断 |
6.2 前后端权限对齐
前端和后端使用相同的角色层级体系,确保权限判断的一致性:
- 前端
hasRole(roles)对应后端RoleEnum.hasPermission(),均基于 level 数值比较 - 前端
hasExactRole(roles)对应后端@RequireRole(value = {...}),均做精确匹配 - 财务权限通过异步 API 调用
/finance/access实时检查,前端缓存结果避免重复请求
6.3 角色自动刷新
当用户的社团关系发生变化时(如被任命为社长、被分配为指导老师),系统自动调整其系统角色:
核心代码实现如下:
java
public void refreshUserRoleByRelations(Long userId) {
User user = userMapper.selectById(userId);
String oldRole = user.getRole();
// 检查是否为社长
if (memberMapper.existsPresident(userId)) {
user.setRole("PRESIDENT");
}
// 检查是否为指导老师
else if (advisorMapper.existsByUserId(userId)) {
user.setRole("ADVISOR");
}
// 检查是否为社团成员
else if (memberMapper.existsActiveMember(userId)) {
user.setRole("MEMBER");
} else {
user.setRole("GUEST");
}
if (!oldRole.equals(user.getRole())) {
userMapper.updateById(user);
}
}
6.4 全链路逻辑删除
所有 17 张业务表均包含 deleted 字段,MyBatis-Plus 全局配置自动处理:
yaml
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
查询时自动追加 WHERE deleted = 0 条件,删除操作变为 UPDATE SET deleted = 1,保证数据可追溯。
七、API 接口概览
7.1 认证接口
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | /api/auth/login |
登录 | 公开 |
| POST | /api/auth/register |
注册 | 公开 |
| GET | /api/auth/info |
获取当前用户信息 | 已登录 |
7.2 社团接口
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /api/club/page |
分页查询社团 | 已登录 |
| GET | /api/club/{id} |
社团详情 | 已登录 |
| POST | /api/club |
创建社团 | ADMIN |
| PUT | /api/club |
更新社团 | 社长/管理员 |
| DELETE | /api/club/{id} |
删除社团 | ADMIN |
7.3 活动接口
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /api/activity/page |
分页查询活动 | 已登录 |
| POST | /api/activity |
创建活动 | 社长/管理员 |
| POST | /api/activity/{id}/register |
报名活动 | 已登录 |
| POST | /api/activity/{id}/checkin |
签到 | 已登录 |
7.4 统一分页规范
分页查询统一使用 pageNum(页码,从 1 开始)和 pageSize(每页条数)参数,返回格式为:
json
{
"code": 200,
"message": "成功",
"data": {
"records": [],
"total": 100,
"current": 1,
"size": 10
}
}
八、Spring Boot 3 适配要点
从 Spring Boot 2.x 迁移到 3.x 有三个关键适配点,每个都对应特定的 artifactId 或包名变化:
8.1 Jakarta EE 迁移
Spring Boot 3.x 使用 jakarta.servlet 替代了 javax.servlet,所有 import 需要更新:
java
// Spring Boot 2.x
import javax.servlet.http.HttpServletRequest;
// Spring Boot 3.x
import jakarta.servlet.http.HttpServletRequest;
8.2 MyBatis-Plus Starter
Spring Boot 3 对应的 MyBatis-Plus Starter 不是 mybatis-plus-boot-starter,而是:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.6</version>
</dependency>
8.3 Knife4j Starter
Spring Boot 3 对应的 Knife4j Starter:
xml
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.5.0</version>
</dependency>
注意 artifactId 中包含 jakarta 关键字,这是 Spring Boot 3 适配版本的区别标识。
8.4 jjwt 0.12.x API 变化
jjwt 0.12.x 对 API 做了重大调整,许多开发者升级时容易踩坑:
java
// 旧版本 (0.11.x)
Jwts.builder()
.setSubject(username)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
// 新版本 (0.12.x)
Jwts.builder()
.subject(username)
.signWith(secretKey) // 自动推断算法
.compact();
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
核心变化:setSignWith 简化为 signWith(自动推断算法),parser().setSigningKey() 改为 parser().verifyWith(),parseClaimsJws() 改为 parseSignedClaims(),getBody() 改为 getPayload()。
九、常见问题
校园社团管理平台用了什么技术栈
后端使用 Spring Boot 3.2.5 + MyBatis-Plus 3.5.6 + MySQL 8 + jjwt 0.12.5 + Knife4j 4.5.0,前端使用 Vue 3.5 + Vite 8 + TypeScript + Element Plus 2.14 + Pinia 3 + ECharts 6。项目不使用 Spring Security,而是通过自定义 @RequireRole 注解 + AuthInterceptor 拦截器实现轻量级权限控制。
Spring Boot 3 如何配置 MyBatis-Plus
Spring Boot 3 需要使用 mybatis-plus-spring-boot3-starter(注意 artifactId 中的 spring-boot3),而不是普通的 mybatis-plus-boot-starter。版本 3.5.6 对应 Spring Boot 3.x,同时需要确保所有 javax.servlet 的引用改为 jakarta.servlet。
如何在不使用 Spring Security 的情况下实现 JWT 认证
核心步骤是:使用 jjwt 库生成和解析 JWT Token,通过实现 HandlerInterceptor 接口创建自定义拦截器,在 preHandle 方法中从请求头提取 Token 并验证,将解析出的用户信息存入 ThreadLocal 供后续业务使用,在 afterCompletion 中清理 ThreadLocal。通过 WebMvcConfigurer 注册拦截器并配置放行路径(如登录、注册、API 文档等)。
什么是双层权限模型
双层权限模型是指系统角色和社团内角色相互独立的设计。系统角色(SUPER_ADMIN/ADMIN/ADVISOR/PRESIDENT/MEMBER/GUEST)控制平台级别的功能访问权限,通过数值层级(100/90/80/70/60/10)实现权限继承。社团内角色(PRESIDENT/VICE_PRESIDENT/MINISTER/OFFICER/MEMBER)控制社团内部的操作权限,如谁能管理社团财务、谁能审批活动等。两层权限通过 PermissionService 服务进行组合判断。
数据范围控制如何实现
通过 PermissionService.scopedClubIdsOrNull() 方法返回两种结果:返回 null 表示当前用户是管理员,可以看到所有数据;返回 List<Long> 表示当前用户只能看到指定社团 ID 范围内的数据。各 Service 层在构建查询条件时,根据返回值动态添加 WHERE club_id IN (...) 条件,实现同一接口对不同角色返回不同数据范围的效果。
jjwt 0.12.x 与旧版本有什么区别
jjwt 0.12.x 的核心 API 变化包括:signWith(SignatureAlgorithm, key) 简化为 signWith(key)(自动推断签名算法),Jwts.parser().setSigningKey(key) 改为 Jwts.parser().verifyWith(key).build(),parseClaimsJws(token).getBody() 改为 parseSignedClaims(token).getPayload()。此外,密钥生成统一使用 Keys.hmacShaKeyFor(),要求密钥长度至少 32 字节(256 bit)。
项目如何处理逻辑删除
所有 17 张业务表均包含 deleted 字段(TINYINT 类型,0 表示未删除,1 表示已删除)。在 MyBatis-Plus 的全局配置中设置 logic-delete-field: deleted、logic-delete-value: 1、logic-not-delete-value: 0,MyBatis-Plus 会自动在所有查询 SQL 后追加 WHERE deleted = 0 条件,并将 DELETE 操作转换为 UPDATE SET deleted = 1,无需手动编写逻辑删除条件。
前端路由守卫如何实现权限控制
Vue Router 的 beforeEach 全局前置守卫实现了三层校验:首先检查 meta.roles 进行层级角色判断(当前角色 level >= 所需角色 level 即放行),然后检查 meta.exactRoles 进行精确角色匹配,最后对标记 meta.financeAccess 的路由异步调用后端接口检查财务权限。权限不通过时重定向到首页,未登录时跳转登录页并携带 redirect 参数。
十、总结
本项目通过 Spring Boot 3 + Vue 3 的前后端分离架构,实现了一个功能完整的校园社团管理平台。核心设计亮点包括:
- 轻量级权限方案:不依赖 Spring Security,5 个类完成认证授权全链路
- 双层权限模型:系统角色层级 + 社团内角色,覆盖复杂业务场景
- 数据范围控制 :基于
scopedClubIdsOrNull的动态查询条件构建 - 审批联动机制:活动审批通过自动更新状态,社团创建通过自动设置社长
- 角色自动刷新:社团关系变化时自动调整系统角色
- 全链路逻辑删除:17 张表统一由 MyBatis-Plus 自动处理
- 活动状态智能计算:基于时间实时计算 ONGOING/ENDED 状态
- 前后端权限对齐:层级判断 + 精确匹配双模式,确保一致性
项目共包含 17 张数据表、12 个 REST 控制器、10 个业务服务模块、15 个前端页面,覆盖社团管理、成员管理、活动管理、申请审核、财务管理、通知公告、社团动态、指导老师、招新管理、数据统计等完整功能链路,适合作为 Spring Boot 3 + Vue 3 全栈开发的学习参考项目。