This commit is contained in:
zpc 2026-03-16 18:10:38 +08:00
parent d88f33e0a5
commit cf67f4a541
14 changed files with 2471 additions and 0 deletions

View File

@ -0,0 +1,149 @@
using System.Net;
using HtmlToPdfService.Client.Models.Responses;
namespace HtmlToPdfService.Client.Exceptions;
/// <summary>
/// HTML 转 PDF 客户端异常
/// </summary>
public class HtmlToPdfClientException : Exception
{
/// <summary>
/// HTTP 状态码
/// </summary>
public HttpStatusCode? StatusCode { get; }
/// <summary>
/// 错误详情(如果服务端返回 ProblemDetails
/// </summary>
public ProblemDetails? ProblemDetails { get; }
/// <summary>
/// 创建异常实例
/// </summary>
public HtmlToPdfClientException(string message)
: base(message)
{
}
/// <summary>
/// 创建带内部异常的实例
/// </summary>
public HtmlToPdfClientException(string message, Exception innerException)
: base(message, innerException)
{
}
/// <summary>
/// 创建带 HTTP 状态码的实例
/// </summary>
public HtmlToPdfClientException(string message, HttpStatusCode statusCode)
: base(message)
{
StatusCode = statusCode;
}
/// <summary>
/// 创建带 ProblemDetails 的实例
/// </summary>
public HtmlToPdfClientException(string message, HttpStatusCode statusCode, ProblemDetails? problemDetails)
: base(message)
{
StatusCode = statusCode;
ProblemDetails = problemDetails;
}
/// <summary>
/// 创建带 ProblemDetails 和内部异常的实例
/// </summary>
public HtmlToPdfClientException(string message, HttpStatusCode statusCode, ProblemDetails? problemDetails, Exception innerException)
: base(message, innerException)
{
StatusCode = statusCode;
ProblemDetails = problemDetails;
}
}
/// <summary>
/// 任务未找到异常
/// </summary>
public class TaskNotFoundException : HtmlToPdfClientException
{
/// <summary>
/// 任务 ID
/// </summary>
public string TaskId { get; }
public TaskNotFoundException(string taskId)
: base($"任务不存在: {taskId}", HttpStatusCode.NotFound)
{
TaskId = taskId;
}
}
/// <summary>
/// 任务冲突异常(如任务状态不允许操作)
/// </summary>
public class TaskConflictException : HtmlToPdfClientException
{
/// <summary>
/// 任务 ID
/// </summary>
public string TaskId { get; }
/// <summary>
/// 当前任务状态
/// </summary>
public string? CurrentStatus { get; }
public TaskConflictException(string taskId, string message, string? currentStatus = null)
: base(message, HttpStatusCode.Conflict)
{
TaskId = taskId;
CurrentStatus = currentStatus;
}
}
/// <summary>
/// 服务不可用异常
/// </summary>
public class ServiceUnavailableException : HtmlToPdfClientException
{
public ServiceUnavailableException(string message)
: base(message, HttpStatusCode.ServiceUnavailable)
{
}
public ServiceUnavailableException(string message, ProblemDetails? problemDetails)
: base(message, HttpStatusCode.ServiceUnavailable, problemDetails)
{
}
}
/// <summary>
/// 认证失败异常
/// </summary>
public class AuthenticationException : HtmlToPdfClientException
{
public AuthenticationException(string message = "认证失败,请检查 API Key")
: base(message, HttpStatusCode.Unauthorized)
{
}
}
/// <summary>
/// 参数验证异常
/// </summary>
public class ValidationException : HtmlToPdfClientException
{
public ValidationException(string message)
: base(message, HttpStatusCode.BadRequest)
{
}
public ValidationException(string message, ProblemDetails? problemDetails)
: base(message, HttpStatusCode.BadRequest, problemDetails)
{
}
}

View File

