这篇教程用 Payment 域接入 PayPal 作样本,演示新增一家厂商 Provider 的完整流程。同样的 9 步适用于任何已有功能域(FileStorage、AI、eSign……)加新厂商。
接入全景
核心约束:抽象层已有契约不动,新增物全部落在 Bitzsoft.Integrations.Payment.PayPal 这个厂商包里,最后在聚合包 .All 的 csproj 和聚合注册方法里挂一个钩子。
准备:确认抽象层符号
开工前先确认目标域抽象包里这几个符号存在,新增厂商会反复引用它们:
| 符号 | 位置 | 用途 |
|---|---|---|
IPaymentProvider | Interfaces/IPaymentProvider.cs | 厂商必须实现的能力接口 |
PaymentProviderCode | Models/PaymentProviderCode.cs | 厂商标识常量,这里要加 PayPal |
PaymentResult<T> / PaymentResult | PaymentResult.cs | 结果包装 |
PaymentException | PaymentException.cs | 领域异常 |
IPaymentConfigProvider<T> | IPaymentConfigProvider.cs | 配置提供器基接口 |
抽象包还要通过 InternalsVisibleTo 向新厂商包开放 internal 成员:
<!-- src/Bitzsoft.Integrations.Payment/Bitzsoft.Integrations.Payment.csproj --><ItemGroup> <InternalsVisibleTo Include="Bitzsoft.Integrations.Payment.Alipay" /> <InternalsVisibleTo Include="Bitzsoft.Integrations.Payment.WeChatPay" /> <InternalsVisibleTo Include="Bitzsoft.Integrations.Payment.Stripe" /> <InternalsVisibleTo Include="Bitzsoft.Integrations.Payment.PayPal" /> <!-- ① 新增 --> <InternalsVisibleTo Include="Bitzsoft.Integrations.Payment.All" /></ItemGroup>第 1 步:创建项目结构
在 src/ 下新建 Bitzsoft.Integrations.Payment.PayPal,csproj 引用三个项目:Payment 抽象层、Compatibility(CompatibilityArgument 等跨 TFM 兼容垫片)、RequestLogging(HTTP 审计脱敏)。
<!-- src/Bitzsoft.Integrations.Payment.PayPal/Bitzsoft.Integrations.Payment.PayPal.csproj --><Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFrameworks>net5.0;net8.0;net10.0</TargetFrameworks> <PackageId>Bitzsoft.Integrations.Payment.PayPal</PackageId> <Description>PayPal 支付实现 — 支持下单 / 查询 / 退款 / 回调验签</Description> <PackageTags>paypal;payment;integration;Bitzsoft</PackageTags> </PropertyGroup>
<ItemGroup> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> </ItemGroup>
<ItemGroup> <!-- ① 抽象层 + 兼容垫片 + 请求日志,三件缺一不可 --> <ProjectReference Include="..\Bitzsoft.Integrations.Compatibility\Bitzsoft.Integrations.Compatibility.csproj" /> <ProjectReference Include="..\Bitzsoft.Integrations.Payment\Bitzsoft.Integrations.Payment.csproj" /> <ProjectReference Include="..\Bitzsoft.Integrations.RequestLogging\Bitzsoft.Integrations.RequestLogging.csproj" /> </ItemGroup></Project>目录布局按既有约定:
Bitzsoft.Integrations.Payment.PayPal/├── PayPalOptions.cs ← 厂商 Options(public sealed)├── IPayPalConfigProvider.cs ← 配置提供器接口(internal)├── PayPalConfigProvider.cs ← 默认实现(internal sealed)├── PayPalPaymentProvider.cs ← Provider 实现(public sealed,ctor internal)├── PayPalProviderDescriptor.cs ← Provider 描述(internal static Instance)├── ServiceCollectionExtensions.cs ← DI 扩展(namespace Microsoft.Extensions.DependencyInjection)├── GlobalUsings.cs└── Internal/ └── PayPalHttpClient.cs ← HTTP 封装(internal sealed)第 2 步:定义 Options
public sealed 类,字符串字段统一以 = string.Empty 兜底,避免 null。HttpClientName 默认 nameof(PayPalPaymentProvider)——这是命名 HttpClient 的关键约定,必须与 Provider 类名一致。
namespace Bitzsoft.Integrations.Payment.PayPal;
/// <summary>PayPal 支付连接配置</summary>public sealed class PayPalOptions{ /// <summary>PayPal 应用 Client Id</summary> public string ClientId { get; set; } = string.Empty;
/// <summary>PayPal 应用 Secret</summary> public string ClientSecret { get; set; } = string.Empty;
/// <summary>网关地址(正式 / 沙箱)</summary> public string BaseUrl { get; set; } = "https://api-m.paypal.com";
/// <summary>异步通知 Webhook Id</summary> public string? WebhookId { get; set; }
/// <summary>HTTP 请求超时时间</summary> public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>命名 HTTP 客户端标识,用于 IHttpClientFactory 匹配</summary> public string HttpClientName { get; set; } = nameof(PayPalPaymentProvider); // ① 与 Provider 类名对齐}第 3 步:定义 ConfigProvider 接口
配置提供器抽象配置来源,支持 IOptions、数据库或配置中心。新厂商定义一个空接口继承 IPaymentConfigProvider<TOptions> 即可,标记为 internal——它是厂商包内部协作细节,不对外。
namespace Bitzsoft.Integrations.Payment.PayPal;
/// <summary>PayPal 配置提供器接口</summary>internal interface IPayPalConfigProvider : IPaymentConfigProvider<PayPalOptions>{}默认实现从 IOptions<PayPalOptions> 取值,也标 internal sealed:
using Microsoft.Extensions.Options;
namespace Bitzsoft.Integrations.Payment.PayPal;
/// <summary>PayPal 默认配置提供器</summary>internal sealed class PayPalConfigProvider : IPayPalConfigProvider{ private readonly IOptions<PayPalOptions> _options;
public PayPalConfigProvider(IOptions<PayPalOptions> options) => _options = options;
public PayPalOptions Current => _options.Value; public string HttpClientName => _options.Value.HttpClientName;}第 4 步:实现 HttpClient 封装
把签名、请求构造、响应读取集中到一个 internal sealed 类,构造时用 IHttpClientFactory 拿命名客户端并设置超时。PayPal 用 OAuth2 换 access token,签名比支付宝简单——这是把厂商差异关在 Internal/ 里的意义。
using System.Net.Http.Headers;using System.Text.Json;
namespace Bitzsoft.Integrations.Payment.PayPal.Internal;
/// <summary>PayPal HTTP 客户端,封装 OAuth2 token 与 JSON 请求</summary>internal sealed class PayPalHttpClient{ private readonly HttpClient _httpClient; private readonly IPayPalConfigProvider _config; private string? _accessToken; private DateTimeOffset _tokenExpiresAt;
public PayPalHttpClient(IHttpClientFactory httpClientFactory, IPayPalConfigProvider config) { var options = config.Current; CompatibilityArgument.ThrowIfNullOrEmpty(options.ClientId); CompatibilityArgument.ThrowIfNullOrEmpty(options.ClientSecret); CompatibilityArgument.ThrowIfNullOrEmpty(options.BaseUrl);
_config = config; _httpClient = httpClientFactory.CreateClient(config.HttpClientName); // ① 命名客户端 _httpClient.Timeout = options.Timeout; }
public async Task<JsonElement> PostAsync(string path, object body, CancellationToken ct = default) { await EnsureTokenAsync(ct).ConfigureAwait(false);
using var content = JsonContent.Create(body); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
using var response = await _httpClient.PostAsync($"{_config.Current.BaseUrl}{path}", content, ct) .ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) { // ② 非 2xx 抛领域异常,由 Provider 统一捕获转 Result throw new PaymentException( PaymentProviderCode.PayPal, $"HTTP_{(int)response.StatusCode}", $"PayPal 请求失败: HTTP {(int)response.StatusCode}"); }
return string.IsNullOrEmpty(text) ? default : JsonDocument.Parse(text).RootElement.Clone(); }
private async Task EnsureTokenAsync(CancellationToken ct) { if (_accessToken is not null && DateTimeOffset.UtcNow < _tokenExpiresAt) return;
// ③ client_credentials 换 token,缓存到过期前 using var form = new FormUrlEncodedContent(new[] { new KeyValuePair<string, string>("grant_type", "client_credentials"), }); // ... 用 Basic auth 换 token,略 _accessToken = "EC-..."; _tokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(45); }}第 5 步:实现 Provider
public sealed class 实现 IPaymentProvider,构造函数标 internal(禁止手动 new,只走 DI)。每个方法按 catch (PaymentException) → catch (HttpRequestException) 分类型捕获,统一转成 PaymentResult。
下面以查询状态为例,展示标准三段式:构造业务参数 → 调 HttpClient → 捕获两类异常转 Result。
using System.Text.Json;using Bitzsoft.Integrations.Payment.PayPal.Internal;using Microsoft.Extensions.Logging;
namespace Bitzsoft.Integrations.Payment.PayPal;
/// <summary>PayPal 支付实现</summary>public sealed class PayPalPaymentProvider : IPaymentProvider{ private readonly PayPalHttpClient _httpClient; private readonly IPayPalConfigProvider _config; private readonly ILogger<PayPalPaymentProvider> _logger;
internal PayPalPaymentProvider( // ① internal ctor,禁止外部 new PayPalHttpClient httpClient, IPayPalConfigProvider config, ILogger<PayPalPaymentProvider> logger) { _httpClient = httpClient; _config = config; _logger = logger; }
public string Code => PaymentProviderCode.PayPal; // ② 复用抽象层常量
public async Task<PaymentResult<PaymentStatusResult>> QueryStatusAsync( string outTradeNo, CancellationToken ct = default) { CompatibilityArgument.ThrowIfNullOrEmpty(outTradeNo);
_logger.LogInformation("PayPal 查询订单状态: {OutTradeNo}", outTradeNo);
JsonElement json; try { json = await _httpClient.PostAsync("/v2/checkout/orders", new { }, ct).ConfigureAwait(false); } catch (PaymentException ex) // ③ 先捕获领域异常,保留原始错误码 { _logger.LogWarning("PayPal 查询订单失败: {Code} - {Msg}", ex.ErrorCode, ex.ProviderMessage); return PaymentResult<PaymentStatusResult>.Fail(ex.ErrorCode ?? "QUERY_FAILED", ex.ProviderMessage ?? ex.Message); } catch (HttpRequestException ex) // ④ 再捕获网络异常,归一为 HTTP_ERROR { _logger.LogWarning(ex, "PayPal 查询订单网络异常"); return PaymentResult<PaymentStatusResult>.Fail("HTTP_ERROR", ex.Message); }
var statusResult = new PaymentStatusResult { OutTradeNo = outTradeNo, TradeNo = json.GetProperty("id").GetString(), Status = MapStatus(json.GetProperty("status").GetString()), // ... 其余字段映射 };
return PaymentResult<PaymentStatusResult>.Success(statusResult); }
// CreateOrderAsync / CloseOrderAsync / RefundAsync / VerifyCallbackAsync 同结构,略 private static PaymentStatus MapStatus(string? s) => s switch { "COMPLETED" => PaymentStatus.Success, "APPROVED" => PaymentStatus.Pending, _ => PaymentStatus.Pending, };}异常处理顺序很重要
PaymentException 必须在 HttpRequestException 之前 catch——PaymentException 继承自 IntegrationException,携带厂商原始错误码,是更具体的信息;HttpRequestException 是兜底的网络层归一。
第 6 步:写 DI 扩展
扩展类放在 Microsoft.Extensions.DependencyInjection 命名空间(这样消费方 using 一次就能用),方法命名 AddBitzsoft{厂商}{域}()。提供「委托配置」和「IConfiguration 配置」两个重载。
using Bitzsoft.Integrations.Payment.PayPal;using Bitzsoft.Integrations.Payment.PayPal.Internal;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection.Extensions;using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.DependencyInjection; // ① 关键:放这个命名空间
/// <summary>PayPal 支付服务注册扩展</summary>public static class PayPalServiceCollectionExtensions{ /// <summary>从委托注册 PayPal 支付服务</summary> public static IServiceCollection AddBitzsoftPayPalPayment( this IServiceCollection services, Action<PayPalOptions> configure) { CompatibilityArgument.ThrowIfNull(configure);
var options = new PayPalOptions(); configure(options);
services.AddHttpClient(options.HttpClientName).AddRequestLogging(options.HttpClientName); // ② 挂审计日志 services.Configure(configure); services.TryAddSingleton<IPayPalConfigProvider, PayPalConfigProvider>(); RegisterProvider(services);
return services; }
/// <summary>从 IConfiguration 注册 PayPal 支付服务</summary> public static IServiceCollection AddBitzsoftPayPalPayment( this IServiceCollection services, IConfiguration configuration, string sectionKey = "PayPal") { CompatibilityArgument.ThrowIfNull(configuration);
var options = new PayPalOptions(); configuration.GetSection(sectionKey).Bind(options);
services.AddHttpClient(options.HttpClientName).AddRequestLogging(options.HttpClientName); services.Configure<PayPalOptions>(configuration.GetSection(sectionKey)); services.TryAddSingleton<IPayPalConfigProvider, PayPalConfigProvider>(); RegisterProvider(services);
return services; }
private static void RegisterProvider(IServiceCollection services) { services.TryAddTransient<PayPalHttpClient>(); services.AddIntegrationProviderCapability<IPaymentProvider, PayPalPaymentProvider>( // ③ 提交 Descriptor + 实现 PayPalProviderDescriptor.Instance, sp => new PayPalPaymentProvider( sp.GetRequiredService<PayPalHttpClient>(), sp.GetRequiredService<IPayPalConfigProvider>(), sp.GetRequiredService<ILogger<PayPalPaymentProvider>>())); }}每个重载内部做四件事:注册命名 HttpClient 并挂 AddRequestLogging、绑定 Options、注册 IConfigProvider、调用 AddIntegrationProviderCapability 把实现和 Descriptor 提交到目录。
第 7 步:定义 Provider Descriptor
internal static 类,暴露一个 IntegrationProviderDescriptor.Instance。这是 Provider 的事实源描述,Catalog 和 Resolver 都依赖它。
namespace Bitzsoft.Integrations.Payment.PayPal;
internal static class PayPalProviderDescriptor{ public static readonly IntegrationProviderDescriptor Instance = new( providerId: PaymentProviderCode.PayPal, // ① 与抽象层常量一致 displayName: "PayPal", domain: "Payment", capabilityTypes: new[] { typeof(IPaymentProvider) }, stability: IntegrationProviderStability.Preview, // ② 新接入先标 Preview,稳定后升 Stable vendor: "PayPal", apiVersion: "v2", authenticationType: "OAuth2", regions: new[] { "Global" }, environments: new[] { "Production", "Sandbox" });}providerId 必须等于第 2 步抽象层里加的那个常量,否则 Resolver 按字符串解析时会失配。stability 新接入一律先标 Preview,走通全部场景再升 Stable。
第 8 步:更新聚合包
聚合包 .All 改两处:csproj 加 ProjectReference,聚合注册方法加一行条件注册。
<!-- src/Bitzsoft.Integrations.Payment.All/Bitzsoft.Integrations.Payment.All.csproj --><ItemGroup> <ProjectReference Include="..\Bitzsoft.Integrations.Payment.Alipay\..." /> <ProjectReference Include="..\Bitzsoft.Integrations.Payment.WeChatPay\..." /> <ProjectReference Include="..\Bitzsoft.Integrations.Payment.Stripe\..." /> <ProjectReference Include="..\Bitzsoft.Integrations.Payment.PayPal\Bitzsoft.Integrations.Payment.PayPal.csproj" /> <!-- ① 新增引用 --></ItemGroup>聚合注册方法按配置节存在性自动注册——不存在的厂商不会被注册:
public static IServiceCollection AddBitzsoftPaymentAll( this IServiceCollection services, IConfiguration configuration, string sectionKey = "Payment"){ var section = configuration.GetSection(sectionKey);
if (section.GetSection("Alipay").Exists()) services.AddBitzsoftAlipayPayment(configuration, $"{sectionKey}:Alipay"); if (section.GetSection("WeChatPay").Exists()) services.AddBitzsoftWeChatPayPayment(configuration, $"{sectionKey}:WeChatPay"); if (section.GetSection("Stripe").Exists()) services.AddBitzsoftStripePayment(configuration, $"{sectionKey}:Stripe"); if (section.GetSection("PayPal").Exists()) // ② 新增条件注册 services.AddBitzsoftPayPalPayment(configuration, $"{sectionKey}:PayPal");
return services;}第 9 步:写 README 与测试
包 README
简明列出能力、配置项、稳定性。例如:
# Bitzsoft.Integrations.Payment.PayPal
PayPal 支付实现,遵循 Bitzsoft.Integrations 三层包模式。
## 能力- 创建订单(Capture order)- 查询状态 / 关闭订单 / 退款- Webhook 回调验签
## 配置(appsettings.json)\`\`\`json{ "PayPal": { "ClientId": "...", "ClientSecret": "...", "BaseUrl": "https://api-m.sandbox.paypal.com" } }\`\`\`
## 注册\`\`\`csharpbuilder.Services.AddBitzsoftPayPalPayment(builder.Configuration.GetSection("PayPal"));\`\`\`
## 稳定性Preview — 主流程已验证,边缘场景持续补充。单元测试
至少覆盖:可正常解析、业务错误码透传、网络异常归一为 HTTP_ERROR。参考既有 ProviderRouting.Tests 的写法:
[Fact]public async Task QueryStatus_PaypalBusinessError_PreservesErrorCode(){ // ① 构造一个返回 HTTP 400 的 HttpMessageHandler var handler = new StubHandler(System.Net.HttpStatusCode.BadRequest, "{}"); var services = new ServiceCollection(); services.AddHttpClient(nameof(PayPalPaymentProvider)).AddRequestLogging(nameof(PayPalPaymentProvider)) .ConfigurePrimaryHttpMessageHandler(() => handler); services.AddBitzsoftPayPalPayment(_ => _.ClientId = "c"); // 其余配置略
var sp = services.BuildServiceProvider(); var provider = sp.GetRequiredService<IPaymentProvider>();
var result = await provider.QueryStatusAsync("ORDER-1");
Assert.False(result.IsSuccess); Assert.StartsWith("HTTP_", result.ErrorCode); // ② 厂商错误码透传}
[Fact]public void Payment_All_Registers_PayPal_When_Configured(){ var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary<string, string?> { ["Payment:PayPal:ClientId"] = "test", }) .Build();
var services = new ServiceCollection(); services.AddBitzsoftPaymentAll(config); // ③ 聚合包注册 var sp = services.BuildServiceProvider(validateScopes: true);
var resolver = sp.GetRequiredService<IIntegrationProviderResolver<IPaymentProvider>>(); Assert.True(resolver.TryResolve("PayPal", out _)); // ④ 可按 ID 解析}完整清单
接入完成前逐项核对:
- 抽象层:
PaymentProviderCode加了厂商常量 - 抽象层:
InternalsVisibleTo加了新厂商包 - 厂商包:csproj 引用 Payment + Compatibility + RequestLogging
- 厂商包:
Options字符串字段全部= string.Empty,HttpClientName = nameof(Provider) - 厂商包:
IConfigProvider接口internal,默认实现internal sealed - 厂商包:
HttpClient封装在Internal/,构造时CreateClient(config.HttpClientName) - 厂商包:
Provider是public sealed,ctorinternal,catch PaymentException先于catch HttpRequestException - 厂商包:DI 扩展在
Microsoft.Extensions.DependencyInjection命名空间,方法名AddBitzsoft{厂商}{域}() - 厂商包:
ProviderDescriptor.Instance的providerId与抽象层常量一致,stability先标Preview - 聚合包:csproj 加 ProjectReference,
AddBitzsoft{域}All()加条件注册 - 测试:业务错误码透传 + 网络异常归一 + 聚合包可解析,三项齐备
- README:能力、配置、稳定性标注完整