Przeglądaj źródła

Merge branch 'report-api-cleanup' of jyx/dcjxb.microservice into master

金逸霄 2 tygodni temu
rodzic
commit
6cb7e309be

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

@@ -9,7 +9,6 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportT
 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.ExamSprintReportRepository;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
@@ -22,7 +21,6 @@ import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
-import java.util.List;
 import java.util.Set;
 import java.util.UUID;
 
@@ -35,7 +33,6 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     private final ExamSprintReportGenerationDispatcher dispatcher;
     private final ExamSprintReportGenerationPipeline pipeline;
     private final ExamSprintReportStorage storage;
-    private final List<ExamSprintReportRenderer> renderers;
     private final ExamSprintReportProperties properties;
     private final Clock clock;
     private final ObjectMapper objectMapper;
@@ -49,13 +46,11 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
             ExamSprintReportProperties properties,
             Clock clock,
             ObjectMapper objectMapper,
-            Validator validator,
-            List<ExamSprintReportRenderer> renderers) {
+            Validator validator) {
         this.repository = repository;
         this.dispatcher = dispatcher;
         this.pipeline = pipeline;
         this.storage = storage;
-        this.renderers = List.copyOf(renderers);
         this.properties = properties;
         this.clock = clock;
         this.objectMapper = objectMapper;
@@ -130,7 +125,6 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         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(),
@@ -138,8 +132,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 generatedReport.createdAt(),
                 generatedReport.updatedAt(),
                 generatedReport.expiresAt(),
-                downloadUrl,
-                previewHtmlUrl);
+                downloadUrl);
     }
 
     @Override
@@ -151,12 +144,10 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         }
 
         String downloadUrl = null;
-        String previewHtmlUrl = null;
         if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
                 && !report.isExpiredAt(now)
                 && report.storageObjectKey() != null) {
             downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
-            previewHtmlUrl = "/api/exam-sprint/reports/" + report.reportId() + "/preview/html";
         }
 
         return new ExamSprintReportDetailResponse(
@@ -167,7 +158,6 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 report.updatedAt(),
                 report.expiresAt(),
                 downloadUrl,
-                previewHtmlUrl,
                 report.failureReason());
     }
 
@@ -189,27 +179,6 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
     }
 
