SnovioService.cs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. using System.Net.Http.Headers;
  2. using System.Text.Json;
  3. using System.Globalization;
  4. using Microsoft.Extensions.Options;
  5. namespace OASystem.API.OAMethodLib.SnovioAPI;
  6. /// <summary>
  7. /// Snov.io API 服务实现。
  8. /// </summary>
  9. public sealed class SnovioService : ISnovioService
  10. {
  11. private const string AccessTokenPath = "v1/oauth/access_token";
  12. private const string CompanySearchStartPath = "v2/database-search/companies/start";
  13. private const string CompanySearchResultPath = "v2/database-search/companies/result";
  14. private const string DomainProspectsSearchStartPath = "v2/domain-search/prospects/start";
  15. private const string DomainProspectsSearchResultPath = "v2/domain-search/prospects/result";
  16. private const string ProspectEmailSearchStartPath = "v2/domain-search/prospects/search-emails/start";
  17. private const string ProspectEmailSearchResultPath = "v2/domain-search/prospects/search-emails/result";
  18. private static readonly JsonSerializerOptions JsonOptions = new()
  19. {
  20. PropertyNameCaseInsensitive = true
  21. };
  22. private readonly HttpClient _httpClient;
  23. private readonly SnovioOptions _options;
  24. private readonly ILogger<SnovioService> _logger;
  25. public SnovioService(
  26. HttpClient httpClient,
  27. IOptions<SnovioOptions> options,
  28. ILogger<SnovioService> logger)
  29. {
  30. _httpClient = httpClient;
  31. _options = options.Value;
  32. _logger = logger;
  33. if (!Uri.TryCreate(NormalizeBaseUrl(_options.BaseUrl), UriKind.Absolute, out var baseAddress))
  34. throw new InvalidOperationException("Snovio:BaseUrl 配置不是有效的绝对地址。");
  35. _httpClient.BaseAddress = baseAddress;
  36. _httpClient.Timeout = TimeSpan.FromSeconds(
  37. _options.TimeoutSeconds > 0 ? _options.TimeoutSeconds : 30);
  38. _httpClient.DefaultRequestHeaders.Accept.Add(
  39. new MediaTypeWithQualityHeaderValue("application/json"));
  40. }
  41. /// <inheritdoc />
  42. public async Task<SnovioAccessTokenResponse> GetAccessTokenAsync(
  43. CancellationToken cancellationToken = default)
  44. {
  45. ValidateCredentials();
  46. var request = new SnovioAccessTokenRequest
  47. {
  48. GrantType = "client_credentials",
  49. ClientId = _options.ClientId.Trim(),
  50. ClientSecret = _options.ClientSecret.Trim()
  51. };
  52. using var content = new FormUrlEncodedContent(new Dictionary<string, string>
  53. {
  54. ["grant_type"] = request.GrantType,
  55. ["client_id"] = request.ClientId,
  56. ["client_secret"] = request.ClientSecret
  57. });
  58. using var response = await _httpClient.PostAsync(
  59. AccessTokenPath,
  60. content,
  61. cancellationToken).ConfigureAwait(false);
  62. var responseBody = await response.Content
  63. .ReadAsStringAsync(cancellationToken)
  64. .ConfigureAwait(false);
  65. if (!response.IsSuccessStatusCode)
  66. {
  67. _logger.LogError(
  68. "Snovio OAuth 获取 token 失败,状态码:{StatusCode},响应:{ResponseBody}",
  69. (int)response.StatusCode,
  70. responseBody);
  71. throw new HttpRequestException(
  72. $"Snovio OAuth 获取 token 失败,HTTP 状态码:{(int)response.StatusCode}。",
  73. null,
  74. response.StatusCode);
  75. }
  76. var tokenResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioAccessTokenResponse>(
  77. responseBody,
  78. JsonOptions);
  79. if (tokenResponse == null || string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
  80. {
  81. _logger.LogError("Snovio OAuth 响应中未返回有效 access_token。");
  82. throw new InvalidOperationException("Snovio OAuth 响应中未返回有效 access_token。");
  83. }
  84. return tokenResponse;
  85. }
  86. /// <inheritdoc />
  87. public async Task<SnovioCompanySearchStartResponse> StartCompanySearchAsync(
  88. SnovioCompanySearchRequest request,
  89. CancellationToken cancellationToken = default)
  90. {
  91. if (request == null)
  92. throw new ArgumentNullException(nameof(request));
  93. if (request.Page < 1)
  94. throw new ArgumentOutOfRangeException(nameof(request.Page), "页码必须大于等于 1。");
  95. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  96. var formFields = BuildCompanySearchForm(request, tokenResponse.AccessToken);
  97. // DatabaseSearch 文档示例将 access_token 放在请求参数中;同时保留 Bearer 请求头,兼容统一认证方式。
  98. var requestUri = $"{CompanySearchStartPath}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
  99. using var content = new FormUrlEncodedContent(formFields);
  100. using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
  101. {
  102. Content = content
  103. };
  104. httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
  105. string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
  106. tokenResponse.AccessToken);
  107. using var response = await _httpClient
  108. .SendAsync(httpRequest, cancellationToken)
  109. .ConfigureAwait(false);
  110. var responseBody = await response.Content
  111. .ReadAsStringAsync(cancellationToken)
  112. .ConfigureAwait(false);
  113. if (!response.IsSuccessStatusCode)
  114. {
  115. _logger.LogError(
  116. "Snovio 公司搜索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
  117. (int)response.StatusCode,
  118. responseBody);
  119. throw new HttpRequestException(
  120. $"Snovio 公司搜索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
  121. null,
  122. response.StatusCode);
  123. }
  124. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioCompanySearchStartResponse>(
  125. responseBody,
  126. JsonOptions);
  127. if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
  128. {
  129. _logger.LogError("Snovio 公司搜索响应中未返回有效 task_hash。");
  130. throw new InvalidOperationException("Snovio 公司搜索响应中未返回有效 task_hash。");
  131. }
  132. return searchResponse;
  133. }
  134. /// <inheritdoc />
  135. public async Task<SnovioCompanySearchResultResponse> GetCompanySearchResultAsync(
  136. string taskHash,
  137. CancellationToken cancellationToken = default)
  138. {
  139. var normalizedTaskHash = ValidateTaskHash(taskHash);
  140. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  141. return await GetCompanySearchResultAsync(
  142. normalizedTaskHash,
  143. tokenResponse,
  144. cancellationToken).ConfigureAwait(false);
  145. }
  146. /// <inheritdoc />
  147. public async Task<SnovioCompanySearchResultResponse> WaitForCompanySearchResultAsync(
  148. string taskHash,
  149. TimeSpan? timeout = null,
  150. TimeSpan? pollInterval = null,
  151. CancellationToken cancellationToken = default)
  152. {
  153. var normalizedTaskHash = ValidateTaskHash(taskHash);
  154. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  155. return await WaitForResultAsync(
  156. () => GetCompanySearchResultAsync(
  157. normalizedTaskHash,
  158. tokenResponse,
  159. cancellationToken),
  160. result => result.Status,
  161. "公司搜索",
  162. timeout,
  163. pollInterval,
  164. cancellationToken).ConfigureAwait(false);
  165. }
  166. /// <inheritdoc />
  167. public async Task<SnovioCompanySearchResultResponse> SearchCompaniesAsync(
  168. SnovioCompanySearchRequest request,
  169. TimeSpan? timeout = null,
  170. TimeSpan? pollInterval = null,
  171. CancellationToken cancellationToken = default)
  172. {
  173. var startResponse = await StartCompanySearchAsync(
  174. request,
  175. cancellationToken).ConfigureAwait(false);
  176. return await WaitForCompanySearchResultAsync(
  177. startResponse.Meta.TaskHash!,
  178. timeout,
  179. pollInterval,
  180. cancellationToken).ConfigureAwait(false);
  181. }
  182. /// <inheritdoc />
  183. public async Task<SnovioDomainProspectsSearchStartResponse> StartDomainProspectsSearchAsync(
  184. SnovioDomainProspectsSearchRequest request,
  185. CancellationToken cancellationToken = default)
  186. {
  187. if (request == null)
  188. throw new ArgumentNullException(nameof(request));
  189. if (string.IsNullOrWhiteSpace(request.Domain))
  190. throw new ArgumentException("域名不能为空。", nameof(request.Domain));
  191. if (request.Page < 1)
  192. throw new ArgumentOutOfRangeException(nameof(request.Page), "页码必须大于等于 1。");
  193. var positions = request.Positions?
  194. .Where(x => !string.IsNullOrWhiteSpace(x))
  195. .Select(x => x.Trim())
  196. .ToList() ?? new List<string>();
  197. if (positions.Count > 10)
  198. throw new ArgumentException("每次搜索最多只能指定 10 个职位。", nameof(request.Positions));
  199. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  200. var formFields = BuildDomainProspectsSearchForm(request, positions, tokenResponse.AccessToken);
  201. // DatabaseSearch 文档示例将 access_token 放在请求参数中;同时保留 Bearer 请求头,兼容统一认证方式。
  202. var requestUri = $"{DomainProspectsSearchStartPath}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
  203. using var content = new FormUrlEncodedContent(formFields);
  204. using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
  205. {
  206. Content = content
  207. };
  208. httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
  209. string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
  210. tokenResponse.AccessToken);
  211. using var response = await _httpClient
  212. .SendAsync(httpRequest, cancellationToken)
  213. .ConfigureAwait(false);
  214. var responseBody = await response.Content
  215. .ReadAsStringAsync(cancellationToken)
  216. .ConfigureAwait(false);
  217. if (!response.IsSuccessStatusCode)
  218. {
  219. _logger.LogError(
  220. "Snovio 域名潜在客户搜索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
  221. (int)response.StatusCode,
  222. responseBody);
  223. throw new HttpRequestException(
  224. $"Snovio 域名潜在客户搜索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
  225. null,
  226. response.StatusCode);
  227. }
  228. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioDomainProspectsSearchStartResponse>(
  229. responseBody,
  230. JsonOptions);
  231. if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
  232. {
  233. _logger.LogError("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
  234. throw new InvalidOperationException("Snovio 域名潜在客户搜索响应中未返回有效 task_hash。");
  235. }
  236. return searchResponse;
  237. }
  238. /// <inheritdoc />
  239. public async Task<SnovioDomainProspectsSearchResultResponse> GetDomainProspectsSearchResultAsync(
  240. string taskHash,
  241. CancellationToken cancellationToken = default)
  242. {
  243. var normalizedTaskHash = ValidateTaskHash(taskHash);
  244. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  245. return await GetDomainProspectsSearchResultAsync(
  246. normalizedTaskHash,
  247. tokenResponse,
  248. cancellationToken).ConfigureAwait(false);
  249. }
  250. /// <inheritdoc />
  251. public async Task<SnovioDomainProspectsSearchResultResponse> WaitForDomainProspectsSearchResultAsync(
  252. string taskHash,
  253. TimeSpan? timeout = null,
  254. TimeSpan? pollInterval = null,
  255. CancellationToken cancellationToken = default)
  256. {
  257. var normalizedTaskHash = ValidateTaskHash(taskHash);
  258. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  259. return await WaitForResultAsync(
  260. () => GetDomainProspectsSearchResultAsync(
  261. normalizedTaskHash,
  262. tokenResponse,
  263. cancellationToken),
  264. result => result.Status,
  265. "域名潜在客户搜索",
  266. timeout,
  267. pollInterval,
  268. cancellationToken).ConfigureAwait(false);
  269. }
  270. /// <inheritdoc />
  271. public async Task<SnovioDomainProspectsSearchResultResponse> SearchDomainProspectsAsync(
  272. SnovioDomainProspectsSearchRequest request,
  273. TimeSpan? timeout = null,
  274. TimeSpan? pollInterval = null,
  275. CancellationToken cancellationToken = default)
  276. {
  277. var startResponse = await StartDomainProspectsSearchAsync(
  278. request,
  279. cancellationToken).ConfigureAwait(false);
  280. return await WaitForDomainProspectsSearchResultAsync(
  281. startResponse.Meta.TaskHash!,
  282. timeout,
  283. pollInterval,
  284. cancellationToken).ConfigureAwait(false);
  285. }
  286. /// <inheritdoc />
  287. public async Task<SnovioProspectEmailSearchStartResponse> StartProspectEmailSearchAsync(
  288. string prospectHash,
  289. SnovioProspectEmailSearchRequest? request = null,
  290. CancellationToken cancellationToken = default)
  291. {
  292. var normalizedProspectHash = ValidateProspectHash(prospectHash);
  293. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  294. var formFields = BuildProspectEmailSearchForm(request);
  295. using var content = new FormUrlEncodedContent(formFields);
  296. using var httpRequest = new HttpRequestMessage(
  297. HttpMethod.Post,
  298. $"{ProspectEmailSearchStartPath}/{Uri.EscapeDataString(normalizedProspectHash)}")
  299. {
  300. Content = content
  301. };
  302. httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
  303. string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
  304. tokenResponse.AccessToken);
  305. using var response = await _httpClient
  306. .SendAsync(httpRequest, cancellationToken)
  307. .ConfigureAwait(false);
  308. var responseBody = await response.Content
  309. .ReadAsStringAsync(cancellationToken)
  310. .ConfigureAwait(false);
  311. if (!response.IsSuccessStatusCode)
  312. {
  313. _logger.LogError(
  314. "Snovio 潜客邮箱检索任务创建失败,状态码:{StatusCode},响应:{ResponseBody}",
  315. (int)response.StatusCode,
  316. responseBody);
  317. throw new HttpRequestException(
  318. $"Snovio 潜客邮箱检索任务创建失败,HTTP 状态码:{(int)response.StatusCode}。",
  319. null,
  320. response.StatusCode);
  321. }
  322. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioProspectEmailSearchStartResponse>(
  323. responseBody,
  324. JsonOptions);
  325. if (searchResponse == null || string.IsNullOrWhiteSpace(searchResponse.Meta?.TaskHash))
  326. {
  327. _logger.LogError("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
  328. throw new InvalidOperationException("Snovio 潜客邮箱检索响应中未返回有效 task_hash。");
  329. }
  330. return searchResponse;
  331. }
  332. /// <inheritdoc />
  333. public async Task<SnovioProspectEmailSearchResultResponse> GetProspectEmailSearchResultAsync(
  334. string taskHash,
  335. CancellationToken cancellationToken = default)
  336. {
  337. var normalizedTaskHash = ValidateTaskHash(taskHash);
  338. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  339. return await GetProspectEmailSearchResultAsync(
  340. normalizedTaskHash,
  341. tokenResponse,
  342. cancellationToken).ConfigureAwait(false);
  343. }
  344. /// <inheritdoc />
  345. public async Task<SnovioProspectEmailSearchResultResponse> WaitForProspectEmailSearchResultAsync(
  346. string taskHash,
  347. TimeSpan? timeout = null,
  348. TimeSpan? pollInterval = null,
  349. CancellationToken cancellationToken = default)
  350. {
  351. var normalizedTaskHash = ValidateTaskHash(taskHash);
  352. var tokenResponse = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
  353. return await WaitForResultAsync(
  354. () => GetProspectEmailSearchResultAsync(
  355. normalizedTaskHash,
  356. tokenResponse,
  357. cancellationToken),
  358. result => result.Status,
  359. "潜客邮箱检索",
  360. timeout,
  361. pollInterval,
  362. cancellationToken).ConfigureAwait(false);
  363. }
  364. /// <inheritdoc />
  365. public async Task<SnovioProspectEmailSearchResultResponse> SearchProspectEmailAsync(
  366. string prospectHash,
  367. SnovioProspectEmailSearchRequest? request = null,
  368. TimeSpan? timeout = null,
  369. TimeSpan? pollInterval = null,
  370. CancellationToken cancellationToken = default)
  371. {
  372. var startResponse = await StartProspectEmailSearchAsync(
  373. prospectHash,
  374. request,
  375. cancellationToken).ConfigureAwait(false);
  376. return await WaitForProspectEmailSearchResultAsync(
  377. startResponse.Meta.TaskHash!,
  378. timeout,
  379. pollInterval,
  380. cancellationToken).ConfigureAwait(false);
  381. }
  382. private async Task<SnovioCompanySearchResultResponse> GetCompanySearchResultAsync(
  383. string taskHash,
  384. SnovioAccessTokenResponse tokenResponse,
  385. CancellationToken cancellationToken)
  386. {
  387. using var httpRequest = CreateAuthorizedGetRequest(
  388. $"{CompanySearchResultPath}/{Uri.EscapeDataString(taskHash)}",
  389. tokenResponse);
  390. using var response = await _httpClient
  391. .SendAsync(httpRequest, cancellationToken)
  392. .ConfigureAwait(false);
  393. var responseBody = await response.Content
  394. .ReadAsStringAsync(cancellationToken)
  395. .ConfigureAwait(false);
  396. if (!response.IsSuccessStatusCode)
  397. {
  398. _logger.LogError(
  399. "Snovio 公司搜索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
  400. (int)response.StatusCode,
  401. responseBody);
  402. throw new HttpRequestException(
  403. $"Snovio 公司搜索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
  404. null,
  405. response.StatusCode);
  406. }
  407. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioCompanySearchResultResponse>(
  408. responseBody,
  409. JsonOptions);
  410. if (searchResponse == null)
  411. throw new InvalidOperationException("Snovio 公司搜索结果响应为空或格式无效。");
  412. return searchResponse;
  413. }
  414. private async Task<SnovioDomainProspectsSearchResultResponse> GetDomainProspectsSearchResultAsync(
  415. string taskHash,
  416. SnovioAccessTokenResponse tokenResponse,
  417. CancellationToken cancellationToken)
  418. {
  419. using var httpRequest = CreateAuthorizedGetRequest(
  420. $"{DomainProspectsSearchResultPath}/{Uri.EscapeDataString(taskHash)}",
  421. tokenResponse);
  422. using var response = await _httpClient
  423. .SendAsync(httpRequest, cancellationToken)
  424. .ConfigureAwait(false);
  425. var responseBody = await response.Content
  426. .ReadAsStringAsync(cancellationToken)
  427. .ConfigureAwait(false);
  428. if (!response.IsSuccessStatusCode)
  429. {
  430. _logger.LogError(
  431. "Snovio 域名潜在客户搜索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
  432. (int)response.StatusCode,
  433. responseBody);
  434. throw new HttpRequestException(
  435. $"Snovio 域名潜在客户搜索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
  436. null,
  437. response.StatusCode);
  438. }
  439. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioDomainProspectsSearchResultResponse>(
  440. responseBody,
  441. JsonOptions);
  442. if (searchResponse == null)
  443. throw new InvalidOperationException("Snovio 域名潜在客户搜索结果响应为空或格式无效。");
  444. return searchResponse;
  445. }
  446. private async Task<SnovioProspectEmailSearchResultResponse> GetProspectEmailSearchResultAsync(
  447. string taskHash,
  448. SnovioAccessTokenResponse tokenResponse,
  449. CancellationToken cancellationToken)
  450. {
  451. using var httpRequest = CreateAuthorizedGetRequest(
  452. $"{ProspectEmailSearchResultPath}/{Uri.EscapeDataString(taskHash)}",
  453. tokenResponse);
  454. using var response = await _httpClient
  455. .SendAsync(httpRequest, cancellationToken)
  456. .ConfigureAwait(false);
  457. var responseBody = await response.Content
  458. .ReadAsStringAsync(cancellationToken)
  459. .ConfigureAwait(false);
  460. if (!response.IsSuccessStatusCode)
  461. {
  462. _logger.LogError(
  463. "Snovio 潜客邮箱检索结果获取失败,状态码:{StatusCode},响应:{ResponseBody}",
  464. (int)response.StatusCode,
  465. responseBody);
  466. throw new HttpRequestException(
  467. $"Snovio 潜客邮箱检索结果获取失败,HTTP 状态码:{(int)response.StatusCode}。",
  468. null,
  469. response.StatusCode);
  470. }
  471. var searchResponse = System.Text.Json.JsonSerializer.Deserialize<SnovioProspectEmailSearchResultResponse>(
  472. responseBody,
  473. JsonOptions);
  474. if (searchResponse == null)
  475. throw new InvalidOperationException("Snovio 潜客邮箱检索结果响应为空或格式无效。");
  476. return searchResponse;
  477. }
  478. private async Task<TResponse> WaitForResultAsync<TResponse>(
  479. Func<Task<TResponse>> getResultAsync,
  480. Func<TResponse, string?> getStatus,
  481. string taskDescription,
  482. TimeSpan? timeout,
  483. TimeSpan? pollInterval,
  484. CancellationToken cancellationToken)
  485. {
  486. var maxWait = timeout ?? TimeSpan.FromMinutes(2);
  487. var interval = pollInterval ?? TimeSpan.FromSeconds(3);
  488. if (maxWait <= TimeSpan.Zero)
  489. throw new ArgumentOutOfRangeException(nameof(timeout), "等待超时时间必须大于 0。");
  490. if (interval <= TimeSpan.Zero)
  491. throw new ArgumentOutOfRangeException(nameof(pollInterval), "轮询间隔必须大于 0。");
  492. var deadline = DateTimeOffset.UtcNow.Add(maxWait);
  493. while (true)
  494. {
  495. var result = await getResultAsync().ConfigureAwait(false);
  496. var status = getStatus(result);
  497. if (string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase))
  498. return result;
  499. if (IsFailedStatus(status))
  500. throw new InvalidOperationException(
  501. $"Snovio {taskDescription}任务失败,状态:{status}。");
  502. _logger.LogDebug(
  503. "Snovio {TaskDescription}任务仍在处理中,状态:{Status}",
  504. taskDescription,
  505. status ?? "unknown");
  506. var remaining = deadline - DateTimeOffset.UtcNow;
  507. if (remaining <= TimeSpan.Zero)
  508. throw new TimeoutException(
  509. $"Snovio {taskDescription}任务在 {maxWait.TotalSeconds:0} 秒内未完成。");
  510. await Task.Delay(
  511. remaining < interval ? remaining : interval,
  512. cancellationToken).ConfigureAwait(false);
  513. }
  514. }
  515. private static bool IsFailedStatus(string? status)
  516. {
  517. return status is not null &&
  518. (status.Equals("failed", StringComparison.OrdinalIgnoreCase) ||
  519. status.Equals("failure", StringComparison.OrdinalIgnoreCase) ||
  520. status.Equals("error", StringComparison.OrdinalIgnoreCase) ||
  521. status.Equals("cancelled", StringComparison.OrdinalIgnoreCase) ||
  522. status.Equals("canceled", StringComparison.OrdinalIgnoreCase));
  523. }
  524. private static string ValidateTaskHash(string taskHash)
  525. {
  526. if (string.IsNullOrWhiteSpace(taskHash))
  527. throw new ArgumentException("task_hash 不能为空。", nameof(taskHash));
  528. return taskHash.Trim();
  529. }
  530. private static string ValidateProspectHash(string prospectHash)
  531. {
  532. if (string.IsNullOrWhiteSpace(prospectHash))
  533. throw new ArgumentException("prospect_hash 不能为空。", nameof(prospectHash));
  534. return prospectHash.Trim();
  535. }
  536. private static HttpRequestMessage CreateAuthorizedGetRequest(
  537. string path,
  538. SnovioAccessTokenResponse tokenResponse)
  539. {
  540. var requestUri = $"{path}?access_token={Uri.EscapeDataString(tokenResponse.AccessToken)}";
  541. var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
  542. httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
  543. string.IsNullOrWhiteSpace(tokenResponse.TokenType) ? "Bearer" : tokenResponse.TokenType,
  544. tokenResponse.AccessToken);
  545. return httpRequest;
  546. }
  547. private static Dictionary<string, string> BuildCompanySearchForm(
  548. SnovioCompanySearchRequest request,
  549. string accessToken)
  550. {
  551. var formFields = new Dictionary<string, string>
  552. {
  553. ["access_token"] = accessToken,
  554. ["page"] = request.Page.ToString(CultureInfo.InvariantCulture)
  555. };
  556. if (!string.IsNullOrWhiteSpace(request.WebhookUrl))
  557. formFields["webhook_url"] = request.WebhookUrl.Trim();
  558. var filters = request.Filters ?? new SnovioCompanySearchFilters();
  559. var company = filters.Company ?? new SnovioCompanyFilter();
  560. AddStringList(formFields, "filters[company][name][include]", company.Name?.Include);
  561. AddStringList(formFields, "filters[company][name][exclude]", company.Name?.Exclude);
  562. AddStringList(formFields, "filters[company][industries][include]", company.Industries?.Include);
  563. AddStringList(formFields, "filters[company][industries][exclude]", company.Industries?.Exclude);
  564. AddStringList(formFields, "filters[company][specialities]", company.Specialities);
  565. if (!string.IsNullOrWhiteSpace(company.Size))
  566. formFields["filters[company][size]"] = company.Size.Trim();
  567. if (company.Revenue?.Min is { } revenueMin)
  568. formFields["filters[company][revenue][min]"] = revenueMin.ToString(CultureInfo.InvariantCulture);
  569. if (company.Revenue?.Max is { } revenueMax)
  570. formFields["filters[company][revenue][max]"] = revenueMax.ToString(CultureInfo.InvariantCulture);
  571. if (company.Founded?.From is { } foundedFrom)
  572. formFields["filters[company][founded][from]"] = foundedFrom.ToString(CultureInfo.InvariantCulture);
  573. if (company.Founded?.Till is { } foundedTill)
  574. formFields["filters[company][founded][till]"] = foundedTill.ToString(CultureInfo.InvariantCulture);
  575. AddLocation(formFields, "include", filters.Locations?.Include);
  576. AddLocation(formFields, "exclude", filters.Locations?.Exclude);
  577. return formFields;
  578. }
  579. private static Dictionary<string, string> BuildDomainProspectsSearchForm(
  580. SnovioDomainProspectsSearchRequest request,
  581. IReadOnlyList<string> positions,
  582. string accessToken)
  583. {
  584. var formFields = new Dictionary<string, string>
  585. {
  586. ["access_token"] = accessToken,
  587. ["domain"] = request.Domain.Trim(),
  588. ["page"] = request.Page.ToString(CultureInfo.InvariantCulture)
  589. };
  590. if (!string.IsNullOrWhiteSpace(request.WebhookUrl))
  591. formFields["webhook_url"] = request.WebhookUrl.Trim();
  592. for (var index = 0; index < positions.Count; index++)
  593. {
  594. formFields[$"positions[{index}]"] = positions[index];
  595. }
  596. return formFields;
  597. }
  598. private static Dictionary<string, string> BuildProspectEmailSearchForm(
  599. SnovioProspectEmailSearchRequest? request)
  600. {
  601. var formFields = new Dictionary<string, string>();
  602. if (!string.IsNullOrWhiteSpace(request?.WebhookUrl))
  603. formFields["webhook_url"] = request.WebhookUrl.Trim();
  604. return formFields;
  605. }
  606. private static void AddStringList(
  607. IDictionary<string, string> formFields,
  608. string keyPrefix,
  609. IEnumerable<string>? values)
  610. {
  611. if (values == null)
  612. return;
  613. var index = 0;
  614. foreach (var value in values.Where(x => !string.IsNullOrWhiteSpace(x)))
  615. {
  616. formFields[$"{keyPrefix}[{index++}]"] = value.Trim();
  617. }
  618. }
  619. private static void AddLocation(
  620. IDictionary<string, string> formFields,
  621. string locationMode,
  622. SnovioLocationFilterItem? location)
  623. {
  624. if (location == null)
  625. return;
  626. if (!string.IsNullOrWhiteSpace(location.Locality))
  627. formFields[$"filters[locations][{locationMode}][locality]"] = location.Locality.Trim();
  628. if (!string.IsNullOrWhiteSpace(location.LocationType))
  629. formFields[$"filters[locations][{locationMode}][location_type]"] = location.LocationType.Trim();
  630. }
  631. private void ValidateCredentials()
  632. {
  633. if (string.IsNullOrWhiteSpace(_options.ClientId))
  634. throw new InvalidOperationException("Snovio:ClientId 未配置。");
  635. if (string.IsNullOrWhiteSpace(_options.ClientSecret))
  636. throw new InvalidOperationException("Snovio:ClientSecret 未配置。");
  637. }
  638. private static string NormalizeBaseUrl(string baseUrl)
  639. {
  640. if (string.IsNullOrWhiteSpace(baseUrl))
  641. return "https://api.snov.io/";
  642. return baseUrl.TrimEnd('/') + "/";
  643. }
  644. }