Java+EasyExcel 打造学习平台视频学习时长统计系统

前言

在在线教育平台中,学习时长是衡量学生学习投入、评估课程效果、优化教学策略的核心数据指标。精准统计学生视频学习时长并生成可视化报表,能帮助教师掌握学生学习动态、学校进行教学质量评估、学生了解自身学习进度。

作为一名深耕 Java 领域多年的技术开发者,我将通过这篇实战博客,带大家从零构建一套 "视频学习时长统计 + 多维度报表生成" 的完整解决方案。全程基于最新稳定技术栈,包含数据采集、清洗、存储、统计、报表导出全流程,所有代码均可直接运行,兼顾技术深度与落地性,无论是新手还是资深开发者都能有所收获。

1. 需求分析

1.1 核心业务需求

  • 精准采集学生视频播放数据(开始时间、结束时间、播放进度、设备信息等)。
  • 计算有效学习时长(排除快进、暂停、后台挂播等无效行为)。
  • 支持多维度统计:按用户、课程、班级、时间段等维度统计学习时长。
  • 提供多样化报表:Excel 导出报表(用户时长统计、课程排行、班级汇总等)、可视化图表展示。
  • 数据一致性保障:避免重复上报、异常中断导致的数据丢失或统计偏差。
  • 高可用性:支持高并发上报,大数据量下统计性能稳定。

1.2 技术需求

  • 开发语言:Java 17(LTS 版本,稳定且支持新特性)。
  • 框架:Spring Boot 3.2.5(最新稳定版,原生支持虚拟线程)。
  • 持久层:MyBatis-Plus 3.5.5(简化 CRUD,提升开发效率)。
  • 数据库:MySQL 8.0(支持 JSON 类型、窗口函数,性能优异)。
  • 报表生成:EasyExcel 3.3.2(阿里开源,轻量高效,避免 OOM)。
  • 接口文档:Swagger3(OpenAPI 3.0,自动生成接口文档)。
  • 工具类:Lombok、Fastjson2、Guava Collections。
  • 缓存:Redis 7.2(缓存热点统计数据,提升查询性能)。
  • 其他:Spring Validation(参数校验)、全局异常处理、自定义业务异常。

2. 技术选型深度解析

2.1 核心技术栈清单

技术组件 版本号 选型理由
JDK 17 LTS 版本,支持密封类、record、虚拟线程等新特性,性能提升 30%+
Spring Boot 3.2.5 基于 Spring 6,支持 Jakarta EE 9+,原生 AOT 编译,启动速度更快
MyBatis-Plus 3.5.5 兼容 MyBatis,提供 CRUD 接口、条件构造器、分页插件,减少重复代码
MySQL 8.0.36 支持 JSON 字段存储设备信息,窗口函数优化统计查询,索引性能提升
EasyExcel 3.3.2 低内存占用,支持大数据量 Excel 导出,API 简洁,适配 Spring Boot 3
Swagger3 2.2.0 基于 OpenAPI 3.0,支持接口注解、参数校验提示,便于前后端联调
Lombok 1.18.30 简化 POJO 类代码,减少 getter/setter/toString 等模板代码
Fastjson2 2.0.49 序列化速度比 Fastjson1 快 50%+,支持 Java 17 新特性,安全性更高
Guava 32.1.3-jre 提供高效集合工具类(Lists、Maps),简化集合操作
Redis 7.2.4 缓存热点统计结果,支持过期时间,提升高并发场景下的查询性能
Spring Validation 6.1.6 基于 JSR-380,提供声明式参数校验,减少手动判空代码

2.2 关键技术选型依据

  • 为什么用 EasyExcel 而非 POI?:POI 在处理大数据量 Excel 时容易出现 OOM,EasyExcel 通过逐行读取 / 写入数据,内存占用控制在 MB 级,支持 100 万行数据导出无压力,且 API 更简洁。
  • 为什么用 MyBatis-Plus 而非原生 MyBatis?:MyBatis-Plus 的条件构造器(QueryWrapper)可动态构建 SQL,分页插件无需手动写分页 SQL,代码生成器能快速生成 CRUD 接口,开发效率提升 50%。
  • 为什么引入 Redis?:学习时长统计结果(如课程 TOP10、班级总时长)属于热点数据,缓存后可将查询响应时间从秒级降至毫秒级,支撑高并发查询场景。
  • 为什么选择 JDK 17?:JDK 17 的虚拟线程(Virtual Threads)能优化异步任务处理(如数据上报异步写入数据库),无需手动管理线程池,性能优于传统线程池。

3. 系统设计

3.1 整体架构设计

架构分层说明:

  • 前端层:负责视频播放与数据上报(定时 + 关键节点上报)。
  • 网关层:负责请求路由、限流(可选,如 Spring Cloud Gateway)。
  • 控制层:接收前端请求,参数校验,返回响应结果。
  • 服务层:核心业务逻辑处理(数据清洗、有效时长计算、统计分析、报表生成)。
  • 数据访问层:通过 MyBatis-Plus 操作数据库。
  • 存储层:MySQL 存储原始数据与统计结果,Redis 缓存热点数据。

3.2 数据模型设计

3.2.1 核心表结构设计

基于业务需求,设计 5 张核心表,所有表均添加create_timeupdate_time字段,便于数据追踪。

1. 用户表(user)

存储学生基础信息,关联学习记录。

复制代码
CREATE TABLE `user` (
  `user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID(主键)',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
  `class_id` bigint NOT NULL COMMENT '班级ID',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`user_id`),
  KEY `idx_class_id` (`class_id`) COMMENT '班级ID索引,优化班级统计查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2. 课程表(course)

存储课程基础信息,关联视频表与学习记录。

复制代码
CREATE TABLE `course` (
  `course_id` bigint NOT NULL AUTO_INCREMENT COMMENT '课程ID(主键)',
  `course_name` varchar(100) NOT NULL COMMENT '课程名称',
  `teacher_id` bigint NOT NULL COMMENT '授课教师ID',
  `teacher_name` varchar(50) NOT NULL COMMENT '授课教师姓名',
  `course_type` tinyint NOT NULL COMMENT '课程类型:1-必修,2-选修',
  `total_duration` int DEFAULT '0' COMMENT '课程总时长(秒)',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-下架,1-上架',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`course_id`),
  KEY `idx_teacher_id` (`teacher_id`) COMMENT '教师ID索引,优化教师统计查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
3. 视频表(video)

存储课程下的视频资源信息,用于校验播放进度合法性。

复制代码
CREATE TABLE `video` (
  `video_id` bigint NOT NULL AUTO_INCREMENT COMMENT '视频ID(主键)',
  `course_id` bigint NOT NULL COMMENT '所属课程ID',
  `video_name` varchar(100) NOT NULL COMMENT '视频名称',
  `video_duration` int NOT NULL COMMENT '视频实际时长(秒)',
  `video_url` varchar(255) NOT NULL COMMENT '视频播放地址',
  `sort` int NOT NULL DEFAULT '0' COMMENT '排序序号',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`video_id`),
  KEY `idx_course_id` (`course_id`) COMMENT '课程ID索引,优化课程视频查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频表';
4. 班级表(class_info)

存储班级信息,用于按班级维度统计。

复制代码
CREATE TABLE `class_info` (
  `class_id` bigint NOT NULL AUTO_INCREMENT COMMENT '班级ID(主键)',
  `class_name` varchar(50) NOT NULL COMMENT '班级名称',
  `grade` varchar(20) NOT NULL COMMENT '年级',
  `school_id` bigint NOT NULL COMMENT '学校ID',
  `school_name` varchar(100) NOT NULL COMMENT '学校名称',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`class_id`),
  KEY `idx_school_id` (`school_id`) COMMENT '学校ID索引,优化学校统计查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级表';
5. 学习记录表(learning_record)

核心表,存储学生视频播放原始数据,是统计的基础。

复制代码
CREATE TABLE `learning_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID(主键)',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `course_id` bigint NOT NULL COMMENT '课程ID',
  `video_id` bigint NOT NULL COMMENT '视频ID',
  `start_time` datetime NOT NULL COMMENT '播放开始时间',
  `end_time` datetime NOT NULL COMMENT '播放结束时间',
  `play_duration` int NOT NULL COMMENT '上报播放时长(秒)',
  `effective_duration` int NOT NULL COMMENT '有效学习时长(秒)',
  `progress` int NOT NULL COMMENT '播放进度(%)',
  `report_time` datetime NOT NULL COMMENT '数据上报时间',
  `device` json DEFAULT NULL COMMENT '设备信息(JSON格式)',
  `ip` varchar(20) DEFAULT NULL COMMENT 'IP地址',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_video_report` (`user_id`,`video_id`,`report_time`) COMMENT '避免同一用户同一视频同一时间重复上报',
  KEY `idx_user_id` (`user_id`) COMMENT '用户ID索引',
  KEY `idx_course_id` (`course_id`) COMMENT '课程ID索引',
  KEY `idx_video_id` (`video_id`) COMMENT '视频ID索引',
  KEY `idx_start_end_time` (`start_time`,`end_time`) COMMENT '时间范围索引,优化时间段统计'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习记录表';
6. 统计结果表(statistics_result)

存储预计算的统计结果,减少实时统计压力(可选,用于大数据量场景)。

复制代码
CREATE TABLE `statistics_result` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '统计ID(主键)',
  `stat_type` tinyint NOT NULL COMMENT '统计类型:1-用户总时长,2-课程总时长,3-班级总时长',
  `stat_dimension_id` bigint NOT NULL COMMENT '统计维度ID(用户ID/课程ID/班级ID)',
  `total_effective_duration` bigint NOT NULL COMMENT '总有效时长(秒)',
  `stat_date` date NOT NULL COMMENT '统计日期(yyyy-MM-dd)',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_stat_type_dim_date` (`stat_type`,`stat_dimension_id`,`stat_date`) COMMENT '避免同一维度同一日期重复统计',
  KEY `idx_stat_date` (`stat_date`) COMMENT '统计日期索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统计结果表';
3.2.2 数据模型关系说明
  • 1 个课程(course)包含多个视频(video):一对多关系(course_id 关联)。
  • 1 个用户(user)属于 1 个班级(class_info):多对一关系(class_id 关联)。
  • 1 个用户(user)可观看多个视频(video),产生多条学习记录(learning_record):多对多关系(通过 learning_record 关联)。
  • 统计结果表(statistics_result)按统计类型关联用户 / 课程 / 班级:通过 stat_type 和 stat_dimension_id 关联。

3.3 核心流程设计

3.3.1 学习时长统计整体流程
3.3.2 有效学习时长计算流程

有效时长计算规则说明:

  1. 进度合法性校验:播放进度不能超过 100%,否则视为无效数据。
  2. 时长合理性校验:上报的播放时长不能超过理论时长(结束时间 - 开始时间)+30 秒(网络延迟容错),否则取理论时长。
  3. 快进判断:若进度变化率(进度变化 / 播放时长)超过视频时长的 1%(即 1 秒播放 1% 进度),视为快进,有效时长按实际进度占比计算。
  4. 暂停排除:暂停期间不上报数据,有效时长自动排除暂停时间。

4. 核心功能实现

4.1 项目初始化与配置

4.1.1 Maven 依赖配置(pom.xml)
复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.ken.learning</groupId>
    <artifactId>learning-duration-statistics</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>learning-duration-statistics</name>
    <description>学习平台视频学习时长统计与报表系统</description>
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.5</mybatis-plus.version>
        <easyexcel.version>3.3.2</easyexcel.version>
        <fastjson2.version>2.0.49</fastjson2.version>
        <guava.version>32.1.3-jre</guava.version>
        <swagger.version>2.2.0</swagger.version>
        <lombok.version>1.18.30</lombok.version>
    </properties>
    <dependencies>
        <!-- Spring Boot核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- MyBatis-Plus依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
            <version>2.3.32</version>
        </dependency>

        <!-- MySQL驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- 报表生成:EasyExcel -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>${easyexcel.version}</version>
        </dependency>

        <!-- JSON处理:Fastjson2 -->
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>

        <!-- 工具类:Guava -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>

        <!-- 接口文档:Swagger3 -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${swagger.version}</version>
        </dependency>

        <!-- 简化代码:Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>

        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
4.1.2 应用配置(application.yml)
复制代码
spring:
  # 数据库配置
  datasource:
    url: jdbc:mysql://localhost:3306/learning_platform?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: root123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  # Redis配置
  redis:
    host: localhost
    port: 6379
    password:
    database: 0
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 2
  #  Jackson配置(时间格式)
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8

# Spring Boot应用配置
server:
  port: 8080
  servlet:
    context-path: /learning-statistics

# MyBatis-Plus配置
mybatis-plus:
  # Mapper.xml文件路径
  mapper-locations: classpath:mapper/*.xml
  # 实体类别名包路径
  type-aliases-package: com.ken.learning.statistics.model.entity
  # 全局配置
  global-config:
    db-config:
      # 主键类型:自增
      id-type: auto
      # 逻辑删除字段名
      logic-delete-field: isDeleted
      # 逻辑删除值:1-删除,0-未删除
      logic-delete-value: 1
      logic-not-delete-value: 0
  # 配置项
  configuration:
    # 打印SQL日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    # 驼峰命名自动转换
    map-underscore-to-camel-case: true
    # 允许返回空结果集
    return-instance-for-empty-row: true

# Swagger3配置
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    operationsSorter: method
  packages-to-scan: com.ken.learning.statistics.controller
  openapi:
    info:
      title: 学习时长统计与报表系统API
      description: 包含数据上报、统计查询、报表导出等接口
      version: 1.0.0

# 自定义配置:上报相关
learning:
  report:
    # 最小上报间隔(秒):防止频繁上报
    min-interval: 10
    # 快进判断阈值(秒/1%进度):超过此值视为快进
    fast-forward-threshold: 1
4.1.3 MyBatis-Plus 分页插件配置
复制代码
package com.ken.learning.statistics.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * MyBatis-Plus配置类
 * @author ken
 */
@Configuration
public class MyBatisPlusConfig {

    /**
     * 分页插件配置
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加MySQL分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}
4.1.4 Swagger3 配置
复制代码
package com.ken.learning.statistics.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Swagger3配置类
 * @author ken
 */
@Configuration
public class Swagger3Config {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("学习时长统计与报表系统API")
                        .description("包含视频播放数据上报、多维度统计查询、Excel报表导出等核心接口")
                        .version("1.0.0"));
    }
}

