实训开始的第一周,我们组的项目文档里差点写出一部包罗万象的“万能系统”,后来被导师一句话点醒:“实训项目做不出太多功能,能做透一个真实场景就很好了。”最终我们选定的方向反而最简单:校园社团管理平台。这个选题听起来不够热门,但当你真正开始拆它的需求、跑它的业务流、调它的权限控制,会发现里面藏着的技术点一点都不少。如果你也正处在大三下学期的创新项目实训阶段,或者想找一个功能边界清晰、前后端都能练到的项目来练手,这篇博客应该能给你一些参考。我会把从选题、技术选型、库表设计到第一轮实现踩过的坑,尽量完整地记录下来。
1. 选题阶段:为什么最后选了校园社团管理平台
1.1 从“这也能做”到“这该做”,需求到底怎么来的
我们团队最开始提了三个候选方向:智能学习助手、二手交易平台、校园社团管理平台。智能学习助手听起来最“聪明”,但深入一聊就发现,资料爬取、推荐模型、对话交互,每一项都是大坑,三周之内做出来的东西大概率是半成品。二手交易平台看起来业务成熟,可支付、IM、信用体系这些难点会快速消耗团队精力,合规上也容易踩线。反观校园社团管理平台,它的用户角色非常明确:学生、社团负责人、学院管理员。核心场景也清晰:创建社团、成员加入、活动发布、活动审核、活动报名。
这个选择背后有一个容易被忽略的判断标准:项目要有“确定的主业务链”。所谓主业务链,就是用户进来之后,按照什么样的路径完成一次有价值的操作。在社团平台里,这条链是“管理员创建活动 -> 提交审核 -> 管理员审核 -> 学生浏览活动 -> 在线报名 -> 活动结束归档”。找到这条链,数据库的表结构、接口设计、权限控制就都有了骨干,团队成员分工时也不会出现“两个人同时改同一个模块”的混乱。
把需求来源拆开看,主要有三个:
- 学院现有社团活动多靠微信群接龙统计,组织者希望有一个集中的发布和报名通道。
- 社联管理部门需要审核活动申请,而不是等出问题后再介入。
- 学生希望活动信息有统一的展示入口,不用关注七八个群。
这三个来源分别对应了三个核心角色:活动发起人、审核人、参与人。一个项目只要有清晰的三类角色,就能天然导出权限设计和数据模型,这是很多宽泛选题不具备的优势。
1.2 技术选型到底听谁的,不是看谁火,而是看谁稳
技术选型是实训项目里最容易爆发争吵的环节。有的队友想用最新框架刷简历,有的觉得用熟的技术能保命。我们的原则很简单:先保证两周内能跑通主干流程,再谈亮点。
最终确定的技术栈如下:
| 层次 | 选型 | 用途 |
|---|---|---|
| 前端 | Vue 3 + Vite + Element Plus + Pinia | 页面搭建、状态管理 |
| 后端 | Spring Boot 3 + MyBatis-Plus | 业务接口、数据访问 |
| 数据库 | MySQL 8 | 业务数据存储 |
| 缓存 | Redis | 登录态、验证码、高频读数据 |
| 工程化 | Maven + npm | 构建依赖 |
| 部署 | Docker Compose + Nginx | 本地验证与统一部署 |
选型的核心逻辑可以概括成三句话。第一句:主技术栈选生态最成熟的,不选最先锋的。Spring Boot 3 和 Vue 3 已经是前两年的主流版本,资料多、踩坑记录全,遇到问题能搜到而不是全靠自己猜。第二句:中间件只加必须的,不加“锦上添花型”的。Redis 我们犹豫了很久,后来决定加,因为活动浏览量刷新频繁,验证码也需要一个带过期时间的存储。这个决定在后来的联调中证明是对的。第三句:明确的替代方案要写进文档,防止卡住后无路可走。比如分页我们默认用 MyBatis-Plus 自带分页插件,但文档里写明如果遇到版本兼容问题,直接改手写 SQL,绝不恋战。
这里想多说一句:实训团队里常常出现“方案争论僵持不下”的情况,我的经验是不要追求说服对方,而是定一个很简单的裁决标准:在同样的实现成本下,选谁出了问题更好排查。带着这个标准,大多数选项之争都能在十分钟内结束。
2. 项目初期的地基搭建:工程结构和数据库设计
2.1 前后端工程结构怎么分,才不辜负“分离”两个字
工程结构看着不起眼,却是初期工作量最大、后期扯皮最多的地方。一个混乱的结构会让两个后端同学改同一个模块,也会让前端面对十几个不知道调哪个的接口。我们最终的前端结构如下:
community-front/ ├── src/ │ ├── api/ # 按模块拆分的接口定义 │ ├── assets/ # 静态资源 │ ├── components/ # 通用组件 │ ├── router/ # 路由与守卫 │ ├── stores/ # Pinia 状态管理 │ ├── views/ # 页面视图 │ ├── utils/ # 请求封装、时间格式化等 │ └── App.vue后端按功能模块分包,而不是按技术类型分包。很多人一上来就建 controller、service、mapper 三个包,然后所有接口全往里面堆,结果就是“用户模块的多表查询”散落在三个毫无关联的目录里。我们换成了按业务拆包:
com.campus.community ├── common/ # 统一响应体、异常处理、工具类 ├── auth/ # 登录认证模块 ├── club/ # 社团管理模块 ├── activity/ # 活动管理模块 ├── user/ # 用户信息模块 └── audit/ # 审核流模块这个结构的价值在迭代到第二周时会越来越明显:改活动审核逻辑时,不需要同时打开五六个包来回跳,所有相关代码都在一个 package 里。给队友分任务也变得容易,一个人认领一个模块,互相之间只通过接口交互,不会出现改文件冲突。
2.2 数据库设计:把 ER 图变成表的时候,别只想着“够用”
数据库设计是这个项目里最需要耐心的一步。我们的核心表一共六张:user、club、club_member、activity、activity_audit、activity_signup,外加一张字典表用于存状态枚举的说明。
几个关键设计点上想展开说说。
user 表不直接把 role 字段做成字符串,而是用role_code关联项目里的枚举类。一开始有人建议直接存“学生”“管理员”,但后来发现role值在前后端都要参与判断,字符串容易因为多打一个空格就出 bug。我们最终用1表示学生、2表示社团负责人、3表示管理员。这样的冗余换来的是接口代码里user.getRole() == UserRole.ADMIN这类清晰判断,比到处拼字符串可靠得多。
activity 表和 activity_audit 表分开设计,是我们在评审中被重点表扬的地方。很多人的第一反应是在 activity 表上加一个“审核状态”字段,改状态就够了。但实际业务中,一次活动可能被驳回两次再通过,审核人、审核时间、驳回理由都不同,单字段只能看到最后一次结果,中间过程全部丢失。我们拆了一张审核记录表,activity 表只保存当前状态,每一次审核插一条记录,这样管理员能看到完整的历史轨迹。这个设计让后来做“进度追踪”功能时几乎没有额外成本。
活动报名表 activity_signup 做了一个小优化:加了一个cancel_status字段,默认 0 表示正常,1 表示已取消。为什么不用删除记录的方式?因为活动组织者需要看到“报名了但后来又取消”的人数,这是活动复盘的重要数据,物理删除会让这个信息彻底消失。类似这种“软删除优先”的思路,推荐大家在实训项目里尽量养成习惯。
数据库初始化 SQL 片段参考:
CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, description TEXT, location VARCHAR(200), activity_time DATETIME, max_people INT DEFAULT 50, current_people INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT '1草稿 2待审核 3已发布 4已结束 5已驳回', club_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE activity_audit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, auditor_id BIGINT NOT NULL, audit_status TINYINT NOT NULL COMMENT '1通过 2驳回', reason VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );一个很值得新手注意的点:表设计时一定要统一命名规范。我们约定布尔字段用is_前缀,时间字段统一xxx_time,业务状态统一用status或xxx_status。看上去很死板,但后面写 MyBatis-Plus 的 QueryWrapper、做列表排序时,这套一致性省了无数查字典的时间。
3. 核心功能模块实操:登录鉴权和活动审核流
3.1 登录鉴权:一个 JWT 方案在一周内踩出的完整坑位
用户认证是几乎所有项目都会遇到的功能,但它绝不是写一个 login 接口那么简单。我们选择的方案是 JWT access token 存前端本地,配合 Redis 存储 refresh token。这个方案不是最复杂的,却是团队两周内能稳定实现的平衡点。
Token 的生成逻辑:
String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) .signWith(secretKey) .compact();JWT 里只放用户 id 和角色,不放任何手机号、学号等敏感信息。因为 token 在前端是可见的,即使只是 base64 编码后的内容,被人篡改角色的风险也真实存在。虽然引入了密钥签名机制可以防篡改,但从最小权限原则出发,少放字段就少一截风险。
前端拦截器的处理逻辑:
service.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { // 尝试用 refresh token 刷新 access token refreshToken().then(() => { location.reload() }) } return Promise.reject(error) } )这里有两个细节想提醒。第一,401 处理不能只跳转登录页,因为用户在某个页面操作时 token 过期,如果直接清掉本地存储跳登录页,他刚才填写的表单数据就全丢了。先刷新 token 再自动重试,体验会好很多。第二,access token 有效期我们设的是 30 分钟,refresh token 是 7 天。这个时间比例不是随便定的,是按“用户一次实训操作的平均时长”来估计的。太短会让用户频繁重新登录,太长又放大了泄露风险。对实训项目来说,30 分钟是个比较流畅的刻度。
后端写了一个简单的拦截器校验 token:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", Integer.parseInt(claims.getSubject())); return true; } response.setStatus(401); return false; }这个拦截器只做了“这个 token 是不是有效”的校验,真正判断当前用户有没有权限操作某接口,还需要在 Service 层里再做一层判断。拦截器管“你是谁”,Service 管“你能做什么”。两层颠倒过来就会出现权限绕过漏洞,比如一个普通学生把接口路径改成管理员的请求就能通过,很多人都栽在这上面。
3.2 活动审核流:为什么可以用状态机思维替代一堆 if else
活动发布和审核是项目里最核心的业务,也是我们第一次体验到“状态机”价值的地方。刚开始写这个功能时,最容易想到的办法是在 Service 里堆 if else:如果当前状态是 1 就改成 2,如果是 2 就不能改……这种写法在只有四五个状态时勉强能用,但每加一个状态就要把所有方法翻出来改一遍,非常容易漏。
我们后来把所有状态和转移规则集中到了一个枚举类里:
public enum ActivityStatus { DRAFT(1, "草稿"), PENDING(2, "待审核"), PUBLISHED(3, "已发布"), FINISHED(4, "已结束"), REJECTED(5, "已驳回"); private final int code; private final String desc; }真正的状态转移规则我们手写了一个小校验表:
- DRAFT 可以提交到 PENDING
- PENDING 可以被审核为 PUBLISHED 或 REJECTED
- REJECTED 可以修改后重新提交到 PENDING
- PUBLISHED 可以手动结束为 FINISHED
每条规则背后都有一个业务含义。比如为什么已驳回的活动可以直接重新提交而不用先回草稿?因为驳回时管理员已经填了理由,发起人修改之后再次提交如果还要先改回草稿,那点一下变成“待审核”不更直接吗?业务上“减少无效操作步骤”有时候比“严格分层”更重要。
这部分的实现代码并不复杂,关键在 Service 层做“当前状态判断”时,用枚举而不是散落的 magic number。我们的审核接口里有一行核心判断:
if (!ActivityStatus.PENDING.equals(currentStatus)) { throw new BusinessException("该活动当前状态不允许审核"); }这一行的意义在于,把状态流转的“合法性”集中在一处校验,后续任何入口想走审核流程都会撞到这堵墙。前端也做了交互层的限制,比如待审核的活动隐藏“编辑”按钮,但后端判断绝不能省略——前端限制只是 UI 体验,后端校验才是真正的保护。
审核记录的产生逻辑里还预留了一个扩展点:每次审核都在 activity_audit 表插入记录,同时更新 activity 表的当前状态。这个两步操作必须放在同一个事务里,否则就会出现“记录了审核结果但状态没变”的脏数据。当时我们写完后这一块的单元测试特意覆盖了这个场景,为后面联调省了不少事。
4. 团队协作里最容易被低估的环节:Git 分支模型和代码评审
4.1 Git 分支模型不是越高深越好,够用且统一才是关键
实训团队最容易在 Git 协作上出的事故是:主力队员直接在 main 分支上开发,其他人拉下来代码时发现功能被覆盖;或者所有人都在一个分支上提交,提交记录一团乱,根本没法定位是哪次改动引入的 bug。
我们用的模型很简单:main 分支保持稳定,dev 分支是集成分支,每个功能从 dev 拉出一个 feature 分支。个人开发的完整流程是:
git checkout dev git pull origin dev git checkout -b feature/activity-audit # 开发、自测 git add . git commit -m "feat: 完成活动审核状态流转" git push origin feature/activity-audit # 在远程仓库发起合并请求有人会觉得这套流程太基础,但实训项目里大多数问题恰恰是因为“听起来太基础所以不做”造成的。有一次我们某位同学在 main 分支上直接改代码,结果另一个同学在 dev 的合并请求里出现了大量冲突,那天下午全组就花在解决冲突上。后来我们定了一条死规矩:任何人都不能直接往 main 和 dev 分支上推代码,所有改动必须经过合并请求和至少一人评审。从那以后,分支冲突再没有成为主要问题。
合并请求的模板我们也做了统一,包含三个部分:改动说明、测试方式、影响范围。测试方式这个字段特别重要,它强制提交者写清楚“我本地怎么验证的”,评审人拿到之后就能按图索骥复跑一遍,省去大量口口相传的时间。
4.2 代码评审不是走形式,set 一个“最少两人看”的门槛
实训项目的代码评审,很容易滑向两种极端:一个不看流程直接合并,另一个把评审做成“找茬大会”让提测的同学改到自闭。我们的评审清单很简单,只看四个方面:
- 接口是否有越权风险(角色判断有没有只存在于前端)
- 异常分支有没有覆盖(比如输入非法值会不会直接 500)
- 查询是否有明显的 N+1 问题
- 命名和结构是否符合模块内约定
评审中我们抓到过两次比较有价值的问题。一次是分页接口里的查询条件可以在前端通过修改参数绕过权限,另一个是多表查询时在循环里调数据库,虽然数据量小没暴露,但一旦真实数据量上来性能会急剧恶化。这类问题写在合并请求评论里,比事后出了 bug 再定位容易得多。
统一评审标准还有一个附带收益:新加入的成员能通过看合并请求快速了解项目写法和约定,不用逐个问人。实训项目团队往往有这么一两个新人,这套清单对他们来说等于是一份隐形的入门文档。
5. 联调阶段的踩坑实录:跨域、时区、静态资源
5.1 跨域是最会伪装的“小问题”,其实根子在后端配置
联调第一天,前端打开页面调接口,控制台报了一串 CORS 错误。当时我们的第一反应是在前端脚手架里开一个 proxy 转发。这个方案在本地开发时确实有效,但仔细一想就会发现问题:上线部署后,前端资源和后端接口会放在同一个域名下由 Nginx 统一代理,proxy 的配置在生产环境根本不生效。如果项目里全是跨域依赖,部署阶段会炸得更惨。
正确的做法是在后端加一个全局 CORS 配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE"); } }这个配置限定的是“开发环境下只允许本地前端地址访问”,比直接允许所有来源安全得多。上线时,则由 Nginx 在同域下转发请求,不再依赖这个配置。跨域问题真正要记住的是:本地开发能跑通不算完,部署拓扑决定了最终方案,开发环境的选择最好一开始就朝着部署环境靠。
5.2 同一段代码,本地和服务器上表现不一样,十有八九是环境问题
排查过最魔幻的一个问题是:接口在本地返回的日期格式正常,部署后返回的却是一串数字。后来定位到是 Jackson 序列化 LocalDateTime 时的格式配置不同导致的结果。本地环境读取的是开发机器的默认配置,服务器上则走了默认的 ISO 格式。这类问题用“在本地再试一次”是解决不了的,只有把配置明确写出来才靠谱。
我们在 application.yml 里做了一个全局约定:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai datasource: url: jdbc:mysql://localhost:3306/campus_community?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai时区配置真的值得单独拿出来提醒。数据库连接串里的serverTimezone=Asia/Shanghai和 JSON 序列化的时区设置是两个独立的东西,少了任何一个,时间数据都会在链路的某个环节悄悄偏移。实训项目里本地电脑和服务器往往不在一台机器,这种环境差异会被放大成“为什么同一个接口数据不一样”,排查思路要从“代码 bug”转向“环境差异”。
5.3 静态资源访问 404,第一次认识 Nginx 的 location 匹配顺序
部署阶段,我们的 Spring Boot 服务运行在 8080 端口,前端打包后的静态文件放在 Nginx 的 html 目录。一开始配置的 Nginx 规则很粗暴:location / { proxy_pass http://127.0.0.1:8080; },结果前端页面能打开但所有接口都不通。后来调整成:
server { listen 80; location /api/ { proxy_pass http://127.0.0.1:8080; } location / { root /data/community-front/dist; try_files $uri $uri/ /index.html; } }location /api/把接口请求转发给后端,location /负责静态文件。这套配置真正理解了 Nginx location 匹配优先级:带/api/的前缀匹配优先级高于普通/,所以接口请求不会和静态资源路由打架。如果你在实训项目里也遇到“页面能开、接口全挂”,先想想是不是把所有请求都代理给了后端,把静态文件漏在了外面。
6. 阶段复盘:实训项目做到这里,我最真实的三个感受
第一次完整地做完一个从需求到部署的闭环,体验和平时写练习项目完全不同。最明显的感受是:实训项目不是在“写代码”,而是在“做决策”。技术选型、库表设计、分支模型、接口边界,每一个环节都是一次取舍,而最有效的判断标准始终是这个选择能不能让团队在有限时间内稳定交付。
第二点感受是:项目文档必须走在代码前面。我们花了两天整理需求文档和接口文档,当时觉得耽误进度,后来发现接口文档就是团队内部沟通的通用语言。前端同学按文档联调,后端同学按文档自测,评审按文档核对功能,省下来的沟通成本远比写文档的投入大。
第三点体会给准备做类似项目的同学:不要把亮点剧情设计成“用了某个高深算法”,而是设计成“某个业务流程做得特别稳”。实训答辩时,评审更想听你怎么拆需求、怎么保证数据一致性、怎么排查线上问题,而不是这个 Redis 缓存设计得多么精妙。稳扎稳打做完一个真实场景,比做出一个半成品的炫技系统有价值得多。
下一阶段我们要做消息通知模块和活动数据统计,到时候再来分享这一轮踩到的新坑。