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

176 阅读22分钟

本文完整拆解一个基于 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 项目目录结构

校园社团管理平台/
├── 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 Boot3.2.5基础框架,使用 Jakarta EE 规范
MyBatis-Plus3.5.6ORM 增强,对应 mybatis-plus-spring-boot3-starter
MySQL8.x数据库,字符集 utf8mb4
jjwt0.12.5JWT 生成与解析(api + impl + jackson 三件套)
Knife4j4.5.0API 文档,对应 knife4j-openapi3-jakarta-spring-boot-starter
Hutool5.8.27Java 工具库
spring-security-crypto—仅引入 BCrypt 加密,不引入完整 Spring Security
Lombok—实体类简化

2.2 前端技术栈

技术版本用途
Vue3.5.34前端框架
Vite8.0.12构建工具
TypeScript~6.0.2类型安全
Element Plus2.14.2UI 组件库
Pinia3.0.4状态管理
Vue Router4.6.4路由管理
Axios1.18.1HTTP 请求
ECharts6.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)

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)

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)

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)

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 完整表清单

序号表名说明关键字段
1user用户表role, status
2club社团表category, star_rating, status
3member社团成员表club_role, status
4advisor指导老师-社团关联club_id, user_id
5activity活动表checkin_code, scope
6activity_registration活动报名表checkin_status, checkin_time
7application申请审核表type, status, reviewer_id
8finance财务记录表type(INCOME/EXPENSE), amount
9finance_budget财务预算表club_id, term
10notice通知公告表scope(ALL/CLUB)
11notice_read通知已读记录notice_id, user_id
12dynamic社团动态表like_count, comment_count
13dynamic_comment动态评论表parent_id (嵌套)
14dynamic_like动态点赞表dynamic_id, user_id
15recruitment招新信息表status, deadline
16recruitment_application招新报名表status
17interview面试安排表time, location

3.4 初始化数据

系统启动时自动插入默认管理员账户和测试社团数据:

  • 管理员:admin / admin123(BCrypt 加密存储)
  • 测试社团:计算机协会、篮球社、志愿者协会

四、后端核心实现

4.1 统一返回格式 Result<T>

所有 API 接口返回统一的 Result<T> 结构,包含状态码、消息和数据三个字段:

@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 统一捕获异常,避免将堆栈信息暴露给前端:

@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 小时:

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)

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String[] value() default {};
    boolean anyLoggedIn() default false;
}

该注解支持两种模式:value 指定所需角色(满足其一即可),anyLoggedIn = true 表示任意已登录用户即可访问。注解可放在类级别(对所有方法生效)或方法级别(覆盖类级别配置)。

认证拦截器 (AuthInterceptor)

拦截器核心流程分为 5 步:

@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 中清理,避免线程池复用导致的内存泄漏:

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 在运行时组合两层信息做出最终权限判断。

系统角色层级

SUPER_ADMIN (100) > ADMIN (90) > ADVISOR (80) > PRESIDENT (70) > MEMBER (60) > GUEST (10)

RoleEnum 枚举通过 level 数值实现层级判断:高级别角色自动拥有低级别角色的所有权限。

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 是权限控制的核心服务,实现了社团级别的细粒度权限判断:

@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 范围:

public List<Long> scopedClubIdsOrNull() {
    if (isPlatformAdmin()) {
        return null;  // null 表示无限制
    }
    // 查询当前用户可见的社团ID列表
    return memberMapper.selectClubIdsByUserId(UserContext.getUserId());
}

各 Service 层在查询时根据返回值动态构建查询条件:

// 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,其他状态保持不变:

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 位签到码,同时自动创建活动审批申请:

// 创建活动
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 审批联动机制

审批通过后系统自动执行关联操作,这是业务逻辑设计的关键亮点:

审批联动的核心价值在于:一次审批操作触发后续多个关联实体的自动更新,避免人工逐条操作导致的数据不一致。审批处理代码如下:

// 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 配置

@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 实体类设计规范

所有实体类遵循统一的设计规范:

@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 与文件上传配置

@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 注入和错误处理:

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 路由守卫与权限控制

路由守卫实现了三层权限校验:

路由守卫的核心代码如下:

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()
})

路由权限配置示例:

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 和用户信息的管理:

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 开发代理配置

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 框架:

类职责
JwtUtilJWT 生成、解析、校验
RequireRole权限注解,声明接口所需角色
AuthInterceptor拦截器,解析 Token + 角色校验
UserContextThreadLocal 用户上下文
PermissionService细粒度社团级权限判断

6.2 前后端权限对齐

前端和后端使用相同的角色层级体系,确保权限判断的一致性:

  • 前端 hasRole(roles) 对应后端 RoleEnum.hasPermission(),均基于 level 数值比较
  • 前端 hasExactRole(roles) 对应后端 @RequireRole(value = {...}),均做精确匹配
  • 财务权限通过异步 API 调用 /finance/access 实时检查,前端缓存结果避免重复请求

6.3 角色自动刷新

当用户的社团关系发生变化时(如被任命为社长、被分配为指导老师),系统自动调整其系统角色:

核心代码实现如下:

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 全局配置自动处理:

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(每页条数)参数,返回格式为:

{
  "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 需要更新:

// 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,而是:

<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:

<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 做了重大调整,许多开发者升级时容易踩坑:

// 旧版本 (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 全栈开发的学习参考项目。