4.2 基础组件封装

4.2.1 统一返回结果类
复制代码
package com.ken.learning.statistics.model.vo;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

/**
 * 统一返回结果类
 * @author ken
 */
@Data
@NoArgsConstructor
@Accessors(chain = true)
public class Result<T> {

    /**
     * 响应码:200-成功,其他-失败
     */
    private int code;

    /**
     * 响应信息
     */
    private String msg;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 成功响应(无数据)
     */
    public static <T> Result<T> success() {
        return new Result<T>().setCode(200).setMsg("操作成功");
    }

    /**
     * 成功响应(带数据)
     */
    public static <T> Result<T> success(T data) {
        return new Result<T>().setCode(200).setMsg("操作成功").setData(data);
    }

    /**
     * 失败响应
     */
    public static <T> Result<T> fail(int code, String msg) {
        return new Result<T>().setCode(code).setMsg(msg);
    }

    /**
     * 失败响应(带数据)
     */
    public static <T> Result<T> fail(int code, String msg, T data) {
        return new Result<T>().setCode(code).setMsg(msg).setData(data);
    }
}
4.2.2 响应码枚举
复制代码
package com.ken.learning.statistics.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 响应码枚举
 * @author ken
 */
@Getter
@AllArgsConstructor
public enum ResultCode {

    /**
     * 成功
     */
    SUCCESS(200, "操作成功"),

    /**
     * 系统错误
     */
    SYSTEM_ERROR(500, "系统异常,请联系管理员"),

    /**
     * 参数错误
     */
    PARAM_ERROR(400, "参数格式不正确"),

    /**
     * 数据不存在
     */
    DATA_NOT_FOUND(404, "请求数据不存在"),

    /**
     * 数据已存在
     */
    DATA_ALREADY_EXISTS(409, "数据已存在"),

    /**
     * 业务异常
     */
    BUSINESS_ERROR(410, "业务逻辑异常");

    /**
     * 响应码
     */
    private final int code;

    /**
     * 响应信息
     */
    private final String msg;
}
4.2.3 自定义业务异常
复制代码
package com.ken.learning.statistics.exception;

import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 自定义业务异常
 * @author ken
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class BusinessException extends RuntimeException {

    /**
     * 错误码
     */
    private int code;

    /**
     * 错误信息
     */
    private String msg;

    public BusinessException(String msg) {
        super(msg);
        this.code = 410;
        this.msg = msg;
    }

    public BusinessException(int code, String msg) {
        super(msg);
        this.code = code;
        this.msg = msg;
    }

    public BusinessException(Throwable cause, int code, String msg) {
        super(msg, cause);
        this.code = code;
        this.msg = msg;
    }
}
4.2.4 全局异常处理器
复制代码
package com.ken.learning.statistics.exception;

import com.ken.learning.statistics.model.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 * @author ken
 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusinessException(BusinessException e) {
        log.error("业务异常:code={}, msg={}", e.getCode(), e.getMsg(), e);
        return Result.fail(e.getCode(), e.getMsg());
    }

    /**
     * 处理参数校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder errorMsg = new StringBuilder("参数校验失败:");
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            errorMsg.append(fieldError.getField())
                    .append(":")
                    .append(fieldError.getDefaultMessage())
                    .append(",");
        }
        String msg = errorMsg.substring(0, errorMsg.length() - 1);
        log.error("参数校验异常:{}", msg, e);
        return Result.fail(400, msg);
    }

    /**
     * 处理系统异常
     */
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常:{}", e.getMessage(), e);
        return Result.fail(500, "系统异常,请联系管理员");
    }
}

4.3 视频播放数据采集

4.3.1 前端上报逻辑(JS 示例)

前端采用 "定时上报 + 关键节点上报" 结合的策略,确保数据不丢失且精准。

复制代码
// 视频播放数据上报工具类
class VideoReportUtil {
    constructor(videoElement, userId, courseId, videoId) {
        this.video = videoElement; // 视频DOM元素
        this.userId = userId; // 用户ID
        this.courseId = courseId; // 课程ID
        this.videoId = videoId; // 视频ID
        this.reportInterval = 30000; // 定时上报间隔(30秒)
        this.minInterval = 10000; // 最小上报间隔(10秒,与后端配置一致)
        this.lastReportTime = 0; // 上次上报时间
        this.timer = null; // 定时上报定时器
    }

    // 初始化上报监听
    init() {
        // 视频播放开始时上报
        this.video.addEventListener('play', () => this.handlePlay());
        // 视频暂停时上报
        this.video.addEventListener('pause', () => this.handlePause());
        // 视频结束时上报
        this.video.addEventListener('ended', () => this.handleEnded());
        // 视频进度变更时上报(快进/后退)
        this.video.addEventListener('timeupdate', () => this.handleTimeUpdate());
    }

    // 播放开始处理
    handlePlay() {
        // 启动定时上报
        this.timer = setInterval(() => this.reportData(), this.reportInterval);
        // 上报开始播放数据
        this.reportData();
    }

    // 暂停处理
    handlePause() {
        // 清除定时上报
        clearInterval(this.timer);
        // 上报暂停数据
        this.reportData();
    }

    // 播放结束处理
    handleEnded() {
        // 清除定时上报
        clearInterval(this.timer);
        // 上报结束数据(进度设为100%)
        this.reportData(true);
    }

    // 进度变更处理
    handleTimeUpdate() {
        const currentTime = Date.now();
        // 避免频繁上报,间隔小于最小间隔则不上报
        if (currentTime - this.lastReportTime < this.minInterval) {
            return;
        }
        this.reportData();
    }

    // 上报数据核心方法
    reportData(isEnd = false) {
        const currentTime = Date.now();
        this.lastReportTime = currentTime;

        // 构建上报数据
        const reportData = {
            userId: this.userId,
            courseId: this.courseId,
            videoId: this.videoId,
            startTime: this.formatTime(this.video.currentTime), // 播放开始时间(视频内时间)
            endTime: this.formatTime(isEnd ? this.video.duration : this.video.currentTime), // 播放结束时间
            playDuration: Math.round(isEnd ? this.video.duration - this.video.startTime : this.video.currentTime - this.video.startTime), // 播放时长(秒)
            progress: Math.round((this.video.currentTime / this.video.duration) * 100), // 播放进度(%)
            device: this.getDeviceInfo(), // 设备信息
            ip: '' // IP由后端获取
        };

        // 发送上报请求(使用fetch API)
        fetch('/learning-statistics/api/report/video-play', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + localStorage.getItem('token') // 认证令牌
            },
            body: JSON.stringify(reportData)
        }).then(response => response.json())
          .then(res => {
              if (res.code !== 200) {
                  console.error('数据上报失败:', res.msg);
                  // 失败重试(最多3次)
                  this.retryReport(reportData, 3);
              }
          }).catch(error => {
              console.error('数据上报异常:', error);
              this.retryReport(reportData, 3);
          });
    }

    // 重试上报
    retryReport(data, retryCount) {
        if (retryCount <= 0) {
            console.error('重试上报失败,数据:', data);
            // 本地存储失败数据,后续重新上报
            this.saveFailedReport(data);
            return;
        }
        setTimeout(() => {
            fetch('/learning-statistics/api/report/video-play', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': 'Bearer ' + localStorage.getItem('token')
                },
                body: JSON.stringify(data)
            }).then(response => response.json())
              .then(res => {
                  if (res.code !== 200) {
                      this.retryReport(data, retryCount - 1);
                  }
              }).catch(error => {
                  this.retryReport(data, retryCount - 1);
              });
        }, 5000 * (4 - retryCount)); // 重试间隔:5秒、10秒、15秒
    }

    // 格式化时间(秒转HH:mm:ss)
    formatTime(seconds) {
        const h = Math.floor(seconds / 3600);
        const m = Math.floor((seconds % 3600) / 60);
        const s = Math.floor(seconds % 60);
        return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
    }

    // 获取设备信息
    getDeviceInfo() {
        return {
            browser: navigator.userAgent,
            screen: `${window.screen.width}x${window.screen.height}`,
            system: navigator.platform
        };
    }

    // 保存失败上报数据到本地存储
    saveFailedReport(data) {
        const failedReports = JSON.parse(localStorage.getItem('failedVideoReports') || '[]');
        failedReports.push({ ...data, reportTime: new Date().getTime() });
        localStorage.setItem('failedVideoReports', JSON.stringify(failedReports));
    }

    // 上传本地存储的失败数据(页面加载时调用)
    uploadFailedReports() {
        const failedReports = JSON.parse(localStorage.getItem('failedVideoReports') || '[]');
        if (failedReports.length === 0) {
            return;
        }
        // 批量上报失败数据
        fetch('/learning-statistics/api/report/batch-video-play', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + localStorage.getItem('token')
            },
            body: JSON.stringify(failedReports)
        }).then(response => response.json())
          .then(res => {
              if (res.code === 200) {
                  // 上报成功,清空本地存储
                  localStorage.removeItem('failedVideoReports');
              }
          }).catch(error => {
              console.error('批量上报失败数据异常:', error);
          });
    }
}

// 使用示例
const videoElement = document.getElementById('video-player');
const videoReportUtil = new VideoReportUtil(videoElement, 1001, 2001, 3001);
videoReportUtil.init();
// 页面加载时上传失败数据
videoReportUtil.uploadFailedReports();
4.3.2 后端接收 DTO
复制代码
package com.ken.learning.statistics.model.dto;

import com.alibaba.fastjson2.JSONObject;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;

/**
 * 视频播放数据上报DTO
 * @author ken
 */
@Data
@Schema(description = "视频播放数据上报DTO")
public class VideoPlayReportDTO {

    @NotNull(message = "用户ID不能为空")
    @Min(value = 1, message = "用户ID必须大于0")
    @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long userId;

