Skip to main content

Exam Service

This page covers lumie-backend/modules/exam. In the current monolith, this is the only backend domain module that owns both synchronous request handling and RabbitMQ-backed worker orchestration.

Responsibility

The exam module owns:

  • exam definitions and answer keys;
  • tenant-managed question type options for new exams;
  • reusable exam templates;
  • exam results and per-question grading data;
  • synchronous single-image OMR grading;
  • batch OMR grading jobs, callbacks, and result persistence;
  • batch report-generation jobs, ZIP assembly, and report download;
  • exam, student, and class-level analytics.

Source Paths

PathRole
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/webPublic HTTP controllers for exams, templates, results, statistics, and reports
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messagingRabbitMQ callback consumers for grading and report completion
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/internal/ExamServiceAdapter.javaInternal synchronous API exported through libs/internal-api
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.javaSpring Modulith outbox bridge to RabbitMQ
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/externalHTTP clients for grading-svc and report-svc
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/storage/OmrMinioStorageAdapter.javaMinIO storage for OMR images and generated artifacts
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/persistence/RedisCallbackDeduplicationAdapter.javaRedis-backed callback deduplication
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/serviceCommand, query, grading, statistics, callback, and sweeper services
lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entityExam, ExamQuestionType, ExamTemplate, ExamResult, QuestionResult, OmrGradingJob, ReportGenerationJob
lumie-backend/libs/internal-api/src/main/java/com/lumie/exam/api/ExamService.javaInternal monolith contract
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlBaseline tables such as exams, exam_results, exam_templates, and job tables
lumie-backend/app/src/main/resources/db/migration/public/V24__add_version_to_domain_mutable_entities.sqlAdds optimistic-lock version to exam_templates
lumie-backend/app/src/main/resources/db/migration/public/V44__create_exam_ai_analyses.sqlRelated exam-side AI cache table outside this module's primary write surface
lumie-backend/app/src/main/resources/db/migration/public/V70__create_exam_question_types.sqlAdds tenant-scoped configurable question type options

Public Surface

SurfaceEntrypoints
Exam CRUDGET/POST /v1/exams, GET /v1/exams/{id}, GET /v1/exams/{id}/full, PATCH /v1/exams/{id}, DELETE /v1/exams/{id}
Question type settingsGET /v1/exam-question-types; staff-only POST /v1/exam-question-types, DELETE /v1/exam-question-types/{id}
Template CRUDGET/POST /v1/exam-templates, GET/PATCH/DELETE /v1/exam-templates/{id}
Synchronous OMRPOST /v1/exams/{examId}/results/omr
Batch OMRPOST /v1/exams/{examId}/results/omr/batch/presign, POST /v1/exams/{examId}/results/omr/batch/confirm, GET /v1/exams/{examId}/omr-jobs/{jobId}, GET /v1/exams/{examId}/omr-jobs/{jobId}/results, GET /v1/exams/{examId}/omr-jobs
Results and correctionsGET /v1/exams/{examId}/results, GET /v1/students/{studentId}/results, GET /v1/results/{resultId}/questions, PATCH /v1/question-results/{id}, DELETE /v1/exams/{examId}/results/{resultId}
OMR artifactsGET /v1/exams/{examId}/results/{resultId}/omr-image, GET /v1/exams/results/export.csv
ReportsGET /v1/reports/students/{studentId}/exams/{examId}, POST /v1/exams/{examId}/reports/batch, GET /v1/exams/{examId}/reports/jobs/{jobId}, GET /v1/exams/{examId}/reports/jobs/{jobId}/download
StatisticsGET /v1/statistics/exams/{examId}, .../grades, .../results-summary, .../choices, .../class-comparison, .../item-analysis, GET /v1/statistics/students/{studentId}/rank, .../stability, .../type-growth, .../normalized, POST /v1/statistics/students/{studentId}/goal-simulation, GET /v1/statistics/dashboard

Internal Surface

