1
0

2 Commitit 4bbc806a23 ... 0dac755a52

Tekijä SHA1 Viesti Päivämäärä
  Lyyyi 0dac755a52 Merge branch 'develop' of http://132.232.92.186:3000/jiangjc/OASystemAPI into develop 3 päivää sitten
  Lyyyi 7063db8857 添加查询团组房型+航班舱位的API支持。 3 päivää sitten

+ 125 - 0
OASystem/OASystem.Api/Controllers/GroupsController.cs

@@ -31500,6 +31500,131 @@ AirHotelPrice
             public int Count { get; set; }
         }
 
+        /// <summary>
+        /// 查询团组成本中机票舱位与房型的匹配组合、价格及人数
+        /// </summary>
+        /// <param name="dto">团组ID</param>
+        /// <returns>机票舱位与房型匹配结果</returns>
+        [HttpPost]
+        public async Task<IActionResult> GroupCostAirHotelMatch(GroupCostAirHotelMatchDto dto)
+        {
+            if (dto == null || dto.Diid <= 0)
+            {
+                return Ok(JsonView(false, "请传入有效的团组ID!"));
+            }
+
+            var di = await _sqlSugar.Queryable<Grp_DelegationInfo>()
+                .FirstAsync(x => x.Id == dto.Diid && x.IsDel == 0);
+            if (di == null)
+            {
+                return Ok(JsonView(false, "团组不存在!"));
+            }
+
+            var mainPrice = await _sqlSugar.Queryable<Grp_GroupCostParameter>()
+                .FirstAsync(x => x.DiId == dto.Diid && x.CostType == "A" && x.IsDel == 0);
+            if (mainPrice == null || mainPrice.IsShare == 0)
+            {
+                return Ok(JsonView(false, "团组成本不存在!"));
+            }
+
+            var costTypeHotelNumbers = await _sqlSugar.Queryable<Grp_CostTypeHotelNumber>()
+                .FirstAsync(x => x.Type == "Default" && x.Diid == dto.Diid && x.IsDel == 0);
+            if (costTypeHotelNumbers == null)
+            {
+                return Ok(JsonView(false, "团组成本房型人数不存在!"));
+            }
+
+            var hotelRooms = new Dictionary<string, int>
+            {
+                { "SGR", Math.Max(costTypeHotelNumbers.SGR, 0) },
+                { "TBR", Math.Max(costTypeHotelNumbers.TBR, 0) },
+                { "JSES", Math.Max(costTypeHotelNumbers.JSES, 0) },
+                { "SUITE", Math.Max(costTypeHotelNumbers.SUITE, 0) }
+            };
+
+            var flights = new Dictionary<string, int>
+            {
+                { "经济舱", Math.Max(mainPrice.JJCRS, 0) },
+                { "公务舱", Math.Max(mainPrice.GWCRS, 0) },
+                { "头等舱", Math.Max(mainPrice.TDCRS, 0) }
+            };
+
+            var flightRoomMatchingRules = new Dictionary<string, string[]>
+            {
+                { "经济舱", new[] { "TBR", "SGR", "JSES", "SUITE" } },
+                { "公务舱", new[] { "SUITE", "JSES", "SGR", "TBR" } },
+                { "头等舱", new[] { "SUITE", "JSES" } }
+            };
+
+            var matchingResult = new Dictionary<string, int>();
+            foreach (var flight in flights.Keys)
+            {
+                MatchRooms(ref hotelRooms, ref flights, flightRoomMatchingRules[flight], flight, ref matchingResult);
+            }
+
+            var otherSinglePrice =
+                mainPrice.VisaCB * mainPrice.VisaXS +
+                mainPrice.BXCB * mainPrice.BXXS +
+                mainPrice.HCPCB * mainPrice.HCPXS +
+                mainPrice.CPCB * mainPrice.CPXS +
+                mainPrice.HSCB * mainPrice.HSXS +
+                mainPrice.DJCB * mainPrice.DJXS +
+                mainPrice.GWCB * mainPrice.GWXS +
+                mainPrice.LYJCB * mainPrice.LYJXS;
+
+            var itemDefinitions = new[]
+            {
+                (CabinType: "经济舱", RoomType: "TBR", RoomTypeName: "双人间", AirSinglePrice: mainPrice.JJCCB * mainPrice.JJCXS, RoomSinglePrice: mainPrice.TBRCB * mainPrice.TBRXS / 2),
+                (CabinType: "经济舱", RoomType: "SGR", RoomTypeName: "单人间", AirSinglePrice: mainPrice.JJCCB * mainPrice.JJCXS, RoomSinglePrice: mainPrice.SGRCB * mainPrice.SGRXS),
+                (CabinType: "经济舱", RoomType: "JSES", RoomTypeName: "小套房", AirSinglePrice: mainPrice.JJCCB * mainPrice.JJCXS, RoomSinglePrice: mainPrice.JSESCB * mainPrice.JSESXS),
+                (CabinType: "经济舱", RoomType: "SUITE", RoomTypeName: "大套房", AirSinglePrice: mainPrice.JJCCB * mainPrice.JJCXS, RoomSinglePrice: mainPrice.SUITECB * mainPrice.SUITEXS),
+                (CabinType: "公务舱", RoomType: "SUITE", RoomTypeName: "大套房", AirSinglePrice: mainPrice.GWCCB * mainPrice.GWCXS, RoomSinglePrice: mainPrice.SUITECB * mainPrice.SUITEXS),
+                (CabinType: "公务舱", RoomType: "JSES", RoomTypeName: "小套房", AirSinglePrice: mainPrice.GWCCB * mainPrice.GWCXS, RoomSinglePrice: mainPrice.JSESCB * mainPrice.JSESXS),
+                (CabinType: "公务舱", RoomType: "SGR", RoomTypeName: "单人间", AirSinglePrice: mainPrice.GWCCB * mainPrice.GWCXS, RoomSinglePrice: mainPrice.SGRCB * mainPrice.SGRXS),
+                (CabinType: "公务舱", RoomType: "TBR", RoomTypeName: "双人间", AirSinglePrice: mainPrice.GWCCB * mainPrice.GWCXS, RoomSinglePrice: mainPrice.TBRCB * mainPrice.TBRXS / 2),
+                (CabinType: "头等舱", RoomType: "SUITE", RoomTypeName: "大套房", AirSinglePrice: mainPrice.TDCCB * mainPrice.TDCXS, RoomSinglePrice: mainPrice.SUITECB * mainPrice.SUITEXS),
+                (CabinType: "头等舱", RoomType: "JSES", RoomTypeName: "小套房", AirSinglePrice: mainPrice.TDCCB * mainPrice.TDCXS, RoomSinglePrice: mainPrice.JSESCB * mainPrice.JSESXS)
+            };
+
+            var items = new List<GroupCostAirHotelMatchItemView>();
+            foreach (var definition in itemDefinitions)
+            {
+                var key = definition.CabinType + definition.RoomType;
+                if (!matchingResult.TryGetValue(key, out var roomCount) || roomCount <= 0)
+                {
+                    continue;
+                }
+
+                var passengerCount = definition.RoomType == "TBR" ? roomCount * 2 : roomCount;
+                var singlePrice = definition.AirSinglePrice + definition.RoomSinglePrice + otherSinglePrice;
+                items.Add(new GroupCostAirHotelMatchItemView
+                {
+                    CombinationName = definition.CabinType + "+" + definition.RoomType,
+                    CabinType = definition.CabinType,
+                    RoomType = definition.RoomType,
+                    RoomTypeName = definition.RoomTypeName,
+                    PassengerCount = passengerCount,
+                    RoomCount = roomCount,
+                    AirSinglePrice = definition.AirSinglePrice,
+                    RoomSinglePrice = definition.RoomSinglePrice,
+                    OtherSinglePrice = otherSinglePrice,
+                    SinglePrice = singlePrice,
+                    TotalPrice = singlePrice * passengerCount
+                });
+            }
+
+            var data = new GroupCostAirHotelMatchView
+            {
+                Diid = dto.Diid,
+                Currency = mainPrice.Currency,
+                Items = items,
+                TotalPassengerCount = items.Sum(x => x.PassengerCount),
+                TotalPrice = items.Sum(x => x.TotalPrice)
+            };
+
+            return Ok(JsonView(true, items.Count > 0 ? "操作成功!" : "暂无可匹配的机票舱位+房型组合!", data, items.Count));
+        }
+
         /// <summary>
         /// 团组系数设置
         /// </summary>

