Преглед изворни кода

feat(exam-sprint): 重构报告接口并扁平化存储路径

金逸霄 пре 2 недеља
родитељ
комит
37a719177e
16 измењених фајлова са 537 додато и 729 уклоњено
  1. 14 17
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  2. 4 2
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
  3. 9 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java
  4. 64 74
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  5. 3 5
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  6. 0 9
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.java
  7. 52 8
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java
  8. 14 8
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java
  9. 110 6
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java
  10. 54 276
      ability-center-runtime/scripts/achievement-report-demo.sh
  11. 85 261
      ability-center-runtime/scripts/outlook-report-demo.sh
  12. 21 10
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java
  13. 1 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java
  14. 20 12
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  15. 65 41
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java
  16. 21 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

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

@@ -1,6 +1,5 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
 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.ExamSprintReportDetailResponse;
@@ -60,13 +59,23 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     @Override
-    public CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request) {
-        validateCreateRequest(request);
+    public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
+        validateOutlookPayload(payload);
+        return submitReportGeneration(ExamSprintReportType.OUTLOOK, payload);
+    }
+
+    @Override
+    public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
+        validateAchievementPayload(payload);
+        return submitReportGeneration(ExamSprintReportType.ACHIEVEMENT, payload);
+    }
+
+    private CreateExamSprintReportResponse submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) {
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
                 UUID.randomUUID().toString(),
-                request.reportType(),
-                request.payload(),
+                reportType,
+                payload,
                 now,
                 now.plus(properties.getRetention()));
         repository.save(report);
@@ -176,18 +185,6 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         return REPORT_GENERATION_DISPATCH_FAILED;
     }
 
-    private void validateCreateRequest(CreateExamSprintReportRequest request) {
-        if (request.reportType() == ExamSprintReportType.OUTLOOK) {
-            validateOutlookPayload(request.payload());
-            return;
-        }
-        if (request.reportType() == ExamSprintReportType.ACHIEVEMENT) {
-            validateAchievementPayload(request.payload());
-            return;
-        }
-        throw new BusinessException(ErrorCode.REPORT_TYPE_UNSUPPORTED);
-    }
-
     private void validateOutlookPayload(JsonNode payload) {
         OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
 

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

@@ -1,12 +1,14 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
+import com.fasterxml.jackson.databind.JsonNode;
 
 public interface ExamSprintReportApplicationService {
 
-    CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request);
+    CreateExamSprintReportResponse createOutlookReport(JsonNode payload);
+
+    CreateExamSprintReportResponse createAchievementReport(JsonNode payload);
 
     ExamSprintReportDetailResponse getReport(String reportId);
 

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

@@ -75,6 +75,7 @@ public class ExamSprintReportProperties {
     public static class Storage {
         private String type = "memory";
         private String containerName = "exam-sprint-reports";
+        private String urlPrefix;
         private String connectionString;
         private String endpoint;
         private String accountName;
@@ -96,6 +97,14 @@ public class ExamSprintReportProperties {
             this.containerName = containerName;
         }
 
+        public String getUrlPrefix() {
+            return urlPrefix;
+        }
+
+        public void setUrlPrefix(String urlPrefix) {
+            this.urlPrefix = urlPrefix;
+        }
+
         public String getConnectionString() {
             return connectionString;
         }

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

@@ -1,6 +1,5 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
@@ -11,6 +10,7 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepo
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.Validation;
@@ -43,16 +43,12 @@ class ExamSprintReportApplicationServiceTest {
     private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
 
     @Test
-    void createReportStoresOutlookTypeAndReturnsReportId() {
+    void createOutlookReportStoresOutlookTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
-        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
-                ExamSprintReportType.OUTLOOK,
-                validOutlookPayload());
-
-        var response = service.createReport(request);
+        var response = service.createOutlookReport(validOutlookPayload());
 
         assertThat(response.reportId()).isNotBlank();
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
@@ -61,16 +57,12 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void createReportStoresAchievementTypeAndReturnsReportId() {
+    void createAchievementReportStoresAchievementTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
-        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
-                ExamSprintReportType.ACHIEVEMENT,
-                validAchievementPayload());
-
-        var response = service.createReport(request);
+        var response = service.createAchievementReport(validAchievementPayload());
 
         assertThat(response.reportId()).isNotBlank();
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
@@ -80,70 +72,42 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void createReportRejectsInvalidAchievementPayloadBeforeSaving() {
-        TestRepository repository = new TestRepository();
-        boolean[] dispatched = {false};
-        DefaultExamSprintReportApplicationService service = service(
-                repository,
-                reportId -> dispatched[0] = true,
-                new TestStorage());
+    void createAchievementReportRejectsInvalidAchievementPayloadBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
         invalidPayload.remove("reportTitle");
 
-        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
-                ExamSprintReportType.ACHIEVEMENT,
-                invalidPayload)))
-                .isInstanceOf(BusinessException.class)
-                .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.VALIDATION_ERROR);
-
-        assertThat(repository.storage).isEmpty();
-        assertThat(dispatched[0]).isFalse();
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
     @Test
-    void createReportRejectsNullOrNonObjectPayloadBeforeSaving() {
-        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.nullNode());
-        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.nullNode());
-        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
-        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
+    void createReportsRejectNullOrNonObjectPayloadBeforeSaving() {
+        assertCreateOutlookReportRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
+        assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
+        assertCreateOutlookReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
+        assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
     }
 
     @Test
-    void createReportRejectsMissingAchievementBeforeValueBeforeSaving() {
-        TestRepository repository = new TestRepository();
-        boolean[] dispatched = {false};
-        DefaultExamSprintReportApplicationService service = service(
-                repository,
-                reportId -> dispatched[0] = true,
-                new TestStorage());
+    void createAchievementReportRejectsMissingAchievementBeforeValueBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
         invalidPayload.withObject("vocabularyComparison").remove("beforeValue");
 
-        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
-                ExamSprintReportType.ACHIEVEMENT,
-                invalidPayload)))
-                .isInstanceOf(BusinessException.class)
-                .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.VALIDATION_ERROR);
-
-        assertThat(repository.storage).isEmpty();
-        assertThat(dispatched[0]).isFalse();
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
     @ParameterizedTest(name = "{0}")
     @MethodSource("invalidAchievementPayloadJsonTypes")