@ -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;
/// <summary>
/// IServiceCollection 扩展方法
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加 HTML 转 PDF 客户端服务
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configure">配置委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddHtmlToPdfClient(
this IServiceCollection services,
Action<HtmlToPdfClientOptions> configure)
{
services.Configure(configure);
// 获取配置以决定是否启用重试
var options = new HtmlToPdfClientOptions();
configure(options);
var httpClientBuilder = services.AddHttpClient<IHtmlToPdfClient, HtmlToPdfClient>();
if (options.EnableRetry)
{
httpClientBuilder.AddPolicyHandler((sp, _) =>
{
var opts = sp.GetRequiredService<IOptions<HtmlToPdfClientOptions>>().Value;
return GetRetryPolicy(opts);
});
}
return services;
}
/// <summary>
/// 添加 HTML 转 PDF 客户端服务(从配置文件读取)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置对象</param>
/// <param name="sectionName">配置节名称,默认 "HtmlToPdfClient"</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddHtmlToPdfClient(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = HtmlToPdfClientOptions.SectionName)
{
var section = configuration.GetSection(sectionName);
services.Configure<HtmlToPdfClientOptions>(section);
var options = section.Get<HtmlToPdfClientOptions>() ?? new HtmlToPdfClientOptions();
var httpClientBuilder = services.AddHttpClient<IHtmlToPdfClient, HtmlToPdfClient>();
if (options.EnableRetry)
{
httpClientBuilder.AddPolicyHandler((sp, _) =>
{
var opts = sp.GetRequiredService<IOptions<HtmlToPdfClientOptions>>().Value;
return GetRetryPolicy(opts);
});
}
return services;
}
/// <summary>
/// 添加命名的 HTML 转 PDF 客户端服务(支持多个不同配置的实例)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="name">客户端名称</param>
/// <param name="configure">配置委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddHtmlToPdfClient(
this IServiceCollection services,
string name,
Action<HtmlToPdfClientOptions> configure)
{
// 为命名客户端注册选项
services.Configure(name, configure);
var options = new HtmlToPdfClientOptions();
configure(options);
var httpClientBuilder = services.AddHttpClient(name, (sp, client) =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<HtmlToPdfClientOptions>>();
var opts = optionsMonitor.Get(name);
ConfigureHttpClient(client, opts);
});
if (options.EnableRetry)
{
httpClientBuilder.AddPolicyHandler((sp, _) =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<HtmlToPdfClientOptions>>();
var opts = optionsMonitor.Get(name);
return GetRetryPolicy(opts);
});
}
// 注册工厂
services.AddSingleton<IHtmlToPdfClientFactory, HtmlToPdfClientFactory>();
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<HttpResponseMessage> 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)));
}
}
/// <summary>
/// 命名客户端工厂接口
/// </summary>
public interface IHtmlToPdfClientFactory
{
/// <summary>
/// 创建指定名称的客户端实例
/// </summary>
/// <param name="name">客户端名称</param>
/// <returns>客户端实例</returns>
IHtmlToPdfClient CreateClient(string name);
}
/// <summary>
/// 命名客户端工厂实现
/// </summary>
internal class HtmlToPdfClientFactory : IHtmlToPdfClientFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptionsMonitor<HtmlToPdfClientOptions> _optionsMonitor;
public HtmlToPdfClientFactory(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<HtmlToPdfClientOptions> 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));
}
}

View File

