feat(report): 添加 PDF 报告生成功能

- 实现 ScreenshotService,通过 HtmlToImage 异步任务 API 截图
- 实现 PdfGenerationService,将截图合并为 PDF 并保存到本地
- 在 ReportQueueConsumer 中集成 PDF 生成流程
- 添加 HtmlToImageSettings、ReportSettings 配置模型
- AssessmentRecord 新增 ReportUrl 字段
- 添加 DebugController 用于手动触发 PDF 生成测试
- 添加 PdfSharpCore NuGet 包依赖
- 更新 .gitignore 忽略生成的 PDF 文件
This commit is contained in:
zpc 2026-03-17 23:05:53 +08:00
parent 7b4a8732a6
commit 6d81fa45f4
18 changed files with 705 additions and 26 deletions

View File

@ -6,39 +6,39 @@
## Tasks
- [ ] 1. 配置模型与数据库变更
- [ ] 1.1 创建 HtmlToImageSettings 配置模型
- [x] 1. 配置模型与数据库变更
- [x] 1.1 创建 HtmlToImageSettings 配置模型
- 在 `MiAssessment.Core/Models/HtmlToImageSettings.cs` 创建配置类
- 包含 BaseUrl、ApiKey、TimeoutSeconds、PollingIntervalMs默认 1000、MaxPollingSeconds默认 120属性
- 所有属性添加 XML 注释
- _Requirements: 1.1, 1.2_
- [ ] 1.2 创建 ReportSettings 配置模型
- [x] 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 属性
- [x] 1.3 在 AppSettings 中添加 CdnPrefix 属性
- 在 `MiAssessment.Model/Models/Auth/AppSettings.cs` 中添加 `CdnPrefix` 字符串属性(默认空字符串)
- appsettings.json 中已有 `"CdnPrefix": ""`,确保模型能正确绑定
- _Requirements: 6.3_
- [ ] 1.4 在 AssessmentRecord 实体添加 ReportUrl 字段
- [x] 1.4 在 AssessmentRecord 实体添加 ReportUrl 字段
- 在 `MiAssessment.Model/Entities/AssessmentRecord.cs` 添加 `ReportUrl` 属性nvarchar(500),可空)
- 添加 `[MaxLength(500)]` 特性和 XML 注释
- _Requirements: 9.1_
- [ ] 1.5 执行数据库迁移脚本
- [x] 1.5 执行数据库迁移脚本
- 创建 SQL 脚本 `ALTER TABLE assessment_records ADD ReportUrl NVARCHAR(500) NULL;`
- 放置在 `temp_sql/` 目录下
- _Requirements: 9.1_
- [ ] 2. Checkpoint - 确认配置模型和数据库变更
- [x] 2. Checkpoint - 确认配置模型和数据库变更
- Ensure all tests pass, ask the user if questions arise.
- [ ] 3. 截图服务实现
- [ ] 3.1 创建 IScreenshotService 接口
- [x] 3. 截图服务实现
- [x] 3.1 创建 IScreenshotService 接口
- 在 `MiAssessment.Core/Interfaces/IScreenshotService.cs` 定义接口
- 包含 `Task<byte[]> CaptureAsync(string url)` 方法
- 添加 XML 注释
- _Requirements: 1.3_
- [ ] 3.2 实现 ScreenshotService
- [x] 3.2 实现 ScreenshotService
- 在 `MiAssessment.Core/Services/ScreenshotService.cs` 实现 IScreenshotService
- 通过 `IHttpClientFactory.CreateClient("HtmlToImage")` 获取 HttpClient
- 注入 `HtmlToImageSettings``ILogger<ScreenshotService>`
@ -62,13 +62,13 @@
- 验证轮询超时时抛出 TimeoutException
- _Requirements: 1.3, 1.5, 1.6, 1.7_
- [ ] 4. PDF 生成服务实现
- [ ] 4.1 创建 IPdfGenerationService 接口
- [x] 4. PDF 生成服务实现
- [x] 4.1 创建 IPdfGenerationService 接口
- 在 `MiAssessment.Core/Interfaces/IPdfGenerationService.cs` 定义接口
- 包含 `Task GeneratePdfAsync(long recordId)` 方法
- 添加 XML 注释
- _Requirements: 7.2_
- [ ] 4.2 实现 PdfGenerationService 核心逻辑
- [x] 4.2 实现 PdfGenerationService 核心逻辑
- 在 `MiAssessment.Core/Services/PdfGenerationService.cs` 实现 IPdfGenerationService
- 注入 `MiAssessmentDbContext`、`IScreenshotService`、`ReportSettings`、`AppSettings`、`ILogger<PdfGenerationService>`
- 实现 `GeneratePdfAsync(long recordId)` 方法:
@ -82,14 +82,14 @@
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 静态方法
- [x] 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 合并与文件保存逻辑
- [x] 4.4 实现 PDF 合并与文件保存逻辑
- 使用 PdfSharpCore 创建 PDF 文档
- 每页尺寸设置为 981.75pt × 694.5pt1309×926px 按 72/96 换算)
- 横版布局,页面边距为 0图片铺满整页
@ -134,26 +134,26 @@
- 验证 PDF 生成后更新 ReportUrl
- _Requirements: 2.2, 3.2, 4.4, 5.4, 5.5, 8.2_
- [ ] 5. Checkpoint - 确认核心服务实现
- [x] 5. Checkpoint - 确认核心服务实现
- Ensure all tests pass, ask the user if questions arise.
- [ ] 6. 服务注册与配置集成
- [ ] 6.1 在 appsettings.json 添加配置节
- [x] 6. 服务注册与配置集成
- [x] 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 注册服务
- [x] 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 包
- [x] 6.3 确保 MiAssessment.Core 项目引用 PdfSharpCore NuGet 包
- 在 `MiAssessment.Core.csproj` 中添加 `<PackageReference Include="PdfSharpCore" />`
- _Requirements: 5.3_
- [ ] 7. ReportQueueConsumer 集成 PDF 生成
- [ ] 7.1 修改 ReportQueueConsumer.ProcessMessageAsync
- [x] 7. ReportQueueConsumer 集成 PDF 生成
- [x] 7.1 修改 ReportQueueConsumer.ProcessMessageAsync
- 在 `ReportGenerationService.GenerateReportAsync` 成功后,通过 scope 解析 `IPdfGenerationService` 并调用 `GeneratePdfAsync`
- PDF 生成调用包裹在 try-catch 中,失败时仅记录 Error 日志,不改变 Status=4不触发重试
- PDF 生成成功时记录 Info 日志
@ -168,14 +168,14 @@
- 验证查询结果仅包含 Status=1 的记录且按 SortOrder 升序
- **Validates: Requirements 2.1**
- [ ] 8. 静态文件中间件配置
- [ ] 8.1 确保 wwwroot/reports/ 目录可通过 HTTP 访问
- [x] 8. 静态文件中间件配置
- [x] 8.1 确保 wwwroot/reports/ 目录可通过 HTTP 访问
- 确认 Program.cs 中 `app.UseStaticFiles()` 已配置(已存在)
- 确保 `wwwroot/reports/` 目录下的 PDF 文件可直接通过 URL 下载
- 如需要,在 `wwwroot/reports/` 下创建 `.gitkeep` 文件确保目录存在
- _Requirements: 11.1, 11.2_
- [ ] 9. Final checkpoint - 确认所有功能集成完成
- [x] 9. Final checkpoint - 确认所有功能集成完成
- Ensure all tests pass, ask the user if questions arise.
## Notes

