团队作业4——项目冲刺

三剑客Alpha冲刺完整报告

一、每日冲刺博客(第1-7天)

第1天:冲刺计划与团队准备

博客标题:智教协作队Alpha冲刺第1天 | 冲刺计划与团队准备

1. 会议(照片)

ce9480128123b0fc1f4bb0e8dbce8c49

2. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成登录页面原型设计 搭建登录页面静态框架,定义表单数据结构 暂无,原型参考明确
林嘉俊 完成用户表数据库设计 搭建后端项目架构,定义用户认证接口规范 Spring Security依赖版本需与Spring Boot匹配
廖鸿基 整理核心功能测试点清单 完成登录与课件上传功能的测试用例初稿 部分接口参数未明确,需同步后端接口定义

3. 项目燃尽图

image

“冲刺第1天,剩余工时50小时”

4. 代码/文档登入记录

image

林嘉俊(后端项目初始化)

对应Issue:[#1 后端项目架构搭建](https://gitee.com/zhijia-team/teaching-system/issues/1)

// 项目核心配置类(标注关键依赖)
@SpringBootApplication
@ComponentScan(basePackages = "com.zhijia") // 扫描自定义组件
@MapperScan("com.zhijia.mapper") // 扫描MyBatis mapper接口
public class TeachingSystemApplication {
    public static void main(String[] args) {
        SpringApplication.run(TeachingSystemApplication.class, args);
        System.out.println("教学系统后端服务启动成功");
    }
}

// pom.xml核心依赖(节选)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

5. 项目程序/模块的最新(运行)截图

登录:
image

6. 每日每人总结

  • 王梓涵:“今天把登录页面的HTML结构搭好了,明天重点做CSS样式和Element组件适配,确保和原型一致。”

  • 林嘉俊:“项目架构搭完了,Security依赖版本问题解决了,明天开始写登录接口的Controller层逻辑。”

  • 廖鸿基:“测试用例框架搭好了,等后端接口定义完就补充参数校验部分,确保用例覆盖所有异常场景。”

第2天:登录页面与认证接口初版完成

博客标题:智教协作队Alpha冲刺第2天 | 登录页面与认证接口初版完成

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成登录页面静态框架搭建 实现表单校验逻辑,完成页面样式优化 表单正则规则与后端要求不一致,需重新对齐手机号/账号格式
林嘉俊 完成后端项目架构搭建 完成登录接口Controller与Service层开发,实现密码加密存储 BCrypt加密后密码比对失败,需调整加密逻辑
廖鸿基 完成登录功能测试用例初稿 完善测试用例,搭建Postman测试集合,配置接口请求头 暂无,接口文档已同步

2. 项目燃尽图

image

3. 代码/文档登入记录

image

王梓涵(前端登录页面)

对应Issue:[#6 前端:用户登录页面开发](https://gitee.com/zhijia-team/teaching-system/issues/6)

<template>
  <div class="login-container">
    <el-card shadow="hover" class="login-card">
      <h2 class="login-title">教学课件管理系统</h2>
      <el-form ref="loginForm" :model="loginForm" :rules="loginRules" label-width="80px">
        <el-form-item label="账号" prop="account">
          <el-input v-model="loginForm.account" placeholder="请输入工号/学号" prefix-icon="User"></el-input>
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="Lock"></el-input>
        </el-form-item>
        <el-form-item label="角色" prop="role">
          <el-select v-model="loginForm.role" placeholder="请选择角色">
            <el-option label="教师" value="teacher"></el-option>
            <el-option label="学生" value="student"></el-option>
            <el-option label="管理员" value="admin"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleLogin" class="login-btn">登录</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';

const router = useRouter();
const loginForm = reactive({ 
  account: '', 
  password: '',
  role: '' 
});
// 与后端对齐的表单校验规则
const loginRules = {
  account: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { min: 5, max: 12, message: '账号长度为5-12位', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { pattern: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,16}$/, message: '密码含大小写字母和数字,长度8-16位', trigger: 'blur' }
  ],
  role: [{ required: true, message: '请选择角色', trigger: 'change' }]
};

const loginFormRef = ref(null);
// 登录按钮点击事件(待对接接口)
const handleLogin = async () => {
  const valid = await loginFormRef.value.validate();
  if (valid) {
    ElMessage.success('表单校验通过,准备请求接口');
    // 后续对接后端登录接口
  }
};
</script>

林嘉俊(用户认证接口)

对应Issue:[#2 后端:用户认证接口开发](https://gitee.com/zhijia-team/teaching-system/issues/2)

// 登录请求参数DTO
@Data
public class LoginDTO {
    @NotBlank(message = "账号不能为空")
    @Size(min = 5, max = 12, message = "账号长度为5-12位")
    private String account;

    @NotBlank(message = "密码不能为空")
    private String password;

    @NotBlank(message = "角色不能为空")
    private String role; // teacher/student/admin
}

// 用户认证Controller
@RestController
@RequestMapping("/api/auth")
@CrossOrigin // 解决前端跨域问题
public class AuthController {
    @Autowired
    private AuthService authService;

    /**
     * 用户登录接口
     * @param loginDTO 登录请求参数
     * @return 登录结果(token+用户信息)
     */
    @PostMapping("/login")
    public ResultVO<LoginVO> login(@Valid @RequestBody LoginDTO loginDTO) {
        return authService.login(loginDTO);
    }
}

// 密码加密与比对工具类
@Component
public class PasswordUtil {
    // BCrypt加密逻辑,解决之前比对失败问题
    public String encryptPassword(String rawPassword) {
        return BCrypt.hashpw(rawPassword, BCrypt.gensalt(10));
    }

    public boolean matchPassword(String rawPassword, String encryptedPassword) {
        return BCrypt.checkpw(rawPassword, encryptedPassword);
    }
}

4. 项目程序/模块的最新(运行)截图

首页:热门课程
image

5. 每日每人总结

  • 王梓涵:“表单校验和样式都搞定了,和后端确认了参数规则,明天就能对接接口实现登录功能。”

  • 林嘉俊:“登录接口通了,密码加密问题解决了,明天写课件上传相关的实体类和接口。”

  • 廖鸿基:“Postman测试集合建好了,登录接口的正向和反向用例都测过了,接口返回符合预期。”

第3天:课件列表页面与基础接口开发

博客标题:智教协作队Alpha冲刺第3天 | 课件列表页面与基础接口开发

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成登录页面表单校验与样式 实现登录接口联调,开发课件列表页面布局与数据渲染 登录后token存储与请求头携带逻辑不清晰,需用Vuex管理状态
林嘉俊 完成登录接口开发与测试 开发课件实体类、Mapper及列表查询接口,配置MinIO文件存储 MinIO客户端连接超时,需检查配置参数与服务状态
廖鸿基 完成登录接口测试用例执行 编写课件列表接口测试用例,设计课件数据构造脚本 暂无,接口文档已提前获取

2. 项目燃尽图

image

3. 代码/文档登入记录

image

王梓涵(Vuex状态管理与课件列表)

对应Issue:[#7 前端:课件列表页面开发](https://gitee.com/zhijia-team/teaching-system/issues/7)

<!-- 课件列表页面核心代码 -->
<template>
  <div class="courseware-container">
    <el-page-header content="课件列表"></el-page-header>
    <el-card shadow="hover" class="search-card">
      <el-input v-model="searchKey" placeholder="请输入课件名称搜索" suffix-icon="Search" @keyup.enter="getCoursewareList"></el-input>
    </el-card>
    <el-table :data="coursewareList" border stripe style="width: 100%; margin-top: 20px;">
      <el-table-column prop="name" label="课件名称" width="200"></el-table-column>
      <el-table-column prop="teacherName" label="上传教师" width="120"></el-table-column>
      <el-table-column prop="subject" label="学科" width="100"></el-table-column>
      <el-table-column prop="grade" label="年级" width="100"></el-table-column>
      <el-table-column prop="uploadTime" label="上传时间" width="180"></el-table-column>
      <el-table-column prop="fileSize" label="文件大小" width="100"></el-table-column>
      <el-table-column label="操作" width="180">
        <template #default="scope">
          <el-button type="primary" size="small" @click="handlePreview(scope.row)">预览</el-button>
          <el-button type="success" size="small" @click="handleDownload(scope.row)">下载</el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-pagination 
      @size-change="handleSizeChange" 
      @current-change="handleCurrentChange"
      :current-page="currentPage" 
      :page-sizes="[10, 20, 50]" 
      :page-size="pageSize"
      layout="total, sizes, prev, pager, next, jumper" 
      :total="total" 
      style="margin-top: 20px; text-align: right;"
    ></el-pagination>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import axios from 'axios';

const store = useStore();
const coursewareList = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const searchKey = ref('');

// 初始化时获取课件列表
onMounted(() => {
  getCoursewareList();
});

// 获取课件列表(携带token)
const getCoursewareList = async () => {
  const token = store.state.user.token;
  const response = await axios.get('/api/courseware/list', {
    params: {
      pageNum: currentPage.value,
      pageSize: pageSize.value,
      keyword: searchKey.value
    },
    headers: {
      'Authorization': `Bearer ${token}` // 携带JWT令牌
    }
  });
  coursewareList.value = response.data.data.list;
  total.value = response.data.data.total;
};

// 分页相关方法
const handleSizeChange = (val) => {
  pageSize.value = val;
  getCoursewareList();
};
const handleCurrentChange = (val) => {
  currentPage.value = val;
  getCoursewareList();
};

// 预览和下载方法(待实现)
const handlePreview = (row) => {
  console.log('预览课件', row);
};
const handleDownload = (row) => {
  console.log('下载课件', row);
};
</script>

林嘉俊(课件接口与MinIO配置)

对应Issue:[#3 后端:课件上传/版本接口开发](https://gitee.com/zhijia-team/teaching-system/issues/3)

// 课件实体类
@Data
@TableName("courseware")
public class Courseware {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name; // 课件名称
    private Long teacherId; // 上传教师ID
    private String teacherName; // 教师姓名(冗余)
    private String subject; // 学科(如数学、语文)
    private String grade; // 年级(如高一、高二)
    private String fileUrl; // MinIO文件存储路径
    private String fileSize; // 文件大小(格式化显示)
    private String fileType; // 文件类型(PPT/PDF/MP4等)
    private Integer version; // 版本号
    private Date uploadTime; // 上传时间
    private Integer viewCount; // 浏览次数
    private Integer downloadCount; // 下载次数
}

// MinIO配置类(解决连接超时问题)
@Configuration
public class MinIOConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.access-key}")
    private String accessKey;
    @Value("${minio.secret-key}")
    private String secretKey;
    @Value("${minio.bucket-name}")
    private String bucketName;

    @Bean
    public MinioClient minioClient() {
        // 配置客户端,增加连接超时设置
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .httpClient(HttpClient.builder()
                        .connectTimeout(Duration.ofSeconds(5)) // 连接超时5秒
                        .writeTimeout(Duration.ofSeconds(30)) // 写入超时30秒
                        .readTimeout(Duration.ofSeconds(30)) // 读取超时30秒
                        .build())
                .build();
    }

    // 初始化时创建存储桶
    @PostConstruct
    public void initBucket() throws Exception {
        MinioClient client = minioClient();
        boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!exists) {
            client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            System.out.println("MinIO存储桶创建成功:" + bucketName);
        }
    }
}

// 课件列表查询接口
@RestController
@RequestMapping("/api/courseware")
@CrossOrigin
public class CoursewareController {
    @Autowired
    private CoursewareService coursewareService;

    /**
     * 分页查询课件列表
     * @param pageNum 页码
     * @param pageSize 每页条数
     * @param keyword 搜索关键词
     * @return 分页结果
     */
    @GetMapping("/list")
    public ResultVO<PageVO<Courseware>> getCoursewareList(
            @RequestParam(defaultValue = "1") Integer pageNum,
            @RequestParam(defaultValue = "10") Integer pageSize,
            @RequestParam(required = false) String keyword) {
        PageVO<Courseware> pageVO = coursewareService.getCoursewareList(pageNum, pageSize, keyword);
        return ResultVO.success(pageVO);
    }
}

4. 项目程序/模块的最新(运行)截图

个人中心:

image
image
image

5. 每日每人总结

  • 王梓涵:“Vuex状态管理搞定了,token能正常携带,课件列表能渲染数据了,明天做上传页面和预览功能。”

  • 林嘉俊:“MinIO连接问题解决了,课件列表接口通了,明天开发文件上传和版本管理接口。”

  • 廖鸿基:“课件列表接口的测试用例写完了,构造了10条测试数据,分页和搜索功能都测过了没问题。”

第4天:课件上传功能开发与联调

博客标题:智教协作队Alpha冲刺第4天 | 课件上传功能开发与联调

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成课件列表页面开发与数据渲染 开发课件上传页面,实现大文件分片上传功能 大文件上传进度条计算不准确,分片上传逻辑复杂
林嘉俊 完成课件列表接口与MinIO配置 开发课件上传、分片合并接口,实现版本管理逻辑 分片合并时文件顺序错乱,需通过索引控制合并顺序
廖鸿基 完成课件列表接口测试 测试课件上传功能,重点测试大文件与多版本上传场景 大文件测试时网络波动导致上传失败,需增加重试机制测试

2. 项目燃尽图

image

3. 代码/文档登入记录

image

王梓涵(大文件分片上传组件)

对应Issue:[#8 前端:课件上传页面开发](https://gitee.com/zhijia-team/teaching-system/issues/8)


<template>
  <div class="upload-container">
    <el-page-header content="课件上传"></el-page-header>
    <el-card shadow="hover" class="upload-card">
      <el-form ref="uploadForm" :model="uploadForm" :rules="uploadRules">
        <el-form-item label="课件名称" prop="name">
          <el-input v-model="uploadForm.name" placeholder="请输入课件名称"></el-input>
        </el-form-item>
        <el-form-item label="所属学科" prop="subject">
          <el-select v-model="uploadForm.subject" placeholder="请选择学科">
            <el-option label="数学" value="数学"></el-option>
            <el-option label="语文" value="语文"></el-option>
            <el-option label="英语" value="英语"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="适用年级" prop="grade">
          <el-select v-model="uploadForm.grade" placeholder="请选择年级">
            <el-option label="高一" value="高一"></el-option>
            <el-option label="高二" value="高二"></el-option>
            <el-option label="高三" value="高三"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="上传文件" prop="file">
          <el-upload
            class="upload-demo"
            :auto-upload="false"
            :on-change="handleFileChange"
            :file-list="fileList"
            accept=".ppt,.pptx,.pdf,.mp4,.mp3"
          <el-button type="primary">点击选择文件</el-button>
          <div class="upload-tip">支持PPT/PDF/MP4/MP3格式,最大支持100MB</div>
          </el-upload>
          <el-progress :percentage="uploadProgress" v-if="uploadProgress > 0" style="margin-top:10px;"></el-progress>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleUpload">开始上传</el-button>
          <el-button @click="resetForm" style="margin-left:10px;">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue';
import { ElMessage, ElProgress } from 'element-plus';
import { useStore } from 'vuex';
import axios from 'axios';

const store = useStore();
const uploadForm = reactive({
  name: '',
  subject: '',
  grade: '',
  file: null
});
const fileList = ref([]);
const uploadProgress = ref(0);
const uploadRules = {
  name: [{ required: true, message: '请输入课件名称', trigger: 'blur' }],
  subject: [{ required: true, message: '请选择学科', trigger: 'change' }],
  grade: [{ required: true, message: '请选择年级', trigger: 'change' }],
  file: [{ required: true, message: '请选择文件', trigger: 'change' }]
};
const uploadFormRef = ref(null);

// 选择文件回调
const handleFileChange = (uploadFile) => {
  uploadForm.file = uploadFile.raw;
  fileList.value = [uploadFile];
};

// 分片上传核心逻辑(解决进度条不准问题)
const handleUpload = async () => {
  const valid = await uploadFormRef.value.validate();
  if (!valid) return;

  const file = uploadForm.file;
  const chunkSize = 5 * 1024 * 1024; // 5MB分片
  const totalChunks = Math.ceil(file.size / chunkSize);
  const fileMd5 = await getFileMd5(file); // 计算文件唯一标识
  const token = store.state.user.token;

  // 1. 预上传请求(告知后端文件信息)
  const preUploadRes = await axios.post('/api/courseware/pre-upload', {
    fileName: file.name,
    fileSize: file.size,
    fileType: file.type,
    fileMd5: fileMd5,
    totalChunks: totalChunks,
    ...uploadForm
  }, { headers: { 'Authorization': `Bearer ${token}` } });

  const { uploadId, existingChunks } = preUploadRes.data.data;
  let uploadedChunks = existingChunks.length;
  uploadProgress.value = Math.round((uploadedChunks / totalChunks) * 100);

  // 2. 上传未完成分片
  for (let i = 0; i < totalChunks; i++) {
    if (existingChunks.includes(i)) continue;
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunkIndex', i);
    formData.append('uploadId', uploadId);
    formData.append('fileMd5', fileMd5);

    await axios.post('/api/courseware/upload-chunk', formData, {
      headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (e) => {
        // 计算当前分片上传进度,叠加到总进度
        const chunkProgress = Math.round((e.loaded / e.total) * 100);
        const currentTotalProgress = Math.round(((uploadedChunks * 100) + chunkProgress) / totalChunks);
        uploadProgress.value = currentTotalProgress > uploadProgress.value ? currentTotalProgress : uploadProgress.value;
      }
    });
    uploadedChunks++;
  }

  // 3. 合并分片
  await axios.post('/api/courseware/merge-chunk', {
    uploadId: uploadId,
    fileMd5: fileMd5,
    fileName: file.name
  }, { headers: { 'Authorization': `Bearer ${token}` } });

  uploadProgress.value = 100;
  ElMessage.success("课件上传成功!");
  resetForm();
};

// 计算文件MD5(简化版)
const getFileMd5 = (file) => {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(file);
    reader.onload = (e) => {
      const spark = new SparkMD5.ArrayBuffer();
      spark.append(e.target.result);
      resolve(spark.end());
    };
  });
};

