SpringBoot+ Vue校园社团管理平台的完整架构设计

本文完整拆解一个基于 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 状态的活动会根据时间动态计算为 ONGOINGENDED,其他状态保持不变:

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: deletedlogic-delete-value: 1logic-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 全栈开发的学习参考项目。

相关推荐
Jodie同志1 小时前
第16~23天:持久化、HITL、流式、MCP与安全
前端·后端·agent
Jodie同志2 小时前
第1~15天:原生Agent、RAG与LangGraph基础(完整代码实操)
前端·后端·agent
Zane19942 小时前
单例线程安全、生产者消费者、死锁:并发面试三连问串讲
java·后端
用户69371750013842 小时前
#DeepSeek+Pi‑Agent 王炸组合跑赢 Claude‑Code!
前端·人工智能·后端
Zane19942 小时前
类变量与实例变量:一个共享列表引发的线上事故
后端·python
Scene2162 小时前
AgentScope 2.0:2. 快速上手 从零构建生产级智能体
后端
前端一课2 小时前
用 TRAE Work 把项目踩坑经验沉淀成「团队可复用工程规范」,新人再也不重复掉坑
前端·后端
神奇小汤圆2 小时前
一文吃透 Spring 框架:原理、实践与面试全解析
后端
站大爷IP2 小时前
Python 的切片把我坑惨了,原来 `[:]` 是浅拷贝,而 `copy.deepcopy` 才是我的救命稻草
后端