|
|
@@ -0,0 +1,777 @@
|
|
|
+using System.Net.Http.Headers;
|
|
|
+using System.Text.Json;
|
|
|
+using System.Globalization;
|
|
|
+using Microsoft.Extensions.Options;
|
|
|
+
|
|
|
+namespace OASystem.API.OAMethodLib.SnovioAPI;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// Snov.io API 服务实现。
|
|
|
+/// </summary>
|
|
|
+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<SnovioService> _logger;
|
|
|
+
|
|
|
+ public SnovioService(
|
|
|
+ HttpClient httpClient,
|
|
|
+ IOptions<SnovioOptions> options,
|
|
|
+ ILogger<SnovioService> 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"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioAccessTokenResponse> 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<string, string>
|
|
|
+ {
|
|
|
+ ["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<SnovioAccessTokenResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (tokenResponse == null || string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
|
|
|
+ {
|
|
|
+ _logger.LogError("Snovio OAuth 响应中未返回有效 access_token。");
|
|
|
+ throw new InvalidOperationException("Snovio OAuth 响应中未返回有效 access_token。");
|
|
|
+ }
|
|
|
+
|
|
|
+ return tokenResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioCompanySearchStartResponse> 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<SnovioCompanySearchStartResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
|
|
|
+ {
|
|
|
+ _logger.LogError("Snovio 公司搜索响应中未返回有效 task_hash。");
|
|
|
+ throw new InvalidOperationException("Snovio 公司搜索响应中未返回有效 task_hash。");
|
|
|
+ }
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioCompanySearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioCompanySearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioCompanySearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioDomainProspectsSearchStartResponse> 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<string>();
|
|
|
+
|
|
|
+ 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<SnovioDomainProspectsSearchStartResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
|
|
|
+ {
|
|
|
+ _logger.LogError("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
|
|
|
+ throw new InvalidOperationException("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
|
|
|
+ }
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioDomainProspectsSearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioDomainProspectsSearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioDomainProspectsSearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioProspectEmailSearchStartResponse> 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<SnovioProspectEmailSearchStartResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
|
|
|
+ {
|
|
|
+ _logger.LogError("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
|
|
|
+ throw new InvalidOperationException("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
|
|
|
+ }
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioProspectEmailSearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioProspectEmailSearchResultResponse> 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <inheritdoc />
|
|
|
+ public async Task<SnovioProspectEmailSearchResultResponse> 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<SnovioCompanySearchResultResponse> 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<SnovioCompanySearchResultResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null)
|
|
|
+ throw new InvalidOperationException("Snovio 公司搜索结果响应为空或格式无效。");
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<SnovioDomainProspectsSearchResultResponse> 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<SnovioDomainProspectsSearchResultResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null)
|
|
|
+ throw new InvalidOperationException("Snovio 域名潜在客户搜索结果响应为空或格式无效。");
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<SnovioProspectEmailSearchResultResponse> 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<SnovioProspectEmailSearchResultResponse>(
|
|
|
+ responseBody,
|
|
|
+ JsonOptions);
|
|
|
+
|
|
|
+ if (searchResponse == null)
|
|
|
+ throw new InvalidOperationException("Snovio 潜客邮箱检索结果响应为空或格式无效。");
|
|
|
+
|
|
|
+ return searchResponse;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<TResponse> WaitForResultAsync<TResponse>(
|
|
|
+ Func<Task<TResponse>> getResultAsync,
|
|
|
+ Func<TResponse, string?> 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<string, string> BuildCompanySearchForm(
|
|
|
+ SnovioCompanySearchRequest request,
|
|
|
+ string accessToken)
|
|
|
+ {
|
|
|
+ var formFields = new Dictionary<string, string>
|
|
|
+ {
|
|
|
+ ["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<string, string> BuildDomainProspectsSearchForm(
|
|
|
+ SnovioDomainProspectsSearchRequest request,
|
|
|
+ IReadOnlyList<string> positions,
|
|
|
+ string accessToken)
|
|
|
+ {
|
|
|
+ var formFields = new Dictionary<string, string>
|
|
|
+ {
|
|
|
+ ["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<string, string> BuildProspectEmailSearchForm(
|
|
|
+ SnovioProspectEmailSearchRequest? request)
|
|
|
+ {
|
|
|
+ var formFields = new Dictionary<string, string>();
|
|
|
+
|
|
|
+ if (!string.IsNullOrWhiteSpace(request?.WebhookUrl))
|
|
|
+ formFields["webhook_url"] = request.WebhookUrl.Trim();
|
|
|
+
|
|
|
+ return formFields;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void AddStringList(
|
|
|
+ IDictionary<string, string> formFields,
|
|
|
+ string keyPrefix,
|
|
|
+ IEnumerable<string>? 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<string, string> 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('/') + "/";
|
|
|
+ }
|
|
|
+}
|