Selaa lähdekoodia

feat(临考突击报告): 优化报告页眉页脚

金逸霄 1 päivä sitten
vanhempi
commit
a3f1046974
24 muutettua tiedostoa jossa 1557 lisäystä ja 48 poistoa
  1. 9 1
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
  2. 17 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
  3. 58 1
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
  4. 11 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/BusinessInfo.java
  5. 2 1
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
  6. 31 1
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
  7. 124 20
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java
  8. 72 6
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  9. 67 1
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  10. BIN
      abilities/exam-sprint/infrastructure/src/main/resources/report-assets/report-logo.png
  11. 62 0
      abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html
  12. 70 8
      abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html
  13. 266 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java
  14. 83 2
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  15. 118 1
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  16. 2 2
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java
  17. 30 3
      deploy/ability-center/docker-compose.yml
  18. 1 1
      deploy/ability-center/runtime/dockerfile
  19. 126 0
      docs/superpowers/plans/2026-05-13-report-business-footer.md
  20. 46 0
      docs/superpowers/plans/2026-05-13-report-header-center-outlook-compact.md
  21. 44 0
      docs/superpowers/plans/2026-05-13-report-logo-header.md
  22. 78 0
      docs/superpowers/plans/2026-05-13-report-playwright-page-header-footer.md
  23. 206 0
      docs/superpowers/plans/2026-05-13-report-student-header.md
  24. 34 0
      docs/superpowers/specs/2026-05-13-report-business-footer-design.md

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