// 重置表单
const resetForm = () => {
  uploadFormRef.value.resetFields();
  fileList.value = [];
  uploadProgress.value = 0;
  uploadForm.file = null;
};
</script>

林嘉俊(课件上传与版本管理接口)

对应Issue:[#3 后端:课件上传/版本接口开发](https://gitee.com/zhijia-team/teaching-system/issues/3)


// 预上传请求DTO
@Data
public class PreUploadDTO {
    @NotBlank(message = "课件名称不能为空")
    private String name;
    @NotBlank(message = "学科不能为空")
    private String subject;
    @NotBlank(message = "年级不能为空")
    private String grade;
    @NotBlank(message = "文件MD5不能为空")
    private String fileMd5;
    private String fileName;
    private Long fileSize;
    private String fileType;
    private Integer totalChunks;
}

// 课件上传Service层(核心逻辑)
@Service
public class CoursewareServiceImpl implements CoursewareService {
    @Autowired
    private MinioClient minioClient;
    @Autowired
    private CoursewareMapper coursewareMapper;
    @Autowired
    private UploadRecordMapper uploadRecordMapper;
    @Value("${minio.bucket-name}")
    private String bucketName;

    // 预上传处理:检查文件是否已上传,生成uploadId
    @Override
    @Transactional
    public PreUploadVO preUpload(PreUploadDTO dto, Long teacherId, String teacherName) {
        // 1. 检查是否存在相同MD5的文件(去重)
        UploadRecord record = uploadRecordMapper.selectOne(new QueryWrapper<UploadRecord>()
                .eq("file_md5", dto.getFileMd5()));

        PreUploadVO vo = new PreUploadVO();
        vo.setUploadId(UUID.randomUUID().toString().replace("-", ""));

        // 2. 若文件已存在,返回已上传分片
        if (record != null) {
            vo.setExistingChunks(JSON.parseArray(record.getUploadedChunks(), Integer.class));
            // 检查是否为同一课件的新版本
            Courseware oldCourseware = coursewareMapper.selectOne(new QueryWrapper<Courseware>()
                    .eq("teacher_id", teacherId)
                    .eq("name", dto.getName()));
            if (oldCourseware != null) {
                vo.setOldVersion(oldCourseware.getVersion());
            }
            return vo;
        }

        // 3. 新增上传记录
        record = new UploadRecord();
        record.setFileMd5(dto.getFileMd5());
        record.setFileName(dto.getFileName());
        record.setUploadId(vo.getUploadId());
        record.setTotalChunks(dto.getTotalChunks());
        record.setUploadedChunks("[]"); // 初始无已上传分片
        record.setStatus(0); // 上传中
        uploadRecordMapper.insert(record);
        return vo;
    }

    // 分片上传:接收分片并存储到MinIO
    @Override
    public void uploadChunk(MultipartFile chunk, Integer chunkIndex, String uploadId, String fileMd5) {
        // 1. 获取上传记录
        UploadRecord record = uploadRecordMapper.selectOne(new QueryWrapper<UploadRecord>()
                .eq("upload_id", uploadId)
                .eq("file_md5", fileMd5));
        if (record == null) {
            throw new BusinessException("上传记录不存在");
        }

        // 2. 上传分片到MinIO(以uploadId作为临时目录)
        String chunkPath = "chunks/" + uploadId + "/" + chunkIndex;
        try {
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(chunkPath)
                    .stream(chunk.getInputStream(), chunk.getSize(), -1)
                    .contentType(chunk.getContentType())
                    .build());
        } catch (Exception e) {
            throw new BusinessException("分片上传失败:" + e.getMessage());
        }

        // 3. 更新已上传分片列表(解决顺序错乱问题)
        List<Integer> existingChunks = JSON.parseArray(record.getUploadedChunks(), Integer.class);
        if (!existingChunks.contains(chunkIndex)) {
            existingChunks.add(chunkIndex);
            // 排序确保分片顺序正确
            Collections.sort(existingChunks);
            record.setUploadedChunks(JSON.toJSONString(existingChunks));
            uploadRecordMapper.updateById(record);
        }
    }

    // 合并分片并创建课件记录(含版本管理)
    @Override
    @Transactional
    public void mergeChunk(String uploadId, String fileMd5, String fileName, Long teacherId, String teacherName) {
        // 1. 合并MinIO中的分片
        UploadRecord record = uploadRecordMapper.selectOne(new QueryWrapper<UploadRecord>()
                .eq("upload_id", uploadId)
                .eq("file_md5", fileMd5));
        String targetPath = "courseware/" + teacherId + "/" + System.currentTimeMillis() + "-" + fileName;

        // 2. 调用MinIO合并接口
        try {
            minioClient.composeObject(ComposeObjectArgs.builder()
                    .bucket(bucketName)
                    .object(targetPath)
                    .sources(IntStream.range(0, record.getTotalChunks())
                            .mapToObj(i -> ComposeSource.builder()
                                    .bucket(bucketName)
                                    .object("chunks/" + uploadId + "/" + i)
                                    .build())
                            .collect(Collectors.toList()))
                    .build());
        } catch (Exception e) {
            throw new BusinessException("分片合并失败:" + e.getMessage());
        }

        // 3. 版本管理:查询同一课件的最新版本
        Courseware oldCourseware = coursewareMapper.selectOne(new QueryWrapper<Courseware>()
                .eq("teacher_id", teacherId)
                .eq("name", record.getFileName().split("\\.")[0])
                .orderByDesc("version")
                .last("limit 1"));
        Integer version = oldCourseware == null ? 1 : oldCourseware.getVersion() + 1;

        // 4. 新增课件记录
        Courseware courseware = new Courseware();
        courseware.setName(record.getFileName().split("\\.")[0]);
        courseware.setTeacherId(teacherId);
        courseware.setTeacherName(teacherName);
        courseware.setSubject(record.getSubject());
        courseware.setGrade(record.getGrade());
        courseware.setFileUrl(targetPath);
        courseware.setFileType(fileName.substring(fileName.lastIndexOf(".")));
        courseware.setFileSize(formatFileSize(record.getFileSize()));
        courseware.setVersion(version);
        courseware.setUploadTime(new Date());
        courseware.setViewCount(0);
        courseware.setDownloadCount(0);
        coursewareMapper.insert(courseware);
    }

    // 文件大小格式化(B→KB/MB)
    private String formatFileSize(Long fileSize) {
        if (fileSize < 1024 * 1024) {
            return String.format("%.2f KB", fileSize / 1024.0);
        } else {
            return String.format("%.2f MB", fileSize / (1024.0 * 1024));
        }
    }
}