@ -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;
/// <summary>
/// HTML 转 PDF/图片服务客户端实现
/// </summary>
public class HtmlToPdfClient : IHtmlToPdfClient
{
private readonly HttpClient _httpClient;
private readonly HtmlToPdfClientOptions _options;
private readonly JsonSerializerOptions _jsonOptions;
/// <summary>
/// 创建客户端实例
/// </summary>
/// <param name="httpClient">HttpClient 实例</param>
/// <param name="options">配置选项</param>
public HtmlToPdfClient(HttpClient httpClient, IOptions<HtmlToPdfClientOptions> 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
/// <inheritdoc />
public async Task<byte[]> 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);
}
/// <inheritdoc />
public async Task<byte[]> 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
/// <inheritdoc />
public async Task<byte[]> 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);
}
/// <inheritdoc />
public async Task<byte[]> 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
/// <inheritdoc />
public async Task<TaskSubmitResult> SubmitPdfTaskAsync(
SourceInfo source,
PdfOptions? options = null,
string? waitUntil = null,
int? timeout = null,
CallbackInfo? callback = null,
bool saveLocal = true,
Dictionary<string, string>? 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<string, string>? headers = null;
if (!string.IsNullOrEmpty(idempotencyKey))
{
headers = new Dictionary<string, string> { ["Idempotency-Key"] = idempotencyKey };
}
return await PostAsync<TaskSubmitResult>("api/tasks/pdf", request, headers, cancellationToken);
}
#endregion
#region -
/// <inheritdoc />
public async Task<TaskSubmitResult> SubmitImageTaskAsync(
SourceInfo source,
ImageOptions? options = null,
string? waitUntil = null,
int? timeout = null,
int? delayAfterLoad = null,
CallbackInfo? callback = null,
bool saveLocal = true,
Dictionary<string, string>? 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<string, string>? headers = null;
if (!string.IsNullOrEmpty(idempotencyKey))
{
headers = new Dictionary<string, string> { ["Idempotency-Key"] = idempotencyKey };
}
return await PostAsync<TaskSubmitResult>("api/tasks/image", request, headers, cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public async Task<BatchSubmitResult> SubmitBatchAsync(
IEnumerable<BatchTaskInput> 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<BatchSubmitResult>("api/tasks/batch", request, null, cancellationToken);
}
/// <inheritdoc />
public async Task<BatchStatusResult?> GetBatchStatusAsync(
string batchId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(batchId))
throw new ArgumentException("批量任务 ID 不能为空", nameof(batchId));
return await GetAsync<BatchStatusResult>($"api/tasks/batch/{batchId}", cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public async Task<TaskDetail?> GetTaskAsync(
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(taskId))
throw new ArgumentException("任务 ID 不能为空", nameof(taskId));
return await GetAsync<TaskDetail>($"api/tasks/{taskId}", cancellationToken);
}
/// <inheritdoc />
public async Task<TaskStatusResult?> GetTaskStatusAsync(
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(taskId))
throw new ArgumentException("任务 ID 不能为空", nameof(taskId));
return await GetAsync<TaskStatusResult>($"api/tasks/{taskId}/status", cancellationToken);
}
/// <inheritdoc />
public async Task<TaskListResult> 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<string>
{
$"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<TaskListResult>(url, cancellationToken) ?? new TaskListResult();
}
/// <inheritdoc />
public async Task<byte[]> 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
);
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<bool> 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
/// <inheritdoc />
public async Task<TaskDetail> 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();
}
/// <inheritdoc />
public async Task<byte[]> 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<T> PostAsync<T>(
string url,
object request,
Dictionary<string, string>? 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<T>(_jsonOptions, cancellationToken);
return result ?? throw new HtmlToPdfClientException("服务端返回空响应");
}
private async Task<byte[]> 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<T?> GetAsync<T>(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<T>(_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<ProblemDetails?> 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<ProblemDetails>(content, _jsonOptions);
}
catch
{
return null;
}
}
#endregion
}

View File

@ -0,0 +1,50 @@
namespace HtmlToPdfService.Client;
/// <summary>
/// HTML 转 PDF 客户端配置选项
/// </summary>
public class HtmlToPdfClientOptions
{
/// <summary>
/// 配置节名称
/// </summary>
public const string SectionName = "HtmlToPdfClient";
/// <summary>
/// 服务端基础 URL必填
/// 例如: https://pdf-service.example.com
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// API Key可选用于认证
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// 请求超时时间(秒),默认 120 秒
/// </summary>
public int TimeoutSeconds { get; set; } = 120;
/// <summary>
/// 是否启用重试机制,默认 false
/// </summary>
public bool EnableRetry { get; set; } = false;
/// <summary>
/// 重试次数,默认 3 次
/// </summary>
public int RetryCount { get; set; } = 3;
/// <summary>
/// 重试基础延迟(毫秒),默认 500ms
/// 使用指数退避策略
/// </summary>
public int RetryBaseDelayMs { get; set; } = 500;
/// <summary>
/// 自定义请求头
/// </summary>
public Dictionary<string, string>? CustomHeaders { get; set; }
}