@@ -61,7 +61,15 @@ final class AchievementReportContentMapper {
                         format(payload.testPaperBeforUnMastery()),
                         format(payload.testPaperAfterUnMastery()),
                         format(payload.testPaperImprovedWordCount()),
-                        hitWords(payload.testPaperImprovedWords())));
+                        hitWords(payload.testPaperImprovedWords())),
+                businessInfo(payload.businessInfo()));
+    }
+
+    private static AchievementReportContent.BusinessInfo businessInfo(cn.yunzhixue.ability.center.examsprint.contracts.report.BusinessInfo businessInfo) {
+        if (businessInfo == null) {
+            return null;
+        }
+        return new AchievementReportContent.BusinessInfo(businessInfo.name(), businessInfo.phone(), businessInfo.address());
     }
 
     private static String reportTitle(String stageName) {

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

@@ -118,6 +118,23 @@ class AchievementReportContentMapperTest {
         assertThat(content.examUnknownWordsHitStatus().hitWords()).containsExactly("number", "bear");
     }
 
+    /** 覆盖调用方新增 BusinessInfo 场景,当 payload 提供机构信息时,应透传到成果报告领域内容供页脚渲染。 */
+    @Test
+    void mapsBusinessInfoToAchievementContent() {
+        ObjectNode payload = pascalPayload();
+        payload.set("BusinessInfo", OBJECT_MAPPER.createObjectNode()
+                .put("Name", "张三")
+                .put("Phone", "138987484")
+                .put("Address", "浙江省杭州市"));
+
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(convert(payload));
+
+        assertThat(content.businessInfo()).isNotNull();
+        assertThat(content.businessInfo().name()).isEqualTo("张三");
+        assertThat(content.businessInfo().phone()).isEqualTo("138987484");
+        assertThat(content.businessInfo().address()).isEqualTo("浙江省杭州市");
+    }
+
     /** 覆盖空 payload 防御场景,当 mapper 收到 null 时,应抛出包含 payload 的 NullPointerException。 */
     @Test
     void rejectsNullPayload() {

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

@@ -36,5 +36,62 @@ public record AchievementExamSprintReportPayload(
         @JsonProperty("TestPaperBeforMasteryRate") @NotNull BigDecimal testPaperBeforMasteryRate,
         @JsonProperty("TestPaperLatestMasteryRate") @NotNull BigDecimal testPaperLatestMasteryRate,
         @JsonProperty("ShouldDisplaySigningGuarantee") Boolean shouldDisplaySigningGuarantee,
-        @JsonProperty("SigningGuarantee") String signingGuarantee) {
+        @JsonProperty("SigningGuarantee") String signingGuarantee,
+        @JsonProperty("BusinessInfo") BusinessInfo businessInfo) {
+
+    public AchievementExamSprintReportPayload(
+            String studentName,
+            BigDecimal studentStage,
+            String stageName,
+            BigDecimal stageVocabulary,
+            BigDecimal studentVocabulary,
+            BigDecimal studentVocabularyBefore,
+            BigDecimal studentUnMastedWordCount,
+            BigDecimal studentImproveWordCount,
+            String testPaperTitle,
+            BigDecimal testPaperWordCount,
+            BigDecimal testPaperBeforUnMastery,
+            BigDecimal testPaperBeforMastery,
+            BigDecimal testPaperLatestMastery,
+            BigDecimal testPaperAfterUnMastery,
+            List<JsonNode> testPaperImprovedWords,
+            BigDecimal testPaperImprovedWordCount,
+            BigDecimal testPaperImproveRate,
+            BigDecimal paperMasteryHitRate,
+            BigDecimal improveStudyEfficiency,
+            BigDecimal studentInitialVocabMastery,
+            BigDecimal studentCurrentVocabMastery,
+            BigDecimal studentVocabMasteryImprovement,
+            BigDecimal testPaperBeforMasteryRate,
+            BigDecimal testPaperLatestMasteryRate,
+            Boolean shouldDisplaySigningGuarantee,
+            String signingGuarantee) {
+        this(studentName,
+                studentStage,
+                stageName,
+                stageVocabulary,
+                studentVocabulary,
+                studentVocabularyBefore,
+                studentUnMastedWordCount,
+                studentImproveWordCount,
+                testPaperTitle,
+                testPaperWordCount,
+                testPaperBeforUnMastery,
+                testPaperBeforMastery,
+                testPaperLatestMastery,
+                testPaperAfterUnMastery,
+                testPaperImprovedWords,
+                testPaperImprovedWordCount,
+                testPaperImproveRate,
+                paperMasteryHitRate,
+                improveStudyEfficiency,
+                studentInitialVocabMastery,
+                studentCurrentVocabMastery,
+                studentVocabMasteryImprovement,
+                testPaperBeforMasteryRate,
+                testPaperLatestMasteryRate,
+                shouldDisplaySigningGuarantee,
+                signingGuarantee,
+                null);
+    }
 }

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

@@ -0,0 +1,11 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record BusinessInfo(
+        @JsonProperty("Name") String name,
+        @JsonProperty("Phone") String phone,
+        @JsonProperty("Address") String address) {
+}

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

@@ -28,7 +28,8 @@ public record OutlookExamSprintReportPayload(
         @JsonProperty("TestPaperUnMasterWordCount") @NotNull @Min(0) Integer testPaperUnMasterWordCount,
         @JsonProperty("TestPaperMastedWordCount") @NotNull @Min(0) Integer testPaperMastedWordCount,
         @JsonProperty("TestPaperWordCount") @NotNull @Min(0) Integer testPaperWordCount,
-        @JsonProperty("Complex") @NotNull Boolean complex) {
+        @JsonProperty("Complex") @NotNull Boolean complex,
+        @JsonProperty("BusinessInfo") BusinessInfo businessInfo) {
 
     public record StudentWordLatest(
             @JsonProperty("WordId") @NotNull @Min(0) Integer wordId,

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

@@ -14,7 +14,8 @@ public record AchievementReportContent(
         Comparison paperKnownWordsComparison,
         StageVocabularySummary stageVocabularySummary,
         TestPaperVocabularySummary testPaperVocabularySummary,
-        ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent {
+        ExamUnknownWordsHitStatus examUnknownWordsHitStatus,
+        BusinessInfo businessInfo) implements ReportContent {
 
     public AchievementReportContent {
         Objects.requireNonNull(summaryMetrics, "summaryMetrics");
@@ -25,6 +26,32 @@ public record AchievementReportContent(
         Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus");
     }
 
+    public AchievementReportContent(
+            String studentName,
+            String reportTitle,
+            String reportSubtitle,
+            String completionTitle,
+            String completionSubtitle,
+            SummaryMetrics summaryMetrics,
+            Comparison vocabularyComparison,
+            Comparison paperKnownWordsComparison,
+            StageVocabularySummary stageVocabularySummary,
+            TestPaperVocabularySummary testPaperVocabularySummary,
+            ExamUnknownWordsHitStatus examUnknownWordsHitStatus) {
+        this(studentName,
+                reportTitle,
+                reportSubtitle,
+                completionTitle,
+                completionSubtitle,
+                summaryMetrics,
+                vocabularyComparison,
+                paperKnownWordsComparison,
+                stageVocabularySummary,
+                testPaperVocabularySummary,
+                examUnknownWordsHitStatus,
+                null);
+    }
+
     @Override
     public ReportType reportType() {
         return ReportType.ACHIEVEMENT;
@@ -75,4 +102,7 @@ public record AchievementReportContent(
             hitWords = hitWords == null ? List.of() : List.copyOf(hitWords);
         }
     }
+
+    public record BusinessInfo(String name, String phone, String address) {
+    }
 }

+ 124 - 20
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java

@@ -13,10 +13,20 @@ import org.slf4j.LoggerFactory;
 
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
 
     private static final Logger log = LoggerFactory.getLogger(DefaultPlaywrightPdfWorker.class);
+    private static final Pattern STYLE_BLOCK_PATTERN = Pattern.compile("(?is)<style[^>]*>(.*?)</style>");
+    private static final Pattern REPORT_HEADER_PATTERN = Pattern.compile("(?is)<header\\b[^>]*class=[\"'][^\"']*\\breport-header\\b[^\"']*[\"'][^>]*>(.*?)</header>");
+    private static final Pattern REPORT_HEADER_BLOCK_PATTERN = Pattern.compile("(?is)<header\\b[^>]*class=[\"'][^\"']*\\breport-header\\b[^\"']*[\"'][^>]*>.*?</header>");
+    private static final Pattern REPORT_FOOTER_BUSINESS_PATTERN = Pattern.compile("(?is)<div\\b[^>]*class=[\"'][^\"']*\\breport-footer-business\\b[^\"']*[\"'][^>]*>(.*)</div>\\s*(?=</body>)");
+    private static final Pattern REPORT_FOOTER_BUSINESS_BLOCK_PATTERN = Pattern.compile("(?is)<div\\b[^>]*class=[\"'][^\"']*\\breport-footer-business\\b[^\"']*[\"'][^>]*>.*</div>\\s*(?=</body>)");
+    private static final String DEFAULT_HEADER_TEMPLATE = "<div></div>";
+    private static final String PAGE_TOP_MARGIN = "76px";
+    private static final String PAGE_BOTTOM_MARGIN = "34px";
 
     private final Playwright playwright;
     private final Browser browser;
@@ -86,7 +96,7 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
             logStageCompleted("设置打印媒体", stageStartedNanos);
 
             stageStartedNanos = System.nanoTime();
-            page.setContent(htmlContent, new Page.SetContentOptions()
+            page.setContent(printBodyHtml(htmlContent), new Page.SetContentOptions()
                     .setWaitUntil(WaitUntilState.LOAD)
                     .setTimeout(renderTimeoutMillis));
             logStageCompleted("设置HTML内容", stageStartedNanos);
@@ -96,15 +106,7 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
             logStageCompleted("等待字体加载完成", stageStartedNanos);
 
             stageStartedNanos = System.nanoTime();
-            byte[] pdfBytes = page.pdf(new Page.PdfOptions()
-                    .setFormat("A4")
-                    .setPrintBackground(true)
-                    .setPreferCSSPageSize(true)
-                    .setMargin(new Margin()
-                            .setTop("0")
-                            .setRight("0")
-                            .setBottom("0")
-                            .setLeft("0")));
+            byte[] pdfBytes = page.pdf(pdfOptions(htmlContent));
             logStageCompleted("生成PDF文件", stageStartedNanos);
             return pdfBytes;
         } catch (Exception exception) {
@@ -124,7 +126,7 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
             logStageCompleted("设置打印媒体", stageStartedNanos);
 
             stageStartedNanos = System.nanoTime();
-            page.setContent(htmlContent, new Page.SetContentOptions()
+            page.setContent(printBodyHtml(htmlContent), new Page.SetContentOptions()
                     .setWaitUntil(WaitUntilState.LOAD)
                     .setTimeout(renderTimeoutMillis));
             logStageCompleted("设置HTML内容", stageStartedNanos);
@@ -134,15 +136,7 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
             logStageCompleted("等待字体加载完成", stageStartedNanos);
 
             stageStartedNanos = System.nanoTime();
-            byte[] pdfBytes = page.pdf(new Page.PdfOptions()
-                    .setFormat("A4")
-                    .setPrintBackground(true)
-                    .setPreferCSSPageSize(true)
-                    .setMargin(new Margin()
-                            .setTop("0")
-                            .setRight("0")
-                            .setBottom("0")
-                            .setLeft("0")));
+            byte[] pdfBytes = page.pdf(pdfOptions(htmlContent));
             logStageCompleted("生成PDF文件", stageStartedNanos);
             reusablePageRenderCount++;
             return pdfBytes;
@@ -168,6 +162,116 @@ final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
         return reusablePage;
     }
 
+    private Page.PdfOptions pdfOptions(String htmlContent) {
+        return new Page.PdfOptions()
+                .setFormat("A4")
+                .setPrintBackground(true)
+                .setPreferCSSPageSize(false)
+                .setDisplayHeaderFooter(true)
+                .setHeaderTemplate(headerTemplate(htmlContent))
+                .setFooterTemplate(footerTemplate(htmlContent))
+                .setMargin(new Margin()
+                        .setTop(PAGE_TOP_MARGIN)
+                        .setRight("0")
+                        .setBottom(PAGE_BOTTOM_MARGIN)
+                        .setLeft("0"));
+    }
+
+    private String headerTemplate(String htmlContent) {
+        Matcher headerMatcher = REPORT_HEADER_PATTERN.matcher(htmlContent);
+        if (!headerMatcher.find()) {
+            return DEFAULT_HEADER_TEMPLATE;
+        }
+
+        return """
+                <style>
+                    %s
+                    .report-header {
+                        box-sizing: border-box;
+                        width: calc(100%% - 64px) !important;
+                        margin: 0 32px !important;
+                    }
+                    .header-logo,
+                    .header-main,
+                    .header-generated-at {
+                        width: 33.3333%% !important;
+                    }
+                    .header-main {
+                        text-align: center !important;
+                    }
+                    .header-generated-at {
+                        text-align: right !important;
+                    }
+                </style>
+                <header class="report-header">%s</header>
+                """.formatted(styleBlocks(htmlContent), headerMatcher.group(1));
+    }
+
+    private String styleBlocks(String htmlContent) {
+        Matcher styleMatcher = STYLE_BLOCK_PATTERN.matcher(htmlContent);
+        StringBuilder styles = new StringBuilder();
+        while (styleMatcher.find()) {
+            styles.append(styleMatcher.group(1)).append('\n');
+        }
+        return styles.toString();
+    }
+
+    private String footerTemplate(String htmlContent) {
+        return """
+                <style>
+                    %s
+                    .report-pdf-footer {
+                        box-sizing: border-box;
+                        width: calc(100%% - 64px);
+                        margin: 0 32px;
+                        display: table;
+                        table-layout: fixed;
+                        font-family: 'MiSans VF', MiSans, ReportFont, sans-serif;
+                        font-size: 9px;
+                        line-height: 1.45;
+                        color: #68768a;
+                    }
+                    .report-footer-business,
+                    .report-footer-page-number {
+                        display: table-cell;
+                        vertical-align: top;
+                    }
+                    .report-footer-business {
+                        width: 75%%;
+                        text-align: left;
+                    }
+                    .report-footer-page-number {
+                        width: 25%%;
+                        text-align: right;
+                        white-space: nowrap;
+                    }
+                </style>
+                <div class="report-pdf-footer">
+                    <div class="report-footer-business">%s</div>
+                    <div class="report-footer-page-number"><span class="pageNumber"></span> / <span class="totalPages"></span></div>
+                </div>
+                """.formatted(styleBlocks(htmlContent), footerBusinessTemplate(htmlContent));
+    }
+
+    private String footerBusinessTemplate(String htmlContent) {
+        Matcher footerMatcher = REPORT_FOOTER_BUSINESS_PATTERN.matcher(htmlContent);
+        if (!footerMatcher.find()) {
+            return "";
+        }
+        return footerMatcher.group(1);
+    }
+
+    private String printBodyHtml(String htmlContent) {
+        String bodyHtml = REPORT_HEADER_BLOCK_PATTERN.matcher(htmlContent).replaceFirst("");
+        bodyHtml = REPORT_FOOTER_BUSINESS_BLOCK_PATTERN.matcher(bodyHtml).replaceFirst("");
+        String pageMarginOverride = """
+                <style>
+                    @page { size: A4; margin: %s 0 %s 0; }
+                </style>
+                """.formatted(PAGE_TOP_MARGIN, PAGE_BOTTOM_MARGIN);
+        return bodyHtml.replaceFirst("(?i)</head>", Matcher.quoteReplacement(pageMarginOverride + "</head>"));
+    }
+
     private void createReusablePage(String reason) {
         long stageStartedNanos = System.nanoTime();
         reusableContext = browser.newContext(new Browser.NewContextOptions().setLocale("zh-CN"));

+ 72 - 6
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java

@@ -12,6 +12,9 @@ import java.io.InputStream;
 import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
 import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.Map;
@@ -23,20 +26,33 @@ import java.util.regex.Pattern;
 public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintReportRenderer {
 
     private static final String TEMPLATE_RESOURCE = "templates/achievement-exam-sprint-report-template.html";
+    private static final String REPORT_LOGO_RESOURCE = "report-assets/report-logo.png";
     private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([A-Za-z0-9]+)}}");
+    private static final DateTimeFormatter GENERATED_AT_FORMATTER = DateTimeFormatter
+            .ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.of("Asia/Shanghai"));
 
     private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
     private final String template;
+    private final String reportLogoDataUri;
 
     public ClasspathAchievementExamSprintReportRenderer() {
-        this(new AchievementExamSprintReportSvgChartBuilder(), loadTemplateFromClasspath(TEMPLATE_RESOURCE));
+        this(new AchievementExamSprintReportSvgChartBuilder(), loadTemplateFromClasspath(TEMPLATE_RESOURCE), loadPngDataUriFromClasspath(REPORT_LOGO_RESOURCE));
     }
 
     ClasspathAchievementExamSprintReportRenderer(
             AchievementExamSprintReportSvgChartBuilder chartBuilder,
             String template) {
+        this(chartBuilder, template, loadPngDataUriFromClasspath(REPORT_LOGO_RESOURCE));
+    }
+
+    ClasspathAchievementExamSprintReportRenderer(
+            AchievementExamSprintReportSvgChartBuilder chartBuilder,
+            String template,
+            String reportLogoDataUri) {
         this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
         this.template = Objects.requireNonNull(template, "template");
+        this.reportLogoDataUri = Objects.requireNonNull(reportLogoDataUri, "reportLogoDataUri");
     }
 
     @Override
@@ -59,7 +75,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
 
             return renderTemplate(
                     template,
-                    placeholders(reportContent, summary, vocabulary, paperKnownWords, stageVocabulary, testPaperVocabulary, hitStatus));
+                    placeholders(reportContent, summary, vocabulary, paperKnownWords, stageVocabulary, testPaperVocabulary, hitStatus, generatedAt));
         } catch (Exception exception) {
             throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
         }
@@ -68,11 +84,16 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     private Map<String, String> placeholders(AchievementReportContent reportContent,
                                               AchievementReportContent.SummaryMetrics summary,
                                               AchievementReportContent.Comparison vocabulary,
-                                              AchievementReportContent.Comparison paperKnownWords,
-                                              AchievementReportContent.StageVocabularySummary stageVocabulary,
-                                              AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary,
-                                              AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
+                                               AchievementReportContent.Comparison paperKnownWords,
+                                               AchievementReportContent.StageVocabularySummary stageVocabulary,
+                                               AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary,
+                                               AchievementReportContent.ExamUnknownWordsHitStatus hitStatus,
+                                               Instant generatedAt) {
         Map<String, String> placeholders = new LinkedHashMap<>();
+        placeholders.put("reportLogoDataUri", escape(reportLogoDataUri));
+        placeholders.put("headerReportType", "学习成果报告");
+        placeholders.put("studentName", escape(reportContent.studentName()));
+        placeholders.put("generatedAtText", escape(formatGeneratedAt(generatedAt)));
         placeholders.put("reportTitle", escape(reportContent.reportTitle()));
         placeholders.put("reportSubtitle", escape(reportContent.reportSubtitle()));
         placeholders.put("completionTitle", escape(reportContent.completionTitle()));
@@ -107,6 +128,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         placeholders.put("hitStatusUnknownWordHitRateText", escape(formatPercentRatio(hitStatus.unknownWordHitRateText())));
         placeholders.put("hitStatusLearningEfficiencyText", escape(withUnit(hitStatus.learningEfficiencyText(), "倍")));
         placeholders.put("hitWords", renderHitWords(hitStatus));
+        placeholders.put("reportFooterBusiness", renderFooterBusiness(reportContent.businessInfo()));
         return placeholders;
     }
 
@@ -135,6 +157,14 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         }
     }
 
+    private static String loadPngDataUriFromClasspath(String resource) {
+        try (InputStream inputStream = new ClassPathResource(resource).getInputStream()) {
+            return "data:image/png;base64," + Base64.getEncoder().encodeToString(inputStream.readAllBytes());
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load achievement exam sprint report logo", exception);
+        }
+    }
+
     private String renderVocabularyComparisonChart(AchievementReportContent.Comparison comparison) {
         return chartBuilder.comparisonBarChart(
                 "vocabulary-growth-chart",
@@ -177,6 +207,38 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         return builder.toString();
     }
 
+    private String renderFooterBusiness(AchievementReportContent.BusinessInfo businessInfo) {
+        if (businessInfo == null) {
+            return "";
+        }
+        String name = normalizeDisplayValue(businessInfo.name());
+        String phone = normalizeDisplayValue(businessInfo.phone());
+        String address = normalizeDisplayValue(businessInfo.address());
+        if (name.isEmpty() && phone.isEmpty() && address.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder firstLine = new StringBuilder();
+        if (!name.isEmpty()) {
+            firstLine.append(escape(name));
+        }
+        if (!phone.isEmpty()) {
+            if (!firstLine.isEmpty()) {
+                firstLine.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+            }
+            firstLine.append("Tel:").append(escape(phone));
+        }
+
+        StringBuilder builder = new StringBuilder("<div class=\"report-footer-business\">");
+        if (!firstLine.isEmpty()) {
+            builder.append("<div class=\"report-footer-business-line\">").append(firstLine).append("</div>");
+        }
+        if (!address.isEmpty()) {
+            builder.append("<div class=\"report-footer-business-line\">").append(escape(address)).append("</div>");
+        }
+        return builder.append("</div>").toString();
+    }
+
     private double safeNonNegativeFinite(Double value) {
         if (value == null || !Double.isFinite(value) || value < 0d) {
             return 0d;
@@ -224,6 +286,10 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         return formatted.replaceAll("\\.0+$", "").replaceAll("(\\.\\d*?)0+$", "$1");
     }
 
+    private String formatGeneratedAt(Instant generatedAt) {
+        return generatedAt == null ? "" : GENERATED_AT_FORMATTER.format(generatedAt);
+    }
+
     private String normalizeDisplayValue(String value) {
         return value == null ? "" : value.trim();
     }

+ 67 - 1
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -17,6 +17,9 @@ import java.io.InputStream;
 import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
@@ -26,9 +29,13 @@ import java.util.regex.Pattern;
 public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {
 
     private static final String TEMPLATE_RESOURCE = "templates/outlook-exam-sprint-report-template.html";
+    private static final String REPORT_LOGO_RESOURCE = "report-assets/report-logo.png";
     private static final String DEFAULT_THEME_COLOR = "#448aff";
     private static final String SVG_CJK_FONT_FAMILY = " font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
     private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
+    private static final DateTimeFormatter GENERATED_AT_FORMATTER = DateTimeFormatter
+            .ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.of("Asia/Shanghai"));
     private static final int CHART_AXIS_LEFT = 34;
     private static final int CHART_AXIS_TOP = 50;
     private static final int CHART_AXIS_BOTTOM = 180;
@@ -36,15 +43,21 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
 
     private final ObjectMapper objectMapper;
     private final String template;
+    private final String reportLogoDataUri;
 
     @Autowired
     public ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper) {
-        this(objectMapper, loadTemplateFromClasspath(TEMPLATE_RESOURCE));
+        this(objectMapper, loadTemplateFromClasspath(TEMPLATE_RESOURCE), loadPngDataUriFromClasspath(REPORT_LOGO_RESOURCE));
     }
 
     ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper, String template) {
+        this(objectMapper, template, loadPngDataUriFromClasspath(REPORT_LOGO_RESOURCE));
+    }
+
+    ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper, String template, String reportLogoDataUri) {
         this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
         this.template = Objects.requireNonNull(template, "template");
+        this.reportLogoDataUri = Objects.requireNonNull(reportLogoDataUri, "reportLogoDataUri");
     }
 
     @Override
@@ -63,6 +76,11 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             OutlookExamSprintReportPayload payloadContract = objectMapper.treeToValue(payloadForDeserialization(payload), OutlookExamSprintReportPayload.class);
             OutlookReportViewModel reportPayload = adaptPayload(payloadContract);
             return template
+                    .replace("{{reportLogoDataUri}}", escape(reportLogoDataUri))
+                    .replace("{{headerReportType}}", "潜力展望报告")
+                    .replace("{{studentName}}", escape(payloadContract.studentName()))
+                    .replace("{{generatedAtText}}", escape(formatGeneratedAt(generatedAt)))
+                    .replace("{{reportFooterBusiness}}", renderFooterBusiness(payloadContract.businessInfo()))
                     .replace("{{syllabusMasterySection}}", renderSyllabusMasteryChart(reportPayload.syllabusMasterySection()))
                     .replace("{{pastPaperVocabularySection}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularySection()))
                     .replace("{{highFrequencyVocabularySection}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularySection()))
@@ -265,6 +283,14 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         }
     }
 
+    private static String loadPngDataUriFromClasspath(String resource) {
+        try (InputStream inputStream = new ClassPathResource(resource).getInputStream()) {
+            return "data:image/png;base64," + Base64.getEncoder().encodeToString(inputStream.readAllBytes());
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load outlook exam sprint report logo", exception);
+        }
+    }
+
     private String renderSyllabusMasteryChart(SyllabusMasteryChart chart) {
         double masteredPercent = percentage(chart.masteredWordCount(), chart.totalWordCount());
         double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());
@@ -723,6 +749,46 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         return String.format(Locale.ROOT, "%.2f", value);
     }
 
+    private String formatGeneratedAt(Instant generatedAt) {
+        return generatedAt == null ? "" : GENERATED_AT_FORMATTER.format(generatedAt);
+    }
+
+    private String renderFooterBusiness(cn.yunzhixue.ability.center.examsprint.contracts.report.BusinessInfo businessInfo) {
+        if (businessInfo == null) {
+            return "";
+        }
+        String name = normalizeDisplayValue(businessInfo.name());
+        String phone = normalizeDisplayValue(businessInfo.phone());
+        String address = normalizeDisplayValue(businessInfo.address());
+        if (name.isEmpty() && phone.isEmpty() && address.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder firstLine = new StringBuilder();
+        if (!name.isEmpty()) {
+            firstLine.append(escape(name));
+        }
+        if (!phone.isEmpty()) {
+            if (!firstLine.isEmpty()) {
+                firstLine.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+            }
+            firstLine.append("Tel:").append(escape(phone));
+        }
+
+        StringBuilder builder = new StringBuilder("<div class=\"report-footer-business\">");
+        if (!firstLine.isEmpty()) {
+            builder.append("<div class=\"report-footer-business-line\">").append(firstLine).append("</div>");
+        }
+        if (!address.isEmpty()) {
+            builder.append("<div class=\"report-footer-business-line\">").append(escape(address)).append("</div>");
+        }
+        return builder.append("</div>").toString();
+    }
+
+    private String normalizeDisplayValue(String value) {
+        return value == null ? "" : value.trim();
+    }
+
     private String escape(String value) {
         if (value == null) {
             return "";

BIN
abilities/exam-sprint/infrastructure/src/main/resources/report-assets/report-logo.png


+ 62 - 0
abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html

@@ -44,6 +44,58 @@
             text-align: center;
         }
 
+        .report-header {
+            display: table;
+            width: 100%;
+            table-layout: fixed;
+            border-bottom: 3px solid #111;
+            margin-bottom: 28px;
+            padding-bottom: 10px;
+        }
+
+        .header-logo,
+        .header-main,
+        .header-generated-at {
+            display: table-cell;
+            vertical-align: top;
+        }
+
+        .header-logo {
+            width: 33.3333%;
+        }
+
+        .header-logo-image {
+            display: block;
+            max-width: 120px;
+            max-height: 42px;
+        }
+
+        .header-main {
+            width: 33.3333%;
+            text-align: center;
+            color: #68768a;
+        }
+
+        .header-report-type {
+            font-size: 13px;
+            line-height: 1.5;
+        }
+
+        .header-student-name {
+            margin-top: 4px;
+            font-size: 13px;
+            line-height: 1.5;
+        }
+
+        .header-generated-at {
+            width: 33.3333%;
+            color: #68768a;
+            font-size: 12px;
+            line-height: 1.5;
+            text-align: right;
+            white-space: nowrap;
+        }
+
         .result-header {
             margin: 0 0 22px;
             padding: 22px 24px;
@@ -245,6 +297,15 @@
 </head>
 <body>
 <div class="report-container">
+    <header class="report-header">
+        <div class="header-logo"><img class="header-logo-image" src="{{reportLogoDataUri}}" alt="IATSE"/></div>
+        <div class="header-main">
+            <div class="header-report-type">{{headerReportType}}</div>
+            <div class="header-student-name">{{studentName}}</div>
+        </div>
+        <div class="header-generated-at">{{generatedAtText}}</div>
+    </header>
+
     <h1 class="report-title">{{reportTitle}}</h1>
     <p class="report-subtitle">{{reportSubtitle}}</p>
 
@@ -330,5 +391,6 @@
         </div>
     </div>
 </div>
+{{reportFooterBusiness}}
 </body>
 </html>

+ 70 - 8
abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html

@@ -45,6 +45,58 @@
             line-height: 1.72;
         }
 
+        .report-header {
+            display: table;
+            width: 100%;
+            table-layout: fixed;
+            border-bottom: 3px solid #111;
+            margin-bottom: 28px;
+            padding-bottom: 10px;
+        }
+
+        .header-logo,
+        .header-main,
+        .header-generated-at {
+            display: table-cell;
+            vertical-align: top;
+        }
+
+        .header-logo {
+            width: 33.3333%;
+        }
+
+        .header-logo-image {
+            display: block;
+            max-width: 120px;
+            max-height: 42px;
+        }
+
+        .header-main {
+            width: 33.3333%;
+            text-align: center;
+            color: #68768a;
+        }
+
+        .header-report-type {
+            font-size: 13px;
+            line-height: 1.5;
+        }
+
+        .header-student-name {
+            margin-top: 4px;
+            font-size: 13px;
+            line-height: 1.5;
+        }
+
+        .header-generated-at {
+            width: 33.3333%;
+            color: #68768a;
+            font-size: 12px;
+            line-height: 1.5;
+            text-align: right;
+            white-space: nowrap;
+        }
+
         .section {
             margin-top: 26px;
         }
@@ -63,7 +115,7 @@
             width: 100%;
             table-layout: fixed;
             border-collapse: separate;
-            border-spacing: 0 16px;
+            border-spacing: 0 10px;
         }
 
         .analysis-row {
@@ -88,9 +140,9 @@
             background: #fafbfc;
             border: 1px solid #e7edf5;
             border-radius: 10px;
-            padding: 20px;
+            padding: 14px;
             page-break-inside: avoid;
-            min-height: 370px;
+            min-height: 275px;
         }
 
         .card-title {
@@ -98,12 +150,12 @@
             font-size: 16px;
             color: #2b4c8a;
             font-weight: 600;
-            margin: 0 0 10px;
+            margin: 0 0 6px;
         }
 
         .chart-box {
-            height: 220px;
-            margin: 10px 0;
+            height: 160px;
+            margin: 4px 0;
             border-radius: 10px;
             background: #f8fbff;
             text-align: center;
@@ -117,9 +169,9 @@
 
         .data-text {
             font-family: 'MiSans VF', MiSans, ReportFont, sans-serif;
-            margin: 6px 0;
+            margin: 2px 0;
             color: #3d4a5d;
-            line-height: 1.72;
+            line-height: 1.45;
         }
 
         .highlight,
@@ -348,6 +400,15 @@
 </head>
 <body>
 <div class="report-container">
+    <header class="report-header">
+        <div class="header-logo"><img class="header-logo-image" src="{{reportLogoDataUri}}" alt="IATSE"/></div>
+        <div class="header-main">
+            <div class="header-report-type">{{headerReportType}}</div>
+            <div class="header-student-name">{{studentName}}</div>
+        </div>
+        <div class="header-generated-at">{{generatedAtText}}</div>
+    </header>
+
     <h1 class="report-title">高考英语临考词汇突击潜力展望报告</h1>
     <p class="report-subtitle">科学规划 · 精准提分 · 短期见效</p>
 
@@ -374,5 +435,6 @@
 
     {{moduleThreeSection}}
 </div>
+{{reportFooterBusiness}}
 </body>
 </html>

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

@@ -14,6 +14,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent;
 import ch.qos.logback.core.read.ListAppender;
 import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -25,6 +26,8 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.text.Normalizer;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -268,6 +271,27 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         }
     }
 
+    @Test
+    void generateKeepsOutlookModuleOneCardsOnFirstPage() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        String html = renderer.render(
+                new UnmodeledReportContent(ReportType.OUTLOOK, samplePayloadWithComplex(true)),
+                Instant.parse("2026-01-03T08:00:00Z"));
+
+        byte[] pdfBytes = pdfGenerator.generate(html);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            String firstPageText = normalizePdfText(pageText(document, 1));
+            assertThat(firstPageText)
+                    .contains("考纲词汇掌握情况")
+                    .contains("真题试卷词汇掌握情况")
+                    .contains("常考词汇掌握情况")
+                    .contains("词频区间掌握度");
+        }
+    }
+
     @Test
     void generateCreatesReadablePdfForAchievementReportTemplate() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
