首页 文章 文章详情

Greenfield API 接入 ASP.NET Core 实战

来源:本站 {{likeCount}} {{commentCount}} 评论 2026-11-15 00:57:48

BTCPay 的 Greenfield API,就是一个普通的 REST API ,没有什么神秘的地方。难点不在框架,而在于 鉴权方式、事件时序、幂等处理 这三件事。下面按跑起来的顺序讲。

一、两个鉴权维度

Greenfield 有两层东西必须一起带上:

维度位置说明
API KeyHeader Authorization: token {apiKey}注意是 token 不是 Bearer,这是最常见的报错来源
Store ID路径参数 /api/v1/stores/{storeId}/invoices路径参数,不是 header

另外有两个容易出问题的基础要求:

1.      必须走 HTTPS ,且 BTCPay 要求你的回调/webhook 地址也是 HTTPS

2.      请求体必须是 application/json ,Content-Type 漏掉会返回 400,而不是「格式错误」这样友好的提示

API Key 的生成路径:BTCPay 后台 → Account → API Keys → Generate,
权限要勾上 btcpay.store.cancreateinvoice 和 btcpay.store.canviewinvoices ,同时把 Key 绑定到具体的 store 上(可以绑一个,也可以勾 All stores)。


如果是给别人/多店铺做集成,建议按 store 各发一把 Key,而不是用 All stores 的总钥匙。 这既是最小权限问题,也是出事时的定位问题 。

二、配置:用 Options 模式,别把密钥写进 appsettings


// BtcPayOptions.cs

public sealed class BtcPayOptions

{

   public const string SectionName = "BtcPay";

   public string BaseUrl { get; set; } = "";        // https://pay.example.com

   public string ApiKey { get; set; } = "";

   public string StoreId { get; set; } = "";

   public string WebhookSecret { get; set; } = "";  // 后面校验签名用

}

builder.Services

   .AddOptions<BtcPayOptions>()

   .Bind(builder.Configuration.GetSection(BtcPayOptions.SectionName))

   .ValidateDataAnnotations()

   .ValidateOnStart();

密钥放 User Secrets(本地)或环境变量/Key Vault(生产)。BaseUrl 结尾 不要带 / ,否则拼 URL 时会出现 //api/v1,有的反代直接 404。

三、HttpClient:用 IHttpClientFactory + 一个 DelegatingHandler


public sealed class BtcPayAuthHandler : DelegatingHandler

{

   private readonly IOptionsMonitor<BtcPayOptions> _options;

   public BtcPayAuthHandler(IOptionsMonitor<BtcPayOptions> options) => _options = options;

   protected override Task<HttpResponseMessage> SendAsync(

       HttpRequestMessage request, CancellationToken ct)

   {

       var apiKey = _options.CurrentValue.ApiKey;

       request.Headers.TryAddWithoutValidation("Authorization", $"token {apiKey}");

       return base.SendAsync(request, ct);

   }

}

builder.Services

   .AddTransient<BtcPayAuthHandler>()

   .AddHttpClient<BtcPayClient>((sp, http) =>

   {

       var opt = sp.GetRequiredService<IOptions<BtcPayOptions>>().Value;

       http.BaseAddress = new Uri(opt.BaseUrl.TrimEnd('/') + "/");

       http.Timeout = TimeSpan.FromSeconds(30);

       http.DefaultRequestHeaders.Accept.Add(

           new MediaTypeWithQualityHeaderValue("application/json"));

   })

   .AddHttpMessageHandler<BtcPayAuthHandler>()

   .AddStandardResilienceHandler(); // 见第七节,注意这里的坑

为什么用 typed client :StoreId、反序列化、错误映射都能收进一个类里,业务代码就不用再关心 URL 拼装了。

四、调用接口:创建 Invoice

DTO 只保留会用到的字段,不要照抄全部 schema:


