feat: 实现课程包数据统计前后端对齐

- 新增 CoursePackageStatsService 及 GET /packages/{id}/stats 接口
- 数据统计页:总授课次数、教师数、学生数、平均评分、趋势、反馈、最近授课、学生表现
- 课程包列表数据统计列:实时计算 usageCount、teacherCount、avgRating
- 前端 getCourseStats 调用真实 API

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-24 13:57:06 +08:00
parent 7f0ea0daa4
commit 342456347e
7 changed files with 509 additions and 4 deletions

View File

@ -284,9 +284,9 @@ export function republishCourse(id: number): Promise<any> {
return api.publishCourse(id) as any;
}
// 获取课程包统计数据 (暂时返回空对象)
export function getCourseStats(_id: number | string): Promise<any> {
return Promise.resolve({});
// 获取课程包统计数据
export function getCourseStats(id: number | string): Promise<any> {
return http.get(`/v1/admin/packages/${id}/stats`) as Promise<any>;
}
// 获取版本历史 (暂时返回空数组)

View File

@ -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<Long> packageIds = responseList.stream().map(CourseResponse::getId).collect(Collectors.toList());
Map<Long, CoursePackageStatsService.PackageStats> 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<CourseResponse> 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<CoursePackageStatsResponse> getCourseStats(@PathVariable Long id) {
return Result.success(coursePackageStatsService.getStats(id));
}
@GetMapping("/all")
@Operation(summary = "获取所有已发布的课程包")
public Result<List<CourseResponse>> getAllPublishedCourses() {

View File

@ -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<LessonTrendItem> lessonTrend;
@Schema(description = "教师反馈分布")
private FeedbackDistribution feedbackDistribution;
@Schema(description = "最近授课记录")
private List<RecentLessonItem> 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;
}
}

View File

@ -36,7 +36,7 @@ public interface CoursePackageMapper extends BaseMapper<CoursePackage> {
" 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);

View File

@ -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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> getStudentPerformance(@Param("coursePackageId") Long coursePackageId);
/**
* 批量获取课程包概览统计用于列表页数据统计列
*
* @param coursePackageIds 课程包 ID 列表
* @return [{coursePackageId, usageCount, teacherCount, avgRating}]
*/
@org.apache.ibatis.annotations.Select("<script>" +
"SELECT " +
" l.course_id AS coursePackageId, " +
" COUNT(l.id) AS usageCount, " +
" COUNT(DISTINCT l.teacher_id) AS teacherCount, " +
" 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 END), 2) AS avgRating " +
"FROM lesson l " +
"LEFT JOIN lesson_feedback lf ON l.id = lf.lesson_id AND lf.deleted = 0 " +
"WHERE l.deleted = 0 AND l.status = 'COMPLETED' " +
" AND l.course_id IN " +
"<foreach item='id' collection='coursePackageIds' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY l.course_id" +
"</script>")
List<Map<String, Object>> getBatchOverviewStats(
@Param("coursePackageIds") List<Long> coursePackageIds);
}

View File

@ -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<Long, PackageStats> getBatchStats(java.util.List<Long> coursePackageIds);
/**
* 课程包概览统计
*/
record PackageStats(int usageCount, int teacherCount, java.math.BigDecimal avgRating) {}
}

View File

@ -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<String, Object> overview = statsMapper.getOverviewStats(coursePackageId);
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(TREND_DAYS);
List<Map<String, Object>> trendRaw = statsMapper.getLessonTrend(coursePackageId, startDate, endDate);
Map<String, Object> feedbackRaw = statsMapper.getFeedbackDistribution(coursePackageId);
List<Map<String, Object>> recentRaw = statsMapper.getRecentLessons(coursePackageId, RECENT_LESSONS_LIMIT);
Map<String, Object> 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<Long, PackageStats> getBatchStats(List<Long> coursePackageIds) {
if (coursePackageIds == null || coursePackageIds.isEmpty()) {
return Map.of();
}
List<Map<String, Object>> rows = statsMapper.getBatchOverviewStats(coursePackageIds);
Map<Long, PackageStats> result = new HashMap<>();
for (Map<String, Object> 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<CoursePackageStatsResponse.LessonTrendItem> toLessonTrend(List<Map<String, Object>> 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<String, Object> 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<CoursePackageStatsResponse.RecentLessonItem> toRecentLessons(List<Map<String, Object>> raw) {
if (raw == null) return List.of();
List<CoursePackageStatsResponse.RecentLessonItem> items = new ArrayList<>();
for (Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> m, String key) {
Object v = m != null ? m.get(key) : null;
return v != null ? v.toString() : null;
}
private static BigDecimal getBigDecimal(Map<String, Object> 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;
}
}
}