View File

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- NuGet 包信息 -->
<PackageId>HtmlToPdfService.Client</PackageId>
<Version>1.0.0</Version>
<Authors>Your Name</Authors>
<Description>HTML 转 PDF/图片服务的 .NET 客户端库,支持依赖注入</Description>
<PackageTags>html;pdf;image;screenshot;puppeteer;client;sdk</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<!-- .NET Standard 2.0 需要额外引用 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="System.Net.Http.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,324 @@
using HtmlToPdfService.Client.Models;
using HtmlToPdfService.Client.Models.Requests;
using HtmlToPdfService.Client.Models.Responses;
namespace HtmlToPdfService.Client;
/// <summary>
/// HTML 转 PDF/图片服务客户端接口
/// </summary>
public interface IHtmlToPdfClient
{
#region PDF
/// <summary>
/// 将 HTML 内容转换为 PDF同步
/// </summary>
/// <param name="html">HTML 内容</param>
/// <param name="options">PDF 选项</param>
/// <param name="saveLocal">是否在服务端保存副本</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>PDF 文件字节数组</returns>
Task<byte[]> ConvertHtmlToPdfAsync(
string html,
PdfOptions? options = null,
bool saveLocal = false,
CancellationToken cancellationToken = default);
/// <summary>
/// 将 URL 页面转换为 PDF同步
/// </summary>
/// <param name="url">页面 URL</param>
/// <param name="options">PDF 选项</param>
/// <param name="waitUntil">等待条件load, domcontentloaded, networkidle0, networkidle2</param>
/// <param name="timeout">超时时间(秒)</param>
/// <param name="saveLocal">是否在服务端保存副本</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>PDF 文件字节数组</returns>
Task<byte[]> ConvertUrlToPdfAsync(
string url,
PdfOptions? options = null,
string? waitUntil = null,
int? timeout = null,
bool saveLocal = false,
CancellationToken cancellationToken = default);
#endregion
#region
/// <summary>
/// 将 HTML 内容转换为图片(同步)
/// </summary>
/// <param name="html">HTML 内容</param>
/// <param name="options">图片选项</param>
/// <param name="saveLocal">是否在服务端保存副本</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>图片文件字节数组</returns>
Task<byte[]> ConvertHtmlToImageAsync(
string html,
ImageOptions? options = null,
bool saveLocal = false,
CancellationToken cancellationToken = default);
/// <summary>
/// 将 URL 页面转换为图片(同步)
/// </summary>
/// <param name="url">页面 URL</param>
/// <param name="options">图片选项</param>
/// <param name="waitUntil">等待条件</param>
/// <param name="timeout">超时时间(秒)</param>
/// <param name="saveLocal">是否在服务端保存副本</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>图片文件字节数组</returns>
Task<byte[]> ConvertUrlToImageAsync(
string url,
ImageOptions? options = null,
string? waitUntil = null,
int? timeout = null,
bool saveLocal = false,
CancellationToken cancellationToken = default);
#endregion
#region - PDF
/// <summary>
/// 提交 PDF 转换任务(异步)
/// </summary>
/// <param name="source">源信息HTML 或 URL</param>
/// <param name="options">PDF 选项</param>
/// <param name="waitUntil">等待条件URL 模式时有效)</param>
/// <param name="timeout">超时时间(秒)</param>
/// <param name="callback">回调配置</param>
/// <param name="saveLocal">是否保存到服务端</param>
/// <param name="metadata">自定义元数据</param>
/// <param name="idempotencyKey">幂等键</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务提交结果</returns>
Task<TaskSubmitResult> SubmitPdfTaskAsync(
SourceInfo source,
PdfOptions? options = null,
string? waitUntil = null,
int? timeout = null,
CallbackInfo? callback = null,
bool saveLocal = true,
Dictionary<string, string>? metadata = null,
string? idempotencyKey = null,
CancellationToken cancellationToken = default);
#endregion
#region -
/// <summary>
/// 提交图片转换任务(异步)
/// </summary>
/// <param name="source">源信息HTML 或 URL</param>
/// <param name="options">图片选项</param>
/// <param name="waitUntil">等待条件URL 模式时有效)</param>
/// <param name="timeout">超时时间(秒)</param>
/// <param name="delayAfterLoad">加载后延迟(毫秒)</param>
/// <param name="callback">回调配置</param>
/// <param name="saveLocal">是否保存到服务端</param>
/// <param name="metadata">自定义元数据</param>
/// <param name="idempotencyKey">幂等键</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务提交结果</returns>
Task<TaskSubmitResult> SubmitImageTaskAsync(
SourceInfo source,
ImageOptions? options = null,
string? waitUntil = null,
int? timeout = null,
int? delayAfterLoad = null,
CallbackInfo? callback = null,
bool saveLocal = true,
Dictionary<string, string>? metadata = null,
string? idempotencyKey = null,
CancellationToken cancellationToken = default);
#endregion
#region
/// <summary>
/// 提交批量转换任务
/// </summary>
/// <param name="tasks">任务列表</param>
/// <param name="callback">回调配置</param>
/// <param name="onEachComplete">每个任务完成时回调</param>
/// <param name="onAllComplete">全部完成时回调</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>批量任务提交结果</returns>
Task<BatchSubmitResult> SubmitBatchAsync(
IEnumerable<BatchTaskInput> tasks,
CallbackInfo? callback = null,
bool onEachComplete = false,
bool onAllComplete = true,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取批量任务状态
/// </summary>
/// <param name="batchId">批量任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>批量任务状态</returns>
Task<BatchStatusResult?> GetBatchStatusAsync(
string batchId,
CancellationToken cancellationToken = default);
#endregion
#region
/// <summary>
/// 获取任务详情
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务详情,不存在返回 null</returns>
Task<TaskDetail?> GetTaskAsync(
string taskId,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取任务状态(轻量级)
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务状态</returns>
Task<TaskStatusResult?> GetTaskStatusAsync(
string taskId,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询任务列表
/// </summary>
/// <param name="status">状态过滤</param>
/// <param name="type">类型过滤pdf 或 image</param>
/// <param name="startDate">开始日期</param>
/// <param name="endDate">结束日期</param>
/// <param name="page">页码</param>
/// <param name="pageSize">每页数量</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务列表</returns>
Task<TaskListResult> QueryTasksAsync(
string? status = null,
string? type = null,
DateTime? startDate = null,
DateTime? endDate = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// 下载任务结果文件
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>文件字节数组</returns>
Task<byte[]> DownloadTaskResultAsync(
string taskId,
CancellationToken cancellationToken = default);
/// <summary>
/// 取消任务
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>是否成功取消</returns>
Task<bool> CancelTaskAsync(
string taskId,
CancellationToken cancellationToken = default);
/// <summary>
/// 重试失败的任务
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>是否成功重新提交</returns>
Task<bool> RetryTaskAsync(
string taskId,
CancellationToken cancellationToken = default);
#endregion
#region
/// <summary>
/// 等待任务完成并返回结果
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="pollingInterval">轮询间隔(毫秒),默认 1000</param>
/// <param name="maxWaitTime">最大等待时间(毫秒),默认 3000005分钟</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>任务详情</returns>
Task<TaskDetail> WaitForTaskAsync(
string taskId,
int pollingInterval = 1000,
int maxWaitTime = 300000,
CancellationToken cancellationToken = default);
/// <summary>
/// 等待任务完成并下载结果
/// </summary>
/// <param name="taskId">任务 ID</param>
/// <param name="pollingInterval">轮询间隔(毫秒)</param>
/// <param name="maxWaitTime">最大等待时间(毫秒)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>文件字节数组</returns>
Task<byte[]> WaitAndDownloadAsync(
string taskId,
int pollingInterval = 1000,
int maxWaitTime = 300000,
CancellationToken cancellationToken = default);
#endregion
}
/// <summary>
/// 批量任务输入项
/// </summary>
public class BatchTaskInput
{
/// <summary>
/// 任务类型pdf 或 image
/// </summary>
public string Type { get; set; } = "pdf";
/// <summary>
/// 源信息
/// </summary>
public SourceInfo Source { get; set; } = new();
/// <summary>
/// PDF 选项Type 为 pdf 时有效)
/// </summary>
public PdfOptions? PdfOptions { get; set; }
/// <summary>
/// 图片选项Type 为 image 时有效)
/// </summary>
public ImageOptions? ImageOptions { get; set; }
/// <summary>
/// 等待条件
/// </summary>
public string? WaitUntil { get; set; }
/// <summary>
/// 超时时间(秒)
/// </summary>
public int? Timeout { get; set; }
/// <summary>
/// 是否保存到服务端
/// </summary>
public bool SaveLocal { get; set; } = true;
/// <summary>
/// 自定义元数据
/// </summary>
public Dictionary<string, string>? Metadata { get; set; }
}

View File

@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models;
/// <summary>
/// 图片转换选项
/// </summary>
public class ImageOptions
{
/// <summary>
/// 图片格式png, jpeg, webp
/// </summary>
[JsonPropertyName("format")]
public string? Format { get; set; }
/// <summary>
/// 图片质量1-100仅对 jpeg/webp 有效
/// </summary>
[JsonPropertyName("quality")]
public int? Quality { get; set; }
/// <summary>
/// 视口宽度(像素)
/// </summary>
[JsonPropertyName("width")]
public int? Width { get; set; }
/// <summary>
/// 视口高度(像素)
/// </summary>
[JsonPropertyName("height")]
public int? Height { get; set; }
/// <summary>
/// 是否截取整个页面
/// </summary>
[JsonPropertyName("fullPage")]
public bool? FullPage { get; set; }
/// <summary>
/// 是否忽略背景色
/// </summary>
[JsonPropertyName("omitBackground")]
public bool? OmitBackground { get; set; }
}

View File

@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models;
/// <summary>
/// PDF 转换选项
/// </summary>
public class PdfOptions
{
/// <summary>
/// 页面格式,如 A4, Letter, Legal 等
/// </summary>
[JsonPropertyName("format")]
public string? Format { get; set; }
/// <summary>
/// 是否横向
/// </summary>
[JsonPropertyName("landscape")]
public bool? Landscape { get; set; }
/// <summary>
/// 是否打印背景
/// </summary>
[JsonPropertyName("printBackground")]
public bool? PrintBackground { get; set; }
/// <summary>
/// 页边距设置
/// </summary>
[JsonPropertyName("margin")]
public MarginOptions? Margin { get; set; }
}
/// <summary>
/// 页边距选项
/// </summary>
public class MarginOptions
{
/// <summary>
/// 上边距(如 "10mm"
/// </summary>
[JsonPropertyName("top")]
public string? Top { get; set; }
/// <summary>
/// 右边距
/// </summary>
[JsonPropertyName("right")]
public string? Right { get; set; }
/// <summary>
/// 下边距
/// </summary>
[JsonPropertyName("bottom")]
public string? Bottom { get; set; }
/// <summary>
/// 左边距
/// </summary>
[JsonPropertyName("left")]
public string? Left { get; set; }
}

View File

@ -0,0 +1,76 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models.Requests;
/// <summary>
/// HTML 转 PDF 请求
/// </summary>
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; }
}
/// <summary>
/// HTML 转图片请求
/// </summary>
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; }
}
/// <summary>
/// URL 转 PDF 请求
/// </summary>
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; }
}
/// <summary>
/// URL 转图片请求
/// </summary>
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; }
}