View File

@ -86,4 +86,8 @@ secrets.json
# WeChat Pay Certificates (private keys)
certs/**/apiclient_key.pem
certs/**/*.p12
certs/**/*.p12
# Generated PDF reports
**/wwwroot/reports/*.pdf

View File

@ -150,6 +150,19 @@ public class ReportQueueConsumer : BackgroundService
await reportService.GenerateReportAsync(message.RecordId);
_logger.LogInformation("报告生成成功RecordId: {RecordId}", 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);
}
}
catch (Exception ex)
{

View File

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using MiAssessment.Core.Interfaces;
using MiAssessment.Model.Base;
namespace MiAssessment.Api.Controllers;
/// <summary>
/// 调试控制器 - 仅用于开发测试,生产环境应移除
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class DebugController : ControllerBase
{
private readonly IPdfGenerationService _pdfService;
private readonly ILogger<DebugController> _logger;
public DebugController(
IPdfGenerationService pdfService,
ILogger<DebugController> logger)
{
_pdfService = pdfService;
_logger = logger;
}
/// <summary>
/// 手动触发 PDF 报告生成(仅测试用)
/// </summary>
/// <param name="recordId">测评记录 ID</param>
[HttpGet("generatePdf")]
public async Task<ApiResponse<object>> GeneratePdf([FromQuery] long recordId)
{
try
{
_logger.LogInformation("手动触发 PDF 生成RecordId: {RecordId}", recordId);
await _pdfService.GeneratePdfAsync(recordId);
return ApiResponse<object>.Success(new { message = $"PDF 生成成功RecordId: {recordId}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "手动触发 PDF 生成失败RecordId: {RecordId}", recordId);
return ApiResponse<object>.Fail(ex.Message, 5000);
}
}
}

View File

@ -7,6 +7,7 @@ using MiAssessment.Core.Mappings;
using MiAssessment.Infrastructure.Cache;
using MiAssessment.Infrastructure.Modules;
using MiAssessment.Model.Data;
using MiAssessment.Core.Models;
using MiAssessment.Model.Models.Auth;
using MiAssessment.Model.Models.Payment;
@ -78,6 +79,28 @@ try
builder.Configuration.GetSection("AppSettings").Bind(appSettings);
builder.Services.AddSingleton(appSettings);
// 配置 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);
// 配置微信支付设置
builder.Services.Configure<WechatPaySettings>(builder.Configuration.GetSection("WechatPaySettings"));

View File

@ -64,5 +64,17 @@
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"HtmlToImageSettings": {
"BaseUrl": "http://192.168.195.15:5100",
"ApiKey": "",
"TimeoutSeconds": 120,
"PollingIntervalMs": 1000,
"MaxPollingSeconds": 120
},
"ReportSettings": {
"BaseUrl": "http://192.168.195.35:5238",
"OutputPath": "wwwroot/reports",
"MaxConcurrency": 5
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,13 @@
namespace MiAssessment.Core.Interfaces;
/// <summary>
/// PDF 报告生成服务接口
/// </summary>
public interface IPdfGenerationService
{
/// <summary>
/// 根据测评记录 ID 生成 PDF 报告
/// </summary>
/// <param name="recordId">测评记录 ID</param>
Task GeneratePdfAsync(long recordId);
}

View File

@ -0,0 +1,14 @@
namespace MiAssessment.Core.Interfaces;
/// <summary>
/// 截图服务接口,封装对外部 HtmlToImage 服务的调用
/// </summary>
public interface IScreenshotService
{
/// <summary>
/// 对指定 URL 进行截图,返回 PNG 图片字节数组
/// </summary>
/// <param name="url">页面完整 URL</param>
/// <returns>PNG 图片字节数组</returns>
Task<byte[]> CaptureAsync(string url);
}

View File

@ -9,6 +9,7 @@
<PackageReference Include="Mapster.DependencyInjection" Version="1.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
</ItemGroup>
<PropertyGroup>

View File

@ -0,0 +1,32 @@
namespace MiAssessment.Core.Models;
/// <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;
}

View File

@ -0,0 +1,22 @@
namespace MiAssessment.Core.Models;
/// <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;
}

View File

@ -0,0 +1,288 @@
using MiAssessment.Core.Interfaces;
using MiAssessment.Core.Models;
using MiAssessment.Model.Data;
using MiAssessment.Model.Entities;
using MiAssessment.Model.Models.Auth;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
namespace MiAssessment.Core.Services;
/// <summary>
/// PDF 报告生成服务实现
/// </summary>
public class PdfGenerationService : IPdfGenerationService
{
/// <summary>
/// PDF 页面宽度1309px × 72/96
/// </summary>
private const double PageWidthPt = 981.75;
/// <summary>
/// PDF 页面高度926px × 72/96
/// </summary>
private const double PageHeightPt = 694.5;
private readonly MiAssessmentDbContext _dbContext;
private readonly IScreenshotService _screenshotService;
private readonly ReportSettings _reportSettings;
private readonly AppSettings _appSettings;
private readonly ILogger<PdfGenerationService> _logger;
/// <summary>
/// 初始化 PDF 报告生成服务
/// </summary>
/// <param name="dbContext">数据库上下文</param>
/// <param name="screenshotService">截图服务</param>
/// <param name="reportSettings">报告生成配置</param>
/// <param name="appSettings">应用程序配置</param>
/// <param name="logger">日志记录器</param>
public PdfGenerationService(
MiAssessmentDbContext dbContext,
IScreenshotService screenshotService,
ReportSettings reportSettings,
AppSettings appSettings,
ILogger<PdfGenerationService> logger)
{
_dbContext = dbContext;
_screenshotService = screenshotService;
_reportSettings = reportSettings;
_appSettings = appSettings;
_logger = logger;
}
/// <inheritdoc />
public async Task GeneratePdfAsync(long recordId)
{
// 1. 验证 BaseUrl 配置
if (string.IsNullOrWhiteSpace(_reportSettings.BaseUrl))
{
throw new InvalidOperationException("ReportSettings:BaseUrl 未配置");
}
// 2. 查询启用的页面配置,按 SortOrder 升序
var pageConfigs = await _dbContext.ReportPageConfigs
.Where(p => p.Status == 1)
.OrderBy(p => p.SortOrder)
.ToListAsync();
if (pageConfigs.Count == 0)
{
throw new InvalidOperationException("报告页面配置为空,无法生成 PDF");
}
// 3. 并发获取所有页面图片
var semaphore = new SemaphoreSlim(_reportSettings.MaxConcurrency);
var tasks = pageConfigs.Select(config => ProcessPageAsync(config, recordId, semaphore)).ToList();
var results = await Task.WhenAll(tasks);
// 4. 按 SortOrder 排序,过滤失败页面
var successPages = pageConfigs
.Zip(results, (config, imageData) => new { config, imageData })
.Where(x => x.imageData != null)
.OrderBy(x => x.config.SortOrder)
.ToList();
if (successPages.Count == 0)
{
throw new InvalidOperationException("所有报告页面处理失败,无法生成 PDF");
}
// 记录部分失败信息
var failedCount = pageConfigs.Count - successPages.Count;
if (failedCount > 0)
{
var failedNames = pageConfigs
.Zip(results, (config, imageData) => new { config, imageData })
.Where(x => x.imageData == null)
.Select(x => x.config.PageName);
_logger.LogWarning("PDF 生成跳过 {FailedCount} 个页面: {FailedPages}RecordId: {RecordId}",
failedCount, string.Join(", ", failedNames), recordId);
}
// 5. 生成 PDF 并保存
var imageList = successPages.Select(x => x.imageData!).ToList();
var fileName = $"report_{recordId}_{DateTime.Now:yyyyMMddHHmmss}.pdf";
var outputDir = _reportSettings.OutputPath;
Directory.CreateDirectory(outputDir);
var filePath = Path.Combine(outputDir, fileName);
BuildAndSavePdf(imageList, filePath);
// 6. 更新数据库 ReportUrl
var cdnPrefix = _appSettings.CdnPrefix?.TrimEnd('/') ?? string.Empty;
var reportUrl = string.IsNullOrEmpty(cdnPrefix)
? $"/reports/{fileName}"
: $"{cdnPrefix}/reports/{fileName}";
var record = await _dbContext.AssessmentRecords.FindAsync(recordId);
if (record != null)
{
record.ReportUrl = reportUrl;
await _dbContext.SaveChangesAsync();
}
_logger.LogInformation("PDF 报告生成成功RecordId: {RecordId},文件路径: {FilePath}",
recordId, filePath);
}
/// <summary>
/// 处理单个页面配置,获取图片字节数组
/// </summary>
/// <param name="config">页面配置</param>
/// <param name="recordId">测评记录 ID</param>
/// <param name="semaphore">并发控制信号量</param>
/// <returns>图片字节数组,失败时返回 null</returns>
private async Task<byte[]?> ProcessPageAsync(ReportPageConfig config, long recordId, SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();
try
{
return config.PageType switch
{
1 => await ReadStaticImageAsync(config),
2 => await CaptureWebPageAsync(config, recordId),
_ => null
};
}
finally
{
semaphore.Release();
}
}
/// <summary>
/// 读取静态图片文件PageType=1
/// </summary>
/// <param name="config">页面配置</param>
/// <returns>图片字节数组,文件不存在时返回 null</returns>
private async Task<byte[]?> ReadStaticImageAsync(ReportPageConfig config)
{
if (string.IsNullOrWhiteSpace(config.ImageUrl))
{
_logger.LogWarning("静态图片路径为空,跳过页面: {PageName}", config.PageName);
return null;
}
var imagePath = Path.Combine("wwwroot", "images", "static-pages", config.ImageUrl);
if (!File.Exists(imagePath))
{
_logger.LogWarning("静态图片文件不存在,跳过页面: {PageName},路径: {ImagePath}",
config.PageName, imagePath);
return null;
}
return await File.ReadAllBytesAsync(imagePath);
}
/// <summary>
/// 对网页进行截图PageType=2
/// </summary>
/// <param name="config">页面配置</param>
/// <param name="recordId">测评记录 ID</param>
/// <returns>截图字节数组,失败时返回 null</returns>
private async Task<byte[]?> CaptureWebPageAsync(ReportPageConfig config, long recordId)
{
if (string.IsNullOrWhiteSpace(config.RouteUrl))
{
_logger.LogError("网页路由路径为空,跳过页面: {PageName}", config.PageName);
return null;
}
var fullUrl = BuildPageUrl(_reportSettings.BaseUrl, config.RouteUrl, recordId);
try
{
return await _screenshotService.CaptureAsync(fullUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "网页截图失败,跳过页面: {PageName}URL: {Url}", config.PageName, fullUrl);
return null;
}
}
/// <summary>
/// 拼接报告页面完整 URL
/// </summary>
/// <param name="baseUrl">API 服务基础地址</param>
/// <param name="routeUrl">页面路由路径</param>
/// <param name="recordId">测评记录 ID</param>
/// <returns>完整的页面访问 URL</returns>
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;
// 清理路径中的双斜杠(保留协议部分的 ://
fullUrl = CleanDoubleSlashes(fullUrl);
// 如果已包含 recordId 参数,直接返回
if (fullUrl.Contains("recordId=", StringComparison.OrdinalIgnoreCase))
{
return fullUrl;
}
// 根据是否已有查询参数决定使用 ? 或 &
var separator = fullUrl.Contains('?') ? "&" : "?";
return $"{fullUrl}{separator}recordId={recordId}";
}
/// <summary>
/// 清理 URL 中的双斜杠,保留协议部分的 ://
/// </summary>
/// <param name="url">原始 URL</param>
/// <returns>清理后的 URL</returns>
private static string CleanDoubleSlashes(string url)
{
// 找到协议分隔符 :// 的位置
var protocolIndex = url.IndexOf("://", StringComparison.Ordinal);
if (protocolIndex < 0)
{
// 无协议前缀,直接替换所有双斜杠
while (url.Contains("//"))
{
url = url.Replace("//", "/");
}
return url;
}
// 保留协议部分,只处理协议之后的路径
var protocol = url[..(protocolIndex + 3)];
var path = url[(protocolIndex + 3)..];
while (path.Contains("//"))
{
path = path.Replace("//", "/");
}
return protocol + path;
}
/// <summary>
/// 使用 PdfSharpCore 将图片列表合并为 PDF 文件并保存
/// </summary>
/// <param name="images">图片字节数组列表</param>
/// <param name="filePath">PDF 输出文件路径</param>
private static void BuildAndSavePdf(List<byte[]> images, string filePath)
{
using var document = new PdfDocument();
foreach (var imageBytes in images)
{
var page = document.AddPage();
page.Width = XUnit.FromPoint(PageWidthPt);
page.Height = XUnit.FromPoint(PageHeightPt);
using var stream = new MemoryStream(imageBytes);
using var xImage = XImage.FromStream(() => new MemoryStream(imageBytes));
using var gfx = XGraphics.FromPdfPage(page);
gfx.DrawImage(xImage, 0, 0, page.Width, page.Height);
}
document.Save(filePath);
}
}

View File

@ -0,0 +1,176 @@
using System.Text;
using System.Text.Json;
using MiAssessment.Core.Interfaces;
using MiAssessment.Core.Models;
using Microsoft.Extensions.Logging;
namespace MiAssessment.Core.Services;
/// <summary>
/// 截图服务实现,通过 HttpClient 直接调用外部 HtmlToImage REST API
/// </summary>
public class ScreenshotService : IScreenshotService
{
private readonly HttpClient _httpClient;
private readonly HtmlToImageSettings _settings;
private readonly ILogger<ScreenshotService> _logger;
/// <summary>
/// 初始化截图服务
/// </summary>
/// <param name="httpClient">已配置的 HtmlToImage HTTP 客户端</param>
/// <param name="settings">HtmlToImage 截图服务配置</param>
/// <param name="logger">日志记录器</param>
public ScreenshotService(
HttpClient httpClient,
HtmlToImageSettings settings,
ILogger<ScreenshotService> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<byte[]> CaptureAsync(string url)
{
// 步骤 1提交截图任务
var taskId = await SubmitTaskAsync(url);
// 步骤 2轮询任务状态
await PollTaskStatusAsync(taskId, url);
// 步骤 3下载截图结果
var imageBytes = await DownloadResultAsync(taskId, url);
return imageBytes;
}
/// <summary>
/// 步骤 1提交截图任务到 HtmlToImage 服务
/// </summary>
/// <param name="url">待截图的页面 URL</param>
/// <returns>任务 ID</returns>
private async Task<string> SubmitTaskAsync(string url)
{
var requestBody = new
{
source = new
{
type = "url",
content = url
},
options = new
{
format = "png",
width = 1309,
height = 926,
fullPage = false
},
waitUntil = "networkidle0",
timeout = 60000,
saveLocal = true
};
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
_logger.LogDebug("提交截图任务URL: {Url}", url);
var response = await _httpClient.PostAsync("/api/tasks/image", content);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("截图任务提交失败URL: {Url}HTTP 状态码: {StatusCode}", url, (int)response.StatusCode);
response.EnsureSuccessStatusCode();
}
var responseJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseJson);
var taskId = doc.RootElement.GetProperty("taskId").GetString()
?? throw new InvalidOperationException($"截图任务提交响应中缺少 taskIdURL: {url}");
_logger.LogDebug("截图任务已提交URL: {Url}TaskId: {TaskId}", url, taskId);
return taskId;
}
/// <summary>
/// 步骤 2轮询截图任务状态直到完成、失败或超时
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="url">页面 URL用于日志</param>
private async Task PollTaskStatusAsync(string taskId, string url)
{
var maxWaitTime = TimeSpan.FromSeconds(_settings.MaxPollingSeconds);
var pollingInterval = TimeSpan.FromMilliseconds(_settings.PollingIntervalMs);
var elapsed = TimeSpan.Zero;
while (elapsed < maxWaitTime)
{
await Task.Delay(pollingInterval);
elapsed += pollingInterval;
var response = await _httpClient.GetAsync($"/api/tasks/{taskId}/status");
var responseJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseJson);
var status = doc.RootElement.GetProperty("status").GetString();
switch (status)
{
case "completed":
_logger.LogDebug("截图任务已完成TaskId: {TaskId}URL: {Url},耗时: {ElapsedMs}ms",
taskId, url, elapsed.TotalMilliseconds);
return;
case "failed":
_logger.LogError("截图任务失败TaskId: {TaskId}URL: {Url},任务状态: {Status}",
taskId, url, status);
throw new InvalidOperationException($"截图任务失败TaskId: {taskId}URL: {url}");
case "stalled":
_logger.LogError("截图任务卡死TaskId: {TaskId}URL: {Url},任务状态: {Status}",
taskId, url, status);
throw new InvalidOperationException($"截图任务卡死TaskId: {taskId}URL: {url}");
}
}
_logger.LogError("截图任务轮询超时TaskId: {TaskId}URL: {Url},已等待: {ElapsedSeconds}秒",
taskId, url, _settings.MaxPollingSeconds);
throw new TimeoutException($"截图任务轮询超时TaskId: {taskId}URL: {url},已等待 {_settings.MaxPollingSeconds} 秒");
}
/// <summary>
/// 步骤 3下载截图结果
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="url">页面 URL用于日志</param>
/// <returns>PNG 图片字节数组</returns>
private async Task<byte[]> DownloadResultAsync(string taskId, string url)
{
_logger.LogDebug("下载截图结果TaskId: {TaskId}URL: {Url}", taskId, url);
var response = await _httpClient.GetAsync($"/api/tasks/{taskId}/download");
if (!response.IsSuccessStatusCode)
{
_logger.LogError("截图结果下载失败TaskId: {TaskId}URL: {Url}HTTP 状态码: {StatusCode}",
taskId, url, (int)response.StatusCode);
response.EnsureSuccessStatusCode();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes == null || imageBytes.Length == 0)
{
_logger.LogError("截图结果为空TaskId: {TaskId}URL: {Url}", taskId, url);
throw new InvalidOperationException($"截图结果为空TaskId: {taskId}URL: {url}");
}
_logger.LogDebug("截图结果下载成功TaskId: {TaskId}URL: {Url},文件大小: {Size} 字节",
taskId, url, imageBytes.Length);
return imageBytes;
}
}

View File

@ -144,6 +144,30 @@ public class ServiceModule : Module
return new ReportDataService(dbContext, logger);
}).As<IReportDataService>().InstancePerLifetimeScope();
// ========== 截图服务注册 ==========
// 注册截图服务(通过命名 HttpClient 调用 HtmlToImage 截图服务)
builder.Register(c =>
{
var httpClientFactory = c.Resolve<System.Net.Http.IHttpClientFactory>();
var settings = c.Resolve<Core.Models.HtmlToImageSettings>();
var logger = c.Resolve<ILogger<ScreenshotService>>();
return new ScreenshotService(httpClientFactory.CreateClient("HtmlToImage"), settings, logger);
}).As<IScreenshotService>().InstancePerLifetimeScope();
// ========== PDF 生成服务注册 ==========
// 注册 PDF 生成服务
builder.Register(c =>
{
var dbContext = c.Resolve<MiAssessmentDbContext>();
var screenshotService = c.Resolve<IScreenshotService>();
var reportSettings = c.Resolve<Core.Models.ReportSettings>();
var appSettings = c.Resolve<AppSettings>();
var logger = c.Resolve<ILogger<PdfGenerationService>>();
return new PdfGenerationService(dbContext, screenshotService, reportSettings, appSettings, logger);
}).As<IPdfGenerationService>().InstancePerLifetimeScope();
// ========== 小程序测评模块服务注册 ==========
// 注册报告生成服务

View File

@ -116,6 +116,12 @@ public class AssessmentRecord
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// PDF 报告文件访问 URL
/// </summary>
[MaxLength(500)]
public string? ReportUrl { get; set; }
// Note: Navigation property to User removed due to type mismatch (User.Id is int, UserId is long)
// The relationship is maintained at the database level but not enforced by EF Core

View File

@ -10,4 +10,9 @@ public class AppSettings
/// 测试环境下IsTest=2 的用户支付金额会改为 0.01 元
/// </summary>
public bool IsTestEnvironment { get; set; } = false;
/// <summary>
/// CDN 前缀地址,用于拼接静态资源访问 URL
/// </summary>
public string CdnPrefix { get; set; } = string.Empty;
}

View File

@ -0,0 +1,2 @@
-- 添加 PDF 报告 URL 字段到测评记录表
ALTER TABLE assessment_records ADD ReportUrl NVARCHAR(500) NULL;