# 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.