using System.Net.Http.Headers;
using System.Text.Json;
using System.Globalization;
using Microsoft.Extensions.Options;
namespace OASystem.API.OAMethodLib.SnovioAPI;
///
/// Snov.io API 服务实现。
///
public sealed class SnovioService : ISnovioService
{
private const string AccessTokenPath = "v1/oauth/access_token";
private const string CompanySearchStartPath = "v2/database-search/companies/start";
private const string CompanySearchResultPath = "v2/database-search/companies/result";
private const string DomainProspectsSearchStartPath = "v2/domain-search/prospects/start";
private const string DomainProspectsSearchResultPath = "v2/domain-search/prospects/result";
private const string ProspectEmailSearchStartPath = "v2/domain-search/prospects/search-emails/start";
private const string ProspectEmailSearchResultPath = "v2/domain-search/prospects/search-emails/result";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient;
private readonly SnovioOptions _options;
private readonly ILogger _logger;
public SnovioService(
HttpClient httpClient,
IOptions options,
ILogger logger)
{
_httpClient = httpClient;
_options = options.Value;
_logger = logger;
if (!Uri.TryCreate(NormalizeBaseUrl(_options.BaseUrl), UriKind.Absolute, out var baseAddress))
throw new InvalidOperationException("Snovio:BaseUrl 配置不是有效的绝对地址。");
_httpClient.BaseAddress = baseAddress;
_httpClient.Timeout = TimeSpan.FromSeconds(
_options.TimeoutSeconds > 0 ? _options.TimeoutSeconds : 30);
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
///
public async Task GetAccessTokenAsync(
CancellationToken cancellationToken = default)
{
ValidateCredentials();
var request = new SnovioAccessTokenRequest
{
GrantType = "client_credentials",
ClientId = _options.ClientId.Trim(),
ClientSecret = _options.ClientSecret.Trim()
};
using var content = new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = request.GrantType,
["client_id"] = request.ClientId,
["client_secret"] = request.ClientSecret
});
using var response = await _httpClient.PostAsync(
AccessTokenPath,
content,
cancellationToken).ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio OAuth 获取 token 失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio OAuth 获取 token 失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var tokenResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (tokenResponse == null || string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
{
_logger.LogError("Snovio OAuth 响应中未返回有效 access_token。");
throw new InvalidOperationException("Snovio OAuth 响应中未返回有效 access_token。");
}
return tokenResponse;
}
///
public async Task StartCompanySearchAsync(
SnovioCompanySearchRequest request,
CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Page < 1)
throw new ArgumentOutOfRangeException(nameof(request.Page), "页码必须大于等于 1。");
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
var formFields = BuildCompanySearchForm(request, tokenResponse.AccessToken);
// DatabaseSearch 文档示例将 access_token 放在请求参数中;同时保留 Bearer 请求头,兼容统一认证方式。
var requestUri = $"{CompanySearchStartPath}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
using var content = new FormUrlEncodedContent(formFields);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = content
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
tokenResponse.AccessToken);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 公司搜索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 公司搜索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
{
_logger.LogError("Snovio 公司搜索响应中未返回有效 task_hash。");
throw new InvalidOperationException("Snovio 公司搜索响应中未返回有效 task_hash。");
}
return searchResponse;
}
///
public async Task GetCompanySearchResultAsync(
string taskHash,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await GetCompanySearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken).ConfigureAwait(false);
}
///
public async Task WaitForCompanySearchResultAsync(
string taskHash,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await WaitForResultAsync(
() => GetCompanySearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken),
result => result.Status,
"公司搜索",
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
///
public async Task SearchCompaniesAsync(
SnovioCompanySearchRequest request,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var startResponse = await StartCompanySearchAsync(
request,
cancellationToken).ConfigureAwait(false);
return await WaitForCompanySearchResultAsync(
startResponse.Meta.TaskHash!,
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
///
public async Task StartDomainProspectsSearchAsync(
SnovioDomainProspectsSearchRequest request,
CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.Domain))
throw new ArgumentException("域名不能为空。", nameof(request.Domain));
if (request.Page < 1)
throw new ArgumentOutOfRangeException(nameof(request.Page), "页码必须大于等于 1。");
var positions = request.Positions?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.ToList() ?? new List();
if (positions.Count > 10)
throw new ArgumentException("每次搜索最多只能指定 10 个职位。", nameof(request.Positions));
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
var formFields = BuildDomainProspectsSearchForm(request, positions, tokenResponse.AccessToken);
// DatabaseSearch 文档示例将 access_token 放在请求参数中;同时保留 Bearer 请求头,兼容统一认证方式。
var requestUri = $"{DomainProspectsSearchStartPath}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
using var content = new FormUrlEncodedContent(formFields);
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = content
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
tokenResponse.AccessToken);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 域名潜在客户搜索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 域名潜在客户搜索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
{
_logger.LogError("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
throw new InvalidOperationException("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
}
return searchResponse;
}
///
public async Task GetDomainProspectsSearchResultAsync(
string taskHash,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await GetDomainProspectsSearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken).ConfigureAwait(false);
}
///
public async Task WaitForDomainProspectsSearchResultAsync(
string taskHash,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await WaitForResultAsync(
() => GetDomainProspectsSearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken),
result => result.Status,
"域名潜在客户搜索",
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
///
public async Task SearchDomainProspectsAsync(
SnovioDomainProspectsSearchRequest request,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var startResponse = await StartDomainProspectsSearchAsync(
request,
cancellationToken).ConfigureAwait(false);
return await WaitForDomainProspectsSearchResultAsync(
startResponse.Meta.TaskHash!,
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
///
public async Task StartProspectEmailSearchAsync(
string prospectHash,
SnovioProspectEmailSearchRequest? request = null,
CancellationToken cancellationToken = default)
{
var normalizedProspectHash = ValidateProspectHash(prospectHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
var formFields = BuildProspectEmailSearchForm(request);
using var content = new FormUrlEncodedContent(formFields);
using var httpRequest = new HttpRequestMessage(
HttpMethod.Post,
$"{ProspectEmailSearchStartPath}/{Uri.EscapeDataString(normalizedProspectHash)}")
{
Content = content
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
tokenResponse.AccessToken);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 潜客邮箱检索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 潜客邮箱检索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
{
_logger.LogError("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
throw new InvalidOperationException("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
}
return searchResponse;
}
///
public async Task GetProspectEmailSearchResultAsync(
string taskHash,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await GetProspectEmailSearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken).ConfigureAwait(false);
}
///
public async Task WaitForProspectEmailSearchResultAsync(
string taskHash,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var normalizedTaskHash = ValidateTaskHash(taskHash);
var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
return await WaitForResultAsync(
() => GetProspectEmailSearchResultAsync(
normalizedTaskHash,
tokenResponse,
cancellationToken),
result => result.Status,
"潜客邮箱检索",
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
///
public async Task SearchProspectEmailAsync(
string prospectHash,
SnovioProspectEmailSearchRequest? request = null,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
CancellationToken cancellationToken = default)
{
var startResponse = await StartProspectEmailSearchAsync(
prospectHash,
request,
cancellationToken).ConfigureAwait(false);
return await WaitForProspectEmailSearchResultAsync(
startResponse.Meta.TaskHash!,
timeout,
pollInterval,
cancellationToken).ConfigureAwait(false);
}
private async Task GetCompanySearchResultAsync(
string taskHash,
SnovioAccessTokenResponse tokenResponse,
CancellationToken cancellationToken)
{
using var httpRequest = CreateAuthorizedGetRequest(
$"{CompanySearchResultPath}/{Uri.EscapeDataString(taskHash)}",
tokenResponse);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 公司搜索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 公司搜索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null)
throw new InvalidOperationException("Snovio 公司搜索结果响应为空或格式无效。");
return searchResponse;
}
private async Task GetDomainProspectsSearchResultAsync(
string taskHash,
SnovioAccessTokenResponse tokenResponse,
CancellationToken cancellationToken)
{
using var httpRequest = CreateAuthorizedGetRequest(
$"{DomainProspectsSearchResultPath}/{Uri.EscapeDataString(taskHash)}",
tokenResponse);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 域名潜在客户搜索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 域名潜在客户搜索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null)
throw new InvalidOperationException("Snovio 域名潜在客户搜索结果响应为空或格式无效。");
return searchResponse;
}
private async Task GetProspectEmailSearchResultAsync(
string taskHash,
SnovioAccessTokenResponse tokenResponse,
CancellationToken cancellationToken)
{
using var httpRequest = CreateAuthorizedGetRequest(
$"{ProspectEmailSearchResultPath}/{Uri.EscapeDataString(taskHash)}",
tokenResponse);
using var response = await _httpClient
.SendAsync(httpRequest, cancellationToken)
.ConfigureAwait(false);
var responseBody = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"Snovio 潜客邮箱检索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
(int)response.StatusCode,
responseBody);
throw new HttpRequestException(
$"Snovio 潜客邮箱检索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
null,
response.StatusCode);
}
var searchResponse = System.Text.Json.JsonSerializer.Deserialize(
responseBody,
JsonOptions);
if (searchResponse == null)
throw new InvalidOperationException("Snovio 潜客邮箱检索结果响应为空或格式无效。");
return searchResponse;
}
private async Task WaitForResultAsync(
Func> getResultAsync,
Func getStatus,
string taskDescription,
TimeSpan? timeout,
TimeSpan? pollInterval,
CancellationToken cancellationToken)
{
var maxWait = timeout ?? TimeSpan.FromMinutes(2);
var interval = pollInterval ?? TimeSpan.FromSeconds(3);
if (maxWait <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "等待超时时间必须大于 0。");
if (interval <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(pollInterval), "轮询间隔必须大于 0。");
var deadline = DateTimeOffset.UtcNow.Add(maxWait);
while (true)
{
var result = await getResultAsync().ConfigureAwait(false);
var status = getStatus(result);
if (string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase))
return result;
if (IsFailedStatus(status))
throw new InvalidOperationException(
$"Snovio {taskDescription}任务失败,状态:{status}。");
_logger.LogDebug(
"Snovio {TaskDescription}任务仍在处理中,状态:{Status}",
taskDescription,
status ?? "unknown");
var remaining = deadline - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero)
throw new TimeoutException(
$"Snovio {taskDescription}任务在 {maxWait.TotalSeconds:0} 秒内未完成。");
await Task.Delay(
remaining < interval ? remaining : interval,
cancellationToken).ConfigureAwait(false);
}
}
private static bool IsFailedStatus(string? status)
{
return status is not null &&
(status.Equals("failed", StringComparison.OrdinalIgnoreCase) ||
status.Equals("failure", StringComparison.OrdinalIgnoreCase) ||
status.Equals("error", StringComparison.OrdinalIgnoreCase) ||
status.Equals("cancelled", StringComparison.OrdinalIgnoreCase) ||
status.Equals("canceled", StringComparison.OrdinalIgnoreCase));
}
private static string ValidateTaskHash(string taskHash)
{
if (string.IsNullOrWhiteSpace(taskHash))
throw new ArgumentException("task_hash 不能为空。", nameof(taskHash));
return taskHash.Trim();
}
private static string ValidateProspectHash(string prospectHash)
{
if (string.IsNullOrWhiteSpace(prospectHash))
throw new ArgumentException("prospect_hash 不能为空。", nameof(prospectHash));
return prospectHash.Trim();
}
private static HttpRequestMessage CreateAuthorizedGetRequest(
string path,
SnovioAccessTokenResponse tokenResponse)
{
var requestUri = $"{path}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
tokenResponse.AccessToken);
return httpRequest;
}
private static Dictionary BuildCompanySearchForm(
SnovioCompanySearchRequest request,
string accessToken)
{
var formFields = new Dictionary
{
["access_token"] = accessToken,
["page"] = request.Page.ToString(CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(request.WebhookUrl))
formFields["webhook_url"] = request.WebhookUrl.Trim();
var filters = request.Filters ?? new SnovioCompanySearchFilters();
var company = filters.Company ?? new SnovioCompanyFilter();
AddStringList(formFields, "filters[company][name][include]", company.Name?.Include);
AddStringList(formFields, "filters[company][name][exclude]", company.Name?.Exclude);
AddStringList(formFields, "filters[company][industries][include]", company.Industries?.Include);
AddStringList(formFields, "filters[company][industries][exclude]", company.Industries?.Exclude);
AddStringList(formFields, "filters[company][specialities]", company.Specialities);
if (!string.IsNullOrWhiteSpace(company.Size))
formFields["filters[company][size]"] = company.Size.Trim();
if (company.Revenue?.Min is { } revenueMin)
formFields["filters[company][revenue][min]"] = revenueMin.ToString(CultureInfo.InvariantCulture);
if (company.Revenue?.Max is { } revenueMax)
formFields["filters[company][revenue][max]"] = revenueMax.ToString(CultureInfo.InvariantCulture);
if (company.Founded?.From is { } foundedFrom)
formFields["filters[company][founded][from]"] = foundedFrom.ToString(CultureInfo.InvariantCulture);
if (company.Founded?.Till is { } foundedTill)
formFields["filters[company][founded][till]"] = foundedTill.ToString(CultureInfo.InvariantCulture);
AddLocation(formFields, "include", filters.Locations?.Include);
AddLocation(formFields, "exclude", filters.Locations?.Exclude);
return formFields;
}
private static Dictionary BuildDomainProspectsSearchForm(
SnovioDomainProspectsSearchRequest request,
IReadOnlyList positions,
string accessToken)
{
var formFields = new Dictionary
{
["access_token"] = accessToken,
["domain"] = request.Domain.Trim(),
["page"] = request.Page.ToString(CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(request.WebhookUrl))
formFields["webhook_url"] = request.WebhookUrl.Trim();
for (var index = 0; index < positions.Count; index++)
{
formFields[$"positions[{index}]"] = positions[index];
}
return formFields;
}
private static Dictionary BuildProspectEmailSearchForm(
SnovioProspectEmailSearchRequest? request)
{
var formFields = new Dictionary();
if (!string.IsNullOrWhiteSpace(request?.WebhookUrl))
formFields["webhook_url"] = request.WebhookUrl.Trim();
return formFields;
}
private static void AddStringList(
IDictionary formFields,
string keyPrefix,
IEnumerable? values)
{
if (values == null)
return;
var index = 0;
foreach (var value in values.Where(x => !string.IsNullOrWhiteSpace(x)))
{
formFields[$"{keyPrefix}[{index++}]"] = value.Trim();
}
}
private static void AddLocation(
IDictionary formFields,
string locationMode,
SnovioLocationFilterItem? location)
{
if (location == null)
return;
if (!string.IsNullOrWhiteSpace(location.Locality))
formFields[$"filters[locations][{locationMode}][locality]"] = location.Locality.Trim();
if (!string.IsNullOrWhiteSpace(location.LocationType))
formFields[$"filters[locations][{locationMode}][location_type]"] = location.LocationType.Trim();
}
private void ValidateCredentials()
{
if (string.IsNullOrWhiteSpace(_options.ClientId))
throw new InvalidOperationException("Snovio:ClientId 未配置。");
if (string.IsNullOrWhiteSpace(_options.ClientSecret))
throw new InvalidOperationException("Snovio:ClientSecret 未配置。");
}
private static string NormalizeBaseUrl(string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
return "https://api.snov.io/";
return baseUrl.TrimEnd('/') + "/";
}
}