# Ability Center Naming Migration Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rename and restructure the current `ability-service` codebase into an `ability-center` with a DDD-aligned `exam-sprint` report family, where `outlook` is the first report type and `achievement` is reserved as the next type. **Architecture:** Execute the migration in vertical slices that keep the reactor green: first rename the top-level modules and add the new DDD module shells, then add final-vocabulary kernel/contracts/runtime adapter code, then implement the new application/domain/infrastructure stack under the final package root, and finally delete the legacy `OutlookReportTask...` code in one cleanup pass. Use one stable public resource model (`/api/exam-sprint/reports`) and one stable domain aggregate (`ExamSprintReport`), with `OUTLOOK`/`ACHIEVEMENT` represented as `reportType` rather than top-level module or path names. **Tech Stack:** Java 17, Maven multi-module build, Spring Boot 3.3, Spring Web, Spring Validation, Spring Scheduling/Async, Jackson, Playwright Java, Azure Blob Storage, JUnit 5, MockMvc. --- > **Execution note:** This directory is not yet initialized as a Git repository. Every commit step below is conditional: only run it after `git init` and only if the user explicitly asks for a commit. ## Target File Structure ### Reactor layout ```text ability-center/ ├── pom.xml ├── ability-center-runtime/ ├── ability-center-kernel/ └── abilities/ └── exam-sprint/ ├── contracts/ ├── application/ ├── domain/ └── infrastructure/ ``` ### Final source layout and responsibilities - `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.java` - Spring Boot entrypoint, async/scheduling enablement, runtime assembly. - `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java` - Public HTTP adapter for `POST/GET /api/exam-sprint/reports`. - `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java` - Final exception-to-response mapping using kernel `ErrorCode`. - `ability-center-runtime/src/main/resources/application.yml` - Final runtime properties under `ability.exam-sprint.report`. - `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java` - Shared response envelope. - `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.java` - Shared business exception base type. - `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` - Shared error codes including `REPORT_NOT_FOUND`, `REPORT_TYPE_UNSUPPORTED`, `EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE`. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.java` - Public create-report envelope with `reportType` and `payload`. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java` - Type-specific payload contract for the existing 临考突击展望报告. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.java` - Accepted response for report creation. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java` - Query response for report status and download link. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java` - Report type enum with `OUTLOOK`, `ACHIEVEMENT`. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java` - Shared generation lifecycle enum. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java` - Runtime-facing use-case interface. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java` - Create/query/download/cleanup orchestration. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java` - Async dispatch contract for generation. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java` - Async implementation that calls the worker. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java` - End-to-end render → PDF → store → finalize flow. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.java` - Scheduled cleanup trigger. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java` - Report retention/download/storage config model bound by runtime. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java` - Stable report aggregate storing `reportId`, `reportType`, `payload`, status, expiry, stored object reference. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java` - Report persistence port. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java` - Report artifact storage port. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java` - Type-aware HTML renderer port. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java` - PDF generation port. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java` - In-memory repository implementation. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java` - In-memory storage implementation with local download URL generation. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java` - Azure Blob storage implementation. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java` - PDF implementation using Playwright. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java` - First report type renderer. - `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html` - Renamed template asset for the outlook report type. ## Task 1: Rename the top-level reactor and create the DDD module shells **Files:** - Modify: `pom.xml` - Move/Modify: `ability-bootstrap/` → `ability-center-runtime/` - Move/Modify: `ability-common/` → `ability-center-kernel/` - Create: `abilities/exam-sprint/contracts/pom.xml` - Create: `abilities/exam-sprint/application/pom.xml` - Create: `abilities/exam-sprint/domain/pom.xml` - Create: `abilities/exam-sprint/infrastructure/pom.xml` - Modify: `ability-task/pom.xml` - Modify: `ability-integration/pom.xml` - Modify: `ability-persistence/pom.xml` - [ ] **Step 1: Run a failing reactor check for the renamed runtime module** Run: `mvn -q -pl ability-center-runtime -am test` Expected: FAIL with `Could not find the selected project in the reactor: ability-center-runtime` - [ ] **Step 2: Move the bootstrap and common modules to their final top-level names** Run: ```bash mv ability-bootstrap ability-center-runtime mv ability-common ability-center-kernel mkdir -p abilities/exam-sprint/contracts mkdir -p abilities/exam-sprint/application mkdir -p abilities/exam-sprint/domain mkdir -p abilities/exam-sprint/infrastructure ``` - [ ] **Step 3: Update the parent reactor to expose the new naming shell while keeping the legacy implementation modules available for the migration window** Replace the root module block in `pom.xml` with: ```xml ability-center ability-center Ability center multi-module project ability-center-runtime ability-center-kernel ability-task ability-integration ability-persistence ability-sync ability-callback abilities/exam-sprint/contracts abilities/exam-sprint/application abilities/exam-sprint/domain abilities/exam-sprint/infrastructure ``` Update the renamed child POM headers to: ```xml cn.yunzhixue ability-center 0.0.1-SNAPSHOT ../pom.xml ``` - [ ] **Step 4: Add the new DDD module POMs with the final artifact names** Create `abilities/exam-sprint/contracts/pom.xml`: ```xml 4.0.0 cn.yunzhixue ability-center 0.0.1-SNAPSHOT ../../../pom.xml exam-sprint-contracts exam-sprint-contracts cn.yunzhixue ability-center-kernel ${project.version} jakarta.validation jakarta.validation-api com.fasterxml.jackson.core jackson-databind ``` Create `abilities/exam-sprint/application/pom.xml`: ```xml 4.0.0 cn.yunzhixue ability-center 0.0.1-SNAPSHOT ../../../pom.xml exam-sprint-application exam-sprint-application cn.yunzhixue ability-center-kernel ${project.version} cn.yunzhixue exam-sprint-contracts ${project.version} cn.yunzhixue exam-sprint-domain ${project.version} org.springframework spring-context org.springframework.boot spring-boot-autoconfigure com.fasterxml.jackson.core jackson-databind org.springframework.boot spring-boot-starter-test test ``` Create `abilities/exam-sprint/domain/pom.xml`: ```xml 4.0.0 cn.yunzhixue ability-center 0.0.1-SNAPSHOT ../../../pom.xml exam-sprint-domain exam-sprint-domain cn.yunzhixue ability-center-kernel ${project.version} cn.yunzhixue exam-sprint-contracts ${project.version} com.fasterxml.jackson.core jackson-databind ``` Create `abilities/exam-sprint/infrastructure/pom.xml`: ```xml 4.0.0 cn.yunzhixue ability-center 0.0.1-SNAPSHOT ../../../pom.xml exam-sprint-infrastructure exam-sprint-infrastructure cn.yunzhixue ability-center-kernel ${project.version} cn.yunzhixue exam-sprint-contracts ${project.version} cn.yunzhixue exam-sprint-domain ${project.version} org.springframework spring-context org.springframework.boot spring-boot-autoconfigure com.microsoft.playwright playwright 1.58.0 com.azure azure-storage-blob 12.28.0 com.fasterxml.jackson.core jackson-databind org.springframework.boot spring-boot-starter-test test ``` - [ ] **Step 5: Point the legacy implementation modules at the renamed parent and kernel module so the reactor still compiles during the migration window** In `ability-task/pom.xml`, `ability-integration/pom.xml`, and `ability-persistence/pom.xml`, replace the parent artifact from `ability-service` to `ability-center` and replace any dependency on `ability-common` with `ability-center-kernel`. For example, `ability-task/pom.xml` should contain: ```xml cn.yunzhixue ability-center-kernel ${project.version} ``` - [ ] **Step 6: Run reactor validation to prove the renamed shell is wired correctly** Run: `mvn -q -pl ability-center-runtime -am test -Dtest=AbilityBootstrapApplicationTests` Expected: PASS with the renamed runtime module and the new empty DDD module shells present in the reactor. - [ ] **Step 7: Commit if Git has been initialized and the user explicitly requested a commit** ```bash git add pom.xml ability-center-runtime ability-center-kernel abilities/exam-sprint ability-task/pom.xml ability-integration/pom.xml ability-persistence/pom.xml git commit -m "refactor: add ability center reactor layout" ``` ## Task 2: Add final-vocabulary kernel contracts and the new public HTTP adapter **Files:** - Create: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java` - Create: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.java` - Create: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.java` - Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java` - Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java` - Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java` - Delete: `ability-center-runtime/src/main/java/cn/yunzhixue/microservice/ability/report/outlook/OutlookReportTaskController.java` - [ ] **Step 1: Write a failing WebMvc adapter test for the new report resource** Create `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`: ```java 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.contracts.report.CreateExamSprintReportResponse; 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 com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; import java.time.Instant; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ExamSprintReportController.class) class ExamSprintReportControllerWebMvcTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockBean private ExamSprintReportApplicationService applicationService; @Test void createReportUsesExamSprintReportsEndpoint() throws Exception { given(applicationService.createReport(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" } } } """; mockMvc.perform(post("/api/exam-sprint/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")); } @Test void downloadReportUsesReportIdInsteadOfStorageKey() throws Exception { given(applicationService.getReport(eq("report-001"))).willReturn(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"), "/api/exam-sprint/reports/report-001/download", null)); given(applicationService.downloadReport("report-001")).willReturn(new ReportDownloadContent( "exam-sprint-outlook-report-report-001.pdf", "%PDF".getBytes(StandardCharsets.US_ASCII), "application/pdf")); 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")); mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/download", "report-001")) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF)); } } ``` - [ ] **Step 2: Run the focused WebMvc test to confirm the final API shape does not exist yet** Run: `mvn -q -pl ability-center-runtime -am test -Dtest=ExamSprintReportControllerWebMvcTest` Expected: FAIL with compilation errors for missing `ExamSprintReportController`, `ExamSprintReportApplicationService`, and the final contract classes. - [ ] **Step 3: Create the final kernel package, the report family contracts, and the runtime controller** Create `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java`: ```java package cn.yunzhixue.ability.center.kernel; public record BaseResponse(String code, String message, T data) { public static BaseResponse success(T data) { return new BaseResponse<>("SUCCESS", "success", data); } public static BaseResponse failure(ErrorCode errorCode) { return new BaseResponse<>(errorCode.getCode(), errorCode.getMessage(), null); } } ``` Create `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.java`: ```java package cn.yunzhixue.ability.center.kernel; public class BusinessException extends RuntimeException { private final ErrorCode errorCode; public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } public ErrorCode getErrorCode() { return errorCode; } } ``` Create `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java`: ```java package cn.yunzhixue.ability.center.kernel; public enum ErrorCode { REPORT_NOT_FOUND("REPORT_NOT_FOUND", "report not found", 404), REPORT_TYPE_UNSUPPORTED("REPORT_TYPE_UNSUPPORTED", "report type unsupported", 400), EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE( "EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE", "exam sprint report download unavailable", 500), VALIDATION_ERROR("VALIDATION_ERROR", "validation error", 400), INTERNAL_ERROR("INTERNAL_ERROR", "internal server error", 500); private final String code; private final String message; private final int httpStatusCode; ErrorCode(String code, String message, int httpStatusCode) { this.code = code; this.message = message; this.httpStatusCode = httpStatusCode; } public String getCode() { return code; } public String getMessage() { return message; } public int getHttpStatusCode() { return httpStatusCode; } } ``` Create `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java`: ```java package cn.yunzhixue.ability.center.examsprint.contracts.report; public enum ExamSprintReportType { OUTLOOK, ACHIEVEMENT } ``` Create `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java`: ```java package cn.yunzhixue.ability.center.examsprint.contracts.report; public enum ExamSprintReportGenerationStatus { PENDING, PROCESSING, SUCCESS, FAILED, EXPIRED } ``` Create `CreateExamSprintReportRequest.java`: ```java 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) { } ``` Create `CreateExamSprintReportResponse.java`: ```java package cn.yunzhixue.ability.center.examsprint.contracts.report; import java.time.Instant; public record CreateExamSprintReportResponse( String reportId, ExamSprintReportType reportType, ExamSprintReportGenerationStatus generationStatus, Instant createdAt, Instant expiresAt) { } ``` Create `ExamSprintReportDetailResponse.java`: ```java package cn.yunzhixue.ability.center.examsprint.contracts.report; import java.time.Instant; public record ExamSprintReportDetailResponse( String reportId, ExamSprintReportType reportType, ExamSprintReportGenerationStatus generationStatus, Instant createdAt, Instant updatedAt, Instant expiresAt, String downloadUrl, String failureReason) { } ``` Create `OutlookExamSprintReportPayload.java` with the final field vocabulary: ```java package cn.yunzhixue.ability.center.examsprint.contracts.report; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; public record OutlookExamSprintReportPayload( @NotNull @Valid ReportMetadata reportMetadata, @NotNull @Valid ReadinessOverview readinessOverview, @NotNull @Valid SyllabusMasteryProfile syllabusMasteryProfile, @NotNull @Valid VocabularyProfile pastPaperVocabularyProfile, @NotNull @Valid VocabularyProfile highFrequencyVocabularyProfile, @NotEmpty List<@Valid VocabularyFrequencyBand> vocabularyFrequencyBands, @NotEmpty List<@Valid SprintPlanOption> sprintPlanOptions, @NotNull @Valid DiagnosticCaseStudy diagnosticCaseStudy) { public record ReportMetadata( @NotBlank String reportVersionLabel, @NotBlank String learnerName, @NotBlank String targetExamName, @NotBlank String sprintPeriodLabel, @NotBlank String authorName) { } public record ReadinessOverview( @NotBlank String summary, @NotBlank String currentStage, @NotBlank String keyInsight, @Min(0) @Max(100) int readinessScore) { } public record SyllabusMasteryProfile( @Min(0) @Max(100) int masteryPercent, @NotBlank String diagnosis, @NotBlank String recommendation, @NotEmpty List<@Valid DimensionScore> dimensionScores) { } public record VocabularyProfile( @Min(0) int masteredWordCount, @Min(1) int totalWordCount, @Min(0) @Max(100) int masteryPercent, @NotBlank String diagnosis, @NotBlank String recommendation, List<@NotBlank String> sampleWords) { } public record DimensionScore(@NotBlank String label, @Min(0) @Max(100) int score) { } public record VocabularyFrequencyBand( @NotBlank String bandLabel, @Min(0) @Max(100) int masteryPercent, @Min(0) @Max(100) int targetPercent) { } public record SprintPlanOption( @NotBlank String planName, @NotBlank String cadenceLabel, String tagLabel, @NotBlank String focus, @NotEmpty List<@NotBlank String> actionItems, @NotBlank String expectedOutcome) { } public record DiagnosticCaseStudy( @NotBlank String title, @NotBlank String context, @NotBlank String diagnosis, @NotBlank String strategy, @NotBlank String keyTakeaway) { } } ``` Create `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java`: ```java 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; public interface ExamSprintReportApplicationService { CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request); ExamSprintReportDetailResponse getReport(String reportId); ReportDownloadContent downloadReport(String reportId); record ReportDownloadContent(String fileName, byte[] bytes, String contentType) { } } ``` Create `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java`: ```java 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.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 org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/exam-sprint/reports") public class ExamSprintReportController { private final ExamSprintReportApplicationService applicationService; public ExamSprintReportController(ExamSprintReportApplicationService applicationService) { this.applicationService = applicationService; } @PostMapping public ResponseEntity> createReport( @Valid @RequestBody CreateExamSprintReportRequest request) { return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createReport(request))); } @GetMapping("/{reportId}") public BaseResponse getReport(@PathVariable String reportId) { return BaseResponse.success(applicationService.getReport(reportId)); } @GetMapping("/{reportId}/download") public ResponseEntity downloadReport(@PathVariable String reportId) { ReportDownloadContent content = applicationService.downloadReport(reportId); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(content.contentType())) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() .filename(content.fileName(), StandardCharsets.UTF_8) .build() .toString()) .body(content.bytes()); } } ``` - [ ] **Step 4: Run the WebMvc slice again and confirm the public API vocabulary is now stable** Run: `mvn -q -pl ability-center-runtime -am test -Dtest=ExamSprintReportControllerWebMvcTest` Expected: PASS with the new `reportId`, `reportType`, and `generationStatus` fields on `/api/exam-sprint/reports`. - [ ] **Step 5: Commit if Git has been initialized and the user explicitly requested a commit** ```bash git add ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel abilities/exam-sprint/contracts abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http git commit -m "refactor: add exam sprint report public contracts" ``` ## Task 3: Introduce the final report aggregate and application service under the new naming system **Files:** - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java` - Create: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java` - [ ] **Step 1: Write the failing application service test under the final package tree** Create `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`: ```java 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.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; 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.ExamSprintReportStorage; import cn.yunzhixue.ability.center.kernel.BusinessException; import cn.yunzhixue.ability.center.kernel.ErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.net.URI; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class ExamSprintReportApplicationServiceTest { private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-02T00:00:00Z"), ZoneOffset.UTC); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Test void createReportStoresOutlookTypeAndReturnsReportId() throws Exception { TestRepository repository = new TestRepository(); TestStorage storage = new TestStorage(); ExamSprintReportProperties properties = properties(); DefaultExamSprintReportApplicationService service = new DefaultExamSprintReportApplicationService( repository, reportId -> { }, storage, properties, FIXED_CLOCK); CreateExamSprintReportRequest request = new CreateExamSprintReportRequest( ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.readTree(""" {"reportMetadata":{"reportVersionLabel":"2026 词汇展望报告","learnerName":"李同学","targetExamName":"雅思 6.5","sprintPeriodLabel":"2026 春季冲刺","authorName":"Ability Bot"}} """)); var response = service.createReport(request); assertThat(response.reportId()).isNotBlank(); ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow(); assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK); assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING); } @Test void getReportReturnsSignedDownloadUrlForSuccessfulReport() { TestRepository repository = new TestRepository(); TestStorage storage = new TestStorage(); ExamSprintReport report = ExamSprintReport.pending( "report-success", ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode(), FIXED_CLOCK.instant().minusSeconds(120), FIXED_CLOCK.instant().plusSeconds(3600)) .success( FIXED_CLOCK.instant().minusSeconds(30), "exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf", "exam-sprint-outlook-report-report-success.pdf"); repository.save(report); DefaultExamSprintReportApplicationService service = new DefaultExamSprintReportApplicationService( repository, reportId -> { }, storage, properties(), FIXED_CLOCK); var response = service.getReport("report-success"); assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS); assertThat(response.downloadUrl()).contains("sig=test"); assertThat(storage.generatedKeys).containsExactly("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf"); } @Test void downloadReportRejectsExpiredReportBeforeCleanupRuns() { TestRepository repository = new TestRepository(); TestStorage storage = new TestStorage(); repository.save(ExamSprintReport.pending( "report-expired", ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode(), 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")); DefaultExamSprintReportApplicationService service = new DefaultExamSprintReportApplicationService( repository, reportId -> { }, storage, properties(), FIXED_CLOCK); assertThatThrownBy(() -> service.downloadReport("report-expired")) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException) exception).getErrorCode()) .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE); } private ExamSprintReportProperties properties() { ExamSprintReportProperties properties = new ExamSprintReportProperties(); properties.setRetention(Duration.ofDays(1)); properties.setDownloadExpiry(Duration.ofMinutes(15)); return properties; } private static class TestRepository implements ExamSprintReportRepository { private final ConcurrentMap storage = new ConcurrentHashMap<>(); @Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; } @Override public Optional findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); } @Override public List findExpiredBefore(Instant instant) { return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList(); } } private static class TestStorage implements ExamSprintReportStorage { private final List generatedKeys = new ArrayList<>(); @Override public StoredExamSprintReportFile upload(String reportId, ExamSprintReportType reportType, String fileName, byte[] pdfBytes, Instant expiresAt) { return new StoredExamSprintReportFile("blob/" + reportId + "/" + fileName, fileName); } @Override public URI generateDownloadUrl(String storageObjectKey, Duration ttl) { generatedKeys.add(storageObjectKey); return URI.create("https://download.example.local/" + storageObjectKey + "?sig=test"); } @Override public Optional download(String storageObjectKey) { return Optional.empty(); } @Override public void delete(String storageObjectKey) { } } } ``` - [ ] **Step 2: Run the focused application test and confirm the final aggregate/application service layer does not exist yet** Run: `mvn -q -pl abilities/exam-sprint/application -am test -Dtest=ExamSprintReportApplicationServiceTest` Expected: FAIL with missing classes such as `ExamSprintReport`, `DefaultExamSprintReportApplicationService`, and `ExamSprintReportStorage`. - [ ] **Step 3: Create the final aggregate, the report ports, and the application implementation** Create `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; public record ExamSprintReport( String reportId, ExamSprintReportType reportType, JsonNode payload, ExamSprintReportGenerationStatus generationStatus, Instant createdAt, Instant updatedAt, Instant expiresAt, String storageObjectKey, String fileName, String failureReason) { public static ExamSprintReport pending( String reportId, ExamSprintReportType reportType, JsonNode payload, Instant createdAt, Instant expiresAt) { return new ExamSprintReport( reportId, reportType, payload, ExamSprintReportGenerationStatus.PENDING, createdAt, createdAt, expiresAt, null, null, null); } public ExamSprintReport processing(Instant updatedAt) { return new ExamSprintReport(reportId, reportType, payload, ExamSprintReportGenerationStatus.PROCESSING, createdAt, updatedAt, expiresAt, storageObjectKey, fileName, null); } public ExamSprintReport success(Instant updatedAt, String storageObjectKey, String fileName) { return new ExamSprintReport(reportId, reportType, payload, ExamSprintReportGenerationStatus.SUCCESS, createdAt, updatedAt, expiresAt, storageObjectKey, fileName, null); } public ExamSprintReport failed(Instant updatedAt, String failureReason) { return new ExamSprintReport(reportId, reportType, payload, ExamSprintReportGenerationStatus.FAILED, createdAt, updatedAt, expiresAt, storageObjectKey, fileName, failureReason); } public ExamSprintReport expired(Instant updatedAt) { return new ExamSprintReport(reportId, reportType, payload, ExamSprintReportGenerationStatus.EXPIRED, createdAt, updatedAt, expiresAt, storageObjectKey, fileName, failureReason); } public ExamSprintReport expiredWithStorageCleared(Instant updatedAt) { return new ExamSprintReport(reportId, reportType, payload, ExamSprintReportGenerationStatus.EXPIRED, createdAt, updatedAt, expiresAt, null, null, failureReason); } public boolean isExpiredAt(Instant instant) { return !expiresAt.isAfter(instant); } } ``` Create `ExamSprintReportRepository.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import java.time.Instant; import java.util.List; import java.util.Optional; public interface ExamSprintReportRepository { ExamSprintReport save(ExamSprintReport report); Optional findById(String reportId); List findExpiredBefore(Instant instant); } ``` Create `ExamSprintReportStorage.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import java.net.URI; import java.time.Duration; import java.time.Instant; import java.util.Optional; public interface ExamSprintReportStorage { StoredExamSprintReportFile upload(String reportId, ExamSprintReportType reportType, String fileName, byte[] pdfBytes, Instant expiresAt); URI generateDownloadUrl(String storageObjectKey, Duration ttl); Optional download(String storageObjectKey); void delete(String storageObjectKey); record StoredExamSprintReportFile(String storageObjectKey, String fileName) { } record StoredExamSprintReportContent(String fileName, byte[] bytes, String contentType) { } } ``` Create `ExamSprintReportRenderer.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; public interface ExamSprintReportRenderer { boolean supports(ExamSprintReportType reportType); String render(JsonNode payload, Instant generatedAt); } ``` Create `ExamSprintReportPdfGenerator.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; public interface ExamSprintReportPdfGenerator { byte[] generate(String htmlContent); } ``` Create `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; public interface ExamSprintReportGenerationDispatcher { void dispatch(String reportId); } ``` Create `ExamSprintReportProperties.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import java.time.Duration; public class ExamSprintReportProperties { private Duration retention = Duration.ofDays(1); private Duration downloadExpiry = Duration.ofMinutes(15); private long cleanupIntervalMs = Duration.ofMinutes(10).toMillis(); private final Async async = new Async(); private final Storage storage = new Storage(); public Duration getRetention() { return retention; } public void setRetention(Duration retention) { this.retention = retention; } public Duration getDownloadExpiry() { return downloadExpiry; } public void setDownloadExpiry(Duration downloadExpiry) { this.downloadExpiry = downloadExpiry; } public long getCleanupIntervalMs() { return cleanupIntervalMs; } public void setCleanupIntervalMs(long cleanupIntervalMs) { this.cleanupIntervalMs = cleanupIntervalMs; } public Async getAsync() { return async; } public Storage getStorage() { return storage; } public static class Async { private int corePoolSize = 2; private int maxPoolSize = 4; private int queueCapacity = 100; public int getCorePoolSize() { return corePoolSize; } public void setCorePoolSize(int corePoolSize) { this.corePoolSize = corePoolSize; } public int getMaxPoolSize() { return maxPoolSize; } public void setMaxPoolSize(int maxPoolSize) { this.maxPoolSize = maxPoolSize; } public int getQueueCapacity() { return queueCapacity; } public void setQueueCapacity(int queueCapacity) { this.queueCapacity = queueCapacity; } } public static class Storage { private String type = "memory"; private String containerName = "exam-sprint-reports"; private String connectionString; private String endpoint; private String accountName; private String accountKey; public String getType() { return type; } public void setType(String type) { this.type = type; } public String getContainerName() { return containerName; } public void setContainerName(String containerName) { this.containerName = containerName; } public String getConnectionString() { return connectionString; } public void setConnectionString(String connectionString) { this.connectionString = connectionString; } public String getEndpoint() { return endpoint; } public void setEndpoint(String endpoint) { this.endpoint = endpoint; } public String getAccountName() { return accountName; } public void setAccountName(String accountName) { this.accountName = accountName; } public String getAccountKey() { return accountKey; } public void setAccountKey(String accountKey) { this.accountKey = accountKey; } } } ``` Create `DefaultExamSprintReportApplicationService.java`: ```java 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 cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage; import cn.yunzhixue.ability.center.kernel.BusinessException; import cn.yunzhixue.ability.center.kernel.ErrorCode; import org.springframework.stereotype.Service; import java.time.Clock; import java.time.Instant; import java.util.UUID; @Service public class DefaultExamSprintReportApplicationService implements ExamSprintReportApplicationService { private final ExamSprintReportRepository repository; private final ExamSprintReportGenerationDispatcher dispatcher; private final ExamSprintReportStorage storage; private final ExamSprintReportProperties properties; private final Clock clock; public DefaultExamSprintReportApplicationService( ExamSprintReportRepository repository, ExamSprintReportGenerationDispatcher dispatcher, ExamSprintReportStorage storage, ExamSprintReportProperties properties, Clock clock) { this.repository = repository; this.dispatcher = dispatcher; this.storage = storage; this.properties = properties; this.clock = clock; } @Override public CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request) { Instant now = clock.instant(); ExamSprintReport report = ExamSprintReport.pending( UUID.randomUUID().toString(), request.reportType(), request.payload(), now, now.plus(properties.getRetention())); repository.save(report); dispatcher.dispatch(report.reportId()); return new CreateExamSprintReportResponse( report.reportId(), report.reportType(), report.generationStatus(), report.createdAt(), report.expiresAt()); } @Override public ExamSprintReportDetailResponse getReport(String reportId) { Instant now = clock.instant(); ExamSprintReport report = requireReport(reportId); if (report.isExpiredAt(now) && report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) { report = repository.save(report.expired(now)); } String downloadUrl = null; if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS && !report.isExpiredAt(now) && report.storageObjectKey() != null) { downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString(); } return new ExamSprintReportDetailResponse( report.reportId(), report.reportType(), report.generationStatus(), report.createdAt(), report.updatedAt(), report.expiresAt(), downloadUrl, report.failureReason()); } @Override public ReportDownloadContent downloadReport(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); } return storage.download(report.storageObjectKey()) .map(content -> new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType())) .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE)); } public void cleanupExpiredReports() { Instant now = clock.instant(); for (ExamSprintReport report : repository.findExpiredBefore(now)) { if (report.storageObjectKey() != null) { storage.delete(report.storageObjectKey()); repository.save(report.expiredWithStorageCleared(now)); } else if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) { repository.save(report.expired(now)); } } } private ExamSprintReport requireReport(String reportId) { return repository.findById(reportId) .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); } } ``` - [ ] **Step 4: Re-run the focused application test and verify the report aggregate vocabulary is now in place** Run: `mvn -q -pl abilities/exam-sprint/application -am test -Dtest=ExamSprintReportApplicationServiceTest` Expected: PASS with `reportId`, `reportType`, `generationStatus`, and `storageObjectKey` replacing the legacy task vocabulary. - [ ] **Step 5: Commit if Git has been initialized and the user explicitly requested a commit** ```bash git add abilities/exam-sprint/domain abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java git commit -m "refactor: add exam sprint report aggregate and application service" ``` ## Task 4: Implement the new generation worker and the OUTLOOK infrastructure adapters **Files:** - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java` - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.java` - Create: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java` - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java` - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java` - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java` - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java` - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java` - Create: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java` - Create: `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html` - [ ] **Step 1: Write the failing worker and renderer tests for the OUTLOOK report type** Create `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import static org.assertj.core.api.Assertions.assertThat; class ExamSprintReportGenerationWorkerTest { private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Test void processMarksReportSuccessAfterUpload() { TestRepository repository = new TestRepository(); repository.save(ExamSprintReport.pending( "report-success", ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode(), FIXED_CLOCK.instant(), FIXED_CLOCK.instant().plusSeconds(86400))); TestStorage storage = new TestStorage(); ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker( repository, List.of(new TestRenderer()), html -> html.getBytes(StandardCharsets.UTF_8), storage, FIXED_CLOCK); worker.process("report-success"); 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"); } private static class TestRenderer implements ExamSprintReportRenderer { @Override public boolean supports(ExamSprintReportType reportType) { return reportType == ExamSprintReportType.OUTLOOK; } @Override public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) { return "ok"; } } private static class TestRepository implements ExamSprintReportRepository { private final ConcurrentMap storage = new ConcurrentHashMap<>(); @Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; } @Override public Optional findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); } @Override public List findExpiredBefore(Instant instant) { return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList(); } } private static class TestStorage implements ExamSprintReportStorage { @Override public StoredExamSprintReportFile upload(String reportId, ExamSprintReportType reportType, String fileName, byte[] pdfBytes, Instant expiresAt) { return new StoredExamSprintReportFile("exam-sprint-reports/outlook/" + reportId + "/" + fileName, fileName); } @Override public java.net.URI generateDownloadUrl(String storageObjectKey, java.time.Duration ttl) { return java.net.URI.create("https://download.example.local/" + storageObjectKey + "?sig=test"); } @Override public Optional download(String storageObjectKey) { return Optional.empty(); } @Override public void delete(String storageObjectKey) { } } } ``` Create `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java` with a fixture-based test that instantiates `ClasspathOutlookExamSprintReportRenderer`, passes an `OutlookExamSprintReportPayload`-shaped `JsonNode`, and asserts the rendered HTML contains `2026 词汇展望报告`, `常考词汇掌握情况`, and `7 天提分冲刺`. - [ ] **Step 2: Run the focused worker and renderer tests to confirm the final generation pipeline does not exist yet** Run: `mvn -q -pl abilities/exam-sprint/application,abilities/exam-sprint/infrastructure -am test -Dtest=ExamSprintReportGenerationWorkerTest,ClasspathOutlookExamSprintReportRendererTest` Expected: FAIL with missing `ExamSprintReportGenerationWorker`, missing infrastructure implementations, and missing final template files. - [ ] **Step 3: Implement the generation worker, the final storage/persistence/PDF adapters, and the first OUTLOOK renderer** Create `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component public class AsyncExamSprintReportGenerationDispatcher implements ExamSprintReportGenerationDispatcher { private final ExamSprintReportGenerationWorker worker; public AsyncExamSprintReportGenerationDispatcher(ExamSprintReportGenerationWorker worker) { this.worker = worker; } @Override @Async("examSprintReportExecutor") public void dispatch(String reportId) { worker.process(reportId); } } ``` Create `ExamSprintReportGenerationWorker.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage; import org.springframework.stereotype.Service; import java.time.Clock; import java.time.Instant; import java.util.List; @Service public class ExamSprintReportGenerationWorker { private final ExamSprintReportRepository repository; private final List renderers; private final ExamSprintReportPdfGenerator pdfGenerator; private final ExamSprintReportStorage storage; private final Clock clock; public ExamSprintReportGenerationWorker( ExamSprintReportRepository repository, List renderers, ExamSprintReportPdfGenerator pdfGenerator, ExamSprintReportStorage storage, Clock clock) { this.repository = repository; this.renderers = renderers; this.pdfGenerator = pdfGenerator; this.storage = storage; this.clock = clock; } public void process(String reportId) { Instant startedAt = clock.instant(); ExamSprintReport report = repository.findById(reportId).orElse(null); if (report == null || report.generationStatus() != ExamSprintReportGenerationStatus.PENDING || report.isExpiredAt(startedAt)) { return; } repository.save(report.processing(startedAt)); try { String html = rendererFor(report).render(report.payload(), startedAt); byte[] pdfBytes = pdfGenerator.generate(html); String typeSegment = report.reportType().name().toLowerCase(); String fileName = "exam-sprint-" + typeSegment + "-report-" + report.reportId() + ".pdf"; var storedFile = storage.upload(report.reportId(), report.reportType(), fileName, pdfBytes, report.expiresAt()); repository.save(repository.findById(reportId).orElseThrow().success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName())); } catch (Exception exception) { repository.findById(reportId).ifPresent(current -> repository.save(current.failed(clock.instant(), failureReasonOf(exception)))); } } private ExamSprintReportRenderer rendererFor(ExamSprintReport report) { return renderers.stream() .filter(renderer -> renderer.supports(report.reportType())) .findFirst() .orElseThrow(() -> new IllegalStateException("No renderer for report type " + report.reportType())); } private String failureReasonOf(Exception exception) { String message = exception.getMessage(); return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; } } ``` Create `ExamSprintReportRetentionScheduler.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class ExamSprintReportRetentionScheduler { private final DefaultExamSprintReportApplicationService applicationService; public ExamSprintReportRetentionScheduler(DefaultExamSprintReportApplicationService applicationService) { this.applicationService = applicationService; } @Scheduled(fixedDelayString = "${ability.exam-sprint.report.cleanup-interval-ms:600000}") public void cleanupExpiredReports() { applicationService.cleanupExpiredReports(); } } ``` Create `InMemoryExamSprintReportRepository.java`: ```java package cn.yunzhixue.ability.center.examsprint.infrastructure.report.repository; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository; import org.springframework.stereotype.Repository; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @Repository public class InMemoryExamSprintReportRepository implements ExamSprintReportRepository { private final ConcurrentMap storage = new ConcurrentHashMap<>(); @Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; } @Override public Optional findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); } @Override public List findExpiredBefore(Instant instant) { return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList(); } } ``` Create `InMemoryExamSprintReportStorage.java`: ```java package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.net.URI; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @Component @ConditionalOnProperty(prefix = "ability.exam-sprint.report.storage", name = "type", havingValue = "memory", matchIfMissing = true) public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage { private final Map storage = new ConcurrentHashMap<>(); @Override public StoredExamSprintReportFile upload(String reportId, ExamSprintReportType reportType, String fileName, byte[] pdfBytes, Instant expiresAt) { String typeSegment = reportType.name().toLowerCase(); String storageObjectKey = "exam-sprint-reports/" + typeSegment + "/" + reportId + "/" + fileName; storage.put(storageObjectKey, pdfBytes); return new StoredExamSprintReportFile(storageObjectKey, fileName); } @Override public URI generateDownloadUrl(String storageObjectKey, Duration ttl) { return URI.create("/api/exam-sprint/reports/" + reportId(storageObjectKey) + "/download"); } @Override public Optional download(String storageObjectKey) { byte[] pdfBytes = storage.get(storageObjectKey); if (pdfBytes == null) { return Optional.empty(); } return Optional.of(new StoredExamSprintReportContent(fileName(storageObjectKey), pdfBytes, "application/pdf")); } @Override public void delete(String storageObjectKey) { storage.remove(storageObjectKey); } private String reportId(String storageObjectKey) { String[] segments = storageObjectKey.split("/"); return segments[2]; } private String fileName(String storageObjectKey) { return storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1); } } ``` Create `AzureBlobExamSprintReportStorage.java` by renaming the current Azure implementation to use: ```java String blobName = "exam-sprint-reports/" + reportType.name().toLowerCase() + "/" + reportId + "/" + fileName; blobClient.setMetadata(Map.of( "reportId", reportId, "reportType", reportType.name(), "expiresAt", expiresAt.toString())); ``` Create `PlaywrightExamSprintReportPdfGenerator.java` by using Playwright Chromium to render the final HTML into PDF bytes under the final package tree. Create `ClasspathOutlookExamSprintReportRenderer.java`: ```java package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType; import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; import java.time.Instant; @Component public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public boolean supports(ExamSprintReportType reportType) { return reportType == ExamSprintReportType.OUTLOOK; } @Override public String render(JsonNode payload, Instant generatedAt) { try { OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class); String template = new String(new ClassPathResource("templates/outlook-exam-sprint-report-template.html") .getInputStream() .readAllBytes(), StandardCharsets.UTF_8); return template .replace("{{reportVersionLabel}}", escape(reportPayload.reportMetadata().reportVersionLabel())) .replace("{{learnerName}}", escape(reportPayload.reportMetadata().learnerName())) .replace("{{targetExamName}}", escape(reportPayload.reportMetadata().targetExamName())) .replace("{{sprintPeriodLabel}}", escape(reportPayload.reportMetadata().sprintPeriodLabel())) .replace("{{authorName}}", escape(reportPayload.reportMetadata().authorName())); } catch (Exception exception) { throw new IllegalStateException("Failed to render outlook exam sprint report", exception); } } private String escape(String value) { return value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">"); } } ``` Copy `ability-integration/src/main/resources/templates/outlook-report-template.html` to `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`, then rename placeholders to the final field names (`reportVersionLabel`, `targetExamName`, `sprintPlanOptions`, `highFrequencyVocabularyProfile`, etc.) while preserving the current visual layout. - [ ] **Step 4: Re-run the focused worker and renderer tests** Run: `mvn -q -pl abilities/exam-sprint/application,abilities/exam-sprint/infrastructure -am test -Dtest=ExamSprintReportGenerationWorkerTest,ClasspathOutlookExamSprintReportRendererTest` Expected: PASS with a successful OUTLOOK report generation path, the final storage object naming, and the renamed template renderer. - [ ] **Step 5: Commit if Git has been initialized and the user explicitly requested a commit** ```bash git add abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java abilities/exam-sprint/infrastructure git commit -m "refactor: add exam sprint report generation infrastructure" ``` ## Task 5: Switch the runtime to the final package tree, remove legacy modules, and verify the full stack **Files:** - Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.java` - Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java` - Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/HealthController.java` - Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java` - Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java` - Create: `ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.json` - Create: `ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-invalid-request.json` - Modify: `ability-center-runtime/src/main/resources/application.yml` - Modify: `ability-center-runtime/pom.xml` - Modify: `pom.xml` - Delete: `ability-task/` - Delete: `ability-integration/` - Delete: `ability-persistence/` - Delete: `ability-sync/` - Delete: `ability-callback/` - Delete: all legacy `cn/yunzhixue/microservice/ability/**` source trees still left under `ability-center-runtime` and `ability-center-kernel` - [ ] **Step 1: Write the final end-to-end controller test against the full stack** Create `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java` by porting the current integration test to the final names: ```java package cn.yunzhixue.ability.center.examsprint.adapter.http; import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport; import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.StreamUtils; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class ExamSprintReportControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private ExamSprintReportRepository reportRepository; @Test void createReportReturnsAcceptedResponse() throws Exception { mockMvc.perform(post("/api/exam-sprint/reports") .contentType(MediaType.APPLICATION_JSON) .content(validRequestJson())) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.data.reportId").isNotEmpty()) .andExpect(jsonPath("$.data.reportType").value("OUTLOOK")) .andExpect(jsonPath("$.data.generationStatus").value("PENDING")); } @Test void getCreatedReportDownloadUrlReturnsPdfContent() throws Exception { MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports") .contentType(MediaType.APPLICATION_JSON) .content(validRequestJson())) .andExpect(status().isAccepted()) .andReturn(); String reportId = readJson(createResult).at("/data/reportId").asText(); JsonNode queryBody = waitForSuccessfulReport(reportId); URI downloadUri = URI.create(queryBody.at("/data/downloadUrl").asText()); mockMvc.perform(get(downloadUri)) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF)); } @Test void expiredReportDownloadIsRejected() throws Exception { MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports") .contentType(MediaType.APPLICATION_JSON) .content(validRequestJson())) .andExpect(status().isAccepted()) .andReturn(); String reportId = readJson(createResult).at("/data/reportId").asText(); JsonNode queryBody = waitForSuccessfulReport(reportId); URI downloadUri = URI.create(queryBody.at("/data/downloadUrl").asText()); ExamSprintReport report = reportRepository.findById(reportId).orElseThrow(); reportRepository.save(new ExamSprintReport( report.reportId(), report.reportType(), report.payload(), report.generationStatus(), report.createdAt(), report.updatedAt(), report.createdAt().minusSeconds(1), report.storageObjectKey(), report.fileName(), report.failureReason())); mockMvc.perform(get(downloadUri)) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.code").value("EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE")); assertThat(reportRepository.findById(reportId).orElseThrow().generationStatus()) .isEqualTo(ExamSprintReportGenerationStatus.EXPIRED); } private String validRequestJson() { return readRequestFromClasspath("requests/exam-sprint-outlook-report-request.json"); } private String readRequestFromClasspath(String resourcePath) { try (InputStream inputStream = new ClassPathResource(resourcePath).getInputStream()) { return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); } catch (Exception exception) { throw new IllegalStateException("Failed to read request fixture from classpath: " + resourcePath, exception); } } private JsonNode readJson(MvcResult result) throws Exception { return objectMapper.readTree(result.getResponse().getContentAsString()); } private JsonNode waitForSuccessfulReport(String reportId) throws Exception { JsonNode queryBody = null; for (int attempt = 0; attempt < 30; attempt++) { MvcResult queryResult = mockMvc.perform(get("/api/exam-sprint/reports/{reportId}", reportId)) .andExpect(status().isOk()) .andReturn(); queryBody = readJson(queryResult); if ("SUCCESS".equals(queryBody.at("/data/generationStatus").asText())) { return queryBody; } Thread.sleep(100L); } return queryBody; } } ``` - [ ] **Step 2: Run the full runtime integration test and confirm the old runtime wiring is still in the way** Run: `mvn -q -pl ability-center-runtime -am test -Dtest=ExamSprintReportControllerTest` Expected: FAIL because the runtime module is still bootstrapping legacy packages, legacy configuration properties, and legacy implementation module dependencies. - [ ] **Step 3: Replace the runtime wiring, the request fixtures, and the reactor dependencies with the final package tree** Create `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.java`: ```java package cn.yunzhixue.ability.center; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync @EnableScheduling @SpringBootApplication public class AbilityCenterRuntimeApplication { public static void main(String[] args) { SpringApplication.run(AbilityCenterRuntimeApplication.class, args); } } ``` Create `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java`: ```java package cn.yunzhixue.ability.center; import cn.yunzhixue.ability.center.kernel.BaseResponse; import cn.yunzhixue.ability.center.kernel.BusinessException; import cn.yunzhixue.ability.center.kernel.ErrorCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; 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; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException exception) { return ResponseEntity .status(HttpStatusCode.valueOf(exception.getErrorCode().getHttpStatusCode())) .body(BaseResponse.failure(exception.getErrorCode())); } @ExceptionHandler({MissingServletRequestParameterException.class, MethodArgumentNotValidException.class, BindException.class}) public ResponseEntity> handleValidationException(Exception exception) { return ResponseEntity .status(HttpStatusCode.valueOf(ErrorCode.VALIDATION_ERROR.getHttpStatusCode())) .body(BaseResponse.failure(ErrorCode.VALIDATION_ERROR)); } @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception exception) { log.error("Unhandled exception caught by global handler", exception); return ResponseEntity .status(HttpStatusCode.valueOf(ErrorCode.INTERNAL_ERROR.getHttpStatusCode())) .body(BaseResponse.failure(ErrorCode.INTERNAL_ERROR)); } } ``` Create `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/HealthController.java`: ```java package cn.yunzhixue.ability.center; import cn.yunzhixue.ability.center.kernel.BaseResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HealthController { @GetMapping("/api/health") public BaseResponse health() { return BaseResponse.success("ok"); } } ``` Create `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java`: ```java package cn.yunzhixue.ability.center.examsprint.configuration; import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.time.Clock; import java.util.concurrent.Executor; @Configuration @EnableConfigurationProperties(ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties.class) public class ExamSprintReportRuntimeConfiguration { @Bean public Clock systemClock() { return Clock.systemUTC(); } @Bean public ExamSprintReportProperties examSprintReportProperties(BoundExamSprintReportProperties bound) { ExamSprintReportProperties properties = new ExamSprintReportProperties(); properties.setRetention(bound.getRetention()); properties.setDownloadExpiry(bound.getDownloadExpiry()); properties.setCleanupIntervalMs(bound.getCleanupIntervalMs()); properties.getAsync().setCorePoolSize(bound.getAsync().getCorePoolSize()); properties.getAsync().setMaxPoolSize(bound.getAsync().getMaxPoolSize()); properties.getAsync().setQueueCapacity(bound.getAsync().getQueueCapacity()); properties.getStorage().setType(bound.getStorage().getType()); properties.getStorage().setContainerName(bound.getStorage().getContainerName()); properties.getStorage().setConnectionString(bound.getStorage().getConnectionString()); properties.getStorage().setEndpoint(bound.getStorage().getEndpoint()); properties.getStorage().setAccountName(bound.getStorage().getAccountName()); properties.getStorage().setAccountKey(bound.getStorage().getAccountKey()); return properties; } @Bean(name = "examSprintReportExecutor") public Executor examSprintReportExecutor(ExamSprintReportProperties properties) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setThreadNamePrefix("exam-sprint-report-"); executor.setCorePoolSize(properties.getAsync().getCorePoolSize()); executor.setMaxPoolSize(properties.getAsync().getMaxPoolSize()); executor.setQueueCapacity(properties.getAsync().getQueueCapacity()); executor.initialize(); return executor; } @ConfigurationProperties(prefix = "ability.exam-sprint.report") public static class BoundExamSprintReportProperties extends ExamSprintReportProperties { } } ``` Replace `ability-center-runtime/src/main/resources/application.yml` with: ```yaml ability: exam-sprint: report: retention: 1d download-expiry: 15m cleanup-interval-ms: 600000 async: core-pool-size: 2 max-pool-size: 4 queue-capacity: 100 storage: type: memory container-name: exam-sprint-reports connection-string: endpoint: account-name: account-key: ``` Create `ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.json`: ```json { "reportType": "OUTLOOK", "payload": { "reportMetadata": { "reportVersionLabel": "2026 词汇展望报告", "learnerName": "李同学", "targetExamName": "雅思 6.5", "sprintPeriodLabel": "2026 春季冲刺", "authorName": "Ability Bot" }, "readinessOverview": { "summary": "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。", "currentStage": "当前阶段:稳态提升", "keyInsight": "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。", "readinessScore": 78 }, "syllabusMasteryProfile": { "masteryPercent": 76, "diagnosis": "核心考纲理解较稳,长尾主题词还存在断层。", "recommendation": "先补齐教育、科技和环境主题词,再做套题复盘。", "dimensionScores": [ {"label": "核心考纲", "score": 82}, {"label": "场景迁移", "score": 71}, {"label": "同义替换", "score": 68} ] }, "pastPaperVocabularyProfile": { "masteredWordCount": 620, "totalWordCount": 800, "masteryPercent": 78, "diagnosis": "Exam 高频词识别准确,但主动输出不够稳定。", "recommendation": "每次精听后补 5 组同义替换并做口头复述。", "sampleWords": ["cohesion", "allocate", "feasible"] }, "highFrequencyVocabularyProfile": { "masteredWordCount": 1400, "totalWordCount": 1800, "masteryPercent": 77, "diagnosis": "Common 高频词覆盖较广,但易混词记忆不牢。", "recommendation": "围绕校园、城市、科技场景做词块复现。", "sampleWords": ["sustainable", "motivate", "urban"] }, "vocabularyFrequencyBands": [ {"bandLabel": "2k 高频", "masteryPercent": 86, "targetPercent": 90}, {"bandLabel": "3k 高频", "masteryPercent": 78, "targetPercent": 88}, {"bandLabel": "学术词", "masteryPercent": 62, "targetPercent": 80} ], "sprintPlanOptions": [ { "planName": "7 天提分冲刺", "cadenceLabel": "1 周", "tagLabel": "推荐", "focus": "先保阅读和听力高频正确率", "actionItems": ["晨读 30 分钟", "晚间套题复盘", "错词二次听写"], "expectedOutcome": "预计把高频词稳定率拉升到 82%" }, { "planName": "21 天系统巩固", "cadenceLabel": "3 周", "tagLabel": "稳妥", "focus": "补齐学术词和写作替换", "actionItems": ["主题词块复现", "周测 2 次", "口语素材改写"], "expectedOutcome": "预计把学术词掌握提升到 75%" } ], "diagnosticCaseStudy": { "title": "教育类阅读题案例", "context": "遇到 unfamiliar policy terms 时定位速度明显下降。", "diagnosis": "说明教育政策主题词和近义替换储备不足。", "strategy": "建立 policy / curriculum / assessment 词网并结合真题复现。", "keyTakeaway": "先按主题建网,再回到题目验证。" } } } ``` Update `ability-center-runtime/pom.xml` dependencies to the final modules only: ```xml cn.yunzhixue ability-center-kernel ${project.version} cn.yunzhixue exam-sprint-contracts ${project.version} cn.yunzhixue exam-sprint-application ${project.version} cn.yunzhixue exam-sprint-domain ${project.version} cn.yunzhixue exam-sprint-infrastructure ${project.version} ``` Replace the root `pom.xml` module block with the final reactor only: ```xml ability-center-runtime ability-center-kernel abilities/exam-sprint/contracts abilities/exam-sprint/application abilities/exam-sprint/domain abilities/exam-sprint/infrastructure ``` After the runtime wiring compiles, delete the legacy modules and package trees listed in this task so only the final naming system remains. - [ ] **Step 4: Run the full test suite and verify the final naming system is the only one left** Run: `mvn -q test` Expected: PASS across runtime, kernel, contracts, application, domain, and infrastructure modules with no remaining references to `OutlookReportTask...`, `ability-task`, `ability-integration`, `ability-persistence`, `/api/reports/outlook/tasks`, or `cn.yunzhixue.microservice.ability`. - [ ] **Step 5: Commit if Git has been initialized and the user explicitly requested a commit** ```bash git add pom.xml ability-center-runtime ability-center-kernel abilities git commit -m "refactor: finalize ability center naming migration" ``` ## Plan Self-Review ### Spec coverage - Repository identity rename (`ability-service` → `ability-center`) is covered by Task 1 and Task 5. - DDD module structure (`runtime`, `kernel`, `contracts`, `application`, `domain`, `infrastructure`) is covered by Task 1 and finalized in Task 5. - Public resource naming (`/api/exam-sprint/reports`) is covered by Task 2 and verified end-to-end in Task 5. - Public contract naming (`reportId`, `reportType`, `generationStatus`, `OutlookExamSprintReportPayload`) is covered by Task 2. - Domain naming (`ExamSprintReport`, `ExamSprintReportRepository`, `storageObjectKey`) is covered by Task 3. - Infrastructure naming and `OUTLOOK` as report type, not top-level capability, is covered by Task 4. - Removal of legacy `OutlookReportTask...`, `taskId`, `storageKey` public lookup, old modules, and old package root is covered by Task 5. ### Placeholder scan - No `TODO`, `TBD`, or “similar to previous task” placeholders remain. - Every task contains exact file paths, concrete commands, and concrete class names. - The only intentional future-facing item is `ACHIEVEMENT` as a reserved `reportType`, which is part of the approved design rather than a placeholder. ### Type consistency - Public create request uses `CreateExamSprintReportRequest` with `reportType` and `payload` consistently. - The stable aggregate name is `ExamSprintReport` everywhere after Task 3. - Public IDs are consistently `reportId`, not `taskId`. - Public status is consistently `generationStatus`, backed by `ExamSprintReportGenerationStatus`. - `Outlook` is consistently treated as a report type in contracts/infrastructure, not as the bounded context name.