Răsfoiți Sursa

feat(exam-sprint): 支持同步返回报表下载链接

金逸霄 2 săptămâni în urmă
părinte
comite
c665cb4d4b
10 a modificat fișierele cu 435 adăugiri și 77 ștergeri
  1. 51 1
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  2. 5 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
  3. 81 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
  4. 4 62
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java
  5. 126 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  6. 47 14
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  7. 14 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportWithUrlResponse.java
  8. 11 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java
  9. 43 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  10. 53 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java

+ 51 - 1
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

@@ -1,7 +1,8 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
@@ -32,6 +33,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     private final ExamSprintReportRepository repository;
     private final ExamSprintReportGenerationDispatcher dispatcher;
+    private final ExamSprintReportGenerationPipeline pipeline;
     private final ExamSprintReportStorage storage;
     private final List<ExamSprintReportRenderer> renderers;
     private final ExamSprintReportProperties properties;
@@ -42,6 +44,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     public DefaultExamSprintReportApplicationService(
             ExamSprintReportRepository repository,
             ExamSprintReportGenerationDispatcher dispatcher,
+            ExamSprintReportGenerationPipeline pipeline,
             ExamSprintReportStorage storage,
             ExamSprintReportProperties properties,
             Clock clock,
@@ -50,6 +53,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
             List<ExamSprintReportRenderer> renderers) {
         this.repository = repository;
         this.dispatcher = dispatcher;
+        this.pipeline = pipeline;
         this.storage = storage;
         this.renderers = List.copyOf(renderers);
         this.properties = properties;
@@ -70,6 +74,18 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         return submitReportGeneration(ExamSprintReportType.ACHIEVEMENT, payload);
     }
 
+    @Override
+    public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
+        validateOutlookPayload(payload);
+        return submitReportGenerationSync(ExamSprintReportType.OUTLOOK, payload);
+    }
+
+    @Override
+    public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
+        validateAchievementPayload(payload);
+        return submitReportGenerationSync(ExamSprintReportType.ACHIEVEMENT, payload);
+    }
+
     private CreateExamSprintReportResponse submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) {
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
@@ -92,6 +108,40 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 report.expiresAt());
     }
 
+    private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
+            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);
+
+        ExamSprintReport generatedReport = pipeline.generate(report.reportId())
+                .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
+        if (generatedReport.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS
+                || generatedReport.storageObjectKey() == null) {
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+
+        String downloadUrl = storage.generateDownloadUrl(
+                generatedReport.storageObjectKey(),
+                properties.getDownloadExpiry()).toString();
+        String previewHtmlUrl = "/api/exam-sprint/reports/" + generatedReport.reportId() + "/preview/html";
+        return new CreateExamSprintReportWithUrlResponse(
+                generatedReport.reportId(),
+                generatedReport.reportType(),
+                generatedReport.generationStatus(),
+                generatedReport.createdAt(),
+                generatedReport.updatedAt(),
+                generatedReport.expiresAt(),
+                downloadUrl,
+                previewHtmlUrl);
+    }
+
     @Override
     public ExamSprintReportDetailResponse getReport(String reportId) {
         Instant now = clock.instant();

+ 5 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java

@@ -1,6 +1,7 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import com.fasterxml.jackson.databind.JsonNode;
 
@@ -10,6 +11,10 @@ public interface ExamSprintReportApplicationService {
 
     CreateExamSprintReportResponse createAchievementReport(JsonNode payload);
 
+    CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload);
+
+    CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload);
+
     ExamSprintReportDetailResponse getReport(String reportId);
 
     ReportDownloadContent downloadReport(String reportId);