+ 250 - 1
OASystem/OASystem.Api/Controllers/ResourceController.cs

@@ -16,6 +16,7 @@ using OASystem.API.OAMethodLib.Hotmail;
 using OASystem.API.OAMethodLib.HunYuanAPI;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI.AppNotice;
+using OASystem.API.OAMethodLib.SnovioAPI;
 using OASystem.API.OAMethodLib.TokenHubAI;
 using OASystem.API.OAMethodLib.TokenHubAI.Models;
 using OASystem.Domain.AesEncryption;
@@ -78,6 +79,7 @@ namespace OASystem.API.Controllers
         private readonly MaterialCostRepository _materialCostRep;
         private readonly HotmailService _hotmailService;
         private readonly ITokenHubService _tokenHubService;
+        private readonly ISnovioService _snovioService;
         /// <summary>
         /// 签证费用归属省份静态数据
         /// </summary>
@@ -117,7 +119,8 @@ namespace OASystem.API.Controllers
             GamesBudgetMasterRepository gamesBudgetMasterRep,
             OverseaVehicleRepository overseaVehicleRep,
             MaterialCostRepository materialCostRep,
-            ITokenHubService tokenHubService
+            ITokenHubService tokenHubService,
+            ISnovioService snovioService
             )
         {
             _mapper = mapper;
@@ -149,6 +152,7 @@ namespace OASystem.API.Controllers
             _overseaVehicleRep = overseaVehicleRep;
             _materialCostRep = materialCostRep;
             _tokenHubService = tokenHubService;
+            _snovioService = snovioService;
         }
 
         #region 车公司资料板块
@@ -9721,6 +9725,251 @@ WHERE
         }
 
         #endregion