public sealed record CreateInvoiceRequest(

   decimal Amount,

   string Currency,

   string? OrderId = null,

   string? ItemDesc = null,

   string? RedirectUrl = null,

   CheckoutOptions? Checkout = null);

public sealed record CheckoutOptions(

   string? DefaultLanguage = null,

   string? RedirectURL = null,

   bool? RedirectAutomatically = null);

public sealed record CreateInvoiceResponse(

   string Id,

   string Status,

   CheckoutLinkData CheckoutLink);

public sealed record CheckoutLinkData(string? RedirectUrl, string? Url);

public sealed class BtcPayClient(HttpClient http, IOptions<BtcPayOptions> options)

{

   public async Task<CreateInvoiceResponse> CreateInvoiceAsync(

       CreateInvoiceRequest req, CancellationToken ct = default)

   {

       var storeId = options.Value.StoreId;

       var resp = await http.PostAsJsonAsync(

           $"api/v1/stores/{storeId}/invoices", req, ct);

       await EnsureSuccessAsync(resp, ct);

       return await resp.Content.ReadFromJsonAsync<CreateInvoiceResponse>(ct)

              ?? throw new InvalidOperationException("BTCPay 返回空响应");

   }

   private static async Task EnsureSuccessAsync(

       HttpResponseMessage resp, CancellationToken ct)

   {

       if (resp.IsSuccessStatusCode) return;

       var body = await resp.Content.ReadAsStringAsync(ct);

       throw new BtcPayException(

           (int)resp.StatusCode,

           string.IsNullOrWhiteSpace(body) ? resp.ReasonPhrase ?? "未知错误" : body);

   }

}

public sealed class BtcPayException(int statusCode, string responseBody)

   : Exception($"BTCPay API 调用失败 ({statusCode}): {responseBody}")

{

   public int StatusCode { get; } = statusCode;

   public string ResponseBody { get; } = responseBody;

}

这里有个实践要点 :不要只抛 EnsureSuccessStatusCode()。BTCPay 的错误响应体里带 code 和
message 字段,401(Key 权限不足)、403(Key 未绑该 store)、400(金额/币种非法)在日志里不区分开,线上排查会浪费很多时间。最好把错误体原样包进异常再记录。

五、业务闭环:订单状态机是核心

这是整个接入里 唯一真正难的地方
。事件时序:

支付方式InvoiceProcessingInvoiceSettled间隔
链上进入 mempool 时触发达到确认数后触发通常 10 分钟以上
闪电网络支付完成时触发几乎同时触发秒级

所以订单必须能表示「 已收到但未确认
」这个中间态。状态机建议:


Pending ──创建invoice──> AwaitingPayment

AwaitingPayment ──Processing──> Paid(未确认)

Paid ──Settled──> Completed

Paid/AwaitingPayment ──Expired/Invalid──> Failed

对应的实体字段至少要有一个  BtcPayInvoiceId 且加唯一索引 ——webhook 会重复投递,靠唯一索引做幂等是最省事的方式:


public class PaymentOrder

{

   public Guid Id { get; set; }

   public string OrderNo { get; set; } = "";

   public decimal Amount { get; set; }

   public string Currency { get; set; } = "";

   public string? BtcPayInvoiceId { get; set; }   // 唯一索引

   public string Status { get; set; } = "Pending";

   public DateTimeOffset CreatedAt { get; set; }

   public DateTimeOffset? SettledAt { get; set; }

}

创建发票时给 orderId 赋自己的 OrderNo,webhook 回来时用它反查本地订单——比用 invoiceId 反查更可靠,因为 invoiceId 有可能在写库之前就丢了(比如创建后进程挂了)。

六、Webhook:签名校验 + 幂等

6.1 签名校验(必做)

Greenfield webhook 用  HMAC-SHA256 ,签名放在 BTCPay-Sig header,格式是 sha256=xxxx,对
原始请求体 计算。


public static bool VerifySignature(string body, string? headerValue, string secret)

