每年三四月份,帮人看毕设的时间比写自己代码的时间还多。被问得最多的题目里,“校园活动发布系统”常年稳居前三——看起来需求清楚、场景熟悉、难度适中,好像随便套个增删改查就能交差。真动手做的人才知道,这题目的坑全藏在细节里:报名名额被抢超了、活动时间和上课冲突没拦住、审核流程走到一半状态乱了、开题报告里“国内外研究现状”写成了产品说明书。我前后完整过手过几个类似的项目,也帮人改过从网上扒下来的源码,这里把“源码怎么搭”和“开题怎么写”这两件事掰开讲透,从需求拆解、表结构设计、并发报名的处理,一直讲到开题报告每一节该填什么、答辩时会被追问哪几个点。不管你是刚接触 Spring Boot 的新手,还是已经写完后端只差文档收尾的老手,这篇里的思路和踩坑记录都能直接拿去用。
1. 这个题目年年有人做,翻车点却年年重复
先说清楚这类系统到底在解决什么问题。一所学校每天产生的活动信息是分散的:社团在自己的群里发海报,学院在官网上挂通知,体育馆的场地预约又是另一套表格。学生想知道“这周有哪些能加素拓分的活动”,得同时在五六个渠道里翻。校园活动发布系统做的事情很朴素——把活动的发布、审核、展示、报名、签到、统计收到一个入口里,让组织者发一次就够,让学生看一处就全。
功能清单听起来不多,但只要往真实场景里放,立刻会冒出三个绕不开的约束。
第一是角色的边界不清。很多人一开始只设计“管理员”和“学生”两个角色,写着写着发现不对:社团负责人要能发布活动,但发布完不能自己给自己审核通过;学院老师要能审核本学院的活动,但看不到别的学院的;普通学生只能报名不能改名额。角色一旦拆不干净,后面所有接口的权限判断都会变成一团 if-else。
第二是状态流转比想象的复杂。一个活动从创建到结束,中间会经历“草稿—待审核—审核驳回—已发布—报名中—报名截止—进行中—已结束—已取消”这一串状态。每两个状态之间不是随便就能跳的,比如“已结束”不能退回“报名中”,“审核驳回”之后修改再提交要能重新进入待审核。状态机没设计好,测试的时候就会出现“已取消的活动还能报名”这种让人哭笑不得的 bug。
第三是并发。这是最容易被忽略、也最容易被答辩老师抓住问的点。一个 50 个名额的讲座,开放报名的那一秒可能有 200 个人同时点“立即报名”。如果你的代码是“先查当前报名人数,小于 50 就插入”,那几乎必然出现超卖——两个请求同时查到 49,都判定通过,最后变成 51 个人。
下面这张表是我整理的高频翻车点,也是后面几章要逐个解决的清单。
| 翻车点 | 典型表现 | 根因 |
|---|---|---|
| 角色权限混乱 | 学生调接口能删活动 | 只在页面隐藏按钮,后端没校验 |
| 状态乱跳 | 已结束活动还能报名 | 缺少状态机与前置校验 |
| 报名超卖 | 名额 50 实到 53 | 查与写不是原子操作 |
| 时间冲突 | 同一时段报了两个活动 | 没有区间重叠检测 |
| 附件丢失 | 换台机器海报全裂 | 用了本地绝对路径存图 |
| 统计不准 | 签到率超过 100% | 报名表存在重复记录 |
我把这个项目的定位讲明白:它不是一个“高精尖”的题目,它的价值在于业务闭环完整、边界条件多、能体现工程思维。你在开题和答辩时要展示的,恰恰是你如何处理上面这些约束,而不是堆了多少个花哨的框架。
1.1 技术选型:够用、能讲清楚、跑得起来
选型这块我给的建议一直很直接:选你能在答辩时讲明白每一层的方案,不要为了“显得高级”硬上微服务。
后端用 Spring Boot + MyBatis-Plus 是比较稳的组合。Spring Boot 的自动装配省掉了大量 XML 配置,MyBatis-Plus 的BaseMapper能让你在单表增删改查上少写一半代码,同时它自带的LambdaQueryWrapper写条件查询可读性很好。数据库选 MySQL 8,原因是窗口函数、CHECK约束、JSON 字段这些在统计和扩展场景里能救急。前端用 Vue 3 + Element Plus,后台管理界面基本靠组件拼装就能出来,学习成本比手写 CSS 低太多。缓存和并发这块,如果时间充裕可以引入 Redis 做名额预扣,时间紧就用数据库的乐观锁,效果差距在毕设规模下并不明显。
反过来,有几样东西我建议别碰:一上来就搞 Spring Cloud 多模块,本地起五六个服务,答辩机器内存不够直接卡死;用 MongoDB 存活动数据,结果连个像样的多表关联统计都写不出来;前端上 Nuxt 做服务端渲染,纯属给自己找麻烦。技术选型的核心判断标准只有一条:每一个引入的组件,你都要能回答“为什么是它,不用它会怎样”。
1.2 源码拿到手之后的第一件事
网上流传的这类源码质量参差不齐,很多人下载完直接mvn spring-boot:run,报一堆错就开始怀疑人生。我的做法是拿到源码先做三件事,顺序不能反。
第一步,看pom.xml或package.json里的依赖版本,尤其是 JDK 版本、Spring Boot 版本、Node 版本这三项。大量“跑不起来”的问题本质是版本不匹配,比如代码是 JDK 8 写的,你本地装的是 JDK 17,javax.*全部变成jakarta.*,编译直接挂。第二步,找配置文件,把数据库连接、文件上传路径、端口这三处改成自己机器的值,然后先只跑数据库脚本,确认表能建成功、初始数据能插进去。第三步才是启动后端,用 Postman 或浏览器直接调一个登录接口,确认连通性没问题,再启前端。
这个顺序的意义在于:把“环境问题”和“代码问题”隔离开。如果你同时启前后端,页面白屏你根本不知道是前端路由错了、后端 500 了,还是跨域被拦了。
2. 角色与权限:先把“谁能干什么”钉死在表里
权限设计的核心不是写代码,是画一张矩阵表。我在动手写任何接口之前,一定会先在纸上把角色和操作的对应关系列清楚。这个项目里我划分了三类角色,另外加了一个“学院/部门”的维度。
| 操作 | 学生 | 组织者(社团/学院) | 管理员 |
|---|---|---|---|
| 浏览活动列表 | 是 | 是 | 是 |
| 报名/取消报名 | 是 | 是 | 否 |
| 创建活动 | 否 | 是 | 是 |
| 编辑活动 | 否 | 仅自己创建的 | 是 |
| 审核活动 | 否 | 否 | 是 |
| 导出报名名单 | 否 | 仅自己活动 | 是 |
| 管理用户与分类 | 否 | 否 | 是 |
这张表定下来之后,代码结构自然就出来了:user表里放一个role字段区分身份,activity表里放creator_id和dept_id。判断“能不能编辑”时,逻辑是“管理员放行;否则比对creator_id是否等于当前登录用户”。注意这里的关键是后端必须做这个判断,前端隐藏按钮只是体验优化,不是安全措施。我见过不止一个项目,前端把“删除”按钮v-if掉了,但接口裸奔,随手改个请求就把别人的活动删了——答辩老师真去点一下,场面很难看。
2.1 活动状态机:把非法跳转挡在服务层
状态字段我建议用整型而不是字符串,方便排序和索引,同时在前端做一层枚举映射。下面是我常用的一套状态定义。
public enum ActivityStatus { DRAFT(0, "草稿"), PENDING(1, "待审核"), REJECTED(2, "已驳回"), PUBLISHED(3, "已发布"), SIGNUP_CLOSED(4, "报名截止"), ONGOING(5, "进行中"), FINISHED(6, "已结束"), CANCELLED(7, "已取消"); // 省略构造与 getter }光有枚举不够,必须配一张“允许的跳转表”。比如从DRAFT只能到PENDING,从PENDING只能到PUBLISHED或REJECTED,从REJECTED修改后回到PENDING。实现方式很简单,用一个Map<ActivityStatus, Set<ActivityStatus>>存合法边,任何状态变更前先查这个 Map,不合法就抛业务异常。
这样做的好处是,所有“状态能不能改”的判断集中在一处,将来加一个新状态只需要改这张表,不用去翻十几个 Service 方法。而且当答辩老师问“如果活动被取消了,已经报名的学生怎么办”,你可以顺着状态机回答:取消时触发一次批量通知,把报名记录标记为“活动取消”,并释放名额——因为这个流程是从状态机的边上推出来的,回答起来特别有底气。
2.2 登录态与接口鉴权的最小实现
毕设项目不需要上 OAuth2 那一套,但要避免“把用户 ID 放在请求参数里”这种致命做法。我的做法是登录成功后签发一个 JWT,载荷里只放userId、role、deptId三个字段和过期时间,前端把 token 放在Authorization头里。后端写一个拦截器,解析 token 后塞进ThreadLocal,Service 层直接取当前用户,不用层层传参。
这里有个实际踩过的坑:ThreadLocal用完必须remove(),否则线程池复用时会串数据。表现是“A 用户偶尔看到 B 用户的数据”,这种间歇性 bug 极难排查。我后来干脆在拦截器的afterCompletion里统一清理,再没出过问题。
3. 数据库设计:从“活动”这张主表往外长
表结构不要一上来就想着“我要设计十几张表”,正确的顺序是先确定核心实体,再顺着关系往外扩。这个系统的核心实体只有三个:用户、活动、报名。其余的表都是从它们身上长出来的。
主表activity的字段设计要格外用心,因为它承载了后面所有查询的过滤条件。
CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '活动标题', category_id BIGINT NOT NULL COMMENT '分类', cover_url VARCHAR(255) DEFAULT NULL COMMENT '封面图', description TEXT COMMENT '详情富文本', location VARCHAR(150) NOT NULL COMMENT '举办地点', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, signup_start DATETIME NOT NULL COMMENT '报名开始时间', signup_end DATETIME NOT NULL COMMENT '报名截止时间', quota INT NOT NULL DEFAULT 0 COMMENT '名额上限,0 表示不限', signed_count INT NOT NULL DEFAULT 0 COMMENT '已报名人数', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态机', creator_id BIGINT NOT NULL, dept_id BIGINT NOT NULL COMMENT '所属院系,用于审核隔离', reject_reason VARCHAR(255) DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_time (status, start_time), KEY idx_creator (creator_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段值得单独解释。signed_count是冗余字段,理论上可以通过COUNT(*)从报名表算出来,我加上它是为了列表页排序和快速判断是否满员,代价是每次报名/取消都要同步维护——这个取舍在毕设规模下非常划算。version是给乐观锁用的,后面讲并发会用到。dept_id是为了实现“学院只看本院活动”的隔离,没有它,审核权限就只能做成全局的了。
3.1 报名表:唯一索引是防重复的最后一道闸
activity_signup这张表是整个系统里最容易出问题的表。我的字段设计是这样的:
CREATE TABLE activity_signup ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1已报名 2已取消 3已签到 4活动取消', signup_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, checkin_time DATETIME DEFAULT NULL, remark VARCHAR(200) DEFAULT NULL, UNIQUE KEY uk_activity_user (activity_id, user_id), KEY idx_user_status (user_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;那个uk_activity_user唯一索引是关键。学生在页面上手抖点两次、或者网络慢重复提交,都会产生两条一样的报名记录。有了唯一索引,数据库层面直接拒绝,业务层捕获DuplicateKeyException后返回“你已经报过名了”,比先查再插要可靠得多。
这里有个细节要提醒:取消报名不要物理删除记录,改成把status置为 2。原因有两个,一是学生取消后想重新报名,唯一索引会冲突,需要走“更新已有记录”的分支;二是活动的历史数据要能留痕,签到统计、报名转化率这些指标都依赖完整记录。物理删除一时爽,后期统计全得重写。
3.2 时间区间冲突检测的 SQL 写法
学生同一时间不能参加两个活动,这个校验要在报名接口里做。判断两个时间区间是否重叠,最简洁的写法是取反:
不重叠的条件是
A.end <= B.start或A.start >= B.end,那么重叠就是它们的否定。
落到 SQL 上:
SELECT COUNT(1) FROM activity_signup s JOIN activity a ON a.id = s.activity_id JOIN activity n ON n.id = #{newActivityId} WHERE s.user_id = #{userId} AND s.status IN (1, 3) AND a.status IN (3, 4, 5) AND a.end_time > n.start_time AND a.start_time < n.end_time;返回值大于 0 就说明冲突。注意这里用>和<而不是>=和<=,因为“上一个活动 12:00 结束,下一个 12:00 开始”严格来说不算冲突,用闭区间会误伤。
性能上,activity_signup(user_id, status)这个联合索引能先筛出该用户的报名,再关联活动表。如果用户报名数量很大(比如一学期几十个),这条 SQL 依然很快。
3.3 附件与富文本:别把图片地址写成绝对路径
活动详情里必然有图片,海报、场地照片、流程图。我见过最常见的问题是把上传后的文件存到本地磁盘D:/upload/xxx.jpg,然后把完整路径写进数据库。换一台机器部署,图片全裂。
正确的做法是:数据库只存相对路径或文件名,比如/files/2024/05/abc123.jpg,前端拼上当前域名访问。同时上传时对文件名做一次 UUID 重命名,保留原始扩展名,避免中文名和特殊字符导致的 404。存储目录按年月分文件夹,单个目录下文件数量不会爆炸,后期迁移也方便。
富文本编辑器返回的 HTML 里同样会内嵌图片地址,这部分要在保存时做一次替换,把临时地址转成正式地址,否则草稿箱里的图和发布后的图会对不上。
4. 后端核心链路:发布、审核、报名、签到怎么串起来
一条完整的主链路是这样的:组织者登录 → 创建活动(草稿)→ 提交审核 → 管理员审核 → 通过后自动进入“已发布” → 到报名开始时间开放报名 → 学生报名 → 报名截止 → 活动开始签到 → 活动结束统计。这条链路上每个节点都有需要单独处理的地方。
审核环节要注意驳回必须带原因,而且驳回后要允许组织者修改再提交。实现上,REJECTED状态下允许编辑活动内容,编辑完调用“提交审核”把状态改回PENDING,同时清空上一次的驳回原因。如果不清空,前端会一直显示旧的驳回理由,组织者会困惑。
发布环节有一个容易被忽略的点:报名开始时间早于当前时间的活动,发布后应该立即开放报名。所以不能用“发布时间”作为判断依据,要用signup_start <= now()来判断当前是否处于报名期。这个判断建议封装成一个方法canSignup(activity),在列表页、详情页、报名接口里复用,避免三处逻辑不一致。
4.1 报名超卖:三种方案的实际取舍
回到开头说的并发问题。我实测过三种方案,这里把差异摆出来。
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 数据库行锁 | SELECT ... FOR UPDATE锁住活动行 | 实现简单,强一致 | 并发高时阻塞明显 |
| 乐观锁 | UPDATE ... WHERE version = ? | 无阻塞,吞吐好 | 失败要重试,需处理重试逻辑 |
| Redis 预扣 | 缓存中DECR判断余量 | 性能最好 | 需处理缓存与库的一致性 |
毕设场景我推荐乐观锁,因为它既有技术含量又容易讲清楚,而且不需要额外部署中间件。核心代码大致是这样:
public void signup(Long activityId, Long userId) { Activity act = activityMapper.selectById(activityId); if (act.getQuota() > 0 && act.getSignedCount() >= act.getQuota()) { throw new BizException("名额已满"); } // 唯一索引兜底重复报名 try { signupMapper.insert(new Signup(activityId, userId)); } catch (DuplicateKeyException e) { throw new BizException("你已报名该活动"); } int rows = activityMapper.incrSignedCount(activityId, act.getVersion()); if (rows == 0) { // 版本冲突,回滚报名记录(实际用事务包裹) throw new BizException("报名人数过多,请重试"); } }对应 Mapper 里的更新语句:
UPDATE activity SET signed_count = signed_count + 1, version = version + 1 WHERE id = #{id} AND version = #{version} AND (quota = 0 OR signed_count < quota)注意WHERE里同时检查了version和名额余量,这是双保险。整个方法要包在事务里,乐观锁失败时抛异常触发回滚,报名记录不会残留。
有人会问:为什么不直接在 SQL 里写signed_count < quota就够了,还要 version 干啥?因为名额检查能防超卖,但防不住“两个请求同时通过检查、同时更新、最终数字多加了两次”的情况——虽然本例中signed_count + 1是原子操作不太会出错,但加上 version 能让整个更新具备“读-改-写”的完整语义,在更复杂的业务(比如要同时更新多个字段)里是必需的。能讲清楚这个区别,答辩时是个加分项。
4.2 签到码:别用自增 ID 直接当二维码内容
签到环节有两种做法,一是管理员在后台点“开始签到”,学生刷新页面出现签到按钮;二是生成一个二维码贴在现场,学生扫码签到。后者体验好得多,但二维码的内容一定要设计好。
直接用活动 ID 做二维码内容是危险的,学生把二维码拍下来发给没到现场的人,照样能签。我的做法是生成一个带签名的 token:activityId + 过期时间戳 + HMAC 签名,签到接口先验签再检查时间戳。二维码每 60 秒刷新一次,过期即失效,这样远程转发基本没用。
String raw = activityId + ":" + expireAt; String sign = hmacSha256(raw, secretKey); String qrContent = raw + ":" + sign;签到成功时更新activity_signup.checkin_time和status = 3,同时要判断该用户是否真的报名了——没报名的人扫码不能凭空签到。这个校验很容易漏掉,导致签到率统计超过 100%。
4.3 定时任务:状态自动流转与提醒
活动状态不能靠人工去点,一定要有定时任务兜底。我用 Spring 的@Scheduled每 5 分钟跑一次,做三件事:把signup_end已过的活动标记为“报名截止”;把start_time已到、end_time未到的活动标记为“进行中”;把end_time已过的活动标记为“已结束”,同时把未签到的报名记录标记为“缺席”。
这里有个坑:定时任务和用户手动操作可能冲突。比如管理员手动取消了活动,定时任务又把它改成“已结束”。解决办法是在更新语句里加状态条件,例如UPDATE activity SET status = 6 WHERE status = 5 AND end_time < NOW(),只对处于预期状态的数据生效,幂等且安全。
5. 前端页面骨架:把状态差异体现在按钮上
前端这块不需要多复杂,但这个系统的页面有一个特点——同一个页面在不同状态下长得完全不一样。活动详情页在“未开始报名”时按钮是灰的,显示倒计时;“报名中”是亮的“立即报名”;“已报名”变成“取消报名”;“名额已满”显示“已满员”;“已结束”显示“活动已结束”。
我的做法是把这套逻辑收敛成一个计算属性:
const actionState = computed(() => { const a = activity.value const now = Date.now() if (a.status === 6 || a.status === 7) return { text: '活动已结束', disabled: true } if (a.signed) return { text: '取消报名', type: 'warning', disabled: false } if (a.quota > 0 && a.signedCount >= a.quota) return { text: '名额已满', disabled: true } if (now < new Date(a.signupStart).getTime()) return { text: '报名未开始', disabled: true } if (now > new Date(a.signupEnd).getTime()) return { text: '报名已截止', disabled: true } return { text: '立即报名', type: 'primary', disabled: false } })把所有分支写在一个地方,页面模板里只绑定actionState,改需求时只改这一处。比在每个按钮上写v-if要干净太多。
列表页的重点是筛选。我建议至少提供“分类”“时间范围”“状态”“关键词”四个筛选条件,并且筛选状态要同步到 URL 的 query 里——这样用户刷新页面筛选条件不丢,分享链接给别人也能看到同样的结果。实现上 Vue Router 的router.replace({ query })一行就够。
后台管理页用 Element Plus 的el-table加el-pagination基本能直接套。审核列表要展示活动的基本信息、提交人、提交时间,并提供“通过”和“驳回”两个操作,驳回时弹一个输入框填原因。导出报名名单可以用后端生成 Excel,也可以用前端SheetJS把表格数据导出,后者更省事。
5.1 移动端适配:别等到答辩才发现手机上看不了
这个系统学生端大概率是在手机上打开的,但很多人做的时候只在电脑浏览器里调,答辩演示时用手机一开,表格全挤成一团。解决成本其实很低:用 Element Plus 的响应式栅格,或者直接引入一套移动端 UI 库做双套页面。时间紧的话,至少保证列表卡片和详情页在 375px 宽度下不横向滚动,按钮可点击区域不小于 44px。
我在实际改稿时用的办法是给容器加max-width: 750px; margin: 0 auto;,在宽屏下居中显示,手机上是满宽,配合flex-wrap的卡片布局,效果能接受。这个细节不复杂,但能明显提升演示时的观感。
6. 开题报告:每一节到底该写什么
源码写完了,开题报告反而是更让人头疼的部分。网上模板一大堆,抄下来千篇一律,老师一眼就能看出来。我按自己的写法把每一节该填什么讲一遍,重点是怎么写得像你真的调研过、真的想过。
选题背景与意义。不要写“随着互联网技术的发展”这种开头。直接从具体场景切入:先说学校当前活动信息分散在哪些渠道,组织者要重复发多少次通知,学生要翻几个群;再说集中化管理之后,信息触达效率提升在哪里,报名统计从人工核对变成自动汇总省了多少人力。意义要落到“解决了一个具体的、可观察的问题”上,而不是空泛地说“提高了管理水平”。
国内外研究现状。这一节最容易写成产品介绍。正确的写法是按“技术路线”分类,而不是按产品罗列。比如可以分三类:基于通用表单工具的活动收集方案,优点是零开发成本,缺点是没有审核和名额控制;基于社交群组的信息发布方案,优点是传播快,缺点是无法沉淀和统计;基于专用管理系统的方案,优点是功能闭环,缺点是需要定制开发。每一类说清楚适用场景和局限,最后落到“本课题选择专用系统路线,是因为校园场景对名额控制、审核流程、签到统计有明确要求”。这样写显得你是分析过才做选择的。
研究内容与技术路线。把功能模块列清楚,每个模块一句话说明它解决什么问题。技术路线用“前端—后端—数据库—部署”四层描述,每层说明选了什么、为什么选。这里可以放一张架构图(用文字描述或用表格代替,不要用花哨的图)。
进度安排。按周排,前后留出至少两周做测试和文档。我的经验是把“联调”这一项单独列出来,占两周,因为实际开发中前后端接口对不上花的时间远超预期。
参考文献。至少 8 到 10 篇,中文期刊加外文文献搭配,内容要和你写的技术点对得上。写并发控制就引并发相关的,写权限设计就引权限模型相关的。千万别出现参考文献列表里全是“XXX 管理系统设计与实现”的情况,这种一看就是凑数。
6.1 开题答辩最容易被问的三个问题
第一个:“你的系统和现成的活动报名工具比,优势在哪?”标准答案是回到场景:通用工具做不了“学院隔离审核”,做不了“名额并发控制”,也做不了“签到与素拓分挂钩”这种校内特有的流程。
第二个:“并发报名的超卖问题你怎么解决的?”这时候你要能一口气说清楚“查-判-写”为什么不是原子的,以及你用乐观锁或唯一索引怎么兜底。这个问题答好了,整个答辩的技术分基本就稳了。
第三个:“如果活动临时取消,已经报名的学生怎么通知?”这是考你有没有想过异常流程。答“状态置为已取消,报名记录批量标记,同时站内信通知”就够了,不需要真的做短信推送。
7. 源码结构与二次开发:拿到项目后怎么改才不崩
一个结构清晰的源码目录长这样:
activity-system ├── activity-api # 后端 │ ├── src/main/java/com/xxx │ │ ├── controller # 接口层,只做参数校验和调用 │ │ ├── service # 业务逻辑,事务在这里 │ │ ├── mapper # 数据访问 │ │ ├── entity # 数据库映射对象 │ │ ├── dto # 出入参对象 │ │ ├── config # 拦截器、跨域、文件上传配置 │ │ └── common # 统一返回、异常、枚举 │ └── src/main/resources │ ├── application.yml │ └── mapper/*.xml ├── activity-web # 前端 │ ├── src/api # 接口封装 │ ├── src/views # 页面 │ ├── src/components # 公共组件 │ └── src/router # 路由与守卫 └── sql └── init.sql改项目时最重要的原则是分层不要被打穿。我看到很多人为了图快,直接在 Controller 里写 SQL 或者操作 Mapper,短期是快了,但后期加一个校验就要改十几个地方。正确的做法是:Controller 只负责接收参数、调用 Service、包装返回;Service 负责业务规则和事务边界;Mapper 只做数据访问。这条线守住了,二次开发才不会被自己坑。
7.1 从零跑起来的具体步骤
- 建库建表。执行
init.sql,注意脚本里如果有CREATE DATABASE,库名要和application.yml里的一致。 - 改配置。数据库账号密码、文件上传目录、服务端口三处必改。上传目录建议用相对路径
./upload,跟着项目走。 - 装依赖。后端
mvn clean install,前端npm install。前端如果卡在依赖下载,换一个国内镜像源能省不少时间。 - 启动后端,访问
http://localhost:8080/doc.html(如果集成了接口文档)或直接调登录接口验证。 - 启动前端,
npm run dev,浏览器打开后先用初始账号登录。 - 走一遍完整流程:创建活动 → 提交审核 → 审核通过 → 换学生账号报名 → 取消报名 → 再报名 → 签到。
这个流程走通了,说明项目是健康的。任何一步卡住,问题范围都很明确。
7.2 想改题目方向时的最小改动路径
有些人做了一半觉得题目太普通,想加亮点。我建议在现有骨架上加,而不是推倒重来。几个成本低、效果好的扩展方向:
- 活动推荐:基于学生历史报名记录的分类偏好,在首页推荐同分类的活动。用简单的标签匹配就行,不需要上协同过滤。
- 数据看板:用 ECharts 画报名趋势、分类分布、签到率。数据从现有表里聚合,一天能做完。
- 消息通知:站内信加邮件提醒,报名成功、活动即将开始、活动取消三个节点触发。
- 素拓分对接:签到成功后按活动配置的分数给学生加分,做一个简单的积分流水表。
这四个方向都能在现有表结构上扩展,不需要大改。关键是加完之后要能讲清楚“为什么加这个”,而不是“为了显得功能多”。
8. 演示与收尾:几个我踩过的实际坑
项目做完到答辩之间,还有一段容易翻车的时间。我把自己遇到过的问题列出来,都是些不注意就会当场尴尬的细节。
演示数据一定要提前造好。空系统演示等于灾难,列表页一片空白,老师根本看不出你做了什么。提前准备至少 8 到 10 条不同状态的活动数据,覆盖待审核、已发布、已结束、已取消,报名记录也要有,签到数据也要有。
演示顺序要设计。不要上来就登管理员账号看后台,那样全是表格很枯燥。先用学生身份浏览活动列表、筛选、报名,展示用户视角;再切到组织者发布活动;最后用管理员审核。这条线走完,系统的价值一目了然。
并发演示不要现场压测。有人想在答辩时开 JMeter 演示并发,结果机器卡死或者数据乱了。并发逻辑在文档里讲清楚,配上代码片段就够了,没必要现场表演。
接口报错要能自己看懂。演示时如果报 500,你要能当场从控制台日志里定位到是哪一行。所以提前把日志级别调成INFO,关键方法的入口和出口各打一条日志,出问题时心里不慌。
数据库备份要有一份。演示过程中误删数据是常事,提前导出一份 SQL,出问题三分钟恢复。
最后说一点个人体会。这类项目真正的价值不在于用了多少技术,而在于你有没有把一条业务链路从“用户点击”到“数据落库”完整地想清楚,并且在每个可能出错的地方都留了一道防线。超卖、重复报名、状态乱跳、权限越界,这些问题的解法都很朴素,无非是唯一索引、乐观锁、状态机、服务端校验——但能不能想到、能不能讲明白为什么这么设计,才是区分“抄了个源码”和“真做过这个项目”的地方。我见过把功能做得花里胡哨但连名额超卖都解释不清的项目,也见过功能朴素但每个边界条件都处理到位的项目,后者的答辩通过率明显更高。源码可以拿来用,开题可以照着改,但那几条核心链路上的判断,最好还是自己动手推一遍。