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 initand only if the user explicitly asks for a commit.
ability-center/
├── pom.xml
├── ability-center-runtime/
├── ability-center-kernel/
└── abilities/
└── exam-sprint/
├── contracts/
├── application/
├── domain/
└── infrastructure/
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.java
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java
POST/GET /api/exam-sprint/reports.ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java
ErrorCode.ability-center-runtime/src/main/resources/application.yml
ability.exam-sprint.report.ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.java
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java
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
reportType and payload.abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.java
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java
OUTLOOK, ACHIEVEMENT.abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.java
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
reportId, reportType, payload, status, expiry, stored object reference.abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html
Files:
pom.xmlability-bootstrap/ → ability-center-runtime/ability-common/ → ability-center-kernel/abilities/exam-sprint/contracts/pom.xmlabilities/exam-sprint/application/pom.xmlabilities/exam-sprint/domain/pom.xmlabilities/exam-sprint/infrastructure/pom.xmlability-task/pom.xmlability-integration/pom.xmlModify: 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
Run:
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
Replace the root module block in pom.xml with:
<artifactId>ability-center</artifactId>
<name>ability-center</name>
<description>Ability center multi-module project</description>
<modules>
<module>ability-center-runtime</module>
<module>ability-center-kernel</module>
<module>ability-task</module>
<module>ability-integration</module>
<module>ability-persistence</module>
<module>ability-sync</module>
<module>ability-callback</module>
<module>abilities/exam-sprint/contracts</module>
<module>abilities/exam-sprint/application</module>
<module>abilities/exam-sprint/domain</module>
<module>abilities/exam-sprint/infrastructure</module>
</modules>
Update the renamed child POM headers to:
<parent>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
Create abilities/exam-sprint/contracts/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>exam-sprint-contracts</artifactId>
<name>exam-sprint-contracts</name>
<dependencies>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
Create abilities/exam-sprint/application/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>exam-sprint-application</artifactId>
<name>exam-sprint-application</name>
<dependencies>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-contracts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Create abilities/exam-sprint/domain/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>exam-sprint-domain</artifactId>
<name>exam-sprint-domain</name>
<dependencies>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-contracts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
Create abilities/exam-sprint/infrastructure/pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>exam-sprint-infrastructure</artifactId>
<name>exam-sprint-infrastructure</name>
<dependencies>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-contracts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.58.0</version>
</dependency>
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-storage-blob</artifactId>
<version>12.28.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
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:
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
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
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"
Files:
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.javaability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.javaability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.javaabilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.javaability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.javaability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.javaDelete: 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:
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));
}
}
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.
Create ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java:
package cn.yunzhixue.ability.center.kernel;
public record BaseResponse<T>(String code, String message, T data) {
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>("SUCCESS", "success", data);
}
public static BaseResponse<Void> 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:
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:
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:
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:
package cn.yunzhixue.ability.center.examsprint.contracts.report;
public enum ExamSprintReportGenerationStatus {
PENDING,
PROCESSING,
SUCCESS,
FAILED,
EXPIRED
}
Create CreateExamSprintReportRequest.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:
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:
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:
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:
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:
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<BaseResponse<CreateExamSprintReportResponse>> createReport(
@Valid @RequestBody CreateExamSprintReportRequest request) {
return ResponseEntity.accepted().body(BaseResponse.success(applicationService.createReport(request)));
}
@GetMapping("/{reportId}")
public BaseResponse<ExamSprintReportDetailResponse> getReport(@PathVariable String reportId) {
return BaseResponse.success(applicationService.getReport(reportId));
}
@GetMapping("/{reportId}/download")
public ResponseEntity<byte[]> 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());
}
}
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
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"
Files:
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.javaCreate: 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:
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<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
@Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; }
@Override public Optional<ExamSprintReport> findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); }
@Override public List<ExamSprintReport> findExpiredBefore(Instant instant) { return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList(); }
}
private static class TestStorage implements ExamSprintReportStorage {
private final List<String> 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<StoredExamSprintReportContent> download(String storageObjectKey) { return Optional.empty(); }
@Override public void delete(String storageObjectKey) { }
}
}
Run: mvn -q -pl abilities/exam-sprint/application -am test -Dtest=ExamSprintReportApplicationServiceTest
Expected: FAIL with missing classes such as ExamSprintReport, DefaultExamSprintReportApplicationService, and ExamSprintReportStorage.
Create abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.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:
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<ExamSprintReport> findById(String reportId);
List<ExamSprintReport> findExpiredBefore(Instant instant);
}
Create ExamSprintReportStorage.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<StoredExamSprintReportContent> download(String storageObjectKey);
void delete(String storageObjectKey);
record StoredExamSprintReportFile(String storageObjectKey, String fileName) {
}
record StoredExamSprintReportContent(String fileName, byte[] bytes, String contentType) {
}
}
Create ExamSprintReportRenderer.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:
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:
package cn.yunzhixue.ability.center.examsprint.application.report;
public interface ExamSprintReportGenerationDispatcher {
void dispatch(String reportId);
}
Create ExamSprintReportProperties.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:
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));
}
}
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
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"
Files:
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.javaabilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.javaabilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.javaCreate: 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:
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 "<html><body>ok</body></html>"; }
}
private static class TestRepository implements ExamSprintReportRepository {
private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
@Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; }
@Override public Optional<ExamSprintReport> findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); }
@Override public List<ExamSprintReport> 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<StoredExamSprintReportContent> 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 天提分冲刺.
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.
Create abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.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:
package cn.yunzhixue.ability.center.examsprint.application.report;
import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
@Service
public class ExamSprintReportGenerationWorker {
private final ExamSprintReportRepository repository;
private final List<ExamSprintReportRenderer> renderers;
private final ExamSprintReportPdfGenerator pdfGenerator;
private final ExamSprintReportStorage storage;
private final Clock clock;
public ExamSprintReportGenerationWorker(
ExamSprintReportRepository repository,
List<ExamSprintReportRenderer> renderers,
ExamSprintReportPdfGenerator pdfGenerator,
ExamSprintReportStorage storage,
Clock clock) {
this.repository = repository;
this.renderers = renderers;
this.pdfGenerator = pdfGenerator;
this.storage = storage;
this.clock = clock;
}
public 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:
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:
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<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
@Override public ExamSprintReport save(ExamSprintReport report) { storage.put(report.reportId(), report); return report; }
@Override public Optional<ExamSprintReport> findById(String reportId) { return Optional.ofNullable(storage.get(reportId)); }
@Override public List<ExamSprintReport> findExpiredBefore(Instant instant) { return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList(); }
}
Create InMemoryExamSprintReportStorage.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<String, byte[]> 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<StoredExamSprintReportContent> 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:
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:
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.
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
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"
Files:
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.javaability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.javaability-center-runtime/src/main/java/cn/yunzhixue/ability/center/HealthController.javaability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.javaability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.javaability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.jsonability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-invalid-request.jsonability-center-runtime/src/main/resources/application.ymlability-center-runtime/pom.xmlpom.xmlability-task/ability-integration/ability-persistence/ability-sync/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:
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;
}
}
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.
Create ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/AbilityCenterRuntimeApplication.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:
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<BaseResponse<Void>> 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<BaseResponse<Void>> handleValidationException(Exception exception) {
return ResponseEntity
.status(HttpStatusCode.valueOf(ErrorCode.VALIDATION_ERROR.getHttpStatusCode()))
.body(BaseResponse.failure(ErrorCode.VALIDATION_ERROR));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<BaseResponse<Void>> 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:
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<String> health() {
return BaseResponse.success("ok");
}
}
Create ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.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:
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:
{
"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:
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>ability-center-kernel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-contracts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.yunzhixue</groupId>
<artifactId>exam-sprint-infrastructure</artifactId>
<version>${project.version}</version>
</dependency>
Replace the root pom.xml module block with the final reactor only:
<modules>
<module>ability-center-runtime</module>
<module>ability-center-kernel</module>
<module>abilities/exam-sprint/contracts</module>
<module>abilities/exam-sprint/application</module>
<module>abilities/exam-sprint/domain</module>
<module>abilities/exam-sprint/infrastructure</module>
</modules>
After the runtime wiring compiles, delete the legacy modules and package trees listed in this task so only the final naming system remains.
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
git add pom.xml ability-center-runtime ability-center-kernel abilities
git commit -m "refactor: finalize ability center naming migration"
ability-service → ability-center) is covered by Task 1 and Task 5.runtime, kernel, contracts, application, domain, infrastructure) is covered by Task 1 and finalized in Task 5./api/exam-sprint/reports) is covered by Task 2 and verified end-to-end in Task 5.reportId, reportType, generationStatus, OutlookExamSprintReportPayload) is covered by Task 2.ExamSprintReport, ExamSprintReportRepository, storageObjectKey) is covered by Task 3.OUTLOOK as report type, not top-level capability, is covered by Task 4.OutlookReportTask..., taskId, storageKey public lookup, old modules, and old package root is covered by Task 5.TODO, TBD, or “similar to previous task” placeholders remain.ACHIEVEMENT as a reserved reportType, which is part of the approved design rather than a placeholder.CreateExamSprintReportRequest with reportType and payload consistently.ExamSprintReport everywhere after Task 3.reportId, not taskId.generationStatus, backed by ExamSprintReportGenerationStatus.Outlook is consistently treated as a report type in contracts/infrastructure, not as the bounded context name.