+ 81 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -0,0 +1,81 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class ExamSprintReportGenerationPipeline {
+
+    private final ExamSprintReportRepository repository;
+    private final List<ExamSprintReportRenderer> renderers;
+    private final ExamSprintReportPdfGenerator pdfGenerator;
+    private final ExamSprintReportStorage storage;
+    private final Clock clock;
+
+    public ExamSprintReportGenerationPipeline(
+            ExamSprintReportRepository repository,
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator,
+            ExamSprintReportStorage storage,
+            Clock clock) {
+        this.repository = repository;
+        this.renderers = List.copyOf(renderers);
+        this.pdfGenerator = pdfGenerator;
+        this.storage = storage;
+        this.clock = clock;
+    }
+
+    public Optional<ExamSprintReport> generate(String reportId) {
+        Instant startedAt = clock.instant();
+        ExamSprintReport report = repository.findById(reportId).orElse(null);
+        if (report == null
+                || report.generationStatus() != ExamSprintReportGenerationStatus.PENDING
+                || report.isExpiredAt(startedAt)) {
+            return Optional.empty();
+        }
+
+        ExamSprintReport processingReport = repository.save(report.processing(startedAt));
+        try {
+            String html = rendererFor(processingReport).render(processingReport.payload(), startedAt);
+            byte[] pdfBytes = pdfGenerator.generate(html);
+            ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
+                    processingReport.reportId(),
+                    processingReport.reportType(),
+                    fileNameOf(processingReport),
+                    pdfBytes,
+                    processingReport.expiresAt());
+            return Optional.of(repository.save(repository.findById(reportId)
+                    .orElseThrow()
+                    .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName())));
+        } catch (Exception exception) {
+            return repository.findById(reportId)
+                    .map(current -> repository.save(current.failed(clock.instant(), failureReasonOf(exception))));
+        }
+    }
+
+    private ExamSprintReportRenderer rendererFor(ExamSprintReport report) {
+        return renderers.stream()
+                .filter(renderer -> renderer.supports(report.reportType()))
+                .findFirst()
+                .orElseThrow(() -> new IllegalStateException("No renderer for report type " + report.reportType()));
+    }
+
+    private String fileNameOf(ExamSprintReport report) {
+        return "exam-sprint-" + report.reportType().name().toLowerCase() + "-report-" + report.reportId() + ".pdf";
+    }
+
+    private String failureReasonOf(Exception exception) {
+        String message = exception.getMessage();
+        return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
+    }
+}

+ 4 - 62
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java

@@ -1,75 +1,17 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import org.springframework.stereotype.Service;
 
-import java.time.Clock;
-import java.time.Instant;
-import java.util.List;
-
 @Service
 public class ExamSprintReportGenerationWorker {
 
-    private final ExamSprintReportRepository repository;
-    private final List<ExamSprintReportRenderer> renderers;
-    private final ExamSprintReportPdfGenerator pdfGenerator;
-    private final ExamSprintReportStorage storage;
-    private final Clock clock;
+    private final ExamSprintReportGenerationPipeline pipeline;
 
-    public ExamSprintReportGenerationWorker(
-            ExamSprintReportRepository repository,
-            List<ExamSprintReportRenderer> renderers,
-            ExamSprintReportPdfGenerator pdfGenerator,
-            ExamSprintReportStorage storage,
-            Clock clock) {
-        this.repository = repository;
-        this.renderers = renderers;
-        this.pdfGenerator = pdfGenerator;
-        this.storage = storage;
-        this.clock = clock;
+    public ExamSprintReportGenerationWorker(ExamSprintReportGenerationPipeline pipeline) {
+        this.pipeline = pipeline;
     }
 
     public void process(String reportId) {
-        Instant startedAt = clock.instant();
-        ExamSprintReport report = repository.findById(reportId).orElse(null);
-        if (report == null
-                || report.generationStatus() != ExamSprintReportGenerationStatus.PENDING
-                || report.isExpiredAt(startedAt)) {
-            return;
-        }
-
-        repository.save(report.processing(startedAt));
-
-        try {
-            String html = rendererFor(report).render(report.payload(), startedAt);
-            byte[] pdfBytes = pdfGenerator.generate(html);
-            String typeSegment = report.reportType().name().toLowerCase();
-            String fileName = "exam-sprint-" + typeSegment + "-report-" + report.reportId() + ".pdf";
-            ExamSprintReportStorage.StoredExamSprintReportFile storedFile =
-                    storage.upload(report.reportId(), report.reportType(), fileName, pdfBytes, report.expiresAt());
-            repository.save(repository.findById(reportId)
-                    .orElseThrow()
-                    .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName()));
-        } catch (Exception exception) {
-            repository.findById(reportId)
-                    .ifPresent(current -> repository.save(current.failed(clock.instant(), failureReasonOf(exception))));
-        }
-    }
-
-    private ExamSprintReportRenderer rendererFor(ExamSprintReport report) {
-        return renderers.stream()
-                .filter(renderer -> renderer.supports(report.reportType()))
-                .findFirst()
-                .orElseThrow(() -> new IllegalStateException("No renderer for report type " + report.reportType()));
-    }
-
-    private String failureReasonOf(Exception exception) {
-        String message = exception.getMessage();
-        return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
+        pipeline.generate(reportId);
     }
 }

