教育场景 精选推荐

在线作业系统完整实现方案:从需求分析到生产部署

HTMLPAGE 团队
20 分钟阅读

全面解析在线教育作业系统的设计与实现,涵盖作业发布、多格式提交、自动批改、防作弊机制、数据分析等核心功能的技术架构与最佳实践。

#在线教育 #作业系统 #自动批改 #教育技术 #系统设计

在线作业系统完整实现方案:从需求分析到生产部署

在线教育的快速发展对作业系统提出了更高的要求。一个优秀的作业系统不仅要支持多种题型和格式,还需要具备自动批改、防作弊、学情分析等高级功能。本文将从实际需求出发,详细讲解如何构建一个功能完善、性能优异的在线作业系统。

为什么作业系统如此重要

在传统教育中,作业是检验学习成果的重要手段。在线教育环境下,作业系统承担着更多职责:

教学层面

  • 即时反馈:学生提交后立即得到批改结果,加速学习闭环
  • 个性化学习:根据作业表现推荐针对性的学习内容
  • 减轻教师负担:自动批改客观题,教师专注于主观题和个性化指导

技术层面

  • 数据收集:积累学习行为数据,为算法优化提供素材
  • 用户粘性:作业是高频互动场景,直接影响用户活跃度
  • 商业价值:作业批改、辅导答疑是重要的付费功能

核心挑战

  • 多样性:题型多样(选择、填空、编程、论述等),提交格式多样(文本、图片、文件、代码)
  • 规模性:高峰期可能有数万学生同时提交
  • 公平性:防止抄袭和作弊,保证评价公正
  • 实时性:批改结果需要快速返回

系统架构设计

整体架构

┌─────────────────────────────────────────────────────────────────┐
│                         客户端层                                 │
│  ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐       │
│  │  Web App  │ │ Mobile H5 │ │ 小程序     │ │  桌面客户端│       │
│  └───────────┘ └───────────┘ └───────────┘ └───────────┘       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         接入层                                   │
│  ┌───────────────────┐ ┌───────────────────┐                   │
│  │    API Gateway    │ │    CDN / OSS      │                   │
│  │   认证、限流、路由  │ │   静态资源、文件   │                   │
│  └───────────────────┘ └───────────────────┘                   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        业务服务层                                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │ 作业服务  │ │ 提交服务  │ │ 批改服务  │ │ 统计服务  │          │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │ 题库服务  │ │ 通知服务  │ │ 用户服务  │ │ 班级服务  │          │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        基础设施层                                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │ MySQL    │ │ Redis    │ │ ES       │ │ Kafka    │          │
│  │ 核心数据  │ │ 缓存/队列 │ │ 搜索/日志 │ │ 消息队列  │          │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                       │
│  │ MinIO    │ │ ClickHouse││ K8s      │                       │
│  │ 对象存储  │ │ 分析数据库│ │ 容器编排  │                       │
│  └──────────┘ └──────────┘ └──────────┘                       │
└─────────────────────────────────────────────────────────────────┘

核心服务职责

作业服务(Assignment Service)

  • 创建、编辑、发布作业
  • 作业配置管理(截止时间、提交限制等)
  • 作业状态流转

提交服务(Submission Service)

  • 接收学生提交
  • 文件上传处理
  • 提交记录管理

批改服务(Grading Service)

  • 自动批改客观题
  • 代码题在线评测
  • 教师批改工作台

统计服务(Analytics Service)

  • 作业完成情况统计
  • 成绩分布分析
  • 学情报告生成

数据模型设计

核心实体关系

┌──────────────┐       ┌──────────────┐
│   Course     │1     *│  Assignment  │
│   课程       ├───────┤   作业       │
└──────────────┘       └───────┬──────┘
                               │1
                               │
                               │*
┌──────────────┐       ┌───────┴──────┐
│   Student    │*     *│  Question    │
│   学生       ├───────┤   题目       │
└──────┬───────┘       └───────┬──────┘
       │1                      │1
       │                       │
       │*                      │*
┌──────┴───────┐       ┌───────┴──────┐
│  Submission  │1     *│   Answer     │
│   提交       ├───────┤   答案       │
└──────────────┘       └──────────────┘

数据库表设计

