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
| Path | Role |
|---|---|
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web | Public HTTP controllers for exams, templates, results, statistics, and reports |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging | RabbitMQ callback consumers for grading and report completion |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/internal/ExamServiceAdapter.java | Internal synchronous API exported through libs/internal-api |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.java | Spring Modulith outbox bridge to RabbitMQ |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/external | HTTP clients for grading-svc and report-svc |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/storage/OmrMinioStorageAdapter.java | MinIO storage for OMR images and generated artifacts |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/persistence/RedisCallbackDeduplicationAdapter.java | Redis-backed callback deduplication |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service | Command, query, grading, statistics, callback, and sweeper services |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entity | Exam, ExamQuestionType, ExamTemplate, ExamResult, QuestionResult, OmrGradingJob, ReportGenerationJob |
lumie-backend/libs/internal-api/src/main/java/com/lumie/exam/api/ExamService.java | Internal monolith contract |
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql | Baseline 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.sql | Adds optimistic-lock version to exam_templates |
lumie-backend/app/src/main/resources/db/migration/public/V44__create_exam_ai_analyses.sql | Related 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.sql | Adds tenant-scoped configurable question type options |
Public Surface
| Surface | Entrypoints |
|---|---|
| Exam CRUD | GET/POST /v1/exams, GET /v1/exams/{id}, GET /v1/exams/{id}/full, PATCH /v1/exams/{id}, DELETE /v1/exams/{id} |
| Question type settings | GET /v1/exam-question-types; staff-only POST /v1/exam-question-types, DELETE /v1/exam-question-types/{id} |
| Template CRUD | GET/POST /v1/exam-templates, GET/PATCH/DELETE /v1/exam-templates/{id} |
| Synchronous OMR | POST /v1/exams/{examId}/results/omr |
| Batch OMR | POST /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 corrections | GET /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 artifacts | GET /v1/exams/{examId}/results/{resultId}/omr-image, GET /v1/exams/results/export.csv |
| Reports | GET /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 |
| Statistics | GET /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
| Type | Path | Notes |
|---|---|---|
| Synchronous internal API | libs/internal-api/.../ExamService.java | Exposes only linkUnregisteredResultsByPhone(...) |
| Internal HTTP read surface | /internal/omr/exams/{id}/full | Worker-facing read path for full exam metadata |
| Internal HTTP read surface | /internal/reports/... | Worker-facing report-statistics and result lookup |
| Spring Modulith listener | StudentRegisteredListener | Backfills unregistered results after student registration commit |
| RabbitMQ callback consumers | grading.omr-callback, report.generation-callback | Restore 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
| Aggregate | Table | Notes |
|---|---|---|
Exam | exams | Stores answer keys, scoring maps, question types, grading mode, and pass score |
ExamQuestionType | exam_question_types | Tenant-managed source list for future question type selections; existing exams keep their question_types JSONB snapshots |
ExamTemplate | exam_templates | Template-only scoring and question metadata |
ExamResult | exam_results | Supports both registered students and phone-only unregistered rows |
QuestionResult | question_results | Per-question grading state for an exam result |
OmrGradingJob | omr_grading_jobs | Tracks batch image processing counts and input object keys |
ReportGenerationJob | report_generation_jobs | Tracks per-student report generation and the final ZIP key |
Two result identities are supported in the same aggregate:
- registered:
student_idis present and the authoritative phone is copied from the student record; - unregistered:
student_idis null andphone_numberbecomes the reconciliation key untilStudentRegisteredEventbackfills 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
tenantSlugin the message payload. - Tenant slug is resolved back to
tenantIdbefore any repository access. - OMR callbacks deduplicate on
omr:cb:{jobId}:{imageIndex}and report callbacks onreport:cb:{jobId}:{reportIndex}using Redis with a 24-hour TTL.
Single-image versus batch grading
processOmrGrading(...)is still synchronous and callsOmrServicePort.gradeOmrImage(...)over HTTP.- Batch grading is queue-backed and persists
OmrGradingJobrows first.
Question type settings
Source anchors: ExamQuestionTypeController.java, ExamQuestionTypeService.java,
TenantCreatedExamQuestionTypeListener.java, Exam.java, ExamTemplate.java, and
V70__create_exam_question_types.sql.
ExamQuestionTypeControllerexposesGET /v1/exam-question-types, plus staff-onlyPOST /v1/exam-question-typesandDELETE /v1/exam-question-types/{id}.ExamQuestionTypeServicestores only the tenant's option list inexam_question_types.- V70 backfills default options for existing tenants, and
TenantCreatedExamQuestionTypeListenerseeds the same defaults for future tenants. ExamandExamTemplatecontinue to store per-question type names inquestion_typesJSONB. There is no foreign key from those snapshots toexam_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-summaryreturns oneStudentResultSummaryResponseper 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
studentIdempty and return an emptyclasseslist, 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
classEntityto 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
| Dependency | Why it exists |
|---|---|
StudentService | Validate student IDs, resolve student phone/name, and backfill unregistered results |
BillingService | Gate OMR usage through MetricType.OMR_MONTHLY |
TenantService | Restore tenant context for callbacks and stale-job sweeping |
OmrServicePort and ReportServicePort | Worker HTTP contracts for synchronous grading or internal report reads |
OmrStoragePort | Presigned uploads, OMR image persistence, and ZIP storage |
RabbitMQ via libs/messaging | Batch job dispatch and callbacks |
Redis | Duplicate-callback suppression |
Failure, Retry, And Idempotency
- Synchronous grading fails fast with
EXAM_NOT_FOUND,STUDENT_NOT_FOUND,OMR_QUOTA_EXCEEDED, orOMR_GRADING_FAILED. - Batch confirm rejects object keys outside the tenant-scoped MinIO prefix.
- Public batch mutation endpoints accept
Idempotency-KeythroughResultControllerandReportController. - 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.
ExamJobTimeoutSweepermarks long-runningPROCESSINGjobs asFAILEDafter 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 buildexits0fromlumie-document/docusauruswithout broken-link or MDX parse failures.JobAcceptedResponsestill mirrors theLocationheader throughstatusUrl, and the callback listeners still bindgrading.omr-callbackandreport.generation-callback.