+ 126 - 0
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -5,6 +5,7 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportG
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
@@ -21,6 +22,7 @@ import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.time.Clock;
 import java.time.Duration;
 import java.time.Instant;
@@ -71,6 +73,80 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
     }
 
+    @Test
+    void createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> {
+                    throw new IllegalStateException("sync create must not dispatch async generation");
+                },
+                storage);
+
+        var response = service.createOutlookReportSync(validOutlookPayload());
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(response.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
+        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
+        assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/preview/html");
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-" + response.reportId() + ".pdf");
+        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+    }
+
+    @Test
+    void createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> {
+                    throw new IllegalStateException("sync create must not dispatch async generation");
+                },
+                storage);
+
+        var response = service.createAchievementReportSync(validAchievementPayload());
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(response.reportType()).isEqualTo(ExamSprintReportType.ACHIEVEMENT);
+        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
+        assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/preview/html");
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-achievement-report-" + response.reportId() + ".pdf");
+        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+    }
+
+    @Test
+    void createOutlookReportSyncRejectsInvalidPayloadBeforeSaving() {
+        assertCreateOutlookReportSyncRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
+    }
+
+    @Test
+    void createOutlookReportSyncThrowsDownloadUnavailableAndKeepsFailedReportWhenGenerationFails() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> { },
+                new TestStorage(),
+                List.of(new FailingRenderer()),
+                html -> html.getBytes(StandardCharsets.UTF_8));
+
+        assertThatThrownBy(() -> service.createOutlookReportSync(validOutlookPayload()))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+
+        assertThat(repository.storage.values())
+                .singleElement()
+                .extracting(ExamSprintReport::generationStatus)
+                .isEqualTo(ExamSprintReportGenerationStatus.FAILED);
+    }
+
     @Test
     void createAchievementReportRejectsInvalidAchievementPayloadBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
@@ -287,9 +363,29 @@ class ExamSprintReportApplicationServiceTest {
             TestRepository repository,
             ExamSprintReportGenerationDispatcher dispatcher,
             TestStorage storage) {
+        return service(
+                repository,
+                dispatcher,
+                storage,
+                List.of(new PreviewTestRenderer()),
+                html -> html.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private DefaultExamSprintReportApplicationService service(
+            TestRepository repository,
+            ExamSprintReportGenerationDispatcher dispatcher,
+            TestStorage storage,
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator) {
         return new DefaultExamSprintReportApplicationService(
                 repository,
                 dispatcher,
+                new ExamSprintReportGenerationPipeline(
+                        repository,
+                        renderers,
+                        pdfGenerator,
+                        storage,
+                        FIXED_CLOCK),
                 storage,
                 properties(),
                 FIXED_CLOCK,
@@ -437,6 +533,23 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(dispatched[0]).isFalse();
     }
 
+    private void assertCreateOutlookReportSyncRejectsInvalidPayload(JsonNode payload) {
+        TestRepository repository = new TestRepository();
+        boolean[] dispatched = {false};
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> dispatched[0] = true,
+                new TestStorage());
+
+        assertThatThrownBy(() -> service.createOutlookReportSync(payload))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+
+        assertThat(repository.storage).isEmpty();
+        assertThat(dispatched[0]).isFalse();
+    }
+
     private static class TestRepository implements ExamSprintReportRepository {
         private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
 
@@ -525,4 +638,17 @@ class ExamSprintReportApplicationServiceTest {
             return "<html><body>preview:" + payload.path("reportTitle").asText() + ":" + generatedAt + "</body></html>";
         }
     }
+
+    private static class FailingRenderer implements ExamSprintReportRenderer {
+
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK;
+        }
+
+        @Override
+        public String render(JsonNode payload, Instant generatedAt) {
+            throw new IllegalStateException("renderer exploded");
+        }
+    }
 }

+ 47 - 14
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -37,12 +37,7 @@ class ExamSprintReportGenerationWorkerTest {
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
-        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
-                repository,
-                List.of(new TestRenderer()),
-                html -> html.getBytes(StandardCharsets.UTF_8),
-                storage,
-                FIXED_CLOCK);
+        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
 
         worker.process("report-success");
 
