diff --git a/.kiro/specs/report-pdf-generation/.config.kiro b/.kiro/specs/report-pdf-generation/.config.kiro
new file mode 100644
index 0000000..d1b6c2d
--- /dev/null
+++ b/.kiro/specs/report-pdf-generation/.config.kiro
@@ -0,0 +1 @@
+{"specId": "a7e2c1d4-8f3b-4e6a-9c5d-2b1f0e8d7a6c", "workflowType": "requirements-first", "specType": "feature"}
\ No newline at end of file
diff --git a/.kiro/specs/report-pdf-generation/design.md b/.kiro/specs/report-pdf-generation/design.md
new file mode 100644
index 0000000..3e0b0d0
--- /dev/null
+++ b/.kiro/specs/report-pdf-generation/design.md
@@ -0,0 +1,609 @@
+# Design Document: PDF 报告生成
+
+## Overview
+
+本设计实现测评报告的 PDF 生成功能,作为现有报告生成流水线的最后一步。当 `ReportGenerationService` 完成结论数据生成(Status=4)后,`ReportQueueConsumer` 自动调用 `PdfGenerationService` 将报告页面转换为 PDF 文件。
+
+核心流程:
+1. 从 `report_page_configs` 表读取所有启用的页面配置(按 SortOrder 排序)
+2. 对每个页面配置,根据 PageType 分别处理:
+ - **PageType=1(静态图片)**:从本地文件系统读取图片字节数据
+ - **PageType=2(网页截图)**:通过外部 HtmlToImage 截图服务对 Razor Pages 页面截图
+3. 使用 SemaphoreSlim 控制并发截图数量,所有截图任务并发执行
+4. 将所有成功获取的图片按 SortOrder 顺序合并为一个 PDF 文件(PdfSharpCore)
+5. PDF 保存到本地磁盘 `wwwroot/reports/`,访问 URL 写入 `assessment_records.ReportUrl`
+
+### 设计决策
+
+| 决策 | 选择 | 理由 |
+|------|------|------|
+| 截图方式 | 异步任务 API(`POST /api/tasks/image` → 轮询 → 下载) | 推荐用于高并发场景,支持批量提交,服务端排队处理,避免长连接超时 |
+| HTTP 调用 | HttpClient + IHttpClientFactory | 不依赖第三方 SDK,直接调用 REST API,通过 IHttpClientFactory 管理连接池和生命周期 |
+| PDF 生成库 | PdfSharpCore | 纯 .NET 实现,无需原生依赖,支持自定义页面尺寸,社区活跃 |
+| 并发控制 | SemaphoreSlim | .NET 原生异步信号量,轻量高效,适合控制 I/O 并发 |
+| 触发时机 | ReportQueueConsumer 中 GenerateReportAsync 成功后调用 | 无需额外队列,复用现有消费者流程,PDF 生成失败不影响结论数据 |
+| 文件存储 | 本地磁盘 + 静态文件中间件 | 当前阶段无需对象存储,通过 Nginx/静态文件中间件直接提供下载 |
+
+## Architecture
+
+```mermaid
+flowchart TB
+ subgraph 现有流程
+ A[ReportQueueConsumer] -->|1. 调用| B[ReportGenerationService]
+ B -->|2. Status=4| C[(assessment_records)]
+ end
+
+ subgraph PDF 生成流程
+ A -->|3. 成功后调用| D[PdfGenerationService]
+ D -->|4. 查询配置| E[(report_page_configs)]
+ D -->|5a. PageType=1| F[本地文件系统
wwwroot/images/static-pages/]
+ D -->|5b. PageType=2 并发| G[ScreenshotService]
+ G -->|6. 提交任务
POST /api/tasks/image| H[HtmlToImage 截图服务
192.168.195.15:5100]
+ G -->|7. 轮询状态
GET /api/tasks/taskId/status| H
+ H -->|8. 下载结果
GET /api/tasks/taskId/download| G
+ G -->|9. 返回 PNG 字节| D
+ D -->|10. 合并图片| I[PdfSharpCore
生成 PDF]
+ I -->|11. 保存文件| J[wwwroot/reports/
report_{id}_{ts}.pdf]
+ D -->|12. 更新 ReportUrl| C
+ end
+
+ subgraph 用户访问
+ K[小程序] -->|13. 下载 PDF| J
+ end
+```
+
+### 时序图
+
+```mermaid
+sequenceDiagram
+ participant Consumer as ReportQueueConsumer
+ participant Report as ReportGenerationService
+ participant PDF as PdfGenerationService
+ participant DB as SQL Server
+ participant Screenshot as ScreenshotService
+ participant HtmlToImage as HtmlToImage 截图服务
+ participant Disk as 本地磁盘
+
+ Consumer->>Report: GenerateReportAsync(recordId)
+ Report->>DB: 写入结论数据, Status=4
+ Report-->>Consumer: 成功返回
+
+ Consumer->>PDF: GeneratePdfAsync(recordId)
+ PDF->>DB: 查询 report_page_configs (Status=1, 按 SortOrder)
+ DB-->>PDF: 页面配置列表
+
+ par 并发截图(SemaphoreSlim 控制)
+ loop 每个 PageType=2 的页面
+ PDF->>Screenshot: CaptureAsync(fullUrl)
+ Screenshot->>HtmlToImage: POST /api/tasks/image(提交任务)
+ HtmlToImage-->>Screenshot: 202 Accepted, taskId
+ loop 轮询直到 completed/failed
+ Screenshot->>HtmlToImage: GET /api/tasks/{taskId}/status
+ HtmlToImage-->>Screenshot: {status: processing/completed}
+ end
+ Screenshot->>HtmlToImage: GET /api/tasks/{taskId}/download
+ HtmlToImage-->>Screenshot: PNG 字节流
+ Screenshot-->>PDF: byte[]
+ end
+ and 读取静态图片
+ loop 每个 PageType=1 的页面
+ PDF->>Disk: File.ReadAllBytesAsync(imagePath)
+ Disk-->>PDF: byte[]
+ end
+ end
+
+ PDF->>PDF: 按 SortOrder 排序所有 Page_Image
+ PDF->>PDF: PdfSharpCore 合并为 PDF
+ PDF->>Disk: 保存 report_{id}_{ts}.pdf
+ PDF->>DB: 更新 ReportUrl 字段
+ PDF-->>Consumer: 完成
+```
+
+## Components and Interfaces
+
+### 1. ReportSettings(配置模型)
+
+```csharp
+///
+/// PDF 报告生成配置
+///
+public class ReportSettings
+{
+ ///
+ /// API 服务内网访问地址,用于拼接报告页面 URL 供截图服务访问
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// PDF 输出目录路径(相对于 ContentRootPath 或绝对路径)
+ ///
+ public string OutputPath { get; set; } = "wwwroot/reports";
+
+ ///
+ /// 最大并发截图数
+ ///
+ public int MaxConcurrency { get; set; } = 5;
+}
+```
+
+位置:`MiAssessment.Core/Models/ReportSettings.cs`
+
+### 2. HtmlToImageSettings(截图服务配置模型)
+
+```csharp
+///
+/// HtmlToImage 截图服务配置
+///
+public class HtmlToImageSettings
+{
+ ///
+ /// 截图服务地址
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// API Key 认证密钥
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+
+ ///
+ /// HTTP 请求超时时间(秒)
+ ///
+ public int TimeoutSeconds { get; set; } = 120;
+
+ ///
+ /// 轮询任务状态的间隔(毫秒)
+ ///
+ public int PollingIntervalMs { get; set; } = 1000;
+
+ ///
+ /// 最大轮询等待时间(秒),超过则视为超时
+ ///
+ public int MaxPollingSeconds { get; set; } = 120;
+}
+```
+
+位置:`MiAssessment.Core/Models/HtmlToImageSettings.cs`
+
+### 3. IScreenshotService(截图服务接口)
+
+```csharp
+///
+/// 截图服务接口,封装对外部 HtmlToImage 服务的调用
+///
+public interface IScreenshotService
+{
+ ///
+ /// 对指定 URL 进行截图,返回 PNG 图片字节数组
+ ///
+ /// 页面完整 URL
+ /// PNG 图片字节数组
+ Task CaptureAsync(string url);
+}
+```
+
+位置:`MiAssessment.Core/Interfaces/IScreenshotService.cs`
+
+### 4. ScreenshotService(截图服务实现)
+
+```csharp
+///
+/// 截图服务实现,通过 HttpClient 直接调用外部 HtmlToImage REST API
+///
+public class ScreenshotService : IScreenshotService
+```
+
+位置:`MiAssessment.Core/Services/ScreenshotService.cs`
+
+依赖注入:
+- `IHttpClientFactory`:创建 HttpClient 实例(使用命名客户端 `"HtmlToImage"`)
+- `HtmlToImageSettings`:截图服务配置(BaseUrl、ApiKey、TimeoutSeconds)
+- `ILogger`:日志
+
+职责:
+- 通过 `IHttpClientFactory.CreateClient("HtmlToImage")` 获取 HttpClient
+- 使用异步任务三步流程完成截图:
+
+**步骤 1:提交任务** — 调用 `POST /api/tasks/image`,请求体如下:
+ ```json
+ {
+ "source": {
+ "type": "url",
+ "content": "{pageUrl}"
+ },
+ "options": {
+ "format": "png",
+ "width": 1309,
+ "height": 926,
+ "fullPage": false
+ },
+ "waitUntil": "networkidle0",
+ "timeout": 60000,
+ "saveLocal": true
+ }
+ ```
+ 响应返回 `202 Accepted`,包含 `taskId` 和 `status`
+
+**步骤 2:轮询状态** — 调用 `GET /api/tasks/{taskId}/status`,间隔 `PollingIntervalMs`(默认 1000ms),直到:
+ - `status` 为 `completed` → 进入步骤 3
+ - `status` 为 `failed` 或 `stalled` → 记录错误日志,抛出 InvalidOperationException
+ - 累计等待超过 `MaxPollingSeconds` → 记录超时日志,抛出 TimeoutException
+
+**步骤 3:下载结果** — 调用 `GET /api/tasks/{taskId}/download`,读取 `response.Content.ReadAsByteArrayAsync()` 获取 PNG 字节数组
+
+- 请求头设置 `X-API-Key: {apiKey}`(当 ApiKey 非空时)
+- 任务提交失败时调用 `response.EnsureSuccessStatusCode()` 抛出 HttpRequestException
+- 下载结果为空字节数组时记录 Warning 日志并抛出异常
+- 所有失败场景记录错误日志(含 URL、taskId、HTTP 状态码、错误信息),抛出异常
+
+### 5. IPdfGenerationService(PDF 生成服务接口)
+
+```csharp
+///
+/// PDF 报告生成服务接口
+///
+public interface IPdfGenerationService
+{
+ ///
+ /// 根据测评记录 ID 生成 PDF 报告
+ ///
+ /// 测评记录 ID
+ Task GeneratePdfAsync(long recordId);
+}
+```
+
+位置:`MiAssessment.Core/Interfaces/IPdfGenerationService.cs`
+
+### 6. PdfGenerationService(PDF 生成服务实现)
+
+```csharp
+///
+/// PDF 报告生成服务实现
+///
+public class PdfGenerationService : IPdfGenerationService
+```
+
+位置:`MiAssessment.Core/Services/PdfGenerationService.cs`
+
+依赖注入:
+- `MiAssessmentDbContext`:查询页面配置、更新 ReportUrl
+- `IScreenshotService`:网页截图
+- `ReportSettings`:配置(BaseUrl、OutputPath、MaxConcurrency)
+- `AppSettings`:CdnPrefix 配置
+- `ILogger`:日志
+
+核心方法 `GeneratePdfAsync(long recordId)` 流程:
+
+1. **验证配置**:检查 `ReportSettings.BaseUrl` 非空
+2. **查询页面配置**:从 `report_page_configs` 查询 Status=1 的记录,按 SortOrder 升序
+3. **并发获取图片**:
+ - 创建 `SemaphoreSlim(maxConcurrency)`
+ - 对每个页面配置创建异步任务:
+ - PageType=1:从本地文件系统读取图片
+ - PageType=2:拼接完整 URL,调用 `IScreenshotService.CaptureAsync`
+ - `Task.WhenAll` 等待所有任务完成
+4. **按 SortOrder 排序**:过滤掉失败的页面,按原始顺序组装图片列表
+5. **生成 PDF**:使用 PdfSharpCore 创建 PDF,每页插入一张图片
+6. **保存文件**:写入磁盘,自动创建目录
+7. **更新数据库**:将 ReportUrl 写入 `assessment_records`
+
+#### URL 拼接逻辑
+
+```csharp
+///
+/// 拼接报告页面完整 URL
+///
+internal static string BuildPageUrl(string baseUrl, string routeUrl, long recordId)
+{
+ // 去除 baseUrl 末尾斜杠
+ var normalizedBase = baseUrl.TrimEnd('/');
+ // 确保 routeUrl 以 / 开头
+ var normalizedRoute = routeUrl.StartsWith('/') ? routeUrl : "/" + routeUrl;
+ var fullUrl = normalizedBase + normalizedRoute;
+
+ // 如果已包含 recordId 参数,直接返回
+ if (fullUrl.Contains("recordId=", StringComparison.OrdinalIgnoreCase))
+ return fullUrl;
+
+ // 根据是否已有查询参数决定使用 ? 或 &
+ var separator = fullUrl.Contains('?') ? "&" : "?";
+ return $"{fullUrl}{separator}recordId={recordId}";
+}
+```
+
+### 7. ReportQueueConsumer 改造
+
+在 `ProcessMessageAsync` 中,`ReportGenerationService.GenerateReportAsync` 成功后,追加调用 `PdfGenerationService.GeneratePdfAsync`:
+
+```csharp
+// 现有:生成结论数据
+await reportService.GenerateReportAsync(message.RecordId);
+
+// 新增:生成 PDF 报告
+try
+{
+ var pdfService = scope.ServiceProvider.GetRequiredService();
+ await pdfService.GeneratePdfAsync(message.RecordId);
+ _logger.LogInformation("PDF 报告生成成功,RecordId: {RecordId}", message.RecordId);
+}
+catch (Exception pdfEx)
+{
+ // PDF 生成失败不影响结论数据,仅记录错误日志
+ _logger.LogError(pdfEx, "PDF 报告生成失败,RecordId: {RecordId}", message.RecordId);
+}
+```
+
+### 8. AssessmentRecord 实体扩展
+
+在 `AssessmentRecord` 实体中新增 `ReportUrl` 属性:
+
+```csharp
+///
+/// PDF 报告文件访问 URL
+///
+[MaxLength(500)]
+public string? ReportUrl { get; set; }
+```
+
+### 9. 服务注册(Program.cs)
+
+```csharp
+// 配置 HtmlToImageSettings
+var htmlToImageSettings = new HtmlToImageSettings();
+builder.Configuration.GetSection("HtmlToImageSettings").Bind(htmlToImageSettings);
+builder.Services.AddSingleton(htmlToImageSettings);
+
+// 注册 HtmlToImage 命名 HttpClient
+builder.Services.AddHttpClient("HtmlToImage", (sp, client) =>
+{
+ var settings = sp.GetRequiredService();
+ client.BaseAddress = new Uri(settings.BaseUrl);
+ client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds);
+ if (!string.IsNullOrEmpty(settings.ApiKey))
+ {
+ client.DefaultRequestHeaders.Add("X-API-Key", settings.ApiKey);
+ }
+});
+
+// 配置 ReportSettings
+var reportSettings = new ReportSettings();
+builder.Configuration.GetSection("ReportSettings").Bind(reportSettings);
+builder.Services.AddSingleton(reportSettings);
+```
+
+`IScreenshotService` 和 `IPdfGenerationService` 通过 Autofac 的 `ServiceModule` 自动扫描注册。
+
+### 10. appsettings.json 新增配置节
+
+```json
+{
+ "HtmlToImageSettings": {
+ "BaseUrl": "http://192.168.195.15:5100",
+ "ApiKey": "",
+ "TimeoutSeconds": 120,
+ "PollingIntervalMs": 1000,
+ "MaxPollingSeconds": 120
+ },
+ "ReportSettings": {
+ "BaseUrl": "http://localhost:5000",
+ "OutputPath": "wwwroot/reports",
+ "MaxConcurrency": 5
+ }
+}
+```
+
+## Data Models
+
+### AssessmentRecord 扩展字段
+
+| 字段 | 类型 | 约束 | 说明 |
+|------|------|------|------|
+| ReportUrl | nvarchar(500) | 可空 | PDF 报告文件的访问 URL |
+
+对应 SQL:
+```sql
+ALTER TABLE assessment_records ADD ReportUrl NVARCHAR(500) NULL;
+```
+
+### ReportPageConfig 现有结构(无变更)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| Id | bigint | 主键 |
+| PageType | int | 1=静态图片, 2=网页截图 |
+| PageName | nvarchar(50) | 页面标识名称 |
+| Title | nvarchar(100) | 页面显示标题 |
+| SortOrder | int | 排序序号 |
+| ImageUrl | nvarchar(500) | 静态图片路径(PageType=1) |
+| RouteUrl | nvarchar(200) | 网页路由路径(PageType=2) |
+| Status | int | 0=禁用, 1=启用 |
+
+### PDF 页面尺寸常量
+
+| 常量 | 值 | 说明 |
+|------|-----|------|
+| PageWidthPx | 1309 | 页面宽度(像素) |
+| PageHeightPx | 926 | 页面高度(像素) |
+| PageWidthPt | 981.75 | PDF 页面宽度(点,1309 × 72/96) |
+| PageHeightPt | 694.5 | PDF 页面高度(点,926 × 72/96) |
+
+### 文件命名规则
+
+```
+report_{recordId}_{yyyyMMddHHmmss}.pdf
+```
+
+示例:`report_12345_20250115103000.pdf`
+
+### ReportUrl 格式
+
+```
+{CdnPrefix}/reports/report_{recordId}_{yyyyMMddHHmmss}.pdf
+```
+
+当 `CdnPrefix` 为空时:`/reports/report_12345_20250115103000.pdf`
+
+
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: URL 拼接正确性
+
+*For any* 合法的 baseUrl(可能带或不带末尾斜杠)、routeUrl(可能带或不带前导斜杠、可能已包含查询参数)和正整数 recordId,`BuildPageUrl(baseUrl, routeUrl, recordId)` 生成的 URL 应满足:
+- 不包含双斜杠(`//`,协议部分除外)
+- 包含 `recordId={recordId}` 参数且仅出现一次
+- 如果 routeUrl 已包含 `recordId` 参数,则不重复追加
+
+**Validates: Requirements 4.1, 4.2, 8.3**
+
+### Property 2: PDF 页面顺序与 SortOrder 一致
+
+*For any* 一组页面配置(包含不同的 SortOrder 值)和对应的图片字节数组,无论并发执行的完成顺序如何,最终生成的 PDF 文件中的页面顺序应严格按照 SortOrder 升序排列。
+
+**Validates: Requirements 5.1, 10.3**
+
+### Property 3: 部分页面失败时的容错性
+
+*For any* 一组页面配置,其中部分页面处理成功、部分页面处理失败(但至少有一个成功),生成的 PDF 应恰好包含所有成功页面的图片,且不包含任何失败页面,页面数量等于成功页面数量。
+
+**Validates: Requirements 4.4, 5.5**
+
+### Property 4: 文件名格式正确性
+
+*For any* 正整数 recordId 和合法的 DateTime 时间戳,生成的 PDF 文件名应匹配正则表达式 `^report_\d+_\d{14}\.pdf$`,且文件名中的 recordId 和时间戳与输入值一致。
+
+**Validates: Requirements 6.2**
+
+### Property 5: ReportUrl 格式正确性
+
+*For any* CdnPrefix 字符串(可能为空)和合法的 PDF 文件名,生成的 ReportUrl 应为 `{CdnPrefix}/reports/{fileName}` 格式,当 CdnPrefix 为空时以 `/reports/` 开头。
+
+**Validates: Requirements 6.3**
+
+### Property 6: PDF 生成失败不影响测评记录状态
+
+*For any* Status=4 的测评记录,当 PDF 生成过程抛出异常时,该记录的 Status 应保持为 4 不变,结论数据不受影响。
+
+**Validates: Requirements 7.4**
+
+### Property 7: 页面配置过滤与排序
+
+*For any* 一组 report_page_configs 记录(包含混合的 Status 值和 SortOrder 值),查询结果应仅包含 Status=1 的记录,且按 SortOrder 升序排列。
+
+**Validates: Requirements 2.1**
+
+## Error Handling
+
+### 截图服务层(ScreenshotService)
+
+| 场景 | 处理方式 |
+|------|----------|
+| 任务提交失败(HTTP 4xx/5xx) | 调用 `EnsureSuccessStatusCode()` 抛出 HttpRequestException,记录 Error 日志(含 URL、HTTP 状态码) |
+| 任务状态变为 `failed` | 记录 Error 日志(含 taskId、URL、任务状态),抛出 InvalidOperationException |
+| 任务状态变为 `stalled` | 记录 Error 日志(含 taskId、URL),抛出 InvalidOperationException |
+| 轮询超过 MaxPollingSeconds | 记录 Error 日志(含 taskId、已等待时间),抛出 TimeoutException |
+| 下载结果失败(HTTP 错误) | 调用 `EnsureSuccessStatusCode()` 抛出 HttpRequestException |
+| 下载结果为空字节数组 | 记录 Warning 日志,视为截图失败,抛出 InvalidOperationException |
+| 截图服务网络不可达 | HttpClient 抛出 HttpRequestException,记录 Error 日志 |
+| HTTP 请求超时 | HttpClient.Timeout 控制(TimeoutSeconds=120),超时后抛出 TaskCanceledException |
+
+### PDF 生成服务层(PdfGenerationService)
+
+| 场景 | 处理方式 |
+|------|----------|
+| `ReportSettings.BaseUrl` 为空 | 抛出 InvalidOperationException,包含"ReportSettings:BaseUrl 未配置"提示 |
+| 无启用的页面配置(Status=1 记录为空) | 抛出 InvalidOperationException,包含"报告页面配置为空,无法生成 PDF"提示 |
+| 静态图片文件不存在(PageType=1) | 记录 Warning 日志,跳过该页面,继续处理后续页面 |
+| 网页截图失败(PageType=2) | 记录 Error 日志(含页面名称、URL、错误信息),跳过该页面,继续处理后续页面 |
+| 所有页面均处理失败 | 抛出 InvalidOperationException,包含"所有报告页面处理失败,无法生成 PDF"提示 |
+| 输出目录不存在 | 自动调用 `Directory.CreateDirectory` 创建目录及父目录 |
+| PDF 文件写入失败(磁盘满/权限不足) | 抛出 IOException,包含具体文件系统错误信息 |
+| 更新 ReportUrl 时数据库异常 | 抛出异常,由调用方(ReportQueueConsumer)捕获并记录日志 |
+
+### 流程编排层(ReportQueueConsumer)
+
+| 场景 | 处理方式 |
+|------|----------|
+| PDF 生成抛出任何异常 | 记录 Error 日志,不改变 Assessment_Record 状态(保持 Status=4),不触发重试 |
+| PDF 生成成功 | 记录 Info 日志,包含 recordId 和文件路径 |
+
+## Testing Strategy
+
+### 测试框架
+
+- 单元测试:xUnit + Moq
+- 属性测试:FsCheck
+- 每个属性测试最少运行 100 次迭代
+
+### 单元测试
+
+| 测试目标 | 测试内容 |
+|----------|----------|
+| ScreenshotService.CaptureAsync | 验证提交任务时发送的 HTTP 请求参数正确(source.type=url、format=png、width=1309、height=926、fullPage=false、waitUntil=networkidle0);验证设置了 X-API-Key 请求头;验证轮询状态直到 completed;验证下载结果返回正确字节数组;验证任务 failed 时抛出异常;验证轮询超时时抛出 TimeoutException |
+| PdfGenerationService.BuildPageUrl | 验证 baseUrl 末尾斜杠处理;验证 routeUrl 已有查询参数时使用 `&`;验证已有 recordId 时不重复追加 |
+| PdfGenerationService.GeneratePdfAsync | 验证无启用配置时抛出异常;验证 BaseUrl 为空时抛出异常;验证所有页面失败时抛出异常;验证部分页面失败时仍生成 PDF;验证 PDF 生成后更新 ReportUrl |
+| PdfGenerationService(静态图片处理) | 验证 PageType=1 时从正确路径读取文件;验证文件不存在时跳过并记录警告 |
+| PdfGenerationService(网页截图处理) | 验证 PageType=2 时调用 ScreenshotService;验证截图失败时跳过并记录错误 |
+| ReportQueueConsumer(PDF 集成) | 验证 GenerateReportAsync 成功后调用 GeneratePdfAsync;验证 PDF 生成失败时不改变 Status=4;验证 PDF 生成失败时仅记录日志 |
+| AssessmentRecord.ReportUrl | 验证实体包含 ReportUrl 属性且可空 |
+
+### 属性测试
+
+每个属性测试必须以注释引用设计文档中的属性编号:
+
+```csharp
+///
+/// Feature: report-pdf-generation, Property 1: URL 拼接正确性
+///
+[Property(MaxTest = 100)]
+public Property BuildPageUrlProducesValidUrl() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 2: PDF 页面顺序与 SortOrder 一致
+///
+[Property(MaxTest = 100)]
+public Property PdfPageOrderMatchesSortOrder() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 3: 部分页面失败时的容错性
+///
+[Property(MaxTest = 100)]
+public Property PartialFailureProducesPartialPdf() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 4: 文件名格式正确性
+///
+[Property(MaxTest = 100)]
+public Property FileNameMatchesExpectedFormat() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 5: ReportUrl 格式正确性
+///
+[Property(MaxTest = 100)]
+public Property ReportUrlMatchesExpectedFormat() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 6: PDF 生成失败不影响测评记录状态
+///
+[Property(MaxTest = 100)]
+public Property PdfFailurePreservesRecordStatus() { ... }
+
+///
+/// Feature: report-pdf-generation, Property 7: 页面配置过滤与排序
+///
+[Property(MaxTest = 100)]
+public Property PageConfigFilteringAndOrdering() { ... }
+```
+
+### 测试数据生成策略
+
+- **baseUrl**:随机生成的 HTTP URL 字符串,包含带/不带末尾斜杠的变体
+- **routeUrl**:随机生成的路径字符串,包含带/不带前导斜杠、带/不带查询参数、带/不带 recordId 参数的变体
+- **recordId**:正整数范围 [1, long.MaxValue]
+- **SortOrder**:[1, 100] 范围内的不重复整数列表
+- **PageType**:{1, 2} 中的随机值
+- **CdnPrefix**:随机字符串,包含空字符串变体
+- **页面配置列表**:长度 [1, 50] 的随机 ReportPageConfig 列表,Status 在 {0, 1} 中随机
+- **图片字节数组**:随机长度 [100, 10000] 的非空字节数组(模拟 PNG 数据)
diff --git a/.kiro/specs/report-pdf-generation/requirements.md b/.kiro/specs/report-pdf-generation/requirements.md
new file mode 100644
index 0000000..153ccd9
--- /dev/null
+++ b/.kiro/specs/report-pdf-generation/requirements.md
@@ -0,0 +1,148 @@
+# 需求文档:PDF 报告生成
+
+## 简介
+
+PDF 报告生成功能是学业邑规划测评系统的核心输出环节。当测评结论数据生成完成(assessment_records.Status=4)后,系统按照 `report_page_configs` 表的配置顺序,将每页报告(Razor Pages 渲染的 HTML 网页截图页和静态图片页)通过外部 HtmlToImage 截图服务转换成图片,然后将所有图片按顺序合并成一个 PDF 文件,存储到本地磁盘并将访问 URL 写入测评记录。
+
+整个 PDF 生成流程在现有报告队列消费者(ReportQueueConsumer)完成结论数据生成之后自动触发,作为报告生成流水线的最后一步。
+
+## 术语表
+
+- **PDF_Generation_Service(PDF 生成服务)**:后端核心服务,负责编排截图、图片收集和 PDF 合并的完整流程
+- **Screenshot_Service(截图服务)**:后端服务,封装对外部 HtmlToImage 截图服务的调用,将 URL 或静态图片转换为 PNG 图片字节数组
+- **HtmlToImage_Client(HtmlToImage 客户端)**:通过 `IHttpClientFactory` 创建的命名 HttpClient(`"HtmlToImage"`),直接调用外部 HtmlToImage 截图服务的异步任务 REST API
+- **Report_Page_Config(报告页面配置)**:`report_page_configs` 表中的记录,定义 PDF 报告中每一页的类型(静态图片或网页截图)、顺序和关联资源
+- **Assessment_Record(测评记录)**:`assessment_records` 表中的记录,Status=4 表示结论数据已生成完成,PDF 生成在此状态之后触发
+- **Static_Image_Page(静态图片页)**:PageType=1 的报告页面,直接从本地文件系统或 CDN 读取图片
+- **Web_Screenshot_Page(网页截图页)**:PageType=2 的报告页面,需要通过截图服务对 Razor Pages 渲染的 HTML 页面进行截图
+- **Page_Image(页面图片)**:单个报告页面经截图或读取后得到的 PNG 图片字节数组,尺寸为 1309×926px
+- **Report_PDF(报告 PDF)**:所有 Page_Image 按 SortOrder 顺序合并生成的最终 PDF 文件
+- **Report_Queue_Consumer(报告队列消费者)**:现有的 BackgroundService,消费 Redis 队列中的报告生成任务,调用 ReportGenerationService 生成结论数据
+- **Base_Url(基础地址)**:当前 API 服务的内网访问地址,用于拼接报告页面的完整 URL 供截图服务访问
+
+## 需求
+
+### 需求 1:HtmlToImage 截图服务集成
+
+**用户故事:** 作为后端系统,我希望集成外部 HtmlToImage 截图服务的 REST API,以便将报告网页页面转换为图片。
+
+#### 验收标准
+
+1. THE Screenshot_Service SHALL 通过 `IHttpClientFactory` 创建命名 HttpClient(`"HtmlToImage"`),配置项包括 BaseUrl(截图服务地址)、ApiKey(认证密钥,通过 `X-API-Key` 请求头传递)、TimeoutSeconds(HTTP 请求超时时间)、PollingIntervalMs(轮询间隔毫秒数,默认 1000)、MaxPollingSeconds(最大轮询等待时间秒数,默认 120)
+2. THE Screenshot_Service SHALL 从 `appsettings.json` 的 `HtmlToImageSettings` 配置节读取截图服务配置,配置节结构为 `{ "BaseUrl": "http://...", "ApiKey": "...", "TimeoutSeconds": 120, "PollingIntervalMs": 1000, "MaxPollingSeconds": 120 }`
+3. WHEN 调用截图服务将 URL 转换为图片时,THE Screenshot_Service SHALL 使用异步任务模式,分三步完成:
+ - **提交任务**:调用 `POST /api/tasks/image`,请求体中 `source.type` 为 `"url"`、`source.content` 为页面完整 URL,图片选项设置为 format=png、width=1309、height=926、fullPage=false,waitUntil=networkidle0,saveLocal=true,响应返回 taskId
+ - **轮询状态**:调用 `GET /api/tasks/{taskId}/status` 轮询任务状态,间隔为 PollingIntervalMs,直到状态变为 `completed` 或 `failed` 或超过 MaxPollingSeconds
+ - **下载结果**:当任务状态为 `completed` 时,调用 `GET /api/tasks/{taskId}/download` 下载 PNG 图片字节数组
+4. WHEN 调用截图服务时,THE Screenshot_Service SHALL 设置 waitUntil 参数为 networkidle0,确保页面中的 ECharts 图表等异步内容渲染完成后再截图
+5. IF 截图任务提交失败(HTTP 错误),THEN THE Screenshot_Service SHALL 记录错误日志,包含页面 URL、错误信息和 HTTP 状态码,并抛出异常终止当前页面的截图操作
+6. IF 截图任务状态变为 `failed` 或 `stalled`,THEN THE Screenshot_Service SHALL 记录错误日志,包含 taskId、页面 URL 和任务状态,并抛出异常
+7. IF 轮询超过 MaxPollingSeconds 仍未完成,THEN THE Screenshot_Service SHALL 记录超时错误日志,包含 taskId 和已等待时间,并抛出 TimeoutException
+
+### 需求 2:报告页面配置读取
+
+**用户故事:** 作为 PDF 生成服务,我希望从 `report_page_configs` 表读取所有启用的页面配置,以便按配置顺序生成 PDF 报告。
+
+#### 验收标准
+
+1. THE PDF_Generation_Service SHALL 从 `report_page_configs` 表查询所有 Status=1(启用)的配置记录,按 SortOrder 升序排列
+2. IF `report_page_configs` 表中不存在任何 Status=1 的配置记录,THEN THE PDF_Generation_Service SHALL 抛出业务异常,包含"报告页面配置为空,无法生成 PDF"提示信息
+3. THE PDF_Generation_Service SHALL 根据每条配置记录的 PageType 字段区分处理方式:PageType=1 读取静态图片,PageType=2 调用截图服务截取网页
+
+### 需求 3:静态图片页处理
+
+**用户故事:** 作为 PDF 生成服务,我希望能读取静态图片页的图片数据,以便将静态图片页纳入 PDF 报告。
+
+#### 验收标准
+
+1. WHEN PageType 为 1(静态图片)时,THE PDF_Generation_Service SHALL 从 Report_Page_Config 的 ImageUrl 字段获取图片路径,从本地文件系统的 `wwwroot/images/static-pages/` 目录读取对应的图片文件
+2. IF ImageUrl 对应的图片文件不存在,THEN THE PDF_Generation_Service SHALL 记录警告日志并跳过该页面,继续处理后续页面,不终止整个 PDF 生成流程
+3. THE PDF_Generation_Service SHALL 读取图片文件的原始字节数据作为该页面的 Page_Image
+
+### 需求 4:网页截图页处理
+
+**用户故事:** 作为 PDF 生成服务,我希望能对 Razor Pages 渲染的报告网页进行截图,以便将网页截图页纳入 PDF 报告。
+
+#### 验收标准
+
+1. WHEN PageType 为 2(网页截图)时,THE PDF_Generation_Service SHALL 从 Report_Page_Config 的 RouteUrl 字段获取页面路由路径,拼接 Base_Url 和 recordId 参数构建完整的页面访问 URL
+2. THE PDF_Generation_Service SHALL 按以下规则拼接 URL:如果 RouteUrl 已包含查询参数(含 `?`),则使用 `&recordId={id}` 追加;如果 RouteUrl 不包含查询参数,则使用 `?recordId={id}` 追加;如果 RouteUrl 中已包含 `recordId` 参数,则不重复追加
+3. THE PDF_Generation_Service SHALL 将构建好的完整 URL 传递给 Screenshot_Service 进行截图,获取返回的 PNG 图片字节数组作为该页面的 Page_Image
+4. IF 某个网页截图页截图失败,THEN THE PDF_Generation_Service SHALL 记录错误日志(包含页面名称、路由 URL 和错误信息),跳过该页面继续处理后续页面,不终止整个 PDF 生成流程
+
+### 需求 5:图片合并为 PDF
+
+**用户故事:** 作为 PDF 生成服务,我希望将所有页面图片按顺序合并成一个 PDF 文件,以便生成完整的测评报告。
+
+#### 验收标准
+
+1. THE PDF_Generation_Service SHALL 将所有成功获取的 Page_Image 按 Report_Page_Config 的 SortOrder 顺序,依次作为 PDF 的每一页插入,生成一个完整的 Report_PDF 文件
+2. THE PDF_Generation_Service SHALL 设置 PDF 页面尺寸与图片尺寸一致(1309×926px),使用横版布局(landscape),页面边距为 0,确保图片铺满整页无白边
+3. THE PDF_Generation_Service SHALL 使用开源 PDF 库(如 PdfSharpCore 或 SkiaSharp)在服务端生成 PDF 文件,不依赖外部 PDF 生成服务
+4. IF 所有页面均处理失败,没有任何有效的 Page_Image,THEN THE PDF_Generation_Service SHALL 抛出业务异常,包含"所有报告页面处理失败,无法生成 PDF"提示信息
+5. WHEN 部分页面处理失败但仍有有效的 Page_Image 时,THE PDF_Generation_Service SHALL 使用已成功获取的 Page_Image 生成 PDF,并在日志中记录跳过的页面数量和名称
+
+### 需求 6:PDF 文件存储
+
+**用户故事:** 作为后端系统,我希望将生成的 PDF 文件存储到服务器本地磁盘,并记录访问 URL,以便用户下载报告。
+
+#### 验收标准
+
+1. THE PDF_Generation_Service SHALL 将生成的 Report_PDF 文件保存到服务器本地磁盘的指定目录,目录路径从 `appsettings.json` 的 `ReportSettings:OutputPath` 配置项读取,默认值为 `wwwroot/reports`
+2. THE PDF_Generation_Service SHALL 使用以下命名规则生成 PDF 文件名:`report_{recordId}_{yyyyMMddHHmmss}.pdf`,其中 recordId 为测评记录 ID,时间戳为生成时间
+3. WHEN PDF 文件保存成功后,THE PDF_Generation_Service SHALL 将文件的访问 URL 写入 Assessment_Record 的 ReportUrl 字段,URL 格式为 `{CdnPrefix}/reports/{fileName}`
+4. IF 输出目录不存在,THEN THE PDF_Generation_Service SHALL 自动创建该目录及其父目录
+5. IF PDF 文件保存失败(磁盘空间不足、权限不足等),THEN THE PDF_Generation_Service SHALL 抛出异常,包含具体的文件系统错误信息
+
+### 需求 7:PDF 生成流程编排
+
+**用户故事:** 作为后端系统,我希望 PDF 生成在结论数据生成完成后自动触发,作为报告生成流水线的最后一步。
+
+#### 验收标准
+
+1. WHEN ReportGenerationService 完成结论数据生成并将 Assessment_Record 状态更新为 4(已完成)后,THE Report_Queue_Consumer SHALL 自动调用 PDF_Generation_Service 生成 PDF 报告
+2. THE PDF_Generation_Service SHALL 提供 `GeneratePdfAsync(long recordId)` 方法作为 PDF 生成的入口,接收测评记录 ID 作为参数
+3. WHEN PDF 生成成功后,THE PDF_Generation_Service SHALL 记录 Info 级别日志,包含 recordId、PDF 文件路径和文件大小
+4. IF PDF 生成过程中发生异常,THEN THE Report_Queue_Consumer SHALL 记录错误日志,但不改变 Assessment_Record 的状态(保持 Status=4),不影响结论数据的完整性
+5. IF PDF 生成失败,THEN THE PDF_Generation_Service SHALL 将 Assessment_Record 的 ReportUrl 字段设置为空字符串,表示 PDF 尚未生成成功
+
+### 需求 8:Base_Url 配置
+
+**用户故事:** 作为后端系统,我希望能配置 API 服务的内网访问地址,以便截图服务能通过该地址访问报告页面。
+
+#### 验收标准
+
+1. THE PDF_Generation_Service SHALL 从 `appsettings.json` 的 `ReportSettings:BaseUrl` 配置项读取 API 服务的内网访问地址,用于拼接报告页面的完整 URL
+2. IF `ReportSettings:BaseUrl` 配置项为空或未配置,THEN THE PDF_Generation_Service SHALL 抛出配置异常,包含"ReportSettings:BaseUrl 未配置"提示信息
+3. THE PDF_Generation_Service SHALL 在拼接 URL 时自动处理 Base_Url 末尾的斜杠,确保生成的 URL 格式正确(不出现双斜杠)
+
+### 需求 9:assessment_records 表 ReportUrl 字段
+
+**用户故事:** 作为后端系统,我希望在 assessment_records 表中存储 PDF 报告的访问 URL,以便前端展示下载链接。
+
+#### 验收标准
+
+1. THE Assessment_Record 实体 SHALL 包含 ReportUrl 字段(nvarchar(500),可空),用于存储 PDF 报告文件的访问 URL
+2. WHEN PDF 生成成功后,THE PDF_Generation_Service SHALL 更新 Assessment_Record 的 ReportUrl 字段为 PDF 文件的完整访问 URL
+3. WHEN 管理员触发"重新生成报告"时,THE PDF_Generation_Service SHALL 在重新生成 PDF 前将 ReportUrl 字段清空,生成成功后写入新的 URL
+
+### 需求 10:并发截图优化
+
+**用户故事:** 作为后端系统,我希望能并发执行多个页面的截图操作,以便缩短 PDF 生成的总耗时。
+
+#### 验收标准
+
+1. THE PDF_Generation_Service SHALL 支持并发截图,使用 `POST /api/tasks/batch` 批量提交所有网页截图任务,或通过并发调用 `POST /api/tasks/image` 提交单个任务,最大并发提交数从 `appsettings.json` 的 `ReportSettings:MaxConcurrency` 配置项读取,默认值为 5
+2. THE PDF_Generation_Service SHALL 使用 SemaphoreSlim 控制并发提交和轮询的数量,确保同时进行的截图任务不超过配置的最大并发数
+3. THE PDF_Generation_Service SHALL 在所有截图任务完成后,按原始 SortOrder 顺序组装 Page_Image 列表,确保 PDF 页面顺序与配置一致,不受并发执行顺序影响
+4. THE PDF_Generation_Service SHALL 为每个截图任务记录耗时日志(Debug 级别),包含页面名称、taskId 和截图耗时毫秒数
+
+### 需求 11:PDF 报告下载接口
+
+**用户故事:** 作为小程序用户,我希望能通过接口下载已生成的 PDF 报告文件。
+
+#### 验收标准
+
+1. WHEN 小程序请求报告详情时,THE API SHALL 在响应中返回 Assessment_Record 的 ReportUrl 字段值,前端通过该 URL 直接下载 PDF 文件
+2. THE API 项目 SHALL 配置静态文件中间件,使 `wwwroot/reports/` 目录下的 PDF 文件可通过 HTTP 直接访问
+3. IF Assessment_Record 的 ReportUrl 为空或 null,THEN THE API SHALL 在响应中返回空字符串,前端据此判断 PDF 报告尚未生成
diff --git a/.kiro/specs/report-pdf-generation/tasks.md b/.kiro/specs/report-pdf-generation/tasks.md
new file mode 100644
index 0000000..bf12744
--- /dev/null
+++ b/.kiro/specs/report-pdf-generation/tasks.md
@@ -0,0 +1,189 @@
+# Implementation Plan: PDF 报告生成
+
+## Overview
+
+在现有报告生成流水线基础上,新增 PDF 生成环节。当 ReportGenerationService 完成结论数据生成(Status=4)后,通过外部 HtmlToImage 异步任务 API 对报告页面截图,将所有图片按 SortOrder 合并为 PDF 文件(PdfSharpCore),保存到本地磁盘并将访问 URL 写入 assessment_records.ReportUrl。
+
+## Tasks
+
+- [ ] 1. 配置模型与数据库变更
+ - [ ] 1.1 创建 HtmlToImageSettings 配置模型
+ - 在 `MiAssessment.Core/Models/HtmlToImageSettings.cs` 创建配置类
+ - 包含 BaseUrl、ApiKey、TimeoutSeconds、PollingIntervalMs(默认 1000)、MaxPollingSeconds(默认 120)属性
+ - 所有属性添加 XML 注释
+ - _Requirements: 1.1, 1.2_
+ - [ ] 1.2 创建 ReportSettings 配置模型
+ - 在 `MiAssessment.Core/Models/ReportSettings.cs` 创建配置类
+ - 包含 BaseUrl、OutputPath(默认 `wwwroot/reports`)、MaxConcurrency(默认 5)属性
+ - _Requirements: 6.1, 8.1, 10.1_
+ - [ ] 1.3 在 AppSettings 中添加 CdnPrefix 属性
+ - 在 `MiAssessment.Model/Models/Auth/AppSettings.cs` 中添加 `CdnPrefix` 字符串属性(默认空字符串)
+ - appsettings.json 中已有 `"CdnPrefix": ""`,确保模型能正确绑定
+ - _Requirements: 6.3_
+ - [ ] 1.4 在 AssessmentRecord 实体添加 ReportUrl 字段
+ - 在 `MiAssessment.Model/Entities/AssessmentRecord.cs` 添加 `ReportUrl` 属性(nvarchar(500),可空)
+ - 添加 `[MaxLength(500)]` 特性和 XML 注释
+ - _Requirements: 9.1_
+ - [ ] 1.5 执行数据库迁移脚本
+ - 创建 SQL 脚本 `ALTER TABLE assessment_records ADD ReportUrl NVARCHAR(500) NULL;`
+ - 放置在 `temp_sql/` 目录下
+ - _Requirements: 9.1_
+
+- [ ] 2. Checkpoint - 确认配置模型和数据库变更
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [ ] 3. 截图服务实现
+ - [ ] 3.1 创建 IScreenshotService 接口
+ - 在 `MiAssessment.Core/Interfaces/IScreenshotService.cs` 定义接口
+ - 包含 `Task CaptureAsync(string url)` 方法
+ - 添加 XML 注释
+ - _Requirements: 1.3_
+ - [ ] 3.2 实现 ScreenshotService
+ - 在 `MiAssessment.Core/Services/ScreenshotService.cs` 实现 IScreenshotService
+ - 通过 `IHttpClientFactory.CreateClient("HtmlToImage")` 获取 HttpClient
+ - 注入 `HtmlToImageSettings` 和 `ILogger`
+ - 实现异步任务三步流程:
+ - 步骤 1:`POST /api/tasks/image` 提交任务(source.type=url, format=png, width=1309, height=926, fullPage=false, waitUntil=networkidle0, saveLocal=true),解析响应获取 taskId
+ - 步骤 2:`GET /api/tasks/{taskId}/status` 轮询状态,间隔 PollingIntervalMs,直到 completed/failed/stalled 或超过 MaxPollingSeconds
+ - 步骤 3:`GET /api/tasks/{taskId}/download` 下载 PNG 字节数组
+ - 任务提交失败时调用 `EnsureSuccessStatusCode()` 抛出 HttpRequestException
+ - 任务状态为 failed/stalled 时抛出 InvalidOperationException
+ - 轮询超时时抛出 TimeoutException
+ - 下载结果为空时抛出 InvalidOperationException
+ - 所有失败场景记录 Error 日志(含 URL、taskId、HTTP 状态码)
+ - _Requirements: 1.1, 1.3, 1.4, 1.5, 1.6, 1.7_
+ - [ ]* 3.3 编写 ScreenshotService 单元测试
+ - 在测试项目中创建 `ScreenshotServiceTests.cs`
+ - 使用 MockHttpMessageHandler 模拟 HTTP 响应
+ - 验证提交任务时的请求参数正确(source.type=url、format=png、width=1309、height=926、fullPage=false、waitUntil=networkidle0)
+ - 验证设置了 X-API-Key 请求头
+ - 验证轮询状态直到 completed 后下载结果
+ - 验证任务 failed 时抛出 InvalidOperationException
+ - 验证轮询超时时抛出 TimeoutException
+ - _Requirements: 1.3, 1.5, 1.6, 1.7_
+
+- [ ] 4. PDF 生成服务实现
+ - [ ] 4.1 创建 IPdfGenerationService 接口
+ - 在 `MiAssessment.Core/Interfaces/IPdfGenerationService.cs` 定义接口
+ - 包含 `Task GeneratePdfAsync(long recordId)` 方法
+ - 添加 XML 注释
+ - _Requirements: 7.2_
+ - [ ] 4.2 实现 PdfGenerationService 核心逻辑
+ - 在 `MiAssessment.Core/Services/PdfGenerationService.cs` 实现 IPdfGenerationService
+ - 注入 `MiAssessmentDbContext`、`IScreenshotService`、`ReportSettings`、`AppSettings`、`ILogger`
+ - 实现 `GeneratePdfAsync(long recordId)` 方法:
+ 1. 验证 `ReportSettings.BaseUrl` 非空,否则抛出 InvalidOperationException
+ 2. 查询 `report_page_configs` 中 Status=1 的记录,按 SortOrder 升序
+ 3. 无启用配置时抛出 InvalidOperationException
+ 4. 使用 SemaphoreSlim(MaxConcurrency) 控制并发
+ 5. PageType=1:从 `wwwroot/images/static-pages/` 读取图片,文件不存在时记录 Warning 跳过
+ 6. PageType=2:拼接完整 URL 调用 `IScreenshotService.CaptureAsync`,失败时记录 Error 跳过
+ 7. 过滤失败页面,按 SortOrder 排序
+ 8. 所有页面均失败时抛出 InvalidOperationException
+ 9. 部分失败时记录跳过的页面数量和名称
+ - _Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 4.1, 4.3, 4.4, 5.4, 5.5, 8.1, 8.2, 10.1, 10.2, 10.3_
+ - [ ] 4.3 实现 BuildPageUrl 静态方法
+ - 在 PdfGenerationService 中实现 `internal static string BuildPageUrl(string baseUrl, string routeUrl, long recordId)`
+ - 处理 baseUrl 末尾斜杠、routeUrl 前导斜杠
+ - 已有查询参数时使用 `&` 追加 recordId
+ - 已包含 recordId 参数时不重复追加
+ - 确保不出现双斜杠(协议部分除外)
+ - _Requirements: 4.1, 4.2, 8.3_
+ - [ ] 4.4 实现 PDF 合并与文件保存逻辑
+ - 使用 PdfSharpCore 创建 PDF 文档
+ - 每页尺寸设置为 981.75pt × 694.5pt(1309×926px 按 72/96 换算)
+ - 横版布局,页面边距为 0,图片铺满整页
+ - 文件名格式:`report_{recordId}_{yyyyMMddHHmmss}.pdf`
+ - 自动创建输出目录(`Directory.CreateDirectory`)
+ - 保存后更新 `assessment_records.ReportUrl` 为 `{CdnPrefix}/reports/{fileName}`
+ - CdnPrefix 为空时 URL 以 `/reports/` 开头
+ - 记录 Info 日志(含 recordId、文件路径)
+ - _Requirements: 5.1, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4, 6.5, 7.3, 9.2_
+ - [ ]* 4.5 编写 BuildPageUrl 属性测试
+ - **Property 1: URL 拼接正确性**
+ - 使用 FsCheck 生成随机 baseUrl、routeUrl、recordId
+ - 验证结果不含双斜杠(协议部分除外)、包含 recordId 参数且仅出现一次、已有 recordId 时不重复追加
+ - **Validates: Requirements 4.1, 4.2, 8.3**
+ - [ ]* 4.6 编写 PDF 页面顺序属性测试
+ - **Property 2: PDF 页面顺序与 SortOrder 一致**
+ - 使用 FsCheck 生成随机 SortOrder 列表和图片字节数组
+ - 验证 PDF 页面顺序严格按 SortOrder 升序排列
+ - **Validates: Requirements 5.1, 10.3**
+ - [ ]* 4.7 编写部分失败容错性属性测试
+ - **Property 3: 部分页面失败时的容错性**
+ - 使用 FsCheck 生成混合成功/失败的页面配置
+ - 验证 PDF 恰好包含所有成功页面,不包含失败页面
+ - **Validates: Requirements 4.4, 5.5**
+ - [ ]* 4.8 编写文件名格式属性测试
+ - **Property 4: 文件名格式正确性**
+ - 使用 FsCheck 生成随机 recordId 和 DateTime
+ - 验证文件名匹配 `^report_\d+_\d{14}\.pdf$`
+ - **Validates: Requirements 6.2**
+ - [ ]* 4.9 编写 ReportUrl 格式属性测试
+ - **Property 5: ReportUrl 格式正确性**
+ - 使用 FsCheck 生成随机 CdnPrefix 和文件名
+ - 验证 ReportUrl 格式为 `{CdnPrefix}/reports/{fileName}`,CdnPrefix 为空时以 `/reports/` 开头
+ - **Validates: Requirements 6.3**
+ - [ ]* 4.10 编写 PdfGenerationService 单元测试
+ - 验证 BaseUrl 为空时抛出异常
+ - 验证无启用配置时抛出异常
+ - 验证所有页面失败时抛出异常
+ - 验证部分页面失败时仍生成 PDF
+ - 验证 PageType=1 从正确路径读取文件
+ - 验证 PageType=2 调用 ScreenshotService
+ - 验证 PDF 生成后更新 ReportUrl
+ - _Requirements: 2.2, 3.2, 4.4, 5.4, 5.5, 8.2_
+
+- [ ] 5. Checkpoint - 确认核心服务实现
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [ ] 6. 服务注册与配置集成
+ - [ ] 6.1 在 appsettings.json 添加配置节
+ - 添加 `HtmlToImageSettings` 配置节(BaseUrl、ApiKey、TimeoutSeconds、PollingIntervalMs、MaxPollingSeconds)
+ - 添加 `ReportSettings` 配置节(BaseUrl、OutputPath、MaxConcurrency)
+ - _Requirements: 1.2, 6.1, 8.1, 10.1_
+ - [ ] 6.2 在 Program.cs 注册服务
+ - 绑定 `HtmlToImageSettings` 配置并注册为 Singleton
+ - 绑定 `ReportSettings` 配置并注册为 Singleton
+ - 注册命名 HttpClient `"HtmlToImage"`,配置 BaseAddress、Timeout、X-API-Key 默认请求头
+ - IScreenshotService 和 IPdfGenerationService 通过 Autofac ServiceModule 自动扫描注册(确认 ServiceModule 能扫描到新服务)
+ - _Requirements: 1.1, 1.2_
+ - [ ] 6.3 确保 MiAssessment.Core 项目引用 PdfSharpCore NuGet 包
+ - 在 `MiAssessment.Core.csproj` 中添加 ``
+ - _Requirements: 5.3_
+
+- [ ] 7. ReportQueueConsumer 集成 PDF 生成
+ - [ ] 7.1 修改 ReportQueueConsumer.ProcessMessageAsync
+ - 在 `ReportGenerationService.GenerateReportAsync` 成功后,通过 scope 解析 `IPdfGenerationService` 并调用 `GeneratePdfAsync`
+ - PDF 生成调用包裹在 try-catch 中,失败时仅记录 Error 日志,不改变 Status=4,不触发重试
+ - PDF 生成成功时记录 Info 日志
+ - _Requirements: 7.1, 7.4_
+ - [ ]* 7.2 编写 PDF 生成失败不影响记录状态的属性测试
+ - **Property 6: PDF 生成失败不影响测评记录状态**
+ - 验证 Status=4 的记录在 PDF 生成异常时 Status 保持为 4
+ - **Validates: Requirements 7.4**
+ - [ ]* 7.3 编写页面配置过滤与排序属性测试
+ - **Property 7: 页面配置过滤与排序**
+ - 使用 FsCheck 生成混合 Status 值的 report_page_configs 记录
+ - 验证查询结果仅包含 Status=1 的记录且按 SortOrder 升序
+ - **Validates: Requirements 2.1**
+
+- [ ] 8. 静态文件中间件配置
+ - [ ] 8.1 确保 wwwroot/reports/ 目录可通过 HTTP 访问
+ - 确认 Program.cs 中 `app.UseStaticFiles()` 已配置(已存在)
+ - 确保 `wwwroot/reports/` 目录下的 PDF 文件可直接通过 URL 下载
+ - 如需要,在 `wwwroot/reports/` 下创建 `.gitkeep` 文件确保目录存在
+ - _Requirements: 11.1, 11.2_
+
+- [ ] 9. Final checkpoint - 确认所有功能集成完成
+ - Ensure all tests pass, ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- Each task references specific requirements for traceability
+- Checkpoints ensure incremental validation
+- Property tests validate universal correctness properties from the design document
+- Unit tests validate specific examples and edge cases
+- IScreenshotService 和 IPdfGenerationService 通过 Autofac ServiceModule 自动扫描注册,无需手动注册
+- PDF 生成失败不影响结论数据完整性(Status 保持为 4)
diff --git a/docs/HtmltoImage外部对接文档.md b/docs/HtmltoImage外部对接文档.md
new file mode 100644
index 0000000..36a7771
--- /dev/null
+++ b/docs/HtmltoImage外部对接文档.md
@@ -0,0 +1,801 @@
+# HTML 转图片 / PDF 服务 — 外部对接文档
+
+> 版本:2.0.1 | 更新日期:2026-03-17
+
+---
+
+## 1. 概述
+
+本服务提供 HTML/URL 转图片和 PDF 的能力,支持同步和异步两种调用模式:
+
+- **同步模式**:请求后直接返回文件流,适合简单、低并发场景
+- **异步模式(推荐)**:提交任务后通过轮询或回调获取结果,适合高并发、大批量场景
+
+**内部测试地址**:`http://192.168.195.15:5100`
+
+---
+
+## 2. 认证
+
+服务支持 API Key 认证。如果服务端开启了认证,所有业务接口都需要携带 Token。
+
+### 2.1 获取 Token
+
+```
+POST /api/auth/token
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "apiKey": "your-api-key",
+ "userId": "your-user-id"
+}
+```
+
+**响应:**
+
+```json
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiIs...",
+ "tokenType": "Bearer",
+ "expiresIn": 3600,
+ "expiresAt": "2026-03-17T11:00:00Z"
+}
+```
+
+### 2.2 请求认证方式
+
+服务支持两种认证方式,任选其一即可:
+
+**方式一:JWT Token(通过 API Key 换取)**
+
+```
+Authorization: Bearer {accessToken}
+```
+
+**方式二:直接使用 API Key**
+
+```
+X-API-Key: your-api-key
+```
+
+或:
+
+```
+Authorization: ApiKey your-api-key
+```
+
+推荐使用 `X-API-Key` Header,Query String 方式(`?api_key=xxx`)存在 Key 泄露到日志的风险。
+
+> 以下路径无需认证:`/health`、`/swagger`、`/metrics`、`/api/auth/token`
+
+### 2.3 刷新 Token
+
+```
+POST /api/auth/refresh
+Authorization: Bearer {当前Token}
+```
+
+---
+
+## 3. 同步转换接口
+
+适合简单场景,请求后直接返回文件二进制流。
+
+### 3.1 HTML 转图片
+
+```
+POST /api/image/convert/html
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "html": "Hello
",
+ "options": {
+ "format": "png",
+ "quality": 90,
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true,
+ "omitBackground": false
+ },
+ "saveLocal": false
+}
+```
+
+**响应**:对应格式的图片文件流(`image/png`、`image/jpeg`、`image/webp`)
+
+### 3.2 URL 转图片
+
+```
+POST /api/image/convert/url
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "url": "https://example.com",
+ "waitUntil": "networkidle0",
+ "timeout": 30000,
+ "options": {
+ "format": "png",
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true
+ },
+ "saveLocal": false
+}
+```
+
+### 3.3 HTML 转 PDF
+
+```
+POST /api/pdf/convert/html
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "html": "Hello World
",
+ "options": {
+ "format": "A4",
+ "landscape": false,
+ "printBackground": true,
+ "margin": {
+ "top": "10mm",
+ "right": "10mm",
+ "bottom": "10mm",
+ "left": "10mm"
+ }
+ },
+ "saveLocal": false
+}
+```
+
+**响应**:`application/pdf` 文件流
+
+### 3.4 URL 转 PDF
+
+```
+POST /api/pdf/convert/url
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "url": "https://example.com",
+ "waitUntil": "networkidle0",
+ "timeout": 30000,
+ "options": {
+ "format": "A4",
+ "printBackground": true
+ },
+ "saveLocal": false
+}
+```
+
+---
+
+## 4. 异步任务接口(推荐)
+
+异步模式下,提交任务后立即返回任务 ID,通过轮询状态或配置回调来获取结果。
+
+### 4.1 提交图片任务
+
+```
+POST /api/tasks/image
+Content-Type: application/json
+Idempotency-Key: {可选,幂等键}
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "html",
+ "content": "Hello World
"
+ },
+ "options": {
+ "format": "png",
+ "quality": 90,
+ "width": 1920,
+ "height": 1080,
+ "fullPage": true,
+ "omitBackground": false
+ },
+ "waitUntil": "networkidle2",
+ "timeout": 60000,
+ "delayAfterLoad": 1000,
+ "callback": {
+ "url": "https://your-app.com/webhook/image-done",
+ "headers": {
+ "X-Custom-Header": "value"
+ },
+ "includeFileData": false
+ },
+ "saveLocal": true,
+ "metadata": {
+ "orderId": "12345",
+ "source": "your-system"
+ }
+}
+```
+
+> `source.type` 为 `"html"` 时,`content` 填 HTML 字符串;为 `"url"` 时,`content` 填页面 URL。
+> `delayAfterLoad`:页面加载完成后额外等待的毫秒数,适合有延迟动画的页面。
+
+**响应(202 Accepted):**
+
+```json
+{
+ "taskId": "550e8400-e29b-41d4-a716-446655440000",
+ "status": "pending",
+ "message": "任务已创建,正在排队处理",
+ "createdAt": "2026-03-17T10:00:00Z",
+ "estimatedWaitTime": 3,
+ "queuePosition": 2,
+ "links": {
+ "self": "/api/tasks/550e8400-...",
+ "status": "/api/tasks/550e8400-.../status",
+ "download": "/api/tasks/550e8400-.../download"
+ }
+}
+```
+
+### 4.2 提交 PDF 任务
+
+```
+POST /api/tasks/pdf
+Content-Type: application/json
+Idempotency-Key: {可选}
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "html",
+ "content": "Hello World
"
+ },
+ "options": {
+ "format": "A4",
+ "landscape": false,
+ "printBackground": true,
+ "margin": {
+ "top": "10mm",
+ "right": "10mm",
+ "bottom": "10mm",
+ "left": "10mm"
+ }
+ },
+ "waitUntil": "networkidle0",
+ "timeout": 60000,
+ "callback": {
+ "url": "https://your-app.com/webhook/pdf-done",
+ "includeFileData": false
+ },
+ "saveLocal": true,
+ "metadata": {
+ "orderId": "12345"
+ }
+}
+```
+
+### 4.3 批量提交任务
+
+```
+POST /api/tasks/batch
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "tasks": [
+ {
+ "type": "image",
+ "source": { "type": "html", "content": "截图 1
" },
+ "imageOptions": { "format": "png", "width": 1920, "height": 1080 }
+ },
+ {
+ "type": "image",
+ "source": { "type": "url", "content": "https://example.com" },
+ "imageOptions": { "format": "jpeg", "quality": 85 },
+ "waitUntil": "networkidle0"
+ },
+ {
+ "type": "pdf",
+ "source": { "type": "html", "content": "文档
" },
+ "pdfOptions": { "format": "A4", "printBackground": true }
+ }
+ ],
+ "callback": {
+ "url": "https://your-app.com/webhook/batch-done"
+ },
+ "onEachComplete": false,
+ "onAllComplete": true
+}
+```
+
+**响应(202 Accepted):**
+
+```json
+{
+ "batchId": "batch-xxxx-xxxx",
+ "taskIds": ["task-1", "task-2", "task-3"],
+ "totalTasks": 3,
+ "successCount": 3,
+ "failedCount": 0,
+ "links": {
+ "status": "/api/tasks/batch/batch-xxxx-xxxx"
+ }
+}
+```
+
+### 4.4 查询任务详情
+
+```
+GET /api/tasks/{taskId}
+```
+
+**响应:**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "type": "image",
+ "source": { "type": "html", "content": "..." },
+ "status": "completed",
+ "createdAt": "2026-03-17T10:00:00Z",
+ "startedAt": "2026-03-17T10:00:01Z",
+ "completedAt": "2026-03-17T10:00:03Z",
+ "duration": 2000,
+ "retryCount": 0,
+ "result": {
+ "fileSize": 204800,
+ "fileType": "png",
+ "downloadUrl": "/api/tasks/550e8400-.../download"
+ },
+ "error": null,
+ "links": {
+ "self": "/api/tasks/550e8400-...",
+ "download": "/api/tasks/550e8400-.../download"
+ }
+}
+```
+
+### 4.5 轻量级状态查询
+
+适合高频轮询场景:
+
+```
+GET /api/tasks/{taskId}/status
+```
+
+**响应:**
+
+```json
+{
+ "taskId": "550e8400-...",
+ "status": "processing",
+ "createdAt": "2026-03-17T10:00:00Z",
+ "startedAt": "2026-03-17T10:00:01Z",
+ "completedAt": null
+}
+```
+
+### 4.6 查询任务列表
+
+```
+GET /api/tasks?status=completed&type=image&page=1&pageSize=20
+```
+
+**Query 参数:**
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `status` | string | 按状态筛选:pending / processing / completed / failed / cancelled |
+| `type` | string | 按类型筛选:image / pdf |
+| `startDate` | datetime | 创建时间起始 |
+| `endDate` | datetime | 创建时间截止 |
+| `page` | int | 页码,默认 1 |
+| `pageSize` | int | 每页数量,默认 20,最大 100 |
+
+### 4.7 下载结果文件
+
+```
+GET /api/tasks/{taskId}/download
+```
+
+**响应**:文件二进制流,Content-Type 根据任务类型自动设置(`image/png`、`image/jpeg`、`image/webp`、`application/pdf`)。
+
+响应头包含:
+- `X-Task-Id`: 任务 ID
+- `X-Expires-At`: 文件过期时间(如有)
+
+> 任务未完成时返回 `409 Conflict`。
+
+### 4.8 取消任务
+
+```
+DELETE /api/tasks/{taskId}
+```
+
+> 仅 `pending` 状态的任务可取消,其他状态返回 `409 Conflict`。
+
+### 4.9 重试任务
+
+```
+POST /api/tasks/{taskId}/retry
+```
+
+> 仅 `failed` 状态的任务可重试,返回 `202 Accepted`。
+
+### 4.10 查询批量任务状态
+
+```
+GET /api/tasks/batch/{batchId}
+```
+
+### 4.11 预检(Dry-run)
+
+提交前检查内容是否可渲染、是否存在 SSRF 风险:
+
+```
+POST /api/tasks/validate
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+ "source": {
+ "type": "url",
+ "content": "https://example.com"
+ }
+}
+```
+
+**响应:**
+
+```json
+{
+ "ok": true,
+ "canRender": true,
+ "suggestedQueue": "normal",
+ "estimatedRenderTimeMs": 2000,
+ "issues": [],
+ "ssrfBlocked": false
+}
+```
+
+### 4.12 回调重放
+
+手动重新触发某个任务的回调通知:
+
+```
+POST /api/tasks/{taskId}/callback/replay
+Content-Type: application/json
+```
+
+**请求体(可选):**
+
+```json
+{
+ "attempts": 3
+}
+```
+
+### 4.13 查询回调日志
+
+```
+GET /api/tasks/{taskId}/callback/logs
+```
+
+**响应:**
+
+```json
+[
+ {
+ "id": "log-xxx",
+ "taskId": "550e8400-...",
+ "callbackUrl": "https://your-app.com/webhook",
+ "attempt": 1,
+ "responseStatus": 200,
+ "success": true,
+ "errorMessage": null,
+ "sentAt": "2026-03-17T10:00:05Z",
+ "responseAt": "2026-03-17T10:00:05Z",
+ "durationMs": 120,
+ "isReplay": false
+ }
+]
+```
+
+---
+
+## 5. 配额查询
+
+### 5.1 查看当前配额
+
+```
+GET /api/quota
+```
+
+### 5.2 检查配额是否足够
+
+```
+GET /api/quota/check
+```
+
+**响应:**
+
+```json
+{
+ "allowed": true,
+ "denyReason": null,
+ "dailyRemaining": 950,
+ "monthlyRemaining": 9800,
+ "concurrentRemaining": 8
+}
+```
+
+---
+
+## 6. 健康检查
+
+```
+GET /health
+```
+
+---
+
+## 7. 回调机制
+
+异步任务支持配置回调 URL,任务完成后服务会主动 POST 通知你的系统。
+
+### 7.1 回调请求格式
+
+```
+POST https://your-app.com/webhook/image-done
+Content-Type: application/json
+X-Callback-Signature: {HMAC签名}
+```
+
+回调 Body 包含任务完成信息(taskId、status、result 等)。如果 `includeFileData` 设为 `true`,回调中会包含 Base64 编码的文件数据。
+
+### 7.2 回调重试
+
+回调失败时,服务会自动进行指数退避重试(最多 3 次)。也可通过 4.12 接口手动重放。
+
+---
+
+## 8. 参数参考
+
+### 8.1 图片选项(options)
+
+| 字段 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `format` | string | `"png"` | 图片格式:`png`、`jpeg`、`webp` |
+| `quality` | int | `90` | 图片质量(1-100),仅 jpeg/webp 有效 |
+| `width` | int | `1920` | 浏览器视口宽度(像素) |
+| `height` | int | `1080` | 浏览器视口高度(像素) |
+| `fullPage` | bool | `true` | 是否截取整个页面(含滚动区域) |
+| `omitBackground` | bool | `false` | 是否透明背景,仅 png 有效 |
+
+> `width`/`height` 直接控制浏览器窗口大小,页面会在该分辨率下渲染后截图。
+
+### 8.2 PDF 选项(options)
+
+| 字段 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `format` | string | `"A4"` | 纸张格式:A4, Letter, Legal, A3 等 |
+| `landscape` | bool | `false` | 是否横向 |
+| `printBackground` | bool | `true` | 是否打印背景色/图 |
+| `width` | string | — | 自定义页面宽度(如 `"210mm"`),设置后忽略 format |
+| `height` | string | — | 自定义页面高度(如 `"297mm"`),设置后忽略 format |
+| `viewportWidth` | int | — | 浏览器视口宽度(像素) |
+| `viewportHeight` | int | — | 浏览器视口高度(像素) |
+| `margin.top/right/bottom/left` | string | `"10mm"` | 页面边距 |
+
+### 8.3 waitUntil 参数
+
+| 值 | 说明 |
+|----|------|
+| `load` | 等待 `load` 事件触发 |
+| `domcontentloaded` | 等待 `DOMContentLoaded` 事件 |
+| `networkidle0` | 等待 500ms 内无网络请求(推荐 SPA 页面) |
+| `networkidle2` | 等待 500ms 内不超过 2 个网络请求(默认) |
+
+### 8.4 任务状态
+
+| 状态 | 说明 |
+|------|------|
+| `pending` | 排队中 |
+| `processing` | 处理中 |
+| `completed` | 已完成 |
+| `failed` | 失败 |
+| `cancelled` | 已取消 |
+| `stalled` | 卡死(超时未完成) |
+
+---
+
+## 9. 错误处理
+
+所有错误响应遵循 RFC 7807 ProblemDetails 格式:
+
+```json
+{
+ "status": 400,
+ "title": "请求参数无效",
+ "detail": "HTML 内容不能为空"
+}
+```
+
+| HTTP 状态码 | 含义 | 处理建议 |
+|-------------|------|----------|
+| 400 | 参数错误 | 检查请求体格式和必填字段 |
+| 401 | 认证失败 | 检查 Token 或 API Key |
+| 404 | 任务不存在 | 确认 taskId 是否正确 |
+| 409 | 状态冲突 | 任务状态不允许当前操作 |
+| 429 | 请求限流 | 降低请求频率,稍后重试 |
+| 503 | 服务繁忙 | 队列已满或服务过载,稍后重试 |
+
+---
+
+## 10. .NET SDK 接入
+
+### 10.1 安装
+
+```bash
+dotnet add package HtmlToPdfService.Client
+```
+
+### 10.2 注册服务
+
+```csharp
+builder.Services.AddHtmlToPdfClient(options =>
+{
+ options.BaseUrl = "http://192.168.195.15:5100";
+ options.ApiKey = "your-api-key";
+ options.TimeoutSeconds = 120;
+ options.EnableRetry = true;
+ options.RetryCount = 3;
+});
+```
+
+### 10.3 使用示例
+
+```csharp
+// 同步转换 — 直接拿到图片字节数组
+public async Task ScreenshotAsync(string html)
+{
+ return await _client.ConvertHtmlToImageAsync(html, new ImageOptions
+ {
+ Format = "png",
+ Width = 1920,
+ Height = 1080,
+ FullPage = true
+ });
+}
+
+// 异步任务 — 提交后等待完成再下载
+public async Task ScreenshotAsyncTask(string html)
+{
+ var result = await _client.SubmitImageTaskAsync(
+ source: new SourceInfo { Type = "html", Content = html },
+ options: new ImageOptions { Format = "png", Width = 1920 });
+
+ return await _client.WaitAndDownloadAsync(result.TaskId);
+}
+```
+
+### 10.4 SDK 配置项
+
+| 配置项 | 类型 | 默认值 | 说明 |
+|--------|------|--------|------|
+| `BaseUrl` | string | — | 服务地址(必填) |
+| `ApiKey` | string | null | API Key |
+| `TimeoutSeconds` | int | 120 | HTTP 请求超时 |
+| `EnableRetry` | bool | false | 是否启用自动重试 |
+| `RetryCount` | int | 3 | 重试次数 |
+
+---
+
+## 11. 其他语言接入(curl 示例)
+
+### 同步 HTML 转图片
+
+```bash
+curl -X POST http://192.168.195.15:5100/api/image/convert/html \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: your-api-key" \
+ -d '{"html": "Hello
", "options": {"format": "png", "width": 1920, "height": 1080}}' \
+ -o screenshot.png
+```
+
+### 异步提交图片任务
+
+```bash
+curl -X POST http://192.168.195.15:5100/api/tasks/image \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: your-api-key" \
+ -d '{
+ "source": {"type": "html", "content": "Hello
"},
+ "options": {"format": "png", "width": 1920, "height": 1080}
+ }'
+```
+
+### 查询状态
+
+```bash
+curl http://192.168.195.15:5100/api/tasks/{taskId}/status \
+ -H "X-API-Key: your-api-key"
+```
+
+### 下载文件
+
+```bash
+curl http://192.168.195.15:5100/api/tasks/{taskId}/download \
+ -H "X-API-Key: your-api-key" \
+ -o result.png
+```
+
+---
+
+## 12. 典型接入流程
+
+### 方式一:同步调用(简单直接)
+
+```
+客户端 → POST /api/image/convert/html → 等待 → 返回图片文件流
+```
+
+适合:单次截图、低并发、对延迟不敏感。
+
+### 方式二:异步轮询
+
+```
+客户端 → POST /api/tasks/image → 返回 taskId
+客户端 → GET /api/tasks/{taskId}/status → 轮询直到 completed
+客户端 → GET /api/tasks/{taskId}/download → 下载图片
+```
+
+适合:需要异步处理但不方便接收回调。
+
+### 方式三:异步回调(推荐)
+
+```
+客户端 → POST /api/tasks/image(带 callback.url)→ 返回 taskId
+服务端 → 处理完成后 POST 回调到 callback.url
+客户端 → 收到回调后 GET /api/tasks/{taskId}/download → 下载图片
+```
+
+适合:高并发、批量处理、生产环境。
+
+---
+
+## 13. 注意事项
+
+1. **幂等性**:异步接口支持 `Idempotency-Key` 请求头,相同 Key 不会重复创建任务
+2. **文件有效期**:生成的文件默认保留 7 天,请及时下载
+3. **SSRF 防护**:URL 模式下,服务会阻止访问内网地址(10.x、192.168.x、127.x 等)
+4. **请求限流**:服务有 IP/用户维度的速率限制,超限返回 429
+5. **超时设置**:复杂页面建议适当增大 `timeout` 参数(单位:毫秒)
+6. **SPA 页面**:转换 React/Vue/Angular 等 SPA 页面时,建议 `waitUntil` 设为 `networkidle0`,并配合 `delayAfterLoad` 等待动画完成