
又是一年迎新季。如果你负责过高校的信息化建设一定对迎新系统的复杂度深有体会——它要在短短三五天内承载几千名新生线上报到、宿舍分配、缴费确认、绿色通道等一连串流程任何一个环节出问题都会让现场排起长队。最近我们刚完成了一套基于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对并发的处理更好支持行级锁和隐形主键而且服务器上装新版数据库相对容易后续维护成本也更低。技术组件版本主要用途SpringBoot2.7.x后端基础框架、依赖管理、自动配置Vue33.x前端框架、组合式API开发Element Plus2.x管理端页面UI组件MyBatis-Plus3.5.xORM、通用Mapper、分页插件MySQL8.0业务数据存储Redis6.x / 7.x验证码、Token、热点数据缓存Nginx1.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 state0 and version旧版本”如果更新影响行数为0就说明被抢过了直接提示重新选择。不这么做的后果我后面会在问题排查里详细讲。数据库字符集必须使用utf8mb4排序规则用utf8mb4_general_ci。新生姓名里可能有生僻字一些系统的字符集用了utf8mb3会存不进去导致乱码或直接报错。还有身份证号我存的是varchar(18)而不是bigint因为身份证号包含X而且用bigint会丢失前导零。手机号同样用varchar存储避免未来的扩展问题。2.2 统一响应、异常处理与权限校验前后端分离项目里后端和前端必须有一套统一的接口约定。我们的约定是成功时返回code200业务失败返回code500或自定义错误码data字段放业务数据。所有接口返回结构都一样前端拦截器才能统一处理。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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;charsetUTF-8); response.getWriter().write(JSONUtil.toJsonStr(Result.error(未登录或登录已过期))); return false; } }这里还有一个经验登录接口一定要做验证码和频控。迎新期间学生第一次登录系统时因为密码复杂很多人会重复输错如果不做验证码这个接口会变成暴力破解的重灾区。我们用了Redis存验证码有效时间两分钟同一个手机号或用户名每分钟最多请求5次。2.3 MyBatis-Plus 的高效写法用了MyBatis-Plus以后最大的感受是代码量至少减少一半。所有单表CRUD都继承BaseMapper不用再写XML。比如查询某个学院待报到学生列表直接LambdaQueryWrapperStudent wrapper new LambdaQueryWrapper(); wrapper.eq(Student::getCollegeCode, collegeCode) .eq(Student::getReportStatus, 0) .orderByAsc(Student::getStudentNo); studentMapper.selectList(wrapper);如果是复杂的报表统计比如按学院统计报到人数用自定义SQL写在Mapper里即可。注意一个坑如果你同时使用LambdaQueryWrapper和自定义XML文件实体类字段名到数据库列名的映射要保持一致MyBatis-Plus默认开启驼峰转下划线但我们把实体类字段也大多数都用数据库一致来命名这样减少混淆。分页插件几乎是必须的。学生列表、缴费记录、公告列表在管理后台都要分页。配置分页插件只需要注册一个BeanConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }之后使用Page对象就能自动拼接limit比如PageStudent page new Page(current, size); studentService.page(page, wrapper);另一个非常实用的功能是自动填充。学生表里有create_time和update_time如果不做自动填充每次insert和update都要手动set当前时间很容易遗漏。实现MetaObjectHandlerComponent 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 vitelatest 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?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse是因为我们内网部署没有启用SSL证书不关掉它会一直警告握手失败。allowPublicKeyRetrievaltrue是MySQL8的驱动要求否则会在某些环境报Public Key Retrieval is not allowed。连接池我们直接用HikariCPSpringBoot2默认就带它。配置上要注意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.activeprod /data/logs/reg.log 21 启动脚本里建议加一个进程检查防止重复启动。多次部署时如果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 nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/data/logs/reg.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/data/logs/reg.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{userId}] %-5level %logger{50} - %msg%n/pattern /encoder /appender4.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驱动之间的时区不匹配。我们当时在数据库连接串里已经加了serverTimezoneAsia/Shanghai但检查发现连接串被application-prod.yaml里的覆盖了某个同事用了旧版的配置。解决方法是统一所有环境的连接串参数同时在MySQL里执行set global time_zone 8:00双保险。SSL连接问题也是高版本MySQL驱动常见的坑。如果内网环境没有配置SSL证书需要把useSSLfalse显式加上否则会提示Communications link failure或Public Key Retrieval is not allowed。之所以搞出这个问题是因为MySQL8.0默认使用caching_sha2_password身份验证和旧驱动不兼容加allowPublicKeyRetrievaltrue后再用新的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后面拼上deleted0没有问题。但在自定义多表关联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时千位分隔符导致学号变成科学计数法。这些问题在测试环境里很难遇到现场一跑就全暴露了。我们第一次试点时提前准备了一个“应急处理表格”把报错截图、用户操作步骤、后端日志堆栈填在一起半天就积累了几十条真问题之后的迭代效率极高。最后不要迷信“一套代码直接上线”。迎新系统每年只用几天但每年都可能面临新需求和新问题。代码注释、数据库设计文档、接口文档一定要跟代码同步维护。我们这套项目里包含了完整的文档目的就是让明年接手的人不会两眼一抹黑。希望这篇文章能让你在开发类似系统时少走一点弯路。