Просмотр исходного кода

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

金逸霄 6 дней назад
Родитель
Сommit
8f95a3214f
16 измененных файлов с 1627 добавлено и 121 удалено
  1. 44 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java
  2. 119 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorker.java
  3. 17 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorkerFactory.java
  4. 20 88
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java
  5. 9 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightPdfWorker.java
  6. 6 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightPdfWorkerFactory.java
  7. 306 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PooledPlaywrightExamSprintReportPdfGenerator.java
  8. 13 7
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  9. 11 5
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  10. 555 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PooledPlaywrightExamSprintReportPdfGeneratorTest.java
  11. 27 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  12. 37 6
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  13. 28 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java
  14. 5 0
      ability-center-runtime/src/main/resources/application.yml
  15. 48 15
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java
  16. 382 0
      docs/superpowers/specs/2026-05-08-exam-sprint-report-pdf-pool-design.md

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

@@ -9,6 +9,7 @@ public class ExamSprintReportProperties {
     private long cleanupIntervalMs = Duration.ofMinutes(10).toMillis();
     private long cleanupIntervalMs = Duration.ofMinutes(10).toMillis();
     private final Async async = new Async();
     private final Async async = new Async();
     private final Storage storage = new Storage();
     private final Storage storage = new Storage();
+    private final Pdf pdf = new Pdf();
 
 
     public Duration getRetention() {
     public Duration getRetention() {
         return retention;
         return retention;
@@ -42,6 +43,10 @@ public class ExamSprintReportProperties {
         return storage;
         return storage;
     }
     }
 
 
+    public Pdf getPdf() {
+        return pdf;
+    }
+
     public static class Async {
     public static class Async {
         private int corePoolSize = 2;
         private int corePoolSize = 2;
         private int maxPoolSize = 4;
         private int maxPoolSize = 4;
@@ -72,6 +77,45 @@ public class ExamSprintReportProperties {
         }
         }
     }
     }
 
 
+    public static class Pdf {
+        private int poolSize = 4;
+        private Duration borrowTimeout = Duration.ofSeconds(30);
+        private Duration launchTimeout = Duration.ofSeconds(30);
+        private Duration renderTimeout = Duration.ofSeconds(30);
+
+        public int getPoolSize() {
+            return poolSize;
+        }
+
+        public void setPoolSize(int poolSize) {
+            this.poolSize = poolSize;
+        }
+
+        public Duration getBorrowTimeout() {
+            return borrowTimeout;
+        }
+
+        public void setBorrowTimeout(Duration borrowTimeout) {
+            this.borrowTimeout = borrowTimeout;
+        }
+
+        public Duration getLaunchTimeout() {
+            return launchTimeout;
+        }
+
+        public void setLaunchTimeout(Duration launchTimeout) {
+            this.launchTimeout = launchTimeout;
+        }
+
+        public Duration getRenderTimeout() {
+            return renderTimeout;
+        }
+
+        public void setRenderTimeout(Duration renderTimeout) {
+            this.renderTimeout = renderTimeout;
+        }
+    }
+
     public static class Storage {
     public static class Storage {
         private String type = "memory";
         private String type = "memory";
         private String containerName = "exam-sprint-reports";
         private String containerName = "exam-sprint-reports";

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

@@ -0,0 +1,119 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.options.Margin;
+import com.microsoft.playwright.options.Media;
+import com.microsoft.playwright.options.WaitUntilState;
+
+import java.util.Objects;
+
+final class DefaultPlaywrightPdfWorker implements PlaywrightPdfWorker {
+
+    private final Playwright playwright;
+    private final Browser browser;
+    private final double renderTimeoutMillis;
+    private boolean closed;
+
+    DefaultPlaywrightPdfWorker(double launchTimeoutMillis, double renderTimeoutMillis) {
+        this.renderTimeoutMillis = renderTimeoutMillis;
+
+        Playwright initializedPlaywright = null;
+        Browser initializedBrowser = null;
+        try {
+            initializedPlaywright = Playwright.create();
+            initializedBrowser = initializedPlaywright.chromium().launch(createLaunchOptions(launchTimeoutMillis));
+        } catch (RuntimeException exception) {
+            closeQuietly(initializedBrowser);
+            closeQuietly(initializedPlaywright);
+            throw new IllegalStateException("Failed to initialize Playwright PDF generator", exception);
+        }
+
+        this.playwright = initializedPlaywright;
+        this.browser = initializedBrowser;
+    }
+
+    @Override
+    public synchronized byte[] generate(String htmlContent) {
+        Objects.requireNonNull(htmlContent, "htmlContent");
+        ensureOpen();
+
+        BrowserContext context = null;
+        try {
+            context = browser.newContext(new Browser.NewContextOptions().setLocale("zh-CN"));
+            context.setDefaultTimeout(renderTimeoutMillis);
+            context.setDefaultNavigationTimeout(renderTimeoutMillis);
+            Page page = context.newPage();
+            page.emulateMedia(new Page.EmulateMediaOptions().setMedia(Media.PRINT));
+            page.setContent(htmlContent, new Page.SetContentOptions()
+                    .setWaitUntil(WaitUntilState.LOAD)
+                    .setTimeout(renderTimeoutMillis));
+            page.evaluate("() => document.fonts ? document.fonts.ready.then(() => true) : true");
+            return page.pdf(new Page.PdfOptions()
+                    .setFormat("A4")
+                    .setPrintBackground(true)
+                    .setPreferCSSPageSize(true)
+                    .setMargin(new Margin()
+                            .setTop("0")
+                            .setRight("0")
+                            .setBottom("0")
+                            .setLeft("0")));
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to generate PDF", exception);
+        } finally {
+            closeQuietly(context);
+        }
+    }
+
+    @Override
+    public synchronized void close() {
+        if (closed) {
+            return;
+        }
+
+        RuntimeException closeFailure = null;
+        try {
+            browser.close();
+        } catch (RuntimeException exception) {
+            closeFailure = exception;
+        }
+        try {
+            playwright.close();
+        } catch (RuntimeException exception) {
+            if (closeFailure == null) {
+                closeFailure = exception;
+            } else {
+                closeFailure.addSuppressed(exception);
+            }
+        }
+        closed = true;
+        if (closeFailure != null) {
+            throw closeFailure;
+        }
+    }
+
+    static BrowserType.LaunchOptions createLaunchOptions(double launchTimeoutMillis) {
+        return new BrowserType.LaunchOptions()
+                .setHeadless(true)
+                .setTimeout(launchTimeoutMillis);
+    }
+
+    private void ensureOpen() {
+        if (closed) {
+            throw new IllegalStateException("PDF worker is closed");
+        }
+    }
+
+    private static void closeQuietly(AutoCloseable closeable) {
+        if (closeable == null) {
+            return;
+        }
+        try {
+            closeable.close();
+        } catch (Exception ignored) {
+        }
+    }
+}

+ 17 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/DefaultPlaywrightPdfWorkerFactory.java

@@ -0,0 +1,17 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+public final class DefaultPlaywrightPdfWorkerFactory implements PlaywrightPdfWorkerFactory {
+
+    private final double launchTimeoutMillis;
+    private final double renderTimeoutMillis;
+
+    public DefaultPlaywrightPdfWorkerFactory(double launchTimeoutMillis, double renderTimeoutMillis) {
+        this.launchTimeoutMillis = launchTimeoutMillis;
+        this.renderTimeoutMillis = renderTimeoutMillis;
+    }
+
+    @Override
+    public PlaywrightPdfWorker create() {
+        return new DefaultPlaywrightPdfWorker(launchTimeoutMillis, renderTimeoutMillis);
+    }
+}

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

@@ -1,30 +1,19 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 
 
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
-import com.microsoft.playwright.Browser;
-import com.microsoft.playwright.BrowserContext;
 import com.microsoft.playwright.BrowserType;
 import com.microsoft.playwright.BrowserType;
-import com.microsoft.playwright.Page;
-import com.microsoft.playwright.Playwright;
-import com.microsoft.playwright.options.Margin;
-import com.microsoft.playwright.options.Media;
-import com.microsoft.playwright.options.WaitUntilState;
 import org.springframework.beans.factory.DisposableBean;
 import org.springframework.beans.factory.DisposableBean;
-import org.springframework.stereotype.Component;
 
 
 import java.util.Objects;
 import java.util.Objects;
 
 
-@Component
 public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator, DisposableBean, AutoCloseable {
 public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator, DisposableBean, AutoCloseable {
 
 
     private static final double DEFAULT_LAUNCH_TIMEOUT_MILLIS = 30_000;
     private static final double DEFAULT_LAUNCH_TIMEOUT_MILLIS = 30_000;
     private static final double DEFAULT_RENDER_TIMEOUT_MILLIS = 30_000;
     private static final double DEFAULT_RENDER_TIMEOUT_MILLIS = 30_000;
 
 
-    private final Object playwrightLock = new Object();
-    private final double launchTimeoutMillis;
-    private final double renderTimeoutMillis;
-    private Playwright playwright;
-    private Browser browser;
+    private final Object workerLock = new Object();
+    private final PlaywrightPdfWorkerFactory workerFactory;
+    private PlaywrightPdfWorker worker;
     private boolean closed;
     private boolean closed;
 
 
     public PlaywrightExamSprintReportPdfGenerator() {
     public PlaywrightExamSprintReportPdfGenerator() {
@@ -32,72 +21,39 @@ public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportP
     }
     }
 
 
     PlaywrightExamSprintReportPdfGenerator(double launchTimeoutMillis, double renderTimeoutMillis) {
     PlaywrightExamSprintReportPdfGenerator(double launchTimeoutMillis, double renderTimeoutMillis) {
-        this.launchTimeoutMillis = launchTimeoutMillis;
-        this.renderTimeoutMillis = renderTimeoutMillis;
+        this(new DefaultPlaywrightPdfWorkerFactory(launchTimeoutMillis, renderTimeoutMillis));
+    }
+
+    PlaywrightExamSprintReportPdfGenerator(PlaywrightPdfWorkerFactory workerFactory) {
+        this.workerFactory = Objects.requireNonNull(workerFactory, "workerFactory");
     }
     }
 
 
     @Override
     @Override
     public byte[] generate(String htmlContent) {
     public byte[] generate(String htmlContent) {
         Objects.requireNonNull(htmlContent, "htmlContent");
         Objects.requireNonNull(htmlContent, "htmlContent");
 
 
-        // Playwright Java objects are not safe for unsynchronized concurrent use; keep the first
-        // Playwright implementation intentionally single-worker until throughput requirements justify a pool.
-        synchronized (playwrightLock) {
+        // Playwright Java objects are not safe for unsynchronized concurrent use; keep this adapter
+        // intentionally single-worker until throughput requirements justify a pool.
+        synchronized (workerLock) {
             ensureOpen();
             ensureOpen();
-            initializeBrowserIfNecessary();
-            BrowserContext context = null;
-            try {
-                context = browser.newContext(new Browser.NewContextOptions().setLocale("zh-CN"));
-                context.setDefaultTimeout(renderTimeoutMillis);
-                context.setDefaultNavigationTimeout(renderTimeoutMillis);
-                Page page = context.newPage();
-                page.emulateMedia(new Page.EmulateMediaOptions().setMedia(Media.PRINT));
-                page.setContent(htmlContent, new Page.SetContentOptions()
-                        .setWaitUntil(WaitUntilState.LOAD)
-                        .setTimeout(renderTimeoutMillis));
-                page.evaluate("() => document.fonts ? document.fonts.ready.then(() => true) : true");
-                return page.pdf(new Page.PdfOptions()
-                        .setFormat("A4")
-                        .setPrintBackground(true)
-                        .setPreferCSSPageSize(true)
-                        .setMargin(new Margin()
-                                .setTop("0")
-                                .setRight("0")
-                                .setBottom("0")
-                                .setLeft("0")));
-            } catch (Exception exception) {
-                throw new IllegalStateException("Failed to generate PDF", exception);
-            } finally {
-                closeQuietly(context);
-            }
+            return worker().generate(htmlContent);
         }
         }
     }
     }
 
 
     @Override
     @Override
     public void destroy() {
     public void destroy() {
-        synchronized (playwrightLock) {
+        synchronized (workerLock) {
             if (closed) {
             if (closed) {
                 return;
                 return;
             }
             }
             RuntimeException closeFailure = null;
             RuntimeException closeFailure = null;
             try {
             try {
-                if (browser != null) {
-                    browser.close();
+                if (worker != null) {
+                    worker.close();
                 }
                 }
             } catch (RuntimeException exception) {
             } catch (RuntimeException exception) {
                 closeFailure = exception;
                 closeFailure = exception;
             }
             }
-            try {
-                if (playwright != null) {
-                    playwright.close();
-                }
-            } catch (RuntimeException exception) {
-                if (closeFailure == null) {
-                    closeFailure = exception;
-                } else {
-                    closeFailure.addSuppressed(exception);
-                }
-            }
             closed = true;
             closed = true;
             if (closeFailure != null) {
             if (closeFailure != null) {
                 throw closeFailure;
                 throw closeFailure;
@@ -116,38 +72,14 @@ public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportP
         }
         }
     }
     }
 
 
-    private void initializeBrowserIfNecessary() {
-        if (browser != null) {
-            return;
-        }
-
-        Playwright initializedPlaywright = null;
-        Browser initializedBrowser = null;
-        try {
-            initializedPlaywright = Playwright.create();
-            initializedBrowser = initializedPlaywright.chromium().launch(createLaunchOptions(launchTimeoutMillis));
-            playwright = initializedPlaywright;
-            browser = initializedBrowser;
-        } catch (RuntimeException exception) {
-            closeQuietly(initializedBrowser);
-            closeQuietly(initializedPlaywright);
-            throw new IllegalStateException("Failed to initialize Playwright PDF generator", exception);
+    private PlaywrightPdfWorker worker() {
+        if (worker == null) {
+            worker = Objects.requireNonNull(workerFactory.create(), "worker");
         }
         }
+        return worker;
     }
     }
 
 
     static BrowserType.LaunchOptions createLaunchOptions(double launchTimeoutMillis) {
     static BrowserType.LaunchOptions createLaunchOptions(double launchTimeoutMillis) {
-        return new BrowserType.LaunchOptions()
-                .setHeadless(true)
-                .setTimeout(launchTimeoutMillis);
-    }
-
-    private static void closeQuietly(AutoCloseable closeable) {
-        if (closeable == null) {
-            return;
-        }
-        try {
-            closeable.close();
-        } catch (Exception ignored) {
-        }
+        return DefaultPlaywrightPdfWorker.createLaunchOptions(launchTimeoutMillis);
     }
     }
 }
 }

+ 9 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightPdfWorker.java

@@ -0,0 +1,9 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+public interface PlaywrightPdfWorker extends AutoCloseable {
+
+    byte[] generate(String htmlContent);
+
+    @Override
+    void close();
+}

+ 6 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightPdfWorkerFactory.java

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+public interface PlaywrightPdfWorkerFactory {
+
+    PlaywrightPdfWorker create();
+}

+ 306 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PooledPlaywrightExamSprintReportPdfGenerator.java

@@ -0,0 +1,306 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.DisposableBean;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class PooledPlaywrightExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator, DisposableBean, AutoCloseable {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
+
+    private final int poolSize;
+    private final ArrayBlockingQueue<PlaywrightPdfWorker> pool;
+    private final Set<PlaywrightPdfWorker> workers = ConcurrentHashMap.newKeySet();
+    private final PlaywrightPdfWorkerFactory workerFactory;
+    private final Duration borrowTimeout;
+    private final AtomicBoolean closed = new AtomicBoolean(false);
+
+    public PooledPlaywrightExamSprintReportPdfGenerator(int poolSize,
+                                                        Duration borrowTimeout,
+                                                        PlaywrightPdfWorkerFactory workerFactory) {
+        if (poolSize < 1) {
+            throw new IllegalArgumentException("poolSize must be at least 1");
+        }
+        this.poolSize = poolSize;
+        this.borrowTimeout = validateBorrowTimeout(borrowTimeout);
+        this.workerFactory = Objects.requireNonNull(workerFactory, "workerFactory");
+        this.pool = new ArrayBlockingQueue<>(poolSize);
+
+        List<PlaywrightPdfWorker> createdWorkers = new ArrayList<>(poolSize);
+        try {
+            for (int i = 0; i < poolSize; i++) {
+                PlaywrightPdfWorker worker = Objects.requireNonNull(this.workerFactory.create(), "worker");
+                workers.add(worker);
+                pool.add(worker);
+                createdWorkers.add(worker);
+            }
+            PoolState state = poolState();
+            LOGGER.info(
+                    "exam_sprint_report_pdf_pool_initialized poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} borrowTimeoutMs={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers(),
+                    this.borrowTimeout.toMillis());
+        } catch (RuntimeException | Error failure) {
+            closeWorkers(createdWorkers);
+            throw failure;
+        }
+    }
+
+    @Override
+    public byte[] generate(String htmlContent) {
+        Objects.requireNonNull(htmlContent, "htmlContent");
+        ensureOpen();
+
+        PlaywrightPdfWorker worker = borrowWorker();
+        boolean success = false;
+        long workerStartedNanos = System.nanoTime();
+        try {
+            byte[] pdfBytes = worker.generate(htmlContent);
+            success = true;
+            PoolState state = poolState();
+            LOGGER.info(
+                    "exam_sprint_report_pdf_worker_generation_completed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} workerRenderMs={} pdfByteLength={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers(),
+                    elapsedMillis(workerStartedNanos),
+                    pdfBytes.length);
+            return pdfBytes;
+        } catch (RuntimeException | Error failure) {
+            PoolState state = poolState();
+            LOGGER.warn(
+                    "exam_sprint_report_pdf_worker_generation_failed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} workerRenderMs={} exceptionType={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers(),
+                    elapsedMillis(workerStartedNanos),
+                    exceptionType(failure));
+            handleFailedWorker(worker, failure);
+            throw failure;
+        } finally {
+            if (success) {
+                returnWorker(worker);
+            }
+        }
+    }
+
+    @Override
+    public void destroy() {
+        close();
+    }
+
+    @Override
+    public void close() {
+        if (!closed.compareAndSet(false, true)) {
+            return;
+        }
+        List<PlaywrightPdfWorker> workersToClose = new ArrayList<>(workers);
+        pool.clear();
+        closeWorkers(workersToClose);
+        PoolState state = poolState();
+        LOGGER.info(
+                "exam_sprint_report_pdf_pool_closed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} closedWorkers={} remainingWorkers={}",
+                state.poolSize(),
+                state.availableWorkers(),
+                state.activeWorkers(),
+                state.managedWorkers(),
+                workersToClose.size(),
+                state.managedWorkers());
+    }
+
+    private PlaywrightPdfWorker borrowWorker() {
+        long borrowStartedNanos = System.nanoTime();
+        long timeoutNanos = borrowTimeout.toNanos();
+        long deadlineNanos = System.nanoTime() + timeoutNanos;
+        long pollIntervalNanos = TimeUnit.MILLISECONDS.toNanos(50);
+
+        try {
+            while (true) {
+                ensureOpen();
+                long remainingNanos = deadlineNanos - System.nanoTime();
+                if (remainingNanos <= 0) {
+                    long borrowWaitMs = elapsedMillis(borrowStartedNanos);
+                    PoolState state = poolState();
+                    LOGGER.warn(
+                            "exam_sprint_report_pdf_worker_borrow_timeout poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} borrowWaitMs={} borrowTimeoutMs={} exceptionType={}",
+                            state.poolSize(),
+                            state.availableWorkers(),
+                            state.activeWorkers(),
+                            state.managedWorkers(),
+                            borrowWaitMs,
+                            borrowTimeout.toMillis(),
+                            IllegalStateException.class.getSimpleName());
+                    throw new IllegalStateException("Timed out waiting for PDF worker after " + borrowTimeout.toMillis() + " ms");
+                }
+
+                PlaywrightPdfWorker worker = pool.poll(Math.min(remainingNanos, pollIntervalNanos), TimeUnit.NANOSECONDS);
+                if (worker != null) {
+                    ensureOpen();
+                    PoolState state = poolState();
+                    LOGGER.info(
+                            "exam_sprint_report_pdf_worker_borrowed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} borrowWaitMs={}",
+                            state.poolSize(),
+                            state.availableWorkers(),
+                            state.activeWorkers(),
+                            state.managedWorkers(),
+                            elapsedMillis(borrowStartedNanos));
+                    return worker;
+                }
+            }
+        } catch (InterruptedException exception) {
+            Thread.currentThread().interrupt();
+            if (closed.get()) {
+                throw new IllegalStateException("PDF generator pool is closed", exception);
+            }
+            throw new IllegalStateException("Timed out waiting for PDF worker after " + borrowTimeout.toMillis() + " ms", exception);
+        }
+    }
+
+    private void returnWorker(PlaywrightPdfWorker worker) {
+        if (closed.get()) {
+            closeManagedWorker(worker);
+            return;
+        }
+        if (!pool.offer(worker)) {
+            closeManagedWorker(worker);
+            PoolState state = poolState();
+            LOGGER.warn(
+                    "exam_sprint_report_pdf_worker_return_failed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} reason=pool_full",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers());
+        } else if (closed.get() && pool.remove(worker)) {
+            closeManagedWorker(worker);
+        } else {
+            PoolState state = poolState();
+            LOGGER.info(
+                    "exam_sprint_report_pdf_worker_returned poolSize={} availableWorkers={} activeWorkers={} managedWorkers={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers());
+        }
+    }
+
+    private void handleFailedWorker(PlaywrightPdfWorker failedWorker, Throwable generateFailure) {
+        closeManagedWorker(failedWorker);
+        if (closed.get()) {
+            return;
+        }
+
+        try {
+            PlaywrightPdfWorker replacementWorker = Objects.requireNonNull(workerFactory.create(), "worker");
+            workers.add(replacementWorker);
+            if (!closed.get()) {
+                if (!pool.offer(replacementWorker)) {
+                    closeManagedWorker(replacementWorker);
+                    PoolState state = poolState();
+                    LOGGER.warn(
+                            "exam_sprint_report_pdf_worker_replacement_failed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} exceptionType={} reason=pool_full",
+                            state.poolSize(),
+                            state.availableWorkers(),
+                            state.activeWorkers(),
+                            state.managedWorkers(),
+                            "None");
+                } else if (closed.get() && pool.remove(replacementWorker)) {
+                    closeManagedWorker(replacementWorker);
+                } else {
+                    PoolState state = poolState();
+                    LOGGER.info(
+                            "exam_sprint_report_pdf_worker_replaced poolSize={} availableWorkers={} activeWorkers={} managedWorkers={}",
+                            state.poolSize(),
+                            state.availableWorkers(),
+                            state.activeWorkers(),
+                            state.managedWorkers());
+                }
+            } else {
+                closeManagedWorker(replacementWorker);
+            }
+        } catch (RuntimeException | Error replacementFailure) {
+            PoolState state = poolState();
+            LOGGER.warn(
+                    "exam_sprint_report_pdf_worker_replacement_failed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} exceptionType={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers(),
+                    exceptionType(replacementFailure),
+                    replacementFailure);
+        }
+    }
+
+    private void ensureOpen() {
+        if (closed.get()) {
+            throw new IllegalStateException("PDF generator pool is closed");
+        }
+    }
+
+    private Duration validateBorrowTimeout(Duration borrowTimeout) {
+        Duration timeout = Objects.requireNonNull(borrowTimeout, "borrowTimeout");
+        if (timeout.isZero() || timeout.isNegative()) {
+            throw new IllegalArgumentException("borrowTimeout must be positive");
+        }
+        return timeout;
+    }
+
+    private void closeWorkers(List<PlaywrightPdfWorker> workers) {
+        for (PlaywrightPdfWorker worker : workers) {
+            closeManagedWorker(worker);
+        }
+    }
+
+    private void closeManagedWorker(PlaywrightPdfWorker worker) {
+        if (workers.remove(worker)) {
+            closeQuietly(worker);
+        }
+    }
+
+    private void closeQuietly(PlaywrightPdfWorker worker) {
+        try {
+            worker.close();
+        } catch (RuntimeException | Error exception) {
+            PoolState state = poolState();
+            LOGGER.warn(
+                    "exam_sprint_report_pdf_worker_close_failed poolSize={} availableWorkers={} activeWorkers={} managedWorkers={} exceptionType={}",
+                    state.poolSize(),
+                    state.availableWorkers(),
+                    state.activeWorkers(),
+                    state.managedWorkers(),
+                    exceptionType(exception),
+                    exception);
+        }
+    }
+
+    private long elapsedMillis(long startedNanos) {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
+    }
+
+    private String exceptionType(Throwable throwable) {
+        return throwable.getClass().getSimpleName();
+    }
+
+    private PoolState poolState() {
+        int availableWorkers = pool.size();
+        int managedWorkers = workers.size();
+        return new PoolState(poolSize, availableWorkers, managedWorkers, Math.max(0, managedWorkers - availableWorkers));
+    }
+
+    private record PoolState(int poolSize, int availableWorkers, int managedWorkers, int activeWorkers) {
+    }
+}

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

@@ -26,13 +26,17 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([A-Za-z0-9]+)}}");
     private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([A-Za-z0-9]+)}}");
 
 
     private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
     private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
+    private final String template;
 
 
     public ClasspathAchievementExamSprintReportRenderer() {
     public ClasspathAchievementExamSprintReportRenderer() {
-        this(new AchievementExamSprintReportSvgChartBuilder());
+        this(new AchievementExamSprintReportSvgChartBuilder(), loadTemplateFromClasspath(TEMPLATE_RESOURCE));
     }
     }
 
 
-    ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) {
+    ClasspathAchievementExamSprintReportRenderer(
+            AchievementExamSprintReportSvgChartBuilder chartBuilder,
+            String template) {
         this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
         this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
+        this.template = Objects.requireNonNull(template, "template");
     }
     }
 
 
     @Override
     @Override
@@ -53,9 +57,9 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
             AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary = reportContent.testPaperVocabularySummary();
             AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary = reportContent.testPaperVocabularySummary();
             AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();
             AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();
 
 
-            return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, stageVocabulary, testPaperVocabulary, hitStatus));
-        } catch (IOException exception) {
-            throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
+            return renderTemplate(
+                    template,
+                    placeholders(reportContent, summary, vocabulary, paperKnownWords, stageVocabulary, testPaperVocabulary, hitStatus));
         } catch (Exception exception) {
         } catch (Exception exception) {
             throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
             throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
         }
         }