@@ -62,12 +57,10 @@ class ExamSprintReportGenerationWorkerTest {
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
-        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
+        ExamSprintReportGenerationWorker worker = createWorker(
                 repository,
                 List.of(new TestRenderer(ExamSprintReportType.ACHIEVEMENT)),
-                html -> html.getBytes(StandardCharsets.UTF_8),
-                storage,
-                FIXED_CLOCK);
+                storage);
 
         worker.process("report-achievement");
 
@@ -87,12 +80,10 @@ class ExamSprintReportGenerationWorkerTest {
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
 
-        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
+        ExamSprintReportGenerationWorker worker = createWorker(
                 repository,
                 List.of(new FailingRenderer()),
-                html -> html.getBytes(StandardCharsets.UTF_8),
-                new TestStorage(),
-                FIXED_CLOCK);
+                new TestStorage());
 
         worker.process("report-failed");
 
@@ -101,6 +92,37 @@ class ExamSprintReportGenerationWorkerTest {
         assertThat(report.failureReason()).isEqualTo("renderer exploded");
     }
 
+    @Test
+    void processDoesNotResurrectReportDeletedBeforeFinalSuccessSave() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-deleted",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+        TestStorage storage = new TestStorage();
+        storage.onUpload(() -> repository.delete("report-deleted"));
+        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
+
+        worker.process("report-deleted");
+
+        assertThat(repository.findById("report-deleted")).isEmpty();
+    }
+
+    private ExamSprintReportGenerationWorker createWorker(
+            TestRepository repository,
+            List<ExamSprintReportRenderer> renderers,
+            TestStorage storage) {
+        return new ExamSprintReportGenerationWorker(
+                new ExamSprintReportGenerationPipeline(
+                        repository,
+                        renderers,
+                        html -> html.getBytes(StandardCharsets.UTF_8),
+                        storage,
+                        FIXED_CLOCK));
+    }
+
     private static class TestRenderer implements ExamSprintReportRenderer {
         private final ExamSprintReportType supportedReportType;
 
@@ -149,6 +171,10 @@ class ExamSprintReportGenerationWorkerTest {
             return Optional.ofNullable(storage.get(reportId));
         }
 
+        void delete(String reportId) {
+            storage.remove(reportId);
+        }
+
         @Override
         public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
             return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList();
@@ -156,6 +182,8 @@ class ExamSprintReportGenerationWorkerTest {
     }
 
     private static class TestStorage implements ExamSprintReportStorage {
+        private Runnable uploadCallback = () -> { };
+
         @Override
         public StoredExamSprintReportFile upload(
                 String reportId,
@@ -163,9 +191,14 @@ class ExamSprintReportGenerationWorkerTest {
                 String fileName,
                 byte[] pdfBytes,
                 Instant expiresAt) {
+            uploadCallback.run();
             return new StoredExamSprintReportFile(fileName, fileName);
         }
 
+        void onUpload(Runnable uploadCallback) {
+            this.uploadCallback = uploadCallback;
+        }
+
         @Override
         public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
             return URI.create("https://download.example.local/" + storageObjectKey + "?sig=test");

+ 14 - 0
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportWithUrlResponse.java

@@ -0,0 +1,14 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import java.time.Instant;
+
+public record CreateExamSprintReportWithUrlResponse(
+        String reportId,
+        ExamSprintReportType reportType,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String downloadUrl,
+        String previewHtmlUrl) {
+}

+ 11 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java

@@ -4,6 +4,7 @@ import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintRepor
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportDownloadContent;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportHtmlPreviewContent;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.kernel.BaseResponse;
 import com.fasterxml.jackson.databind.JsonNode;
@@ -43,6 +44,16 @@ public class ExamSprintReportController {
         return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createAchievementReport(payload)));
     }
 
+    @PostMapping("/outlook-reports/sync")
+    public BaseResponse<CreateExamSprintReportWithUrlResponse> createOutlookReportSync(@RequestBody JsonNode payload) {
+        return BaseResponse.success(applicationService.createOutlookReportSync(payload));
+    }
+
+    @PostMapping("/achievement-reports/sync")
+    public BaseResponse<CreateExamSprintReportWithUrlResponse> createAchievementReportSync(@RequestBody JsonNode payload) {
+        return BaseResponse.success(applicationService.createAchievementReportSync(payload));
+    }
+
     @PostMapping("/reports")
     public ResponseEntity<Void> createReportDeprecated() {
         return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).build();

+ 43 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java

@@ -51,6 +51,26 @@ class ExamSprintReportControllerTest {
                 .andExpect(jsonPath("$.data.generationStatus").value("PENDING"));
     }
 
+    @Test
+    void createOutlookReportSyncReturnsDownloadUrlAndPdfIsDownloadable() throws Exception {
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports/sync")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(payloadJson(validRequestJson())))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.reportId").isNotEmpty())
+                .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
+                .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.downloadUrl").isNotEmpty())
+                .andExpect(jsonPath("$.data.previewHtmlUrl").isNotEmpty())
+                .andReturn();
+
+        URI downloadUri = URI.create(readJson(createResult).at("/data/downloadUrl").asText());
+
+        mockMvc.perform(get(downloadUri))
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
+    }
+
     @Test
     void createReportWithInvalidPayloadReturnsValidationError() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/outlook-reports")
