大四那会儿我做过一个很典型的课设级项目高校实习管理系统。说它是管理系统其实更像把学校里学生找实习、报企业、交材料、打成绩这一套线下流程搬到线上。当时选型就是SpringBoot Vue数据库用MySQLORM用MyBatis没有任何花哨中间件。做完之后我发现这类系统看起来功能简单但真要把流程串顺、让老师、学生、企业三方都觉得好用里面值得抠的细节非常多。这篇文章就完整复盘一下我的设计与实现过程从需求拆解到数据库设计、后端接口、前端页面、部署上线尽量把每一处的选型理由和踩坑点都写清楚。适合正在做毕设或者课程设计、想快速搭一套前后端分离管理系统的人参考。1. 项目背景与功能定位1.1 为什么需要实习管理系统很多高校的实习管理还停留在“填表-盖章-交纸质”阶段。学生找好实习单位后要打印申请表找导师签字再交到学院实习过程中要定期汇报最后还要把实习手册、考核表、鉴定表往系统里录一遍。老师这边更头疼几十上百份材料要逐个审核记录成绩归档存证。这套流程如果做成线上系统核心价值就是让状态可追踪、材料可沉淀、审批有留痕。我做的这套系统瞄准的正是这个痛点。它不是一个大而全的校园服务平台而是专门围绕“实习全生命周期”设计的小型业务系统。适合的场景包括学生提交实习申请、企业发布实习岗位、导师审核并分配、学生提交周报与总结、导师评定成绩、学院管理员统一查看统计。整个系统内部闭环不依赖第三方服务部署成本低也符合课程设计或毕业设计的复杂度要求。1.2 核心角色与业务流程系统里必须有四种角色缺一个流程都跑不通。学生查看实习岗位申请岗位提交实习申请填写周报、月报和实习总结查看成绩。企业发布岗位接收学生申请确认实习接收关系给出简要评价。导师校内指导老师审核学生实习申请查看学生填写的周报总结评定实习成绩。管理员维护班级信息、专业信息管理用户账号配置实习时间段查看全校实习数据统计。业务流程上我设计了这样一条主线学生登录后先完善个人简历然后浏览企业发布的实习岗位点击申请后岗位所属企业进行确认企业确认后学生需要再向导师提交正式实习申请这一步保留学校审批环节导师审核通过后形成有效实习记录实习期间学生按周期提交周报实习结束后提交总结和实习单位盖章证明导师根据周报完成度、总结质量和企业反馈综合给出成绩。整体状态机是待申请 → 企业待确认 → 导师待审核 → 实习中 → 待评定 → 已结束。1.3 功能清单把角色拆开后功能点就很清晰了学生端注册登录、个人信息维护、简历编辑、岗位浏览与申请、实习申请提交、周报填写、实习总结提交、成绩查看。企业端岗位发布与下架、实习申请处理、学生状态查看、实习评价。导师端学生实习申请审核、周报查看与批注、成绩评定、所带学生列表。管理端用户管理、班级管理、专业管理、统计报表、实习时间段配置。这个功能清单看着不大但已经覆盖了一个典型业务系统的全部要素用户权限、核心业务流转、文件上传、状态变更、数据统计。做课程设计时功能数量不是越多越好而是要把这些点做扎实。2. 技术选型与架构设计2.1 为什么是SpringBoot Vue 前后端分离如果你只是做一个小系统JSP Servlet也能实现但维护起来真的很痛苦。SpringBoot的好处在于内置Tomcat、自动配置、起步依赖管理写一个main方法就能启动基本上不需要XML配置。配合MyBatisSQL都是自己写的逻辑透明调试也方便。这种组合在高校项目里几乎成了标配一方面确实够用另一方面遇到问题时网上能参考的资料最多。Vue这边我选择了Vue2生态因为当时稳定配合Element UI组件库。Vue的核心价值是数据驱动视图做表单、列表、弹窗、分页这类操作特别顺手。前后端分离后后端只提供JSON接口前端通过axios调用两者各自独立开发、独立部署后续如果想把系统改成小程序或者App端后端接口可以复用这也是我坚持前后端分离的主要原因。2.2 MyBatis与MySQL的搭配数据库选MySQL纯粹是通用性考虑学过的、见过的、部署过的基本都是它。MyBatis相比JPA胜在SQL可控。举个例子统计每个导师带了多少个实习学生JPA你得写JPQL或者用SpecificationMyBatis直接写一行GROUP BY查询就可以了性能如何、索引有没有命中自己心里特别清楚。对于实习管理这种报表需求较多的系统MyBatis显然更顺手。另外MyBatis的动态SQL也非常有用。筛选学生列表时管理员可能按班级、姓名、状态等组合条件查询直接在Mapper XML里用where if标签拼接查询条件比自己写StringBuilder拼SQL干净得多。项目依赖我放在pom.xml里关键部分如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies2.3 项目整体目录结构后端采用常见的分层结构controller接收请求service处理业务mapper操作数据库。前端采用Vue CLI标准结构按view组织页面。整体如下backend ├── src/main/java/com/example/internship │ ├── controller # 接口层 │ ├── service # 业务逻辑层 │ ├── mapper # MyBatis Mapper接口 │ ├── entity # 数据实体 │ ├── dto # 请求/响应对象 │ ├── config # 拦截器、跨域配置 │ ├── common # 统一结果、异常、工具类 │ └── InternshipApplication.java └── src/main/resources ├── mapper # Mapper XML文件 └── application.yml frontend ├── src/api # axios请求封装 ├── src/assets ├── src/components # 公共组件 ├── src/router # 路由配置 ├── src/store # Vuex状态管理 ├── src/views # 页面 └── src/utils # 工具函数这种结构重点分明业务代码不会堆在一个包里排查问题的时候能快速定位。3. 数据库设计与核心表结构3.1 实体关系梳理数据库设计我一般先画实体关系。这个系统的核心实体有用户、学生信息、教师信息、企业信息、岗位、实习申请、实习记录、周报、成绩。整体关系不算多但要注意几个关键点用户表是所有角色登录的凭证通过role字段区分角色学生和教师分别关联用户ID。企业和教师是独立实体不挂在用户体系里也行但企业登录也需要账号所以企业表也存账号密码。实习申请是业务的核心它关联学生、岗位、导师、企业多方。周报和成绩都关联到一条实习记录上实习记录从审核通过的申请中产生。3.2 核心表字段设计这里我只列几张最核心的表。用户表要包含登录所需字段不能明文存密码我用的是BCrypt加密后的密文。学生表要关联班级和专业方便管理端按条件筛选。岗位表主要是标题、描述、地点、薪资范围、状态等。实习申请表的状态字段是整个流程的驱动我习惯用字符串状态加时间戳。学生表的设计如下CREATE TABLE student ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL COMMENT 关联用户ID, student_no varchar(20) NOT NULL COMMENT 学号, name varchar(50) NOT NULL COMMENT 姓名, gender varchar(10) DEFAULT NULL, phone varchar(20) DEFAULT NULL, major varchar(50) DEFAULT NULL, class_name varchar(50) DEFAULT NULL, resume text COMMENT 简历简介, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;实习申请表是整个系统的枢纽字段设计时重点考虑状态流转和历史记录的沉淀CREATE TABLE internship_apply ( id int NOT NULL AUTO_INCREMENT, student_id int NOT NULL, position_id int NOT NULL, company_id int NOT NULL, teacher_id int DEFAULT NULL, status varchar(20) DEFAULT PENDING COMMENT 状态PENDING-待企业确认 COMPANY_CONFIRMED-企业已确认 TEACHER_PENDING-待导师审核 ACTIVE-实习中 FINISHED-已结束 REJECTED-驳回, apply_time datetime DEFAULT NULL, company_confirm_time datetime DEFAULT NULL, teacher_audit_time datetime DEFAULT NULL, start_date date DEFAULT NULL, end_date date DEFAULT NULL, reject_reason varchar(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.3 建表SQL示例周报表要注意约束一条实习记录只能填某个时间范围的周报不然学生会乱填。我用唯一索引限制周次CREATE TABLE weekly_report ( id int NOT NULL AUTO_INCREMENT, internship_id int NOT NULL, week_no int NOT NULL COMMENT 第几周, content text NOT NULL COMMENT 周报内容, work_summary text COMMENT 工作总结, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_internship_week (internship_id, week_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;成绩表则要冗余一下学生、课程这里指实习项目和导师信息方便后期报表查询避免到处关联CREATE TABLE internship_score ( id int NOT NULL AUTO_INCREMENT, internship_id int NOT NULL, student_id int NOT NULL, teacher_id int NOT NULL, score decimal(5,2) DEFAULT NULL, comment varchar(500) DEFAULT NULL, level varchar(20) DEFAULT NULL COMMENT 优秀/良好/及格/不及格, audit_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;数据库设计的教训表字段宁可拆细不要塞一个大字段。比如“企业评价”一开始我就放在实习记录表里后来发现导师评分、企业评价、学生自评三个维度混在一起查询很别扭。拆成单独的评价表和成绩表后逻辑清爽了很多。4. 后端核心模块实现4.1 统一返回结果与异常处理前端和后端约定接口风格是一件非常重要的事。我这里定义了一个Result类包含code、message、data三个字段code为200表示成功400为参数错误401未登录500系统异常。所有Controller都返回这个对象前端只需要在拦截器里统一判断code即可不用每个接口单独写错误处理。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT fail(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理用RestControllerAdvice拦截业务异常和未知异常统一返回Result。这样一来Controller里面就不需要写一堆try-catch了代码非常简洁。4.2 登录认证与拦截器登录我用了JWT。流程是用户提交账号密码后端验证通过后生成token返回给前端前端把token存在localStorage中每次请求在请求头带上Authorization字段后端写一个拦截器解析token并把用户信息放入ThreadLocal方便Service获取当前登录人。JWT的生成代码很简单使用io.jsonwebtoken的JJWT库public String createToken(int userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器要注意放行登录、注册、接口文档等路径其他路径全部校验。另外我处理跨域问题是在项目里加了CorsConfiguration而不是在Nginx层处理这样本地调试前后端分离环境更方便。这里我踩过一个坑SpringBoot拦截器对OPTIONS预检请求也会拦截导致前端axios跨域预检被返回401。解决办法是在拦截器里直接放行OPTIONS请求否则前后端联调时所有带token的复杂请求都会失败。4.3 实习申请流程实现实习申请的接口是整个系统的核心它不是一个简单的插入操作而是一连串状态迁移。我的实现方式是单独写一个InternshipApplyService方法名直接表达业务动作比如applyInternship、companyConfirm、teacherAudit、startInternship等这样看代码就像读需求文档。以学生提交申请为例Transactional public void applyInternship(ApplyRequest request) { Student student studentMapper.findByUserId(CurrentUser.getId()); if (student null) { throw new BizException(请先完善学生信息); } InternshipApply apply new InternshipApply(); apply.setStudentId(student.getId()); apply.setPositionId(request.getPositionId()); apply.setCompanyId(request.getCompanyId()); apply.setStatus(PENDING); apply.setApplyTime(new Date()); internshipApplyMapper.insert(apply); }注意这里加了Transactional因为后续还会有插入日志、扣减岗位名额等操作事务保证不会出现半个成功半个失败的情况。企业确认后再提交导师审核实际上我是在一个申请记录上连续修改状态。用多个表去记录申请副本会更规范但对于课设来说单表状态流转更直观也更容易讲清楚。4.4 实习成绩评定逻辑成绩评定不能简单存一个数字就完事。我设计了三个维度周报提交质量、实习总结质量、企业评价。导师端接收这三个维度打分系统按权重计算综合分再映射为等级。比如周报占30%总结占30%企业评价占40%。为了控制总分不超过100所有输入限制在0到100分范围内。计算逻辑public BigDecimal calcScore(Integer weekly, Integer summary, Integer companyScore) { BigDecimal result BigDecimal.valueOf(weekly * 0.3 summary * 0.3 companyScore * 0.4); return result.setScale(1, RoundingMode.HALF_UP); }等级映射就很简单了90以上优秀80到90良好60到80及格低于60不及格。这里我觉得比较重要的点是成绩不能由导师随意改要改就要走审核接口并记录修改日志否则答辩时老师问你“成绩怎么保证真实性”你会答不上来。4.5 文件上传与预览学生需要上传实习证明或盖章扫描件这类场景我直接用本地存储不上云。因为课设项目没有域名和对象存储本地存储是最务实的做法。上传接口使用SpringMVC的MultipartFile保存路径配置在application.yml里file: upload-dir: ./upload/文件保存时用UUID生成新文件名避免中文名和重复名带来的问题。返回给前端的是文件访问相对路径前端把这个路径和业务绑定。预览则通过一个ResourceHandler映射虚拟路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceHandler(file: uploadDir /); } }这里要特别注意路径分隔符Windows和Linux不一样直接用File.separator拼接最稳妥。我用相对路径./upload部署时在jar包同级目录产生upload文件夹简单也直观。5. 前端核心模块实现5.1 Vue工程搭建与路由权限前端我选择用Vue CLI快速初始化项目安装vue-router、vuex、axios、element-ui。路由权限通过导航守卫控制根据本地存储的roles字段判断当前用户能访问哪些页面。比如管理员页面路径为/admin如果角色是student访问直接跳转到首页并提示无权限。路由设计const routes [ { path: /login, component: Login }, { path: /, component: Layout, children: [ { path: jobs, name: JobList, component: JobList, meta: { roles: [student] } }, { path: apply, name: MyApply, component: MyApply, meta: { roles: [student] } }, { path: reports, name: ReportList, component: ReportList, meta: { roles: [student, teacher] } }, { path: students, name: StudentManage, component: StudentManage, meta: { roles: [admin] } } ]} ];导航守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } const roles JSON.parse(localStorage.getItem(roles)); if (to.meta.roles !to.meta.roles.some(r roles.includes(r))) { next(/403); return; } next(); });5.2 页面组件与API封装前端所有请求都走一个封装后的request.js在axios实例里统一设置baseURL并给每个请求加上token请求头。响应拦截器里对code非200的情况统一ElMessage提示对401则跳到登录页。service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use(res { const result res.data; if (result.code ! 200) { Message.error(result.message); if (result.code 401) { router.push(/login); } return Promise.reject(result); } return result.data; });页面组件以列表页为例通常包含搜索表单、表格、分页、操作按钮四部分。我用Element UI的el-table和el-pagination结合搜索条件绑定在search对象里点击查询时重新请求列表分页参数变化时也重新请求。岗位列表页最关键的是状态展示我用el-tag根据status显示不同颜色。例如正常绿色、已停用灰色。这类小封装能大大提升后台管理页的观感答辩时也能加分。5.3 关键交互场景有几个交互做得不好会让系统显得很业余。第一个是学生选择岗位后需要回显企业信息。前端在提交申请前先弹一个确认框展示岗位名称、企业名称、实习地点用户确认后才提交。这能防止误操作。第二个是导师审核时常见操作是通过、驳回。我设计成审核弹窗通过时可以直接编辑成绩权重驳回时必须填写原因。后端如果检查到驳回原因为空直接抛参数异常。这样可以倒逼用户填完整信息保证数据质量。第三个是周报编辑页。周报内容较长我做成富文本保存纯文本并限制字数3000。前端用el-input的typetextarea加maxlength实时显示剩余字数。提交时按实习记录周次查重已经填过的周次不允许再次填写直接提示“该周周报已提交”。6. 部署与运行6.1 本地环境准备本地跑这套系统需要准备JDK 8以上、Maven 3.6、MySQL 5.7以上、Node.js 14。版本方面SpringBoot我用的2.5版本前端Vue CLI用的4.5兼容性比较稳妥不需要踩新版本那些builder配置坑。环境准备的关键是MySQL初始化。我新建了一个internship_db数据库直接把SQL脚本导入。需要注意数据库账号密码和application.yml中保持一致否则后端启动时会报连接拒绝。6.2 打包配置与启动步骤后端打包很直接在项目根目录执行mvn clean package -DskipTests生成target目录下的jar包然后运行java -jar internship-system.jar前端打包前先修改.env.production里的API地址让请求指向后端实际地址然后执行npm run build生成dist目录里面是纯静态资源放到Nginx的html目录配置Nginx把/api开头的请求反向代理到后端端口同时把非/api请求指向dist下的index.html。Nginx配置片段server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }我把后端接口前缀统一加上了/api这样代理规则写起来很舒服前后端联调时也不会出现路径拼错的问题。部署时最容易出的问题是前端打包后直接双击index.html打开结果一片空白。这是因为路由用了history模式。解决办法是改成hash模式或者像上面这样在Nginx配置try_files。我实际项目里改成了hash模式省去很多部署环境差异问题。7. 常见问题与排查技巧实录在这个项目上我遇到并解决了不少问题挑几个最有代表性的写出来后面写着类似管理系统的同学可以直接照方抓药。问题现象可能原因解决办法后端启动时报数据库连接失败application.yml里的账号密码或数据库名错误核对MySQL连接配置使用show databases确认库是否存在前端请求接口跨域报错后端未配置CORS或拦截器拦截了OPTIONS后端配置跨域并在拦截器中放行预检请求登录成功后刷新页面跳回登录页路由守卫只判断token但未判断token是否过期在路由守卫中增加向后端校验token有效性的接口调用前端表格数据不更新没有使用key绑定或没强制刷新列表在操作回调中重新调用列表接口图片上传后访问404静态资源映射路径配置错误检查addResourceHandlers中的路径注意斜杠与目录拼写MyBatis查询结果字段为null实体类驼峰字段与数据库下划线字段未匹配在application.yml开启map-underscore-to-camel-case: true分页数据总数不对SQL写错用了LIMIT但是没有统计总数使用PageHelper插件或手写count查询还有一个我印象很深的问题是MyBatis在写多表关联查询时结果集的映射经常对不上字段。后来我习惯在Mapper XML里为每一个查询字段指定别名例如select idselectApplyDetail resultTypemap SELECT a.id, s.student_no AS studentNo, s.name AS studentName, c.name AS companyName, p.title AS positionTitle, a.status AS status FROM internship_apply a LEFT JOIN student s ON a.student_id s.id LEFT JOIN company c ON a.company_id c.id LEFT JOIN position p ON a.position_id p.id WHERE a.id #{id} /select因为返回的是Map不需要定义专门的VO写起来最快但下游取值必须严格按别名大小写来否则取不到。这个方法适合快速实现联表查询如果长期维护建议还是定义清晰的VO类。排查技巧方面我强烈建议从一开始就在后端加上统一日志。每个Controller入口打一行请求日志参数、执行时间都打出来。定位问题时先看日志再断言请求是否到达后端顺着链路一层层查效率非常高。前端打开开发者工具重点是Network面板里请求的状态码和响应体很多所谓“后端毛病”其实是前端参数没传对。最后分享一个我在实际调试中的体会不要一上来就写全部功能先撸一个最小的闭环——管理员登录、创建学生账号、学生登录、提交申请、导师审核、打分。这个闭环跑通后再往里加周报、上传、统计等功能。小闭环跑通了你心里就有底后面新增内容基本上就是在一个稳定的骨架上贴肉。我在这个项目里就是先把主线流程打通再扩充边角功能整个过程基本没有返工。这套系统做完后我最大的收获其实不是学会了某个框架而是理顺了一个业务需求如何一步步变成可运行的代码。实习管理说起来不起眼但把角色、状态、权限、数据关系理清楚你能迁移到很多类似的管理系统上。如果你正在做类似的课设或毕设建议先把核心流程的数据库表和状态设计画明白再动手写代码这条路会顺利很多。
阅读完成 · 觉得有帮助?