From cf67f4a541d821ccebadbbe31b2256a1079ae8ef Mon Sep 17 00:00:00 2001 From: zpc Date: Mon, 16 Mar 2026 18:10:38 +0800 Subject: [PATCH] 21 --- .../Exceptions/HtmlToPdfClientException.cs | 149 +++++ .../Extensions/ServiceCollectionExtensions.cs | 187 ++++++ .../HtmlToPdfClient.cs | 559 ++++++++++++++++++ .../HtmlToPdfClientOptions.cs | 50 ++ .../HtmlToPdfService.Client.csproj | 37 ++ .../IHtmlToPdfClient.cs | 324 ++++++++++ .../Models/ImageOptions.cs | 46 ++ .../Models/PdfOptions.cs | 64 ++ .../Models/Requests/ConvertHtmlRequest.cs | 76 +++ .../Models/Requests/TaskRequests.cs | 151 +++++ .../Models/Responses/ErrorResponse.cs | 40 ++ .../Models/Responses/TaskResponses.cs | 484 +++++++++++++++ src/HtmlToPdfService.Client/README.md | 298 ++++++++++ src/HtmlToPdfService.sln | 6 + 14 files changed, 2471 insertions(+) create mode 100644 src/HtmlToPdfService.Client/Exceptions/HtmlToPdfClientException.cs create mode 100644 src/HtmlToPdfService.Client/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/HtmlToPdfService.Client/HtmlToPdfClient.cs create mode 100644 src/HtmlToPdfService.Client/HtmlToPdfClientOptions.cs create mode 100644 src/HtmlToPdfService.Client/HtmlToPdfService.Client.csproj create mode 100644 src/HtmlToPdfService.Client/IHtmlToPdfClient.cs create mode 100644 src/HtmlToPdfService.Client/Models/ImageOptions.cs create mode 100644 src/HtmlToPdfService.Client/Models/PdfOptions.cs create mode 100644 src/HtmlToPdfService.Client/Models/Requests/ConvertHtmlRequest.cs create mode 100644 src/HtmlToPdfService.Client/Models/Requests/TaskRequests.cs create mode 100644 src/HtmlToPdfService.Client/Models/Responses/ErrorResponse.cs create mode 100644 src/HtmlToPdfService.Client/Models/Responses/TaskResponses.cs create mode 100644 src/HtmlToPdfService.Client/README.md diff --git a/src/HtmlToPdfService.Client/Exceptions/HtmlToPdfClientException.cs b/src/HtmlToPdfService.Client/Exceptions/HtmlToPdfClientException.cs new file mode 100644 index 0000000..5b6d5e0 --- /dev/null +++ b/src/HtmlToPdfService.Client/Exceptions/HtmlToPdfClientException.cs @@ -0,0 +1,149 @@ +using System.Net; +using HtmlToPdfService.Client.Models.Responses; + +namespace HtmlToPdfService.Client.Exceptions; + +/// +/// HTML 转 PDF 客户端异常 +/// +public class HtmlToPdfClientException : Exception +{ + /// + /// HTTP 状态码 + /// + public HttpStatusCode? StatusCode { get; } + + /// + /// 错误详情(如果服务端返回 ProblemDetails) + /// + public ProblemDetails? ProblemDetails { get; } + + /// + /// 创建异常实例 + /// + public HtmlToPdfClientException(string message) + : base(message) + { + } + + /// + /// 创建带内部异常的实例 + /// + public HtmlToPdfClientException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// 创建带 HTTP 状态码的实例 + /// + public HtmlToPdfClientException(string message, HttpStatusCode statusCode) + : base(message) + { + StatusCode = statusCode; + } + + /// + /// 创建带 ProblemDetails 的实例 + /// + public HtmlToPdfClientException(string message, HttpStatusCode statusCode, ProblemDetails? problemDetails) + : base(message) + { + StatusCode = statusCode; + ProblemDetails = problemDetails; + } + + /// + /// 创建带 ProblemDetails 和内部异常的实例 + /// + public HtmlToPdfClientException(string message, HttpStatusCode statusCode, ProblemDetails? problemDetails, Exception innerException) + : base(message, innerException) + { + StatusCode = statusCode; + ProblemDetails = problemDetails; + } +} + +/// +/// 任务未找到异常 +/// +public class TaskNotFoundException : HtmlToPdfClientException +{ + /// + /// 任务 ID + /// + public string TaskId { get; } + + public TaskNotFoundException(string taskId) + : base($"任务不存在: {taskId}", HttpStatusCode.NotFound) + { + TaskId = taskId; + } +} + +/// +/// 任务冲突异常(如任务状态不允许操作) +/// +public class TaskConflictException : HtmlToPdfClientException +{ + /// + /// 任务 ID + /// + public string TaskId { get; } + + /// + /// 当前任务状态 + /// + public string? CurrentStatus { get; } + + public TaskConflictException(string taskId, string message, string? currentStatus = null) + : base(message, HttpStatusCode.Conflict) + { + TaskId = taskId; + CurrentStatus = currentStatus; + } +} + +/// +/// 服务不可用异常 +/// +public class ServiceUnavailableException : HtmlToPdfClientException +{ + public ServiceUnavailableException(string message) + : base(message, HttpStatusCode.ServiceUnavailable) + { + } + + public ServiceUnavailableException(string message, ProblemDetails? problemDetails) + : base(message, HttpStatusCode.ServiceUnavailable, problemDetails) + { + } +} + +/// +/// 认证失败异常 +/// +public class AuthenticationException : HtmlToPdfClientException +{ + public AuthenticationException(string message = "认证失败,请检查 API Key") + : base(message, HttpStatusCode.Unauthorized) + { + } +} + +/// +/// 参数验证异常 +/// +public class ValidationException : HtmlToPdfClientException +{ + public ValidationException(string message) + : base(message, HttpStatusCode.BadRequest) + { + } + + public ValidationException(string message, ProblemDetails? problemDetails) + : base(message, HttpStatusCode.BadRequest, problemDetails) + { + } +} + diff --git a/src/HtmlToPdfService.Client/Extensions/ServiceCollectionExtensions.cs b/src/HtmlToPdfService.Client/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d759b32 --- /dev/null +++ b/src/HtmlToPdfService.Client/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; + +namespace HtmlToPdfService.Client.Extensions; + +/// +/// IServiceCollection 扩展方法 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 添加 HTML 转 PDF 客户端服务 + /// + /// 服务集合 + /// 配置委托 + /// 服务集合 + public static IServiceCollection AddHtmlToPdfClient( + this IServiceCollection services, + Action configure) + { + services.Configure(configure); + + // 获取配置以决定是否启用重试 + var options = new HtmlToPdfClientOptions(); + configure(options); + + var httpClientBuilder = services.AddHttpClient(); + + if (options.EnableRetry) + { + httpClientBuilder.AddPolicyHandler((sp, _) => + { + var opts = sp.GetRequiredService>().Value; + return GetRetryPolicy(opts); + }); + } + + return services; + } + + /// + /// 添加 HTML 转 PDF 客户端服务(从配置文件读取) + /// + /// 服务集合 + /// 配置对象 + /// 配置节名称,默认 "HtmlToPdfClient" + /// 服务集合 + public static IServiceCollection AddHtmlToPdfClient( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = HtmlToPdfClientOptions.SectionName) + { + var section = configuration.GetSection(sectionName); + services.Configure(section); + + var options = section.Get() ?? new HtmlToPdfClientOptions(); + + var httpClientBuilder = services.AddHttpClient(); + + if (options.EnableRetry) + { + httpClientBuilder.AddPolicyHandler((sp, _) => + { + var opts = sp.GetRequiredService>().Value; + return GetRetryPolicy(opts); + }); + } + + return services; + } + + /// + /// 添加命名的 HTML 转 PDF 客户端服务(支持多个不同配置的实例) + /// + /// 服务集合 + /// 客户端名称 + /// 配置委托 + /// 服务集合 + public static IServiceCollection AddHtmlToPdfClient( + this IServiceCollection services, + string name, + Action configure) + { + // 为命名客户端注册选项 + services.Configure(name, configure); + + var options = new HtmlToPdfClientOptions(); + configure(options); + + var httpClientBuilder = services.AddHttpClient(name, (sp, client) => + { + var optionsMonitor = sp.GetRequiredService>(); + var opts = optionsMonitor.Get(name); + ConfigureHttpClient(client, opts); + }); + + if (options.EnableRetry) + { + httpClientBuilder.AddPolicyHandler((sp, _) => + { + var optionsMonitor = sp.GetRequiredService>(); + var opts = optionsMonitor.Get(name); + return GetRetryPolicy(opts); + }); + } + + // 注册工厂 + services.AddSingleton(); + + return services; + } + + private static void ConfigureHttpClient(HttpClient client, HtmlToPdfClientOptions options) + { + if (!string.IsNullOrEmpty(options.BaseUrl)) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); + } + + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + + if (!string.IsNullOrEmpty(options.ApiKey)) + { + client.DefaultRequestHeaders.Add("X-Api-Key", options.ApiKey); + } + + if (options.CustomHeaders != null) + { + foreach (var header in options.CustomHeaders) + { + client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + private static IAsyncPolicy GetRetryPolicy(HtmlToPdfClientOptions options) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable) + .WaitAndRetryAsync( + options.RetryCount, + retryAttempt => TimeSpan.FromMilliseconds( + options.RetryBaseDelayMs * Math.Pow(2, retryAttempt - 1))); + } +} + +/// +/// 命名客户端工厂接口 +/// +public interface IHtmlToPdfClientFactory +{ + /// + /// 创建指定名称的客户端实例 + /// + /// 客户端名称 + /// 客户端实例 + IHtmlToPdfClient CreateClient(string name); +} + +/// +/// 命名客户端工厂实现 +/// +internal class HtmlToPdfClientFactory : IHtmlToPdfClientFactory +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptionsMonitor _optionsMonitor; + + public HtmlToPdfClientFactory( + IHttpClientFactory httpClientFactory, + IOptionsMonitor optionsMonitor) + { + _httpClientFactory = httpClientFactory; + _optionsMonitor = optionsMonitor; + } + + public IHtmlToPdfClient CreateClient(string name) + { + var httpClient = _httpClientFactory.CreateClient(name); + var options = _optionsMonitor.Get(name); + return new HtmlToPdfClient(httpClient, Options.Create(options)); + } +} + diff --git a/src/HtmlToPdfService.Client/HtmlToPdfClient.cs b/src/HtmlToPdfService.Client/HtmlToPdfClient.cs new file mode 100644 index 0000000..8790c92 --- /dev/null +++ b/src/HtmlToPdfService.Client/HtmlToPdfClient.cs @@ -0,0 +1,559 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using HtmlToPdfService.Client.Exceptions; +using HtmlToPdfService.Client.Models; +using HtmlToPdfService.Client.Models.Requests; +using HtmlToPdfService.Client.Models.Responses; + +namespace HtmlToPdfService.Client; + +/// +/// HTML 转 PDF/图片服务客户端实现 +/// +public class HtmlToPdfClient : IHtmlToPdfClient +{ + private readonly HttpClient _httpClient; + private readonly HtmlToPdfClientOptions _options; + private readonly JsonSerializerOptions _jsonOptions; + + /// + /// 创建客户端实例 + /// + /// HttpClient 实例 + /// 配置选项 + public HtmlToPdfClient(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + // 配置 HttpClient + if (!string.IsNullOrEmpty(_options.BaseUrl)) + { + _httpClient.BaseAddress = new Uri(_options.BaseUrl.TrimEnd('/') + "/"); + } + + _httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds); + + // 添加认证头 + if (!string.IsNullOrEmpty(_options.ApiKey)) + { + _httpClient.DefaultRequestHeaders.Add("X-Api-Key", _options.ApiKey); + } + + // 添加自定义头 + if (_options.CustomHeaders != null) + { + foreach (var header in _options.CustomHeaders) + { + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + #region 同步 PDF 转换 + + /// + public async Task ConvertHtmlToPdfAsync( + string html, + PdfOptions? options = null, + bool saveLocal = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(html)) + throw new ArgumentException("HTML 内容不能为空", nameof(html)); + + var request = new ConvertHtmlToPdfRequest + { + Html = html, + Options = options, + SaveLocal = saveLocal ? true : null + }; + + return await PostForBytesAsync("api/pdf/convert/html", request, cancellationToken); + } + + /// + public async Task ConvertUrlToPdfAsync( + string url, + PdfOptions? options = null, + string? waitUntil = null, + int? timeout = null, + bool saveLocal = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("URL 不能为空", nameof(url)); + + var request = new ConvertUrlToPdfRequest + { + Url = url, + Options = options, + WaitUntil = waitUntil, + Timeout = timeout, + SaveLocal = saveLocal ? true : null + }; + + return await PostForBytesAsync("api/pdf/convert/url", request, cancellationToken); + } + + #endregion + + #region 同步图片转换 + + /// + public async Task ConvertHtmlToImageAsync( + string html, + ImageOptions? options = null, + bool saveLocal = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(html)) + throw new ArgumentException("HTML 内容不能为空", nameof(html)); + + var request = new ConvertHtmlToImageRequest + { + Html = html, + Options = options, + SaveLocal = saveLocal ? true : null + }; + + return await PostForBytesAsync("api/image/convert/html", request, cancellationToken); + } + + /// + public async Task ConvertUrlToImageAsync( + string url, + ImageOptions? options = null, + string? waitUntil = null, + int? timeout = null, + bool saveLocal = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("URL 不能为空", nameof(url)); + + var request = new ConvertUrlToImageRequest + { + Url = url, + Options = options, + WaitUntil = waitUntil, + Timeout = timeout, + SaveLocal = saveLocal ? true : null + }; + + return await PostForBytesAsync("api/image/convert/url", request, cancellationToken); + } + + #endregion + + #region 异步任务 - PDF + + /// + public async Task SubmitPdfTaskAsync( + SourceInfo source, + PdfOptions? options = null, + string? waitUntil = null, + int? timeout = null, + CallbackInfo? callback = null, + bool saveLocal = true, + Dictionary? metadata = null, + string? idempotencyKey = null, + CancellationToken cancellationToken = default) + { + var request = new PdfTaskRequest + { + Source = source, + Options = options, + WaitUntil = waitUntil, + Timeout = timeout, + Callback = callback, + SaveLocal = saveLocal, + Metadata = metadata + }; + + Dictionary? headers = null; + if (!string.IsNullOrEmpty(idempotencyKey)) + { + headers = new Dictionary { ["Idempotency-Key"] = idempotencyKey }; + } + + return await PostAsync("api/tasks/pdf", request, headers, cancellationToken); + } + + #endregion + + #region 异步任务 - 图片 + + /// + public async Task SubmitImageTaskAsync( + SourceInfo source, + ImageOptions? options = null, + string? waitUntil = null, + int? timeout = null, + int? delayAfterLoad = null, + CallbackInfo? callback = null, + bool saveLocal = true, + Dictionary? metadata = null, + string? idempotencyKey = null, + CancellationToken cancellationToken = default) + { + var request = new ImageTaskRequest + { + Source = source, + Options = options, + WaitUntil = waitUntil, + Timeout = timeout, + DelayAfterLoad = delayAfterLoad, + Callback = callback, + SaveLocal = saveLocal, + Metadata = metadata + }; + + Dictionary? headers = null; + if (!string.IsNullOrEmpty(idempotencyKey)) + { + headers = new Dictionary { ["Idempotency-Key"] = idempotencyKey }; + } + + return await PostAsync("api/tasks/image", request, headers, cancellationToken); + } + + #endregion + + #region 批量任务 + + /// + public async Task SubmitBatchAsync( + IEnumerable tasks, + CallbackInfo? callback = null, + bool onEachComplete = false, + bool onAllComplete = true, + CancellationToken cancellationToken = default) + { + var request = new BatchTaskRequest + { + Tasks = tasks.Select(t => new BatchTaskItem + { + Type = t.Type, + Source = t.Source, + PdfOptions = t.PdfOptions, + ImageOptions = t.ImageOptions, + WaitUntil = t.WaitUntil, + Timeout = t.Timeout, + SaveLocal = t.SaveLocal, + Metadata = t.Metadata + }).ToList(), + Callback = callback, + OnEachComplete = onEachComplete, + OnAllComplete = onAllComplete + }; + + return await PostAsync("api/tasks/batch", request, null, cancellationToken); + } + + /// + public async Task GetBatchStatusAsync( + string batchId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(batchId)) + throw new ArgumentException("批量任务 ID 不能为空", nameof(batchId)); + + return await GetAsync($"api/tasks/batch/{batchId}", cancellationToken); + } + + #endregion + + #region 任务管理 + + /// + public async Task GetTaskAsync( + string taskId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + return await GetAsync($"api/tasks/{taskId}", cancellationToken); + } + + /// + public async Task GetTaskStatusAsync( + string taskId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + return await GetAsync($"api/tasks/{taskId}/status", cancellationToken); + } + + /// + public async Task QueryTasksAsync( + string? status = null, + string? type = null, + DateTime? startDate = null, + DateTime? endDate = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + var queryParams = new List + { + $"page={page}", + $"pageSize={pageSize}" + }; + + if (!string.IsNullOrEmpty(status)) + queryParams.Add($"status={Uri.EscapeDataString(status)}"); + if (!string.IsNullOrEmpty(type)) + queryParams.Add($"type={Uri.EscapeDataString(type)}"); + if (startDate.HasValue) + queryParams.Add($"startDate={startDate.Value:O}"); + if (endDate.HasValue) + queryParams.Add($"endDate={endDate.Value:O}"); + + var url = $"api/tasks?{string.Join("&", queryParams)}"; + + return await GetAsync(url, cancellationToken) ?? new TaskListResult(); + } + + /// + public async Task DownloadTaskResultAsync( + string taskId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + var response = await _httpClient.GetAsync($"api/tasks/{taskId}/download", cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + throw new TaskNotFoundException(taskId); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + var problem = await TryReadProblemDetailsAsync(response); + throw new TaskConflictException(taskId, problem?.Detail ?? "任务尚未完成"); + } + + await EnsureSuccessAsync(response); + + return await response.Content.ReadAsByteArrayAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ); + } + + /// + public async Task CancelTaskAsync( + string taskId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + var response = await _httpClient.DeleteAsync($"api/tasks/{taskId}", cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + throw new TaskNotFoundException(taskId); + + if (response.StatusCode == HttpStatusCode.Conflict) + return false; + + await EnsureSuccessAsync(response); + return true; + } + + /// + public async Task RetryTaskAsync( + string taskId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + var response = await _httpClient.PostAsync( + $"api/tasks/{taskId}/retry", + new StringContent("{}", Encoding.UTF8, "application/json"), + cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + throw new TaskNotFoundException(taskId); + + if (response.StatusCode == HttpStatusCode.Conflict) + return false; + + await EnsureSuccessAsync(response); + return true; + } + + #endregion + + #region 等待任务完成 + + /// + public async Task WaitForTaskAsync( + string taskId, + int pollingInterval = 1000, + int maxWaitTime = 300000, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(taskId)) + throw new ArgumentException("任务 ID 不能为空", nameof(taskId)); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromMilliseconds(maxWaitTime); + + while (!cancellationToken.IsCancellationRequested) + { + var task = await GetTaskAsync(taskId, cancellationToken); + + if (task == null) + throw new TaskNotFoundException(taskId); + + if (task.Status == "completed" || task.Status == "failed" || task.Status == "cancelled") + return task; + + if (DateTime.UtcNow - startTime > timeout) + throw new TimeoutException($"等待任务 {taskId} 超时"); + + await Task.Delay(pollingInterval, cancellationToken); + } + + throw new OperationCanceledException(); + } + + /// + public async Task WaitAndDownloadAsync( + string taskId, + int pollingInterval = 1000, + int maxWaitTime = 300000, + CancellationToken cancellationToken = default) + { + var task = await WaitForTaskAsync(taskId, pollingInterval, maxWaitTime, cancellationToken); + + if (task.Status == "failed") + throw new HtmlToPdfClientException($"任务失败: {task.Error?.Message ?? "未知错误"}"); + + if (task.Status == "cancelled") + throw new HtmlToPdfClientException("任务已取消"); + + return await DownloadTaskResultAsync(taskId, cancellationToken); + } + + #endregion + + #region 私有方法 + + private async Task PostAsync( + string url, + object request, + Dictionary? headers, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request, _jsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = content + }; + + if (headers != null) + { + foreach (var header in headers) + { + requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + var response = await _httpClient.SendAsync(requestMessage, cancellationToken); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + return result ?? throw new HtmlToPdfClientException("服务端返回空响应"); + } + + private async Task PostForBytesAsync( + string url, + object request, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request, _jsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(url, content, cancellationToken); + await EnsureSuccessAsync(response); + + return await response.Content.ReadAsByteArrayAsync( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ); + } + + private async Task GetAsync(string url, CancellationToken cancellationToken) + where T : class + { + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await EnsureSuccessAsync(response); + + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + } + + private async Task EnsureSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + return; + + var problemDetails = await TryReadProblemDetailsAsync(response); + var message = problemDetails?.Detail ?? problemDetails?.Title ?? response.ReasonPhrase ?? "请求失败"; + + throw response.StatusCode switch + { + HttpStatusCode.Unauthorized => new AuthenticationException(message), + HttpStatusCode.BadRequest => new ValidationException(message, problemDetails), + HttpStatusCode.ServiceUnavailable => new ServiceUnavailableException(message, problemDetails), + _ => new HtmlToPdfClientException(message, response.StatusCode, problemDetails) + }; + } + + private async Task TryReadProblemDetailsAsync(HttpResponseMessage response) + { + try + { + var content = await response.Content.ReadAsStringAsync( +#if NET8_0_OR_GREATER + default +#endif + ); + + if (string.IsNullOrEmpty(content)) + return null; + + return JsonSerializer.Deserialize(content, _jsonOptions); + } + catch + { + return null; + } + } + + #endregion +} + diff --git a/src/HtmlToPdfService.Client/HtmlToPdfClientOptions.cs b/src/HtmlToPdfService.Client/HtmlToPdfClientOptions.cs new file mode 100644 index 0000000..94ca2dd --- /dev/null +++ b/src/HtmlToPdfService.Client/HtmlToPdfClientOptions.cs @@ -0,0 +1,50 @@ +namespace HtmlToPdfService.Client; + +/// +/// HTML 转 PDF 客户端配置选项 +/// +public class HtmlToPdfClientOptions +{ + /// + /// 配置节名称 + /// + public const string SectionName = "HtmlToPdfClient"; + + /// + /// 服务端基础 URL(必填) + /// 例如: https://pdf-service.example.com + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// API Key(可选,用于认证) + /// + public string? ApiKey { get; set; } + + /// + /// 请求超时时间(秒),默认 120 秒 + /// + public int TimeoutSeconds { get; set; } = 120; + + /// + /// 是否启用重试机制,默认 false + /// + public bool EnableRetry { get; set; } = false; + + /// + /// 重试次数,默认 3 次 + /// + public int RetryCount { get; set; } = 3; + + /// + /// 重试基础延迟(毫秒),默认 500ms + /// 使用指数退避策略 + /// + public int RetryBaseDelayMs { get; set; } = 500; + + /// + /// 自定义请求头 + /// + public Dictionary? CustomHeaders { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/HtmlToPdfService.Client.csproj b/src/HtmlToPdfService.Client/HtmlToPdfService.Client.csproj new file mode 100644 index 0000000..4653d9e --- /dev/null +++ b/src/HtmlToPdfService.Client/HtmlToPdfService.Client.csproj @@ -0,0 +1,37 @@ + + + + net8.0;netstandard2.0 + latest + enable + enable + true + $(NoWarn);CS1591 + + + HtmlToPdfService.Client + 1.0.0 + Your Name + HTML 转 PDF/图片服务的 .NET 客户端库,支持依赖注入 + html;pdf;image;screenshot;puppeteer;client;sdk + MIT + git + + + + + + + + + + + + + + + + + + + diff --git a/src/HtmlToPdfService.Client/IHtmlToPdfClient.cs b/src/HtmlToPdfService.Client/IHtmlToPdfClient.cs new file mode 100644 index 0000000..ba6eab4 --- /dev/null +++ b/src/HtmlToPdfService.Client/IHtmlToPdfClient.cs @@ -0,0 +1,324 @@ +using HtmlToPdfService.Client.Models; +using HtmlToPdfService.Client.Models.Requests; +using HtmlToPdfService.Client.Models.Responses; + +namespace HtmlToPdfService.Client; + +/// +/// HTML 转 PDF/图片服务客户端接口 +/// +public interface IHtmlToPdfClient +{ + #region 同步 PDF 转换 + + /// + /// 将 HTML 内容转换为 PDF(同步) + /// + /// HTML 内容 + /// PDF 选项 + /// 是否在服务端保存副本 + /// 取消令牌 + /// PDF 文件字节数组 + Task ConvertHtmlToPdfAsync( + string html, + PdfOptions? options = null, + bool saveLocal = false, + CancellationToken cancellationToken = default); + + /// + /// 将 URL 页面转换为 PDF(同步) + /// + /// 页面 URL + /// PDF 选项 + /// 等待条件:load, domcontentloaded, networkidle0, networkidle2 + /// 超时时间(秒) + /// 是否在服务端保存副本 + /// 取消令牌 + /// PDF 文件字节数组 + Task ConvertUrlToPdfAsync( + string url, + PdfOptions? options = null, + string? waitUntil = null, + int? timeout = null, + bool saveLocal = false, + CancellationToken cancellationToken = default); + + #endregion + + #region 同步图片转换 + + /// + /// 将 HTML 内容转换为图片(同步) + /// + /// HTML 内容 + /// 图片选项 + /// 是否在服务端保存副本 + /// 取消令牌 + /// 图片文件字节数组 + Task ConvertHtmlToImageAsync( + string html, + ImageOptions? options = null, + bool saveLocal = false, + CancellationToken cancellationToken = default); + + /// + /// 将 URL 页面转换为图片(同步) + /// + /// 页面 URL + /// 图片选项 + /// 等待条件 + /// 超时时间(秒) + /// 是否在服务端保存副本 + /// 取消令牌 + /// 图片文件字节数组 + Task ConvertUrlToImageAsync( + string url, + ImageOptions? options = null, + string? waitUntil = null, + int? timeout = null, + bool saveLocal = false, + CancellationToken cancellationToken = default); + + #endregion + + #region 异步任务 - PDF + + /// + /// 提交 PDF 转换任务(异步) + /// + /// 源信息(HTML 或 URL) + /// PDF 选项 + /// 等待条件(URL 模式时有效) + /// 超时时间(秒) + /// 回调配置 + /// 是否保存到服务端 + /// 自定义元数据 + /// 幂等键 + /// 取消令牌 + /// 任务提交结果 + Task SubmitPdfTaskAsync( + SourceInfo source, + PdfOptions? options = null, + string? waitUntil = null, + int? timeout = null, + CallbackInfo? callback = null, + bool saveLocal = true, + Dictionary? metadata = null, + string? idempotencyKey = null, + CancellationToken cancellationToken = default); + + #endregion + + #region 异步任务 - 图片 + + /// + /// 提交图片转换任务(异步) + /// + /// 源信息(HTML 或 URL) + /// 图片选项 + /// 等待条件(URL 模式时有效) + /// 超时时间(秒) + /// 加载后延迟(毫秒) + /// 回调配置 + /// 是否保存到服务端 + /// 自定义元数据 + /// 幂等键 + /// 取消令牌 + /// 任务提交结果 + Task SubmitImageTaskAsync( + SourceInfo source, + ImageOptions? options = null, + string? waitUntil = null, + int? timeout = null, + int? delayAfterLoad = null, + CallbackInfo? callback = null, + bool saveLocal = true, + Dictionary? metadata = null, + string? idempotencyKey = null, + CancellationToken cancellationToken = default); + + #endregion + + #region 批量任务 + + /// + /// 提交批量转换任务 + /// + /// 任务列表 + /// 回调配置 + /// 每个任务完成时回调 + /// 全部完成时回调 + /// 取消令牌 + /// 批量任务提交结果 + Task SubmitBatchAsync( + IEnumerable tasks, + CallbackInfo? callback = null, + bool onEachComplete = false, + bool onAllComplete = true, + CancellationToken cancellationToken = default); + + /// + /// 获取批量任务状态 + /// + /// 批量任务 ID + /// 取消令牌 + /// 批量任务状态 + Task GetBatchStatusAsync( + string batchId, + CancellationToken cancellationToken = default); + + #endregion + + #region 任务管理 + + /// + /// 获取任务详情 + /// + /// 任务 ID + /// 取消令牌 + /// 任务详情,不存在返回 null + Task GetTaskAsync( + string taskId, + CancellationToken cancellationToken = default); + + /// + /// 获取任务状态(轻量级) + /// + /// 任务 ID + /// 取消令牌 + /// 任务状态 + Task GetTaskStatusAsync( + string taskId, + CancellationToken cancellationToken = default); + + /// + /// 查询任务列表 + /// + /// 状态过滤 + /// 类型过滤:pdf 或 image + /// 开始日期 + /// 结束日期 + /// 页码 + /// 每页数量 + /// 取消令牌 + /// 任务列表 + Task QueryTasksAsync( + string? status = null, + string? type = null, + DateTime? startDate = null, + DateTime? endDate = null, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default); + + /// + /// 下载任务结果文件 + /// + /// 任务 ID + /// 取消令牌 + /// 文件字节数组 + Task DownloadTaskResultAsync( + string taskId, + CancellationToken cancellationToken = default); + + /// + /// 取消任务 + /// + /// 任务 ID + /// 取消令牌 + /// 是否成功取消 + Task CancelTaskAsync( + string taskId, + CancellationToken cancellationToken = default); + + /// + /// 重试失败的任务 + /// + /// 任务 ID + /// 取消令牌 + /// 是否成功重新提交 + Task RetryTaskAsync( + string taskId, + CancellationToken cancellationToken = default); + + #endregion + + #region 等待任务完成 + + /// + /// 等待任务完成并返回结果 + /// + /// 任务 ID + /// 轮询间隔(毫秒),默认 1000 + /// 最大等待时间(毫秒),默认 300000(5分钟) + /// 取消令牌 + /// 任务详情 + Task WaitForTaskAsync( + string taskId, + int pollingInterval = 1000, + int maxWaitTime = 300000, + CancellationToken cancellationToken = default); + + /// + /// 等待任务完成并下载结果 + /// + /// 任务 ID + /// 轮询间隔(毫秒) + /// 最大等待时间(毫秒) + /// 取消令牌 + /// 文件字节数组 + Task WaitAndDownloadAsync( + string taskId, + int pollingInterval = 1000, + int maxWaitTime = 300000, + CancellationToken cancellationToken = default); + + #endregion +} + +/// +/// 批量任务输入项 +/// +public class BatchTaskInput +{ + /// + /// 任务类型:pdf 或 image + /// + public string Type { get; set; } = "pdf"; + + /// + /// 源信息 + /// + public SourceInfo Source { get; set; } = new(); + + /// + /// PDF 选项(Type 为 pdf 时有效) + /// + public PdfOptions? PdfOptions { get; set; } + + /// + /// 图片选项(Type 为 image 时有效) + /// + public ImageOptions? ImageOptions { get; set; } + + /// + /// 等待条件 + /// + public string? WaitUntil { get; set; } + + /// + /// 超时时间(秒) + /// + public int? Timeout { get; set; } + + /// + /// 是否保存到服务端 + /// + public bool SaveLocal { get; set; } = true; + + /// + /// 自定义元数据 + /// + public Dictionary? Metadata { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/ImageOptions.cs b/src/HtmlToPdfService.Client/Models/ImageOptions.cs new file mode 100644 index 0000000..dc0ab02 --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/ImageOptions.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models; + +/// +/// 图片转换选项 +/// +public class ImageOptions +{ + /// + /// 图片格式:png, jpeg, webp + /// + [JsonPropertyName("format")] + public string? Format { get; set; } + + /// + /// 图片质量(1-100),仅对 jpeg/webp 有效 + /// + [JsonPropertyName("quality")] + public int? Quality { get; set; } + + /// + /// 视口宽度(像素) + /// + [JsonPropertyName("width")] + public int? Width { get; set; } + + /// + /// 视口高度(像素) + /// + [JsonPropertyName("height")] + public int? Height { get; set; } + + /// + /// 是否截取整个页面 + /// + [JsonPropertyName("fullPage")] + public bool? FullPage { get; set; } + + /// + /// 是否忽略背景色 + /// + [JsonPropertyName("omitBackground")] + public bool? OmitBackground { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/PdfOptions.cs b/src/HtmlToPdfService.Client/Models/PdfOptions.cs new file mode 100644 index 0000000..c8c6846 --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/PdfOptions.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models; + +/// +/// PDF 转换选项 +/// +public class PdfOptions +{ + /// + /// 页面格式,如 A4, Letter, Legal 等 + /// + [JsonPropertyName("format")] + public string? Format { get; set; } + + /// + /// 是否横向 + /// + [JsonPropertyName("landscape")] + public bool? Landscape { get; set; } + + /// + /// 是否打印背景 + /// + [JsonPropertyName("printBackground")] + public bool? PrintBackground { get; set; } + + /// + /// 页边距设置 + /// + [JsonPropertyName("margin")] + public MarginOptions? Margin { get; set; } +} + +/// +/// 页边距选项 +/// +public class MarginOptions +{ + /// + /// 上边距(如 "10mm") + /// + [JsonPropertyName("top")] + public string? Top { get; set; } + + /// + /// 右边距 + /// + [JsonPropertyName("right")] + public string? Right { get; set; } + + /// + /// 下边距 + /// + [JsonPropertyName("bottom")] + public string? Bottom { get; set; } + + /// + /// 左边距 + /// + [JsonPropertyName("left")] + public string? Left { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/Requests/ConvertHtmlRequest.cs b/src/HtmlToPdfService.Client/Models/Requests/ConvertHtmlRequest.cs new file mode 100644 index 0000000..b0d3f02 --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/Requests/ConvertHtmlRequest.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models.Requests; + +/// +/// HTML 转 PDF 请求 +/// +internal class ConvertHtmlToPdfRequest +{ + [JsonPropertyName("html")] + public string Html { get; set; } = string.Empty; + + [JsonPropertyName("options")] + public PdfOptions? Options { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } +} + +/// +/// HTML 转图片请求 +/// +internal class ConvertHtmlToImageRequest +{ + [JsonPropertyName("html")] + public string Html { get; set; } = string.Empty; + + [JsonPropertyName("options")] + public ImageOptions? Options { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } +} + +/// +/// URL 转 PDF 请求 +/// +internal class ConvertUrlToPdfRequest +{ + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("waitUntil")] + public string? WaitUntil { get; set; } + + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + [JsonPropertyName("options")] + public PdfOptions? Options { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } +} + +/// +/// URL 转图片请求 +/// +internal class ConvertUrlToImageRequest +{ + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("waitUntil")] + public string? WaitUntil { get; set; } + + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + [JsonPropertyName("options")] + public ImageOptions? Options { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/Requests/TaskRequests.cs b/src/HtmlToPdfService.Client/Models/Requests/TaskRequests.cs new file mode 100644 index 0000000..de6833b --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/Requests/TaskRequests.cs @@ -0,0 +1,151 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models.Requests; + +/// +/// 异步 PDF 任务请求 +/// +internal class PdfTaskRequest +{ + [JsonPropertyName("source")] + public SourceInfo Source { get; set; } = new(); + + [JsonPropertyName("options")] + public PdfOptions? Options { get; set; } + + [JsonPropertyName("waitUntil")] + public string? WaitUntil { get; set; } + + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + [JsonPropertyName("callback")] + public CallbackInfo? Callback { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// 异步图片任务请求 +/// +internal class ImageTaskRequest +{ + [JsonPropertyName("source")] + public SourceInfo Source { get; set; } = new(); + + [JsonPropertyName("options")] + public ImageOptions? Options { get; set; } + + [JsonPropertyName("waitUntil")] + public string? WaitUntil { get; set; } + + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + [JsonPropertyName("delayAfterLoad")] + public int? DelayAfterLoad { get; set; } + + [JsonPropertyName("callback")] + public CallbackInfo? Callback { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// 批量任务请求 +/// +internal class BatchTaskRequest +{ + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = new(); + + [JsonPropertyName("callback")] + public CallbackInfo? Callback { get; set; } + + [JsonPropertyName("onEachComplete")] + public bool OnEachComplete { get; set; } + + [JsonPropertyName("onAllComplete")] + public bool OnAllComplete { get; set; } = true; +} + +/// +/// 批量任务项 +/// +internal class BatchTaskItem +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "pdf"; + + [JsonPropertyName("source")] + public SourceInfo Source { get; set; } = new(); + + [JsonPropertyName("pdfOptions")] + public PdfOptions? PdfOptions { get; set; } + + [JsonPropertyName("imageOptions")] + public ImageOptions? ImageOptions { get; set; } + + [JsonPropertyName("waitUntil")] + public string? WaitUntil { get; set; } + + [JsonPropertyName("timeout")] + public int? Timeout { get; set; } + + [JsonPropertyName("saveLocal")] + public bool? SaveLocal { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// 源信息 +/// +public class SourceInfo +{ + /// + /// 源类型:html 或 url + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "html"; + + /// + /// 源内容:HTML 字符串或 URL + /// + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +/// +/// 回调配置 +/// +public class CallbackInfo +{ + /// + /// 回调 URL + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// 自定义回调头 + /// + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + /// + /// 是否在回调中包含文件数据 + /// + [JsonPropertyName("includeFileData")] + public bool IncludeFileData { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/Responses/ErrorResponse.cs b/src/HtmlToPdfService.Client/Models/Responses/ErrorResponse.cs new file mode 100644 index 0000000..b52051e --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/Responses/ErrorResponse.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models.Responses; + +/// +/// API 错误响应(ProblemDetails 格式) +/// +public class ProblemDetails +{ + /// + /// HTTP 状态码 + /// + [JsonPropertyName("status")] + public int? Status { get; set; } + + /// + /// 错误标题 + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// 错误详情 + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + /// + /// 错误类型 URI + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// 实例路径 + /// + [JsonPropertyName("instance")] + public string? Instance { get; set; } +} + diff --git a/src/HtmlToPdfService.Client/Models/Responses/TaskResponses.cs b/src/HtmlToPdfService.Client/Models/Responses/TaskResponses.cs new file mode 100644 index 0000000..20e360b --- /dev/null +++ b/src/HtmlToPdfService.Client/Models/Responses/TaskResponses.cs @@ -0,0 +1,484 @@ +using System.Text.Json.Serialization; + +namespace HtmlToPdfService.Client.Models.Responses; + +/// +/// 任务提交响应 +/// +public class TaskSubmitResult +{ + /// + /// 任务 ID + /// + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + /// + /// 任务状态 + /// + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + /// + /// 消息 + /// + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// + /// 创建时间 + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 预计等待时间(秒) + /// + [JsonPropertyName("estimatedWaitTime")] + public int EstimatedWaitTime { get; set; } + + /// + /// 队列位置 + /// + [JsonPropertyName("queuePosition")] + public int QueuePosition { get; set; } + + /// + /// 相关链接 + /// + [JsonPropertyName("links")] + public TaskLinks? Links { get; set; } +} + +/// +/// 批量任务提交响应 +/// +public class BatchSubmitResult +{ + /// + /// 批量任务 ID + /// + [JsonPropertyName("batchId")] + public string BatchId { get; set; } = string.Empty; + + /// + /// 所有任务 ID + /// + [JsonPropertyName("taskIds")] + public List TaskIds { get; set; } = new(); + + /// + /// 总任务数 + /// + [JsonPropertyName("totalTasks")] + public int TotalTasks { get; set; } + + /// + /// 成功数 + /// + [JsonPropertyName("successCount")] + public int SuccessCount { get; set; } + + /// + /// 失败数 + /// + [JsonPropertyName("failedCount")] + public int FailedCount { get; set; } + + /// + /// 相关链接 + /// + [JsonPropertyName("links")] + public BatchLinks? Links { get; set; } +} + +/// +/// 任务详情 +/// +public class TaskDetail +{ + /// + /// 任务 ID + /// + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + /// + /// 任务类型:pdf 或 image + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// 源信息 + /// + [JsonPropertyName("source")] + public TaskSourceInfo? Source { get; set; } + + /// + /// 任务状态 + /// + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + /// + /// 创建时间 + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 开始时间 + /// + [JsonPropertyName("startedAt")] + public DateTime? StartedAt { get; set; } + + /// + /// 完成时间 + /// + [JsonPropertyName("completedAt")] + public DateTime? CompletedAt { get; set; } + + /// + /// 耗时(毫秒) + /// + [JsonPropertyName("duration")] + public long Duration { get; set; } + + /// + /// 重试次数 + /// + [JsonPropertyName("retryCount")] + public int RetryCount { get; set; } + + /// + /// 任务结果 + /// + [JsonPropertyName("result")] + public TaskResultInfo? Result { get; set; } + + /// + /// 错误信息 + /// + [JsonPropertyName("error")] + public TaskErrorInfo? Error { get; set; } + + /// + /// 相关链接 + /// + [JsonPropertyName("links")] + public TaskLinks? Links { get; set; } +} + +/// +/// 任务源信息 +/// +public class TaskSourceInfo +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +/// +/// 任务结果信息 +/// +public class TaskResultInfo +{ + [JsonPropertyName("fileSize")] + public long? FileSize { get; set; } + + [JsonPropertyName("fileType")] + public string? FileType { get; set; } + + [JsonPropertyName("downloadUrl")] + public string? DownloadUrl { get; set; } +} + +/// +/// 任务错误信息 +/// +public class TaskErrorInfo +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +/// +/// 任务状态响应(轻量级) +/// +public class TaskStatusResult +{ + /// + /// 任务 ID + /// + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + /// + /// 任务状态 + /// + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + /// + /// 创建时间 + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 开始时间 + /// + [JsonPropertyName("startedAt")] + public DateTime? StartedAt { get; set; } + + /// + /// 完成时间 + /// + [JsonPropertyName("completedAt")] + public DateTime? CompletedAt { get; set; } +} + +/// +/// 批量任务状态 +/// +public class BatchStatusResult +{ + /// + /// 批量任务 ID + /// + [JsonPropertyName("batchId")] + public string BatchId { get; set; } = string.Empty; + + /// + /// 状态 + /// + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + /// + /// 总任务数 + /// + [JsonPropertyName("totalTasks")] + public int TotalTasks { get; set; } + + /// + /// 已完成数 + /// + [JsonPropertyName("completedTasks")] + public int CompletedTasks { get; set; } + + /// + /// 失败数 + /// + [JsonPropertyName("failedTasks")] + public int FailedTasks { get; set; } + + /// + /// 处理中数 + /// + [JsonPropertyName("processingTasks")] + public int ProcessingTasks { get; set; } + + /// + /// 等待中数 + /// + [JsonPropertyName("pendingTasks")] + public int PendingTasks { get; set; } + + /// + /// 创建时间 + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 完成时间 + /// + [JsonPropertyName("completedAt")] + public DateTime? CompletedAt { get; set; } + + /// + /// 任务列表 + /// + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = new(); + + /// + /// 相关链接 + /// + [JsonPropertyName("links")] + public BatchLinks? Links { get; set; } +} + +/// +/// 批量任务中的单个任务状态 +/// +public class BatchTaskStatus +{ + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + [JsonPropertyName("type")] + public string Type { get; set; } = "pdf"; + + [JsonPropertyName("duration")] + public long Duration { get; set; } + + [JsonPropertyName("downloadUrl")] + public string? DownloadUrl { get; set; } + + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } +} + +/// +/// 任务列表响应 +/// +public class TaskListResult +{ + /// + /// 总数 + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 当前页 + /// + [JsonPropertyName("page")] + public int Page { get; set; } + + /// + /// 每页数量 + /// + [JsonPropertyName("pageSize")] + public int PageSize { get; set; } + + /// + /// 任务列表 + /// + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + /// + /// 分页链接 + /// + [JsonPropertyName("links")] + public PaginationLinks? Links { get; set; } +} + +/// +/// 任务摘要 +/// +public class TaskSummary +{ + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("duration")] + public long Duration { get; set; } + + [JsonPropertyName("fileSize")] + public long? FileSize { get; set; } + + [JsonPropertyName("links")] + public TaskLinks? Links { get; set; } +} + +/// +/// 任务相关链接 +/// +public class TaskLinks +{ + [JsonPropertyName("self")] + public string? Self { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("download")] + public string? Download { get; set; } + + [JsonPropertyName("cancel")] + public string? Cancel { get; set; } + + [JsonPropertyName("retry")] + public string? Retry { get; set; } +} + +/// +/// 批量任务链接 +/// +public class BatchLinks +{ + [JsonPropertyName("status")] + public string? Status { get; set; } +} + +/// +/// 分页链接 +/// +public class PaginationLinks +{ + [JsonPropertyName("first")] + public string? First { get; set; } + + [JsonPropertyName("prev")] + public string? Prev { get; set; } + + [JsonPropertyName("next")] + public string? Next { get; set; } + + [JsonPropertyName("last")] + public string? Last { get; set; } +} + +/// +/// 任务取消响应 +/// +public class TaskCancelResult +{ + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = "cancelled"; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +/// +/// 任务重试响应 +/// +public class TaskRetryResult +{ + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = "pending"; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + diff --git a/src/HtmlToPdfService.Client/README.md b/src/HtmlToPdfService.Client/README.md new file mode 100644 index 0000000..bf2366a --- /dev/null +++ b/src/HtmlToPdfService.Client/README.md @@ -0,0 +1,298 @@ +# HtmlToPdfService.Client + +HTML 转 PDF/图片服务的 .NET 客户端库,支持依赖注入。 + +## 安装 + +```bash +dotnet add package HtmlToPdfService.Client +``` + +## 快速开始 + +### 方式一:依赖注入(推荐) + +```csharp +// Program.cs +using HtmlToPdfService.Client.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// 添加客户端服务 +builder.Services.AddHtmlToPdfClient(options => +{ + options.BaseUrl = "https://pdf-service.example.com"; + options.ApiKey = "your-api-key"; // 可选 + options.EnableRetry = true; // 可选,启用重试 +}); + +var app = builder.Build(); +``` + +```csharp +// 使用服务 +public class MyService +{ + private readonly IHtmlToPdfClient _pdfClient; + + public MyService(IHtmlToPdfClient pdfClient) + { + _pdfClient = pdfClient; + } + + public async Task GeneratePdfAsync() + { + return await _pdfClient.ConvertHtmlToPdfAsync("

