# DDD Naming Governance JsonNode Payload Loop 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:** Complete a small third DDD naming governance loop by introducing `AchievementReportContent` as a domain-owned content concept for the `ACHIEVEMENT` report path and removing direct Jackson / `JsonNode` usage from `exam-sprint-domain` main source without changing external HTTP contracts. **Architecture:** Keep public request/response DTOs, HTTP JSON fields, paths, status codes, and public enum literals unchanged. The application boundary still accepts external `JsonNode`, validates contract payload shape, and converts the selected `ACHIEVEMENT` payload into domain `AchievementReportContent`; the legacy `OUTLOOK` path remains explicitly wrapped as transitional unmodeled content with an exit condition. Domain owns the `ReportContent` abstraction and `AchievementReportContent`, while infrastructure renderers adapt domain content into HTML without moving `Storage` / `Renderer` / `PdfGenerator` or splitting `DefaultExamSprintReportApplicationService`. **Tech Stack:** Java 17 source level, Maven multi-module build, Spring Boot 3.3.5, JUnit 5, AssertJ, ArchUnit, Jackson at application/infrastructure/runtime boundaries only. --- > **Execution note:** Do not create git commits unless the user explicitly asks for commits. Treat each “commit checkpoint” below as a local verification checkpoint until commit permission is given. > **Scope guard:** This loop only models the `ACHIEVEMENT` report content. Do not migrate `OUTLOOK` payload records, do not rename `ExamSprintReport`, do not move `Storage` / `Renderer` / `PdfGenerator`, do not split `DefaultExamSprintReportApplicationService`, do not change `ReportGenerationStatus` / `ReportType`, and do not edit public contract enum names or DTO field names. > **Initial worktree baseline:** The isolated worktree is `/Users/exiao/Codes/dcjxb.microservice/.worktrees/refactor-ddd-jsonnode-payload-loop` on branch `refactor/ddd命名治理三轮-jsonnode-payload`. Before writing this plan, `git status --short` was empty in the source worktree, a new worktree was created from `master`, and `mvn -q test` was run in the new worktree without a tool-reported failure; Maven output was truncated because of normal test logs. ## Current Research Findings ### Current domain Jackson / `JsonNode` usage Command: ```bash rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java" ``` Current matches: ```text abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java:import com.fasterxml.jackson.databind.JsonNode; abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java: String render(JsonNode payload, Instant generatedAt); abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:import com.fasterxml.jackson.databind.JsonNode; abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java: JsonNode payload, abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java: JsonNode payload, abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java: public JsonNode payload() { abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java: private static JsonNode copyPayload(JsonNode payload) { ``` Active domain Jackson debt is exactly two domain classes: - `ExamSprintReport` - `ExamSprintReportRenderer` ### Current payload / render conversion chain Command: ```bash rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint" ``` Important chain: ```text runtime HTTP controller -> ExamSprintReportApplicationService accepts JsonNode DefaultExamSprintReportApplicationService.validateAchievementPayload(JsonNode) DefaultExamSprintReportApplicationService.readPayload(JsonNode, AchievementExamSprintReportPayload.class) ExamSprintReport.pending(..., JsonNode payload, ...) ExamSprintReportGenerationPipeline.generate(...) ExamSprintReportGenerationPipeline calls renderer.render(processingReport.payload(), startedAt) ClasspathAchievementExamSprintReportRenderer.render(JsonNode, Instant) ClasspathAchievementExamSprintReportRenderer objectMapper.treeToValue(payload, AchievementExamSprintReportPayload.class) ``` The same pattern exists for `OUTLOOK`, but `OUTLOOK` has a larger payload and renderer surface. ### Current ArchUnit Jackson allowlist Command: ```bash rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java" ``` Current allowlist: ```java private static final Set CURRENT_DOMAIN_JACKSON_DEBT = Set.of( "ExamSprintReport", "ExamSprintReportRenderer"); @ArchTest static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses() .that().resideInAPackage("..domain..") .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT)) .should().dependOnClassesThat().resideInAnyPackage( "com.fasterxml.jackson.." ); ``` ### Trial report type selection Use `ACHIEVEMENT` as the trial report type. Reasons: 1. `AchievementExamSprintReportPayload` is smaller than `OutlookExamSprintReportPayload`: 4 top-level text fields plus 4 nested content groups, compared with the much larger `OUTLOOK` payload with charts, frequency plans, case studies, color handling, and more renderer-specific edge cases. 2. `ClasspathAchievementExamSprintReportRenderer` already uses a simple placeholder map and a separate SVG chart builder, so replacing contract payload DTO usage with domain content has a smaller blast radius. 3. Existing application and runtime tests already exercise `ACHIEVEMENT` sync generation and public JSON compatibility. 4. This slice can remove domain compile-time Jackson debt while leaving `OUTLOOK` JSON conversion as an explicitly named transitional compatibility path. ## Target File Structure ### Create - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java` - Domain-owned report content marker with `ReportType reportType()`; no Jackson imports. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java` - Strongly named domain content for the `ACHIEVEMENT` report; fields mirror only what the current achievement renderer needs. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java` - Transitional wrapper for report types not modeled in this loop; stores an opaque boundary-prepared object and has an explicit exit condition in this plan. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java` - Package-private application boundary mapper from contract `AchievementExamSprintReportPayload` to domain `AchievementReportContent`. - `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java` - TDD test for mapping every renderer-used field and null handling. ### Modify - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java` - Replace `JsonNode payload` with `ReportContent content`; keep `reportType` and lifecycle methods unchanged. - `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java` - Change `render(JsonNode payload, Instant generatedAt)` to `render(ReportContent content, Instant generatedAt)`. - `abilities/exam-sprint/domain/pom.xml` - Remove `jackson-databind` after domain source no longer imports Jackson. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java` - Convert `ACHIEVEMENT` JSON/contracts payload to `AchievementReportContent`; wrap `OUTLOOK` JSON as `UnmodeledReportContent` for this loop. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java` - Pass `processingReport.content()` into the renderer. - `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java` - Assert domain `AchievementReportContent` for achievement saves; keep public response assertions on contracts enums/DTOs. - `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java` - Update test renderers and report construction to `ReportContent`. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java` - Render from `AchievementReportContent`; remove contract payload DTO and `ObjectMapper.treeToValue(...)` from the selected path. - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java` - Continue using `OutlookExamSprintReportPayload` and Jackson inside infrastructure by unwrapping `UnmodeledReportContent` for `OUTLOOK` only. - `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java` - Convert sample JSON to domain content in the test boundary and call `render(AchievementReportContent, Instant)`. - `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java` - Wrap sample `JsonNode` as `UnmodeledReportContent` before rendering. - `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java` - Wrap sample content when rendering before PDF generation. - `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java` - Remove the Jackson allowlist and make domain -> Jackson a hard rule. - `docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md` - Record the third-loop result and remaining content modeling debt. ### Keep unchanged - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java` - Public payload record name and JSON field names remain unchanged. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java` - Not migrated in this loop. - `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java` - Public enum remains `OUTLOOK`, `ACHIEVEMENT`. - `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java` - Public application boundary still accepts `JsonNode` because runtime HTTP JSON remains unchanged. - `abilities/exam-sprint/infrastructure/pom.xml` - Keep `exam-sprint-contracts` and Jackson dependencies because infrastructure still renders legacy `OUTLOOK` from contract DTOs. - `DefaultExamSprintReportApplicationService`, `Storage`, `Renderer`, and `PdfGenerator` locations and names. ## Task 1: Establish current baseline and active debt **Files:** - Verify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java` - Verify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java` - Verify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java` - [ ] **Step 1: Confirm branch and clean worktree** Run: ```bash git status --short git status -sb ``` Expected: branch is `refactor/ddd命名治理三轮-jsonnode-payload`; before implementation edits, `git status --short` shows only this plan document as untracked or modified. - [ ] **Step 2: Search current domain Jackson usage** Run: ```bash rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java" ``` Expected current matches are only `ExamSprintReport` and `ExamSprintReportRenderer`. - [ ] **Step 3: Search payload/render conversion chain** Run: ```bash rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint" ``` Expected: application accepts `JsonNode`; application validates using contract payload classes; pipeline currently renders `processingReport.payload()`; infrastructure renderers currently deserialize `JsonNode` with `treeToValue(...)`. - [ ] **Step 4: Check current ArchUnit Jackson allowlist** Run: ```bash rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java" ``` Expected: allowlist contains `ExamSprintReport` and `ExamSprintReportRenderer`. - [ ] **Step 5: Run full baseline tests** Run: ```bash mvn -q test ``` Expected: PASS in this worktree. If this fails before implementation, stop and record the exact failing module/test before continuing. ## Task 2: Add domain report content concepts with tests first **Files:** - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java` - Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java` - Create: `abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java` - [ ] **Step 1: Write the failing domain content test** Create `AchievementReportContentTest.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class AchievementReportContentTest { @Test void reportsAchievementTypeAndCopiesHitWords() { List hitWords = new java.util.ArrayList<>(List.of("number", "bear")); AchievementReportContent content = sampleContent(hitWords); hitWords.add("mutated"); assertThat(content.reportType()).isEqualTo(ReportType.ACHIEVEMENT); assertThat(content.examUnknownWordsHitStatus().hitWords()) .containsExactly("number", "bear"); } @Test void rejectsNullRequiredGroups() { assertThatThrownBy(() -> new AchievementReportContent( "title", "subtitle", "completion title", "completion subtitle", null, comparison(), comparison(), hitStatus(List.of("number")))) .isInstanceOf(NullPointerException.class) .hasMessageContaining("summaryMetrics"); } private AchievementReportContent sampleContent(List hitWords) { return new AchievementReportContent( "高考英语临考突击学习成果报告", "2024真题 · 两周专项训练 · 真实提分效果", "恭喜完成两周考前突击专项训练", "基于2024英语真题试卷 · 真实学习效果分析", new AchievementReportContent.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"), comparison(), comparison(), hitStatus(hitWords)); } private AchievementReportContent.Comparison comparison() { return new AchievementReportContent.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词"); } private AchievementReportContent.ExamUnknownWordsHitStatus hitStatus(List hitWords) { return new AchievementReportContent.ExamUnknownWordsHitStatus( "1.93%", "0.48倍", "207 个", "203 个", "4 个", hitWords); } } ``` - [ ] **Step 2: Run the domain test and verify RED** Run: ```bash mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test ``` Expected: FAIL because `AchievementReportContent` does not exist. - [ ] **Step 3: Add `ReportContent`** Create `ReportContent.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; public sealed interface ReportContent permits AchievementReportContent, UnmodeledReportContent { ReportType reportType(); } ``` - [ ] **Step 4: Add `AchievementReportContent`** Create `AchievementReportContent.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import java.util.List; import java.util.Objects; public record AchievementReportContent( String reportTitle, String reportSubtitle, String completionTitle, String completionSubtitle, SummaryMetrics summaryMetrics, Comparison vocabularyComparison, Comparison paperKnownWordsComparison, ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent { public AchievementReportContent { Objects.requireNonNull(summaryMetrics, "summaryMetrics"); Objects.requireNonNull(vocabularyComparison, "vocabularyComparison"); Objects.requireNonNull(paperKnownWordsComparison, "paperKnownWordsComparison"); Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus"); } @Override public ReportType reportType() { return ReportType.ACHIEVEMENT; } public record SummaryMetrics( String vocabularyGrowthText, String paperKnownWordsGrowthText, String unknownWordHitRateText, String learningEfficiencyText) { } public record Comparison( Double beforeValue, Double afterValue, String beforeText, String afterText, String growthText) { } public record ExamUnknownWordsHitStatus( String unknownWordHitRateText, String learningEfficiencyText, String unknownWordsBeforeText, String unknownWordsAfterText, String reducedUnknownWordsText, List hitWords) { public ExamUnknownWordsHitStatus { hitWords = hitWords == null ? null : List.copyOf(hitWords); } } } ``` - [ ] **Step 5: Add transitional `UnmodeledReportContent`** Create `UnmodeledReportContent.java`: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import java.util.Objects; public record UnmodeledReportContent(ReportType reportType, Object source) implements ReportContent { public UnmodeledReportContent { Objects.requireNonNull(reportType, "reportType"); Objects.requireNonNull(source, "source"); } } ``` Exit condition: remove `UnmodeledReportContent` after a later loop introduces `OutlookReportContent` and the `OUTLOOK` renderer no longer needs JSON-backed content. - [ ] **Step 6: Run domain content tests** Run: ```bash mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test ``` Expected: PASS. ## Task 3: Convert application boundary from contract payload to domain content **Files:** - Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java` - Create: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java` - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java` - [ ] **Step 1: Write mapper test first** Create `AchievementReportContentMapperTest.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload; import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent; import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class AchievementReportContentMapperTest { @Test void mapsEveryRendererUsedAchievementFieldToDomainContent() { AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload()); assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告"); assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19"); assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0); assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347 词"); assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("+4 个"); assertThat(content.examUnknownWordsHitStatus().hitWords()) .containsExactly("number", "bear", "popular", "importance"); } @Test void rejectsNullPayload() { assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("payload"); } private AchievementExamSprintReportPayload payload() { return new AchievementExamSprintReportPayload( "高考英语临考突击学习成果报告", "2024真题 · 两周专项训练 · 真实提分效果", "恭喜完成两周考前突击专项训练", "基于2024英语真题试卷 · 真实学习效果分析", new AchievementExamSprintReportPayload.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"), new AchievementExamSprintReportPayload.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词"), new AchievementExamSprintReportPayload.Comparison(650.0, 654.0, "650 个", "654 个", "+4 个"), new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus( "1.93%", "0.48倍", "207 个", "203 个", "4 个", List.of("number", "bear", "popular", "importance"))); } } ``` - [ ] **Step 2: Run mapper test and verify RED** Run: ```bash mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test ``` Expected: FAIL because `AchievementReportContentMapper` does not exist. - [ ] **Step 3: Implement mapper** Create `AchievementReportContentMapper.java`: ```java package cn.yunzhixue.ability.center.examsprint.application.report; import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload; import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent; import java.util.Objects; final class AchievementReportContentMapper { private AchievementReportContentMapper() { } static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) { Objects.requireNonNull(payload, "payload"); return new AchievementReportContent( payload.reportTitle(), payload.reportSubtitle(), payload.completionTitle(), payload.completionSubtitle(), new AchievementReportContent.SummaryMetrics( payload.summaryMetrics().vocabularyGrowthText(), payload.summaryMetrics().paperKnownWordsGrowthText(), payload.summaryMetrics().unknownWordHitRateText(), payload.summaryMetrics().learningEfficiencyText()), comparison(payload.vocabularyComparison()), comparison(payload.paperKnownWordsComparison()), new AchievementReportContent.ExamUnknownWordsHitStatus( payload.examUnknownWordsHitStatus().unknownWordHitRateText(), payload.examUnknownWordsHitStatus().learningEfficiencyText(), payload.examUnknownWordsHitStatus().unknownWordsBeforeText(), payload.examUnknownWordsHitStatus().unknownWordsAfterText(), payload.examUnknownWordsHitStatus().reducedUnknownWordsText(), payload.examUnknownWordsHitStatus().hitWords())); } private static AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) { return new AchievementReportContent.Comparison( comparison.beforeValue(), comparison.afterValue(), comparison.beforeText(), comparison.afterText(), comparison.growthText()); } } ``` - [ ] **Step 4: Run mapper test GREEN** Run: ```bash mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test ``` Expected: PASS. - [ ] **Step 5: Change achievement validation to return domain content** In `DefaultExamSprintReportApplicationService`, change achievement entry methods to preserve validation and conversion at the application boundary: ```java public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) { AchievementReportContent content = validateAchievementPayload(payload); return submitReportGeneration(ReportType.ACHIEVEMENT, content); } public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) { AchievementReportContent content = validateAchievementPayload(payload); return submitReportGenerationSync(ReportType.ACHIEVEMENT, content); } ``` Change outlook entry methods to wrap boundary-prepared JSON without modeling it in this loop: ```java public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) { validateOutlookPayload(payload); return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy())); } public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) { validateOutlookPayload(payload); return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy())); } ``` Change private submit methods from `JsonNode payload` to `ReportContent content`, and pass `content` to `ExamSprintReport.pending(...)`. Change `validateAchievementPayload` to: ```java private AchievementReportContent validateAchievementPayload(JsonNode payload) { validateAchievementPayloadShape(payload); AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class); Set> violations = validator.validate(reportPayload); if (!violations.isEmpty()) { throw new BusinessException(ErrorCode.VALIDATION_ERROR); } return AchievementReportContentMapper.toDomainContent(reportPayload); } ``` - [ ] **Step 6: Run application tests to expose compile failures before domain model migration is complete** Run: ```bash mvn -q -pl abilities/exam-sprint/application -am test ``` Expected: FAIL until `ExamSprintReport` accepts `ReportContent` and tests are updated. Record the first compile failure as the expected red state for the next task. ## Task 4: Replace domain `JsonNode` with domain `ReportContent` **Files:** - Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java` - Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java` - Modify: `abilities/exam-sprint/domain/pom.xml` - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java` - [ ] **Step 1: Update `ExamSprintReport`** Remove: ```java import com.fasterxml.jackson.databind.JsonNode; ``` Change the record field and factory parameter to: ```java public record ExamSprintReport( String reportId, ReportType reportType, ReportContent content, ReportGenerationStatus generationStatus, Instant createdAt, Instant updatedAt, Instant expiresAt, String storageObjectKey, String fileName, String failureReason) { public ExamSprintReport { if (content != null && reportType != content.reportType()) { throw new IllegalArgumentException("reportType must match content.reportType"); } } public static ExamSprintReport pending( String reportId, ReportType reportType, ReportContent content, Instant createdAt, Instant expiresAt) { ``` Replace all constructor arguments currently passing `payload` with `content`. Delete the overridden `payload()` accessor and `copyPayload(...)` helper. - [ ] **Step 2: Update renderer port** Change `ExamSprintReportRenderer.java` to: ```java package cn.yunzhixue.ability.center.examsprint.domain.report; import java.time.Instant; public interface ExamSprintReportRenderer { boolean supports(ReportType reportType); String render(ReportContent content, Instant generatedAt); } ``` - [ ] **Step 3: Update pipeline render call** In `ExamSprintReportGenerationPipeline`, replace: ```java String html = renderer.render(processingReport.payload(), startedAt); ``` with: ```java String html = renderer.render(processingReport.content(), startedAt); ``` - [ ] **Step 4: Remove domain Jackson dependency** In `abilities/exam-sprint/domain/pom.xml`, remove only this dependency block: ```xml com.fasterxml.jackson.core jackson-databind ``` - [ ] **Step 5: Verify domain source no longer references Jackson** Run: ```bash mvn -q -pl abilities/exam-sprint/domain -am test rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java" ``` Expected: domain tests PASS; `rg` returns no matches in domain main source. ## Task 5: Adapt application tests and selected application assertions **Files:** - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java` - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java` - [ ] **Step 1: Update application service content assertions** In `ExamSprintReportApplicationServiceTest`, import: ```java import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent; import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent; import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent; ``` Replace the current achievement payload assertion: ```java assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告"); ``` with: ```java assertThat(saved.content()).isInstanceOf(AchievementReportContent.class); AchievementReportContent content = (AchievementReportContent) saved.content(); assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告"); assertThat(content.examUnknownWordsHitStatus().hitWords()) .containsExactly("number", "bear", "popular", "importance"); ``` - [ ] **Step 2: Replace raw `ObjectNode` report construction with transitional content helpers** Add helper methods near existing payload helpers: ```java private ReportContent unmodeledOutlookContent() { return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode()); } private AchievementReportContent validAchievementContent() { return AchievementReportContentMapper.toDomainContent( OBJECT_MAPPER.convertValue(validAchievementPayload(), AchievementExamSprintReportPayload.class)); } ``` Replace `ExamSprintReport.pending(..., OBJECT_MAPPER.createObjectNode(), ...)` for `OUTLOOK` tests with `unmodeledOutlookContent()`. Replace `ExamSprintReport.pending(..., validAchievementPayload(), ...)` for `ACHIEVEMENT` tests with `validAchievementContent()`. - [ ] **Step 3: Preserve copy behavior at application boundary** Replace the old `createOutlookReportCopiesPayloadBeforeSaving` assertion with: ```java ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow(); UnmodeledReportContent content = (UnmodeledReportContent) saved.content(); JsonNode savedPayload = (JsonNode) content.source(); assertThat(savedPayload.path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学"); ``` This verifies the copy now happens in the application boundary for the legacy `OUTLOOK` path. - [ ] **Step 4: Update test renderers** Change all `ExamSprintReportRenderer` test doubles from: ```java public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) { ``` to: ```java public String render(ReportContent content, Instant generatedAt) { ``` For `PreviewTestRenderer`, if the title is needed, use: ```java String title = content instanceof AchievementReportContent achievementContent ? achievementContent.reportTitle() : ""; return "preview:" + title + ":" + generatedAt + ""; ``` - [ ] **Step 5: Update worker tests** In `ExamSprintReportGenerationWorkerTest`, import `ReportContent` and `UnmodeledReportContent`, add: ```java private ReportContent unmodeledOutlookContent() { return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode()); } private ReportContent unmodeledAchievementContent() { return new UnmodeledReportContent(ReportType.ACHIEVEMENT, OBJECT_MAPPER.createObjectNode()); } ``` Use `unmodeledOutlookContent()` for existing `OUTLOOK` worker tests and `unmodeledAchievementContent()` for the file-name-only `ACHIEVEMENT` worker test. Change test renderer signatures to `render(ReportContent content, Instant generatedAt)`. - [ ] **Step 6: Run application verification** Run: ```bash mvn -q -pl abilities/exam-sprint/application -am test ``` Expected: PASS. Tests should now distinguish domain content assertions from public API/contract assertions. ## Task 6: Adapt infrastructure renderers and renderer tests **Files:** - Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java` - Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java` - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java` - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java` - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java` - [ ] **Step 1: Run infrastructure tests to expose port signature failures** Run: ```bash mvn -q -pl abilities/exam-sprint/infrastructure -am test ``` Expected: FAIL until infrastructure renderers implement `render(ReportContent, Instant)`. - [ ] **Step 2: Change achievement renderer to domain content** In `ClasspathAchievementExamSprintReportRenderer`, remove imports for `AchievementExamSprintReportPayload`, `JsonNode`, `ObjectMapper`, and `Autowired`. Add imports: ```java import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent; import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent; ``` Change constructors to: ```java public ClasspathAchievementExamSprintReportRenderer() { this(new AchievementExamSprintReportSvgChartBuilder()); } ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) { this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder"); } ``` Change render entry point to: ```java public String render(ReportContent content, Instant generatedAt) { if (!(content instanceof AchievementReportContent reportContent)) { throw new IllegalArgumentException("Achievement renderer requires AchievementReportContent"); } try { AchievementReportContent.SummaryMetrics summary = reportContent.summaryMetrics(); AchievementReportContent.Comparison vocabulary = reportContent.vocabularyComparison(); AchievementReportContent.Comparison paperKnownWords = reportContent.paperKnownWordsComparison(); AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus(); return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, hitStatus)); } catch (IOException exception) { throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception); } catch (Exception exception) { throw new IllegalStateException("Failed to render achievement exam sprint report", exception); } } ``` Change helper signatures from `AchievementExamSprintReportPayload.*` to `AchievementReportContent.*`. - [ ] **Step 3: Change outlook renderer to unwrap transitional content in infrastructure** In `ClasspathOutlookExamSprintReportRenderer`, keep `ObjectMapper`, `JsonNode`, and `OutlookExamSprintReportPayload` because `OUTLOOK` remains unmodeled. Add: ```java import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent; import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent; ``` Change render entry point to: ```java public String render(ReportContent content, Instant generatedAt) { if (!(content instanceof UnmodeledReportContent unmodeledContent) || unmodeledContent.reportType() != ReportType.OUTLOOK || !(unmodeledContent.source() instanceof JsonNode payload)) { throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content"); } try { OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class); return loadTemplate() .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart())) .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart())) .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart())) .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart())) .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan())) .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy())); } catch (IOException exception) { throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception); } catch (Exception exception) { throw new IllegalStateException("Failed to render outlook exam sprint report", exception); } } ``` - [ ] **Step 4: Update achievement renderer tests to pass domain content** In `ClasspathAchievementExamSprintReportRendererTest`, replace `JsonNode samplePayload()` with: ```java private AchievementReportContent sampleContent() throws Exception { AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(samplePayloadJson(), AchievementExamSprintReportPayload.class); return new AchievementReportContent( payload.reportTitle(), payload.reportSubtitle(), payload.completionTitle(), payload.completionSubtitle(), new AchievementReportContent.SummaryMetrics( payload.summaryMetrics().vocabularyGrowthText(), payload.summaryMetrics().paperKnownWordsGrowthText(), payload.summaryMetrics().unknownWordHitRateText(), payload.summaryMetrics().learningEfficiencyText()), new AchievementReportContent.Comparison( payload.vocabularyComparison().beforeValue(), payload.vocabularyComparison().afterValue(), payload.vocabularyComparison().beforeText(), payload.vocabularyComparison().afterText(), payload.vocabularyComparison().growthText()), new AchievementReportContent.Comparison( payload.paperKnownWordsComparison().beforeValue(), payload.paperKnownWordsComparison().afterValue(), payload.paperKnownWordsComparison().beforeText(), payload.paperKnownWordsComparison().afterText(), payload.paperKnownWordsComparison().growthText()), new AchievementReportContent.ExamUnknownWordsHitStatus( payload.examUnknownWordsHitStatus().unknownWordHitRateText(), payload.examUnknownWordsHitStatus().learningEfficiencyText(), payload.examUnknownWordsHitStatus().unknownWordsBeforeText(), payload.examUnknownWordsHitStatus().unknownWordsAfterText(), payload.examUnknownWordsHitStatus().reducedUnknownWordsText(), payload.examUnknownWordsHitStatus().hitWords())); } private JsonNode samplePayloadJson() throws Exception { return OBJECT_MAPPER.readTree(""" { "reportTitle": "高考英语临考突击学习成果报告", "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果", "completionTitle": "恭喜完成两周考前突击专项训练", "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析", "summaryMetrics": { "vocabularyGrowthText": "+19", "paperKnownWordsGrowthText": "+4", "unknownWordHitRateText": "1.93%", "learningEfficiencyText": "0.48倍" }, "vocabularyComparison": { "beforeValue": 2328, "afterValue": 2347, "beforeText": "2328 词", "afterText": "2347 词", "growthText": "+19 词" }, "paperKnownWordsComparison": { "beforeValue": 650, "afterValue": 654, "beforeText": "650 个", "afterText": "654 个", "growthText": "+4 个" }, "examUnknownWordsHitStatus": { "unknownWordHitRateText": "1.93%", "learningEfficiencyText": "0.48倍", "unknownWordsBeforeText": "207 个", "unknownWordsAfterText": "203 个", "reducedUnknownWordsText": "4 个", "hitWords": ["number", "bear", "popular", "importance"] } } """); } ``` When mutating test content, create a small helper that rebuilds `AchievementReportContent` with the changed field. Example for hit words: ```java AchievementReportContent content = sampleContent(); AchievementReportContent mutated = new AchievementReportContent( "成果", content.reportSubtitle(), content.completionTitle(), content.completionSubtitle(), content.summaryMetrics(), content.vocabularyComparison(), content.paperKnownWordsComparison(), new AchievementReportContent.ExamUnknownWordsHitStatus( content.examUnknownWordsHitStatus().unknownWordHitRateText(), content.examUnknownWordsHitStatus().learningEfficiencyText(), content.examUnknownWordsHitStatus().unknownWordsBeforeText(), content.examUnknownWordsHitStatus().unknownWordsAfterText(), content.examUnknownWordsHitStatus().reducedUnknownWordsText(), java.util.List.of("number", "bear