From 7b4a8732a600fc63206b4a5f0ade415ea2c0c39b Mon Sep 17 00:00:00 2001 From: zpc Date: Tue, 17 Mar 2026 21:38:54 +0800 Subject: [PATCH] 21 --- .../specs/report-pdf-generation/.config.kiro | 1 + .kiro/specs/report-pdf-generation/design.md | 609 +++++++++++++ .../report-pdf-generation/requirements.md | 148 ++++ .kiro/specs/report-pdf-generation/tasks.md | 189 +++++ docs/HtmltoImage外部对接文档.md | 801 ++++++++++++++++++ 5 files changed, 1748 insertions(+) create mode 100644 .kiro/specs/report-pdf-generation/.config.kiro create mode 100644 .kiro/specs/report-pdf-generation/design.md create mode 100644 .kiro/specs/report-pdf-generation/requirements.md create mode 100644 .kiro/specs/report-pdf-generation/tasks.md create mode 100644 docs/HtmltoImage外部对接文档.md 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` 等待动画完成