Skip to main content

Student Service

This page covers lumie-backend/modules/student.

Responsibility

The student module owns:

  • tenant-scoped student records linked to auth users;
  • student lifecycle operations such as activate, deactivate, delete, password reset, and login ID change;
  • XLSX/CSV bulk import and CSV export;
  • student search and statistics;
  • the StudentRegisteredEvent used to trigger cross-module follow-up work.

Source Paths

PathRole
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.javaPublic student HTTP API
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/internal/StudentServiceAdapter.javaInternal monolith API implementation
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentCommandService.javaLifecycle, deletion, bulk import, and event publishing
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentQueryService.javaQuery, export, enrollment trend, and dropout summary
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentExcelParser.javaApache POI-based XLSX parser and multi-encoding CSV parser
lumie-backend/modules/student/src/test/java/com/lumie/student/application/service/StudentExcelParserTest.javaParser coverage for UTF-8, x-windows-949, and CSV edge cases
lumie-backend/modules/student/src/main/java/com/lumie/student/domain/entity/Student.javaStudent aggregate
lumie-backend/libs/internal-api/src/main/java/com/lumie/student/api/StudentService.javaInternal lookup and validation contract
lumie-backend/libs/internal-api/src/main/java/com/lumie/student/api/StudentRegisteredEvent.javaAfter-commit event contract
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlBaseline students table
lumie-backend/app/src/main/resources/db/migration/public/V91__normalize_student_parent_phone_and_drop_unique_index.sqlParent-phone blank normalization and guardian-number uniqueness relaxation

Public Surface

SurfaceEntrypoints
Student CRUDPOST /v1/students, GET /v1/students, GET /v1/students/{id}, PATCH /v1/students/{id}, DELETE /v1/students/{id}
Lifecycle and credentialsPOST /v1/students/{id}/deactivate, POST /v1/students/{id}/reactivate, POST /v1/students/{id}/reset-password, PATCH /v1/students/{id}/login-id
Batch lifecyclePOST /v1/students/batch/deactivate, POST /v1/students/batch/reactivate, POST /v1/students/batch/delete
Bulk import and exportPOST /v1/students/bulk, GET /v1/students/export.csv
StatisticsGET /v1/students/statistics/enrollment-trend, GET /v1/students/statistics/dropout-summary

Student list filters

GET /v1/students accepts the existing isActive, search, pagination, and sort parameters plus these optional filters:

ParameterMeaning
classIdReturns students with an active enrollment in the class. This takes precedence when combined with hasActiveEnrollment.
hasActiveEnrollment=trueReturns students with at least one active class enrollment.
hasActiveEnrollment=falseReturns students with no active class enrollment, which powers the unassigned view.

The student query service obtains enrollment IDs through the class module's internal API; the student module does not import class domain entities.

Internal Surface

StudentService exports:

  • lookup by student ID, phone, or auth user IDs;
  • validation of a student inside a tenant;
  • user-ID search for keyword-based joins;
  • batched lookup helpers for other modules.

The exam module is the most important current consumer because it uses student phone lookups and the StudentRegisteredEvent backfill path.

Aggregate And Data Shape

AggregateTableNotes
StudentstudentsStores user linkage, login ID mirror, contact numbers, school, birth year, memo, and active flag

Important invariants in Student:

  • student and parent phone numbers are normalized to digits on write; blank parent_phone values are stored as NULL;
  • user_login_id mirrors auth-side login identity for efficient lookup;
  • delete is implemented by deleting the auth user, with database cascade removing the student row.

Runtime Flows

Register-and-backfill flow

What the event publish point looks like

Source anchor: lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentCommandService.java publishes the event from StudentCommandService.registerStudent:

eventPublisher.publishEvent(
new StudentRegisteredEvent(
saved.getId(),
saved.getPhone(),
tenantSlug,
TenantContextHolder.getRequiredTenantId()
)
);

This event is published after the student row is persisted, and downstream listeners consume it after commit. The event carries both tenantSlug and tenantId so replayed listeners can restore the full RLS tenant context. Bulk import publishes the same event once for each successfully saved student row.