@@ -290,6 +314,180 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         }
     }
 
+    @Test
+    void generateRepeatsReportHeaderAndFooterPageNumbers() throws Exception {
+        byte[] pdfBytes = pdfGenerator.generate("""
+                <html>
+                <head>
+                    <meta charset=\"UTF-8\"/>
+                    <style>
+                        @page { size: A4; margin: 0; }
+                        body { margin: 0; font-family: MiSans, ReportFont, sans-serif; }
+                        .report-container { padding: 32px; }
+                        .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
+                        .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
+                        .header-logo { width: 180px; }
+                        .header-main { text-align: center; color: #68768a; }
+                        .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
+                        .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
+                        .page { height: 1122px; page-break-after: always; }
+                    </style>
+                </head>
+                <body>
+                    <div class=\"report-container\">
+                        <header class=\"report-header\">
+                            <div class=\"header-logo\"></div>
+                            <div class=\"header-main\">
+                                <div class=\"header-report-type\">个人学情报告</div>
+                                <div class=\"header-student-name\">分页测试学生</div>
+                            </div>
+                            <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
+                        </header>
+                        <main>
+                            <section class=\"page\">第一页正文内容</section>
+                            <section class=\"page\">第二页正文内容</section>
+                        </main>
+                    </div>
+                </body>
+                </html>
+                """);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            int pageCount = document.getNumberOfPages();
+            assertThat(pageCount).isGreaterThanOrEqualTo(2);
+            String normalizedText = normalizePdfText(new PDFTextStripper().getText(document));
+            assertThat(countOccurrences(normalizedText, "个人学情报告")).isEqualTo(pageCount);
+            assertThat(countOccurrences(normalizedText, "分页测试学生")).isEqualTo(pageCount);
+            assertThat(normalizedText)
+                    .contains("2026-05-1310:08:54")
+                    .contains("1/" + pageCount)
+                    .contains(pageCount + "/" + pageCount);
+        }
+    }
+
+    @Test
+    void generateKeepsContinuedPageBodyAwayFromHeaderAndFooter() throws Exception {
+        byte[] pdfBytes = pdfGenerator.generate("""
+                <html>
+                <head>
+                    <meta charset=\"UTF-8\"/>
+                    <style>
+                        @page { size: A4; margin: 0; }
+                        body { margin: 0; font-family: MiSans, ReportFont, sans-serif; font-size: 14px; }
+                        .report-container { padding: 32px; }
+                        .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
+                        .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
+                        .header-logo { width: 180px; }
+                        .header-main { text-align: center; color: #68768a; }
+                        .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
+                        .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
+                        .filler { height: 620px; }
+                        .large-card { height: 720px; border: 1px solid #eaeef5; page-break-inside: avoid; }
+                        .bottom-marker { margin-top: 650px; }
+                    </style>
+                </head>
+                <body>
+                    <div class=\"report-container\">
+                        <header class=\"report-header\">
+                            <div class=\"header-logo\"></div>
+                            <div class=\"header-main\">
+                                <div class=\"header-report-type\">个人学情报告</div>
+                                <div class=\"header-student-name\">分页测试学生</div>
+                            </div>
+                            <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
+                        </header>
+                        <main>
+                            <section class=\"filler\">第一页正文内容</section>
+                            <section class=\"large-card\">PAGE_TWO_BODY_START<div class=\"bottom-marker\">PAGE_TWO_BODY_END</div></section>
+                        </main>
+                    </div>
+                </body>
+                </html>
+                """);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(2);
+            List<TextLine> pageTwoLines = textLines(pdfBytes, 2);
+
+            TextLine continuedBodyStart = findLineContaining(pageTwoLines, "PAGE_TWO_BODY_START");
+            TextLine continuedBodyEnd = findLineContaining(pageTwoLines, "PAGE_TWO_BODY_END");
+            float headerBottomY = pageTwoLines.stream()
+                    .filter(line -> line.text.contains("个人学情报告")
+                            || line.text.contains("分页测试学生")
+                            || line.text.contains("2026-05-1310:08:54"))
+                    .map(TextLine::bottomY)
+                    .max(Float::compare)
+                    .orElseThrow(() -> new AssertionError("Missing repeated report header in " + pageTwoLines));
+            TextLine footer = findLineContaining(pageTwoLines, "2/");
+
+            assertThat(continuedBodyStart.y)
+                    .as("continued page body should start below the repeated report header reserved area")
+                    .isGreaterThanOrEqualTo(headerBottomY + 8f);
+            assertThat(footer.y - continuedBodyEnd.bottomY())
+                    .as("continued page body should not collide with footer reserved area")
+                    .isGreaterThanOrEqualTo(32f);
+        }
+    }
+
+    @Test
+    void generatePlacesBusinessInfoLeftAndPageNumbersRightInFooter() throws Exception {
+        byte[] pdfBytes = pdfGenerator.generate("""
+                <html>
+                <head>
+                    <meta charset=\"UTF-8\"/>
+                    <style>
+                        @page { size: A4; margin: 0; }
+                        body { margin: 0; font-family: MiSans, ReportFont, sans-serif; font-size: 14px; }
+                        .report-container { padding: 32px; }
+                        .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
+                        .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
+                        .header-logo { width: 180px; }
+                        .header-main { text-align: center; color: #68768a; }
+                        .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
+                        .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
+                        .page { height: 1122px; page-break-after: always; }
+                    </style>
+                </head>
+                <body>
+                    <div class=\"report-container\">
+                        <header class=\"report-header\">
+                            <div class=\"header-logo\"></div>
+                            <div class=\"header-main\">
+                                <div class=\"header-report-type\">学习成果报告</div>
+                                <div class=\"header-student-name\">分页测试学生</div>
+                            </div>
+                            <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
+                        </header>
+                        <main>
+                            <section class=\"page\">第一页正文内容</section>
+                            <section class=\"page\">第二页正文内容</section>
+                        </main>
+                    </div>
+                    <div class=\"report-footer-business\">
+                        <div class=\"report-footer-business-line\">张三&nbsp;&nbsp;&nbsp;&nbsp;Tel:138987484</div>
+                        <div class=\"report-footer-business-line\">浙江省杭州市</div>
+                    </div>
+                </body>
+                </html>
+                """);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(2);
+            List<TextLine> pageTwoLines = textLines(pdfBytes, 2);
+            TextLine businessLine = findLineContaining(pageTwoLines, "张三Tel:138987484");
+            TextLine addressLine = findLineContaining(pageTwoLines, "浙江省杭州市");
+            TextLine pageNumberLine = findLineContaining(pageTwoLines, "2/");
+            float pageWidth = document.getPage(1).getMediaBox().getWidth();
+
+            assertThat(businessLine.x).isLessThan(pageWidth / 2f);
+            assertThat(addressLine.x).isLessThan(pageWidth / 2f);
+            assertThat(pageNumberLine.x).isGreaterThan(pageWidth / 2f);
+        }
+    }
+
     private void assertPdfHeader(byte[] pdfBytes) {
         assertThat(pdfBytes).isNotEmpty();
         assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
@@ -313,6 +511,74 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
     }
 
