본문으로 건너뛰기

리포트

목적

report-svc는 백엔드 데이터로 학생별 시험 리포트를 만들고, ReportLab으로 단일 페이지 PDF를 렌더링합니다. 주요 운영 경로는 RabbitMQ를 통한 비동기 방식이지만, 단일 리포트 생성을 위한 동기 대체 라우트도 유지합니다.

공유 워커 규칙은 워커 개요를 참조하세요.

소스 경로

경로역할
lumie-worker/services/report/main.pyFastAPI 앱, 수명주기, health 라우트, 동기 대체 라우트
lumie-worker/services/report/src/schema.pyMQ 명령 및 콜백 페이로드 모델
lumie-worker/services/report/src/usecase.py리포트 생성 오케스트레이션
lumie-worker/services/report/src/mq/consumer.pyRabbitMQ 컨슈머와 콜백 페이로드 빌더
lumie-worker/services/report/src/adapters/reportlab_renderer.pyReportLab PDF 렌더링
lumie-worker/services/report/src/domain/report_data.py타입이 지정된 리포트 데이터 모델
lumie-worker/services/report/src/adapters/exam_api.py백엔드 시험, 결과, 순위, 통계 클라이언트
lumie-worker/services/report/src/adapters/student_api.py백엔드 학생 프로필 클라이언트
lumie-worker/contracts/mq-schemas-v1.yaml수동 유지 report MQ 계약 참고 자료

공개 표면

운영 라우트:

  • GET /health
  • GET /metrics

동기 대체 라우트:

  • POST /v1/reports/students/{student_id}/exams/{exam_id}
  • 필수 헤더: X-Tenant-Slug
  • 응답 타입: application/pdf

RabbitMQ 표면:

  • 명령 큐: report.generation-request
  • 콜백 routing key: report.generation.callback

큐 페이로드는 services/report/src/schema.pyReportCommand로 검증됩니다.

ReportCommand 예시

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"schemaVersion": 1
}

성공 콜백 예시

services/report/src/mq/consumer.pyresult["report_bytes"]reportBytes에 base64 인코딩하며, 백엔드 ReportCallbackRequest도 같은 필드를 받습니다.

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}

실패 콜백 예시

{
"jobId": 1201,
"examId": 42,
"studentId": 1001,
"tenantSlug": "demo",
"reportIndex": 0,
"totalReports": 2,
"success": false,
"error": "404: exam result not found",
"reportBytes": null
}

처리 플로우

  1. FastAPI 시작 시 의존성 컨테이너를 구성하고, ReportLab 렌더러를 예열하고, 콜백 발행기를 연결하고, RabbitMQ 컨슈머를 시작합니다.
  2. GenerateReportUseCase는 학생 프로필, 시험 통계, 안정성 지수, 해당 학생의 시험 결과, 학생 순위를 백엔드에서 병렬로 5개 읽습니다.
  3. 순위 조회는 best-effort입니다. 실패해도 리포트는 계속 렌더링됩니다.
  4. 워커는 선택된 시험 결과에 대한 문제 수준 결과를 가져옵니다.
  5. 타입이 지정된 ReportData 객체를 구성합니다.
  6. ReportLab이 ReportRenderer.generate_pdf(...)를 통해 리포트를 단일 페이지 PDF로 렌더링합니다.
  7. MQ 경로는 생성된 바이트를 reportBytes 콜백 필드에 base64 인코딩합니다.

워커가 요청된 학생과 시험 쌍에 대한 시험 결과를 찾지 못하면 HTTPException(404)를 발생시킵니다.

워커가 백엔드에서 가져오는 데이터

