
1. 项目概述与价值拆解1.1 为什么实验报告管理是个真需求实验报告管理这事儿只要是在高校、职校或者培训机构待过的人都不会陌生。每学期一到实验课收官阶段任课老师桌上堆着一摞摞纸质实验报告批改、登记成绩、统计及格率全是手工劳动。学生这边也不轻松报告用完就丢期末复习想找之前的实验记录基本靠翻聊天记录。至于管理员——压根没有这个角色因为实验报告从来就没有被管理过。这个基于SpringBoot和Vue的实验报告管理系统解决的正是这么一件事把实验报告的提交、批改、归档、统计全流程搬到线上。老师在线布置实验任务学生在线提交报告教师在线批阅打分系统自动生成成绩统计。说得直白一点就是给实验课这个场景单独做了一套轻量级OA系统。适合谁来参考呢第一类是计算机相关专业的毕业生毕业设计选了这个题目需要一套能讲清楚原理、能展示实操的完整项目第二类是高校实验室管理员或实验课老师想找个现成方案改善报告收集和批改效率第三类是打算用SpringBootVue这套组合练手的中级开发者想看看一个完整业务系统是怎么从零到一搭起来的。1.2 技术选型的底层逻辑SpringBoot加Vue这套组合在目前国内的技术生态里几乎成了中小型管理系统的事实标准。后端用SpringBoot因为它内置了Tomcat、自动配置、起步依赖这些机制让开发者不用折腾一堆XML配置前端用Vue因为它的组件化开发模式和响应式数据绑定特别适合表单密集、交互频繁的管理类页面。选这套方案还有一个现实考量部署成本低。SpringBoot打成jar包扔服务器上就能跑Vue构建出来的静态文件用Nginx托管两分钟就能完成部署。对比动不动就上微服务、上K8s的方案这种单体架构对实验报告管理这种量级的业务来说恰好是杀鸡用牛刀反而更省心的选择。1.3 这个系统到底管什么从功能边界来看这系统覆盖了实验报告生命周期的几个核心环节。实验任务管理是源头老师创建实验项目、设置截止时间、附上实验要求报告提交是核心流程学生上传文档、填写实验数据批阅评分是教师的日常操作打分加评语成绩统计是收尾工作汇总每个学生的实验总评。听起来功能不算复杂但真要做成能用的系统涉及的角色权限、状态流转、文件处理、数据统计每一块都有不少细节要抠。下面我按自己的实操经验把这个系统从设计到实现再到部署的完整链路拆开讲。2. 整体架构设计与思路拆解2.1 前后端分离的架构模式与数据流转这个项目采用的是标准的前后端分离模式。前端运行在Nginx上负责页面渲染和用户交互后端运行在独立的SpringBoot进程中提供RESTful API。浏览器访问页面时先从Nginx加载静态资源然后通过Ajax请求调用后端接口后端处理完业务逻辑后返回JSON数据前端再把这些数据渲染到页面上。这种分离模式最直接的好处是开发和部署互不干扰。前端工程师只管Vue组件和页面交互后端工程师专心写接口和业务逻辑。部署的时候也灵活前端静态文件可以扔到CDN上后端可以做成集群哪个环节出问题都好排查。针对这个项目来说学生交报告和老师批报告发生在不同时间段前后端分离还能让系统在高并发提交时段比如截止前五分钟通过横向扩展Nginx实例来缓解压力。2.2 后端分层架构与模块划分后端按照SpringBoot的标准分层架构来组织Controller层接收HTTP请求Service层处理业务逻辑Mapper/Repository层做数据持久化。再加上Model实体类、DTO传输对象、Config配置类、Util工具类总共六个包。这种分层的好处是职责单一、便于测试。Controller只做参数接收和结果返回Service只做业务判断和数据处理后续要换数据库或者加缓存都不会牵一发动全身。模块划分上主要分为五大块。用户模块管登录注册和权限控制实验模块管实验任务的增删改查报告模块管提交和状态流转评分模块管批阅和成绩录入统计模块管汇总分析和导出。每个模块内部自治对外只暴露必要的接口。2.3 前端技术栈与组件化设计前端用的是Vue 2.6.x加Vue Router加Vuex加Element UI这套组合。Element UI是饿了么团队开源的桌面端组件库表格、表单、弹窗、分页这些管理系统的标配组件都封装好了开发效率非常高。组件化设计上把公共部分拆成了几个基础组件UploadFile做文件上传ScoreInput做成绩录入StatusTag做状态展示PaginationTable做分页表格。每个页面通过组合这些基础组件来搭建避免了大量重复代码。这里特别说明一下为什么不用Vue 3。虽然Vue 3的Composition API更现代但Element UI对应的Vue 3版本Element Plus当时还不够稳定而且Vue 2的生态资料更丰富遇到问题搜解决方案更容易。对毕业设计或者中小型项目来说选成熟稳定的方案比追新更重要。2.4 数据库设计与表结构规划数据库层面我采用的是MySQL 5.7。核心表有用户表、实验任务表、实验报告表、课程表、通知公告表等下面给出主要表的结构设计。用户表sys_user包含id、username、password、name、role、email、phone、avatar、create_time这些字段。其中role字段用字符串存储取值为ADMIN、TEACHER、STUDENT三种。实验任务表experiment_task包含id、title、description、course_id、teacher_id、deadline、status、create_time。deadline用datetime类型status用int标识0表示未开始1表示进行中2表示已截止。实验报告表experiment_report是核心表包含id、task_id、student_id、content、file_url、score、comment、submit_time、status。status同样用int0表示未提交1表示已提交待批阅2表示已批阅。这个表通过task_id和student_id联合唯一索引确保每个学生只对同一实验提交一份报告。课程表course包含id、name、code、teacher_id、semester用于确定实验任务归属哪门课程。通知表notice包含id、title、content、create_time用于发布系统公告。3. 核心功能设计与实操要点3.1 登录认证与JWT权限控制登录模块是这个系统的第一道关卡。用户输入账号密码后后端校验用户名是否存在、密码是否匹配。密码存储时绝不能明文保存我用的是Spring Security自带的BCryptPasswordEncoder做加密每次登录比对时用encoder.matches()来验证。密码校验通过后系统生成一个JWT令牌返回给前端前端把它存在localStorage里。后续每个接口请求都在HTTP头里带上这个令牌Authorization: Bearer 。后端通过拦截器解析令牌识别出当前用户的角色再根据角色判断能否访问对应接口。这里有个关键细节JWT令牌要设置合理的过期时间。我设的是24小时学生当天登录后一整天都不用重新认证但如果跨天使用就需要重新登录。这种设计对实验报告系统这种使用频率不高的场景来说体验刚刚好。3.2 实验任务发布与截止日期管理老师创建实验任务时前端表单收集实验标题、所属课程、实验要求、截止时间这些信息提交到后端的POST /api/task接口。后端校验截止时间必须晚于当前时间然后写入experiment_task表。这里有个实用的小设计新增、编辑任务都走同一个表单组件通过判断是否有taskId来决定是调用POST还是PUT接口。这样复用了页面代码老师要修改某个实验的要求时不需要重新填写整表。截止日期管理还有个隐藏需求任务到期后要自动改状态。我是用SpringBoot自带的Scheduled定时任务实现的每天凌晨扫描一次experiment_task表把deadline早于当前时间的任务状态改成2已截止。这个做法好在够简单、不依赖额外中间件缺点是最长会有24小时的延迟。如果要求精确到分钟就得引入xxl-job这类分布式调度框架了。3.3 学生提交报告与文件上传的坑学生提交报告是整个流程里坑最多的环节实操时主要在文件上传和表单校验这两块踩雷。文件类型校验不能只在后端做前端也要做。我碰到过学生把实验报告的rar压缩包直接传上来后端又没做过滤结果坐等数据库被塞满。我的做法是前后端双重拦截前端用Element UI的Upload组件加accept属性限制文件类型后端加一层MIME类型白名单判断只允许.pdf、.doc、.docx这三种格式。虽然MIME类型可以伪造但这一层已经能挡住99%的误操作。文件存储路径不要直接存全路径。我在file_url字段里存的是相对于上传根目录的路径比如uploads/report/20240511_xxxx.pdf真正的物理路径通过配置项拼接。后续如果要迁移服务器或者把文件挪到OSS只需要改动配置不需要动数据库。另一个容易被忽略的点是学生可能会重复提交覆盖之前的报告。我的设计是如果该学生对该任务已有一条报告记录再次提交时后端执行UPDATE而不是INSERT同时保留一个version字段记录提交次数。这样既防止了垃圾数据又能让老师看到学生是否多次修改了报告。3.4 教师批阅与成绩管理教师批阅报告的交互流程是先从待批阅列表里选中一份报告查看学生提交的内容和附件然后输入分数和评语点击保存。评分规则我设计成可配置的在实验任务里增加了一个score_type字段。取值1表示百分制取值2表示五级制优秀、良好、中等、及格、不及格。前端根据这个字段动态渲染分数输入框或者下拉框后端统一存储成数字五级制映射到90、80、70、60、50这几个阈值。批阅完成后报告状态改成2已批阅学生的成绩列表页会自动出现这条分数。教师端还有一个批阅进度条显示已批阅/待批阅的百分比这个数据通过后端一个简单的聚合查询实时计算给老师的教学管理提供了直观参考。3.5 数据统计与报表导出统计模块是体现系统价值的地方我实现了三个维度的统计按学生统计实验总评成绩按任务统计提交率和平均分按课程统计成绩分布区间。导出报表用的是EasyPoi工具库这个库封装了Apache POI可以用注解的方式快速生成Excel。导出时后端先查询数据库再把数据封装成带有Excel注解的实体列表调用EasyPoi的导出方法就能直接输出xls文件。前端用window.open方法访问导出接口浏览器会自动下载文件。这里有个兼容性细节window.open打开接口地址时如果后端接口需要JWT认证浏览器直接访问会因为没有Authorization头而被拒绝。解决办法是在接口上加一层白名单放行或者把token拼接在URL参数里。我采用的是后者在导出请求里带上?tokenxxx后端从参数里取。4. 开发环境搭建与项目初始化4.1 开发前需要准备的工具清单JDK 1.8或以上版本推荐直接用JDK 1.8虽然老但稳定很多高校的机器上预装的都是这个版本Maven 3.6用于管理后端依赖和构建打包Node.js 14.x以上Vue CLI 4或5需要这个版本才能正常运行MySQL 5.7或8.05.7在字符集处理上更省心8.0性能更好但要注意账号认证插件差异IDEA 2021或以上版本自带SpringBoot初始化向导省去手工创建目录的麻烦Navicat或DataGrip数据库可视化管理工具日常查表建表效率高4.2 后端项目初始化步骤第一步在IDEA里选择Spring Initializr创建项目Group填com.exampleArtifact填report-system。依赖里勾选Spring Web、MyBatis、MySQL Driver、Spring Security、Lombok这几个起步依赖。第二步修改application.yml配置文件配置数据源信息、MyBatis的mapper-locations和JWT密钥。核心配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/report_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: your-secret-key-should-be-long-enough expire: 86400000这里有个新手很容易踩的坑MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver5.7的驱动类名是com.mysql.jdbc.Driver如果版本不对启动时会直接报ClassNotFoundException。还有serverTimezone参数不配的话连接MySQL 8.0会报时区错误。第三步在启动类上加MapperScan注解扫描Mapper接口然后按照前面设计的包结构创建controller、service、mapper、entity这些包。4.3 前端项目初始化步骤用Vue CLI创建前端项目命令是vue create report-web。创建时选择Manually select features勾选Router和Vuex。进入项目后安装Element UI和Axiosnpm install element-ui npm install axios安装完成后在main.js里注册Element UIVue.use(ElementUI)。然后配置Axios的拦截器统一处理token注入和响应错误axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) axios.interceptors.response.use( response response.data, error { if (error.response.status 401) { router.push(/login) } return Promise.reject(error) } )统一拦截器的好处是所有页面都不需要关心token怎么加、401怎么处理登录失效时自动跳回登录页。这套机制在前后端分离项目里是标配做法。4.4 数据库初始化与测试数据准备建议把建表语句和初始数据写在sql脚本里部署时一键导入。我习惯在项目根目录放一个db目录里面包含schema.sql和data.sql两个文件。schema.sql建表data.sql插入管理员账号、几个测试老师和学生账号。测试账号数据里所有密码统一用BCrypt加密后的密文。生成方法官方推荐的是在启动类里临时写个CommandLineRunner打印一行加密结果或者用在线BCrypt生成器。注意网上很多在线工具生成的哈希格式不一致最好用Spring Security自带的BCryptPasswordEncoder来生成。4.5 前后端联调与跨域处理开发环境下前端跑在8081端口后端跑在8080端口浏览器访问前端页面发请求到后端必然产生跨域。解决方法是在后端加一个CORS配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns写法SpringBoot 2.4以上版本不支持allowedOrigins()加allowCredentials(true)的组合会直接报错必须用allowedOriginPatterns()。这个细节困扰过不少人我在实际项目里碰到过一次排查了半天。5. 核心业务模块实现与代码解析5.1 用户登录接口与权限拦截器详解用户登录对应的Controller方法接收LoginRequest对象里面包含username和password字段。Service层先从数据库查出用户用BCrypt比对密码比对成功生成JWT并返回。JWT的payload里放userId和role签发时用配置里的secret做签名。权限拦截器继承HandlerInterceptor重写preHandle方法。先从请求头取Authorization字段去掉Bearer前缀后解析JWT验证签名和过期时间。验证通过就把用户信息放到ThreadLocal变量里供后续Controller取用。验证失败直接返回401状态码和错误信息JSON。这里有一个值得注意的优化点不要把JWT的密钥硬编码在代码里应该放到配置文件。也不要直接在拦截器里写业务逻辑保持拦截器只做校验和放行。如果后续要扩展权限粒度比如教师只能访问自己课程的接口可以在拦截器里查出课程归属再做比对。5.2 实验报告提交接口的实现细节提交报告的接口是POST /api/report/submit接收的参数包括taskId、content富文本内容和fileMultipartFile。这个接口的关键在于事务控制先查task是否存在、是否在截止时间之前再查是否已有报告记录最后决定INSERT还是UPDATE。Transactional public Result submitReport(ReportSubmitRequest request, MultipartFile file, Long studentId) { ExperimentTask task taskMapper.selectById(request.getTaskId()); if (task null) { return Result.error(实验任务不存在); } if (task.getDeadline().before(new Date())) { return Result.error(该实验已截止提交); } ExperimentReport report reportMapper.selectByTaskAndStudent(request.getTaskId(), studentId); if (report null) { report new ExperimentReport(); report.setTaskId(request.getTaskId()); report.setStudentId(studentId); report.setContent(request.getContent()); report.setStatus(1); // 处理文件上传 if (file ! null !file.isEmpty()) { String fileUrl uploadFile(file); report.setFileUrl(fileUrl); } reportMapper.insert(report); } else { // 已存在则覆盖 report.setContent(request.getContent()); report.setVersion(report.getVersion() 1); if (file ! null !file.isEmpty()) { String fileUrl uploadFile(file); report.setFileUrl(fileUrl); } reportMapper.updateById(report); } return Result.success(提交成功); }文件上传方法里我建议用UUID生成新文件名避免文件名冲突。后缀从原始文件名里截取拼在一起保存。上传目录按月分文件夹比如202405这样方便后续清理或者备份。5.3 批阅评分与成绩汇总的SQL逻辑批阅接口相对简单UPDATE experiment_report SET score #{score}, comment #{comment}, status 2 WHERE id #{id}。这里要注意权限教师只能批改自己课程下的实验报告。SQL上通过多表关联来校验report表join experiment_task表再join course表最后比对course.teacher_id和当前登录用户的id。成绩汇总的逻辑用三条聚合SQL实现。按学生汇总时SELECT student_id, AVG(score) AS avg_score, COUNT() AS total FROM experiment_report WHERE status 2 GROUP BY student_id。按任务统计时SELECT task_id, COUNT() AS total, AVG(score) AS avg_score FROM experiment_report WHERE status 2 GROUP BY task_id。这些查询结果直接封装成Map或VO返回给前端前端用图表库展示。5.4 前端报告提交页面的实现报告提交页面的核心交互是富文本编辑器加文件上传。富文本编辑器我选用的是vue-quill-editor安装方便功能覆盖实验报告的图文描述需求。学生先在编辑器里写实验目的、实验原理、实验步骤、实验结果分析然后上传实验数据表格或者截图文件。前端的关键校验有两点一是提交前判断当前时间是否超过截止时间超时禁掉提交按钮并给出提示二是文件大小限制超过5MB的附件直接在前端拦截避免不必要的上传流量消耗。页面初始化时调用后端接口查询该学生对当前任务的提交状态。如果已有报告把content和fileUrl回显到页面上让学生知道自己之前提交了什么内容也方便做修改。这就是编辑和新增两个交互模式的统一处理逻辑。5.5 教师端报告批阅列表的实现批阅列表页用的是Element UI的el-table组件加el-pagination分页组件。表格列包含学生姓名、学号、提交时间、报告标题、状态、操作按钮。操作按钮根据状态动态渲染状态为1显示批阅状态为2显示查看详情。这里分享一个体验优化细节点击批阅按钮后弹出的不是单独路由页面而是el-drawer抽屉组件左边显示学生报告内容右边放分数输入框和评语文本框。这种布局的好处是教师能在查看报告的同时直接操作评分不需要来回跳转页面批改效率提高不少。6. 打包部署与项目上线实操6.1 后端打包与运行后端的打包非常简单在项目根目录执行mvn clean package -DskipTestsMaven会执行编译、测试跳过、打包三步最终在target目录下生成report-system-0.0.1.jar这个可执行jar包。启动命令是java -jar report-system-0.0.1.jar。如果服务器内存有限可以加上JVM参数限制内存占用java -Xms256m -Xmx512m -jar report-system-0.0.1.jar实测下来这个系统跑在一台1核2G的云服务器上完全够用内存占用峰值不超过600MB。生产环境建议用nohup方式后台运行nohup java -jar report-system-0.0.1.jar app.log 21 。日志输出到app.log文件里方便排查问题。6.2 前端打包与Nginx配置前端打包前需要先修改环境配置文件。Vue CLI默认区分开发环境和生产环境生产环境文件是.env.production里面配置后端接口地址VUE_APP_BASE_URLhttp://你的服务器IP:8080如果后端和前端部署在同一台服务器这里可以配成http://localhost:8080因为Nginx监听80端口做反向代理时浏览器请求的其实是Nginx再由Nginx转发到后端8080端口。打包命令是npm run build执行完成后会在dist目录下生成静态文件。把这些文件上传到服务器的/usr/share/nginx/html目录或自定义目录然后配置Nginxserver { listen 80; server_name your-domain.com; location / { root /var/www/report-web; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里的try_files $uri $uri/ /index.html配置是Vue Router的history模式必需项不加这行刷新页面就会404。如果使用hash模式可以不用配但URL会带#号美观度差点。我在这个项目里用的是history模式所以这行配置特别重要。6.3 部署文档的编写心得部署文档是这个项目交付物里容易被低估的部分。我在写部署文档时坚持一个原则一个从没接触过项目的人照着文档从零开始也能部署成功。所以文档里必须包含这几块内容环境要求清单JDK、MySQL、Node、Nginx的版本号、数据库初始化的详细步骤、后端配置文件修改说明、前端环境变量配置说明、打包命令、部署路径约定、Nginx配置样例、启动检查方法、常见报错排查表。每个步骤都配上截图和预期结果说明让人能确认自己每一步做对了。6.4 项目讲解视频与答辩准备的思路如果是毕业设计场景视频讲解要按三条线展开。第一条线是业务线这个系统给谁用、解决什么问题、有哪些功能流程。第二条线是技术线采用了什么技术栈、为什么这么选、核心难点怎么解决的。第三条线是展示线实际运行起来的效果演示包括登录、提交、批阅、统计全流程走一遍。答辩时考官大概率会问的问题包括为什么用JWT而不是Session、为什么不用Spring Cloud微服务、文件上传的安全性怎么保证、并发提交报告时数据库怎么保证数据一致性。这些问题在项目开发阶段就提前想好答案答辩会从容很多。7. 常见问题与排查技巧实录7.1 数据库连接失败问题启动后端时控制台报Communications link failure十有八九是数据库连接串或账号密码配错了。排查思路是先确认MySQL服务是否启动再用命令行mysql -u root -p确认账号能正常登录最后检查application.yml里的url格式和用户名密码。还有个隐蔽原因MySQL 8.0的默认认证插件是caching_sha2_password而项目中用的数据库驱动版本较老时不支持这种认证方式。解决办法是启动MySQL时指定使用mysql_native_password或者升级驱动到8.0以上版本。7.2 Vue页面白屏或空白页问题前端部署后访问出现白屏先在浏览器按F12看控制台报什么错。最常见的两种一种是dist目录里的index.html引用静态资源的路径是绝对路径/部署在子目录时资源找不到另一种是构建时环境变量配置错误导致接口请求全部404。第一种情况的解法是在Vue CLI的配置文件vue.config.js里设置publicPath: ./让打包后的资源引用改成相对路径。这样整个dist目录复制到哪里都能直接运行。7.3 文件上传后访问404文件上传成功但下载附件时404通常是上传目录和访问映射没配对。SpringBoot默认不会把本机任意目录当静态资源托管需要在配置类里做资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadPath /); }注意file:后面必须有结尾的斜杠否则文件路径拼接会出错。uploadPath的值从配置里读取就是前面设计的文件存储根目录。这里我吃过亏少了末尾斜杠导致整条链路全部报错。7.4 跨域请求被拦截联调阶段前端报跨域错误先看后端有没有配置CORS。如果配置了还报错大概率是Allow-Credentials和Allow-Origin的配合问题两者不能一个用通配符一个用具体值。最终排查顺序建议是先看Network面板确认请求到底发出去了没再看响应头里有没有Access-Control-Allow-Origin字段最后看后端日志有没有该请求的记录。按这个顺序一步步排除几分钟就能定位。7.5 数据统计图渲染异常统计模块的图表是用ECharts实现的偶尔出现图表不显示或者数据对不上。多数情况不是ECharts本身的问题而是后端返回的数据格式与图表所需格式不匹配。比如ECharts的饼图需要[{name: 优秀, value: 10}]这种格式而后端返回的是[{label: 优秀, count: 10}]。我踩过一次后就把前后端的数据格式约定写进了接口文档统计接口统一返回给前端已经格式化的数据图表层只负责渲染。7.6 服务器重启后系统异常部署到服务器后出现过这种情况系统跑着一切正常服务器一重启就报错。排查后发现是MySQL服务没有设置开机自启服务器重启后数据库没起后端应用自然也连不上数据库。解决方法是systemctl enable mysqld把MySQL加进开机自启列表。另外把SpringBoot应用也注册成systemd服务配置Restartalways这样进程崩溃或者服务器重启后会自动拉起不用人工登录服务器手动启动。8. 写在最后的操作建议这个项目整套流程走下来从需求分析到数据库设计从后端接口开发到前端页面搭建从本地联调到服务器部署每个环节都有值得反复琢磨的细节。我个人实际操作中的体会是做这类管理系统技术难点其实不在某个具体功能而在于把各种边界情况处理好。学生提前交报告怎么办、截止时间到了还硬交怎么办、老师打错分要改怎么处理、同一个学生换了头像会不会影响数据关联这些问题在设计阶段想得越细开发阶段的返工就越少。最后再分享一个小技巧开发阶段一定要养成写接口文档的习惯不一定非要用Swagger简单的Markdown表格也行。把每个接口的路径、参数、返回结构记清楚前后端联调时能少吵很多架写部署文档和答辩讲解时也能直接复用。这个习惯我保持到现在实际收益远比想象中大。