简介:一份基于Java实现的教学资源管理系统项目包,面向教育信息化开发者和Java Web学习者,针对传统教育资源分散、权限不清、师生互动不便等问题,提供完整的设计思路与可运行代码。压缩包内含1368个文件,大小约9.96MB;其中537个js、178个css、84个html等前端资源配合less/scss样式和163个png图片构成完整界面素材,19个java、21个jsp等后端代码承载业务逻辑,另附sql数据库脚本和jar依赖,可导入开发环境直接阅读。目前已有46人浏览学习。系统基于MVC架构,集成Spring Boot、Spring Security、JPA与MySQL,前端采用Vue.js和响应式设计,适配PC与移动端。资源管理支持上传、编辑、删除与分类,课程模块提供建课与选课入口,在线互动包含论坛与消息系统,并有Xenon等后台UI主题可供参考。借助源码可梳理从用户认证、权限校验到资源管理与在线互动的完整实现链路,适合作为毕业设计、课程设计或教学信息化项目的基础参考。
1. 教学资源管理系统:用 Java 生态先把边界定清楚
做教学资源管理系统,最怕的不是功能写不出来,而是做到一半发现文件没地方放、检索慢、权限一塌糊涂。去年我帮一所职业院校的老师搭过一套,教师上传课件和视频,学生按课程检索下载,管理员负责审核和统计。技术栈就是 Spring Boot + MyBatis Plus + MySQL 这套 Java 生态里最成熟的组合。这篇文章把数据库设计、上传与检索实现、权限控制和几个真实翻车记录完整拆开,适合正要做毕设或校内信息系统的 Java 开发者直接照着复现。你拿到手能跑通一条完整链路:登录、上传、审核、检索、下载、计数,缺的环节也能从踩坑记录里找到对应解法。
2. 数据库设计:五张表撑起资源管理的核心流程
2.1 表结构设计:用户、分类、资源、下载日志、审核日志
教学资源管理系统里,资源是核心,但围绕资源的用户、分类、下载记录和审核记录才是让系统能运转起来的关键。我第一次做这类系统时只设计了三张表,结果上线一周就发现问题:资源删了但下载记录还在,审核状态改来改去没有痕迹,分类想调整子级就得改代码。后来重构成了五张表,结构就稳了。
-- 用户表 CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt加密存储', `real_name` varchar(50) DEFAULT NULL, `role` tinyint NOT NULL DEFAULT '3' COMMENT '1管理员 2教师 3学生', `department` varchar(100) DEFAULT NULL COMMENT '院系/教研室', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 资源表 CREATE TABLE `t_resource` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '资源标题', `category_id` bigint NOT NULL COMMENT '所属分类', `file_name` varchar(255) NOT NULL COMMENT '原始文件名', `file_size` bigint NOT NULL COMMENT '字节数', `file_type` varchar(20) DEFAULT NULL COMMENT '扩展名', `storage_path` varchar(500) NOT NULL COMMENT '相对存储路径', `description` text COMMENT '资源描述', `tags` varchar(255) DEFAULT NULL COMMENT '逗号分隔标签', `download_count` int NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待审核 1已发布 2已下架', `creator_id` bigint NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`), KEY `idx_title` (`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教学资源表';这段 DDL 里有几个字段是后来补的,不是一开始就想得这么全。storage_path只存相对路径而不是完整 URL,是因为系统部署后域名或 IP 会变,存完整地址等于给自己埋雷。deleted是逻辑删除标记,所有查询默认带上deleted = 0,这样管理员误删资源还能恢复。file_size用 bigint 而不是 int,一个 4G 以上的视频文件,int 会直接溢出,这是个容易忽略的细节。
2.2 为什么不用物理外键和自增以外的约束
有些读者可能已经注意到,t_resource里category_id和creator_id都没有加物理外键。这不是偷懒,是刻意为之。物理外键在 InnoDB 里会引入额外的锁和检查开销,资源表又是高频写入和更新的表,外键在并发上传和删除的场景下容易成为性能瓶颈。更实际的原因是这类管理系统后期大概率要做分表或迁移,物理外键会变成绊脚石。
那数据完整性怎么保证?我的习惯是在应用层做校验,插入前查一次分类是否存在、用户是否存在。虽然多一条 SQL,但换来的是迁移和扩展的自由度。另外,分类表我用了 parent_id 做自关联,允许两级分类,比如“Java 开发”下面挂“课件”“视频”“实验指导书”,查询子分类时先查父节点再拼category_id in (...),避免用递归 CTE 搞得 SQL 很复杂。
-- 分类表 CREATE TABLE `t_category` ( `id` bigint NOT NULL AUTO_INCREMENT, `parent_id` bigint NOT NULL DEFAULT '0' COMMENT '0表示顶级', `name` varchar(50) NOT NULL, `sort` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源分类表'; -- 下载记录表 CREATE TABLE `t_download_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `resource_id` bigint NOT NULL, `user_id` bigint NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_resource` (`resource_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载记录表';分类表只用了四个字段,够用且不过度设计。下载记录表每次下载插入一条,不做更新,只做追加,因为它是审计数据,改了就没有意义。关于 MyBatis Plus 的 AutoGenerator 能从实体类自动生成建表 SQL,这个功能我一般只在开发环境快速起表结构时用。生产环境我还是手写 DDL,因为索引的设计、字段注释、字符集和表的物理属性,都是生成工具给不了的。用工具生成的表,十个里有八个是索引缺失或者字段类型拍脑袋定的。
3. 上传与检索:文件存储策略和关键词查询实现
3.1 文件上传:按日期分目录,UUID 重命名,绕开临时目录的坑
教学资源系统里上传的文件以课件、视频、压缩包为主,单个文件从几 MB 到几个 GB 不等。我选择的方案是本地磁盘存储 + MySQL 存元数据,没有引入 FastDFS 或 OSS。原因很简单:校内系统并发不高,文件量在 TB 级别以内,本地存储完全扛得住,运维成本也低;等真到了要扩容量的时候,把storage_path迁移到 OSS 只是改一个存储策略类的事,对上层接口没有侵入。
上传接口的实现,我一般不会直接调用file.transferTo(),因为这是个容易翻车的写法。Spring 在处理大文件时会先把文件写到临时目录,transferTo 依赖的是临时文件的位置,跨操作系统时经常出问题。我习惯用 Apache Commons IO 或 JDK 的Files.copy自己做流式拷贝,这样能明确控制写入目标。
@PostMapping("/api/resource/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("title") String title, @RequestParam("categoryId") Long categoryId, @RequestParam(value = "tags", required = false) String tags, @RequestHeader("Authorization") String token) { // 1. 鉴权:从 token 解析用户,校验角色 User loginUser = userService.parseToken(token); if (loginUser.getRole() != RoleEnum.TEACHER && loginUser.getRole() != RoleEnum.ADMIN) { return Result.error("只有教师和管理员可以上传资源"); } // 2. 校验文件非空且大小不超过 2GB if (file.isEmpty() || file.getSize() > 2L * 1024 * 1024 * 1024) { return Result.error("文件为空或超过2GB限制"); } // 3. 按日期生成存储目录,避免单目录文件过多 String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String originalName = StringUtils.cleanPath( Objects.requireNonNull(file.getOriginalFilename())); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; String relativePath = datePath + "/" + newName; String baseDir = resourceProperties.getStorageDir(); Path targetPath = Paths.get(baseDir, relativePath).toAbsolutePath().normalize(); try { // 4. 创建目录并流式写入 Files.createDirectories(targetPath.getParent()); try (InputStream in = file.getInputStream()) { Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException e) { log.error("文件存储失败: {}", relativePath, e); return Result.error("文件存储失败,请检查磁盘空间"); } // 5. 保存资源记录,状态置为待审核 Resource resource = new Resource(); resource.setTitle(title); resource.setCategoryId(categoryId); resource.setFileName(originalName); resource.setFileSize(file.getSize()); resource.setFileType(ext.replace(".", "")); resource.setStoragePath(relativePath); resource.setTags(tags); resource.setStatus(ResourceStatus.PENDING); resource.setCreatorId(loginUser.getId()); resourceService.save(resource); return Result.success(resource.getId()); }逻辑说明:先解析 token 拿到登录用户,判断角色是否允许上传,这一步必须在文件落盘之前做,否则游客也能往服务器上写文件,磁盘几分钟就能被打满。文件名校验用StringUtils.cleanPath是为了去掉可疑的路径穿越片段。按yyyy/MM/dd分目录,一个目录最多几千个文件,目录内查找不会太慢。
参数说明:resourceProperties.getStorageDir()是自定义配置项,在application.yml里对应resource.storage-dir。Linux 部署时我建议配成/data/resource-storage,和项目目录分离,这样重启应用或者重新部署时文件不会丢失。2GB限制是业务层面的,Spring 默认单文件最大 1MB,需要改spring.servlet.multipart.max-file-size和max-request-size,否则文件到 2MB 就会被拦下来,这个配置文件我放在下方。
spring: servlet: multipart: max-file-size: 2048MB max-request-size: 3072MB3.2 检索实现:MyBatis Plus 条件构造器与分页
资源检索的核心诉求是三个维度:关键词、分类、状态。用户只会看到已发布的资源,管理员能看到全部。我选择直接用 MySQL 的LIKE配合 MyBatis Plus 的LambdaQueryWrapper完成,没有上 Elasticsearch。判断依据是:教学资源系统的数据量级一般在十万条以内,LIKE '%关键词%'虽然走不了索引,但这个量级下查询时间在几十毫秒内,完全感知不到;而引入 ES 意味着多维护一套集群,对这类项目是过度设计。
@Override public IPage<ResourceVO> searchResources(String keyword, Long categoryId, Integer pageNum, Integer pageSize) { Page<Resource> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Resource> wrapper = Wrappers.lambdaQuery(); // 过滤逻辑删除 wrapper.eq(Resource::getDeleted, 0); // 默认只查已发布资源 wrapper.eq(Resource::getStatus, ResourceStatus.PUBLISHED); if (StringUtils.hasText(keyword)) { // 标题 OR 标签 双字段匹配 wrapper.and(w -> w .like(Resource::getTitle, keyword) .or() .like(Resource::getTags, keyword)); } if (categoryId != null) { wrapper.eq(Resource::getCategoryId, categoryId); } // 下载量优先,其次按更新时间倒序 wrapper.orderByDesc(Resource::getDownloadCount); wrapper.orderByDesc(Resource::getUpdateTime); IPage<Resource> result = this.page(page, wrapper); // 转换为 VO,隐藏存储路径和审核状态 return result.convert(resource -> { ResourceVO vo = new ResourceVO(); vo.setId(resource.getId()); vo.setTitle(resource.getTitle()); vo.setFileName(resource.getFileName()); vo.setFileSize(resource.getFileSize()); vo.setDownloadCount(resource.getDownloadCount()); vo.setCreateTime(resource.getCreateTime()); return vo; }); }逻辑说明:检索条件用 Lambda 写法,字段名改成方法引用,编译期就能发现字段名拼写错误。关键词匹配限定在标题和标签上,两字段是业务上最敏感的区域,加描述匹配会把查询范围撑得很大,返回一堆不相关结果。分类过滤是精确匹配。
参数说明:pageNum从 1 开始,这是 MyBatis Plus 的惯例。分页条数我习惯固定成10或20,不开放给前端随意调大,防止有人一次拉全量数据把接口拖垮。orderByDesc先按下载量后按更新时间,能保证热门资源靠前,同时新上传的资源也有机会露出来。如果资源量真的突破了百万,那时候再在t_resource表加个fulltext index,用MATCH ... AGAINST替换LIKE,应用层只需要改一个查询方法,这是后话。
4. 避坑排查:五个真实翻车记录与解决思路
4.1 上传大视频文件报 NoSuchFileException
现象:开发环境上传 200MB 的 MP4 一切正常,部署到服务器后同样文件报java.nio.file.NoSuchFileException,偶尔还伴随FileUploadBase$FileSizeLimitExceededException。
原因:Tomcat 处理上传时会把文件先写到java.io.tmpdir指定的临时目录,文件超过一定大小后临时目录空间不足或目录不存在,transferTo 就会失败。责任不在业务代码,在环境配置。
解决:统一不走系统临时目录。调用file.getInputStream()+Files.copy直接写到业务存储目录,同时显式指定应用级临时目录,在启动脚本里加-Djava.io.tmpdir=/data/tmp。从那以后我就没在项目里再直接用过transferTo(),这是血泪经验。
4.2 Windows 开发正常、Linux 部署后资源 404
现象:本地 Windows 上传的图片能预览,部署到 CentOS 后上传能成功但访问全部 404。检查文件已经在磁盘上,路径也在数据库里,就是通过 URL 访问不到。
原因:我最初拼接 URL 用的\,Windows 下路径分隔符是反斜杠,Tomcat 默认把反斜杠当作非法字符处理。另一个坑是存储目录配在了项目resources/static下面,Linux 重启应用后目录被覆盖。
解决:存储路径所有拼接都换成/,或者在配置里用File.separator统一。存储目录改到/data/resource-storage,然后映射一个/files/**的静态资源处理器,指向磁盘上的物理路径。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + resourceProperties.getStorageDir() + "/"); } }4.3 启动就报 OutOfMemoryError,堆调到 8000MB 还是挂
现象:Spring Boot 启动后没多久报java.lang.OutOfMemoryError: Metaspace,我以为是堆不够,把-Xmx从 4G 调到 8G,重启后依旧报错。
原因:堆内存和元空间是两回事。项目引了大量第三方依赖,CGLIB 生成的代理类全部占元空间,-Xmx控制的是堆,控制不了 Metaspace。默认元空间大小约 256MB,对大型 Spring Boot 应用不够。
解决:启动脚本同时设堆和元空间参数,用-XX:MaxMetaspaceSize=512m。从那以后我排查 OOM 先看日志头几行,分清是Java heap space还是Metaspace,再去调对应参数,不再无脑加堆。
4.4 中文关键词搜不到,英文能搜到
现象:学生搜索“操作系统”返回空,搜“OS”却正常。数据库里明明有标题包含“操作系统”的资源。
原因:MySQL 连接串少了characterEncoding=utf8,导致中文以latin1传到数据库后端。另一个隐蔽原因是表字段字符集是utf8mb4_general_ci没问题,但连接串里characterEncoding和serverTimezone配置相互干扰,中文变成了乱码。
解决:连接串统一加上characterEncoding=utf8&useUnicode=true,并把数据库、表、字段字符集全部迁移到utf8mb4。以后所有新库我都在建库 SQL 里直接写DEFAULT CHARSET=utf8mb4,这个习惯避免了一整类字符集问题。
4.5 MyBatis Plus 生成 SQL 与手写 DDL 的索引差异
现象:用 AutoGenerator 根据实体类生成建表 SQL,上线后资源列表查询越来越慢,explain显示type=ALL全表扫描。
原因:生成器按实体字段逐个建索引,把所有字段都建了普通索引,但查询条件是category_id + status + deleted的组合,没有联合索引,MySQL 只能选其中一个索引回表,数据量大后性能指数级下降。
解决:删掉多余索引,手写联合索引idx_category_status (category_id, status, deleted),覆盖查询条件。实体类上保留@TableField注解做逻辑删除标识,SQL 和实体各自维护,生成器只用来生成代码,不再直接生成生产表的 DDL。
5. 权限与高并发验证:用 AOP 注解收口接口权限
5.1 自定义注解 + AOP 切面:细粒度角色控制
上传、下载、审核、管理这几个接口对角色要求完全不同。我见过很多项目的做法是在 Controller 方法里手写if判断角色,又丑又容易漏。后来我改成自定义注解加 AOP 切面,所有接口的权限校验统一走切面,业务代码里一行判断都没有。
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { RoleEnum[] value(); }@Aspect @Component public class RoleCheckAspect { @Autowired private UserService userService; @Before("@annotation(requireRole)") public void checkRole(JoinPoint joinPoint, RequireRole requireRole) { // 从请求头解析 token,拿到当前用户角色 ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { throw new BizException("无法获取请求上下文"); } String token = attrs.getRequest().getHeader("Authorization"); User loginUser = userService.parseToken(token); boolean passed = Arrays.stream(requireRole.value()) .anyMatch(role -> role.getCode() == loginUser.getRole()); if (!passed) { throw new BizException("没有权限访问该接口"); } } }逻辑说明:@Before通知在方法执行前拦截,如果requireRole.value()里不包含当前用户的角色,直接抛异常,由全局异常处理器转成 HTTP 403。Controller 用法是:
@RequireRole({RoleEnum.ADMIN, RoleEnum.TEACHER}) @PostMapping("/api/resource/upload") public Result upload(...) { ... }参数说明:value()是角色数组,支持一个接口多个角色。这个方法的好处是权限逻辑集中在一个类里,以后要加操作日志、限流,直接在同一个切面上加逻辑就够了,不用每个接口单独改。
5.2 并发下载验证:下载量计数必须用原子更新
资源管理系统里的下载量是教学效果评估的参考指标。我的实现是在下载接口里先插一条下载日志,再把资源表下载量加一。刚开始用“先查再更”的思路,先select当前计数,再update,结果用并发脚本一压就发现数字对不上,一百次并发下载统计出来只有八十多次。
原因很简单:两个事务同时读到download_count = 100,各自加一后写回都是101,丢失了一次更新。后来改成一条 SQL 原子更新,就再没出过问题。
@Transactional public void increaseDownloadCount(Long resourceId, Long userId) { // 1. 插入下载日志 DownloadLog log = new DownloadLog(); log.setResourceId(resourceId); log.setUserId(userId); downloadLogMapper.insert(log); // 2. 原子自增,避免并发覆盖 resourceMapper.updateDownloadCount(resourceId); }<update id="updateDownloadCount"> UPDATE t_resource SET download_count = download_count + 1 WHERE id = #{id} </update>验证方法:写一段 bash 脚本模拟并发下载,观察下载量最终值是否与请求次数一致。我用的是xargs -P 10同时发十个请求,循环二十轮。
for i in $(seq 1 20); do echo $i done | xargs -P 10 -I {} curl -s -o /dev/null \ -H "Authorization: test-token" \ "http://localhost:8080/api/resource/download/1"逻辑说明:-P 10表示十个并发,跑二十次就是 200 个下载请求。只要最终download_count是 200,说明并发处理没问题;如果小于 200,就去检查是不是用了先查后更的写法。这套验证脚本我现在每次写接口都会顺手跑一遍,能救回不少并发问题。
从那以后,我每次给管理系统加接口,都会先问自己一句:这个接口谁在什么时候能调,改数据的关键操作是不是原子的?先把注解加上、把更新的 SQL 写成原子表达式,再去写业务代码。这套系统最花时间的从来不是 CRUD,而是这些边界和并发细节。希望帮到你。
本文还有配套的精品资源,点击获取