459 lines
15 KiB
C#
459 lines
15 KiB
C#
using System.Diagnostics;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using PuppeteerSharp;
|
||
using HtmlToPdfService.Core.Models;
|
||
using HtmlToPdfService.Core.Options;
|
||
using HtmlToPdfService.Core.Pool;
|
||
using HtmlToPdfService.Core.Storage;
|
||
|
||
namespace HtmlToPdfService.Core.Services;
|
||
|
||
/// <summary>
|
||
/// Puppeteer 图片转换服务实现
|
||
/// </summary>
|
||
public class PuppeteerImageService : IImageService
|
||
{
|
||
private readonly PdfServiceOptions _options;
|
||
private readonly ILogger<PuppeteerImageService> _logger;
|
||
private readonly IBrowserPool _browserPool;
|
||
private readonly IFileStorage _fileStorage;
|
||
private readonly ICallbackService _callbackService;
|
||
|
||
public PuppeteerImageService(
|
||
IOptions<PdfServiceOptions> options,
|
||
ILogger<PuppeteerImageService> logger,
|
||
IBrowserPool browserPool,
|
||
IFileStorage fileStorage,
|
||
ICallbackService callbackService)
|
||
{
|
||
_options = options.Value;
|
||
_logger = logger;
|
||
_browserPool = browserPool;
|
||
_fileStorage = fileStorage;
|
||
_callbackService = callbackService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 HTML 内容转换为图片
|
||
/// </summary>
|
||
public async Task<ConversionResult> ConvertHtmlToImageAsync(
|
||
string html,
|
||
ScreenshotOptions? options = null,
|
||
int? viewportWidth = null,
|
||
int? viewportHeight = null,
|
||
int? delayAfterLoad = null,
|
||
string? callbackUrl = null,
|
||
Dictionary<string, string>? callbackHeaders = null,
|
||
bool? includePdfInCallback = null,
|
||
bool? saveLocal = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var requestId = Guid.NewGuid().ToString();
|
||
var startTime = DateTime.UtcNow;
|
||
var stopwatch = Stopwatch.StartNew();
|
||
|
||
_logger.LogInformation("开始转换 HTML to Image, RequestId: {RequestId}", requestId);
|
||
|
||
IBrowser? browser = null;
|
||
IPage? page = null;
|
||
|
||
try
|
||
{
|
||
// 验证 HTML 内容大小
|
||
var htmlSize = System.Text.Encoding.UTF8.GetByteCount(html);
|
||
if (htmlSize > _options.Conversion.MaxHtmlSize)
|
||
{
|
||
throw new ArgumentException(
|
||
$"HTML 内容过大: {htmlSize} bytes,最大允许: {_options.Conversion.MaxHtmlSize} bytes");
|
||
}
|
||
|
||
// 从浏览器池获取实例
|
||
browser = await _browserPool.AcquireAsync(cancellationToken);
|
||
_logger.LogDebug("获取浏览器实例成功, RequestId: {RequestId}", requestId);
|
||
|
||
// 创建新页面
|
||
page = await browser.NewPageAsync();
|
||
|
||
// 设置视口大小
|
||
if (viewportWidth.HasValue || viewportHeight.HasValue)
|
||
{
|
||
await page.SetViewportAsync(new ViewPortOptions
|
||
{
|
||
Width = viewportWidth ?? 1920,
|
||
Height = viewportHeight ?? 1080
|
||
});
|
||
}
|
||
|
||
// 设置 HTML 内容
|
||
await page.SetContentAsync(html, new NavigationOptions
|
||
{
|
||
Timeout = _options.Conversion.DefaultTimeout,
|
||
WaitUntil = ParseWaitUntil(_options.Conversion.DefaultWaitUntil)
|
||
});
|
||
|
||
// 如果设置了延迟,等待指定时间(用于动画、延迟加载等)
|
||
if (delayAfterLoad.HasValue && delayAfterLoad.Value > 0)
|
||
{
|
||
_logger.LogDebug("等待 {Delay}ms 以完成动画/延迟渲染", delayAfterLoad.Value);
|
||
await Task.Delay(delayAfterLoad.Value, cancellationToken);
|
||
}
|
||
|
||
// 生成图片
|
||
var screenshotOptions = options ?? new ScreenshotOptions { FullPage = true };
|
||
var imageData = await page.ScreenshotDataAsync(screenshotOptions);
|
||
|
||
stopwatch.Stop();
|
||
|
||
_logger.LogInformation("HTML 转图片成功, RequestId: {RequestId}, 耗时: {Duration}ms, 大小: {Size} bytes",
|
||
requestId, stopwatch.ElapsedMilliseconds, imageData.Length);
|
||
|
||
// 构建结果
|
||
var result = new ConversionResult
|
||
{
|
||
RequestId = requestId,
|
||
Success = true,
|
||
PdfData = imageData,
|
||
FileSize = imageData.Length,
|
||
Duration = stopwatch.ElapsedMilliseconds,
|
||
StartTime = startTime,
|
||
CompleteTime = DateTime.UtcNow
|
||
};
|
||
|
||
// 保存本地副本
|
||
var shouldSaveLocal = saveLocal ?? _options.Storage.SaveLocalCopy;
|
||
if (shouldSaveLocal)
|
||
{
|
||
var extension = GetImageExtension(screenshotOptions.Type);
|
||
var fileName = $"{requestId}{extension}";
|
||
var (filePath, downloadUrl) = await SaveImageAsync(requestId, imageData, extension, cancellationToken);
|
||
result.LocalFilePath = filePath;
|
||
result.DownloadUrl = downloadUrl;
|
||
}
|
||
|
||
// 发送回调
|
||
await SendCallbackIfNeededAsync(
|
||
result,
|
||
"html",
|
||
html,
|
||
options,
|
||
callbackUrl,
|
||
callbackHeaders,
|
||
includePdfInCallback);
|
||
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
stopwatch.Stop();
|
||
_logger.LogError(ex, "HTML 转图片失败, RequestId: {RequestId}, 耗时: {Duration}ms",
|
||
requestId, stopwatch.ElapsedMilliseconds);
|
||
|
||
var result = new ConversionResult
|
||
{
|
||
RequestId = requestId,
|
||
Success = false,
|
||
Duration = stopwatch.ElapsedMilliseconds,
|
||
StartTime = startTime,
|
||
CompleteTime = DateTime.UtcNow,
|
||
ErrorMessage = ex.Message,
|
||
ExceptionDetails = ex.ToString()
|
||
};
|
||
|
||
// 发送失败回调
|
||
await SendCallbackIfNeededAsync(
|
||
result,
|
||
"html",
|
||
html,
|
||
options,
|
||
callbackUrl,
|
||
callbackHeaders,
|
||
includePdfInCallback);
|
||
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
// 关闭页面
|
||
if (page != null)
|
||
{
|
||
await page.CloseAsync();
|
||
}
|
||
|
||
// 归还浏览器到池中
|
||
if (browser != null)
|
||
{
|
||
_browserPool.Release(browser);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将 URL 转换为图片
|
||
/// </summary>
|
||
public async Task<ConversionResult> ConvertUrlToImageAsync(
|
||
string url,
|
||
ScreenshotOptions? options = null,
|
||
int? viewportWidth = null,
|
||
int? viewportHeight = null,
|
||
WaitUntilNavigation[]? waitUntil = null,
|
||
int? timeout = null,
|
||
int? delayAfterLoad = null,
|
||
string? callbackUrl = null,
|
||
Dictionary<string, string>? callbackHeaders = null,
|
||
bool? includePdfInCallback = null,
|
||
bool? saveLocal = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var requestId = Guid.NewGuid().ToString();
|
||
var startTime = DateTime.UtcNow;
|
||
var stopwatch = Stopwatch.StartNew();
|
||
|
||
_logger.LogInformation("开始转换 URL to Image, RequestId: {RequestId}, URL: {Url}", requestId, url);
|
||
|
||
IBrowser? browser = null;
|
||
IPage? page = null;
|
||
|
||
try
|
||
{
|
||
// 验证 URL
|
||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||
{
|
||
throw new ArgumentException($"无效的 URL: {url}");
|
||
}
|
||
|
||
// 从浏览器池获取实例
|
||
browser = await _browserPool.AcquireAsync(cancellationToken);
|
||
_logger.LogDebug("获取浏览器实例成功, RequestId: {RequestId}", requestId);
|
||
|
||
// 创建新页面
|
||
page = await browser.NewPageAsync();
|
||
|
||
// 设置视口大小
|
||
if (viewportWidth.HasValue || viewportHeight.HasValue)
|
||
{
|
||
await page.SetViewportAsync(new ViewPortOptions
|
||
{
|
||
Width = viewportWidth ?? 1920,
|
||
Height = viewportHeight ?? 1080
|
||
});
|
||
}
|
||
|
||
// 导航到 URL
|
||
var navigationOptions = new NavigationOptions
|
||
{
|
||
Timeout = timeout ?? _options.Conversion.DefaultTimeout,
|
||
WaitUntil = waitUntil ?? ParseWaitUntil(_options.Conversion.DefaultWaitUntil)
|
||
};
|
||
|
||
await page.GoToAsync(url, navigationOptions);
|
||
|
||
// 如果设置了延迟,等待指定时间(用于动画、延迟加载等)
|
||
if (delayAfterLoad.HasValue && delayAfterLoad.Value > 0)
|
||
{
|
||
_logger.LogDebug("等待 {Delay}ms 以完成动画/延迟渲染", delayAfterLoad.Value);
|
||
await Task.Delay(delayAfterLoad.Value, cancellationToken);
|
||
}
|
||
|
||
// 生成图片
|
||
var screenshotOptions = options ?? new ScreenshotOptions { FullPage = true };
|
||
var imageData = await page.ScreenshotDataAsync(screenshotOptions);
|
||
|
||
stopwatch.Stop();
|
||
|
||
_logger.LogInformation("URL 转图片成功, RequestId: {RequestId}, 耗时: {Duration}ms, 大小: {Size} bytes",
|
||
requestId, stopwatch.ElapsedMilliseconds, imageData.Length);
|
||
|
||
// 构建结果
|
||
var result = new ConversionResult
|
||
{
|
||
RequestId = requestId,
|
||
Success = true,
|
||
PdfData = imageData,
|
||
FileSize = imageData.Length,
|
||
Duration = stopwatch.ElapsedMilliseconds,
|
||
StartTime = startTime,
|
||
CompleteTime = DateTime.UtcNow
|
||
};
|
||
|
||
// 保存本地副本
|
||
var shouldSaveLocal = saveLocal ?? _options.Storage.SaveLocalCopy;
|
||
if (shouldSaveLocal)
|
||
{
|
||
var extension = GetImageExtension(screenshotOptions.Type);
|
||
var (filePath, downloadUrl) = await SaveImageAsync(requestId, imageData, extension, cancellationToken);
|
||
result.LocalFilePath = filePath;
|
||
result.DownloadUrl = downloadUrl;
|
||
}
|
||
|
||
// 发送回调
|
||
await SendCallbackIfNeededAsync(
|
||
result,
|
||
"url",
|
||
url,
|
||
options,
|
||
callbackUrl,
|
||
callbackHeaders,
|
||
includePdfInCallback);
|
||
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
stopwatch.Stop();
|
||
_logger.LogError(ex, "URL 转图片失败, RequestId: {RequestId}, URL: {Url}, 耗时: {Duration}ms",
|
||
requestId, url, stopwatch.ElapsedMilliseconds);
|
||
|
||
var result = new ConversionResult
|
||
{
|
||
RequestId = requestId,
|
||
Success = false,
|
||
Duration = stopwatch.ElapsedMilliseconds,
|
||
StartTime = startTime,
|
||
CompleteTime = DateTime.UtcNow,
|
||
ErrorMessage = ex.Message,
|
||
ExceptionDetails = ex.ToString()
|
||
};
|
||
|
||
// 发送失败回调
|
||
await SendCallbackIfNeededAsync(
|
||
result,
|
||
"url",
|
||
url,
|
||
options,
|
||
callbackUrl,
|
||
callbackHeaders,
|
||
includePdfInCallback);
|
||
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
// 关闭页面
|
||
if (page != null)
|
||
{
|
||
await page.CloseAsync();
|
||
}
|
||
|
||
// 归还浏览器到池中
|
||
if (browser != null)
|
||
{
|
||
_browserPool.Release(browser);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存图片到本地
|
||
/// </summary>
|
||
private async Task<(string FilePath, string DownloadUrl)> SaveImageAsync(
|
||
string requestId,
|
||
byte[] imageData,
|
||
string extension,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// 修改文件名为图片扩展名
|
||
var originalRequestId = requestId;
|
||
requestId = $"{requestId}{extension}";
|
||
|
||
return await _fileStorage.SaveAsync(requestId, imageData, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取图片扩展名
|
||
/// </summary>
|
||
private string GetImageExtension(ScreenshotType? type)
|
||
{
|
||
return type switch
|
||
{
|
||
ScreenshotType.Jpeg => ".jpg",
|
||
ScreenshotType.Webp => ".webp",
|
||
_ => ".png"
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析等待条件
|
||
/// </summary>
|
||
private WaitUntilNavigation[] ParseWaitUntil(string waitUntil)
|
||
{
|
||
return waitUntil.ToLowerInvariant() switch
|
||
{
|
||
"load" => new[] { WaitUntilNavigation.Load },
|
||
"domcontentloaded" => new[] { WaitUntilNavigation.DOMContentLoaded },
|
||
"networkidle0" => new[] { WaitUntilNavigation.Networkidle0 },
|
||
"networkidle2" => new[] { WaitUntilNavigation.Networkidle2 },
|
||
_ => new[] { WaitUntilNavigation.Networkidle2 }
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 发送回调(如果需要)
|
||
/// </summary>
|
||
private async Task SendCallbackIfNeededAsync(
|
||
ConversionResult result,
|
||
string sourceType,
|
||
string sourceContent,
|
||
object? options,
|
||
string? callbackUrl,
|
||
Dictionary<string, string>? callbackHeaders,
|
||
bool? includePdfInCallback)
|
||
{
|
||
// 确定回调 URL(请求级优先于全局配置)
|
||
var effectiveCallbackUrl = callbackUrl ?? _options.Callback.DefaultUrl;
|
||
if (string.IsNullOrEmpty(effectiveCallbackUrl))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 确定是否包含图片数据
|
||
var shouldIncludePdf = includePdfInCallback ?? _options.Callback.IncludePdfData;
|
||
|
||
// 构建回调负载
|
||
var payload = new CallbackPayload
|
||
{
|
||
RequestId = result.RequestId,
|
||
Status = result.Success ? "success" : "failed",
|
||
Timestamp = DateTime.UtcNow,
|
||
Duration = result.Duration,
|
||
Source = new CallbackSource
|
||
{
|
||
Type = sourceType,
|
||
Content = sourceContent,
|
||
Options = options
|
||
}
|
||
};
|
||
|
||
if (result.Success)
|
||
{
|
||
payload.Result = new CallbackResult
|
||
{
|
||
FileSize = result.FileSize,
|
||
DownloadUrl = result.DownloadUrl,
|
||
PdfBase64 = shouldIncludePdf && result.PdfData != null
|
||
? Convert.ToBase64String(result.PdfData)
|
||
: null,
|
||
ExpiresAt = result.DownloadUrl != null
|
||
? DateTime.UtcNow.AddHours(_options.Storage.RetentionHours)
|
||
: null
|
||
};
|
||
}
|
||
else
|
||
{
|
||
payload.Error = new CallbackError
|
||
{
|
||
Code = "CONVERSION_FAILED",
|
||
Message = result.ErrorMessage ?? "未知错误",
|
||
Details = result.ExceptionDetails
|
||
};
|
||
}
|
||
|
||
// 发送回调
|
||
await _callbackService.SendCallbackAsync(
|
||
effectiveCallbackUrl,
|
||
payload,
|
||
callbackHeaders);
|
||
}
|
||
}
|
||
|