4. 项目程序/模块的最新(运行)截图

①学校管理
image
学院的增删改查

②班级管理
image
image
班级的增删改查

5. 每日每人总结

  • 王梓涵:“分片上传功能搞定了,进度条计算精准了,明天开发课件预览和学生评分组件,重点做星标评分的交互。”

  • 林嘉俊:“上传和版本管理接口通了,解决了分片顺序问题,明天开发评分接口和权限控制逻辑,确保学生只能评自己的课程。”

  • 廖鸿基:“测试了10MB和50MB的文件上传,多版本上传也测了,接口稳定性没问题,明天准备评分功能的测试用例。”

第5天:学生评分功能开发与权限控制

博客标题:智教协作队Alpha冲刺第5天 | 评分功能落地与权限管控

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成课件上传页面开发 开发课程评分组件、学生课程列表页面,实现星标评分交互 星标评分与评语提交的联动逻辑,需防止重复提交
林嘉俊 完成课件上传与版本接口开发 开发评分接口、权限拦截器,实现课程数据权限过滤 权限拦截器与JWT令牌结合时,用户角色信息获取延迟
廖鸿基 完成课件上传功能测试 编写评分功能测试用例,测试权限控制场景(越权访问拦截) 构造不同角色的JWT令牌测试复杂,需后端提供测试令牌接口