Hello World

"); + } +} +``` + +### 方式二:从配置文件读取 + +```json +// appsettings.json +{ + "HtmlToPdfClient": { + "BaseUrl": "https://pdf-service.example.com", + "ApiKey": "your-api-key", + "TimeoutSeconds": 120, + "EnableRetry": true, + "RetryCount": 3 + } +} +``` + +```csharp +// Program.cs +builder.Services.AddHtmlToPdfClient(builder.Configuration); +``` + +### 方式三:多服务器配置(命名客户端) + +```csharp +// 注册多个命名客户端 +builder.Services.AddHtmlToPdfClient("production", options => +{ + options.BaseUrl = "https://pdf-prod.example.com"; + options.ApiKey = "prod-api-key"; +}); + +builder.Services.AddHtmlToPdfClient("staging", options => +{ + options.BaseUrl = "https://pdf-staging.example.com"; + options.ApiKey = "staging-api-key"; +}); +``` + +```csharp +// 使用工厂创建指定客户端 +public class MyService +{ + private readonly IHtmlToPdfClientFactory _factory; + + public MyService(IHtmlToPdfClientFactory factory) + { + _factory = factory; + } + + public async Task GeneratePdfAsync(bool useProduction) + { + var clientName = useProduction ? "production" : "staging"; + var client = _factory.CreateClient(clientName); + return await client.ConvertHtmlToPdfAsync("

