This commit is contained in:
zpc 2026-03-17 21:38:54 +08:00
parent 6dc392f2ab
commit 7b4a8732a6
5 changed files with 1748 additions and 0 deletions

View File

@ -0,0 +1 @@
{"specId": "a7e2c1d4-8f3b-4e6a-9c5d-2b1f0e8d7a6c", "workflowType": "requirements-first", "specType": "feature"}

View 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. IPdfGenerationServicePDF 生成服务接口)
```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. PdfGenerationServicePDF 生成服务实现)
```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验证截图失败时跳过并记录错误 |
| ReportQueueConsumerPDF 集成) | 验证 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 数据)

View File

@ -0,0 +1,148 @@
# 需求文档PDF 报告生成
## 简介
PDF 报告生成功能是学业邑规划测评系统的核心输出环节。当测评结论数据生成完成assessment_records.Status=4系统按照 `report_page_configs` 表的配置顺序将每页报告Razor Pages 渲染的 HTML 网页截图页和静态图片页)通过外部 HtmlToImage 截图服务转换成图片,然后将所有图片按顺序合并成一个 PDF 文件,存储到本地磁盘并将访问 URL 写入测评记录。
整个 PDF 生成流程在现有报告队列消费者ReportQueueConsumer完成结论数据生成之后自动触发作为报告生成流水线的最后一步。
## 术语表
- **PDF_Generation_ServicePDF 生成服务)**:后端核心服务,负责编排截图、图片收集和 PDF 合并的完整流程
- **Screenshot_Service截图服务**:后端服务,封装对外部 HtmlToImage 截图服务的调用,将 URL 或静态图片转换为 PNG 图片字节数组
- **HtmlToImage_ClientHtmlToImage 客户端)**:通过 `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 供截图服务访问
## 需求
### 需求 1HtmlToImage 截图服务集成
**用户故事:** 作为后端系统,我希望集成外部 HtmlToImage 截图服务的 REST API以便将报告网页页面转换为图片。
#### 验收标准
1. THE Screenshot_Service SHALL 通过 `IHttpClientFactory` 创建命名 HttpClient`"HtmlToImage"`),配置项包括 BaseUrl截图服务地址、ApiKey认证密钥通过 `X-API-Key` 请求头传递、TimeoutSecondsHTTP 请求超时时间、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=falsewaitUntil=networkidle0saveLocal=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_ImageTHEN THE PDF_Generation_Service SHALL 抛出业务异常,包含"所有报告页面处理失败,无法生成 PDF"提示信息
5. WHEN 部分页面处理失败但仍有有效的 Page_Image 时THE PDF_Generation_Service SHALL 使用已成功获取的 Page_Image 生成 PDF并在日志中记录跳过的页面数量和名称
### 需求 6PDF 文件存储
**用户故事:** 作为后端系统,我希望将生成的 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 抛出异常,包含具体的文件系统错误信息
### 需求 7PDF 生成流程编排
**用户故事:** 作为后端系统,我希望 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 尚未生成成功
### 需求 8Base_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 格式正确(不出现双斜杠)
### 需求 9assessment_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 和截图耗时毫秒数
### 需求 11PDF 报告下载接口
**用户故事:** 作为小程序用户,我希望能通过接口下载已生成的 PDF 报告文件。
#### 验收标准
1. WHEN 小程序请求报告详情时THE API SHALL 在响应中返回 Assessment_Record 的 ReportUrl 字段值,前端通过该 URL 直接下载 PDF 文件
2. THE API 项目 SHALL 配置静态文件中间件,使 `wwwroot/reports/` 目录下的 PDF 文件可通过 HTTP 直接访问
3. IF Assessment_Record 的 ReportUrl 为空或 nullTHEN THE API SHALL 在响应中返回空字符串,前端据此判断 PDF 报告尚未生成

View 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.5pt1309×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

View 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` HeaderQuery 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` 等待动画完成