-    @Override
-    public ReportHtmlPreviewContent previewReportHtml(String reportId) {
-        Instant now = clock.instant();
-        ExamSprintReport report = requireReport(reportId);
-        if (report.isExpiredAt(now)) {
-            if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
-                repository.save(report.expired(now));
-            }
-            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
-        }
-        if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS || report.storageObjectKey() == null) {
-            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
-        }
-        String html = renderers.stream()
-                .filter(renderer -> renderer.supports(report.reportType()))
-                .findFirst()
-                .map(renderer -> renderer.render(report.payload(), report.updatedAt()))
-                .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
-        return new ReportHtmlPreviewContent(html, "text/html;charset=UTF-8");
-    }
-
     public void cleanupExpiredReports() {
         Instant now = clock.instant();
         for (ExamSprintReport report : repository.findExpiredAtOrBefore(now)) {

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

@@ -19,11 +19,6 @@ public interface ExamSprintReportApplicationService {
 
     ReportDownloadContent downloadReport(String reportId);
 
-    ReportHtmlPreviewContent previewReportHtml(String reportId);
-
     record ReportDownloadContent(String fileName, byte[] bytes, String contentType) {
     }
-
-    record ReportHtmlPreviewContent(String html, String contentType) {
-    }
 }

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

@@ -1,6 +1,8 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+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;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
@@ -21,6 +23,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import java.lang.reflect.RecordComponent;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.time.Clock;
@@ -28,6 +31,7 @@ import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
@@ -44,6 +48,20 @@ class ExamSprintReportApplicationServiceTest {
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
 
+    @Test
+    void publicReportContractsExposeDownloadUrlOnly() {
+        assertReportRecordUsesDownloadUrlOnly(CreateExamSprintReportWithUrlResponse.class);
+        assertReportRecordUsesDownloadUrlOnly(ExamSprintReportDetailResponse.class);
+    }
+
+    @Test
+    void applicationServiceDoesNotDeclareHtmlPreviewMethod() {
+        assertThat(Arrays.stream(ExamSprintReportApplicationService.class.getDeclaredMethods())
+                        .map(method -> method.getName())
+                        .toList())
+                .doesNotContain(removedHtmlPreviewMethodName());
+    }
+
     @Test
     void createOutlookReportStoresOutlookTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
@@ -90,7 +108,6 @@ class ExamSprintReportApplicationServiceTest {
         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");
@@ -114,7 +131,6 @@ class ExamSprintReportApplicationServiceTest {
         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");
@@ -219,7 +235,7 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void getReportReturnsDownloadAndPreviewUrlsForSuccessfulReport() {
+    void getReportReturnsDownloadUrlForSuccessfulReport() {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         ExamSprintReport report = ExamSprintReport.pending(
@@ -239,49 +255,10 @@ class ExamSprintReportApplicationServiceTest {
 
         assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
         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-achievement-report-report-success.pdf");
     }
 
-    @Test
-    void previewReportHtmlRendersSavedPayloadForSuccessfulReport() {
-        TestRepository repository = new TestRepository();
-        repository.save(ExamSprintReport.pending(
-                        "report-preview",
-                        ExamSprintReportType.ACHIEVEMENT,
-                        validAchievementPayload(),
-                        FIXED_CLOCK.instant().minusSeconds(120),
-                        FIXED_CLOCK.instant().plusSeconds(3600))
-                .success(
-                        FIXED_CLOCK.instant().minusSeconds(30),
-                        "exam-sprint-achievement-report-report-preview.pdf",
-                        "exam-sprint-achievement-report-report-preview.pdf"));
-        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
-
-        var content = service.previewReportHtml("report-preview");
-
-        assertThat(content.contentType()).isEqualTo("text/html;charset=UTF-8");
-        assertThat(content.html()).contains("preview:高考英语临考突击学习成果报告:2026-01-01T23:59:30Z");
-    }
-
-    @Test
-    void previewReportHtmlRejectsPendingReport() {
-        TestRepository repository = new TestRepository();
-        repository.save(ExamSprintReport.pending(
-                "report-pending",
-                ExamSprintReportType.ACHIEVEMENT,
-                validAchievementPayload(),
-                FIXED_CLOCK.instant().minusSeconds(120),
-                FIXED_CLOCK.instant().plusSeconds(3600)));
-        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
-
-        assertThatThrownBy(() -> service.previewReportHtml("report-pending"))
-                .isInstanceOf(BusinessException.class)
-                .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
-    }
-
     @Test
     void downloadReportRejectsExpiredReportBeforeCleanupRuns() {
         TestRepository repository = new TestRepository();
@@ -390,8 +367,7 @@ class ExamSprintReportApplicationServiceTest {
                 properties(),
                 FIXED_CLOCK,
                 OBJECT_MAPPER,
-                VALIDATOR,
-                List.of(new PreviewTestRenderer()));
+                VALIDATOR);
     }
 
     private ExamSprintReportProperties properties() {
@@ -401,6 +377,26 @@ class ExamSprintReportApplicationServiceTest {
         return properties;
     }
 
+    private List<String> recordComponentNames(Class<? extends Record> recordType) {
+        return Arrays.stream(recordType.getRecordComponents())
+                .map(RecordComponent::getName)
+                .toList();
+    }
+
+    private void assertReportRecordUsesDownloadUrlOnly(Class<? extends Record> recordType) {
+        assertThat(recordComponentNames(recordType))
+                .contains("downloadUrl")
+                .doesNotContain(removedHtmlPreviewUrlFieldName());
+    }
+
+    private String removedHtmlPreviewUrlFieldName() {
+        return "preview" + "HtmlUrl";
+    }
+
+    private String removedHtmlPreviewMethodName() {
+        return "preview" + "ReportHtml";
+    }
+
     private ObjectNode validOutlookPayload() {
         return (ObjectNode) OBJECT_MAPPER.valueToTree(new OutlookExamSprintReportPayload(
                 new OutlookExamSprintReportPayload.ReportMetadata(

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

@@ -9,6 +9,5 @@ public record CreateExamSprintReportWithUrlResponse(
         Instant createdAt,
         Instant updatedAt,
         Instant expiresAt,
-        String downloadUrl,
-        String previewHtmlUrl) {
+        String downloadUrl) {
 }

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

@@ -10,6 +10,5 @@ public record ExamSprintReportDetailResponse(
         Instant updatedAt,
         Instant expiresAt,
         String downloadUrl,
-        String previewHtmlUrl,
         String failureReason) {
 }

+ 7 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java

@@ -13,6 +13,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.NoHandlerFoundException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
 
 @RestControllerAdvice
 public class GlobalExceptionHandler {
@@ -37,6 +39,11 @@ public class GlobalExceptionHandler {
                 .body(BaseResponse.failure(ErrorCode.VALIDATION_ERROR));
     }
 
+    @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class})
+    public ResponseEntity<BaseResponse<Void>> handleNotFoundException(Exception exception) {
+        return ResponseEntity.notFound().build();
+    }
+
     @ExceptionHandler(Exception.class)
     public ResponseEntity<BaseResponse<Void>> handleException(Exception exception) {
         log.error("Unhandled exception caught by global handler", exception);

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

@@ -2,7 +2,6 @@ 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.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
@@ -10,7 +9,6 @@ import cn.yunzhixue.ability.center.kernel.BaseResponse;
 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;
@@ -54,11 +52,6 @@ public class ExamSprintReportController {
         return BaseResponse.success(applicationService.createAchievementReportSync(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));
@@ -76,12 +69,4 @@ public class ExamSprintReportController {
                                 .toString())
                 .body(content.bytes());
     }
-
-    @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()
-                .contentType(MediaType.parseMediaType(content.contentType()))
-                .body(content.html());
-    }
 }

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

@@ -61,7 +61,6 @@ class ExamSprintReportControllerTest {
                 .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());
@@ -81,7 +80,7 @@ class ExamSprintReportControllerTest {
     }
 
     @Test
-    void createAchievementReportDownloadAndPreviewHtml() throws Exception {
+    void createAchievementReportDownloadUrlReturnsPdfContent() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(payloadJson(validAchievementRequestJson())))
@@ -94,28 +93,14 @@ class ExamSprintReportControllerTest {
         String reportId = readJson(createResult).at("/data/reportId").asText();
         JsonNode queryBody = waitForSuccessfulReport(reportId);
         URI downloadUri = URI.create(queryBody.at("/data/downloadUrl").asText());
-        URI previewHtmlUri = URI.create(queryBody.at("/data/previewHtmlUrl").asText());
 
         mockMvc.perform(get(downloadUri))
                 .andExpect(status().isOk())
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
-
-        MvcResult previewResult = mockMvc.perform(get(previewHtmlUri))
-                .andExpect(status().isOk())
-                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
-                .andReturn();
-
-        assertThat(previewResult.getResponse().getContentAsString(StandardCharsets.UTF_8))
-                .contains("高考英语临考突击学习成果报告")
-                .contains("模块一:词汇量对比")
-                .contains("模块二:试卷熟词量对比")
-                .contains("模块三:实考生词命中状况")
-                .contains("achievement-bar-chart")
-                .doesNotContain("echarts");
     }
 
     @Test
-    void createAchievementReportSyncReturnsDownloadUrlAndPreviewHtml() throws Exception {
+    void createAchievementReportSyncReturnsDownloadUrlAndPdfIsDownloadable() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports/sync")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(payloadJson(validAchievementRequestJson())))
@@ -123,18 +108,13 @@ class ExamSprintReportControllerTest {
                 .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());
+        URI downloadUri = URI.create(readJson(createResult).at("/data/downloadUrl").asText());
 
-        MvcResult previewResult = mockMvc.perform(get(previewHtmlUri))
+        mockMvc.perform(get(downloadUri))
                 .andExpect(status().isOk())
-                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
-                .andReturn();
-
-        assertThat(previewResult.getResponse().getContentAsString(StandardCharsets.UTF_8))
-                .contains("高考英语临考突击学习成果报告");
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
     @Test

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

@@ -2,7 +2,6 @@ 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.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
@@ -117,8 +116,7 @@ class ExamSprintReportControllerWebMvcTest {
                 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"));
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
 
         String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
 
@@ -128,8 +126,7 @@ class ExamSprintReportControllerWebMvcTest {
                 .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"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-sync-001.pdf"));
 
         verify(applicationService).createOutlookReportSync(any());
     }
@@ -143,8 +140,7 @@ class ExamSprintReportControllerWebMvcTest {
                 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"));
+                "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
 
         String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
 
@@ -154,18 +150,20 @@ class ExamSprintReportControllerWebMvcTest {
                 .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"));
+                .andExpect(jsonPath("$.data.downloadUrl").value("https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-achievement-report-report-sync-002.pdf"));
 
         verify(applicationService).createAchievementReportSync(any());
     }
 
     @Test
-    void oldGenericCreateReportEndpointIsRemoved() throws Exception {
+    void removedReportEndpointsAreNotExposed() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/reports")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"reportType\":\"OUTLOOK\",\"payload\":{}}"))
-                .andExpect(status().isMethodNotAllowed());
+                .andExpect(status().isNotFound());
+
+        mockMvc.perform(get("/api/exam-sprint/reports/{reportId}" + "/preview" + "/html", "report-001"))
+                .andExpect(status().isNotFound());
 
         verifyNoInteractions(applicationService);
     }
@@ -191,7 +189,6 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:05:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
                 "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(
                 "exam-sprint-outlook-report-report-001.pdf",
@@ -208,18 +205,6 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
-    @Test
-    void previewHtmlReturnsTextHtmlForSucceededReport() throws Exception {
-        given(applicationService.previewReportHtml("report-001")).willReturn(new ReportHtmlPreviewContent(
-                "<html><body>高考英语临考突击学习成果报告</body></html>",
-                "text/html;charset=UTF-8"));
-
-        mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/preview/html", "report-001"))
-                .andExpect(status().isOk())
-                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
-                .andExpect(content().string(org.hamcrest.Matchers.containsString("高考英语临考突击学习成果报告")));
-    }
-
     @Test
     void getReportMapsNewKernelBusinessExceptionToBusinessStatusCode() throws Exception {
         given(applicationService.getReport(eq("missing-report")))

+ 499 - 0
docs/plans/2026-04-27-report-preview-and-legacy-api-removal.md

@@ -0,0 +1,499 @@
+# Report Preview and Legacy API Removal Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Remove the unused public HTML preview API and deprecated generic report creation endpoint while preserving typed report creation, status query, PDF generation, and PDF download.
+
+**Architecture:** Treat this as an API contract cleanup: tests first lock the desired public contract, contracts/application/controller are then simplified to remove `previewHtmlUrl` and preview methods, and the PDF generation pipeline keeps using internal HTML renderers. The runtime controller should expose only typed creation, detail query, and PDF download endpoints.
+
+**Tech Stack:** Java 17, Spring Boot MVC, Maven reactor, JUnit 5, AssertJ, MockMvc, Jackson records/DTOs.
+
+---
+
+## Working Directory
+
+Run all commands from this worktree unless a step says otherwise:
+
+```text
+/Users/exiao/Codes/dcjxb.microservice/.worktrees/report-api-cleanup
+```
+
+Baseline already verified before writing this plan:
+
+```bash
+mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportControllerWebMvcTest,ExamSprintReportControllerTest test
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
+```
+
+Both commands passed on `2026-04-27`.
+
+Do not create a git commit unless the user explicitly asks for one.
+
+---
+
+### Task 1: Update Runtime Controller Tests for Removed Public Contract
+
+**Files:**
+- Modify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`
+- Modify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java`
+
+**Step 1: Write the failing controller-level tests**
+
+In `ExamSprintReportControllerWebMvcTest.java`:
+
+1. Remove the import of `ExamSprintReportApplicationService.ReportHtmlPreviewContent`.
+2. Update `new CreateExamSprintReportWithUrlResponse(...)` calls so they pass only these fields:
+
+```java
+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")
+```
+
+3. Update the achievement sync fixture the same way, keeping only the `downloadUrl` as the final argument.
+4. Update `new ExamSprintReportDetailResponse(...)` calls so they pass only these fields:
+
+```java
+new ExamSprintReportDetailResponse(
+        "report-001",
+        ExamSprintReportType.OUTLOOK,
+        ExamSprintReportGenerationStatus.SUCCESS,
+        Instant.parse("2026-01-01T00:00:00Z"),
+        Instant.parse("2026-01-01T00:05:00Z"),
+        Instant.parse("2026-01-02T00:00:00Z"),
+        "https://dcjxb-cdntest.yunzhixue.cn/exam-assault-report/exam-sprint-outlook-report-report-001.pdf",
+        null)
+```
+
+5. Remove assertions that check `$.data.previewHtmlUrl`.
+6. Replace the old `oldGenericCreateReportEndpointIsRemoved` test with a test that proves removed endpoints are no longer exposed:
+
+```java
+@Test
+void removedReportEndpointsAreNotExposed() throws Exception {
+    mockMvc.perform(post("/api/exam-sprint/reports")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content("{\"reportType\":\"OUTLOOK\",\"payload\":{}}"))
+            .andExpect(status().isNotFound());
+
+    mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/preview/html", "report-001"))
+            .andExpect(status().isNotFound());
+
+    verifyNoInteractions(applicationService);
+}
+```
+
+7. Delete the `previewHtmlReturnsTextHtmlForSucceededReport` test.
+8. Remove any stubbing of `applicationService.previewReportHtml(...)`.
+
+In `ExamSprintReportControllerTest.java`:
+
+1. Remove `jsonPath("$.data.previewHtmlUrl").isNotEmpty()` assertions.
+2. Remove extraction of `previewHtmlUri`.
+3. Remove `mockMvc.perform(get(previewHtmlUri))` calls and HTML-content assertions.
+4. Keep the PDF download assertions for both report types.
+
+**Step 2: Run controller tests to verify they fail before implementation**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportControllerWebMvcTest,ExamSprintReportControllerTest test
+```
+
+Expected: FAIL. Accept either compile failures from the still-old DTO constructor shapes, or test failures because the preview endpoint still exists / `previewHtmlUrl` still appears.
+
+**Step 3: Checkpoint status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: only the two runtime test files and this plan file should be modified so far.
+
+Do not commit.
+
+---
+
+### Task 2: Update Application Service Tests for Removed `previewHtmlUrl` and Preview Service Method
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+**Step 1: Write failing application-contract tests**
+
+Add imports near the other `java.*` imports:
+
+```java
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+```
+
+Add this test near the top of `ExamSprintReportApplicationServiceTest`:
+
+```java
+@Test
+void publicReportContractsDoNotExposeHtmlPreview() {
+    assertThat(recordComponentNames(CreateExamSprintReportWithUrlResponse.class))
+            .contains("downloadUrl")
+            .doesNotContain("previewHtmlUrl");
+    assertThat(recordComponentNames(ExamSprintReportDetailResponse.class))
+            .contains("downloadUrl")
+            .doesNotContain("previewHtmlUrl");
+    assertThat(Arrays.stream(ExamSprintReportApplicationService.class.getDeclaredMethods())
+            .map(Method::getName))
+            .doesNotContain("previewReportHtml");
+}
+```
+
+Add this helper near the other private helpers:
+
+```java
+private List<String> recordComponentNames(Class<? extends Record> recordType) {
+    return Arrays.stream(recordType.getRecordComponents())
+            .map(RecordComponent::getName)
+            .toList();
+}
+```
+
+Update existing application service tests:
+
+1. In `createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl`, delete the `response.previewHtmlUrl()` assertion.
+2. In `createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl`, delete the `response.previewHtmlUrl()` assertion.
+3. Rename `getReportReturnsDownloadAndPreviewUrlsForSuccessfulReport` to `getReportReturnsDownloadUrlForSuccessfulReport`.
+4. In that renamed test, delete the `response.previewHtmlUrl()` assertion.
+5. Delete `previewReportHtmlRendersSavedPayloadForSuccessfulReport`.
+6. Delete `previewReportHtmlRejectsPendingReport`.
+
+**Step 2: Run application tests to verify they fail before implementation**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
+```
+
+Expected: FAIL because `previewHtmlUrl` is still a record component and `previewReportHtml` is still on the application service interface.
+
+**Step 3: Checkpoint status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: runtime tests, application test, and this plan file are modified.
+
+Do not commit.
+
+---
+
+### Task 3: Remove Preview Fields from Contract Records
+
+**Files:**
+- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportWithUrlResponse.java`
+- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java`
+
+**Step 1: Implement the contract change**
+
+Change `CreateExamSprintReportWithUrlResponse` to:
+
+```java
+public record CreateExamSprintReportWithUrlResponse(
+        String reportId,
+        ExamSprintReportType reportType,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String downloadUrl) {
+}
+```
+
+Change `ExamSprintReportDetailResponse` to:
+
+```java
+public record ExamSprintReportDetailResponse(
+        String reportId,
+        ExamSprintReportType reportType,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String downloadUrl,
+        String failureReason) {
+}
+```
+
+**Step 2: Run targeted compile/tests to expose remaining call sites**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application,ability-center-runtime -am -DskipTests compile test-compile
+```
+
+Expected: FAIL with constructor or method references that still include `previewHtmlUrl` or preview service methods.
+
+**Step 3: Checkpoint status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Do not commit.
+
+---
+
+### Task 4: Remove Preview Method from Application Service and Implementation
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java`
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+- Modify as needed: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+**Step 1: Remove preview from the service interface**
+
+In `ExamSprintReportApplicationService.java`:
+
+1. Delete `ReportHtmlPreviewContent previewReportHtml(String reportId);`.
+2. Delete the nested `ReportHtmlPreviewContent` record.
+3. Keep `ReportDownloadContent`.
+
+Final shape should be:
+
+```java
+public interface ExamSprintReportApplicationService {
+
+    CreateExamSprintReportResponse createOutlookReport(JsonNode payload);
+
+    CreateExamSprintReportResponse createAchievementReport(JsonNode payload);
+
+    CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload);
+
+    CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload);
+
+    ExamSprintReportDetailResponse getReport(String reportId);
+
+    ReportDownloadContent downloadReport(String reportId);
+
+    record ReportDownloadContent(String fileName, byte[] bytes, String contentType) {
+    }
+}
+```
+
+**Step 2: Remove preview implementation**
+
+In `DefaultExamSprintReportApplicationService.java`:
+
+1. Remove the import of `ExamSprintReportRenderer` if it is no longer needed.
+2. Remove the `private final List<ExamSprintReportRenderer> renderers;` field.
+3. Remove the `List<ExamSprintReportRenderer> renderers` constructor parameter.
+4. Remove `this.renderers = List.copyOf(renderers);`.
+5. In `submitReportGenerationSync`, remove `previewHtmlUrl` construction and return `CreateExamSprintReportWithUrlResponse` with `downloadUrl` as the final argument.
+6. In `getReport`, remove the `previewHtmlUrl` local variable, remove its assignment, and return `ExamSprintReportDetailResponse` with `failureReason` immediately after `downloadUrl`.
+7. Delete the entire `previewReportHtml(String reportId)` method.
+8. Keep `List` import if still used for another type; otherwise remove it.
+
+**Step 3: Update test service factory constructor call**
+
+In `ExamSprintReportApplicationServiceTest.java`, update `new DefaultExamSprintReportApplicationService(...)` to remove the final `List.of(new PreviewTestRenderer())` argument. Keep `PreviewTestRenderer` if it is still used by `ExamSprintReportGenerationPipeline` in the test helper.
+
+Expected constructor call shape:
+
+```java
+return new DefaultExamSprintReportApplicationService(
+        repository,
+        dispatcher,
+        new ExamSprintReportGenerationPipeline(
+                repository,
+                renderers,
+                pdfGenerator,
+                storage,
+                FIXED_CLOCK),
+        storage,
+        properties(),
+        FIXED_CLOCK,
+        OBJECT_MAPPER,
+        VALIDATOR);
+```
+
+**Step 4: Run application tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
+```
+
+Expected: PASS.
+
+**Step 5: Checkpoint status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Do not commit.
+
+---
+
+### Task 5: Remove Public Controller Endpoints and Update Runtime Tests
+
+**Files:**
+- Modify: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java`
+- Modify if needed: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`
+- Modify if needed: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java`
+
+**Step 1: Remove controller endpoint methods**
+
+In `ExamSprintReportController.java`:
+
+1. Remove imports:
+
+```java
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportHtmlPreviewContent;
+import org.springframework.http.HttpStatus;
+```
+
+2. Delete `createReportDeprecated()`.
+3. Delete `previewReportHtml(String reportId)`.
+4. Keep `downloadReport(String reportId)` unchanged.
+
+**Step 2: Fix runtime test compile errors**
+
+If compile errors remain in runtime tests:
+
+1. Remove imports that only supported deleted preview tests.
+2. Ensure `CreateExamSprintReportWithUrlResponse` and `ExamSprintReportDetailResponse` constructor calls use the new DTO shapes.
+3. Ensure no test references `previewHtmlUrl`.
+4. Keep the `removedReportEndpointsAreNotExposed` test from Task 1.
+
+**Step 3: Run runtime tests**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportControllerWebMvcTest,ExamSprintReportControllerTest test
+```
+
+Expected: PASS.
+
+**Step 4: Checkpoint status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Do not commit.
+
+---
+
+### Task 6: Search for Remaining Preview API References
+
+**Files:**
+- Inspect only unless references require cleanup.
+
+**Step 1: Search source and test code for removed public contract names**
+
+Run:
+
+```bash
+rg "previewHtmlUrl|previewReportHtml|/preview/html|createReportDeprecated" ability-center-runtime abilities/exam-sprint
+```
+
+Expected: no matches in `src/main/java` or active tests. It is acceptable for this implementation plan or historical docs to mention the removed names.
+
+**Step 2: If matches remain in production or active test code, remove them**
+
+Rules:
+
+- Remove public URL construction for `/preview/html`.
+- Remove preview endpoint stubs/assertions.
+- Do not remove HTML templates, renderer implementations, or PDF pipeline renderer usage.
+
+**Step 3: Run compile for affected modules**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application,ability-center-runtime -am -DskipTests compile test-compile
+```
+
+Expected: PASS.
+
+Do not commit.
+
+---
+
+### Task 7: Final Verification
+
+**Files:**
+- No code edits expected.
+
+**Step 1: Run targeted application tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
+```
+
+Expected: PASS.
+
+**Step 2: Run targeted runtime tests**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportControllerWebMvcTest,ExamSprintReportControllerTest test
+```
+
+Expected: PASS.
+
+**Step 3: Run broader affected-module verification**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application,ability-center-runtime -am test
+```
+
+Expected: PASS.
+
+**Step 4: Inspect final diff**
+
+Run:
+
+```bash
+git status --short
+git diff -- ability-center-runtime abilities/exam-sprint docs/plans/2026-04-27-report-preview-and-legacy-api-removal.md
+```
+
+Expected:
+
+- Removed preview endpoint and old generic endpoint from the controller.
+- Removed `previewHtmlUrl` from response records.
+- Removed preview service method and content record.
+- Runtime/application tests updated to assert the new contract.
+- Internal HTML renderers/templates and PDF generation pipeline remain intact.
+
+Do not commit unless the user explicitly asks.