Java教学资源管理系统:Spring Boot+MyBatis-Plus权限设计与文件上传下载实践
简介一套基于Java实现的教学资源管理系统完整工程面向高校学生、毕业设计者及Java Web学习者解决传统教育资源存储分散、检索困难、权限控制薄弱等问题。压缩包共包含一千三百六十八个文件整体大小约9.96MB前端涵盖js、css、less、html、png等界面资源后端包含Java、JSP、SQL等核心代码与数据库初始化脚本并附有项目配置、XML部署描述及目录说明便于直接导入和整体部署。系统采用经典MVC架构整合Spring、Spring Security、MySQL与JPA技术从模型、视图、控制器三层实现业务逻辑、界面展示和请求调度并通过安全框架完成用户认证与授权管理。功能模块覆盖用户账号管理、教学资源上传分类、权限分配、课程创建选课以及在线互动讨论前端基于Vue.js和响应式布局可自适应PC与移动设备。目前已有45人学习可借此快速搭建教学资源管理原型深入理解Java Web前后端分层开发、数据库持久化与权限认证流程作为课设、毕设或工程实践均具参考价值。1. Java 教学资源管理系统从需求到落地的关键判断教学资源管理系统是 Java 课程设计案例源码里出镜率最高的题目之一但很多同学把大量精力花在页面样式上最后核心功能撑不起来。真实教学场景里教师上传课件、视频和作业模板学生按课程分类检索下载管理员维护用户、课程和资源状态这三条链路才是系统的骨架。系统要解决的是资源散落在班级群和网盘里导致版本混乱、权限失控的问题。用 Java 实现这类系统常见路线是 Spring Boot 做后端接口MySQL 存数据前端用 Thymeleaf 服务端渲染只要把数据模型和权限边界理清楚这套组合可以在一周内跑通所有核心功能。这篇文章适合作课程设计的学生参考也适合需要快速搭建内部资源库的一线工程师拿来改改就用。2. 系统设计与数据模型RBAC 权限、资源分类与六张核心表2.1 角色权限模型为什么选 RBAC 而不是在代码里写死身份判断教学资源管理系统的用户只有三类学生、教师、管理员。很多入门项目直接在业务代码里写if (user.getRole().equals(STUDENT))这种散落判断遇到「助教」、「课程负责人」这类新角色时全项目搜索替换改完还容易漏。更合理的做法是引入 RBAC 模型把用户、角色、权限拆开代码里只判断「当前角色是否具备某个权限」新增角色不需要改动业务逻辑。RBAC 落地的第一步是定义角色注解。下面这段代码是自定义的权限标记注解标注在 Controller 方法上拦截器统一读取。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }value()接收多个角色名比如RequireRole({TEACHER, ADMIN})表示该方法教师和管理员都能访问。Retention(RetentionPolicy.RUNTIME)必须保留到运行时拦截器才能通过反射读到。这个注解定义放在 common 包里所有 Controller 共用。角色判断的逻辑封装成一个工具方法避免每个接口重复写查询代码。public boolean hasRole(User user, String... roles) { if (user null) { return false; } ListString userRoles roleMapper.selectRoleCodesByUserId(user.getId()); for (String role : roles) { if (userRoles.contains(role)) { return true; } } return false; }selectRoleCodesByUserId走user_role关联表去查role表返回的是角色编码集合例如[STUDENT]或[TEACHER, ADMIN]。这里的细节是角色编码用STUDENT、TEACHER这种稳定字符串不要用数据库自增 id 做判断否则环境迁移时角色 id 一旦变化代码里的判断就全错了。之所以不用单一的user.role字段存角色是因为现实场景里一个人可能有多重身份教师同时是某门课程的课程负责人还兼任管理员。单字段只能存一个值多选之后就只能在业务代码里拆逗号、做字符串匹配非常别扭。RBAC 的多对多关系天然支持这种情况这也是 java 基础面试题里常问的权限设计考点。2.2 资源分类设计课程、章节、资源类型的粒度怎么定资源分类的粒度直接决定后续接口的复杂度。常见错误是只建一张resource表里面放一个category字段值是「语文」「数学」这种平铺分类课程数量过了几十个列表页和权限控制都会失控。推荐的分层模型是三层课程 - 章节 - 资源文件。课程属于某个教师章节挂在课程之下资源文件挂在章节之下。学生按课程树形结构浏览教师按课程管理自己名下的全部资源。资源本身还要区分课件、视频、习题、参考资料这个维度用一个file_type字段存枚举值即可不必单独建字典表。资源类型可以用枚举类约束接口入参不合法时直接抛参数校验异常比在 Service 里手写字符串判断更干净。public enum ResourceType { PPT(PPT, 课件), VIDEO(VIDEO, 视频), PDF(PDF, 文档), WORD(WORD, Word 文档), OTHER(OTHER, 其他); private final String code; private final String desc; ResourceType(String code, String desc) { this.code code; this.desc desc; } public static boolean isValid(String code) { for (ResourceType type : values()) { if (type.code.equals(code)) { return true; } } return false; } }isValid静态方法做统一合法性校验前端传入的file_type不在枚举里就直接拒绝。这里有个实际收益上传接口只允许白名单类型减少了服务器被传上去恶意脚本或可执行文件的风险。枚举的desc字段用于前端下拉框展示后端返回枚举 code前端根据 code 映射显示文本两边不用硬编码字典表。这样的三层分类加上资源类型维度让查询可以走组合条件按课程 ID 过滤、按章节 ID 过滤、按资源类型过滤三个参数任意组合。时机成熟再引入标签系统也不影响原有数据结构。2.3 六张核心表字段、外键关系与建表细节我用 MySQL 建表字符集统一utf8mb4因为课程名称和资源标题都可能包含中文utf8mb4才能完整支持中文和特殊字符避免乱码。六个核心表的职责如下。表名职责关键字段user用户基本信息id, username, password, real_name, statusrole角色定义id, role_code, role_nameuser_role用户与角色关联id, user_id, role_idcourse课程信息id, course_name, course_code, teacher_idresource教学资源文件id, course_id, chapter_id, title, file_path, file_sizedownload_log下载行为日志id, user_id, resource_id, download_time我一般不建议建物理外键只在代码层面维护关系。理由很实际物理外键在批量删除或导入数据时容易触发约束冲突线上调整数据还得先禁用外键检查而且高并发写入时外键校验会带来额外的锁开销。逻辑外键配合 Service 层校验是 Java 后端项目里更常见的做法。下面是user、resource两张关键表的建表 SQL。CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) DEFAULT NULL, email VARCHAR(100) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE resource ( id INT NOT NULL AUTO_INCREMENT, course_id INT NOT NULL, chapter_id INT DEFAULT NULL, title VARCHAR(200) NOT NULL, file_type VARCHAR(20) DEFAULT NULL COMMENT PPT/VIDEO/PDF/WORD, file_path VARCHAR(255) NOT NULL, file_size BIGINT DEFAULT 0, uploader_id INT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个表的设计细节值得展开。username上的唯一索引uk_username防止账号重复注册注册接口做插入操作时数据库层也会兜底拦截不依赖代码先查询再判断。resource表的file_path存相对路径而不是完整绝对路径因为部署环境可能从 Windows 迁移到 Linux存绝对路径会全部失效相对路径配合统一的存储根目录才能保证可移植。file_size用BIGINT存字节数不要用VARCHAR存「45.2MB」这种格式化字符串。原因很直接排序和统计时数字字段才能做聚合计算比如「按文件大小倒序展示」或「统计某课程资源总占用空间」字符串存储这两件事都做不了。create_time用DATETIME类型配合DEFAULT CURRENT_TIMESTAMP插入时自动生成时间代码里不需要手动设置。3. 用 Spring Boot MyBatis-Plus 实现资源上传、下载与检索接口3.1 技术选型Spring Boot 与 MyBatis-Plus 的组合逻辑回到标题里的 Java。实现这套系统最省事的技术组合是 Spring Boot MyBatis-Plus MySQL。Spring Boot 内置 Tomcat自动配置了数据源、JSON 序列化和静态资源映射起步成本比传统 SSH 项目低很多MyBatis-Plus 在 MyBatis 之上提供了 BaseMapper 通用 CRUD单表增删改查不用手写 XML还有现成的分页插件。这套组合对课程设计和中小型内部系统来说代码量最少维护也直观。依赖只需要引入spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java和lombok前端页面部分再加spring-boot-starter-thymeleaf。Java 版本建议用 JDK 8 或 JDK 17太新的版本可能遇到中间件兼容问题这个坑在 java 环境变量配置时经常被顺带踩到。数据源配置时 URL 里带上characterEncodingutf8和serverTimezoneAsia/Shanghai两项分别解决中文乱码和日期时间差 8 小时的问题。spring: datasource: url: jdbc:mysql://localhost:3306/teach_res?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 100MB max-request-size: 200MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automax-file-size和max-request-size是针对视频和压缩包这类大资源设置的。Spring Boot 默认单文件限制 1MB教学视频动辄几十 MB不放大上传直接失败。max-request-size要大于max-file-size因为一次请求可能携带多个文件。log-impl用StdOutImpl是为了开发时在控制台看到每条 SQL 的执行情况排查查询问题非常直观生产环境再关掉。这套后端接口本身就是典型的 java 开发 api 接口以供外部调用的场景。Controller 层全部返回统一 JSON 结构不直接返回 ModelAndView这样后续拆 Vue 前端或者让移动端调用都不需要改 Service 层逻辑。统一返回结构定义如下。public class Result { private int code; private String message; private Object data; public static Result success(Object data) { Result result new Result(); result.code 200; result.message success; result.data data; return result; } public static Result error(String message) { Result result new Result(); result.code 500; result.message message; return result; } }Result里code200表示成功500表示业务异常。前端拿到结果后先判断code再渲染数据错误提示统一用message字段展示避免接口各处返回格式不一致导致前端解析困难。Result直接用静态工厂方法构造比 new 关键字更清晰这也是 Java 项目里常见的写法。3.2 资源上传接口MultipartFile 接收、磁盘存储与路径映射上传接口是资源管理系统的核心。前端以multipart/form-data提交文件后端用MultipartFile接收。这里有一个关键决定文件存储位置。常见做法是存本地磁盘专用目录数据库只存相对路径。不要尝试把文件转成byte[]存进 MySQL 的 BLOB 字段数据库会迅速膨胀备份迁移都变成灾难。看上传接口的实现。RestController RequestMapping(/api/resource) public class ResourceController { Value(${upload.dir}) private String uploadDir; PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(courseId) Integer courseId, RequestParam(value chapterId, required false) Integer chapterId, RequestParam(title) String title, HttpSession session) { if (file.isEmpty()) { return Result.error(文件不能为空); } User currentUser (User) session.getAttribute(loginUser); if (currentUser null) { return Result.error(请先登录); } if (!ResourceType.isValid(extractExt(file.getOriginalFilename()))) { return Result.error(不支持的文件类型); } // 用 UUID 重命名文件避免中文名和重名文件相互覆盖 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1); String storedName UUID.randomUUID().toString() . ext; java.io.File dest new java.io.File(uploadDir File.separator storedName); try { file.transferTo(dest); } catch (IOException e) { return Result.error(文件保存失败); } Resource res new Resource(); res.setCourseId(courseId); res.setChapterId(chapterId); res.setTitle(title); res.setFileType(extractExt(originalFilename).toUpperCase()); res.setFilePath(storedName); res.setFileSize(file.getSize()); res.setUploaderId(currentUser.getId()); resourceService.save(res); return Result.success(res.getId()); } }这段代码里有几个关键取舍。UUID 重命名是防止两个学生上传同名「期末复习.pptx」时互相覆盖File.separator根据操作系统自动切换路径分隔符避免 Windows 反斜杠和 Linux 正斜杠导致的路径错乱file.transferTo(dest)是 Spring 封装的原子写入比手动FileOutputStream更安全写入过程中出错不会留下半截文件。file_path只存storedName这种相对文件名不拼接uploadDir这是为了部署时迁移灵活。下载时再在 Service 里拼接完整路径配置从application.yml读取。uploadDir应该配置在项目外部比如 Linux 下的/data/teach_resource不要放到 classes 目录或 jar 包同级目录否则重新部署时文件会被清掉。3.3 资源检索与分页接口参数与条件构造资源列表接口承担三个职责按课程过滤、按资源类型过滤、关键词模糊搜索还要做分页。MyBatis-Plus 的分页插件让这套逻辑非常简洁先注册拦截器。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }PaginationInnerInterceptor(DbType.MYSQL)里的DbType必须和实际数据库一致写错会导致分页 SQL 方言不对。注册完成后查询接口用LambdaQueryWrapper拼条件即可。public PageResource searchResources(Integer courseId, String type, String keyword, Integer pageNum, Integer pageSize) { LambdaQueryWrapperResource wrapper new LambdaQueryWrapper(); wrapper.eq(courseId ! null, Resource::getCourseId, courseId) .eq(StringUtils.hasText(type), Resource::getFileType, type) .like(StringUtils.hasText(keyword), Resource::getTitle, keyword) .orderByDesc(Resource::getCreateTime); PageResource page new Page(pageNum, pageSize); return resourceMapper.selectPage(page, wrapper); }eq的第一个参数是条件开关courseId为 null 时该条件不进 SQL从而让课程、类型、关键词三个参数任意组合。like做的是标题模糊查询教学资源系统数据量通常只有几千条like完全够用。orderByDesc按时间倒序保证最新上传的资源排在前面。分页查询接口里pageSize要兜底限制前端传100000时后端要强制改成50不然一次拉全量数据会拖垮数据库。下载接口单独写接收资源主键查库后从配置根目录拼接完整路径用输入输出流写回浏览器。GetMapping(/download/{id}) public void download(PathVariable(id) Integer id, HttpServletResponse response, HttpSession session) { User currentUser (User) session.getAttribute(loginUser); if (currentUser null) { return; } Resource res resourceService.getById(id); if (res null || res.getStatus() 0) { return; } java.io.File file new java.io.File(uploadDir File.separator res.getFilePath()); if (!file.exists()) { return; } response.setContentType(application/octet-stream); response.setHeader(Content-Disposition, attachment;filename URLEncoder.encode(res.getTitle() _ file.getName(), StandardCharsets.UTF_8)); try (InputStream is new FileInputStream(file); OutputStream os response.getOutputStream()) { byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); } } catch (IOException e) { log.error(下载失败, e); } }下载接口不做权限校验的话任何登录用户都能拉取全部资源所以方法里先判断当前用户是否为空。URLEncoder.encode处理文件名编码否则浏览器下载中文名文件时显示乱码。这里只做了登录校验如果题目要求「某些课程仅特定班级可下载」还需要在下载前校验用户对课程是否有访问权限这是安全设计里值得主动加的分数点。4. 从接口到页面Thymeleaf 渲染、登录拦截与前后端联调4.1 Thymeleaf 模板与静态资源路径配置用户端页面用 Thymeleaf 服务端渲染原因很简单不用额外启动前端开发服务器Spring Boot 启动后直接访问页面部署也省事。Thymeleaf 的模板文件放在src/main/resources/templates目录静态资源放在src/main/resources/static目录Spring Boot 自动完成映射。开发阶段要注意关闭模板缓存否则每次改动 HTML 后刷新页面看到的还是旧内容。spring: thymeleaf: cache: false prefix: classpath:/templates/ suffix: .htmlcache: false只用于开发环境线上部署要改回true否则每个页面请求都会重新解析模板性能下降明显。prefix和suffix是 Spring Boot 的默认值不写也能工作写出来是为了让刚入门的人一眼看清模板解析规则。静态资源的路径规划要提前想清楚。我把公开接口放在/api下页面放在/page下静态资源放在/assets下三个前缀互不干扰。引入 Bootstrap 尽量下载到本地static/assets/css目录引用不要依赖 CDN因为评审环境可能断网CDN 加载失败会让整个页面样式全部丢失。th:href{/assets/css/bootstrap.min.css}写法会自动拼接上下文路径部署到带项目名的容器下也不会错。4.2 登录拦截器与接口鉴权Session 校验怎么落地资源系统不能让人未登录就下载资源也不能让学生直接访问教师管理页面。统一用一个拦截器做登录校验比在每个 Controller 方法里重复写 Session 判断要干净得多。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); if (session.getAttribute(loginUser) null) { String uri request.getRequestURI(); if (uri.startsWith(/page/login) || uri.startsWith(/assets)) { return true; } response.sendRedirect(/page/login); return false; } return true; } }拦截器放行登录页本身和静态资源两个前缀其余页面全部跳转登录页。这里有个容易疏忽的点/page/login不能只放行 GET 请求因为登录表单的 POST 提交也走这个路径放行整个前缀才能让登录动作正常完成。接口层的鉴权比页面层更严格。只靠登录拦截器挡不住「学生直接调用删除接口」的问题因为只要 Session 有效任何请求都能通过拦截器。删除、下架、修改资源这类写操作必须在 Controller 方法上加RequireRole注解或在方法内调用hasRole做二次校验。页面隐藏按钮只是视觉上的隐藏后端接口的权限校验才是真正的边界。这也是 java 基础面试题里常被追问的「前端权限能否替代后端权限」的答案。注册完拦截器后动态资源配置/登录逻辑如下。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/page/login, /assets/**, /api/resource/download/**); } }addPathPatterns(/**)拦截所有路径excludePathPatterns放行显式列出的白名单。/api/resource/download/**是否放行取决于业务需求如果资源允许游客下载就放行如果必须登录则不要加进这里。我在项目里通常把「公开分享」做成独立的status字段控制而不是在拦截器层面一刀切。4.3 资源列表页联调分页、搜索与下载按钮资源列表页是用户看到最多的界面。Thymeleaf 用th:each遍历分页记录下载按钮指向后端下载接口而不是文件的静态 URL。这样控制权限的同时还能记录下载日志。table classtable table-hover thead tr th资源名称/th th类型/th th大小/th th操作/th /tr /thead tbody tr th:eachres : ${page.records} td th:text${res.title}/td td th:text${res.fileType}/td td th:text${#numbers.formatInteger(res.fileSize / 1024, 0)}/td td a th:href{/api/resource/download/{id}(id${res.id})} classbtn btn-sm btn-primary下载/a /td /tr /tbody /table#numbers.formatInteger是 Thymeleaf 内置数值格式化工具把字节数换算成 KB 显示实际项目中可以在 Java 侧直接返回fileSize / 1024.0加单位。下载按钮触发/api/resource/download/{id}接口Service 里在返回文件的同时写入download_log这样一个操作同时完成了资源下发和数据统计。搜索和分页联调时最容易翻车的是筛选条件丢失具体表现为搜索关键词后点击第二页结果变成了全量列表。原因是分页链接只拼了pageNum没有保留courseId和keyword。解决方法是 Controller 把当前查询条件回写到 Model页面生成分页链接时把所有 query 参数都带上下次请求。分页组件建议自己写一个简单的页码循环数据量不大时远比引入前端分页插件靠谱。5. 避坑指南教学资源管理系统中的 5 个高频问题5.1 上传大文件报 MaxUploadSizeExceededException现象上传视频或压缩包时接口返回 500控制台出现MaxUploadSizeExceededException。原因Spring Boot 内置 Tomcat 的默认限制是单文件 1MB、请求 10MB课件 PPT 稍大就触发。解决先调大spring.servlet.multipart.max-file-size和max-request-size再确认前端没有走 nginx如果走了 nginx 还需要同步调整client_max_body_size。配置值要和业务文件大小上限对齐教学视频常见设 100MB 或 200MB不要盲目设成 10G否则高并发上传会瞬间打满服务器磁盘带宽。这个坑排查起来很快看异常堆栈里的是MaxUploadSizeExceededException就知道是限制问题。5.2 下载接口的路径穿越漏洞现象接口通过filename参数直接拼文件路径攻击者把参数改成../../config/application.yml就能下载到配置文件。原因没有把文件名和路径分离请求参数直接参与了文件系统路径的拼接。解决下载接口只接收资源主键路径从数据库读取永远不信任客户端传入的文件名。存储文件时就用 UUID 重命名原始文件名单独存字段下载响应头里用URLEncoder.encode编码后再放回Content-Disposition这样既安全又不影响用户看到的文件名。这条问题在评审阶段被问到的概率很高值得提前处理干净。5.3 修改页面后访问效果没变化现象改了templates目录下的 HTML刷新浏览器还是旧页面重启也不生效。原因有两个Thymeleaf 默认开启模板缓存开发时必须关闭Spring Boot 项目构建时target/classes下的旧文件没被清理启动时加载了旧模板。解决开发环境在application.yml里设置spring.thymeleaf.cachefalseIDE 里每次修改后手动 clean 再启动。这个坑不致命但会让人在开发调试时浪费大量时间把它当成编译环境的必查项本质是 java 基础面试里常说的「编译产物和源码不同步」的现场版。5.4 本地 Windows 环境正常部署到 Linux 后路径全错现象本地测试上传下载都正常打成 jar 包放上云服务器之后上传报目录不存在下载 404。原因Windows 开发机上路径用反斜杠拼接部署到 Linux 后路径分隔符不识别。解决所有路径拼接统一用File.separator或Paths.get()存储路径入数据库时只存相对文件名部署时通过外置配置指定upload.dir。配置项不要写死在代码里启动命令加--spring.config.location或环境变量均可。这条属于 X 型血泪经验本地能跑和部署能跑是两回事提前用Paths.get可以彻底规避。5.5 资源表中文乱码与时间差 8 小时现象课程名称写入数据库后变成问号create_time记录的时间比本地时间早 8 小时。原因建表时字符集不是utf8mb4或者 JDBC 连接没指定characterEncoding和serverTimezone。解决建库建表都显式指定utf8mb4连接 URL 带上useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai。如果表里已有乱码数据先导出备份再重建表直接 update 往往无效。时间问题上Java 侧字段用LocalDateTime而不是java.util.Date配合 MyBatis-Plus 的类型映射基本不会出偏差。6. 把系统做扎实批量导入、接口自测与日志验证系统运行一段时间后教师一定会提一个需求一项一项录入资源太慢能不能用 Excel 批量导入。这个功能用 Apache POI 读取 Excel 每一行把课程编号、资源标题、文件类型映射成Resource实体再批量调用保存方法。整个过程里我踩过最深的坑是「课程编号明明是数字Excel 读出来却是字符串」空单元格还会返回 null直接Integer.parseInt会抛NumberFormatException。处理方式是先统一转字符串再判空脏数据直接跳过并输出行号方便教师定位问题。接口的可靠性验证不能只靠手工点页面。写一个SpringBootTest单元测试覆盖检索接口改动字段或调整权限逻辑后跑一遍测试能拦住大部分回归问题。SpringBootTest public class ResourceServiceTest { Autowired private ResourceService resourceService; Test public void testSearchByCourseId() { PageResource page resourceService.searchResources(1, null, 期末, 1, 10); Assert.assertNotNull(page.getRecords()); for (Resource res : page.getRecords()) { Assert.assertEquals(Integer.valueOf(1), res.getCourseId()); } } }这个测试验证的是「课程 1 关键词「期末」」的查询结果每条记录都断言了courseId1把条件拼接是否正确一并覆盖了。跑测试时 MyBatis-Plus 的StdOutImpl日志会打印实际生成的 SQL能看到WHERE course_id ? AND title LIKE ?这样的语句这是排查动态条件拼接问题最直接的手段。遇到 SQL 里该出现的条件没出现优先检查LambdaQueryWrapper的条件开关参数是不是写反了。最后提供一个部署环境下的接口连通性验证方法不需要打开浏览器就能判断下载链路是否正常。curl -v -b JSESSIONID你的会话ID -o /tmp/test_download.pdf \ http://your-server:8080/api/resource/download/12返回 HTTP 200 且/tmp/test_download.pdf文件大小不为 0说明下载链路通如果返回 302说明被拦截器重定向到了登录页检查 Session 传递。这套系统最花时间的地方不在增删改查而在权限边界是否清晰、下载日志是否完整、异常处理是否兜底。我自己的习惯是先把这些容易翻车的点记成清单再去做界面美化顺序反了会非常痛苦。希望这篇笔记能帮你少走几步弯路。本文还有配套的精品资源点击获取