说实话,校园社团管理这块需求一直挺拧巴的。场地不大,功能看着也不复杂,但真正去对接社团联合会、各社团负责人、普通社员的时候,你会发现信息流特别散:报名靠微信接龙,成员名单靠会长手动更新Excel,活动审批靠纸质单子跑签字,到年底评优的时候才发现数据根本对不上。去年我接手了某高校社团联合会的数字化改造项目,就用SpringBoot+Vue把这套系统从零撸了出来。这个项目本身不算难,但胜在业务流程完整,涉及权限分级、活动报名、社团成员生命周期管理、审批流、数据统计分析,特别适合想系统掌握前后端分离开发的开发者拿来练手,也适合作为毕业设计或课程设计的完整参考。
整个技术栈是Java + SpringBoot + MySQL + MyBatis做后端,前端用Vue + Element UI,前后端完全分离。这套组合在目前国内开发者的接受度很高,源码组织也相对规范,后续扩展学分认定、社团评级、二课积分对接都非常方便。下面我把设计和实现的关键环节拆开讲,每一步都会说明“为什么这样做”,而不是只丢代码。
1. 社团管理现状与这个系统的核心定位
1.1 为什么用SpringBoot+Vue而不是单体模板
很多同学做类似系统,第一反应是找一个JSP + Servlet的老模板改一改,或者直接上手一个SSM单体项目。但实际操作下来,校园社团管理系统的需求有几个特点,决定了前后端分离更适合:
第一,客户端类型会持续增加。初期只有电脑端管理,后续学生几乎必然要求手机端查看活动、扫码报名、接收通知。如果一开始就用前后端分离的结构,后端API可以同时服务Web管理端和未来可能的移动端,不需要重写业务逻辑。
第二,权限模型比较复杂。系统里至少要区分系统管理员、社团联合会审核人员、社团负责人、普通社员,不同角色看到的页面和操作按钮都不一样。后端只负责提供接口和校验权限,前端通过动态路由渲染不同菜单,逻辑更清晰。
第三,部署环境相对简单。校园内网服务器配置普遍有限,Nginx托管Vue静态文件加反向代理Java后端,这种模式成熟稳定,资源占用也小。
1.2 功能范围:三类角色与五个核心模块
我在需求调研阶段找社团联合会的工作人员和各社团负责人聊过,把零散的需求整理成三个核心角色、五个业务模块:
- 系统管理员:负责用户管理、全局配置、数据备份。
- 社团联合会:负责社团成立审批、活动审批、年度考核。
- 社团负责人:负责成员管理、活动发起、通知发布、物资申请。
- 普通社员:浏览社团、报名活动、查看自己的参与记录。
五个核心模块分别是:社团管理、成员管理、活动管理、审批管理、数据统计。很多社团还会要求加入“学分/二课积分记录”模块,这个我会在数据库设计里提前预留字段,避免后期改表结构。
这里有一个非常关键的取舍:业务功能不要一开始就做全。第一版我只做“发布活动 + 在线报名 + 成员审批 + 社团信息展示”,审批流和数据分析放到第二版。项目启动后最大的风险是范围蔓延,一旦某个社团负责人说“能不能顺便加一个社团内部投票”,你顺着加功能,项目就可能无限期拖延。
2. 数据库设计:10张表如何撑起整个业务流程
2.1 用户、角色与社团的关系建模
这套系统的数据库设计我花的时间最多,因为表结构一旦定下来,后端和前端都要贴着它走。最终核心表控制在10张左右,避免过度设计。
第一组是账号权限相关的表:用户表、角色表、用户角色关联表。用户表的核心字段如下:
CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) UNIQUE NOT NULL COMMENT '学号/工号', real_name VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, email VARCHAR(100), phone VARCHAR(20), avatar VARCHAR(255), status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );很多课设项目里有独立的角色表(admin、user、club_manager),但这套项目里用户与社团的关系是多对多的:一个学生可以同时加入多个社团,在一个社团是普通社员,在另一个社团可能是负责人。所以我额外设计了“社团成员表”,而不是把社团角色字段直接放到用户表里。
社团成员表是这个系统的核心关联表,它的设计直接决定后面功能好不好写:
CREATE TABLE club_member ( id INT AUTO_INCREMENT PRIMARY KEY, club_id INT NOT NULL, user_id INT NOT NULL, member_role TINYINT NOT NULL COMMENT '1社长 2副社长 3干事 4普通成员', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已拒绝 3已退出', join_time DATETIME, exit_time DATETIME, UNIQUE KEY uk_club_user (club_id, user_id) );注意这里有一个细节:唯一索引用的是 club_id + user_id。什么意思?同一个用户同一个社团只能有一条成员记录,后续报名、查看数据都依赖这条记录。但实际业务里会出现“退出社团重新申请”的情况,所以不能物理删除记录,而是通过 status 字段控制状态流转,保留完整历史。
社团表本身反而不复杂,重点是几个冗余字段:成员数量、活动数量、成立时间、指导老师。成员数量和活动数量是典型的反范式设计,虽然可以通过count查询实时算出来,但社团列表页、首页排行、年度考核统计都需要频繁展示成员规模,每次都去count会拖慢SQL,我选择在写入成员记录时同步做冗余计数。
2.2 活动报名与审批记录的核心表
活动模块是这个系统真正的业务重心。活动表、活动报名表、审批记录表三张表缺一不可。
活动表的关键字段要考虑:报名开始时间、报名截止时间、活动开始时间、活动地点、报名人数上限、当前已报名人数、活动状态(草稿、待审核、已发布、已结束、已取消)、活动类型(讲座、比赛、志愿、培训)。
CREATE TABLE activity ( id INT AUTO_INCREMENT PRIMARY KEY, club_id INT NOT NULL, title VARCHAR(100) NOT NULL, description TEXT, activity_type TINYINT COMMENT '1讲座 2比赛 3志愿 4团建', location VARCHAR(200), start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, signup_start DATETIME NOT NULL, signup_end DATETIME NOT NULL, max_people INT DEFAULT 50, current_people INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT '0草稿 1待审核 2已发布 3已结束 4已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_club_id (club_id), KEY idx_start_time (start_time) );活动报名表的设计没有太大悬念,唯一要注意的是加一个 UNIQUE KEY 防止同一用户重复报名:
CREATE TABLE activity_signup ( id INT AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL, user_id INT NOT NULL, signup_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT '1已报名 2已签到 3已取消', UNIQUE KEY uk_activity_user (activity_id, user_id) );至于审批记录表,很多项目会忽略,结果就是活动审核流程没有留痕,出问题无法追责。我的做法是独立一张审批表,记录审批对象类型(社团成立、活动发布、物资申请)、审批人、审批结果、审批意见、审批时间。
2.3 表设计时的几个关键取舍
这里分享三个我踩过的坑,都是真实项目中会遇到的。
第一,数据库里统一用下划线命名,字段全部用DATETIME类型。MyBatis开启驼峰映射之后,Java实体类的createTime可以自动映射到create_time,少写很多别名。
第二,不要用物理删除。社团成立是历史记录,活动取消是历史记录,用户退出社团也是历史记录。全套系统只做逻辑删除,表里统一加status或deleted字段。这样年度考核、数据统计、行为追溯都有数据可依。
第三,冗余字段必须想清楚更新场景。我在社团成员表里维护成员数量,那么每个加入、退出、审核拒绝的操作都需要同步更新club表的member_count。这不是一个字段的事,而是一连串事务操作。如果嫌麻烦,宁可不要冗余字段,直接SQL聚合,避免出现数据不一致。
3. 后端实现:JWT鉴权、权限控制与MyBatis持久层
3.1 登录流程与Token续期机制
后端我用的是SpringBoot 2.x版本。登录这套没有用传统Session,而是用JWT做无状态认证。原因很简单:前后端分离部署后,可能会配置多台后端实例,Session同步很麻烦;JWT天然支持水平扩展,解析速度快。
核心流程是:用户提交学号和密码,后端校验通过后签发JWT,JWT里只放userId、角色标识、过期时间三个信息,然后返回给前端。前端把Token存在localStorage,每次请求通过Authorization头带上,后端拦截器统一校验。
这里有一个很容易被忽视的细节:JWT过期时间不宜太长但也不能太短。我设置成2小时,同时配合前端axios响应拦截器,当接口返回401时自动跳转登录页。2小时对于学生课间操作来说其实是够的,但如果学生写活动总结写了很久突然提交,Token刚好过期导致内容丢失,体验非常差。所以我在前端做了一件事:在localStorage里记录Token的签发时间,距离过期不足10分钟时主动调用刷新接口换取新Token。
刷新Token接口实际上就是校验旧Token有效后重新签发一个新的,不需要单独的refresh_token体系。对校园应用来说这个复杂度够了。
登录的密码存储用的是BCrypt加密。很多人图省事直接用MD5,但MD5在现在算力下几乎等于明文,学生选课系统这种级别的数据库一旦泄露,整个学号密码库都可能被撞库。虽然一个社团管理系统不是什么高价值目标,但我还是建议从一开始就养成BCrypt的习惯。
3.2 MyBatis动态SQL与一对多查询
MyBatis在这个项目里负责所有数据库操作。我没有用MyBatis-Plus,这是因为课题说明明确写了MyBatis,而且手写动态SQL能更好理解数据库细节。但如果你是为了快速实现,用MyBatis-Plus会省下大量CRUD代码。
项目里使用MyBatis最核心的场景是复杂查询。比如社团列表页需要同时展示社团名称、简介、成员数、社长姓名、指导老师、本周活动数,这个SQL要用LEFT JOIN带聚合子查询:
<select id="selectClubListWithStat" resultMap="ClubResultMap"> SELECT c.*, m.member_count, a.activity_count, u.real_name AS president_name FROM club c LEFT JOIN ( SELECT club_id, COUNT(*) AS member_count FROM club_member WHERE status = 1 GROUP BY club_id ) m ON c.id = m.club_id LEFT JOIN ( SELECT club_id, COUNT(*) AS activity_count FROM activity WHERE status = 2 GROUP BY club_id ) a ON c.id = a.club_id LEFT JOIN sys_user u ON c.president_id = u.id <where> <if test="keyword != null and keyword != ''"> AND c.club_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="category != null"> AND c.category_id = #{category} </if> </where> ORDER BY c.create_time DESC </select>这段SQL让我学会一个道理:能用LEFT JOIN加GROUP BY聚合解决的,尽量别用子查询套子查询。第一版我用的是嵌套子查询,数据量小的时候没问题,但社团列表页一旦加载慢,大量时间都花在内层子查询上。改成LEFT JOIN + 聚合临时表之后,整个列表页接口从300多毫秒降到50毫秒内,效果非常明显。
一对多查询是另一个高频场景。比如“活动详情”需要返回活动基本信息 + 报名列表 + 社团信息,如果用一条SQL查活动,再循环查报名列表,就是典型的N+1问题。我的做法是用MyBatis的collection嵌套结果映射:
<resultMap id="ActivityDetailMap" type="Activity"> <id property="id" column="id"/> <result property="title" column="title"/> <collection property="signupUserList" ofType="User"> <id property="id" column="uid"/> <result property="realName" column="real_name"/> </collection> </resultMap>Java对象Activity里声明一个List<User> signupUserList,查询用LEFT JOIN,一条SQL拿到所有数据,MyBatis会自动把同一活动下的多行对象组装成列表。
3.3 文件存储与静态资源映射
社团管理系统绕不开图片上传:社团Logo、活动海报、用户头像。这部分我踩过一个很实际的坑:开发时用户上传的图片存在本地路径C:/upload,但是项目打包部署到Linux服务器后,路径找不到,所有图片404。
最终的方案是:配置文件中设置upload.dir作为图片存储根目录,SpringBoot通过addResourceHandlers把该目录映射到 /upload/** 的URL路径。用户浏览图片时访问的是 /upload/xxxx.jpg,实际读取的是服务器的真实磁盘目录。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }注意upload.dir要以/结尾,路径用绝对路径。这个方案虽然简单,但通过Nginx部署时还可以把 /upload 直接交给Nginx处理,Java后端连静态资源都不需要碰。
存储命名上我统一用UUID生成文件名,避免中文名和重复名引起的路径问题。同时把实际文件名存在表里,页面展示用原始名称,磁盘文件用UUID名称,隔离性强。
4. 前端Vue实现:动态菜单、组件复用与请求封装
4.1 axios拦截器与Token注入
前端用的是Vue2 + Element UI,这套组合在中文社区资料最多,遇到问题基本一搜就有答案。关于为什么不用Vue3,我个人的看法是:如果项目从零开始,Vue3 + Vite当然更好;但如果系统里已有大量基于Vue2管理后台模板、组件库和依赖生态,为了稳定性和交付时间,Vue2依然是性价比高的选择。
axios封装是前端架构第一步。我在项目根目录建了utils/request.js,统一管理所有HTTP请求:
import axios from 'axios' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' return Promise.reject(new Error('未登录或登录已过期')) } return res }, error => { const message = error.response?.data?.message || '网络异常' console.error(message) return Promise.reject(error) } )这套封装的核心思路是:统一处理Token注入和错误提示,业务组件里只关心数据流,不关心状态码判断。所有后端接口返回的格式也统一成{ code: 200, data: ..., message: 'ok' },这样前后端联调时不会因为返回格式不统一而扯皮。
4.2 动态路由和角色菜单
权限控制在前端的实现比很多人想象的要复杂。后端角色有管理员、社团联合会、社团负责人、普通社员,前端不能让普通社员看到“审批管理”菜单,也不能让社团负责人看到“系统设置”。
我在登录接口返回里带上了两个关键字段:roles数组和menus数组。roles是角色标识,menus是按角色过滤后的菜单列表。前端拿到menus之后动态添加路由,而不是在router/index.js里写死所有路由。
function addDynamicRoutes(menus) { menus.forEach(menu => { router.addRoute({ path: menu.path, component: () => import(`@/views/${menu.component}`), meta: { title: menu.title, icon: menu.icon } }) }) }这里有一个实际经验:Vue Router 4移除了router.addRoutes方法,只能逐个addRoute。而Vue2对应的Vue Router 3仍然支持addRoutes,但一次性添加后刷新页面路由会丢失。所以我必须在路由守卫里每次刷新都重新同步一次,判断当前路由是否已存在,不存在才addRoute。
菜单配置我选择存后端数据库表,而不是写死在前端。好处是未来系统管理人员可以动态调整菜单顺序、新增外部链接菜单,不需要改代码重新打包。坏处是开发时多写一张菜单表,前端多一个根据菜单配置生成路由的逻辑。对于毕设和实际管理系统,这个成本值得花。
4.3 报名流程的前端交互设计
活动报名这块,我遇到过很多交互问题。最典型的是:用户点击报名按钮后,后端接口调用失败,但页面没给任何反馈,用户以为报名成功了,结果活动现场签到名单上没有他的名字。
我在活动详情页做了一套完整的报名状态机:
- 未登录:点击报名跳转登录页,登录后自动带回活动页。
- 已登录但未加入社团:弹窗提示需要先加入该社团,并提供跳转链接。
- 已开始但未到报名时间:按钮置灰,显示“报名未开始”。
- 已到截止时间:按钮置灰,显示“报名已截止”。
- 人数已满:按钮置灰,显示“名额已满”。
- 已报名:按钮显示“已报名”,点击后展开取消报名确认框。
- 活动已取消:不再展示报名入口。
前端把所有状态都预先考虑,而不是等用户点下去再提示。这个交互原则对任何管理系统都适用:用户能做的合法操作和非法操作,界面都要提前给足信息。
组件的复用方面,我把“人员选择器”抽成了全局组件。社团负责人创建活动时需要添加负责人、设置签到管理员;联合会审批时需要选择审批人。这个选择器需要支持按用户姓名/学号搜索、多选、回显,抽成组件后省了大量重复代码。
5. 联调阶段踩过的坑:跨域、事务与会话一致性问题
5.1 跨域配置的一个细节
前后端分离必然遇到跨域问题。开发阶段我在后端写了一个CorsConfig,放行所有跨域请求,因为是开发环境,配置宽松没问题。
但部署到同一台服务器的Nginx下,前端请求指向的是相对路径/api,由Nginx将请求转发到后端8080端口。这时如果后端仍然配置了allowedOriginPatterns("*")并且allowCredentials(true),浏览器会直接报跨域错误,因为Nginx转发后请求的Origin还是前端域名,而后端响应头的Access-Control-Allow-Origin不允许携带凭证。
最终我建议的稳妥方案:开发环境用后端CORS配置放行本地地址,上线后由Nginx统一处理跨域,后端不再开启跨域。这样做还能顺便减少后端不必要的OPTIONS预检请求,接口响应速度更快。
5.2 @Transactional失效的真凶
这个坑我记得很清楚。事情是这样的:学生报名活动后,需要同时写入活动报名表、更新活动表current_people、给用户加二课积分记录,我写了一个addSignup方法,加了@Transactional注解,但测试发现报名记录写了三份,活动人数并没有加,积分也没入账。
排查了半天,最后发现问题出在:我在同一个Service内部调用了另一个带@Transactional的方法,而Spring的事务是基于代理的,同类内部调用不会经过代理,所以这个注解完全没生效。
正确的做法有三种:把需要事务控制的代码拆分到另一个Service类里,通过注入调用;或者用AopContext.currentProxy()获取代理对象再调用;或者干脆在一个方法里把事务范围明确标到入口方法上。
最终我的方案是拆分Service。报名入口方法放在ActivitySignupService,需要调用的积分增加逻辑拆到CreditRecordService,由入口方法统一加@Transactional。这样事务粒度清晰,后续排查也好定位。
5.3 活动并发报名导致的数据问题
活动报名模块上线第一天就遇到一个数据bug:热门活动名额设置为500人,实际报名成功达到了520人。
问题根源是典型的超卖:多个用户同时请求报名接口,每个请求都先查询 current_people 是否小于 max_people,如果是,就执行INSERT。因为查询和插入之间没有加锁,两个请求可能同时看到499,然后同时插入,最后current_people变成了501。
我没有用Redis分布式锁,对这个系统来说太重了。解决方式是在数据库层面加条件更新:
UPDATE activity SET current_people = current_people + 1 WHERE id = #{activityId} AND current_people < max_people AND status = 2执行这条更新语句后,如果影响行数为0,说明名额已满或活动不在可报名状态,后端直接返回“报名失败:名额已满或活动已关闭”。先做条件更新,再插入报名记录,两步操作放在一个事务里,就彻底解决了超卖问题。
这里还能延展出另一个经验:所有涉及数值递增、名额扣减的更新操作,都应该用SQL自更新而不是先查后算。先查后算在单用户场景没问题,一旦并发上来就必出问题。
6. 打包部署、环境配置与完整复现指南
6.1 前后端分离部署的两种方式
这个项目部署实际上有两种路线,我都验证过。
路线一:简单版。前后端都丢到同一台Tomcat或SpringBoot内置容器里。前端build后的dist目录复制到resources/static目录下,让SpringBoot直接托管静态页面。这种方式文件最少、部署最省心,但不适合前后端继续分头开发。
路线二:标准版。前端dist部署到Nginx的html目录,后端SpringBoot独立跑在8080端口(或者改成8443),Nginx把/api路径代理到后端。
路线二是我实际使用的方案,核心Nginx配置如下:
server { listen 80; server_name your-domain; root /www/app/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { alias /www/app/upload/; } }注意proxy_pass后面带不带斜杠是有区别的。proxy_pass http://127.0.0.1:8080/;会把/api前缀去掉,转发到后端的根路径,和后端Controller定义的接口路径正好对上。如果写成不带斜杠的http://127.0.0.1:8080,Nginx会保留原始URI前缀,后端收到 /api/xxx 很可能匹配不到路由。
前端Vue调用后端接口时,我用的是相对路径/api,同时还配置了VUE_APP_BASE_API环境变量。开发环境中后端接口前缀加上/api,部署时由Nginx处理,这样前端代码里不需要区分开发和生产环境。
6.2 多环境配置与日志
SpringBoot的多环境配置我用得比较熟练。application.yml只保留公共配置,application-dev.yml配置本地数据库和上传目录,application-prod.yml配置线上数据库、Nginx地址以及日志路径。
日志方面我强烈建议开启MyBatis的慢SQL监控:
mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl开发阶段开StdOutImpl很方便,能直接看到SQL和参数;生产环境记得换掉,不然日志会膨胀。更合理的做法是配置Logback把MyBatis日志单独输出到一个文件,设置超过500ms的执行SQL记录为error级别,方便后期排查性能问题。
实际上线后我发现,虽然每个活动查询单条SQL都是毫秒级,但活动列表页因为要和社团表、用户表、报名表多次关联,慢SQL查询超过300毫秒的情况很常见。后来在活动表增加了联合索引 idx_club_status_start(club_id, status, start_time),列表页查询稳定在200毫秒以内,时间主要花在排序和分页count上,这个索引收益非常明显。
6.3 完整源码结构与复现步骤
按照一个可交付的标准,项目源码分成三个目录:club-server 是SpringBoot后端,club-web 是Vue前端,sql 目录存放数据库初始化脚本。
后端类分包结构是:
- config:CORS配置、MyBatis配置、拦截器配置
- controller:按模块划分,ActivityController、ClubController、AuthController
- service:业务逻辑层,接口和实现分离
- mapper:MyBatis接口类,XML写在resources/mapper下
- entity:与数据库表对应的实体类
- dto:前端传参对象,避免实体类直接暴露
- common:统一返回结果、异常处理、常量定义
复现的步骤,按我的经验建议这样走:
第一步,执行 sql/init.sql,把数据库表结构和演示数据一起建好。演示数据很重要,包含几个社团、几十个用户、几条活动记录,不然登录进去全部是空页面,前端很多交互看不到效果。
第二步,改 application-dev.yml 里的数据库连接信息,把账号密码换成你自己的。
第三步,启动后端,确认本地8080端口能访问Swagger或接口文档。我这里用的Knife4j增强文档,比原生Swagger UI好看很多,测试接口非常方便。
第四步,前端 club-web 运行 npm install,然后 npm run dev,启动后本地访问 http://localhost:8081。前端开发服务器通过Vue的代理配置把 /api 转发到本机8080后端。
第五步,联调通过后,npm run build 生成dist,参考上面的Nginx配置部署。
在整个复现过程中,我建议你先跑通“发布活动—报名—审批”这条主线,再去看统计报表和成员管理。这条主线覆盖了系统80%的权限逻辑和状态流转,吃透它比两眼一抹黑地看完整代码有效得多。
这个项目跑起来之后,某高校社团联合会的负责人反馈,往年年底评优需要花一周时间整理各社团的活动记录、成员规模数据,现在系统里直接导出报表,半天就能搞定。做这种系统给我最大的体会就是:性能优化、复杂设计都是后话,把业务状态流转理清楚、把表关系设计到位,比什么都重要。如果你正准备复现这套SpringBoot+Vue的社团信息管理系统,建议优先花时间在数据库表和事务边界上,这两块做扎实了,整个项目的骨架就稳了。