去年年底接了个高校的信息化项目要做一套教师工作量管理系统。需求听上去不复杂无非就是把教师每学期承担的教学任务、课时量、工作量系数这些数据管起来让教务处的老师不用再靠Excel来回传递。但真动手之后才发现从需求梳理到技术选型再到最后部署上线中间全是细节。今天把这个项目的完整实现过程拆出来从技术栈选择、数据库设计、核心代码写法到部署排查一条线讲清楚给准备做同类系统的同学一个可参考的样板。这个系统的技术组合是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端完全分离源码附带完整文档覆盖了权限管理、工作量申报、审核流程、统计报表这些核心模块。如果你正在学Java Web全栈或者接了类似的实训课设/毕设/小型管理项目这套东西的架构思路和代码细节可以直接拿来落地。1. 需求拆解与整体设计思路1.1 教师工作量管理到底在管什么开工之前先把业务搞清楚这一点比选什么技术栈都重要。教师工作量管理表面看是统计教师上了多少节课实际上是一套牵扯到课时计算、系数折算、审核确认、绩效核算的完整业务链。我之前走访了几个教务处的老师总结下来核心痛点就三个。一是数据分散每个学院各自统计表格格式五花八门期末汇总的时候光是统一格式就要耗掉两三天。二是计算口径不统一同一个实践环节有的学院按1.0折算有的按1.2折算年底核算工作量的时候争议不断。三是审核流程不透明教师申报之后不知道卡在哪个环节只能反复打电话问教务员。所以这个系统在设计的时候业务上必须回答三个问题工作量由谁录入、按什么规则计算、经过什么流程确认。我的方案是分角色、分流程来处理。教师端负责录入个人工作量申报单选择本学期的课程或实践环节系统自动带入基本信息教师只需要填写实际授课周数、周课时数。学院教务员负责初审核对课时和系数是否填写合理。教务处管理员负责终审和汇总确认之后的数据进入统计报表可以按学院、按教师、按学期多维度查询。整个流程有状态流转每一步都有操作日志谁在什么时候做了什么操作全部留痕。1.2 技术选型为什么是SpringBoot2 Vue3 MyBatis-Plus这套组合很多同学纠结技术栈觉得越新越好。我的观点是项目选型要看场景。高校内部的业务管理系统特点是并发量不大但业务流程复杂、权限层级多、后续可能有多个学院的信息化系统要对接。这种情况下稳定、成熟、资料多的技术栈才是最优先的。后端用SpringBoot 2.7.x搭配JDK8。SpringBoot2到现在依然是Java Web领域应用最广的版本网上遇到的问题解决方案也最全。JDK8语法在团队协作时基本没有学习成本lambda表达式和Stream用法大家都很熟。不用JDK17的原因是项目要求部署在一台老一点的服务器上JDK8对系统资源的占用更可控。持久层选了MyBatis-Plus而不是纯MyBatis或者Spring Data JPA。原因很直接这个系统里有大量单表CRUD操作比如教师信息维护、学院信息管理、学期配置等MyBatis-Plus的BaseMapper拿到手就能用省去写一堆重复XML的时间。而真正复杂的查询比如按学院统计工作量汇总数据、多表联查审核记录又可以写自定义SQL放在Mapper.xml里灵活性不受影响。JPA虽然写起来也快但遇到复杂统计查询时需要写JPQL或者原生SQL调试起来不如MyBatis直观。前端用Vue3 Element Plus Vite。Vue3的组合式APIComposition API在处理多角色页面时比Vue2的选项式API更清爽尤其是一个页面要同时兼容教师端和管理员端的不同操作按钮时逻辑复用性明显更好。Element Plus的表格、表单、弹窗组件覆盖了这类管理系统的绝大多数界面需求不需要再引额外的UI库。数据库用MySQL8.0。8.0相比5.7的改进很实在窗口函数做排名统计特别方便JSON类型支持配合WorkloadDetail这类灵活的明细结构也很好用。而且8.0的默认字符集已经是utf8mb4不需要像5.7那样安装时还要专门配置。1.3 系统功能模块划分整个系统按业务域拆成五个模块每个模块对应后端一个独立的Controller层逻辑前端对应一个菜单分组。第一个是系统管理模块负责用户管理、角色管理、菜单权限分配。这里用的是经典的RBAC模型用户挂角色角色挂菜单权限通过拦截器校验接口访问权限。第二个是基础数据模块维护学院信息、专业信息、教师档案、课程库。第三个是工作量管理模块这是核心包含工作量申报单的创建、提交、审核、退回、重新提交的完整生命周期。第四个是统计报表模块支持按学期、按学院、按教师个人汇总工作量数据支持导出Excel。第五个是系统工具模块包含操作日志、数据备份等功能。权限设计上我不推荐把角色写死在代码里判断。实践做法是给每个接口配置权限标识比如workload:apply表示工作量申报权限workload:audit表示工作量审核权限。用户的菜单和按钮根据其角色关联的权限集合动态渲染这样如果后续想增加一个学院院长角色来查看本学院所有教师的工作量汇总只需要在数据库里给新角色分配对应权限不用改动后端代码。2. 数据库设计与工作量计算规则2.1 工作量计算的核心逻辑在设计表结构之前得先把工作量的计算规则定清楚。不同学校的规则差异很大我参考了一个通用性较强的口径方便后续按学校情况调整。工作量由三个要素决定课程类型、课时数、折算系数。比如一门专业核心课每周4课时共16周理论课的系数是1.0那么这门课的工作量就是 4×16×1.064 学时。同一门课如果是实践环节系数可能是1.2那工作量就变成了76.8学时。还有一种情况是合班授课一个老师同时给两个班上课有的学校系数会乘以1.1或1.2有的学校不折算这个完全看学校的文件规定。所以数据库里不能只存一个工作量数字而是要存足够的原始明细让系统能够按照配置的计算公式动态算出门课的工作量。我把这个设计成了workload_calculate_config表字段包括课程类型、工作量类型、折算系数、是否启用。这样每次规则调整改数据库配置就行代码层面不用动。2.2 核心表结构设计详解整体数据库我设计了9张表这里挑最核心的4张讲清楚设计思路。教师表teacher_info主键自增关联用户表sys_user的user_id用来绑定登录账号。冗余了学院ID、职称、入职年份。学院ID冗余是为了查询教师工作量列表时减少一次联表这种可接受范围内的冗余能显著提升列表页的响应速度。工作量申报单表workload_apply这是核心业务表。字段包括申报学期、教师ID、申报状态、学院审核意见、教务处审核意见、提交时间。状态字段是关键我设计成了tinyint类型0草稿、1待学院审核、2学院退回、3待教务处审核、4教务处退回、5审核通过。这种状态机的设计结合update_time时间戳就能完整还原一张单子的流转过程。工作量明细表workload_apply_detail与申报单主表是一对多关系。每条明细包括课程名称、课程类型、授课班级、计划周数、周课时、折算系数、工作量结果。具体计算出来的工作量数值也会冗余存储在这张表里便于后续统计时直接SUM不用每条记录都重新计算。学期配置表semester_config存学期名称、开始日期、结束日期、是否当前学期。这个设计很实用因为很多查询和申报操作都限定在当前学期通过这张表可以统一管理学期切换的逻辑不会出现在代码里写死学期编号的情况。2.3 工作量认定流程的状态机设计刚才提到申报单有状态流转这里我详细说一下状态机的处理。我在代码里用一个枚举类WorkflowStatusEnum来定义这些状态和允许的转换路径。比如草稿状态下只能执行提交或删除提交后进入待学院审核学院审核通过后进入待教务处审核学院或教务处任何一个环节退回状态就变成退回修改。退回状态下教师修改完重新提交状态再次进入待学院审核。这里有个实操细节特别容易踩坑审核操作必须用乐观锁控制并发。教务员A和教务处管理员B可能同时打开一张单子A点了通过B也点了通过如果不加锁就会出现状态被覆盖的问题。我的做法是在workload_apply表加一个version字段每次更新时检查version值不匹配就报错提示单据状态已变更请刷新后重试。这个防并发问题的方法简单有效比用悲观锁性能更好也更符合这个系统的并发场景。3. 后端核心代码实现与关键配置3.1 SpringBoot2 MyBatis-Plus的项目骨架搭建项目结构用的是标准的多模块单工程结构后端代码按controller、service、mapper、entity、dto、vo分层。实体类用MyBatis-Plus的注解标注表名和主键策略。application.yml里几个关键配置我贴一下都是实测踩过坑之后的经验值。server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/workload_db?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.workload.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这几个配置里MySQL8.0的驱动类和时区参数是重点。serverTimezoneAsia/Shanghai必须加否则时间字段的读写会差8小时。allowPublicKeyRetrievaltrue这个参数很多人会漏掉MySQL8.0默认使用caching_sha2_password认证插件在JDBC连接时如果没配置这个参数经常会报Public Key Retrieval is not allowed错误。MyBatis-Plus的逻辑删除配置我也单独说明一下。逻辑删除就是数据不真删而是通过一个deleted字段标记为已删除。做教师工作量这种系统非常推荐尤其是申报单这种有审计需求的业务表一旦误删了数据物理删除的恢复成本极高。配置之后MyBatis-Plus会自动在查询语句后面加上AND deleted 0更新语句也会带条件不需要自己手动写。3.2 分页插件的配置和常见分页坑工作量列表页、审核列表页、统计列表页都需要分页查询MyBatis-Plus的分页插件配置如下。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(500L); paginationInnerInterceptor.setOverflow(false); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }设置MaxLimit(500L)是我加的一个保护机制。管理系统的列表页虽然有分页但如果有人直接调接口传个size999999一次查出来的数据量过大会拖垮数据库。限制单次最大查询500条之后前端列表再怎么翻页也在可控范围。Overflow(false)的意思是不允许页数溢出如果请求的页码超过总页数直接返回空列表而不是自动跳到最后一页这样对业务数据的语义更安全。分页还有一个坑如果分页查询里带了自定义的联表SQLMyBatis-Plus的Page对象拿到total值有时会不准确。MyBatis-Plus的默认优化器会尝试把原SQL包一层select count(*) from (...) total但如果你SQL里有GROUP BY这个count会直接统计分组后的行数而不是原始数据的行数。这种情况下要自己写专门的count查询用page.setTotal()手动设置。工作量统计报表那个模块就是这种情况我在Mapper里单独写了selectWorkloadStatisticsCount方法把嵌套的汇总SQL单独查一次count确保total正确。3.3 登录认证与权限控制的落地方式这个系统选的是经典方案Token令牌 拦截器 RBAC权限标识。用户登录成功后后端生成一个UUID字符串作为token通过Redis存储设置过期时间为2小时。前端每次请求在header里带上Authorization: Bearer 你的token后端拦截器解析token从Redis里拿到用户ID再查出用户的权限集合放到ThreadLocal里供业务层使用。拦截器的实现核心代码如下Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StrUtil.isBlank(token) || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录或登录已过期); } String realToken token.substring(7); LoginUser loginUser redisUtils.get(RedisKeyConstants.LOGIN_TOKEN_KEY realToken); if (loginUser null) { throw new BusinessException(401, 登录已过期请重新登录); } // 校验接口权限 HandlerMethod handlerMethod (HandlerMethod) handler; RequiresPermission requiresPermission handlerMethod.getMethodAnnotation(RequiresPermission.class); if (requiresPermission ! null !loginUser.getPermissions().contains(requiresPermission.value())) { throw new BusinessException(403, 无权限访问); } UserContext.set(loginUser); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }第一行对OPTIONS请求的大写判断和处理是必须的。前后端分离项目必然存在跨域浏览器会在正式请求前发一个OPTIONS预检请求这个请求不会带业务token如果不直接放行前端就会因为拦截器拦截预检请求而频繁报跨域错误。很多人调试的时候怎么都跨不过去就是忘了处理这一层。自定义注解RequiresPermission标记在Controller方法上形如RequiresPermission(workload:audit)拦截器里有这个注解就校验权限。这种写法比较直白业务开发时扫一眼Controller方法就能看出接口的权限要求。需要注意权限标识的设计最好遵循模块:操作的格式工整且不容易重复。3.4 工作量登记与审核接口的业务逻辑工作量申报这块教师在前端填好明细后提交保存。由于明细可能有多条主表和明细表需要在一个事务里同时写入我直接设计了DTO接收对象一张单子的所有明细数据一次性传过来。Service层核心方法是submitApply逻辑伪码如下Transactional(rollbackFor Exception.class) public void submitApply(WorkloadApplyDTO dto) { // 1. 校验该教师在当前学期是否已有未结束的申报单 LambdaQueryWrapperWorkloadApply wrapper new LambdaQueryWrapper(); wrapper.eq(WorkloadApply::getTeacherId, dto.getTeacherId()) .eq(WorkloadApply::getSemesterId, dto.getSemesterId()) .ne(WorkloadApply::getDeleted, 1) .in(WorkloadApply::getStatus, Arrays.asList(0, 1, 3)); Long count applyMapper.selectCount(wrapper); if (count 0) { throw new BusinessException(当前学期已有进行中的申报单请勿重复提交); } // 2. 插入主表数据状态为待学院审核 WorkloadApply apply new WorkloadApply(); apply.setTeacherId(dto.getTeacherId()); apply.setSemesterId(dto.getSemesterId()); apply.setStatus(WorkflowStatusEnum.WAIT_COLLEGE_AUDIT.getCode()); applyMapper.insert(apply); // 3. 批量插入明细逐条计算工作量 ListWorkloadApplyDetail details dto.getDetails(); details.forEach(detail - { BigDecimal workload calcWorkload(detail); detail.setWorkload(workload); detail.setApplyId(apply.getId()); }); detailService.saveBatch(details); // 4. 记录操作日志 logService.addLog(提交工作量申报, 申报单号: apply.getId()); }这段代码解决了一个容易忽略的业务问题一个学期教师只能有一张进行中的申报单。如果允许教师分多次提交多张单子审核人员面对的状态就混乱了。所以提交前先查是否存在状态为草稿、待学院审核、待教务处审核的单子存在就拒绝新增。这种逻辑放在程序员的位置上看很简单但需求方经常不会主动提需要开发者在设计数据库时就预判到。calcWorkload的计算逻辑也值得一提它根据明细中的课程类型和学期配置去查workload_calculate_config表拿到折算系数然后周课时 * 计划周数 * 系数结果保留两位小数。这是一个独立的私有方法方便后续如果计算规则变化只改这一个方法即可。审核接口和提交接口类似但多了并发控制和操作日志记录。审核时把主表status改成对应状态同时插入一条audit_record表记录包含审核人、审核意见、审核时间。这条记录表很重要后续如果出现工作量争议凭操作日志可以还原完整的审核链条。4. 前端Vue3实现与接口联调细节4.1 前端项目结构和请求封装前端用Vite构建的Vue3项目目录结构是views页面、router路由、store状态管理、api接口请求、utils工具、components公共组件。其中api目录每个业务模块独立一个文件例如workload.js、system.js这样后端接口变动时改动范围小不牵扯其他模块。axios请求封装是我每次开发都要最先完成的部分。统一在请求拦截器里加上token在响应拦截器里统一处理错误码。这个系统里后端统一返回Result对象结构是{ code, message, data }。code为200才是成功401跳转登录页403提示无权限其他code弹出错误消息。这个统一包裹格式强烈建议不要省。如果不统一返回格式前端每个接口调用都要自己判断返回数据是不是有效写起来繁琐出错概率也高。统一之后普通的请求代码就是export function getWorkloadPage(params) { return request({ url: /workload/apply/page, method: get, params }) }页面里调用时直接const res await getWorkloadPage(params)res.data就是后端返回的数据部分。4.2 Vue3组合式API在页面里的写法页面开发里教师工作量申报页面是最复杂的它既要支持明细行的动态增删又要支持草稿保存和提交两种操作。这个场景用Vue3组合式API的reactive处理非常顺手。核心逻辑大致如下const formRef ref(null) const detailList ref([]) // 添加明细行 function addRow() { detailList.value.push({ courseName: , courseType: , className: , planWeeks: 0, weekHours: 0, coefficient: 1.0 }) } // 删除明细行 function removeRow(index) { detailList.value.splice(index, 1) } // 保存草稿 async function saveDraft() { const formData { teacherId: userStore.userId, semesterId: currentSemester.id, details: detailList.value } await saveApplyDraft(formData) ElMessage.success(草稿保存成功) } // 提交审核 async function submitApply() { await formRef.value.validate() // 确认弹窗 await ElMessageBox.confirm(提交后将进入学院审核流程是否继续, 提示, { type: warning }) const formData { teacherId: userStore.userId, semesterId: currentSemester.id, details: detailList.value } await saveApplySubmit(formData) ElMessage.success(提交审核成功) router.push(/workload/myList) }这里有几个经验点。第一动态增删行数据的时候不要用reactive包整个数组再用索引修改Vue3的响应式代理在数组索引赋值时会有性能问题用ref包数组然后直接push和splice是最稳的。第二ElMessage的引入方式在Element Plus里要用全量引入按需引入时样式容易丢。如果你用unplugin-vue-components做按需引入别忘了配置ElMessage和ElMessageBox的样式文件手动引入。4.3 动态菜单和按钮权限的渲染前端菜单是根据登录用户的权限动态生成的。用户登录后后端在返回登录信息的同时会返回一个权限标识列表permissions以及一个符合当前用户角色的菜单树menus。前端拿到菜单数据后在路由守卫router.beforeEach里动态注册路由。按钮级别的控制我封装了一个自定义指令v-permission使用方式el-button v-permissionworkload:audit typeprimary审核/el-button指令的注册代码如下const permissionDirective { mounted(el, binding) { const required binding.value const userStore useUserStore() if (!userStore.permissions.includes(required)) { el.parentNode el.parentNode.removeChild(el) } } }用指令比在模板里写v-if判断要干净得多。页面上一堆v-ifuserStore.permissions.includes(xxx)可读性很差指令一处定义全页面复用。但注意指令的mounted钩子只在元素创建时执行一次如果权限数据是异步加载的要改成在update钩子里再做一次校验确保权限数据到位后还能再处理一次。这个系统由于菜单和页面权限都从后端动态返回实现了所谓的前后端权限联动。假如后续新增了一个督导查看角色只需要在管理后台给该角色配置菜单权限前端登录后就会自动看到对应的统计页面不需要重新发版。4.4 报表可视化与Excel导出的实现统计报表页面是这个系统里最直观体现系统价值的模块。教务处的人打开页面选学期选学院就能看到每个教师的工作量汇总排名。我用了Element Plus的表格展示数据同时配合ECharts画了柱状图和饼图分别展示各学院工作量对比和工作量类型分布。图表这块要注意一个细节ECharts初始化的时候如果容器还没有宽高数据或者Tab页切换后容器被重新渲染图表会显示空白。解决方案是在nextTick里初始化并在容器大小变化时调用chart.resize()。如果报表页面是放在Tab标签页里的还要在Tab切换事件里手动触发resize否则从别的Tab切回来图表会挤压成一条线。Excel导出是通过后端接口实现的没有用前端插件直接生成。后端采用Apache POI生成xlsx文件设置好响应头Content-Disposition: attachment; filenamexxx.xlsx前端用window.open或a标签下载触发即可。这里同样有个小坑文件名如果包含中文需要做URL编码处理否则下载下来的文件名会是乱码。我封装了一个downloadFile工具函数统一处理blob类型和文件名解析把Content-Disposition里的filename用decodeURIComponent解析出来再赋值给a标签的download属性。5. 系统部署与问题排查实录5.1 本地开发环境搭建步骤拿到源码之后最快跑起来的顺序是这样的。第一步准备环境安装JDK8、Maven3.6、Node16、MySQL8.0。第二步初始化数据库用源码里的sql/init.sql脚本建库建表这个脚本包含了所有表结构和初始数据包括管理员账号。建议用Navicat或命令行直接执行执行之前确认数据库版本是8.05.7有部分语法不兼容。第三步启动后端。修改application.yml里的数据库账号密码在项目根目录执行mvn spring-boot:run或者用IDEA打开项目后直接运行启动类。启动成功后控制台会打印端口号默认8080。第四步启动前端。在frontend目录下执行npm install装依赖可能会花几分钟如果网络不好可以配置淘宝镜像源。依赖装完后执行npm run devVite默认为5173端口浏览器访问后在登录页输入管理员账号密码。如果前后端要联调需要在前端代码里配置代理。Vite的vite.config.js里配置server.proxy把/api前缀的请求代理到http://localhost:8080同时把后端context-path里的/api前缀去掉或者让后端保留然后前端请求时带双前缀。我在联调时踩过一次这个双前缀的坑后来统一方案是后端设context-path/api前端代理时不再额外拼路径只在axios请求前缀里保留/api。5.2 部署到Linux服务器的步骤局域网部署比云服务器简单但流程基本一致。我用一台CentOS7.9服务器做演示。后端部分用mvn clean package -DskipTests打jar包生成的文件在target目录下。服务器上先装好JDK8和MySQL8.0数据库脚本执行完后使用nohup java -jar workload-server.jar --spring.profiles.activeprod /logs/workload.log 21 启动服务。--spring.profiles.activeprod会加载application-prod.yml里的生产环境配置这套配置里数据库密码、日志级别、文件路径都是独立设置的和开发环境隔离。前端部分在本地或服务器上执行npm run build生成dist目录。然后用Nginx做静态文件服务并把/api请求反向代理到后端的8080端口。Nginx的核心配置片段如下server { listen 80; server_name your-domain; location / { root /usr/local/frontend/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; } }try_files $uri $uri/ /index.html;这行是前端路由刷新不404的关键。Vue3使用history模式时直接访问/workload/myList这样的路径Nginx默认找不到对应文件如果没配置try_files就会返回404。配置了try_files之后所有前端路由请求都会回退到index.html由Vue路由接管。很多同学部署Vue项目时刷新白屏原因基本都在这。5.3 常见问题排查实录我把这套系统开发部署过程中遇到的典型问题整理成一张速查表按照排查顺序排列基本覆盖了大多数人的坑。现象可能原因排查与解决MySQL连接报Public Key Retrieval is not allowedJDBC连接串缺少allowPublicKeyRetrieval参数URL末尾加上allowPublicKeyRetrievaltrue前端调用后端接口报跨域Nginx反代未生效或后端未允许跨域Nginx配置location /api/代理后端配置CorsFilter时间字段相差8小时数据库连接时区未设置URL加serverTimezoneAsia/Shanghai刷新页面404前端路由与Nginx未配合配置try_files $uri $uri/ /index.html分页数据total不准分页查询含GROUP BY语句自定义count查询手动设置page.total审核并发导致状态异常缺少乐观锁控制version字段update时检查导出Excel中文文件名乱码响应头未做URL编码文件名使用URLEncoder.encode并decodeVite启动后页面访问空白端口冲突或代理未生效检查5173是否被占用查看vite.config.js代理设置逻辑删除后数据重复出现唯一索引和逻辑删除共存冲突建表时使用联合唯一索引包含deleted字段内存溢出导致服务重启jar包启动时没设置JVM参数java -Xms512m -Xmx1024m -jar 启动第九条逻辑删除和唯一索引的冲突值得展开说一下。比如教师表里的工号字段设置了唯一索引用户删除一位教师后这条记录的deleted字段变成1而不是真删除。后续如果重新添加同工号的教师由于唯一索引仍然生效插入会失败因为数据库里已经有一条相同工号的数据哪怕它标记为已删除。解决方案有两种一个是把联合唯一索引改成包含deleted字段UNIQUE KEY uk_teacher_no (teacher_no, deleted)另一种是删除时顺手更新工号字段加个时间戳后缀把原值让出来。我采用的是第二种简单直接不会影响历史查询。5.4 项目文档的使用方法源码附带的文档是一个详细的Markdown文件包含了需求规格说明、数据库设计说明书、接口文档、部署手册。很多人拿到文档后喜欢从头到尾读一遍这不是最高效的方式。我的建议是按角色分步骤读。如果是想跑通项目先看部署手册照着把系统启动起来。启动成功后打开首页点一遍各个菜单对功能模块有宏观认识。然后看数据库设计说明书重点看表结构和状态字段含义。最后看接口文档按业务模块对照Controller代码和前端调用去理解数据流。如果是想基于这套系统二次开发第1章需求规格说明和第3章数据库设计说明书要精读后面接口文档适合当作工具书随时翻阅。二次开发时最需要小心的就是不要破坏已有的状态机流转逻辑宁可在一个流程上扩展也不要另起炉灶。6. 项目后续扩展与经验总结6.1 从教师工作量延伸到绩效考核的扩展思路这套系统上线运行稳定之后很自然的扩展方向是衔接年度绩效考核。工作量数据是绩效考核的重要输入目前系统已经把每个教师每个学期的工作量明细和汇总数据都存进了数据库绩效考核模块可以直接复用这些数据再补充科研积分、教学成果、学生评教等维度的数据就能组成一套比较完整的绩效评分表。扩展时我建议不要在原有工作量系统里堆功能而应该拆分成独立的考核模块通过教师ID和学期ID关联查询。这是我在实际开发中反复验证的架构思路。工作量管理是流程型业务绩效考核是结果型业务两者代码逻辑差异很大。如果耦合在一起工作量申报流程一旦调整绩效考核逻辑很容易被波及风险不可控。6.2 这套代码可以迁移到哪些同类场景我做完这个项目后的最大感受是教师工作量管理的业务模型其实可以套用到很多场景。比如企业内部的工时管理系统核心逻辑是员工申报工时、主管审核、部门汇总流程模型几乎一致。再比如科研项目的经费报账系统也是申报、审核、退回、汇总的链条。这套系统的角色权限模型和状态机设计在这些场景里都可以复用改改表结构、换换业务字段就行。代码层面最值得沉淀的是状态机流转和审核日志这套东西几乎是所有审批类业务的通用骨架。如果后续要做新项目我会把这部分抽出来做成一个通用流程组件配置化定义流程节点和角色权限数据库驱动流程变化极大减少重复造轮子。结合我自己带这个项目的经验有几个最后的提醒。开发这类管理系统前期的业务调研比写代码更重要需求方说的简单统计工作量背后往往藏着一堆计算规则和管理流程。技术层面Java Web的技术栈选择不是越新越好稳定成熟、团队熟悉、坑有解决方案才是优先考量。功能实现层面权限控制和状态流转这种底层逻辑要一次性设计稳返工成本是最高的。文档不能等到项目最后才写每个接口开发完同步记录后面部署和维护的时候这些文档能省掉大量回忆和沟通成本。目前这个系统已经在某高校的教学管理系统里跑了两个学期教务处那边最满意的是统计报表功能和审核留痕从Excel时代过渡到线上流程之后期末工作量核算的时间从原来的两周缩短到两天。技术最终是服务于业务的把业务理顺了代码怎么写都不会太偏。