Hello

"); + } +} +``` + +### 方式四:直接实例化 + +```csharp +using HtmlToPdfService.Client; +using Microsoft.Extensions.Options; + +var options = new HtmlToPdfClientOptions +{ + BaseUrl = "https://pdf-service.example.com", + ApiKey = "your-api-key" +}; + +using var httpClient = new HttpClient(); +var client = new HtmlToPdfClient(httpClient, Options.Create(options)); + +var pdf = await client.ConvertHtmlToPdfAsync("

Hello

"); +``` + +## 功能示例 + +### 同步 PDF 转换 + +```csharp +// HTML 转 PDF +var pdf = await client.ConvertHtmlToPdfAsync( + html: "

Hello

", + options: new PdfOptions + { + Format = "A4", + Landscape = false, + PrintBackground = true, + Margin = new MarginOptions + { + Top = "10mm", + Right = "10mm", + Bottom = "10mm", + Left = "10mm" + } + }); + +// URL 转 PDF +var pdf = await client.ConvertUrlToPdfAsync( + url: "https://example.com", + waitUntil: "networkidle0", + timeout: 30); +``` + +### 同步图片转换 + +```csharp +// HTML 转图片 +var image = await client.ConvertHtmlToImageAsync( + html: "

Hello

", + options: new ImageOptions + { + Format = "png", + Width = 1920, + Height = 1080, + FullPage = true + }); + +// URL 转图片 +var image = await client.ConvertUrlToImageAsync( + url: "https://example.com", + options: new ImageOptions { Format = "jpeg", Quality = 90 }); +``` + +### 异步任务 + +```csharp +// 提交 PDF 任务 +var result = await client.SubmitPdfTaskAsync( + source: new SourceInfo { Type = "html", Content = "

