前言

在在線教育平台中,學習時長是衡量學生學習投入、評估課程效果、優化教學策略的核心數據指標。精準統計學生視頻學習時長並生成可視化報表,能幫助教師掌握學生學習動態、學校進行教學質量評估、學生了解自身學習進度。

作為一名深耕 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 整體架構設計

Java整合EasyExcel_數據

架構分層説明:

  • 前端層:負責視頻播放與數據上報(定時 + 關鍵節點上報)。
  • 網關層:負責請求路由、限流(可選,如 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 學習時長統計整體流程

Java整合EasyExcel_#java_02

3.3.2 有效學習時長計算流程

Java整合EasyExcel_#java_03

有效時長計算規則説明:

  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 個月的原始記錄遷移至歸檔表)
  1. 緩存優化
  • 引入 Redis 集羣,支持緩存分片和高可用
  • 實現緩存預熱機制(每日統計完成後主動更新熱點緩存)
  • 對不同維度統計結果設置差異化過期時間(用户統計 24h,課程統計 12h)
  1. 計算優化
  • 採用分佈式任務調度(如 XXL-Job),將全量統計任務分片執行
  • 引入時序數據庫(如 InfluxDB)存儲學習時長時序數據,優化趨勢分析性能
  • 對大數據量報表導出採用異步生成 + 郵件通知模式,避免前端超時

6.2 功能擴展建議

  1. 多終端適配
  • 增加終端類型統計(PC / 移動端 / 平板),分析用户學習設備偏好
  • 針對移動端添加網絡類型統計(WiFi/4G/5G),優化視頻加載策略
  1. 智能分析
  • 基於學習時長和課程完成度,構建用户學習畫像
  • 識別異常學習行為(如集中在深夜刷時長),提供預警功能
  • 預測用户課程完成概率,及時推送學習提醒
  1. 可視化擴展
  • 集成 ECharts 實現學習時長趨勢圖、分佈圖、對比圖等可視化圖表
  • 支持自定義報表模板,用户可配置統計維度和指標
  • 增加數據看板,實時展示平台整體學習數據(總時長、活躍用户、熱門課程)
  1. 接口擴展
  • 提供開放 API,支持第三方系統(如教務系統)集成學習數據
  • 增加數據訂閲功能,支持按周 / 月自動推送統計報表