{

   if (string.IsNullOrEmpty(headerValue) || !headerValue.StartsWith("sha256="))

       return false;

   var provided = headerValue["sha256=".Length..].Trim();

   using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));

   var computed =

       Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)))

              .ToLowerInvariant();

   return CryptographicOperations.FixedTimeEquals(

       Encoding.UTF8.GetBytes(provided),

       Encoding.UTF8.GetBytes(computed));

}

两个坑,都是真实会踩到的:

3.      必须读原始 body ,不能先绑定成 model 再序列化回来算哈希——Razor/MVC 的 model binder 会改写字段顺序和空白,签名必然对不上。所以 webhook 端点要用 [FromBody] string 或者手动读 Request.Body。

4.      比较必须用 FixedTimeEquals,不要用 ==。虽然 webhook 场景下时序攻击不现实,但这是习惯问题,代码评审时也会被问。

6.2 幂等 + 快速响应

BTCPay 的回调
会重试 ,且在特定情况(比如「收到支付但又开了新的发票」)会多次触发同一事件。所以:

●    先快速返回 200 ,业务处理丢进 Channel<WebhookJob> 后台队列

●    处理时「先查状态,状态已到位就跳过」

●    定时回查兜底(webhook 丢失时不会有任何通知,这个必须有)


[ApiController]

public class BtcPayWebhookController : ControllerBase

{

   [HttpPost("/webhooks/btcpay")]

   public async Task<IActionResult> Receive(

       [FromServices] IOptions<BtcPayOptions> opt,

       [FromServices] Channel<WebhookJob> queue)

   {

       // 读原始体

       using var reader = new StreamReader(Request.Body);

       var body = await reader.ReadToEndAsync();

       var sig = Request.Headers["BTCPay-Sig"].FirstOrDefault();

       if (!WebhookSignature.VerifySignature(body, sig, opt.Value.WebhookSecret))

           return Unauthorized();

       var pending = JsonSerializer.Deserialize<PendingWebhook>(body,

           new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

       if (pending is null) return BadRequest();

       // 立即入队,不在这里做业务

       await queue.Writer.WriteAsync(new WebhookJob(pending));

       return Ok();

   }

}

public sealed record PendingWebhook(string DeliveryId, string WebhookId, string Type, JsonElement Data);

public sealed record WebhookJob(PendingWebhook Payload);

注册方式:后台 → Store → Webhooks → Create,URL 填 https://你的域名/webhooks/btcpay, 勾选事件里一定要包含 :

●    InvoiceProcessing

●    InvoiceSettled

●    InvoiceExpired

●    InvoiceInvalid

Secret 复制到
BtcPayOptions.WebhookSecret。

6.3 兜底轮询


public async Task<InvoiceStatus?> GetInvoiceAsync(string invoiceId, CancellationToken ct)

{

   var resp = await http.GetAsync($"api/v1/stores/{options.Value.StoreId}/invoices/{invoiceId}", ct);

   if (resp.StatusCode == HttpStatusCode.NotFound) return null;

   await EnsureSuccessAsync(resp, ct);

   return await resp.Content.ReadFromJsonAsync<InvoiceStatus>(ct);

}

建议用一个 BackgroundService 扫「处于 Paid(AwaitingPayment) 且创建超过 N 分钟」的订单,逐个查一次状态。 BTCPay 官方明确说过 webhook 不保证送达 ,所以这一步不是可选项。

七、重试策略:AddStandardResilienceHandler 的坑

上面写了 .AddStandardResilienceHandler(),但这里要提醒一句:

创建 Invoice 这类非幂等的 POST 请求,默认会被重试。  如果第一次请求其实已经成功、只是响应超时了,重试会创建出
两张发票 。用户看到哪张、你库里存哪张,就乱了。

三种处理方式,按推荐顺序:

5.      用 orderId 做幂等并查重 :创建前先按 orderId 查一次是否已有 invoice(推荐,Greenfield 支持按 orderId 查)

6.      给 POST 请求单独配置 AddResilienceHandler,只允许重试
HttpRequestException 且限定 1 次

7.      最省事但最差:POST 不加重试,只加重试到 GET


// 方案 1 的关键调用:按 orderId 查已有 invoice

public async Task<CreateInvoiceResponse?> FindByOrderIdAsync(

   string orderId, CancellationToken ct = default)

{

   var resp = await http.GetAsync(

       $"api/v1/stores/{options.Value.StoreId}/invoices?orderId={Uri.EscapeDataString(orderId)}", ct);

   await EnsureSuccessAsync(resp, ct);

   var list = await resp.Content.ReadFromJsonAsync<List<CreateInvoiceResponse>>(ct);

   return list?.FirstOrDefault();

}

另外还有个细节:HttpClient.Timeout = 30s,但 BTCPay 在某些负载下创建发票会超过 10 秒, 加上重试放大,一次结账可能占掉 60 秒 。电商场景里用户等不了这么久,所以更稳的做法是: 创建或获取发票这一步做成异步任务
,前端轮询自己后端的订单状态。

八、DI 注册总览


// Program.cs 里按顺序拼起来

builder.Services.Configure<BtcPayOptions>(

   builder.Configuration.GetSection(BtcPayOptions.SectionName));

builder.Services