-- 作业表
CREATE TABLE assignments (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    course_id BIGINT NOT NULL COMMENT '所属课程',
    title VARCHAR(200) NOT NULL COMMENT '作业标题',
    description TEXT COMMENT '作业说明',
    type ENUM('homework', 'quiz', 'exam') DEFAULT 'homework' COMMENT '作业类型',
    
    -- 时间配置
    start_time DATETIME NOT NULL COMMENT '开始时间',
    end_time DATETIME NOT NULL COMMENT '截止时间',
    late_deadline DATETIME COMMENT '补交截止时间',
    
    -- 提交配置
    max_submissions INT DEFAULT 1 COMMENT '最大提交次数',
    allow_late BOOLEAN DEFAULT FALSE COMMENT '是否允许补交',
    late_penalty DECIMAL(5,2) DEFAULT 0 COMMENT '迟交扣分比例',
    
    -- 批改配置
    grading_mode ENUM('auto', 'manual', 'mixed') DEFAULT 'mixed' COMMENT '批改模式',
    total_score DECIMAL(6,2) NOT NULL COMMENT '总分',
    pass_score DECIMAL(6,2) COMMENT '及格分数',
    
    -- 防作弊配置
    shuffle_questions BOOLEAN DEFAULT FALSE COMMENT '题目乱序',
    shuffle_options BOOLEAN DEFAULT FALSE COMMENT '选项乱序',
    time_limit INT COMMENT '限时(分钟)',
    
    -- 发布状态
    status ENUM('draft', 'published', 'closed', 'archived') DEFAULT 'draft',
    published_at DATETIME,
    
    -- 审计字段
    created_by BIGINT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_course_status (course_id, status),
    INDEX idx_end_time (end_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作业表';

-- 题目表
CREATE TABLE questions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    assignment_id BIGINT NOT NULL COMMENT '所属作业',
    parent_id BIGINT DEFAULT NULL COMMENT '父题ID(用于大题下的小题)',
    sort_order INT NOT NULL DEFAULT 0 COMMENT '题目顺序',
    
    -- 题目类型
    type ENUM(
        'single_choice',      -- 单选题
        'multiple_choice',    -- 多选题
        'true_false',         -- 判断题
        'fill_blank',         -- 填空题
        'short_answer',       -- 简答题
        'essay',              -- 论述题
        'programming',        -- 编程题
        'file_upload',        -- 文件上传题
        'matching',           -- 配对题
        'ordering'            -- 排序题
    ) NOT NULL,
    
    -- 题目内容
    content TEXT NOT NULL COMMENT '题目内容(支持Markdown)',
    options JSON COMMENT '选项配置',
    correct_answer TEXT COMMENT '标准答案',
    answer_explanation TEXT COMMENT '答案解析',
    
    -- 评分配置
    score DECIMAL(5,2) NOT NULL COMMENT '分值',
    partial_credit BOOLEAN DEFAULT FALSE COMMENT '是否允许部分得分',
    grading_criteria JSON COMMENT '评分标准(用于主观题)',
    
    -- 扩展配置
    metadata JSON COMMENT '扩展信息(如编程题的测试用例)',
    
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_assignment (assignment_id),
    INDEX idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';

-- 学生提交表
CREATE TABLE submissions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    assignment_id BIGINT NOT NULL,
    student_id BIGINT NOT NULL,
    attempt_number INT NOT NULL DEFAULT 1 COMMENT '提交次数',
    
    -- 提交状态
    status ENUM(
        'in_progress',   -- 答题中
        'submitted',     -- 已提交
        'grading',       -- 批改中
        'graded',        -- 已批改
        'returned'       -- 已退回
    ) DEFAULT 'in_progress',
    
    -- 时间记录
    started_at DATETIME NOT NULL COMMENT '开始答题时间',
    submitted_at DATETIME COMMENT '提交时间',
    graded_at DATETIME COMMENT '批改完成时间',
    
    -- 成绩
    total_score DECIMAL(6,2) COMMENT '总分',
    is_late BOOLEAN DEFAULT FALSE COMMENT '是否迟交',
    late_penalty_applied DECIMAL(5,2) DEFAULT 0 COMMENT '实际扣除的迟交分数',
    
    -- 防作弊数据
    ip_address VARCHAR(45) COMMENT '提交IP',
    user_agent VARCHAR(500) COMMENT '浏览器信息',
    time_spent INT COMMENT '用时(秒)',
    suspicious_behavior JSON COMMENT '可疑行为记录',
    
    -- 教师反馈
    feedback TEXT COMMENT '教师评语',
    graded_by BIGINT COMMENT '批改人',
    
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_assignment_student_attempt (assignment_id, student_id, attempt_number),
    INDEX idx_student (student_id),
    INDEX idx_status (status),
    INDEX idx_submitted_at (submitted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生提交表';

-- 答案表
CREATE TABLE answers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    submission_id BIGINT NOT NULL,
    question_id BIGINT NOT NULL,
    
    -- 答案内容
    answer_content TEXT COMMENT '文本答案',
    answer_files JSON COMMENT '附件列表',
    
    -- 评分结果
    score DECIMAL(5,2) COMMENT '得分',
    is_correct BOOLEAN COMMENT '是否正确(客观题)',
    auto_graded BOOLEAN DEFAULT FALSE COMMENT '是否自动批改',
    
    -- 批改详情
    grading_details JSON COMMENT '批改详情(如编程题的测试结果)',
    teacher_comment TEXT COMMENT '教师批注',
    
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_submission_question (submission_id, question_id),
    INDEX idx_question (question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答案表';

题目选项数据结构

不同题型的 options 字段结构:

// 单选题/多选题选项
interface ChoiceQuestionOptions {
  choices: {
    key: string;      // A, B, C, D
    content: string;  // 选项内容
    isCorrect: boolean;
  }[];
  allowPartial?: boolean; // 多选题是否允许部分得分
}

// 填空题选项
interface FillBlankOptions {
  blanks: {
    placeholder: string;    // 占位符,如 {{blank1}}
    acceptableAnswers: string[];  // 可接受的答案列表
    caseSensitive: boolean;
    scorePerBlank: number;
  }[];
}

// 编程题选项
interface ProgrammingOptions {
  language: 'python' | 'javascript' | 'java' | 'cpp' | 'go';
  templateCode?: string;
  testCases: {
    input: string;
    expectedOutput: string;
    isHidden: boolean;   // 隐藏测试用例
    score: number;
    timeLimit: number;   // 毫秒
    memoryLimit: number; // MB
  }[];
  enableCodePlayground: boolean;
}

// 配对题选项
interface MatchingOptions {
  leftColumn: { key: string; content: string }[];
  rightColumn: { key: string; content: string }[];
  correctPairs: { left: string; right: string }[];
}

核心功能实现

1. 作业发布流程

// assignment.service.ts
@Injectable()
export class AssignmentService {
  constructor(
    private readonly assignmentRepo: AssignmentRepository,
    private readonly questionRepo: QuestionRepository,
    private readonly notificationService: NotificationService,
    private readonly cacheManager: Cache,
  ) {}

  /**
   * 发布作业
   * 1. 验证作业配置
   * 2. 验证题目完整性
   * 3. 计算总分
   * 4. 更新状态
   * 5. 发送通知
   */
  async publishAssignment(
    assignmentId: number,
    teacherId: number
  ): Promise<Assignment> {
    // 获取作业详情
    const assignment = await this.assignmentRepo.findOneWithQuestions(assignmentId);
    
    if (!assignment) {
      throw new NotFoundException('作业不存在');
    }
    
    if (assignment.createdBy !== teacherId) {
      throw new ForbiddenException('无权限发布此作业');
    }
    
    if (assignment.status !== 'draft') {
      throw new BadRequestException('只能发布草稿状态的作业');
    }

    // 验证题目
    await this.validateQuestions(assignment);

    // 计算总分
    const totalScore = assignment.questions.reduce(
      (sum, q) => sum + Number(q.score), 
      0
    );

    // 更新作业状态
    const updatedAssignment = await this.assignmentRepo.update(assignmentId, {
      status: 'published',
      publishedAt: new Date(),
      totalScore,
    });

    // 清除相关缓存
    await this.invalidateCache(assignment.courseId);

    // 发送通知给所有学生
    await this.notifyStudents(assignment);

    return updatedAssignment;
  }

  private async validateQuestions(assignment: Assignment): Promise<void> {
    const questions = assignment.questions;

    if (questions.length === 0) {
      throw new BadRequestException('作业必须包含至少一道题目');
    }

    for (const question of questions) {
      // 验证题目内容
      if (!question.content?.trim()) {
        throw new BadRequestException(`题目 ${question.sortOrder + 1} 内容不能为空`);
      }

      // 验证分值
      if (question.score <= 0) {
        throw new BadRequestException(`题目 ${question.sortOrder + 1} 分值必须大于0`);
      }

      // 验证客观题的答案
      if (this.isObjectiveQuestion(question.type)) {
        if (!question.correctAnswer) {
          throw new BadRequestException(
            `客观题 ${question.sortOrder + 1} 必须设置正确答案`
          );
        }
      }

      // 验证编程题的测试用例
      if (question.type === 'programming') {
        const options = question.metadata as ProgrammingOptions;
        if (!options?.testCases?.length) {
          throw new BadRequestException(
            `编程题 ${question.sortOrder + 1} 必须设置测试用例`
          );
        }
      }
    }
  }

  private isObjectiveQuestion(type: string): boolean {
    return ['single_choice', 'multiple_choice', 'true_false', 'fill_blank'].includes(type);
  }

  private async notifyStudents(assignment: Assignment): Promise<void> {
    // 获取课程的所有学生
    const students = await this.courseService.getEnrolledStudents(assignment.courseId);
    
    // 批量发送通知
    const notifications = students.map(student => ({
      userId: student.id,
      type: 'assignment_published',
      title: '新作业发布',
      content: `${assignment.title} 已发布,截止时间:${format(assignment.endTime, 'MM-dd HH:mm')}`,
      data: {
        assignmentId: assignment.id,
        courseId: assignment.courseId,
      },
    }));

    await this.notificationService.batchCreate(notifications);
  }
}

2. 作业提交处理

学生提交作业涉及多个步骤,需要保证数据完整性和原子性:

// submission.service.ts
@Injectable()
export class SubmissionService {
  constructor(
    private readonly submissionRepo: SubmissionRepository,
    private readonly answerRepo: AnswerRepository,
    private readonly assignmentRepo: AssignmentRepository,
    private readonly fileService: FileService,
    private readonly gradingQueue: Queue,
    private readonly dataSource: DataSource,
  ) {}

  /**
   * 提交作业
   * 使用数据库事务保证原子性
   */
  async submitAssignment(
    assignmentId: number,
    studentId: number,
    answers: SubmitAnswerDto[],
    metadata: SubmissionMetadata
  ): Promise<Submission> {
    // 获取作业信息
    const assignment = await this.assignmentRepo.findOne(assignmentId);
    
    if (!assignment || assignment.status !== 'published') {
      throw new BadRequestException('作业不存在或未发布');
    }

    // 检查提交时间
    const now = new Date();
    const isLate = now > assignment.endTime;
    
    if (isLate && !assignment.allowLate) {
      throw new BadRequestException('作业已截止,不允许提交');
    }
    
    if (assignment.lateDeadline && now > assignment.lateDeadline) {
      throw new BadRequestException('已超过补交截止时间');
    }

    // 检查提交次数
    const existingSubmissions = await this.submissionRepo.countByStudentAndAssignment(
      studentId, 
      assignmentId
    );
    
    if (existingSubmissions >= assignment.maxSubmissions) {
      throw new BadRequestException(
        `已达到最大提交次数限制(${assignment.maxSubmissions}次)`
      );
    }

    // 使用事务处理提交
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 创建提交记录
      const submission = await queryRunner.manager.save(Submission, {
        assignmentId,
        studentId,
        attemptNumber: existingSubmissions + 1,
        status: 'submitted',
        startedAt: metadata.startedAt,
        submittedAt: now,
        isLate,
        ipAddress: metadata.ipAddress,
        userAgent: metadata.userAgent,
        timeSpent: metadata.timeSpent,
      });

      // 保存答案
      const savedAnswers = await this.saveAnswers(
        queryRunner.manager, 
        submission.id, 
        answers
      );

      // 处理需要上传的文件
      await this.processFileAnswers(savedAnswers, answers);

      await queryRunner.commitTransaction();

      // 提交成功后,加入批改队列
      await this.gradingQueue.add('grade-submission', {
        submissionId: submission.id,
        assignmentId,
        hasObjectiveQuestions: this.hasObjectiveQuestions(assignment),
      }, {
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 1000,
        },
      });

      return submission;

    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  private async saveAnswers(
    manager: EntityManager,
    submissionId: number,
    answers: SubmitAnswerDto[]
  ): Promise<Answer[]> {
    const answerEntities = answers.map(answer => ({
      submissionId,
      questionId: answer.questionId,
      answerContent: answer.textAnswer,
      answerFiles: answer.files || [],
    }));

    return manager.save(Answer, answerEntities);
  }

  private async processFileAnswers(
    savedAnswers: Answer[], 
    originalAnswers: SubmitAnswerDto[]
  ): Promise<void> {
    for (const savedAnswer of savedAnswers) {
      const original = originalAnswers.find(a => a.questionId === savedAnswer.questionId);
      
      if (original?.pendingFiles?.length) {
        // 将临时文件移动到正式存储位置
        const permanentFiles = await this.fileService.moveToPermanent(
          original.pendingFiles,
          `submissions/${savedAnswer.submissionId}`
        );
        
        // 更新答案的文件列表
        await this.answerRepo.update(savedAnswer.id, {
          answerFiles: permanentFiles,
        });
      }
    }
  }
}

3. 自动批改引擎

自动批改是作业系统的核心功能,需要支持多种题型:

// grading.service.ts
@Injectable()
export class GradingService {
  constructor(
    private readonly submissionRepo: SubmissionRepository,
    private readonly answerRepo: AnswerRepository,
    private readonly questionRepo: QuestionRepository,
    private readonly codeJudgeService: CodeJudgeService,
    private readonly aiGradingService: AiGradingService,
  ) {}

  /**
   * 自动批改提交
   */
  async gradeSubmission(submissionId: number): Promise<void> {
    const submission = await this.submissionRepo.findOneWithAnswers(submissionId);
    
    if (!submission) {
      throw new Error(`Submission ${submissionId} not found`);
    }

    // 获取作业的所有题目
    const questions = await this.questionRepo.findByAssignment(submission.assignmentId);
    const questionMap = new Map(questions.map(q => [q.id, q]));

    let totalScore = 0;
    const gradingResults: GradingResult[] = [];

    // 逐题批改
    for (const answer of submission.answers) {
      const question = questionMap.get(answer.questionId);
      
      if (!question) continue;

      const result = await this.gradeAnswer(question, answer);
      gradingResults.push(result);

      // 更新答案得分
      await this.answerRepo.update(answer.id, {
        score: result.score,
        isCorrect: result.isCorrect,
        autoGraded: result.autoGraded,
        gradingDetails: result.details,
      });

      if (result.score !== null) {
        totalScore += result.score;
      }
    }

    // 应用迟交扣分
    if (submission.isLate) {
      const assignment = await this.assignmentRepo.findOne(submission.assignmentId);
      const penalty = totalScore * (assignment.latePenalty / 100);
      totalScore -= penalty;
      
      await this.submissionRepo.update(submissionId, {
        latePenaltyApplied: penalty,
      });
    }

    // 更新提交状态
    const allGraded = gradingResults.every(r => r.autoGraded);
    await this.submissionRepo.update(submissionId, {
      totalScore,
      status: allGraded ? 'graded' : 'grading',
      gradedAt: allGraded ? new Date() : null,
    });
  }

  /**
   * 批改单个答案
   */
  private async gradeAnswer(
    question: Question, 
    answer: Answer
  ): Promise<GradingResult> {
    switch (question.type) {
      case 'single_choice':
        return this.gradeSingleChoice(question, answer);
      
      case 'multiple_choice':
        return this.gradeMultipleChoice(question, answer);
      
      case 'true_false':
        return this.gradeTrueFalse(question, answer);
      
      case 'fill_blank':
        return this.gradeFillBlank(question, answer);
      
      case 'programming':
        return this.gradeProgramming(question, answer);
      
      case 'short_answer':
        return this.gradeShortAnswer(question, answer);
      
      default:
        // 主观题需要人工批改
        return {
          score: null,
          isCorrect: null,
          autoGraded: false,
          details: { message: '需要教师批改' },
        };
    }
  }

  /**
   * 单选题批改
   */
  private gradeSingleChoice(question: Question, answer: Answer): GradingResult {
    const studentAnswer = answer.answerContent?.trim().toUpperCase();
    const correctAnswer = question.correctAnswer?.trim().toUpperCase();
    
    const isCorrect = studentAnswer === correctAnswer;
    
    return {
      score: isCorrect ? question.score : 0,
      isCorrect,
      autoGraded: true,
      details: {
        studentAnswer,
        correctAnswer,
      },
    };
  }

  /**
   * 多选题批改
   * 支持全对得满分和部分得分两种模式
   */
  private gradeMultipleChoice(question: Question, answer: Answer): GradingResult {
    const options = question.options as ChoiceQuestionOptions;
    const studentAnswers = this.parseMultipleChoiceAnswer(answer.answerContent);
    const correctAnswers = options.choices
      .filter(c => c.isCorrect)
      .map(c => c.key.toUpperCase());

    // 计算正确选择和错误选择
    const correctSelected = studentAnswers.filter(a => correctAnswers.includes(a));
    const wrongSelected = studentAnswers.filter(a => !correctAnswers.includes(a));
    
    let score = 0;
    let isCorrect = false;

    if (wrongSelected.length === 0) {
      // 没有选错
      if (correctSelected.length === correctAnswers.length) {
        // 全对
        score = question.score;
        isCorrect = true;
      } else if (options.allowPartial && correctSelected.length > 0) {
        // 部分正确
        score = question.score * (correctSelected.length / correctAnswers.length);
      }
    }

    return {
      score,
      isCorrect,
      autoGraded: true,
      details: {
        studentAnswers,
        correctAnswers,
        correctSelected,
        wrongSelected,
      },
    };
  }

  /**
   * 填空题批改
   * 支持多个空格,每个空格独立计分
   */
  private gradeFillBlank(question: Question, answer: Answer): GradingResult {
    const options = question.options as FillBlankOptions;
    const studentAnswers = this.parseFillBlankAnswer(answer.answerContent);
    
    let totalScore = 0;
    const blankResults: BlankResult[] = [];

    for (let i = 0; i < options.blanks.length; i++) {
      const blank = options.blanks[i];
      const studentAnswer = studentAnswers[i]?.trim() || '';
      
      // 检查答案是否匹配(支持多个可接受答案)
      let isCorrect = false;
      for (const acceptable of blank.acceptableAnswers) {
        if (blank.caseSensitive) {
          isCorrect = studentAnswer === acceptable;
        } else {
          isCorrect = studentAnswer.toLowerCase() === acceptable.toLowerCase();
        }
        if (isCorrect) break;
      }

      if (isCorrect) {
        totalScore += blank.scorePerBlank;
      }

      blankResults.push({
        index: i,
        studentAnswer,
        isCorrect,
        acceptableAnswers: blank.acceptableAnswers,
      });
    }

    return {
      score: totalScore,
      isCorrect: totalScore === question.score,
      autoGraded: true,
      details: { blankResults },
    };
  }

  /**
   * 编程题批改
   * 调用在线评测服务
   */
  private async gradeProgramming(
    question: Question, 
    answer: Answer
  ): Promise<GradingResult> {
    const options = question.metadata as ProgrammingOptions;
    const code = answer.answerContent;

    if (!code?.trim()) {
      return {
        score: 0,
        isCorrect: false,
        autoGraded: true,
        details: { error: '未提交代码' },
      };
    }

    try {
      // 调用代码评测服务
      const judgeResult = await this.codeJudgeService.judge({
        language: options.language,
        code,
        testCases: options.testCases,
      });

      // 计算得分
      let earnedScore = 0;
      const testResults: TestCaseResult[] = [];

      for (let i = 0; i < judgeResult.results.length; i++) {
        const result = judgeResult.results[i];
        const testCase = options.testCases[i];

        if (result.status === 'accepted') {
          earnedScore += testCase.score;
        }

        // 对于非隐藏测试用例,返回详细信息
        if (!testCase.isHidden) {
          testResults.push({
            index: i,
            input: testCase.input,
            expectedOutput: testCase.expectedOutput,
            actualOutput: result.output,
            status: result.status,
            executionTime: result.executionTime,
            memoryUsed: result.memoryUsed,
          });
        } else {
          testResults.push({
            index: i,
            status: result.status,
            isHidden: true,
          });
        }
      }

      return {
        score: earnedScore,
        isCorrect: earnedScore === question.score,
        autoGraded: true,
        details: {
          testResults,
          totalPassed: judgeResult.results.filter(r => r.status === 'accepted').length,
          totalTests: judgeResult.results.length,
        },
      };

    } catch (error) {
      console.error('Code judging failed:', error);
      return {
        score: null,
        isCorrect: null,
        autoGraded: false,
        details: { error: '代码评测服务暂时不可用' },
      };
    }
  }

  /**
   * 简答题 AI 辅助批改
   */
  private async gradeShortAnswer(
    question: Question, 
    answer: Answer
  ): Promise<GradingResult> {
    // 如果配置了 AI 辅助批改
    if (question.gradingCriteria?.enableAI) {
      try {
        const aiResult = await this.aiGradingService.gradeShortAnswer({
          question: question.content,
          referenceAnswer: question.correctAnswer,
          gradingCriteria: question.gradingCriteria,
          studentAnswer: answer.answerContent,
          maxScore: question.score,
        });

        return {
          score: aiResult.suggestedScore,
          isCorrect: aiResult.suggestedScore >= question.score * 0.6,
          autoGraded: false, // AI 只是辅助,仍需教师确认
          details: {
            aiAnalysis: aiResult.analysis,
            aiSuggestedScore: aiResult.suggestedScore,
            keyPointsCovered: aiResult.keyPointsCovered,
            improvementSuggestions: aiResult.suggestions,
          },
        };
      } catch (error) {
        console.error('AI grading failed:', error);
      }
    }

    // 默认返回待批改状态
    return {
      score: null,
      isCorrect: null,
      autoGraded: false,
      details: { message: '需要教师批改' },
    };
  }
}

4. 防作弊机制

在线考试和作业需要防止作弊行为:

// anti-cheat.service.ts
@Injectable()
export class AntiCheatService {
  constructor(
    private readonly cacheManager: Cache,
    private readonly submissionRepo: SubmissionRepository,
  ) {}

  /**
   * 记录可疑行为
   */
  async recordSuspiciousBehavior(
    submissionId: number,
    behavior: SuspiciousBehavior
  ): Promise<void> {
    const key = `suspicious:${submissionId}`;
    
    // 获取现有记录
    const existing = await this.cacheManager.get<SuspiciousBehavior[]>(key) || [];
    existing.push({
      ...behavior,
      timestamp: new Date().toISOString(),
    });

    // 缓存记录(保留 24 小时)
    await this.cacheManager.set(key, existing, 86400);

    // 如果可疑行为过多,触发预警
    if (existing.length >= 5) {
      await this.triggerAlert(submissionId, existing);
    }
  }

  /**
   * 监测的可疑行为类型
   */
  detectBehaviors(event: BrowserEvent): SuspiciousBehavior | null {
    // 1. 切换标签页/窗口
    if (event.type === 'blur' || event.type === 'visibilitychange') {
      return {
        type: 'tab_switch',
        severity: 'medium',
        description: '离开考试页面',
      };
    }

    // 2. 复制文本
    if (event.type === 'copy') {
      return {
        type: 'copy_attempt',
        severity: 'high',
        description: '尝试复制内容',
      };
    }

    // 3. 右键菜单
    if (event.type === 'contextmenu') {
      return {
        type: 'context_menu',
        severity: 'low',
        description: '打开右键菜单',
      };
    }

    // 4. 快捷键(Ctrl+C, Ctrl+V, etc.)
    if (event.type === 'keydown' && event.ctrlKey) {
      const suspiciousKeys = ['c', 'v', 'a', 'f', 'p'];
      if (suspiciousKeys.includes(event.key.toLowerCase())) {
        return {
          type: 'suspicious_shortcut',
          severity: 'medium',
          description: `使用快捷键 Ctrl+${event.key.toUpperCase()}`,
        };
      }
    }

    return null;
  }

  /**
   * 答案相似度检测
   * 检测同一作业中不同学生答案的相似度
   */
  async detectPlagiarism(assignmentId: number): Promise<PlagiarismReport[]> {
    // 获取所有提交
    const submissions = await this.submissionRepo.findAllByAssignment(assignmentId);
    
    const reports: PlagiarismReport[] = [];

    // 两两比较(优化:可以使用局部敏感哈希减少比较次数)
    for (let i = 0; i < submissions.length; i++) {
      for (let j = i + 1; j < submissions.length; j++) {
        const sub1 = submissions[i];
        const sub2 = submissions[j];

        // 计算相似度
        const similarity = await this.calculateSimilarity(
          sub1.answers,
          sub2.answers
        );

        if (similarity > 0.85) {
          reports.push({
            submission1Id: sub1.id,
            submission2Id: sub2.id,
            student1Id: sub1.studentId,
            student2Id: sub2.studentId,
            similarity,
            suspiciousAnswers: await this.findSimilarAnswers(sub1, sub2),
          });
        }
      }
    }

    return reports;
  }

  private async calculateSimilarity(
    answers1: Answer[], 
    answers2: Answer[]
  ): Promise<number> {
    let totalSimilarity = 0;
    let comparableCount = 0;

    for (const a1 of answers1) {
      const a2 = answers2.find(a => a.questionId === a1.questionId);
      
      if (a2 && a1.answerContent && a2.answerContent) {
        // 使用编辑距离或余弦相似度
        const sim = this.textSimilarity(a1.answerContent, a2.answerContent);
        totalSimilarity += sim;
        comparableCount++;
      }
    }

    return comparableCount > 0 ? totalSimilarity / comparableCount : 0;
  }

  private textSimilarity(text1: string, text2: string): number {
    // 简化的 Jaccard 相似度
    const words1 = new Set(text1.toLowerCase().split(/\s+/));
    const words2 = new Set(text2.toLowerCase().split(/\s+/));
    
    const intersection = [...words1].filter(w => words2.has(w)).length;
    const union = new Set([...words1, ...words2]).size;
    
    return union > 0 ? intersection / union : 0;
  }
}

学情分析与报告

成绩统计服务

// analytics.service.ts
@Injectable()
export class AnalyticsService {
  /**
   * 生成作业统计报告
   */
  async generateAssignmentReport(assignmentId: number): Promise<AssignmentReport> {
    const submissions = await this.submissionRepo.findGradedByAssignment(assignmentId);
    const assignment = await this.assignmentRepo.findOne(assignmentId);

    const scores = submissions.map(s => s.totalScore).filter(s => s !== null);
    
    // 基础统计
    const stats = {
      totalStudents: await this.getTotalEnrolledStudents(assignment.courseId),
      submittedCount: submissions.length,
      gradedCount: submissions.filter(s => s.status === 'graded').length,
      averageScore: this.average(scores),
      medianScore: this.median(scores),
      maxScore: Math.max(...scores),
      minScore: Math.min(...scores),
      standardDeviation: this.standardDeviation(scores),
      passRate: this.calculatePassRate(scores, assignment.passScore),
    };

    // 分数分布
    const distribution = this.calculateDistribution(scores, assignment.totalScore);

    // 题目分析
    const questionAnalysis = await this.analyzeQuestions(assignmentId);

    // 难度评估
    const difficultyAssessment = this.assessDifficulty(stats, questionAnalysis);

    return {
      assignmentId,
      generatedAt: new Date(),
      stats,
      distribution,
      questionAnalysis,
      difficultyAssessment,
    };
  }

  /**
   * 分析每道题的得分情况
   */
  private async analyzeQuestions(assignmentId: number): Promise<QuestionAnalysis[]> {
    const questions = await this.questionRepo.findByAssignment(assignmentId);
    const answers = await this.answerRepo.findByAssignment(assignmentId);

    return questions.map(question => {
      const questionAnswers = answers.filter(a => a.questionId === question.id);
      const scores = questionAnswers.map(a => a.score).filter(s => s !== null);
      
      // 计算得分率
      const scoreRate = scores.length > 0 
        ? this.average(scores) / question.score 
        : 0;

      // 选择题选项分析
      let optionAnalysis = null;
      if (['single_choice', 'multiple_choice'].includes(question.type)) {
        optionAnalysis = this.analyzeChoiceOptions(question, questionAnswers);
      }

      // 常见错误分析
      const commonErrors = this.identifyCommonErrors(question, questionAnswers);

      return {
        questionId: question.id,
        questionNumber: question.sortOrder + 1,
        type: question.type,
        maxScore: question.score,
        averageScore: this.average(scores),
        scoreRate,
        correctRate: questionAnswers.filter(a => a.isCorrect).length / questionAnswers.length,
        optionAnalysis,
        commonErrors,
        difficulty: this.categorizeDifficulty(scoreRate),
      };
    });
  }

  private analyzeChoiceOptions(
    question: Question, 
    answers: Answer[]
  ): OptionAnalysis[] {
    const options = question.options as ChoiceQuestionOptions;
    const answerCounts = new Map<string, number>();

    // 统计每个选项的选择次数
    for (const answer of answers) {
      const selected = (answer.answerContent || '').toUpperCase().split('');
      for (const choice of selected) {
        answerCounts.set(choice, (answerCounts.get(choice) || 0) + 1);
      }
    }

    return options.choices.map(choice => ({
      key: choice.key,
      content: choice.content.slice(0, 50), // 截断内容
      isCorrect: choice.isCorrect,
      selectedCount: answerCounts.get(choice.key.toUpperCase()) || 0,
      selectedRate: (answerCounts.get(choice.key.toUpperCase()) || 0) / answers.length,
    }));
  }

  /**
   * 生成学生学情报告
   */
  async generateStudentReport(
    studentId: number, 
    courseId: number
  ): Promise<StudentReport> {
    const submissions = await this.submissionRepo.findByStudentAndCourse(
      studentId, 
      courseId
    );

    // 整体表现
    const overallPerformance = {
      totalAssignments: await this.assignmentRepo.countByCourse(courseId),
      completedAssignments: submissions.length,
      completionRate: submissions.length / await this.assignmentRepo.countByCourse(courseId),
      averageScore: this.average(submissions.map(s => s.totalScore)),
      lateSubmissions: submissions.filter(s => s.isLate).length,
    };

    // 趋势分析
    const trend = this.analyzeScoreTrend(submissions);

    // 知识点掌握情况
    const knowledgePoints = await this.analyzeKnowledgePoints(studentId, courseId);

    // 薄弱点识别
    const weakPoints = knowledgePoints
      .filter(kp => kp.masteryLevel < 0.6)
      .sort((a, b) => a.masteryLevel - b.masteryLevel);

    // 学习建议
    const recommendations = this.generateRecommendations(
      overallPerformance, 
      weakPoints
    );

    return {
      studentId,
      courseId,
      generatedAt: new Date(),
      overallPerformance,
      trend,
      knowledgePoints,
      weakPoints,
      recommendations,
    };
  }

  private generateRecommendations(
    performance: OverallPerformance, 
    weakPoints: KnowledgePointAnalysis[]
  ): Recommendation[] {
    const recommendations: Recommendation[] = [];

    // 完成率建议
    if (performance.completionRate < 0.8) {
      recommendations.push({
        type: 'completion',
        priority: 'high',
        title: '提高作业完成率',
        description: `当前作业完成率为 ${(performance.completionRate * 100).toFixed(0)}%,建议按时完成所有作业以巩固学习效果。`,
        actionItems: [
          '设置作业提醒',
          '制定每日学习计划',
          '遇到困难及时请教老师',
        ],
      });
    }

    // 迟交建议
    if (performance.lateSubmissions > 2) {
      recommendations.push({
        type: 'timeliness',
        priority: 'medium',
        title: '改善时间管理',
        description: `已有 ${performance.lateSubmissions} 次迟交记录,建议提前规划作业时间。`,
        actionItems: [
          '在截止日期前 2 天开始作业',
          '分解大作业为小任务',
          '使用日历应用跟踪截止日期',
        ],
      });
    }

    // 薄弱知识点建议
    for (const weakPoint of weakPoints.slice(0, 3)) {
      recommendations.push({
        type: 'knowledge',
        priority: 'medium',
        title: `加强「${weakPoint.name}」学习`,
        description: `该知识点掌握程度为 ${(weakPoint.masteryLevel * 100).toFixed(0)}%,建议重点复习。`,
        actionItems: [
          '回顾相关课程视频',
          '完成推荐练习题',
          '参考答案解析理解解题思路',
        ],
        relatedResources: weakPoint.relatedResources,
      });
    }

    return recommendations;
  }
}

高并发处理

作业截止前往往出现提交高峰,系统需要能够应对:

提交限流与队列

// rate-limiter.service.ts
@Injectable()
export class RateLimiterService {
  constructor(private readonly redis: Redis) {}

  /**
   * 令牌桶限流
   */
  async checkRateLimit(
    key: string, 
    limit: number, 
    windowSeconds: number
  ): Promise<boolean> {
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;

    // 使用 Redis 有序集合实现滑动窗口
    await this.redis.zremrangebyscore(key, 0, windowStart);
    
    const count = await this.redis.zcard(key);
    
    if (count < limit) {
      await this.redis.zadd(key, now, `${now}-${Math.random()}`);
      await this.redis.expire(key, windowSeconds);
      return true;
    }
    
    return false;
  }
}

// submission.controller.ts
@Controller('submissions')
export class SubmissionController {
  @Post()
  @UseInterceptors(ThrottlerInterceptor)
  async submit(
    @Body() dto: SubmitAssignmentDto,
    @CurrentUser() user: User,
    @Ip() ip: string,
  ) {
    // 用户级限流
    const userKey = `submit:user:${user.id}`;
    if (!await this.rateLimiter.checkRateLimit(userKey, 10, 60)) {
      throw new TooManyRequestsException('提交过于频繁,请稍后再试');
    }

    // 作业级限流
    const assignmentKey = `submit:assignment:${dto.assignmentId}`;
    if (!await this.rateLimiter.checkRateLimit(assignmentKey, 100, 10)) {
      // 高峰期,加入队列等待
      const ticket = await this.submissionQueue.add({
        userId: user.id,
        ...dto,
      });
      
      return {
        status: 'queued',
        ticket: ticket.id,
        estimatedWait: await this.estimateWaitTime(dto.assignmentId),
      };
    }

    return this.submissionService.submit(dto, user, ip);
  }
}

总结

构建一个完善的在线作业系统需要考虑以下关键点:

  1. 数据模型设计:灵活支持多种题型,为扩展预留空间
  2. 自动批改引擎:客观题自动批改,主观题 AI 辅助
  3. 防作弊机制:行为监测 + 相似度检测
  4. 学情分析:多维度统计,个性化学习建议
  5. 高并发处理:限流 + 队列保证系统稳定

作业系统是在线教育平台的核心模块,直接影响教学效果和用户体验。希望本文的设计思路和代码实现能为你的开发工作提供参考。

延伸阅读