+    private String pageText(PDDocument document, int pageNumber) throws Exception {
+        PDFTextStripper stripper = new PDFTextStripper();
+        stripper.setStartPage(pageNumber);
+        stripper.setEndPage(pageNumber);
+        return stripper.getText(document);
+    }
+
+    private int countOccurrences(String value, String needle) {
+        int count = 0;
+        int index = 0;
+        while ((index = value.indexOf(needle, index)) >= 0) {
+            count++;
+            index += needle.length();
+        }
+        return count;
+    }
+
+    private List<TextLine> textLines(byte[] pdfBytes, int pageNumber) throws Exception {
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            TextLineStripper stripper = new TextLineStripper();
+            stripper.setSortByPosition(true);
+            stripper.setStartPage(pageNumber);
+            stripper.setEndPage(pageNumber);
+            stripper.getText(document);
+            return stripper.lines();
+        }
+    }
+
+    private TextLine findLineContaining(List<TextLine> lines, String text) {
+        return lines.stream()
+                .filter(line -> line.text.contains(text))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("Missing PDF text line: " + text + " in " + lines));
+    }
+
+    private static final class TextLineStripper extends PDFTextStripper {
+
+        private final List<TextLine> lines = new ArrayList<>();
+
+        private TextLineStripper() throws java.io.IOException {
+        }
+
+        @Override
+        protected void writeString(String text, List<TextPosition> textPositions) {
+            if (text == null || text.isBlank() || textPositions.isEmpty()) {
+                return;
+            }
+            float minY = Float.MAX_VALUE;
+            float maxY = Float.MIN_VALUE;
+            float minX = Float.MAX_VALUE;
+            for (TextPosition position : textPositions) {
+                minX = Math.min(minX, position.getXDirAdj());
+                minY = Math.min(minY, position.getYDirAdj());
+                maxY = Math.max(maxY, position.getYDirAdj() + position.getHeightDir());
+            }
+            lines.add(new TextLine(Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", ""), minX, minY, maxY));
+        }
+
+        private List<TextLine> lines() {
+            return lines.stream()
+                    .sorted(Comparator.comparing(TextLine::y))
+                    .toList();
+        }
+    }
+
+    private record TextLine(String text, float x, float y, float bottomY) {
+    }
+
     private AchievementReportContent sampleAchievementContent() {
         return new AchievementReportContent(
                 "吴泓妤",

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

@@ -46,7 +46,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
     void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
 
-        String html = renderer.render(sampleContent(), Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(sampleContent(), Instant.parse("2026-05-13T02:08:54.657335Z"));
 
         assertSectionClassForTitle(html, "模块一:词汇量对比", "section comparison-section");
         assertSectionClassForTitle(html, "模块二:试卷熟词量对比", "section comparison-section");
@@ -143,6 +143,53 @@ class ClasspathAchievementExamSprintReportRendererTest {
         assertThat(countOccurrences(html, "class='chart-x-axis'")).isEqualTo(2);
     }
 
+    @Test
+    void renderShowsStudentNameInReportHeader() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+
+        String html = renderer.render(sampleContent(), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html)
+                .contains("class=\"report-header\"")
+                .contains("class=\"header-logo\"")
+                .contains("<img class=\"header-logo-image\" src=\"data:image/png;base64,")
+                .contains("class=\"header-report-type\">学习成果报告</div>")
+                .contains("class=\"header-student-name\">吴泓妤</div>")
+                .contains("class=\"header-generated-at\"")
+                .contains("class=\"header-generated-at\">2026-05-13 10:08:54</div>")
+                .doesNotContain("{{reportLogoDataUri}}")
+                .doesNotContain("{{studentName}}")
+                .doesNotContain("{{generatedAtText}}")
+                .doesNotContain("2026-05-13T02:08:54.657335Z");
+        assertCssRuleContains(html, ".header-logo", "width: 33.3333%;");
+        assertCssRuleContains(html, ".header-main", "width: 33.3333%;", "text-align: center;");
+        assertCssRuleContains(html, ".header-generated-at", "width: 33.3333%;", "text-align: right;");
+    }
+
+    @Test
+    void renderShowsBusinessInfoForFooterWhenProvided() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = withBusinessInfo(sampleContent(),
+                new AchievementReportContent.BusinessInfo("张三", "138987484", "浙江省杭州市"));
+
+        String html = renderer.render(content, Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html)
+                .contains("class=\"report-footer-business\"")
+                .contains("张三")
+                .contains("Tel:138987484")
+                .contains("浙江省杭州市");
+    }
+
+    @Test
+    void renderOmitsBusinessInfoFooterWhenMissing() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+
+        String html = renderer.render(sampleContent(), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html).doesNotContain("class=\"report-footer-business\"");
+    }
+
     @Test
     void supportsOnlyAchievementReportType() {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
@@ -155,13 +202,16 @@ class ClasspathAchievementExamSprintReportRendererTest {
     void renderEscapesPayloadTextAndHitWords() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
         AchievementReportContent content = withHitWords(
-                withReportTitle(sampleContent(), "成果<script>alert(1)</script>"),
+                withStudentName(
+                        withReportTitle(sampleContent(), "成果<script>alert(1)</script>"),
+                        "测试<script>alert(1)</script>"),
                 List.of("number", "bear", "popular", "importance", "bear<script>"));
 
         String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("成果&lt;script&gt;alert(1)&lt;/script&gt;")
+                .contains("测试&lt;script&gt;alert(1)&lt;/script&gt;")
                 .contains("bear&lt;script&gt;")
                 .doesNotContain("<script>alert(1)</script>")
                 .doesNotContain("bear<script>");
@@ -430,6 +480,21 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 content.examUnknownWordsHitStatus());
     }
 
+    private AchievementReportContent withStudentName(AchievementReportContent content, String studentName) {
+        return new AchievementReportContent(
+                studentName,
+                content.reportTitle(),
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                content.vocabularyComparison(),
+                content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
+                content.examUnknownWordsHitStatus());
+    }
+
     private AchievementReportContent withVocabularyComparison(AchievementReportContent content,
                                                               AchievementReportContent.Comparison vocabularyComparison) {
         return new AchievementReportContent(
@@ -468,6 +533,22 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         hitWords));
     }
 
+    private AchievementReportContent withBusinessInfo(AchievementReportContent content, AchievementReportContent.BusinessInfo businessInfo) {
+        return new AchievementReportContent(
+                content.studentName(),
+                content.reportTitle(),
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                content.vocabularyComparison(),
+                content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
+                content.examUnknownWordsHitStatus(),
+                businessInfo);
+    }
+
     private AchievementReportContent withStageAndTestPaperSummaries(
             AchievementReportContent content,
             AchievementReportContent.StageVocabularySummary stageVocabularySummary,

+ 118 - 1
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -123,6 +123,72 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("低频词:30.0%(酌情学习)");
     }
 
+    /**
+     * 覆盖展望报告页头展示场景,应在预留 logo 区旁展示个人学情报告、学生姓名与生成时间。
+     */
+    @Test
+    void renderShowsStudentNameInReportHeader() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html)
+                .contains("class=\"report-header\"")
+                .contains("class=\"header-logo\"")
+                .contains("<img class=\"header-logo-image\" src=\"data:image/png;base64,")
+                .contains("class=\"header-report-type\">潜力展望报告</div>")
+                .contains("class=\"header-student-name\">20260318测试</div>")
+                .contains("class=\"header-generated-at\"")
+                .contains("class=\"header-generated-at\">2026-05-13 10:08:54</div>")
+                .doesNotContain("{{reportLogoDataUri}}")
+                .doesNotContain("{{studentName}}")
+                .doesNotContain("{{generatedAtText}}")
+                .doesNotContain("2026-05-13T02:08:54.657335Z");
+        assertCssRuleContains(html, ".header-logo", "width: 33.3333%;");
+        assertCssRuleContains(html, ".header-main", "width: 33.3333%;", "text-align: center;");
+        assertCssRuleContains(html, ".header-generated-at", "width: 33.3333%;", "text-align: right;");
+    }
+
+    @Test
+    void renderUsesCompactModuleOneCardLayout() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertCssRuleContains(html, ".analysis-table", "border-spacing: 0 10px;");
+        assertCssRuleContains(html, ".card", "padding: 14px;", "min-height: 275px;");
+        assertCssRuleContains(html, ".card-title", "margin: 0 0 6px;");
+        assertCssRuleContains(html, ".chart-box", "height: 160px;", "margin: 4px 0;");
+        assertCssRuleContains(html, ".data-text", "line-height: 1.45;", "margin: 2px 0;");
+    }
+
+    @Test
+    void renderShowsBusinessInfoForFooterWhenProvided() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
+        payload.set("BusinessInfo", OBJECT_MAPPER.createObjectNode()
+                .put("Name", "张三")
+                .put("Phone", "138987484")
+                .put("Address", "浙江省杭州市"));
+
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html)
+                .contains("class=\"report-footer-business\"")
+                .contains("张三")
+                .contains("Tel:138987484")
+                .contains("浙江省杭州市");
+    }
+
+    @Test
+    void renderOmitsBusinessInfoFooterWhenMissing() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-05-13T02:08:54.657335Z"));
+
+        assertThat(html).doesNotContain("class=\"report-footer-business\"");
+    }
+
     /**
      * 覆盖官方上游词汇 payload 中词频与掌握度组合变化时,应复用词频区间计算逻辑生成所有图表数值。
      */