@@ -123,9 +127,11 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         return normalized.isEmpty() || isTemplatePlaceholder(normalized) ? normalized : normalized + "词汇量";
         return normalized.isEmpty() || isTemplatePlaceholder(normalized) ? normalized : normalized + "词汇量";
     }
     }
 
 
-    private String loadTemplate() throws IOException {
-        try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
+    private static String loadTemplateFromClasspath(String templateResource) {
+        try (InputStream inputStream = new ClassPathResource(templateResource).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
         }
         }
     }
     }
 
 

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

@@ -34,9 +34,15 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     private static final int CHART_AXIS_HEIGHT = CHART_AXIS_BOTTOM - CHART_AXIS_TOP;
     private static final int CHART_AXIS_HEIGHT = CHART_AXIS_BOTTOM - CHART_AXIS_TOP;
 
 
     private final ObjectMapper objectMapper;
     private final ObjectMapper objectMapper;
+    private final String template;
 
 
     public ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper) {
     public ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper) {
+        this(objectMapper, loadTemplateFromClasspath(TEMPLATE_RESOURCE));
+    }
+
+    ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper, String template) {
         this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
         this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
+        this.template = Objects.requireNonNull(template, "template");
     }
     }
 
 
     @Override
     @Override
@@ -54,15 +60,13 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         try {
         try {
             OutlookExamSprintReportPayload payloadContract = objectMapper.treeToValue(payloadForDeserialization(payload), OutlookExamSprintReportPayload.class);
             OutlookExamSprintReportPayload payloadContract = objectMapper.treeToValue(payloadForDeserialization(payload), OutlookExamSprintReportPayload.class);
             OutlookReportViewModel reportPayload = adaptPayload(payloadContract);
             OutlookReportViewModel reportPayload = adaptPayload(payloadContract);
-            return loadTemplate()
+            return template
                     .replace("{{syllabusMasterySection}}", renderSyllabusMasteryChart(reportPayload.syllabusMasterySection()))
                     .replace("{{syllabusMasterySection}}", renderSyllabusMasteryChart(reportPayload.syllabusMasterySection()))
                     .replace("{{pastPaperVocabularySection}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularySection()))
                     .replace("{{pastPaperVocabularySection}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularySection()))
                     .replace("{{highFrequencyVocabularySection}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularySection()))
                     .replace("{{highFrequencyVocabularySection}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularySection()))
                     .replace("{{frequencyBandSection}}", renderVocabularyFrequencyBandChart(reportPayload.frequencyBandSection()))
                     .replace("{{frequencyBandSection}}", renderVocabularyFrequencyBandChart(reportPayload.frequencyBandSection()))
                     .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.studyPlan()))
                     .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.studyPlan()))
                     .replace("{{moduleThreeSection}}", renderModuleThreeSection(payloadContract.complex(), reportPayload.caseStudy()));
                     .replace("{{moduleThreeSection}}", renderModuleThreeSection(payloadContract.complex(), reportPayload.caseStudy()));
-        } catch (IOException exception) {
-            throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
         } catch (Exception exception) {
         } catch (Exception exception) {
             throw new IllegalStateException("Failed to render outlook exam sprint report", exception);
             throw new IllegalStateException("Failed to render outlook exam sprint report", exception);
         }
         }