Hello

" }, + options: new PdfOptions { Format = "A4" }, + callback: new CallbackInfo { Url = "https://my-app.com/callback" }); + +Console.WriteLine($"任务 ID: {result.TaskId}"); +Console.WriteLine($"队列位置: {result.QueuePosition}"); + +// 查询任务状态 +var status = await client.GetTaskStatusAsync(result.TaskId); +Console.WriteLine($"状态: {status.Status}"); + +// 等待任务完成并下载 +var pdf = await client.WaitAndDownloadAsync(result.TaskId); +``` + +### 批量任务 + +```csharp +var tasks = new List +{ + new BatchTaskInput + { + Type = "pdf", + Source = new SourceInfo { Type = "html", Content = "

Doc 1

" } + }, + new BatchTaskInput + { + Type = "image", + Source = new SourceInfo { Type = "url", Content = "https://example.com" }, + ImageOptions = new ImageOptions { Format = "png" } + } +}; + +var batch = await client.SubmitBatchAsync(tasks); +Console.WriteLine($"批量任务 ID: {batch.BatchId}"); + +// 查询批量任务状态 +var batchStatus = await client.GetBatchStatusAsync(batch.BatchId); +Console.WriteLine($"已完成: {batchStatus.CompletedTasks}/{batchStatus.TotalTasks}"); +``` + +### 任务管理 + +```csharp +// 查询任务列表 +var list = await client.QueryTasksAsync( + status: "completed", + type: "pdf", + page: 1, + pageSize: 20); + +// 取消任务 +await client.CancelTaskAsync(taskId); + +// 重试失败的任务 +await client.RetryTaskAsync(taskId); +``` + +## 配置选项 + +| 选项 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `BaseUrl` | string | - | 服务端地址(必填) | +| `ApiKey` | string | null | API Key(可选) | +| `TimeoutSeconds` | int | 120 | 请求超时时间 | +| `EnableRetry` | bool | false | 是否启用重试 | +| `RetryCount` | int | 3 | 重试次数 | +| `RetryBaseDelayMs` | int | 500 | 重试基础延迟(指数退避) | +| `CustomHeaders` | Dictionary | null | 自定义请求头 | + +## 异常处理 + +```csharp +using HtmlToPdfService.Client.Exceptions; + +try +{ + var pdf = await client.ConvertHtmlToPdfAsync("

