21
This commit is contained in:
parent
6dc392f2ab
commit
7b4a8732a6
1
.kiro/specs/report-pdf-generation/.config.kiro
Normal file
1
.kiro/specs/report-pdf-generation/.config.kiro
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"specId": "a7e2c1d4-8f3b-4e6a-9c5d-2b1f0e8d7a6c", "workflowType": "requirements-first", "specType": "feature"}
|
||||
609
.kiro/specs/report-pdf-generation/design.md
Normal file
609
.kiro/specs/report-pdf-generation/design.md
Normal file
|
|
@ -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[本地文件系统<br/>wwwroot/images/static-pages/]
|
||||
D -->|5b. PageType=2 并发| G[ScreenshotService]
|
||||
G -->|6. 提交任务<br/>POST /api/tasks/image| H[HtmlToImage 截图服务<br/>192.168.195.15:5100]
|
||||
G -->|7. 轮询状态<br/>GET /api/tasks/taskId/status| H
|
||||
H -->|8. 下载结果<br/>GET /api/tasks/taskId/download| G
|
||||
G -->|9. 返回 PNG 字节| D
|
||||
D -->|10. 合并图片| I[PdfSharpCore<br/>生成 PDF]
|
||||
I -->|11. 保存文件| J[wwwroot/reports/<br/>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
|
||||
/// <summary>
|
||||
/// PDF 报告生成配置
|
||||
/// </summary>
|
||||
public class ReportSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// API 服务内网访问地址,用于拼接报告页面 URL 供截图服务访问
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// PDF 输出目录路径(相对于 ContentRootPath 或绝对路径)
|
||||
/// </summary>
|
||||
public string OutputPath { get; set; } = "wwwroot/reports";
|
||||
|
||||
/// <summary>
|
||||
/// 最大并发截图数
|
||||
/// </summary>
|
||||
public int MaxConcurrency { get; set; } = 5;
|
||||
}
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Models/ReportSettings.cs`
|
||||
|
||||
### 2. HtmlToImageSettings(截图服务配置模型)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// HtmlToImage 截图服务配置
|
||||
/// </summary>
|
||||
public class HtmlToImageSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// 截图服务地址
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// API Key 认证密钥
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 请求超时时间(秒)
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// 轮询任务状态的间隔(毫秒)
|
||||
/// </summary>
|
||||
public int PollingIntervalMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// 最大轮询等待时间(秒),超过则视为超时
|
||||
/// </summary>
|
||||
public int MaxPollingSeconds { get; set; } = 120;
|
||||
}
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Models/HtmlToImageSettings.cs`
|
||||
|
||||
### 3. IScreenshotService(截图服务接口)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 截图服务接口,封装对外部 HtmlToImage 服务的调用
|
||||
/// </summary>
|
||||
public interface IScreenshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// 对指定 URL 进行截图,返回 PNG 图片字节数组
|
||||
/// </summary>
|
||||
/// <param name="url">页面完整 URL</param>
|
||||
/// <returns>PNG 图片字节数组</returns>
|
||||
Task<byte[]> CaptureAsync(string url);
|
||||
}
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Interfaces/IScreenshotService.cs`
|
||||
|
||||
### 4. ScreenshotService(截图服务实现)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 截图服务实现,通过 HttpClient 直接调用外部 HtmlToImage REST API
|
||||
/// </summary>
|
||||
public class ScreenshotService : IScreenshotService
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Services/ScreenshotService.cs`
|
||||
|
||||
依赖注入:
|
||||
- `IHttpClientFactory`:创建 HttpClient 实例(使用命名客户端 `"HtmlToImage"`)
|
||||
- `HtmlToImageSettings`:截图服务配置(BaseUrl、ApiKey、TimeoutSeconds)
|
||||
- `ILogger<ScreenshotService>`:日志
|
||||
|
||||
职责:
|
||||
- 通过 `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
|
||||
/// <summary>
|
||||
/// PDF 报告生成服务接口
|
||||
/// </summary>
|
||||
public interface IPdfGenerationService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据测评记录 ID 生成 PDF 报告
|
||||
/// </summary>
|
||||
/// <param name="recordId">测评记录 ID</param>
|
||||
Task GeneratePdfAsync(long recordId);
|
||||
}
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Interfaces/IPdfGenerationService.cs`
|
||||
|
||||
### 6. PdfGenerationService(PDF 生成服务实现)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// PDF 报告生成服务实现
|
||||
/// </summary>
|
||||
public class PdfGenerationService : IPdfGenerationService
|
||||
```
|
||||
|
||||
位置:`MiAssessment.Core/Services/PdfGenerationService.cs`
|
||||
|
||||
依赖注入:
|
||||
- `MiAssessmentDbContext`:查询页面配置、更新 ReportUrl
|
||||
- `IScreenshotService`:网页截图
|
||||
- `ReportSettings`:配置(BaseUrl、OutputPath、MaxConcurrency)
|
||||
- `AppSettings`:CdnPrefix 配置
|
||||
- `ILogger<PdfGenerationService>`:日志
|
||||
|
||||
核心方法 `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
|
||||
/// <summary>
|
||||
/// 拼接报告页面完整 URL
|
||||
/// </summary>
|
||||
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<IPdfGenerationService>();
|
||||
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
|
||||
/// <summary>
|
||||
/// PDF 报告文件访问 URL
|
||||
/// </summary>
|
||||
[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<HtmlToImageSettings>();
|
||||
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
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 1: URL 拼接正确性
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property BuildPageUrlProducesValidUrl() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 2: PDF 页面顺序与 SortOrder 一致
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property PdfPageOrderMatchesSortOrder() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 3: 部分页面失败时的容错性
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property PartialFailureProducesPartialPdf() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 4: 文件名格式正确性
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property FileNameMatchesExpectedFormat() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 5: ReportUrl 格式正确性
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property ReportUrlMatchesExpectedFormat() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 6: PDF 生成失败不影响测评记录状态
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public Property PdfFailurePreservesRecordStatus() { ... }
|
||||
|
||||
/// <summary>
|
||||
/// Feature: report-pdf-generation, Property 7: 页面配置过滤与排序
|
||||
/// </summary>
|
||||
[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 数据)
|
||||
148
.kiro/specs/report-pdf-generation/requirements.md
Normal file
148
.kiro/specs/report-pdf-generation/requirements.md
Normal file
|
|
@ -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 报告尚未生成
|
||||
189
.kiro/specs/report-pdf-generation/tasks.md
Normal file
189
.kiro/specs/report-pdf-generation/tasks.md
Normal file
|
|
@ -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<byte[]> CaptureAsync(string url)` 方法
|
||||
- 添加 XML 注释
|
||||
- _Requirements: 1.3_
|
||||
- [ ] 3.2 实现 ScreenshotService
|
||||
- 在 `MiAssessment.Core/Services/ScreenshotService.cs` 实现 IScreenshotService
|
||||
- 通过 `IHttpClientFactory.CreateClient("HtmlToImage")` 获取 HttpClient
|
||||
- 注入 `HtmlToImageSettings` 和 `ILogger<ScreenshotService>`
|
||||
- 实现异步任务三步流程:
|
||||
- 步骤 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<PdfGenerationService>`
|
||||
- 实现 `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` 中添加 `<PackageReference Include="PdfSharpCore" />`
|
||||
- _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)
|
||||
801
docs/HtmltoImage外部对接文档.md
Normal file
801
docs/HtmltoImage外部对接文档.md
Normal file
|
|
@ -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": "<html><body><h1>Hello</h1></body></html>",
|
||||
"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": "<html><body><h1>Hello World</h1></body></html>",
|
||||
"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": "<h1>Hello World</h1>"
|
||||
},
|
||||
"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": "<h1>Hello World</h1>"
|
||||
},
|
||||
"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": "<h1>截图 1</h1>" },
|
||||
"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": "<h1>文档</h1>" },
|
||||
"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<byte[]> ScreenshotAsync(string html)
|
||||
{
|
||||
return await _client.ConvertHtmlToImageAsync(html, new ImageOptions
|
||||
{
|
||||
Format = "png",
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
FullPage = true
|
||||
});
|
||||
}
|
||||
|
||||
// 异步任务 — 提交后等待完成再下载
|
||||
public async Task<byte[]> 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": "<h1>Hello</h1>", "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": "<h1>Hello</h1>"},
|
||||
"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` 等待动画完成
|
||||
Loading…
Reference in New Issue
Block a user