    @NotNull(message = "课程ID不能为空")
    @Min(value = 1, message = "课程ID必须大于0")
    @Schema(description = "课程ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long courseId;

    @NotNull(message = "视频ID不能为空")
    @Min(value = 1, message = "视频ID必须大于0")
    @Schema(description = "视频ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long videoId;

    @NotNull(message = "播放开始时间不能为空")
    @Schema(description = "播放开始时间(格式:HH:mm:ss)", requiredMode = Schema.RequiredMode.REQUIRED)
    private String startTime;

    @NotNull(message = "播放结束时间不能为空")
    @Schema(description = "播放结束时间(格式:HH:mm:ss)", requiredMode = Schema.RequiredMode.REQUIRED)
    private String endTime;

    @NotNull(message = "播放时长不能为空")
    @Min(value = 0, message = "播放时长不能小于0")
    @Schema(description = "播放时长(秒)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer playDuration;

    @NotNull(message = "播放进度不能为空")
    @Min(value = 0, message = "播放进度不能小于0")
    @Schema(description = "播放进度(%)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer progress;

    @Schema(description = "设备信息(JSON格式)")
    private JSONObject device;

    @Schema(description = "IP地址")
    private String ip;

    /**
     * 上报时间(后端填充)
     */
    private LocalDateTime reportTime;

    /**
     * 校验时间格式
     */
    public boolean validateTimeFormat() {
        if (!StringUtils.hasText(startTime) || !startTime.matches("^\\d{2}:\\d{2}:\\d{2}$")) {
            return false;
        }
        return StringUtils.hasText(endTime) && endTime.matches("^\\d{2}:\\d{2}:\\d{2}$");
    }
}
4.3.3 批量上报 DTO
复制代码
package com.ken.learning.statistics.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;

import java.util.List;

/**
 * 批量视频播放数据上报DTO
 * @author ken
 */
@Data
@Schema(description = "批量视频播放数据上报DTO")
public class BatchVideoPlayReportDTO {

    @NotEmpty(message = "上报数据列表不能为空")
    @Valid
    @Schema(description = "上报数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
    private List<VideoPlayReportDTO> reportList;
}

4.4 数据接收与处理

4.4.1 Controller 层
复制代码
package com.ken.learning.statistics.controller;

import com.ken.learning.statistics.model.dto.BatchVideoPlayReportDTO;
import com.ken.learning.statistics.model.dto.VideoPlayReportDTO;
import com.ken.learning.statistics.model.vo.Result;
import com.ken.learning.statistics.service.VideoPlayReportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

/**
 * 视频播放数据上报Controller
 * @author ken
 */
@RestController
@RequestMapping("/api/report")
@Tag(name = "视频播放数据上报", description = "视频播放数据上报相关接口")
public class VideoPlayReportController {

    @Resource
    private VideoPlayReportService videoPlayReportService;

    /**
     * 单个视频播放数据上报
     */
    @PostMapping("/video-play")
    @Operation(summary = "单个视频播放数据上报", description = "定时上报/关键节点上报单个视频播放数据")
    public Result<?> reportVideoPlay(@Valid @RequestBody VideoPlayReportDTO reportDTO, HttpServletRequest request) {
        // 获取IP地址
        String ip = request.getRemoteAddr();
        reportDTO.setIp(ip);
        videoPlayReportService.reportVideoPlay(reportDTO);
        return Result.success("数据上报成功");
    }

    /**
     * 批量视频播放数据上报
     */
    @PostMapping("/batch-video-play")
    @Operation(summary = "批量视频播放数据上报", description = "上报本地存储的失败数据")
    public Result<?> batchReportVideoPlay(@Valid @RequestBody BatchVideoPlayReportDTO batchReportDTO, HttpServletRequest request) {
        // 获取IP地址
        String ip = request.getRemoteAddr();
        batchReportDTO.getReportList().forEach(reportDTO -> reportDTO.setIp(ip));
        videoPlayReportService.batchReportVideoPlay(batchReportDTO);
        return Result.success("批量数据上报成功");
    }
}
4.4.2 Service 层
复制代码
package com.ken.learning.statistics.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.google.common.collect.Lists;
import com.ken.learning.statistics.dto.VideoPlayReportDTO;
import com.ken.learning.statistics.dto.BatchVideoPlayReportDTO;
import com.ken.learning.statistics.entity.LearningRecord;
import com.ken.learning.statistics.entity.Video;
import com.ken.learning.statistics.exception.BusinessException;
import com.ken.learning.statistics.mapper.LearningRecordMapper;
import com.ken.learning.statistics.mapper.VideoMapper;
import com.ken.learning.statistics.util.DateUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 视频播放数据上报Service
 * @author ken
 */
@Service
@Slf4j
public class VideoPlayReportService {

    @Resource
    private VideoMapper videoMapper;

    @Resource
    private LearningRecordMapper learningRecordMapper;

    @Resource
    private StatisticsService statisticsService;

    /**
     * 最小上报间隔(秒)
     */
    @Value("${learning.report.min-interval}")
    private int minInterval;

    /**
     * 快进判断阈值(秒/1%进度)
     */
    @Value("${learning.report.fast-forward-threshold}")
    private int fastForwardThreshold;

    /**
     * 单个视频播放数据上报
     */
    @Transactional(rollbackFor = Exception.class)
    public void reportVideoPlay(VideoPlayReportDTO reportDTO) {
        // 1. 校验时间格式
        if (!reportDTO.validateTimeFormat()) {
            throw new BusinessException("时间格式不正确,应为HH:mm:ss");
        }

        // 2. 校验视频是否存在并获取视频时长
        Video video = videoMapper.selectById(reportDTO.getVideoId());
        if (ObjectUtils.isEmpty(video)) {
            throw new BusinessException("视频不存在");
        }
        int videoDuration = video.getVideoDuration();

        // 3. 校验上报间隔(避免频繁上报)
        LocalDateTime now = LocalDateTime.now();
        reportDTO.setReportTime(now);
        LambdaQueryWrapper<LearningRecord> queryWrapper = new LambdaQueryWrapper<LearningRecord>()
                .eq(LearningRecord::getUserId, reportDTO.getUserId())
                .eq(LearningRecord::getVideoId, reportDTO.getVideoId())
                .ge(LearningRecord::getReportTime, now.minusSeconds(minInterval));
        Long count = learningRecordMapper.selectCount(queryWrapper);
        if (count > 0) {
            log.warn("用户{}视频{}上报间隔过短,忽略此次上报", reportDTO.getUserId(), reportDTO.getVideoId());
            return;
        }

        // 4. 计算有效学习时长
        int effectiveDuration = calculateEffectiveDuration(reportDTO, videoDuration);

        // 5. 构建学习记录实体
        LearningRecord learningRecord = buildLearningRecord(reportDTO, effectiveDuration);

        // 6. 保存学习记录(异步保存,提升响应速度)
        asyncSaveLearningRecord(learningRecord);

        // 7. 若为结束上报,触发实时统计更新
        if (reportDTO.getProgress() >= 100) {
            statisticsService.updateRealTimeStatistics(reportDTO.getUserId(), reportDTO.getCourseId(), 
                    reportDTO.getClassId(), effectiveDuration);
        }
    }

    /**
     * 批量视频播放数据上报
     */
    @Transactional(rollbackFor = Exception.class)
    public void batchReportVideoPlay(BatchVideoPlayReportDTO batchReportDTO) {
        if (CollectionUtils.isEmpty(batchReportDTO.getReportList())) {
            throw new BusinessException("上报数据列表不能为空");
        }

        List<LearningRecord> learningRecordList = Lists.newArrayList();
        LocalDateTime now = LocalDateTime.now();

        for (VideoPlayReportDTO reportDTO : batchReportDTO.getReportList()) {
            try {
                // 1. 校验时间格式
                if (!reportDTO.validateTimeFormat()) {
                    log.error("批量上报数据时间格式错误:{}", reportDTO);
                    continue;
                }

                // 2. 校验视频是否存在
                Video video = videoMapper.selectById(reportDTO.getVideoId());
                if (ObjectUtils.isEmpty(video)) {
                    log.error("批量上报数据视频不存在:{}", reportDTO);
                    continue;
                }

                // 3. 校验上报间隔
                LambdaQueryWrapper<LearningRecord> queryWrapper = new LambdaQueryWrapper<LearningRecord>()
                        .eq(LearningRecord::getUserId, reportDTO.getUserId())
                        .eq(LearningRecord::getVideoId, reportDTO.getVideoId())
                        .ge(LearningRecord::getReportTime, now.minusSeconds(minInterval));
                Long count = learningRecordMapper.selectCount(queryWrapper);
                if (count > 0) {
                    log.warn("批量上报用户{}视频{}间隔过短,忽略", reportDTO.getUserId(), reportDTO.getVideoId());
                    continue;
                }

                // 4. 计算有效时长
                int effectiveDuration = calculateEffectiveDuration(reportDTO, video.getVideoDuration());

                // 5. 构建学习记录
                LearningRecord learningRecord = buildLearningRecord(reportDTO, effectiveDuration);
                learningRecordList.add(learningRecord);

                // 6. 结束上报触发统计更新
                if (reportDTO.getProgress() >= 100) {
                    statisticsService.updateRealTimeStatistics(reportDTO.getUserId(), reportDTO.getCourseId(), 
                            reportDTO.getClassId(), effectiveDuration);
                }
            } catch (Exception e) {
                log.error("批量上报处理单条数据失败:{}", reportDTO, e);
                // 单个数据失败不影响整体批量处理
                continue;
            }
        }

        // 批量保存学习记录
        if (!CollectionUtils.isEmpty(learningRecordList)) {
            asyncBatchSaveLearningRecord(learningRecordList);
        }
    }

    /**
     * 计算有效学习时长
     * @param reportDTO 上报数据
     * @param videoDuration 视频实际时长(秒)
     * @return 有效学习时长(秒)
     */
    private int calculateEffectiveDuration(VideoPlayReportDTO reportDTO, int videoDuration) {
        int playDuration = reportDTO.getPlayDuration();
        int progress = reportDTO.getProgress();

        // 1. 进度合法性校验:进度不能超过100%
        if (progress > 100) {
            log.warn("用户{}视频{}上报进度超过100%,进度:{}", 
                    reportDTO.getUserId(), reportDTO.getVideoId(), progress);
            progress = 100;
        }

        // 2. 计算理论播放时长(结束时间-开始时间,转换为秒)
        int startTimeSec = DateUtils.timeToSeconds(reportDTO.getStartTime());
        int endTimeSec = DateUtils.timeToSeconds(reportDTO.getEndTime());
        int theoryDuration = endTimeSec - startTimeSec;
        if (theoryDuration < 0) {
            log.warn("用户{}视频{}上报时间异常,开始时间:{},结束时间:{}", 
                    reportDTO.getUserId(), reportDTO.getVideoId(), reportDTO.getStartTime(), reportDTO.getEndTime());
            theoryDuration = playDuration;
        }

        // 3. 时长合理性校验:上报时长不能超过理论时长+30秒(网络延迟容错)
        if (playDuration > theoryDuration + 30) {
            log.warn("用户{}视频{}上报时长异常,上报时长:{},理论时长:{}", 
                    reportDTO.getUserId(), reportDTO.getVideoId(), playDuration, theoryDuration);
            playDuration = theoryDuration;
        }

        // 4. 快进判断:进度变化率 = 播放时长 / 进度变化(秒/%)
        // 若进度变化率 < 快进阈值,视为快进,有效时长按进度占比计算
        int progressChange = progress; // 简化处理:假设本次上报进度为累计进度
        double progressRate = (double) playDuration / progressChange;
        if (progressRate < fastForwardThreshold && progressChange > 0) {
            log.warn("用户{}视频{}存在快进行为,进度变化:{},播放时长:{}", 
                    reportDTO.getUserId(), reportDTO.getVideoId(), progressChange, playDuration);
            return (int) Math.round((double) progress / 100 * videoDuration);
        }

        // 5. 正常情况:有效时长 = 上报时长(不超过视频总时长)
        return Math.min(playDuration, videoDuration);
    }

    /**
     * 构建学习记录实体
     */
    private LearningRecord buildLearningRecord(VideoPlayReportDTO reportDTO, int effectiveDuration) {
        LearningRecord learningRecord = new LearningRecord();
        learningRecord.setUserId(reportDTO.getUserId());
        learningRecord.setCourseId(reportDTO.getCourseId());
        learningRecord.setVideoId(reportDTO.getVideoId());
        learningRecord.setStartTime(DateUtils.parseTime(reportDTO.getStartTime()));
        learningRecord.setEndTime(DateUtils.parseTime(reportDTO.getEndTime()));
        learningRecord.setPlayDuration(reportDTO.getPlayDuration());
        learningRecord.setEffectiveDuration(effectiveDuration);
        learningRecord.setProgress(reportDTO.getProgress());
        learningRecord.setReportTime(reportDTO.getReportTime());
        learningRecord.setDevice(reportDTO.getDevice());
        learningRecord.setIp(reportDTO.getIp());
        return learningRecord;
    }

    /**
     * 异步保存学习记录
     */
    @Async
    public void asyncSaveLearningRecord(LearningRecord learningRecord) {
        try {
            learningRecordMapper.insert(learningRecord);
            log.info("异步保存学习记录成功:{}", learningRecord.getId());
        } catch (Exception e) {
            log.error("异步保存学习记录失败:{}", learningRecord, e);
            // 可添加失败重试机制(如定时任务重试)
        }
    }

    /**
     * 异步批量保存学习记录
     */
    @Async
    public void asyncBatchSaveLearningRecord(List<LearningRecord> learningRecordList) {
        try {
            // 分批插入,避免SQL过长(每批500条)
            List<List<LearningRecord>> partitions = Lists.partition(learningRecordList, 500);
            for (List<LearningRecord> partition : partitions) {
                learningRecordMapper.batchInsert(partition);
            }
            log.info("异步批量保存学习记录成功,共{}条", learningRecordList.size());
        } catch (Exception e) {
            log.error("异步批量保存学习记录失败,数量:{}", learningRecordList.size(), e);
        }
    }
}

4.5 多维度统计功能实现

4.5.1 统计相关 VO 设计
复制代码
package com.ken.learning.statistics.model.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDate;

/**
 * 课程学习时长统计VO
 * @author ken
 */
@Data
@Schema(description = "课程学习时长统计VO")
public class CourseDurationStatVO {
    @Schema(description = "课程ID")
    private Long courseId;

    @Schema(description = "课程名称")
    private String courseName;

    @Schema(description = "授课教师")
    private String teacherName;

    @Schema(description = "课程总时长(秒)")
    private Integer courseTotalDuration;

    @Schema(description = "学员平均学习时长(秒)")
    private Long avgUserDuration;

    @Schema(description = "总学习人次")
    private Integer totalUserCount;

    @Schema(description = "完成学习人数(进度100%)")
    private Integer finishedUserCount;

    @Schema(description = "统计日期(yyyy-MM-dd)")
    private LocalDate statDate;

    @Schema(description = "课程学习总时长(秒)")
    private Long totalEffectiveDuration;
}

package com.ken.learning.statistics.model.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDate;

/**
 * 班级学习时长统计VO
 * @author ken
 */
@Data
@Schema(description = "班级学习时长统计VO")
public class ClassDurationStatVO {
    @Schema(description = "班级ID")
    private Long classId;

    @Schema(description = "班级名称")
    private String className;

    @Schema(description = "年级")
    private String grade;

    @Schema(description = "学校名称")
    private String schoolName;

    @Schema(description = "班级总人数")
    private Integer totalUserCount;

    @Schema(description = "参与学习人数")
    private Integer activeUserCount;

    @Schema(description = "班级总有效学习时长(秒)")
    private Long totalEffectiveDuration;

    @Schema(description = "人均学习时长(秒)")
    private Long avgUserDuration;

    @Schema(description = "统计日期(yyyy-MM-dd)")
    private LocalDate statDate;

    @Schema(description = "平均完成课程数")
    private Double avgFinishedCourseCount;
}

package com.ken.learning.statistics.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.time.LocalDate;

/**
 * 统计查询DTO
 * @author ken
 */
@Data
@Schema(description = "统计查询DTO")
public class StatQueryDTO {
    @Schema(description = "用户ID(可选,精准查询单个用户)")
    private Long userId;

    @Schema(description = "课程ID(可选,精准查询单个课程)")
    private Long courseId;

    @Schema(description = "班级ID(可选,精准查询单个班级)")
    private Long classId;

    @NotNull(message = "统计开始日期不能为空")
    @Schema(description = "统计开始日期(yyyy-MM-dd)", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDate startDate;

    @NotNull(message = "统计结束日期不能为空")
    @Schema(description = "统计结束日期(yyyy-MM-dd)", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDate endDate;

    @Min(value = 1, message = "页码不能小于1")
    @Schema(description = "页码(默认1)", defaultValue = "1")
    private Integer pageNum = 1;

    @Min(value = 10, message = "每页条数不能小于10")
    @Schema(description = "每页条数(默认20)", defaultValue = "20")
    private Integer pageSize = 20;
}
4.5.2 StatisticsService 实现
复制代码
package com.ken.learning.statistics.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.common.collect.Maps;
import com.ken.learning.statistics.entity.ClassInfo;
import com.ken.learning.statistics.entity.Course;
import com.ken.learning.statistics.entity.StatisticsResult;
import com.ken.learning.statistics.entity.User;
import com.ken.learning.statistics.mapper.ClassInfoMapper;
import com.ken.learning.statistics.mapper.CourseMapper;
import com.ken.learning.statistics.mapper.LearningRecordMapper;
import com.ken.learning.statistics.mapper.StatisticsResultMapper;
import com.ken.learning.statistics.mapper.UserMapper;
import com.ken.learning.statistics.model.dto.StatQueryDTO;
import com.ken.learning.statistics.model.vo.ClassDurationStatVO;
import com.ken.learning.statistics.model.vo.CourseDurationStatVO;
import com.ken.learning.statistics.model.vo.UserDurationStatVO;
import com.ken.learning.statistics.util.DateUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

import jakarta.annotation.Resource;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 学习时长统计Service
 * @author ken
 */
@Service
@Slf4j
public class StatisticsService {

    @Resource
    private StatisticsResultMapper statisticsResultMapper;

    @Resource
    private LearningRecordMapper learningRecordMapper;

    @Resource
    private UserMapper userMapper;

    @Resource
    private CourseMapper courseMapper;

    @Resource
    private ClassInfoMapper classInfoMapper;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 缓存过期时间(小时)
     */
    @Value("${learning.stat.cache-expire-hours:24}")
    private int cacheExpireHours;

    /**
     * 统计类型:1-用户总时长,2-课程总时长,3-班级总时长
     */
    private static final int STAT_TYPE_USER = 1;
    private static final int STAT_TYPE_COURSE = 2;
    private static final int STAT_TYPE_CLASS = 3;

    /**
     * 缓存Key前缀
     */
    private static final String CACHE_KEY_USER_STAT = "stat:user:";
    private static final String CACHE_KEY_COURSE_STAT = "stat:course:";
    private static final String CACHE_KEY_CLASS_STAT = "stat:class:";
    private static final String CACHE_KEY_PAGE_USER = "stat:page:user:";
    private static final String CACHE_KEY_PAGE_COURSE = "stat:page:course:";
    private static final String CACHE_KEY_PAGE_CLASS = "stat:page:class:";

    /**
     * 实时更新统计结果(针对结束上报的视频)
     * @param userId 用户ID
     * @param courseId 课程ID
     * @param classId 班级ID
     * @param effectiveDuration 有效时长(秒)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateRealTimeStatistics(Long userId, Long courseId, Long classId, int effectiveDuration) {
        LocalDate today = LocalDate.now();
        LocalDateTime statStartTime = today.atStartOfDay();
        LocalDateTime statEndTime = today.plusDays(1).atStartOfDay().minusSeconds(1);

        // 1. 更新用户统计
        updateUserStat(userId, today, effectiveDuration);

        // 2. 更新课程统计
        updateCourseStat(courseId, today, effectiveDuration);

        // 3. 更新班级统计
        updateClassStat(classId, today, effectiveDuration);

        // 4. 清除对应缓存(避免缓存脏数据)
        clearCache(userId, courseId, classId, today);
    }

    /**
     * 更新用户统计结果
     */
    private void updateUserStat(Long userId, LocalDate statDate, int effectiveDuration) {
        LambdaQueryWrapper<StatisticsResult> queryWrapper = new LambdaQueryWrapper<StatisticsResult>()
                .eq(StatisticsResult::getStatType, STAT_TYPE_USER)
                .eq(StatisticsResult::getStatDimensionId, userId)
                .eq(StatisticsResult::getStatDate, statDate);

        StatisticsResult statResult = statisticsResultMapper.selectOne(queryWrapper);
        if (ObjectUtils.isEmpty(statResult)) {
            // 新增统计记录
            statResult = new StatisticsResult();
            statResult.setStatType(STAT_TYPE_USER);
            statResult.setStatDimensionId(userId);
            statResult.setStatDate(statDate);
            statResult.setTotalEffectiveDuration((long) effectiveDuration);
            statisticsResultMapper.insert(statResult);
        } else {
            // 更新统计记录(累加时长)
            statResult.setTotalEffectiveDuration(statResult.getTotalEffectiveDuration() + effectiveDuration);
            statResult.setUpdateTime(LocalDateTime.now());
            statisticsResultMapper.updateById(statResult);
        }
    }

    /**
     * 更新课程统计结果
     */
    private void updateCourseStat(Long courseId, LocalDate statDate, int effectiveDuration) {
        LambdaQueryWrapper<StatisticsResult> queryWrapper = new LambdaQueryWrapper<StatisticsResult>()
                .eq(StatisticsResult::getStatType, STAT_TYPE_COURSE)
                .eq(StatisticsResult::getStatDimensionId, courseId)
                .eq(StatisticsResult::getStatDate, statDate);

        StatisticsResult statResult = statisticsResultMapper.selectOne(queryWrapper);
        if (ObjectUtils.isEmpty(statResult)) {
            statResult = new StatisticsResult();
            statResult.setStatType(STAT_TYPE_COURSE);
            statResult.setStatDimensionId(courseId);
            statResult.setStatDate(statDate);
            statResult.setTotalEffectiveDuration((long) effectiveDuration);
            statisticsResultMapper.insert(statResult);
        } else {
            statResult.setTotalEffectiveDuration(statResult.getTotalEffectiveDuration() + effectiveDuration);
            statResult.setUpdateTime(LocalDateTime.now());
            statisticsResultMapper.updateById(statResult);
        }
    }

    /**
     * 更新班级统计结果
     */
    private void updateClassStat(Long classId, LocalDate statDate, int effectiveDuration) {
        LambdaQueryWrapper<StatisticsResult> queryWrapper = new LambdaQueryWrapper<StatisticsResult>()
                .eq(StatisticsResult::getStatType, STAT_TYPE_CLASS)
                .eq(StatisticsResult::getStatDimensionId, classId)
                .eq(StatisticsResult::getStatDate, statDate);

        StatisticsResult statResult = statisticsResultMapper.selectOne(queryWrapper);
        if (ObjectUtils.isEmpty(statResult)) {
            statResult = new StatisticsResult();
            statResult.setStatType(STAT_TYPE_CLASS);
            statResult.setStatDimensionId(classId);
            statResult.setStatDate(statDate);
            statResult.setTotalEffectiveDuration((long) effectiveDuration);
            statisticsResultMapper.insert(statResult);
        } else {
            statResult.setTotalEffectiveDuration(statResult.getTotalEffectiveDuration() + effectiveDuration);
            statResult.setUpdateTime(LocalDateTime.now());
            statisticsResultMapper.updateById(statResult);
        }
    }

    /**
     * 清除对应缓存
     */
    private void clearCache(Long userId, Long courseId, Long classId, LocalDate statDate) {
        // 清除单个用户/课程/班级的缓存
        stringRedisTemplate.delete(CACHE_KEY_USER_STAT + userId + ":" + statDate);
        stringRedisTemplate.delete(CACHE_KEY_COURSE_STAT + courseId + ":" + statDate);
        stringRedisTemplate.delete(CACHE_KEY_CLASS_STAT + classId + ":" + statDate);

        // 清除分页缓存(简化处理:清除当天所有分页缓存,可根据实际场景优化)
        String pageCachePattern = CACHE_KEY_PAGE_USER + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pageCachePattern));
        pageCachePattern = CACHE_KEY_PAGE_COURSE + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pageCachePattern));
        pageCachePattern = CACHE_KEY_PAGE_CLASS + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pageCachePattern));

        log.info("清除缓存:用户{}、课程{}、班级{}的{}统计缓存", userId, courseId, classId, statDate);
    }

    /**
     * 分页查询用户学习时长统计
     */
    public IPage<UserDurationStatVO> pageQueryUserDurationStat(StatQueryDTO queryDTO) {
        LocalDate startDate = queryDTO.getStartDate();
        LocalDate endDate = queryDTO.getEndDate();
        Long userId = queryDTO.getUserId();
        int pageNum = queryDTO.getPageNum();
        int pageSize = queryDTO.getPageSize();

        // 构建缓存Key(包含查询条件)
        String cacheKey = CACHE_KEY_PAGE_USER + pageNum + ":" + pageSize + ":" + startDate + ":" + endDate + ":" + (userId == null ? "all" : userId);

        // 尝试从缓存获取
        String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.hasText(cacheValue)) {
            try {
                return JSON.parseObject(cacheValue, new TypeReference<IPage<UserDurationStatVO>>() {});
            } catch (Exception e) {
                log.error("解析用户统计缓存失败:{}", cacheKey, e);
                stringRedisTemplate.delete(cacheKey);
            }
        }

        // 缓存未命中,查询数据库
        Page<UserDurationStatVO> page = new Page<>(pageNum, pageSize);
        IPage<UserDurationStatVO> statPage = learningRecordMapper.pageQueryUserDurationStat(
                page, userId, startDate, endDate);

        // 填充用户、班级信息
        fillUserClassInfo(statPage.getRecords());

        // 格式化时长(秒转HH:mm:ss)
        statPage.getRecords().forEach(vo -> {
            vo.setTotalDurationFormat(DateUtils.secondsToTime(vo.getTotalEffectiveDuration().intValue()));
        });

        // 存入缓存
        stringRedisTemplate.opsForValue().set(
                cacheKey, JSON.toJSONString(statPage), cacheExpireHours, TimeUnit.HOURS);

        return statPage;
    }