Hello

"); +} +catch (ValidationException ex) +{ + // 参数验证失败 (400) + Console.WriteLine($"参数错误: {ex.Message}"); +} +catch (AuthenticationException ex) +{ + // 认证失败 (401) + Console.WriteLine($"认证失败: {ex.Message}"); +} +catch (TaskNotFoundException ex) +{ + // 任务不存在 (404) + Console.WriteLine($"任务不存在: {ex.TaskId}"); +} +catch (TaskConflictException ex) +{ + // 任务状态冲突 (409) + Console.WriteLine($"任务状态: {ex.CurrentStatus}"); +} +catch (ServiceUnavailableException ex) +{ + // 服务不可用 (503) + Console.WriteLine($"服务繁忙: {ex.Message}"); +} +catch (HtmlToPdfClientException ex) +{ + // 其他错误 + Console.WriteLine($"错误: {ex.Message}, 状态码: {ex.StatusCode}"); +} +``` + +## 支持的框架 + +- .NET 8.0 +- .NET Standard 2.0(兼容 .NET Framework 4.6.1+、.NET Core 2.0+ 等) + +## License + +MIT + diff --git a/src/HtmlToPdfService.sln b/src/HtmlToPdfService.sln index 458d86f..707d35b 100644 --- a/src/HtmlToPdfService.sln +++ b/src/HtmlToPdfService.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlToPdfService.Infrastruc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlToPdfService.Tests", "HtmlToPdfService.Tests\HtmlToPdfService.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF01-345678901234}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HtmlToPdfService.Client", "HtmlToPdfService.Client\HtmlToPdfService.Client.csproj", "{F6A7B8C9-D0E1-2345-F012-456789012345}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {E5F6A7B8-C9D0-1234-EF01-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU {E5F6A7B8-C9D0-1234-EF01-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5F6A7B8-C9D0-1234-EF01-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2345-F012-456789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F012-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal