본문으로 건너뛰기

리포트

목적

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

공유 worker 규칙은 Workers Overview를 참조하세요.

소스 경로

경로역할
lumie-worker/services/report/main.pyFastAPI 앱, lifespan, health 라우트, 동기 fallback 라우트
lumie-worker/services/report/src/schema.pyMQ 명령 및 콜백 payload 모델
lumie-worker/services/report/src/usecase.py리포트 생성 오케스트레이션
lumie-worker/services/report/src/mq/consumer.pyRabbitMQ consumer와 콜백 payload 빌더
lumie-worker/services/report/src/domain/report_generator.pyJinja2와 Playwright 렌더링
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/services/report/templates/exam_report.htmlJPEG로 캡처되는 HTML 템플릿
lumie-worker/contracts/mq-schemas-v1.yaml수동 유지 report MQ 계약 참고 자료

공개 표면

운영 라우트:

  • GET /health
  • GET /metrics

동기 fallback 라우트:

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

RabbitMQ 표면:

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

큐 payload는 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["jpg_bytes"]reportBytes에 base64 인코딩하며, 백엔드 ReportCallbackRequest도 같은 필드를 받습니다.

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

실패 콜백 예시

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

처리 플로우

  1. FastAPI 시작 시 의존성 container를 구성하고, Playwright 브라우저를 예열하고, callback publisher를 연결하고, RabbitMQ consumer를 시작합니다.
  2. GenerateReportUseCase는 학생 프로필, 시험 통계, 안정성 지수, 해당 학생의 시험 결과, 학생 순위를 백엔드에서 병렬로 5개 읽습니다.
  3. 순위 조회는 best-effort입니다. 실패해도 리포트는 계속 렌더링됩니다.
  4. 워커는 선택된 시험 결과에 대한 문제 수준 결과를 가져옵니다.
  5. 타입이 지정된 ReportData 객체를 구성하고, exam_report.html Jinja2 템플릿으로 HTML을 렌더링합니다.
  6. Playwright가 Chromium 페이지를 열고 HTML을 로드한 뒤, 전체 페이지 JPEG 스크린샷을 캡처합니다.
  7. MQ 경로는 생성된 바이트를 reportBytes 콜백 필드에 base64 인코딩합니다.

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

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

report worker는 데이터베이스를 직접 조회하지 않습니다. 서명된 httpx adapter를 통해 백엔드 /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가 아니라 이미지를 렌더링합니다.

  • ReportGenerator.generate_jpg(...)는 JPEG 바이트를 반환합니다
  • 동기 HTTP 라우트는 image/jpeg로 응답합니다
  • MQ 콜백은 그 JPEG 바이트를 reportBytes에 base64 인코딩합니다

필드 이름은 일반적이지만, 현재 서비스 동작은 이미지 기반입니다.

템플릿은 고정 크기이며 스크린샷 렌더링용으로 설계되었습니다.

  • viewport: 794 x 1123
  • 기본 device scale factor: 4
  • 첫 요청 지연을 줄이기 위해 브라우저를 시작 시 예열

계약 드리프트 메모

lumie-worker/contracts/mq-schemas-v1.yaml는 여전히 reportBytes를 base64 인코딩된 PDF로 설명합니다. 구현은 현재 ReportGenerator.generate_jpg(...)에서 나온 JPEG 바이트를 base64 인코딩하고, 동기 라우트도 image/jpeg를 반환합니다. 계약 파일이 수정되기 전까지는 구현을 런타임 source of truth로 취급하세요.

외부 의존성

의존성존재 이유
aio-pika를 통한 RabbitMQreport 작업 수신 및 완료 콜백 발행
httpx를 통한 백엔드 HTTP API학생, 시험, 결과, 순위, 안정성 데이터 조회
Jinja2타입이 지정된 리포트 데이터로 템플릿 렌더링
Playwright ChromiumHTML 템플릿을 최종 이미지로 변환
OpenTelemetry와 Prometheus지연 시간 측정 및 백엔드/렌더링 작업 추적

실패 의미

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

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

운영 메모

  • 기본 큐 prefetch는 2이며, grading보다 낮습니다. Chromium 렌더링이 메모리를 많이 사용하기 때문입니다.
  • GET /health는 상태와 서비스 이름을 모두 반환합니다.
  • /metricsreport_generation_total, report_generation_duration_seconds, report_generation_inflight를 노출합니다.
  • 서비스는 종료 시 callback publisher, Playwright 브라우저, 공유 httpx.AsyncClient를 닫습니다.
  • LUMIE_BACKEND_URLLUMIE_INTERNAL_HMAC_SECRET은 필수 설정입니다.
  • 동기 fallback 라우트는 내부적으로 ReportCommand를 구성하고 media_type="image/jpeg"로 원시 JPEG 바이트를 반환합니다.

백엔드 트리거 예시

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_jpg" \
lumie-worker/services/report lumie-worker/contracts/mq-schemas-v1.yaml

검증 성공 신호:

  • pytest0으로 종료하고 services/report/tests/test_generate_report_usecase.py 슬라이스가 통과
  • worker 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가 non-null이 됨