2. 项目燃尽图

image

3. 代码/文档登入记录

image

image

王梓涵(星标评分组件)

对应Issue:[#9 前端:学生评分功能开发](https://gitee.com/zhijia-team/teaching-system/issues/9)


<template>
  <div class="score-container">
    <el-page-header content="我的课程评分"></el-page-header>
    <el-card shadow="hover" v-for="course in courseList" :key="course.id" class="course-card">
      <div class="course-info">
        <h3>{{ course.name }}</h3>
        <p>授课教师:{{ course.teacherName }} | 学科:{{ course.subject }} | 年级:{{ course.grade }}</p>
      </div>
      <div class="score-section">
        <el-rate v-model="scoreForm[course.id]" :max="5" @change="handleScoreChange(course.id)"></el-rate>
        <el-input
          v-model="commentForm[course.id]"
          placeholder="请输入评语(10-50字)"
          type="textarea"
          rows="2"
          style="margin-top:10px;"
        ></el-input>
        <el-button
          type="primary"
          style="margin-top:10px;"
          @click="handleSubmit(course.id)"
          :disabled="submitLoading[course.id]"
        >
          <el-icon v-if="submitLoading[course.id]"><Loading></Loading></el-icon>
          {{ course.scored ? "已评分" : "提交评分" }}
        </el-button>
      </div>
    </el-card>
  </div>
</template>

<script setup>
import { reactive, ref, onMounted } from 'vue';
import { ElMessage, ElRate, ElLoading } from 'element-plus';
import { useStore } from 'vuex';
import axios from 'axios';
import { Loading } from '@element-plus/icons-vue';

const store = useStore();
const courseList = ref([]);
const scoreForm = reactive({}); // 存储各课程评分:{courseId: score}
const commentForm = reactive({}); // 存储各课程评语:{courseId: comment}
const submitLoading = reactive({}); // 防止重复提交:{courseId: boolean}

// 初始化获取学生所选课程及已有评分
onMounted(() => {
  getStudentCourseList();
});

// 获取学生课程列表
const getStudentCourseList = async () => {
  const token = store.state.user.token;
  const res = await axios.get('/api/student/course', {
    headers: { 'Authorization': `Bearer ${token}` }
  });
  courseList.value = res.data.data;
  // 初始化表单(回填已有评分)
  courseList.value.forEach(course => {
    if (course.score) {
      scoreForm[course.id] = course.score;
      commentForm[course.id] = course.comment;
    } else {
      scoreForm[course.id] = 0;
      commentForm[course.id] = '';
    }
    submitLoading[course.id] = false;
  });
};

// 提交评分
const handleSubmit = async (courseId) => {
  const score = scoreForm[courseId];
  const comment = commentForm[courseId];
  if (score < 1) {
    ElMessage.warning("请选择评分");
    return;
  }
  if (comment.length < 10 || comment.length > 50) {
    ElMessage.warning("评语需10-50字");
    return;
  }

  submitLoading[courseId] = true;
  const token = store.state.user.token;
  try {
    await axios.post('/api/score/submit', {
      courseId: courseId,
      score: score,
      comment: comment
    }, { headers: { 'Authorization': `Bearer ${token}` } });
    ElMessage.success("评分提交成功!");
    // 标记为已评分
    const course = courseList.value.find(item => item.id === courseId);
    course.scored = true;
  } catch (e) {
    ElMessage.error("提交失败:" + e.response.data.message);
  } finally {
    submitLoading[courseId] = false;
  }
};

// 评分变化回调(可选)
const handleScoreChange = (courseId) => {
  console.log("课程" + courseId + "评分:" + scoreForm[courseId]);
};
</script>

林嘉俊(权限拦截器与评分接口)

对应Issue:[#10 后端:权限控制与评分接口开发](https://gitee.com/zhijia-team/teaching-system/issues/10)


// 自定义权限注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String[] value(); // 允许的角色,如{"teacher", "admin"}
}

// JWT权限拦截器(解决角色信息延迟问题)
@Component
public class JwtAuthInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取Token
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            throw new BusinessException("请先登录");
        }
        token = token.substring(7);

        // 2. 解析Token获取用户信息(缓存用户信息到ThreadLocal)
        Claims claims = jwtUtil.parseToken(token);
        Long userId = Long.valueOf(claims.get("userId").toString());
        String role = claims.get("role").toString();
        UserContext.setUserId(userId);
        UserContext.setRole(role);

        // 3. 角色权限校验
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class);
            if (requireRole != null) {
                String[] allowRoles = requireRole.value();
                if (!Arrays.asList(allowRoles).contains(role)) {
                    throw new BusinessException("无权限访问");
                }
            }
        }
        return true;
    }

    // 清除ThreadLocal,防止内存泄漏
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserContext.clear();
    }
}

