|
|
@@ -0,0 +1,789 @@
|
|
|
+# Exam Sprint Report Logging Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Add useful, safe business logs for `OUTLOOK` and `ACHIEVEMENT` exam-sprint report creation, generation, query, download, and cleanup flows.
|
|
|
+
|
|
|
+**Architecture:** Keep logs at application-service and generation-pipeline boundaries. Application logs describe public operations and business availability decisions; pipeline logs describe generation stages and failures. Renderer, PDF, and storage internals stay unchanged unless a later failure proves a missing boundary.
|
|
|
+
|
|
|
+**Tech Stack:** Java 17, Spring Boot 3.3.5, SLF4J, JUnit 5, AssertJ, Spring Boot `OutputCaptureExtension`, Maven multi-module project.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File Structure
|
|
|
+
|
|
|
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`
|
|
|
+ - Responsibility: stage-level logs for pending report lookup, processing transition, HTML rendering, PDF generation, storage upload, success, skip reasons, and generation failure.
|
|
|
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
|
|
|
+ - Responsibility: public operation logs for async submit, dispatch failure, sync submit, query, download, and cleanup summary.
|
|
|
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`
|
|
|
+ - Responsibility: log-capture tests proving generation success and failure logs include useful report identifiers and stage names.
|
|
|
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
|
|
|
+ - Responsibility: log-capture tests proving dispatch failure, query, missing storage download, and cleanup logs are emitted without leaking dispatch exception messages.
|
|
|
+
|
|
|
+## Task 1: Add failing pipeline log tests
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add log-capture imports**
|
|
|
+
|
|
|
+Add these imports after the existing JUnit import block:
|
|
|
+
|
|
|
+```java
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.springframework.boot.test.system.CapturedOutput;
|
|
|
+import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Enable output capture for the test class**
|
|
|
+
|
|
|
+Change the class declaration from:
|
|
|
+
|
|
|
+```java
|
|
|
+class ExamSprintReportGenerationWorkerTest {
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+@ExtendWith(OutputCaptureExtension.class)
|
|
|
+class ExamSprintReportGenerationWorkerTest {
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add a successful generation log test**
|
|
|
+
|
|
|
+Insert this test after `processMarksReportSuccessAfterUpload()`:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Test
|
|
|
+ void processLogsSuccessfulGenerationStages(CapturedOutput output) {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-log-success",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant(),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(86400)));
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
|
|
|
+
|
|
|
+ worker.process("report-log-success");
|
|
|
+
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_generation_started")
|
|
|
+ .contains("reportId=report-log-success")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("stage=render_html")
|
|
|
+ .contains("stage=pdf_generation")
|
|
|
+ .contains("stage=storage_upload")
|
|
|
+ .contains("exam_sprint_report_generation_succeeded")
|
|
|
+ .contains("storageObjectKey=exam-sprint-outlook-report-report-log-success.pdf")
|
|
|
+ .doesNotContain("<html><body>ok</body></html>");
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Add a failed generation log test**
|
|
|
+
|
|
|
+Insert this test after `processMarksReportFailedWhenGenerationPipelineThrows()`:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Test
|
|
|
+ void processLogsFailedGenerationStage(CapturedOutput output) {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-log-failed",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant(),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(86400)));
|
|
|
+
|
|
|
+ ExamSprintReportGenerationWorker worker = createWorker(
|
|
|
+ repository,
|
|
|
+ List.of(new FailingRenderer()),
|
|
|
+ new TestStorage());
|
|
|
+
|
|
|
+ worker.process("report-log-failed");
|
|
|
+
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_generation_failed")
|
|
|
+ .contains("reportId=report-log-failed")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("stage=render_html")
|
|
|
+ .contains("failureReason=renderer exploded")
|
|
|
+ .contains("exceptionType=IllegalStateException");
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run the focused test and verify it fails**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the application module does not yet emit `exam_sprint_report_generation_started`, `stage=render_html`, or `exam_sprint_report_generation_failed` logs.
|
|
|
+
|
|
|
+## Task 2: Implement generation pipeline logs
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add logger and timing imports**
|
|
|
+
|
|
|
+Add these imports:
|
|
|
+
|
|
|
+```java
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Add a logger field**
|
|
|
+
|
|
|
+Inside the class, above the existing fields, add:
|
|
|
+
|
|
|
+```java
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(ExamSprintReportGenerationPipeline.class);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Replace `generate` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `generate(String reportId)` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ public Optional<ExamSprintReport> generate(String reportId) {
|
|
|
+ long totalStartedNanos = System.nanoTime();
|
|
|
+ Instant startedAt = clock.instant();
|
|
|
+ ExamSprintReport report = repository.findById(reportId).orElse(null);
|
|
|
+ if (report == null) {
|
|
|
+ log.info("exam_sprint_report_generation_skipped reportId={} reason=not_found", reportId);
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ if (report.generationStatus() != ExamSprintReportGenerationStatus.PENDING) {
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=not_pending",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus());
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ if (report.isExpiredAt(startedAt)) {
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.expiresAt());
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_started reportId={} reportType={} generationStatus={} startedAt={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ startedAt);
|
|
|
+ ExamSprintReport processingReport = repository.save(report.processing(startedAt));
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_processing reportId={} reportType={} generationStatus={}",
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ processingReport.generationStatus());
|
|
|
+
|
|
|
+ String stage = "render_html";
|
|
|
+ try {
|
|
|
+ long stageStartedNanos = System.nanoTime();
|
|
|
+ String html = rendererFor(processingReport).render(processingReport.payload(), startedAt);
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} htmlLength={}",
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ stage,
|
|
|
+ elapsedMillis(stageStartedNanos),
|
|
|
+ html.length());
|
|
|
+
|
|
|
+ stage = "pdf_generation";
|
|
|
+ stageStartedNanos = System.nanoTime();
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} pdfBytes={}",
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ stage,
|
|
|
+ elapsedMillis(stageStartedNanos),
|
|
|
+ pdfBytes.length);
|
|
|
+
|
|
|
+ stage = "storage_upload";
|
|
|
+ stageStartedNanos = System.nanoTime();
|
|
|
+ ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ fileNameOf(processingReport),
|
|
|
+ pdfBytes,
|
|
|
+ processingReport.expiresAt());
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} storageObjectKey={} fileName={}",
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ stage,
|
|
|
+ elapsedMillis(stageStartedNanos),
|
|
|
+ storedFile.storageObjectKey(),
|
|
|
+ storedFile.fileName());
|
|
|
+
|
|
|
+ stage = "success_save";
|
|
|
+ ExamSprintReport successReport = repository.save(repository.findById(reportId)
|
|
|
+ .orElseThrow()
|
|
|
+ .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName()));
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={} fileName={}",
|
|
|
+ successReport.reportId(),
|
|
|
+ successReport.reportType(),
|
|
|
+ successReport.generationStatus(),
|
|
|
+ elapsedMillis(totalStartedNanos),
|
|
|
+ successReport.storageObjectKey(),
|
|
|
+ successReport.fileName());
|
|
|
+ return Optional.of(successReport);
|
|
|
+ } catch (Exception exception) {
|
|
|
+ String failureReason = failureReasonOf(exception);
|
|
|
+ log.error(
|
|
|
+ "exam_sprint_report_generation_failed reportId={} reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
|
|
|
+ processingReport.reportId(),
|
|
|
+ processingReport.reportType(),
|
|
|
+ stage,
|
|
|
+ failureReason,
|
|
|
+ exception.getClass().getSimpleName(),
|
|
|
+ elapsedMillis(totalStartedNanos),
|
|
|
+ exception);
|
|
|
+ return repository.findById(reportId)
|
|
|
+ .map(current -> repository.save(current.failed(clock.instant(), failureReason)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Add elapsed-time helper**
|
|
|
+
|
|
|
+Add this method below `fileNameOf` and above `failureReasonOf`:
|
|
|
+
|
|
|
+```java
|
|
|
+ private long elapsedMillis(long startedNanos) {
|
|
|
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Run the focused test and verify it passes**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. The output-capture tests should find generation start, stage completion, success, and failure logs.
|
|
|
+
|
|
|
+## Task 3: Add failing application-service log tests
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add log-capture imports**
|
|
|
+
|
|
|
+Add these imports after the existing JUnit imports:
|
|
|
+
|
|
|
+```java
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.springframework.boot.test.system.CapturedOutput;
|
|
|
+import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Enable output capture for the test class**
|
|
|
+
|
|
|
+Change the class declaration from:
|
|
|
+
|
|
|
+```java
|
|
|
+class ExamSprintReportApplicationServiceTest {
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+@ExtendWith(OutputCaptureExtension.class)
|
|
|
+class ExamSprintReportApplicationServiceTest {
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Extend the dispatch-failure test with log assertions**
|
|
|
+
|
|
|
+Change the method signature from:
|
|
|
+
|
|
|
+```java
|
|
|
+ void createOutlookReportReturnsFailedStatusWhenDispatchFails() {
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+ void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
|
|
|
+```
|
|
|
+
|
|
|
+At the end of that test, after the existing status and failure-reason assertions, add:
|
|
|
+
|
|
|
+```java
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_submitted")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("mode=async")
|
|
|
+ .contains("exam_sprint_report_dispatch_failed")
|
|
|
+ .contains("failureReason=report_generation_dispatch_failed")
|
|
|
+ .contains("exceptionType=IllegalStateException")
|
|
|
+ .doesNotContain("task-executor-7 rejected")
|
|
|
+ .doesNotContain("dispatcher unavailable");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Extend the query-success test with log assertions**
|
|
|
+
|
|
|
+Change the method signature from:
|
|
|
+
|
|
|
+```java
|
|
|
+ void getReportReturnsDownloadUrlForSuccessfulReport() {
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+ void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
|
|
|
+```
|
|
|
+
|
|
|
+At the end of that test, add:
|
|
|
+
|
|
|
+```java
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_query_completed")
|
|
|
+ .contains("reportId=report-success")
|
|
|
+ .contains("reportType=ACHIEVEMENT")
|
|
|
+ .contains("generationStatus=SUCCESS")
|
|
|
+ .contains("downloadUrlIncluded=true");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Add a download missing-storage log test**
|
|
|
+
|
|
|
+Insert this test after `downloadReportRejectsExpiredReportBeforeCleanupRuns()`:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Test
|
|
|
+ void downloadReportLogsMissingStorageContent(CapturedOutput output) {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-missing-content",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(600),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(3600)).success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(300),
|
|
|
+ "exam-sprint-outlook-report-report-missing-content.pdf",
|
|
|
+ "exam-sprint-outlook-report-report-missing-content.pdf"));
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.downloadReport("report-missing-content"))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_download_started")
|
|
|
+ .contains("reportId=report-missing-content")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("exam_sprint_report_download_missing_storage_content")
|
|
|
+ .contains("storageObjectKey=exam-sprint-outlook-report-report-missing-content.pdf");
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Extend the cleanup failure test with log assertions**
|
|
|
+
|
|
|
+Change the method signature from:
|
|
|
+
|
|
|
+```java
|
|
|
+ void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+ void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
|
|
|
+```
|
|
|
+
|
|
|
+At the end of that test, add:
|
|
|
+
|
|
|
+```java
|
|
|
+ assertThat(output.getOut())
|
|
|
+ .contains("exam_sprint_report_cleanup_item_failed")
|
|
|
+ .contains("reportId=report-delete-fails")
|
|
|
+ .contains("storageObjectKey=first.pdf")
|
|
|
+ .contains("exceptionType=IllegalStateException")
|
|
|
+ .contains("exam_sprint_report_cleanup_completed")
|
|
|
+ .contains("scannedCount=2")
|
|
|
+ .contains("storageClearedCount=1")
|
|
|
+ .contains("markedExpiredCount=0")
|
|
|
+ .contains("failedCount=1");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Run the focused test and verify it fails**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because `DefaultExamSprintReportApplicationService` does not yet emit `exam_sprint_report_submitted`, `exam_sprint_report_query_completed`, download missing-storage, or cleanup summary logs.
|
|
|
+
|
|
|
+## Task 4: Implement application-service logs
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add logger, optional, and timing imports**
|
|
|
+
|
|
|
+Add these imports:
|
|
|
+
|
|
|
+```java
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+```
|
|
|
+
|
|
|
+Keep the existing `java.util.Set` and `java.util.UUID` imports.
|
|
|
+
|
|
|
+- [ ] **Step 2: Add a logger field**
|
|
|
+
|
|
|
+Inside the class, above `REPORT_GENERATION_DISPATCH_FAILED`, add:
|
|
|
+
|
|
|
+```java
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Replace `submitReportGeneration` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `submitReportGeneration(ExamSprintReportType reportType, JsonNode payload)` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ private CreateExamSprintReportResponse submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ ExamSprintReport report = ExamSprintReport.pending(
|
|
|
+ UUID.randomUUID().toString(),
|
|
|
+ reportType,
|
|
|
+ payload,
|
|
|
+ now,
|
|
|
+ now.plus(properties.getRetention()));
|
|
|
+ repository.save(report);
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_submitted reportId={} reportType={} generationStatus={} expiresAt={} mode=async",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.expiresAt());
|
|
|
+ try {
|
|
|
+ dispatcher.dispatch(report.reportId());
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_dispatched reportId={} reportType={} mode=async",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType());
|
|
|
+ } catch (RuntimeException exception) {
|
|
|
+ report = repository.save(report.failed(now, dispatchFailureReason(exception)));
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_dispatch_failed reportId={} reportType={} failureReason={} exceptionType={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.failureReason(),
|
|
|
+ exception.getClass().getSimpleName());
|
|
|
+ }
|
|
|
+ return new CreateExamSprintReportResponse(
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.createdAt(),
|
|
|
+ report.expiresAt());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Replace `submitReportGenerationSync` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `submitReportGenerationSync(ExamSprintReportType reportType, JsonNode payload)` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
|
|
|
+ ExamSprintReportType reportType,
|
|
|
+ JsonNode payload) {
|
|
|
+ long startedNanos = System.nanoTime();
|
|
|
+ Instant now = clock.instant();
|
|
|
+ ExamSprintReport report = ExamSprintReport.pending(
|
|
|
+ UUID.randomUUID().toString(),
|
|
|
+ reportType,
|
|
|
+ payload,
|
|
|
+ now,
|
|
|
+ now.plus(properties.getRetention()));
|
|
|
+ repository.save(report);
|
|
|
+
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_sync_generation_started reportId={} reportType={} generationStatus={} expiresAt={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.expiresAt());
|
|
|
+ Optional<ExamSprintReport> generatedReportOptional = pipeline.generate(report.reportId());
|
|
|
+ if (generatedReportOptional.isEmpty()) {
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_sync_generation_unavailable reportId={} reportType={} reason=pipeline_empty durationMs={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ elapsedMillis(startedNanos));
|
|
|
+ throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+
|
|
|
+ ExamSprintReport generatedReport = generatedReportOptional.get();
|
|
|
+ if (generatedReport.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS
|
|
|
+ || generatedReport.storageObjectKey() == null) {
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_sync_generation_unavailable reportId={} reportType={} generationStatus={} storageObjectKeyPresent={} durationMs={}",
|
|
|
+ generatedReport.reportId(),
|
|
|
+ generatedReport.reportType(),
|
|
|
+ generatedReport.generationStatus(),
|
|
|
+ generatedReport.storageObjectKey() != null,
|
|
|
+ elapsedMillis(startedNanos));
|
|
|
+ throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+
|
|
|
+ String downloadUrl = storage.generateDownloadUrl(
|
|
|
+ generatedReport.storageObjectKey(),
|
|
|
+ properties.getDownloadExpiry()).toString();
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_sync_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={}",
|
|
|
+ generatedReport.reportId(),
|
|
|
+ generatedReport.reportType(),
|
|
|
+ generatedReport.generationStatus(),
|
|
|
+ elapsedMillis(startedNanos),
|
|
|
+ generatedReport.storageObjectKey());
|
|
|
+ return new CreateExamSprintReportWithUrlResponse(
|
|
|
+ generatedReport.reportId(),
|
|
|
+ generatedReport.reportType(),
|
|
|
+ generatedReport.generationStatus(),
|
|
|
+ generatedReport.createdAt(),
|
|
|
+ generatedReport.updatedAt(),
|
|
|
+ generatedReport.expiresAt(),
|
|
|
+ downloadUrl);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Replace `getReport` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `getReport(String reportId)` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Override
|
|
|
+ public ExamSprintReportDetailResponse getReport(String reportId) {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ ExamSprintReport report = requireReport(reportId);
|
|
|
+ if (report.isExpiredAt(now) && report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
|
|
|
+ report = repository.save(report.expired(now));
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_marked_expired_on_query reportId={} reportType={} generationStatus={} expiresAt={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.expiresAt());
|
|
|
+ }
|
|
|
+
|
|
|
+ String downloadUrl = null;
|
|
|
+ if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
|
|
|
+ && !report.isExpiredAt(now)
|
|
|
+ && report.storageObjectKey() != null) {
|
|
|
+ downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_query_completed reportId={} reportType={} generationStatus={} downloadUrlIncluded={} storageObjectKeyPresent={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ downloadUrl != null,
|
|
|
+ report.storageObjectKey() != null);
|
|
|
+ return new ExamSprintReportDetailResponse(
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.createdAt(),
|
|
|
+ report.updatedAt(),
|
|
|
+ report.expiresAt(),
|
|
|
+ downloadUrl,
|
|
|
+ report.failureReason());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Replace `downloadReport` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `downloadReport(String reportId)` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Override
|
|
|
+ public ReportDownloadContent downloadReport(String reportId) {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ ExamSprintReport report = requireReport(reportId);
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_download_started reportId={} reportType={} generationStatus={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus());
|
|
|
+ if (report.isExpiredAt(now)) {
|
|
|
+ if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
|
|
|
+ repository.save(report.expired(now));
|
|
|
+ }
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.expiresAt());
|
|
|
+ throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+ if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS) {
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=not_success",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus());
|
|
|
+ throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+ if (report.storageObjectKey() == null) {
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=missing_storage_key",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus());
|
|
|
+ throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+ return storage.download(report.storageObjectKey())
|
|
|
+ .map(content -> {
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_download_succeeded reportId={} reportType={} storageObjectKey={} fileName={} bytes={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.storageObjectKey(),
|
|
|
+ content.fileName(),
|
|
|
+ content.bytes().length);
|
|
|
+ return new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType());
|
|
|
+ })
|
|
|
+ .orElseThrow(() -> {
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_download_missing_storage_content reportId={} reportType={} storageObjectKey={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.storageObjectKey());
|
|
|
+ return new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ });
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Replace `cleanupExpiredReports` with the logged implementation**
|
|
|
+
|
|
|
+Replace the whole `cleanupExpiredReports()` method with:
|
|
|
+
|
|
|
+```java
|
|
|
+ public void cleanupExpiredReports() {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ List<ExamSprintReport> expiredReports = repository.findExpiredAtOrBefore(now);
|
|
|
+ int storageClearedCount = 0;
|
|
|
+ int markedExpiredCount = 0;
|
|
|
+ int failedCount = 0;
|
|
|
+ for (ExamSprintReport report : expiredReports) {
|
|
|
+ try {
|
|
|
+ if (report.storageObjectKey() != null) {
|
|
|
+ storage.delete(report.storageObjectKey());
|
|
|
+ repository.save(report.expiredWithStorageCleared(now));
|
|
|
+ storageClearedCount++;
|
|
|
+ } else if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
|
|
|
+ repository.save(report.expired(now));
|
|
|
+ markedExpiredCount++;
|
|
|
+ }
|
|
|
+ } catch (RuntimeException exception) {
|
|
|
+ failedCount++;
|
|
|
+ log.warn(
|
|
|
+ "exam_sprint_report_cleanup_item_failed reportId={} reportType={} generationStatus={} storageObjectKey={} exceptionType={}",
|
|
|
+ report.reportId(),
|
|
|
+ report.reportType(),
|
|
|
+ report.generationStatus(),
|
|
|
+ report.storageObjectKey(),
|
|
|
+ exception.getClass().getSimpleName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info(
|
|
|
+ "exam_sprint_report_cleanup_completed scannedCount={} storageClearedCount={} markedExpiredCount={} failedCount={}",
|
|
|
+ expiredReports.size(),
|
|
|
+ storageClearedCount,
|
|
|
+ markedExpiredCount,
|
|
|
+ failedCount);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: Add elapsed-time helper**
|
|
|
+
|
|
|
+Add this method below `requireReport` and above `dispatchFailureReason`:
|
|
|
+
|
|
|
+```java
|
|
|
+ private long elapsedMillis(long startedNanos) {
|
|
|
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: Run the focused test and verify it passes**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. The output-capture tests should find async submit, dispatch failure, query, missing storage download, and cleanup logs.
|
|
|
+
|
|
|
+## Task 5: Regression verification
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Verify only; no additional source edits expected.
|
|
|
+
|
|
|
+- [ ] **Step 1: Run the application report tests together**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 2: Run the full application module tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl abilities/exam-sprint/application -am test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 3: Inspect the diff for sensitive logging**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java docs/superpowers/specs/2026-04-27-exam-sprint-report-logging-design.md docs/superpowers/plans/2026-04-27-exam-sprint-report-logging.md
|
|
|
+```
|
|
|
+
|
|
|
+Expected: Diff contains `reportId`, `reportType`, `generationStatus`, `stage`, `durationMs`, `storageObjectKey`, and `fileName` logs. Diff does not log full payload JSON, rendered HTML, PDF bytes content, Azure connection strings, Azure account names, Azure account keys, or complete exception messages for async dispatch failure.
|
|
|
+
|
|
|
+- [ ] **Step 4: Do not create a git commit unless explicitly requested**
|
|
|
+
|
|
|
+Because the current session has no explicit user request to commit, stop after verification and report the modified files plus test results. If the user later asks to commit, follow the repository git safety protocol before creating a commit.
|