TypePathNotes
Synchronous internal APIlibs/internal-api/.../ExamService.javaExposes only linkUnregisteredResultsByPhone(...)
Internal HTTP read surface/internal/omr/exams/{id}/fullWorker-facing read path for full exam metadata
Internal HTTP read surface/internal/reports/...Worker-facing report-statistics and result lookup
Spring Modulith listenerStudentRegisteredListenerBackfills unregistered results after student registration commit
RabbitMQ callback consumersgrading.omr-callback, report.generation-callbackRestore tenant context before processing

The internal API is intentionally narrow. Other modules do not create or mutate exams through an in-process write contract.

Aggregates And Tables

AggregateTableNotes
ExamexamsStores answer keys, scoring maps, question types, grading mode, and pass score
ExamQuestionTypeexam_question_typesTenant-managed source list for future question type selections; existing exams keep their question_types JSONB snapshots
ExamTemplateexam_templatesTemplate-only scoring and question metadata
ExamResultexam_resultsSupports both registered students and phone-only unregistered rows
QuestionResultquestion_resultsPer-question grading state for an exam result
OmrGradingJobomr_grading_jobsTracks batch image processing counts and input object keys
ReportGenerationJobreport_generation_jobsTracks per-student report generation and the final ZIP key

Two result identities are supported in the same aggregate:

  • registered: student_id is present and the authoritative phone is copied from the student record;
  • unregistered: student_id is null and phone_number becomes the reconciliation key until StudentRegisteredEvent backfills it later.

Runtime Flows

Batch OMR grading

What the outbox loop proves

Source anchor: lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ResultCommandService.java ResultCommandService.confirmBatchOmrGrading(...).

for (int i = 0; i < imageKeys.size(); i++) {
eventPublisher.publishEvent(new OmrGradingRequestedEvent(
savedJob.getId(), examId, tenantSlug,
imageKeys.get(i), i, imageKeys.size()));
}

The job row and per-image publish events are written in the same transaction, then JobRequestForwarder drains them to RabbitMQ after commit.

Key Contracts

OMR storage contract

  • Presigned batch uploads are stored under tmp/<storageTenantId>/omr/<batchKey>/<sanitizedFileName>.
  • Batch confirm rejects object keys that do not start with the expected tenant prefix or that contain ...
  • Result deletion best-effort deletes the stored OMR image from MinIO before removing the row.

Callback contract

  • Both callback consumers require tenantSlug in the message payload.
  • Tenant slug is resolved back to tenantId before any repository access.
  • OMR callbacks deduplicate on omr:cb:{jobId}:{imageIndex} and report callbacks on report:cb:{jobId}:{reportIndex} using Redis with a 24-hour TTL.

Single-image versus batch grading

  • processOmrGrading(...) is still synchronous and calls OmrServicePort.gradeOmrImage(...) over HTTP.
  • Batch grading is queue-backed and persists OmrGradingJob rows first.

Question type settings

Source anchors: ExamQuestionTypeController.java, ExamQuestionTypeService.java, TenantCreatedExamQuestionTypeListener.java, Exam.java, ExamTemplate.java, and V70__create_exam_question_types.sql.

  • ExamQuestionTypeController exposes GET /v1/exam-question-types, plus staff-only POST /v1/exam-question-types and DELETE /v1/exam-question-types/{id}.
  • ExamQuestionTypeService stores only the tenant's option list in exam_question_types.
  • V70 backfills default options for existing tenants, and TenantCreatedExamQuestionTypeListener seeds the same defaults for future tenants.
  • Exam and ExamTemplate continue to store per-question type names in question_types JSONB. There is no foreign key from those snapshots to exam_question_types, so deleting an option only removes it from future selections.

Learning report result summary

Source anchors: StatisticsController.java, StudentGradeQueryService.java, StudentResultSummaryResponse.java, ClassService.java, and ClassEnrollmentRepository.java.

  • GET /v1/statistics/exams/{examId}/results-summary returns one StudentResultSummaryResponse per student result shown in the learning report dashboard.
  • Each summary includes the score fields used by report generation plus classes, a list of { classId, className } entries for the student's active class enrollments.
  • Deleted or unregistered students keep studentId empty and return an empty classes list, so report generation can still show historical result rows without implying a current class membership.
  • Class enrollments are loaded in batch through the class module and joined with classEntity to avoid per-student class lookups.

Example Contracts

