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:
parent
7b4a8732a6
commit
6d81fa45f4
|
|
@ -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.5pt(1309×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
|
||||
|
|
|
|||
4
server/MiAssessment/.gitignore
vendored
4
server/MiAssessment/.gitignore
vendored
|
|
@ -87,3 +87,7 @@ secrets.json
|
|||
# WeChat Pay Certificates (private keys)
|
||||
certs/**/apiclient_key.pem
|
||||
certs/**/*.p12
|
||||
|
||||
|
||||
# Generated PDF reports
|
||||
**/wwwroot/reports/*.pdf
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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($"截图任务提交响应中缺少 taskId,URL: {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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
// ========== 小程序测评模块服务注册 ==========
|
||||
|
||||
// 注册报告生成服务
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
2
temp_sql/add_report_url.sql
Normal file
2
temp_sql/add_report_url.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- 添加 PDF 报告 URL 字段到测评记录表
|
||||
ALTER TABLE assessment_records ADD ReportUrl NVARCHAR(500) NULL;
|
||||
Loading…
Reference in New Issue
Block a user