@@ -94,6 +114,29 @@ class ExamSprintReportControllerTest {
                 .doesNotContain("echarts");
     }
 
+    @Test
+    void createAchievementReportSyncReturnsDownloadUrlAndPreviewHtml() throws Exception {
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports/sync")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(payloadJson(validAchievementRequestJson())))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
+                .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.downloadUrl").isNotEmpty())
+                .andExpect(jsonPath("$.data.previewHtmlUrl").isNotEmpty())
+                .andReturn();
+
+        URI previewHtmlUri = URI.create(readJson(createResult).at("/data/previewHtmlUrl").asText());
+
+        MvcResult previewResult = mockMvc.perform(get(previewHtmlUri))
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
+                .andReturn();
+
+        assertThat(previewResult.getResponse().getContentAsString(StandardCharsets.UTF_8))
+                .contains("高考英语临考突击学习成果报告");
+    }
+
     @Test
     void createAchievementReportWithInvalidPayloadReturnsValidationError() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/achievement-reports")

+ 53 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java

@@ -4,6 +4,7 @@ import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintRepor
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportDownloadContent;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportHtmlPreviewContent;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
@@ -107,6 +108,58 @@ class ExamSprintReportControllerWebMvcTest {
         Assertions.assertEquals("number", payload.path("examUnknownWordsHitStatus").path("hitWords").get(0).asText());
     }
 
+    @Test
+    void createOutlookReportSyncReturnsDownloadUrl() throws Exception {
+        given(applicationService.createOutlookReportSync(any())).willReturn(new CreateExamSprintReportWithUrlResponse(
+                "report-sync-001",
+                ExamSprintReportType.OUTLOOK,
+                ExamSprintReportGenerationStatus.SUCCESS,
+                Instant.parse("2026-01-01T00:00:00Z"),
+                Instant.parse("2026-01-01T00:01:00Z"),
+                Instant.parse("2026-01-02T00:00:00Z"),
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf",
+                "/api/exam-sprint/reports/report-sync-001/preview/html"));
+
+        String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
+
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports/sync")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(requestJson))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
+                .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"))
+                .andExpect(jsonPath("$.data.previewHtmlUrl").value("/api/exam-sprint/reports/report-sync-001/preview/html"));
+
+        verify(applicationService).createOutlookReportSync(any());
+    }
+
+    @Test
+    void createAchievementReportSyncReturnsDownloadUrl() throws Exception {
+        given(applicationService.createAchievementReportSync(any())).willReturn(new CreateExamSprintReportWithUrlResponse(
+                "report-sync-002",
+                ExamSprintReportType.ACHIEVEMENT,
+                ExamSprintReportGenerationStatus.SUCCESS,
+                Instant.parse("2026-01-01T00:00:00Z"),
+                Instant.parse("2026-01-01T00:01:00Z"),
+                Instant.parse("2026-01-02T00:00:00Z"),
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf",
+                "/api/exam-sprint/reports/report-sync-002/preview/html"));
+
+        String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
+
+        mockMvc.perform(post("/api/exam-sprint/achievement-reports/sync")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(requestJson))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
+                .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"))
+                .andExpect(jsonPath("$.data.previewHtmlUrl").value("/api/exam-sprint/reports/report-sync-002/preview/html"));
+
+        verify(applicationService).createAchievementReportSync(any());
+    }
+
     @Test
     void oldGenericCreateReportEndpointIsRemoved() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/reports")