@@ -247,9 +251,11 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             int scoreGain) {
             int scoreGain) {
     }
     }
 
 
-    private String loadTemplate() throws IOException {
-        try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
+    private static String loadTemplateFromClasspath(String templateResource) {
+        try (InputStream inputStream = new ClassPathResource(templateResource).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
         }
         }
     }
     }
 
 

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

@@ -0,0 +1,555 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PooledPlaywrightExamSprintReportPdfGeneratorTest {
+
+    @Test
+    void constructorCreatesPoolSizeWorkers() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(3, factory)) {
+            assertThat(factory.workers()).hasSize(3);
+            assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isZero());
+        }
+    }
+
+    @Test
+    void generateReturnsSuccessfulWorkerToPoolForReuse() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
+            assertThat(asString(generator.generate("first"))).isEqualTo("worker-1:first");
+            assertThat(asString(generator.generate("second"))).isEqualTo("worker-1:second");
+
+            assertThat(factory.workers()).hasSize(1);
+            assertThat(factory.worker(0).htmlContents()).containsExactly("first", "second");
+        }
+    }
+
+    @Test
+    void generateEmitsStructuredPoolLogsForSuccessPath() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(100), factory)) {
+            assertThat(asString(generator.generate("html"))).isEqualTo("worker-1:html");
+        } finally {
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_pool_initialized");
+            assertThat(message).contains("poolSize=1");
+            assertThat(message).contains("borrowTimeoutMs=100");
+        });
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_borrowed");
+            assertThat(message).contains("borrowWaitMs=");
+            assertThat(message).contains("availableWorkers=");
+            assertThat(message).contains("activeWorkers=");
+            assertThat(message).contains("managedWorkers=");
+        });
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_generation_completed");
+            assertThat(message).contains("workerRenderMs=");
+            assertThat(message).contains("pdfByteLength=");
+        });
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_returned");
+            assertThat(message).contains("availableWorkers=");
+        });
+        assertThat(messages).anySatisfy(message -> assertThat(message).contains("exam_sprint_report_pdf_pool_closed"));
+    }
+
+    @Test
+    void concurrentGenerateEntersAtMostPoolSizeWorkers() throws Exception {
+        CountDownLatch firstBatchEntered = new CountDownLatch(2);
+        CountDownLatch releaseWorkers = new CountDownLatch(1);
+        AtomicInteger activeWorkers = new AtomicInteger();
+        AtomicInteger maxActiveWorkers = new AtomicInteger();
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            int active = activeWorkers.incrementAndGet();
+            maxActiveWorkers.accumulateAndGet(active, Math::max);
+            firstBatchEntered.countDown();
+            await(releaseWorkers);
+            activeWorkers.decrementAndGet();
+            return bytes("worker-" + worker.id() + ":" + html);
+        }));
+        ExecutorService executor = Executors.newFixedThreadPool(4);
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, Duration.ofSeconds(2), factory)) {
+            List<Future<byte[]>> futures = new ArrayList<>();
+            for (int i = 0; i < 4; i++) {
+                String html = "html-" + i;
+                futures.add(executor.submit(() -> generator.generate(html)));
+            }
+
+            assertThat(firstBatchEntered.await(1, TimeUnit.SECONDS)).isTrue();
+            assertThat(maxActiveWorkers.get()).isLessThanOrEqualTo(2);
+
+            releaseWorkers.countDown();
+            for (Future<byte[]> future : futures) {
+                assertThat(asString(future.get(1, TimeUnit.SECONDS))).startsWith("worker-");
+            }
+            assertThat(maxActiveWorkers.get()).isLessThanOrEqualTo(2);
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void generateTimesOutWhenNoWorkerIsAvailable() throws Exception {
+        CountDownLatch workerEntered = new CountDownLatch(1);
+        CountDownLatch releaseWorker = new CountDownLatch(1);
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            workerEntered.countDown();
+            await(releaseWorker);
+            return bytes("done");
+        }));
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(50), factory)) {
+            Future<byte[]> borrowed = executor.submit(() -> generator.generate("held"));
+            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();
+
+            assertThatThrownBy(() -> generator.generate("waiting"))
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("Timed out waiting for PDF worker");
+
+            releaseWorker.countDown();
+            assertThat(asString(borrowed.get(1, TimeUnit.SECONDS))).isEqualTo("done");
+        } finally {
+            releaseWorker.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void generateTimeoutEmitsStructuredPoolLog() throws Exception {
+        CountDownLatch workerEntered = new CountDownLatch(1);
+        CountDownLatch releaseWorker = new CountDownLatch(1);
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            workerEntered.countDown();
+            await(releaseWorker);
+            return bytes("done");
+        }));
+        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofMillis(50), factory)) {
+            Future<byte[]> borrowed = executor.submit(() -> generator.generate("held"));
+            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();
+
+            assertThatThrownBy(() -> generator.generate("waiting"))
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("Timed out waiting for PDF worker");
+
+            releaseWorker.countDown();
+            assertThat(asString(borrowed.get(1, TimeUnit.SECONDS))).isEqualTo("done");
+        } finally {
+            releaseWorker.countDown();
+            executor.shutdownNow();
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_borrow_timeout");
+            assertThat(message).contains("poolSize=1");
+            assertThat(message).contains("borrowTimeoutMs=50");
+            assertThat(message).contains("borrowWaitMs=");
+            assertThat(message).contains("exceptionType=IllegalStateException");
+            assertThat(message).contains("availableWorkers=");
+            assertThat(message).contains("activeWorkers=");
+            assertThat(message).contains("managedWorkers=");
+        });
+    }
+
+    @Test
+    void generateFailureEmitsStructuredFailureAndReplacementLogs() {
+        RuntimeException generateFailure = new RuntimeException("boom");
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> {
+            if (worker.id() == 1) {
+                worker.generateAction(html -> {
+                    throw generateFailure;
+                });
+            }
+        });
+        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
+            assertThatThrownBy(() -> generator.generate("failing"))
+                    .isSameAs(generateFailure);
+        } finally {
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_generation_failed");
+            assertThat(message).contains("workerRenderMs=");
+            assertThat(message).contains("exceptionType=RuntimeException");
+            assertThat(message).contains("availableWorkers=");
+            assertThat(message).contains("activeWorkers=");
+            assertThat(message).contains("managedWorkers=");
+        });
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_replaced");
+            assertThat(message).contains("poolSize=1");
+            assertThat(message).contains("availableWorkers=");
+            assertThat(message).contains("activeWorkers=");
+            assertThat(message).contains("managedWorkers=");
+        });
+    }
+
+    @Test
+    void replacementFailureEmitsStructuredReplacementFailureLog() {
+        RuntimeException generateFailure = new RuntimeException("boom");
+        RuntimeException replacementFailure = new RuntimeException("replacement failed");
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            throw generateFailure;
+        }));
+        factory.failOnCreateNumber(2, replacementFailure);
+        Logger logger = (Logger) LoggerFactory.getLogger(PooledPlaywrightExamSprintReportPdfGenerator.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
+            assertThatThrownBy(() -> generator.generate("failing"))
+                    .isSameAs(generateFailure);
+        } finally {
+            logger.detachAppender(appender);
+            appender.stop();
+        }
+
+        List<String> messages = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .toList();
+
+        assertThat(messages).anySatisfy(message -> {
+            assertThat(message).contains("exam_sprint_report_pdf_worker_replacement_failed");
+            assertThat(message).contains("exceptionType=RuntimeException");
+            assertThat(message).contains("availableWorkers=");
+            assertThat(message).contains("activeWorkers=");
+            assertThat(message).contains("managedWorkers=");
+        });
+    }
+
+    @Test
+    void generateFailureClosesFailedWorkerCreatesReplacementAndDoesNotRetryCurrentRequest() {
+        RuntimeException generateFailure = new RuntimeException("boom");
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> {
+            if (worker.id() == 1) {
+                worker.generateAction(html -> {
+                    throw generateFailure;
+                });
+            }
+        });
+
+        try (PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, factory)) {
+            assertThatThrownBy(() -> generator.generate("failing"))
+                    .isSameAs(generateFailure);
+
+            assertThat(factory.workers()).hasSize(2);
+            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
+            assertThat(factory.worker(1).htmlContents()).isEmpty();
+
+            assertThat(asString(generator.generate("after-failure"))).isEqualTo("worker-2:after-failure");
+            assertThat(factory.worker(1).htmlContents()).containsExactly("after-failure");
+        }
+    }
+
+    @Test
+    void closeClosesIdleWorkersOnceAndRejectsGenerate() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+        PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, factory);
+
+        generator.close();
+        generator.close();
+
+        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
+        assertThatThrownBy(() -> generator.generate("html"))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("PDF generator pool is closed");
+    }
+
+    @Test
+    void closeClosesActiveWorkerWhileGenerateIsRunning() throws Exception {
+        CountDownLatch workerEntered = new CountDownLatch(1);
+        CountDownLatch releaseWorker = new CountDownLatch(1);
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            workerEntered.countDown();
+            await(releaseWorker);
+            return bytes("done");
+        }));
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+
+        try {
+            PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofSeconds(5), factory);
+            Future<byte[]> activeGenerate = executor.submit(() -> generator.generate("active"));
+            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();
+
+            generator.close();
+
+            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
+            releaseWorker.countDown();
+            assertThat(asString(activeGenerate.get(1, TimeUnit.SECONDS))).isEqualTo("done");
+            assertThat(factory.worker(0).closeCount()).isEqualTo(1);
+        } finally {
+            releaseWorker.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void waitingBorrowFailsPromptlyWithPoolClosedWhenCloseIsCalled() throws Exception {
+        CountDownLatch workerEntered = new CountDownLatch(1);
+        CountDownLatch releaseWorker = new CountDownLatch(1);
+        FakeWorkerFactory factory = new FakeWorkerFactory(worker -> worker.generateAction(html -> {
+            workerEntered.countDown();
+            await(releaseWorker);
+            return bytes("done");
+        }));
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        AtomicReference<Throwable> waitingFailure = new AtomicReference<>();
+        Thread waitingThread = null;
+
+        try {
+            PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(1, Duration.ofSeconds(5), factory);
+            Future<byte[]> activeGenerate = executor.submit(() -> generator.generate("active"));
+            assertThat(workerEntered.await(1, TimeUnit.SECONDS)).isTrue();
+            waitingThread = new Thread(() -> {
+                try {
+                    generator.generate("waiting");
+                } catch (Throwable failure) {
+                    waitingFailure.set(failure);
+                }
+            }, "pooled-pdf-waiting-borrow-test");
+            waitingThread.start();
+            awaitThreadState(waitingThread, Thread.State.TIMED_WAITING);
+
+            generator.close();
+
+            waitingThread.join(1_000);
+            assertThat(waitingThread.isAlive()).isFalse();
+            assertThat(waitingFailure.get())
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("PDF generator pool is closed");
+            releaseWorker.countDown();
+            assertThat(asString(activeGenerate.get(1, TimeUnit.SECONDS))).isEqualTo("done");
+        } finally {
+            if (waitingThread != null) {
+                waitingThread.interrupt();
+            }
+            releaseWorker.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void destroyClosesIdleWorkersOnceAndRejectsGenerate() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+        PooledPlaywrightExamSprintReportPdfGenerator generator = newGenerator(2, factory);
+
+        generator.destroy();
+        generator.destroy();
+
+        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
+        assertThatThrownBy(() -> generator.generate("html"))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("PDF generator pool is closed");
+    }
+
+    @Test
+    void constructorRejectsInvalidPoolSettings() {
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+
+        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(0, Duration.ofMillis(1), factory))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("poolSize");
+        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(1, Duration.ZERO, factory))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("borrowTimeout");
+        assertThatThrownBy(() -> new PooledPlaywrightExamSprintReportPdfGenerator(1, Duration.ofMillis(-1), factory))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("borrowTimeout");
+    }
+
+    @Test
+    void constructorClosesAlreadyCreatedWorkersWhenFactoryFails() {
+        RuntimeException createFailure = new RuntimeException("create failed");
+        FakeWorkerFactory factory = new FakeWorkerFactory();
+        factory.failOnCreateNumber(3, createFailure);
+
+        assertThatThrownBy(() -> newGenerator(3, factory))
+                .isSameAs(createFailure);
+        assertThat(factory.workers()).hasSize(2);
+        assertThat(factory.workers()).allSatisfy(worker -> assertThat(worker.closeCount()).isEqualTo(1));
+    }
+
+    private static PooledPlaywrightExamSprintReportPdfGenerator newGenerator(int poolSize,
+                                                                            FakeWorkerFactory factory) {
+        return newGenerator(poolSize, Duration.ofMillis(100), factory);
+    }
+
+    private static PooledPlaywrightExamSprintReportPdfGenerator newGenerator(int poolSize,
+                                                                            Duration borrowTimeout,
+                                                                            FakeWorkerFactory factory) {
+        return new PooledPlaywrightExamSprintReportPdfGenerator(poolSize, borrowTimeout, factory);
+    }
+
+    private static String asString(byte[] bytes) {
+        return new String(bytes, StandardCharsets.UTF_8);
+    }
+
+    private static byte[] bytes(String value) {
+        return value.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static void await(CountDownLatch latch) {
+        try {
+            assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue();
+        } catch (InterruptedException exception) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException(exception);
+        }
+    }
+
+    private static void awaitThreadState(Thread thread, Thread.State expectedState) throws InterruptedException {
+        long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
+        while (System.nanoTime() < deadlineNanos && thread.getState() != expectedState) {
+            Thread.sleep(10);
+        }
+        assertThat(thread.getState()).isEqualTo(expectedState);
+    }
+
+    private static final class FakeWorkerFactory implements PlaywrightPdfWorkerFactory {
+
+        private final List<FakeWorker> workers = Collections.synchronizedList(new ArrayList<>());
+        private final Consumer<FakeWorker> customizer;
+        private int failOnCreateNumber;
+        private RuntimeException createFailure;
+
+        private FakeWorkerFactory() {
+            this(worker -> {
+            });
+        }
+
+        private FakeWorkerFactory(Consumer<FakeWorker> customizer) {
+            this.customizer = customizer;
+        }
+
+        @Override
+        public synchronized PlaywrightPdfWorker create() {
+            int nextId = workers.size() + 1;
+            if (nextId == failOnCreateNumber) {
+                throw createFailure;
+            }
+            FakeWorker worker = new FakeWorker(nextId);
+            customizer.accept(worker);
+            workers.add(worker);
+            return worker;
+        }
+
+        private List<FakeWorker> workers() {
+            return workers;
+        }
+
+        private FakeWorker worker(int index) {
+            return workers.get(index);
+        }
+
+        private void failOnCreateNumber(int failOnCreateNumber, RuntimeException createFailure) {
+            this.failOnCreateNumber = failOnCreateNumber;
+            this.createFailure = createFailure;
+        }
+    }
+
+    private static final class FakeWorker implements PlaywrightPdfWorker {
+
+        private final int id;
+        private final List<String> htmlContents = Collections.synchronizedList(new ArrayList<>());
+        private final AtomicInteger closeCount = new AtomicInteger();
+        private Function<String, byte[]> generateAction;
+
+        private FakeWorker(int id) {
+            this.id = id;
+        }
+
+        @Override
+        public byte[] generate(String htmlContent) {
+            htmlContents.add(htmlContent);
+            if (generateAction != null) {
+                return generateAction.apply(htmlContent);
+            }
+            return bytes("worker-" + id + ":" + htmlContent);
+        }
+
+        @Override
+        public void close() {
+            closeCount.incrementAndGet();
+        }
+
+        private int id() {
+            return id;
+        }
+
+        private List<String> htmlContents() {
+            return htmlContents;
+        }
+
+        private int closeCount() {
+            return closeCount.get();
+        }
+
+        private void generateAction(Function<String, byte[]> generateAction) {
+            this.generateAction = generateAction;
+        }
+    }
+}

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