+
+        #region Snovio 第三方检索
+
+        /// <summary>
+        /// 创建 Snov.io 公司数据库搜索任务,并等待返回实际结果。
+        /// </summary>
+        /// <param name="request">公司名称、行业、地区等搜索条件。</param>
+        /// <param name="timeoutSeconds">最大等待秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="pollIntervalSeconds">结果轮询间隔秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为公司搜索结果。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioSearchCompanies(
+            [FromBody] SnovioCompanySearchRequest request,
+            [FromQuery] int? timeoutSeconds = null,
+            [FromQuery] int? pollIntervalSeconds = null,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.SearchCompaniesAsync(
+                    request,
+                    ToSnovioDuration(timeoutSeconds, nameof(timeoutSeconds)),
+                    ToSnovioDuration(pollIntervalSeconds, nameof(pollIntervalSeconds)),
+                    cancellationToken),
+                "Snovio 公司搜索");
+        }
+
+        /// <summary>
+        /// 创建 Snov.io 公司数据库搜索任务,仅返回 task_hash,不等待最终结果。
+        /// </summary>
+        /// <param name="request">公司名称、行业、地区等搜索条件。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为包含 task_hash 和结果地址的任务响应。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioStartCompanySearch(
+            [FromBody] SnovioCompanySearchRequest request,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.StartCompanySearchAsync(request, cancellationToken),
+                "Snovio 公司搜索任务创建");
+        }
+
+        /// <summary>
+        /// 获取 Snov.io 公司数据库搜索任务当前结果。
+        /// </summary>
+        /// <param name="taskHash">创建任务时返回的 task_hash。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为公司搜索结果;任务处理中时返回 in_progress 状态。</returns>
+        [HttpGet]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioGetCompanySearchResult(
+            [FromQuery] string taskHash,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.GetCompanySearchResultAsync(taskHash, cancellationToken),
+                "Snovio 公司搜索结果获取");
+        }
+
+        /// <summary>
+        /// 创建 Snov.io 域名潜在客户搜索任务,并等待返回实际结果。
+        /// </summary>
+        /// <param name="request">域名、职位和分页等搜索条件。</param>
+        /// <param name="timeoutSeconds">最大等待秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="pollIntervalSeconds">结果轮询间隔秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为域名潜在客户搜索结果。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioSearchDomainProspects(
+            [FromBody] SnovioDomainProspectsSearchRequest request,
+            [FromQuery] int? timeoutSeconds = null,
+            [FromQuery] int? pollIntervalSeconds = null,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.SearchDomainProspectsAsync(
+                    request,
+                    ToSnovioDuration(timeoutSeconds, nameof(timeoutSeconds)),
+                    ToSnovioDuration(pollIntervalSeconds, nameof(pollIntervalSeconds)),
+                    cancellationToken),
+                "Snovio 域名潜在客户搜索");
+        }
+
+        /// <summary>
+        /// 创建 Snov.io 域名潜在客户搜索任务,仅返回 task_hash,不等待最终结果。
+        /// </summary>
+        /// <param name="request">域名、职位和分页等搜索条件。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为包含 task_hash 和结果地址的任务响应。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioStartDomainProspectsSearch(
+            [FromBody] SnovioDomainProspectsSearchRequest request,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.StartDomainProspectsSearchAsync(request, cancellationToken),
+                "Snovio 域名潜在客户搜索任务创建");
+        }
+
+        /// <summary>
+        /// 获取 Snov.io 域名潜在客户搜索任务当前结果。
+        /// </summary>
+        /// <param name="taskHash">创建任务时返回的 task_hash。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为潜在客户列表;任务处理中时返回 in_progress 状态。</returns>
+        [HttpGet]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioGetDomainProspectsSearchResult(
+            [FromQuery] string taskHash,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.GetDomainProspectsSearchResultAsync(taskHash, cancellationToken),
+                "Snovio 域名潜在客户搜索结果获取");
+        }
+
+        /// <summary>
+        /// 创建指定潜客的 Snov.io 邮箱检索任务,并等待返回实际邮箱结果。
+        /// </summary>
+        /// <param name="prospectHash">域名潜在客户搜索结果中返回的 prospect_hash。</param>
+        /// <param name="request">可选的邮箱检索请求体,目前支持 webhook_url。</param>
+        /// <param name="timeoutSeconds">最大等待秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="pollIntervalSeconds">结果轮询间隔秒数,不传时由 Snovio 服务使用默认值。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为邮箱列表及其 SMTP 状态。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioSearchProspectEmail(
+            [FromQuery] string prospectHash,
+            [FromBody] SnovioProspectEmailSearchRequest? request,
+            [FromQuery] int? timeoutSeconds = null,
+            [FromQuery] int? pollIntervalSeconds = null,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.SearchProspectEmailAsync(
+                    prospectHash,
+                    request,
+                    ToSnovioDuration(timeoutSeconds, nameof(timeoutSeconds)),
+                    ToSnovioDuration(pollIntervalSeconds, nameof(pollIntervalSeconds)),
+                    cancellationToken),
+                "Snovio 潜客邮箱检索");
+        }
+
+        /// <summary>
+        /// 创建指定潜客的 Snov.io 邮箱检索任务,仅返回 task_hash,不等待最终结果。
+        /// </summary>
+        /// <param name="prospectHash">域名潜在客户搜索结果中返回的 prospect_hash。</param>
+        /// <param name="request">可选的邮箱检索请求体,目前支持 webhook_url。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为包含 task_hash 和结果地址的任务响应。</returns>
+        [HttpPost]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioStartProspectEmailSearch(
+            [FromQuery] string prospectHash,
+            [FromBody] SnovioProspectEmailSearchRequest? request,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.StartProspectEmailSearchAsync(
+                    prospectHash,
+                    request,
+                    cancellationToken),
+                "Snovio 潜客邮箱检索任务创建");
+        }
+
+        /// <summary>
+        /// 获取 Snov.io 潜客邮箱检索任务当前结果。
+        /// </summary>
+        /// <param name="taskHash">邮箱检索任务创建时返回的 task_hash。</param>
+        /// <param name="cancellationToken">取消令牌。</param>
+        /// <returns>统一响应体,Data 为邮箱及 SMTP 状态;任务处理中时返回 in_progress 状态。</returns>
+        [HttpGet]
+        [ProducesResponseType(typeof(JsonView), StatusCodes.Status200OK)]
+        public async Task<IActionResult> SnovioGetProspectEmailSearchResult(
+            [FromQuery] string taskHash,
+            CancellationToken cancellationToken = default)
+        {
+            return await ExecuteSnovioAsync(
+                () => _snovioService.GetProspectEmailSearchResultAsync(taskHash, cancellationToken),
+                "Snovio 潜客邮箱检索结果获取");
+        }
+
+        /// <summary>
+        /// 将秒数转换为 Snovio 服务使用的时间间隔。
+        /// </summary>
+        private static TimeSpan? ToSnovioDuration(int? seconds, string parameterName)
+        {
+            if (!seconds.HasValue)
+                return null;
+
+            if (seconds.Value <= 0)
+                throw new ArgumentOutOfRangeException(parameterName, "时间参数必须大于 0 秒。");
+
+            return TimeSpan.FromSeconds(seconds.Value);
+        }
+
+        /// <summary>
+        /// 统一处理 Snovio 服务调用结果和异常。
+        /// </summary>
+        private async Task<IActionResult> ExecuteSnovioAsync<T>(
+            Func<Task<T>> operation,
+            string operationName)
+        {
+            try
+            {
+                var result = await operation();
+                return Ok(JsonView(true, $"{operationName}成功", result));
+            }
+            catch (OperationCanceledException)
+            {
+                throw;
+            }
+            catch (ArgumentException ex)
+            {
+                return Ok(JsonView(false, ex.Message));
+            }
+            catch (TimeoutException ex)
+            {
+                _logger.LogWarning(ex, "{OperationName}超时", operationName);
+                return Ok(JsonView(false, ex.Message));
+            }
+            catch (HttpRequestException ex)
+            {
+                _logger.LogError(ex, "{OperationName}请求失败", operationName);
+                return Ok(JsonView(false, $"{operationName}请求失败:{ex.Message}"));
+            }
+            catch (InvalidOperationException ex)
+            {
+                _logger.LogError(ex, "{OperationName}返回结果无效或任务失败", operationName);
+                return Ok(JsonView(false, ex.Message));
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "{OperationName}发生未知异常", operationName);
+                return Ok(JsonView(false, $"{operationName}失败,请稍后重试。"));
+            }
+        }
+
+        #endregion
     }
 }
 

+ 169 - 0
OASystem/OASystem.Api/OAMethodLib/SnovioAPI/ISnovioService.cs

