Building a Production-Ready Online Exam System with Spring Boot 3: Paper Assembly, Anti-Cheating & Auto-Grading

This article details a production-grade Spring Boot 3 online exam system covering domain modeling, constraint-based paper assembly algorithms, anti-cheating measures with behavior logging and risk scoring, auto-grading strategies for objective and subjective questions, high-concurrency handling for exam start/submit, and score analysis with streaming exports.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Building a Production-Ready Online Exam System with Spring Boot 3: Paper Assembly, Anti-Cheating & Auto-Grading

1. Business Scenarios

The same question-bank core typically serves four scenarios: question-bank management, formal online exams, practice assessments, and certificate exams. They reuse the question table but differ greatly in requirements. Question-bank management focuses on asset accumulation with versioning and bulk import, no time windows or grading. Online exams enforce strict timing, strong anti-cheating, auto-grading for objective questions and manual grading for subjective ones, with score archiving. Practice assessments demand instant feedback — scores appear immediately after submission, and wrong questions feed back into the next practice round. Certificate exams are the strictest: fixed or drawn paper libraries require test equating, grading often uses double-marking with arbitration, and score release is controlled.

Unified modeling should not create four separate table sets. Instead, use "exam plan + paper type" to distinguish scenarios, converging differences into configuration and state machines. For example, practice assessments can grade synchronously in the submission endpoint; formal exams only persist answers and state, deferring grading to async; certificate exams must consider difficulty coefficients and equating strategies during paper assembly.

2. Domain Model

2.1 Entity Relationships

knowledge_point  知识点(树形,支持多级)
question 题目(题干、题型、难度、答案、解析)
question_option 题目选项
question_kp 题目-知识点关联(多对多,含权重)
paper 试卷(模板/实例)
paper_question 试卷题目(快照,含顺序号、分值)
exam_plan 考试计划(时间窗、时长、次数、组卷规则)
exam_attempt 作答记录(一次考试一次 attempt)
attempt_answer 作答明细(题目快照 + 考生答案 + 得分)
attempt_event 行为日志(切屏、抓拍、粘贴等)
exam_result 成绩(怼分、排名、发布状态)

2.2 Three Key Design Decisions

(1) Papers must snapshot questions. Question stems and answers in the bank are continuously revised, but historical papers must remain immutable. paper_question cannot store only question_id; it must redundantly store a JSON snapshot of the question content, options, answer, and score, ensuring "once a paper is generated it is frozen." A common pitfall: the snapshot contains the answer, so the paper-delivery API must strip that field — never send the full snapshot JSON to the frontend.

(2) Decouple answer records from papers. exam_attempt holds paper_id and exam_plan_id, separating "exam administration" from "answer sheet." Retakes within the plan's allowed rounds increment

attempt_no"; if the business treats a retake as an independent session, create a new <code>exam_plan

instead of hard-coding retake logic into the plan itself.

(3) Scores land on attempt_answer , exam_result only aggregates. Grading results, grader identity, and grading timestamps for both objective and subjective questions reside on the detail rows. The score summary table can be recalculated at any time, avoiding the inability to roll back scores when grading logic changes.

2.3 Core Table Definitions (Excerpts)

