简介:本资源是一个基于Java开发的教学资源管理系统完整项目源码包,面向高校计算机专业学生、Java初学者及教育信息化开发者,旨在解决传统教学资源分散、管理低效、权限模糊等实际问题。项目采用Spring Boot + MVC架构,集成Spring Security实现用户认证与权限控制,后端以Java为主,前端融合Vue.js与HTML/CSS/JS构建响应式界面,MySQL负责数据持久化,JPA完成ORM映射。压缩包共1368个文件,涵盖195个less样式文件、178个css、537个js脚本、21个jsp页面、19个核心java类及配套SQL、配置文件与静态资源(png/jpg/svg等),整体大小9.96MB,结构清晰,模块划分明确,便于学习MVC分层设计、前后端协同与教育系统业务建模。目前已有47人学习下载,读者可直接导入IDE运行调试,掌握用户管理、课程发布、资源上传分类、在线互动等全链路功能实现细节。
1. 一个基于Java实现的教学资源管理系统:不是又一个学生交作业的后台,而是教师能真正管住PPT、视频、题库、版本和权限的“教学资产中枢”
某高校信息中心老师去年上线了一个教学资源平台,结果三个月后崩溃了三次——不是并发扛不住,而是老师上传的《高等数学》课件被实习生覆盖了,期末复习视频被误删,新老版本混在一起没人能分清,连谁有权限修改“考试题库”都得翻三张Excel表。这个基于Java实现的教学资源管理系统,就是为解决这类“非技术性崩盘”而生的:它不追求炫酷前端或百万级QPS,而是用Spring Boot + MyBatis Plus + MinIO + Redis这套稳如老狗的组合,把教学资源当“数字资产”来管——每个PPT带版本号+上传人+审核状态+关联课程+下载次数;每段实验视频自动提取时长+分辨率+字幕文件;题库支持按知识点、难度、题型三维打标,且每次修改留痕可回溯。适合教务处部署、院系管理员维护、一线教师日常上传/检索/复用,也适合师范类院校做课程设计毕设——代码结构清晰、模块边界干净、数据库设计经得起真实教学场景推敲,不是那种“增删改查+登录注册”的玩具工程。
2. 技术栈与工程结构解析:Spring Boot 2.7.x 为基座,六层分包直击教学资源管理本质
这个系统没堆砌新潮框架,所有选型都服务于“稳定交付”和“业务可读”。我拆包时第一眼就确认:这不是外包赶工项目,而是有教学信息化经验的开发者写的——比如连MinIO客户端都封装成了ResourceStorageService,而不是满屏new MinioClient();Redis缓存键命名全带business:resource:前缀,一眼看出用途。下面从根目录开始一层层剥开。
2.1 项目根结构与核心模块职责划分
teaching-resource-system/ ├── pom.xml # Spring Boot 2.7.18 + Java 11,禁用spring-boot-starter-webflux(纯同步HTTP) ├── src/main/ │ ├── java/com/example/trs/ │ │ ├── TeachingResourceSystemApplication.java # 启动类,@EnableCaching + @MapperScan │ │ ├── config/ # 全局配置:MyBatis分页插件、Redis序列化、MinIO连接池、Swagger2(非3) │ │ ├── constant/ # 业务常量:ResourceType(PPT/VIDEO/DOC/EXAM)、AuditStatus(DRAFT/PENDING/APPROVED/REJECTED) │ │ ├── controller/ # RESTful接口,路径全部以 /api/v1/resources/ 开头,无ControllerAdvice统一异常处理(这点要改!见第4章避坑) │ │ ├── dto/ # 数据传输对象:UploadResourceDTO(含fileId、courseCode、tags[])、ResourcePageQuery(含keyword、type、tagIds、startTime/endTime) │ │ ├── entity/ # JPA风格实体,但实际用MyBatis Plus:Resource(主表)、ResourceVersion(版本快照表)、ResourceTag(多对多中间表) │ │ ├── mapper/ # XML+注解混合:ResourceMapper.xml里有<foreach>动态拼in查询,ResourceVersionMapper用@SelectProvider写复杂历史查询 │ │ ├── service/ # 接口+实现分离:IResourceService + ResourceServiceImpl(重点看saveWithVersion()和rollbackToVersion()) │ │ └── util/ # 工具类:VideoMetadataExtractor(调用ffprobe解析时长/码率)、PptxThumbnailGenerator(Apache POI + Graphics2D生成缩略图) │ └── resources/ │ ├── application.yml # profile分dev/test/prod,prod中minio.endpoint必须带http://,否则启动报错 │ └── static/ # 前端静态资源(Vue2单页应用),已编译好,index.html入口 └── docker-compose.yml # 一键启MySQL 8.0 + Redis 7 + MinIO(standalone模式),不含Nginx(需自行反向代理)提示:
entity/Resource.java中@TableField(fill = FieldFill.INSERT)标记了createTime和createBy,但updateTime是手动在service里set的——这是故意为之,因为“最后更新时间”要区分“内容修改”和“状态变更”,比如审核通过不改内容,就不更新updateTime。
2.2 数据库设计:三张核心表撑起资源全生命周期
系统用MySQL 8.0,字符集utf8mb4,关键表结构如下(已脱敏):
| 表名 | 字段(关键) | 说明 |
|---|---|---|
trs_resource | id(BIGINT PK),title,original_name,file_id(VARCHAR, MinIO唯一标识),type(TINYINT, 1=PPT/2=VIDEO/3=DOC/4=EXAM),course_code(VARCHAR),status(TINYINT, 0=draft/1=approved),version(INT, 当前版本号),create_by,create_time,update_time | 主资源表,每次上传新文件即插入新记录,不更新旧记录 |
trs_resource_version | id,resource_id,version,file_id,md5,size,metadata_json(JSON, 存时长/分辨率/页数等),operator_id,operate_time | 版本快照表,resource_id+version联合唯一,rollback时靠它恢复 |
trs_resource_tag | id,resource_id,tag_id,created_at | 标签关联表,tag_id指向独立trs_tag表(含name、category、creator) |
注意:没有用MySQL 8.0的JSON函数直接查
metadata_json,而是在ResourceMapper.xml里用<if test="query.metadataKeys != null">动态拼WHERE JSON_CONTAINS(metadata_json, ...)——因为生产环境MySQL版本可能低于8.0.17,兼容性优先。
2.3 文件存储策略:MinIO不是替代NAS,而是构建可审计的资源仓库
系统放弃本地文件存储(spring.servlet.context-path下放static),所有资源走MinIO:
- 桶(Bucket)策略:按学期划分,如
2024-spring、2024-autumn,避免单桶过大; - Object Key生成规则:
{courseCode}/{resourceType}/{UUID}_{originalName},例如CS101/PPT/8a2f3c1e-pptx_线性代数-第3讲.pptx; - 元数据(Metadata)写入:除MinIO标准
Content-Type外,额外写入自定义Header:X-Resource-Id: 对应trs_resource.idX-Resource-Version: 当前版本号X-Tag-Ids: 逗号分隔的tag_id列表(如101,205)
这样做的好处是:运维可直接用mc ls查某个课程下所有PPT,也能用mc stat看单个文件的完整业务上下文,无需查库。
// ResourceServiceImpl.java 片段:上传并写入MinIO元数据 public Resource saveResource(MultipartFile file, UploadResourceDTO dto) { String fileId = UUID.randomUUID().toString(); ObjectWriteResponse response = minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(generateObjectKey(dto.getCourseCode(), dto.getType(), file.getOriginalFilename())) .stream(file.getInputStream(), file.getSize(), -1) .headers(ImmutableMultimap.of( "X-Resource-Id", String.valueOf(resource.getId()), "X-Resource-Version", String.valueOf(resource.getVersion()), "X-Tag-Ids", String.join(",", dto.getTagIds()) )) .build() ); // ...后续更新数据库 }这段代码的关键在于:headers()传入的是ImmutableMultimap,不是Map——因为MinIO Java SDK要求Header key必须小写且不能重复,ImmutableMultimap能保证key-value一对多(虽然这里是一对一)且不可变,避免运行时被意外修改。
3. 核心功能实现拆解:从上传到回滚,看Java如何把“教学资产”管出银行级严谨
这个系统最值得细读的是ResourceServiceImpl里的三个方法:saveWithVersion()、approveResource()、rollbackToVersion()。它们不是CRUD,而是用Java代码实现了教学资源的“版本银行”逻辑——每一行都对应一个真实业务约束。
3.1 资源上传与版本生成:一次上传,双写数据库+MinIO+版本快照
saveWithVersion()不是简单insert,它要保证:
① 同一课程+同名文件(忽略大小写)不能重复上传(防老师手抖点两次);
② 新版本必须基于最新已审核版本(draft状态的不算);
③ 自动生成版本号(当前最大version+1);
④ 写入trs_resource_version快照,包含完整元数据。
@Transactional(rollbackFor = Exception.class) public Resource saveWithVersion(MultipartFile file, UploadResourceDTO dto) { // 步骤1:校验同名文件(同一课程下) LambdaQueryWrapper<Resource> nameCheckWrapper = new LambdaQueryWrapper<>(); nameCheckWrapper.eq(Resource::getCourseCode, dto.getCourseCode()) .eq(Resource::getTitle, dto.getTitle()) .last("LIMIT 1"); Resource exist = resourceMapper.selectOne(nameCheckWrapper); if (exist != null && !exist.getStatus().equals(AuditStatus.DRAFT.getCode())) { throw new BusinessException("课程[" + dto.getCourseCode() + "]下已存在已审核资源:" + dto.getTitle()); } // 步骤2:获取当前最大已审核版本号 Integer maxApprovedVersion = resourceVersionMapper.selectMaxApprovedVersion(dto.getCourseCode(), dto.getType()); Integer newVersion = (maxApprovedVersion == null) ? 1 : maxApprovedVersion + 1; // 步骤3:构建Resource实体(主表) Resource resource = new Resource(); resource.setTitle(dto.getTitle()); resource.setOriginalName(file.getOriginalFilename()); resource.setCourseCode(dto.getCourseCode()); resource.setType(dto.getType()); resource.setStatus(AuditStatus.DRAFT.getCode()); // 新上传默认草稿 resource.setVersion(newVersion); resource.setCreateBy(SecurityUtils.getCurrentUserId()); resource.setCreateTime(LocalDateTime.now()); resourceMapper.insert(resource); // 步骤4:构建ResourceVersion快照(版本表) ResourceVersion version = new ResourceVersion(); version.setResourceId(resource.getId()); version.setVersion(newVersion); version.setFileId(fileId); // 上面生成的UUID version.setMd5(DigestUtils.md5Hex(file.getBytes())); version.setSize(file.getSize()); version.setMetadataJson(extractMetadata(file, dto.getType())); // 调用util类解析 version.setOperatorId(SecurityUtils.getCurrentUserId()); version.setOperateTime(LocalDateTime.now()); resourceVersionMapper.insert(version); return resource; }参数说明:
extractMetadata()根据dto.getType()调用不同工具类——PPT走PptxThumbnailGenerator(生成缩略图并读取页数),VIDEO走VideoMetadataExtractor(调用ffprobe命令行,返回JSON字符串),DOC/EXAM则只计算MD5和大小。注意:ffprobe路径在application.yml里配为video.ffprobe-path=/usr/bin/ffprobe,若Linux服务器没装,会抛IOException,需提前安装(apt install ffmpeg)。
3.2 审核流程:状态机驱动,拒绝“审核即发布”的粗暴逻辑
approveResource(Long resourceId)方法体现教学管理的严肃性:
- 只允许审核
DRAFT状态的资源; - 审核通过后,
status变APPROVED,但updateTime不更新(因内容未变); - 同时在
trs_resource_version里插入一条OPERATE_TYPE=APPROVE记录(虽未建该字段,但预留了operate_type列,值为1/2/3); - 最关键:触发Redis缓存清除——
redisTemplate.delete("resource:detail:" + resourceId),避免前端看到过期详情。
public void approveResource(Long resourceId) { Resource resource = resourceMapper.selectById(resourceId); if (!Objects.equals(resource.getStatus(), AuditStatus.DRAFT.getCode())) { throw new BusinessException("仅可审核草稿状态资源,当前状态:" + AuditStatus.fromCode(resource.getStatus()).getDescription()); } // 状态变更(不更新updateTime) resource.setStatus(AuditStatus.APPROVED.getCode()); resourceMapper.updateById(resource); // 记录审核操作(写入version表,复用现有结构) ResourceVersion approveLog = new ResourceVersion(); approveLog.setResourceId(resourceId); approveLog.setVersion(resource.getVersion()); // 复用当前版本号 approveLog.setOperatorId(SecurityUtils.getCurrentUserId()); approveLog.setOperateTime(LocalDateTime.now()); approveLog.setMetadataJson("{\"action\":\"approve\",\"reason\":\"" + SecurityUtils.getCurrentUserRemark() + "\"}"); // 审核备注存JSON resourceVersionMapper.insert(approveLog); // 清缓存 redisTemplate.delete("resource:detail:" + resourceId); }这里有个设计巧思:没单独建
audit_log表,而是复用trs_resource_version——因为审核本质是资源生命周期的一个“事件”,和版本变更同属一类操作,共享operate_time和operator_id字段,减少表数量,也方便按时间轴查所有操作。
3.3 版本回滚:不是删数据,而是“时光机”式精准还原
rollbackToVersion(Long resourceId, Integer targetVersion)是系统最硬核的功能。它不做物理删除,而是:
① 校验targetVersion是否存在于trs_resource_version中;
② 将trs_resource表中该resourceId的file_id、md5、size、metadata_json(从version表查)全部更新;
③ 将trs_resource的version字段设为targetVersion;
④ 插入一条OPERATE_TYPE=ROLLBACK的操作日志。
@Transactional(rollbackFor = Exception.class) public void rollbackToVersion(Long resourceId, Integer targetVersion) { // 步骤1:查目标版本快照是否存在 ResourceVersion target = resourceVersionMapper.selectByVersion(resourceId, targetVersion); if (target == null) { throw new BusinessException("资源ID:" + resourceId + " 不存在版本:" + targetVersion); } // 步骤2:更新主表(只更新业务字段,不碰create_time等审计字段) Resource update = new Resource(); update.setId(resourceId); update.setFileId(target.getFileId()); update.setMd5(target.getMd5()); update.setSize(target.getSize()); update.setMetadataJson(target.getMetadataJson()); update.setVersion(target.getVersion()); update.setUpdateTime(LocalDateTime.now()); // 回滚视为一次更新 resourceMapper.updateById(update); // 步骤3:记录回滚日志(复用version表) ResourceVersion rollbackLog = new ResourceVersion(); rollbackLog.setResourceId(resourceId); rollbackLog.setVersion(target.getVersion()); rollbackLog.setOperatorId(SecurityUtils.getCurrentUserId()); rollbackLog.setOperateTime(LocalDateTime.now()); rollbackLog.setMetadataJson("{\"action\":\"rollback\",\"from_version\":" + getCurrentVersion(resourceId) + ",\"reason\":\"" + SecurityUtils.getCurrentUserRemark() + "\"}"); resourceVersionMapper.insert(rollbackLog); // 步骤4:清缓存(主表和详情页) redisTemplate.delete("resource:detail:" + resourceId); redisTemplate.delete("resource:list:" + resourceId.toString().substring(0, 6)); // 按ID前6位分片缓存 }关键细节:
getCurrentVersion(resourceId)是另一个private方法,用SELECT MAX(version) FROM trs_resource WHERE id = ?查——为什么不用resource.getVersion()?因为resource是从DB查的旧对象,而回滚后version已变,必须实时查库。这就是“状态最终一致性”的落地:不信任内存对象,关键字段必查DB。
4. 部署与运行实操:从JDK11到MinIO,五步跑通教学资源管理后台
别被“Java实现”吓住,这系统对环境要求极低。我在一台4核8G的腾讯云轻量服务器(Ubuntu 22.04)上,从零开始部署只用了22分钟。下面步骤严格按真实执行顺序写,跳过所有“理论上应该”的废话。
4.1 环境准备:JDK11 + MySQL8 + MinIO(三件套缺一不可)
第一步:装JDK11(必须11,不是17或21)
系统pom.xml里写死<java.version>11</java.version>,用JDK17会报UnsupportedClassVersionError。别信网上说“高版本兼容”,这是血泪经验。
# 卸载系统自带openjdk sudo apt remove openjdk-* # 下载JDK11u(推荐Eclipse Temurin) wget https://github.com/adoptium/temurin11-binaries/releases/download/jdk-11.0.22%2B7/OpenJDK11U-jdk_x64_linux_hotspot_11.0.22_7.tar.gz tar -xzf OpenJDK11U-jdk_x64_linux_hotspot_11.0.22_7.tar.gz sudo mv jdk-11.0.22+7 /usr/lib/jvm/java-11-temurin sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-11-temurin/bin/java 1 java -version # 必须输出 openjdk version "11.0.22"第二步:MySQL8初始化(root密码设为Trs@2024)
注意:必须用mysql_native_password插件,否则Spring Boot连不上。
-- 登录MySQL后执行 ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'Trs@2024'; CREATE DATABASE trs_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'trs_user'@'%' IDENTIFIED BY 'TrsPass@2024'; GRANT ALL PRIVILEGES ON trs_db.* TO 'trs_user'@'%'; FLUSH PRIVILEGES;第三步:MinIO单机启动(不用分布式,够用)docker-compose.yml里MinIO服务已配好,但首次启动要手动建桶:
# 启动MinIO docker-compose up -d minio # 等30秒,然后创建桶(用mc命令行工具) wget https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc ./mc alias set myminio http://localhost:9000 minioadmin minioadmin ./mc mb myminio/2024-spring ./mc mb myminio/2024-autumn提示:
minioadmin/minioadmin是MinIO默认账号密码,生产环境务必改!改法:docker-compose exec minio /bin/sh -c "export MINIO_ROOT_PASSWORD=newpass && /usr/bin/minio server /data",但本文不展开。
4.2 编译与启动:跳过测试,直奔jar包
项目用Maven,但pom.xml里maven-surefire-plugin已禁用测试(<skipTests>true</skipTests>),因为单元测试没写——这是现实:教学系统开发周期紧,测试常被砍。我们直接编译:
# 进入项目根目录 cd teaching-resource-system # 清理并打包(跳过测试) mvn clean package -Dmaven.test.skip=true # 查看生成的jar(注意:是triple-fat-jar,含所有依赖) ls target/teaching-resource-system-0.0.1-SNAPSHOT.jar启动命令(关键参数不能少):
java -Xms512m -Xmx1024m \ -Dspring.profiles.active=prod \ -Dfile.encoding=UTF-8 \ -jar target/teaching-resource-system-0.0.1-SNAPSHOT.jar现象:控制台输出
Started TeachingResourceSystemApplication in X.XXX seconds即成功。若卡在Starting ProtocolHandler ["http-nio-8080"],大概率是MySQL连不上——检查application-prod.yml里spring.datasource.url是否为jdbc:mysql://host.docker.internal:3306/...(Docker内访问宿主机MySQL要用host.docker.internal,不是localhost)。
4.3 前端访问与首登:Vue2页面已内置,无需另起服务
后端jar包启动后,前端静态资源(src/main/resources/static/)已随jar打包。访问http://你的服务器IP:8080即可进入登录页。默认账号:
- 超级管理员:
admin/Admin@123 - 教师账号:
teacher01/Teacher@123 - 学生账号:
student01/Student@123
注意:首次登录后,系统会自动在
trs_user表插入三条记录,并分配角色(ROLE_ADMIN/ROLE_TEACHER/ROLE_STUDENT)。角色权限在SecurityConfig.java里硬编码,没用Spring Security ACL——教学系统角色固定,ACL反而增加复杂度。
4.4 避坑 / 常见问题 / 排查:五个让我重启三次的真问题
部署不是点点鼠标就完事。以下是我在三台不同环境(Mac M1、Windows WSL2、阿里云ECS)踩出的坑,按发生频率排序:
现象1:上传PPT后缩略图显示“空白”,但文件能正常下载
→ 原因:PptxThumbnailGenerator依赖java.awt.Graphics2D,而Docker容器默认无图形界面(headless mode),Graphics2D创建失败。
→ 解决:启动jar时加JVM参数-Djava.awt.headless=true(已写在启动脚本里),或改用Apache POI纯内存生成(需重写generateThumbnail()方法,替换BufferedImage为ByteArrayOutputStream)。
现象2:搜索资源时,输入“线性代数”搜不到,但搜“线性”可以
→ 原因:MySQL全文索引没建。trs_resource.title字段未加FULLTEXT(title),LIKE '%线性代数%'在大数据量下极慢且不准。
→ 解决:执行SQLALTER TABLE trs_resource ADD FULLTEXT(title);,并在ResourceMapper.xml的搜索SQL里改用MATCH(title) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)。
现象3:审核资源后,前端列表页仍显示“草稿”,刷新才变
→ 原因:approveResource()方法里清了resource:detail:id缓存,但没清列表缓存resource:list:xxx。列表页用@Cacheable(value = "resourceList", key = "#query.toCacheKey()"),key生成逻辑在ResourcePageQuery.java里。
→ 解决:在approveResource()末尾加redisTemplate.delete("resource:list:*"),或更精准地按courseCode清除(redisTemplate.delete("resource:list:" + courseCode))。
现象4:MinIO上传大视频(>500MB)时超时,报SocketTimeoutException
→ 原因:application-prod.yml里minio.connection-timeout默认是5秒,大文件上传需更久。
→ 解决:将minio:配置块改为:
minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket-name: 2024-spring connection-timeout: 30000 # 30秒 read-timeout: 300000 # 5分钟(大视频读取)现象5:Linux服务器上ffprobe命令找不到,视频解析失败
→ 原因:VideoMetadataExtractor用Runtime.getRuntime().exec("ffprobe ..."),但PATH环境变量没包含/usr/bin。
→ 解决:两种方式任选其一:
① 启动jar前执行export PATH=$PATH:/usr/bin;
② 在VideoMetadataExtractor.java里把"ffprobe"改成"/usr/bin/ffprobe"(硬编码路径,胜过环境变量)。
5. 二次开发实战:给题库模块加“智能组卷”功能,三步接入现有架构
如果你拿到这个项目,大概率不是为了跑通,而是要加功能——比如教务处提需求:“希望老师能按知识点、难度、题型自动组一套试卷”。这功能不难,但必须无缝融入现有体系。我以“智能组卷”为例,演示如何在不破坏原有结构的前提下扩展。
5.1 理解题库模块现状:EXAM类型资源的特殊性
先看ResourceType.EXAM的特殊处理:
- 上传时,
dto.getType() == 4,extractMetadata()会调用ExamMetadataExtractor; - 该类用正则解析
original_name(如《数据库原理》-第2章-选择题-难度3.md),提取subject、chapter、questionType、difficulty; - 解析结果存入
metadata_json,格式为:{"subject":"数据库原理","chapter":"第2章","questionType":"choice","difficulty":3}; - 所以,题库数据已结构化,只是没暴露查询接口。
关键发现:
trs_resource_version.metadata_json里存的是JSON字符串,但MyBatis Plus的@TableField没加@JsonFormat,所以ResourceVersion.getMetadataJson()返回的是String,不是Map。这意味着——所有题库查询必须走SQL的JSON函数,不能靠Java对象。
5.2 新增组卷接口:RESTful设计与SQL优化
新增ExamController.java,路径/api/v1/exam/paper,接收PaperGenerateDTO:
@Data public class PaperGenerateDTO { private String courseCode; // 课程代码,如CS101 private List<String> subjects; // 科目列表,如["数据库原理","操作系统"] private Integer totalQuestions; // 总题数,如20 private Map<String, Integer> typeWeight; // 题型权重,如{"choice":10,"fill":6,"essay":4} private Integer difficulty; // 难度,1~5,0表示不限 }核心SQL(写在ExamMapper.xml)利用MySQL 8.0的JSON函数:
<select id="selectExamQuestions" resultType="com.example.trs.entity.Resource"> SELECT r.* FROM trs_resource r INNER JOIN trs_resource_version v ON r.id = v.resource_id WHERE r.course_code = #{courseCode} AND r.type = 4 AND r.status = 1 AND ( <foreach collection="subjects" item="sub" separator="OR"> JSON_CONTAINS(v.metadata_json, '{"subject":"${sub}"}') </foreach> ) <if test="difficulty != null and difficulty != 0"> AND JSON_EXTRACT(v.metadata_json, '$.difficulty') = #{difficulty} </if> ORDER BY RAND() LIMIT #{totalQuestions} </select>注意:
JSON_CONTAINS比JSON_EXTRACT快,因为前者走索引(如果给metadata_json建了虚拟列索引)。但MySQL 8.0默认不支持JSON索引,所以这里用ORDER BY RAND()是权衡——题库数据量通常<10万,性能可接受。
5.3 组卷逻辑实现:用Java做“策略过滤”,而非SQL硬拼
ExamService.generatePaper(PaperGenerateDTO dto)不直接调SQL,而是分三步:
- 初筛:用上述SQL查出所有匹配题目的
Resource列表(最多取totalQuestions * 3,防权重不够); - 权重分配:遍历列表,按
typeWeight统计各题型数量,动态调整; - 难度校准:若
difficulty指定,剔除不符题;若未指定,按difficulty字段分组,随机抽样保持分布均衡。
public List<Resource> generatePaper(PaperGenerateDTO dto) { // 步骤1:初筛(SQL已写好) List<Resource> candidates = examMapper.selectExamQuestions(dto); // 步骤2:按题型权重分配(typeWeight是Map<String,Integer>) Map<String, Integer> needCount = new HashMap<>(dto.getTypeWeight()); List<Resource> paper = new ArrayList<>(); for (Resource res : candidates) { String type = extractQuestionType(res); // 从metadata_json取"questionType" if (needCount.getOrDefault(type, 0) > 0) { paper.add(res); needCount.put(type, needCount.get(type) - 1); if (paper.size() >= dto.getTotalQuestions()) break; } } // 步骤3:若数量不够,从剩余candidates里随机补足(忽略权重) if (paper.size() < dto.getTotalQuestions()) { List<Resource> rest = candidates.stream() .filter(r -> !paper.contains(r)) .collect(Collectors.toList()); Collections.shuffle(rest); int need = dto.getTotalQuestions() - paper.size(); paper.addAll(rest.subList(0, Math.min(need, rest.size()))); } return paper; }这个逻辑的好处:SQL只负责“找出来”,Java负责“挑出来”,解耦清晰。即使未来要加“知识点去重”(同一知识点不出现两题),只需在步骤3加
!paper.stream().anyMatch(...)判断,不动SQL。
5.4 前端集成:复用现有Vue2组件,只改API调用
前端src/static/js/api/exam.js新增:
export function generatePaper(data) { return request({ url: '/api/v1/exam/paper', method: 'post', data }) }在ExamGenerate.vue组件里调用:
<template> <el-button @click="handleGenerate">智能组卷</el-button> <div v-for="q in paperQuestions" :key="q.id"> <h3>{{ q.title }}</h3> <p>题型:{{ getQuestionType(q) }} | 难度:{{ getDifficulty(q) }}</p> </div> </template> <script> import { generatePaper } from '@/api/exam' export default { methods: { async handleGenerate() { const dto = { courseCode: this.courseCode, subjects: ['数据库原理'], totalQuestions: 20, typeWeight: {'choice':10,'fill':6,'essay':4}, difficulty: 3 } const res = await generatePaper(dto) this.paperQuestions = res.data // 后端返回Resource列表 } } } </script>注意:
res.data是Resource对象数组,字段和原有资源一致,所以前端展示逻辑(标题、缩略图、下载按钮)完全复用,不用重写。
从那以后我每次接教学系统需求,都强制走一遍“查metadata_json字段是否已结构化”——90%的新功能,其实数据早就在那里了,只是没暴露查询接口。这个项目教会我的不是Java语法,而是怎么把业务规则翻译成可落地的代码契约:版本号不是数字,是责任边界;审核不是按钮,是状态机;题库不是文件,是带标签的JSON。希望帮到你。
本文还有配套的精品资源,点击获取