From 342456347e62c66bfce56c68642d0fd5e753064e Mon Sep 17 00:00:00 2001 From: zhonghua Date: Tue, 24 Mar 2026 13:57:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E5=8C=85=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CoursePackageStatsService 及 GET /packages/{id}/stats 接口 - 数据统计页:总授课次数、教师数、学生数、平均评分、趋势、反馈、最近授课、学生表现 - 课程包列表数据统计列:实时计算 usageCount、teacherCount、avgRating - 前端 getCourseStats 调用真实 API Made-with: Cursor --- reading-platform-frontend/src/api/course.ts | 6 +- .../admin/AdminCourseController.java | 23 +++ .../response/CoursePackageStatsResponse.java | 112 +++++++++++ .../platform/mapper/CoursePackageMapper.java | 2 +- .../mapper/CoursePackageStatsMapper.java | 155 +++++++++++++++ .../service/CoursePackageStatsService.java | 33 ++++ .../impl/CoursePackageStatsServiceImpl.java | 182 ++++++++++++++++++ 7 files changed, 509 insertions(+), 4 deletions(-) create mode 100644 reading-platform-java/src/main/java/com/reading/platform/dto/response/CoursePackageStatsResponse.java create mode 100644 reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageStatsMapper.java create mode 100644 reading-platform-java/src/main/java/com/reading/platform/service/CoursePackageStatsService.java create mode 100644 reading-platform-java/src/main/java/com/reading/platform/service/impl/CoursePackageStatsServiceImpl.java diff --git a/reading-platform-frontend/src/api/course.ts b/reading-platform-frontend/src/api/course.ts index a957f50..5be0cdc 100644 --- a/reading-platform-frontend/src/api/course.ts +++ b/reading-platform-frontend/src/api/course.ts @@ -284,9 +284,9 @@ export function republishCourse(id: number): Promise { return api.publishCourse(id) as any; } -// 获取课程包统计数据 (暂时返回空对象) -export function getCourseStats(_id: number | string): Promise { - return Promise.resolve({}); +// 获取课程包统计数据 +export function getCourseStats(id: number | string): Promise { + return http.get(`/v1/admin/packages/${id}/stats`) as Promise; } // 获取版本历史 (暂时返回空数组) diff --git a/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminCourseController.java b/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminCourseController.java index 9ca0f11..de73b9d 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminCourseController.java +++ b/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminCourseController.java @@ -14,8 +14,10 @@ import com.reading.platform.dto.request.CourseCreateRequest; import com.reading.platform.dto.request.CoursePageQueryRequest; import com.reading.platform.dto.request.CourseUpdateRequest; import com.reading.platform.dto.request.CourseRejectRequest; +import com.reading.platform.dto.response.CoursePackageStatsResponse; import com.reading.platform.dto.response.CourseResponse; import com.reading.platform.entity.CoursePackage; +import com.reading.platform.service.CoursePackageStatsService; import com.reading.platform.service.CoursePackageService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -24,7 +26,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** @@ -40,6 +44,7 @@ import java.util.stream.Collectors; public class AdminCourseController { private final CoursePackageService courseService; + private final CoursePackageStatsService coursePackageStatsService; @PostMapping @Log(module = LogModule.COURSE_PACKAGE, type = LogOperationType.CREATE, description = "创建课程包") @@ -96,6 +101,18 @@ public class AdminCourseController { .map(course -> courseService.getCourseByIdWithLessons(course.getId())) .collect(Collectors.toList()); + // 合并实时统计数据(使用次数、教师数、平均评分) + List packageIds = responseList.stream().map(CourseResponse::getId).collect(Collectors.toList()); + Map statsMap = coursePackageStatsService.getBatchStats(packageIds); + for (CourseResponse resp : responseList) { + CoursePackageStatsService.PackageStats stats = statsMap.get(resp.getId()); + if (stats != null) { + resp.setUsageCount(stats.usageCount()); + resp.setTeacherCount(stats.teacherCount()); + resp.setAvgRating(stats.avgRating()); + } + } + PageResult result = PageResult.of(responseList, page.getTotal(), page.getCurrent(), page.getSize()); log.info("课程包列表查询结果,total={}, list={}", result.getTotal(), result.getList().size()); return Result.success(result); @@ -141,6 +158,12 @@ public class AdminCourseController { return Result.success(); } + @GetMapping("/{id}/stats") + @Operation(summary = "获取课程包数据统计") + public Result getCourseStats(@PathVariable Long id) { + return Result.success(coursePackageStatsService.getStats(id)); + } + @GetMapping("/all") @Operation(summary = "获取所有已发布的课程包") public Result> getAllPublishedCourses() { diff --git a/reading-platform-java/src/main/java/com/reading/platform/dto/response/CoursePackageStatsResponse.java b/reading-platform-java/src/main/java/com/reading/platform/dto/response/CoursePackageStatsResponse.java new file mode 100644 index 0000000..98d61b6 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/dto/response/CoursePackageStatsResponse.java @@ -0,0 +1,112 @@ +package com.reading.platform.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 课程包数据统计响应 + * 用于超管端「数据统计」页面 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "课程包数据统计响应") +public class CoursePackageStatsResponse { + + @Schema(description = "课程包名称") + private String courseName; + + @Schema(description = "总授课次数") + private Integer totalLessons; + + @Schema(description = "参与教师数") + private Integer totalTeachers; + + @Schema(description = "参与学生数") + private Integer totalStudents; + + @Schema(description = "平均评分") + private BigDecimal avgRating; + + @Schema(description = "授课记录趋势 [{date, count}]") + private List lessonTrend; + + @Schema(description = "教师反馈分布") + private FeedbackDistribution feedbackDistribution; + + @Schema(description = "最近授课记录") + private List recentLessons; + + @Schema(description = "学生表现统计") + private StudentPerformance studentPerformance; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LessonTrendItem { + private String date; + private Integer count; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FeedbackDistribution { + private BigDecimal designQuality; + private BigDecimal participation; + private BigDecimal goalAchievement; + private Integer totalFeedbacks; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RecentLessonItem { + private TeacherInfo teacher; + @com.fasterxml.jackson.annotation.JsonProperty("class") + private ClassInfo classInfo; + private String startDatetime; + private Integer actualDuration; + private Integer duration; + private String status; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TeacherInfo { + private Long id; + private String name; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ClassInfo { + private Long id; + private String name; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StudentPerformance { + private BigDecimal avgFocus; + private BigDecimal avgParticipation; + private BigDecimal avgInterest; + private BigDecimal avgUnderstanding; + } +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageMapper.java b/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageMapper.java index 233b912..2f9a263 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageMapper.java +++ b/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageMapper.java @@ -36,7 +36,7 @@ public interface CoursePackageMapper extends BaseMapper { " SELECT COUNT(DISTINCT l.teacher_id) " + " FROM lesson l " + " WHERE l.course_id = #{coursePackageId} " + - " AND l.status = 'completed' " + + " AND l.status = 'COMPLETED' " + ") " + "WHERE cp.id = #{coursePackageId}") void updateTeacherCount(@Param("coursePackageId") Long coursePackageId); diff --git a/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageStatsMapper.java b/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageStatsMapper.java new file mode 100644 index 0000000..56dcec3 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/mapper/CoursePackageStatsMapper.java @@ -0,0 +1,155 @@ +package com.reading.platform.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * 课程包数据统计 Mapper + * 用于超管端「数据统计」页面 + */ +@Mapper +public interface CoursePackageStatsMapper { + + /** + * 获取课程包概览统计(总授课、教师数、学生数、平均评分) + * + * @param coursePackageId 课程包 ID + * @return {totalLessons, totalTeachers, totalStudents, avgRating} + */ + @org.apache.ibatis.annotations.Select( + "SELECT " + + " COUNT(l.id) AS totalLessons, " + + " COUNT(DISTINCT l.teacher_id) AS totalTeachers, " + + " COUNT(DISTINCT sr.student_id) AS totalStudents, " + + " ROUND(AVG(CASE WHEN lf.id IS NOT NULL THEN " + + " (COALESCE(lf.design_quality,0) + COALESCE(lf.participation,0) + COALESCE(lf.goal_achievement,0)) / 3.0 " + + " ELSE NULL END), 2) AS avgRating " + + "FROM lesson l " + + "LEFT JOIN student_record sr ON l.id = sr.lesson_id AND sr.deleted = 0 " + + "LEFT JOIN lesson_feedback lf ON l.id = lf.lesson_id AND lf.deleted = 0 " + + "WHERE l.course_id = #{coursePackageId} " + + " AND l.deleted = 0 " + + " AND l.status = 'COMPLETED'" + ) + Map getOverviewStats(@Param("coursePackageId") Long coursePackageId); + + /** + * 获取授课记录趋势(按日期分组,最近 7 天或 30 天) + * + * @param coursePackageId 课程包 ID + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return [{date, count}] + */ + @org.apache.ibatis.annotations.Select( + "SELECT DATE_FORMAT(l.lesson_date, '%Y-%m-%d') AS date, COUNT(l.id) AS count " + + "FROM lesson l " + + "WHERE l.course_id = #{coursePackageId} " + + " AND l.deleted = 0 " + + " AND l.status = 'COMPLETED' " + + " AND l.lesson_date >= #{startDate} " + + " AND l.lesson_date <= #{endDate} " + + "GROUP BY l.lesson_date " + + "ORDER BY l.lesson_date ASC" + ) + List> getLessonTrend( + @Param("coursePackageId") Long coursePackageId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 获取教师反馈分布统计 + * + * @param coursePackageId 课程包 ID + * @return {designQuality, participation, goalAchievement, totalFeedbacks} + */ + @org.apache.ibatis.annotations.Select( + "SELECT " + + " ROUND(AVG(lf.design_quality), 2) AS designQuality, " + + " ROUND(AVG(lf.participation), 2) AS participation, " + + " ROUND(AVG(lf.goal_achievement), 2) AS goalAchievement, " + + " COUNT(lf.id) AS totalFeedbacks " + + "FROM lesson_feedback lf " + + "INNER JOIN lesson l ON lf.lesson_id = l.id " + + "WHERE l.course_id = #{coursePackageId} " + + " AND l.deleted = 0 " + + " AND lf.deleted = 0" + ) + Map getFeedbackDistribution(@Param("coursePackageId") Long coursePackageId); + + /** + * 获取最近授课记录(含教师、班级、时间、时长、状态) + * + * @param coursePackageId 课程包 ID + * @param limit 数量限制 + * @return 授课记录列表 + */ + @org.apache.ibatis.annotations.Select( + "SELECT " + + " l.id AS lessonId, " + + " l.teacher_id AS teacherId, " + + " t.name AS teacherName, " + + " l.class_id AS classId, " + + " c.name AS className, " + + " l.start_datetime AS startDatetime, " + + " l.actual_duration AS actualDuration, " + + " l.status AS status " + + "FROM lesson l " + + "LEFT JOIN teacher t ON l.teacher_id = t.id " + + "LEFT JOIN clazz c ON l.class_id = c.id " + + "WHERE l.course_id = #{coursePackageId} " + + " AND l.deleted = 0 " + + "ORDER BY COALESCE(l.start_datetime, l.created_at) DESC, l.id DESC " + + "LIMIT #{limit}" + ) + List> getRecentLessons( + @Param("coursePackageId") Long coursePackageId, + @Param("limit") int limit); + + /** + * 获取学生表现统计(专注度、参与度、兴趣度、理解度) + * + * @param coursePackageId 课程包 ID + * @return {avgFocus, avgParticipation, avgInterest, avgUnderstanding} + */ + @org.apache.ibatis.annotations.Select( + "SELECT " + + " ROUND(AVG(sr.focus), 2) AS avgFocus, " + + " ROUND(AVG(sr.participation), 2) AS avgParticipation, " + + " ROUND(AVG(sr.interest), 2) AS avgInterest, " + + " ROUND(AVG(sr.understanding), 2) AS avgUnderstanding " + + "FROM student_record sr " + + "INNER JOIN lesson l ON sr.lesson_id = l.id " + + "WHERE l.course_id = #{coursePackageId} " + + " AND l.deleted = 0 " + + " AND l.status = 'COMPLETED' " + + " AND sr.deleted = 0" + ) + Map getStudentPerformance(@Param("coursePackageId") Long coursePackageId); + + /** + * 批量获取课程包概览统计(用于列表页数据统计列) + * + * @param coursePackageIds 课程包 ID 列表 + * @return [{coursePackageId, usageCount, teacherCount, avgRating}] + */ + @org.apache.ibatis.annotations.Select("") + List> getBatchOverviewStats( + @Param("coursePackageIds") List coursePackageIds); +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/service/CoursePackageStatsService.java b/reading-platform-java/src/main/java/com/reading/platform/service/CoursePackageStatsService.java new file mode 100644 index 0000000..e2537b3 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/service/CoursePackageStatsService.java @@ -0,0 +1,33 @@ +package com.reading.platform.service; + +import com.reading.platform.dto.response.CoursePackageStatsResponse; + +import java.util.Map; + +/** + * 课程包数据统计服务 + * 用于超管端「数据统计」页面及列表页数据统计列 + */ +public interface CoursePackageStatsService { + + /** + * 获取课程包详细统计数据(数据统计页面) + * + * @param coursePackageId 课程包 ID + * @return 统计数据 + */ + CoursePackageStatsResponse getStats(Long coursePackageId); + + /** + * 批量获取课程包概览统计(列表页数据统计列) + * + * @param coursePackageIds 课程包 ID 列表 + * @return packageId -> {usageCount, teacherCount, avgRating} + */ + Map getBatchStats(java.util.List coursePackageIds); + + /** + * 课程包概览统计 + */ + record PackageStats(int usageCount, int teacherCount, java.math.BigDecimal avgRating) {} +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/service/impl/CoursePackageStatsServiceImpl.java b/reading-platform-java/src/main/java/com/reading/platform/service/impl/CoursePackageStatsServiceImpl.java new file mode 100644 index 0000000..1e71db1 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/service/impl/CoursePackageStatsServiceImpl.java @@ -0,0 +1,182 @@ +package com.reading.platform.service.impl; + +import com.reading.platform.common.exception.BusinessException; +import com.reading.platform.dto.response.CoursePackageStatsResponse; +import com.reading.platform.entity.CoursePackage; +import com.reading.platform.mapper.CoursePackageMapper; +import com.reading.platform.mapper.CoursePackageStatsMapper; +import com.reading.platform.service.CoursePackageStatsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 课程包数据统计服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CoursePackageStatsServiceImpl implements CoursePackageStatsService { + + private final CoursePackageMapper coursePackageMapper; + private final CoursePackageStatsMapper statsMapper; + + private static final int TREND_DAYS = 14; + private static final int RECENT_LESSONS_LIMIT = 10; + + @Override + public CoursePackageStatsResponse getStats(Long coursePackageId) { + CoursePackage pkg = coursePackageMapper.selectById(coursePackageId); + if (pkg == null) { + throw new BusinessException("课程包不存在: " + coursePackageId); + } + + Map overview = statsMapper.getOverviewStats(coursePackageId); + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(TREND_DAYS); + List> trendRaw = statsMapper.getLessonTrend(coursePackageId, startDate, endDate); + Map feedbackRaw = statsMapper.getFeedbackDistribution(coursePackageId); + List> recentRaw = statsMapper.getRecentLessons(coursePackageId, RECENT_LESSONS_LIMIT); + Map studentPerfRaw = statsMapper.getStudentPerformance(coursePackageId); + + return CoursePackageStatsResponse.builder() + .courseName(pkg.getName()) + .totalLessons(getInt(overview, "totalLessons")) + .totalTeachers(getInt(overview, "totalTeachers")) + .totalStudents(getInt(overview, "totalStudents")) + .avgRating(getBigDecimal(overview, "avgRating")) + .lessonTrend(toLessonTrend(trendRaw)) + .feedbackDistribution(toFeedbackDistribution(feedbackRaw)) + .recentLessons(toRecentLessons(recentRaw)) + .studentPerformance(toStudentPerformance(studentPerfRaw)) + .build(); + } + + @Override + public Map getBatchStats(List coursePackageIds) { + if (coursePackageIds == null || coursePackageIds.isEmpty()) { + return Map.of(); + } + List> rows = statsMapper.getBatchOverviewStats(coursePackageIds); + Map result = new HashMap<>(); + for (Map row : rows) { + Long id = getLong(row, "coursePackageId"); + if (id != null) { + result.put(id, new PackageStats( + getInt(row, "usageCount"), + getInt(row, "teacherCount"), + getBigDecimal(row, "avgRating") + )); + } + } + // 确保所有 ID 都有条目(无数据时为 0) + for (Long id : coursePackageIds) { + result.putIfAbsent(id, new PackageStats(0, 0, BigDecimal.ZERO)); + } + return result; + } + + private List toLessonTrend(List> raw) { + if (raw == null) return List.of(); + return raw.stream() + .map(r -> CoursePackageStatsResponse.LessonTrendItem.builder() + .date(getString(r, "date")) + .count(getInt(r, "count")) + .build()) + .collect(Collectors.toList()); + } + + private CoursePackageStatsResponse.FeedbackDistribution toFeedbackDistribution(Map raw) { + if (raw == null || getInt(raw, "totalFeedbacks") == 0) { + return null; + } + return CoursePackageStatsResponse.FeedbackDistribution.builder() + .designQuality(getBigDecimal(raw, "designQuality")) + .participation(getBigDecimal(raw, "participation")) + .goalAchievement(getBigDecimal(raw, "goalAchievement")) + .totalFeedbacks(getInt(raw, "totalFeedbacks")) + .build(); + } + + private List toRecentLessons(List> raw) { + if (raw == null) return List.of(); + List items = new ArrayList<>(); + for (Map r : raw) { + Object startDt = r.get("startDatetime"); + String startDatetime = startDt != null ? startDt.toString() : null; + items.add(CoursePackageStatsResponse.RecentLessonItem.builder() + .teacher(CoursePackageStatsResponse.TeacherInfo.builder() + .id(getLong(r, "teacherId")) + .name(getString(r, "teacherName")) + .build()) + .classInfo(CoursePackageStatsResponse.ClassInfo.builder() + .id(getLong(r, "classId")) + .name(getString(r, "className")) + .build()) + .startDatetime(startDatetime) + .actualDuration(getInt(r, "actualDuration")) + .duration(null) + .status(getString(r, "status")) + .build()); + } + return items; + } + + private CoursePackageStatsResponse.StudentPerformance toStudentPerformance(Map raw) { + if (raw == null) return null; + return CoursePackageStatsResponse.StudentPerformance.builder() + .avgFocus(getBigDecimal(raw, "avgFocus")) + .avgParticipation(getBigDecimal(raw, "avgParticipation")) + .avgInterest(getBigDecimal(raw, "avgInterest")) + .avgUnderstanding(getBigDecimal(raw, "avgUnderstanding")) + .build(); + } + + private static int getInt(Map m, String key) { + Object v = m != null ? m.get(key) : null; + if (v == null) return 0; + if (v instanceof Number) return ((Number) v).intValue(); + try { + return Integer.parseInt(v.toString()); + } catch (NumberFormatException e) { + return 0; + } + } + + private static Long getLong(Map m, String key) { + Object v = m != null ? m.get(key) : null; + if (v == null) return null; + if (v instanceof Number) return ((Number) v).longValue(); + try { + return Long.parseLong(v.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String getString(Map m, String key) { + Object v = m != null ? m.get(key) : null; + return v != null ? v.toString() : null; + } + + private static BigDecimal getBigDecimal(Map m, String key) { + Object v = m != null ? m.get(key) : null; + if (v == null) return BigDecimal.ZERO; + if (v instanceof BigDecimal) return (BigDecimal) v; + if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue()).setScale(2, RoundingMode.HALF_UP); + try { + return new BigDecimal(v.toString()).setScale(2, RoundingMode.HALF_UP); + } catch (NumberFormatException e) { + return BigDecimal.ZERO; + } + } +}