刚做完一套基于Spring Boot的社会实践活动报名系统,前后花了大概三周时间。从需求分析、数据库设计到前后端联调,整个流程走下来,有些地方比预想中绕,有些地方则是做完才反应过来其实能省不少事。这篇就当作是给正在选毕业设计题目、或者准备做同类管理系统的同学一份真实参考。
先说清楚这套系统能干什么。它解决的是高校里社会实践活动报名环节的典型痛点:活动通知靠辅导员转发Excel、学生报名靠接龙或者现场交表、审核进度不透明、学时统计全靠期末手工算。系统上线后,学生可以在线浏览活动、提交报名、查看审核状态、获取电子签到记录;管理员可以发布活动、审核报名、导出名单、按学期统计实践学时;整个流程从发布到归档全部线上化,数据落库,自动汇总。
从技术选型上看,Spring Boot做后端几乎是目前这类项目的默认答案:生态成熟、上手快、资料多,JWT做登录态、MyBatis-Plus操作数据库,再配一个Vue3的前端页面,整套东西做出来既有工作量可写,又不至于难度失控。这套组合尤其适合毕业设计,因为每层都有足够的内容可以写进论文,同时每一层单独拿出来都不会卡太久。
1. 项目到底要做什么:从需求反推系统设计
很多同学做毕业设计容易一上来就写代码,写到一半发现业务漏洞百出,又回去改表结构。这种做法的代价很大。我习惯先花两三天时间把需求想清楚,把角色和业务流程理顺,再动工程。磨刀不误砍柴工,这句话在做系统时是字面意义上的。
1.1 三类核心角色与权限边界
社会实践活动报名系统表面上是“一个报名功能”,但仔细拆下来,角色的权限边界差异很大。
- 学生:登录后能浏览已发布的活动列表、查看活动详情、提交报名申请、撤销未审核的报名、查看自己的审核进度和签到记录、查看累计实践学时。学生账号应该由管理员统一导入,或者支持学号自助注册后由管理员审核开通。
- 院系管理员:负责本院系的活动发布、审核本院系学生的报名、记录签到情况、导出报名名单、查看本院系学时统计。
- 校级管理员(实践中心):拥有全部权限,可以管理所有院系的数据、审核活动上架、发布校级活动、查看全校范围内的统计报表。
权限设计上不建议自己写一套复杂的RBAC,反而用基于角色的简单判断就够了。Spring Boot里拦截器加自定义注解就能完成大部分鉴权工作,三个角色互不越权即可。这个项目的答辩重点通常会在权限设计上,建议把这块吃透。
1.2 核心业务流程图解:一次完整报名要经过哪些环节
一次完整的报名活动,业务链路是这样的:
活动发布(院系管理员填写活动信息、时间、地点、人数上限、学时数)→ 校级管理员审核活动 → 活动状态变为“报名中” → 学生浏览到活动 → 学生提交报名申请 → 系统校验活动是否在报名期内、人数是否已满 → 报名成功,状态为“待审核” → 院系管理员审核报名(通过/不通过)→ 通过后状态变“已通过” → 活动开始,线下签到(管理员扫码或手动勾选)→ 活动结束 → 系统根据签到名单自动为已签到学生累计学时 → 学生查看学时记录。
这个链路里有一个很重要的状态点:报名成功不等于报名通过。报名成功只是代表学生成功提交了申请,占了一个名额;而管理员审核通过才代表申请生效。很多第一次做这类系统的同学会把这两个状态混淆,做成“提交即报名成功”,后续统计学时就会出问题。
1.3 功能模块划分:一份可以直接背下来的清单
模块划分直接对应论文里的功能需求章节,我按重要程度排个序:
- 活动管理:活动CRUD、活动上下架、活动分类、活动审核、活动收藏
- 报名管理:提交报名、取消报名、报名审核、人数控制、防重复报名校验
- 签到管理:管理员手动签到、二维码签到、签到记录查看
- 学时管理:学时规则配置、活动学时自动累计、学期总学时统计
- 用户管理:学生账号导入、角色管理、密码重置、用户状态管理
- 数据看板:活动参与热度、院系报名情况、学时完成进度(给论文填充图表的绝佳素材)
这些模块不要全都做,挑选核心4-5个做深就够了。我见过很多同学项目页面做了一堆,但功能都是半吊子,答辩时反而被问倒。在这个项目里,“报名+审核+签到+学时统计”这个核心闭环一旦完全跑通,整个项目的主心骨就立住了。
2. 技术选型与架构思路:不炫技,求稳
毕设项目的技术选型有一个原则:选你最熟悉、资料最多、最不容易翻车的组合。Spring Boot全家桶刚好就是这样一套组合。以下是我的实际选型和理由。
2.1 后端:Spring Boot 2.7.x 的取舍
Spring Boot 2.7.x是目前毕业设计里的主力版本,资料最多,遇到问题基本都能搜到现成答案。为什么不用3.x?3.x要求Java 17起步,部分旧教程不兼容,而且很多老师和答辩环境的JDK版本不一定跟得上,没有必要给自己增加变量。
配套组件方面:
- MyBatis-Plus 3.5.x:比MyBatis原生省掉大量XML编写,分页插件、条件构造器、自动填充都是实际开发里非常省时间的特性。
- Redis(选配):用来做活动人数预扣减和接口防刷。如果不想引入Redis,只靠数据库行锁同样能实现人数控制,两种方案在答辩上都能解释清楚。
- JWT做无状态登录:选它不是因为比Session好多少,而是毕业设计里“无状态鉴权”是一个天然的加分讨论点,而且前后端分离场景下确实比Session省心。
- Apache POI:导出Excel报名名单时用,不需要引入太复杂的报表组件。
2.2 前端:管理端用Vue3 + Element Plus
管理端我用了Vue3 + Element Plus + Axios + Vite。Vue3的组合式API逻辑复用比Options API舒服不少,但如果你对Vue3不熟,用Vue2.7也能做,没必要为了版本硬着头皮上。前端开发有个很实用的建议:先写页面骨架,用Mock数据把交互流程跑通,再对接真实接口。这样可以减少联调时的沟通成本,后端接口有问题也容易定位。
2.3 数据库设计:六张核心表搞定全部业务
数据库设计是整个系统里我花时间最多、也认为最重要的部分。用几张核心表来梳理:
- sys_user:用户表。字段:id、username、password、real_name、role(0学生/1院系管理员/2校级管理员)、college_id、student_no、phone、status、created_at。
- activity:活动表。字段:id、title、description、category、college_id(发布院系)、location、start_time、end_time、enroll_start_time、enroll_end_time、capacity(人数上限)、hours(实践学时)、status(0待审核/1报名中/2进行中/3已结束/4已下架)、created_by、created_at。
- activity_enrollment:报名表。字段:id、activity_id、student_id、status(0待审核/1已通过/2未通过/3已取消)、apply_time、audit_time、audit_remark。
- activity_sign_in:签到表。字段:id、activity_id、student_id、sign_in_time、sign_in_type(0手动/1二维码)。
- college:院系列表。字段:id、name。
- 学时记录可以并入签到表,通过活动和学生联合查询即可统计出来,不需要单独建表增加复杂度。
报名表和活动表之间设计了一个冗余字段:当报名通过时,在活动表里用一个已通过人数(approved_count)字段累加。这个冗余字段能有效减少统计时的查询压力。这是一个典型的空间换时间的设计,可以在论文里单独展开讲讲。
2.4 三层架构与工程目录:给代码分好楼层
包结构设计只要遵循Controller → Service → Mapper 的分层思路就不会乱。
com.example.practice ├── controller // 接收HTTP请求、参数校验 ├── service // 业务逻辑层,事务处理 ├── mapper // MyBatis-Plus的数据访问层 ├── entity // 数据库实体 ├── dto // 前端入参对象 ├── vo // 返回前端的数据对象 ├── config // 配置类(拦截器、跨域、全局异常) ├── common // 公共工具类、统一返回结果、常量 └── security // JWT拦截、权限校验很多同学会把返回结果直接塞Map,看似省事,但接口文档没法写,前端看着也累。我习惯用统一的返回结果类R,包含code、message、data三个字段,所有接口全部返回这个结构,前端处理就会非常清爽。
3. 核心功能实现细节与难点拆解
接下来讲几个实现过程中最有含金量的部分。这几块内容做好了,答辩时会非常加分。
3.1 报名接口双重防重复校验
这是我认为整个系统里最值得写进论文的功能点。学生端如果点击两次提交报名,或者用户开着两个标签页同时提交,就可能导致同一个人对同一活动出现多条报名记录。
我用了两层防护:
第一层:数据库层面的唯一索引。对activity_enrollment表建立联合唯一索引(activity_id, student_id)。这一条能从根本上杜绝重复数据,无论应用层出了什么并发问题,数据库都能兜底。
ALTER TABLE activity_enrollment ADD UNIQUE INDEX uk_activity_student (activity_id, student_id);第二层:业务层的预检查。在Service层写一个判断逻辑,报名前先查一下是否已有记录。虽然数据库有兜底,但业务层的提前判断可以返回更友好的提示信息,而不是报一个SQL异常。
// 报名前检查 long count = enrollmentMapper.selectCount( new LambdaQueryWrapper<ActivityEnrollment>() .eq(ActivityEnrollment::getActivityId, activityId) .eq(ActivityEnrollment::getStudentId, studentId) ); if (count > 0) { throw new BizException("请勿重复报名"); }最后在Service方法上加上@Transactional注解,确保判断和插入操作在同一个事务里,防止并发间隙导致数据不一致。
3.2 人数控制:超卖问题从源头解决
实践活动通常有人数上限,报名人数达到上限后就不能再报名。这里有一个经典的“超卖”问题——两个同学同时提交,数据库里的当前人数都是49,而活动上限是50,两个人同时查到未满,同时写入,结果实际报名人数变成51。
解决方案用的是数据库行级锁(悲观锁)。在活动表上通过selectByIdForUpdate锁定这一行数据,然后检查当前已通过报名人数是否到达上限:
Activity activity = activityMapper.selectByIdForUpdate(activityId); // 统计已通过人数 Integer approvedCount = enrollmentMapper .selectApprovedCountByActivityId(activityId); if (approvedCount >= activity.getCapacity()) { throw new BizException("活动报名人数已满"); }注意一点:for update锁的范围要精确到这一行,而不是锁表。这只对修改操作有影响,常规的查询不会触发阻塞。做并发压测后我发现这个方案在毕业设计的流量规模下完全够用,如果不需要处理千级QPS,完全不需要引入Redis的方案。
3.3 状态机:活动状态流转不再靠if嵌套
活动从创建到下架,状态变化是有规则的,不能是任何状态都能跳转到任何状态。我把状态流转定义成一个简单的状态机常量类:
public enum ActivityStatus { PENDING(0, "待审核"), ENROLLING(1, "报名中"), ONGOING(2, "进行中"), FINISHED(3, "已结束"), OFFLINE(4, "已下架"); private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(0, Set.of(1, 4)); // 审核通过上架 或 下架 ALLOWED_TRANSITIONS.put(1, Set.of(2, 4)); // 开始活动 或 下架 ALLOWED_TRANSITIONS.put(2, Set.of(3)); // 结束活动 ALLOWED_TRANSITIONS.put(3, Set.of(0)); // 重新编辑再上架(可选) } public static boolean canTransfer(Integer from, Integer to) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(from); return allowed != null && allowed.contains(to); } }这样一来,每一次状态变更的核心逻辑都汇聚到了一个地方,管理页面上的按钮显隐条件也更好统一控制。答辩时能把这个状态机的设计说清楚,说明你真懂业务,而不是只学会了CRUD。
3.4 签到与学时自动累计
签到这一步我做了两种模式:管理员手动勾选和二维码扫码签到。二维码签到的原理是:活动开始时,管理员在详情页点击“生成签到二维码”,后端把 activityId + 当前时间戳加密生成一个短码,学生扫码后调后端接口解码校验即可。
学时累计的逻辑就一句话:活动结束后,遍历该活动的签到记录,为每个已签到的学生加上活动的学时时长。
// 活动结束后批量累计学时 List<ActivitySignIn> signInList = signInMapper.selectList( new LambdaQueryWrapper<ActivitySignIn>() .eq(ActivitySignIn::getActivityId, activityId) ); for (ActivitySignIn signIn : signInList) { // 更新学生的累计学时 studentStatsMapper.incrementHours(signIn.getStudentId(), activity.getHours()); }这块如果做成定时任务来跑会更专业一点,比如活动结束时间过了以后自动触发,避免需要管理员手动点击。我用的方式是在查询学生详情页时动态统计学时,而不是存一个固定的学时字段,这样数据实时性更好,也少一个数据一致性维护的麻烦。
3.5 管理端核心页面:活动审核列表与报名管理
管理端最核心的两个视图:
- 活动审核列表:待审核活动按照提交时间排序,每条记录有“查看详情”“通过”“驳回”操作。驳回时弹出窗口填写驳回理由,这个理由会推送到学生端。驳回理由是可选的,但我建议做上,因为学生在看到“未通过”却不知道原因时,会频繁咨询管理员,反而增加工作量。
- 报名管理视图:对某个活动,展示所有报名记录。按状态分组展示,可以按学号搜索、批量通过、批量拒绝、一键导出Excel。
导出Excel用Apache POI实现,代码不复杂:
try (Workbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("报名名单"); // 设置表头 Row header = sheet.createRow(0); String[] columns = {"序号", "学号", "姓名", "院系", "联系方式", "报名时间", "状态"}; for (int i = 0; i < columns.length; i++) { header.createCell(i).setCellValue(columns[i]); } // 填充数据 // ... response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); workbook.write(response.getOutputStream()); }前端点击导出按钮后,直接请求后端接口,浏览器会收到文件流并自动下载。如果导出过程中数据量很大,建议用异步导出,但毕业设计体量下同步导出就够了。
4. 从0到1的搭建路线与实操记录
接下来是完全可以按着做的操作路线。这一部分是基于我的企鹅项目实际经历整理出来的,所有操作步骤都会标注容易出问题的地方。
4.1 环境准备与初始化步骤
本机环境建议:
- JDK 1.8 或 JDK 11(推荐JDK 8,兼容性最好)
- Maven 3.6+
- MySQL 5.7+ 或 MySQL 8.0
- Node.js 14+(前端用)
- 开发工具:IntelliJ IDEA + VSCode
创建项目直接去 Spring Initializr 网页勾选依赖,或者用IDEA自带的Spring Initializr创建。基础依赖选Spring Web、MyBatis Framework、MySQL Driver、Validation。
我个人比较推荐用IDEA的Spring Initializr创建,IDEA会把Maven项目的目录结构提前建好,省去手动创建目录的麻烦。
整体项目结构:
springboot-practice-system/ ├── src/main/java/com/example/practice/ ├── src/main/resources/ │ ├── application.yml │ ├── mapper/ │ └── static/ ├── pom.xml └── frontend/ // 前端Vue3工程放这里4.2 数据库初始化SQL脚本
创建数据库后,执行以下脚本。我已尽量避免使用LinuxMate式的过度嵌套写法,但索引和数据完整性一定要建好:
CREATE DATABASE `practice_system` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE `practice_system`; CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) NOT NULL COMMENT '真实姓名', `role` tinyint NOT NULL COMMENT '0学生 1院系管理员 2校级管理员', `college_id` bigint DEFAULT NULL, `student_no` varchar(20) DEFAULT NULL COMMENT '学号', `phone` varchar(20) DEFAULT NULL, `status` tinyint DEFAULT 1 COMMENT '1启用 0禁用', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_student_no` (`student_no`) ) ENGINE=InnoDB COMMENT='系统用户表'; CREATE TABLE `college` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='院系表'; CREATE TABLE `activity` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL, `description` text, `category` varchar(50) DEFAULT NULL COMMENT '活动分类', `college_id` bigint DEFAULT NULL COMMENT '发布院系ID', `location` varchar(200) NOT NULL, `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `enroll_start_time` datetime NOT NULL, `enroll_end_time` datetime NOT NULL, `capacity` int NOT NULL DEFAULT 50 COMMENT '人数上限', `hours` decimal(4,1) NOT NULL DEFAULT 0.0 COMMENT '实践学时', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待审核 1报名中 2进行中 3已结束 4已下架', `created_by` bigint NOT NULL COMMENT '创建人ID', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_college` (`college_id`) ) ENGINE=InnoDB COMMENT='实践活动表'; CREATE TABLE `activity_enrollment` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_id` bigint NOT NULL, `student_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2未通过 3已取消', `apply_time` datetime DEFAULT CURRENT_TIMESTAMP, `audit_time` datetime DEFAULT NULL, `audit_remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_student` (`activity_id`, `student_id`), KEY `idx_student` (`student_id`), CONSTRAINT `fk_enrollment_activity` FOREIGN KEY (`activity_id`) REFERENCES `activity`(`id`) ) ENGINE=InnoDB COMMENT='活动报名表'; CREATE TABLE `activity_sign_in` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_id` bigint NOT NULL, `student_id` bigint NOT NULL, `sign_in_time` datetime DEFAULT CURRENT_TIMESTAMP, `sign_in_type` tinyint DEFAULT 0 COMMENT '0手动 1二维码', PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_student` (`activity_id`, `student_id`) ) ENGINE=InnoDB COMMENT='活动签到表';4.3 Spring Boot核心配置:YAML里的小心机
application.yml里有几个细节需要提前设置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/practice_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai servlet: multipart: max-file-size: 10MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0有几个点值得注意:
serverTimezone=Asia/Shanghai是必须写的,不然MySQL 8连接时经常报时区错误。map-underscore-to-camel-case: true让数据库的snake_case字段自动映射到Java的驼峰属性,不是必须,但省很多事。- MyBatis-Plus的逻辑删除建议直接配置上,为以后预留软删除能力,虽然这个系统不是特别需要,但有总比没有强。
4.4 登录接口与JWT鉴权的落地方式
用户登录流程很简单:前端提交账号密码,后端校验通过后生成JWT返回给前端。JWT生成用的是Java JWT库:
// 生成JWT String token = JWT.create() .withSubject(userId.toString()) .withClaim("role", user.getRole()) .withClaim("username", user.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)) .sign(Algorithm.HMAC256("你的密钥字符串"));然后写一个拦截器统一处理请求头里的Token,从里面解析用户信息放入ThreadLocal,后续Service层可以直接获取当前用户。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains("/api/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BizException("未登录"); } // 解析token,校验合法性 // 放入ThreadLocal return true; } }我这里用JWT而不是Session,有几层考虑。其一,前端是Vue,部署时和前端完全分离,Session跨域处理麻烦些;其二,无状态接口在后端做横向扩展时不需要考虑会话同步问题;其三,论文里可以围绕这点展开写几百字的优缺点分析,答辩时也能主动讲清楚为什么做这个选择。
4.5 前端Vue3对接后端接口的典型写法
以Vue3 + Vite + Axios为例,封装请求时统一处理Token注入和错误提示:
// request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动带上token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error('网络错误') } return Promise.reject(error) } )在Vite里配置代理转发,避免开发时跨域问题:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样开发时前端请求/api/auth/login,Vite会自动转发给后端8080端口,完全不需要后端开启CORS。
5. 踩坑记录与排查技巧实录
这一部分是我认为本文最值钱的地方。很多问题是查教程查不到、做了三五个项目才会遇到的,我把它们列成一个速查表。
5.1 时区与JSON格式问题
第一次联调时发现数据库里的时间是正常的,但接口返回给前端的时间少了8个小时。原因是MySQL服务器时区、Spring Boot时区、Jackson序列化时区三者没有统一。处理办法是在MySQL连接串里加上serverTimezone=Asia/Shanghai,在application.yml里设置spring.jackson.time-zone: Asia/Shanghai,数据库连接串和Jackson时区保持一致后问题就消失了。
5.2 前端跨域问题
开发环境跨域出现时,前端控制台会报CORS错误。解决方案有很多,后端加CORS配置、前端代理转发、或者统一用Nginx反向代理。我的建议是开发阶段使用Vite的proxy功能,上线阶段用Nginx。后端加CORS配置适合快速测试,但安全性和灵活性都不如网关层处理。
5.3 MyBatis-Plus分页插件失效
MyBatis-Plus的分页插件在3.5.x版本以后需要显式配置拦截器,否则分页方法执行时返回的是全量数据而不是当前页。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }另外注意分页对象(Page)不能再被序列化放进统一返回结果里的data字段,通常需要从Page对象中取出records列表和total分别返回。
5.4 JWT过期时间与用户状态校验
JWT的无状态也会带来一个小坑:如果用户被禁用或密码被重置,旧的JWT仍然有效(因为JWT本身没有被存储和检查)。要解决这个问题,我加了一个简单的双查询机制:拦截器解析出用户ID后,再到数据库里查一下用户状态,如果被禁用就拒绝访问。虽然多了一次查询,但对于毕设级别系统来说完全值得,它让权限控制变得更加可靠。
// 从Token解析出userId后,检查数据库用户状态 SysUser user = userMapper.selectById(userId); if (user == null || user.getStatus() == 0) { throw new BizException("账号已被禁用"); }5.5 端口占用问题
开发中经常遇到的情况是:前一个项目启动后没关干净,再启动新项目时报端口被占用。解决方式有几种,最粗暴的是杀掉占用进程。Windows下:
netstat -ano | findstr :8080 taskkill /PID 进程号 /FLinux/macOS下:
lsof -i :8080 kill -9 进程号如果你频繁遇到这个问题,可以养成习惯:关闭Spring Boot服务时用IDEA的Stop按钮而非直接关窗口,前端服务也一样。
5.6 Excel导出文件名中文乱码
POI导出Excel时,设置响应头文件名时中文经常乱码。这不是数据乱码,而是HTTP响应头不支持非ASCII字符。解决方案是进行URL编码:
String fileName = URLEncoder.encode("报名名单.xlsx", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);用RFC 5987的filename*格式,兼容现代主流浏览器,中文文件名不会变成乱码。
5.7 并发测试时发现报名人数超限
用JMeter或者Postman的Runner模式对报名接口做并发压测时,如果不加锁,很可能出现报名人数超限的情况。我就是用这种方式验证了事务加锁的必要性。测试时如果发现偶尔出现超限,检查一下Service层是否加了@Transactional,以及是否真正使用了for update而不是普通查询。
6. 总结一点个人心得
最后说说实际做完这个项目后,我对这类系统的几个认识。
第一,不要把“毕业设计系统”当成玩具来做。虽然技术难度不高,但业务逻辑里的边界情况其实非常多,每一个小细节都可以深入展开。一个“防重复报名”的并发问题,就能串联起数据库索引、事务隔离、锁机制等好几块知识点,论文里的深度马上就不一样了。
第二,代码写完后一定要自己把核心流程完整跑一遍。我跑了一遍之后发现,有个预定下架的活动状态没有自动更新,原因是只靠业务触发,没有定时任务兜底。后来补了一个定时任务,每5分钟扫描一次,将到达报名截止时间的活动状态自动改成“已结束”。这类小问题通常在测试流程中暴露,早暴露早修好。
第三,如果能把系统的数据库设计、状态机设计、并发处理这几个点整理清楚,答辩的压力会小很多。面试官或评委通常不会纠结你用没用最新技术,他们更关心:这个系统的业务难点在哪里,你是怎么分析和解决的。把这几个问题回答好,胜过堆十个功能页面。
后面如果有精力,可以在这个系统上继续加法,比如对接学校统一身份认证、生成活动证书PDF、接入企业微信消息提醒。这些方向都很有意思,而且技术路径相对成熟,适合做二期扩展。
希望这份经验记录能对正在做类似项目的同学有一点帮助。如果做这个系统时也遇到相同的问题,欢迎照着上面列的方法去试。