   .AddTransient<BtcPayAuthHandler>()

   .AddHttpClient<BtcPayClient>((sp, http) => { /* 见第三节 */ })

   .AddHttpMessageHandler<BtcPayAuthHandler>();

builder.Services.AddSingleton(Channel.CreateUnbounded<WebhookJob>());

builder.Services.AddHostedService<WebhookDispatcher>();   // 消费队列

builder.Services.AddHostedService<InvoiceReconciler>();   // 兜底轮询

Channel<WebhookJob> 注册成单例,webhook Controller 拿 writer,HostedService 拿 reader。 注意别用 Channel.CreateBounded 又不设满时的处理策略 ,webhook 积压时会阻塞 HTTP 请求。

九、开发调试:先 Regtest 再说

不要一开始就对着 mainnet 调。标准做法:


git clone https://github.com/btcpayserver/btcpayserver.git

cd btcpayserver/BTCPayServer.Tests

docker-compose -f docker-compose.yml up dev

起完后本地是  regtest  环境,页面顶部显示 REGTEST 标识,可以用 Regtest 钱包给自己发币, 几秒钟出块,不花真钱 。这一步在国内能省掉大量等待时间——不需要 mainnet 的 IBD,几秒钟就能开始测。

调试时把 SaaS dev tunnel(或者自建内网穿透)指向本地的 /webhooks/btcpay,才能收到 webhook。

十、上线前检查清单

●    [ ] API Key 按 store 下发,权限只勾 cancreateinvoice + canviewinvoices

●    [ ] API Key / WebhookSecret 走环境变量或 Key Vault,仓库里零硬编码

●    [ ] BtcPayInvoiceId 和 OrderNo 都有唯一索引

●    [ ] webhook 端点读原始 body 算 HMAC,用 FixedTimeEquals 比较

●    [ ] webhook 端点不做重业务,立即返回 200

●    [ ] 事件覆盖 Processing / Settled / Expired / Invalid 四类

●    [ ] 有兜底轮询任务

●    [ ] 创建 Invoice 的 POST 不会因重试产生重复发票

●    [ ] 日志里记录 invoiceId、orderId、事件类型、签名校验结果(但不记 body 全文和 secret)

●    [ ] 订单状态机能表达「已付未确认」


相关评论
发表
暂无相关评论...
{{item.userName}} {{item.dateDescription}}
{{item.likeCount}} 回复
{{item.content}}
{{child.userName}}@{{child.atUserName}} {{child.content}}
{{child.dateDescription}}
{{child.likeCount}} 回复