@@ -15,6 +15,33 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
 
     private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
     private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
 
 
+    @Test
+    void renderUsesTemplateProvidedAtConstructionTime() {
+        String template = """
+                <html>
+                <body>
+                <div data-test-marker="constructor-template">constructor-template-marker</div>
+                <h1>{{reportTitle}}</h1>
+                <p>{{studentName}}</p>
+                <section>{{vocabularyComparisonChart}}</section>
+                <section>{{paperKnownWordsComparisonChart}}</section>
+                <section>{{hitWords}}</section>
+                </body>
+                </html>
+                """;
+        ClasspathAchievementExamSprintReportRenderer renderer =
+                new ClasspathAchievementExamSprintReportRenderer(new AchievementExamSprintReportSvgChartBuilder(), template);
+
+        String html = renderer.render(sampleContent(), Instant.parse("2026-05-08T00:00:00Z"));
+
+        assertThat(html)
+                .contains("<html>")
+                .contains("constructor-template-marker")
+                .contains("高考英语临考突击学习成果报告")
+                .doesNotContain("{{reportTitle}}")
+                .doesNotContain("{{vocabularyComparisonChart}}");
+    }
+
     @Test
     @Test
     void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
     void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();

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

@@ -25,6 +25,39 @@ class ClasspathOutlookExamSprintReportRendererTest {
     private static final String SVG_CJK_FONT_FAMILY = "font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
     private static final String SVG_CJK_FONT_FAMILY = "font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
     private static final Pattern SVG_START_TAG_PATTERN = Pattern.compile("<svg\\b[^>]*>");
     private static final Pattern SVG_START_TAG_PATTERN = Pattern.compile("<svg\\b[^>]*>");
 
 
+    /**
+     * 覆盖构造时注入模板的场景,渲染应复用该模板并替换所有展望报告占位符。
+     */
+    @Test
+    void renderUsesTemplateProvidedAtConstructionTime() {
+        String template = """
+                <html>
+                <body>
+                <div data-test-marker="outlook-constructor-template">outlook-constructor-template-marker</div>
+                <section>{{syllabusMasterySection}}</section>
+                <section>{{pastPaperVocabularySection}}</section>
+                <section>{{highFrequencyVocabularySection}}</section>
+                <section>{{frequencyBandSection}}</section>
+                <section>{{studySuggestionSection}}</section>
+                <section>{{moduleThreeSection}}</section>
+                </body>
+                </html>
+                """;
+        ClasspathOutlookExamSprintReportRenderer renderer =
+                new ClasspathOutlookExamSprintReportRenderer(new ObjectMapper(), template);
+
+        String html = renderer.render(
+                unmodeledOutlookContent(callerVocabularyPayloadWithComplex(true)),
+                Instant.parse("2026-05-08T00:00:00Z"));
+
+        assertThat(html)
+                .contains("<html>")
+                .contains("outlook-constructor-template-marker")
+                .contains("考纲词汇掌握情况")
+                .doesNotContain("{{syllabusMasterySection}}")
+                .doesNotContain("{{moduleThreeSection}}");
+    }
+
     /**
     /**
      * 覆盖官方上游词汇 payload 触发完整展望报告渲染时,应保留设计稿动态结构并输出由词汇数据计算出的核心数值。
      * 覆盖官方上游词汇 payload 触发完整展望报告渲染时,应保留设计稿动态结构并输出由词汇数据计算出的核心数值。
      */
      */
@@ -309,23 +342,21 @@ class ClasspathOutlookExamSprintReportRendererTest {
     }
     }
 
 
     /**
     /**
-     * 覆盖渲染官方上游词汇 payload 并加载 classpath 模板时,模板输入流应在渲染结束后关闭。
+     * 覆盖构造 renderer 并加载 classpath 模板时,模板输入流应在构造期加载结束后关闭。
      */
      */
     @Test
     @Test
