在线作业系统完整实现方案:从需求分析到生产部署
在线教育的快速发展对作业系统提出了更高的要求。一个优秀的作业系统不仅要支持多种题型和格式,还需要具备自动批改、防作弊、学情分析等高级功能。本文将从实际需求出发,详细讲解如何构建一个功能完善、性能优异的在线作业系统。
为什么作业系统如此重要
在传统教育中,作业是检验学习成果的重要手段。在线教育环境下,作业系统承担着更多职责:
教学层面
- 即时反馈:学生提交后立即得到批改结果,加速学习闭环
- 个性化学习:根据作业表现推荐针对性的学习内容
- 减轻教师负担:自动批改客观题,教师专注于主观题和个性化指导
技术层面
- 数据收集:积累学习行为数据,为算法优化提供素材
- 用户粘性:作业是高频互动场景,直接影响用户活跃度
- 商业价值:作业批改、辅导答疑是重要的付费功能
核心挑战
- 多样性:题型多样(选择、填空、编程、论述等),提交格式多样(文本、图片、文件、代码)
- 规模性:高峰期可能有数万学生同时提交
- 公平性:防止抄袭和作弊,保证评价公正
- 实时性:批改结果需要快速返回
系统架构设计
整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 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);
}
}
总结
构建一个完善的在线作业系统需要考虑以下关键点:
- 数据模型设计:灵活支持多种题型,为扩展预留空间
- 自动批改引擎:客观题自动批改,主观题 AI 辅助
- 防作弊机制:行为监测 + 相似度检测
- 学情分析:多维度统计,个性化学习建议
- 高并发处理:限流 + 队列保证系统稳定
作业系统是在线教育平台的核心模块,直接影响教学效果和用户体验。希望本文的设计思路和代码实现能为你的开发工作提供参考。


