☰
Java教学资源管理系统:Spring Boot+MyBatis-Plus权限设计与文件上传下载实践
2026/9/25 1:45:50 网站建设 项目流程

简介:一套基于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; } List<String> 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, status
role角色定义id, role_code, role_name
user_role用户与角色关联id, user_id, role_id
course课程信息id, course_name, course_code, teacher_id
resource教学资源文件id, course_id, chapter_id, title, file_path, file_size
download_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个表的设计细节值得展开。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 里带上characterEncoding=utf8和serverTimezone=Asia/Shanghai两项,分别解决中文乱码和日期时间差 8 小时的问题。

spring: datasource: url: jdbc:mysql://localhost:3306/teach_res?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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: auto

max-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里code=200表示成功,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 Page<Resource> searchResources(Integer courseId, String type, String keyword, Integer pageNum, Integer pageSize) { LambdaQueryWrapper<Resource> 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); Page<Resource> 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: .html

cache: 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 class="table table-hover"> <thead> <tr> <th>资源名称</th> <th>类型</th> <th>大小</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="res : ${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})}" class="btn 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.cache=false,IDE 里每次修改后手动 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 带上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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() { Page<Resource> page = resourceService.searchResources(1, null, "期末", 1, 10); Assert.assertNotNull(page.getRecords()); for (Resource res : page.getRecords()) { Assert.assertEquals(Integer.valueOf(1), res.getCourseId()); } } }

这个测试验证的是「课程 1 + 关键词「期末」」的查询结果,每条记录都断言了courseId=1,把条件拼接是否正确一并覆盖了。跑测试时 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 传递。这套系统最花时间的地方不在增删改查,而在权限边界是否清晰、下载日志是否完整、异常处理是否兜底。我自己的习惯是先把这些容易翻车的点记成清单,再去做界面美化,顺序反了会非常痛苦。希望这篇笔记能帮你少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询