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; /// /// Puppeteer 图片转换服务实现 /// public class PuppeteerImageService : IImageService { private readonly PdfServiceOptions _options; private readonly ILogger _logger; private readonly IBrowserPool _browserPool; private readonly IFileStorage _fileStorage; private readonly ICallbackService _callbackService; public PuppeteerImageService( IOptions options, ILogger logger, IBrowserPool browserPool, IFileStorage fileStorage, ICallbackService callbackService) { _options = options.Value; _logger = logger; _browserPool = browserPool; _fileStorage = fileStorage; _callbackService = callbackService; } /// /// 将 HTML 内容转换为图片 /// public async Task ConvertHtmlToImageAsync( string html, ScreenshotOptions? options = null, int? viewportWidth = null, int? viewportHeight = null, int? delayAfterLoad = null, string? callbackUrl = null, Dictionary? 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); } } } /// /// 将 URL 转换为图片 /// public async Task 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? 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); } } } /// /// 保存图片到本地 /// 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); } /// /// 获取图片扩展名 /// private string GetImageExtension(ScreenshotType? type) { return type switch { ScreenshotType.Jpeg => ".jpg", ScreenshotType.Webp => ".webp", _ => ".png" }; } /// /// 解析等待条件 /// 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 } }; } /// /// 发送回调(如果需要) /// private async Task SendCallbackIfNeededAsync( ConversionResult result, string sourceType, string sourceContent, object? options, string? callbackUrl, Dictionary? 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); } }