Forráskód Böngészése

新增了统计切屏页面次数的统计

Jiang Pengyu 4 hónapja
szülő
commit
4e4d82f7e0

+ 1 - 0
.gitignore

@@ -31,3 +31,4 @@ build/
 
 ### VS Code ###
 .vscode/
+/docs

+ 37 - 2
src/main/java/com/njuzr/eaibackend/controller/BehaviorRecordController.java

@@ -1,7 +1,9 @@
 package com.njuzr.eaibackend.controller;
 
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
 import com.njuzr.eaibackend.service.BehaviorRecordService;
 import com.njuzr.eaibackend.service.BehaviorOverviewService;
+import com.njuzr.eaibackend.service.WindowSwitchService;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import com.njuzr.eaibackend.vo.PauseStatisticsVO;
@@ -21,12 +23,15 @@ import org.springframework.web.multipart.MultipartFile;
 public class BehaviorRecordController {
     private final BehaviorRecordService behaviorRecordService;
     private final BehaviorOverviewService behaviorOverviewService;
+    private final WindowSwitchService windowSwitchService;
 
     @Autowired
-    public BehaviorRecordController(BehaviorRecordService behaviorRecordService, 
-            BehaviorOverviewService behaviorOverviewService) {
+    public BehaviorRecordController(BehaviorRecordService behaviorRecordService,
+            BehaviorOverviewService behaviorOverviewService,
+            WindowSwitchService windowSwitchService) {
         this.behaviorRecordService = behaviorRecordService;
         this.behaviorOverviewService = behaviorOverviewService;
+        this.windowSwitchService = windowSwitchService;
     }
 
     @GetMapping()
@@ -102,4 +107,34 @@ public class BehaviorRecordController {
         String result = behaviorOverviewService.migrateBehaviorRecordsToOverviews(studentId, assignmentId);
         return MyResponse.success(result);
     }
+
+    /**
+     * 上报窗口切换行为数据(每次暂存时触发)
+     * 前端在学生点击「暂存」时调用,上报距上次暂存以来的窗口切换次数与详情
+     */
+    @PostMapping("/windowSwitch")
+    public MyResponse reportWindowSwitch(@RequestBody WindowSwitchDTO dto) {
+        log.info("收到窗口切换上报: studentId={}, assignmentId={}, switchCount={}",
+                dto.getStudentId(), dto.getAssignmentId(), dto.getSwitchCount());
+        windowSwitchService.recordWindowSwitch(dto);
+        return MyResponse.success(null);
+    }
+
+    /**
+     * 查询某学生某作业的窗口切换汇总(总切换次数、暂存次数)
+     */
+    @GetMapping("/windowSwitch/summary")
+    public MyResponse getWindowSwitchSummary(
+            @RequestParam Long studentId,
+            @RequestParam Long assignmentId) {
+        return MyResponse.success(windowSwitchService.getSummaryByStudentAndAssignment(studentId, assignmentId));
+    }
+
+    /**
+     * 查询某作业所有学生的窗口切换汇总列表,按总切换次数降序
+     */
+    @GetMapping("/windowSwitch/summaryByAssignment")
+    public MyResponse getWindowSwitchSummaryByAssignment(@RequestParam Long assignmentId) {
+        return MyResponse.success(windowSwitchService.getSummaryByAssignment(assignmentId));
+    }
 }

+ 35 - 0
src/main/java/com/njuzr/eaibackend/dto/WindowSwitchDTO.java

@@ -0,0 +1,35 @@
+package com.njuzr.eaibackend.dto;
+
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 窗口切换行为上报请求体
+ */
+@Data
+public class WindowSwitchDTO {
+
+    @NotNull(message = "studentId 不能为空")
+    private Long studentId;
+
+    @NotNull(message = "assignmentId 不能为空")
+    private Long assignmentId;
+
+    @NotNull(message = "switchCount 不能为空")
+    private Integer switchCount;
+
+    @NotNull(message = "switchEvents 不能为空")
+    private List<SwitchEvent> switchEvents;
+
+    @Data
+    public static class SwitchEvent {
+        /** 事件类型:hidden(离开页面)或 visible(返回页面) */
+        private String eventType;
+
+        /** 事件发生时间,ISO 8601 格式(UTC),如 2026-04-14T10:05:30.123Z */
+        private String timestamp;
+    }
+}

+ 35 - 0
src/main/java/com/njuzr/eaibackend/mapper/WindowSwitchRecordMapper.java

@@ -0,0 +1,35 @@
+package com.njuzr.eaibackend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.njuzr.eaibackend.po.StudentWindowSwitchRecord;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 窗口切换行为记录 Mapper
+ */
+@Mapper
+public interface WindowSwitchRecordMapper extends BaseMapper<StudentWindowSwitchRecord> {
+
+    /**
+     * 查询某学生某作业的总切换次数及分段上报次数
+     *
+     * @param studentId    学生ID
+     * @param assignmentId 作业ID
+     * @return 汇总结果
+     */
+    WindowSwitchSummaryVO selectSummaryByStudentAndAssignment(
+            @Param("studentId") Long studentId,
+            @Param("assignmentId") Long assignmentId);
+
+    /**
+     * 查询某作业所有学生的切换次数汇总,按总切换次数降序排列
+     *
+     * @param assignmentId 作业ID
+     * @return 各学生的汇总列表
+     */
+    List<WindowSwitchSummaryVO> selectSummaryByAssignment(@Param("assignmentId") Long assignmentId);
+}

+ 41 - 0
src/main/java/com/njuzr/eaibackend/po/StudentWindowSwitchRecord.java

@@ -0,0 +1,41 @@
+package com.njuzr.eaibackend.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 学生写作页面窗口切换行为记录(按暂存区间分段存储)
+ * 对应数据库表:student_window_switch_record
+ */
+@Data
+@TableName("student_window_switch_record")
+public class StudentWindowSwitchRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 学生ID */
+    private Long studentId;
+
+    /** 作业ID */
+    private Long assignmentId;
+
+    /** 本次暂存区间内离开写作窗口的次数(hidden 事件数量) */
+    private Integer switchCount;
+
+    /**
+     * 本次暂存区间内的切换事件详情,JSON 数组字符串
+     * 结构示例:[{"eventType":"hidden","timestamp":"2026-04-14T10:05:30.123Z"},...]
+     */
+    private String switchEvents;
+
+    /** 上报时间(即学生点击暂存的时间) */
+    private Date reportTime;
+
+    /** 记录创建时间 */
+    private Date createdAt;
+}