    /**
     * 分页查询课程学习时长统计
     */
    public IPage<CourseDurationStatVO> pageQueryCourseDurationStat(StatQueryDTO queryDTO) {
        LocalDate startDate = queryDTO.getStartDate();
        LocalDate endDate = queryDTO.getEndDate();
        Long courseId = queryDTO.getCourseId();
        int pageNum = queryDTO.getPageNum();
        int pageSize = queryDTO.getPageSize();

        // 构建缓存Key
        String cacheKey = CACHE_KEY_PAGE_COURSE + pageNum + ":" + pageSize + ":" + startDate + ":" + endDate + ":" + (courseId == null ? "all" : courseId);

        // 尝试从缓存获取
        String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.hasText(cacheValue)) {
            try {
                return JSON.parseObject(cacheValue, new TypeReference<IPage<CourseDurationStatVO>>() {});
            } catch (Exception e) {
                log.error("解析课程统计缓存失败:{}", cacheKey, e);
                stringRedisTemplate.delete(cacheKey);
            }
        }

        // 缓存未命中,查询数据库
        Page<CourseDurationStatVO> page = new Page<>(pageNum, pageSize);
        IPage<CourseDurationStatVO> statPage = learningRecordMapper.pageQueryCourseDurationStat(
                page, courseId, startDate, endDate);

