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 8cc9c89..cdfd36e 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysFileController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysFileController.java @@ -15,6 +15,7 @@ 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; @@ -52,6 +53,9 @@ public class SysFileController { private SysConfigService sysConfigService; private SysFileMinIoUtil sysFileMinIoUtil; + @Inject + private LearningFileAccessService learningFileAccessService; + @At("") @Ok("beetl:/platform/sys/file/index.html") @SaCheckPermission("sys.manager.file") @@ -124,6 +128,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); } @@ -132,6 +137,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); } @@ -170,11 +176,82 @@ public class SysFileController { return Result.success(files); } + @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) { + 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()); 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 index c29803b..1949fd3 100644 --- a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningChapterContentController.java +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningChapterContentController.java @@ -11,6 +11,7 @@ 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; @@ -37,6 +38,9 @@ public class LearningChapterContentController { @Inject private Dao dao; + @Inject + private LearningResourceProgressService resourceProgressService; + @At("") @Ok("beetl:/platform/zhgh/Learning/chapterContent/index.html") @SaCheckPermission("learning.chapter.content") @@ -132,7 +136,8 @@ public class LearningChapterContentController { } else { dao.updateIgnoreNull(outline); } - ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), "enabled"); + ensureRule(outline.getCourseId(), "outline", outline.getId(), outline.getRequired(), outline.getStatus()); + resourceProgressService.refreshOutline(outline.getId()); return Result.success(); } @@ -163,6 +168,7 @@ public class LearningChapterContentController { } 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(); } @@ -223,7 +229,7 @@ public class LearningChapterContentController { if (resource.getRequired() == null) { resource.setRequired(false); } - if (resource.getAllowPreview() == null) { + if (Boolean.TRUE.equals(resource.getRequired()) || resource.getAllowPreview() == null) { resource.setAllowPreview(true); } if (resource.getAllowDownload() == null) { @@ -241,6 +247,7 @@ public class LearningChapterContentController { dao.updateIgnoreNull(resource); } ensureRule(resource.getCourseId(), "resource", resource.getId(), resource.getRequired(), resource.getStatus()); + resourceProgressService.refreshOutline(resource.getOutlineId()); return Result.success(); } @@ -252,8 +259,10 @@ public class LearningChapterContentController { 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(); } @@ -265,6 +274,7 @@ public class LearningChapterContentController { if (rule == null) { rule = defaultRule(courseId, targetType, targetId, false, "enabled"); } + if ("manual".equals(rule.getCompletionRule())) rule.setCompletionRule("all_required_resource"); return Result.success(rule); } @@ -278,6 +288,18 @@ public class LearningChapterContentController { 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); @@ -287,9 +309,14 @@ public class LearningChapterContentController { } 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())) { - dao.update(LearningOutlineResource.class, Chain.make("required", rule.getRequired()).add("status", rule.getStatus()), Cnd.where("id", "=", rule.getTargetId())); + 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(); } 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 index 78c8025..6053b18 100644 --- a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseDisplayController.java +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningCourseDisplayController.java @@ -21,6 +21,8 @@ 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; @@ -60,6 +62,12 @@ public class LearningCourseDisplayController { @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") @@ -86,15 +94,19 @@ public class LearningCourseDisplayController { * pageForm 传分页页码和每页条数;keyword、courseTypeId、recommendFlag 分别用于关键词、分类和推荐标识筛选; * sortType 支持 default(综合排序)和 view_count(观看次数倒序)。返回值为 Pagination,list 中包含课程基础字段、课节数、总时长、观看次数和当前用户学习进度。 */ - public Result pageData(PageForm pageForm, String keyword, String courseTypeId, String recommendFlag, String sortType) { + 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("c.recommendFlags", "like", "%" + 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(""" @@ -114,11 +126,13 @@ public class LearningCourseDisplayController { 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), 0) AS progressPercent, + 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') AS resourceCount, + 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'), 0) AS totalDurationSeconds + 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 @@ -127,7 +141,7 @@ public class LearningCourseDisplayController { listSql.setCondition(cnd); listSql.params().set("userId", SecurityUtil.getUserId()); // 排序字段由固定选项映射,禁止将前端参数直接拼接进SQL。 - listSql.setVar("orderBy", "view_count".equals(sortType) + 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())); @@ -135,6 +149,7 @@ public class LearningCourseDisplayController { 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))); @@ -161,9 +176,9 @@ public class LearningCourseDisplayController { 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') AS resourceCount, + 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'), 0) AS totalDurationSeconds + 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 @@ -179,6 +194,7 @@ public class LearningCourseDisplayController { 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); } @@ -212,12 +228,14 @@ public class LearningCourseDisplayController { .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); } @@ -233,22 +251,26 @@ public class LearningCourseDisplayController { // 章节学习规则控制其下媒体播放行为;未配置规则时保持原有的可拖动行为。 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", resource.getFileData()) + .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", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getProgressPercent() : 0) - .addv("studySeconds", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getStudySeconds() : 0) - .addv("lastPositionSeconds", recordMap.containsKey(resource.getOutlineId()) ? value(recordMap.get(resource.getOutlineId()).getLastPositionSeconds()) : 0) - .addv("maxPositionSeconds", resourceMaxPosition(recordMap.get(resource.getOutlineId()), resource.getId())) + .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", recordMap.containsKey(resource.getOutlineId()) ? recordMap.get(resource.getOutlineId()).getCompleteStatus() : "not_started"); + .addv("completeStatus", resourceState.getString("completeStatus", "not_started")); childrenMap.computeIfAbsent(resource.getOutlineId(), key -> new ArrayList<>()).add(node); } return Result.success(roots); @@ -259,6 +281,7 @@ public class LearningCourseDisplayController { @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; 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 index 251af0d..dcd21e7 100644 --- a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStatisticsController.java +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStatisticsController.java @@ -10,6 +10,7 @@ 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; @@ -39,6 +40,9 @@ public class LearningStatisticsController { @Inject private Dao dao; + @Inject + private LearningResourceProgressService resourceProgressService; + @At("") @Ok("beetl:/platform/zhgh/Learning/statistics/index.html") @SaCheckPermission("learning.statistics") @@ -49,6 +53,7 @@ public class LearningStatisticsController { @ApiOperation("课程统计") @SaCheckPermission("learning.statistics") public Result coursePageData(LearningStatisticsPageForm pageForm) { + refreshProgress(pageForm); Cnd cnd = courseCondition(pageForm); Sql countSql = Sqls.create(""" SELECT COUNT(1) @@ -63,21 +68,21 @@ public class LearningStatisticsController { SELECT c.id AS courseId, c.courseName, - IFNULL(COUNT(uc.userId), 0) AS learnerCount, - IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount, + 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, - CASE - WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0 - THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) - ELSE COUNT(1) - END AS targetCount, + 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 ( @@ -87,7 +92,6 @@ public class LearningStatisticsController { SUM(IFNULL(r.studySeconds, 0)) AS studySeconds, COUNT(DISTINCT CASE WHEN r.completeStatus = 'completed' - AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0) THEN r.outlineId ELSE NULL END) AS completedOutlineCount @@ -96,14 +100,14 @@ public class LearningStatisticsController { JOIN ( SELECT courseId, - CASE - WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0 - THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) - ELSE COUNT(1) - END AS targetCount, + 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 @@ -120,7 +124,10 @@ public class LearningStatisticsController { 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)))); + 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)); } @@ -128,6 +135,7 @@ public class LearningStatisticsController { @ApiOperation("分工会统计") @SaCheckPermission("learning.statistics") public Result unionPageData(LearningStatisticsPageForm pageForm) { + refreshProgress(pageForm); Cnd cnd = unionCondition(pageForm); Sql countSql = Sqls.create(""" SELECT COUNT(1) @@ -152,9 +160,9 @@ public class LearningStatisticsController { c.courseName, un.id AS unionId, IFNULL(un.name, uc.unionName) AS unionName, - COUNT(uc.userId) AS learnerCount, + COUNT(DISTINCT uc.userId) AS learnerCount, IFNULL(SUM(uc.studySeconds), 0) AS studySeconds, - IFNULL(SUM(CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN 1 ELSE 0 END), 0) AS completedCount + COUNT(DISTINCT CASE WHEN target.targetCount > 0 AND uc.completedOutlineCount >= target.targetCount THEN uc.userId END) AS completedCount FROM learning_course c JOIN ( SELECT @@ -165,7 +173,6 @@ public class LearningStatisticsController { SUM(IFNULL(r.studySeconds, 0)) AS studySeconds, COUNT(DISTINCT CASE WHEN r.completeStatus = 'completed' - AND ((target.requiredCount > 0 AND o.required = 1) OR target.requiredCount = 0) THEN r.outlineId ELSE NULL END) AS completedOutlineCount @@ -174,14 +181,14 @@ public class LearningStatisticsController { JOIN ( SELECT courseId, - CASE - WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0 - THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) - ELSE COUNT(1) - END AS targetCount, + 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 @@ -191,14 +198,14 @@ public class LearningStatisticsController { LEFT JOIN ( SELECT courseId, - CASE - WHEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) > 0 - THEN SUM(CASE WHEN required = 1 THEN 1 ELSE 0 END) - ELSE COUNT(1) - END AS targetCount, + 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 @@ -216,11 +223,65 @@ public class LearningStatisticsController { int learnerCount = row.getInt("learnerCount", 0); int completedCount = row.getInt("completedCount", 0); row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0))); - row.put("completeRate", learnerCount == 0 ? "0%" : BigDecimal.valueOf(completedCount * 100.0 / learnerCount).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + "%"); + 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") @@ -244,6 +305,17 @@ public class LearningStatisticsController { 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()); @@ -258,8 +330,11 @@ public class LearningStatisticsController { } private String recordWhere(LearningStatisticsPageForm pageForm) { - StringBuilder where = new StringBuilder("WHERE 1 = 1"); - if (hasBranchScope()) { + 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("'"); @@ -289,6 +364,7 @@ public class LearningStatisticsController { columns.put("learnerCount", "learnerCount"); columns.put("completedCount", "completedCount"); columns.put("avgStudySeconds", "avgStudySeconds"); + columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)"); return columns.get(prop); } @@ -298,7 +374,8 @@ public class LearningStatisticsController { columns.put("unionName", "unionName"); columns.put("learnerCount", "learnerCount"); columns.put("studySeconds", "studySeconds"); - columns.put("completeRate", "completedCount"); + columns.put("completeRate", "completedCount / NULLIF(learnerCount, 0)"); + columns.put("completedCount", "completedCount"); return columns.get(prop); } @@ -314,6 +391,11 @@ public class LearningStatisticsController { 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; 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 index 44cdc4c..9a0507d 100644 --- a/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStudyRecordController.java +++ b/src/main/java/com/budwk/app/zhgh/learning/controller/LearningStudyRecordController.java @@ -16,6 +16,7 @@ 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; @@ -50,6 +51,9 @@ public class LearningStudyRecordController { @Inject private Dao dao; + @Inject + private LearningResourceProgressService resourceProgressService; + @At @ApiOperation("学习记录分页") @SaCheckPermission("learning.my.record") @@ -58,6 +62,7 @@ public class LearningStudyRecordController { @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(); @@ -68,7 +73,7 @@ public class LearningStudyRecordController { cnd.andEX("r.unionId", "=", unionId); cnd.andEX("r.courseId", "=", courseId); cnd.andEX("r.completeStatus", "=", completeStatus); - appendScope(cnd); + cnd.and("r.userId", "=", SecurityUtil.getUserId()); String orderColumn = getOrderColumn(pageForm.getPageOrderName()); if (StrUtil.isNotBlank(orderColumn) && StrUtil.isNotBlank(pageForm.getPageOrderBy())) { @@ -101,22 +106,21 @@ public class LearningStudyRecordController { @SaCheckPermission("learning.my.record") public Result h5Summary() { String userId = SecurityUtil.getUserId(); - Sql sql = Sqls.create(""" - SELECT IFNULL(SUM(IFNULL(studySeconds, 0)), 0) AS studySeconds, - COUNT(DISTINCT courseId) AS courseCount, - COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN courseId END) AS completedCourseCount, - COUNT(DISTINCT outlineId) AS outlineCount, - COUNT(DISTINCT CASE WHEN completeStatus = 'completed' THEN outlineId END) AS completedOutlineCount - FROM learning_study_record - WHERE userId = @userId - """); - sql.params().set("userId", userId); - sql.setCallback(Sqls.callback.map()); - dao.execute(sql); - NutMap data = sql.getObject(NutMap.class); - if (data == null) { - data = NutMap.NEW(); + 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); @@ -140,6 +144,8 @@ public class LearningStudyRecordController { 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 """); @@ -147,7 +153,14 @@ public class LearningStudyRecordController { sql.setCallback(Sqls.callback.maps()); dao.execute(sql); List rows = sql.getList(NutMap.class); - rows.forEach(row -> row.put("studyTimeText", formatStudySeconds(row.getInt("studySeconds", 0)))); + 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); } @@ -170,6 +183,9 @@ public class LearningStudyRecordController { 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("章节不存在"); @@ -184,32 +200,17 @@ public class LearningStudyRecordController { .and("outlineId", "=", outline.getId())); if (record == null) { record = buildRecord(userId, course, outline, now); - int startPositionSeconds = sanitizePosition(positionSeconds, 0); - record.setLastPositionSeconds(startPositionSeconds); - record.setResourceMaxPositionData(resourceMaxPositionData(resourceId, startPositionSeconds)); dao.insert(record); } else { - int requiredSeconds = requiredSeconds(outline.getId()); - int startPositionSeconds = sanitizePosition(positionSeconds, 0); - Map resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData()); - int maxPositionSeconds = value(resourceMaxPositions.get(resourceId)); - boolean needUpdateMaxPosition = !resourceMaxPositions.containsKey(resourceId) || startPositionSeconds > maxPositionSeconds; - if (needUpdateMaxPosition) { - resourceMaxPositions.put(resourceId, Math.max(startPositionSeconds, maxPositionSeconds)); - } - Chain chain = Chain.make("latestStudyTime", now) - .add("courseName", course.getCourseName()) - .add("outlineName", outline.getTitle()) - .add("lastPositionSeconds", sanitizePosition(positionSeconds, record.getLastPositionSeconds())) - .add("requiredSeconds", requiredSeconds) - .add("progressPercent", progress(record.getStudySeconds(), requiredSeconds)) - .add("completeStatus", "completed".equals(record.getCompleteStatus()) ? "completed" : "studying"); - if (needUpdateMaxPosition) { - chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions)); - } - dao.update(LearningStudyRecord.class, chain, Cnd.where("id", "=", record.getId())); - record = dao.fetch(LearningStudyRecord.class, record.getId()); + 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()); @@ -230,16 +231,12 @@ public class LearningStudyRecordController { Cnd.where("id", "=", courseId)); LearningCourse updatedCourse = dao.fetch(LearningCourse.class, courseId); - return Result.success(NutMap.NEW() - .addv("recordId", record.getId()) - .addv("segmentId", segment.getId()) - .addv("studySeconds", record.getStudySeconds()) - .addv("lastPositionSeconds", record.getLastPositionSeconds()) - .addv("maxPositionSeconds", resourceMaxPosition(record, resourceId)) - .addv("requiredSeconds", record.getRequiredSeconds()) - .addv("progressPercent", record.getProgressPercent()) - .addv("completeStatus", record.getCompleteStatus()) - .addv("viewCount", value(updatedCourse == null ? null : updatedCourse.getViewCount()))); + 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 @@ -293,11 +290,15 @@ public class LearningStudyRecordController { if (resource == null || !courseId.equals(resource.getCourseId())) { return Result.success(); } - dao.update(LearningStudyRecord.class, - Chain.make("lastPositionSeconds", positionSeconds).add("latestStudyTime", new Date()), - Cnd.where("userId", "=", SecurityUtil.getUserId()) - .and("courseId", "=", courseId) + 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(); } @@ -366,53 +367,57 @@ public class LearningStudyRecordController { private NutMap addActiveSeconds(LearningStudySegment segment, int activeSeconds, boolean finish, boolean completed, Integer positionSeconds) { Date now = new Date(); - LearningStudyRecord record = dao.fetch(LearningStudyRecord.class, segment.getRecordId()); + LearningStudyRecord record = resourceProgressService.lock(segment.getRecordId()); LearningOutlineResource resource = dao.fetch(LearningOutlineResource.class, segment.getResourceId()); - int studyIncrementSeconds = effectiveStudySeconds(record, resource, segment.getResourceId(), activeSeconds, positionSeconds); - int segmentSeconds = value(segment.getActiveSeconds()) + studyIncrementSeconds; - dao.update(LearningStudySegment.class, Chain.make("activeSeconds", segmentSeconds).add("lastHeartbeatTime", now), Cnd.where("id", "=", segment.getId())); - - int studySeconds = value(record.getStudySeconds()) + studyIncrementSeconds; - int requiredSeconds = requiredSeconds(record.getOutlineId()); - int maxPositionSeconds = resourceMaxPosition(record, segment.getResourceId()); - // 自然播放结束且当前章节仅有一个媒体资源时,补齐定时器未统计到的尾部秒数,避免跨端状态不一致。 - if (completed && canCompleteByNaturalEnd(segment, positionSeconds, requiredSeconds)) { - studySeconds = Math.max(studySeconds, requiredSeconds); + segment = dao.fetch(LearningStudySegment.class, segment.getId()); + if (!"studying".equals(segment.getState())) { + return resourceProgressService.state(record, resource); } - int progressPercent = progress(studySeconds, requiredSeconds); - String completeStatus = completeStatus(studySeconds, requiredSeconds, finish); - if ("completed".equals(completeStatus)) { - progressPercent = 100; + 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); } - Chain chain = Chain.make("latestStudyTime", now) - .add("studySeconds", studySeconds) - .add("requiredSeconds", requiredSeconds) - .add("progressPercent", progressPercent) - .add("completeStatus", completeStatus); - int lastPositionSeconds = value(record.getLastPositionSeconds()); - if (positionSeconds != null && positionSeconds >= 0) { - lastPositionSeconds = positionSeconds; - chain.add("lastPositionSeconds", positionSeconds); - } - if (resource != null && isMediaResource(resource)) { - Map resourceMaxPositions = resourceMaxPositions(record.getResourceMaxPositionData()); - int currentPositionSeconds = Math.max(0, positionSeconds == null ? 0 : positionSeconds); - maxPositionSeconds = Math.max(value(resourceMaxPositions.get(segment.getResourceId())), currentPositionSeconds); - resourceMaxPositions.put(segment.getResourceId(), maxPositionSeconds); - chain.add("resourceMaxPositionData", Json.toJson(resourceMaxPositions)); - } - if ("completed".equals(completeStatus) && record.getCompletedAt() == null) { - chain.add("completedAt", now); + 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())); - return NutMap.NEW() - .addv("studySeconds", studySeconds) - .addv("studyTimeText", formatStudySeconds(studySeconds)) - .addv("lastPositionSeconds", lastPositionSeconds) - .addv("maxPositionSeconds", maxPositionSeconds) - .addv("requiredSeconds", requiredSeconds) - .addv("progressPercent", progressPercent) - .addv("completeStatus", completeStatus); + 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) { @@ -431,21 +436,7 @@ public class LearningStudyRecordController { } private int requiredSeconds(String outlineId) { - LearningStudyRule rule = dao.fetch(LearningStudyRule.class, Cnd.where("targetType", "=", "outline") - .and("targetId", "=", outlineId) - .and("status", "=", "enabled")); - if (rule != null && value(rule.getMinStudySeconds()) > 0) { - return rule.getMinStudySeconds(); - } - Sql sql = Sqls.create(""" - SELECT IFNULL(SUM(IFNULL(durationSeconds, 0)), 0) - FROM learning_outline_resource - WHERE outlineId = @outlineId AND status = 'enabled' - """); - sql.params().set("outlineId", outlineId); - sql.setCallback(Sqls.callback.integer()); - dao.execute(sql); - return Math.max(0, sql.getInt()); + return resourceProgressService.requiredSeconds(outlineId); } /** 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 index 23b554b..7fefad2 100644 --- a/src/main/java/com/budwk/app/zhgh/learning/param/LearningStatisticsPageForm.java +++ b/src/main/java/com/budwk/app/zhgh/learning/param/LearningStatisticsPageForm.java @@ -13,4 +13,10 @@ public class LearningStatisticsPageForm extends PageForm { 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/views/platform/zhgh/Learning/chapterContent/index.html b/src/main/resources/views/platform/zhgh/Learning/chapterContent/index.html index 9027f2a..d7e301c 100644 --- a/src/main/resources/views/platform/zhgh/Learning/chapterContent/index.html +++ b/src/main/resources/views/platform/zhgh/Learning/chapterContent/index.html @@ -47,21 +47,8 @@ layout("/layouts/platform.html"){ - - - - - - - - - - - - -
- 搜索 + 搜索 重置
@@ -100,7 +87,7 @@ layout("/layouts/platform.html"){ - + @@ -179,7 +166,7 @@ layout("/layouts/platform.html"){ - + @@ -215,6 +202,7 @@ layout("/layouts/platform.html"){ + + 保存完成规则 - + @@ -345,8 +336,8 @@ layout("/layouts/platform.html"){ - - + + @@ -433,7 +424,7 @@ layout("/layouts/platform.html"){ { name: "混合资料", code: "mixed" } ], completionRuleOptions: [ - { name: "完成所有必学资料", code: "all_required_resource" }, + { name: "完成全部学习资料", code: "all_required_resource" }, { name: "按资料学习进度", code: "resource_progress" }, { name: "达到最少学习时长", code: "min_study_time" }, { name: "管理员手动确认", code: "manual" } @@ -727,10 +718,22 @@ layout("/layouts/platform.html"){ status: "enabled" } } + if (this.resourceForm.required) this.$set(this.resourceForm, "allowPreview", true) this.resourceTypeChange(this.resourceForm.resourceType) this.resourceDialogVisible = true this.$nextTick(() => this.$refs.resourceForm && this.$refs.resourceForm.clearValidate()) }, + resourceRequiredChange(required) { + if (required) this.$set(this.resourceForm, "allowPreview", true) + }, + resourcePreviewChange(allowPreview) { + if (!allowPreview && this.resourceForm.required) { + this.$set(this.resourceForm, "allowPreview", true) + this.$message.warning("必学资料必须允许预览,请先关闭“是否必学”") + return + } + this.$set(this.resourceForm, "allowPreview", allowPreview) + }, resourceTypeChange(val) { const type = this.resourceTypeOptions.find(v => v.code === val) this.fileAccept = type ? type.accept : "" @@ -842,7 +845,9 @@ layout("/layouts/platform.html"){ media.onerror = () => { window.URL.revokeObjectURL(media.src) } - media.src = url + const fileId = url.match(/[?&]id=([^&]+)/) + media.src = this.resourceForm.resourceType === "audio" && fileId + ? "/platform/sys/file/preview?id=" + fileId[1] : url }, validResourceExt(resourceType, fileExt) { const item = this.resourceTypeOptions.find(v => v.code === resourceType) diff --git a/src/main/resources/views/platform/zhgh/Learning/courseDisplay/indexV2.html b/src/main/resources/views/platform/zhgh/Learning/courseDisplay/indexV2.html index d334ba6..fb2b2b0 100644 --- a/src/main/resources/views/platform/zhgh/Learning/courseDisplay/indexV2.html +++ b/src/main/resources/views/platform/zhgh/Learning/courseDisplay/indexV2.html @@ -27,9 +27,9 @@ layout("/layouts/platform.html"){ .course-list { display: flex; flex-direction: column; gap: 16px; } .course-card { min-height: 160px; display: grid; grid-template-columns: 236px minmax(0, 1fr) 112px; overflow: hidden; background: #fff; border: 0; border-radius: 10px; box-shadow: 0 5px 18px rgba(30, 66, 115, .08); transition: box-shadow .2s, transform .2s; } .course-card:hover { transform: translateY(-1px); box-shadow: 0 9px 24px rgba(30, 67, 119, .12); } - .course-cover { position: relative; min-height: 160px; overflow: hidden; background: #eaf5ee; } - .course-cover img { width: 100%; height: 100%; display: block; object-fit: cover; } - .course-cover-empty { width: 100%; height: 100%; min-height: 160px; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); } + .course-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; align-self: center; overflow: hidden; background: #eaf5ee; } + .course-cover img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; } + .course-cover-empty { position: absolute; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #77a28a; font-size: 18px; background: linear-gradient(135deg, #e5f5ea, #f3faf6); } .course-cover-empty i { margin-right: 8px; font-size: 32px; } .course-card:nth-child(3n+2) .course-cover-empty { color: #7188ae; background: linear-gradient(135deg, #e7effc, #f3f7fe); } .course-card:nth-child(3n) .course-cover-empty { color: #bc8768; background: linear-gradient(135deg, #fff0e7, #fff8f3); } @@ -198,7 +198,7 @@ layout("/layouts/platform.html"){ return minutes ? hours + "小时" + minutes + "分钟" : hours + "小时" }, startStudy(course) { - window.location.href = loc() + "/study?id=" + encodeURIComponent(course.id || "") + pjaxReplace("/platform/learning/course/display/study?id=" + encodeURIComponent(course.id || "")) } }, created() { diff --git a/src/main/resources/views/platform/zhgh/Learning/courseDisplay/studyV2.html b/src/main/resources/views/platform/zhgh/Learning/courseDisplay/studyV2.html index df798e3..beb9594 100644 --- a/src/main/resources/views/platform/zhgh/Learning/courseDisplay/studyV2.html +++ b/src/main/resources/views/platform/zhgh/Learning/courseDisplay/studyV2.html @@ -108,11 +108,12 @@ layout("/layouts/platform.html"){
请从左侧目录选择需要学习的资料
@@ -121,7 +122,8 @@ layout("/layouts/platform.html"){
{{course.courseName}}{{course.courseTypeName || '课程'}}
正在学习:{{selectedResource.title || '请选择课节'}}
- 打开资料 + 预览资料 + 下载资料
共 {{resourceCount}} 课节 总时长 {{formatCourseDuration(course.totalDurationSeconds)}} {{course.viewCount || 0}} 次观看
@@ -130,7 +132,7 @@ layout("/layouts/platform.html"){
{{studying ? '正在记录有效学习时长:' + recordStudyTime : '播放课程后,系统将自动记录有效学习时长。'}}
- 开始学习 + 开始学习 结束学习 下一节
@@ -143,24 +145,36 @@ layout("/layouts/platform.html"){