report 워커는 데이터베이스를 직접 조회하지 않습니다. 서명된 httpx 어댑터를 통해 백엔드 /internal/** 라우트를 호출합니다.

  • /internal/reports/students/<studentId>
  • /internal/reports/exams/<examId>/statistics
  • /internal/reports/students/<studentId>/stability
  • /internal/reports/students/<studentId>/rank
  • /internal/reports/students/<studentId>/results
  • /internal/reports/results/<resultId>/questions

ExamClient는 대규모 배치 중 반복 조회를 줄이기 위해 시험 통계를 60초 동안 캐시합니다.

모든 /internal/** 요청에는 다음이 포함됩니다.

  • X-Tenant-Slug
  • X-Signature
  • X-Timestamp

이 헤더는 공유 HMAC 서명 헬퍼를 통해 생성됩니다. 이 서비스에 서명되지 않은 백엔드 읽기를 추가하지 마세요.

렌더링 모델

현재 구현은 PDF를 렌더링합니다.

  • ReportRenderer.generate_pdf(...)는 PDF 바이트를 반환합니다
  • 동기 HTTP 라우트는 application/pdf로 응답합니다
  • MQ 콜백은 그 PDF 바이트를 reportBytes에 base64 인코딩합니다

renderer는 asyncio.to_thread(...)를 통해 ReportLab 작업을 실행하므로 CPU-bound PDF 생성이 이벤트 루프를 막지 않습니다.

외부 의존성

의존성존재 이유
aio-pika를 통한 RabbitMQreport 작업 수신 및 완료 콜백 발행
httpx를 통한 백엔드 HTTP API학생, 시험, 결과, 순위, 안정성 데이터 조회
ReportLab타입이 지정된 리포트 데이터를 단일 페이지 PDF로 렌더링
MinIO배치 report 작업에서 생성된 PDF 저장
OpenTelemetry와 Prometheus지연 시간 측정 및 백엔드/렌더링 작업 추적

실패 의미

실패동작
잘못된 MQ 본문공유 MQ 처리에서 거부되고 broker DLQ 경로로 이동
백엔드 순위 조회 실패로그만 남기고 무시, 순위 없이 리포트 렌더링 지속
백엔드 시험 결과 누락HTTPException(404) 발생, MQ 경로에서는 실패 콜백
필수 백엔드 데이터 호출 실패핸들러 실패 metric 및 MQ retry 경로
재시도 후에도 콜백 발행 실패broker 재시도를 위해 MQ 메시지를 nack

큐 처리는 콜백 전달을 작업 완료의 일부로 취급합니다. 생성된 PDF가 있어도 콜백이 전달되지 않으면 완료된 report 작업으로 간주하지 않습니다.

운영 메모

  • 기본 큐 prefetch는 2이며, grading보다 낮습니다. PDF 렌더링이 CPU와 메모리에 민감하기 때문입니다.
  • GET /health는 상태와 서비스 이름을 모두 반환합니다.
  • /metricsreport_generation_total, report_generation_duration_seconds, report_generation_inflight를 노출합니다.
  • 서비스는 종료 시 콜백 publisher, report renderer, 공유 httpx.AsyncClient를 닫습니다.
  • LUMIE_BACKEND_URLLUMIE_INTERNAL_HMAC_SECRET은 필수 설정입니다.
  • 동기 대체 라우트는 내부적으로 ReportCommand를 구성하고 media_type="application/pdf"로 원시 PDF 바이트를 반환합니다.

백엔드 트리거 예시

report 작업을 생성하는 백엔드 배치 엔트리포인트는 POST /v1/exams/{examId}/reports/batchReportController.createBatchReport(...)입니다.

curl -i \
-X POST http://localhost:8080/v1/exams/42/reports/batch \
-H 'Content-Type: application/json' \
-H 'X-Tenant-Slug: demo' \
-H 'Cookie: lumie_access_token=<staff-session-cookie>' \
-d '{"studentIds":[1001,1002]}'

예상 백엔드 응답:

  • HTTP 202 Accepted
  • Location: /v1/exams/42/reports/jobs/{jobId}
  • 승인된 작업 id와 status URL이 담긴 JSON 본문

검증

cd lumie-worker
uv run pytest services/report/tests
cd /path/to/Lumie
rg -n "report.generation-request|report.generation.callback|reportBytes|generate_pdf" \
lumie-worker/services/report lumie-worker/contracts/mq-schemas-v1.yaml

검증 성공 신호:

  • pytest0으로 종료하고 services/report/tests/test_generate_report_usecase.py 슬라이스가 통과
  • 워커 grep이 services/report/src/config.pyreport.generation-requestservices/report/src/adapters/callback_mq.pyreport.generation.callback을 보여줌
  • 성공한 배치 실행 후 결국 백엔드 GET /v1/exams/{examId}/reports/jobs/{jobId}processedReports == totalReports를 반환
  • 최소 1개 이상의 리포트가 성공했다면 zipFileKey가 null이 아님