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('/') + "/"; } }