@@ -0,0 +1,169 @@
+namespace OASystem.API.OAMethodLib.SnovioAPI;
+
+/// <summary>
+/// Snov.io API 服务。
+/// </summary>
+public interface ISnovioService
+{
+    /// <summary>
+    /// 使用配置中的 Client ID 和 Client Secret 获取 OAuth 访问令牌。
+    /// </summary>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>Snov.io OAuth 访问令牌响应。</returns>
+    Task<SnovioAccessTokenResponse> GetAccessTokenAsync(
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建 Snov.io 公司数据库搜索任务。
+    /// </summary>
+    /// <param name="request">公司搜索请求体。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>包含 task_hash 和结果地址的异步任务响应。</returns>
+    Task<SnovioCompanySearchStartResponse> StartCompanySearchAsync(
+        SnovioCompanySearchRequest request,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 根据 task_hash 获取公司搜索任务当前结果。
+    /// </summary>
+    /// <param name="taskHash">创建公司搜索任务时返回的 task_hash。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>公司搜索结果响应;任务未完成时可返回处理中状态。</returns>
+    Task<SnovioCompanySearchResultResponse> GetCompanySearchResultAsync(
+        string taskHash,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 按指定间隔轮询公司搜索任务,直到任务完成、失败或超时。
+    /// </summary>
+    /// <param name="taskHash">创建公司搜索任务时返回的 task_hash。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的公司搜索结果响应。</returns>
+    /// <exception cref="TimeoutException">任务在最大等待时间内未完成时抛出。</exception>
+    /// <exception cref="InvalidOperationException">Snov.io 返回任务失败状态时抛出。</exception>
+    Task<SnovioCompanySearchResultResponse> WaitForCompanySearchResultAsync(
+        string taskHash,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建公司搜索任务并轮询等待实际结果。
+    /// </summary>
+    /// <param name="request">公司搜索条件和分页请求体。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的公司搜索结果响应。</returns>
+    Task<SnovioCompanySearchResultResponse> SearchCompaniesAsync(
+        SnovioCompanySearchRequest request,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建 Snov.io 域名潜在客户搜索任务。
+    /// </summary>
+    /// <param name="request">域名潜在客户搜索请求体。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>包含 task_hash 和结果地址的异步任务响应。</returns>
+    Task<SnovioDomainProspectsSearchStartResponse> StartDomainProspectsSearchAsync(
+        SnovioDomainProspectsSearchRequest request,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 根据 task_hash 获取域名潜在客户搜索任务当前结果。
+    /// </summary>
+    /// <param name="taskHash">创建域名潜在客户搜索任务时返回的 task_hash。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>域名潜在客户搜索结果响应;任务未完成时可返回处理中状态。</returns>
+    Task<SnovioDomainProspectsSearchResultResponse> GetDomainProspectsSearchResultAsync(
+        string taskHash,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 按指定间隔轮询域名潜在客户搜索任务,直到任务完成、失败或超时。
+    /// </summary>
+    /// <param name="taskHash">创建域名潜在客户搜索任务时返回的 task_hash。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的域名潜在客户搜索结果响应。</returns>
+    /// <exception cref="TimeoutException">任务在最大等待时间内未完成时抛出。</exception>
+    /// <exception cref="InvalidOperationException">Snov.io 返回任务失败状态时抛出。</exception>
+    Task<SnovioDomainProspectsSearchResultResponse> WaitForDomainProspectsSearchResultAsync(
+        string taskHash,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建域名潜在客户搜索任务并轮询等待实际结果。
+    /// </summary>
+    /// <param name="request">域名、职位筛选和分页请求体。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的域名潜在客户搜索结果响应。</returns>
+    Task<SnovioDomainProspectsSearchResultResponse> SearchDomainProspectsAsync(
+        SnovioDomainProspectsSearchRequest request,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建指定潜客的邮箱检索任务。
+    /// </summary>
+    /// <param name="prospectHash">域名潜客搜索结果中返回的 prospect_hash,用于定位单个潜客。</param>
+    /// <param name="request">邮箱检索请求体,可选,目前支持 webhook_url 回调地址。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>包含邮箱检索 task_hash 和结果地址的异步任务响应。</returns>
+    Task<SnovioProspectEmailSearchStartResponse> StartProspectEmailSearchAsync(
+        string prospectHash,
+        SnovioProspectEmailSearchRequest? request = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 根据 task_hash 获取潜客邮箱检索任务当前结果。
+    /// </summary>
+    /// <param name="taskHash">邮箱检索任务的 task_hash。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>潜客邮箱检索结果响应;任务未完成时可返回处理中状态。</returns>
+    Task<SnovioProspectEmailSearchResultResponse> GetProspectEmailSearchResultAsync(
+        string taskHash,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 按指定间隔轮询潜客邮箱检索任务,直到任务完成、失败或超时。
+    /// </summary>
+    /// <param name="taskHash">邮箱检索任务的 task_hash。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的潜客邮箱检索结果响应。</returns>
+    /// <exception cref="TimeoutException">任务在最大等待时间内未完成时抛出。</exception>
+    /// <exception cref="InvalidOperationException">Snov.io 返回任务失败状态时抛出。</exception>
+    Task<SnovioProspectEmailSearchResultResponse> WaitForProspectEmailSearchResultAsync(
+        string taskHash,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    /// 创建潜客邮箱检索任务并轮询等待实际邮箱结果。
+    /// </summary>
+    /// <param name="prospectHash">域名潜客搜索结果中返回的 prospect_hash,用于定位单个潜客。</param>
+    /// <param name="request">邮箱检索请求体,可选,目前支持 webhook_url 回调地址。</param>
+    /// <param name="timeout">最大等待时间,未指定时默认 2 分钟。</param>
+    /// <param name="pollInterval">两次结果查询之间的间隔,未指定时默认 3 秒。</param>
+    /// <param name="cancellationToken">取消令牌。</param>
+    /// <returns>任务完成后的潜客邮箱检索结果响应。</returns>
+    Task<SnovioProspectEmailSearchResultResponse> SearchProspectEmailAsync(
+        string prospectHash,
+        SnovioProspectEmailSearchRequest? request = null,
+        TimeSpan? timeout = null,
+        TimeSpan? pollInterval = null,
+        CancellationToken cancellationToken = default);
+}

+ 845 - 0
OASystem/OASystem.Api/OAMethodLib/SnovioAPI/SnovioModels.cs

@@ -0,0 +1,845 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OASystem.API.OAMethodLib.SnovioAPI;
+
+/// <summary>
+/// Snov.io OAuth access_token 请求体。
+/// </summary>
+public sealed class SnovioAccessTokenRequest
+{
+    /// <summary>
+    /// OAuth 授权类型,固定为 client_credentials。
+    /// </summary>
+    [JsonPropertyName("grant_type")]
+    public string GrantType { get; set; } = "client_credentials";
+
+    /// <summary>
+    /// Snov.io Client ID。
+    /// </summary>
+    [JsonPropertyName("client_id")]
+    public string ClientId { get; set; } = string.Empty;
+
+    /// <summary>
+    /// Snov.io Client Secret。
+    /// </summary>
+    [JsonPropertyName("client_secret")]
+    public string ClientSecret { get; set; } = string.Empty;
+}
+
+/// <summary>
+/// Snov.io OAuth access_token 响应体。
+/// </summary>
+public sealed class SnovioAccessTokenResponse
+{
+    /// <summary>
+    /// 访问令牌。
+    /// </summary>
+    [JsonPropertyName("access_token")]
+    public string AccessToken { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 令牌类型,通常为 Bearer。
+    /// </summary>
+    [JsonPropertyName("token_type")]
+    public string TokenType { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 令牌有效期,单位为秒。
+    /// </summary>
+    [JsonPropertyName("expires_in")]
+    public int ExpiresIn { get; set; }
+}
+
+/// <summary>
+/// Snov.io 公司数据库搜索请求体。
+/// </summary>
+public sealed class SnovioCompanySearchRequest
+{
+    /// <summary>
+    /// 结果页码,默认从第 1 页开始。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; } = 1;
+
+    /// <summary>
+    /// 可选的异步结果回调地址。
+    /// </summary>
+    [JsonPropertyName("webhook_url")]
+    public string? WebhookUrl { get; set; }
+
+    /// <summary>
+    /// 公司搜索筛选条件。
+    /// </summary>
+    [JsonPropertyName("filters")]
+    public SnovioCompanySearchFilters Filters { get; set; } = new();
+}
+
+/// <summary>
+/// Snov.io 公司数据库搜索筛选条件。
+/// </summary>
+public sealed class SnovioCompanySearchFilters
+{
+    /// <summary>
+    /// 公司名称、行业、规模、营收和成立年份等筛选条件。
+    /// </summary>
+    [JsonPropertyName("company")]
+    public SnovioCompanyFilter Company { get; set; } = new();
+
+    /// <summary>
+    /// 公司所在地区的包含和排除条件。
+    /// </summary>
+    [JsonPropertyName("locations")]
+    public SnovioLocationFilter Locations { get; set; } = new();
+}
+
+/// <summary>
+/// 公司筛选条件。
+/// </summary>
+public sealed class SnovioCompanyFilter
+{
+    /// <summary>
+    /// 公司名称包含或排除列表。
+    /// </summary>
+    [JsonPropertyName("name")]
+    public SnovioStringListFilter? Name { get; set; }
+
+    /// <summary>
+    /// 公司行业包含或排除列表。
+    /// </summary>
+    [JsonPropertyName("industries")]
+    public SnovioStringListFilter? Industries { get; set; }
+
+    /// <summary>
+    /// 公司规模,例如 1-10、51-200、10001+。
+    /// </summary>
+    [JsonPropertyName("size")]
+    public string? Size { get; set; }
+
+    /// <summary>
+    /// 公司营收最小值和最大值,单位由 Snov.io 数据源定义。
+    /// </summary>
+    [JsonPropertyName("revenue")]
+    public SnovioRevenueFilter? Revenue { get; set; }
+
+    /// <summary>
+    /// 公司专长或业务领域列表。
+    /// </summary>
+    [JsonPropertyName("specialities")]
+    public List<string> Specialities { get; set; } = new();
+
+    /// <summary>
+    /// 公司成立年份范围。
+    /// </summary>
+    [JsonPropertyName("founded")]
+    public SnovioFoundedFilter? Founded { get; set; }
+}
+
+/// <summary>
+/// 字符串列表包含/排除条件。
+/// </summary>
+public sealed class SnovioStringListFilter
+{
+    /// <summary>
+    /// 需要匹配的字符串列表。
+    /// </summary>
+    [JsonPropertyName("include")]
+    public List<string> Include { get; set; } = new();
+
+    /// <summary>
+    /// 需要排除的字符串列表。
+    /// </summary>
+    [JsonPropertyName("exclude")]
+    public List<string> Exclude { get; set; } = new();
+}
+
+/// <summary>
+/// 地区包含/排除条件。
+/// </summary>
+public sealed class SnovioLocationFilter
+{
+    /// <summary>
+    /// 需要包含的地区条件。
+    /// </summary>
+    [JsonPropertyName("include")]
+    public SnovioLocationFilterItem? Include { get; set; }
+
+    /// <summary>
+    /// 需要排除的地区条件。
+    /// </summary>
+    [JsonPropertyName("exclude")]
+    public SnovioLocationFilterItem? Exclude { get; set; }
+}
+
+/// <summary>
+/// 地区条件明细。
+/// </summary>
+public sealed class SnovioLocationFilterItem
+{
+    /// <summary>
+    /// 地区名称,例如 London 或 Atlanta。
+    /// </summary>
+    [JsonPropertyName("locality")]
+    public string? Locality { get; set; }
+
+    /// <summary>
+    /// 地区类型,例如 city、state、country、region、subregion。
+    /// </summary>
+    [JsonPropertyName("location_type")]
+    public string? LocationType { get; set; }
+}
+
+/// <summary>
+/// 公司营收范围。
+/// </summary>
+public sealed class SnovioRevenueFilter
+{
+    /// <summary>
+    /// 营收下限。
+    /// </summary>
+    [JsonPropertyName("min")]
+    public long? Min { get; set; }
+
+    /// <summary>
+    /// 营收上限。
+    /// </summary>
+    [JsonPropertyName("max")]
+    public long? Max { get; set; }
+}
+
+/// <summary>
+/// 公司成立年份范围。
+/// </summary>
+public sealed class SnovioFoundedFilter
+{
+    /// <summary>
+    /// 成立年份下限。
+    /// </summary>
+    [JsonPropertyName("from")]
+    public int? From { get; set; }
+
+    /// <summary>
+    /// 成立年份上限。
+    /// </summary>
+    [JsonPropertyName("till")]
+    public int? Till { get; set; }
+}
+
+/// <summary>
+/// Snov.io 公司搜索创建任务响应体。
+/// </summary>
+public sealed class SnovioCompanySearchStartResponse
+{
+    /// <summary>
+    /// 创建任务时通常为空数组。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public List<JsonElement> Data { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务的元数据,包含 task_hash 和页码。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioCompanySearchStartMeta Meta { get; set; } = new();
+
+    /// <summary>
+    /// Snov.io 返回的结果查询地址。
+    /// </summary>
+    [JsonPropertyName("links")]
+    public SnovioApiLinks Links { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务响应状态。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// Snov.io 公司搜索创建任务元数据。
+/// </summary>
+public sealed class SnovioCompanySearchStartMeta
+{
+    /// <summary>
+    /// 异步公司搜索任务唯一标识,用于查询结果。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+
+    /// <summary>
+    /// 本次搜索请求的结果页码。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; }
+}
+
+/// <summary>
+/// Snov.io 公司搜索结果响应体。
+/// </summary>
+public sealed class SnovioCompanySearchResultResponse
+{
+    /// <summary>
+    /// 公司搜索结果数据。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public SnovioCompanySearchResultData Data { get; set; } = new();
+
+    /// <summary>
+    /// 公司搜索任务元数据。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioCompanySearchResultMeta Meta { get; set; } = new();
+
+    /// <summary>
+    /// 文档示例中该字段为空数组。
+    /// </summary>
+    [JsonPropertyName("links")]
+    public List<JsonElement> Links { get; set; } = new();
+
+    /// <summary>
+    /// 任务处理状态,例如 completed 或 in_progress。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// 公司搜索结果数据。
+/// </summary>
+public sealed class SnovioCompanySearchResultData
+{
+    /// <summary>
+    /// 符合筛选条件的公司总数。
+    /// </summary>
+    [JsonPropertyName("total")]
+    public long Total { get; set; }
+
+    /// <summary>
+    /// 当前结果页码。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; }
+
+    /// <summary>
+    /// 结果总页数。
+    /// </summary>
+    [JsonPropertyName("total_pages")]
+    public int TotalPages { get; set; }
+
+    /// <summary>
+    /// 当前页的公司列表。
+    /// </summary>
+    [JsonPropertyName("companies")]
+    public List<SnovioCompanySearchCompany> Companies { get; set; } = new();
+}
+
+/// <summary>
+/// Snov.io 公司搜索结果中的公司信息。
+/// </summary>
+public sealed class SnovioCompanySearchCompany
+{
+    /// <summary>
+    /// 公司名称。
+    /// </summary>
+    [JsonPropertyName("name")]
+    public string? Name { get; set; }
+
+    /// <summary>
+    /// 公司域名。
+    /// </summary>
+    [JsonPropertyName("domain")]
+    public string? Domain { get; set; }
+
+    /// <summary>
+    /// 公司所在地区。
+    /// </summary>
+    [JsonPropertyName("location")]
+    public string? Location { get; set; }
+
+    /// <summary>
+    /// 公司所属行业。
+    /// </summary>
+    [JsonPropertyName("industry")]
+    public string? Industry { get; set; }
+
+    /// <summary>
+    /// 公司规模。
+    /// </summary>
+    [JsonPropertyName("size")]
+    public string? Size { get; set; }
+
+    /// <summary>
+    /// 公司营收信息。
+    /// </summary>
+    [JsonPropertyName("revenue")]
+    public SnovioCompanyRevenue? Revenue { get; set; }
+
+    /// <summary>
+    /// 公司 Logo 地址。
+    /// </summary>
+    [JsonPropertyName("logo_url")]
+    public string? LogoUrl { get; set; }
+}
+
+/// <summary>
+/// 公司搜索结果中的公司营收信息。
+/// </summary>
+public sealed class SnovioCompanyRevenue
+{
+    /// <summary>
+    /// 营收区间下限。
+    /// </summary>
+    [JsonPropertyName("min")]
+    public long? Min { get; set; }
+
+    /// <summary>
+    /// 营收区间上限。
+    /// </summary>
+    [JsonPropertyName("max")]
+    public long? Max { get; set; }
+
+    /// <summary>
+    /// Snov.io 返回的营收值。
+    /// </summary>
+    [JsonPropertyName("reported")]
+    public long? Reported { get; set; }
+}
+
+/// <summary>
+/// 公司搜索结果元数据。
+/// </summary>
+public sealed class SnovioCompanySearchResultMeta
+{
+    /// <summary>
+    /// 异步公司搜索任务唯一标识。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+
+    /// <summary>
+    /// 当前结果页码。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; }
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索请求体。
+/// </summary>
+public sealed class SnovioDomainProspectsSearchRequest
+{
+    /// <summary>
+    /// 要搜索的公司域名,例如 snov.io。
+    /// </summary>
+    [JsonPropertyName("domain")]
+    public string Domain { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 结果页码,默认从第 1 页开始。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; } = 1;
+
+    /// <summary>
+    /// 潜在客户职位列表,最多 10 个。
+    /// </summary>
+    [JsonPropertyName("positions")]
+    public List<string> Positions { get; set; } = new();
+
+    /// <summary>
+    /// 可选的异步结果回调地址。
+    /// </summary>
+    [JsonPropertyName("webhook_url")]
+    public string? WebhookUrl { get; set; }
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索创建任务响应体。
+/// </summary>
+public sealed class SnovioDomainProspectsSearchStartResponse
+{
+    /// <summary>
+    /// 创建任务时通常为空数组。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public List<JsonElement> Data { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务的元数据,包含域名、页码、职位和 task_hash。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioDomainProspectsSearchStartMeta Meta { get; set; } = new();
+
+    /// <summary>
+    /// Snov.io 返回的结果查询地址。
+    /// </summary>
+    [JsonPropertyName("links")]
+    public SnovioApiLinks Links { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务响应状态。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索创建任务元数据。
+/// </summary>
+public sealed class SnovioDomainProspectsSearchStartMeta
+{
+    /// <summary>
+    /// 本次搜索的公司域名。
+    /// </summary>
+    [JsonPropertyName("domain")]
+    public string? Domain { get; set; }
+
+    /// <summary>
+    /// 结果类型,潜客搜索通常为 prospects。
+    /// </summary>
+    [JsonPropertyName("tab")]
+    public string? Tab { get; set; }
+
+    /// <summary>
+    /// 异步域名潜客搜索任务唯一标识。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+
+    /// <summary>
+    /// 本次搜索请求的结果页码。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; }
+
+    /// <summary>
+    /// 用于筛选潜客的职位列表。
+    /// </summary>
+    [JsonPropertyName("positions")]
+    public List<string> Positions { get; set; } = new();
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索结果响应体。
+/// </summary>
+public sealed class SnovioDomainProspectsSearchResultResponse
+{
+    /// <summary>
+    /// 当前页的潜在客户列表。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public List<SnovioDomainProspect> Data { get; set; } = new();
+
+    /// <summary>
+    /// 域名潜在客户搜索结果元数据。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioDomainProspectsSearchResultMeta Meta { get; set; } = new();
+
+    /// <summary>
+    /// 下一页结果链接信息。
+    /// </summary>
+    [JsonPropertyName("links")]
+    public SnovioApiLinks Links { get; set; } = new();
+
+    /// <summary>
+    /// 任务处理状态,例如 completed 或 in_progress。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索结果中的潜客信息。
+/// </summary>
+public sealed class SnovioDomainProspect
+{
+    /// <summary>
+    /// 潜客名。
+    /// </summary>
+    [JsonPropertyName("first_name")]
+    public string? FirstName { get; set; }
+
+    /// <summary>
+    /// 潜客姓。
+    /// </summary>
+    [JsonPropertyName("last_name")]
+    public string? LastName { get; set; }
+
+    /// <summary>
+    /// 潜客职位。
+    /// </summary>
+    [JsonPropertyName("position")]
+    public string? Position { get; set; }
+
+    /// <summary>
+    /// Snov.io 获取该潜客信息的来源页面。
+    /// </summary>
+    [JsonPropertyName("source_page")]
+    public string? SourcePage { get; set; }
+
+    /// <summary>
+    /// 启动该潜客邮箱检索任务的地址,地址末段包含 prospect_hash。
+    /// </summary>
+    [JsonPropertyName("search_emails_start")]
+    public string? SearchEmailsStart { get; set; }
+}
+
+/// <summary>
+/// Snov.io 域名潜在客户搜索结果元数据。
+/// </summary>
+public sealed class SnovioDomainProspectsSearchResultMeta
+{
+    /// <summary>
+    /// 本次搜索的公司域名。
+    /// </summary>
+    [JsonPropertyName("domain")]
+    public string? Domain { get; set; }
+
+    /// <summary>
+    /// 结果类型,潜客搜索通常为 prospects。
+    /// </summary>
+    [JsonPropertyName("tab")]
+    public string? Tab { get; set; }
+
+    /// <summary>
+    /// 异步域名潜客搜索任务唯一标识。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+
+    /// <summary>
+    /// 当前结果页码。
+    /// </summary>
+    [JsonPropertyName("page")]
+    public int Page { get; set; }
+
+    /// <summary>
+    /// 本次搜索使用的职位筛选列表。
+    /// </summary>
+    [JsonPropertyName("positions")]
+    public List<string> Positions { get; set; } = new();
+
+    /// <summary>
+    /// 符合条件的潜客总数。
+    /// </summary>
+    [JsonPropertyName("total_count")]
+    public int TotalCount { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索请求体。
+/// </summary>
+public sealed class SnovioProspectEmailSearchRequest
+{
+    /// <summary>
+    /// 可选的异步任务回调地址。
+    /// </summary>
+    [JsonPropertyName("webhook_url")]
+    public string? WebhookUrl { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索创建任务响应体。
+/// </summary>
+public sealed class SnovioProspectEmailSearchStartResponse
+{
+    /// <summary>
+    /// 创建任务时通常为空数组。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public List<JsonElement> Data { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务的元数据,包含邮箱检索 task_hash。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioProspectEmailSearchStartMeta Meta { get; set; } = new();
+
+    /// <summary>
+    /// Snov.io 返回的邮箱结果查询地址。
+    /// </summary>
+    [JsonPropertyName("links")]
+    public SnovioApiLinks Links { get; set; } = new();
+
+    /// <summary>
+    /// 创建任务响应状态。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索创建任务元数据。
+/// </summary>
+public sealed class SnovioProspectEmailSearchStartMeta
+{
+    /// <summary>
+    /// 异步邮箱检索任务唯一标识,用于查询邮箱结果。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索结果响应体。
+/// </summary>
+public sealed class SnovioProspectEmailSearchResultResponse
+{
+    /// <summary>
+    /// 潜客邮箱检索结果数据。
+    /// </summary>
+    [JsonPropertyName("data")]
+    public SnovioProspectEmailSearchResultData Data { get; set; } = new();
+
+    /// <summary>
+    /// 邮箱检索任务元数据。
+    /// </summary>
+    [JsonPropertyName("meta")]
+    public SnovioProspectEmailSearchResultMeta Meta { get; set; } = new();
+
+    // 文档示例中该字段返回空数组,因此使用 JsonElement 列表兼容实际响应。
+    [JsonPropertyName("links")]
+    public List<JsonElement> Links { get; set; } = new();
+
+    /// <summary>
+    /// 任务处理状态,例如 completed 或 in_progress。
+    /// </summary>
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索结果数据。
+/// </summary>
+[System.Text.Json.Serialization.JsonConverter(typeof(SnovioProspectEmailSearchResultDataConverter))]
+public sealed class SnovioProspectEmailSearchResultData
+{
+    /// <summary>
+    /// 邮箱检索任务开始时间。
+    /// </summary>
+    [JsonPropertyName("searching_date")]
+    public string? SearchingDate { get; set; }
+
+    /// <summary>
+    /// 当前潜客检索到的邮箱列表。
+    /// </summary>
+    [JsonPropertyName("emails")]
+    public List<SnovioProspectEmail> Emails { get; set; } = new();
+}
+
+/// <summary>
+/// 兼容 Snov.io 潜客邮箱结果中 data 为对象或空数组的响应转换器。
+/// </summary>
+internal sealed class SnovioProspectEmailSearchResultDataConverter
+    : System.Text.Json.Serialization.JsonConverter<SnovioProspectEmailSearchResultData>
+{
+    public override SnovioProspectEmailSearchResultData Read(
+        ref Utf8JsonReader reader,
+        Type typeToConvert,
+        JsonSerializerOptions options)
+    {
+        if (reader.TokenType == JsonTokenType.Null)
+            return new SnovioProspectEmailSearchResultData();
+
+        if (reader.TokenType == JsonTokenType.StartObject)
+        {
+            using var document = JsonDocument.ParseValue(ref reader);
+            var root = document.RootElement;
+            var result = new SnovioProspectEmailSearchResultData();
+
+            if (root.TryGetProperty("searching_date", out var searchingDate) &&
+                searchingDate.ValueKind == JsonValueKind.String)
+            {
+                result.SearchingDate = searchingDate.GetString();
+            }
+
+            if (root.TryGetProperty("emails", out var emailsElement) &&
+                emailsElement.ValueKind == JsonValueKind.Array)
+            {
+                result.Emails = emailsElement.Deserialize<List<SnovioProspectEmail>>(options)
+                                ?? new List<SnovioProspectEmail>();
+            }
+
+            return result;
+        }
+
+        if (reader.TokenType == JsonTokenType.StartArray)
+        {
+            using var document = JsonDocument.ParseValue(ref reader);
+            var emails = document.RootElement.Deserialize<List<SnovioProspectEmail>>(options);
+
+            return new SnovioProspectEmailSearchResultData
+            {
+                Emails = emails ?? new List<SnovioProspectEmail>()
+            };
+        }
+
+        throw new System.Text.Json.JsonException(
+            "Snov.io 潜客邮箱检索响应中的 data 必须是对象、数组或 null。");
+    }
+
+    public override void Write(
+        Utf8JsonWriter writer,
+        SnovioProspectEmailSearchResultData value,
+        JsonSerializerOptions options)
+    {
+        writer.WriteStartObject();
+
+        if (value.SearchingDate != null)
+            writer.WriteString("searching_date", value.SearchingDate);
+
+        writer.WritePropertyName("emails");
+        System.Text.Json.JsonSerializer.Serialize(writer, value.Emails, options);
+
+        writer.WriteEndObject();
+    }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱信息。
+/// </summary>
+public sealed class SnovioProspectEmail
+{
+    /// <summary>
+    /// 潜客邮箱地址。
+    /// </summary>
+    [JsonPropertyName("email")]
+    public string? Email { get; set; }
+
+    /// <summary>
+    /// SMTP 校验状态,例如 valid 或 unknown。
+    /// </summary>
+    [JsonPropertyName("smtp_status")]
+    public string? SmtpStatus { get; set; }
+}
+
+/// <summary>
+/// Snov.io 潜客邮箱检索结果元数据。
+/// </summary>
+public sealed class SnovioProspectEmailSearchResultMeta
+{
+    /// <summary>
+    /// 异步邮箱检索任务唯一标识。
+    /// </summary>
+    [JsonPropertyName("task_hash")]
+    public string? TaskHash { get; set; }
+}
+
+/// <summary>
+/// Snov.io 异步任务结果链接。
+/// </summary>
+public sealed class SnovioApiLinks
+{
+    /// <summary>
+    /// 当前异步任务的结果查询地址。
+    /// </summary>
+    [JsonPropertyName("result")]
+    public string? Result { get; set; }
+
+    /// <summary>
+    /// 下一页结果地址;没有下一页时通常为空字符串或 null。
+    /// </summary>
+    [JsonPropertyName("next")]
+    public string? Next { get; set; }
+}

+ 32 - 0
OASystem/OASystem.Api/OAMethodLib/SnovioAPI/SnovioOptions.cs

@@ -0,0 +1,32 @@
+namespace OASystem.API.OAMethodLib.SnovioAPI;
+
+/// <summary>
+/// Snov.io API 配置。
+/// </summary>
+public sealed class SnovioOptions
+{
+    /// <summary>
+    /// appsettings.json 中的 Snovio 配置节名称。
+    /// </summary>
+    public const string SectionName = "Snovio";
+
+    /// <summary>
+    /// Snov.io API 根地址。
+    /// </summary>
+    public string BaseUrl { get; set; } = "https://api.snov.io/";
+
+    /// <summary>
+    /// 在 Snov.io 账户 API 设置中获取的 Client ID。
+    /// </summary>
+    public string ClientId { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 在 Snov.io 账户 API 设置中获取的 Client Secret。
+    /// </summary>
+    public string ClientSecret { get; set; } = string.Empty;
+
+    /// <summary>
+    /// OAuth 请求超时时间(秒)。
+    /// </summary>
+    public int TimeoutSeconds { get; set; } = 30;
+}

+ 777 - 0
OASystem/OASystem.Api/OAMethodLib/SnovioAPI/SnovioService.cs

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

+ 7 - 0
OASystem/OASystem.Api/Program.cs

@@ -15,6 +15,7 @@ using OASystem.API.OAMethodLib.JuHeAPI;
 using OASystem.API.OAMethodLib.MicrosoftGraphMailbox;
 using OASystem.API.OAMethodLib.QiYeWeChatAPI;
 using OASystem.API.OAMethodLib.Quartz.Jobs;
+using OASystem.API.OAMethodLib.SnovioAPI;
 using OASystem.API.OAMethodLib.SignalR.HubService;
 using OASystem.API.OAMethodLib.TokenHubAI;
 using OASystem.Infrastructure.Logging;
@@ -508,6 +509,12 @@ builder.Services.AddHttpClient("PublicQiYeWeChatApi", c => c.BaseAddress = new U
 builder.Services.AddTokenHubService(builder.Configuration);
 #endregion
 
+#region Snov.io API 服务
+builder.Services.Configure<SnovioOptions>(
+    builder.Configuration.GetSection(SnovioOptions.SectionName));
+builder.Services.AddHttpClient<ISnovioService, SnovioService>();
+#endregion
+
 #region 有道 API 服务
 //builder.Services.AddControllersWithViews();
 //builder.Services.AddSingleton<IYouDaoApiService, YouDaoApiService>();

+ 7 - 0
OASystem/OASystem.Api/appsettings.json

@@ -591,6 +591,13 @@
     ]
   },
   "AllowedHosts": "*",
+  // Snov.io API 配置。ClientId 和 ClientSecret 请通过环境变量或本地配置注入。
+  "Snovio": {
+    "BaseUrl": "https://api.snov.io/",
+    "ClientId": "9ed0d6b07b2aaf34bbf677355c033e82",
+    "ClientSecret": "f17ddff674b55cad830467f9403d9bf5",
+    "TimeoutSeconds": 30
+  },
   // 腾讯云 TokenHub 配置
   "TokenHub": {
     // apiKey - 雷怡

+ 11 - 0
OASystem/OASystem.Domain/Dtos/Groups/GroupCostSavaDto.cs

@@ -1386,6 +1386,17 @@ namespace OASystem.Domain.Dtos.Groups
         public int Diid { get; set; }
     }
 
+    /// <summary>
+    /// 查询团组成本中机票舱位与房型的匹配结果
+    /// </summary>
+    public class GroupCostAirHotelMatchDto
+    {
+        /// <summary>
+        /// 团组ID
+        /// </summary>
+        public int Diid { get; set; }
+    }
+
     public class QueryGroupCostCoefficientDto
     {
         public int Diid { get; set; }

+ 96 - 0
OASystem/OASystem.Domain/ViewModels/Groups/GroupCostAirHotelMatchView.cs

@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+
+namespace OASystem.Domain.ViewModels.Groups
+{
+    /// <summary>
+    /// 团组成本机票舱位与房型匹配结果
+    /// </summary>
+    public class GroupCostAirHotelMatchView
+    {
+        /// <summary>
+        /// 团组ID
+        /// </summary>
+        public int Diid { get; set; }
+
+        /// <summary>
+        /// 成本币种
+        /// </summary>
+        public string Currency { get; set; }
+
+        /// <summary>
+        /// 匹配组合
+        /// </summary>
+        public List<GroupCostAirHotelMatchItemView> Items { get; set; } = new();
+
+        /// <summary>
+        /// 匹配后的总人数
+        /// </summary>
+        public int TotalPassengerCount { get; set; }
+
+        /// <summary>
+        /// 匹配后的总价格
+        /// </summary>
+        public decimal TotalPrice { get; set; }
+    }
+
+    /// <summary>
+    /// 单个机票舱位与房型组合
+    /// </summary>
+    public class GroupCostAirHotelMatchItemView
+    {
+        /// <summary>
+        /// 组合名称,例如:经济舱+SGR
+        /// </summary>
+        public string CombinationName { get; set; }
+
+        /// <summary>
+        /// 舱位类型
+        /// </summary>
+        public string CabinType { get; set; }
+
+        /// <summary>
+        /// 房型编码:TBR、SGR、JSES、SUITE
+        /// </summary>
+        public string RoomType { get; set; }
+
+        /// <summary>
+        /// 房型名称
+        /// </summary>
+        public string RoomTypeName { get; set; }
+
+        /// <summary>
+        /// 实际人数;TBR按每间2人计算
+        /// </summary>
+        public int PassengerCount { get; set; }
+
+        /// <summary>
+        /// 房间数
+        /// </summary>
+        public int RoomCount { get; set; }
+
+        /// <summary>
+        /// 机票单人价格
+        /// </summary>
+        public decimal AirSinglePrice { get; set; }
+
+        /// <summary>
+        /// 房型单人价格
+        /// </summary>
+        public decimal RoomSinglePrice { get; set; }
+
+        /// <summary>
+        /// 其他成本单人价格
+        /// </summary>
+        public decimal OtherSinglePrice { get; set; }
+
+        /// <summary>
+        /// 组合单人价格
+        /// </summary>
+        public decimal SinglePrice { get; set; }
+
+        /// <summary>
+        /// 组合总价格
+        /// </summary>
+        public decimal TotalPrice { get; set; }
+    }
+}