@@ -417,7 +483,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         assertThat(html)
                 .contains("考前半个月·核心突击期")
                 .contains("考前半小时·临阵巩固期")
-                .doesNotContain("注入学生")
+                .contains("注入学生&lt;script&gt;alert(1)&lt;/script&gt;")
                 .doesNotContain("evil-word")
                 .doesNotContain("注入文章")
                 .doesNotContain("<script>alert(1)</script>")
@@ -661,6 +727,57 @@ class ClasspathOutlookExamSprintReportRendererTest {
         return gridLines;
     }
 
+    private void assertCssRuleContains(String html, String selector, String... declarations) {
+        String rule = extractCssRule(html, selector);
+        assertThat(rule)
+                .as("CSS rule for %s", selector)
+                .contains(declarations);
+    }
+
+    private String extractCssRule(String html, String selector) {
+        String css = extractCssText(html);
+        int searchFrom = 0;
+        while (searchFrom < css.length()) {
+            int selectorIndex = css.indexOf(selector, searchFrom);
+            if (selectorIndex < 0) {
+                break;
+            }
+
+            int braceStart = css.indexOf('{', selectorIndex + selector.length());
+            if (braceStart < 0) {
+                break;
+            }
+
+            boolean startsRule = selectorIndex == 0 || css.substring(0, selectorIndex).trim().endsWith("}");
+            boolean selectorMatchesExactly = css.substring(selectorIndex + selector.length(), braceStart).trim().isEmpty();
+            if (startsRule && selectorMatchesExactly) {
+                int braceEnd = css.indexOf('}', braceStart);
+                assertThat(braceEnd)
+                        .as("CSS rule for %s should close", selector)
+                        .isGreaterThan(braceStart);
+                return css.substring(selectorIndex, braceEnd + 1);
+            }
+
+            searchFrom = selectorIndex + selector.length();
+        }
+
+        throw new AssertionError("CSS rule should exist for selector '" + selector + "' in style block:\n" + css);
+    }
+
+    private String extractCssText(String html) {
+        int styleStart = html.indexOf("<style>");
+        assertThat(styleStart)
+                .as("HTML should contain style block")
+                .isGreaterThanOrEqualTo(0);
+
+        int cssStart = styleStart + "<style>".length();
+        int styleEnd = html.indexOf("</style>", cssStart);
+        assertThat(styleEnd)
+                .as("HTML should close style block")
+                .isGreaterThan(cssStart);
+        return html.substring(cssStart, styleEnd);
+    }
+
     private JsonNode payloadWithCallerControlledTextSamples() throws Exception {
         ObjectNode payload = (ObjectNode) callerVocabularyPayload();
         payload.put("StudentName", "注入学生<script>alert(1)</script>");

+ 2 - 2
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java

@@ -50,7 +50,7 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .containsPattern("\\.analysis-table\\s*\\{[^}]*width\\s*:\\s*100%\\s*;[^}]*table-layout\\s*:\\s*fixed\\s*;[^}]*}")
                 .containsPattern("\\.analysis-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
                 .containsPattern("\\.card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
-                .containsPattern("\\.card\\s*\\{[^}]*min-height\\s*:\\s*370px\\s*;[^}]*}")
+                .containsPattern("\\.card\\s*\\{[^}]*min-height\\s*:\\s*275px\\s*;[^}]*}")
                 .doesNotContainPattern("(?i)(^|[\\s{;])display\\s*:\\s*flex\\b")
                 .doesNotContainPattern("(?i)(^|[\\s{;])flex-direction\\s*:")
                 .containsPattern("\\.card-title\\s*\\{[^}]*font-family\\s*:\\s*'MiSans VF', MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*16px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
@@ -69,7 +69,7 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .containsPattern("\\.freq-star-cell\\s*\\{[^}]*width\\s*:\\s*28px\\s*;[^}]*text-align\\s*:\\s*right\\s*;[^}]*vertical-align\\s*:\\s*middle\\s*;[^}]*}")
                 .doesNotContainPattern("\\.crown\\s*\\{[^}]*position\\s*:\\s*absolute\\s*;")
                 .containsPattern("\\.crown\\s*\\{[^}]*position\\s*:\\s*relative\\s*;[^}]*top\\s*:\\s*-4px\\s*;[^}]*}")
-                .containsPattern("\\.data-text\\s*\\{[^}]*font-family\\s*:\\s*'MiSans VF', MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
+                .containsPattern("\\.data-text\\s*\\{[^}]*font-family\\s*:\\s*'MiSans VF', MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.45\\s*;[^}]*}")
                 .contains(".text-desc")
                 .containsPattern("\\.text-desc\\s*\\{[^}]*font-family\\s*:\\s*'MiSans VF', MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
                 .containsPattern("\\.freq-data\\s*\\{[^}]*font-family\\s*:\\s*'MiSans VF', MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")

+ 30 - 3
deploy/ability-center/docker-compose.yml

@@ -4,7 +4,7 @@ services:
     restart: always
 
     environment:
-      SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-test}
+      SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
       NODE_OPTIONS: --dns-result-order=ipv4first
       PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
       PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
@@ -15,9 +15,36 @@ services:
 
     volumes:
       - type: bind
-        source: /data/project/ability-center
-        target: /app
+        source: /data/project/ability-center/app/dcjxb-ability-center.jar
+        target: /app/dcjxb-ability-center.jar
+        read_only: true
         bind:
           create_host_path: false
 
+      - type: bind
+        source: /data/project/ability-center/config/application.yml
+        target: /app/config/application.yml
+        read_only: true
+        bind:
+          create_host_path: false
+
+      - type: bind
+        source: /data/project/ability-center/config/application-prod.yml
+        target: /app/config/application-prod.yml
+        read_only: true
+        bind:
+          create_host_path: false
+
+      - type: bind
+        source: /data/project/ability-center/logs
+        target: /app/logs
+        bind:
+          create_host_path: true
+
+      - type: bind
+        source: /data/project/ability-center/tmp
+        target: /app/tmp
+        bind:
+          create_host_path: true
+
     network_mode: host

+ 1 - 1
deploy/ability-center/runtime/dockerfile

@@ -20,4 +20,4 @@ RUN fc-cache -fv \
 EXPOSE 8500
 
 # 启动前检查挂载文件,缺失时快速失败
-CMD ["sh", "-c", "PROFILE=${SPRING_PROFILES_ACTIVE:-test}; case \"$PROFILE\" in *,*) echo \"Unsupported comma-separated SPRING_PROFILES_ACTIVE: $PROFILE\"; exit 1 ;; esac; test -f /app/dcjxb-ability-center.jar || { echo 'Missing /app/dcjxb-ability-center.jar'; exit 1; }; test -f /app/application.yml || { echo 'Missing /app/application.yml'; exit 1; }; test -f \"/app/application-${PROFILE}.yml\" || { echo \"Missing /app/application-${PROFILE}.yml\"; exit 1; }; exec java -Xms256m -Xmx512m -Djava.net.preferIPv4Stack=true -Dspring.profiles.active=$PROFILE -jar /app/dcjxb-ability-center.jar --spring.config.location=/app/application.yml,/app/application-${PROFILE}.yml"]
+CMD ["sh", "-c", "PROFILE=${SPRING_PROFILES_ACTIVE:-prod}; case \"$PROFILE\" in *,*) echo \"Unsupported comma-separated SPRING_PROFILES_ACTIVE: $PROFILE\"; exit 1 ;; esac; test -f /app/dcjxb-ability-center.jar || { echo 'Missing /app/dcjxb-ability-center.jar'; exit 1; }; test -f /app/config/application.yml || { echo 'Missing /app/config/application.yml'; exit 1; }; test -f \"/app/config/application-${PROFILE}.yml\" || { echo \"Missing /app/config/application-${PROFILE}.yml\"; exit 1; }; exec java -Xms256m -Xmx512m -Djava.net.preferIPv4Stack=true -Dspring.profiles.active=$PROFILE -jar /app/dcjxb-ability-center.jar --spring.config.location=/app/config/application.yml,/app/config/application-${PROFILE}.yml"]

+ 126 - 0
docs/superpowers/plans/2026-05-13-report-business-footer.md

@@ -0,0 +1,126 @@
+# Report Business Footer Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add optional `BusinessInfo` support to both exam sprint reports, show concrete report type text in repeated headers, and render business contact info on the left side of repeated footers with page numbers on the right.
+
+**Architecture:** Extend report payload contracts with a shared optional `BusinessInfo` record. Renderers own business-data formatting by emitting `.report-footer-business` markup, while `DefaultPlaywrightPdfWorker` extracts that markup into Chromium's native footer template next to the page-number placeholders. Existing native page header/footer margin handling remains unchanged.
+
+**Tech Stack:** Java records, Jackson JSON binding, Bean Validation annotations, HTML templates, Playwright Java PDF header/footer templates, JUnit 5, AssertJ, PDFBox.
+
+---
+
+## File Map
+
+- Create `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/BusinessInfo.java`: shared optional payload object.
+- Modify `AchievementExamSprintReportPayload.java`: add `BusinessInfo businessInfo` field.
+- Modify `OutlookExamSprintReportPayload.java`: add `BusinessInfo businessInfo` field.
+- Modify `AchievementReportContent.java`: add nested optional domain `BusinessInfo` and compact constructor normalization.
+- Modify `AchievementReportContentMapper.java`: map payload business info to domain content.
+- Modify achievement/outlook HTML templates: replace fixed header type placeholder and add optional footer business placeholder.
+- Modify achievement/outlook renderers: provide report type placeholder and footer business markup.
+- Modify `DefaultPlaywrightPdfWorker.java`: extract optional footer business block and place it left of right-aligned page numbers.
+- Modify tests under application and infrastructure to cover new behavior.
+
+## Task 1: Contract and Domain BusinessInfo Flow
+
+**Files:**
+- Create: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/BusinessInfo.java`
+- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java`
+- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.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`
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
+
+- [ ] **Step 1: Write failing mapper test**
+
+Add a test that parses an achievement payload with `BusinessInfo`, maps it, and asserts `businessInfo().name()`, `phone()`, and `address()` are populated.
+
+- [ ] **Step 2: Run mapper test to verify RED**
+
+Run: `mvn -pl abilities/exam-sprint/application -Dtest=AchievementReportContentMapperTest test`
+Expected: compilation failure or assertion failure because `BusinessInfo` is not modeled yet.
+
+- [ ] **Step 3: Add contracts and domain fields**
+
+Create `BusinessInfo` contract record and add optional fields to both payload records. Add `AchievementReportContent.BusinessInfo` and a nullable `businessInfo` component.
+
+- [ ] **Step 4: Map achievement business info**
+
+Update `AchievementReportContentMapper.toDomainContent` to convert non-null payload business info to domain business info.
+
+- [ ] **Step 5: Run mapper test to verify GREEN**
+
+Run: `mvn -pl abilities/exam-sprint/application -Dtest=AchievementReportContentMapperTest test`
+Expected: PASS.
+
+## Task 2: Renderer Header Type and Footer Business Markup
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+- Test: achievement/outlook renderer tests.
+
+- [ ] **Step 1: Write failing renderer tests**
+
+Assert achievement header contains `学习成果报告`; outlook header contains `潜力展望报告`; business info renders in `.report-footer-business` when present and does not render when missing.
+
+- [ ] **Step 2: Run renderer tests to verify RED**
+
+Run: `mvn -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathAchievementExamSprintReportRendererTest,ClasspathOutlookExamSprintReportRendererTest test`
+Expected: FAIL because templates still contain fixed header text and no footer business markup.
+
+- [ ] **Step 3: Implement renderer placeholders**
+
+Replace fixed header report type text with `{{headerReportType}}`. Add `{{reportFooterBusiness}}` before `</body>`. Render escaped optional business markup with first line name/phone and second line address.
+
+- [ ] **Step 4: Run renderer tests to verify GREEN**
+
+Run: `mvn -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathAchievementExamSprintReportRendererTest,ClasspathOutlookExamSprintReportRendererTest test`
+Expected: PASS.
+
+## Task 3: PDF Footer Layout Extraction
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
+
+- [ ] **Step 1: Write failing PDF footer test**
+
+Generate multi-page HTML containing `.report-footer-business`, then use PDFBox text positions to assert business text is on the left half of the page and `2 / total` is on the right half.
+
+- [ ] **Step 2: Run PDF footer test to verify RED**
+
+Run: `mvn -pl abilities/exam-sprint/infrastructure -Dtest=PlaywrightExamSprintReportPdfGeneratorTest#generatePlacesBusinessInfoLeftAndPageNumbersRightInFooter test`
+Expected: FAIL because footer template currently only renders centered page numbers.
+
+- [ ] **Step 3: Implement footer extraction**
+
+Add a `.report-footer-business` regex extractor, remove that source block from print body HTML, and build a footer template with left business block and right page-number block.
+
+- [ ] **Step 4: Run PDF footer test to verify GREEN**
+
+Run: `mvn -pl abilities/exam-sprint/infrastructure -Dtest=PlaywrightExamSprintReportPdfGeneratorTest#generatePlacesBusinessInfoLeftAndPageNumbersRightInFooter test`
+Expected: PASS.
+
+## Task 4: Full Verification
+
+**Files:**
+- No additional production files.
+
+- [ ] **Step 1: Run contract/application relevant tests**
+
+Run: `mvn -pl abilities/exam-sprint/application test`
+Expected: PASS.
+
+- [ ] **Step 2: Run infrastructure tests**
+
+Run: `mvn -pl abilities/exam-sprint/infrastructure test`
+Expected: PASS.
+
+- [ ] **Step 3: Review diff**
+
+Run: `git diff -- abilities/exam-sprint docs/superpowers/specs/2026-05-13-report-business-footer-design.md docs/superpowers/plans/2026-05-13-report-business-footer.md`
+Expected: only intended contract, mapper, renderer, template, PDF worker, test, and documentation changes.

+ 46 - 0
docs/superpowers/plans/2026-05-13-report-header-center-outlook-compact.md

@@ -0,0 +1,46 @@
+# Report Header Center And Outlook Compact Layout Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Center the repeated report header middle column visually and make outlook report module one compact enough for the four analysis cards to fit on the first page.
+
+**Architecture:** Keep report templates as the source of visual CSS and mirror critical header layout overrides inside `DefaultPlaywrightPdfWorker` so Chromium's native header template matches HTML previews. Tighten only outlook module-one card spacing and chart dimensions; do not change report data or achievement report body layout.
+
+**Tech Stack:** HTML/CSS templates, Java renderer tests, Playwright Java PDF generation, PDFBox text extraction, JUnit 5, AssertJ.
+
+---
+
+## File Map
+
+- Modify `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`: header column widths.
+- Modify `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`: header column widths and compact module-one card CSS.
+- Modify `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java`: native header template column-width override.
+- Modify `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`: header CSS assertions.
+- Modify `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`: header and compact card CSS assertions.
+- Modify `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`: PDF first-page module-one assertion.
+
+## Task 1: Header Centering CSS
+
+- [ ] Write failing renderer assertions that `.header-logo`, `.header-main`, and `.header-generated-at` all use `width: 33.3333%` in both achievement and outlook templates.
+- [ ] Run renderer tests and verify failure.
+- [ ] Update both templates and PDF header override to force equal-width columns.
+- [ ] Run renderer tests and verify pass.
+
+## Task 2: Outlook Module-One Compact Cards
+
+- [ ] Write failing outlook renderer assertions for compact CSS: `.analysis-table` row spacing `10px`, `.card` padding `14px`, `.card` min-height `275px`, `.chart-box` height `160px`, and tighter `.data-text` line-height.
+- [ ] Run outlook renderer test and verify failure.
+- [ ] Update outlook template CSS only.
+- [ ] Run outlook renderer test and verify pass.
+
+## Task 3: PDF First-Page Coverage
+
+- [ ] Write failing PDF test that renders a complex outlook report and asserts page 1 text contains all four module-one card titles: `考纲词汇掌握情况`, `真题试卷词汇掌握情况`, `常考词汇掌握情况`, `词频区间掌握度`.
+- [ ] Run the PDF test and verify failure before compact CSS.
+- [ ] After Task 2 implementation, run the PDF test and verify pass.
+
+## Task 4: Verification
+
+- [ ] Run `mvn -pl abilities/exam-sprint/infrastructure -am test`.
+- [ ] Confirm `BUILD SUCCESS` and zero failures.
+- [ ] Review diff for only intended layout, tests, and plan changes.

+ 44 - 0
docs/superpowers/plans/2026-05-13-report-logo-header.md

@@ -0,0 +1,44 @@
+# Report Logo Header Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Render the uploaded `report-logo.png` in the shared page header for both exam sprint PDF/HTML reports.
+
+**Architecture:** Keep the PNG under classpath resources and have each renderer embed it as a Base64 data URI. Templates receive a `{{reportLogoDataUri}}` placeholder so Playwright PDF generation does not depend on relative file URL resolution.
+
+**Tech Stack:** Java 17, Spring `ClassPathResource`, static HTML templates, JUnit 5, AssertJ.
+
+---
+
+## Files
+
+- Existing asset: `abilities/exam-sprint/infrastructure/src/main/resources/report-assets/report-logo.png`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+
+## Task 1: Write failing tests
+
+- [ ] Add assertions to both `renderShowsStudentNameInReportHeader` tests that rendered HTML contains `<img class="header-logo-image" src="data:image/png;base64,` and does not contain `{{reportLogoDataUri}}`.
+- [ ] Run `mvn -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathOutlookExamSprintReportRendererTest,ClasspathAchievementExamSprintReportRendererTest test` and verify RED because templates/renderers do not inject the logo yet.
+
+## Task 2: Implement logo embedding
+
+- [ ] Update both templates: replace empty `.header-logo` content with `<img class="header-logo-image" src="{{reportLogoDataUri}}" alt="IATSE"/>`.
+- [ ] Add `.header-logo-image` CSS with `display: block; max-width: 120px; max-height: 42px;` to both templates.
+- [ ] In both renderers, load `report-assets/report-logo.png` via `ClassPathResource`, convert bytes using `Base64.getEncoder().encodeToString(...)`, prefix `data:image/png;base64,`, and replace/fill `reportLogoDataUri`.
+- [ ] Run the focused renderer tests and verify GREEN.
+
+## Task 3: Verify module
+
+- [ ] Run `mvn -pl abilities/exam-sprint/infrastructure test`.
+- [ ] Inspect relevant diff and confirm only report header/logo changes, tests, the uploaded asset, and plan docs are included.
+
+## Self-review
+
+- Spec coverage: both reports render the uploaded PNG logo in the left header slot.
+- Placeholder scan: no unresolved implementation placeholders in this plan.
+- Type consistency: all resource names and placeholder names match the existing renderer/template style.

+ 78 - 0
docs/superpowers/plans/2026-05-13-report-playwright-page-header-footer.md

@@ -0,0 +1,78 @@
+# Report Playwright Page Header Footer Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make both exam sprint PDF reports render the report header on every page, format generated time as Beijing local time, and show `current page / total pages` in the footer.
+
+**Architecture:** Keep the existing report HTML templates as the source of header content, but teach the Playwright PDF worker to extract that `.report-header` markup and pass it to Chromium's native `displayHeaderFooter` templates. Format `Instant generatedAt` in each renderer with `Asia/Shanghai` and `yyyy-MM-dd HH:mm:ss`.
+
+**Tech Stack:** Java, Playwright Java, Chromium PDF, JUnit 5, AssertJ, PDFBox.
+
+---
+
+### Task 1: Renderer Generated Time Format
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+
+- [ ] **Step 1: Write failing renderer tests**
+
+Add assertions that rendered HTML contains `2026-05-13 10:08:54` and does not contain `2026-05-13T02:08:54.657335Z` for both report renderers.
+
+- [ ] **Step 2: Run renderer tests to verify RED**
+
+Run: `./mvnw -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathAchievementExamSprintReportRendererTest,ClasspathOutlookExamSprintReportRendererTest test`
+
+Expected: FAIL because current renderer uses `Instant.toString()`.
+
+- [ ] **Step 3: Implement Beijing time formatter**
+
+Use `DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"))` in both renderers.
+
+- [ ] **Step 4: Run renderer tests to verify GREEN**
+
+Run the same Maven command. Expected: PASS.
+
+### Task 2: Playwright Native Header and Footer
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java`
+
+- [ ] **Step 1: Write failing PDF test**
+
+Add a multi-page HTML test with `.report-header`, generate PDF, and assert extracted text contains repeated header text and footer page text such as `1/2` and `2/2` after whitespace normalization.
+
+- [ ] **Step 2: Run PDF test to verify RED**
+
+Run: `./mvnw -pl abilities/exam-sprint/infrastructure -Dtest=PlaywrightExamSprintReportPdfGeneratorTest#generateRepeatsReportHeaderAndFooterPageNumbers test`
+
+Expected: FAIL because Playwright header/footer are not configured.
+
+- [ ] **Step 3: Implement PDF options**
+
+In both `page.pdf(...)` call sites, call a shared method returning `Page.PdfOptions` with `displayHeaderFooter=true`, a header template built from the source `.report-header`, a footer template with `<span class="pageNumber"></span> / <span class="totalPages"></span>`, and top/bottom margins that reserve space.
+
+- [ ] **Step 4: Run PDF test to verify GREEN**
+
+Run the same Maven single-test command. Expected: PASS.
+
+### Task 3: Full Relevant Verification
+
+**Files:**
+- No production files beyond Task 1 and Task 2.
+
+- [ ] **Step 1: Run all relevant infrastructure tests**
+
+Run: `./mvnw -pl abilities/exam-sprint/infrastructure test`
+
+Expected: PASS.
+
+- [ ] **Step 2: Review diff**
+
+Run: `git diff -- abilities/exam-sprint/infrastructure docs/superpowers/plans/2026-05-13-report-playwright-page-header-footer.md`
+
+Expected: only intended renderer, PDF worker, tests, and plan changes.

+ 206 - 0
docs/superpowers/plans/2026-05-13-report-student-header.md

@@ -0,0 +1,206 @@
+# Report Student Header Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a consistent PDF/HTML page header to both exam sprint reports that displays the student name from `StudentName`, with the logo area left empty for a future asset.
+
+**Architecture:** The report renderers already own final HTML generation. Add template placeholders for `studentName` and `generatedAtText`, fill them from the existing report content or payload contract, and keep escaping at the renderer boundary. The left logo slot is CSS-only empty space in both templates.
+
+**Tech Stack:** Java 17 records, Spring component renderers, static HTML templates, JUnit 5, AssertJ.
+
+---
+
+## Files
+
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+
+## Task 1: Add failing renderer tests for the shared page header
+
+- [ ] **Step 1: Add Outlook header assertions**
+
+In `ClasspathOutlookExamSprintReportRendererTest`, add a test that renders `callerVocabularyPayload()` and asserts:
+
+```java
+assertThat(html)
+        .contains("class=\"report-header\"")
+        .contains("class=\"header-logo\"")
+        .contains("个人学情报告")
+        .contains("class=\"header-student-name\">20260318测试</div>")
+        .contains("class=\"header-generated-at\"")
+        .doesNotContain("{{studentName}}")
+        .doesNotContain("{{generatedAtText}}");
+```
+
+- [ ] **Step 2: Add Achievement header assertions**
+
+In `ClasspathAchievementExamSprintReportRendererTest`, add a test that renders `sampleContent()` and asserts:
+
+```java
+assertThat(html)
+        .contains("class=\"report-header\"")
+        .contains("class=\"header-logo\"")
+        .contains("个人学情报告")
+        .contains("class=\"header-student-name\">测试临考</div>")
+        .contains("class=\"header-generated-at\"")
+        .doesNotContain("{{studentName}}")
+        .doesNotContain("{{generatedAtText}}");
+```
+
+- [ ] **Step 3: Add escaping coverage**
+
+Extend existing escaping tests so malicious student names are escaped. For achievement, create content with `studentName` set to `测试<script>alert(1)</script>` and assert escaped text appears. For outlook, mutate `StudentName` in the JsonNode to the same value and assert escaped text appears.
+
+- [ ] **Step 4: Run focused tests and verify RED**
+
+Run:
+
+```bash
+./mvnw -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathOutlookExamSprintReportRendererTest,ClasspathAchievementExamSprintReportRendererTest test
+```
+
+Expected: tests fail because `report-header`, `studentName`, and `generatedAtText` placeholders do not exist or are not replaced yet.
+
+## Task 2: Implement template placeholders and renderer replacements
+
+- [ ] **Step 1: Add shared header markup to both templates**
+
+Insert this block immediately after `<div class="report-container">` in both templates:
+
+```html
+<header class="report-header">
+    <div class="header-logo" aria-hidden="true"></div>
+    <div class="header-main">
+        <div class="header-report-type">个人学情报告</div>
+        <div class="header-student-name">{{studentName}}</div>
+    </div>
+    <div class="header-generated-at">{{generatedAtText}}</div>
+</header>
+```
+
+Add CSS before `</style>` in both templates:
+
+```css
+.report-header {
+    display: table;
+    width: 100%;
+    table-layout: fixed;
+    border-bottom: 3px solid #111;
+    margin-bottom: 28px;
+    padding-bottom: 10px;
+}
+
+.header-logo,
+.header-main,
+.header-generated-at {
+    display: table-cell;
+    vertical-align: top;
+}
+
+.header-logo {
+    width: 180px;
+}
+
+.header-main {
+    text-align: center;
+    color: #68768a;
+}
+
+.header-report-type {
+    font-size: 13px;
+    line-height: 1.5;
+}
+
+.header-student-name {
+    margin-top: 4px;
+    font-size: 13px;
+    line-height: 1.5;
+}
+
+.header-generated-at {
+    width: 260px;
+    color: #68768a;
+    font-size: 12px;
+    line-height: 1.5;
+    text-align: right;
+    white-space: nowrap;
+}
+```
+
+- [ ] **Step 2: Fill placeholders in Outlook renderer**
+
+In `ClasspathOutlookExamSprintReportRenderer.render`, add replacements for `{{studentName}}` and `{{generatedAtText}}` before returning the template string:
+
+```java
+.replace("{{studentName}}", escape(payloadContract.studentName()))
+.replace("{{generatedAtText}}", escape(formatGeneratedAt(generatedAt)))
+```
+
+Add a private formatter:
+
+```java
+private String formatGeneratedAt(Instant generatedAt) {
+    return generatedAt == null ? "" : generatedAt.toString();
+}
+```
+
+Reuse the existing `escape(String value)` method in this renderer.
+
+- [ ] **Step 3: Fill placeholders in Achievement renderer**
+
+In `ClasspathAchievementExamSprintReportRenderer.placeholders`, add:
+
+```java
+placeholders.put("studentName", escape(reportContent.studentName()));
+placeholders.put("generatedAtText", escape(formatGeneratedAt(generatedAt)));
+```
+
+Pass `generatedAt` into `placeholders(...)` from `render(...)`. Add a private formatter:
+
+```java
+private String formatGeneratedAt(Instant generatedAt) {
+    return generatedAt == null ? "" : generatedAt.toString();
+}
+```
+
+- [ ] **Step 4: Run focused tests and verify GREEN**
+
+Run:
+
+```bash
+./mvnw -pl abilities/exam-sprint/infrastructure -Dtest=ClasspathOutlookExamSprintReportRendererTest,ClasspathAchievementExamSprintReportRendererTest test
+```
+
+Expected: both renderer test classes pass.
+
+## Task 3: Final verification
+
+- [ ] **Step 1: Run module tests**
+
+Run:
+
+```bash
+./mvnw -pl abilities/exam-sprint/infrastructure test
+```
+
+Expected: infrastructure module tests pass.
+
+- [ ] **Step 2: Inspect diff**
+
+Run:
+
+```bash
+git diff -- abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
+```
+
+Expected: only the planned header, placeholder replacement, and tests changed.
+
+## Self-review
+
+- Spec coverage: both reports display `StudentName` in the page header; logo area is empty; generated time is present; escaping is tested.
+- Placeholder scan: no `TBD`, `TODO`, or unresolved implementation placeholders in this plan.
+- Type consistency: all referenced classes and paths exist in the current project; `generatedAt` is already part of renderer signatures.

+ 34 - 0
docs/superpowers/specs/2026-05-13-report-business-footer-design.md

@@ -0,0 +1,34 @@
+# Report Business Footer Design
+
+## Goal
+
+Update exam sprint PDF reports so the repeated page header shows the concrete report type, and the repeated page footer supports optional business contact information on the left with page numbers aligned right.
+
+## Requirements
+
+- Achievement report header type text: `学习成果报告`.
+- Outlook report header type text: `潜力展望报告`.
+- Both report payloads accept optional `BusinessInfo`:
+  - `Name`
+  - `Phone`
+  - `Address`
+- Footer layout:
+  - Left: business info block.
+  - Right: current page / total pages.
+- Business info text layout:
+  - First line: `Name    Tel:Phone` when both values exist.
+  - Second line: `Address` when it exists.
+- If `BusinessInfo` is missing or all fields are blank, the entire left business info block is not rendered.
+- If some fields are blank, render only available fields and avoid dangling labels such as `Tel:` without a phone number.
+
+## Architecture
+
+Keep the renderers responsible for report-specific text and business data. Each renderer writes the report type into the existing `.header-report-type` element and emits an optional `.report-footer-business` HTML block. `DefaultPlaywrightPdfWorker` remains transport/layout focused: it extracts `.report-header` into Chromium's native header template and extracts `.report-footer-business` into Chromium's native footer template beside the page-number placeholders.
+
+Achievement report content is already modeled, so `AchievementReportContent` gets an optional nested `BusinessInfo` value. Outlook report rendering already deserializes the payload contract directly, so the renderer reads `OutlookExamSprintReportPayload.businessInfo()`.
+
+## Validation
+
+- Contract/mapper tests prove optional `BusinessInfo` flows into domain/view rendering.
+- Renderer tests prove report type text and optional business footer markup render correctly.
+- PDF tests prove footer business info is left-side content and page numbers remain right-side content, while existing header/footer spacing tests continue to pass.