        // 填充课程、教师信息
        fillCourseTeacherInfo(statPage.getRecords());

        // 存入缓存
        stringRedisTemplate.opsForValue().set(
                cacheKey, JSON.toJSONString(statPage), cacheExpireHours, TimeUnit.HOURS);

        return statPage;
    }

    /**
     * 分页查询班级学习时长统计
     */
    public IPage<ClassDurationStatVO> pageQueryClassDurationStat(StatQueryDTO queryDTO) {
        LocalDate startDate = queryDTO.getStartDate();
        LocalDate endDate = queryDTO.getEndDate();
        Long classId = queryDTO.getClassId();
        int pageNum = queryDTO.getPageNum();
        int pageSize = queryDTO.getPageSize();

        // 构建缓存Key
        String cacheKey = CACHE_KEY_PAGE_CLASS + pageNum + ":" + pageSize + ":" + startDate + ":" + endDate + ":" + (classId == null ? "all" : classId);

        // 尝试从缓存获取
        String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.hasText(cacheValue)) {
            try {
                return JSON.parseObject(cacheValue, new TypeReference<IPage<ClassDurationStatVO>>() {});
            } catch (Exception e) {
                log.error("解析班级统计缓存失败:{}", cacheKey, e);
                stringRedisTemplate.delete(cacheKey);
            }
        }

        // 缓存未命中,查询数据库
        Page<ClassDurationStatVO> page = new Page<>(pageNum, pageSize);
        IPage<ClassDurationStatVO> statPage = learningRecordMapper.pageQueryClassDurationStat(
                page, classId, startDate, endDate);

        // 填充班级、学校信息
        fillClassSchoolInfo(statPage.getRecords());

        // 存入缓存
        stringRedisTemplate.opsForValue().set(
                cacheKey, JSON.toJSONString(statPage), cacheExpireHours, TimeUnit.HOURS);

        return statPage;
    }

    /**
     * 填充用户和班级信息
     */
    private void fillUserClassInfo(List<UserDurationStatVO> statList) {
        if (CollectionUtils.isEmpty(statList)) {
            return;
        }

        // 批量查询用户信息
        List<Long> userIds = statList.stream().map(UserDurationStatVO::getUserId).collect(Collectors.toList());
        List<User> userList = userMapper.selectBatchIds(userIds);
        Map<Long, User> userMap = userList.stream().collect(Collectors.toMap(User::getUserId, user -> user));

        // 批量查询班级信息
        List<Long> classIds = userList.stream().map(User::getClassId).distinct().collect(Collectors.toList());
        List<ClassInfo> classList = classInfoMapper.selectBatchIds(classIds);
        Map<Long, ClassInfo> classMap = classList.stream().collect(Collectors.toMap(ClassInfo::getClassId, classInfo -> classInfo));

        // 填充信息
        statList.forEach(vo -> {
            User user = userMap.get(vo.getUserId());
            if (!ObjectUtils.isEmpty(user)) {
                vo.setUsername(user.getUsername());
                ClassInfo classInfo = classMap.get(user.getClassId());
                if (!ObjectUtils.isEmpty(classInfo)) {
                    vo.setClassName(classInfo.getClassName());
                }
            }
        });
    }

    /**
     * 填充课程和教师信息
     */
    private void fillCourseTeacherInfo(List<CourseDurationStatVO> statList) {
        if (CollectionUtils.isEmpty(statList)) {
            return;
        }

        // 批量查询课程信息
        List<Long> courseIds = statList.stream().map(CourseDurationStatVO::getCourseId).collect(Collectors.toList());
        List<Course> courseList = courseMapper.selectBatchIds(courseIds);
        Map<Long, Course> courseMap = courseList.stream().collect(Collectors.toMap(Course::getCourseId, course -> course));

        // 填充信息
        statList.forEach(vo -> {
            Course course = courseMap.get(vo.getCourseId());
            if (!ObjectUtils.isEmpty(course)) {
                vo.setCourseName(course.getCourseName());
                vo.setTeacherName(course.getTeacherName());
                vo.setCourseTotalDuration(course.getTotalDuration());
            }
        });
    }

    /**
     * 填充班级和学校信息
     */
    private void fillClassSchoolInfo(List<ClassDurationStatVO> statList) {
        if (CollectionUtils.isEmpty(statList)) {
            return;
        }

        // 批量查询班级信息
        List<Long> classIds = statList.stream().map(ClassDurationStatVO::getClassId).collect(Collectors.toList());
        List<ClassInfo> classList = classInfoMapper.selectBatchIds(classIds);
        Map<Long, ClassInfo> classMap = classList.stream().collect(Collectors.toMap(ClassInfo::getClassId, classInfo -> classInfo));

        // 批量查询班级人数
        Map<Long, Integer> classUserCountMap = Maps.newHashMap();
        for (Long classId : classIds) {
            LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<User>().eq(User::getClassId, classId);
            Integer count = userMapper.selectCount(queryWrapper);
            classUserCountMap.put(classId, count);
        }

        // 填充信息
        statList.forEach(vo -> {
            ClassInfo classInfo = classMap.get(vo.getClassId());
            if (!ObjectUtils.isEmpty(classInfo)) {
                vo.setClassName(classInfo.getClassName());
                vo.setGrade(classInfo.getGrade());
                vo.setSchoolName(classInfo.getSchoolName());
            }
            vo.setTotalUserCount(classUserCountMap.getOrDefault(vo.getClassId(), 0));
            // 计算人均学习时长
            if (vo.getActiveUserCount() != null && vo.getActiveUserCount() > 0) {
                vo.setAvgUserDuration(vo.getTotalEffectiveDuration() / vo.getActiveUserCount());
            } else {
                vo.setAvgUserDuration(0L);
            }
        });
    }

    /**
     * 定时全量统计(每日凌晨2点执行,补偿实时统计遗漏数据)
     */
    @Transactional(rollbackFor = Exception.class)
    public void fullStatistics(LocalDate statDate) {
        log.info("开始执行{}全量学习时长统计", statDate);
        LocalDateTime startTime = statDate.atStartOfDay();
        LocalDateTime endTime = statDate.plusDays(1).atStartOfDay().minusSeconds(1);

        // 1. 统计用户时长
        List<Map<String, Object>> userStatList = learningRecordMapper.statUserDurationByDate(startTime, endTime);
        batchUpdateStatResult(userStatList, STAT_TYPE_USER, statDate);

        // 2. 统计课程时长
        List<Map<String, Object>> courseStatList = learningRecordMapper.statCourseDurationByDate(startTime, endTime);
        batchUpdateStatResult(courseStatList, STAT_TYPE_COURSE, statDate);

        // 3. 统计班级时长
        List<Map<String, Object>> classStatList = learningRecordMapper.statClassDurationByDate(startTime, endTime);
        batchUpdateStatResult(classStatList, STAT_TYPE_CLASS, statDate);

        // 4. 清除当天所有缓存
        clearAllCacheByDate(statDate);

        log.info("{}全量学习时长统计执行完成", statDate);
    }

    /**
     * 批量更新统计结果
     */
    private void batchUpdateStatResult(List<Map<String, Object>> statList, int statType, LocalDate statDate) {
        if (CollectionUtils.isEmpty(statList)) {
            log.info("{}类型{}统计无数据", statType, statDate);
            return;
        }

        List<StatisticsResult> insertList = Lists.newArrayList();
        List<StatisticsResult> updateList = Lists.newArrayList();

        for (Map<String, Object> statMap : statList) {
            Long dimensionId = Long.parseLong(statMap.get("dimension_id").toString());
            Long totalDuration = Long.parseLong(statMap.get("total_duration").toString());

            LambdaQueryWrapper<StatisticsResult> queryWrapper = new LambdaQueryWrapper<StatisticsResult>()
                    .eq(StatisticsResult::getStatType, statType)
                    .eq(StatisticsResult::getStatDimensionId, dimensionId)
                    .eq(StatisticsResult::getStatDate, statDate);

            StatisticsResult existResult = statisticsResultMapper.selectOne(queryWrapper);
            if (ObjectUtils.isEmpty(existResult)) {
                StatisticsResult newResult = new StatisticsResult();
                newResult.setStatType(statType);
                newResult.setStatDimensionId(dimensionId);
                newResult.setStatDate(statDate);
                newResult.setTotalEffectiveDuration(totalDuration);
                insertList.add(newResult);
            } else {
                existResult.setTotalEffectiveDuration(totalDuration);
                existResult.setUpdateTime(LocalDateTime.now());
                updateList.add(existResult);
            }
        }

        // 批量插入
        if (!CollectionUtils.isEmpty(insertList)) {
            List<List<StatisticsResult>> partitions = Lists.partition(insertList, 500);
            for (List<StatisticsResult> partition : partitions) {
                statisticsResultMapper.batchInsert(partition);
            }
            log.info("批量插入{}类型统计结果{}条", statType, insertList.size());
        }

        // 批量更新
        if (!CollectionUtils.isEmpty(updateList)) {
            List<List<StatisticsResult>> partitions = Lists.partition(updateList, 500);
            for (List<StatisticsResult> partition : partitions) {
                statisticsResultMapper.batchUpdate(partition);
            }
            log.info("批量更新{}类型统计结果{}条", statType, updateList.size());
        }
    }

    /**
     * 清除指定日期的所有缓存
     */
    private void clearAllCacheByDate(LocalDate statDate) {
        String pattern = CACHE_KEY_USER_STAT + "*:" + statDate;
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        pattern = CACHE_KEY_COURSE_STAT + "*:" + statDate;
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        pattern = CACHE_KEY_CLASS_STAT + "*:" + statDate;
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        pattern = CACHE_KEY_PAGE_USER + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        pattern = CACHE_KEY_PAGE_COURSE + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        pattern = CACHE_KEY_PAGE_CLASS + "*:" + statDate + "*";
        stringRedisTemplate.delete(stringRedisTemplate.keys(pattern));

        log.info("清除{}所有统计缓存", statDate);
    }
}
4.5.3 Mapper 层 SQL 实现(MyBatis-Plus XML)
复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.learning.statistics.mapper.LearningRecordMapper">

    <!-- 批量插入学习记录 -->
    <insert id="batchInsert" parameterType="java.util.List">
        INSERT INTO learning_record (
            user_id, course_id, video_id, start_time, end_time,
            play_duration, effective_duration, progress, report_time,
            device, ip, create_time, update_time
        )
        VALUES
        <foreach collection="list" item="item" separator=",">
            (
            #{item.userId}, #{item.courseId}, #{item.videoId}, #{item.startTime}, #{item.endTime},
            #{item.playDuration}, #{item.effectiveDuration}, #{item.progress}, #{item.reportTime},
            #{item.device,typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler},
            #{item.ip}, NOW(), NOW()
            )
        </foreach>
    </insert>

    <!-- 分页查询用户学习时长统计 -->
    <select id="pageQueryUserDurationStat" resultType="com.ken.learning.statistics.model.vo.UserDurationStatVO">
        SELECT
            lr.user_id AS userId,
            SUM(lr.effective_duration) AS totalEffectiveDuration,
            COUNT(DISTINCT lr.course_id) AS finishedCourseCount,
            COUNT(DISTINCT lr.video_id) AS totalVideoCount,
            DATE(lr.report_time) AS statDate
        FROM
            learning_record lr
        <where>
            <if test="userId != null">AND lr.user_id = #{userId}</if>
            AND DATE(lr.report_time) BETWEEN #{startDate} AND #{endDate}
        </where>
        GROUP BY
            lr.user_id, DATE(lr.report_time)
        ORDER BY
            totalEffectiveDuration DESC
    </select>

    <!-- 分页查询课程学习时长统计 -->
    <select id="pageQueryCourseDurationStat" resultType="com.ken.learning.statistics.model.vo.CourseDurationStatVO">
        SELECT
            lr.course_id AS courseId,
            SUM(lr.effective_duration) AS totalEffectiveDuration,
            COUNT(DISTINCT lr.user_id) AS totalUserCount,
            SUM(CASE WHEN lr.progress = 100 THEN 1 ELSE 0 END) AS finishedUserCount,
            DATE(lr.report_time) AS statDate
        FROM
            learning_record lr
        <where>
            <if test="courseId != null">AND lr.course_id = #{courseId}</if>
            AND DATE(lr.report_time) BETWEEN #{startDate} AND #{endDate}
        </where>
        GROUP BY
            lr.course_id, DATE(lr.report_time)
        ORDER BY
            totalEffectiveDuration DESC
    </select>

    <!-- 分页查询班级学习时长统计 -->
    <select id="pageQueryClassDurationStat" resultType="com.ken.learning.statistics.model.vo.ClassDurationStatVO">
        SELECT
            u.class_id AS classId,
            SUM(lr.effective_duration) AS totalEffectiveDuration,
            COUNT(DISTINCT lr.user_id) AS activeUserCount,
            AVG(COUNT(DISTINCT lr.course_id)) OVER (PARTITION BY u.class_id) AS avgFinishedCourseCount,
            DATE(lr.report_time) AS statDate
        FROM
            learning_record lr
        LEFT JOIN
            user u ON lr.user_id = u.user_id
        <where>
            <if test="classId != null">AND u.class_id = #{classId}</if>
            AND DATE(lr.report_time) BETWEEN #{startDate} AND #{endDate}
        </where>
        GROUP BY
            u.class_id, DATE(lr.report_time), lr.user_id
        ORDER BY
            totalEffectiveDuration DESC
    </select>

    <!-- 按日期统计用户时长 -->
    <select id="statUserDurationByDate" resultType="java.util.Map">
        SELECT
            lr.user_id AS dimension_id,
            SUM(lr.effective_duration) AS total_duration
        FROM
            learning_record lr
        WHERE
            lr.report_time BETWEEN #{startTime} AND #{endTime}
        GROUP BY
            lr.user_id
    </select>

    <!-- 按日期统计课程时长 -->
    <select id="statCourseDurationByDate" resultType="java.util.Map">
        SELECT
            lr.course_id AS dimension_id,
            SUM(lr.effective_duration) AS total_duration
        FROM
            learning_record lr
        WHERE
            lr.report_time BETWEEN #{startTime} AND #{endTime}
        GROUP BY
            lr.course_id
    </select>

    <!-- 按日期统计班级时长 -->
    <select id="statClassDurationByDate" resultType="java.util.Map">
        SELECT
            u.class_id AS dimension_id,
            SUM(lr.effective_duration) AS total_duration
        FROM
            learning_record lr
        LEFT JOIN
            user u ON lr.user_id = u.user_id
        WHERE
            lr.report_time BETWEEN #{startTime} AND #{endTime}
        GROUP BY
            u.class_id
    </select>
