又是一年迎新季。如果你负责过高校的信息化建设,一定对迎新系统的复杂度深有体会——它要在短短三五天内承载几千名新生线上报到、宿舍分配、缴费确认、绿色通道等一连串流程,任何一个环节出问题,都会让现场排起长队。最近我们刚完成了一套基于SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0的大学生迎新系统,代码量不算巨大,但业务环节非常完整,已经在某高校连续两年稳定运行。这篇文章我想把整个项目的设计思路、技术选型、核心实现和踩坑记录都整理出来,给准备做类似系统或者想自己搭一个Java Web项目练手的开发者做个参考。不管你是刚入门的学生,还是需要在高校落地的工程师,这套内容应该都能帮到你。
1. 项目整体设计与技术选型思路
1.1 迎新系统的真实业务场景
很多没做过高校项目的人会以为,迎新就是让新生填一张基本信息表。真上手之后才会发现,迎新系统是全校信息化里最“单点爆破”的业务之一:每年开学只有几天,但参与对象多、流程交叉多、状态变更多。学生要注册账号、核验录取信息、确认专业和班级、在线缴纳学费和住宿费、申请绿色通道或助学贷款回执、填写军训服装尺码、选择宿舍和床位、打印报到单,辅导员要确认到校状态、分配宿舍、审核资助申请,学院管理员要统计报到率和缴费率,学校领导还要在数据大屏上实时看总报到人数。
这些业务不是独立的,它们之间有关联状态。比如学生必须先完成身份核验,才能进入缴费和选宿舍环节;选择宿舍又和性别、学院、学费标准有关;绿色通道申请通过后,缴费状态要自动更新为已审批。所以系统的核心不是写几个增删改查接口,而是设计一套清晰的状态流转和业务规则。
当初我们接到的需求就是把这些流程全部线上化,并且要求界面操作尽量简单,因为使用系统的辅导员和志愿者不一定是技术背景,培训时间又有限。最终我们采用前后端分离的方式,管理员端和学生端都做成网页应用,没有做App和小程序,原因很简单:开学季流量高度集中,网页端能快速适配,也不依赖第三方应用审核。技术选型的重点也紧紧围绕这个落地要求展开。
1.2 为什么选择这套技术栈
先说后端。SpringBoot2在2023年后虽然官方进入了维护期,但在高校和传统企业项目里仍然占据绝对主流。选择SpringBoot2而不是SpringBoot3,主要考虑到大量老依赖、旧版连接池、JDK版本和国产数据库的兼容性,2.7.x版本足够稳定。而且团队里很多开发人员已经习惯SpringBoot2的写法,接手成本低。当然,如果你是新项目从零开始且没有历史包袱,用SpringBoot3也没问题,只是我需要提醒一句:一些第三方starter可能还没完全跟上JDK17的模块化约束。
前端选Vue3而不是Vue2,是为了长期维护考虑。Vue3的组合式API在复杂业务组件里特别实用,比如新生报到页面有大量表单联动、文件上传和进度条,用Composition API能把逻辑按业务维度拆分,而不是像Vue2的Options API那样按data/methods纵向堆叠。构建工具直接用Vite,开发时热更新速度比Webpack快很多。UI框架选择了Element Plus,表格、表单、步骤条、时间选择器这些组件直接拿来用,能省大量重复工作。
MyBatis-Plus几乎成了Java后端开发的默认ORM。它不像JPA那样有严格的实体关系映射,也不像纯MyBatis那样每个方法都要写SQL和XML。MyBatis-Plus提供BaseMapper和IService,普通的单表CRUD不用手写SQL,用LambdaQueryWrapper就能解决大部分查询,配合分页插件和代码生成器,开发效率非常高。对于迎新系统这种业务逻辑明确、多表关联也不复杂的项目,这个选择很合适。
MySQL8.0带来了窗口函数、更好的JSON支持和默认的utf8mb4字符集。我特意在项目里用了MySQL8而不是MySQL5.7,因为8.0对并发的处理更好,支持行级锁和隐形主键,而且服务器上装新版数据库相对容易,后续维护成本也更低。
| 技术组件 | 版本 | 主要用途 |
|---|---|---|
| SpringBoot | 2.7.x | 后端基础框架、依赖管理、自动配置 |
| Vue3 | 3.x | 前端框架、组合式API开发 |
| Element Plus | 2.x | 管理端页面UI组件 |
| MyBatis-Plus | 3.5.x | ORM、通用Mapper、分页插件 |
| MySQL | 8.0+ | 业务数据存储 |
| Redis | 6.x / 7.x | 验证码、Token、热点数据缓存 |
| Nginx | 1.20+ | 前端静态资源托管、反向代理 |
1.3 系统架构与目录规划
整个系统是标准的前后端分离架构。后端只暴露RESTful API,不渲染页面;前端所有页面通过浏览器访问Nginx托管的静态资源,再通过HTTP调用后端接口。这样做的直接好处是:学校如果想给某个学院定制页面,只需要改前端,不用动后端;未来如果要做移动端,接口可以直接复用。
后端模块化方面,我没有按传统的controller、service、mapper三层就交差了事,而是增加了common(通用结果类、异常、工具类)、config(配置类)、security(权限校验拦截器)、dto(前端传入的参数对象)、vo(返回给前端的数据对象)这些包。一开始很多开发者会觉得分太细,但在实际项目里,前端往往需要几个表拼接的数据,直接用Entity返回容易泄露多余字段,用Vo专门做聚合更容易控制。
src/main/java/com/xx/reg ├── common // Result、PageResult、全局异常处理 ├── config // MyBatisPlusConfig、RedisConfig、CorsConfig ├── controller // 各业务模块的接口入口 ├── service // 业务逻辑层接口与实现 ├── mapper // MyBatis-Plus BaseMapper ├── entity // 数据库表对应的实体类 ├── dto // 接收前端参数的请求对象 ├── vo // 返回给前端的视图对象 ├── security // 登录拦截器、JWT工具类、用户上下文 └── utils // 公共工具类前端目录我更倾向于按业务模块划分,而不是按页面元素划分:
src ├── api // 每个业务模块的接口封装 ├── assets // 静态资源、样式 ├── components // 公共组件:分页、上传、步骤条 ├── router // 路由配置与守卫 ├── stores // Pinia状态管理:用户信息、菜单权限 ├── views // 页面,按 admin/student 分目录 └── utils // 请求封装、工具函数这种结构在多人协作时冲突极少,每个人负责自己的业务模块就行。项目后期加功能,比如增加一个“接站信息登记”,只需要在api下加一个文件,在views下加一个目录,其他模块几乎不用动。
2. 后端核心实现与设计细节
2.1 数据库建模与关键设计点
数据库是整个系统的基础。迎新涉及的表大致有:用户表、学生信息表、学院表、专业表、班级表、宿舍楼表、宿舍房间表、床位表、报到记录表、缴费记录表、绿色通道申请表、公告表、辅导员分配表等。我在这里只挑几个容易犯错的关键点来聊。
用户表和学生信息表当时是分开设计的。用户表只管登录账号、密码、角色、状态,学生表存放学生的学籍信息,比如身份证号、考生号、民族、生源地、照片、录取专业编码等。为什么要分开?因为学生数据是通过招生办Excel批量导入的,导入时可能还没有账号;而一个学生可能有多个账号或用同一个账号进入不同角色。比如辅导员本身也可能是研究生新生,那他需要同时用教师身份和新生身份登录。这种情况下用户角色表用多对多或用户表的role字段存多个角色都行,但最稳妥的方案是单独建一张用户角色关联表,避免因为角色互斥导致一个人只能在一个系统里工作。
宿舍相关的表我用了三级结构:宿舍楼,宿舍房间,床位。宿舍楼记录楼栋名称、性别限制、所属校区、楼层数。房间记录房间号、房型(四人间、六人间)、可住人数、当前已住人数。床位记录床位编号和状态。这样设计以后,选宿舍时可以先把符合性别和学院的楼栋筛选出来,再查空房间,再展示空床位,逻辑非常清晰。
一个很重要的点:床位表要有乐观锁version字段。因为迎新当天会有大量学生同时打开选床位页面,如果两个学生同时看到同一个床位都去提交,后端必须保证只有一个能成功。最简单的方法是在更新床位状态的SQL里加上“where state=0 and version=旧版本”,如果更新影响行数为0,就说明被抢过了,直接提示重新选择。不这么做的后果我后面会在问题排查里详细讲。
数据库字符集必须使用utf8mb4,排序规则用utf8mb4_general_ci。新生姓名里可能有生僻字,一些系统的字符集用了utf8mb3会存不进去,导致乱码或直接报错。还有身份证号我存的是varchar(18)而不是bigint,因为身份证号包含X,而且用bigint会丢失前导零。手机号同样用varchar存储,避免未来的扩展问题。
2.2 统一响应、异常处理与权限校验
前后端分离项目里,后端和前端必须有一套统一的接口约定。我们的约定是:成功时返回code=200,业务失败返回code=500或自定义错误码,data字段放业务数据。所有接口返回结构都一样,前端拦截器才能统一处理。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }全局异常处理是必须的。别看这只是一个小的设计点,它能避免你整个项目里到处写try-catch。我们定义了一个全局异常处理器,拦截所有Controller层的异常,然后打日志并返回统一错误结果。特别要注意的是,如果某个异常没被捕获直接抛出,Tomcat默认会返回一个比较丑的HTML错误页面,前端解析JSON会直接失败。
权限校验这块,我当时纠结过用Shiro还是Spring Security还是JWT加拦截器。最后选择了最简单的方案:JWT + Redis + HandlerInterceptor。为什么没用Spring Security?因为项目角色只有三种:超级管理员、学院管理员、辅导员/学生,权限模型非常清晰,Spring Security会引入大量配置和概念,对团队来说反而是一种负担。JWT本身是无状态的,配合Redis存放token状态,可以在用户修改密码或管理员强制下线时主动失效token。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); String userId = JwtUtil.parseToken(token); if (userId != null && redisTemplate.hasKey("login:token:" + userId)) { UserContext.set(userId); return true; } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSONUtil.toJsonStr(Result.error("未登录或登录已过期"))); return false; } }这里还有一个经验:登录接口一定要做验证码和频控。迎新期间学生第一次登录系统时,因为密码复杂,很多人会重复输错,如果不做验证码,这个接口会变成暴力破解的重灾区。我们用了Redis存验证码,有效时间两分钟,同一个手机号或用户名每分钟最多请求5次。
2.3 MyBatis-Plus 的高效写法
用了MyBatis-Plus以后,最大的感受是代码量至少减少一半。所有单表CRUD都继承BaseMapper,不用再写XML。比如查询某个学院待报到学生列表,直接:
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Student::getCollegeCode, collegeCode) .eq(Student::getReportStatus, 0) .orderByAsc(Student::getStudentNo); studentMapper.selectList(wrapper);如果是复杂的报表统计,比如按学院统计报到人数,用自定义SQL写在Mapper里即可。注意一个坑:如果你同时使用LambdaQueryWrapper和自定义XML文件,实体类字段名到数据库列名的映射要保持一致,MyBatis-Plus默认开启驼峰转下划线,但我们把实体类字段也大多数都用数据库一致来命名,这样减少混淆。
分页插件几乎是必须的。学生列表、缴费记录、公告列表在管理后台都要分页。配置分页插件只需要注册一个Bean:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }之后使用Page对象就能自动拼接limit,比如:
Page<Student> page = new Page<>(current, size); studentService.page(page, wrapper);另一个非常实用的功能是自动填充。学生表里有create_time和update_time,如果不做自动填充,每次insert和update都要手动set当前时间,很容易遗漏。实现MetaObjectHandler:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }代码生成器我推荐用一个简单的模板项目,根据数据库表自动生成entity/mapper/service/controller。虽然生成的代码质量中等,但它能帮你把最基本的CRUD接口全部铺好,节约的时间非常可观。不过生成器生成的Controller直接放在生产环境不合适,通常我会把自动生成的接口当成脚手架,再按业务需求重写或补充。
2.4 报到与宿舍分配的业务逻辑实现
报到流程是整个系统的核心,它不是单表更新,而是一连串状态流转。我们的设计是:学生登录后,进入报到页,页面上会展示一个报到流程进度条。每一步的完成状态都需要满足前置条件。比如“身份信息确认”需要学生上传身份证正反面照片和录取通知书照片,由辅导员审核通过后进度条才点亮;“在线缴费”需要对接校园支付网关,回调通知后更新缴费状态;“宿舍选择”需要学生所在学院已经被管理员开启选宿舍功能,否则页面上直接隐藏。
宿舍分配我单独说一下,因为这里并发问题最严重。最初我们打算让学生自由选择任意空床位,后来和学工处讨论后放弃了。自由选择的体验虽然好,但开学前几天会出现大量选完又退选的情况,床位状态频繁变化,还会产生同一个宿舍全是某个地区学生的问题。最终设计成:由学院管理员先按照班级导入宿舍分配计划,例如“计算机1班分到3号楼201-206房间”,然后学生在计划范围内选择空闲床位。这样既保留了学生的选择权,又把管理边界划清楚了。
分配床位的核心接口如下:
@Transactional(rollbackFor = Exception.class) public boolean assignBed(String studentId, Long bedId) { // 1. 锁住床位记录,确保并发下只有一个事务能commit Bed bed = bedMapper.selectByIdForUpdate(bedId); if (bed == null || bed.getStatus() != BedStatus.EMPTY.getValue()) { throw new BizException("该床位已被占用"); } // 2. 校验性别、学院是否匹配 if (!checkBedConstraint(studentId, bed)) { throw new BizException("选择的床位不符合分配规则"); } // 3. 更新床位状态 bed.setStatus(BedStatus.OCCUPIED.getValue()); bed.setStudentId(studentId); bed.setVersion(bed.getVersion() + 1); bedMapper.updateById(bed); // 4. 更新宿舍房间已住人数 dormRoomMapper.incrementUsed(bed.getRoomId()); // 5. 记录学生报到进度 reportService.updateReportStep(studentId, "DORM", true); return true; }selectByIdForUpdate是自定义SQL,加了FOR UPDATE行锁。关键点在于,事务提交前其他事务尝试更新这个床位会阻塞,从而避免了多人抢同一个床位。这里还有一个容易忽略的问题:如果先查床位再执行更新,中间有一步抛出异常,事务必须回滚,所以方法的@Transactional一定不能漏掉。我们当时在压测时就发现,如果忘了加事务,偶尔会出现床位上学生ID是A,但床位状态还是空着的脏数据。
绿色通道的审批逻辑也算个重点。贫困生申请绿色通道后,需要上传证明材料,辅导员在后台审核,审核通过后,缴费环节会自动跳过学费部分。这个状态不能直接用布尔值,因为还要区分“未申请”、“审核中”、“通过”、“驳回”,所以我们用了一个状态字段。审批通过的动作使用了MyBatis-Plus的LambdaUpdateWrapper来更新,保证只更新指定状态的数据:
lambdaUpdate() .eq(Student::getId, studentId) .eq(Student::getGreenChannelStatus, GreenStatus.REVIEWING.getValue()) .set(Student::getGreenChannelStatus, GreenStatus.PASSED.getValue()) .update();这样能防止辅导员重复提交审批导致状态错乱。
3. 前端 Vue3 的工程化落地
3.1 Vite + Vue3 项目搭建与路由
前端部分采用Vite作为构建工具,这是Vue3生态里强烈推荐的方案。初始化项目很简单:
npm create vite@latest reg-frontend -- --template vue然后安装项目依赖,Element Plus和Pinia、Vue Router是必须的:
npm install element-plus pinia vue-router axios echarts sass在main.js里,需要注册Element Plus和Pinia。注意Element Plus按需引入时,用官方插件unplugin-auto-import和unplugin-vue-components,开发时体验很好,能自动导入组件和样式。不过生产环境如果担心CDN原因,建议全部打包进去,因为迎新系统规模不大,包体积多几百KB也没关系。
路由设计方面,我按照角色做了拆分。学生端的路径是/student,管理端的路径是/admin,都嵌套在同一个布局组件下。路由守卫写在全局前置守卫里,判断当前用户是否登录,以及是否具备访问该页面的角色权限。学生端、管理端有各自的Layout,里面包含顶部导航和侧边栏。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } if (token && to.path === '/login') { next('/'); return; } const userRole = localStorage.getItem('userRole'); if (to.meta.roles && !to.meta.roles.includes(userRole)) { next('/403'); return; } next(); });迎新系统的高峰期是开学前一周,有很多学生第一次使用电脑或手机浏览器。所以页面上所有操作按钮都需要有明确的loading状态,防止学生因为网络慢而反复点击。Vue3的v-loading指令可以直接使用,我一般在提交按钮里绑定一个isSubmitting变量,提交中禁用按钮并显示文字。
3.2 关键页面组件拆解
报到页是最复杂的页面。我们用了Element Plus的步骤条组件,把完整的报到过程拆成“身份核验”、“基本信息”、“宿舍选择”、“缴费完成”、“报到单”五个步骤。每个步骤都是独立的子组件,通过父组件维护当前步骤索引。状态提交流程采用先保存后下一步,防止用户跳步。
身份核验步骤里要上传身份证双面图片。上传组件我们用Element Plus的el-upload,同时限制图片大小不能超过5MB,格式只允许jpg和png。这个限制很重要,因为有些手机拍照的照片随便就是10MB以上,如果后端不限制,服务器磁盘和带宽会被占满。
宿舍选择页面是前端交互最多的部分。我们会先从后端接口获取当前学院可选的宿舍楼列表,然后展示成卡片,点击楼栋后展示这个楼栋的各个房间,房间下方列出每个床位。已占用的床位用灰色禁选,可选床位用高亮边框。学生点击床位的瞬间,前端需要再次调用接口确认床位是否仍然空闲,避免页面上展示过期数据。
数据统计大屏是我们后期加的一个页面,主要给学工处的大屏幕展示总报到率、各学院报到人数、最近1小时报到趋势。ECharts用起来很顺手。这里有一个经验:ECharts在组件销毁后一定要调用dispose方法,并且轮询定时器要记得clearInterval,否则在路由切换几次之后,页面会明显卡顿。我们甚至遇到过因为这个原因导致浏览器标签页直接崩溃的问题。
onBeforeUnmount(() => { if (chart) { chart.dispose(); } if (timer) { clearInterval(timer); } });3.3 Axios封装与接口对接
前端所有请求都通过一个封装好的axios实例发送,而不是每个页面单独调用。封装请求最重要的两个点是:统一request URL、自动携带Token、统一处理响应错误。
import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; const request = axios.create({ baseURL: '/api', timeout: 15000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录'); router.push('/login'); } else { ElMessage.error(error.message || '网络异常,请稍后重试'); } return Promise.reject(error); } ); export default request;这里用了baseURL: '/api'而不是写死后端地址,这样在不同环境里只需要改Nginx的反向代理配置,前端代码不用重新构建。开发时我会在vite.config.js里配置proxy代理到本机后端,实现联调无跨域。
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }联调时最烦的一件事是接口字段名对不上。我们统一约定后端返回的JSON字段使用驼峰命名,前端也直接用同名属性。日期时间全部返回字符串,格式为yyyy-MM-dd HH:mm:ss,避免时区差异导致前端解析出错。这个约定从一开始就要在团队里明确,不然后期改起来很痛苦。
4. 部署配置与生产环境优化
4.1 MySQL8连接配置与连接池调优
MySQL8相比5.7,连接驱动名和后端连接参数都有变化。第一次部署时我们就踩过坑:数据库连接报SSL警告,还有时区错误。比较稳妥的JDBC URL配置如下:
jdbc:mysql://localhost:3306/reg_system?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=trueuseSSL=false是因为我们内网部署,没有启用SSL证书,不关掉它会一直警告握手失败。allowPublicKeyRetrieval=true是MySQL8的驱动要求,否则会在某些环境报Public Key Retrieval is not allowed。
连接池我们直接用HikariCP,SpringBoot2默认就带它。配置上要注意maximumPoolSize和minimumIdle。迎新那天高峰期并发并不算特别高,但也需要给200~300并发预留。我们当时把最大连接数设为50,初始化连接数设为10。后来压测发现,如果高峰期有接近上百个并发请求同时进来,50个连接也够用,因为每个接口查询都很快。如果某些报表查询特别慢,才需要单独调大或优化SQL。
spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 50 idle-timeout: 30000 pool-name: RegHikariPool connection-timeout: 5000连接池超时时间也很关键。如果数据库老了或慢查询堆积,连接5秒内获取不到,前端会直接报网络错误。可以稍微调大connection-timeout到10秒,但更重要的是给慢SQL加监控,不要让连接池被慢查询拖死。
4.2 前后端分离部署方案
后端部署最简单,打成jar包扔服务器上跑就行。我们的生产环境是一台8核16G的云主机,数据库和Redis都在同一台机器上,因为学校没有独立机房,这样成本最低。
mvn clean package -DskipTests nohup java -jar reg-system.jar --spring.profiles.active=prod > /data/logs/reg.log 2>&1 &启动脚本里建议加一个进程检查,防止重复启动。多次部署时如果jar包还没结束,会端口冲突报错,所以先检查PID再杀进程:
PID=$(ps -ef | grep reg-system.jar | grep -v grep | awk '{print $2}') if [ -n "$PID" ]; then kill -9 $PID fi前端build后的静态文件放到Nginx的html目录,Nginx配置两个关键点:一是将所有/api路径反向代理到后端,二是解决前端Vue Router history模式刷新404的问题。
location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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 / { root /data/reg-frontend; index index.html; try_files $uri $uri/ /index.html; }try_files那行是为了解决history模式路由刷新404。如果你用hash模式就不会有这个问题,但hash模式下URL中会有#号,用户分享链接时不友好,所以我们还是用了history模式,把Nginx的try_files加上即可。
部署完成后,我习惯再用curl检查一下接口是否正常返回JSON,而不是直接打开浏览器。比如:
curl -X POST http://localhost:8080/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"xxxxx"}'如果返回了token或错误信息就说明后端起来了。Nginx侧也可以用nginx -t检查配置语法再reload。
4.3 安全加固与日志规范
迎新的学生信息包含身份证、联系电话、家庭住址,属于敏感数据。虽然不涉及网络安全法里特别高强度的合规要求,但作为开发团队必须要有底线意识。我们做了几件事:所有管理员密码强制要求复杂度,学生默认密码首次登录必须修改;敏感字段如手机号、身份证在管理后台展示时部分脱敏;导出Excel时增加操作权限校验,并且记录导出日志。
密码存储使用BCrypt加密。Spring Security里自带BCrypt,我们没引入完整Security,但单独用了spring-security-crypto这个依赖。BCrypt会自动加盐,每次加密的产物不一样,相同密码两次加密结果也不同,安全性比MD5高得多。数据库中绝对不允许明文密码,这条在代码评审时是不容讨价还价的。
日志方面,用SpringBoot自带的logback配置按天滚动输出,保留30天。日志内容里不能打身份证号和密码。我们在全局异常处理里,对异常信息做了脱敏处理,并且把用户上下文塞进MDC,这样日志里能自动带上操作人ID,排查问题会方便很多。
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>/data/logs/reg.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>/data/logs/reg.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{userId}] %-5level %logger{50} - %msg%n</pattern> </encoder> </appender>4.4 性能与并发处理经验
迎新系统上线前,必须做一次压力测试。不是所有学校都要求这个,但如果不做,现场出问题后果很严重。我们当时使用JMeter模拟了200个并发用户同时进行登录、查看宿舍、提交选床操作。第一次测试就发现了两个瓶颈:
第一个瓶颈是登录接口里校验验证码时,每次都要查询MySQL里的验证码或直接从Redis获取。我们做成了Redis获取,问题不大。第二个瓶颈是宿舍列表接口会一次性把所有房间数据都返回给前端,数据量大的时候,响应体可能上百KB,拖慢了整个页面。后来改成按楼栋分页懒加载,一次只返回20个房间,响应时间从1.2秒降到了150毫秒。
针对热点数据的缓存,我们用Redis缓存了学校公告、校区列表、专业列表这些几乎不变化的数据。缓存失效策略很简单,管理员修改这些数据时手动删除对应key。注意缓存穿透问题,如果某个key不存在,容易被大量请求打到数据库,所以我们用了“空值缓存”加短暂过期时间。
还有一点值得注意:数据库表要有合理的索引。学生表除了主键,还要在report_status、college_code、student_no和id_card_no上建立索引。否则学院管理员统计报到率时,全表扫描几万条数据会非常慢。我们在建索引时发现,青桐学生表的id_card_no加上唯一索引后,导入数据时能及时发现重复,避免了后面账号关联错乱。
5. 常见问题与排查实录
5.1 数据库时区与SSL连接报错
部署到服务器第一周就收到了页面报错:The server time zone value '�й���ʱ��' is unrecognized。这个错误本质是MySQL和JDBC驱动之间的时区不匹配。我们当时在数据库连接串里已经加了serverTimezone=Asia/Shanghai,但检查发现连接串被application-prod.yaml里的覆盖了,某个同事用了旧版的配置。解决方法是统一所有环境的连接串参数,同时在MySQL里执行set global time_zone = '+8:00'双保险。
SSL连接问题也是高版本MySQL驱动常见的坑。如果内网环境没有配置SSL证书,需要把useSSL=false显式加上,否则会提示Communications link failure或Public Key Retrieval is not allowed。之所以搞出这个问题,是因为MySQL8.0默认使用caching_sha2_password身份验证,和旧驱动不兼容,加allowPublicKeyRetrieval=true后再用新的mysql-connector-j就能解决。
5.2 跨域问题与预检请求
开发联调的时候,前端和后端分别跑在localhost:5173和localhost:8080,前端请求一定会触发跨域。我们用Vite的proxy代理解决了开发环境,生产环境用Nginx反代,所以理论上不需要后端额外处理跨域。但有的运维同事习惯直接打开前端文件,或者某些测试环境没有配Nginx,就会遇到跨域报错。为了稳妥,我在后端配置了全局CORS过滤器:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }这里有一个坑:当请求方法是OPTIONS预检时,Spring Security或拦截器如果拦截它,会返回401导致浏览器认为不允许跨域。我们的AuthInterceptor里要专门放行OPTIONS请求,否则就会出现“request failed with status code 401”但业务接口本身没问题的情况。
5.3 MyBatis-Plus 逻辑删除联查出现的坑
我们给部分表加了逻辑删除,比如学生退学或管理员误删宿舍楼记录时,数据不会物理删除,只是deleted字段变成1。单表查询时,MyBatis-Plus会自动在SQL后面拼上deleted=0,没有问题。但在自定义多表关联SQL时,如果你手写了JOIN语句,MyBatis-Plus不会自动给你加逻辑删除条件。我们有一次统计学院人数时,把已经逻辑删除的学生也统计进去了,导致报到率虚高。
解决方法是所有自定义SQL里都手动加and t.deleted = 0,或者在XML文件里统一给每个表加。这个坑确实容易被忽略,最好在代码评审时专门检查。
另一个逻辑删除的坑是唯一索引。如果某张表用逻辑删除,而你又给某个字段建了唯一索引,比如学生表的学生编号唯一,那么当一条记录被逻辑删除后,再导入一个相同学生编号的新学生,会因唯一索引冲突而失败。因为逻辑删除的记录还在表里,deleted字段也还在,唯一索引依然生效。解决办法是唯一索引不建在该字段上,或改用比较复杂的状态字段并扣回。对于迎新系统,最终我们选择了物理删除退学学生记录,因为他们的历史数据意义不大,而且能避免唯一索引冲突。
5.4 前端 build 后图片资源404
前端打包后上传到Nginx,打开网站发现所有静态图片都404,但CSS和JS正常。排查发现,是Vite打包时配置的base路径不对。默认base是/,如果你的前端应用部署在服务器根路径,应该没有问题。但当时某高校的运维把前端文件放在了一个子目录/reg下,比如访问地址是http://xxx/reg/,这时构建时必须设置base: '/reg/',否则打包出来的index.html引用的JS资源路径是/assets/xxx.js,浏览器会请求到根路径,自然404。
// vite.config.js export default defineConfig({ base: process.env.NODE_ENV === 'production' ? '/reg/' : '/', // ... });如果不想改base,另一种做法是把Nginx的root指到打包目录,保持访问根路径。看运维习惯,我们项目后来统一用Nginx根路径部署,避免了这类问题。不过如果你遇到图片404,可以先检查首页引用的CSS和JS路径是否正确,再检查图片路径是否因为路由模式原因被解析成了相对路径。使用相对路径时,要确保所有img标签的src前面加/assets斜杠,而不是简写成assets。
另外,ECharts组件的某些地图json文件打包后路径也会出问题,需要特别注意public目录和静态资源目录的区别。我的经验是,所有通过JS直接加载的静态文件,尽量放在public目录中,这样不会因为打包哈希和路径问题产生意外。
写在最后的一点经验
每次开发这类业务系统,技术难点反而不是最大障碍,真正决定项目成败的是业务梳理和组织协调。迎新系统涉及学工处、财务处、宿管中心、网络信息中心、招生办多个部门,每个部门都有自己的表格和审批流程。我们当时做的第一件事不是写代码,而是把所有部门的线下Excel整理成一份“报到流程共识表”,明确了每一步由谁录入、由谁审核、由谁能否跳过。前期多花了一周,但在后期开发中省了至少三倍时间。
还有一个建议是,如果条件允许,在大规模使用前先选一个学院做试点。试运行中可以发现很多真实问题,比如某些新生上传的照片格式不对、某些老批次手机浏览器兼容性差、辅导员导出Excel时千位分隔符导致学号变成科学计数法。这些问题在测试环境里很难遇到,现场一跑就全暴露了。我们第一次试点时,提前准备了一个“应急处理表格”,把报错截图、用户操作步骤、后端日志堆栈填在一起,半天就积累了几十条真问题,之后的迭代效率极高。
最后,不要迷信“一套代码直接上线”。迎新系统每年只用几天,但每年都可能面临新需求和新问题。代码注释、数据库设计文档、接口文档一定要跟代码同步维护。我们这套项目里包含了完整的文档,目的就是让明年接手的人不会两眼一抹黑。希望这篇文章能让你在开发类似系统时少走一点弯路。