CREATE TABLE question (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  bank_id BIGINT NOT NULL COMMENT 'question bank/subject',
  type TINYINT NOT NULL COMMENT '1 single-choice 2 multi-choice 3 true-false 4 fill-blank 5 short-answer',
  difficulty DECIMAL(3,2) NOT NULL DEFAULT 0.50 COMMENT '0-1, larger = harder',
  score DECIMAL(5,1) NOT NULL DEFAULT 1.0 COMMENT 'default score',
  stem TEXT NOT NULL,
  answer JSON NOT NULL COMMENT 'standard answer snapshot',
  analysis TEXT COMMENT 'explanation',
  status TINYINT NOT NULL DEFAULT 1,
  version INT NOT NULL DEFAULT 0,
  KEY idx_pool (bank_id, type, difficulty, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE paper_question (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  paper_id BIGINT NOT NULL,
  question_id BIGINT NOT NULL,
  seq_no INT NOT NULL COMMENT 'paper order',
  score DECIMAL(5,1) NOT NULL,
  snapshot JSON NOT NULL COMMENT 'stem/options/answer frozen snapshot',
  UNIQUE KEY uk_paper_q (paper_id, question_id),
  UNIQUE KEY uk_paper_seq (paper_id, seq_no)
);

CREATE TABLE exam_attempt (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  plan_id BIGINT NOT NULL,
  paper_id BIGINT NOT NULL,
  user_id BIGINT NOT NULL,
  attempt_no INT NOT NULL DEFAULT 1 COMMENT 'answer round within same plan, retake increments',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0 not started 1 in progress 2 submitted 3 graded 4 absent',
  start_time DATETIME NULL,
  end_time DATETIME NULL COMMENT 'server-calculated deadline',
  submit_time DATETIME NULL,
  score DECIMAL(6,2) NULL,
  version INT NOT NULL DEFAULT 0,
  UNIQUE KEY uk_plan_user_no (plan_id, user_id, attempt_no),
  KEY idx_end_time (status, end_time)
);
uk_plan_user_no

enforces "one answer record per plan per user per round"; it must not block retakes. Idempotent exam start also requires an exam-start token and Redis lock, covered later.

3. Paper Assembly Strategies

Paper assembly is the most technically demanding part — essentially a constrained combinatorial sampling problem: given question-type ratios, knowledge-point distribution, difficulty distribution, and total-score constraints, draw a non-duplicate set of questions from the bank that satisfies all constraints.

3.1 Rule Model

Abstract the assembly strategy into a rule list, not hard-coded logic:

public record RuleItem(
  Long bankId,
  Long kpId,         // knowledge point, null = unlimited
  Integer type,      // question type, null = unlimited
  BigDecimal difficultyMin,
  BigDecimal difficultyMax,
  Integer count,     // quantity
  BigDecimal score   // score per question
){}

A paper is a List<RuleItem> plus total-score validation. Admins configure rules in the backend without code deployments.

3.2 Sampling Algorithms

ORDER BY RAND()

should be avoided — on million-row banks it triggers full-table scans and temporary-table sorts, a classic slow-SQL hotspot. Two practical approaches:

ID-range sampling. First SELECT MIN(id), MAX(id) to get the range, pick a random point, then locate with id >= ? LIMIT 1, repeat until quantity met; fall back to the other method on failure. Works when IDs are contiguous and deletions are few.

Bucketed pre-grouping with in-memory shuffle. Aggregate by (type, difficulty bucket, kp), cache each group's ID list in Redis, refresh asynchronously on bank changes. During assembly, shuffle the cached list and take the first N — microsecond latency. Banks are usually ~100k rows, so ID sets fit comfortably in memory; this is the preferred method, with ID-range sampling as fallback or for ultra-large banks.

3.3 Assembly Flow

@Service
@RequiredArgsConstructor
public class PaperAssembler {
  private final QuestionPoolCache poolCache; // Redis bucket cache
  private final PaperQuestionMapper mapper;

  public List<PaperQuestion> assemble(Long paperId, List<RuleItem> rules) {
    // Sort rules by available question count ascending — satisfy scarce rules first to reduce later conflicts
    List<RuleItem> sorted = rules.stream()
        .sorted(Comparator.comparingInt(poolCache::sizeOf))
        .toList();

    Set<Long> picked = new HashSet<>();
    List<PaperQuestion> result = new ArrayList<>();
    int seq = 1;

    for (RuleItem rule : sorted) {
      List<Long> pool = poolCache.candidates(rule);
      // Filter already-picked questions to avoid cross-rule duplicates
      List<Long> available = pool.stream().filter(id -> !picked.contains(id)).toList();
      if (available.size() < rule.count()) {
        throw new BizException("Rule " + rule.kpId() + " insufficient questions, need "
            + rule.count() + ", available " + available.size());
      }
      // Shuffle + slice for randomness
      List<Long> shuffled = new ArrayList<>(available);
      Collections.shuffle(shuffled, ThreadLocalRandom.current());
      for (int i = 0; i < rule.count(); i++) {
        Long qid = shuffled.get(i);
        picked.add(qid);
        result.add(new PaperQuestion(paperId, qid, seq++, rule.score()));
      }
    }
    return result;
  }
}

Engineering extras: allow constraint relaxation when rules are too tight (e.g., widen difficulty bucket by one level, expand knowledge point to parent node), retry up to 3 times, then alert admin to adjust rules. After drawing, compute the paper's average difficulty; if it deviates from the target band, perform local swaps (exchange low-difficulty for high-difficulty questions) to avoid overall skew. Finally, run a unified paper validator checking total score, question count, type ratios, knowledge-point coverage, and duplicate rate — reject publication if validation fails.

3.4 Dynamic Difficulty Calibration

Manually set difficulty is only an initial value. The system periodically recalculates from real data:

difficulty = 1 - (average_score_on_question / max_score_of_question)

If sample size is insufficient (e.g., fewer than 30 responses), retain the original value; otherwise smooth-update with 0.7 * old + 0.3 * recalculated to prevent abrupt swings that would disrupt existing assembly rules. Simultaneously output discrimination index — the difference between high-score-group and low-score-group correct rates; questions with discrimination below 0.2 are flagged for retirement.

3.5 Manual Assembly

Manual assembly is not just drag-and-drop. It must do three things: real-time duplicate detection (show count of already-selected questions per knowledge point), real-time constraint validation (dashboard displaying total score and ratios), and AI-assisted recommendation (suggest similar-difficulty, nearby-knowledge-point questions based on current selections, using vector search). Manual results also pass through the same validator to ensure structural consistency with auto-assembled papers.

4. Exam Process

4.1 Exam-Start Validation

The exam-start endpoint is one of the highest-concurrency entry points; validation order follows "cheap checks first, expensive later":

Plan status and time window — cached locally with scheduled refresh.

Registration eligibility and remaining attempts — resolved with a single joint query.

Whether already started — idempotency via unique key + exam-start token.

Device and session mutual exclusion — Redis records current active session.

Face verification — only for certificate exams, executed asynchronously without blocking paper delivery.

Step 4 is not a simple SETNX. The approach: Redis stores the active session at attempt:active:{userId}; a later start uses a Lua script to compare-and-swap, simultaneously sending a forced-offline event to the old session. A plain SETNX would block the newcomer but could not evict the previous session. The offline event is written to attempt_event for later risk scoring.

4.2 Question Shuffling

Shuffling prevents neighbor copying. For each attempt generate a displaySeq → questionId mapping, stored in Redis at attempt:order:{attemptId}. Frontend renders by displaySeq; answers are submitted by questionId. Option shuffling works similarly: store an optionOrder array in the question snapshot, frontend reorders per array, submission uses original option values. Remember: shuffling only affects presentation, not grading — grading always uses questionId.

4.3 Answer Caching

Answer writes are extremely frequent; cannot persist to DB per question. Use "Redis primary write + scheduled flush":

// key: exam:answer:{attemptId}, field: questionId, value: {"v":"A,B","ts":1730000000}
public void saveAnswer(Long attemptId, Long questionId, String answer) {
  String key = "exam:answer:" + attemptId;
  redis.opsForHash().put(key, questionId.toString(),
      JSON.toJSONString(new AnswerPayload(answer, System.currentTimeMillis())));
  redis.expire(key, Duration.ofHours(6));
  // Mark as dirty for scheduled bulk flush
  redis.opsForSet().add("exam:dirty:" + attemptId, questionId.toString());
}

Flush job scans active attempts every 10 seconds, batch-upserts to attempt_answer; on submission, force full flush and verify. If Redis is unavailable, degrade to direct DB writes, relying on API-layer rate limiting to protect the database.

4.4 Countdown Synchronization

Countdown must be server-authoritative. Paper delivery returns endTime (server timestamp) and serverTime; frontend computes offset = serverTime - localTime to correct local display; every 30 seconds a heartbeat recalibrates offset and returns server-side remaining seconds. Heartbeat can use WebSocket or polling — never trust the frontend with countdown. Submission uses the server's endTime to judge timeout; frontend clock tampering is ineffective.

4.5 Auto-Submission

Unsubmitted exams at deadline must be server-enforced. Two common implementations:

Redis ZSet scan. exam:deadline uses endTime as score; a scheduled job runs every second: ZRANGEBYSCORE 0 now LIMIT 0 500 to fetch due attempts and push to MQ. Must ZREM or use atomic Lua pop to avoid duplicate dispatch; consumer remains idempotent by attemptId. Drawback: scan frequency scales with data volume, requiring sharding.

RocketMQ delayed messages. At exam start, send a delayed message with delay equal to exam duration. Note version differences: 4.x only supports fixed delay levels, and broker config changes are not a general solution; 5.x's scheduled messages support arbitrary delays. Don't treat 4.x limits as universal. In practice I prefer ZSet with sharded scanning — simple, controllable, and extending exam time only requires updating the score.

5. Anti-Cheating Design

Core philosophy: frontend measures only raise cheating cost; server-side validation and behavior logging are the safety net.

Common vectors handled in three layers: screen-switching for answer search — tracked via visibilitychange and blur events, threshold triggers alert or forced submit; impersonation — pre-exam face verification plus random in-exam snapshots, images stored in object storage for manual spot-check; multi-device answering — device fingerprint + session mutual exclusion, later login kicks earlier and logs event; neighbor copying — question shuffling, option shuffling, and content watermarks reduce success rate at source; copy-paste monitoring — listen for copy, paste, right-click, log events; scripted answering — analyze answer intervals and trajectory anomalies, feed into risk model, low risk observe, high risk manual review; post-submission tampering — irreversible state machine and audit logs, attempt state only advances via CAS.

5.1 Device Fingerprint & Server Validation

Frontend collects Canvas/WebGL fingerprint, UA, screen size, timezone, SHA-256 to produce device ID, bound to user at exam start. Same device ID logging into multiple accounts for the same exam within a short window is flagged high-risk. Device fingerprint is not a silver bullet — browser environments change — but serves as a useful auxiliary signal.

5.2 Camera Snapshot Engineering Details

Snapshots must not block answering. Every 60 seconds frontend uses getUserMedia + Canvas.drawImage to capture a frame, compress to JPEG (quality 0.6, width 320px), request a direct-upload credential from the server, upload to MinIO/OSS, then call an API to record metadata:

@PostMapping("/snapshot")
public R<Void> snapshot(@RequestParam Long attemptId,
    @RequestParam String objectKey) {
  // File goes direct to object storage; here we only record metadata to avoid large files hitting the app
  eventProducer.send(new AttemptEvent(attemptId, EventType.SNAPSHOT,
      Map.of("objectKey", objectKey, "ts", System.currentTimeMillis())));
  return R.ok();
}

Snapshot images must be stored de-identified with a retention period (e.g., auto-purge after 90 days) and users must be informed in the pre-exam agreement — this is the compliance baseline.

5.3 Behavior Logs & Risk Scoring

All behavior events — screen switches, snapshots, pastes, missed heartbeats, high-frequency answer changes — are unified into MQ and batch-written to attempt_event. A pre-grading risk model computes:

riskScore = screenSwitches * 5 + snapshotAnomalies * 20 + pasteCount * 8 + answerIntervalAnomalies * 15

Attempts with riskScore > 60 are auto-tagged "pending review" and routed to a manual audit queue. This avoids false positives on normal candidates while concentrating limited proctor resources on the most suspicious papers. Anomaly events are also pushed via WebSocket to a proctor dashboard for real-time visibility.

6. Auto-Grading

6.1 Extensible Grading Strategy Design

Use the Strategy pattern to dispatch by question type; adding a new type only requires implementing the interface and registering it:

public interface GradingStrategy {
  int type();
  GradeResult grade(QuestionSnapshot q, String answer);
}

@Component
public class MultiChoiceStrategy implements GradingStrategy {
  public int type() { return 2; }
  public GradeResult grade(QuestionSnapshot q, String answer) {
    Set<String> correct = q.answerSet();
    Set<String> actual = splitAndNormalize(answer);
    if (actual.isEmpty()) return GradeResult.zero("unanswered");
    // Wrong selection = zero
    if (!correct.containsAll(actual)) return GradeResult.zero("contains wrong option");
    if (actual.equals(correct)) return GradeResult.full(q.score());
    // Partial credit for missing options (configurable)
    return GradeResult.partial(q.score().multiply(new BigDecimal("0.5")), "missing options");
  }
}

Grading must use the snapshot answer from paper_question, never re-query the current bank answer — otherwise a bank edit corrupts all historical papers.

6.2 Per-Type Rule Highlights

Single-choice and true-false use exact match after trim and case normalization. Multi-choice: full credit for all correct, partial for missing options, zero for any wrong option — rule values are configurable. Fill-in-the-blank: each blank scored independently; each blank's answer normalized (trim, full/half-width conversion, punctuation removal, lowercase), support multiple standard answers per blank (e.g., ["Java","JAVA","java"] treated as equivalent). Numeric questions use tolerance comparison (e.g., ±0.01). Short-answer and essay do not produce a final score directly; they output a suggested score with rationale for human confirmation.

6.3 Subjective Question Assisted Grading

Pure manual grading is costly; adopt a two-step "machine-assisted + human confirmation" approach. First, keyword hitting: pre-define scoring points (e.g., "time complexity O(nlogn)" = 2 points), hit adds to suggested score. Second, similarity reference: compute Jaccard similarity on tokenized reference answer or cosine similarity on embeddings, provide a score range. Finally, human grader sees suggested score, highlighted scoring points, and reference answer — one-click confirm or adjust.

Certificate exams should enable double-marking: two graders score independently; if score difference exceeds a threshold (e.g., 10% of max), a third arbitrator decides, and the arbitration result stands.

6.4 Grading Flow & Release

Submit → status SUBMITTED → objective instant grading (ms)
    → subjective into pending queue (MQ)
    → human grading complete
    → status GRADED → score validation (total = sum of details)
    → score release (controlled by plan config, batch delayed release supported)

Before release, run a consistency check: exam_result.score == SUM(attempt_answer.score); mismatch triggers alert and blocks release. Grading is irreversible; any recalculation is implemented by adding a new "grading version" while preserving history.

7. High Concurrency & Reliability

7.1 Exam-Start Peak

Thousands starting simultaneously concentrate load on the paper-delivery endpoint. Ten minutes before the exam, bulk-load the paper's questions into Redis at paper:snapshot:{paperId}; delivery reads only Redis, no DB hit — answer fields stay server-side, response includes only display fields. Gateway applies per-user token-bucket rate limiting to thwart scripted rushes. If business allows, give a ±5-minute flexible entry window to flatten the instantaneous spike. Also avoid returning full question stems; return only question IDs and metadata, serve stems via internal CDN/OSS signed URLs or batched fetches to avoid saturating both bandwidth and database.

7.2 Submission Queue

Submission must not perform grading or aggregation. The endpoint does three things only: persist answers, transition state, dispatch message — return within 200ms.

@Transactional
public void submit(Long attemptId, Long userId) {
  // CAS state transition, naturally idempotent: only IN_PROGRESS → SUBMITTED
  int updated = attemptMapper.casUpdateStatus(
      attemptId, userId, AttemptStatus.IN_PROGRESS, AttemptStatus.SUBMITTED);
  if (updated == 0) return; // already submitted, return success
  answerService.flushToDb(attemptId);

  // Don't send inside transaction; message may arrive before commit; send after commit
  TransactionSynchronizationManager.registerSynchronization(
      new TransactionSynchronization() {
        @Override
        public void afterCommit() {
          mqProducer.send("exam-grade-topic",
              new GradeMessage(attemptId, System.currentTimeMillis()));
        }
      });
}

For higher message reliability, add a local message table or RocketMQ transaction messages. Grading consumer processes idempotently by attemptId, skipping duplicates.

7.3 Idempotent Submission

At least three safety layers. First: state-machine CAS UPDATE ... WHERE status = 1; zero affected rows means already submitted. Second: unique key uk_plan_user_no prevents duplicate start within the same round; retakes increment attempt_no. Third: idempotency table — frontend submits with a submitToken issued at exam start; server validates via Redis SETNX to prevent replay.

7.4 Offline Recovery

Candidate disconnects are the most common production incident. Frontend writes answers to IndexedDB, maintaining an "unconfirmed queue"; on reconnect, resend unsynced answers by questionId. Do not trust client timestamps; server uses attempt_answer version or last persisted record as authority. Client only pushes locally unsynced answers. Redis answers written to Hash in real-time, TTL = exam duration + 2 hours; MySQL batch flush every 10 seconds, worst-case loss = 10 seconds. Recovery flow: re-enter exam, verify attempt status still IN_PROGRESS, fetch server-side answers and remaining time, frontend merges local unsynced answers, continue answering.

8. Score Analysis & Wrap-Up

8.1 Statistical Reports

Score data volume is large; real-time aggregation won't hold. Use offline pre-computation + online query. After exam ends, MQ triggers aggregation jobs producing: score-band distribution, mean, median, standard deviation, per-question score rate, knowledge-point mastery, discrimination ranking. Results written to exam_stat wide table; if volume grows further, move to ClickHouse. Frontend queries read directly from result tables. Rankings maintained in Redis ZSet for real-time view and segmented refresh.

8.2 Wrong-Question Analysis

For each candidate maintain a wrong-question notebook: from attempt_answer where score < full_score, aggregate by knowledge point, output "weak knowledge points Top 5". In practice-assessment scenarios, the wrong-question notebook can directly feed the next round's paper assembly, closing the learning loop.

8.3 Export Capability

Exporting 10k+ score records must use streaming; never selectList to load all at once:

public void export(Long planId, OutputStream out) {
  try (ExcelWriter writer = EasyExcel.write(out, ScoreRow.class).build()) {
    WriteSheet sheet = EasyExcel.writerSheet("成绩").build();
    int pageNo = 1;
    while (true) {
      List<ScoreRow> rows = resultMapper.page(planId, (pageNo - 1) * 2000, 2000);
      if (rows.isEmpty()) break;
      writer.write(rows, sheet);
      pageNo++;
    }
  }
}

Large-file exports become async tasks: submit, generate, upload to OSS, return time-limited signed download URL, avoiding long-connection timeouts.

8.4 Data Security

Exports and list APIs mask phone numbers and ID cards per rules; only authorized roles see plaintext. Sensitive fields encrypted with AES-GCM at rest, keys managed via KMS. Data permissions isolated by school, class, session; graders see only their assigned question blocks. All score queries, modifications, exports write audit logs — who changed what and when must be traceable to a person. Scores invisible to candidates before release; release operation requires secondary confirmation and is logged.

8.5 Closing Remarks

If this goes live, the most valuable parts aren't any single algorithm but a few bottom lines: snapshot papers and answers so bank revisions don't pollute history; drive attempts with a state-machine CAS — idempotency, deduplication, and offline recovery all converge in one place; keep the submission endpoint lightweight — grading, stats, export all async; never hard-code assembly rules, grading rules, or risk thresholds. Frontend anti-cheating helps, but don't treat it as the security boundary — server-side logging and validation are the real guard.

Future evolution: assembly can adopt constraint solvers for equated papers; subjective grading can leverage LLMs for suggested scores but human final sign-off must remain; behavior logs shouldn't stay post-hoc — feed them into real-time risk control for in-exam alerting.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

RedisHigh ConcurrencyMySQLRocketMQDomain ModelingSpring Boot 3Anti-CheatingAuto-GradingOnline Exam SystemPaper Assembly
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.