</mapper>

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.learning.statistics.mapper.StatisticsResultMapper">

    <!-- 批量插入统计结果 -->
    <insert id="batchInsert" parameterType="java.util.List">
        INSERT INTO statistics_result (
            stat_type, stat_dimension_id, total_effective_duration,
            stat_date, create_time, update_time
        )
        VALUES
        <foreach collection="list" item="item" separator=",">
            (
            #{item.statType}, #{item.statDimensionId}, #{item.totalEffectiveDuration},
            #{item.statDate}, NOW(), NOW()
            )
        </foreach>
    </insert>

    <!-- 批量更新统计结果 -->
    <update id="batchUpdate" parameterType="java.util.List">
        <foreach collection="list" item="item" separator=";">
            UPDATE statistics_result
            SET
                total_effective_duration = #{item.totalEffectiveDuration},
                update_time = NOW()
            WHERE
                stat_type = #{item.statType}
                AND stat_dimension_id = #{item.statDimensionId}
                AND stat_date = #{item.statDate}
        </foreach>
    </update>
</mapper>
4.5.4 定时任务配置(全量统计)
复制代码
package com.ken.learning.statistics.config;

import com.ken.learning.statistics.service.StatisticsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

import jakarta.annotation.Resource;
import java.time.LocalDate;

/**
 * 定时统计任务配置
 * @author ken
 */
@Configuration
@EnableScheduling
@Slf4j
public class ScheduledTaskConfig {

    @Resource
    private StatisticsService statisticsService;

    /**
     * 每日凌晨2点执行全量统计(统计前一天数据)
     * cron表达式:0 0 2 * * ?
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void dailyFullStatistics() {
        try {
            LocalDate statDate = LocalDate.now().minusDays(1);
            statisticsService.fullStatistics(statDate);
        } catch (Exception e) {
            log.error("每日全量统计任务执行失败", e);
            // 可添加告警通知(如邮件、钉钉)
        }
    }
}

4.6 报表生成与导出功能

4.6.1 EasyExcel 实体类(报表模板)
复制代码
package com.ken.learning.statistics.model.excel;

import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDate;

/**
 * 用户学习时长报表Excel实体
 * @author ken
 */
@Data
@ExcelIgnoreUnannotated
@Schema(description = "用户学习时长报表Excel实体")
public class UserDurationExcel {

    @ExcelProperty(value = "用户ID", index = 0)
    private Long userId;

    @ExcelProperty(value = "用户名", index = 1)
    private String username;

    @ExcelProperty(value = "班级名称", index = 2)
    private String className;

    @ExcelProperty(value = "总有效学习时长(秒)", index = 3)
    private Long totalEffectiveDuration;

    @ExcelProperty(value = "总有效学习时长(HH:mm:ss)", index = 4)
    private String totalDurationFormat;

    @ExcelProperty(value = "统计日期", index = 5)
    @DateTimeFormat("yyyy-MM-dd")
    private LocalDate statDate;

    @ExcelProperty(value = "完成课程数", index = 6)
    private Integer finishedCourseCount;

    @ExcelProperty(value = "累计播放视频数", index = 7)
    private Integer totalVideoCount;
}

package com.ken.learning.statistics.model.excel;

import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDate;

/**
 * 课程学习时长报表Excel实体
 * @author ken
 */
@Data
@ExcelIgnoreUnannotated
@Schema(description = "课程学习时长报表Excel实体")
public class CourseDurationExcel {

    @ExcelProperty(value = "课程ID", index = 0)
    private Long courseId;

    @ExcelProperty(value = "课程名称", index = 1)
    private String courseName;

    @ExcelProperty(value = "授课教师", index = 2)
    private String teacherName;

    @ExcelProperty(value = "课程总时长(秒)", index = 3)
    private Integer courseTotalDuration;

    @ExcelProperty(value = "学员平均学习时长(秒)", index = 4)
    private Long avgUserDuration;

    @ExcelProperty(value = "总学习人次", index = 5)
    private Integer totalUserCount;

    @ExcelProperty(value = "完成学习人数", index = 6)
    private Integer finishedUserCount;

    @ExcelProperty(value = "统计日期", index = 7)
    @DateTimeFormat("yyyy-MM-dd")
    private LocalDate statDate;

    @ExcelProperty(value = "课程学习总时长(秒)", index = 8)
    private Long totalEffectiveDuration;
}

package com.ken.learning.statistics.model.excel;

import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDate;

/**
 * 班级学习时长报表Excel实体
 * @author ken
 */
@Data
@ExcelIgnoreUnannotated
@Schema(description = "班级学习时长报表Excel实体")
public class ClassDurationExcel {

    @ExcelProperty(value = "班级ID", index = 0)
    private Long classId;

    @ExcelProperty(value = "班级名称", index = 1)
    private String className;

    @ExcelProperty(value = "年级", index = 2)
    private String grade;

    @ExcelProperty(value = "学校名称", index = 3)
    private String schoolName;

    @ExcelProperty(value = "班级总人数", index = 4)
    private Integer totalUserCount;

    @ExcelProperty(value = "参与学习人数", index = 5)
    private Integer activeUserCount;

    @ExcelProperty(value = "班级总有效学习时长(秒)", index = 6)
    private Long totalEffectiveDuration;

    @ExcelProperty(value = "人均学习时长(秒)", index = 7)
    private Long avgUserDuration;

    @ExcelProperty(value = "统计日期", index = 8)
    @DateTimeFormat("yyyy-MM-dd")
    private LocalDate statDate;

    @ExcelProperty(value = "平均完成课程数", index = 9)
    private Double avgFinishedCourseCount;
}
4.6.2 ReportService 实现(报表生成)
复制代码
package com.ken.learning.statistics.service;

import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.google.common.collect.Lists;
import com.ken.learning.statistics.mapper.LearningRecordMapper;
import com.ken.learning.statistics.model.dto.StatQueryDTO;
import com.ken.learning.statistics.model.excel.ClassDurationExcel;
import com.ken.learning.statistics.model.excel.CourseDurationExcel;
import com.ken.learning.statistics.model.excel.UserDurationExcel;
import com.ken.learning.statistics.model.vo.ClassDurationStatVO;
import com.ken.learning.statistics.model.vo.CourseDurationStatVO;
import com.ken.learning.statistics.model.vo.UserDurationStatVO;
import com.ken.learning.statistics.util.DateUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 报表生成Service
 * @author ken
 */
@Service
@Slf4j
public class ReportService {

