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
ACHIEVEMENTreport content. Do not migrateOUTLOOKpayload records, do not renameExamSprintReport, do not moveStorage/Renderer/PdfGenerator, do not splitDefaultExamSprintReportApplicationService, do not changeReportGenerationStatus/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-loopon branchrefactor/ddd命名治理三轮-jsonnode-payload. Before writing this plan,git status --shortwas empty in the source worktree, a new worktree was created frommaster, andmvn -q testwas run in the new worktree without a tool-reported failure; Maven output was truncated because of normal test logs.
JsonNode usageCommand:
rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
Current matches:
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:
ExamSprintReportExamSprintReportRendererCommand:
rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint"
Important chain:
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.
Command:
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:
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.."
);
Use ACHIEVEMENT as the trial report type.
Reasons:
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.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.ACHIEVEMENT sync generation and public JSON compatibility.OUTLOOK JSON conversion as an explicitly named transitional compatibility path.abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java
ReportType reportType(); no Jackson imports.abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
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
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
AchievementExamSprintReportPayload to domain AchievementReportContent.abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
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
render(JsonNode payload, Instant generatedAt) to render(ReportContent content, Instant generatedAt).abilities/exam-sprint/domain/pom.xml
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
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
processingReport.content() into the renderer.abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
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
ReportContent.abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
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
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
render(AchievementReportContent, Instant).abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
JsonNode as UnmodeledReportContent before rendering.abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java
docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java
OUTLOOK, ACHIEVEMENT.abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
JsonNode because runtime HTTP JSON remains unchanged.abilities/exam-sprint/infrastructure/pom.xml
exam-sprint-contracts and Jackson dependencies because infrastructure still renders legacy OUTLOOK from contract DTOs.DefaultExamSprintReportApplicationService, Storage, Renderer, and PdfGenerator locations and names.Files:
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.javaVerify: ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java
[ ] Step 1: Confirm branch and clean worktree
Run:
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.
Run:
rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
Expected current matches are only ExamSprintReport and ExamSprintReportRenderer.
Run:
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(...).
Run:
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.
Run:
mvn -q test
Expected: PASS in this worktree. If this fails before implementation, stop and record the exact failing module/test before continuing.
Files:
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.javaCreate: 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:
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);
}
}
Run:
mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test
Expected: FAIL because AchievementReportContent does not exist.
ReportContentCreate ReportContent.java:
package cn.yunzhixue.ability.center.examsprint.domain.report;
public sealed interface ReportContent permits AchievementReportContent, UnmodeledReportContent {
ReportType reportType();
}
AchievementReportContentCreate AchievementReportContent.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);
}
}
}
UnmodeledReportContentCreate UnmodeledReportContent.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.
Run:
mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test
Expected: PASS.
Files:
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.javaabilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.javaModify: 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:
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")));
}
}
Run:
mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test
Expected: FAIL because AchievementReportContentMapper does not exist.
Create AchievementReportContentMapper.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());
}
}
Run:
mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test
Expected: PASS.
In DefaultExamSprintReportApplicationService, change achievement entry methods to preserve validation and conversion at the application boundary:
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:
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:
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);
}
Run:
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.
JsonNode with domain ReportContentFiles:
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.javaabilities/exam-sprint/domain/pom.xmlModify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
[ ] Step 1: Update ExamSprintReport
Remove:
import com.fasterxml.jackson.databind.JsonNode;
Change the record field and factory parameter to:
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.
Change ExamSprintReportRenderer.java to:
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);
}
In ExamSprintReportGenerationPipeline, replace:
String html = renderer.render(processingReport.payload(), startedAt);
with:
String html = renderer.render(processingReport.content(), startedAt);
In abilities/exam-sprint/domain/pom.xml, remove only this dependency block:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Run:
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.
Files:
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.javaModify: 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:
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:
assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
with:
assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
AchievementReportContent content = (AchievementReportContent) saved.content();
assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
assertThat(content.examUnknownWordsHitStatus().hitWords())
.containsExactly("number", "bear", "popular", "importance");
ObjectNode report construction with transitional content helpersAdd helper methods near existing payload helpers:
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().
Replace the old createOutlookReportCopiesPayloadBeforeSaving assertion with:
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.
Change all ExamSprintReportRenderer test doubles from:
public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
to:
public String render(ReportContent content, Instant generatedAt) {
For PreviewTestRenderer, if the title is needed, use:
String title = content instanceof AchievementReportContent achievementContent
? achievementContent.reportTitle()
: "";
return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
In ExamSprintReportGenerationWorkerTest, import ReportContent and UnmodeledReportContent, add:
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).
Run:
mvn -q -pl abilities/exam-sprint/application -am test
Expected: PASS. Tests should now distinguish domain content assertions from public API/contract assertions.
Files:
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.javaabilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.javaabilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.javaabilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.javaModify: 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:
mvn -q -pl abilities/exam-sprint/infrastructure -am test
Expected: FAIL until infrastructure renderers implement render(ReportContent, Instant).
In ClasspathAchievementExamSprintReportRenderer, remove imports for AchievementExamSprintReportPayload, JsonNode, ObjectMapper, and Autowired. Add imports:
import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
Change constructors to:
public ClasspathAchievementExamSprintReportRenderer() {
this(new AchievementExamSprintReportSvgChartBuilder());
}
ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) {
this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
}
Change render entry point to:
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.*.
In ClasspathOutlookExamSprintReportRenderer, keep ObjectMapper, JsonNode, and OutlookExamSprintReportPayload because OUTLOOK remains unmodeled. Add:
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
Change render entry point to:
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);
}
}
In ClasspathAchievementExamSprintReportRendererTest, replace JsonNode samplePayload() with:
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:
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"));
Add helper in each affected test class:
private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
}
Change calls from:
renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
to:
renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
For achievement PDF rendering tests, use AchievementReportContent instead of JSON.
Run:
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.
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:
rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
Expected: no matches.
In ExamSprintArchitectureTest, remove:
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:
@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:
@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.
Run:
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.
Files:
docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.mdVerify: 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:
> 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:
> 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.
Replace the domain -> jackson row with:
| `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:
| 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 |
Near the first/second loop summary, add:
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.
Run:
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.
Files:
Verify: all modified files
[ ] Step 1: Run targeted module suites with -am
Run:
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.
Run:
mvn -q test
Expected: PASS. If unrelated failures occur, record exact module/test names and do not widen the governance scope without review.
Run:
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:
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
Review these exact points before claiming completion:
- 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.
Suggested message:
refactor(exam-sprint): 推进DDD命名治理三轮内容建模
Recommended execution mode:
systematic-debugging before fixing any unexpected test/build failure.verification-before-completion before claiming the implementation is complete.requesting-code-review after implementation and verification.