Realitas Rekayasa Sistem Ujian Online Berskala Besar
Membangun platform Computer Based Testing (CBT) menghadirkan tantangan arsitektur unik yang jarang ditemui pada aplikasi CRUD bisnis biasa:
- Manipulasi Jam Klien & Latensi Jaringan: Jika sisa waktu ujian bergantung pada jam browser pengguna, siswa nakal dapat dengan mudah menghentikan eksekusi JavaScript atau memundurkan jam sistem operasi untuk memperoleh perpanjangan waktu ilegal.
- Risiko Kehilangan Data Saat Koneksi Terputus: Jika koneksi WiFi siswa terputus setelah 45 menit mengerjakan 50 soal, mengirim seluruh jawaban sekaligus di akhir ujian adalah resep bencana kehilangan data massal.
- Telemetri Pengawasan Berskala Ribuan Pengguna: Guru dan panitia pengawas ujian membutuhkan visibilitas instan atas aktivitas siswa, sisa durasi, dan indikasi kecurangan tanpa membebani database utama melalui polling HTTP agresif setiap 2 detik.
Pada proyek mfarim/fastapi-elearning-react, kami merancang arsitektur engine ujian asinkron yang menjamin nol kehilangan data (zero data-loss), perhitungan waktu otoritatif server, dan telemetri native WebSocket.
1. Timer Otoritatif Server & State Machine Ujian
Prinsip fundamental dari arsitektur ujian aman adalah: klien browser tidak boleh pernah menentukan sisa waktu ujian.
Pada berkas backend/app/api/v1/exam_runner.py, ketika siswa memulai ujian melalui /api/v1/exams/{id}/start, backend mencatat timestamp UTC mutlak started_at di dalam tabel database:
PYTHON
# backend/app/api/v1/exam_runner.py
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.exam import Examination, ExamAttempt
from app.schemas.exam import ExamStartResponse
from app.api.deps import require_roles
@router.post("/{id}/start", response_model=ApiResponse[ExamStartResponse])
async def start_exam(
id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
student_id = current_user.student_profile.id
now = datetime.utcnow()
# 1. Ambil data ujian & pastikan sudah terbit
stmt = (
select(Examination)
.options(selectinload(Examination.questions))
.where(Examination.id == id, Examination.deleted_at.is_(None))
)
exam = (await db.execute(stmt)).scalars().first()
if not exam or exam.status == "draft":
raise HTTPException(status_code=400, detail="Ujian belum dapat diakses")
total_duration_sec = exam.duration_minutes * 60
# 2. Cek apakah siswa sudah memiliki sesi attempt yang sedang berjalan
att_stmt = (
select(ExamAttempt)
.options(selectinload(ExamAttempt.answers))
.where(ExamAttempt.examination_id == id, ExamAttempt.student_id == student_id)
.order_by(ExamAttempt.id.desc())
)
attempt = (await db.execute(att_stmt)).scalars().first()
if attempt and attempt.status in ["in_progress"]:
# Resume sesi: Hitung selisih waktu mutlak dari server
elapsed = (now - attempt.started_at).total_seconds()
remaining = max(0, int(total_duration_sec - elapsed))
# Eksekusi hard cut-off batas waktu
if remaining <= 0:
attempt.status = "submitted"
attempt.finished_at = now
await db.commit()
raise HTTPException(status_code=400, detail="Batas waktu ujian telah habis")
else:
# Buat sesi attempt baru
attempt = ExamAttempt(
examination_id=id,
student_id=student_id,
status="in_progress",
started_at=now,
)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
remaining = total_duration_sec
return ApiResponse.ok(
data=build_exam_start_dto(attempt, exam, remaining),
message="Sesi ujian berhasil dimulai"
)
Keunggulan Arsitektur Ini:
- Kebal Masalah Jaringan: Jika baterai laptop siswa habis atau WiFi mati selama 5 menit, saat halaman dibuka kembali, backend menghitung: $$\text{Sisa Waktu} = (\text{Durasi} \times 60) - (\text{Jam Server Sekarang} - \text{Jam Mulai Ujian})$$ Siswa tidak akan bisa memanipulasi waktu tambahan sedikit pun.
- Deadline Keras Tanpa Kompromi: Saat
remaining <= 0, kiriman jawaban berikutnya akan langsung ditolak oleh backend dan status attempt diubah otomatis menjadi"submitted".
2. Autosave Instan dengan Sinkronisasi UI Optimistik
Mengharuskan siswa menekan tombol "Submit" untuk seluruh soal di akhir ujian adalah pola desain buruk. Jika koneksi bermasalah pada detik terakhir, kerja keras siswa akan lenyap.
Di frontend React (frontend/src/pages/exams/ExamRunner.tsx), setiap kali siswa mengklik salah satu opsi jawaban, warna nomor soal pada palet navigasi langsung berubah hijau secara optimistik di UI lokal, sementara background request dikirimkan ke server secara debounced:
PYTHON
# backend/app/api/v1/exam_runner.py
@router.post("/attempts/{attempt_id}/answers", response_model=ApiResponse[Dict[str, Any]])
async def submit_answer(
attempt_id: int,
payload: SubmitAnswerRequest,
db: AsyncSession = Depends(get_db),
current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
attempt = await get_valid_in_progress_attempt(db, attempt_id, current_user.student_profile.id)
# 1. Operasi Upsert: Perbarui jawaban jika sudah ada, atau buat baru
stmt = select(ExamAnswer).where(
ExamAnswer.exam_attempt_id == attempt_id,
ExamAnswer.question_id == payload.question_id
)
answer = (await db.execute(stmt)).scalars().first()
if not answer:
answer = ExamAnswer(
exam_attempt_id=attempt_id,
question_id=payload.question_id,
student_answer=payload.selected_option,
)
db.add(answer)
else:
answer.student_answer = payload.selected_option
answer.updated_at = datetime.utcnow()
await db.commit()
# 2. Siarkan progres terkini ke dashboard guru pengawas via WebSocket
await ws_manager.broadcast_to_exam(
exam_id=attempt.examination_id,
event_type="ANSWER_SUBMITTED",
data={
"studentId": current_user.student_profile.id,
"attemptId": attempt_id,
"questionId": payload.question_id,
"totalAnswered": await count_answered_questions(db, attempt_id)
}
)
return ApiResponse.ok(message="Jawaban berhasil tersimpan")
3. Deteksi Kecurangan & Ambang Batas Pelanggaran (Anti-Cheat)
Untuk menjamin integritas ujian, aplikasi mengintegrasikan deteksi perilaku di frontend yang dipadukan dengan kebijakan ambang batas ketat di sisi server.
Event Hook di Frontend (ExamRunner.tsx)
TYPESCRIPT
// Deteksi perpindahan tab browser atau keluar dari mode fullscreen
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
reportViolation("TAB_SWITCH_DETECTED");
}
};
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
reportViolation("FULLSCREEN_EXITED");
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
Eksekusi Diskualifikasi Server (Threshold 5 Pelanggaran)
Ketika siswa berpindah tab atau keluar dari jendela ujian, counter pelanggaran akan bertambah. Saat pelanggaran mencapai 5 kali, server secara sepihak mengunci dan mengakhiri sesi ujian:
PYTHON
# backend/app/api/v1/exam_runner.py
@router.post("/attempts/{attempt_id}/violation", response_model=ApiResponse[Dict[str, Any]])
async def report_violation(
attempt_id: int,
payload: ViolationReportRequest,
db: AsyncSession = Depends(get_db),
current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
attempt = await get_valid_in_progress_attempt(db, attempt_id, current_user.student_profile.id)
attempt.violations += 1
force_submitted = False
# Ambang batas tegas: Otomatis submit saat pelanggaran ke-5
if attempt.violations >= 5:
attempt.status = "force_finished"
attempt.finished_at = datetime.utcnow()
force_submitted = True
await db.commit()
# Kirim peringatan seketika ke layar pengawas guru
await ws_manager.broadcast_to_exam(
exam_id=attempt.examination_id,
event_type="VIOLATION_LOGGED",
data={
"studentId": current_user.student_profile.id,
"studentName": current_user.name,
"violationsCount": attempt.violations,
"violationType": payload.violation_type,
"forceSubmitted": force_submitted,
}
)
return ApiResponse.ok(
data={"violations": attempt.violations, "forceSubmitted": force_submitted},
message="Pelanggaran tercatat"
)
4. Arsitektur Native ASGI WebSocket dengan ConnectionManager
Berbeda dengan framework sinkron yang membutuhkan gateway tambahan seperti Socket.IO atau Centrifugo, FastAPI mendukung WebSocket secara native langsung di atas event loop ASGI.
Di backend/app/services/websocket_manager.py, kami membangun manajer koneksi berbasis kamar (room-based):
PYTHON
# backend/app/services/websocket_manager.py
import json
import logging
from typing import Dict, Set
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# Memetakan examination_id -> Kumpulan WebSocket pengawas yang aktif
self.active_rooms: Dict[int, Set[WebSocket]] = {}
async def connect(self, exam_id: int, websocket: WebSocket):
await websocket.accept()
if exam_id not in self.active_rooms:
self.active_rooms[exam_id] = set()
self.active_rooms[exam_id].add(websocket)
logger.info(f"Pengawas terhubung ke ujian {exam_id}. Total pengawas: {len(self.active_rooms[exam_id])}")
def disconnect(self, exam_id: int, websocket: WebSocket):
if exam_id in self.active_rooms:
self.active_rooms[exam_id].discard(websocket)
if not self.active_rooms[exam_id]:
del self.active_rooms[exam_id]
async def broadcast_to_exam(self, exam_id: int, event_type: str, data: dict):
"""Menyiarkan event telemetri ke seluruh pengawas yang tergabung di ruangan"""
if exam_id not in self.active_rooms:
return
payload = {"eventType": event_type, "data": data}
message_str = json.dumps(payload, default=str)
dead_connections = set()
for connection in self.active_rooms[exam_id]:
try:
await connection.send_text(message_str)
except Exception as e:
logger.warning(f"Gagal mengirim pesan telemetri: {e}")
dead_connections.add(connection)
# Bersihkan socket yang koneksinya sudah putus
for dead in dead_connections:
self.disconnect(exam_id, dead)
ws_manager = ConnectionManager()
Skalabilitas Horizontal dengan Redis Pub/Sub
Pada deployment satu server, ConnectionManager berjalan efisien di dalam memori dengan latensi sub-milidetik.
Saat aplikasi diskalakan secara horizontal menjadi beberapa kontainer Docker atau worker Uvicorn di balik load balancer Nginx, antar-node disinkronkan melalui saluran Redis Pub/Sub:
PYTHON
# Publikasi event ke saluran Redis:
async def publish_telemetry(exam_id: int, event_type: str, data: dict):
channel = f"exam:{exam_id}:telemetry"
await redis_client.publish(channel, json.dumps({"eventType": event_type, "data": data}))
# Worker latar belakang yang mendengarkan di setiap node FastAPI:
async def redis_listener(exam_id: int):
pubsub = redis_client.pubsub()
await pubsub.subscribe(f"exam:{exam_id}:telemetry")
async for msg in pubsub.listen():
if msg["type"] == "message":
# Teruskan ke WebSocket klien lokal di node server ini
await ws_manager.broadcast_local(exam_id, msg["data"])
5. Integrasi Layar Pengawas Guru (Exam Monitor)
Di layar pengawas guru (frontend/src/pages/exams/ExamMonitor.tsx), pesan WebSocket yang masuk diterjemahkan menjadi pembaruan visual instan:
TYPESCRIPT
// frontend/src/pages/exams/ExamMonitor.tsx
useEffect(() => {
const ws = new WebSocket(`wss://${window.location.host}/ws/exams/${examId}/monitor`);
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
switch (payload.eventType) {
case "VIOLATION_LOGGED":
// Tampilkan badge merah dan peringatan audio/visual
updateCandidateViolation(payload.data.studentId, payload.data.violationsCount);
break;
case "ANSWER_SUBMITTED":
// Naikkan persentase progress bar penyelesaian siswa
updateCandidateProgress(payload.data.studentId, payload.data.totalAnswered);
break;
case "EXAM_FINISHED":
markCandidateCompleted(payload.data.studentId);
break;
}
};
return () => ws.close();
}, [examId]);
Arsitektur ini mengubah proses pengawasan ujian dari pengecekan manual berkala menjadi pusat kendali telemetri real-time yang sanggup memantau ratusan peserta ujian secara simultan tanpa membebani database utama.
Ringkasan Rekomendasi Arsitektural
- Jangan percayakan jam ujian pada perangkat siswa: Selalu gunakan selisih waktu terverifikasi dari timestamp UTC server pada setiap interaksi.
- Pola autosave ter-debounce jauh lebih unggul dibanding submit massal: Mengamankan pekerjaan siswa dari pemadaman listrik/WiFi serta meratakan beban penulisan database.
- Terapkan ambang batas kecurangan otomatis: Kombinasi pendeteksi visibilitychange di browser dan pembatasan 5 kali pelanggaran di server menghadirkan sistem ujian yang adil dan transparan.
- Optimalkan native ASGI WebSocket dengan manajemen koneksi mati: FastAPI mampu mengelola ribuan koneksi WebSocket persisten hanya dengan konsumsi memori ~15MB–25MB per 1.000 koneksi idle.
Pada Part 4, kita akan mengulas arsitektur keamanan tingkat lanjut: Stateless JWT, Role-Based Access Control (RBAC) via Dependency Injection, dan Fitur 1-Click Administrative Impersonation dengan Zero-Reauth Session Recovery.