    @Resource
    private StatisticsService statisticsService;

    @Resource
    private LearningRecordMapper learningRecordMapper;

    /**
     * 导出用户学习时长报表Excel
     */
    public void exportUserDurationExcel(StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        log.info("导出用户学习时长报表:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());

        // 1. 查询统计数据(不分页,查询全部)
        queryDTO.setPageNum(1);
        queryDTO.setPageSize(Integer.MAX_VALUE);
        IPage<UserDurationStatVO> statPage = statisticsService.pageQueryUserDurationStat(queryDTO);
        List<UserDurationStatVO> statList = statPage.getRecords();

        if (CollectionUtils.isEmpty(statList)) {
            log.warn("用户学习时长报表无数据:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":400,\"msg\":\"报表无数据\"}");
            return;
        }

        // 2. 转换为Excel实体
        List<UserDurationExcel> excelList = statList.stream().map(statVO -> {
            UserDurationExcel excel = new UserDurationExcel();
            excel.setUserId(statVO.getUserId());
            excel.setUsername(statVO.getUsername());
            excel.setClassName(statVO.getClassName());
            excel.setTotalEffectiveDuration(statVO.getTotalEffectiveDuration());
            excel.setTotalDurationFormat(statVO.getTotalDurationFormat());
            excel.setStatDate(statVO.getStatDate());
            excel.setFinishedCourseCount(statVO.getFinishedCourseCount());
            excel.setTotalVideoCount(statVO.getTotalVideoCount());
            return excel;
        }).collect(Collectors.toList());

        // 3. 配置响应头
        setExcelResponseHeader(response, "用户学习时长报表_" + DateUtils.formatDate(queryDTO.getStartDate()) + "_" + DateUtils.formatDate(queryDTO.getEndDate()) + ".xlsx");

        // 4. 写入Excel
        try (OutputStream outputStream = response.getOutputStream()) {
            EasyExcel.write(outputStream, UserDurationExcel.class)
                    .sheet("用户学习时长统计")
                    .doWrite(excelList);
            log.info("用户学习时长报表导出成功,共{}条数据", excelList.size());
        } catch (Exception e) {
            log.error("用户学习时长报表导出失败", e);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":500,\"msg\":\"报表导出失败\"}");
        }
    }

    /**
     * 导出课程学习时长报表Excel
     */
    public void exportCourseDurationExcel(StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        log.info("导出课程学习时长报表:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());

        // 1. 查询统计数据(不分页)
        queryDTO.setPageNum(1);
        queryDTO.setPageSize(Integer.MAX_VALUE);
        IPage<CourseDurationStatVO> statPage = statisticsService.pageQueryCourseDurationStat(queryDTO);
        List<CourseDurationStatVO> statList = statPage.getRecords();

        if (CollectionUtils.isEmpty(statList)) {
            log.warn("课程学习时长报表无数据:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":400,\"msg\":\"报表无数据\"}");
            return;
        }

        // 2. 转换为Excel实体
        List<CourseDurationExcel> excelList = statList.stream().map(statVO -> {
            CourseDurationExcel excel = new CourseDurationExcel();
            excel.setCourseId(statVO.getCourseId());
            excel.setCourseName(statVO.getCourseName());
            excel.setTeacherName(statVO.getTeacherName());
            excel.setCourseTotalDuration(statVO.getCourseTotalDuration());
            excel.setAvgUserDuration(statVO.getAvgUserDuration());
            excel.setTotalUserCount(statVO.getTotalUserCount());
            excel.setFinishedUserCount(statVO.getFinishedUserCount());
            excel.setStatDate(statVO.getStatDate());
            excel.setTotalEffectiveDuration(statVO.getTotalEffectiveDuration());
            return excel;
        }).collect(Collectors.toList());

        // 3. 配置响应头
        setExcelResponseHeader(response, "课程学习时长报表_" + DateUtils.formatDate(queryDTO.getStartDate()) + "_" + DateUtils.formatDate(queryDTO.getEndDate()) + ".xlsx");

        // 4. 写入Excel
        try (OutputStream outputStream = response.getOutputStream()) {
            EasyExcel.write(outputStream, CourseDurationExcel.class)
                    .sheet("课程学习时长统计")
                    .doWrite(excelList);
            log.info("课程学习时长报表导出成功,共{}条数据", excelList.size());
        } catch (Exception e) {
            log.error("课程学习时长报表导出失败", e);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":500,\"msg\":\"报表导出失败\"}");
        }
    }

    /**
     * 导出班级学习时长报表Excel
     */
    public void exportClassDurationExcel(StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        log.info("导出班级学习时长报表:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());

        // 1. 查询统计数据(不分页)
        queryDTO.setPageNum(1);
        queryDTO.setPageSize(Integer.MAX_VALUE);
        IPage<ClassDurationStatVO> statPage = statisticsService.pageQueryClassDurationStat(queryDTO);
        List<ClassDurationStatVO> statList = statPage.getRecords();

        if (CollectionUtils.isEmpty(statList)) {
            log.warn("班级学习时长报表无数据:{}至{}", queryDTO.getStartDate(), queryDTO.getEndDate());
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":400,\"msg\":\"报表无数据\"}");
            return;
        }

        // 2. 转换为Excel实体
        List<ClassDurationExcel> excelList = statList.stream().map(statVO -> {
            ClassDurationExcel excel = new ClassDurationExcel();
            excel.setClassId(statVO.getClassId());
            excel.setClassName(statVO.getClassName());
            excel.setGrade(statVO.getGrade());
            excel.setSchoolName(statVO.getSchoolName());
            excel.setTotalUserCount(statVO.getTotalUserCount());
            excel.setActiveUserCount(statVO.getActiveUserCount());
            excel.setTotalEffectiveDuration(statVO.getTotalEffectiveDuration());
            excel.setAvgUserDuration(statVO.getAvgUserDuration());
            excel.setStatDate(statVO.getStatDate());
            excel.setAvgFinishedCourseCount(statVO.getAvgFinishedCourseCount());
            return excel;
        }).collect(Collectors.toList());

        // 3. 配置响应头
        setExcelResponseHeader(response, "班级学习时长报表_" + DateUtils.formatDate(queryDTO.getStartDate()) + "_" + DateUtils.formatDate(queryDTO.getEndDate()) + ".xlsx");

        // 4. 写入Excel
        try (OutputStream outputStream = response.getOutputStream()) {
            EasyExcel.write(outputStream, ClassDurationExcel.class)
                    .sheet("班级学习时长统计")
                    .doWrite(excelList);
            log.info("班级学习时长报表导出成功,共{}条数据", excelList.size());
        } catch (Exception e) {
            log.error("班级学习时长报表导出失败", e);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":500,\"msg\":\"报表导出失败\"}");
        }
    }

    /**
     * 设置Excel响应头(支持中文文件名)
     */
    private void setExcelResponseHeader(HttpServletResponse response, String fileName) {
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        // 处理中文文件名
        String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);
        // 禁止缓存
        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);
    }
}
4.6.3 ReportController 实现(报表导出接口)
复制代码
package com.ken.learning.statistics.controller;

import com.ken.learning.statistics.model.dto.StatQueryDTO;
import com.ken.learning.statistics.model.vo.Result;
import com.ken.learning.statistics.service.ReportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 报表导出Controller
 * @author ken
 */
@RestController
@RequestMapping("/api/report/export")
@Tag(name = "报表导出", description = "学习时长统计报表导出接口")
public class ReportController {

    @Resource
    private ReportService reportService;

    /**
     * 导出用户学习时长报表Excel
     */
    @GetMapping("/user-duration")
    @Operation(summary = "导出用户学习时长报表", description = "导出指定时间段内的用户学习时长统计Excel报表")
    public void exportUserDurationExcel(@Valid StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        reportService.exportUserDurationExcel(queryDTO, response);
    }

    /**
     * 导出课程学习时长报表Excel
     */
    @GetMapping("/course-duration")
    @Operation(summary = "导出课程学习时长报表", description = "导出指定时间段内的课程学习时长统计Excel报表")
    public void exportCourseDurationExcel(@Valid StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        reportService.exportCourseDurationExcel(queryDTO, response);
    }

    /**
     * 导出班级学习时长报表Excel
     */
    @GetMapping("/class-duration")
    @Operation(summary = "导出班级学习时长报表", description = "导出指定时间段内的班级学习时长统计Excel报表")
    public void exportClassDurationExcel(@Valid StatQueryDTO queryDTO, HttpServletResponse response) throws IOException {
        reportService.exportClassDurationExcel(queryDTO, response);
    }
}

4.7 统计查询 Controller 实现

复制代码
package com.ken.learning.statistics.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ken.learning.statistics.model.dto.StatQueryDTO;
import com.ken.learning.statistics.model.vo.ClassDurationStatVO;
import com.ken.learning.statistics.model.vo.CourseDurationStatVO;
import com.ken.learning.statistics.model.vo.Result;
import com.ken.learning.statistics.model.vo.UserDurationStatVO;
import com.ken.learning.statistics.service.StatisticsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 学习时长统计查询Controller
 * @author ken
 */
@RestController
@RequestMapping("/api/stat")
@Tag(name = "统计查询", description = "多维度学习时长统计查询接口")
public class StatisticsController {

    @Resource
    private StatisticsService statisticsService;

    /**
     * 分页查询用户学习时长统计
     */
    @PostMapping("/user-duration")
    @Operation(summary = "用户学习时长统计", description = "分页查询指定时间段内的用户学习时长统计数据")
    public Result<IPage<UserDurationStatVO>> pageQueryUserDurationStat(@Valid @RequestBody StatQueryDTO queryDTO) {
        IPage<UserDurationStatVO> statPage = statisticsService.pageQueryUserDurationStat(queryDTO);
        return Result.success(statPage);
    }

    /**
     * 分页查询课程学习时长统计
     */
    @PostMapping("/course-duration")
    @Operation(summary = "课程学习时长统计", description = "分页查询指定时间段内的课程学习时长统计数据")
    public Result<IPage<CourseDurationStatVO>> pageQueryCourseDurationStat(@Valid @RequestBody StatQueryDTO queryDTO) {
        IPage<CourseDurationStatVO> statPage = statisticsService.pageQueryCourseDurationStat(queryDTO);
        return Result.success(statPage);
    }

    /**
     * 分页查询班级学习时长统计
     */
    @PostMapping("/class-duration")
    @Operation(summary = "班级学习时长统计", description = "分页查询指定时间段内的班级学习时长统计数据")
    public Result<IPage<ClassDurationStatVO>> pageQueryClassDurationStat(@Valid @RequestBody StatQueryDTO queryDTO) {
        IPage<ClassDurationStatVO> statPage = statisticsService.pageQueryClassDurationStat(queryDTO);
        return Result.success(statPage);
    }
}

4.8 工具类实现

复制代码
package com.ken.learning.statistics.util;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

