For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Generate 临考词汇突击 PDF filenames as {StudentNameOrReportId}-临考词汇突击成果报告/潜力展望报告-{AsiaShanghaiTimestamp}.pdf.
Architecture: Keep filename generation centralized in ExamSprintReportGenerationPipeline, because it already builds the upload filename and has access to ExamSprintReport, ReportType, content, and the injected Clock. Propagate optional lowercase studentName through the achievement contract/domain mapper, add lowercase alias validation support for outlook, and stop default in-memory storage from parsing report id out of localized filenames. Tests should lock worker-level generation, service-level sync behavior, and in-memory storage URL generation so persisted storageObjectKey, persisted fileName, and download behavior remain consistent.
Tech Stack: Java 17, Maven, JUnit 5, AssertJ, Jackson JsonNode, Spring Boot test utilities.
Files:
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.javaStep 1: Write failing tests and update existing expectations
Update processMarksReportSuccessAfterUpload() to use an outlook payload containing root studentName:
repository.save(ExamSprintReport.pending(
"report-success",
ReportType.OUTLOOK,
unmodeledOutlookContentWithStudentName("冯亿豪"),
FIXED_CLOCK.instant(),
FIXED_CLOCK.instant().plusSeconds(86400)));
Expect:
assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
Update processLogsSuccessfulGenerationStages(...) to expect:
.contains("storageObjectKey=report-log-success-临考词汇突击潜力展望报告-20260101080000.pdf")
because its helper can keep an empty payload to exercise fallback behavior.
Update processCreatesAchievementFileNameAndStorageKeyAfterUpload() to use achievement content with studentName = "吴泓妤". Expect:
assertThat(report.fileName()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
assertThat(report.storageObjectKey()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
Add a focused fallback test:
@Test
void processFallsBackToReportIdWhenStudentNameIsBlank() {
TestRepository repository = new TestRepository();
repository.save(ExamSprintReport.pending(
"report-blank-student",
ReportType.OUTLOOK,
unmodeledOutlookContentWithStudentName(" "),
FIXED_CLOCK.instant(),
FIXED_CLOCK.instant().plusSeconds(86400)));
TestStorage storage = new TestStorage();
ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
worker.process("report-blank-student");
ExamSprintReport report = repository.findById("report-blank-student").orElseThrow();
assertThat(report.fileName()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
assertThat(report.storageObjectKey()).isEqualTo(report.fileName());
}
Add helper:
private ReportContent unmodeledOutlookContentWithStudentName(String studentName) {
return new UnmodeledReportContent(
ReportType.OUTLOOK,
OBJECT_MAPPER.createObjectNode().put("studentName", studentName));
}
Step 2: Run worker tests to verify failure
Run:
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test
Expected: FAIL with assertions showing old filenames like exam-sprint-outlook-report-report-success.pdf.
studentNameFiles:
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.javaabilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.javaAchievementExamSprintReportPayload or AchievementReportContent directly.Step 1: Add optional contract field
Add lowercase studentName as the first record component without validation annotations:
public record AchievementExamSprintReportPayload(
String studentName,
@NotBlank String reportTitle,
...)
Because Java records use positional constructors, update existing new AchievementExamSprintReportPayload(...) calls by adding a first argument such as "吴泓妤" in happy-path tests or null where the name is irrelevant.
Step 2: Add optional domain field
Add String studentName as the first AchievementReportContent component:
public record AchievementReportContent(
String studentName,
String reportTitle,
...)
Do not add Objects.requireNonNull(studentName, ...); missing names must be valid.
Step 3: Map the field
In AchievementReportContentMapper.toDomainContent(...), pass payload.studentName() into the new first domain constructor argument.
Step 4: Run focused compile/test
Run:
mvn -pl abilities/exam-sprint/application -Dtest=AchievementReportContentMapperTest,ExamSprintReportGenerationWorkerTest test
Expected: compilation succeeds after all constructor call sites are updated. Worker filename assertions may still fail until Task 3 implements the pipeline filename rule.
Files:
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.javaStep 1: Add imports
Add:
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;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
ReportContent and UnmodeledReportContent may already be absent from this file; only add what is needed.
Step 2: Add constants
Near the logger:
private static final ZoneId REPORT_FILENAME_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter REPORT_FILENAME_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(REPORT_FILENAME_ZONE);
Step 3: Replace fileNameOf(...)
Replace the existing method with:
private String fileNameOf(ExamSprintReport report) {
return studentNameOrReportId(report) + "-" + reportTitle(report.reportType()) + "-"
+ REPORT_FILENAME_TIMESTAMP_FORMATTER.format(clock.instant()) + ".pdf";
}
Step 4: Add helper methods
Add:
private String studentNameOrReportId(ExamSprintReport report) {
return studentNameFromContent(report.content())
.filter(name -> !name.isBlank())
.map(String::trim)
.orElse(report.reportId());
}
private Optional<String> studentNameFromContent(ReportContent content) {
if (content instanceof UnmodeledReportContent unmodeledReportContent
&& unmodeledReportContent.source() instanceof JsonNode payload) {
JsonNode studentName = payload.get("studentName");
if (studentName != null && studentName.isTextual()) {
return Optional.of(studentName.asText());
}
}
if (content instanceof AchievementReportContent achievementReportContent) {
return Optional.ofNullable(achievementReportContent.studentName());
}
return Optional.empty();
}
private String reportTitle(ReportType reportType) {
return switch (reportType) {
case ACHIEVEMENT -> "临考词汇突击成果报告";
case OUTLOOK -> "临考词汇突击潜力展望报告";
};
}
Step 5: Run worker tests
Run:
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test
Expected: PASS.
Files:
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.javaStep 1: Update sync filename expectations
Update createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() to add lowercase studentName to the existing valid outlook payload and expect the Shanghai timestamp. Because FIXED_CLOCK = 2026-01-02T00:00:00Z, expected timestamp is 20260102080000:
ObjectNode payload = validOutlookPayload();
payload.put("studentName", "冯亿豪");
var response = service.createOutlookReportSync(payload);
String expectedFileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf";
assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
assertThat(saved.fileName()).isEqualTo(expectedFileName);
assertThat(storage.generatedKeys).containsExactly(expectedFileName);
Update createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() similarly if it should assert the filename, otherwise keep its existing renderer-focused assertions and add a separate filename test.
Update createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() using the studentName from validAchievementPayload():
String expectedFileName = "吴泓妤-临考词汇突击成果报告-20260102080000.pdf";
Step 2: Add missing-name fallback service test
Add a sync test for outlook payload missing studentName:
@Test
void createOutlookReportSyncFallsBackToReportIdWhenStudentNameIsMissing() {
TestRepository repository = new TestRepository();
TestStorage storage = new TestStorage();
DefaultExamSprintReportApplicationService service = service(
repository,
reportId -> {
throw new IllegalStateException("sync create must not dispatch async generation");
},
storage);
ObjectNode payload = validOutlookPayload().deepCopy();
payload.remove("studentName");
var response = service.createOutlookReportSync(payload);
String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
assertThat(saved.fileName()).isEqualTo(expectedFileName);
}
If validOutlookPayload() uses StudentName instead of lowercase studentName, add lowercase studentName to the test payload explicitly for the new naming contract, and keep renderer-specific StudentName assertions unchanged.
Step 3: Run application service tests
Run:
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest test
Expected: PASS.
Files:
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.javaabilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.javaabilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.javaStep 1: Add outlook lowercase alias without global ObjectMapper relaxation
Add @JsonAlias("studentName") to the existing @JsonProperty("StudentName") component in OutlookExamSprintReportPayload. Do not disable FAIL_ON_UNKNOWN_PROPERTIES globally in tests or production.
Step 2: Keep validation side effects local
When both StudentName and lowercase studentName exist, validate a copied payload that removes lowercase studentName, while preserving the original raw payload for persistence and filename generation.
Step 3: Trim filename student names
In ExamSprintReportGenerationPipeline, trim studentName before deciding whether to use it or fall back to reportId.
Step 4: Add trim coverage
Add a worker-level test where studentName = " 冯亿豪 " yields 冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf.
Step 5: Verify
Run:
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test
mvn -pl abilities/exam-sprint/application test
Expected: PASS.
Files:
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.javaabilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.javaStep 1: Write failing storage test
Create a test that uploads fileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf" with reportId = "report-001", then expects generateDownloadUrl(...) to return /api/exam-sprint/reports/report-001/download.
Also cover download(...) returning the original localized filename, application/pdf, and defensive byte copies.
Step 2: Verify RED
Run:
mvn -pl abilities/exam-sprint/infrastructure -Dtest=InMemoryExamSprintReportStorageTest test
Expected: FAIL because old in-memory storage parses report id from the legacy filename prefix.
Step 3: Implement storage fix
Store the reportId supplied to upload(...) in the in-memory stored record and have generateDownloadUrl(...) use that stored value instead of parsing storageObjectKey.
Step 4: Verify GREEN
Run:
mvn -pl abilities/exam-sprint/infrastructure -Dtest=InMemoryExamSprintReportStorageTest test
mvn -pl abilities/exam-sprint/infrastructure test
Expected: PASS.
Files:
Step 1: Run application module tests
Run:
mvn -pl abilities/exam-sprint/application test
Expected: PASS.
Step 2: Run full test suite
Run:
mvn test
Expected: BUILD SUCCESS.
Step 3: Review diff
Run:
git diff -- abilities/exam-sprint/application docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md docs/plans/2026-04-29-exam-sprint-report-filename.md
Expected: Diff only contains filename tests, pipeline filename generation, and the docs for this change.
Files:
Step 1: Check status
Run:
git status --short
Expected: Only the planned docs, test, and pipeline files are modified or untracked.
Step 2: Commit only if the user explicitly asks
Run only after explicit commit approval:
git add docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md \
docs/plans/2026-04-29-exam-sprint-report-filename.md \
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java \
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java \
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java \
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/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java \
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java \
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java \
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java \
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
git commit -m "fix(exam-sprint): 调整报告 PDF 文件名"
Expected: Commit succeeds without bypassing hooks.