# Exam Sprint Report Filename Implementation Plan > **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. --- ### Task 1: Worker-level filename tests **Files:** - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java` **Step 1: Write failing tests and update existing expectations** Update `processMarksReportSuccessAfterUpload()` to use an outlook payload containing root `studentName`: ```java repository.save(ExamSprintReport.pending( "report-success", ReportType.OUTLOOK, unmodeledOutlookContentWithStudentName("冯亿豪"), FIXED_CLOCK.instant(), FIXED_CLOCK.instant().plusSeconds(86400))); ``` Expect: ```java assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf"); assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf"); ``` Update `processLogsSuccessfulGenerationStages(...)` to expect: ```java .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: ```java assertThat(report.fileName()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf"); assertThat(report.storageObjectKey()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf"); ``` Add a focused fallback test: ```java @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: ```java 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: ```bash mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test ``` Expected: FAIL with assertions showing old filenames like `exam-sprint-outlook-report-report-success.pdf`. ### Task 2: Propagate achievement `studentName` **Files:** - Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java` - Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java` - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java` - Modify tests that instantiate `AchievementExamSprintReportPayload` or `AchievementReportContent` directly. **Step 1: Add optional contract field** Add lowercase `studentName` as the first record component without validation annotations: ```java 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: ```java 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: ```bash 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. ### Task 3: Implement pipeline filename generation **Files:** - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java` **Step 1: Add imports** Add: ```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; 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: ```java 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: ```java 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: ```java private String studentNameOrReportId(ExamSprintReport report) { return studentNameFromContent(report.content()) .filter(name -> !name.isBlank()) .map(String::trim) .orElse(report.reportId()); } private Optional 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: ```bash mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test ``` Expected: PASS. ### Task 4: Service-level sync tests **Files:** - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java` **Step 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`: ```java 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()`: ```java String expectedFileName = "吴泓妤-临考词汇突击成果报告-20260102080000.pdf"; ``` **Step 2: Add missing-name fallback service test** Add a sync test for outlook payload missing `studentName`: ```java @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: ```bash mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest test ``` Expected: PASS. ### Task 5: Outlook validation alias and trim review fixes **Files:** - Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java` - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java` - Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java` - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java` **Step 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: ```bash mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test mvn -pl abilities/exam-sprint/application test ``` Expected: PASS. ### Task 6: In-memory storage localized filename compatibility **Files:** - Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java` - Create: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java` **Step 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: ```bash 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: ```bash mvn -pl abilities/exam-sprint/infrastructure -Dtest=InMemoryExamSprintReportStorageTest test mvn -pl abilities/exam-sprint/infrastructure test ``` Expected: PASS. ### Task 7: Regression verification **Files:** - No code changes expected. **Step 1: Run application module tests** Run: ```bash mvn -pl abilities/exam-sprint/application test ``` Expected: PASS. **Step 2: Run full test suite** Run: ```bash mvn test ``` Expected: BUILD SUCCESS. **Step 3: Review diff** Run: ```bash 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. ### Task 8: Commit if requested **Files:** - Commit all modified files from this plan only. **Step 1: Check status** Run: ```bash 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: ```bash 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.