diff --git a/src/main/java/com/budwk/app/base/utils/OfficePlusUtil.java b/src/main/java/com/budwk/app/base/utils/OfficePlusUtil.java index bc8dd8c..23c265d 100644 --- a/src/main/java/com/budwk/app/base/utils/OfficePlusUtil.java +++ b/src/main/java/com/budwk/app/base/utils/OfficePlusUtil.java @@ -3,6 +3,7 @@ package com.budwk.app.base.utils; import cn.hutool.core.io.FileUtil; import com.aspose.cells.PdfSaveOptions; import com.aspose.cells.Workbook; +import com.aspose.slides.Presentation; import com.aspose.words.Document; import com.aspose.words.SaveFormat; import com.budwk.app.base.exception.BaseException; @@ -30,11 +31,34 @@ public class OfficePlusUtil { wordConvertPdf(sourcePath, targetPath); } else if (suffix.equals("xls") || suffix.equals("xlsx")) { excelConvertPdf(sourcePath, targetPath); + } else if (suffix.equalsIgnoreCase("ppt") || suffix.equalsIgnoreCase("pptx")) { + presentationConvertPdf(sourcePath, targetPath); } else { throw new BaseException("不支持的文档格式"); } } + /** + * PPT演示文稿转PDF + * + * @param sourcePath 源文件路径 + * @param targetPath 目标文件路径 + */ + public static void presentationConvertPdf(String sourcePath, String targetPath) { + Presentation presentation = null; + try { + presentation = new Presentation(sourcePath); + presentation.save(targetPath, com.aspose.slides.SaveFormat.Pdf); + } catch (Exception e) { + log.info("转换pdf报错", e); + throw new BaseException("PPT转换PDF失败"); + } finally { + if (presentation != null) { + presentation.dispose(); + } + } + } + /** * excel转pdf * diff --git a/src/main/java/com/budwk/app/sys/controller/SysFileController.java b/src/main/java/com/budwk/app/sys/controller/SysFileController.java index ec974c9..6b0e082 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysFileController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysFileController.java @@ -2,6 +2,7 @@ package com.budwk.app.sys.controller; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import com.budwk.app.base.annotation.SLog; import com.budwk.app.base.page.Pagination; @@ -11,8 +12,12 @@ import com.budwk.app.sys.enums.SysFileEngineTypeEnum; import com.budwk.app.sys.models.Sys_file; import com.budwk.app.sys.services.SysConfigService; import com.budwk.app.sys.services.SysFileService; +import com.budwk.app.sys.utils.SysFileMinIoUtil; +import com.google.common.net.HttpHeaders; +import com.budwk.app.zhgh.learning.service.LearningFileAccessService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; import org.nutz.dao.Cnd; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; @@ -23,12 +28,14 @@ import org.nutz.mvc.upload.UploadAdaptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.io.OutputStream; import java.util.List; @IocBean @At("/platform/sys/file") @Ok("json:full") @Api(tags = "文件管理") +@Slf4j public class SysFileController { @Inject @@ -37,6 +44,9 @@ public class SysFileController { @Inject private SysConfigService sysConfigService; + @Inject + private LearningFileAccessService learningFileAccessService; + @At("") @Ok("beetl:/platform/sys/file/index.html") @SaCheckPermission("sys.manager.file") @@ -95,6 +105,7 @@ public class SysFileController { @Ok("void") @SaCheckLogin public void download(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!learningFileAccessService.check(id, true, response)) return; sysFileService.download(id, request, response); } @@ -103,6 +114,7 @@ public class SysFileController { @SaCheckLogin @ApiOperation("文件管理-转换PDF") public void convertPDF(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!learningFileAccessService.check(id, true, response)) return; sysFileService.convertPDF(id, request, response); } @@ -142,4 +154,135 @@ public class SysFileController { } + @At + @Ok("void") + @SaCheckLogin + public void preview(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!learningFileAccessService.check(id, false, response)) return; + Sys_file file = sysFileService.detail(id); + String contentType = switch (StrUtil.blankToDefault(file.getSuffix(), "").toLowerCase()) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "bmp" -> "image/bmp"; + case "mp3" -> "audio/mpeg"; + case "aac" -> "audio/aac"; + case "wav" -> "audio/wav"; + case "ogg" -> "audio/ogg"; + case "m4a" -> "audio/mp4"; + case "pdf" -> "application/pdf"; + default -> null; + }; + if (contentType == null) { + response.sendError(415, "该资料不支持在线预览"); + return; + } + byte[] bytes = SysFileEngineTypeEnum.LOCAL.getValue().equals(file.getEngine()) + ? FileUtil.readBytes(file.getStoragePath()) + : SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); + response.setContentType(contentType); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline"); + if (contentType.startsWith("audio/")) { + response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); + String range = request.getHeader(HttpHeaders.RANGE); + if (StrUtil.isNotBlank(range) && range.startsWith("bytes=") && !range.contains(",")) { + long start; + long end; + try { + String specification = range.substring(6).trim(); + if (!specification.matches("[0-9]*-[0-9]*")) throw new NumberFormatException(); + String[] bounds = specification.split("-", -1); + if (bounds[0].isEmpty()) { + long length = Long.parseLong(bounds[1]); + if (length <= 0) throw new NumberFormatException(); + start = Math.max(0, bytes.length - length); + end = bytes.length - 1; + } else { + start = Long.parseLong(bounds[0]); + end = bounds[1].isEmpty() ? bytes.length - 1 : Math.min(Long.parseLong(bounds[1]), bytes.length - 1); + } + if (start >= bytes.length || end < start) throw new NumberFormatException(); + } catch (NumberFormatException e) { + response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length); + response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + return; + } + int length = (int) (end - start + 1); + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + bytes.length); + response.setContentLength(length); + try (OutputStream out = response.getOutputStream()) { + out.write(bytes, (int) start, length); + } + return; + } + } + response.setContentLength(bytes.length); + try (OutputStream out = response.getOutputStream()) { + out.write(bytes); + } + } + + @At + @SaCheckLogin + @ApiOperation("文件管理-视频播放") + @Ok("void") + public void videoPlay(String id, HttpServletRequest request, HttpServletResponse response) throws IOException { + if (!learningFileAccessService.check(id, false, response)) return; + Sys_file sys_file = sysFileService.detail(id); + byte[] bytes = SysFileMinIoUtil.getFileBytes(sys_file.getBucket(), sys_file.getStoragePath()); + + + // 设置响应头 + String fileName = sys_file.getName(); + String contentType = null; + + switch (FileUtil.extName(fileName).toLowerCase()) { + case "mp4" -> contentType = "video/mp4"; + case "avi" -> contentType = "video/x-msvideo"; + case "mkv" -> contentType = "video/x-matroska"; + case "mov" -> contentType = "video/quicktime"; + case "wmv" -> contentType = "video/x-ms-wmv"; + default -> contentType = "application/octet-stream"; + } + + response.setContentType(contentType); + response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); + + String rangeHeader = request.getHeader(HttpHeaders.RANGE); + if (StrUtil.isNotBlank(rangeHeader) && rangeHeader.startsWith("bytes=")) { + // 处理Range请求 + String[] ranges = rangeHeader.substring(6).split("-"); + long start = StrUtil.isNotBlank(ranges[0]) ? Long.parseLong(ranges[0]) : 0; + long end = (ranges.length > 1 && StrUtil.isNotBlank(ranges[1])) ? + Long.parseLong(ranges[1]) : bytes.length - 1; + + if (start >= bytes.length || end >= bytes.length) { + response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + bytes.length); + response.setStatus(416); + return; + } + + int contentLength = (int) (end - start + 1); + response.setStatus(206); + response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)); + response.setHeader(HttpHeaders.CONTENT_RANGE, StrUtil.format("bytes {}-{}/{}", start, end, bytes.length)); + + try (OutputStream out = response.getOutputStream()) { + out.write(bytes, (int) start, contentLength); + } catch (IOException e) { + log.error("视频播放失败", e); + } + } else { + // 非Range请求 + response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length)); + try (OutputStream out = response.getOutputStream()) { + out.write(bytes); + } catch (IOException e) { + log.error("视频播放失败", e); + } + } + } + } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java index d82cd90..b610b45 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysFileServiceImpl.java @@ -147,7 +147,11 @@ public class SysFileServiceImpl extends BaseServiceImpl implements Sys @Override public Sys_file detail(String id) { - return null; + Sys_file file = this.fetch(id); + if (file == null) { + throw new BaseException("文件记录不存在"); + } + return file; } @Override diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningChapterContentController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningChapterContentController.java new file mode 100644 index 0000000..fcaee15 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningChapterContentController.java @@ -0,0 +1,410 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.learning.models.LearningCourse; +import com.budwk.app.zhgh.learning.models.LearningCourseOutline; +import com.budwk.app.zhgh.learning.models.LearningCourseType; +import com.budwk.app.zhgh.learning.models.LearningOutlineResource; +import com.budwk.app.zhgh.learning.models.LearningStudyRule; +import com.budwk.app.zhgh.learning.service.LearningResourceProgressService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.pager.Pager; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-章节内容管理") +@At("/platform/learning/chapter/content") +public class LearningChapterContentController { + + @Inject + private Dao dao; + + @Inject + private LearningResourceProgressService resourceProgressService; + + @At("") + @Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html") + @SaCheckPermission("learning.chapter.content") + public void index() { + } + + @At + @ApiOperation("课程列表") + @SaCheckPermission("learning.chapter.content") + public Result courses(String courseId, String courseName, String courseTypeId, String lecturerName, String status) { + Cnd cnd = Cnd.NEW(); + cnd.andEX("id", "=", courseId); + cnd.and(Cnd.likeEX("courseName", courseName)); + cnd.andEX("courseTypeId", "=", courseTypeId); + cnd.and(Cnd.likeEX("lecturerName", lecturerName)); + cnd.andEX("status", "=", status); + cnd.desc("createdAt"); + return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50))); + } + + @At + @ApiOperation("课程类型") + @SaCheckPermission("learning.chapter.content") + public Result courseTypes() { + return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt"))); + } + + @At + @ApiOperation("课程大纲树") + @SaCheckPermission("learning.chapter.content") + public Result tree(String courseId) { + if (StrUtil.isBlank(courseId)) { + return Result.success(List.of()); + } + List outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).asc("sortOrder").asc("createdAt")); + Map> childrenMap = new HashMap<>(); + List roots = new ArrayList<>(); + for (LearningCourseOutline outline : outlines) { + NutMap map = NutMap.NEW() + .addv("id", outline.getId()) + .addv("courseId", outline.getCourseId()) + .addv("courseName", outline.getCourseName()) + .addv("parentId", outline.getParentId()) + .addv("nodeType", outline.getNodeType()) + .addv("title", outline.getTitle()) + .addv("subtitle", outline.getSubtitle()) + .addv("description", outline.getDescription()) + .addv("sortOrder", outline.getSortOrder()) + .addv("required", outline.getRequired()) + .addv("status", outline.getStatus()) + .addv("children", new ArrayList<>()); + String parentId = StrUtil.blankToDefault(outline.getParentId(), ""); + childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(map); + } + roots.addAll(childrenMap.getOrDefault("", new ArrayList<>())); + appendChildren(roots, childrenMap); + return Result.success(roots); + } + + private void appendChildren(List nodes, Map> childrenMap) { + for (NutMap node : nodes) { + List children = childrenMap.getOrDefault(node.getString("id"), new ArrayList<>()); + node.put("children", children); + appendChildren(children, childrenMap); + } + } + + @At + @ApiOperation("新增/编辑大纲节点") + @SaCheckPermission("learning.chapter.content") + @SLog(tag = "保存大纲节点", msg = "节点标题:${args[0].title}") + public Result saveNode(LearningCourseOutline outline) { + Result checkResult = checkNode(outline); + if (checkResult != null) { + return checkResult; + } + LearningCourse course = dao.fetch(LearningCourse.class, outline.getCourseId()); + outline.setCourseName(course == null ? outline.getCourseName() : course.getCourseName()); + if ("chapter".equals(outline.getNodeType())) { + outline.setParentId(""); + } + if (outline.getRequired() == null) { + outline.setRequired(false); + } + if (StrUtil.isBlank(outline.getStatus())) { + outline.setStatus("enabled"); + } + if (outline.getSortOrder() == null) { + outline.setSortOrder(nextNodeSort(outline.getCourseId(), outline.getParentId())); + } + if (StrUtil.isBlank(outline.getId())) { + dao.insert(outline); + } else { + dao.updateIgnoreNull(outline); + } + ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), outline.getStatus()); + resourceProgressService.refreshOutline(outline.getId()); + return Result.success(); + } + + @At + @ApiOperation("删除大纲节点") + @SaCheckPermission("learning.chapter.content") + @SLog(tag = "删除大纲节点", msg = "节点ID:${args[0]}") + public Result deleteNode(String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要删除的数据"); + } + int childCount = dao.count(LearningCourseOutline.class, Cnd.where("parentId", "=", id)); + int resourceCount = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", id)); + if (childCount > 0 || resourceCount > 0) { + return Result.error("该节点下存在子节点或学习资料,请先删除后再操作"); + } + dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "outline")); + dao.clear(LearningCourseOutline.class, Cnd.where("id", "=", id)); + return Result.success(); + } + + @At + @ApiOperation("启用/禁用大纲节点") + @SaCheckPermission("learning.chapter.content") + public Result toggleNode(String id, String status) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要操作的数据"); + } + dao.update(LearningCourseOutline.class, Chain.make("status", status), Cnd.where("id", "=", id)); + dao.update(LearningStudyRule.class, Chain.make("status", status), Cnd.where("targetId", "=", id).and("targetType", "=", "outline")); + resourceProgressService.refreshOutline(id); + return Result.success(); + } + + @At + @ApiOperation("移动大纲节点") + @SaCheckPermission("learning.chapter.content") + public Result moveNode(String id, String direction) { + LearningCourseOutline current = dao.fetch(LearningCourseOutline.class, id); + if (current == null) { + return Result.error("节点不存在"); + } + Cnd cnd = Cnd.where("courseId", "=", current.getCourseId()).and("parentId", "=", StrUtil.blankToDefault(current.getParentId(), "")); + if ("up".equals(direction)) { + cnd.and("sortOrder", "<", current.getSortOrder()).desc("sortOrder"); + } else { + cnd.and("sortOrder", ">", current.getSortOrder()).asc("sortOrder"); + } + LearningCourseOutline target = dao.fetch(LearningCourseOutline.class, cnd); + if (target == null) { + return Result.success(); + } + Integer currentSort = current.getSortOrder(); + dao.update(LearningCourseOutline.class, Chain.make("sortOrder", target.getSortOrder()), Cnd.where("id", "=", current.getId())); + dao.update(LearningCourseOutline.class, Chain.make("sortOrder", currentSort), Cnd.where("id", "=", target.getId())); + return Result.success(); + } + + @At + @ApiOperation("学习资料分页") + @SaCheckPermission("learning.chapter.content") + public Result resourcePage(PageForm pageForm, String outlineId) { + if (StrUtil.isBlank(outlineId)) { + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), 0, List.of())); + } + Cnd cnd = Cnd.where("outlineId", "=", outlineId); + String orderColumn = getResourceOrderColumn(pageForm.getPageOrderName()); + if (StrUtil.isBlank(orderColumn)) { + cnd.asc("sortOrder").asc("createdAt"); + } else if ("descending".equals(pageForm.getPageOrderBy())) { + cnd.desc(orderColumn); + } else { + cnd.asc(orderColumn); + } + int count = dao.count(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId)); + Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningOutlineResource.class, cnd, pager))); + } + + @At + @ApiOperation("新增/编辑学习资料") + @SaCheckPermission("learning.chapter.content") + @SLog(tag = "保存学习资料", msg = "资料标题:${args[0].resourceTitle}") + public Result saveResource(LearningOutlineResource resource) { + Result checkResult = checkResource(resource); + if (checkResult != null) { + return checkResult; + } + if (resource.getRequired() == null) { + resource.setRequired(false); + } + if (Boolean.TRUE.equals(resource.getRequired()) || resource.getAllowPreview() == null) { + resource.setAllowPreview(true); + } + if (resource.getAllowDownload() == null) { + resource.setAllowDownload(false); + } + if (StrUtil.isBlank(resource.getStatus())) { + resource.setStatus("enabled"); + } + if (resource.getSortOrder() == null) { + resource.setSortOrder(nextResourceSort(resource.getOutlineId())); + } + if (StrUtil.isBlank(resource.getId())) { + dao.insert(resource); + } else { + dao.updateIgnoreNull(resource); + } + ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus()); + resourceProgressService.refreshOutline(resource.getOutlineId()); + return Result.success(); + } + + @At + @ApiOperation("删除学习资料") + @SaCheckPermission("learning.chapter.content") + @SLog(tag = "删除学习资料", msg = "资料ID:${args[0]}") + public Result deleteResource(String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要删除的数据"); + } + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, id); + dao.clear(LearningStudyRule.class, Cnd.where("targetId", "=", id).and("targetType", "=", "resource")); + dao.clear(LearningOutlineResource.class, Cnd.where("id", "=", id)); + if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId()); + return Result.success(); + } + + @At + @ApiOperation("查询学习规则") + @SaCheckPermission("learning.chapter.content") + public Result getRule(String courseId, String targetType, String targetId) { + LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId)); + if (rule == null) { + rule = defaultRule(courseId, targetType, targetId, false, "enabled"); + } + if ("manual".equals(rule.getCompletionRule())) rule.setCompletionRule("all_required_resource"); + return Result.success(rule); + } + + @At + @ApiOperation("保存学习规则") + @SaCheckPermission("learning.chapter.content") + public Result saveRule(LearningStudyRule rule) { + if (StrUtil.isBlank(rule.getCourseId()) || StrUtil.isBlank(rule.getTargetType()) || StrUtil.isBlank(rule.getTargetId())) { + return Result.error("规则对象不能为空"); + } + if (StrUtil.isBlank(rule.getStatus())) { + rule.setStatus("enabled"); + } + if (!List.of("all_required_resource", "resource_progress", "min_study_time").contains( + rule.getCompletionRule() == null ? "" : rule.getCompletionRule())) { + return Result.error("??????????"); + } + if ("min_study_time".equals(rule.getCompletionRule()) + && (rule.getMinStudySeconds() == null || rule.getMinStudySeconds() <= 0)) { + return Result.error("??????????0"); + } + if ("resource_progress".equals(rule.getCompletionRule()) + && (rule.getCompletePercent() == null || rule.getCompletePercent() <= 0 || rule.getCompletePercent() > 100)) { + return Result.error("????????0????100"); + } + LearningStudyRule old = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", rule.getCourseId()).and("targetType", "=", rule.getTargetType()).and("targetId", "=", rule.getTargetId())); + if (old == null) { + dao.insert(rule); + } else { + rule.setId(old.getId()); + dao.updateIgnoreNull(rule); + } + if ("outline".equals(rule.getTargetType())) { + dao.update(LearningCourseOutline.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId())); + resourceProgressService.refreshOutline(rule.getTargetId()); + } + if ("resource".equals(rule.getTargetType())) { + Chain resourceChain = Chain.make("required", rule.getRequired()).add("status", rule.getStatus()); + if (Boolean.TRUE.equals(rule.getRequired())) resourceChain.add("allowPreview", true); + dao.update(LearningOutlineResource.class, resourceChain, Cnd.where("id", "=", rule.getTargetId())); + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, rule.getTargetId()); + if (resource != null) resourceProgressService.refreshOutline(resource.getOutlineId()); + } + return Result.success(); + } + + private Result checkNode(LearningCourseOutline outline) { + if (outline == null || StrUtil.isBlank(outline.getCourseId())) { + return Result.error("请选择课程"); + } + if (StrUtil.isBlank(outline.getNodeType())) { + return Result.error("请选择节点类型"); + } + if (StrUtil.isBlank(outline.getTitle())) { + return Result.error("标题不能为空"); + } + if ("section".equals(outline.getNodeType()) && StrUtil.isBlank(outline.getParentId())) { + return Result.error("新增节需要选择所属章"); + } + return null; + } + + private Result checkResource(LearningOutlineResource resource) { + if (resource == null || StrUtil.isBlank(resource.getCourseId()) || StrUtil.isBlank(resource.getOutlineId())) { + return Result.error("请选择章/节节点"); + } + if (StrUtil.isBlank(resource.getResourceTitle())) { + return Result.error("资料标题不能为空"); + } + if (StrUtil.isBlank(resource.getResourceType())) { + return Result.error("请选择资料类型"); + } + if (StrUtil.isBlank(resource.getFileData())) { + return Result.error("请上传附件"); + } + return null; + } + + private Integer nextNodeSort(String courseId, String parentId) { + LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("parentId", "=", StrUtil.blankToDefault(parentId, "")).desc("sortOrder")); + return outline == null || outline.getSortOrder() == null ? 1 : outline.getSortOrder() + 1; + } + + private Integer nextResourceSort(String outlineId) { + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, Cnd.where("outlineId", "=", outlineId).desc("sortOrder")); + return resource == null || resource.getSortOrder() == null ? 1 : resource.getSortOrder() + 1; + } + + private void ensureRule(String courseId, String targetType, String targetId, Boolean required, String status) { + LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", targetType).and("targetId", "=", targetId)); + if (rule == null) { + dao.insert(defaultRule(courseId, targetType, targetId, required, status)); + } else { + dao.update(LearningStudyRule.class, + Chain.make("required", required != null && required).add("status", StrUtil.blankToDefault(status, "enabled")), + Cnd.where("id", "=", rule.getId())); + } + } + + private LearningStudyRule defaultRule(String courseId, String targetType, String targetId, Boolean required, String status) { + LearningStudyRule rule = new LearningStudyRule(); + rule.setCourseId(courseId); + rule.setTargetType(targetType); + rule.setTargetId(targetId); + rule.setRequired(required != null && required); + rule.setStudyMode("mixed"); + rule.setCompletionRule("all_required_resource"); + rule.setCompletePercent(90); + rule.setMinStudySeconds(0); + rule.setUnlockRule("free"); + rule.setAllowSkip(false); + rule.setAllowDrag(true); + rule.setPauseCountTime(false); + rule.setHiddenCountTime(false); + rule.setInactiveCountTime(false); + rule.setStatus(StrUtil.blankToDefault(status, "enabled")); + return rule; + } + + private String getResourceOrderColumn(String prop) { + Map columns = new HashMap<>(); + columns.put("resourceTitle", "resourceTitle"); + columns.put("resourceType", "resourceType"); + columns.put("fileExt", "fileExt"); + columns.put("durationSeconds", "durationSeconds"); + columns.put("sortOrder", "sortOrder"); + columns.put("required", "required"); + columns.put("allowPreview", "allowPreview"); + columns.put("allowDownload", "allowDownload"); + columns.put("status", "status"); + return columns.get(prop); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseDisplayController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseDisplayController.java new file mode 100644 index 0000000..6053b18 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseDisplayController.java @@ -0,0 +1,380 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.CharsetUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.base.utils.OfficePlusUtil; +import com.budwk.app.sys.enums.SysFileEngineTypeEnum; +import com.budwk.app.sys.models.Sys_file; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import com.budwk.app.sys.utils.SysFileMinIoUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.learning.models.LearningCourseOutline; +import com.budwk.app.zhgh.learning.models.LearningCourseType; +import com.budwk.app.zhgh.learning.models.LearningOutlineResource; +import com.budwk.app.zhgh.learning.models.LearningStudyRecord; +import com.budwk.app.zhgh.learning.models.LearningStudyRule; +import com.budwk.app.zhgh.learning.service.LearningFileAccessService; +import com.budwk.app.zhgh.learning.service.LearningResourceProgressService; +import com.google.common.net.HttpHeaders; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-课程展示") +@At("/platform/learning/course/display") +public class LearningCourseDisplayController { + + @Inject + private Dao dao; + + @Inject + private SysDictService sysDictService; + + @Inject + private LearningFileAccessService learningFileAccessService; + + @Inject + private LearningResourceProgressService resourceProgressService; + + @At("") + @Ok("beetl:/platform/zhgh/Learning/courseDisplay/indexV2.html") + @SaCheckPermission("learning.course.display") + public void index() { + } + + @At("/detail") + @Ok("beetl:/platform/zhgh/Learning/courseDisplay/detail.html") + @SaCheckPermission("learning.course.display") + public void detail() { + } + + @At("/study") + @Ok("beetl:/platform/zhgh/Learning/courseDisplay/studyV2.html") + @SaCheckPermission("learning.course.display") + public void study() { + } + + @At + @ApiOperation("课程展示列表") + @SaCheckPermission("learning.course.display") + /** + * 查询课程展示列表。 + * pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选; + * sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。 + */ + public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType, String excludeCourseId) { + Cnd cnd = Cnd.where("c.status", "=", "published"); + if (StrUtil.isNotBlank(keyword)) { + cnd.and(Cnd.exps("c.courseName", "like", "%" + keyword + "%") + .or("c.courseIntro", "like", "%" + keyword + "%")); + } + cnd.andEX("c.courseTypeId", "=", courseTypeId); + cnd.andEX("c.id", "!=", excludeCourseId); + if (StrUtil.isNotBlank(recommendFlag)) { + cnd.and(Cnd.exps("c.recommendFlags", "=", recommendFlag) + .or("c.recommendFlags", "like", recommendFlag + ",%") + .or("c.recommendFlags", "like", "%," + recommendFlag) + .or("c.recommendFlags", "like", "%," + recommendFlag + ",%")); + } + + Sql countSql = Sqls.create(""" + SELECT COUNT(1) + FROM learning_course c + LEFT JOIN learning_course_type t ON t.id = c.courseTypeId + $condition + """); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + int count = countSql.getInt(); + + Sql listSql = Sqls.create(""" + SELECT c.id, c.courseName, c.courseIntro, c.startTime, c.endTime, c.openType, c.cover, + c.recommendFlags, c.courseTypeId, c.lecturerName, c.sortNum, + COALESCE(c.viewCount, 0) AS viewCount, t.typeName AS courseTypeName, + COALESCE((SELECT ROUND(AVG(IFNULL(sr.progressPercent, 0))) + FROM learning_study_record sr + WHERE sr.courseId = c.id AND sr.userId = @userId + AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = sr.outlineId + AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1)), 0) AS progressPercent, + (SELECT COUNT(1) FROM learning_outline_resource r + WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount, + COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r + WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds + FROM learning_course c + LEFT JOIN learning_course_type t ON t.id = c.courseTypeId + $condition + ORDER BY $orderBy + """); + listSql.setCondition(cnd); + listSql.params().set("userId", SecurityUtil.getUserId()); + // 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。 + listSql.setVar("orderBy", "random".equals(sortType) ? "RAND()" : "view_count".equals(sortType) + ? "COALESCE(c.viewCount, 0) DESC, c.sortNum ASC, c.createdAt DESC" + : "c.sortNum ASC, c.createdAt DESC"); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + List rows = listSql.getList(NutMap.class); + rows.forEach(row -> { + row.putAll(resourceProgressService.courseState(row.getString("id"), SecurityUtil.getUserId())); + Object startTime = row.get("startTime"); + Object endTime = row.get("endTime"); + row.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime))); + row.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime))); + }); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, rows)); + } + + @At + @ApiOperation("课程学习基础信息") + @SaCheckPermission("learning.course.display") + /** + * 查询学习页课程信息。 + * id 传课程主键;返回 NutMap,包含课程介绍、讲师、分类、课节数、总时长和后台配置的观看次数。 + */ + public Result courseInfo(String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择课程"); + } + // 学习页复用课程配置和资源统计数据,观看次数由课程管理后台配置,不在访问时自动累加。 + Sql sql = Sqls.create(""" + SELECT c.id, c.courseName, c.courseIntro, c.suitablePeople, c.learningGoal, c.coursePeriod, + c.courseTags, c.startTime, c.endTime, c.openType, c.cover, c.recommendFlags, + c.lecturerName, c.lecturerInfo, COALESCE(c.viewCount, 0) AS viewCount, + t.typeName AS courseTypeName, + (SELECT COUNT(1) FROM learning_outline_resource r + WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1) AS resourceCount, + COALESCE((SELECT SUM(r.durationSeconds) FROM learning_outline_resource r + WHERE r.courseId = c.id AND r.status = 'enabled' AND COALESCE(r.allowPreview, 1) = 1), 0) AS totalDurationSeconds + FROM learning_course c + LEFT JOIN learning_course_type t ON t.id = c.courseTypeId + WHERE c.id = @id + """); + sql.params().set("id", id); + sql.setCallback(Sqls.callback.map()); + dao.execute(sql); + NutMap course = sql.getObject(NutMap.class); + if (course == null) { + return Result.error("课程不存在"); + } + Object startTime = course.get("startTime"); + Object endTime = course.get("endTime"); + course.put("startTimeText", startTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) startTime))); + course.put("endTimeText", endTime == null ? "" : DateUtil.formatDateTime(DateUtil.date((java.util.Date) endTime))); + course.putAll(resourceProgressService.courseState(id, SecurityUtil.getUserId())); + return Result.success(course); + } + + @At + @ApiOperation("课程学习安排") + @SaCheckPermission("learning.course.display") + public Result studyTree(String courseId) { + if (StrUtil.isBlank(courseId)) { + return Result.success(List.of()); + } + List outlines = dao.query(LearningCourseOutline.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt")); + List resources = dao.query(LearningOutlineResource.class, Cnd.where("courseId", "=", courseId).and("status", "=", "enabled").asc("sortOrder").asc("createdAt")); + List records = dao.query(LearningStudyRecord.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId())); + List outlineRules = dao.query(LearningStudyRule.class, Cnd.where("courseId", "=", courseId).and("targetType", "=", "outline").and("status", "=", "enabled")); + Map> childrenMap = new HashMap<>(); + List roots = new ArrayList<>(); + Map recordMap = new HashMap<>(); + Map outlineRuleMap = new HashMap<>(); + for (LearningStudyRecord record : records) { + recordMap.put(record.getOutlineId(), record); + } + for (LearningStudyRule outlineRule : outlineRules) { + outlineRuleMap.put(outlineRule.getTargetId(), outlineRule); + } + + Map outlineNodeMap = new HashMap<>(); + for (LearningCourseOutline outline : outlines) { + List children = new ArrayList<>(); + NutMap node = NutMap.NEW() + .addv("id", outline.getId()) + .addv("type", "outline") + .addv("nodeType", outline.getNodeType()) + .addv("title", outline.getTitle()) + .addv("description", outline.getDescription()) + .addv("required", outline.getRequired()) + .addv("progressPercent", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getProgressPercent() : 0) + .addv("studySeconds", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getStudySeconds() : 0) + .addv("lastPositionSeconds", recordMap.containsKey(outline.getId()) ? value(recordMap.get(outline.getId()).getLastPositionSeconds()) : 0) + .addv("completeStatus", recordMap.containsKey(outline.getId()) ? recordMap.get(outline.getId()).getCompleteStatus() : "not_started") + .addv("children", children); + node.putAll(resourceProgressService.outlineState(recordMap.get(outline.getId()), outline)); + outlineNodeMap.put(outline.getId(), node); + childrenMap.put(outline.getId(), children); + } + for (LearningCourseOutline outline : outlines) { + NutMap node = outlineNodeMap.get(outline.getId()); + if (StrUtil.isBlank(outline.getParentId())) { + roots.add(node); + } else { + childrenMap.computeIfAbsent(outline.getParentId(), key -> new ArrayList<>()).add(node); + } + } + for (LearningOutlineResource resource : resources) { + // 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。 + LearningStudyRule outlineRule = outlineRuleMap.get(resource.getOutlineId()); + boolean allowDrag = outlineRule == null || !Boolean.FALSE.equals(outlineRule.getAllowDrag()); + NutMap resourceState = resourceProgressService.state(recordMap.get(resource.getOutlineId()), resource); + NutMap node = NutMap.NEW() + .addv("id", resource.getId()) + .addv("type", "resource") + .addv("outlineId", resource.getOutlineId()) + .addv("title", resource.getResourceTitle()) + .addv("resourceType", resource.getResourceType()) + .addv("studied", resourceState.getBoolean("studied", false)) + .addv("fileExt", resource.getFileExt()) + .addv("fileData", !Boolean.FALSE.equals(resource.getAllowPreview()) || Boolean.TRUE.equals(resource.getAllowDownload()) ? resource.getFileData() : null) + .addv("allowPreview", !Boolean.FALSE.equals(resource.getAllowPreview())) + .addv("allowDownload", Boolean.TRUE.equals(resource.getAllowDownload())) + .addv("durationSeconds", resource.getDurationSeconds()) + .addv("required", resource.getRequired()) + .addv("progressPercent", resourceState.getInt("progressPercent", 0)) + .addv("studySeconds", resourceState.getInt("studySeconds", 0)) + .addv("lastPositionSeconds", resourceState.getInt("lastPositionSeconds", 0)) + .addv("maxPositionSeconds", resourceState.getInt("maxPositionSeconds", 0)) + .addv("allowDrag", allowDrag) + .addv("completeStatus", resourceState.getString("completeStatus", "not_started")); + childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node); + } + return Result.success(roots); + } + + @At + @Ok("void") + @ApiOperation("PDF内联预览") + @SaCheckPermission("learning.course.display") + public void pdfPreview(String id, HttpServletResponse response) throws IOException { + if (!learningFileAccessService.check(id, false, response)) return; + if (StrUtil.isBlank(id)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "文件ID不能为空"); + return; + } + Sys_file file = dao.fetch(Sys_file.class, id); + if (file == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在"); + return; + } + byte[] bytes; + if (SysFileEngineTypeEnum.MINIO.getValue().equals(file.getEngine())) { + bytes = SysFileMinIoUtil.getFileBytes(file.getBucket(), file.getStoragePath()); + } else { + File localFile = FileUtil.file(file.getStoragePath()); + if (!FileUtil.exist(localFile)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在"); + return; + } + bytes = IoUtil.readBytes(FileUtil.getInputStream(localFile)); + } + if (!"pdf".equalsIgnoreCase(file.getSuffix())) { + File sourceFile = File.createTempFile("learning_preview_origin", "." + file.getSuffix()); + File pdfFile = File.createTempFile("learning_preview", ".pdf"); + try { + Files.write(sourceFile.toPath(), bytes, StandardOpenOption.WRITE); + OfficePlusUtil.convert(sourceFile.getPath(), pdfFile.getPath()); + if (!FileUtil.exist(pdfFile) || pdfFile.length() == 0) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件转换PDF失败"); + return; + } + bytes = IoUtil.readBytes(FileUtil.getInputStream(pdfFile)); + } finally { + FileUtil.del(sourceFile); + FileUtil.del(pdfFile); + } + } + String fileName = StrUtil.blankToDefault(FileUtil.mainName(file.getName()), "preview") + ".pdf"; + String encodedName = URLEncoder.encode(fileName, CharsetUtil.UTF_8).replace("+", "%20"); + response.setContentType("application/pdf"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"preview.pdf\"; filename*=UTF-8''" + encodedName); + response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(bytes.length)); + try (OutputStream out = response.getOutputStream()) { + out.write(bytes); + } + } + + @At + @ApiOperation("启用课程类型") + @SaCheckPermission("learning.course.display") + public Result courseTypes() { + return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt"))); + } + + @At + @ApiOperation("推荐标识") + @SaCheckPermission("learning.course.display") + public Result recommendOptions() { + Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育")); + if (root == null) { + return Result.success(List.of()); + } + List firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location")); + Sys_dict group = firstLevel.stream() + .filter(dict -> "推荐标识".equals(dict.getName()) || "推荐标识".equals(dict.getCode())) + .findFirst() + .orElse(null); + if (group == null) { + return Result.success(firstLevel); + } + List children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location")); + return Result.success(children.isEmpty() ? firstLevel : children); + } + + private int value(Integer value) { + return value == null ? 0 : value; + } + + /** + * 读取指定资源的最高已观看秒数。该值用于两端限制快进,最近播放位置仍单独用于续播。 + */ + private int resourceMaxPosition(LearningStudyRecord record, String resourceId) { + if (record == null || StrUtil.isBlank(resourceId)) { + return 0; + } + if (StrUtil.isBlank(record.getResourceMaxPositionData())) { + return value(record.getLastPositionSeconds()); + } + try { + NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData()); + return Math.max(0, positions.getInt(resourceId, 0)); + } catch (Exception ignored) { + // 历史记录未保存资源级位置时,从零开始建立最高观看位置。 + return 0; + } + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseManageController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseManageController.java new file mode 100644 index 0000000..53b9395 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseManageController.java @@ -0,0 +1,247 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.sys.models.Sys_dict; +import com.budwk.app.sys.services.SysDictService; +import com.budwk.app.zhgh.learning.models.LearningCourse; +import com.budwk.app.zhgh.learning.models.LearningCourseOutline; +import com.budwk.app.zhgh.learning.models.LearningCourseType; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-课程管理") +@At("/platform/learning/course/manage") +public class LearningCourseManageController { + + @Inject + private Dao dao; + + @Inject + private SysDictService sysDictService; + + @At("") + @Ok("beetl:/platform/zhgh/Learning/courseManage/index.html") + @SaCheckPermission("learning.course.manage") + public void index() { + } + + @At + @ApiOperation("课程列表") + @SaCheckPermission("learning.course.manage") + public Result pageData(PageForm pageForm, + String courseName, + String courseTypeId, + String startTime, + String endTime, + String lecturerName, + String status, + String recommendFlag) { + Cnd cnd = Cnd.NEW(); + cnd.and(Cnd.likeEX("c.courseName", courseName)); + cnd.andEX("c.courseTypeId", "=", courseTypeId); + cnd.and(Cnd.likeEX("c.lecturerName", lecturerName)); + cnd.andEX("c.status", "=", status); + if (StrUtil.isNotBlank(recommendFlag)) { + cnd.and("c.recommendFlags", "like", "%" + recommendFlag + "%"); + } + if (StrUtil.isNotBlank(startTime)) { + cnd.and("c.startTime", ">=", DateUtil.parse(startTime)); + } + if (StrUtil.isNotBlank(endTime)) { + cnd.and("c.endTime", "<=", DateUtil.parse(endTime)); + } + + String orderColumn = getOrderColumn(pageForm.getPageOrderName()); + String orderBy = "descending".equals(pageForm.getPageOrderBy()) ? "desc" : "asc"; + if (StrUtil.isBlank(orderColumn)) { + orderColumn = "c.sortNum"; + orderBy = "asc"; + } + + Sql countSql = Sqls.create(""" + SELECT COUNT(1) + FROM learning_course c + LEFT JOIN learning_course_type t ON t.id = c.courseTypeId + $condition + """); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + int count = countSql.getInt(); + + Sql listSql = Sqls.create(""" + SELECT c.*, t.typeName AS courseTypeName + FROM learning_course c + LEFT JOIN learning_course_type t ON t.id = c.courseTypeId + $condition + ORDER BY $orderColumn $orderBy + """); + listSql.setCondition(cnd); + listSql.setVar("orderColumn", orderColumn); + listSql.setVar("orderBy", orderBy); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + + Pagination pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, listSql.getList(NutMap.class)); + return Result.success(pagination); + } + + @At + @ApiOperation("新增课程") + @SaCheckPermission("learning.course.manage") + @SLog(tag = "新增课程", msg = "课程名称:${args[0].courseName}") + @Aop(TransAop.READ_COMMITTED) + /** course 传课程表单字段,viewCount 为非负整数;返回 Result 表示新增是否成功。 */ + public Result doAdd(LearningCourse course) { + Result checkResult = checkCourse(course, null); + if (checkResult != null) { + return checkResult; + } + if (StrUtil.isBlank(course.getStatus())) { + course.setStatus("draft"); + } + if (course.getViewCount() == null) { + course.setViewCount(0); + } + dao.insert(course); + return Result.success(); + } + + @At + @ApiOperation("编辑课程") + @SaCheckPermission("learning.course.manage") + @SLog(tag = "编辑课程", msg = "课程ID:${args[0].id}") + @Aop(TransAop.READ_COMMITTED) + /** course 传课程主键及需要更新的表单字段;返回 Result 表示编辑是否成功。 */ + public Result doEdit(LearningCourse course) { + if (StrUtil.isBlank(course.getId())) { + return Result.error("请选择要编辑的数据"); + } + Result checkResult = checkCourse(course, course.getId()); + if (checkResult != null) { + return checkResult; + } + dao.updateIgnoreNull(course); + return Result.success(); + } + + @At + @ApiOperation("删除课程") + @SaCheckPermission("learning.course.manage") + @SLog(tag = "删除课程", msg = "课程ID:${args[0]}") + @Aop(TransAop.READ_COMMITTED) + /** id 传课程主键;返回 Result,存在章节内容时返回不可删除提示。 */ + public Result doDelete(String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要删除的数据"); + } + int outlineCount = dao.count(LearningCourseOutline.class, Cnd.where("courseId", "=", id)); + if (outlineCount > 0) { + return Result.error("该课程存在章节内容,请先删除章节内容后再删除课程"); + } + dao.clear(LearningCourse.class, Cnd.where("id", "=", id)); + return Result.success(); + } + + @At + @ApiOperation("启用课程类型") + @SaCheckPermission("learning.course.manage") + public Result courseTypes() { + return Result.success(dao.query(LearningCourseType.class, Cnd.where("enabled", "=", true).asc("sortNum").asc("createdAt"))); + } + + @At + @ApiOperation("学习教育数据字典") + @SaCheckPermission("learning.course.manage") + public Result learningDictOptions(String name) { + Sys_dict root = sysDictService.fetch(Cnd.where("code", "=", "学习教育").or("name", "=", "学习教育")); + if (root == null) { + return Result.success(List.of()); + } + List firstLevel = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(root.getId())).and("disabled", "=", false).asc("location")); + if (StrUtil.isBlank(name)) { + return Result.success(firstLevel); + } + Sys_dict group = firstLevel.stream() + .filter(dict -> name.equals(dict.getName()) || name.equals(dict.getCode())) + .findFirst() + .orElse(null); + if (group == null) { + return Result.success(firstLevel); + } + List children = sysDictService.query(Cnd.where("parentId", "=", Strings.sNull(group.getId())).and("disabled", "=", false).asc("location")); + return Result.success(children.isEmpty() ? firstLevel : children); + } + + private Result checkCourse(LearningCourse course, String excludeId) { + if (course == null || StrUtil.isBlank(course.getCourseName())) { + return Result.error("课程名称不能为空"); + } + if (StrUtil.isBlank(course.getCourseTypeId())) { + return Result.error("请选择课程类型"); + } + if (StrUtil.isBlank(course.getLecturerName())) { + return Result.error("授课讲师不能为空"); + } + if (course.getSortNum() == null) { + return Result.error("排序编码不能为空"); + } + if (course.getViewCount() != null && course.getViewCount() < 0) { + return Result.error("观看次数不能小于0"); + } + if (!"long_term".equals(course.getOpenType()) && (course.getStartTime() == null || course.getEndTime() == null)) { + return Result.error("请选择开课时间"); + } + Cnd cnd = Cnd.where("courseName", "=", course.getCourseName().trim()); + if (StrUtil.isNotBlank(excludeId)) { + cnd.and("id", "!=", excludeId); + } + if (dao.count(LearningCourse.class, cnd) > 0) { + return Result.error("课程名称已存在"); + } + course.setCourseName(course.getCourseName().trim()); + if ("long_term".equals(course.getOpenType())) { + course.setStartTime(null); + course.setEndTime(null); + } + return null; + } + + private String getOrderColumn(String prop) { + Map columns = new HashMap<>(); + columns.put("courseName", "c.courseName"); + columns.put("courseTypeName", "t.typeName"); + columns.put("lecturerName", "c.lecturerName"); + columns.put("startTime", "c.startTime"); + columns.put("status", "c.status"); + columns.put("recommendFlags", "c.recommendFlags"); + columns.put("sortNum", "c.sortNum"); + columns.put("viewCount", "c.viewCount"); + return columns.get(prop); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseTypeController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseTypeController.java new file mode 100644 index 0000000..5e885e8 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseTypeController.java @@ -0,0 +1,110 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.zhgh.learning.models.LearningCourseType; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.pager.Pager; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-课程类型设置") +@At("/platform/learning/course/type") +public class LearningCourseTypeController { + + @Inject + private Dao dao; + + @At("") + @Ok("beetl:/platform/zhgh/Learning/courseType/index.html") + @SaCheckPermission("learning.course.type") + public void index() { + } + + @At + @ApiOperation("课程类型列表") + @SaCheckPermission("learning.course.type") + public Result pageData(PageForm pageForm, @Param("typeName") String typeName) { + Cnd cnd = Cnd.NEW(); + cnd.and(Cnd.likeEX("typeName", typeName)); + cnd.asc("sortNum").asc("createdAt"); + int count = dao.count(LearningCourseType.class, cnd); + Pager pager = dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize()); + Pagination pagination = new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), count, dao.query(LearningCourseType.class, cnd, pager)); + return Result.success(pagination); + } + + @At + @ApiOperation("新增课程类型") + @SaCheckPermission("learning.course.type") + @SLog(tag = "新增课程类型", msg = "课程类型名称:${args[0].typeName}") + public Result doAdd(LearningCourseType courseType) { + Result checkResult = checkCourseType(courseType, null); + if (checkResult != null) { + return checkResult; + } + if (courseType.getEnabled() == null) { + courseType.setEnabled(true); + } + dao.insert(courseType); + return Result.success(); + } + + @At + @ApiOperation("编辑课程类型") + @SaCheckPermission("learning.course.type") + @SLog(tag = "编辑课程类型", msg = "课程类型ID:${args[0].id}") + public Result doEdit(LearningCourseType courseType) { + if (StrUtil.isBlank(courseType.getId())) { + return Result.error("请选择要编辑的数据"); + } + Result checkResult = checkCourseType(courseType, courseType.getId()); + if (checkResult != null) { + return checkResult; + } + dao.updateIgnoreNull(courseType); + return Result.success(); + } + + @At + @ApiOperation("删除课程类型") + @SaCheckPermission("learning.course.type") + @SLog(tag = "删除课程类型", msg = "课程类型ID:${args[0]}") + public Result doDelete(String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要删除的数据"); + } + dao.clear(LearningCourseType.class, Cnd.where("id", "=", id)); + return Result.success(); + } + + private Result checkCourseType(LearningCourseType courseType, String excludeId) { + if (courseType == null || StrUtil.isBlank(courseType.getTypeName())) { + return Result.error("课程类型名称不能为空"); + } + if (courseType.getSortNum() == null) { + return Result.error("排序编号不能为空"); + } + Cnd cnd = Cnd.where("typeName", "=", courseType.getTypeName().trim()); + if (StrUtil.isNotBlank(excludeId)) { + cnd.and("id", "!=", excludeId); + } + if (dao.count(LearningCourseType.class, cnd) > 0) { + return Result.error("课程类型名称已存在"); + } + courseType.setTypeName(courseType.getTypeName().trim()); + return null; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningPlatformController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningPlatformController.java new file mode 100644 index 0000000..d80ba1e --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningPlatformController.java @@ -0,0 +1,47 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +@IocBean +@At("/platform/learning") +public class LearningPlatformController { + + @At("/activity/manage") + @Ok("beetl:/platform/zhgh/Learning/activityManage/index.html") + @SaCheckPermission("learning.activity.manage") + public void activityManage() { + } + + @At("/my/record") + @Ok("beetl:/platform/zhgh/Learning/myRecord/index.html") + @SaCheckPermission("learning.my.record") + public void myRecord() { + } + + @At("/my/record/h5") + @Ok("beetl:/platform/zhghh5/learning/myRecord/index.html") + @SaCheckPermission("learning.my.record") + public void myRecordH5() { + } + + @At("/statistics") + @Ok("beetl:/platform/zhgh/Learning/statistics/index.html") + @SaCheckPermission("learning.statistics") + public void statistics() { + } + + @At("/course/h5") + @Ok("beetl:/platform/zhghh5/learning/course/index.html") + @SaCheckPermission("learning.course.display") + public void courseH5() { + } + + @At("/course/h5/study") + @Ok("beetl:/platform/zhghh5/learning/course/study.html") + @SaCheckPermission("learning.course.display") + public void courseH5Study() { + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStatisticsController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStatisticsController.java new file mode 100644 index 0000000..dcd21e7 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStatisticsController.java @@ -0,0 +1,412 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.result.Result; +import com.budwk.app.base.utils.PageUtil; +import com.budwk.app.sys.models.Sys_union; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.learning.models.LearningCourse; +import com.budwk.app.zhgh.learning.service.LearningResourceProgressService; +import com.budwk.app.zhgh.learning.param.LearningStatisticsPageForm; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.cri.Static; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-学习统计") +@At("/platform/learning/statistics") +public class LearningStatisticsController { + + @Inject + private Dao dao; + + @Inject + private LearningResourceProgressService resourceProgressService; + + @At("") + @Ok("beetl:/platform/zhgh/Learning/statistics/index.html") + @SaCheckPermission("learning.statistics") + public void index() { + } + + @At + @ApiOperation("课程统计") + @SaCheckPermission("learning.statistics") + public Result coursePageData(LearningStatisticsPageForm pageForm) { + refreshProgress(pageForm); + Cnd cnd = courseCondition(pageForm); + Sql countSql = Sqls.create(""" + SELECT COUNT(1) + FROM learning_course c + $condition + """); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + + Sql listSql = Sqls.create(""" + SELECT + c.id AS courseId, + c.courseName, + COUNT(DISTINCT uc.userId) AS learnerCount, + COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount, + IFNULL(ROUND(AVG(uc.studySeconds)), 0) AS avgStudySeconds + FROM learning_course c + LEFT JOIN ( + SELECT + courseId, + COUNT(1) AS targetCount, + SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount + FROM learning_course_outline + WHERE status = 'enabled' + AND EXISTS (SELECT 1 FROM learning_outline_resource lr + WHERE lr.outlineId = learning_course_outline.id + AND lr.courseId = learning_course_outline.courseId + AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1) + GROUP BY courseId + ) target ON target.courseId = c.id + LEFT JOIN ( + SELECT + r.courseId, + r.userId, + SUM(IFNULL(r.studySeconds, 0)) AS studySeconds, + COUNT(DISTINCT CASE + WHEN r.completeStatus = 'completed' + THEN r.outlineId + ELSE NULL + END) AS completedOutlineCount + FROM learning_study_record r + JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled' + JOIN ( + SELECT + courseId, + COUNT(1) AS targetCount, + SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount + FROM learning_course_outline + WHERE status = 'enabled' + AND EXISTS (SELECT 1 FROM learning_outline_resource lr + WHERE lr.outlineId = learning_course_outline.id + AND lr.courseId = learning_course_outline.courseId + AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1) + GROUP BY courseId + ) target ON target.courseId = r.courseId + $recordCondition + GROUP BY r.courseId, r.userId + ) uc ON uc.courseId = c.id + $condition + GROUP BY c.id, c.courseName + ORDER BY $orderColumn $orderBy + """); + listSql.setCondition(cnd); + listSql.setVar("recordCondition", new Static(recordWhere(pageForm))); + setOrder(listSql, pageForm, "course"); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + List rows = listSql.getList(NutMap.class); + rows.forEach(row -> { + row.put("avgStudyTimeText", formatStudySeconds(row.getInt("avgStudySeconds", 0))); + row.put("completeRate", completeRate(row.getInt("completedCount", 0), row.getInt("learnerCount", 0))); + }); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows)); + } + + @At + @ApiOperation("分工会统计") + @SaCheckPermission("learning.statistics") + public Result unionPageData(LearningStatisticsPageForm pageForm) { + refreshProgress(pageForm); + Cnd cnd = unionCondition(pageForm); + Sql countSql = Sqls.create(""" + SELECT COUNT(1) + FROM learning_course c + JOIN ( + SELECT r.courseId, r.unionId + FROM learning_study_record r + $recordCondition + GROUP BY r.courseId, r.unionId + ) uc ON uc.courseId = c.id + LEFT JOIN sys_union un ON un.id = uc.unionId + $condition + """); + countSql.setCondition(cnd); + countSql.setVar("recordCondition", new Static(recordWhere(pageForm))); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + + Sql listSql = Sqls.create(""" + SELECT + c.id AS courseId, + c.courseName, + un.id AS unionId, + IFNULL(un.name, uc.unionName) AS unionName, + COUNT(DISTINCT uc.userId) AS learnerCount, + IFNULL(SUM(uc.studySeconds), 0) AS studySeconds, + COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount + FROM learning_course c + JOIN ( + SELECT + r.courseId, + r.userId, + r.unionId, + MAX(r.unionName) AS unionName, + SUM(IFNULL(r.studySeconds, 0)) AS studySeconds, + COUNT(DISTINCT CASE + WHEN r.completeStatus = 'completed' + THEN r.outlineId + ELSE NULL + END) AS completedOutlineCount + FROM learning_study_record r + JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled' + JOIN ( + SELECT + courseId, + COUNT(1) AS targetCount, + SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount + FROM learning_course_outline + WHERE status = 'enabled' + AND EXISTS (SELECT 1 FROM learning_outline_resource lr + WHERE lr.outlineId = learning_course_outline.id + AND lr.courseId = learning_course_outline.courseId + AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1) + GROUP BY courseId + ) target ON target.courseId = r.courseId + $recordCondition + GROUP BY r.courseId, r.userId, r.unionId + ) uc ON uc.courseId = c.id + LEFT JOIN sys_union un ON un.id = uc.unionId + LEFT JOIN ( + SELECT + courseId, + COUNT(1) AS targetCount, + SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) AS requiredCount + FROM learning_course_outline + WHERE status = 'enabled' + AND EXISTS (SELECT 1 FROM learning_outline_resource lr + WHERE lr.outlineId = learning_course_outline.id + AND lr.courseId = learning_course_outline.courseId + AND lr.status = 'enabled' AND COALESCE(lr.allowPreview, 1) = 1) + GROUP BY courseId + ) target ON target.courseId = c.id + $condition + GROUP BY c.id, c.courseName, uc.unionId, un.id, un.name + ORDER BY $orderColumn $orderBy + """); + listSql.setCondition(cnd); + listSql.setVar("recordCondition", new Static(recordWhere(pageForm))); + setOrder(listSql, pageForm, "union"); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + List rows = listSql.getList(NutMap.class); + rows.forEach(row -> { + int learnerCount = row.getInt("learnerCount", 0); + int completedCount = row.getInt("completedCount", 0); + row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))); + row.put("completeRate", completeRate(completedCount, learnerCount)); + }); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows)); + } + + @At + @ApiOperation("学习列表") + @SaCheckPermission("learning.statistics") + public Result recordPageData(LearningStatisticsPageForm pageForm) { + refreshProgress(pageForm); + Cnd cnd = Cnd.NEW(); + cnd.andEX("r.courseId", "=", pageForm.getCourseId()); + cnd.andEX("r.unionId", "=", pageForm.getUnionId()); + cnd.and(Cnd.likeEX("r.loginName", pageForm.getLoginName())); + cnd.and(Cnd.likeEX("r.userName", pageForm.getUserName())); + if ("completed".equals(pageForm.getCompleteStatus())) { + cnd.and("r.completeStatus", "=", "completed"); + } else if ("incomplete".equals(pageForm.getCompleteStatus())) { + cnd.and(Cnd.exps("r.completeStatus", "!=", "completed").or("r.completeStatus", "is", null)); + } + if (!hasSchoolScope()) { + if (hasBranchScope()) { + cnd.and("r.unionId", "=", SecurityUtil.getUnionId()); + } else { + cnd.and("r.userId", "=", SecurityUtil.getUserId()); + } + } + String from = """ + FROM learning_study_record r + JOIN learning_course_outline o ON o.id = r.outlineId AND o.courseId = r.courseId AND o.status = 'enabled' + JOIN (SELECT DISTINCT outlineId, courseId FROM learning_outline_resource + WHERE status = 'enabled' AND COALESCE(allowPreview, 1) = 1) lr + ON lr.outlineId = r.outlineId AND lr.courseId = r.courseId + """; + Sql countSql = Sqls.create("SELECT COUNT(1) " + from + " $condition"); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + Map columns = new HashMap<>(); + for (String column : List.of("loginName", "userName", "unionName", "courseName", "outlineName", + "firstStudyTime", "latestStudyTime", "studySeconds", "progressPercent", "completeStatus")) { + columns.put(column, "r." + column); + } + String orderColumn = columns.get(pageForm.getPageOrderName()); + if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) { + cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy())); + } else { + cnd.desc("r.latestStudyTime"); + } + Sql listSql = Sqls.create("SELECT r.* " + from + " $condition"); + listSql.setCondition(cnd); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + List rows = listSql.getList(NutMap.class); + rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)))); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows)); + } + + @At + @ApiOperation("可选课程") + @SaCheckPermission("learning.statistics") + public Result courseOptions(String courseName) { + Cnd cnd = Cnd.NEW(); + cnd.and(Cnd.likeEX("courseName", courseName)); + cnd.desc("createdAt").asc("sortNum"); + return Result.success(dao.query(LearningCourse.class, cnd, dao.createPager(1, 50))); + } + + @At + @ApiOperation("可选分工会") + @SaCheckPermission("learning.statistics") + public Result unionOptions() { + if (hasSchoolScope()) { + return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name"))); + } + if (hasBranchScope()) { + return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId()))); + } + return Result.success(List.of()); + } + + private void refreshProgress(LearningStatisticsPageForm pageForm) { + Cnd cnd = Cnd.NEW(); + cnd.andEX("courseId", "=", pageForm.getCourseId()); + cnd.andEX("unionId", "=", pageForm.getUnionId()); + if (!hasSchoolScope()) { + if (hasBranchScope()) cnd.and("unionId", "=", SecurityUtil.getUnionId()); + else cnd.and("userId", "=", SecurityUtil.getUserId()); + } + resourceProgressService.refreshRecords(cnd); + } + + private Cnd courseCondition(LearningStatisticsPageForm pageForm) { + Cnd cnd = Cnd.NEW(); + cnd.andEX("c.id", "=", pageForm.getCourseId()); + cnd.and(Cnd.likeEX("c.courseName", pageForm.getCourseName())); + return cnd; + } + + private Cnd unionCondition(LearningStatisticsPageForm pageForm) { + Cnd cnd = courseCondition(pageForm); + cnd.andEX("uc.unionId", "=", pageForm.getUnionId()); + return cnd; + } + + private String recordWhere(LearningStatisticsPageForm pageForm) { + StringBuilder where = new StringBuilder("WHERE EXISTS (SELECT 1 FROM learning_course_outline so " + + "JOIN learning_outline_resource sr ON sr.outlineId = so.id AND sr.courseId = so.courseId " + + "WHERE so.id = r.outlineId AND so.courseId = r.courseId AND so.status = 'enabled' " + + "AND sr.status = 'enabled' AND COALESCE(sr.allowPreview, 1) = 1)"); + if (!hasSchoolScope() && hasBranchScope()) { + where.append(" AND r.unionId = '").append(escapeSql(SecurityUtil.getUnionId())).append("'"); + } else if (!hasSchoolScope()) { + where.append(" AND r.userId = '").append(escapeSql(SecurityUtil.getUserId())).append("'"); + } + if (StrUtil.isNotBlank(pageForm.getUnionId())) { + where.append(" AND r.unionId = '").append(escapeSql(pageForm.getUnionId())).append("'"); + } + return where.toString(); + } + + private void setOrder(Sql sql, LearningStatisticsPageForm pageForm, String type) { + String orderColumn = "union".equals(type) ? getUnionOrderColumn(pageForm.getPageOrderName()) : getCourseOrderColumn(pageForm.getPageOrderName()); + String orderBy = PageUtil.getOrder(pageForm.getPageOrderBy()); + if (StrUtil.isBlank(orderColumn)) { + orderColumn = "union".equals(type) ? "c.courseName ASC, un.unionCode" : "c.sortNum ASC, c.createdAt"; + orderBy = "DESC"; + } else if (StrUtil.isBlank(orderBy)) { + orderBy = "ASC"; + } + sql.setVar("orderColumn", new Static(orderColumn)); + sql.setVar("orderBy", new Static(orderBy)); + } + + private String getCourseOrderColumn(String prop) { + Map columns = new HashMap<>(); + columns.put("courseName", "c.courseName"); + columns.put("learnerCount", "learnerCount"); + columns.put("completedCount", "completedCount"); + columns.put("avgStudySeconds", "avgStudySeconds"); + columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)"); + return columns.get(prop); + } + + private String getUnionOrderColumn(String prop) { + Map columns = new HashMap<>(); + columns.put("courseName", "c.courseName"); + columns.put("unionName", "unionName"); + columns.put("learnerCount", "learnerCount"); + columns.put("studySeconds", "studySeconds"); + columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)"); + columns.put("completedCount", "completedCount"); + return columns.get(prop); + } + + private boolean hasSchoolScope() { + return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name()); + } + + private boolean hasBranchScope() { + return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name()); + } + + private String escapeSql(String value) { + return value == null ? "" : value.replace("'", "''"); + } + + private String completeRate(int completedCount, int learnerCount) { + return learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(learnerCount), 2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%"; + } + + private String formatStudySeconds(Integer seconds) { + int value = seconds == null ? 0 : seconds; + int hour = value / 3600; + int minute = value % 3600 / 60; + int second = value % 60; + if (hour > 0) { + return hour + "小时" + minute + "分" + second + "秒"; + } + if (minute > 0) { + return minute + "分" + second + "秒"; + } + return second + "秒"; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStudyRecordController.java b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStudyRecordController.java new file mode 100644 index 0000000..9a0507d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStudyRecordController.java @@ -0,0 +1,615 @@ +package com.budwk.app.zhgh.learning.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.constant.RoleConstant; +import com.budwk.app.base.page.Pagination; +import com.budwk.app.base.param.PageForm; +import com.budwk.app.base.result.Result; +import com.budwk.app.base.utils.PageUtil; +import com.budwk.app.sys.models.Sys_union; +import com.budwk.app.web.commons.auth.utils.AuthUtil; +import com.budwk.app.web.commons.auth.utils.SecurityUtil; +import com.budwk.app.zhgh.learning.models.LearningCourse; +import com.budwk.app.zhgh.learning.models.LearningCourseOutline; +import com.budwk.app.zhgh.learning.models.LearningOutlineResource; +import com.budwk.app.zhgh.learning.models.LearningStudyRecord; +import com.budwk.app.zhgh.learning.models.LearningStudyRule; +import com.budwk.app.zhgh.learning.models.LearningStudySegment; +import com.budwk.app.zhgh.learning.service.LearningResourceProgressService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.dao.Chain; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.dao.Sqls; +import org.nutz.dao.sql.Sql; +import org.nutz.dao.util.cri.SqlExpressionGroup; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@IocBean +@Ok("json:full") +@Api("学习教育平台-学习记录") +@At("/platform/learning/study/record") +public class LearningStudyRecordController { + + private static final int MAX_HEARTBEAT_SECONDS = 30; + + @Inject + private Dao dao; + + @Inject + private LearningResourceProgressService resourceProgressService; + + @At + @ApiOperation("学习记录分页") + @SaCheckPermission("learning.my.record") + public Result pageData(PageForm pageForm, + @Param("keyword") String keyword, + @Param("unionId") String unionId, + @Param("courseId") String courseId, + @Param("completeStatus") String completeStatus) { + resourceProgressService.refreshRecords(Cnd.where("userId", "=", SecurityUtil.getUserId())); + Cnd cnd = Cnd.NEW(); + if (StrUtil.isNotBlank(keyword)) { + SqlExpressionGroup seg = new SqlExpressionGroup(); + seg.or("r.loginName", "like", "%" + keyword + "%"); + seg.or("r.userName", "like", "%" + keyword + "%"); + cnd.and(seg); + } + cnd.andEX("r.unionId", "=", unionId); + cnd.andEX("r.courseId", "=", courseId); + cnd.andEX("r.completeStatus", "=", completeStatus); + cnd.and("r.userId", "=", SecurityUtil.getUserId()); + + String orderColumn = getOrderColumn(pageForm.getPageOrderName()); + if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) { + cnd.orderBy(orderColumn, PageUtil.getOrder(pageForm.getPageOrderBy())); + } else { + cnd.desc("r.latestStudyTime"); + } + + Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_study_record r $condition"); + countSql.setCondition(cnd); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + + Sql listSql = Sqls.create(""" + SELECT r.* + FROM learning_study_record r + $condition + """); + listSql.setCondition(cnd); + listSql.setPager(dao.createPager(pageForm.getPageNumber(), pageForm.getPageSize())); + listSql.setCallback(Sqls.callback.maps()); + dao.execute(listSql); + List rows = listSql.getList(NutMap.class); + rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)))); + return Result.success(new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), countSql.getInt(), rows)); + } + + @At + @ApiOperation("移动端我的学习汇总") + @SaCheckPermission("learning.my.record") + public Result h5Summary() { + String userId = SecurityUtil.getUserId(); + java.util.Set courseIds = new java.util.HashSet<>(); + for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, Cnd.where("userId", "=", userId))) { + courseIds.add(record.getCourseId()); + } + int seconds = 0, outlines = 0, completedOutlines = 0, completedCourses = 0; + for (String courseId : courseIds) { + NutMap state = resourceProgressService.courseState(courseId, userId); + seconds += actualStudySeconds(courseId, userId); + outlines += state.getInt("outlineCount", 0); + completedOutlines += state.getInt("completedOutlineCount", 0); + if (state.getInt("outlineCount", 0) > 0 && state.getInt("progressPercent", 0) == 100) completedCourses++; + } + NutMap data = NutMap.NEW().addv("studySeconds", seconds).addv("courseCount", courseIds.size()) + .addv("completedCourseCount", completedCourses).addv("outlineCount", outlines) + .addv("completedOutlineCount", completedOutlines); + int studySeconds = data.getInt("studySeconds", 0); + data.put("studyHour", studySeconds / 3600); + data.put("studyMinute", studySeconds % 3600 / 60); + return Result.success(data); + } + + @At + @ApiOperation("移动端我的学习课程") + @SaCheckPermission("learning.my.record") + public Result h5Courses() { + String userId = SecurityUtil.getUserId(); + Sql sql = Sqls.create(""" + SELECT r.courseId, + MAX(r.courseName) AS courseName, + MAX(c.cover) AS cover, + COUNT(1) AS outlineCount, + SUM(CASE WHEN r.completeStatus = 'completed' THEN 1 ELSE 0 END) AS completedOutlineCount, + IFNULL(SUM(IFNULL(r.studySeconds, 0)), 0) AS studySeconds, + IFNULL(ROUND(AVG(IFNULL(r.progressPercent, 0))), 0) AS progressPercent, + MAX(r.latestStudyTime) AS latestStudyTime + FROM learning_study_record r + LEFT JOIN learning_course c ON c.id = r.courseId + WHERE r.userId = @userId + AND EXISTS (SELECT 1 FROM learning_outline_resource pr WHERE pr.outlineId = r.outlineId + AND pr.status = 'enabled' AND COALESCE(pr.allowPreview, 1) = 1) + GROUP BY r.courseId + ORDER BY latestStudyTime DESC + """); + sql.params().set("userId", userId); + sql.setCallback(Sqls.callback.maps()); + dao.execute(sql); + List rows = sql.getList(NutMap.class); + rows.forEach(row -> { + row.putAll(resourceProgressService.courseState(row.getString("courseId"), userId)); + row.put("studySeconds", actualStudySeconds(row.getString("courseId"), userId)); + row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))); + }); + rows.sort(java.util.Comparator.comparingInt((NutMap row) -> + row.getInt("progressPercent", 0) == 100 && row.getInt("outlineCount", 0) > 0 + && row.getInt("completedOutlineCount", 0) >= row.getInt("outlineCount", 0) ? 1 : 0)); + return Result.success(rows); + } + + @At + @ApiOperation("开始学习") + @SaCheckPermission("learning.course.display") + @Aop(TransAop.READ_COMMITTED) + /** + * courseId、resourceId 分别传课程和资源主键,positionSeconds 传当前播放秒数; + * 返回学习记录、学习时段、学习进度和本次自增后的课程观看次数。 + */ + public Result start(@Param("courseId") String courseId, + @Param("resourceId") String resourceId, + @Param("positionSeconds") Integer positionSeconds) { + if (StrUtil.hasBlank(courseId, resourceId)) { + return Result.error("请选择课程资料后开始学习"); + } + LearningCourse course = dao.fetch(LearningCourse.class, courseId); + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId); + if (course == null || resource == null || !courseId.equals(resource.getCourseId())) { + return Result.error("课程资料不存在"); + } + if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) { + return Result.error("该资料不允许学习预览"); + } + LearningCourseOutline outline = dao.fetch(LearningCourseOutline.class, resource.getOutlineId()); + if (outline == null) { + return Result.error("章节不存在"); + } + + String userId = SecurityUtil.getUserId(); + Date now = new Date(); + closeUnfinishedSegments(userId, "interrupted"); + + LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, Cnd.where("userId", "=", userId) + .and("courseId", "=", courseId) + .and("outlineId", "=", outline.getId())); + if (record == null) { + record = buildRecord(userId, course, outline, now); + dao.insert(record); + } else { + record = resourceProgressService.lock(record.getId()); + } + int startPositionSeconds = sanitizePosition(positionSeconds, 0); + resourceProgressService.savePosition(record, resourceId, startPositionSeconds, null, false); + dao.update(LearningStudyRecord.class, Chain.make("latestStudyTime", now) + .add("courseName", course.getCourseName()).add("outlineName", outline.getTitle()) + .add("lastPositionSeconds", startPositionSeconds) + .add("resourceMaxPositionData", record.getResourceMaxPositionData()), + Cnd.where("id", "=", record.getId())); + + LearningStudySegment segment = new LearningStudySegment(); + segment.setRecordId(record.getId()); + segment.setUserId(userId); + segment.setCourseId(courseId); + segment.setOutlineId(outline.getId()); + segment.setResourceId(resourceId); + segment.setResourceName(resource.getResourceTitle()); + segment.setStartTime(now); + segment.setLastHeartbeatTime(now); + segment.setActiveSeconds(0); + segment.setState("studying"); + dao.insert(segment); + + // 每次成功创建学习时段记为一次观看,使用原子自增避免并发开始学习时丢失计数。 + dao.update(LearningCourse.class, + Chain.makeSpecial("viewCount", "IFNULL(viewCount, 0) + 1"), + Cnd.where("id", "=", courseId)); + LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId); + + resourceProgressService.refreshOutline(record, requiredSeconds(outline.getId())); + NutMap resourceState = resourceProgressService.state(record, resource); + resourceState.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId())); + resourceState.addv("recordId", record.getId()).addv("segmentId", segment.getId()) + .addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount())); + return Result.success(resourceState); + } + + @At + @ApiOperation("学习心跳") + @SaCheckPermission("learning.course.display") + @Aop(TransAop.READ_COMMITTED) + public Result heartbeat(@Param("segmentId") String segmentId, + @Param("activeSeconds") Integer activeSeconds, + @Param("positionSeconds") Integer positionSeconds) { + LearningStudySegment segment = fetchOwnStudyingSegment(segmentId); + if (segment == null) { + return Result.error("学习时段已结束,请重新开始学习"); + } + int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS)); + return Result.success(addActiveSeconds(segment, seconds, false, false, positionSeconds)); + } + + @At + @ApiOperation("结束学习") + @SaCheckPermission("learning.course.display") + @Aop(TransAop.READ_COMMITTED) + /** + * segmentId 传当前学习时段主键,activeSeconds 传本次尚未上报的有效学习秒数,positionSeconds 传当前播放位置; + * completed 仅在视频或音频自然播放结束时传 true。返回值为 NutMap,包含累计学习时长、进度和完成状态。 + */ + public Result finish(@Param("segmentId") String segmentId, + @Param("activeSeconds") Integer activeSeconds, + @Param("positionSeconds") Integer positionSeconds, + @Param("completed") Boolean completed) { + LearningStudySegment segment = fetchOwnStudyingSegment(segmentId); + if (segment == null) { + return Result.success(); + } + int seconds = Math.max(0, Math.min(activeSeconds == null ? 0 : activeSeconds, MAX_HEARTBEAT_SECONDS)); + NutMap result = addActiveSeconds(segment, seconds, true, Boolean.TRUE.equals(completed), positionSeconds); + dao.update(LearningStudySegment.class, Chain.make("endTime", new Date()).add("state", "finished"), Cnd.where("id", "=", segmentId)); + return Result.success(result); + } + + @At + @ApiOperation("保存播放位置") + @SaCheckPermission("learning.course.display") + @Aop(TransAop.READ_COMMITTED) + public Result position(@Param("courseId") String courseId, + @Param("resourceId") String resourceId, + @Param("positionSeconds") Integer positionSeconds) { + if (StrUtil.hasBlank(courseId, resourceId) || positionSeconds == null || positionSeconds < 0) { + return Result.success(); + } + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, resourceId); + if (resource == null || !courseId.equals(resource.getCourseId())) { + return Result.success(); + } + LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, + Cnd.where("userId", "=", SecurityUtil.getUserId()).and("courseId", "=", courseId) + .and("outlineId", "=", resource.getOutlineId())); + if (record == null) return Result.success(); + record = resourceProgressService.lock(record.getId()); + resourceProgressService.savePosition(record, resourceId, positionSeconds, null, false); + dao.update(LearningStudyRecord.class, Chain.make("lastPositionSeconds", positionSeconds) + .add("latestStudyTime", new Date()).add("resourceMaxPositionData", record.getResourceMaxPositionData()), + Cnd.where("id", "=", record.getId())); + return Result.success(); + } + + @At + @ApiOperation("可选课程") + @SaCheckPermission("learning.my.record") + public Result courseOptions() { + return Result.success(dao.query(LearningCourse.class, Cnd.NEW().asc("sortNum").desc("createdAt"))); + } + + @At + @ApiOperation("可选分工会") + @SaCheckPermission("learning.my.record") + public Result unionOptions() { + if (hasSchoolScope()) { + return Result.success(dao.query(Sys_union.class, Cnd.NEW().asc("unionCode").asc("name"))); + } + if (hasBranchScope()) { + return Result.success(dao.query(Sys_union.class, Cnd.where("id", "=", SecurityUtil.getUnionId()))); + } + return Result.success(List.of()); + } + + @At + @ApiOperation("删除学习记录") + @SaCheckPermission("learning.my.record") + public Result delete(@Param("id") String id) { + if (StrUtil.isBlank(id)) { + return Result.error("请选择要删除的学习记录"); + } + LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, id); + if (record == null) { + return Result.error("学习记录不存在"); + } + if (!canOperate(record)) { + return Result.error("无权删除该学习记录"); + } + dao.clear(LearningStudySegment.class, Cnd.where("recordId", "=", id)); + dao.clear(LearningStudyRecord.class, Cnd.where("id", "=", id)); + return Result.success(); + } + + private LearningStudyRecord buildRecord(String userId, LearningCourse course, LearningCourseOutline outline, Date now) { + NutMap user = currentUserInfo(userId); + int requiredSeconds = requiredSeconds(outline.getId()); + LearningStudyRecord record = new LearningStudyRecord(); + record.setUserId(userId); + record.setLoginName(user.getString("loginname", SecurityUtil.getUserLoginname())); + record.setUserName(user.getString("username", SecurityUtil.getUserUsername())); + record.setUnionId(user.getString("unionId", SecurityUtil.getUnionId())); + record.setUnionName(user.getString("unionName", "")); + record.setCourseId(course.getId()); + record.setCourseName(course.getCourseName()); + record.setOutlineId(outline.getId()); + record.setOutlineName(outline.getTitle()); + record.setFirstStudyTime(now); + record.setLatestStudyTime(now); + record.setStudySeconds(0); + record.setLastPositionSeconds(0); + record.setResourceMaxPositionData("{}"); + record.setRequiredSeconds(requiredSeconds); + record.setProgressPercent(0); + record.setCompleteStatus("studying"); + return record; + } + + private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) { + Date now = new Date(); + LearningStudyRecord record = resourceProgressService.lock(segment.getRecordId()); + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId()); + segment = dao.fetch(LearningStudySegment.class, segment.getId()); + if (!"studying".equals(segment.getState())) { + return resourceProgressService.state(record, resource); + } + if (Boolean.FALSE.equals(resource.getAllowPreview()) || !"enabled".equals(resource.getStatus())) { + dao.update(LearningStudySegment.class, Chain.make("state", "interrupted").add("endTime", now), + Cnd.where("id", "=", segment.getId())); + resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId())); + return resourceProgressService.state(record, resource); + } + NutMap before = resourceProgressService.state(record, resource); + int increment = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds); + LearningStudyRule rule = resourceProgressService.outlineRule(record.getOutlineId()); + boolean timeRule = rule != null && "min_study_time".equals(rule.getCompletionRule()); + if (timeRule) increment = activeSeconds; + int duration = value(resource.getDurationSeconds()); + // 自然播放结束以到达资源末尾为准,补齐计时器和心跳延迟遗漏的秒数。 + boolean naturalEnd = completed && isMediaResource(resource) && positionSeconds != null && positionSeconds > 0 + && (duration <= 0 || positionSeconds >= Math.max(0, duration - 2)); + if (naturalEnd && duration > 0 && !timeRule) { + increment = Math.max(increment, duration - before.getInt("studySeconds", 0)); + } + Chain segmentChain = Chain.make("activeSeconds", value(segment.getActiveSeconds()) + increment) + .add("lastHeartbeatTime", now); + if (finish) segmentChain.add("state", "finished").add("endTime", now); + dao.update(LearningStudySegment.class, segmentChain, Cnd.where("id", "=", segment.getId())); + Integer maxPosition = isMediaResource(resource) && positionSeconds != null ? Math.max(0, positionSeconds) : null; + resourceProgressService.savePosition(record, resource.getId(), positionSeconds, maxPosition, naturalEnd); + Chain chain = Chain.make("latestStudyTime", now).add("resourceMaxPositionData", record.getResourceMaxPositionData()); + if (positionSeconds != null && positionSeconds >= 0) chain.add("lastPositionSeconds", positionSeconds); + dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId())); + resourceProgressService.refreshOutline(record, requiredSeconds(record.getOutlineId())); + NutMap result = resourceProgressService.state(record, resource); + result.addv("courseProgress", resourceProgressService.courseState(record.getCourseId(), record.getUserId())); + result.addv("studyTimeText", formatStudySeconds(result.getInt("studySeconds", 0))); + return result; + } + + private int actualStudySeconds(String courseId, String userId) { + Sql sql = Sqls.create(""" + SELECT COALESCE(SUM(s.activeSeconds), 0) + FROM learning_study_segment s + JOIN learning_study_record r ON r.id = s.recordId AND r.userId = s.userId AND r.courseId = s.courseId + WHERE s.courseId = @courseId AND s.userId = @userId + """); + sql.params().set("courseId", courseId).set("userId", userId); + sql.setCallback(Sqls.callback.integer()); + dao.execute(sql); + return Math.max(0, sql.getInt()); + } + + private LearningStudySegment fetchOwnStudyingSegment(String segmentId) { + if (StrUtil.isBlank(segmentId)) { + return null; + } + return dao.fetch(LearningStudySegment.class, Cnd.where("id", "=", segmentId) + .and("userId", "=", SecurityUtil.getUserId()) + .and("state", "=", "studying")); + } + + private void closeUnfinishedSegments(String userId, String state) { + dao.update(LearningStudySegment.class, + Chain.make("endTime", new Date()).add("state", state), + Cnd.where("userId", "=", userId).and("state", "=", "studying")); + } + + private int requiredSeconds(String outlineId) { + return resourceProgressService.requiredSeconds(outlineId); + } + + /** + * 校验媒体自然播放结束能否完成当前章节。 + * segment 表示当前用户的学习时段;positionSeconds 必须到达资源末尾附近;requiredSeconds 为章节要求学习时长。 + * 仅单资源章节且配置时长不超过媒体时长时返回 true,防止一个资源结束误完成包含多个资源或设置了更长时长的章节。 + */ + private boolean canCompleteByNaturalEnd(LearningStudySegment segment, Integer positionSeconds, int requiredSeconds) { + if (positionSeconds == null || requiredSeconds <= 0) { + return false; + } + LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId()); + if (resource == null || !("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()))) { + return false; + } + int durationSeconds = value(resource.getDurationSeconds()); + if (durationSeconds <= 0 || positionSeconds < Math.max(0, durationSeconds - 2) || requiredSeconds > durationSeconds) { + return false; + } + Sql countSql = Sqls.create("SELECT COUNT(1) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled'"); + countSql.params().set("outlineId", segment.getOutlineId()); + countSql.setCallback(Sqls.callback.integer()); + dao.execute(countSql); + return countSql.getInt() == 1; + } + + /** + * 仅统计当前资源超过历史最高观看位置的新增区间,回退复习不会重复累计学习时长。 + * record 保存各资源最高位置,resource 用于区分媒体和非媒体资料,positionSeconds 为本次心跳时的播放器位置。 + */ + private int effectiveStudySeconds(LearningStudyRecord record, LearningOutlineResource resource, String resourceId, int activeSeconds, Integer positionSeconds) { + if (!isMediaResource(resource)) { + return activeSeconds; + } + int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds); + int maxPositionSeconds = resourceMaxPosition(record, resourceId); + return Math.min(activeSeconds, Math.max(0, currentPositionSeconds - maxPositionSeconds)); + } + + /** + * 读取学习记录中按资源保存的最高已观看秒数。历史记录或异常 JSON 视为未保存,避免影响已有记录的使用。 + */ + private int resourceMaxPosition(LearningStudyRecord record, String resourceId) { + return value(resourceMaxPositions(record == null ? null : record.getResourceMaxPositionData()).get(resourceId)); + } + + /** + * 将资源 ID 与最高观看秒数转换为可持久化数据;键为资源 ID,值为该资源累计到达过的最远秒数。 + */ + private String resourceMaxPositionData(String resourceId, int positionSeconds) { + Map positions = new HashMap<>(); + positions.put(resourceId, Math.max(0, positionSeconds)); + return Json.toJson(positions); + } + + /** + * 解析资源最高位置 JSON。返回值为资源 ID 到秒数的映射,空值或异常数据返回空映射。 + */ + private Map resourceMaxPositions(String data) { + Map positions = new HashMap<>(); + if (StrUtil.isBlank(data)) { + return positions; + } + try { + NutMap source = Json.fromJson(NutMap.class, data); + for (String resourceId : source.keySet()) { + positions.put(resourceId, Math.max(0, source.getInt(resourceId, 0))); + } + } catch (Exception ignored) { + // 历史记录可能没有该字段或保存了异常内容,按未记录最高位置处理。 + } + return positions; + } + + /** + * 判断资料是否使用播放器计时。视频和音频按未重复观看区间累计,其他资料沿用活跃时长累计。 + */ + private boolean isMediaResource(LearningOutlineResource resource) { + return resource != null && ("video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType())); + } + + private int progress(Integer studySeconds, int requiredSeconds) { + if (requiredSeconds <= 0) { + return 0; + } + return Math.min(100, (int) Math.floor(value(studySeconds) * 100.0 / requiredSeconds)); + } + + private String completeStatus(Integer studySeconds, int requiredSeconds, boolean finish) { + if (requiredSeconds <= 0) { + return finish ? "completed" : "studying"; + } + if (value(studySeconds) >= requiredSeconds) { + return "completed"; + } + return value(studySeconds) > 0 || finish ? "studying" : "not_started"; + } + + private NutMap currentUserInfo(String userId) { + Sql sql = Sqls.create("SELECT id, loginname, username, unionId AS unionId, unionName AS unionName FROM vw_user WHERE id = @id"); + sql.params().set("id", userId); + sql.setCallback(Sqls.callback.map()); + dao.execute(sql); + NutMap user = sql.getObject(NutMap.class); + return user == null ? NutMap.NEW() : user; + } + + private void appendScope(Cnd cnd) { + if (hasSchoolScope()) { + return; + } + if (hasBranchScope()) { + cnd.and("r.unionId", "=", SecurityUtil.getUnionId()); + return; + } + cnd.and("r.userId", "=", SecurityUtil.getUserId()); + } + + private boolean hasSchoolScope() { + return AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name()); + } + + private boolean hasBranchScope() { + return AuthUtil.hasRoleOr(RoleConstant.BRANCH_UNION_ADMIN.name()); + } + + private boolean canOperate(LearningStudyRecord record) { + if (hasSchoolScope()) { + return true; + } + if (hasBranchScope()) { + return StrUtil.equals(record.getUnionId(), SecurityUtil.getUnionId()); + } + return StrUtil.equals(record.getUserId(), SecurityUtil.getUserId()); + } + + private String getOrderColumn(String prop) { + Map columns = new HashMap<>(); + columns.put("loginName", "r.loginName"); + columns.put("userName", "r.userName"); + columns.put("unionName", "r.unionName"); + columns.put("courseName", "r.courseName"); + columns.put("outlineName", "r.outlineName"); + columns.put("firstStudyTime", "r.firstStudyTime"); + columns.put("latestStudyTime", "r.latestStudyTime"); + columns.put("studySeconds", "r.studySeconds"); + columns.put("progressPercent", "r.progressPercent"); + columns.put("completeStatus", "r.completeStatus"); + return columns.get(prop); + } + + private String formatStudySeconds(Integer seconds) { + int value = value(seconds); + int hour = value / 3600; + int minute = value % 3600 / 60; + int second = value % 60; + if (hour > 0) { + return hour + "小时" + minute + "分" + second + "秒"; + } + if (minute > 0) { + return minute + "分" + second + "秒"; + } + return second + "秒"; + } + + private int value(Integer value) { + return value == null ? 0 : value; + } + + private int sanitizePosition(Integer positionSeconds, Integer defaultValue) { + if (positionSeconds == null || positionSeconds < 0) { + return value(defaultValue); + } + return positionSeconds; + } +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourse.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourse.java new file mode 100644 index 0000000..e3cfdfe --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourse.java @@ -0,0 +1,122 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +@Data +@Table +@EqualsAndHashCode(callSuper = true) +public class LearningCourse extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("课程名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String courseName; + + @Column + @Comment("课程类型") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseTypeId; + + @Column + @Comment("授课讲师") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String lecturerName; + + @Column + @Comment("讲师基本信息") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String lecturerInfo; + + @Column + @Comment("课程简介") + @ColDefine(customType = "longtext") + private String courseIntro; + + @Column + @Comment("适合人群") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String suitablePeople; + + @Column + @Comment("学习目标") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String learningGoal; + + @Column + @Comment("课程周期") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String coursePeriod; + + @Column + @Comment("开课类型") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String openType; + + @Column + @Comment("开始时间") + @ColDefine(type = ColType.DATETIME) + private Date startTime; + + @Column + @Comment("结束时间") + @ColDefine(type = ColType.DATETIME) + private Date endTime; + + @Column + @Comment("课程状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; + + @Column + @Comment("推荐标识") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String recommendFlags; + + @Column + @Comment("课程标签") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String courseTags; + + @Column + @Comment("学习对象") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String targetType; + + @Column + @Comment("指定组织") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String targetOrgText; + + @Column + @Comment("排序编码") + @ColDefine(type = ColType.INT) + private Integer sortNum; + + @Column + @Comment("观看次数") + @ColDefine(type = ColType.INT) + private Integer viewCount; + + @Column + @Comment("课程封面") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String cover; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseOutline.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseOutline.java new file mode 100644 index 0000000..d8fd148 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseOutline.java @@ -0,0 +1,75 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +@Data +@Table +@EqualsAndHashCode(callSuper = true) +public class LearningCourseOutline extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("所属课程ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseId; + + @Column + @Comment("所属课程名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String courseName; + + @Column + @Comment("父节点ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String parentId; + + @Column + @Comment("节点类型") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String nodeType; + + @Column + @Comment("章/节标题") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String title; + + @Column + @Comment("副标题") + @ColDefine(type = ColType.VARCHAR, width = 200) + private String subtitle; + + @Column + @Comment("简介") + @ColDefine(customType = "longtext") + private String description; + + @Column + @Comment("排序值") + @ColDefine(type = ColType.INT) + private Integer sortOrder; + + @Column + @Comment("是否必学") + @ColDefine(type = ColType.BOOLEAN) + private Boolean required; + + @Column + @Comment("状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseType.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseType.java new file mode 100644 index 0000000..ba5d47d --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningCourseType.java @@ -0,0 +1,45 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +@Data +@Table +@EqualsAndHashCode(callSuper = true) +public class LearningCourseType extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("课程类型名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String typeName; + + @Column + @Comment("排序编号") + @ColDefine(type = ColType.INT) + private Integer sortNum; + + @Column + @Comment("是否启用") + @ColDefine(type = ColType.BOOLEAN) + private Boolean enabled; + + @Column + @Comment("备注") + @ColDefine(type = ColType.VARCHAR, width = 500) + private String remark; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningOutlineResource.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningOutlineResource.java new file mode 100644 index 0000000..27648a1 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningOutlineResource.java @@ -0,0 +1,85 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +@Data +@Table +@EqualsAndHashCode(callSuper = true) +public class LearningOutlineResource extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("所属课程ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseId; + + @Column + @Comment("所属章/节ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String outlineId; + + @Column + @Comment("资料标题") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String resourceTitle; + + @Column + @Comment("资料类型") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String resourceType; + + @Column + @Comment("文件扩展名") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String fileExt; + + @Column + @Comment("附件") + @ColDefine(customType = "longtext") + private String fileData; + + @Column + @Comment("视频/音频时长") + @ColDefine(type = ColType.INT) + private Integer durationSeconds; + + @Column + @Comment("排序值") + @ColDefine(type = ColType.INT) + private Integer sortOrder; + + @Column + @Comment("是否必学") + @ColDefine(type = ColType.BOOLEAN) + private Boolean required; + + @Column + @Comment("是否允许预览") + @ColDefine(type = ColType.BOOLEAN) + private Boolean allowPreview; + + @Column + @Comment("是否允许下载") + @ColDefine(type = ColType.BOOLEAN) + private Boolean allowDownload; + + @Column + @Comment("状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRecord.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRecord.java new file mode 100644 index 0000000..6ea2a74 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRecord.java @@ -0,0 +1,124 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Index; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.entity.annotation.TableIndexes; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +@Data +@Table("learning_study_record") +@TableIndexes({ + @Index(name = "idx_learning_record_user_course_outline", fields = {"userId", "courseId", "outlineId"}, unique = true), + @Index(name = "idx_learning_record_union", fields = {"unionId"}, unique = false), + @Index(name = "idx_learning_record_course", fields = {"courseId"}, unique = false) +}) +@EqualsAndHashCode(callSuper = true) +public class LearningStudyRecord extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("学习人ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + + @Column + @Comment("工号") + @ColDefine(type = ColType.VARCHAR, width = 120) + private String loginName; + + @Column + @Comment("姓名") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String userName; + + @Column + @Comment("所属分工会ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String unionId; + + @Column + @Comment("所属分工会") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String unionName; + + @Column + @Comment("课程ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseId; + + @Column + @Comment("课程名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String courseName; + + @Column + @Comment("章节ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String outlineId; + + @Column + @Comment("章节名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String outlineName; + + @Column + @Comment("第一次进入时间") + @ColDefine(type = ColType.DATETIME) + private Date firstStudyTime; + + @Column + @Comment("最近学习时间") + @ColDefine(type = ColType.DATETIME) + private Date latestStudyTime; + + @Column + @Comment("累计有效学习时长(秒)") + @ColDefine(type = ColType.INT) + private Integer studySeconds; + + @Column + @Comment("最近播放位置(秒)") + @ColDefine(type = ColType.INT) + private Integer lastPositionSeconds; + + @Column + @Comment("各资源最高已观看位置(JSON)") + @ColDefine(type = ColType.TEXT) + private String resourceMaxPositionData; + + @Column + @Comment("要求学习时长(秒)") + @ColDefine(type = ColType.INT) + private Integer requiredSeconds; + + @Column + @Comment("学习进度") + @ColDefine(type = ColType.INT) + private Integer progressPercent; + + @Column + @Comment("完成状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String completeStatus; + + @Column + @Comment("完成时间") + @ColDefine(type = ColType.DATETIME) + private Date completedAt; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRule.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRule.java new file mode 100644 index 0000000..03c03a3 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudyRule.java @@ -0,0 +1,100 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +@Data +@Table +@EqualsAndHashCode(callSuper = true) +public class LearningStudyRule extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("所属课程ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseId; + + @Column + @Comment("规则对象类型") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String targetType; + + @Column + @Comment("规则对象ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String targetId; + + @Column + @Comment("是否必学") + @ColDefine(type = ColType.BOOLEAN) + private Boolean required; + + @Column + @Comment("学习方式") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String studyMode; + + @Column + @Comment("完成规则") + @ColDefine(type = ColType.VARCHAR, width = 50) + private String completionRule; + + @Column + @Comment("完成比例") + @ColDefine(type = ColType.INT) + private Integer completePercent; + + @Column + @Comment("最少学习时长") + @ColDefine(type = ColType.INT) + private Integer minStudySeconds; + + @Column + @Comment("解锁规则") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String unlockRule; + + @Column + @Comment("是否允许跳过") + @ColDefine(type = ColType.BOOLEAN) + private Boolean allowSkip; + + @Column + @Comment("是否允许拖动") + @ColDefine(type = ColType.BOOLEAN) + private Boolean allowDrag; + + @Column + @Comment("暂停是否计时") + @ColDefine(type = ColType.BOOLEAN) + private Boolean pauseCountTime; + + @Column + @Comment("页面隐藏是否计时") + @ColDefine(type = ColType.BOOLEAN) + private Boolean hiddenCountTime; + + @Column + @Comment("长时间无操作是否计时") + @ColDefine(type = ColType.BOOLEAN) + private Boolean inactiveCountTime; + + @Column + @Comment("状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String status; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudySegment.java b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudySegment.java new file mode 100644 index 0000000..4a146a5 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/models/LearningStudySegment.java @@ -0,0 +1,88 @@ +package com.budwk.app.zhgh.learning.models; + +import com.budwk.app.base.model.BaseModel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.nutz.dao.entity.annotation.ColDefine; +import org.nutz.dao.entity.annotation.ColType; +import org.nutz.dao.entity.annotation.Column; +import org.nutz.dao.entity.annotation.Comment; +import org.nutz.dao.entity.annotation.Index; +import org.nutz.dao.entity.annotation.Name; +import org.nutz.dao.entity.annotation.Table; +import org.nutz.dao.entity.annotation.TableIndexes; +import org.nutz.dao.interceptor.annotation.PrevInsert; + +import java.util.Date; + +@Data +@Table("learning_study_segment") +@TableIndexes({ + @Index(name = "idx_learning_segment_record", fields = {"recordId"}, unique = false), + @Index(name = "idx_learning_segment_user_state", fields = {"userId", "state"}, unique = false) +}) +@EqualsAndHashCode(callSuper = true) +public class LearningStudySegment extends BaseModel { + + @Column + @Name + @Comment("ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + @PrevInsert(uu32 = true) + private String id; + + @Column + @Comment("学习记录ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String recordId; + + @Column + @Comment("学习人ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String userId; + + @Column + @Comment("课程ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String courseId; + + @Column + @Comment("章节ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String outlineId; + + @Column + @Comment("资源ID") + @ColDefine(type = ColType.VARCHAR, width = 32) + private String resourceId; + + @Column + @Comment("资源名称") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String resourceName; + + @Column + @Comment("开始时间") + @ColDefine(type = ColType.DATETIME) + private Date startTime; + + @Column + @Comment("结束时间") + @ColDefine(type = ColType.DATETIME) + private Date endTime; + + @Column + @Comment("最近心跳时间") + @ColDefine(type = ColType.DATETIME) + private Date lastHeartbeatTime; + + @Column + @Comment("本时段有效学习时长(秒)") + @ColDefine(type = ColType.INT) + private Integer activeSeconds; + + @Column + @Comment("状态") + @ColDefine(type = ColType.VARCHAR, width = 20) + private String state; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/param/LearningStatisticsPageForm.java b/src/main/java/com/budwk/app/zhgh/learning/param/LearningStatisticsPageForm.java new file mode 100644 index 0000000..7fefad2 --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/param/LearningStatisticsPageForm.java @@ -0,0 +1,22 @@ +package com.budwk.app.zhgh.learning.param; + +import com.budwk.app.base.param.PageForm; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public class LearningStatisticsPageForm extends PageForm { + + private String courseId; + + private String courseName; + + private String unionId; + + private String loginName; + + private String userName; + + private String completeStatus; +} diff --git a/src/main/java/com/budwk/app/zhgh/learning/service/LearningFileAccessService.java b/src/main/java/com/budwk/app/zhgh/learning/service/LearningFileAccessService.java new file mode 100644 index 0000000..48a342a --- /dev/null +++ b/src/main/java/com/budwk/app/zhgh/learning/service/LearningFileAccessService.java @@ -0,0 +1,42 @@ +package com.budwk.app.zhgh.learning.service; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.util.StrUtil; +import com.budwk.app.zhgh.learning.models.LearningOutlineResource; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.regex.Pattern; + +@IocBean +public class LearningFileAccessService { + @Inject + private Dao dao; + + public boolean check(String fileId, boolean download, HttpServletResponse response) throws IOException { + if (StrUtil.isBlank(fileId)) { + response.sendError(400, "文件ID不能为空"); + return false; + } + Pattern filePattern = Pattern.compile("(? 0; + boolean media = "video".equals(resource.getResourceType()) || "audio".equals(resource.getResourceType()); + boolean completed = positions.getInt("completed:" + id, 0) == 1 + || (required > 0 && seconds >= required) + || (!media && required == 0 && totals.getInt("finishes", 0) > 0); + int percent = completed ? 100 : required > 0 ? Math.min(99, (int) (seconds * 100.0 / required)) : 0; + int maxPosition = Math.max(0, positions.getInt(id, 0)); + return NutMap.NEW().addv("resourceId", id).addv("studied", studied) + .addv("studySeconds", seconds).addv("requiredSeconds", required) + .addv("progressPercent", percent) + .addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started") + .addv("lastPositionSeconds", Math.max(0, positions.getInt("last:" + id, maxPosition))) + .addv("maxPositionSeconds", maxPosition); + } + + // 保留原有资源最高位置键,附加独立续播位置和完成标记,不修改数据库结构。 + public void savePosition(LearningStudyRecord record, String resourceId, Integer position, Integer maxPosition, boolean completed) { + NutMap positions = positions(record); + if (position != null && position >= 0) positions.put("last:" + resourceId, position); + if (maxPosition != null) positions.put(resourceId, Math.max(positions.getInt(resourceId, 0), maxPosition)); + if (completed) positions.put("completed:" + resourceId, 1); + record.setResourceMaxPositionData(Json.toJson(positions)); + } + + public LearningStudyRule outlineRule(String outlineId) { + return dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline") + .and("targetId", "=", outlineId).and("status", "=", "enabled")); + } + + public int requiredSeconds(String outlineId) { + LearningStudyRule rule = outlineRule(outlineId); + if (rule != null && "min_study_time".equals(rule.getCompletionRule())) { + return Math.max(0, rule.getMinStudySeconds() == null ? 0 : rule.getMinStudySeconds()); + } + Sql sql = Sqls.create("SELECT COALESCE(SUM(durationSeconds), 0) FROM learning_outline_resource WHERE outlineId = @outlineId AND status = 'enabled' AND COALESCE(allowPreview, 1) = 1"); + sql.params().set("outlineId", outlineId); + sql.setCallback(Sqls.callback.integer()); + dao.execute(sql); + return Math.max(0, sql.getInt()); + } + + public NutMap outlineState(LearningStudyRecord record, LearningCourseOutline outline) { + List resources = outline != null + && "enabled".equals(outline.getStatus()) + ? dao.query(LearningOutlineResource.class, Cnd.where("outlineId", "=", outline.getId()) + .and("courseId", "=", outline.getCourseId()).and("status", "=", "enabled") + .and(Cnd.exps("allowPreview", "=", true).or("allowPreview", "is", null))) : List.of(); + int seconds = 0; + int totalPercent = 0; + boolean studied = false; + boolean completed = !resources.isEmpty(); + for (LearningOutlineResource resource : resources) { + NutMap state = state(record, resource); + seconds += state.getInt("studySeconds", 0); + totalPercent += state.getInt("progressPercent", 0); + studied |= state.getBoolean("studied", false); + completed &= "completed".equals(state.getString("completeStatus")); + } + int percent = resources.isEmpty() ? 0 : totalPercent / resources.size(); + LearningStudyRule rule = outline == null ? null : outlineRule(outline.getId()); + int required = resources.isEmpty() ? 0 : requiredSeconds(outline.getId()); + String mode = rule == null ? "all_required_resource" : rule.getCompletionRule(); + if (!resources.isEmpty() && "resource_progress".equals(mode)) { + int threshold = rule.getCompletePercent() == null ? 100 : Math.max(1, Math.min(100, rule.getCompletePercent())); + completed = studied && totalPercent >= threshold * resources.size(); + percent = (int) Math.min(100, totalPercent * 100L / (resources.size() * threshold)); + } else if (!resources.isEmpty() && "min_study_time".equals(mode)) { + completed = studied && required > 0 && seconds >= required; + percent = required > 0 ? (int) Math.min(100, seconds * 100L / required) : 0; + } + return NutMap.NEW().addv("counted", !resources.isEmpty()).addv("studySeconds", seconds) + .addv("requiredSeconds", required).addv("progressPercent", completed ? 100 : Math.min(99, percent)) + .addv("completeStatus", completed ? "completed" : studied ? "studying" : "not_started"); + } + + public NutMap courseState(String courseId, String userId) { + List records = dao.query(LearningStudyRecord.class, + Cnd.where("courseId", "=", courseId).and("userId", "=", userId)); + java.util.Map recordMap = new java.util.HashMap<>(); + for (LearningStudyRecord record : records) recordMap.put(record.getOutlineId(), record); + int count = 0, completed = 0, percent = 0, seconds = 0; + for (LearningCourseOutline outline : dao.query(LearningCourseOutline.class, + Cnd.where("courseId", "=", courseId).and("status", "=", "enabled"))) { + NutMap state = outlineState(recordMap.get(outline.getId()), outline); + if (!state.getBoolean("counted", false)) continue; + count++; + percent += state.getInt("progressPercent", 0); + seconds += state.getInt("studySeconds", 0); + if ("completed".equals(state.getString("completeStatus"))) completed++; + } + return NutMap.NEW().addv("outlineCount", count).addv("completedOutlineCount", completed) + .addv("progressPercent", count == 0 ? 0 : percent / count).addv("studySeconds", seconds); + } + + public void refreshRecords(Cnd condition) { + for (LearningStudyRecord record : dao.query(LearningStudyRecord.class, condition)) { + refreshOutline(record, requiredSeconds(record.getOutlineId())); + } + } + + public void refreshOutline(String outlineId) { + refreshRecords(Cnd.where("outlineId", "=", outlineId)); + } + + public void refreshOutline(LearningStudyRecord record, int requiredSeconds) { + NutMap state = outlineState(record, dao.fetch(LearningCourseOutline.class, record.getOutlineId())); + boolean completed = "completed".equals(state.getString("completeStatus")); + dao.update(LearningStudyRecord.class, Chain.make("studySeconds", state.getInt("studySeconds", 0)) + .add("requiredSeconds", state.getInt("requiredSeconds", 0)).add("progressPercent", state.getInt("progressPercent", 0)) + .add("completeStatus", state.getString("completeStatus")) + .add("completedAt", completed ? record.getCompletedAt() == null ? new Date() : record.getCompletedAt() : null), + Cnd.where("id", "=", record.getId())); + } + + private NutMap positions(LearningStudyRecord record) { + if (record == null || record.getResourceMaxPositionData() == null) return NutMap.NEW(); + try { + NutMap positions = Json.fromJson(NutMap.class, record.getResourceMaxPositionData()); + return positions == null ? NutMap.NEW() : positions; + } catch (Exception ignored) { + return NutMap.NEW(); + } + } +} diff --git a/src/main/resources/db/learning_platform_init.sql b/src/main/resources/db/learning_platform_init.sql new file mode 100644 index 0000000..1b7bf81 --- /dev/null +++ b/src/main/resources/db/learning_platform_init.sql @@ -0,0 +1,212 @@ +-- 学习教育平台初始化(MySQL),不包含课程、附件或学习记录。 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_course` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称', + `courseTypeId` VARCHAR(32) DEFAULT NULL COMMENT '课程类型', + `lecturerName` VARCHAR(100) DEFAULT NULL COMMENT '授课讲师', + `lecturerInfo` VARCHAR(500) DEFAULT NULL COMMENT '讲师基本信息', + `courseIntro` LONGTEXT DEFAULT NULL COMMENT '课程简介', + `suitablePeople` VARCHAR(500) DEFAULT NULL COMMENT '适合人群', + `learningGoal` VARCHAR(500) DEFAULT NULL COMMENT '学习目标', + `coursePeriod` VARCHAR(50) DEFAULT NULL COMMENT '课程周期', + `openType` VARCHAR(20) DEFAULT NULL COMMENT '开课类型', + `startTime` DATETIME DEFAULT NULL COMMENT '开始时间', + `endTime` DATETIME DEFAULT NULL COMMENT '结束时间', + `status` VARCHAR(20) DEFAULT NULL COMMENT '课程状态', + `recommendFlags` VARCHAR(500) DEFAULT NULL COMMENT '推荐标识', + `courseTags` VARCHAR(500) DEFAULT NULL COMMENT '课程标签', + `targetType` VARCHAR(20) DEFAULT NULL COMMENT '学习对象', + `targetOrgText` VARCHAR(500) DEFAULT NULL COMMENT '指定组织', + `sortNum` INT DEFAULT NULL COMMENT '排序编码', + `viewCount` INT DEFAULT NULL COMMENT '观看次数', + `cover` VARCHAR(500) DEFAULT NULL COMMENT '课程封面', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_course_outline` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID', + `courseName` VARCHAR(100) DEFAULT NULL COMMENT '所属课程名称', + `parentId` VARCHAR(32) DEFAULT NULL COMMENT '父节点ID', + `nodeType` VARCHAR(20) DEFAULT NULL COMMENT '节点类型', + `title` VARCHAR(100) DEFAULT NULL COMMENT '章/节标题', + `subtitle` VARCHAR(200) DEFAULT NULL COMMENT '副标题', + `description` LONGTEXT DEFAULT NULL COMMENT '简介', + `sortOrder` INT DEFAULT NULL COMMENT '排序值', + `required` BOOLEAN DEFAULT NULL COMMENT '是否必学', + `status` VARCHAR(20) DEFAULT NULL COMMENT '状态', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_course_type` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `typeName` VARCHAR(100) DEFAULT NULL COMMENT '课程类型名称', + `sortNum` INT DEFAULT NULL COMMENT '排序编号', + `enabled` BOOLEAN DEFAULT NULL COMMENT '是否启用', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_outline_resource` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID', + `outlineId` VARCHAR(32) DEFAULT NULL COMMENT '所属章/节ID', + `resourceTitle` VARCHAR(100) DEFAULT NULL COMMENT '资料标题', + `resourceType` VARCHAR(20) DEFAULT NULL COMMENT '资料类型', + `fileExt` VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名', + `fileData` LONGTEXT DEFAULT NULL COMMENT '附件', + `durationSeconds` INT DEFAULT NULL COMMENT '视频/音频时长', + `sortOrder` INT DEFAULT NULL COMMENT '排序值', + `required` BOOLEAN DEFAULT NULL COMMENT '是否必学', + `allowPreview` BOOLEAN DEFAULT NULL COMMENT '是否允许预览', + `allowDownload` BOOLEAN DEFAULT NULL COMMENT '是否允许下载', + `status` VARCHAR(20) DEFAULT NULL COMMENT '状态', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_study_record` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID', + `loginName` VARCHAR(120) DEFAULT NULL COMMENT '工号', + `userName` VARCHAR(100) DEFAULT NULL COMMENT '姓名', + `unionId` VARCHAR(32) DEFAULT NULL COMMENT '所属分工会ID', + `unionName` VARCHAR(100) DEFAULT NULL COMMENT '所属分工会', + `courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID', + `courseName` VARCHAR(100) DEFAULT NULL COMMENT '课程名称', + `outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID', + `outlineName` VARCHAR(100) DEFAULT NULL COMMENT '章节名称', + `firstStudyTime` DATETIME DEFAULT NULL COMMENT '第一次进入时间', + `latestStudyTime` DATETIME DEFAULT NULL COMMENT '最近学习时间', + `studySeconds` INT DEFAULT NULL COMMENT '累计有效学习时长(秒)', + `lastPositionSeconds` INT DEFAULT NULL COMMENT '最近播放位置(秒)', + `resourceMaxPositionData` TEXT DEFAULT NULL COMMENT '各资源最高已观看位置(JSON)', + `requiredSeconds` INT DEFAULT NULL COMMENT '要求学习时长(秒)', + `progressPercent` INT DEFAULT NULL COMMENT '学习进度', + `completeStatus` VARCHAR(20) DEFAULT NULL COMMENT '完成状态', + `completedAt` DATETIME DEFAULT NULL COMMENT '完成时间', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_learning_record_user_course_outline` (`userId`, `courseId`, `outlineId`), + KEY `idx_learning_record_union` (`unionId`), + KEY `idx_learning_record_course` (`courseId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_study_rule` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `courseId` VARCHAR(32) DEFAULT NULL COMMENT '所属课程ID', + `targetType` VARCHAR(20) DEFAULT NULL COMMENT '规则对象类型', + `targetId` VARCHAR(32) DEFAULT NULL COMMENT '规则对象ID', + `required` BOOLEAN DEFAULT NULL COMMENT '是否必学', + `studyMode` VARCHAR(20) DEFAULT NULL COMMENT '学习方式', + `completionRule` VARCHAR(50) DEFAULT NULL COMMENT '完成规则', + `completePercent` INT DEFAULT NULL COMMENT '完成比例', + `minStudySeconds` INT DEFAULT NULL COMMENT '最少学习时长', + `unlockRule` VARCHAR(20) DEFAULT NULL COMMENT '解锁规则', + `allowSkip` BOOLEAN DEFAULT NULL COMMENT '是否允许跳过', + `allowDrag` BOOLEAN DEFAULT NULL COMMENT '是否允许拖动', + `pauseCountTime` BOOLEAN DEFAULT NULL COMMENT '暂停是否计时', + `hiddenCountTime` BOOLEAN DEFAULT NULL COMMENT '页面隐藏是否计时', + `inactiveCountTime` BOOLEAN DEFAULT NULL COMMENT '长时间无操作是否计时', + `status` VARCHAR(20) DEFAULT NULL COMMENT '状态', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `learning_study_segment` ( + `id` VARCHAR(32) NOT NULL COMMENT 'ID', + `recordId` VARCHAR(32) DEFAULT NULL COMMENT '学习记录ID', + `userId` VARCHAR(32) DEFAULT NULL COMMENT '学习人ID', + `courseId` VARCHAR(32) DEFAULT NULL COMMENT '课程ID', + `outlineId` VARCHAR(32) DEFAULT NULL COMMENT '章节ID', + `resourceId` VARCHAR(32) DEFAULT NULL COMMENT '资源ID', + `resourceName` VARCHAR(100) DEFAULT NULL COMMENT '资源名称', + `startTime` DATETIME DEFAULT NULL COMMENT '开始时间', + `endTime` DATETIME DEFAULT NULL COMMENT '结束时间', + `lastHeartbeatTime` DATETIME DEFAULT NULL COMMENT '最近心跳时间', + `activeSeconds` INT DEFAULT NULL COMMENT '本时段有效学习时长(秒)', + `state` VARCHAR(20) DEFAULT NULL COMMENT '状态', + `createdBy` VARCHAR(32) DEFAULT NULL, + `createdAt` BIGINT DEFAULT NULL, + `updatedBy` VARCHAR(32) DEFAULT NULL, + `updatedAt` BIGINT DEFAULT NULL, + `delFlag` BOOLEAN DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_learning_segment_record` (`recordId`), + KEY `idx_learning_segment_user_state` (`userId`, `state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +START TRANSACTION; + +INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('a03149321797435d9d54f9ba03cb275b', 'H5', '培训教育', '/platform/sys/file/download?id=tveo21cc2ki3toav43rpvtankg', 31, '17a7f8ad3ee947b4a26175049a9c253d', 1757990226056, '17a7f8ad3ee947b4a26175049a9c253d', 1764214972112, false); +INSERT INTO sys_module (id, platform, name, icon, sortNum, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('b1ae9f3fcb164be9a8d857f3acf3c3bd', 'PC', '培训教育', '/platform/sys/file/download?id=p7if6ffrrshi7ppttcaajkvdbt', 9, '17a7f8ad3ee947b4a26175049a9c253d', 1756798359497, '17a7f8ad3ee947b4a26175049a9c253d', 1770772532570, false); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80001', '', '0167', '学习教育平台', 'Learning', 'menu', '', '', 'ti-book', true, false, 'learning', null, 611, false, '1af78f95c16c4504a8bd714d9e76def3', 1777739844000, '17a7f8ad3ee947b4a26175049a9c253d', 1780199079929, false, null, 'PC', 'b1ae9f3fcb164be9a8d857f3acf3c3bd', false); + +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80001'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80001'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80001'); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('3e9a7611fa96490d8adc393387b1d65a', '', '0170', '我的学习', null, 'menu', '/platform/learning/my/record/h5', null, null, true, false, 'learning.my.record.h5', null, 1005, false, '1af78f95c16c4504a8bd714d9e76def3', 1779111719650, '1af78f95c16c4504a8bd714d9e76def3', 1780209965809, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('82946217f37e466d87f123c6987991e1', '', '0170', '学习教育', '', 'menu', '/platform/learning/course/h5', null, null, true, false, 'learning.course.h5', null, 1004, false, '1af78f95c16c4504a8bd714d9e76def3', 1779011454247, '1af78f95c16c4504a8bd714d9e76def3', 1780209948730, false, null, 'H5', 'a03149321797435d9d54f9ba03cb275b', false); + +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '82946217f37e466d87f123c6987991e1'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', '3e9a7611fa96490d8adc393387b1d65a'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '82946217f37e466d87f123c6987991e1'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', '3e9a7611fa96490d8adc393387b1d65a'); + +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80002', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670001', '课程类型设置', 'Course Type', 'menu', '/platform/learning/course/type', 'data-pjax', '', true, false, 'learning.course.type', null, 612, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80003', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670002', '课程管理', 'Course Manage', 'menu', '/platform/learning/course/manage', 'data-pjax', '', true, false, 'learning.course.manage', null, 613, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80004', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670003', '章节内容管理', 'Chapter Content', 'menu', '/platform/learning/chapter/content', 'data-pjax', '', true, false, 'learning.chapter.content', null, 614, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80005', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670004', '课程展示', 'Course Display', 'menu', '/platform/learning/course/display', 'data-pjax', '', true, false, 'learning.course.display', null, 615, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80007', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670006', '学习统计', 'Learning Statistics', 'menu', '/platform/learning/statistics', 'data-pjax', '', true, false, 'learning.statistics', null, 617, false, '', 1777739844000, '', 1777739844000, false, null, 'PC', null, false); +INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled, permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, processKey, platform, moduleId, isQuickEntry) VALUES ('a0f47f6a6d734c8b9c6ce5c0dfd80008', 'a0f47f6a6d734c8b9c6ce5c0dfd80001', '01670007', '我的学习记录', 'My Learning Record', 'menu', '/platform/learning/my/record', 'data-pjax', '', true, false, 'learning.my.record', null, 618, false, '', 1777792288000, '', 1777792288000, false, null, 'PC', null, false); + +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80005'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('6d603b652abee584ba6274dc1fcee3bb', 'a0f47f6a6d734c8b9c6ce5c0dfd80008'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('fd753e8a8c5a404e8e00ef8e0b0c69da', 'a0f47f6a6d734c8b9c6ce5c0dfd80008'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80002'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80003'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80004'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80005'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80007'); +INSERT INTO sys_role_menu (roleId, menuId) VALUES ('7f4a820d5a394bb8a0a92d6699afe7c8', 'a0f47f6a6d734c8b9c6ce5c0dfd80008'); + +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('077f4c7f51ec41cc9e47559a6b8bf6d5', '', '0038', '学习教育', 'LEARNING', false, 991, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, '17a7f8ad3ee947b4a26175049a9c253d', 1777768520956, false); +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('5477dc43ee1044108aae0e6241d3a87a', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020004', '专题课程', '专题课程', false, 1002, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, '17a7f8ad3ee947b4a26175049a9c253d', 1777769110156, false); +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('6fa4bb9701e34511ab86ae3cbc39a053', '077f4c7f51ec41cc9e47559a6b8bf6d5', '00380002', '推荐标识', 'Recommended Mark', false, 993, true, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764849, '17a7f8ad3ee947b4a26175049a9c253d', 1777768764850, false); +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('7863497c73d14e5bb14245d4e7f60089', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020002', '新开课程', '新开课程', false, 1000, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768964121, '17a7f8ad3ee947b4a26175049a9c253d', 1777769085321, false); +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('d8afd3f8e1fe4c25a8089a810707b433', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020001', '热门课程', '热门课程', false, 999, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768946919, '17a7f8ad3ee947b4a26175049a9c253d', 1777769069517, false); +INSERT INTO sys_dict (id, parentId, path, name, code, disabled, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag) VALUES ('e1dbf9e5dd8a45ea89cec68eb36d901f', '6fa4bb9701e34511ab86ae3cbc39a053', '003800020003', '推荐课程', '推荐课程', false, 1001, false, '17a7f8ad3ee947b4a26175049a9c253d', 1777768990516, '17a7f8ad3ee947b4a26175049a9c253d', 1777769094915, false); + +COMMIT; diff --git a/src/main/resources/ioc/upload.json b/src/main/resources/ioc/upload.json index d1bce19..bfc5d70 100644 --- a/src/main/resources/ioc/upload.json +++ b/src/main/resources/ioc/upload.json @@ -32,7 +32,7 @@ var ioc={ // 单个文件最大尺寸(大约的值,单位为字节,即 209715200 为 200M) maxFileSize : 209715200, // 正则表达式匹配可以支持的文件名 - nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4)$' + nameFilter : '^(.+[.])(gif||svg|jpg|png|jpeg|doc|docx|xls|xlsx|rar|zip|7z|txt|pdf|pptx|ppt|mp4|(?i:mp3|wav|aac))$' } }, fileUpload : { diff --git a/src/main/resources/static/components/plugins/sysPageFormSearch/search.vue b/src/main/resources/static/components/plugins/sysPageFormSearch/search.vue index 6eec213..9035c3e 100644 --- a/src/main/resources/static/components/plugins/sysPageFormSearch/search.vue +++ b/src/main/resources/static/components/plugins/sysPageFormSearch/search.vue @@ -1,7 +1,7 @@