2026-04-20-ability-center-naming-migration.md 99 KB

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

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:

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:

<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>
  • Step 4: Add the new DDD module POMs with the final artifact names

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>
  • 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:

<dependency>
    <groupId>cn.yunzhixue</groupId>
    <artifactId>ability-center-kernel</artifactId>
    <version>${project.version}</version>
</dependency>
  • 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

    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:

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:

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());
    }
}
  • 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

    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:

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) { }
    }
}
  • 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:

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));
    }
}
  • 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

    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:

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 天提分冲刺.

  • 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:

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }
}

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

    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:

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:

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.

  • 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

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