These examples come directly from ResultController, ReportController, JobAcceptedResponse, OmrBatchConfirmRequest, OmrGradingCallbackRequest, OmrJobStatusResponse, ReportBatchRequest, and ReportCallbackRequest.

Batch OMR Confirm

POST /v1/exams/15/results/omr/batch/confirm
Idempotency-Key: omr-batch-15
Content-Type: application/json

{
"batchKey": "20260614-01",
"objectKeys": [
"tmp/15/omr/20260614-01/page-1.png",
"tmp/15/omr/20260614-01/page-2.png"
]
}
HTTP/1.1 202 Accepted
Location: /v1/exams/15/omr-jobs/341

{
"jobId": 341,
"statusUrl": "/v1/exams/15/omr-jobs/341"
}

OMR Callback Payload

// RabbitMQ body consumed by OmrGradingCallbackListener from grading.omr-callback
{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"success": true,
"error": null,
"phoneNumber": "01012345678",
"totalScore": 92,
"grade": 1,
"results": [
{
"questionNumber": 1,
"studentAnswer": "3",
"correctAnswer": "3",
"score": 5,
"earnedScore": 5,
"questionType": "MULTIPLE_CHOICE"
}
]
}

Job Status Polling

GET /v1/exams/15/omr-jobs/341
HTTP/1.1 200 OK

{
"jobId": 341,
"examId": 15,
"status": "PROCESSING",
"totalImages": 2,
"processedImages": 1,
"successCount": 1,
"failCount": 0,
"savedCount": 1,
"createdAt": "<timestamp>"
}

Report Batch And Callback

POST /v1/exams/15/reports/batch
Content-Type: application/json

{
"studentIds": [101, 102]
}
// RabbitMQ body consumed by ReportGenerationCallbackListener from report.generation-callback
{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}

Dependencies And Boundaries

DependencyWhy it exists
StudentServiceValidate student IDs, resolve student phone/name, and backfill unregistered results
BillingServiceGate OMR usage through MetricType.OMR_MONTHLY
TenantServiceRestore tenant context for callbacks and stale-job sweeping
OmrServicePort and ReportServicePortWorker HTTP contracts for synchronous grading or internal report reads
OmrStoragePortPresigned uploads, OMR image persistence, and ZIP storage
RabbitMQ via libs/messagingBatch job dispatch and callbacks
RedisDuplicate-callback suppression

Failure, Retry, And Idempotency

  • Synchronous grading fails fast with EXAM_NOT_FOUND, STUDENT_NOT_FOUND, OMR_QUOTA_EXCEEDED, or OMR_GRADING_FAILED.
  • Batch confirm rejects object keys outside the tenant-scoped MinIO prefix.
  • Public batch mutation endpoints accept Idempotency-Key through ResultController and ReportController.
  • RabbitMQ publish failures leave the Spring Modulith event publication incomplete, so the event is retried on restart.
  • Duplicate callbacks are ignored by Redis deduplication instead of double-writing results.
  • Late callbacks for already-terminal jobs are logged and skipped.
  • ExamJobTimeoutSweeper marks long-running PROCESSING jobs as FAILED after two hours so frontend polling can terminate.

Observability

The module has the richest structured job logging in the backend:

  • enqueue logs in JobRequestForwarder;
  • callback accepted, rejected, failed, and success logs in both Rabbit listeners;
  • stale-job sweep warnings in ExamJobTimeoutSweeper;
  • normal command/query service logs around grading, report generation, and result edits.

The module also depends on spring-boot-starter-actuator and micrometer-registry-prometheus through its Gradle build.

Verification

Use these commands when changing this module or validating the docs against code:

./gradlew :modules:exam:test
./gradlew :app:test --tests '*Exam*'
cd lumie-document/docusaurus && npm run build

Expected success signals:

  • Gradle exits with BUILD SUCCESSFUL, and the exam module tests still cover result, report, and callback-processing paths.
  • npm run build exits 0 from lumie-document/docusaurus without broken-link or MDX parse failures.
  • JobAcceptedResponse still mirrors the Location header through statusUrl, and the callback listeners still bind grading.omr-callback and report.generation-callback.