-    void createReportRejectsAchievementPayloadWithInvalidJsonTypes(
+    void createAchievementReportRejectsAchievementPayloadWithInvalidJsonTypes(
             String caseName,
             Consumer<ObjectNode> mutatePayload) {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
         mutatePayload.accept(invalidPayload);
 
-        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, invalidPayload);
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
     @Test
-    void createReportReturnsFailedStatusWhenDispatchFails() {
+    void createOutlookReportReturnsFailedStatusWhenDispatchFails() {
         TestRepository repository = new TestRepository();
         DefaultExamSprintReportApplicationService service = service(
                 repository,
@@ -152,11 +116,7 @@ class ExamSprintReportApplicationServiceTest {
                 },
                 new TestStorage());
 
-        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
-                ExamSprintReportType.OUTLOOK,
-                validOutlookPayload());
-
-        var response = service.createReport(request);
+        var response = service.createOutlookReport(validOutlookPayload());
 
         assertThat(response.reportId()).isNotBlank();
         assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
@@ -169,12 +129,12 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void createReportCopiesPayloadBeforeSaving() {
+    void createOutlookReportCopiesPayloadBeforeSaving() {
         TestRepository repository = new TestRepository();
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
         ObjectNode payload = validOutlookPayload().deepCopy();
 
-        var response = service.createReport(new CreateExamSprintReportRequest(ExamSprintReportType.OUTLOOK, payload));
+        var response = service.createOutlookReport(payload);
 
         payload.withObject("reportMetadata").put("learnerName", "王同学");
 
@@ -194,7 +154,7 @@ class ExamSprintReportApplicationServiceTest {
                         FIXED_CLOCK.instant().plusSeconds(3600))
                 .success(
                         FIXED_CLOCK.instant().minusSeconds(30),
-                        "exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf",
+                        "exam-sprint-achievement-report-report-success.pdf",
                         "exam-sprint-achievement-report-report-success.pdf");
         repository.save(report);
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
@@ -205,7 +165,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
         assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/report-success/preview/html");
         assertThat(storage.generatedKeys)
-                .containsExactly("exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf");
+                .containsExactly("exam-sprint-achievement-report-report-success.pdf");
     }
 
     @Test
@@ -219,7 +179,7 @@ class ExamSprintReportApplicationServiceTest {
                         FIXED_CLOCK.instant().plusSeconds(3600))
                 .success(
                         FIXED_CLOCK.instant().minusSeconds(30),
-                        "exam-sprint-reports/achievement/report-preview/exam-sprint-achievement-report-report-preview.pdf",
+                        "exam-sprint-achievement-report-report-preview.pdf",
                         "exam-sprint-achievement-report-report-preview.pdf"));
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
 
@@ -257,7 +217,7 @@ class ExamSprintReportApplicationServiceTest {
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
-                "exam-sprint-reports/outlook/report-expired/exam-sprint-outlook-report-report-expired.pdf",
+                "exam-sprint-outlook-report-report-expired.pdf",
                 "exam-sprint-outlook-report-report-expired.pdf"));
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
@@ -296,7 +256,7 @@ class ExamSprintReportApplicationServiceTest {
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
-                "exam-sprint-reports/outlook/report-delete-fails/first.pdf",
+                "first.pdf",
                 "first.pdf"));
         repository.save(ExamSprintReport.pending(
                 "report-delete-succeeds",
@@ -305,9 +265,9 @@ class ExamSprintReportApplicationServiceTest {
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
-                "exam-sprint-reports/outlook/report-delete-succeeds/second.pdf",
+                "second.pdf",
                 "second.pdf"));
-        storage.failDeleteFor("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
+        storage.failDeleteFor("first.pdf");
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
         service.cleanupExpiredReports();
@@ -315,12 +275,12 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport failedDeleteReport = repository.findById("report-delete-fails").orElseThrow();
         ExamSprintReport successfulDeleteReport = repository.findById("report-delete-succeeds").orElseThrow();
         assertThat(failedDeleteReport.storageObjectKey())
-                .isEqualTo("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
+                .isEqualTo("first.pdf");
         assertThat(successfulDeleteReport.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
         assertThat(successfulDeleteReport.storageObjectKey()).isNull();
         assertThat(storage.deletedKeys)
-                .contains("exam-sprint-reports/outlook/report-delete-fails/first.pdf")
-                .contains("exam-sprint-reports/outlook/report-delete-succeeds/second.pdf");
+                .contains("first.pdf")
+                .contains("second.pdf");
     }
 
     private DefaultExamSprintReportApplicationService service(
@@ -443,7 +403,7 @@ class ExamSprintReportApplicationServiceTest {
                         (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
     }
 
-    private void assertCreateReportRejectsInvalidPayload(ExamSprintReportType reportType, com.fasterxml.jackson.databind.JsonNode payload) {
+    private void assertCreateOutlookReportRejectsInvalidPayload(JsonNode payload) {
         TestRepository repository = new TestRepository();
         boolean[] dispatched = {false};
         DefaultExamSprintReportApplicationService service = service(
@@ -451,7 +411,24 @@ class ExamSprintReportApplicationServiceTest {
                 reportId -> dispatched[0] = true,
                 new TestStorage());
 
-        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(reportType, payload)))
+        assertThatThrownBy(() -> service.createOutlookReport(payload))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+
+        assertThat(repository.storage).isEmpty();
+        assertThat(dispatched[0]).isFalse();
+    }
+
+    private void assertCreateAchievementReportRejectsInvalidPayload(JsonNode payload) {
+        TestRepository repository = new TestRepository();
+        boolean[] dispatched = {false};
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> dispatched[0] = true,
+                new TestStorage());
+
+        assertThatThrownBy(() -> service.createAchievementReport(payload))
                 .isInstanceOf(BusinessException.class)
                 .extracting(exception -> ((BusinessException) exception).getErrorCode())
                 .isEqualTo(ErrorCode.VALIDATION_ERROR);
@@ -496,13 +473,13 @@ class ExamSprintReportApplicationServiceTest {
                 String fileName,
                 byte[] pdfBytes,
                 Instant expiresAt) {
-            return new StoredExamSprintReportFile("blob/" + reportId + "/" + fileName, fileName);
+            return new StoredExamSprintReportFile(fileName, fileName);
         }
 
         @Override
         public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
             generatedKeys.add(storageObjectKey);
-            return URI.create("/api/exam-sprint/reports/" + storageObjectKey.split("/")[2] + "/download");
+            return URI.create("/api/exam-sprint/reports/" + reportIdFromStorageObjectKey(storageObjectKey) + "/download");
         }
 
         @Override
@@ -521,6 +498,19 @@ class ExamSprintReportApplicationServiceTest {
         void failDeleteFor(String storageObjectKey) {
             deleteFailures.add(storageObjectKey);
         }
+
+        private String reportIdFromStorageObjectKey(String storageObjectKey) {
+            String fileName = storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
+            String outlookPrefix = "exam-sprint-outlook-report-";
+            String achievementPrefix = "exam-sprint-achievement-report-";
+            if (fileName.startsWith(outlookPrefix)) {
+                return fileName.substring(outlookPrefix.length(), fileName.length() - ".pdf".length());
+            }
+            if (fileName.startsWith(achievementPrefix)) {
+                return fileName.substring(achievementPrefix.length(), fileName.length() - ".pdf".length());
+            }
+            throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
+        }
     }
 
     private static class PreviewTestRenderer implements ExamSprintReportRenderer {

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

@@ -48,7 +48,7 @@ class ExamSprintReportGenerationWorkerTest {
 
         ExamSprintReport report = repository.findById("report-success").orElseThrow();
         assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
-        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
         assertThat(report.fileName()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
     }
 
@@ -74,8 +74,7 @@ class ExamSprintReportGenerationWorkerTest {
         ExamSprintReport report = repository.findById("report-achievement").orElseThrow();
         assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
         assertThat(report.fileName()).isEqualTo("exam-sprint-achievement-report-report-achievement.pdf");
-        assertThat(report.storageObjectKey()).isEqualTo(
-                "exam-sprint-reports/achievement/report-achievement/exam-sprint-achievement-report-report-achievement.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-achievement-report-report-achievement.pdf");
     }
 
     @Test
@@ -164,8 +163,7 @@ class ExamSprintReportGenerationWorkerTest {
                 String fileName,
                 byte[] pdfBytes,
                 Instant expiresAt) {
-            String typeSegment = reportType.name().toLowerCase();
-            return new StoredExamSprintReportFile("exam-sprint-reports/" + typeSegment + "/" + reportId + "/" + fileName, fileName);
+            return new StoredExamSprintReportFile(fileName, fileName);
         }
 
         @Override

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

@@ -1,9 +0,0 @@
-package cn.yunzhixue.ability.center.examsprint.contracts.report;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import jakarta.validation.constraints.NotNull;
-
-public record CreateExamSprintReportRequest(
-        @NotNull ExamSprintReportType reportType,
-        @NotNull JsonNode payload) {
-}

+ 52 - 8
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java

@@ -7,6 +7,7 @@ import com.azure.storage.blob.BlobContainerClient;
 import com.azure.storage.blob.BlobContainerClientBuilder;
 import com.azure.storage.blob.models.BlobHttpHeaders;
 import com.azure.storage.common.StorageSharedKeyCredential;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Component;
@@ -25,21 +26,30 @@ import java.util.Optional;
 public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage {
 
     private final BlobContainerClient containerClient;
+    private final String containerName;
+    private final String urlPrefix;
     private final Clock clock;
 
+    @Autowired
     public AzureBlobExamSprintReportStorage(
             @Value("${ability.exam-sprint.report.storage.container-name:exam-sprint-reports}") String containerName,
             @Value("${ability.exam-sprint.report.storage.connection-string:}") String connectionString,
             @Value("${ability.exam-sprint.report.storage.endpoint:}") String endpoint,
             @Value("${ability.exam-sprint.report.storage.account-name:}") String accountName,
             @Value("${ability.exam-sprint.report.storage.account-key:}") String accountKey,
+            @Value("${ability.exam-sprint.report.storage.url-prefix:}") String urlPrefix,
             Clock clock) {
-        this(buildContainerClient(containerName, connectionString, endpoint, accountName, accountKey), clock);
+        this(buildContainerClient(containerName, connectionString, endpoint, accountName, accountKey),
+                containerName,
+                urlPrefix,
+                clock);
         this.containerClient.createIfNotExists();
     }
 
-    AzureBlobExamSprintReportStorage(BlobContainerClient containerClient, Clock clock) {
+    AzureBlobExamSprintReportStorage(BlobContainerClient containerClient, String containerName, String urlPrefix, Clock clock) {
         this.containerClient = containerClient;
+        this.containerName = normalizeSegment(containerName, "containerName");
+        this.urlPrefix = normalizeUrlPrefix(urlPrefix);
         this.clock = clock;
     }
 
@@ -50,7 +60,7 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
             String fileName,
             byte[] pdfBytes,
             Instant expiresAt) {
-        String blobName = "exam-sprint-reports/" + reportType.name().toLowerCase() + "/" + reportId + "/" + fileName;
+        String blobName = fileName;
         BlobClient blobClient = containerClient.getBlobClient(blobName);
         blobClient.upload(new ByteArrayInputStream(pdfBytes), pdfBytes.length, true);
         blobClient.setHttpHeaders(new BlobHttpHeaders().setContentType("application/pdf"));
@@ -64,7 +74,9 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
 
     @Override
     public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
-        return URI.create("/api/exam-sprint/reports/" + reportId(storageObjectKey) + "/download");
+        // This implementation returns a public OSS/CDN URL, so ttl is not used.
+        String normalizedStorageObjectKey = normalizeSegment(storageObjectKey, "storageObjectKey");
+        return URI.create(urlPrefix + "/" + containerName + "/" + normalizedStorageObjectKey);
     }
 
     @Override
@@ -92,9 +104,40 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
         return storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
     }
 
-    private String reportId(String storageObjectKey) {
-        String[] segments = storageObjectKey.split("/");
-        return segments[2];
+    private static String normalizeUrlPrefix(String value) {
+        if (!hasText(value)) {
+            throw new IllegalStateException("Azure storage url-prefix is incomplete");
+        }
+        String normalized = value.trim();
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        if (!hasText(normalized)) {
+            throw new IllegalStateException("Azure storage url-prefix is incomplete");
+        }
+        return normalized;
+    }
+
+    private static String normalizeSegment(String value, String name) {
+        String normalized = trimSlashes(value);
+        if (!hasText(normalized)) {
+            throw new IllegalStateException("Azure storage " + name + " is incomplete");
+        }
+        return normalized;
+    }
+
+    private static String trimSlashes(String value) {
+        if (value == null) {
+            return "";
+        }
+        String normalized = value.trim();
+        while (normalized.startsWith("/")) {
+            normalized = normalized.substring(1);
+        }
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        return normalized;
     }
 
     private static BlobContainerClient buildContainerClient(
@@ -103,7 +146,8 @@ public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage
             String endpoint,
             String accountName,
             String accountKey) {
-        BlobContainerClientBuilder builder = new BlobContainerClientBuilder().containerName(containerName);
+        String normalizedContainerName = normalizeSegment(containerName, "containerName");
+        BlobContainerClientBuilder builder = new BlobContainerClientBuilder().containerName(normalizedContainerName);
 
         if (hasText(connectionString)) {
             builder.connectionString(connectionString);

+ 14 - 8
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java

@@ -25,15 +25,13 @@ public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage
             String fileName,
             byte[] pdfBytes,
             Instant expiresAt) {
-        String typeSegment = reportType.name().toLowerCase();
-        String storageObjectKey = "exam-sprint-reports/" + typeSegment + "/" + reportId + "/" + fileName;
-        storage.put(storageObjectKey, new StoredFile(fileName, pdfBytes.clone(), "application/pdf"));
-        return new StoredExamSprintReportFile(storageObjectKey, fileName);
+        storage.put(fileName, new StoredFile(fileName, pdfBytes.clone(), "application/pdf"));
+        return new StoredExamSprintReportFile(fileName, fileName);
     }
 
     @Override
     public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
-        return URI.create("/api/exam-sprint/reports/" + reportId(storageObjectKey) + "/download");
+        return URI.create("/api/exam-sprint/reports/" + reportIdFromStorageObjectKey(storageObjectKey) + "/download");
     }
 
     @Override
@@ -53,9 +51,17 @@ public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage
         storage.remove(storageObjectKey);
     }
 
-    private String reportId(String storageObjectKey) {
-        String[] segments = storageObjectKey.split("/");
-        return segments[2];
+    private String reportIdFromStorageObjectKey(String storageObjectKey) {
+        String fileName = storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
+        String outlookPrefix = "exam-sprint-outlook-report-";
+        String achievementPrefix = "exam-sprint-achievement-report-";
+        if (fileName.startsWith(outlookPrefix)) {
+            return fileName.substring(outlookPrefix.length(), fileName.length() - ".pdf".length());
+        }
+        if (fileName.startsWith(achievementPrefix)) {
+            return fileName.substring(achievementPrefix.length(), fileName.length() - ".pdf".length());
+        }
+        throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
     }
 
     private record StoredFile(String fileName, byte[] bytes, String contentType) {

+ 110 - 6
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java

@@ -4,7 +4,9 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportT
 import com.azure.storage.blob.BlobClient;
 import com.azure.storage.blob.BlobContainerClient;
 import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
 
+import java.lang.reflect.Constructor;
 import java.net.URI;
 import java.time.Clock;
 import java.time.Duration;
@@ -12,6 +14,7 @@ import java.time.Instant;
 import java.time.ZoneOffset;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -23,24 +26,125 @@ class AzureBlobExamSprintReportStorageTest {
     private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-03T08:00:00Z"), ZoneOffset.UTC);
 
     @Test
-    void generateDownloadUrlReturnsApplicationDownloadEndpoint() {
+    void springSelectsTheAutowiredConstructor() throws Exception {
+        AutowiredAnnotationBeanPostProcessor beanPostProcessor = new AutowiredAnnotationBeanPostProcessor();
+
+        Constructor<?>[] candidateConstructors = beanPostProcessor.determineCandidateConstructors(
+                AzureBlobExamSprintReportStorage.class,
+                "azureBlobExamSprintReportStorage");
+
+        Constructor<AzureBlobExamSprintReportStorage> expectedConstructor =
+                AzureBlobExamSprintReportStorage.class.getConstructor(
+                        String.class,
+                        String.class,
+                        String.class,
+                        String.class,
+                        String.class,
+                        String.class,
+                        Clock.class);
+
+        assertThat(candidateConstructors).containsExactly(expectedConstructor);
+    }
+
+    @Test
+    void generateDownloadUrlReturnsConfiguredOssFileLink() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
-        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(containerClient, FIXED_CLOCK);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                "https://dcjxb-cdntest.yunzhixue.cn",
+                FIXED_CLOCK);
 
         URI downloadUrl = storage.generateDownloadUrl(
-                "exam-sprint-reports/outlook/report-123/exam-sprint-outlook-report-report-123.pdf",
+                "exam-sprint-outlook-report-report-123.pdf",
                 Duration.ofMinutes(15));
 
-        assertThat(downloadUrl).isEqualTo(URI.create("/api/exam-sprint/reports/report-123/download"));
+        assertThat(downloadUrl).isEqualTo(URI.create(
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-123.pdf"));
         verifyNoInteractions(containerClient);
     }
 
+    @Test
+    void generateDownloadUrlNormalizesExtraSlashes() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "/exam-assault-report/",
+                "https://dcjxb-cdntest.yunzhixue.cn/",
+                FIXED_CLOCK);
+
+        URI downloadUrl = storage.generateDownloadUrl(
+                "/file.pdf/",
+                Duration.ofMinutes(15));
+
+        assertThat(downloadUrl).isEqualTo(URI.create(
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/file.pdf"));
+    }
+
+    @Test
+    void generateDownloadUrlRejectsBlankStorageObjectKey() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                "https://dcjxb-cdntest.yunzhixue.cn",
+                FIXED_CLOCK);
+
+        assertThatThrownBy(() -> storage.generateDownloadUrl("   ", Duration.ofMinutes(15)))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Azure storage storageObjectKey is incomplete");
+    }
+
+    @Test
+    void generateDownloadUrlRejectsAllSlashStorageObjectKey() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                "https://dcjxb-cdntest.yunzhixue.cn",
+                FIXED_CLOCK);
+
+        assertThatThrownBy(() -> storage.generateDownloadUrl("///", Duration.ofMinutes(15)))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Azure storage storageObjectKey is incomplete");
+    }
+
+    @Test
+    void constructorRejectsAllSlashUrlPrefix() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+
+        assertThatThrownBy(() -> new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                "///",
+                FIXED_CLOCK))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Azure storage url-prefix is incomplete");
+    }
+
+    @Test
+    void constructorRejectsBlankAfterTrimUrlPrefix() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+
+        assertThatThrownBy(() -> new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                " / ",
+                FIXED_CLOCK))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Azure storage url-prefix is incomplete");
+    }
+
     @Test
     void uploadWritesUploadedAtMetadataFromInjectedClock() {
         BlobContainerClient containerClient = mock(BlobContainerClient.class);
         BlobClient blobClient = mock(BlobClient.class);
-        when(containerClient.getBlobClient("exam-sprint-reports/outlook/report-123/file.pdf")).thenReturn(blobClient);
-        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(containerClient, FIXED_CLOCK);
+        when(containerClient.getBlobClient("file.pdf")).thenReturn(blobClient);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(
+                containerClient,
+                "exam-assault-report",
+                "https://dcjxb-cdntest.yunzhixue.cn",
+                FIXED_CLOCK);
 
         storage.upload(
                 "report-123",

+ 54 - 276
ability-center-runtime/scripts/achievement-report-demo.sh

@@ -2,286 +2,64 @@
 
 set -euo pipefail
 
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REQUEST_DIR="${SCRIPT_DIR}/../src/test/resources/requests"
-VALID_REQUEST="${REQUEST_DIR}/exam-sprint-achievement-report-request.json"
-INVALID_REQUEST="${REQUEST_DIR}/exam-sprint-achievement-report-invalid-request.json"
-BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
-OUTPUT_PATH="${OUTPUT_PATH:-${PWD}/achievement-report-demo.pdf}"
-HTML_OUTPUT_PATH="${HTML_OUTPUT_PATH:-${PWD}/achievement-report-preview.html}"
-MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
-POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-1}"
-JSON_PARSER=""
-TMP_FILES=()
-
-usage() {
-  cat <<EOF
-Usage:
-  $(basename "$0") success
-  $(basename "$0") invalid
-  $(basename "$0") --help
-
-Modes:
-  success   Submit the valid sample request, poll report status, then download the PDF and preview HTML.
-  invalid   Submit the invalid sample request and print the validation error response.
-
-Environment variables:
-  BASE_URL               API base URL. Default: http://127.0.0.1:8080
-  OUTPUT_PATH            PDF output path for success mode.
-                         Default: current working directory/achievement-report-demo.pdf
-  HTML_OUTPUT_PATH       HTML preview output path for success mode.
-                         Default: current working directory/achievement-report-preview.html
-  MAX_ATTEMPTS           Polling attempts for success mode. Default: 30
-  POLL_INTERVAL_SECONDS  Seconds between polling attempts. Default: 1
-
-Examples:
-  $(basename "$0") success
-  BASE_URL=http://127.0.0.1:8081 $(basename "$0") invalid
-EOF
-}
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+ENDPOINT="${BASE_URL%/}/api/exam-sprint/achievement-reports"
+RESPONSE_BODY="$(mktemp)"
 
 cleanup() {
-  if [ "${#TMP_FILES[@]}" -gt 0 ]; then
-    rm -f "${TMP_FILES[@]}"
-  fi
+  rm -f "$RESPONSE_BODY"
 }
 
 trap cleanup EXIT
 
-die() {
-  printf 'Error: %s\n' "$1" >&2
+http_code="$({
+  curl -sS \
+    -o "$RESPONSE_BODY" \
+    -w '%{http_code}' \
+    -X POST \
+    -H 'Content-Type: application/json' \
+    --data-binary @- \
+    "$ENDPOINT" <<'JSON'
+{
+  "reportTitle": "高考英语临考突击学习成果报告",
+  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+  "completionTitle": "恭喜完成两周考前突击专项训练",
+  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+  "summaryMetrics": {
+    "vocabularyGrowthText": "+19",
+    "paperKnownWordsGrowthText": "+4",
+    "unknownWordHitRateText": "1.93%",
+    "learningEfficiencyText": "0.48倍"
+  },
+  "vocabularyComparison": {
+    "beforeValue": 2328,
+    "afterValue": 2347,
+    "beforeText": "2328 词",
+    "afterText": "2347 词",
+    "growthText": "+19 词"
+  },
+  "paperKnownWordsComparison": {
+    "beforeValue": 650,
+    "afterValue": 654,
+    "beforeText": "650 个",
+    "afterText": "654 个",
+    "growthText": "+4 个"
+  },
+  "examUnknownWordsHitStatus": {
+    "unknownWordHitRateText": "1.93%",
+    "learningEfficiencyText": "0.48倍",
+    "unknownWordsBeforeText": "207 个",
+    "unknownWordsAfterText": "203 个",
+    "reducedUnknownWordsText": "4 个",
+    "hitWords": ["number", "bear", "popular", "importance"]
+  }
+}
+JSON
+})"
+
+printf 'HTTP %s\n' "$http_code"
+cat "$RESPONSE_BODY"
+
+if [ "$http_code" != "202" ]; then
   exit 1
-}
-
-make_temp_file() {
-  local file
-  file="$(mktemp)"
-  TMP_FILES+=("$file")
-  printf '%s\n' "$file"
-}
-
-require_command() {
-  command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
-}
-
-select_json_parser() {
-  if command -v jq >/dev/null 2>&1; then
-    JSON_PARSER="jq"
-  elif command -v python3 >/dev/null 2>&1; then
-    JSON_PARSER="python3"
-  else
-    die "JSON parsing requires jq or python3, but neither was found in PATH."
-  fi
-}
-
-json_get() {
-  local file="$1"
-  local path="$2"
-
-  if [ "$JSON_PARSER" = "jq" ]; then
-    jq -er ".$path" "$file"
-  else
-    python3 - "$file" "$path" <<'PY'
-import json
-import sys
-from pathlib import Path
-
-file_path = Path(sys.argv[1])
-path = [part for part in sys.argv[2].split('.') if part]
-value = json.loads(file_path.read_text(encoding='utf-8'))
-
-for part in path:
-    if isinstance(value, dict) and part in value:
-        value = value[part]
-    else:
-        sys.exit(1)
-
-if value is None:
-    sys.exit(1)
-
-if isinstance(value, (dict, list)):
-    print(json.dumps(value, ensure_ascii=False))
-else:
-    print(value)
-PY
-  fi
-}
-
-pretty_print_json() {
-  local file="$1"
-
-  if [ "$JSON_PARSER" = "jq" ]; then
-    jq . "$file"
-  else
-    python3 - "$file" <<'PY'
-import json
-import sys
-from pathlib import Path
-
-file_path = Path(sys.argv[1])
-data = json.loads(file_path.read_text(encoding='utf-8'))
-print(json.dumps(data, ensure_ascii=False, indent=2))
-PY
-  fi
-}
-
-normalize_url() {
-  local url="$1"
-
-  if [[ "$url" =~ ^https?:// ]]; then
-    printf '%s\n' "$url"
-  elif [[ "$url" == /* ]]; then
-    printf '%s%s\n' "${BASE_URL%/}" "$url"
-  else
-    printf '%s/%s\n' "${BASE_URL%/}" "$url"
-  fi
-}
-
-post_json() {
-  local url="$1"
-  local payload="$2"
-  local output="$3"
-
-  curl -sS -o "$output" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data "@$payload" "$url"
-}
-
-get_json() {
-  local url="$1"
-  local output="$2"
-
-  curl -sS -o "$output" -w '%{http_code}' "$url"
-}
-
-download_file() {
-  local url="$1"
-  local output="$2"
-
-  curl -sS -L -o "$output" -w '%{http_code}' "$url"
-}
-
-ensure_request_files() {
-  [ -f "$VALID_REQUEST" ] || die "Missing request fixture: $VALID_REQUEST"
-  [ -f "$INVALID_REQUEST" ] || die "Missing request fixture: $INVALID_REQUEST"
-}
-
-run_success_mode() {
-  local submit_url submit_body submit_status report_id query_url query_body query_status current_status
-  local download_url download_target download_status preview_html_url preview_html_target preview_html_status attempt
-
-  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
-  submit_body="$(make_temp_file)"
-  submit_status="$(post_json "$submit_url" "$VALID_REQUEST" "$submit_body")" || die "Failed to submit request to ${submit_url}"
-
-  if [ "$submit_status" != "202" ]; then
-    printf 'Submit request failed with HTTP %s\n' "$submit_status" >&2
-    pretty_print_json "$submit_body" >&2
-    exit 1
-  fi
-
-  report_id="$(json_get "$submit_body" 'data.reportId')" || die "Accepted response did not contain data.reportId"
-  printf 'Report created: %s\n' "$report_id"
-
-  query_url="${BASE_URL%/}/api/exam-sprint/reports/${report_id}"
-  query_body="$(make_temp_file)"
-
-  for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
-    query_status="$(get_json "$query_url" "$query_body")" || die "Failed to query report status from ${query_url}"
-    if [ "$query_status" != "200" ]; then
-      printf 'Query report failed with HTTP %s\n' "$query_status" >&2
-      pretty_print_json "$query_body" >&2
-      exit 1
-    fi
-
-    current_status="$(json_get "$query_body" 'data.generationStatus')" || die "Report response did not contain data.generationStatus"
-    printf 'Poll %d/%s: %s\n' "$attempt" "$MAX_ATTEMPTS" "$current_status"
-
-    case "$current_status" in
-      SUCCESS)
-        download_url="$(json_get "$query_body" 'data.downloadUrl')" || die "Successful report did not contain data.downloadUrl"
-        download_url="$(normalize_url "$download_url")"
-        download_target="$(make_temp_file)"
-        download_status="$(download_file "$download_url" "$download_target")" || die "Failed to download PDF from ${download_url}"
-        if [ "$download_status" != "200" ]; then
-          printf 'Download failed with HTTP %s\n' "$download_status" >&2
-          pretty_print_json "$download_target" >&2
-          exit 1
-        fi
-
-        preview_html_url="$(json_get "$query_body" 'data.previewHtmlUrl')" || die "Successful report did not contain data.previewHtmlUrl"
-        preview_html_url="$(normalize_url "$preview_html_url")"
-        preview_html_target="$(make_temp_file)"
-        preview_html_status="$(download_file "$preview_html_url" "$preview_html_target")" || die "Failed to download preview HTML from ${preview_html_url}"
-        if [ "$preview_html_status" != "200" ]; then
-          printf 'Preview HTML download failed with HTTP %s\n' "$preview_html_status" >&2
-          pretty_print_json "$preview_html_target" >&2
-          exit 1
-        fi
-
-        mv "$download_target" "$OUTPUT_PATH"
-        mv "$preview_html_target" "$HTML_OUTPUT_PATH"
-        printf 'PDF downloaded to: %s\n' "$OUTPUT_PATH"
-        printf 'Preview HTML downloaded to: %s\n' "$HTML_OUTPUT_PATH"
-        return 0
-        ;;
-      FAILED|EXPIRED)
-        printf 'Report ended with status %s\n' "$current_status" >&2
-        pretty_print_json "$query_body" >&2
-        exit 1
-        ;;
-    esac
-
-    sleep "$POLL_INTERVAL_SECONDS"
-  done
-
-  printf 'Report did not reach SUCCESS within %s attempts\n' "$MAX_ATTEMPTS" >&2
-  pretty_print_json "$query_body" >&2
-  exit 1
-}
-
-run_invalid_mode() {
-  local submit_url response_body response_status
-
-  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
-  response_body="$(make_temp_file)"
-  response_status="$(post_json "$submit_url" "$INVALID_REQUEST" "$response_body")" || die "Failed to submit invalid request to ${submit_url}"
-
-  printf 'HTTP %s\n' "$response_status"
-  pretty_print_json "$response_body"
-
-  if [ "$response_status" != "400" ]; then
-    die "Expected HTTP 400 for invalid request, got ${response_status}"
-  fi
-}
-
-main() {
-  local mode="${1:-}"
-
-  if [ "$#" -gt 1 ]; then
-    usage
-    exit 1
-  fi
-
-  case "$mode" in
-    ""|--help|-h|help)
-      usage
-      ;;
-    success)
-      require_command curl
-      select_json_parser
-      ensure_request_files
-      run_success_mode
-      ;;
-    invalid)
-      require_command curl
-      select_json_parser
-      ensure_request_files
-      run_invalid_mode
-      ;;
-    *)
-      usage
-      exit 1
-      ;;
-  esac
-}
-
-main "$@"
+fi

+ 85 - 261
ability-center-runtime/scripts/outlook-report-demo.sh

@@ -2,271 +2,95 @@
 
 set -euo pipefail
 
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REQUEST_DIR="${SCRIPT_DIR}/../src/test/resources/requests"
-VALID_REQUEST="${REQUEST_DIR}/exam-sprint-outlook-report-request.json"
-INVALID_REQUEST="${REQUEST_DIR}/exam-sprint-outlook-report-invalid-request.json"
-BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
-OUTPUT_PATH="${OUTPUT_PATH:-${PWD}/outlook-report-demo.pdf}"
-MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
-POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-1}"
-JSON_PARSER=""
-TMP_FILES=()
-
-usage() {
-  cat <<EOF
-Usage:
-  $(basename "$0") success
-  $(basename "$0") invalid
-  $(basename "$0") --help
-
-Modes:
-  success   Submit the valid sample request, poll report status, then download the PDF.
-  invalid   Submit the invalid sample request and print the validation error response.
-
-Environment variables:
-  BASE_URL               API base URL. Default: http://127.0.0.1:8080
-  OUTPUT_PATH            PDF output path for success mode.
-                         Default: current working directory/outlook-report-demo.pdf
-  MAX_ATTEMPTS           Polling attempts for success mode. Default: 30
-  POLL_INTERVAL_SECONDS  Seconds between polling attempts. Default: 1
-
-Examples:
-  $(basename "$0") success
-  BASE_URL=http://127.0.0.1:8081 $(basename "$0") invalid
-EOF
-}
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+ENDPOINT="${BASE_URL%/}/api/exam-sprint/outlook-reports"
+RESPONSE_BODY="$(mktemp)"
 
 cleanup() {
-  if [ "${#TMP_FILES[@]}" -gt 0 ]; then
-    rm -f "${TMP_FILES[@]}"
-  fi
+  rm -f "$RESPONSE_BODY"
 }
 
 trap cleanup EXIT
 
-die() {
-  printf 'Error: %s\n' "$1" >&2
-  exit 1
-}
-
-make_temp_file() {
-  local file
-  file="$(mktemp)"
-  TMP_FILES+=("$file")
-  printf '%s\n' "$file"
-}
-
-require_command() {
-  command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
-}
-
-select_json_parser() {
-  if command -v jq >/dev/null 2>&1; then
-    JSON_PARSER="jq"
-  elif command -v python3 >/dev/null 2>&1; then
-    JSON_PARSER="python3"
-  else
-    die "JSON parsing requires jq or python3, but neither was found in PATH."
-  fi
-}
-
-json_get() {
-  local file="$1"
-  local path="$2"
-
-  if [ "$JSON_PARSER" = "jq" ]; then
-    jq -er ".$path" "$file"
-  else
-    python3 - "$file" "$path" <<'PY'
-import json
-import sys
-from pathlib import Path
-
-file_path = Path(sys.argv[1])
-path = [part for part in sys.argv[2].split('.') if part]
-value = json.loads(file_path.read_text(encoding='utf-8'))
-
-for part in path:
-    if isinstance(value, dict) and part in value:
-        value = value[part]
-    else:
-        sys.exit(1)
-
-if value is None:
-    sys.exit(1)
-
-if isinstance(value, (dict, list)):
-    print(json.dumps(value, ensure_ascii=False))
-else:
-    print(value)
-PY
-  fi
-}
-
-pretty_print_json() {
-  local file="$1"
-
-  if [ "$JSON_PARSER" = "jq" ]; then
-    jq . "$file"
-  else
-    python3 - "$file" <<'PY'
-import json
-import sys
-from pathlib import Path
-
-file_path = Path(sys.argv[1])
-data = json.loads(file_path.read_text(encoding='utf-8'))
-print(json.dumps(data, ensure_ascii=False, indent=2))
-PY
-  fi
-}
-
-normalize_url() {
-  local url="$1"
-
-  if [[ "$url" =~ ^https?:// ]]; then
-    printf '%s\n' "$url"
-  elif [[ "$url" == /* ]]; then
-    printf '%s%s\n' "${BASE_URL%/}" "$url"
-  else
-    printf '%s/%s\n' "${BASE_URL%/}" "$url"
-  fi
-}
-
-post_json() {
-  local url="$1"
-  local payload="$2"
-  local output="$3"
-
-  curl -sS -o "$output" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data "@$payload" "$url"
-}
-
-get_json() {
-  local url="$1"
-  local output="$2"
-
-  curl -sS -o "$output" -w '%{http_code}' "$url"
-}
-
-download_file() {
-  local url="$1"
-  local output="$2"
-
-  curl -sS -L -o "$output" -w '%{http_code}' "$url"
-}
-
-ensure_request_files() {
-  [ -f "$VALID_REQUEST" ] || die "Missing request fixture: $VALID_REQUEST"
-  [ -f "$INVALID_REQUEST" ] || die "Missing request fixture: $INVALID_REQUEST"
-}
-
-run_success_mode() {
-  local submit_url submit_body submit_status report_id query_url query_body query_status current_status
-  local download_url download_target download_status attempt
-
-  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
-  submit_body="$(make_temp_file)"
-  submit_status="$(post_json "$submit_url" "$VALID_REQUEST" "$submit_body")" || die "Failed to submit request to ${submit_url}"
-
-  if [ "$submit_status" != "202" ]; then
-    printf 'Submit request failed with HTTP %s\n' "$submit_status" >&2
-    pretty_print_json "$submit_body" >&2
-    exit 1
-  fi
-
-  report_id="$(json_get "$submit_body" 'data.reportId')" || die "Accepted response did not contain data.reportId"
-  printf 'Report created: %s\n' "$report_id"
-
-  query_url="${BASE_URL%/}/api/exam-sprint/reports/${report_id}"
-  query_body="$(make_temp_file)"
-
-  for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
-    query_status="$(get_json "$query_url" "$query_body")" || die "Failed to query report status from ${query_url}"
-    if [ "$query_status" != "200" ]; then
-      printf 'Query report failed with HTTP %s\n' "$query_status" >&2
-      pretty_print_json "$query_body" >&2
-      exit 1
-    fi
-
-    current_status="$(json_get "$query_body" 'data.generationStatus')" || die "Report response did not contain data.generationStatus"
-    printf 'Poll %d/%s: %s\n' "$attempt" "$MAX_ATTEMPTS" "$current_status"
-
-    case "$current_status" in
-      SUCCESS)
-        download_url="$(json_get "$query_body" 'data.downloadUrl')" || die "Successful report did not contain data.downloadUrl"
-        download_url="$(normalize_url "$download_url")"
-        download_target="$(make_temp_file)"
-        download_status="$(download_file "$download_url" "$download_target")" || die "Failed to download PDF from ${download_url}"
-        if [ "$download_status" != "200" ]; then
-          printf 'Download failed with HTTP %s\n' "$download_status" >&2
-          pretty_print_json "$download_target" >&2
-          exit 1
-        fi
-
-        mv "$download_target" "$OUTPUT_PATH"
-        printf 'PDF downloaded to: %s\n' "$OUTPUT_PATH"
-        return 0
-        ;;
-      FAILED|EXPIRED)
-        printf 'Report ended with status %s\n' "$current_status" >&2
-        pretty_print_json "$query_body" >&2
-        exit 1
-        ;;
-    esac
-
-    sleep "$POLL_INTERVAL_SECONDS"
-  done
-
-  printf 'Report did not reach SUCCESS within %s attempts\n' "$MAX_ATTEMPTS" >&2
-  pretty_print_json "$query_body" >&2
+http_code="$({
+  curl -sS \
+    -o "$RESPONSE_BODY" \
+    -w '%{http_code}' \
+    -X POST \
+    -H 'Content-Type: application/json' \
+    --data-binary @- \
+    "$ENDPOINT" <<'JSON'
+{
+  "reportMetadata": {
+    "reportVersionLabel": "2026 词汇展望报告",
+    "learnerName": "李同学",
+    "targetExamName": "雅思 6.5",
+    "sprintPeriodLabel": "2026 春季冲刺",
+    "authorName": "Ability Bot"
+  },
+  "readinessOverview": {
+    "summary": "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
+    "currentStage": "当前阶段:稳态提升",
+    "keyInsight": "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
+    "readinessScore": 78
+  },
+  "syllabusMasteryChart": {
+    "totalWordCount": 4200,
+    "masteredWordCount": 3192,
+    "unmasteredWordCount": 1008,
+    "masteryPercent": 76,
+    "summaryLabel": "考纲词汇掌握概览",
+    "recommendation": "先补齐教育、科技和环境主题词,再做套题复盘。"
+  },
+  "pastPaperVocabularyChart": {
+    "totalWordCount": 800,
+    "unknownWordCountBeforeSprint": 180,
+    "unknownWordCountAfterSprint": 120,
+    "projectedScoreGainLabel": "预计提分 5-10 分",
+    "recommendation": "每次精听后补 5 组同义替换并做口头复述。"
+  },
+  "highFrequencyVocabularyChart": {
+    "basicCorePercent": 77,
+    "highScorePercent": 62,
+    "highlightLabel": "Common 高频词覆盖较广,但易混词记忆不牢。"
+  },
+  "vocabularyFrequencyBandChart": {
+    "bars": [
+      {"bandLabel": "2k 高频", "currentValue": 86, "priorityLabel": "优先学习", "themeColor": "#448aff"},
+      {"bandLabel": "3k 高频", "currentValue": 78, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
+      {"bandLabel": "学术词", "currentValue": 62, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
+    ]
+  },
+  "frequencyPlan": {
+    "cards": [
+      {"cadencePerWeek": 3, "scoreGainLabel": "+12 分", "winRatePercent": 78, "recommended": true, "badgeLabel": "推荐", "emphasisIcon": "③"},
+      {"cadencePerWeek": 2, "scoreGainLabel": "+8 分", "winRatePercent": 61, "recommended": false, "badgeLabel": "稳妥", "emphasisIcon": "②"}
+    ],
+    "recommendationTitle": "💡建议策略",
+    "recommendationSummary": "7 天提分冲刺优先保证高频词正确率,再逐步覆盖中频词。",
+    "phaseSuggestions": [
+      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
+      {"title": "考前一周·强化巩固期", "description": "结合真题错词做循环复盘。"}
+    ]
+  },
+  "scoreImprovementCaseStudy": {
+    "headline": "教育类阅读题案例",
+    "learnerName": "李同学",
+    "studyPeriodLabel": "考前半个月·核心突击期",
+    "memorizedWordCount": 705,
+    "examHitWordCount": 237,
+    "hitRatePercent": 33.6,
+    "baselineScoreLabel": "70 分以下",
+    "finalScore": 89,
+    "scoreGain": 19
+  }
+}
+JSON
+})"
+
+printf 'HTTP %s\n' "$http_code"
+cat "$RESPONSE_BODY"
+
+if [ "$http_code" != "202" ]; then
   exit 1
-}
-
-run_invalid_mode() {
-  local submit_url response_body response_status
-
-  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
-  response_body="$(make_temp_file)"
-  response_status="$(post_json "$submit_url" "$INVALID_REQUEST" "$response_body")" || die "Failed to submit invalid request to ${submit_url}"
-
-  printf 'HTTP %s\n' "$response_status"
-  pretty_print_json "$response_body"
-
-  if [ "$response_status" != "400" ]; then
-    die "Expected HTTP 400 for invalid request, got ${response_status}"
-  fi
-}
-
-main() {
-  local mode="${1:-}"
-
-  if [ "$#" -gt 1 ]; then
-    usage
-    exit 1
-  fi
-
-  case "$mode" in
-    ""|--help|-h|help)
-      usage
-      ;;
-    success)
-      require_command curl
-      select_json_parser
-      ensure_request_files
-      run_success_mode
-      ;;
-    invalid)
-      require_command curl
-      select_json_parser
-      ensure_request_files
-      run_invalid_mode
-      ;;
-    *)
-      usage
-      exit 1
-      ;;
-  esac
-}
-
-main "$@"
+fi

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

@@ -3,13 +3,13 @@ package cn.yunzhixue.ability.center.examsprint.adapter.http;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService;
 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.CreateExamSprintReportRequest;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.kernel.BaseResponse;
-import jakarta.validation.Valid;
+import com.fasterxml.jackson.databind.JsonNode;
 import org.springframework.http.ContentDisposition;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
 import java.nio.charset.StandardCharsets;
 
 @RestController
-@RequestMapping("/api/exam-sprint/reports")
+@RequestMapping("/api/exam-sprint")
 public class ExamSprintReportController {
 
     private final ExamSprintReportApplicationService applicationService;
@@ -31,18 +31,29 @@ public class ExamSprintReportController {
         this.applicationService = applicationService;
     }
 
-    @PostMapping
-    public ResponseEntity<BaseResponse<CreateExamSprintReportResponse>> createReport(
-            @Valid @RequestBody CreateExamSprintReportRequest request) {
-        return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createReport(request)));
+    @PostMapping("/outlook-reports")
+    public ResponseEntity<BaseResponse<CreateExamSprintReportResponse>> createOutlookReport(
+            @RequestBody JsonNode payload) {
+        return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createOutlookReport(payload)));
     }
 
-    @GetMapping("/{reportId}")
+    @PostMapping("/achievement-reports")
+    public ResponseEntity<BaseResponse<CreateExamSprintReportResponse>> createAchievementReport(
+            @RequestBody JsonNode payload) {
+        return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createAchievementReport(payload)));
+    }
+
+    @PostMapping("/reports")
+    public ResponseEntity<Void> createReportDeprecated() {
+        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).build();
+    }
+
+    @GetMapping("/reports/{reportId}")
     public BaseResponse<ExamSprintReportDetailResponse> getReport(@PathVariable String reportId) {
         return BaseResponse.success(applicationService.getReport(reportId));
     }
 
-    @GetMapping("/{reportId}/download")
+    @GetMapping("/reports/{reportId}/download")
     public ResponseEntity<byte[]> downloadReport(@PathVariable String reportId) {
         ReportDownloadContent content = applicationService.downloadReport(reportId);
         return ResponseEntity.ok()
@@ -55,7 +66,7 @@ public class ExamSprintReportController {
                 .body(content.bytes());
     }
 
-    @GetMapping(value = "/{reportId}/preview/html", produces = MediaType.TEXT_HTML_VALUE)
+    @GetMapping(value = "/reports/{reportId}/preview/html", produces = MediaType.TEXT_HTML_VALUE)
     public ResponseEntity<String> previewReportHtml(@PathVariable String reportId) {
         ReportHtmlPreviewContent content = applicationService.previewReportHtml(reportId);
         return ResponseEntity.ok()

+ 1 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java

@@ -36,6 +36,7 @@ public class ExamSprintReportRuntimeConfiguration {
         properties.getStorage().setEndpoint(bound.getStorage().getEndpoint());
         properties.getStorage().setAccountName(bound.getStorage().getAccountName());
         properties.getStorage().setAccountKey(bound.getStorage().getAccountKey());
+        properties.getStorage().setUrlPrefix(bound.getStorage().getUrlPrefix());
         return properties;
     }
 

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

@@ -42,9 +42,9 @@ class ExamSprintReportControllerTest {
 
     @Test
     void createReportReturnsAcceptedResponse() throws Exception {
-        mockMvc.perform(post("/api/exam-sprint/reports")
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(validRequestJson()))
+                        .content(payloadJson(validRequestJson())))
                 .andExpect(status().isAccepted())
                 .andExpect(jsonPath("$.data.reportId").isNotEmpty())
                 .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
@@ -53,18 +53,18 @@ class ExamSprintReportControllerTest {
 
     @Test
     void createReportWithInvalidPayloadReturnsValidationError() throws Exception {
-        mockMvc.perform(post("/api/exam-sprint/reports")
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(invalidRequestJson()))
+                        .content(payloadJson(invalidRequestJson())))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
     }
 
     @Test
     void createAchievementReportDownloadAndPreviewHtml() throws Exception {
-        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports")
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(validAchievementRequestJson()))
+                        .content(payloadJson(validAchievementRequestJson())))
                 .andExpect(status().isAccepted())
                 .andExpect(jsonPath("$.data.reportId").isNotEmpty())
                 .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
@@ -96,18 +96,18 @@ class ExamSprintReportControllerTest {
 
     @Test
     void createAchievementReportWithInvalidPayloadReturnsValidationError() throws Exception {
-        mockMvc.perform(post("/api/exam-sprint/reports")
+        mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(invalidAchievementRequestJson()))
+                        .content(payloadJson(invalidAchievementRequestJson())))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
     }
 
     @Test
     void getCreatedReportDownloadUrlReturnsPdfContent() throws Exception {
-        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports")
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(validRequestJson()))
+                        .content(payloadJson(validRequestJson())))
                 .andExpect(status().isAccepted())
                 .andReturn();
 
@@ -122,9 +122,9 @@ class ExamSprintReportControllerTest {
 
     @Test
     void expiredReportDownloadIsRejected() throws Exception {
-        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports")
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(validRequestJson()))
+                        .content(payloadJson(validRequestJson())))
                 .andExpect(status().isAccepted())
                 .andReturn();
 
@@ -188,6 +188,14 @@ class ExamSprintReportControllerTest {
         return objectMapper.readTree(result.getResponse().getContentAsString());
     }
 
+    private String payloadJson(String wrappedRequestJson) {
+        try {
+            return objectMapper.writeValueAsString(objectMapper.readTree(wrappedRequestJson).path("payload"));
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to extract payload json", exception);
+        }
+    }
+
     private JsonNode waitForSuccessfulReport(String reportId) throws Exception {
         JsonNode queryBody = null;
         for (int attempt = 0; attempt < 30; attempt++) {

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

@@ -11,6 +11,8 @@ import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import cn.yunzhixue.ability.center.GlobalExceptionHandler;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.SpringBootConfiguration;
@@ -21,9 +23,13 @@ import org.springframework.http.MediaType;
 import org.springframework.context.annotation.Import;
 import org.springframework.test.web.servlet.MockMvc;
 
+import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 
+import org.mockito.ArgumentCaptor;
+
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -48,69 +54,74 @@ class ExamSprintReportControllerWebMvcTest {
     private ExamSprintReportApplicationService applicationService;
 
     @Test
-    void createReportUsesExamSprintReportsEndpoint() throws Exception {
-        given(applicationService.createReport(any())).willReturn(new CreateExamSprintReportResponse(
+    void createOutlookReportUsesIndependentEndpoint() throws Exception {
+        given(applicationService.createOutlookReport(any())).willReturn(new CreateExamSprintReportResponse(
                 "report-001",
                 ExamSprintReportType.OUTLOOK,
                 ExamSprintReportGenerationStatus.PENDING,
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z")));
 
-        String requestJson = """
-                {
-                  "reportType": "OUTLOOK",
-                  "payload": {
-                    "reportMetadata": {
-                      "reportVersionLabel": "2026 词汇展望报告",
-                      "learnerName": "李同学",
-                      "targetExamName": "雅思 6.5",
-                      "sprintPeriodLabel": "2026 春季冲刺",
-                      "authorName": "Ability Bot"
-                    }
-                  }
-                }
-                """;
+        String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
 
-        mockMvc.perform(post("/api/exam-sprint/reports")
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(requestJson))
                 .andExpect(status().isAccepted())
                 .andExpect(jsonPath("$.data.reportId").value("report-001"))
                 .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
                 .andExpect(jsonPath("$.data.generationStatus").value("PENDING"));
+
+        ArgumentCaptor<JsonNode> payloadCaptor = ArgumentCaptor.forClass(JsonNode.class);
+        verify(applicationService).createOutlookReport(payloadCaptor.capture());
+        JsonNode payload = payloadCaptor.getValue();
+        Assertions.assertEquals("李同学", payload.path("reportMetadata").path("learnerName").asText());
+        Assertions.assertEquals("雅思 6.5", payload.path("reportMetadata").path("targetExamName").asText());
     }
 
     @Test
-    void createReportReturnsValidationErrorWhenJsonIsMalformed() throws Exception {
+    void createAchievementReportUsesIndependentEndpoint() throws Exception {
+        given(applicationService.createAchievementReport(any())).willReturn(new CreateExamSprintReportResponse(
+                "report-002",
+                ExamSprintReportType.ACHIEVEMENT,
+                ExamSprintReportGenerationStatus.PENDING,
+                Instant.parse("2026-01-01T00:00:00Z"),
+                Instant.parse("2026-01-02T00:00:00Z")));
+
+        String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
+
+        mockMvc.perform(post("/api/exam-sprint/achievement-reports")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(requestJson))
+                .andExpect(status().isAccepted())
+                .andExpect(jsonPath("$.data.reportId").value("report-002"))
+                .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
+                .andExpect(jsonPath("$.data.generationStatus").value("PENDING"));
+
+        ArgumentCaptor<JsonNode> payloadCaptor = ArgumentCaptor.forClass(JsonNode.class);
+        verify(applicationService).createAchievementReport(payloadCaptor.capture());
+        JsonNode payload = payloadCaptor.getValue();
+        Assertions.assertEquals("高考英语临考突击学习成果报告", payload.path("reportTitle").asText());
+        Assertions.assertEquals("+19", payload.path("summaryMetrics").path("vocabularyGrowthText").asText());
+        Assertions.assertEquals(2347, payload.path("vocabularyComparison").path("afterValue").asInt());
+        Assertions.assertEquals("number", payload.path("examUnknownWordsHitStatus").path("hitWords").get(0).asText());
+    }
+
+    @Test
+    void oldGenericCreateReportEndpointIsRemoved() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"reportType\":\"OUTLOOK\",\"payload\":{]"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+                        .content("{\"reportType\":\"OUTLOOK\",\"payload\":{}}"))
+                .andExpect(status().isMethodNotAllowed());
 
         verifyNoInteractions(applicationService);
     }
 
     @Test
-    void createReportReturnsValidationErrorWhenReportTypeEnumIsUnknown() throws Exception {
-        String requestJson = """
-                {
-                  "reportType": "UNKNOWN",
-                  "payload": {
-                    "reportMetadata": {
-                      "reportVersionLabel": "2026 词汇展望报告",
-                      "learnerName": "李同学",
-                      "targetExamName": "雅思 6.5",
-                      "sprintPeriodLabel": "2026 春季冲刺",
-                      "authorName": "Ability Bot"
-                    }
-                  }
-                }
-                """;
-
-        mockMvc.perform(post("/api/exam-sprint/reports")
+    void createOutlookReportReturnsValidationErrorWhenJsonIsMalformed() throws Exception {
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(requestJson))
+                        .content("{\"reportMetadata\":{]"))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
 
@@ -126,7 +137,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-01T00:05:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
-                "/api/exam-sprint/reports/report-001/download",
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf",
                 "/api/exam-sprint/reports/report-001/preview/html",
                 null));
         given(applicationService.downloadReport("report-001")).willReturn(new ReportDownloadContent(
@@ -137,7 +148,7 @@ class ExamSprintReportControllerWebMvcTest {
         mockMvc.perform(get("/api/exam-sprint/reports/{reportId}", "report-001"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportId").value("report-001"))
-                .andExpect(jsonPath("$.data.downloadUrl").value("/api/exam-sprint/reports/report-001/download"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf"));
 
         mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/download", "report-001"))
                 .andExpect(status().isOk())
@@ -167,6 +178,19 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(jsonPath("$.code").value("REPORT_NOT_FOUND"));
     }
 
+    private String requestPayloadJson(String resourcePath) throws Exception {
+        return objectMapper.writeValueAsString(requestPayload(resourcePath));
+    }
+
+    private JsonNode requestPayload(String resourcePath) throws Exception {
+        try (InputStream inputStream = ExamSprintReportControllerWebMvcTest.class.getClassLoader().getResourceAsStream(resourcePath)) {
+            if (inputStream == null) {
+                throw new IllegalArgumentException("Missing test resource: " + resourcePath);
+            }
+            return objectMapper.readTree(inputStream).path("payload");
+        }
+    }
+
     @SpringBootConfiguration
     @EnableAutoConfiguration
     static class TestApplication {

+ 21 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -0,0 +1,21 @@
+package cn.yunzhixue.ability.center.examsprint.configuration;
+
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportProperties;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExamSprintReportRuntimeConfigurationTest {
+
+    @Test
+    void examSprintReportPropertiesCopiesStorageUrlPrefixFromBoundProperties() {
+        ExamSprintReportRuntimeConfiguration configuration = new ExamSprintReportRuntimeConfiguration();
+        ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties bound =
+                new ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties();
+        bound.getStorage().setUrlPrefix("https://cdn.example.test/reports");
+
+        ExamSprintReportProperties properties = configuration.examSprintReportProperties(bound);
+
+        assertThat(properties.getStorage().getUrlPrefix()).isEqualTo("https://cdn.example.test/reports");
+    }
+}