学生活动管理平台这个题目,几乎每年毕业设计季都会被大量选中,但真正能把它做出“能跑、能讲、能答辩”三个层次的人并不多。大部分人的成品停在“活动增删改查 + 报名列表 + 一个后台管理页”,代码能启动,但一被问到“名额抢完了怎么办”“审批被驳回之后活动是什么状态”“学生干部能看到别院系的数据吗”,就答不上来了。我前后帮人看过不少这类项目,也在自己的实践里完整走过一遍从开题报告到源码交付的流程,踩过的坑基本都集中在几个固定位置:需求边界没划清、表结构设计得太随意、并发场景没考虑、权限只做了一个 role 字段。
这篇内容面向的是正在做或者准备做这个题目的同学,也适合任何想做一个“报名类、审批类”信息系统的开发者参考。我会把重点放在三件事上:一是这个题目里真正有技术含量的模块到底是哪几个;二是数据库和并发这类硬骨头怎么啃;三是开题报告和源码之间怎么对得上,让评审和答辩环节少掉分。中间会穿插可直接复用的建表语句、核心方法和技术选型理由,尽量做到拿来就能改。
1. 这个题目的难点不在增删改查,而在需求边界
1.1 需求文档里最常缺的那几块
我见过的大部分学生活动管理平台的需求描述,基本是这么写的:管理员发布活动,学生浏览活动并报名,管理员审核报名,活动结束后统计参与人数。四句话结束。问题在于,这四句话里藏着至少十几个没定义清楚的分支,而这些分支恰恰是系统设计的核心。
举几个具体的:活动发布之后能不能改时间?改了时间之后已经报名的学生要不要重新确认?报名需不需要审核,还是先到先得?名额满了之后是直接关闭还是进候补队列?活动开始前多久停止报名?学生报名后不来,要不要记录违约?一个学生能不能同时报同一个活动的多个场次?活动是院系级的还是全校级的,跨院系能不能报名?这些问题的答案直接决定了表结构、接口数量和前端页面数量。
我的建议是在正式写代码前,先用一张表格把所有状态和流转列出来,哪怕只是手写。因为这决定了你后面数据库里要放几个状态字段,而不是事后发现状态不够用再去加字段、改判断逻辑,那时候改起来成本翻倍。
1.2 把三类角色和四条主流程钉死
角色这件事一定要收敛。很多项目一上来就设计“超级管理员、管理员、院系管理员、辅导员、学生干部、普通学生”六种角色,结果做到一半发现权限矩阵写不完,每个接口都要判断一堆 if。
我的做法是收敛成三类主体:平台管理员(负责账号、院系、活动类型这类基础数据)、活动组织者(发布活动、管理报名、组织签到,通常由学生干部或指导教师担任)、学生(浏览、报名、取消、签到、评价)。如果确实需要院系隔离,不要新增角色,而是在账号上加一个“所属院系”属性,用数据权限来控,这个后面第 4 章会展开讲。
四条主流程则是:活动从草稿到发布到结束的生命周期、报名从提交到通过或拒绝到签到、审批从发起到流转到归档、统计从原始记录到汇总报表。把这几条线画清楚,页面数量基本就定了,不会出现做到一半发现漏了一个模块的情况。
1.3 技术选型为什么最后都落在 Spring Boot 加 Vue 这套组合
选型这块我不打算罗列一堆框架做对比表,因为对这个题目来说,选型的核心约束只有三个:上手成本、资料密度、答辩时可解释性。
后端用 Spring Boot 的理由很实际:它把配置、依赖注入、Web 层、数据访问层全部收敛到一套约定里,你不需要自己搭 Servlet 容器,一个 main 方法就能起服务。对一个需要在两三周内出成品的项目来说,这种“少折腾”比性能重要得多。数据访问层用 MyBatis-Plus 或者 MyBatis,前者自带通用 CRUD 和分页插件,能省掉大量模板代码;但如果你的开题报告里写了“研究 MyBatis 映射机制”之类的点,那就老老实实用原生 MyBatis 写 XML,别让报告和代码对不上。
前端选 Vue 的核心原因是组件化和生态成熟。活动列表、报名表单、后台表格这些页面结构高度重复,用组件抽出来之后,改一处就能全局生效。搭配 Element Plus 这类组件库,表格、分页、弹窗、表单校验基本开箱可用。如果团队里有人更熟 React,那用 React 也完全没问题,但不要在开题报告里写 Vue、代码里用 React,这种低级不一致在评审环节非常扎眼。
数据库就用 MySQL,原因是这个系统本质上是一堆关系明确的结构化数据,活动、用户、报名、审批,天然适合关系型建模,用文档数据库反而要把关联查询写到应用层,得不偿失。
2. 数据库设计:把“活动”这个名词拆到不能再拆
2.1 核心表的拆分逻辑
很多人建表的时候习惯一张activity表塞下所有东西:活动名、时间、地点、人数、组织者、审核状态、报名列表。这是后面所有问题的根源。
我的拆分思路是按“名词的生命周期”来切。活动本身是主体,报名是活动和用户之间的关系,审批是活动的一个附属流程,签到是报名的延伸。对应下来大致是这么几张表:
| 表名 | 职责 | 关键点 |
|---|---|---|
sys_user | 账号主体 | 角色、所属院系、状态 |
activity | 活动主表 | 状态机字段、名额、时间窗 |
activity_signup | 报名记录 | 用户与活动唯一约束、签到状态 |
activity_approval | 审批流水 | 一次审批一条,可追溯 |
activity_category | 活动分类 | 便于统计和筛选 |
sys_notice | 通知消息 | 与业务事件解耦 |
这里有个细节值得强调:审批不要做成活动表上的一个 status 字段。因为审批可能是多级的,也可能被驳回后重新提交,一个字段存不下历史。做成流水表,每次操作插一条记录,不仅能还原整个审批链路,答辩的时候演示起来也更有说服力。
同理,签到状态不要放在活动表上,它属于报名记录,一个活动有几十上百条签到状态,放主表根本存不下。
2.2 那些必须提前加的字段
这一节是我认为最值得抄的部分。以下几个字段,如果一开始不加,后面补起来会很痛苦:
create_time/update_time:每张业务表都要有。不是为了展示,是为了排查问题时能定位到“这条数据是什么时候被改的”。用 MySQL 的DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP可以自动维护。deleted(逻辑删除):活动被“删除”时,如果物理删除,报名记录就变成孤儿数据,统计报表直接错乱。加一个 0/1 标记,查询时过滤掉即可。version(乐观锁版本号):这个字段平时不用,但在报名扣名额的时候是救命稻草,后面会详细讲。remark:几乎每个业务表都会在某个时刻需要一句备注,事后加字段要改实体、改 mapper、改前端,不如一次加上。
还有一类容易漏的是时间窗字段。活动表里不要只存一个start_time,至少要有signup_start_time、signup_end_time、activity_start_time、activity_end_time四个。报名是否开放、活动是否进行中、能否签到,全部由这四个时间和服务端当前时间比较得出,而不是靠一个“是否开放报名”的开关字段手动维护。因为开关字段一定会忘记改。
活动主表的建表语句大概长这样,可以直接拿去改:
CREATE TABLE `activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `title` VARCHAR(120) 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 '活动地点', `organizer_id` BIGINT NOT NULL COMMENT '组织者用户ID', `dept_id` BIGINT DEFAULT NULL COMMENT '归属院系,用于数据隔离', `quota` INT NOT NULL DEFAULT 0 COMMENT '名额上限', `signed_count` INT NOT NULL DEFAULT 0 COMMENT '已报名人数(冗余)', `signup_start` DATETIME NOT NULL COMMENT '报名开始时间', `signup_end` DATETIME NOT NULL COMMENT '报名截止时间', `start_time` DATETIME NOT NULL COMMENT '活动开始时间', `end_time` DATETIME NOT NULL COMMENT '活动结束时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待审 2已发布 3已驳回 4已结束 5已取消', `need_audit` TINYINT NOT NULL DEFAULT 0 COMMENT '报名是否需要审核', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', `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, PRIMARY KEY (`id`), KEY `idx_status_signup` (`status`, `signup_end`), KEY `idx_dept` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动主表';signed_count这个冗余字段是有意加的。如果每次都去activity_signup里 count 一遍,活动列表页一刷新就是几十次子查询,页面响应会很难看。冗余字段的代价是必须保证它和明细一致,这一点靠后面要讲的并发处理来保证。
2.3 索引和唯一约束:让数据库替你兜住一部分 bug
我特别想强调唯一约束这件事。报名表上有一组唯一索引是必须要有的:
UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`, `deleted`)这条索引能直接挡掉一类最常见的脏数据:同一个学生对同一个活动重复报名。你把逻辑写在 Java 里做“先查再插”,在高并发下是一定会漏的,两个请求同时查到“没报过”,然后都插进去。加上唯一索引之后,第二个插入会抛异常,你在代码里捕获这个异常,给用户返回“你已报名”,逻辑就闭环了。
需要注意deleted要放进唯一索引里,否则用户取消报名(逻辑删除)之后再想重新报名,会撞上唯一约束。常见的做法就是用物理删除、或者把 deleted 设计成一个时间戳/自增序号参与唯一键,具体用哪种看你的业务对历史记录的依赖程度。我个人倾向于报名记录保留物理行,用一个signup_status字段标记“已取消”,唯一索引就建在(activity_id, user_id)上,取消后再报名就是 update 状态而不是 insert,逻辑更干净。
索引方面,活动列表页最常见的查询是“某状态 + 报名未截止”,所以(status, signup_end)这个联合索引很有价值。院系隔离的系统还要在dept_id上建索引。别一股脑给所有字段加索引,写操作会变慢,而且维护成本高。
3. 报名名额的并发问题:从超卖到幂等
3.1 先把问题复现出来
假设名额 50 个,不需要审核,先到先得。你的代码大概率是这么写的:
Activity activity = activityMapper.selectById(id); if (activity.getSignedCount() >= activity.getQuota()) { throw new BizException("名额已满"); } // 插入报名记录 // 更新 signed_count + 1单线程跑一万遍都没问题。但只要人数一多,就会出现“50 个名额报进去 53 个人”的情况。原因很直接:两个请求同时读到signed_count = 49,都判断通过,都去加 1,最终结果可能是 51,而实际插入了 2 条记录。
这个现象有个专业名词叫超卖,在秒杀系统里是经典问题,但在学生活动场景里同样存在——尤其是那种热门讲座、演唱会门票式的活动,开放报名的那一分钟并发量其实很可观。
复现方法很简单:用压测工具开 100 个线程同时打报名接口,把名额设成 10,然后看数据库里到底插了多少条。我建议每个做这个题目的人都亲手做一次这个实验,因为只有亲眼看到数据错乱,才会真正理解为什么要做并发控制。
3.2 三种方案的取舍
解决方案我知道的有三个,从简单到复杂分别是:
方案一:数据库行锁(悲观锁)。在事务里先SELECT ... FOR UPDATE把活动这行锁住,再判断再扣减。
SELECT * FROM activity WHERE id = #{id} FOR UPDATE;优点是实现最简单,逻辑绝对正确。缺点是锁的粒度大,同一个活动的所有报名请求串行执行,吞吐量上不去。但对于校级活动这种量级(几百人同时抢),完全够用,我非常推荐直接用它,别过度设计。
方案二:乐观锁。用上面那个version字段,更新时带上版本号:
UPDATE activity SET signed_count = signed_count + 1, version = version + 1 WHERE id = #{id} AND version = #{version} AND signed_count < quota;看影响行数,如果是 0 就说明被别人抢先了,返回失败或者重试。这个方案并发性能更好,但需要处理重试逻辑,而且要注意signed_count < quota这个条件必须在 SQL 里,不能只在 Java 里判断。
方案三:缓存预扣。用缓存中间件(常见的是 Redis)做名额计数器,先原子递减,减成功了再落库。这是高并发系统的标准做法,但对这个题目来说属于明显过度设计,除非你的开题报告里明确写了“研究高并发场景下的名额控制”,否则不建议上。引入缓存之后的一致性问题(扣了缓存但落库失败怎么办、缓存重启怎么办)会让你多花一倍时间。
我个人在这个题目上的建议是:用乐观锁,做好失败提示。理由是它比悲观锁性能好,比缓存方案简单,而且在答辩时能讲清楚原理,是个加分项。下面给出核心代码。
3.3 唯一索引加状态机:真正的兜底
乐观锁解决的是signed_count的准确性问题,但报名记录本身的重复插入还要靠唯一索引。完整的报名流程我一般这么写:
@Transactional(rollbackFor = Exception.class) public void signup(Long activityId, Long userId) { Activity act = activityMapper.selectById(activityId); // 1. 基础校验:状态、时间窗、是否本人 if (act == null || act.getStatus() != 2) { throw new BizException("活动不存在或未开放报名"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(act.getSignupStart()) || now.isAfter(act.getSignupEnd())) { throw new BizException("不在报名时间内"); } // 2. 已报名直接返回,保证幂等 Long cnt = signupMapper.countByActivityAndUser(activityId, userId); if (cnt > 0) { throw new BizException("你已报名该活动"); } // 3. 乐观锁扣名额,影响行数为 0 说明抢完了 int rows = activityMapper.increaseSignedCount(activityId, act.getVersion()); if (rows == 0) { throw new BizException("名额已满,请稍后再试"); } // 4. 插入报名记录,唯一索引兜底 ActivitySignup record = new ActivitySignup(); record.setActivityId(activityId); record.setUserId(userId); record.setSignupStatus(act.getNeedAudit() == 1 ? 0 : 1); // 0待审 1已通过 record.setCheckinStatus(0); try { signupMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException("你已报名该活动"); } }注意这里的顺序:先扣名额再插记录。如果反过来的话,记录插进去了但名额没抢到,就得回滚,事务里多做一次无效写。不过先扣后插也有风险——插记录失败要回滚名额,所以整个方法必须在同一个事务里,rollbackFor一定要写。
还有一点,第 2 步的“先查”只是为了给用户一个友好的提示,真正防止重复的是第 4 步的唯一索引。这两者不冲突,前者提升体验,后者保证正确。
3.4 取消报名和名额回补
取消报名看起来简单,其实有两个坑。
第一个坑是时间窗。活动开始前 24 小时还能不能取消?这个规则必须写进代码,否则会出现活动已经开始了还有人取消,名额回补了却没人能用。我的做法是在取消接口里判断now > activity.getStartTime()就拒绝,必要时可以再收窄到“开始前 N 小时”。
第二个坑是重复回补。如果列表页上取消按钮能被连点两次,signed_count就会被减两次。解决方式还是状态机:取消时用带条件的 UPDATE。
UPDATE activity_signup SET signup_status = 2, cancel_time = NOW() WHERE id = #{id} AND signup_status IN (0, 1);影响行数为 1 才继续去减signed_count,为 0 说明已经取消过了,直接返回。这样即使并发点两次也只有一次生效。
名额回补的更新语句也要加保护,防止减成负数:
UPDATE activity SET signed_count = signed_count - 1 WHERE id = #{id} AND signed_count > 0;4. 审批流与权限模型:一个 role 字段扛不住的东西
4.1 RBAC 的最小实现
权限这块,很多人第一反应是在用户表上加一个role字段,值是admin或者student。少量接口这么干可以,但只要出现“组织者只能管自己发布的活动”“院系管理员只能看本院系数据”这类需求,这个字段就不够用了。
最小可用的做法是三张表:用户、角色、用户角色关联。菜单和按钮权限再加一张权限表和角色权限关联表。但对于这个题目,我建议只做到“角色 + 数据范围”两层,不要上完整的菜单级权限,那是后台管理框架的活儿,跟你这个系统的核心价值没关系。
CREATE TABLE `sys_role` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `role_code` VARCHAR(32) NOT NULL COMMENT 'ADMIN / ORGANIZER / STUDENT', `role_name` VARCHAR(64) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_role_code` (`role_code`) );权限判断放在哪一层是个设计问题。放在 Controller 里最直观,但会散落到各个方法;放在拦截器或者切面里更统一,但需要定义好注解。我用的是自定义注解加拦截器的方案:在方法上标@RequireRole("ORGANIZER"),拦截器解析注解比对当前登录用户的角色集合。这样既直观又不重复。
4.2 活动审批的状态流转
审批这件事,如果不把状态画清楚,代码里会写出一堆互相矛盾的 if。我的做法是先定义状态枚举,然后强制所有状态变更都走一个方法,不允许别处直接 set。
活动侧的状态是:草稿 → 待审 → 已发布 / 已驳回,发布之后还能到已结束或已取消。驳回之后可以改完重新提交,回到待审。这里有个细节:已驳回的活动能不能直接编辑?能,但要保留驳回原因给组织者看,所以审批流水表里要存comment字段。
审批流水的表结构:
CREATE TABLE `activity_approval` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `approver_id` BIGINT NOT NULL, `action` TINYINT NOT NULL COMMENT '1通过 2驳回', `comment` VARCHAR(500) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_activity` (`activity_id`) );驳回原因一定要展示给组织者,否则他改都不知道改哪儿,这个体验细节在演示环节很容易被注意到。
4.3 数据权限:看得见和改得动是两件事
这是我在这个项目里踩得比较深的一个坑。一开始我把“能不能看”和“能不能改”混在一起判断,结果出现了组织者能看到别院系活动、但点进详情又报错的情况。
正确的拆分是两层:可见性决定列表和详情能不能返回,可操作性决定编辑、删除、审核这些写操作能不能执行。可见性用查询条件拼 SQL 实现,比如非管理员一律加上dept_id = #{currentDeptId}或者organizer_id = #{currentUserId}。可操作性用注解拦截。
还有一个容易忽略的点:详情接口和列表接口的权限必须一致。很多系统列表页做了过滤,但详情接口直接按 ID 查,把 URL 里的 ID 一改就能看到别人的数据。这种越权是最常见的漏洞之一,答辩时如果被问到“你怎么防止越权访问”,能答出这一条会很有说服力。
具体做法是在详情接口里拿到数据后,再判断一次当前用户是否在可见范围内,不在就返回 403。这一行判断代码只有几行,但能挡住绝大多数问题。
5. 签到、统计与通知:让系统看起来是活的
5.1 签到码的设计取舍
签到功能是整个系统里最能出效果的部分,也是设计上最容易走偏的部分。
最粗糙的做法是活动详情页放一个固定的六位数字码,学生输入正确就算签到。问题是这个码会被截图转发,一个人签到完发给全寝室,统计就废了。稍微好一点的做法是动态码:页面定时刷新一个带时效的码,服务端校验时间窗口。再进一步是二维码加签名 token,token 里编码活动 ID、时间戳、随机串,服务端验签并检查时间窗口。
我的建议是用二维码而不是数字码,因为二维码的视觉效果好,演示时很加分,而且实现难度并不高。生成二维码只需要一个工具库,把一段字符串编码成图片返回给前端即可。
服务端校验逻辑大概是这样:token 里带activityId + expireAt + sign,签名的密钥放在服务端配置里。学生扫码后前端把 token 传给后端,后端验签、比对活动 ID、检查是否过期,然后更新报名记录的签到状态。
public void checkin(String token, Long userId) { CheckinToken t = tokenParser.parse(token); // 验签 + 解析 if (t.getExpireAt() < System.currentTimeMillis()) { throw new BizException("签到码已失效,请让组织者刷新"); } int rows = signupMapper.updateCheckin(t.getActivityId(), userId); if (rows == 0) { throw new BizException("未找到你的报名记录"); } }updateCheckin里同样要带条件AND checkin_status = 0,保证重复扫码不会重复计数。
5.2 统计查询怎么写才不慢
后台首页一般要有几个数字:活动总数、本月活动数、报名总人次、平均签到率。这些如果用一条条count(*)去查,页面会明显卡顿。
我的做法是:活动总数、报名总数这类全局指标直接查,因为数据量不大;签到率这类需要关联统计的,用一条 GROUP BY 语句一次性算出来:
SELECT a.id, a.title, a.quota, COUNT(s.id) AS signup_cnt, SUM(CASE WHEN s.checkin_status = 1 THEN 1 ELSE 0 END) AS checkin_cnt FROM activity a LEFT JOIN activity_signup s ON s.activity_id = a.id AND s.signup_status = 1 WHERE a.deleted = 0 AND a.status = 4 GROUP BY a.id, a.title, a.quota ORDER BY a.end_time DESC LIMIT 20;注意activity_signup上要有(activity_id, signup_status)的联合索引,否则每次统计都是全表扫描。
还有一个坑是统计口径。报名人次和报名人数不是一回事:一个人报了三场活动,人次算 3,人数算 1。这两个数字在报表里经常被混用,导致对不上。我建议在页面上把标签写清楚,比如“报名人次”,避免歧义。
5.3 通知消息怎么落地
通知功能很多人做成一个独立的消息模块,写一堆站内信、已读未读。对这个题目来说,够用就好。
我的方案是事件驱动的最小实现:活动审核通过、活动被驳回、报名成功、活动即将开始,这四个节点触发通知。通知表结构就是user_id + title + content + read_flag + create_time,前端在右上角显示未读数,点开是一个列表。
关键在于“触发”这一步放在哪。如果散落在各个 Service 里直接调noticeService.send(),后面加一种通知就要改好几处。更好的做法是发布应用内事件,用监听器统一处理。这样业务代码里只需要publishEvent(new ActivityApprovedEvent(activityId)),通知内容怎么组装、发给谁,都在监听器里做。
要不要做邮件或短信?我建议不做。一来要配置第三方服务,二来演示时容易出问题,三来对答辩没有任何加分。站内通知足够了。
6. 开题报告和源码怎么对得上
6.1 开题报告里哪些内容可以后补,哪些必须前置
开题报告的固定结构一般是:研究背景与意义、国内外现状、研究内容与目标、技术路线与方案、进度安排、参考文献。我按“先写后写”的优先级给个排序。
必须前置写清楚的是研究内容和功能模块列表,因为这是你后面写代码的地图。如果报告里列了六个模块,代码里只有四个,答辩时一定会被追问。反过来,如果代码里做了一个报告里没提的特色模块,那是个加分项,说明你有额外探索。
可以后补的是技术路线里的具体版本号、数据库表清单、接口文档。这些在开题阶段写到“使用 Spring Boot 搭建后端服务,MySQL 存储业务数据”这个粒度就够了,细节留到实现阶段再补。
最容易写空的是国内外现状。这一节如果写成“国外起步较早,技术成熟;国内发展迅速,应用广泛”,基本等于没写。我建议换个角度:不要泛泛谈“学生活动管理系统”,而是聚焦到你实现的某个技术点。比如谈“活动报名的并发控制方案”或者“基于角色的细粒度权限模型”,这个话题在公开资料里有足够多的方案可以比较,写起来就有内容,而且直接支撑你后面的设计决策。
6.2 源码结构怎么组织才不显得像拼的
评审和答辩老师看代码,第一眼看的就是目录结构。如果controller下面混着工具类,utils里塞着业务逻辑,那给人的印象就是“东拼西凑”。
我用的分层是标准的四层:controller(只做参数校验和响应封装)、service(业务逻辑)、mapper(数据访问)、entity(数据对象),再加上config、common、exception、dto这几个辅助包。DTO 和 Entity 一定要分开,请求参数用XxxQuery、XxxForm,返回用XxxVO。有人觉得这是过度设计,但在答辩场景里,这套分层本身就是“我懂规范”的信号。
另一个建议是把 README 认真写。至少要包含:项目简介、技术栈版本、数据库初始化脚本位置、启动步骤、默认账号密码、功能模块截图。很多项目的 README 只有一行“学生活动管理系统”,看一眼就不想往下翻了。
6.3 答辩现场最可能被问到的几个问题
我把这些年听过的高频问题整理一下,每个都给出回答方向:
- “你的项目和网上现成的有什么区别?”别说“我加了签到功能”,太弱。要答具体的技术决策,比如“我在报名模块用乐观锁加唯一索引解决了并发超卖和重复报名,这是很多同类项目没处理的”。
- “数据库为什么这么设计,符合三范式吗?”老老实实说哪几张表做了冗余、为什么冗余(比如
signed_count是为了避免列表页子查询),这比硬说“完全符合三范式”可信得多。 - “如果同时有一千个人报名怎么办?”分层次答:先用唯一索引挡重复,再用乐观锁控名额,量再大可以引入缓存预扣。能说出这个递进关系就很好。
- “权限是怎么做的?”答 RBAC 加数据范围,重点提“列表过滤 + 详情二次校验”防止越权。
- “你做了测试吗?”不要空口说做了,直接说“我用压测工具模拟了 100 并发报名 10 个名额的场景,修复前出现超卖,修复后计数准确”,这个回答的含金量非常高。
7. 实际跑起来之后才会遇到的几个坑
7.1 时间字段的时区和格式
这个问题几乎人人都踩。表现是:数据库里存的时间是对的,但页面上显示差了 8 小时,或者反过来。
根因在于 MySQL 连接串没指定时区、Java 里用了java.util.Date、序列化时又没配格式,三者任意一个不对都会出问题。我的处理是全部统一:数据库连接串加上时区参数,实体类里用LocalDateTime,全局配置序列化格式为yyyy-MM-dd HH:mm:ss。这样从存储到传输到展示是一条线,不会出现某处差 8 小时。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/activity_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai还有个小细节:时间比较一定要在服务端做,不要信任前端传上来的“当前时间”。前端时钟可以随便改,服务端用自己系统时间判断才可靠。
7.2 图片上传和路径回显
上传功能看起来简单,坑却不少。第一个坑是存本地还是存对象存储。这个题目我建议存本地,放在一个不随打包变化的目录里,数据库只存相对路径,比如/upload/2024/05/xxx.jpg。配置一个静态资源映射,把 URL 前缀映射到磁盘目录,前端直接用这个相对路径访问。
第二个坑是前端路由和后端静态资源打架。如果你的前端是 history 模式,刷新页面会出现 404;如果后端拦截器配置了统一前缀,静态资源也会被拦。解决方式是配置静态资源放行,同时前端路由加上 fallback 到 index.html。
第三个坑是文件名冲突。用户上传的原始文件名可能重复,必须重命名,用时间戳加随机串,同时保留扩展名做类型校验。只靠前端限制文件类型是不够的,后端也要检查扩展名,否则传个脚本上去会有安全问题。
7.3 列表分页和导出
分页这块,如果用 MyBatis-Plus 的分页插件,记得配置拦截器,否则Page对象不会生效,会查全表返回。这个问题我第一次用的时候就踩了,页面能显示但总数永远是全部数据。
导出的话,数据量小(几千行以内)直接查出来写 Excel 就行,用现成的工具库一行代码搞定。数据量大就要分批查,每批 5000 条写一次,避免内存溢出。另外导出的字段名要做中文映射,别把signed_count这种字段名直接暴露给用户看。
还有一个细节:导出接口如果直接GET带着一堆查询条件,参数一长 URL 会超限。要么改成 POST,要么把条件做短。
7.4 部署和首次启动
最后说一下跑起来这件事。开发环境和部署环境最大的区别是数据库初始化和文件路径。
数据库脚本一定要单独放一个sql目录,包含建表语句和基础数据(角色、分类、默认管理员账号)。别人拿到你的项目,第一步就是执行这个脚本,如果脚本里有语法错误或者缺字段,整个项目就跑不起来。
前端打包之后要配置请求地址。如果后端和前端同域部署,直接配反向代理把/api转发到后端端口就行;如果是分开部署,要在打包配置里改环境变量。最常见的问题是打包之后接口全 404,十有八九是反向代理的前缀没配对。
还有一点,application.yml里的数据库密码不要写死成自己本地的,要么用环境变量覆盖,要么在 README 里明确写清楚要改哪里。这个细节虽然小,但会让拿到项目的人少折腾半小时。
我自己做这类系统的体会是:功能列表永远可以再加,但稳住核心的三条线——报名的数据准确性、审批的状态清晰度、权限的边界——才是这个项目真正的技术含量所在。很多看起来“功能很多”的成品,一旦被问到某条数据是怎么来的、为什么是这个状态,就露怯了。反过来,功能不多但每条链路都闭环、每个状态都能说清楚为什么这么设计,答辩的时候底气完全是两回事。报名模块的压测实验我建议一定要亲手做一遍,数据从错乱到准确的这个过程,比看十篇讲并发的文章都有用。