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