Преглед на файлове

Merge branch 'feature/exam-sprint-report-filename' of jyx/dcjxb.microservice into master

金逸霄 преди 2 седмици
родител
ревизия
a2d8f350cd
променени са 16 файла, в които са добавени 853 реда и са изтрити 48 реда
  1. 1 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
  2. 13 1
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  3. 41 1
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
  4. 2 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
  5. 52 20
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  6. 86 9
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  7. 1 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
  8. 2 1
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
  9. 1 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
  10. 2 0
      abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java
  11. 7 16
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java
  12. 1 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  13. 6 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  14. 58 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java
  15. 439 0
      docs/plans/2026-04-29-exam-sprint-report-filename.md
  16. 141 0
      docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md

+ 1 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java

@@ -13,6 +13,7 @@ final class AchievementReportContentMapper {
     static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
         Objects.requireNonNull(payload, "payload");
         return new AchievementReportContent(
+                payload.studentName(),
                 payload.reportTitle(),
                 payload.reportSubtitle(),
                 payload.completionTitle(),

+ 13 - 1
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

@@ -18,6 +18,7 @@ import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Validator;
 import org.slf4j.Logger;
@@ -382,10 +383,21 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     private void validateOutlookPayload(JsonNode payload) {
-        OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
+        OutlookExamSprintReportPayload reportPayload = readPayload(outlookPayloadForValidation(payload), OutlookExamSprintReportPayload.class);
         validatePayload(reportPayload);
     }
 
+    private JsonNode outlookPayloadForValidation(JsonNode payload) {
+        requireObjectPayload(payload);
+        if (!payload.has("StudentName") || !payload.has("studentName")) {
+            return payload;
+        }
+
+        ObjectNode validationPayload = payload.deepCopy();
+        validationPayload.remove("studentName");
+        return validationPayload;
+    }
+
     private <T> void validatePayload(T reportPayload) {
         Set<ConstraintViolation<T>> violations = validator.validate(reportPayload);
         if (!violations.isEmpty()) {

+ 41 - 1
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -1,17 +1,24 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+import com.fasterxml.jackson.databind.JsonNode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
@@ -20,6 +27,9 @@ import java.util.concurrent.TimeUnit;
 public class ExamSprintReportGenerationPipeline {
 
     private static final Logger log = LoggerFactory.getLogger(ExamSprintReportGenerationPipeline.class);
+    private static final ZoneId FILE_NAME_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter FILE_NAME_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(FILE_NAME_ZONE);
 
     private final ExamSprintReportRepository repository;
     private final List<ExamSprintReportRenderer> renderers;
@@ -166,7 +176,37 @@ public class ExamSprintReportGenerationPipeline {
     }
 
     private String fileNameOf(ExamSprintReport report) {
-        return "exam-sprint-" + report.reportType().name().toLowerCase() + "-report-" + report.reportId() + ".pdf";
+        return studentNameOrReportId(report)
+                + "-"
+                + reportTitle(report.reportType())
+                + "-"
+                + FILE_NAME_TIME_FORMATTER.format(clock.instant())
+                + ".pdf";
+    }
+
+    private String studentNameOrReportId(ExamSprintReport report) {
+        String studentName = studentNameFromContent(report.content());
+        String trimmedStudentName = studentName == null ? null : studentName.trim();
+        return trimmedStudentName == null || trimmedStudentName.isBlank() ? report.reportId() : trimmedStudentName;
+    }
+
+    private String studentNameFromContent(ReportContent content) {
+        if (content instanceof UnmodeledReportContent unmodeledReportContent
+                && unmodeledReportContent.source() instanceof JsonNode payload) {
+            JsonNode studentName = payload.get("studentName");
+            return studentName != null && studentName.isTextual() ? studentName.asText() : null;
+        }
+        if (content instanceof AchievementReportContent achievementReportContent) {
+            return achievementReportContent.studentName();
+        }
+        return null;
+    }
+
+    private String reportTitle(ReportType reportType) {
+        return switch (reportType) {
+            case OUTLOOK -> "临考词汇突击潜力展望报告";
+            case ACHIEVEMENT -> "临考词汇突击成果报告";
+        };
     }
 
     private String failureReasonOf(Exception exception) {

+ 2 - 0
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java

@@ -15,6 +15,7 @@ class AchievementReportContentMapperTest {
     void mapsEveryRendererUsedAchievementFieldToDomainContent() {
         AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());
 
+        assertThat(content.studentName()).isEqualTo("吴泓妤");
         assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
@@ -33,6 +34,7 @@ class AchievementReportContentMapperTest {
 
     private AchievementExamSprintReportPayload payload() {
         return new AchievementExamSprintReportPayload(
+                "吴泓妤",
                 "高考英语临考突击学习成果报告",
                 "2024真题 · 两周专项训练 · 真实提分效果",
                 "恭喜完成两周考前突击专项训练",

+ 52 - 20
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -183,7 +183,10 @@ class ExamSprintReportApplicationServiceTest {
                 },
                 storage);
 
-        var response = service.createOutlookReportSync(validOutlookPayload());
+        ObjectNode payload = validOutlookPayload();
+        payload.put("studentName", "冯亿豪");
+
+        var response = service.createOutlookReportSync(payload);
 
         assertThat(response.reportId()).isNotBlank();
         assertThat(response.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
@@ -192,11 +195,13 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-" + response.reportId() + ".pdf");
-        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+        String expectedFileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf";
+        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
+        assertThat(saved.fileName()).isEqualTo(expectedFileName);
+        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
     }
 
-    /** 覆盖同步创建展望报告的调用方词汇报文场景,当生成成功时,应在预览内容中使用 StudentName。 */
+    /** 覆盖同步创建展望报告的调用方词汇报文场景,当 lowercase studentName 缺失时,应回退到 reportId 生成文件名。 */
     @Test
     void createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() throws Exception {
         TestRepository repository = new TestRepository();
@@ -215,14 +220,40 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-" + response.reportId() + ".pdf");
-        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+        String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
+        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
+        assertThat(saved.fileName()).isEqualTo(expectedFileName);
+        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
         assertThat(storage.uploadedContents).containsOnlyKeys(saved.storageObjectKey());
         assertThat(new String(storage.uploadedContents.get(saved.storageObjectKey()).bytes(), StandardCharsets.UTF_8))
                 .contains("preview:20260318测试:")
                 .contains(FIXED_CLOCK.instant().toString());
     }
 
+    /** 覆盖同步创建展望报告的调用方词汇报文场景,当 lowercase studentName 为空白时,应回退到 reportId 生成文件名。 */
+    @Test
+    void createOutlookReportSyncFallsBackToReportIdWhenLowercaseStudentNameIsBlank() throws Exception {
+        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 = callerVocabularyPayload();
+        payload.put("studentName", "   \t  \n ");
+
+        var response = service.createOutlookReportSync(payload);
+
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
+        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
+        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
+        assertThat(saved.fileName()).isEqualTo(expectedFileName);
+        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
+    }
+
     /** 覆盖同步创建成果报告场景,当有效 achievement payload 生成成功时,应上传 PDF 并返回下载地址。 */
     @Test
     void createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() {
@@ -244,8 +275,10 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-achievement-report-" + response.reportId() + ".pdf");
-        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+        String expectedFileName = "吴泓妤-临考词汇突击成果报告-20260102080000.pdf";
+        assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
+        assertThat(saved.fileName()).isEqualTo(expectedFileName);
+        assertThat(storage.generatedKeys).containsExactly(expectedFileName);
     }
 
     /** 覆盖同步展望报告下载地址生成失败场景,当存储层抛错时,应转换为下载不可用且日志不泄露异常消息。 */
@@ -405,6 +438,7 @@ class ExamSprintReportApplicationServiceTest {
                         "exam-sprint-achievement-report-report-success.pdf",
                         "exam-sprint-achievement-report-report-success.pdf");
         repository.save(report);
+        storage.mapStorageObjectKeyToReportId("exam-sprint-achievement-report-report-success.pdf", "report-success");
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
         var response = service.getReport("report-success");
@@ -728,6 +762,7 @@ class ExamSprintReportApplicationServiceTest {
 
     private ObjectNode validAchievementPayload() {
         return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
+                "吴泓妤",
                 "高考英语临考突击学习成果报告",
                 "2024真题 · 两周专项训练 · 真实提分效果",
                 "恭喜完成两周考前突击专项训练",
@@ -857,6 +892,7 @@ class ExamSprintReportApplicationServiceTest {
         private final List<String> deletedKeys = new ArrayList<>();
         private final List<String> deleteFailures = new ArrayList<>();
         private final ConcurrentMap<String, StoredExamSprintReportContent> uploadedContents = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, String> reportIdsByStorageObjectKey = new ConcurrentHashMap<>();
         private RuntimeException generateDownloadUrlFailure;
         private RuntimeException downloadFailure;
 
@@ -868,6 +904,7 @@ class ExamSprintReportApplicationServiceTest {
                 byte[] pdfBytes,
                 Instant expiresAt) {
             uploadedContents.put(fileName, new StoredExamSprintReportContent(fileName, pdfBytes, "application/pdf"));
+            reportIdsByStorageObjectKey.put(fileName, reportId);
             return new StoredExamSprintReportFile(fileName, fileName);
         }
 
@@ -877,7 +914,11 @@ class ExamSprintReportApplicationServiceTest {
                 throw generateDownloadUrlFailure;
             }
             generatedKeys.add(storageObjectKey);
-            return URI.create("/api/exam-sprint/reports/" + reportIdFromStorageObjectKey(storageObjectKey) + "/download");
+            String reportId = reportIdsByStorageObjectKey.get(storageObjectKey);
+            if (reportId == null) {
+                throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
+            }
+            return URI.create("/api/exam-sprint/reports/" + reportId + "/download");
         }
 
         @Override
@@ -908,17 +949,8 @@ class ExamSprintReportApplicationServiceTest {
             downloadFailure = failure;
         }
 
-        private String reportIdFromStorageObjectKey(String storageObjectKey) {
-            String fileName = storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
-            String outlookPrefix = "exam-sprint-outlook-report-";
-            String achievementPrefix = "exam-sprint-achievement-report-";
-            if (fileName.startsWith(outlookPrefix)) {
-                return fileName.substring(outlookPrefix.length(), fileName.length() - ".pdf".length());
-            }
-            if (fileName.startsWith(achievementPrefix)) {
-                return fileName.substring(achievementPrefix.length(), fileName.length() - ".pdf".length());
-            }
-            throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
+        void mapStorageObjectKeyToReportId(String storageObjectKey, String reportId) {
+            reportIdsByStorageObjectKey.put(storageObjectKey, reportId);
         }
     }
 

+ 86 - 9
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -1,5 +1,6 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
@@ -33,13 +34,16 @@ class ExamSprintReportGenerationWorkerTest {
     private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    /**
+     * 覆盖 OUTLOOK 报告上传成功且 payload 带 lowercase studentName 时,期望用学生姓名、潜力展望标题和上海时间生成文件名。
+     */
     @Test
     void processMarksReportSuccessAfterUpload() {
         TestRepository repository = new TestRepository();
         repository.save(ExamSprintReport.pending(
                 "report-success",
                 ReportType.OUTLOOK,
-                unmodeledOutlookContent(),
+                unmodeledOutlookContentWithStudentName("冯亿豪"),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -49,10 +53,13 @@ class ExamSprintReportGenerationWorkerTest {
 
         ExamSprintReport report = repository.findById("report-success").orElseThrow();
         assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
-        assertThat(report.fileName()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
     }
 
+    /**
+     * 覆盖成功生成日志在 OUTLOOK 空 payload 触发 reportId fallback 时,期望记录新的潜力展望 storageObjectKey。
+     */
     @Test
     void processLogsSuccessfulGenerationStages(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -76,18 +83,21 @@ class ExamSprintReportGenerationWorkerTest {
                 .contains("pdfByteLength=")
                 .contains("stage=storage_upload")
                 .contains("exam_sprint_report_generation_succeeded")
-                .contains("storageObjectKey=exam-sprint-outlook-report-report-log-success.pdf")
+                .contains("storageObjectKey=report-log-success-临考词汇突击潜力展望报告-20260101080000.pdf")
                 .doesNotContain("SENSITIVE_HTML_DO_NOT_LOG")
                 .doesNotContain("<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>");
     }
 
+    /**
+     * 覆盖 ACHIEVEMENT 报告上传成功且 content 携带 lowercase studentName 时,期望用学生姓名生成成果报告文件名。
+     */
     @Test
     void processCreatesAchievementFileNameAndStorageKeyAfterUpload() {
         TestRepository repository = new TestRepository();
         repository.save(ExamSprintReport.pending(
                 "report-achievement",
                 ReportType.ACHIEVEMENT,
-                unmodeledAchievementContent(),
+                achievementContentWithStudentName("吴泓妤"),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -100,8 +110,54 @@ class ExamSprintReportGenerationWorkerTest {
 
         ExamSprintReport report = repository.findById("report-achievement").orElseThrow();
         assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(report.fileName()).isEqualTo("exam-sprint-achievement-report-report-achievement.pdf");
-        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-achievement-report-report-achievement.pdf");
+        assertThat(report.fileName()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
+    }
+
+    /**
+     * 覆盖 OUTLOOK payload 中 lowercase studentName 为空白时,期望回退到 reportId 生成潜力展望文件名。
+     */
+    @Test
+    void processFallsBackToReportIdWhenStudentNameIsBlank() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-blank-student",
+                ReportType.OUTLOOK,
+                unmodeledOutlookContentWithStudentName("   \t  \n "),
+                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.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
+        assertThat(report.storageObjectKey()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
+    }
+
+    /**
+     * 覆盖 OUTLOOK payload 中 lowercase studentName 首尾有空白时,期望 trim 后生成潜力展望文件名。
+     */
+    @Test
+    void processTrimsStudentNameForFileName() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-trim-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-trim-student");
+
+        ExamSprintReport report = repository.findById("report-trim-student").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
+        assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
     }
 
     @Test
@@ -218,8 +274,29 @@ class ExamSprintReportGenerationWorkerTest {
         return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
     }
 
-    private ReportContent unmodeledAchievementContent() {
-        return new UnmodeledReportContent(ReportType.ACHIEVEMENT, OBJECT_MAPPER.createObjectNode());
+    private ReportContent unmodeledOutlookContentWithStudentName(String studentName) {
+        return new UnmodeledReportContent(
+                ReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode().put("studentName", studentName));
+    }
+
+    private ReportContent achievementContentWithStudentName(String studentName) {
+        return new AchievementReportContent(
+                studentName,
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementReportContent.SummaryMetrics("+19", "+4", "0.0193", "0.48"),
+                new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
+                new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        "0.0193",
+                        "0.48",
+                        "207",
+                        "203",
+                        "4",
+                        List.of("number", "bear", "popular", "importance")));
     }
 
     private static class TestRenderer implements ExamSprintReportRenderer {

+ 1 - 0
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java

@@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotNull;
 import java.util.List;
 
 public record AchievementExamSprintReportPayload(
+        String studentName,
         @NotBlank String reportTitle,
         @NotBlank String reportSubtitle,
         @NotBlank String completionTitle,

+ 2 - 1
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java

@@ -1,5 +1,6 @@
 package cn.yunzhixue.ability.center.examsprint.contracts.report;
 
+import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.DecimalMax;
@@ -12,7 +13,7 @@ import jakarta.validation.constraints.NotNull;
 import java.util.List;
 
 public record OutlookExamSprintReportPayload(
-        @JsonProperty("StudentName") @NotBlank String studentName,
+        @JsonProperty("StudentName") @JsonAlias("studentName") @NotBlank String studentName,
         @JsonProperty("StudentStage") @NotNull @Min(0) Integer studentStage,
         @JsonProperty("StageName") @NotBlank String stageName,
         @JsonProperty("StageVocabulary") @NotNull @Min(0) Integer stageVocabulary,

+ 1 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java

@@ -4,6 +4,7 @@ import java.util.List;
 import java.util.Objects;
 
 public record AchievementReportContent(
+        String studentName,
         String reportTitle,
         String reportSubtitle,
         String completionTitle,

+ 2 - 0
abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java

@@ -24,6 +24,7 @@ class AchievementReportContentTest {
     @Test
     void rejectsNullRequiredGroups() {
         assertThatThrownBy(() -> new AchievementReportContent(
+                null,
                 "title",
                 "subtitle",
                 "completion title",
@@ -38,6 +39,7 @@ class AchievementReportContentTest {
 
     private AchievementReportContent sampleContent(List<String> hitWords) {
         return new AchievementReportContent(
+                "吴泓妤",
                 "高考英语临考突击学习成果报告",
                 "2024真题 · 两周专项训练 · 真实提分效果",
                 "恭喜完成两周考前突击专项训练",

+ 7 - 16
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java

@@ -25,13 +25,17 @@ public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage
             String fileName,
             byte[] pdfBytes,
             Instant expiresAt) {
-        storage.put(fileName, new StoredFile(fileName, pdfBytes.clone(), "application/pdf"));
+        storage.put(fileName, new StoredFile(reportId, fileName, pdfBytes.clone(), "application/pdf"));
         return new StoredExamSprintReportFile(fileName, fileName);
     }
 
     @Override
     public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
-        return URI.create("/api/exam-sprint/reports/" + reportIdFromStorageObjectKey(storageObjectKey) + "/download");
+        StoredFile storedFile = storage.get(storageObjectKey);
+        if (storedFile == null) {
+            throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
+        }
+        return URI.create("/api/exam-sprint/reports/" + storedFile.reportId() + "/download");
     }
 
     @Override
@@ -51,19 +55,6 @@ public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage
         storage.remove(storageObjectKey);
     }
 
-    private String reportIdFromStorageObjectKey(String storageObjectKey) {
-        String fileName = storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
-        String outlookPrefix = "exam-sprint-outlook-report-";
-        String achievementPrefix = "exam-sprint-achievement-report-";
-        if (fileName.startsWith(outlookPrefix)) {
-            return fileName.substring(outlookPrefix.length(), fileName.length() - ".pdf".length());
-        }
-        if (fileName.startsWith(achievementPrefix)) {
-            return fileName.substring(achievementPrefix.length(), fileName.length() - ".pdf".length());
-        }
-        throw new IllegalStateException("Unexpected storage object key: " + storageObjectKey);
-    }
-
-    private record StoredFile(String fileName, byte[] bytes, String contentType) {
+    private record StoredFile(String reportId, String fileName, byte[] bytes, String contentType) {
     }
 }

+ 1 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java

@@ -233,6 +233,7 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 AchievementExamSprintReportPayload.class
         );
         return new AchievementReportContent(
+                payload.studentName(),
                 payload.reportTitle(),
                 payload.reportSubtitle(),
                 payload.completionTitle(),

+ 6 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java

@@ -207,6 +207,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
         AchievementReportContent content = sampleContent();
         AchievementReportContent mutated = new AchievementReportContent(
+                content.studentName(),
                 content.reportTitle(),
                 content.reportSubtitle(),
                 content.completionTitle(),
@@ -267,6 +268,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
         AchievementReportContent content = sampleContent();
         AchievementReportContent mutated = new AchievementReportContent(
+                content.studentName(),
                 content.reportTitle(),
                 content.reportSubtitle(),
                 content.completionTitle(),
@@ -301,6 +303,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 AchievementExamSprintReportPayload.class
         );
         return new AchievementReportContent(
+                payload.studentName(),
                 payload.reportTitle(),
                 payload.reportSubtitle(),
                 payload.completionTitle(),
@@ -332,6 +335,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     private AchievementReportContent withReportTitle(AchievementReportContent content, String reportTitle) {
         return new AchievementReportContent(
+                content.studentName(),
                 reportTitle,
                 content.reportSubtitle(),
                 content.completionTitle(),
@@ -345,6 +349,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
     private AchievementReportContent withVocabularyComparison(AchievementReportContent content,
                                                               AchievementReportContent.Comparison vocabularyComparison) {
         return new AchievementReportContent(
+                content.studentName(),
                 content.reportTitle(),
                 content.reportSubtitle(),
                 content.completionTitle(),
@@ -358,6 +363,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
     private AchievementReportContent withHitWords(AchievementReportContent content, List<String> hitWords) {
         AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = content.examUnknownWordsHitStatus();
         return new AchievementReportContent(
+                content.studentName(),
                 content.reportTitle(),
                 content.reportSubtitle(),
                 content.completionTitle(),

+ 58 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java

@@ -0,0 +1,58 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage.StoredExamSprintReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage.StoredExamSprintReportFile;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InMemoryExamSprintReportStorageTest {
+
+    /**
+     * 上传中文新文件名后生成下载 URL 时,应使用上传时的 reportId 而不是从文件名前缀解析。
+     */
+    @Test
+    void generateDownloadUrlUsesUploadedReportIdForLocalizedFileName() {
+        InMemoryExamSprintReportStorage storage = new InMemoryExamSprintReportStorage();
+        StoredExamSprintReportFile file = storage.upload(
+                "report-001",
+                ReportType.OUTLOOK,
+                "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf",
+                new byte[]{1, 2, 3},
+                Instant.parse("2026-01-02T08:05:00Z"));
+
+        URI downloadUrl = storage.generateDownloadUrl(file.storageObjectKey(), Duration.ofMinutes(5));
+
+        assertThat(downloadUrl).isEqualTo(URI.create("/api/exam-sprint/reports/report-001/download"));
+    }
+
+    /**
+     * 下载已上传的中文新文件名文件时,应返回原始文件名、PDF content type,并对 bytes 做防御性拷贝。
+     */
+    @Test
+    void downloadReturnsLocalizedFileNamePdfContentTypeAndClonedBytes() {
+        InMemoryExamSprintReportStorage storage = new InMemoryExamSprintReportStorage();
+        byte[] uploadedBytes = new byte[]{1, 2, 3};
+        StoredExamSprintReportFile file = storage.upload(
+                "report-001",
+                ReportType.OUTLOOK,
+                "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf",
+                uploadedBytes,
+                Instant.parse("2026-01-02T08:05:00Z"));
+        uploadedBytes[0] = 9;
+
+        StoredExamSprintReportContent content = storage.download(file.storageObjectKey()).orElseThrow();
+
+        assertThat(content.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf");
+        assertThat(content.bytes()).containsExactly(1, 2, 3).isNotSameAs(uploadedBytes);
+        assertThat(content.contentType()).isEqualTo("application/pdf");
+
+        content.bytes()[0] = 7;
+        assertThat(storage.download(file.storageObjectKey()).orElseThrow().bytes()).containsExactly(1, 2, 3);
+    }
+}

+ 439 - 0
docs/plans/2026-04-29-exam-sprint-report-filename.md

@@ -0,0 +1,439 @@
+# 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<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:
+
+```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.

+ 141 - 0
docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md

@@ -0,0 +1,141 @@
+# Exam Sprint Report Filename Design
+
+## Goal
+
+Update 临考词汇突击 PDF report filenames so generated files use the student's name, the business report title, and an `Asia/Shanghai` timestamp.
+
+## Scope
+
+This change is limited to the exam-sprint report generation filename path and the minimal payload/domain/storage-test propagation needed to make `studentName` available for both report types and keep generated download URLs working with localized filenames.
+
+It changes the `fileName` passed to `ExamSprintReportStorage.upload(...)`. Because the existing storage implementations return the same value as `storageObjectKey` in current tests and Azure uses `fileName` as the blob name, the visible storage key and downloaded filename will also change.
+
+This change does not alter:
+
+- API response schemas
+- report rendering content
+- PDF generation
+- storage interfaces and Azure storage behavior
+- download URL generation semantics
+- report state transitions
+
+## Recommended Approach
+
+Keep filename construction centralized in `ExamSprintReportGenerationPipeline.fileNameOf(...)`.
+
+The pipeline already owns the upload filename and has access to `ExamSprintReport`, `ReportType`, report content, and the injected `Clock`. This is the smallest safe change and keeps Azure storage, in-memory storage, logs, and persisted `fileName`/`storageObjectKey` values consistent. For achievement reports, add optional `studentName` propagation from the request contract into `AchievementReportContent` so the pipeline can use the same root field rule as outlook reports.
+
+## Business Rules
+
+### Filename format
+
+Generated PDF filenames must use one of these formats:
+
+```text
+{StudentName}-临考词汇突击成果报告-{currentTime}.pdf
+{StudentName}-临考词汇突击潜力展望报告-{currentTime}.pdf
+```
+
+Examples:
+
+```text
+吴泓妤-临考词汇突击成果报告-20250627133544.pdf
+冯亿豪-临考词汇突击潜力展望报告-20250627120841.pdf
+```
+
+### Student name source
+
+- Read lowercase `studentName` from the report payload root for filename generation.
+- If `studentName` is missing, JSON `null`, non-textual, empty, or all whitespace, use `reportId` as the filename prefix.
+- Trim leading and trailing whitespace before using `studentName`.
+- Outlook validation must continue to accept the existing upstream `StudentName` field and may also accept lowercase `studentName` as an alias, without letting the filename-only lowercase field weaken unrelated validation.
+
+### Report title mapping
+
+- `ReportType.ACHIEVEMENT` maps to `临考词汇突击成果报告`.
+- `ReportType.OUTLOOK` maps to `临考词汇突击潜力展望报告`.
+
+### Timestamp
+
+- Use the pipeline's injected `Clock`.
+- Format the current instant in `Asia/Shanghai`.
+- Use pattern `yyyyMMddHHmmss`.
+- Keep the `.pdf` extension.
+
+## Components
+
+### `ExamSprintReportGenerationPipeline`
+
+Update `fileNameOf(ExamSprintReport report)` to build the business filename instead of `exam-sprint-{type}-report-{reportId}.pdf`.
+
+Add focused private helpers if needed:
+
+- `studentNameOrReportId(ExamSprintReport report)`
+- `studentNameFromContent(ReportContent content)`
+- `reportTitle(ReportType reportType)`
+- `formattedShanghaiTimestamp()`
+
+The implementation should support both current content shapes:
+
+- `UnmodeledReportContent` wrapping a `JsonNode` source, used by outlook reports.
+- `AchievementReportContent`, used by achievement reports after payload validation and mapping, with optional `studentName` propagated from `AchievementExamSprintReportPayload.studentName`.
+
+If an achievement payload omits `studentName`, the implementation should still fall back to `reportId` rather than failing.
+
+### `AchievementExamSprintReportPayload`
+
+Add optional root field `studentName` without `@NotBlank`, because missing or blank names are valid and must fall back to `reportId`.
+
+### `OutlookExamSprintReportPayload`
+
+Keep the existing `@JsonProperty("StudentName")` contract and add lowercase `studentName` as a Jackson alias for validation compatibility. When both `StudentName` and `studentName` appear, validation should avoid treating the filename-only alias as a replacement for the canonical upstream name, while the persisted raw payload still keeps lowercase `studentName` for filename generation.
+
+### `AchievementReportContent` and `AchievementReportContentMapper`
+
+Add optional `studentName` to `AchievementReportContent` and map it from the contract payload. Existing validation should continue to require the current report content fields only.
+
+### `InMemoryExamSprintReportStorage`
+
+Stop deriving `reportId` from `storageObjectKey` filename prefixes. The new localized filenames are not parseable by the old `exam-sprint-*-report-{reportId}.pdf` convention. The in-memory storage should retain the `reportId` supplied to `upload(...)` with the stored file and use that value in `generateDownloadUrl(...)`.
+
+## Data Flow
+
+1. The application service validates and saves the submitted report.
+2. The generation pipeline renders HTML and generates PDF bytes.
+3. For achievement reports, `AchievementReportContentMapper` preserves root `studentName` on the domain content. For outlook reports, the unmodeled JSON content preserves root lowercase `studentName` while validation remains compatible with canonical `StudentName`.
+4. Before uploading, the pipeline builds the filename from report content, report type, report id, and `Clock`.
+5. `storage.upload(...)` receives the new filename.
+6. The stored file result is persisted on `ExamSprintReport` as `storageObjectKey` and `fileName`.
+7. Existing query, download, cleanup, and logging flows continue to use the persisted values.
+
+## Error Handling
+
+- Missing or invalid `studentName` must not fail generation.
+- Unknown report type should fail fast through the existing pipeline exception handling rather than producing a misleading filename.
+- Time formatting should be deterministic under tests by using the injected `Clock`.
+
+## Testing Strategy
+
+Follow TDD:
+
+1. Update application/generation tests to expect the new filename format and verify they fail against current code.
+2. Implement the minimal filename-generation changes.
+3. Run the focused application tests.
+4. Run the full Maven test suite.
+
+Required coverage:
+
+- Outlook report with lowercase `studentName` stores `冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf` when `Clock` is fixed at `2026-01-01T00:00:00Z`.
+- Achievement report with lowercase `studentName` stores `吴泓妤-临考词汇突击成果报告-20260101080000.pdf` for the same clock instant.
+- Missing or blank `studentName` falls back to `{reportId}-临考词汇突击潜力展望报告-20260101080000.pdf` or the corresponding achievement title.
+- Existing storage key, persisted `fileName`, generated download key tracking, and log assertions are updated to the same new filename.
+- Default in-memory storage can generate download URLs after localized filename upload without parsing report id from the filename.
+
+## Acceptance Criteria
+
+- Generated outlook PDF filenames follow `{StudentNameOrReportId}-临考词汇突击潜力展望报告-{yyyyMMddHHmmss}.pdf`.
+- Generated achievement PDF filenames follow `{StudentNameOrReportId}-临考词汇突击成果报告-{yyyyMMddHHmmss}.pdf`.
+- `currentTime` is formatted in `Asia/Shanghai`.
+- Empty, missing, null, or non-textual `studentName` uses `reportId`.
+- In-memory storage download URL generation remains compatible with localized filenames.
+- All affected tests pass.