高校教师教研信息填报系统实战:SpringBoot+Vue3前后端分离开发全解析
不得不感慨一句这两年凡是跟“填表”沾边的系统都会成为刚需。尤其高校里的教研信息填报以前靠Excel来回发、邮箱反复收导员和教务老师一到月底就崩溃。我自己最近刚好基于Java SpringBoot Vue3 MyBatis这套组合完整复刻了一遍前后端分离的高校教师教研信息填报系统数据库用的MySQL业务上覆盖了教师信息维护、教研成果填报、院系审核和统计看板。这篇文章就把整个实现过程、选型思路、踩坑记录都摊开讲清楚想直接cv源码改改就用的朋友这篇值得存一下。系统本身解决的痛点很简单把“教师填表 — 教研室汇总 — 院系审核 — 教务处存档”这条工作流从线下挪到线上所有人操作留痕、状态可追踪、汇总不再靠手工。适合用来做毕业设计、课程设计或者作为公司内部小中台系统的模板。下面按我实际开发的顺序从整体设计、数据库、后端、前端、部署、排错这六块逐层拆解。1. 整体项目设计与技术选型思路1.1 为什么选前后端分离而不是单体JSP高校内部系统有个特点流程不复杂但角色多、状态多、权限边界清楚。用传统的单体JSPServlet硬怼也能跑但后续想接移动端、想换皮肤、想独立上线给不同院系用都会非常痛苦。前后端分离方案下后端只暴露JSON接口前端用Vue3独立构建后面就算要把这套逻辑套到Pad端、小程序端后端基本不用动。这套系统的核心角色有三类普通教师、院系管理员、教务处管理员。每个角色看到的菜单、能做的操作完全不一样所以权限模型从一开始就要定清楚不能等开发到一半再补。技术栈选型上我采用的是目前Java全栈里最主流、也最容易招人/找资料/抄代码的一套后端Spring Boot 2.7.xJDK1.8兼容MyBatisXML模式MySQL 8.0JWT做无状态登录。前端Vue 3.2组合式APIVite 4.x构建Pinia管理状态Element Plus做UIECharts做统计图表。部署前后端单独打包Nginx做反向代理和静态资源服务。这套选型的核心理由就一句话生态成熟遇到问题能搜到答案。高校项目一般都有明确交付周期千万不要为“炫技”去引入乱七八糟的微服务、分布式事务单机前后端分离已经是复杂度与稳定性的最佳平衡点。1.2 业务流程和模块边界怎么划分功能模块我拆成了六个大块系统登录与权限控制、教师信息管理、教研填报管理、审核流程管理、数据统计分析、系统配置管理。前端对应六个路由模块后端对应六个Controller模块。其中填报管理是核心业务它不能只是一个简单的“新增一条记录”而是要支持“按学期/学年创建填报任务 → 教师按任务填报 → 院系审核 → 教务处归档”的完整状态机。状态流转用整数表示0草稿、1待提交、2院系审核中、3院系退回、4教务处审核中、5通过、6归档。数据权限也要在这里想清楚教师只能看到自己所在院系的填报任务院系管理员能看到全院系的填报数据教务处能看到全部。注意这里不能只靠接口里传userId就去查要在SQL层强制拼接数据权限条件防止越权访问。2. 数据库设计从ER模型到建表SQL2.1 核心表结构设计详解数据库设计是整个系统里最不能赶时间的环节。我画完ER图后一共设计了8张核心表这里逐个说明设计意图。sys_user用户表主键id、username、passwordBCrypt加密、real_name、role_type1教师 2院系管理员 3教务处管理员、college_id所属院系、phone、email、status。注意这里用户名不要用工号高校人员调动频繁用工号做登录名一旦变更就容易出问题建议用独立的username字段。sys_college院系表主键id、college_name、code、description。这张表看似简单但它是整个数据权限体系的地基所有用户和业务数据都要通过college_id关联到这张表。teach_teacher_info教师信息表主键id、user_id、teacher_no、title职称、degree学历、research_direction研究方向、join_date、profile。这里采用用户表与教师信息表分离的方式方便以后教师档案扩充字段不用动登录体系。teach_task填报任务表主键id、task_name、academic_year、semester、task_type1教学工作量 2科研成果 3教研项目、start_time、end_time、status、create_by。填报任务由教务处创建发布后教师在有效时间内可以填报。teach_submit_record填报记录表主键id、task_id、user_id、submit_dataJSON类型、status、submit_time、reject_reason、audit_user_id、audit_time。这里把教师填写的内容统一放JSON字段而不是为每类填报任务单独建表。刚开始我也纠结过要不要拆表后来想通了填报字段经常变今年要统计论文分区明年又要统计横向课题到账金额拆成固化的字段表以后每次加字段都要改表结构加接口运维成本太高。用JSON字段配合前端动态表单渲染加字段只改字典配置就行。teach_attachment附件表主键id、biz_type、biz_id、file_name、file_path、file_size、upload_user、upload_time。用于存放教师上传的佐证材料比如论文首页扫描件、项目立项书等。sys_dict_data字典数据表主键id、dict_type、dict_label、dict_value、sort_no、status。统一维护职称选项、学历选项、成果类型选项等避免前端写死下拉框。sys_operation_log操作日志表主键id、user_id、operation、method、params、ip、create_time。审计追踪用高校项目评审时经常要求有操作留痕这张表能派上大用场。2.2 索引设计与数据字典索引这一块填报记录表是数据量增长最快的表这里一定要加联合索引。我实际建表时在teach_submit_record上建了(task_id, user_id)联合索引因为查询基本都是以“某个用户在某任务下的记录”或“某个任务下的所有记录”为维度。如果后续数据量真的超过百万级可以再考虑增加(status, audit_time)索引支撑待办查询和归档统计。CREATE TABLE teach_submit_record ( id bigint(20) NOT NULL AUTO_INCREMENT, task_id bigint(20) NOT NULL COMMENT 填报任务ID, user_id bigint(20) NOT NULL COMMENT 填报人用户ID, submit_data json DEFAULT NULL COMMENT 填报内容(JSON格式), status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1待提交 2院系审核中 3院系退回 4教务处审核中 5通过 6归档, submit_time datetime DEFAULT NULL COMMENT 提交时间, reject_reason varchar(500) DEFAULT NULL COMMENT 驳回原因, audit_user_id bigint(20) DEFAULT NULL COMMENT 审核人ID, audit_time datetime DEFAULT NULL COMMENT 审核时间, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_task_user (task_id,user_id), KEY idx_status_time (status,audit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT填报记录表;字段注释一定要写清楚尤其是状态字段的枚举含义。时间字段统一用datetime不要踩timestamp在2038年的坑字符集必须用utf8mb4不要用utf8否则用户填个生僻字或者Emoji表情直接报错。3. 后端核心实现与细节拆解3.1 SpringBoot工程结构与统一返回体后端的包结构我习惯按“入口 → 控制层 → 业务层 → 持久层”拆分com.xxx.teachfill ├── TeachFillApplication.java ├── config (跨域、拦截器、自定义异常处理) ├── controller (接口入口只做参数接收与路由) ├── service (业务逻辑层业务规则都在这里) ├── mapper (MyBatis接口对应XML文件) ├── entity (数据库实体严格对应表字段) ├── dto (前端交互对象用于参数校验与返回) ├── utils (JWT工具、日期工具、文件工具) └── common (统一返回体、常量、异常类)统一返回体我采用最简方案codemessagedata。code为200表示成功401表示未登录或登录过期403表示无权限500表示服务器异常。前端axios拦截器统一判断code而不是只看HTTP状态码这样后端业务异常也能走统一提示通道。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }这里要提醒一个新手最常犯的错误把entity直接返回给前端。数据库字段有时候会多出一些敏感信息比如密码的hash值、内部备注一旦直接返回等于把这些信息暴露给所有调用方。我的做法是专门建VO对象只保留前端要用的字段。虽然多写几个类麻烦一点但数据安全这件事不能省。3.2 登录鉴权与JWT方案细节登录接口流程用户名密码 → BCrypt校验 → 生成JWT → 返回token和用户基本信息。JWT的有效期我设置为12小时考虑到教师填表往往集中在某个时间段登录一次够用过期后前端自动跳转登录页即可。JWT里我放了三个字段userId、roleType、collegeId。这三个字段每次请求拦截器解析出来直接塞到ThreadLocal里业务层随时可以拿到当前登录人身份而不需要每次查数据库。数据权限的过滤也是靠collegeId实现拦截器里根据角色判断是否有权访问某个院系的数据。Component public class JwtInterceptor 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); try { Claims claims JwtUtil.parseToken(token); Long userId Long.valueOf(claims.get(userId).toString()); Integer roleType Integer.valueOf(claims.get(roleType).toString()); Long collegeId Long.valueOf(claims.get(collegeId).toString()); UserContext.set(new UserContext.UserInfo(userId, roleType, collegeId)); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }这里要注意一个非常隐蔽的坑拦截器只拦截了需要登录的接口但有些接口比如文件预览可能是通过img标签直接访问的它会带上token吗带不上。所以附件预览接口我单独做了处理允许短期内有效的临时token参数或者把附件权限校验下沉到越权判断逻辑里而不是依赖全局拦截器。3.3 MyBatis动态SQL与填报查询优化MyBatis在这套系统里最大的价值就是动态SQL。填报任务列表的查询条件是可变的按学年、按学期、按任务类型、按状态。如果用JPA写这种多条件查询会非常痛苦而MyBatis的where标签配合if就能优雅解决。select idselectTaskPage resultTypecom.xxx.teachfill.entity.TeachTask SELECT * FROM teach_task where if testacademicYear ! null and academicYear ! AND academic_year #{academicYear} /if if testsemester ! null and semester ! AND semester #{semester} /if if testtaskType ! null AND task_type #{taskType} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select批量插入也要用MyBatis的foreach特性。教师端如果一次性填了20条科研成果后端接口接收List一条insert语句搞定insert idbatchInsert INSERT INTO teach_submit_record (task_id, user_id, submit_data, status, create_time) VALUES foreach collectionlist itemitem separator, (#{item.taskId}, #{item.userId}, #{item.submitData}, 0, NOW()) /foreach /insert不要循环去调单条insert20条数据可能看不出差异但如果是500人的批量导入循环插入的时间和数据库连接开销会让你等到怀疑人生。还有一个重要细节MySQL的JSON字段在MyBatis里要配置类型处理器。如果直接用String接收和插入MySQL会做隐式转换表面上能跑但遇到JSON里的特殊字符就会出问题。我在application.yml里配置mybatis: configuration: map-underscore-to-camel-case: true type-handlers-package: com.xxx.teachfill.config然后自定义一个JacksonJsonTypeHandler用ObjectMapper把Java对象序列化成JSON字符串存入MySQL读取时反序列化成Map或List。这样submit_data字段就能直接映射到MapString, Object前端传什么结构都能存。3.4 文件上传与附件管理的实现教师填报教研成果时经常要传PDF、图片等佐证材料文件上传接口需要考虑三点文件大小限制、文件类型过滤、文件存储路径规划。Spring Boot里配置spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB存储路径我建议放在服务器的/data/teachfill/files/目录下按业务类型/年月/建二级目录文件名用UUID 原始扩展名拼接。千万不要用原始文件名直接落盘否则中文文件名和重名覆盖问题会搞得你头皮发麻。上传接口的逻辑很简单接收MultipartFile→ 校验扩展名和大小 → 生成存储路径 → 保存文件 → 将文件元数据写入teach_attachment表 → 返回文件ID给前端。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file, RequestParam(bizType) String bizType) { if (file.isEmpty()) { return Result.error(400, 上传文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); ListString allowExt Arrays.asList(.pdf, .jpg, .png, .doc, .docx, .xls, .xlsx, .zip); if (!allowExt.contains(ext.toLowerCase())) { return Result.error(400, 不支持的文件类型); } // 生成存储文件名 fileName UUID.randomUUID().toString() ext; // 拼接日期目录 /data/teachfill/files/ bizType / yyyyMM / // 写入附件表返回附件id }附件下载/预览接口要做权限控制这是很多人容易漏掉的地方。我实际项目里就遇到过教师上传的未公开课题申报书结果只要猜到文件URL就能被别人下载。最简单的做法是下载接口不要直接暴露文件路径而是通过attachmentId去附件表校验bizId归属人再决定是否允许下载。4. 前端Vue3工程实战要点4.1 Vite构建与工程目录结构前端我用Vite创建相比Webpack最大的感受就是快尤其在高校机房那种配置不算高的机器上启动开发服务器从十几秒缩短到两三秒非常明显。工程目录src ├── api (按模块封装的接口请求如task.js、user.js、stats.js) ├── assets (静态资源) ├── components (通用组件) ├── router (路由配置含动态路由与权限控制) ├── stores (Pinia状态) ├── views (页面视图) ├── utils (axios封装、日期格式化等工具) ├── App.vue └── main.js入口配置路由权限是整套前端安全的前置保障。我的方案是登录成功后后端返回该用户的角色前端根据角色注册可见路由表同时配合router.beforeEach做本地校验。这里要说清楚前端路由控制只是改善用户体验真正的权限校验永远在后端接口。万一有人绕过前端直接调用接口后端拦截器必须能挡住。4.2 Pinia状态管理与axios封装Pinia比Vuex清爽太多了没有那么多概念适合中小型项目。我建了三个storeuserStore存用户信息、token、角色、appStore存全局Loading状态、折叠状态、dictStore存字典数据登录后拉一次全局共享。axios封装里要处理的几个关键点请求拦截器加Authorization头、响应拦截器统一处理code、HTTP 401时跳登录页、下载文件时处理blob类型。// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else if (res.code 401) { userStore.resetAuth() router.push(/login) return Promise.reject(new Error(res.message)) } else { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )baseURL这里有个我踩过好几轮的坑开发环境下.env.development里的VITE_API_BASE_URL设成http://localhost:8080/api配合后端CORS配置生产环境设成/api由Nginx统一代理到后端服务地址。两种环境用同一个变量名但值不同千万不能在代码里写死。4.3 动态表单渲染与填报页面实现教师填报的难点是任务类型不同表单字段就不同。比如“教学工作量”要填课程名称、课时数、课程类型“科研成果”要填论文标题、期刊名、收录情况、影响因子。如果每种类型都硬编码一套表单以后新增任务类型还得改代码、重新打包发布。我用的是动态表单配置方案后端在创建填报任务时同时配置该任务的表单字段定义字段名、标签、类型、是否必填、选项值存成JSON放在任务表里。前端拿到任务详情后通过组件遍历字段配置动态渲染表单项。template el-form refformRef :modelformData :rulesformRules label-width120px el-form-item v-forfield in taskFieldConfig :keyfield.prop :labelfield.label :propfield.prop el-input v-iffield.type input v-modelformData[field.prop] :placeholderfield.placeholder / el-select v-else-iffield.type select v-modelformData[field.prop] :placeholderfield.placeholder el-option v-foropt in field.options :keyopt.value :labelopt.label :valueopt.value / /el-select el-date-picker v-else-iffield.type date v-modelformData[field.prop] typedate value-formatYYYY-MM-DD / /el-form-item /el-form /template刚开始我甚至考虑过用form-generator这类现成库但都因为定制性受限放弃了。自己写一个简单的动态渲染组件虽然代码量多一点但后面任何奇葩需求都能接得住而且不用绑死某个第三方库的版本。4.4 统计看板与ECharts集成教务处管理员最想要的页面就是统计看板按院系统计填报完成率、按成果类型统计数量、按教师维度统计教研业绩。ECharts的Vue3封装我直接用vue-echarts不用自己手动初始化实例响应式数据更新也自动处理。填报率统计接口返回结构简单直接{ code: 200, data: { totalCount: 256, submittedCount: 198, pendingCount: 58, collegeStats: [ { collegeName: 计算机学院, total: 45, submitted: 42 }, { collegeName: 外国语学院, total: 38, submitted: 20 } ] } }前端的柱状图和饼图都是直接映射这个数据结构。这里需要提醒一下性能问题统计接口不要每次实时count全表数据量大了会很慢。我的处理方式是在填报状态变更时同步更新一张冗余统计表统计接口只查冗余表复杂统计走定时任务凌晨跑批刷新。这套思路在数据量到十万级时依然能扛住。5. 联调、打包与Nginx部署配置5.1 开发环境跨域配置开发阶段前后端分离最烦的就是跨域。前后端跑在不同端口前端5173后端8080浏览器会直接拦截POST请求。我的做法是在后端统一配置CORS而不是让前端代理原因很简单后端配置一处生效前端代理还得改Vite配置多人协作时容易漏改。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns(*)配allowCredentials(true)是Spring Boot 2.4以上版本的标准写法老版本用allowedOrigins(*)配allowCredentials(true)会被浏览器拦截这个细节花了我不少时间排查。5.2 生产环境Nginx配置与刷新404打包后前端生成dist目录后端打成jar包。生产环境的架构是80端口Nginx接收所有请求静态资源直接返回/api路径反向代理到后端的8080端口。Nginx关键配置如下server { listen 80; server_name teachfill.example.com; # 前端静态资源 location / { root /data/teachfill/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; 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 /files/ { alias /data/teachfill/files/; } }try_files $uri $uri/ /index.html;这行是Vue Router使用history模式时必配的否则用户刷新页面或手输路径直接404。后端接口的proxy_pass http://127.0.0.1:8080/;末尾那个斜杠也不能漏否则/api/task/list会被代理成/api/task/list而不是去掉/api前缀后的/task/list。5.3 后端生产启动命令后端生产环境我用Systemd托管而不是直接java -jar裸跑这样能实现开机自启、崩溃自动重启。[Unit] Descriptionteachfill-backend Aftersyslog.target network.target [Service] Userteachfill Groupteachfill WorkingDirectory/data/teachfill ExecStart/usr/local/java/bin/java -Xms512m -Xmx1024m -jar /data/teachfill/teachfill-backend.jar --spring.profiles.activeprod Restartalways RestartSec10 [Install] WantedBymulti-user.targetJVM内存参数要根据机器配置设定如果是2G内存的云服务器-Xms512m -Xmx1024m是比较合理的区间。堆内存开太大反而会导致系统整体swap拖慢所有服务。6. 常见问题排查与避坑经验6.1 登录接口报401/403这个问题出现频率最高。先分清是HTTP状态码401还是业务code 401。如果是HTTP 401说明请求根本没到后端被CORS拦截或Nginx代理路径错误如果是业务code 401说明token缺失或过期。排查方法打开浏览器开发者工具看Network里的请求路径、请求头、响应体基本一眼就能定位。如果确定是token问题优先检查后端拦截器放行的OPTIONS预检请求。前后端分离项目浏览器跨域请求会先发一个OPTIONS请求如果你的拦截器把OPTIONS也拦截了token还没发直接就被踢出去前端永远登录不上。Override public boolean preHandle(...) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 其余逻辑 }6.2 MyBatis查询结果字段为nullMySQL字段是create_timeJava实体属性是createTime如果没有开启map-underscore-to-camel-case查询结果就是null。另一个常见坑是关联查询时两张表都有id字段结果集映射会错乱。我习惯在select里给字段起别名不偷懒select idselectTeacherWithCollege resultTypecom.xxx.teachfill.dto.TeacherVO SELECT t.id AS teacherId, t.teacher_no AS teacherNo, t.real_name AS realName, c.college_name AS collegeName FROM teach_teacher_info t LEFT JOIN sys_college c ON t.college_id c.id /select定义resultType时也要检查一下如果查询返回多个表的字段最好单独建VO类而不是复用entity。6.3 MySQL 8.0与旧驱动导致的连接失败当前主流还是MySQL 8.0驱动类已经从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver。连接串建议加上时区参数否则会报The server time zone value错误spring: datasource: url: jdbc:mysql://127.0.0.1:3306/teachfill?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue driver-class-name: com.mysql.cj.jdbc.Driver username: root password: xxxxallowPublicKeyRetrievaltrue这个参数在MySQL 8.0里几乎是必须的否则会报Public Key Retrieval is not allowed错误。密码加密规则也要注意如果8.0的默认加密插件是caching_sha2_password而你的JDBC驱动版本太老就会出现认证失败解决方法是升级驱动到8.0.x以上版本。6.4 前端打包部署后图片/接口404打包部署后出现404十有八九是资源路径配置问题。先检查vite.config.js里的base是不是/部署在根路径下就没问题如果部署在子目录比如http://xxx.com/sys/base必须改成/sys/否则所有静态资源路径都找不到。接口404就检查Nginx的proxy_pass末尾斜杠和前端baseURL是否匹配。还有一种情况是后端接口路径带/api前缀Nginx转发后变成/api/api/xxx这种低级错误我遇到过不止一次。6.5 大数据量Excel导出优化教研信息按年度归档后教务处经常要导出Excel存档。如果直接用POI逐行写入几千条数据还算能忍到了几万条就开始卡顿。我的做法是分批查询每500条写一批配合SXSSFWorkbookPOI的流式版本控制内存占用。还要注意Excel的sheet行数上限是1048576行超过这个上限要拆分成多个sheet但高校教研数据正常不会走到这一步。另一个优化点是导出接口加异步化点击导出后先创建导出任务后台线程生成文件完成后上传到附件表并通知前端下载。虽然实现复杂一点但遇到大文件导出时用户体验完全不一样。7. 实操总结与个性化改造建议系统跑通后后续还可以做这些扩展把填报任务做成可配置的“模板”支持拖拽式设计表单增加消息通知功能任务发布后给教师发短信或邮箱提醒对接学校统一身份认证实现工号直接登录用定时任务自动生成每月的填报进度报表。我个人在实际项目中的体会是填报类系统真正难的地方从来不是写代码而是梳理清楚业务规则的边界。比如“教师提交后还能不能修改”“院系退回后教师修改完是回到院系还是直接跳到教务处”“历史学期的数据能否继续填报”这些问题在动手前就要和业务方一条条确认。代码里的状态机写得再漂亮规则搞错了上线就是一场灾难。如果看到这篇文章时你正准备做类似系统我的建议是先把数据库表和状态流转图画明白再开始写代码后面能省掉大量返工时间。这套技术栈的方案应付绝大多数高校内部的填表、审批、统计场景都是绰绰有余的而且由于全部是主流技术团队接手和二次开发的门槛也低很多。最后再分享一个实用小技巧在teach_submit_record表上加上update_time字段并且在应用层每次更新数据时刷新这个字段。运维排查问题的时候这个字段比create_time有用得多能让你一眼看出最近有哪些教师修改过填报内容审计和问题追溯都方便很多。