// 评分接口(带权限控制)
@RestController
@RequestMapping("/api/score")
@CrossOrigin
public class ScoreController {
    @Autowired
    private ScoreService scoreService;

    // 学生提交评分(仅学生可访问)
    @PostMapping("/submit")
    @RequireRole("student")
    public ResultVO submitScore(@Valid @RequestBody ScoreSubmitDTO dto) {
        Long studentId = UserContext.getUserId();
        scoreService.submitScore(dto, studentId);
        return ResultVO.success("评分提交成功");
    }

    // 教师查看课程评分(仅教师可访问)
    @GetMapping("/course/{courseId}")
    @RequireRole("teacher")
    public ResultVO<List<ScoreVO>> getCourseScore(@PathVariable Long courseId) {
        Long teacherId = UserContext.getUserId();
        // 权限过滤:仅能查看自己授课的课程评分
        List<ScoreVO> scoreList = scoreService.getCourseScore(courseId, teacherId);
        return ResultVO.success(scoreList);
    }

    // 测试用令牌生成接口(供测试人员使用)
    @GetMapping("/test/token")
    @RequireRole("admin")
    public ResultVO<String> generateTestToken(@RequestParam String role, @RequestParam Long userId) {
        String token = jwtUtil.generateToken(userId, role);
        return ResultVO.success(token);
    }
}

4. 项目程序/模块的最新(运行)截图

③用户管理
image
image
image

④授予管理
image
管理“学生/教师/课程管理员”对某课程是否有查看的权限

5. 每日每人总结

  • 王梓涵:“评分组件的防重复提交做好了,学生和教师的评分页面都开发完了,明天重点做成绩统计的前端页面,特别是图表展示部分。”

  • 林嘉俊:“权限拦截器和评分接口通了,用ThreadLocal解决了角色信息延迟问题,明天开发成绩统计和Excel导出接口,确保统计逻辑准确。”

  • 廖鸿基:“权限场景都测过了,包括越权访问、重复评分等异常场景,后端给了测试令牌接口后效率高多了,明天准备成绩统计功能的测试用例。”

第6天:成绩统计功能开发与Excel导出

博客标题:智教协作队Alpha冲刺第6天 | 成绩统计与数据导出落地

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成学生评分组件开发 开发成绩统计页面,实现分数段图表展示,对接Excel导出接口 ECharts图表数据联动延迟,跨班级对比图表样式需优化
林嘉俊 完成权限拦截器与评分接口开发 开发成绩统计接口、Excel导出工具类,实现跨班级数据对比 大数据量Excel导出时内存溢出,需用SXSSFWorkbook优化
廖鸿基 完成评分功能与权限测试 测试成绩统计功能,重点测试Excel导出与跨班级对比场景 大数据量导出测试时,需构造1000条以上成绩数据,效率较低

2. 项目燃尽图

image

3. 代码/文档登入记录

image

王梓涵(成绩统计图表页面)