View File

@ -0,0 +1,151 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models.Requests;
/// <summary>
/// 异步 PDF 任务请求
/// </summary>
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<string, string>? Metadata { get; set; }
}
/// <summary>
/// 异步图片任务请求
/// </summary>
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<string, string>? Metadata { get; set; }
}
/// <summary>
/// 批量任务请求
/// </summary>
internal class BatchTaskRequest
{
[JsonPropertyName("tasks")]
public List<BatchTaskItem> 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;
}
/// <summary>
/// 批量任务项
/// </summary>
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<string, string>? Metadata { get; set; }
}
/// <summary>
/// 源信息
/// </summary>
public class SourceInfo
{
/// <summary>
/// 源类型html 或 url
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "html";
/// <summary>
/// 源内容HTML 字符串或 URL
/// </summary>
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// 回调配置
/// </summary>
public class CallbackInfo
{
/// <summary>
/// 回调 URL
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
/// <summary>
/// 自定义回调头
/// </summary>
[JsonPropertyName("headers")]
public Dictionary<string, string>? Headers { get; set; }
/// <summary>
/// 是否在回调中包含文件数据
/// </summary>
[JsonPropertyName("includeFileData")]
public bool IncludeFileData { get; set; }
}

View File

@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models.Responses;
/// <summary>
/// API 错误响应ProblemDetails 格式)
/// </summary>
public class ProblemDetails
{
/// <summary>
/// HTTP 状态码
/// </summary>
[JsonPropertyName("status")]
public int? Status { get; set; }
/// <summary>
/// 错误标题
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// 错误详情
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; }
/// <summary>
/// 错误类型 URI
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// 实例路径
/// </summary>
[JsonPropertyName("instance")]
public string? Instance { get; set; }
}