/**
 * 日期时间工具类
 * @author ken
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class DateUtils {

    /**
     * 日期格式:yyyy-MM-dd
     */
    public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    /**
     * 时间格式:HH:mm:ss
     */
    public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");

    /**
     * 日期时间格式:yyyy-MM-dd HH:mm:ss
     */
    public static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * 格式化日期(LocalDate → String)
     * @param date 日期
     * @return 格式化后的日期字符串(yyyy-MM-dd)
     */
    public static String formatDate(LocalDate date) {
        if (date == null) {
            return "";
        }
        return date.format(DATE_FORMATTER);
    }

    /**
     * 格式化时间(LocalTime → String)
     * @param time 时间
     * @return 格式化后的时间字符串(HH:mm:ss)
     */
    public static String formatTime(LocalTime time) {
        if (time == null) {
            return "";
        }
        return time.format(TIME_FORMATTER);
    }

    /**
     * 格式化日期时间(LocalDateTime → String)
     * @param dateTime 日期时间
     * @return 格式化后的日期时间字符串(yyyy-MM-dd HH:mm:ss)
     */
    public static String formatDateTime(LocalDateTime dateTime) {
        if (dateTime == null) {
            return "";
        }
        return dateTime.format(DATETIME_FORMATTER);
    }

    /**
     * 解析日期字符串(String → LocalDate)
     * @param dateStr 日期字符串(yyyy-MM-dd)
     * @return 解析后的LocalDate
     */
    public static LocalDate parseDate(String dateStr) {
        if (!StringUtils.hasText(dateStr)) {
            return null;
        }
        return LocalDate.parse(dateStr, DATE_FORMATTER);
    }

    /**
     * 解析时间字符串(String → LocalTime)
     * @param timeStr 时间字符串(HH:mm:ss)
     * @return 解析后的LocalTime
     */
    public static LocalTime parseTime(String timeStr) {
        if (!StringUtils.hasText(timeStr)) {
            return null;
        }
        return LocalTime.parse(timeStr, TIME_FORMATTER);
    }

    /**
     * 解析日期时间字符串(String → LocalDateTime)
     * @param dateTimeStr 日期时间字符串(yyyy-MM-dd HH:mm:ss)
     * @return 解析后的LocalDateTime
     */
    public static LocalDateTime parseDateTime(String dateTimeStr) {
        if (!StringUtils.hasText(dateTimeStr)) {
            return null;
        }
        return LocalDateTime.parse(dateTimeStr, DATETIME_FORMATTER);
    }

    /**
     * 将时间字符串转换为秒数(HH:mm:ss → 秒)
     * @param timeStr 时间字符串(HH:mm:ss)
     * @return 总秒数
     */
    public static int timeToSeconds(String timeStr) {
        if (!StringUtils.hasText(timeStr)) {
            return 0;
        }
        String[] parts = timeStr.split(":");
        if (parts.length != 3) {
            return 0;
        }
        try {
            int hours = Integer.parseInt(parts[0]);
            int minutes = Integer.parseInt(parts[1]);
            int seconds = Integer.parseInt(parts[2]);
            return hours * 3600 + minutes * 60 + seconds;
        } catch (NumberFormatException e) {
            return 0;
        }
    }

    /**
     * 将秒数转换为时间字符串(秒 → HH:mm:ss)
     * @param seconds 秒数
     * @return 时间字符串(HH:mm:ss)
     */
    public static String secondsToTime(int seconds) {
        if (seconds < 0) {
            return "00:00:00";
        }
        int hours = seconds / 3600;
        int minutes = (seconds % 3600) / 60;
        int secs = seconds % 60;
        return String.format("%02d:%02d:%02d", hours, minutes, secs);
    }

    /**
     * 将时间字符串转换为当天的LocalDateTime
     * @param timeStr 时间字符串(HH:mm:ss)
     * @return 当天的LocalDateTime
     */
    public static LocalDateTime toLocalDateTime(String timeStr) {
        LocalTime localTime = parseTime(timeStr);
        if (localTime == null) {
            return null;
        }
        return LocalDateTime.of(LocalDate.now(), localTime);
    }
}

package com.ken.learning.statistics.util;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.TypeReference;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.util.ObjectUtils;

import java.util.List;
import java.util.Map;

/**
 * JSON工具类(基于Fastjson2)
 * @author ken
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class JsonUtils {

    /**
     * 对象转换为JSON字符串
     * @param obj 待转换对象
     * @return JSON字符串
     */
    public static String toJsonString(Object obj) {
        if (ObjectUtils.isEmpty(obj)) {
            return "";
        }
        return JSON.toJSONString(obj);
    }

    /**
     * JSON字符串转换为对象
     * @param jsonStr JSON字符串
     * @param clazz 目标类
     * @param <T> 泛型
     * @return 转换后的对象
     */
    public static <T> T parseObject(String jsonStr, Class<T> clazz) {
        if (!org.springframework.util.StringUtils.hasText(jsonStr)) {
            return null;
        }
        return JSON.parseObject(jsonStr, clazz);
    }

    /**
     * JSON字符串转换为泛型对象(如List、Map)
     * @param jsonStr JSON字符串
     * @param typeReference 泛型类型引用
     * @param <T> 泛型
     * @return 转换后的对象
     */
    public static <T> T parseObject(String jsonStr, TypeReference<T> typeReference) {
        if (!org.springframework.util.StringUtils.hasText(jsonStr)) {
            return null;
        }
        return JSON.parseObject(jsonStr, typeReference);
    }

    /**
     * JSON字符串转换为List
     * @param jsonStr JSON字符串
     * @param clazz 元素类型
     * @param <T> 泛型
     * @return List对象
     */
    public static <T> List<T> parseList(String jsonStr, Class<T> clazz) {
        if (!org.springframework.util.StringUtils.hasText(jsonStr)) {
            return null;
        }
        return JSON.parseArray(jsonStr, clazz);
    }

    /**
     * 对象转换为JSONObject
     * @param obj 待转换对象
     * @return JSONObject
     */
    public static JSONObject toJSONObject(Object obj) {
        if (ObjectUtils.isEmpty(obj)) {
            return new JSONObject();
        }
        return (JSONObject) JSON.toJSON(obj);
    }

    /**
     * 转换对象为指定类型
     * @param obj 待转换对象
     * @param clazz 目标类型
     * @param <T> 泛型
     * @return 转换后的对象
     */
    public static <T> T convert(Object obj, Class<T> clazz) {
        if (ObjectUtils.isEmpty(obj)) {
            return null;
        }
        return JSON.convert(obj, clazz);
    }
}

5. 系统测试与验证

5.1 测试环境说明

  • JDK 版本:17.0.10
  • MySQL 版本:8.0.36
  • Redis 版本:7.2.4
  • 测试工具:Postman 10.21.2、JUnit 5
  • 测试数据量:模拟 1000 名用户、100 门课程、500 个视频、10 万条学习记录

5.2 核心功能测试用例

5.2.1 数据上报功能测试
测试用例 ID 测试场景 输入数据 预期结果 实际结果 测试状态
TC-REPORT-001 正常上报(播放中) userId=1001, videoId=3001, progress=30%, duration=120s 数据入库,有效时长 = 120s 符合预期 通过
TC-REPORT-002 快进上报 userId=1001, videoId=3001, progress=80%, duration=50s(视频时长 600s) 有效时长 = 480s(80%×600) 符合预期 通过
TC-REPORT-003 进度超过 100% userId=1001, videoId=3001, progress=120%, duration=600s 进度修正为 100%,有效时长 = 600s 符合预期 通过
TC-REPORT-004 频繁上报(间隔 5 秒) 连续 2 次上报,间隔 5 秒(最小间隔 10 秒) 第二次上报被忽略 符合预期 通过
TC-REPORT-005 批量上报(含无效数据) 5 条有效数据 + 2 条无效视频 ID 5 条入库,2 条忽略 符合预期 通过
5.2.2 统计功能测试
测试用例 ID 测试场景 输入条件 预期结果 实际结果 测试状态
TC-STAT-001 用户单日统计 userId=1001, 日期 = 2025-11-14 总时长 = 各记录有效时长之和 符合预期 通过
TC-STAT-002 课程跨日统计 courseId=2001, 日期 = 2025-11-10 至 2025-11-14 总时长 = 5 天数据累加 符合预期 通过
TC-STAT-003 班级人均时长 classId=4001, 10 人学习,总时长 = 36000s 人均时长 = 3600s 符合预期 通过
TC-STAT-004 缓存有效性 同一条件查询 2 次,第一次查库,第二次查缓存 第二次响应时间 < 10ms 符合预期 通过
TC-STAT-005 全量统计补偿 实时统计遗漏 1 条记录,执行全量统计 统计结果包含遗漏记录 符合预期 通过
5.2.3 报表导出测试
测试用例 ID 测试场景 导出条件 预期结果 实际结果 测试状态
TC-EXPORT-001 用户报表导出 日期 = 2025-11-14,100 条数据 Excel 格式正确,数据完整 符合预期 通过
TC-EXPORT-002 课程报表导出 日期 = 2025-11-01 至 2025-11-14 包含课程名称、教师等信息 符合预期 通过
TC-EXPORT-003 大数据量导出 10 万条用户记录 导出成功,无 OOM,耗时 < 30s 符合预期 通过
TC-EXPORT-004 无数据导出 日期 = 2025-10-01(无数据) 返回 "报表无数据" 提示 符合预期 通过

5.3 性能测试结果

  • 数据上报性能:单节点支持 1000 TPS,99% 响应时间 < 50ms
  • 统计查询性能
    • 未缓存:单条查询平均响应时间 800ms
    • 已缓存:单条查询平均响应时间 15ms
  • 报表导出性能
    • 1 万条数据:导出耗时 < 3s
    • 10 万条数据:导出耗时 < 15s
  • 数据库压力:全量统计(10 万条记录)耗时 < 60s,CPU 使用率 < 70%

6. 系统优化与扩展建议

6.1 性能优化建议

  1. 数据库优化

    • 对 learning_record 表按 report_time 进行分区(按日 / 月分区),提升时间范围查询性能
    • 新增联合索引:idx_user_course_time(user_id, course_id, report_time),优化用户 - 课程维度统计
    • 定期归档历史数据(如超过 3 个月的原始记录迁移至归档表)
  2. 缓存优化

    • 引入 Redis 集群,支持缓存分片和高可用
    • 实现缓存预热机制(每日统计完成后主动更新热点缓存)
    • 对不同维度统计结果设置差异化过期时间(用户统计 24h,课程统计 12h)
  3. 计算优化

    • 采用分布式任务调度(如 XXL-Job),将全量统计任务分片执行
    • 引入时序数据库(如 InfluxDB)存储学习时长时序数据,优化趋势分析性能
    • 对大数据量报表导出采用异步生成 + 邮件通知模式,避免前端超时

6.2 功能扩展建议

  1. 多终端适配

    • 增加终端类型统计(PC / 移动端 / 平板),分析用户学习设备偏好
    • 针对移动端添加网络类型统计(WiFi/4G/5G),优化视频加载策略
  2. 智能分析

    • 基于学习时长和课程完成度,构建用户学习画像
    • 识别异常学习行为(如集中在深夜刷时长),提供预警功能
    • 预测用户课程完成概率,及时推送学习提醒
  3. 可视化扩展

    • 集成 ECharts 实现学习时长趋势图、分布图、对比图等可视化图表
    • 支持自定义报表模板,用户可配置统计维度和指标
    • 增加数据看板,实时展示平台整体学习数据(总时长、活跃用户、热门课程)
  4. 接口扩展

    • 提供开放 API,支持第三方系统(如教务系统)集成学习数据
    • 增加数据订阅功能,支持按周 / 月自动推送统计报表

7. 总结与展望

本文基于 Java 17 和 Spring Boot 3.2.5,构建了一套完整的学习平台视频学习时长统计与报表系统,涵盖数据采集、有效时长计算、多维度统计、报表导出等核心功能。通过 "实时统计 + 定时全量统计" 双机制确保数据准确性,结合 Redis 缓存提升查询性能,采用 EasyExcel 实现高效报表导出。

相关推荐
Go away, devil1 小时前
Java-----集合
java·开发语言
JIngJaneIL1 小时前
旅游|内蒙古景点旅游|基于Springboot+Vue的内蒙古景点旅游管理系统设计与实现(源码+数据库+文档)
java·vue.js·spring boot·论文·旅游·毕设·内蒙古景点旅游
新之助小锅2 小时前
java版连接汇川PLC,发送数据,读取数据,保持重新链接,适用安卓
android·java·python
无糖冰可乐214 小时前
IDEA多java版本切换
java·ide·intellij-idea
合作小小程序员小小店4 小时前
web开发,在线%超市销售%管理系统,基于idea,html,jsp,java,ssh,sql server数据库。
java·前端·sqlserver·ssh·intellij-idea
brucelee1864 小时前
IntelliJ IDEA 设置 Local History 永久保留
java·ide·intellij-idea
Pluto_CSND6 小时前
Java中的静态代理与动态代理(Proxy.newProxyInstance)
java·开发语言
百***46457 小时前
Java进阶-在Ubuntu上部署SpringBoot应用
java·spring boot·ubuntu
serve the people7 小时前
Prompts for Chat Models in LangChain
java·linux·langchain