-    void renderClosesTemplateInputStreamAfterLoadingClasspathTemplate() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+    void constructorClosesTemplateInputStreamAfterLoadingClasspathTemplate() throws Exception {
         ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
         ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
         TrackingTemplateClassLoader trackingClassLoader = new TrackingTemplateClassLoader(originalClassLoader);
         TrackingTemplateClassLoader trackingClassLoader = new TrackingTemplateClassLoader(originalClassLoader);
 
 
         try {
         try {
             Thread.currentThread().setContextClassLoader(trackingClassLoader);
             Thread.currentThread().setContextClassLoader(trackingClassLoader);
+            new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
 
-            renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
+            assertThat(trackingClassLoader.inputStream.closed).isTrue();
         } finally {
         } finally {
             Thread.currentThread().setContextClassLoader(originalClassLoader);
             Thread.currentThread().setContextClassLoader(originalClassLoader);
         }
         }
-
-        assertThat(trackingClassLoader.inputStream.closed).isTrue();
     }
     }
 
 
     /**
     /**

+ 28 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java

@@ -4,8 +4,12 @@ import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintRepor
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportWarmupRunner;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportWarmupRunner;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.DefaultPlaywrightPdfWorkerFactory;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightPdfWorkerFactory;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PooledPlaywrightExamSprintReportPdfGenerator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Bean;
@@ -43,6 +47,10 @@ public class ExamSprintReportRuntimeConfiguration {
         properties.getStorage().setAccountName(bound.getStorage().getAccountName());
         properties.getStorage().setAccountName(bound.getStorage().getAccountName());
         properties.getStorage().setAccountKey(bound.getStorage().getAccountKey());
         properties.getStorage().setAccountKey(bound.getStorage().getAccountKey());
         properties.getStorage().setDownloadUrlPrefix(bound.getStorage().getDownloadUrlPrefix());
         properties.getStorage().setDownloadUrlPrefix(bound.getStorage().getDownloadUrlPrefix());
+        properties.getPdf().setPoolSize(bound.getPdf().getPoolSize());
+        properties.getPdf().setBorrowTimeout(bound.getPdf().getBorrowTimeout());
+        properties.getPdf().setLaunchTimeout(bound.getPdf().getLaunchTimeout());
+        properties.getPdf().setRenderTimeout(bound.getPdf().getRenderTimeout());
         return properties;
         return properties;
     }
     }
 
 
@@ -57,6 +65,26 @@ public class ExamSprintReportRuntimeConfiguration {
         return executor;
         return executor;
     }
     }
 
 
+    @Bean
+    @ConditionalOnMissingBean(PlaywrightPdfWorkerFactory.class)
+    public PlaywrightPdfWorkerFactory playwrightPdfWorkerFactory(ExamSprintReportProperties properties) {
+        ExamSprintReportProperties.Pdf pdf = properties.getPdf();
+        return new DefaultPlaywrightPdfWorkerFactory(
+                pdf.getLaunchTimeout().toMillis(),
+                pdf.getRenderTimeout().toMillis());
+    }
+
+    @Bean
+    public ExamSprintReportPdfGenerator examSprintReportPdfGenerator(
+            ExamSprintReportProperties properties,
+            PlaywrightPdfWorkerFactory workerFactory) {
+        ExamSprintReportProperties.Pdf pdf = properties.getPdf();
+        return new PooledPlaywrightExamSprintReportPdfGenerator(
+                pdf.getPoolSize(),
+                pdf.getBorrowTimeout(),
+                workerFactory);
+    }
+
     @Bean
     @Bean
     public ExamSprintReportWarmupRunner examSprintReportWarmupRunner(
     public ExamSprintReportWarmupRunner examSprintReportWarmupRunner(
             List<ExamSprintReportRenderer> renderers,
             List<ExamSprintReportRenderer> renderers,

+ 5 - 0
ability-center-runtime/src/main/resources/application.yml

@@ -8,6 +8,11 @@ ability:
         core-pool-size: 2
         core-pool-size: 2
         max-pool-size: 4
         max-pool-size: 4
         queue-capacity: 100
         queue-capacity: 100
+      pdf:
+        pool-size: 4
+        borrow-timeout: 30s
+        launch-timeout: 30s
+        render-timeout: 30s
       storage:
       storage:
         type: memory
         type: memory
         container-name: exam-sprint-reports
         container-name: exam-sprint-reports

+ 48 - 15
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -6,17 +6,17 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfG
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
-import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightPdfWorker;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightPdfWorkerFactory;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PooledPlaywrightExamSprintReportPdfGenerator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Import;
 
 
-import java.time.Clock;
+import java.time.Duration;
 import java.time.Instant;
 import java.time.Instant;
-import java.time.ZoneOffset;
 
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThat;
 
 
@@ -35,9 +35,29 @@ class ExamSprintReportRuntimeConfigurationTest {
                 .isEqualTo("https://dcjxbtest.blob.core.chinacloudapi.cn");
                 .isEqualTo("https://dcjxbtest.blob.core.chinacloudapi.cn");
     }
     }
 
 
+    @Test
+    void examSprintReportPropertiesCopiesPdfSettingsFromBoundProperties() {
+        ExamSprintReportRuntimeConfiguration configuration = new ExamSprintReportRuntimeConfiguration();
+        ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties bound =
+                new ExamSprintReportRuntimeConfiguration.BoundExamSprintReportProperties();
+        bound.getPdf().setPoolSize(6);
+        bound.getPdf().setBorrowTimeout(Duration.ofSeconds(12));
+        bound.getPdf().setLaunchTimeout(Duration.ofSeconds(13));
+        bound.getPdf().setRenderTimeout(Duration.ofSeconds(14));
+
+        ExamSprintReportProperties properties = configuration.examSprintReportProperties(bound);
+
+        assertThat(properties.getPdf().getPoolSize()).isEqualTo(6);
+        assertThat(properties.getPdf().getBorrowTimeout()).isEqualTo(Duration.ofSeconds(12));
+        assertThat(properties.getPdf().getLaunchTimeout()).isEqualTo(Duration.ofSeconds(13));
+        assertThat(properties.getPdf().getRenderTimeout()).isEqualTo(Duration.ofSeconds(14));
+    }
+
     @Test
     @Test
     void examSprintReportWarmupRunnerIsRegisteredAndWiredBySpringContext() {
     void examSprintReportWarmupRunnerIsRegisteredAndWiredBySpringContext() {
         new ApplicationContextRunner()
         new ApplicationContextRunner()
+                .withBean(PlaywrightPdfWorkerFactory.class,
+                        ExamSprintReportRuntimeConfigurationTest::fakePlaywrightPdfWorkerFactory)
                 .withUserConfiguration(ExamSprintReportRuntimeConfiguration.class, WarmupRunnerCollaboratorsConfiguration.class)
                 .withUserConfiguration(ExamSprintReportRuntimeConfiguration.class, WarmupRunnerCollaboratorsConfiguration.class)
                 .run(context -> assertThat(context)
                 .run(context -> assertThat(context)
                         .hasSingleBean(ExamSprintReportWarmupRunner.class)
                         .hasSingleBean(ExamSprintReportWarmupRunner.class)
@@ -45,19 +65,37 @@ class ExamSprintReportRuntimeConfigurationTest {
     }
     }
 
 
     @Test
     @Test
-    void defaultPdfGeneratorComponentRegistersPlaywrightImplementationWithoutLaunchingChromium() {
+    void runtimeConfigurationRegistersPooledPdfGenerator() {
+        PlaywrightPdfWorkerFactory fakeFactory = fakePlaywrightPdfWorkerFactory();
+
         new ApplicationContextRunner()
         new ApplicationContextRunner()
-                .withUserConfiguration(PlaywrightPdfGeneratorConfiguration.class)
+                .withBean(PlaywrightPdfWorkerFactory.class, () -> fakeFactory)
+                .withUserConfiguration(ExamSprintReportRuntimeConfiguration.class, WarmupRunnerCollaboratorsConfiguration.class)
+                .withPropertyValues(
+                        "ability.exam-sprint.report.pdf.pool-size=1",
+                        "ability.exam-sprint.report.pdf.borrow-timeout=1s",
+                        "ability.exam-sprint.report.pdf.launch-timeout=30s",
+                        "ability.exam-sprint.report.pdf.render-timeout=30s")
                 .run(context -> {
                 .run(context -> {
+                    assertThat(context).hasSingleBean(PlaywrightPdfWorkerFactory.class);
+                    assertThat(context.getBean(PlaywrightPdfWorkerFactory.class)).isSameAs(fakeFactory);
                     assertThat(context).hasSingleBean(ExamSprintReportPdfGenerator.class);
                     assertThat(context).hasSingleBean(ExamSprintReportPdfGenerator.class);
                     assertThat(context.getBean(ExamSprintReportPdfGenerator.class))
                     assertThat(context.getBean(ExamSprintReportPdfGenerator.class))
-                            .isInstanceOf(PlaywrightExamSprintReportPdfGenerator.class);
+                            .isInstanceOf(PooledPlaywrightExamSprintReportPdfGenerator.class);
                 });
                 });
     }
     }
 
 
-    @Configuration
-    @Import(PlaywrightExamSprintReportPdfGenerator.class)
-    static class PlaywrightPdfGeneratorConfiguration {
+    private static PlaywrightPdfWorkerFactory fakePlaywrightPdfWorkerFactory() {
+        return () -> new PlaywrightPdfWorker() {
+            @Override
+            public byte[] generate(String htmlContent) {
+                return new byte[] {1};
+            }
+
+            @Override
+            public void close() {
+            }
+        };
     }
     }
 
 
     @Configuration
     @Configuration
@@ -82,10 +120,5 @@ class ExamSprintReportRuntimeConfigurationTest {
                 }
                 }
             };
             };
         }
         }
-
-        @Bean
-        ExamSprintReportPdfGenerator examSprintReportPdfGenerator() {
-            return htmlContent -> new byte[] {1};
-        }
     }
     }
 }
 }

+ 382 - 0
docs/superpowers/specs/2026-05-08-exam-sprint-report-pdf-pool-design.md

@@ -0,0 +1,382 @@
+# 临考词汇突击报告生成并发优化设计
+
+## 背景
+
+临考词汇突击报告同步生成接口当前热态单请求耗时约 260ms 到 300ms,但 20 并发下存在 PDF 生成阶段排队风险。源码检查显示:
+
+- `validation`、`prepare_content`、`render_html`、`storage_upload` 阶段本地代码没有全局串行锁,基本可并行。
+- `PlaywrightExamSprintReportPdfGenerator.generate()` 使用 `synchronized (playwrightLock)` 包住完整 PDF 生成流程,单实例内所有报告共用一个 PDF worker。
+- 两个 HTML renderer 每次 `render()` 都从 classpath 读取模板文件;模板不常变,适合在启动时加载到内存。
+
+目标是按分阶段方案优化:先降低 HTML 模板加载开销,再通过 PDF generator 池解决 PDF 阶段单 worker 串行瓶颈。
+
+## 目标
+
+1. 将报告 HTML 模板加载到内存,避免每次渲染重复读取 classpath 资源。
+2. 将 PDF 生成从单 worker 串行改为可配置的有限并行。
+3. 支持 20 并发同步报告生成时降低 PDF 排队时间。
+4. 保持现有报告生成接口、日志语义和领域接口稳定。
+5. 为池大小、等待超时、渲染超时提供配置入口,便于压测后调优。
+
+## 非目标
+
+1. 不改变报告业务内容、HTML 模板结构或 PDF 样式。
+2. 不引入模板热更新;模板仍随应用发布变更。
+3. 不将同步接口改成异步接口。
+4. 不在第一版追求 20 个 PDF 任务完全并行;采用有限并行保护 CPU、内存和 Chromium 资源。
+5. 不改变 Azure Blob 存储实现的协议或对象路径规则。
+
+## 方案概览
+
+采用两阶段实施:
+
+### 阶段 1:模板内存缓存
+
+将以下 renderer 的模板从“每次 render 读取”改为“构造时一次性读取并缓存为 `String`”:
+
+- `ClasspathAchievementExamSprintReportRenderer`
+- `ClasspathOutlookExamSprintReportRenderer`
+
+模板字段使用不可变 `String`:
+
+```java
+private final String template;
+```
+
+renderer 是 Spring 单例,`String` 不可变,因此模板缓存天然线程安全,不需要额外锁。
+
+模板加载失败时应在 Bean 构造阶段抛出异常,让应用启动失败。这样能尽早暴露缺失模板或 classpath 资源错误,避免首次请求才失败。
+
+### 阶段 2:PDF generator 池
+
+新增池化 PDF generator,实现现有领域接口:
+
+```java
+public interface ExamSprintReportPdfGenerator {
+    byte[] generate(String htmlContent);
+}
+```
+
+建议新增实现:
+
+```text
+PooledPlaywrightExamSprintReportPdfGenerator
+```
+
+池化实现内部维护 N 个独立 worker。每个 worker 持有独立的 Playwright 和 Browser 资源,避免多个线程并发访问同一个 Playwright Java 对象。
+
+每次调用 `generate(html)` 时:
+
+1. 从阻塞队列中借出一个 worker。
+2. 使用该 worker 生成 PDF。
+3. 成功后归还 worker。
+4. 如果生成失败,关闭该 worker 并尝试创建替换 worker;当前请求失败并交给 pipeline 标记报告失败。
+5. 如果借出 worker 超时,抛出运行时异常。
+
+## 组件设计
+
+### Template loading
+
+renderer 内部保留私有加载方法,但调用时机从 `render()` 移到构造函数。
+
+Achievement renderer 示例结构:
+
+```java
+public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintReportRenderer {
+    private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
+    private final String template;
+
+    public ClasspathAchievementExamSprintReportRenderer() {
+        this(new AchievementExamSprintReportSvgChartBuilder(), loadTemplateFromClasspath(TEMPLATE_RESOURCE));
+    }
+
+    ClasspathAchievementExamSprintReportRenderer(
+            AchievementExamSprintReportSvgChartBuilder chartBuilder,
+            String template) {
+        this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
+        this.template = Objects.requireNonNull(template, "template");
+    }
+
+    @Override
+    public String render(ReportContent content, Instant generatedAt) {
+        return renderTemplate(template, placeholders(...));
+    }
+}
+```
+
+Outlook renderer 同理。
+
+为便于单元测试,保留包内可见构造函数允许传入模板字符串,避免测试依赖 classpath 读取次数。
+
+### PDF worker
+
+新增内部 worker 抽象,职责是封装单个 Playwright/Browser 生命周期:
+
+```java
+interface PlaywrightPdfWorker extends AutoCloseable {
+    byte[] generate(String htmlContent);
+}
+```
+
+默认 worker 实现可复用当前 `PlaywrightExamSprintReportPdfGenerator` 的核心逻辑,但锁的范围仅保留在 worker 内部,池通过“同一时刻一个 worker 只借给一个调用方”保证并发安全。
+
+每个 worker:
+
+- 懒加载或构造时初始化 Playwright/Browser。
+- 每次生成创建新的 `BrowserContext` 和 `Page`。
+- 生成后关闭 `BrowserContext`。
+- 应用关闭时关闭 Browser 和 Playwright。
+
+推荐第一版采用启动时初始化所有 worker,理由是:
+
+- 避免首次 20 并发同时触发多个 Chromium 初始化。
+- 启动期暴露 Playwright 环境问题。
+- 和现有 warmup 目标一致,降低冷态抖动。
+
+### PDF pool
+
+池使用 `BlockingQueue<PlaywrightPdfWorker>` 管理可用 worker。
+
+核心流程:
+
+```java
+public byte[] generate(String htmlContent) {
+    PlaywrightPdfWorker worker = borrowWorker();
+    boolean reusable = false;
+    try {
+        byte[] pdf = worker.generate(htmlContent);
+        reusable = true;
+        return pdf;
+    } finally {
+        if (reusable) {
+            returnWorker(worker);
+        } else {
+            replaceWorker(worker);
+        }
+    }
+}
+```
+
+借出 worker 使用可配置超时,避免请求无限等待:
+
+```text
+borrow-timeout
+```
+
+如果池关闭或超时,抛出 `IllegalStateException`。
+
+### 配置
+
+新增配置段:
+
+```yaml
+ability:
+  exam-sprint:
+    report:
+      pdf:
+        pool-size: 4
+        borrow-timeout: 30s
+        launch-timeout: 30s
+        render-timeout: 30s
+```
+
+默认值:
+
+| 配置 | 默认值 | 说明 |
+|---|---:|---|
+| `pool-size` | `4` | PDF worker 数量 |
+| `borrow-timeout` | `30s` | 等待可用 worker 的最长时间 |
+| `launch-timeout` | `30s` | Chromium 启动超时 |
+| `render-timeout` | `30s` | 页面渲染与 PDF 输出超时 |
+
+`pool-size` 第一版默认 4,与现有异步生成线程池 `max-pool-size=4` 对齐,也能避免一次性拉起过多 Chromium 进程。后续通过压测决定是否提高到 6 或 8。
+
+### Spring Bean 装配
+
+保持领域层依赖不变:
+
+```java
+ExamSprintReportGenerationPipeline(
+    ...,
+    ExamSprintReportPdfGenerator pdfGenerator,
+    ...)
+```
+
+运行时只注入一个主 `ExamSprintReportPdfGenerator` Bean,即池化实现。
+
+当前单 worker 类可以演进为 worker 实现,或保留为测试/备用实现。为了避免多 Bean 歧义,生产配置下只暴露池化 generator 作为主 Bean。
+
+## 并发模型
+
+当前模型:
+
+```text
+20 并发请求 -> 20 个请求线程进入 pipeline -> 1 个 PDF generator 锁 -> 串行生成 PDF
+```
+
+优化后模型:
+
+```text
+20 并发请求 -> 20 个请求线程进入 pipeline -> 4 个 PDF worker -> 分批并行生成 PDF
+```
+
+按单个 PDF 生成 200ms 到 300ms 估算:
+
+- 当前单 worker:20 个 PDF 的排队窗口约 4s 到 6s。
+- `pool-size=4`:20 个 PDF 的排队窗口约 1s 到 1.5s。
+
+实际端到端仍取决于 CPU、内存、GC、Azure 上传、Tomcat 线程池和容器资源限制。
+
+## 错误处理
+
+### 模板加载失败
+
+模板加载失败时在 renderer 构造阶段抛出 `IllegalStateException` 或 `UncheckedIOException`。应用启动失败,避免运行时不可预测失败。
+
+### worker 初始化失败
+
+池启动初始化任一 worker 失败时,关闭已创建 worker,并让应用启动失败。这样可以尽早发现 Playwright 或 Chromium 环境问题。
+
+### borrow 超时
+
+如果在 `borrow-timeout` 内无法获得 worker,抛出 `IllegalStateException`。现有 pipeline 会捕获异常并将报告标记为 `FAILED`。
+
+### PDF 生成失败
+
+如果 worker 生成失败:
+
+1. 当前请求失败。
+2. 关闭该 worker。
+3. 尝试创建替换 worker 并放回池。
+4. 替换失败时记录警告,池容量临时降低;后续请求仍可使用其他 worker。
+
+第一版不在同一个请求内自动换 worker 重试,避免重复生成导致耗时不可控。
+
+### 应用关闭
+
+池实现 `DisposableBean` 或 `AutoCloseable`,关闭所有 worker。关闭过程应尽量关闭所有资源,并汇总或记录异常。
+
+## 日志与可观测性
+
+保留现有阶段日志:
+
+- `render_html`
+- `pdf_generation`
+- `storage_upload`
+- `generation_succeeded`
+
+新增或增强 PDF 池日志:
+
+- 池初始化成功:`poolSize`、`launchTimeout`、`renderTimeout`
+- worker borrow 超时:`poolSize`、`borrowTimeout`
+- worker 替换失败:异常类型
+
+不需要在每次请求都输出 worker id,除非压测排查需要。避免高并发下日志过量。
+
+## 测试策略
+
+### 模板缓存单元测试
+
+1. Achievement renderer 使用构造期模板能够正常渲染。
+2. Outlook renderer 使用构造期模板能够正常渲染。
+3. 关键占位符替换结果与现有断言保持一致。
+4. 模板缺失或读取失败时构造失败。
+
+### PDF pool 单元测试
+
+使用 fake worker 和 fake factory,避免真实 Playwright 并发测试过重。
+
+覆盖场景:
+
+1. `pool-size=4` 时最多 4 个 worker 同时执行。
+2. 第 5 个请求会等待 worker 归还。
+3. borrow 超时时抛出异常。
+4. 生成成功后 worker 归还池。
+5. 生成失败后 worker 被关闭并触发替换。
+6. `destroy()` 会关闭所有空闲 worker。
+7. pool 关闭后再次 `generate()` 会失败。
+
+### Playwright 集成测试
+
+保留现有真实 PDF 生成测试,验证中文文本、SVG、模板报告仍可生成 PDF。真实 Playwright 并发测试可作为较重集成测试,不放入快速单元测试路径。
+
+### 压测验证
+
+实施后在测试环境执行:
+
+```text
+预热:ACHIEVEMENT / OUTLOOK 各 5 到 10 次
+并发:20
+持续:3 到 5 分钟
+场景:ACHIEVEMENT 单接口、OUTLOOK 单接口、两接口混合
+```
+
+关注指标:
+
+- `sync_generation_succeeded durationMs` P50/P95/P99
+- `pdf_generation durationMs` P50/P95/P99
+- 错误率
+- CPU 使用率
+- JVM heap 与 GC pause
+- 容器内存与 Chromium 子进程内存
+- Azure `storage_upload` P95/P99 与 429/5xx
+
+验收建议:
+
+- 错误率为 0。
+- 20 并发下 P95 满足业务 SLA。
+- 长时间 CPU 不持续超过 80%。
+- 无 OOM、容器重启、明显 Full GC 抖动。
+
+## 风险与缓解
+
+### Chromium 资源占用上升
+
+池化会同时持有多个 Browser,内存和 native 资源占用会上升。
+
+缓解:默认 `pool-size=4`,通过压测逐步调高。
+
+### Playwright 线程安全
+
+Playwright Java 对象不适合无保护共享。
+
+缓解:每个 worker 持有独立 Playwright/Browser,池保证同一 worker 同时只被一个线程使用。
+
+### 请求线程等待 worker
+
+sync 接口在 worker 不足时会阻塞等待。
+
+缓解:设置 `borrow-timeout`,并通过压测调整 `pool-size`。
+
+### 启动时间增加
+
+启动时初始化多个 worker 会增加启动耗时。
+
+缓解:接受启动期成本换取请求期稳定性;如启动耗时不可接受,再评估 lazy 初始化。
+
+### 模板启动加载导致启动失败
+
+模板缺失会导致应用无法启动。
+
+缓解:这是期望行为,可提前暴露发布包问题。
+
+## 实施顺序
+
+1. 将两个 renderer 的模板加载移动到构造阶段并缓存为 `String`。
+2. 运行相关 renderer 和报告生成测试。
+3. 新增 PDF 配置属性。
+4. 抽取 Playwright PDF worker 与 worker factory。
+5. 实现 `PooledPlaywrightExamSprintReportPdfGenerator`。
+6. 调整 Spring 装配,生产注入池化 PDF generator。
+7. 补充 PDF pool 单元测试。
+8. 运行现有测试和真实 PDF 集成测试。
+9. 在测试环境做 20 并发压测,根据结果调整 `pool-size`。
+
+## 成功标准
+
+1. 两类报告的 HTML 和 PDF 输出保持兼容。
+2. `render_html` 不再每次读取 classpath 模板。
+3. PDF 生成阶段支持配置化有限并行。
+4. 20 并发下 PDF 阶段不再单 worker 串行排队。
+5. 现有报告生成日志仍能按阶段定位耗时。
+6. 单元测试覆盖模板缓存、池借还、超时、失败替换和关闭流程。