View File

@ -0,0 +1,484 @@
using System.Text.Json.Serialization;
namespace HtmlToPdfService.Client.Models.Responses;
/// <summary>
/// 任务提交响应
/// </summary>
public class TaskSubmitResult
{
/// <summary>
/// 任务 ID
/// </summary>
[JsonPropertyName("taskId")]
public string TaskId { get; set; } = string.Empty;
/// <summary>
/// 任务状态
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// 消息
/// </summary>
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
/// <summary>
/// 创建时间
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// 预计等待时间(秒)
/// </summary>
[JsonPropertyName("estimatedWaitTime")]
public int EstimatedWaitTime { get; set; }
/// <summary>
/// 队列位置
/// </summary>
[JsonPropertyName("queuePosition")]
public int QueuePosition { get; set; }
/// <summary>
/// 相关链接
/// </summary>
[JsonPropertyName("links")]
public TaskLinks? Links { get; set; }
}
/// <summary>
/// 批量任务提交响应
/// </summary>
public class BatchSubmitResult
{
/// <summary>
/// 批量任务 ID
/// </summary>
[JsonPropertyName("batchId")]
public string BatchId { get; set; } = string.Empty;
/// <summary>
/// 所有任务 ID
/// </summary>
[JsonPropertyName("taskIds")]
public List<string> TaskIds { get; set; } = new();
/// <summary>
/// 总任务数
/// </summary>
[JsonPropertyName("totalTasks")]
public int TotalTasks { get; set; }
/// <summary>
/// 成功数
/// </summary>
[JsonPropertyName("successCount")]
public int SuccessCount { get; set; }
/// <summary>
/// 失败数
/// </summary>
[JsonPropertyName("failedCount")]
public int FailedCount { get; set; }
/// <summary>
/// 相关链接
/// </summary>
[JsonPropertyName("links")]
public BatchLinks? Links { get; set; }
}
/// <summary>
/// 任务详情
/// </summary>
public class TaskDetail
{
/// <summary>
/// 任务 ID
/// </summary>
[JsonPropertyName("taskId")]
public string TaskId { get; set; } = string.Empty;
/// <summary>
/// 任务类型pdf 或 image
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// 源信息
/// </summary>
[JsonPropertyName("source")]
public TaskSourceInfo? Source { get; set; }
/// <summary>
/// 任务状态
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
/// <summary>
/// 创建时间
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// 开始时间
/// </summary>
[JsonPropertyName("startedAt")]
public DateTime? StartedAt { get; set; }
/// <summary>
/// 完成时间
/// </summary>
[JsonPropertyName("completedAt")]
public DateTime? CompletedAt { get; set; }
/// <summary>
/// 耗时(毫秒)
/// </summary>
[JsonPropertyName("duration")]
public long Duration { get; set; }
/// <summary>
/// 重试次数
/// </summary>
[JsonPropertyName("retryCount")]
public int RetryCount { get; set; }
/// <summary>
/// 任务结果
/// </summary>
[JsonPropertyName("result")]
public TaskResultInfo? Result { get; set; }
/// <summary>
/// 错误信息
/// </summary>
[JsonPropertyName("error")]
public TaskErrorInfo? Error { get; set; }
/// <summary>
/// 相关链接
/// </summary>
[JsonPropertyName("links")]
public TaskLinks? Links { get; set; }
}
/// <summary>
/// 任务源信息
/// </summary>
public class TaskSourceInfo
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("content")]
public string? Content { get; set; }
}
/// <summary>
/// 任务结果信息
/// </summary>
public class TaskResultInfo
{
[JsonPropertyName("fileSize")]
public long? FileSize { get; set; }
[JsonPropertyName("fileType")]
public string? FileType { get; set; }
[JsonPropertyName("downloadUrl")]
public string? DownloadUrl { get; set; }
}
/// <summary>
/// 任务错误信息
/// </summary>
public class TaskErrorInfo
{
[JsonPropertyName("code")]
public string? Code { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
/// <summary>
/// 任务状态响应(轻量级)
/// </summary>
public class TaskStatusResult
{
/// <summary>
/// 任务 ID
/// </summary>
[JsonPropertyName("taskId")]
public string TaskId { get; set; } = string.Empty;
/// <summary>
/// 任务状态
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
/// <summary>
/// 创建时间
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// 开始时间
/// </summary>
[JsonPropertyName("startedAt")]
public DateTime? StartedAt { get; set; }
/// <summary>
/// 完成时间
/// </summary>
[JsonPropertyName("completedAt")]
public DateTime? CompletedAt { get; set; }
}
/// <summary>
/// 批量任务状态
/// </summary>
public class BatchStatusResult
{
/// <summary>
/// 批量任务 ID
/// </summary>
[JsonPropertyName("batchId")]
public string BatchId { get; set; } = string.Empty;
/// <summary>
/// 状态
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// 总任务数
/// </summary>
[JsonPropertyName("totalTasks")]
public int TotalTasks { get; set; }
/// <summary>
/// 已完成数
/// </summary>
[JsonPropertyName("completedTasks")]
public int CompletedTasks { get; set; }
/// <summary>
/// 失败数
/// </summary>
[JsonPropertyName("failedTasks")]
public int FailedTasks { get; set; }
/// <summary>
/// 处理中数
/// </summary>
[JsonPropertyName("processingTasks")]
public int ProcessingTasks { get; set; }
/// <summary>
/// 等待中数
/// </summary>
[JsonPropertyName("pendingTasks")]
public int PendingTasks { get; set; }
/// <summary>
/// 创建时间
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// 完成时间
/// </summary>
[JsonPropertyName("completedAt")]
public DateTime? CompletedAt { get; set; }
/// <summary>
/// 任务列表
/// </summary>
[JsonPropertyName("tasks")]
public List<BatchTaskStatus> Tasks { get; set; } = new();
/// <summary>
/// 相关链接
/// </summary>
[JsonPropertyName("links")]
public BatchLinks? Links { get; set; }
}
/// <summary>
/// 批量任务中的单个任务状态
/// </summary>
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; }
}
/// <summary>
/// 任务列表响应
/// </summary>
public class TaskListResult
{
/// <summary>
/// 总数
/// </summary>
[JsonPropertyName("total")]
public int Total { get; set; }
/// <summary>
/// 当前页
/// </summary>
[JsonPropertyName("page")]
public int Page { get; set; }
/// <summary>
/// 每页数量
/// </summary>
[JsonPropertyName("pageSize")]
public int PageSize { get; set; }
/// <summary>
/// 任务列表
/// </summary>
[JsonPropertyName("items")]
public List<TaskSummary> Items { get; set; } = new();
/// <summary>
/// 分页链接
/// </summary>
[JsonPropertyName("links")]
public PaginationLinks? Links { get; set; }
}
/// <summary>
/// 任务摘要
/// </summary>
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; }
}
/// <summary>
/// 任务相关链接
/// </summary>
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; }
}
/// <summary>
/// 批量任务链接
/// </summary>
public class BatchLinks
{
[JsonPropertyName("status")]
public string? Status { get; set; }
}
/// <summary>
/// 分页链接
/// </summary>
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; }
}
/// <summary>
/// 任务取消响应
/// </summary>
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;
}
/// <summary>
/// 任务重试响应
/// </summary>
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;
}

View File

@ -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<byte[]> GeneratePdfAsync()
{
return await _pdfClient.ConvertHtmlToPdfAsync("<h1>Hello World</h1>");
}
}
```
### 方式二:从配置文件读取
```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<byte[]> GeneratePdfAsync(bool useProduction)
{
var clientName = useProduction ? "production" : "staging";
var client = _factory.CreateClient(clientName);
return await client.ConvertHtmlToPdfAsync("<h1>Hello</h1>");
}
}
```
### 方式四:直接实例化
```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("<h1>Hello</h1>");
```
## 功能示例
### 同步 PDF 转换
```csharp
// HTML 转 PDF
var pdf = await client.ConvertHtmlToPdfAsync(
html: "<html><body><h1>Hello</h1></body></html>",
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: "<html><body><h1>Hello</h1></body></html>",
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 = "<h1>Hello</h1>" },
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<BatchTaskInput>
{
new BatchTaskInput
{
Type = "pdf",
Source = new SourceInfo { Type = "html", Content = "<h1>Doc 1</h1>" }
},
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("<h1>Hello</h1>");
}
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

View File

@ -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