对应Issue:[#11 前端:成绩统计页面开发](https://gitee.com/zhijia-team/teaching-system/issues/11)


<template>
  <div class="stat-container">
    <el-page-header content="成绩统计分析"></el-page-header>
    <el-card shadow="hover" class="filter-card">
      <el-form :model="filterForm" inline>
        <el-form-item label="课程">
          <el-select v-model="filterForm.courseId" placeholder="请选择课程" @change="getStatData">
            <el-option v-for="course in courseList" :key="course.id" :label="course.name" :value="course.id"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="班级">
          <el-select v-model="filterForm.classIds" placeholder="请选择班级(可多选)" multiple @change="getStatData">
            <el-option v-for="cls in classList" :key="cls.id" :label="cls.name" :value="cls.id"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="exportExcel">导出Excel</el-button>
        </el-form-item>
      </el-form>
    </el-card>
    
    <div class="chart-container" style="margin-top:20px;">
      <el-card shadow="hover" class="chart-card">
        <h3 slot="header">分数段分布(人数)</h3>
        <div style="height:400px;">
          <echarts :option="scoreSectionOption" style="width:100%;height:100%;"></echarts>
        </div>
      </el-card>
      
      <el-card shadow="hover" class="chart-card" style="margin-top:20px;">
        <h3 slot="header">班级平均分对比</h3>
        <div style="height:400px;">
          <echarts :option="classAvgOption" style="width:100%;height:100%;"></echarts>
        </div>
      </el-card>
    </div>
    
    <el-card shadow="hover" style="margin-top:20px;">
      <h3 slot="header">统计详情</h3>
      <el-table :data="statDetail" border stripe>
        <el-table-column label="统计项" prop="item" width="150"></el-table-column>
        <el-table-column label="数值" prop="value"></el-table-column>
      </el-table>
    </el-card>
  </div>
</template>

<script setup>
import { reactive, ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useStore } from 'vuex';
import axios from 'axios';
import * as echarts from 'echarts';

const store = useStore();
const filterForm = reactive({
  courseId: '',
  classIds: []
});
const courseList = ref([]);
const classList = ref([]);
const statDetail = ref([]);
// ECharts配置
const scoreSectionOption = ref({});
const classAvgOption = ref({});

// 初始化加载课程和班级列表
onMounted(() => {
  getCourseAndClassList();
});

// 获取课程和班级数据
const getCourseAndClassList = async () => {
  const token = store.state.user.token;
  const res = await axios.get('/api/teacher/course-class', {
    headers: { 'Authorization': `Bearer ${token}` }
  });
  courseList.value = res.data.data.courses;
  classList.value = res.data.data.classes;
};

// 获取统计数据
const getStatData = async () => {
  if (!filterForm.courseId || filterForm.classIds.length === 0) {
    ElMessage.warning("请选择课程和班级");
    return;
  }
  
  const token = store.state.user.token;
  const res = await axios.get('/api/grade/stat', {
    params: {
      courseId: filterForm.courseId,
      classIds: filterForm.classIds.join(',')
    },
    headers: { 'Authorization': `Bearer ${token}` }
  });
  const data = res.data.data;
  
  // 填充统计详情
  statDetail.value = [
    { item: '参与人数', value: data.totalCount },
    { item: '平均分', value: data.avgScore.toFixed(2) },
    { item: '最高分', value: data.maxScore },
    { item: '最低分', value: data.minScore },
    { item: '及格率', value: (data.passRate * 100).toFixed(2) + '%' },
    { item: '优秀率(85分及以上)', value: (data.excellentRate * 100).toFixed(2) + '%' }
  ];
  
  // 初始化分数段分布图表(解决联动延迟问题)
  initScoreSectionChart(data.scoreSection);
  // 初始化班级平均分对比图表
  initClassAvgChart(data.classAvgList);
};

// 分数段分布图表初始化
const initScoreSectionChart = (scoreSectionData) => {
  scoreSectionOption.value = {
    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
    legend: { data: ['人数'], top: 0 },
    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
    xAxis: {
      type: 'category',
      data: Object.keys(scoreSectionData),
      axisLabel: { rotate: 0 }
    },
    yAxis: { type: 'value', name: '人数' },
    series: [{
      name: '人数',
      type: 'bar',
      data: Object.values(scoreSectionData),
      itemStyle: { color: '#409EFF' },
      emphasis: { itemStyle: { color: '#67C23A' } }
    }]
  };
};

// 班级平均分对比图表初始化
const initClassAvgChart = (classAvgList) => {
  classAvgOption.value = {
    tooltip: { trigger: 'item' },
    legend: { data: ['平均分'], top: 0 },
    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
    xAxis: { type: 'category', data: classAvgList.map(item => item.className) },
    yAxis: { type: 'value', name: '平均分', min: 0, max: 100 },
    series: [{
      name: '平均分',
      type: 'line',
      data: classAvgList.map(item => item.avgScore.toFixed(2)),
      symbol: 'circle',
      symbolSize: 8,
      lineStyle: { width: 2 },
      itemStyle: { color: '#E6A23C' }
    }]
  };
};

// 导出Excel
const exportExcel = async () => {
  if (!filterForm.courseId || filterForm.classIds.length === 0) {
    ElMessage.warning("请选择课程和班级");
    return;
  }
  
  ElMessageBox.confirm(
    "确定要导出当前统计数据吗?",
    "导出确认",
    { confirmButtonText: "确定", cancelButtonText: "取消", type: "info" }
  ).then(async () => {
    const token = store.state.user.token;
    // 构造导出请求(响应为文件流)
    const response = await axios.get('/api/grade/export', {
      params: {
        courseId: filterForm.courseId,
        classIds: filterForm.classIds.join(',')
      },
      headers: { 'Authorization': `Bearer ${token}` },
      responseType: 'blob' // 关键:指定响应类型为blob
    });
    
    // 处理文件流,触发下载
    const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
    const fileName = `成绩统计_${courseList.value.find(c => c.id === filterForm.courseId).name}_${new Date().toLocaleDateString()}.xlsx`;
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    ElMessage.success("导出成功!");
  });
};
</script>

林嘉俊(成绩统计与Excel导出接口)

对应Issue:[#12 后端:成绩统计与导出接口开发](https://gitee.com/zhijia-team/teaching-system/issues/12)


// 成绩统计结果VO
@Data
public class GradeStatVO {
    private Integer totalCount; // 参与人数
    private Double avgScore; // 平均分
    private Integer maxScore; // 最高分
    private Integer minScore; // 最低分
    private Double passRate; // 及格率(60分及以上)
    private Double excellentRate; // 优秀率(85分及以上)
    private Map<String, Integer> scoreSection; // 分数段分布:key=分数段,value=人数
    private List<ClassAvgVO> classAvgList; // 班级平均分列表
}

// Excel导出工具类(解决内存溢出问题)
@Component
public class ExcelExportUtil {
    /**
     * 大数据量Excel导出(SXSSFWorkbook)
     */
    public void exportGradeExcel(List<GradeDetailVO> dataList, OutputStream outputStream) {
        // 1. 使用SXSSFWorkbook,设置内存中保留的行数(超过则写入磁盘)
        SXSSFWorkbook workbook = new SXSSFWorkbook(100);
        SXSSFSheet sheet = workbook.createSheet("成绩详情");
        
        // 2. 定义表头
        String[] headers = {"班级", "姓名", "学号", "课程", "成绩", "等级"};
        SXSSFRow headerRow = sheet.createRow(0);
        for (int i = 0; i < headers.length; i++) {
            SXSSFCell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
            // 设置表头样式
            CellStyle headerStyle = workbook.createCellStyle();
            Font font = workbook.createFont();
            font.setBold(true);
            headerStyle.setFont(font);
            headerStyle.setAlignment(HorizontalAlignment.CENTER);
            cell.setCellStyle(headerStyle);
        }
        
        // 3. 填充数据
        for (int i = 0; i < dataList.size(); i++) {
            GradeDetailVO data = dataList.get(i);
            SXSSFRow dataRow = sheet.createRow(i + 1);
            dataRow.createCell(0).setCellValue(data.getClassName());
            dataRow.createCell(1).setCellValue(data.getStudentName());
            dataRow.createCell(2).setCellValue(data.getStudentAccount());
            dataRow.createCell(3).setCellValue(data.getCourseName());
            dataRow.createCell(4).setCellValue(data.getScore());
            // 成绩等级判断
            String level = data.getScore() >= 85 ? "优秀" : (data.getScore() >= 60 ? "及格" : "不及格");
            dataRow.createCell(5).setCellValue(level);
        }
        
        // 4. 自适应列宽
        for (int i = 0; i < headers.length; i++) {
            sheet.autoSizeColumn(i);
            // 调整列宽,避免内容被截断
            sheet.setColumnWidth(i, sheet.getColumnWidth(i) + 2048);
        }
        
        // 5. 写入输出流
        try {
            workbook.write(outputStream);
        } catch (IOException e) {
            throw new BusinessException("Excel导出失败:" + e.getMessage());
        } finally {
            // 6. 清理临时文件,释放内存
            workbook.dispose();
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

// 成绩统计与导出接口
@RestController
@RequestMapping("/api/grade")
@CrossOrigin
public class GradeController {
    @Autowired
    private GradeService gradeService;
    @Autowired
    private ExcelExportUtil excelExportUtil;
    
    // 成绩统计接口(仅教师和管理员可访问)
    @GetMapping("/stat")
    @RequireRole({"teacher", "admin"})
    public ResultVO<GradeStatVO> getGradeStat(
            @RequestParam Long courseId,
            @RequestParam String classIds) {
        Long teacherId = UserContext.getUserId();
        List<Long> classIdList = Arrays.stream(classIds.split(","))
                .map(Long::valueOf)
                .collect(Collectors.toList());
        GradeStatVO statVO = gradeService.getGradeStat(courseId, classIdList, teacherId);
        return ResultVO.success(statVO);
    }
    
    // 成绩导出接口
    @GetMapping("/export")
    @RequireRole({"teacher", "admin"})
    public void exportGradeExcel(
            @RequestParam Long courseId,
            @RequestParam String classIds,
            HttpServletResponse response) {
        Long teacherId = UserContext.getUserId();
        List<Long> classIdList = Arrays.stream(classIds.split(","))
                .map(Long::valueOf)
                .collect(Collectors.toList());
        List<GradeDetailVO> dataList = gradeService.getGradeDetail(courseId, classIdList, teacherId);
        
        // 设置响应头,触发浏览器下载
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("UTF-8");
        String fileName = null;
        try {
            fileName = URLEncoder.encode("成绩统计.xlsx", "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
        
        // 导出Excel
        try {
            excelExportUtil.exportGradeExcel(dataList, response.getOutputStream());
        } catch (IOException e) {
            throw new BusinessException("导出流写入失败:" + e.getMessage());
        }
    }
}

4. 项目程序/模块的最新(运行)截图

⑤学习对象管理
image

⑥课程科目管理
image

⑦角色权限管理
image
具体设置 特定角色 对特定接口的权限

5. 每日每人总结

  • 王梓涵:“成绩统计的图表功能搞定了,优化了图表联动逻辑,导出接口也对接好了,明天做系统整体联调,把各个模块串起来。”

  • 林嘉俊:“用SXSSFWorkbook解决了Excel导出内存溢出问题,统计接口的逻辑也没问题,明天做接口异常处理和日志优化,确保系统稳定。”

  • 廖鸿基:“统计和导出功能都测过了,包括1000条数据的大数据场景,接口响应时间在2秒内,符合预期,明天做系统全流程测试。”

第7天:Alpha阶段收尾与全流程测试

博客标题:智教协作队Alpha冲刺第7天 | Alpha阶段圆满收尾

1. 工作进展

成员 昨天已完成的工作 今天计划完成的工作 工作中遇到的困难
王梓涵 完成成绩统计页面开发 系统全流程联调,修复前端页面兼容性问题,完善操作提示 平板端课件预览页面排版错乱,需适配不同屏幕尺寸
林嘉俊 完成成绩统计与导出接口开发 接口异常处理优化,添加操作日志,修复已知Bug 日志打印过多导致性能轻微下降,需调整日志级别
廖鸿基 完成成绩统计功能测试 执行系统全流程测试,编写Alpha阶段测试报告,收集用户体验反馈 部分边界场景测试用例遗漏,需补充后重新执行

2. 项目燃尽图

【截图位置】插入Leangoo燃尽图第7天截图,标注“剩余工时0小时,Alpha阶段核心任务全部完成”,燃尽线与理想线基本重合

3. 代码/文档登入记录

image

![image](https://img2024.cnblogs.com/blog/3698289/202512/3698289-20251203124218878-1093488401.png)

王梓涵(平板端适配优化)

对应Issue:[#13 前端:多端兼容性优化](https://gitee.com/zhijia-team/teaching-system/issues/13)


// 课件预览页面的响应式样式优化(解决平板端排版问题)
<style scoped>
.preview-container {
  padding: 15px;
  box-sizing: border-box;
}
/* 桌面端样式 */
.courseware-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}
.preview-content {
  width: 100%;
  height: 800px;
  border: 1px solid #e6e6e6;
}
/* 平板端适配(屏幕宽度768px-1200px) */
@media screen and (max-width: 1200px) and (min-width: 768px) {
  .courseware-info {
    flex-direction: column;
    align-items: flex-start;
  }
  .courseware-info > div {
    margin-bottom: 10px;
    width: 100%;
  }
  .preview-content {
    height: 500px; /* 适配平板屏幕高度 */
  }
  /* 按钮组样式调整 */
  .btn-group {
    display: flex;
    justify-content: space-between;
    width: 100%;
  }
  .btn-group > el-button {
    flex: 1;
    margin-right: 5px;
  }
}
/* 小屏平板适配(屏幕宽度小于768px) */
@media screen and (max-width: 768px) {
  .preview-content {
    height: 350px;
  }
  .courseware-title {
    font-size: 16px;
    font-weight: bold;
  }
}

// 全局操作提示优化(统一提示样式)
import { ElMessage } from 'element-plus';
export const showMessage = (message, type = 'info') => {
  ElMessage({
    message: message,
    type: type,
    duration: 2000,
    center: true // 提示框居中显示,提升用户体验
  });
};
// 在登录、上传等功能中调用
// showMessage("登录成功", "success");
// showMessage("请选择文件", "warning");

林嘉俊(接口优化与日志配置)

对应Issue:[#14 后端:接口优化与日志配置](https://gitee.com/zhijia-team/teaching-system/issues/14)


// 全局异常处理类(统一异常响应格式)
@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    // 业务异常处理
    @ExceptionHandler(BusinessException.class)
    public ResultVO handleBusinessException(BusinessException e) {
        logger.warn("业务异常:{}", e.getMessage());
        return ResultVO.error(e.getMessage());
    }
    
    // 参数校验异常处理
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultVO handleValidException(MethodArgumentNotValidException e) {
        // 获取校验失败的字段和提示信息
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining(","));
        logger.warn("参数校验异常:{}", message);
        return ResultVO.error(message);
    }
    
    // 系统异常处理(不暴露具体异常信息给前端)
    @ExceptionHandler(Exception.class)
    public ResultVO handleException(Exception e) {
        logger.error("系统异常:", e); // 打印完整异常栈
        return ResultVO.error("系统繁忙,请稍后再试");
    }
}

// 日志配置优化(调整日志级别,减少冗余日志)
// application.yml日志配置
logging:
  level:
    root: INFO # 根日志级别为INFO
    com.zhijia.mapper: DEBUG # MyBatis日志为DEBUG,便于调试SQL
    com.zhijia.controller: INFO # 控制器日志为INFO
    com.zhijia.service: INFO # 服务层日志为INFO
  file:
    name: logs/teaching-system.log # 日志文件路径
  pattern:
    console: '%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n' # 控制台日志格式
    file: '%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n' # 文件日志格式
    
// 操作日志注解(记录关键业务操作)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperLog {
    String value() default ""; // 操作描述
}
    
// 操作日志切面
@Aspect
@Component
public class OperLogAspect {
    private static final Logger operLogger = LoggerFactory.getLogger("operLog");
    
    @Pointcut("@annotation(com.zhijia.annotation.OperLog)")
    public void operLogPointcut() {}
    
    @Around("operLogPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 记录操作前信息
        OperLog operLog = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(OperLog.class);
        String operDesc = operLog.value();
        Long userId = UserContext.getUserId();
        String role = UserContext.getRole();
        String requestUrl = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getRequestURI();
        
        // 2. 执行目标方法
        long startTime = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long costTime = System.currentTimeMillis() - startTime;
        
        // 3. 记录操作日志
        operLogger.info("操作人ID:{},角色:{},操作描述:{},请求地址:{},耗时:{}ms",
                userId, role, operDesc, requestUrl, costTime);
        return result;
    }
}

// 在关键接口上添加操作日志注解
@PostMapping("/upload")
@RequireRole("teacher")
@OperLog("教师上传课件")
public ResultVO uploadCourseware(...) {
    // 课件上传逻辑
}

4. 项目程序/模块的最新(运行)截图

课程界面:搜索、筛选课程
用户的角色role不同,在该界面的接口处理逻辑不同
学生,教师,课程管理员都会查询user_to_course表,仅获取有权限的课程
超级管理员会获取全部的课程

image

5. 每日每人总结与Alpha阶段感悟

  • 王梓涵:“今天把平板端的兼容性问题解决了,全流程跑下来很顺畅。Alpha阶段最大的收获是学会了前后端联调的技巧,从原型到页面落地,看着自己写的代码能正常运行特别有成就感。后续可以优化课件预览的加载速度,提升用户体验。”

  • 林嘉俊:“全局异常处理和日志优化做完后,系统稳定性提升了不少。这7天冲刺让我明白,后端开发不仅要实现功能,还要考虑异常场景和性能问题。接下来可以研究MinIO的文件预热,让大课件预览更快。”

  • 廖鸿基:“全流程测试发现了3个边界Bug,都已经修复了,测试报告也写完了。通过这次测试,我对‘测试驱动开发’有了更深的理解,提前设计用例能帮开发少走很多弯路。后续可以增加自动化测试脚本,提高测试效率。”

二、Alpha阶段冲刺总结

1. 冲刺目标完成情况

Alpha阶段计划完成的核心功能全部落地,具体完成情况如下:

功能模块 计划功能点 完成情况 备注
用户登录与权限 多角色登录、权限控制、JWT认证 100%完成 新增测试令牌接口,便于测试
课件管理 多格式上传、版本管理、分片上传、预览 100%完成 支持PPT/PDF/MP4/MP3格式,最大100MB
学生评分 星标评分、评语提交、匿名反馈 100%完成 添加防重复提交机制
成绩统计与导出 多维度统计、跨班对比、Excel导出 100%完成 优化大数据量导出性能

2. 团队协作亮点

  • 每日站会高效同步:固定15分钟站会,明确当日目标,及时暴露问题,团队响应时间不超过1小时。

  • 代码规范统一:提前制定Git分支规范(master/dev/feature)和代码注释规范,减少合并冲突。

  • 前后端并行开发:通过接口文档提前对齐参数,前端开发页面的同时后端开发接口,提升开发效率。

  • 测试同步跟进:测试人员提前编写用例,开发完成后立即执行测试,缩短Bug修复周期。

3. 存在的问题与改进方向

(1)当前问题

  • 大课件(50MB以上)预览加载较慢,需等待3-5秒;

  • 平板端部分按钮点击区域较小,操作不够便捷;

  • 缺乏自动化测试脚本,回归测试效率较低。

(2)改进方向

  • 技术优化:引入MinIO文件预签名URL和分片加载,提升大课件预览速度;

  • 交互优化:调整平板端按钮尺寸和间距,增加点击区域;

  • 测试优化:开发Junit单元测试和Postman自动化测试脚本,实现关键接口自动回归。

4. 下一阶段计划

  • Beta阶段重点:解决Alpha阶段遗留问题,新增课件收藏、成绩预警功能(低于60分自动提醒教师);

  • 用户调研:邀请10位教师和50名学生进行Beta版本试用,收集真实使用反馈;

  • 部署优化:将系统部署到云服务器,实现公网访问,便于大规模测试。

Alpha阶段的7天冲刺,是团队从“需求”到“产品”的一次完整实践。我们不仅完成了功能开发,更在协作中磨合了节奏、提升了能力。接下来,我们将带着Alpha阶段的经验和问题,全力投入Beta阶段的开发,打造更贴合教学需求的实用系统。

posted @ 2025-12-03 14:31    阅读(14)  评论(0)    收藏  举报