Key Behaviors

  • Student registration and reactivation both perform quota checks through MetricType.STUDENTS.
  • Student phone remains unique when present and must normalize to an 11-digit mobile number for registration, update, and bulk import. parent_phone is optional, not unique, and still uses the broader shared phone parser, so one guardian phone number can be attached to multiple student rows.
  • Single delete requires the student to be inactive, clears active class enrollments through ClassService.dropActiveEnrollmentsForStudent(...), then deletes the auth user.
  • Bulk import is a two-pass process:
    • pass 1 validates all rows and collects every row error;
    • pass 2 creates auth users and student rows only for valid rows.
  • The import parser accepts .xlsx uploads plus .csv uploads decoded in order as UTF-8, x-windows-949, then EUC-KR. StudentExcelParser.parseCsvRecords(...) keeps trying those charsets until the decoded header matches 학생 이름,학생 연락처, rejects malformed quoted CSV fields, caps uploads at 1,000 data rows, and rejects CSV records over 32 columns.
  • Bulk import rejects text values that could be interpreted as spreadsheet formulas in free-text columns. CSV export also prefixes such stored values with an apostrophe before writing cells.
  • POST /v1/students/bulk requires Idempotency-Key. StudentController fingerprints the upload by byte size and SHA-256 content hash before delegating to IdempotencyService.executeOnce(...).
  • Bulk import requires at least INSTRUCTOR authority at the controller boundary.
  • Dropout summary is an approximation based on updated_at for inactive rows because the schema has no dedicated deactivated_at field.

Representative Contract Example

These representative examples are anchored to StudentExcelParser, StudentExcelParserTest, StudentController, and BulkImportResult.

Template row shape

The first worksheet or CSV file must start with these columns:

학생 이름,학생 연락처,학교명,출생 연도,학부모 연락처,메모
김철수,01012340001,한빛고,2008,01012340002,재원

StudentExcelParserTest confirms that the first data row is reported as rowNumber = 2, because the header occupies row 1. The same row numbering applies to CSV uploads.

StudentExcelParserTest.parsesCsvWhenFilenameHasCsvExtension(...) covers the UTF-8 path, and StudentExcelParserTest.parsesCp949CsvExportedFromKoreanExcel(...) proves that Korean Excel CSV exports encoded as x-windows-949 are accepted by the same parser. StudentExcelParser also includes EUC-KR in the fallback charset list before it rejects the file as an invalid template.

Source anchors:

  • lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentExcelParser.java
  • lumie-backend/modules/student/src/test/java/com/lumie/student/application/service/StudentExcelParserTest.java

Partial-success bulk import response

POST /v1/students/bulk returns 200 OK even when some rows fail, because the import runs inline and reports row-level errors instead of switching to an async job model.

{
"totalRows": 2,
"successCount": 1,
"failureCount": 1,
"errors": [
{
"rowNumber": 3,
"column": "phone",
"value": "010-12",
"code": "STUDENT_502",
"message": "전화번호 형식이 올바르지 않습니다 (예: 010-1234-5678)"
}
]
}

rowNumber points back to the spreadsheet row label, not the zero-based array index. Once failureCount is non-zero, callers must inspect errors[] instead of treating 200 OK as a full success signal.

Source anchors:

  • lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.java
  • lumie-backend/modules/student/src/main/java/com/lumie/student/application/dto/response/BulkImportResult.java

Dependencies And Boundaries

DependencyWhy it exists
AuthServiceCreate and delete auth users, reset passwords, and change login IDs
BillingServiceStudent quota checks
ClassServiceDrop active enrollments before permanent deletion

Failure Modes

  • Registration and update can fail with INVALID_PHONE_NUMBER for a student phone that is not 11 digits after normalization or for an invalid parentPhone, and with DUPLICATE_PHONE for duplicate student phone numbers.
  • Registration can also fail with AUTH_OP_FAILED or quota-related errors before the student row is saved.
  • Delete fails for active students.
  • Bulk import rejects invalid template shape, malformed CSV, over-limit row count, and over-limit CSV column count before row-level insertion begins. For .csv uploads, that includes files whose bytes do not decode to the expected Korean header under UTF-8, x-windows-949, or EUC-KR.
  • Bulk import can fail per-row on invalid phone format, duplicate phone, quota exhaustion, unsafe spreadsheet-formula text, auth creation failure, or unexpected insert errors.
  • Because auth deletion is the destructive step for permanent delete, a failed auth-side delete leaves the student row in place.

Observability And Quota Behavior

  • Lifecycle and batch operations log success counts and failures in StudentCommandService.
  • Bulk import returns partial success with row-level errors instead of all-or-nothing rollback.
  • The student module is quota-aware, but like the staff module it currently receives a permissive unlimited result from the billing internal adapter.

Verification

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

Expected success signals:

  • Gradle finishes with BUILD SUCCESSFUL.
  • StudentExcelParserTest and StudentCommandServiceTest pass without failures.
  • Docusaurus finishes without MDX or broken-link errors for backend/student-svc.