|
|
@@ -0,0 +1,1266 @@
|
|
|
+# 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<String> 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/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.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<String> 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<String> 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<String> 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<String> 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<ConstraintViolation<AchievementExamSprintReportPayload>> 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
|
|
|
+<dependency>
|
|
|
+ <groupId>com.fasterxml.jackson.core</groupId>
|
|
|
+ <artifactId>jackson-databind</artifactId>
|
|
|
+</dependency>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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 "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.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(
|
|
|
+ "成果<script>alert(1)</script>",
|
|
|
+ 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<script>")));
|
|
|
+String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Update outlook renderer and PDF tests to wrap JSON content**
|
|
|
+
|
|
|
+Add helper in each affected test class:
|
|
|
+
|
|
|
+```java
|
|
|
+private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
|
|
|
+ return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Change calls from:
|
|
|
+
|
|
|
+```java
|
|
|
+renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
+```
|
|
|
+
|
|
|
+to:
|
|
|
+
|
|
|
+```java
|
|
|
+renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
+```
|
|
|
+
|
|
|
+For achievement PDF rendering tests, use `AchievementReportContent` instead of JSON.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run infrastructure verification**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -q -pl abilities/exam-sprint/infrastructure -am test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. Achievement renderer no longer depends on contract payload DTOs or Jackson for its render input; outlook renderer still does by explicit transitional design.
|
|
|
+
|
|
|
+## Task 7: Tighten ArchUnit Jackson rule and run runtime/API compatibility checks
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Verify no domain Jackson matches before tightening**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: no matches.
|
|
|
+
|
|
|
+- [ ] **Step 2: Remove Jackson allowlist and make hard rule**
|
|
|
+
|
|
|
+In `ExamSprintArchitectureTest`, remove:
|
|
|
+
|
|
|
+```java
|
|
|
+import com.tngtech.archunit.core.domain.JavaClass;
|
|
|
+import com.tngtech.archunit.base.DescribedPredicate;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
|
|
|
+ "ExamSprintReport",
|
|
|
+ "ExamSprintReportRenderer");
|
|
|
+```
|
|
|
+
|
|
|
+Replace:
|
|
|
+
|
|
|
+```java
|
|
|
+@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.."
|
|
|
+ );
|
|
|
+```
|
|
|
+
|
|
|
+with:
|
|
|
+
|
|
|
+```java
|
|
|
+@ArchTest
|
|
|
+static final ArchRule domain_should_not_depend_on_jackson = noClasses()
|
|
|
+ .that().resideInAPackage("..domain..")
|
|
|
+ .should().dependOnClassesThat().resideInAnyPackage(
|
|
|
+ "com.fasterxml.jackson.."
|
|
|
+ );
|
|
|
+```
|
|
|
+
|
|
|
+Delete the `areNotNamed(...)` helper if it has no remaining caller.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run architecture/runtime verification**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -q -pl ability-center-runtime -Dtest=ExamSprintArchitectureTest test
|
|
|
+mvn -q -pl ability-center-runtime -am test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. Runtime HTTP tests continue to verify public JSON fields and enum literals such as `OUTLOOK`, `ACHIEVEMENT`, `PENDING`, `PROCESSING`, `SUCCESS`, `FAILED`, and `EXPIRED`.
|
|
|
+
|
|
|
+## Task 8: Update governance documentation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md`
|
|
|
+- Verify: `docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md`
|
|
|
+
|
|
|
+- [ ] **Step 1: Update design document status**
|
|
|
+
|
|
|
+Change the status line near the top to:
|
|
|
+
|
|
|
+```markdown
|
|
|
+> Status: Third governance loop planned for Jackson / `JsonNode` payload governance. The intended slice introduces `AchievementReportContent` as a domain concept while keeping external contracts stable.
|
|
|
+```
|
|
|
+
|
|
|
+After implementation, change it to:
|
|
|
+
|
|
|
+```markdown
|
|
|
+> Status: Third governance loop implemented for `AchievementReportContent`; `exam-sprint-domain` no longer depends on Jackson / `JsonNode`. Remaining content debt is modeling `OUTLOOK` and retiring transitional unmodeled content.
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Update technical debt register rows**
|
|
|
+
|
|
|
+Replace the `domain -> jackson` row with:
|
|
|
+
|
|
|
+```markdown
|
|
|
+| `domain -> jackson` | Cleared at compile-time in the `AchievementReportContent` loop by replacing domain `JsonNode` with `ReportContent`; `OUTLOOK` still uses a transitional unmodeled content wrapper prepared at application boundary | introduce `OutlookReportContent`, remove `UnmodeledReportContent`, and keep ArchUnit hard rule |
|
|
|
+```
|
|
|
+
|
|
|
+Update the payload records row to:
|
|
|
+
|
|
|
+```markdown
|
|
|
+| Payload records contain business behavior | `AchievementReportContent` now owns the selected achievement content shape; `OutlookExamSprintReportPayload` and richer invariants remain in contracts/infrastructure | migrate `OutlookReportContent` and move stable invariants into domain value objects incrementally |
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add third-loop result and next-loop recommendation**
|
|
|
+
|
|
|
+Near the first/second loop summary, add:
|
|
|
+
|
|
|
+```markdown
|
|
|
+Third loop result: `AchievementReportContent` is introduced as strongly named domain report content; the application boundary converts public achievement payload JSON/contracts DTOs into domain content; domain main source no longer imports Jackson / `JsonNode`; and the domain-to-Jackson architecture rule is tightened to a hard guardrail.
|
|
|
+
|
|
|
+Remaining payload debt: `OUTLOOK` content remains unmodeled through a transitional `UnmodeledReportContent` wrapper so this loop does not migrate all payload records or rewrite the larger outlook renderer.
|
|
|
+
|
|
|
+Next recommended loop: introduce `OutlookReportContent` and retire `UnmodeledReportContent`, then review whether rendering/file/PDF ports should move to application in a separate dedicated loop.
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run documentation grep checks**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+rg "AchievementReportContent|domain -> jackson|JsonNode|UnmodeledReportContent|OutlookReportContent" "docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md" "docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: matches describe this loop, remaining debt, and next recommendation clearly.
|
|
|
+
|
|
|
+## Task 9: Final verification and review preparation
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Verify: all modified files
|
|
|
+
|
|
|
+- [ ] **Step 1: Run targeted module suites with `-am`**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -q -pl abilities/exam-sprint/application -am test
|
|
|
+mvn -q -pl abilities/exam-sprint/infrastructure -am test
|
|
|
+mvn -q -pl ability-center-runtime -am test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all PASS.
|
|
|
+
|
|
|
+- [ ] **Step 2: Run full reactor tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -q test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. If unrelated failures occur, record exact module/test names and do not widen the governance scope without review.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run required final searches**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+git status --short
|
|
|
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
|
|
|
+rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
|
|
|
+rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"
|
|
|
+```
|
|
|
+
|
|
|
+Expected:
|
|
|
+
|
|
|
+```text
|
|
|
+git status --short
|
|
|
+# only files intentionally touched by this plan
|
|
|
+
|
|
|
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
|
|
|
+# no matches
|
|
|
+
|
|
|
+rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
|
|
|
+# no matches
|
|
|
+
|
|
|
+rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"
|
|
|
+# no matches
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Prepare code review checklist**
|
|
|
+
|
|
|
+Review these exact points before claiming completion:
|
|
|
+
|
|
|
+```text
|
|
|
+- domain main source has no Jackson / JsonNode imports or types.
|
|
|
+- domain main source still has no contracts.report imports.
|
|
|
+- abilities/exam-sprint/domain/pom.xml has no exam-sprint-contracts and no jackson-databind dependency.
|
|
|
+- ACHIEVEMENT path stores AchievementReportContent in ExamSprintReport.
|
|
|
+- application boundary converts external JsonNode/contracts payload to AchievementReportContent.
|
|
|
+- public contracts DTO fields and enum literals remain unchanged.
|
|
|
+- runtime HTTP JSON fields, paths, status codes, and enum strings remain unchanged.
|
|
|
+- OUTLOOK remains transitional and documented through UnmodeledReportContent; no full OUTLOOK payload migration happened in this loop.
|
|
|
+- ArchUnit Jackson allowlist is removed only after the source grep confirms no domain Jackson usage.
|
|
|
+- Storage / Renderer / PdfGenerator were not moved.
|
|
|
+- DefaultExamSprintReportApplicationService was not split.
|
|
|
+- ExamSprintReport was not renamed.
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Commit checkpoint, only if explicitly requested**
|
|
|
+
|
|
|
+Suggested message:
|
|
|
+
|
|
|
+```text
|
|
|
+refactor(exam-sprint): 推进DDD命名治理三轮内容建模
|
|
|
+```
|
|
|
+
|
|
|
+## Implementation Handoff
|
|
|
+
|
|
|
+Recommended execution mode:
|
|
|
+
|
|
|
+1. Use subagent-driven development, with one task per implementation subagent after this plan is reviewed.
|
|
|
+2. Review after each task for dependency direction, public API compatibility, and scope control.
|
|
|
+3. Use TDD for each production-code change: write or update the failing test first, run it to observe the expected failure, implement the smallest code change, and rerun targeted verification.
|
|
|
+4. Use `systematic-debugging` before fixing any unexpected test/build failure.
|
|
|
+5. Use `verification-before-completion` before claiming the implementation is complete.
|
|
|
+6. Use `requesting-code-review` after implementation and verification.
|