+ 35 - 6
src/main/java/com/njuzr/eaibackend/service/ExportBehaviorOverviewService.java

@@ -4,12 +4,14 @@ import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.mapper.BehaviorOverviewMapper;
 import com.njuzr.eaibackend.mapper.AssignmentMapper;
 import com.njuzr.eaibackend.mapper.ClassMapper;
+import com.njuzr.eaibackend.mapper.WindowSwitchRecordMapper;
 import com.njuzr.eaibackend.po.BehaviorOverview;
 import com.njuzr.eaibackend.po.Assignment;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.service.ClassService;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.StudentInfoVO;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
 import com.njuzr.eaibackend.utils.ZipExport;
 import java.util.stream.Collectors;
 import java.util.Set;
@@ -41,15 +43,17 @@ public class ExportBehaviorOverviewService {
     private final ClassService classService;
     private final AssignmentMapper assignmentMapper;
     private final ClassMapper classMapper;
+    private final WindowSwitchRecordMapper windowSwitchRecordMapper;
 
     @Autowired
-    public ExportBehaviorOverviewService(BehaviorOverviewService behaviorOverviewService, BehaviorOverviewMapper behaviorOverviewMapper, UserService userService, ClassService classService, AssignmentMapper assignmentMapper, ClassMapper classMapper) {
+    public ExportBehaviorOverviewService(BehaviorOverviewService behaviorOverviewService, BehaviorOverviewMapper behaviorOverviewMapper, UserService userService, ClassService classService, AssignmentMapper assignmentMapper, ClassMapper classMapper, WindowSwitchRecordMapper windowSwitchRecordMapper) {
         this.behaviorOverviewService = behaviorOverviewService;
         this.behaviorOverviewMapper = behaviorOverviewMapper;
         this.userService = userService;
         this.classService = classService;
         this.assignmentMapper = assignmentMapper;
         this.classMapper = classMapper;
+        this.windowSwitchRecordMapper = windowSwitchRecordMapper;
     }
 
     /**
@@ -147,7 +151,7 @@ public class ExportBehaviorOverviewService {
             int rowIdx = 0;
             Row header = summary.createRow(rowIdx++);
             String[] headers = new String[]{
-                    "name", "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount"
+                    "name", "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount", "windowSwitchCount"
             };
             for (int i = 0; i < headers.length; i++) {
                 header.createCell(i).setCellValue(headers[i]);
@@ -167,6 +171,8 @@ public class ExportBehaviorOverviewService {
                     Long studentId = student.getStudentId();
                     if (studentId == null) continue;
                     
+                    long windowSwitchCount = getWindowSwitchCount(studentId, assignmentId);
+
                     // 查询该学生在指定作业的行为概览
                     BehaviorOverviewVO vo = behaviorOverviewService.getBehaviorOverview(studentId, assignmentId);
                     if (vo != null) {
@@ -180,7 +186,8 @@ public class ExportBehaviorOverviewService {
                         row.createCell(col++).setCellValue(vo.getDeleteCount() == null ? 0 : vo.getDeleteCount());
                         row.createCell(col++).setCellValue(vo.getCopyCount() == null ? 0 : vo.getCopyCount());
                         row.createCell(col++).setCellValue(vo.getCopyCharacterCount() == null ? 0 : vo.getCopyCharacterCount());
-                        row.createCell(col).setCellValue(vo.getLongPauseCount() == null ? 0 : vo.getLongPauseCount());
+                        row.createCell(col++).setCellValue(vo.getLongPauseCount() == null ? 0 : vo.getLongPauseCount());
+                        row.createCell(col).setCellValue(windowSwitchCount);
 
                         // 为每个学生创建一个事件明细sheet
                         String sheetName = safeSheetName(name != null ? name : "Unknown");
@@ -198,7 +205,8 @@ public class ExportBehaviorOverviewService {
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
-                        row.createCell(col).setCellValue(0);
+                        row.createCell(col++).setCellValue(0);
+                        row.createCell(col).setCellValue(windowSwitchCount);
                     }
                 }
             }
@@ -326,13 +334,15 @@ public class ExportBehaviorOverviewService {
         int rowIdx = 0;
         Row header = sheet.createRow(rowIdx++);
         String[] headers = new String[]{
-                "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount"
+                "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount", "windowSwitchCount"
         };
         for (int i = 0; i < headers.length; i++) {
             Cell cell = header.createCell(i);
             cell.setCellValue(headers[i]);
         }
 
+        long windowSwitchCount = getWindowSwitchCount(overview.getStudentId(), overview.getAssignmentId());
+
         Row row = sheet.createRow(rowIdx);
         int col = 0;
         row.createCell(col++).setCellValue(overview.getStudentId() == null ? 0 : overview.getStudentId());
@@ -341,7 +351,8 @@ public class ExportBehaviorOverviewService {
         row.createCell(col++).setCellValue(overview.getDeleteCount() == null ? 0 : overview.getDeleteCount());
         row.createCell(col++).setCellValue(overview.getCopyCount() == null ? 0 : overview.getCopyCount());
         row.createCell(col++).setCellValue(overview.getCopyCharacterCount() == null ? 0 : overview.getCopyCharacterCount());
-        row.createCell(col).setCellValue(overview.getLongPauseCount() == null ? 0 : overview.getLongPauseCount());
+        row.createCell(col++).setCellValue(overview.getLongPauseCount() == null ? 0 : overview.getLongPauseCount());
+        row.createCell(col).setCellValue(windowSwitchCount);
 
         for (int i = 0; i < headers.length; i++) {
             sheet.autoSizeColumn(i);
@@ -408,5 +419,23 @@ public class ExportBehaviorOverviewService {
         return v == null ? 0 : v;
     }
 
+    /**
+     * 查询某学生某作业的窗口切换总次数,若无记录则返回 0
+     */
+    private long getWindowSwitchCount(Long studentId, Long assignmentId) {
+        if (studentId == null || assignmentId == null) {
+            return 0L;
+        }
+        try {
+            WindowSwitchSummaryVO summary = windowSwitchRecordMapper
+                    .selectSummaryByStudentAndAssignment(studentId, assignmentId);
+            return summary != null && summary.getTotalSwitchCount() != null
+                    ? summary.getTotalSwitchCount()
+                    : 0L;
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
+
     // 已改为批量映射方式,不再需要单查姓名方法
 }

+ 36 - 0
src/main/java/com/njuzr/eaibackend/service/WindowSwitchService.java

@@ -0,0 +1,36 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+
+import java.util.List;
+
+/**
+ * 窗口切换行为统计 Service
+ */
+public interface WindowSwitchService {
+
+    /**
+     * 上报并保存一次暂存区间内的窗口切换数据
+     *
+     * @param dto 前端上报的窗口切换数据
+     */
+    void recordWindowSwitch(WindowSwitchDTO dto);
+
+    /**
+     * 查询某学生某作业的窗口切换汇总(总切换次数、暂存次数)
+     *
+     * @param studentId    学生ID
+     * @param assignmentId 作业ID
+     * @return 汇总统计VO
+     */
+    WindowSwitchSummaryVO getSummaryByStudentAndAssignment(Long studentId, Long assignmentId);
+
+    /**
+     * 查询某作业所有学生的窗口切换汇总列表,按总切换次数降序
+     *
+     * @param assignmentId 作业ID
+     * @return 各学生汇总列表
+     */
+    List<WindowSwitchSummaryVO> getSummaryByAssignment(Long assignmentId);
+}

+ 72 - 0
src/main/java/com/njuzr/eaibackend/service/impl/WindowSwitchServiceImpl.java

@@ -0,0 +1,72 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.mapper.WindowSwitchRecordMapper;
+import com.njuzr.eaibackend.po.StudentWindowSwitchRecord;
+import com.njuzr.eaibackend.service.WindowSwitchService;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 窗口切换行为统计 ServiceImpl
+ */
+@Slf4j
+@Service
+public class WindowSwitchServiceImpl implements WindowSwitchService {
+
+    private final WindowSwitchRecordMapper windowSwitchRecordMapper;
+    private final ObjectMapper objectMapper;
+
+    @Autowired
+    public WindowSwitchServiceImpl(WindowSwitchRecordMapper windowSwitchRecordMapper) {
+        this.windowSwitchRecordMapper = windowSwitchRecordMapper;
+        this.objectMapper = new ObjectMapper();
+    }
+
+    @Override
+    public void recordWindowSwitch(WindowSwitchDTO dto) {
+        String eventsJson;
+        try {
+            eventsJson = objectMapper.writeValueAsString(dto.getSwitchEvents());
+        } catch (JsonProcessingException e) {
+            log.error("序列化 switchEvents 失败: studentId={}, assignmentId={}", dto.getStudentId(), dto.getAssignmentId(), e);
+            throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "switchEvents 序列化失败");
+        }
+
+        StudentWindowSwitchRecord record = new StudentWindowSwitchRecord();
+        record.setStudentId(dto.getStudentId());
+        record.setAssignmentId(dto.getAssignmentId());
+        record.setSwitchCount(dto.getSwitchCount());
+        record.setSwitchEvents(eventsJson);
+        record.setReportTime(new Date());
+        record.setCreatedAt(new Date());
+
+        int rows = windowSwitchRecordMapper.insert(record);
+        if (rows != 1) {
+            log.error("窗口切换记录写入失败: studentId={}, assignmentId={}", dto.getStudentId(), dto.getAssignmentId());
+            throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "记录写入数据库失败");
+        }
+
+        log.info("窗口切换记录已保存: studentId={}, assignmentId={}, switchCount={}",
+                dto.getStudentId(), dto.getAssignmentId(), dto.getSwitchCount());
+    }
+
+    @Override
+    public WindowSwitchSummaryVO getSummaryByStudentAndAssignment(Long studentId, Long assignmentId) {
+        return windowSwitchRecordMapper.selectSummaryByStudentAndAssignment(studentId, assignmentId);
+    }
+
+    @Override
+    public List<WindowSwitchSummaryVO> getSummaryByAssignment(Long assignmentId) {
+        return windowSwitchRecordMapper.selectSummaryByAssignment(assignmentId);
+    }
+}

+ 22 - 0
src/main/java/com/njuzr/eaibackend/vo/WindowSwitchSummaryVO.java

@@ -0,0 +1,22 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.Data;
+
+/**
+ * 窗口切换汇总统计 VO
+ */
+@Data
+public class WindowSwitchSummaryVO {
+
+    /** 学生ID */
+    private Long studentId;
+
+    /** 作业ID */
+    private Long assignmentId;
+
+    /** 该作业全程总窗口切换次数(各暂存区间 switchCount 之和) */
+    private Long totalSwitchCount;
+
+    /** 上报次数(即暂存次数) */
+    private Long stageTimes;
+}

+ 33 - 0
src/main/resources/mapper/WindowSwitchRecordMapper.xml

@@ -0,0 +1,33 @@
+<?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.njuzr.eaibackend.mapper.WindowSwitchRecordMapper">
+
+    <!-- 查询某学生某作业的总切换次数及分段上报次数 -->
+    <select id="selectSummaryByStudentAndAssignment"
+            resultType="com.njuzr.eaibackend.vo.WindowSwitchSummaryVO">
+        SELECT
+            student_id        AS studentId,
+            assignment_id     AS assignmentId,
+            SUM(switch_count) AS totalSwitchCount,
+            COUNT(*)          AS stageTimes
+        FROM student_window_switch_record
+        WHERE student_id    = #{studentId}
+          AND assignment_id = #{assignmentId}
+        GROUP BY student_id, assignment_id
+    </select>
+
+    <!-- 查询某作业所有学生的切换次数汇总,按总切换次数降序 -->
+    <select id="selectSummaryByAssignment"
+            resultType="com.njuzr.eaibackend.vo.WindowSwitchSummaryVO">
+        SELECT
+            student_id        AS studentId,
+            assignment_id     AS assignmentId,
+            SUM(switch_count) AS totalSwitchCount,
+            COUNT(*)          AS stageTimes
+        FROM student_window_switch_record
+        WHERE assignment_id = #{assignmentId}
+        GROUP BY student_id, assignment_id
+        ORDER BY totalSwitchCount DESC
+    </select>
+
+</mapper>