using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.WinForms; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Vmianqian; /// /// 支付宝监控状态变更事件参数。 /// 用于通知主界面:登录成功、Cookie 失效、轮询启动、轮询停止、发生异常等。 /// public sealed class AlipayStatusChangedEventArgs : EventArgs { /// /// 当前状态代码,例如:Ready / Running / Stopped / CookieExpired / Error /// public string StatusCode { get; init; } = string.Empty; /// /// 给 UI 展示的中文描述信息。 /// public string Message { get; init; } = string.Empty; /// /// 可选异常对象,便于调用方记录详细日志。 /// public Exception? Exception { get; init; } } /// /// 支付宝登录成功事件参数。 /// 当用户在 WebView2 中完成扫码登录后,提取 Cookie 并通知主界面。 /// public sealed class AlipayLoginSucceededEventArgs : EventArgs { /// /// 提取到的原始 Cookie 列表。 /// public IReadOnlyList Cookies { get; init; } = Array.Empty(); /// /// 已转换为 HttpClient 可用格式的 CookieContainer。 /// public CookieContainer CookieContainer { get; init; } = new(); /// /// 自动提取到的 ctoken。 /// 可来自请求 URL 参数,也可来自 Cookie。 /// public string CToken { get; init; } = string.Empty; /// /// 当前登录完成后所在地址。 /// public string CurrentUrl { get; init; } = string.Empty; } /// /// 支付宝账单事件。 /// 当轮询到一笔新的收款记录时,向外抛出此事件,便于主界面更新表格、调用服务端回调。 /// public sealed class AlipayPaymentDetectedEventArgs : EventArgs { /// /// 支付宝订单号 / 流水号。 /// 用于本地去重。 /// public string OrderNo { get; init; } = string.Empty; /// /// 金额。 /// public decimal Amount { get; init; } /// /// 付款说明 / 备注。 /// public string Remark { get; init; } = string.Empty; /// /// 收款时间。 /// public DateTimeOffset PaidAt { get; init; } /// /// 付款人信息。 /// public string Payer { get; init; } = string.Empty; /// /// 原始响应 JSON,便于调试。 /// public string RawJson { get; init; } = string.Empty; } /// /// 支付宝监控配置。 /// 该类只保存“支付宝监控”所需的核心参数。 /// public sealed class AlipayMonitorOptions { /// /// 支付宝登录页地址。 /// 实战中可根据抓包结果改成更稳定的登录入口。 /// public string LoginUrl { get; set; } = "https://auth.alipay.com/login/index.htm"; /// /// 个人账单接口地址。 /// 注意:这里先给出占位地址,真实项目中必须通过 F12 抓包确认。 /// public string BillApiUrl { get; set; } = "https://consumeprod.alipay.com/record/advanced.htm"; /// /// 轮询最小间隔秒数。 /// 为了降低风控,不建议写死固定频率。 /// public int MinPollSeconds { get; set; } = 15; /// /// 轮询最大间隔秒数。 /// public int MaxPollSeconds { get; set; } = 35; /// /// 可选:支付宝 AppId。 /// 如果某些接口请求头 / 参数里需要带上,可从 UI 配置传入。 /// public string AppId { get; set; } = string.Empty; /// /// 可选:支付宝用户 PID / UserId。 /// 如果后续业务需要绑定到账户信息,可以通过配置保存。 /// public string UserId { get; set; } = string.Empty; /// /// 可选:从真实请求 URL 中提取出来的 ctoken。 /// 许多支付宝接口会把它作为防 CSRF / 会话校验参数放在 QueryString 中。 /// public string CToken { get; set; } = string.Empty; /// /// 可选:JSONP 回调名。 /// 某些支付宝接口返回 callback({...}) 这种 JSONP,而不是纯 JSON。 /// public string JsonpCallback { get; set; } = "callback"; /// /// 账单查询条数。 /// public int PageSize { get; set; } = 10; /// /// 轮询时默认回查最近多少天的账单。 /// public int QueryDays { get; set; } = 1; } /// /// 支付宝账单接口响应模型示例。 /// 注意:真实字段名要以 F12 抓到的 JSON 为准,这里只是演示“如何解析 JSON”。 /// public sealed class AlipayBillApiResponse { [JsonPropertyName("success")] public bool Success { get; set; } [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; [JsonPropertyName("data")] public AlipayBillApiData? Data { get; set; } /// /// 支付宝消息中心接口常见字段:ok / fail。 /// 你的当前接口 getMsgInfosNew.json 返回的就是这一套结构。 /// [JsonPropertyName("stat")] public string Stat { get; set; } = string.Empty; /// /// 支付宝消息中心返回的消息数组。 /// [JsonPropertyName("infos")] public List Infos { get; set; } = new(); } /// /// 支付宝账单接口 data 节点。 /// public sealed class AlipayBillApiData { [JsonPropertyName("records")] public List Records { get; set; } = new(); } /// /// 单笔账单记录示例。 /// 真实字段名称、结构、时间格式请以抓包结果为准后再微调。 /// public sealed class AlipayBillRecord { /// /// 支付宝交易流水号 / 订单号。 /// 去重时优先使用这个字段。 /// [JsonPropertyName("tradeNo")] public string TradeNo { get; set; } = string.Empty; /// /// 订单号备用字段。 /// 有些接口可能叫 bizInNo / trade_no / orderNo。 /// [JsonPropertyName("orderNo")] public string OrderNo { get; set; } = string.Empty; /// /// 金额。 /// [JsonPropertyName("amount")] public decimal Amount { get; set; } /// /// 备注。 /// [JsonPropertyName("remark")] public string Remark { get; set; } = string.Empty; /// /// 付款方昵称。 /// [JsonPropertyName("payerName")] public string PayerName { get; set; } = string.Empty; /// /// 状态,例如 SUCCESS。 /// [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; /// /// 支付时间文本。 /// 真实接口可能是 yyyy-MM-dd HH:mm:ss,也可能是时间戳。 /// [JsonPropertyName("gmtCreate")] public string GmtCreate { get; set; } = string.Empty; /// /// 兜底接收真实支付宝接口中的其它字段。 /// 当当前模型字段名与真实返回不一致时,可从这里继续提取。 /// [JsonExtensionData] public Dictionary Extra { get; set; } = new(); public string GetFirstNonEmpty(params string[] names) { foreach (var name in names) { if (!Extra.TryGetValue(name, out var element)) { continue; } var value = element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.Number => element.ToString(), JsonValueKind.True => "true", JsonValueKind.False => "false", _ => element.ToString() }; if (!string.IsNullOrWhiteSpace(value)) { return value.Trim(); } } return string.Empty; } public decimal TryGetAmountFromExtra() { foreach (var key in new[] { "amount", "totalAmount", "transAmount", "price", "money", "paidAmount" }) { if (!Extra.TryGetValue(key, out var element)) { continue; } if (element.ValueKind == JsonValueKind.Number && element.TryGetDecimal(out var decimalValue)) { return decimalValue; } var text = element.ToString(); if (decimal.TryParse(text, out decimalValue)) { return decimalValue; } } var mergedText = string.Join(" ", new[] { Remark, GetFirstNonEmpty("content", "title", "mainDesc", "desc", "memo") }.Where(x => !string.IsNullOrWhiteSpace(x))); var match = Regex.Match(mergedText, @"(?:¥|¥)?\s*(\d+(?:\.\d{1,2})?)"); if (match.Success && decimal.TryParse(match.Groups[1].Value, out var parsed)) { return parsed; } return 0m; } public string BuildDebugPreview() { if (Extra.Count == 0) { return $"tradeNo={TradeNo}, orderNo={OrderNo}, amount={Amount}, remark={Remark}, payerName={PayerName}, gmtCreate={GmtCreate}"; } var parts = Extra .Take(12) .Select(x => $"{x.Key}={x.Value}") .ToList(); return string.Join(", ", parts); } } /// /// 支付宝 Web 轮询监听核心类。 /// 职责: /// 1. 接收登录后 Cookie /// 2. 用 HttpClient + CookieContainer 调用账单接口 /// 3. 随机延迟轮询,降低风控 /// 4. 基于订单号进行本地内存去重 /// 5. 当 Cookie 失效时通过 Event 通知 UI 停止轮询 /// public sealed class AlipayMonitor : IDisposable { static AlipayMonitor() { // .NET Core / .NET 5+ 默认不加载 GBK/GB2312/GB18030 等代码页。 // 支付宝部分 HTML 页面仍可能使用这些中文编码,因此这里必须显式注册。 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } private readonly AlipayMonitorOptions _options; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly Random _random = new(); private readonly HashSet _processedOrderNos = new(StringComparer.OrdinalIgnoreCase); private readonly object _syncRoot = new(); private HttpClient? _httpClient; private HttpClientHandler? _httpHandler; private CancellationTokenSource? _pollingCts; private CookieContainer? _cookieContainer; private bool _hasCompletedInitialSnapshot; private bool _disposed; /// /// 状态变更事件。 /// public event EventHandler? StatusChanged; /// /// 检测到新收款事件。 /// public event EventHandler? PaymentDetected; /// /// 当前是否正在轮询。 /// public bool IsRunning => _pollingCts is { IsCancellationRequested: false }; public AlipayMonitor(AlipayMonitorOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); if (_options.MinPollSeconds <= 0) { _options.MinPollSeconds = 15; } if (_options.MaxPollSeconds < _options.MinPollSeconds) { _options.MaxPollSeconds = _options.MinPollSeconds; } } /// /// 设置登录后的 Cookie。 /// 每次重新扫码登录后,都应该调用这个方法刷新 HttpClient 会话。 /// /// 由 WebView2 Cookie 转换而来的 CookieContainer。 public void SetCookies(CookieContainer cookieContainer) { ObjectDisposedException.ThrowIf(_disposed, this); _cookieContainer = cookieContainer ?? throw new ArgumentNullException(nameof(cookieContainer)); _hasCompletedInitialSnapshot = false; RecreateHttpClient(); RaiseStatus("Ready", "支付宝 Cookie 已更新,可以开始轮询。"); } /// /// 更新当前会话使用的 ctoken。 /// 一般在 WebView2 登录完成后,和 Cookie 一起刷新。 /// public void SetCtoken(string? ctoken) { ObjectDisposedException.ThrowIf(_disposed, this); _options.CToken = ctoken?.Trim() ?? string.Empty; RaiseStatus("Ready", string.IsNullOrWhiteSpace(_options.CToken) ? "支付宝 ctoken 未提取到,当前仅使用 Cookie 轮询。" : $"支付宝 ctoken 已更新:{_options.CToken}"); } /// /// 手动导入已保存的订单号,用于重启程序后避免重复推送最近账单。 /// 如果不需要,可不调用。 /// public void SeedProcessedOrders(IEnumerable orderNos) { ObjectDisposedException.ThrowIf(_disposed, this); if (orderNos == null) { return; } lock (_syncRoot) { foreach (var orderNo in orderNos) { if (!string.IsNullOrWhiteSpace(orderNo)) { _processedOrderNos.Add(orderNo.Trim()); } } } } /// /// 启动后台轮询任务。 /// 采用 Task + Task.Delay 方式实现,便于取消与控制随机间隔。 /// public void Start() { ObjectDisposedException.ThrowIf(_disposed, this); if (_cookieContainer == null || _httpClient == null) { throw new InvalidOperationException("尚未设置支付宝 Cookie,请先登录并提取 Cookie。"); } if (IsRunning) { return; } _pollingCts = new CancellationTokenSource(); var token = _pollingCts.Token; _ = Task.Run(async () => { RaiseStatus("Running", "支付宝轮询已启动。"); while (!token.IsCancellationRequested) { try { await PollOnceAsync(token); } catch (OperationCanceledException) { break; } catch (Exception ex) { RaiseStatus("Error", $"支付宝轮询发生异常:{ex.Message}", ex); } var delaySeconds = _random.Next(_options.MinPollSeconds, _options.MaxPollSeconds + 1); try { await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token); } catch (OperationCanceledException) { break; } } RaiseStatus("Stopped", "支付宝轮询已停止。"); }, token); } /// /// 停止后台轮询。 /// public void Stop() { if (_pollingCts == null) { return; } try { _pollingCts.Cancel(); } catch { } finally { _pollingCts.Dispose(); _pollingCts = null; } } /// /// 单次轮询账单接口。 /// 这里演示了: /// 1. 如何带 Cookie 发请求 /// 2. 如何判断 403 / 302 /// 3. 如何解析 JSON /// 4. 如何进行本地去重 /// private async Task PollOnceAsync(CancellationToken cancellationToken) { if (_httpClient == null) { throw new InvalidOperationException("HttpClient 未初始化。"); } var rawResponseText = await FetchBillPageContentAsync(cancellationToken); if (TryReadRecordsFromHtml(rawResponseText, out var htmlRecords)) { RaiseStatus("Trace", $"支付宝 HTML 页面解析成功,本次返回记录数:{htmlRecords.Count}"); foreach (var record in htmlRecords) { cancellationToken.ThrowIfCancellationRequested(); ProcessRecord(record, rawResponseText); } return; } RaiseStatus("Trace", $"支付宝响应未命中账单表格。tradeRecordsIndex={rawResponseText.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase)},J-item={rawResponseText.Contains("J-item-", StringComparison.OrdinalIgnoreCase)},tbody={rawResponseText.Contains("(json, _jsonOptions); } catch (Exception ex) { // advanced.htm 很可能返回 HTML,因此这里不要立刻判定 Cookie 失效。 RaiseStatus("Trace", $"支付宝响应不是可解析 JSON:{ex.Message},原始响应预览:{TrimForLog(rawResponseText)}"); RaiseCookieExpiredAndStop($"支付宝账单页面解析失败,疑似 Cookie 失效或触发验证。原始响应预览:{TrimForLog(rawResponseText)}"); return; } // 兼容多种常见结构: // 1. data.records // 2. stat + infos // 3. 根节点 records/list/tradeList var records = payload?.Data?.Records; if ((records == null || records.Count == 0) && payload?.Infos != null) { records = payload.Infos; } if ((records == null || records.Count == 0) && TryReadRecordsFromRoot(json, out var rootRecords)) { records = rootRecords; } // 如果 stat=ok,即使 infos 为空,也说明 Cookie 实际可用,只是当前没有账单消息。 if (payload != null && string.Equals(payload.Stat, "ok", StringComparison.OrdinalIgnoreCase) && records == null) { records = new List(); } if (records == null) { RaiseCookieExpiredAndStop($"支付宝账单响应结构异常,疑似会话失效。原始响应预览:{TrimForLog(rawResponseText)}"); return; } RaiseStatus("Trace", $"支付宝轮询成功,本次返回记录数:{records.Count}"); if (!_hasCompletedInitialSnapshot) { var seededCount = SeedInitialSnapshot(records); _hasCompletedInitialSnapshot = true; RaiseStatus("Ready", $"支付宝首次账单基线预热完成,已载入 {seededCount} 条历史记录,后续仅处理新收款。"); return; } foreach (var record in records) { cancellationToken.ThrowIfCancellationRequested(); ProcessRecord(record, rawResponseText); } } /// /// 拉取支付宝账单页内容。 /// 对 advanced.htm 优先使用 GET 获取真实 HTML 页面源码; /// 若未命中表格,再退回 POST 表单方式。 /// 这样更贴近你在浏览器地址栏直接访问 advanced.htm 时看到的页面结果。 /// private async Task FetchBillPageContentAsync(CancellationToken cancellationToken) { var isConsumeHtmlPage = _options.BillApiUrl.Contains("consumeprod.alipay.com/record/advanced.htm", StringComparison.OrdinalIgnoreCase); if (!isConsumeHtmlPage) { using var request = BuildBillRequest(); using var response = await _httpClient!.SendAsync(request, cancellationToken); return await EnsureResponseAndReadAsync(response, cancellationToken); } using var getRequest = BuildConsumeHtmlGetRequest(); using var getResponse = await _httpClient!.SendAsync(getRequest, cancellationToken); var getHtml = await EnsureResponseAndReadAsync(getResponse, cancellationToken); if (LooksLikeBillHtml(getHtml)) { return getHtml; } RaiseStatus("Trace", $"支付宝 GET advanced.htm 未命中表格,尝试回退 POST。响应预览:{TrimForLog(getHtml, 400)}"); using var postRequest = BuildConsumeHtmlPostRequest(); using var postResponse = await _httpClient!.SendAsync(postRequest, cancellationToken); return await EnsureResponseAndReadAsync(postResponse, cancellationToken); } /// /// 构建账单接口请求。 /// 注意:这里的 QueryString / Header / Referer 都只是“示例模板”, /// 必须根据你 F12 抓到的真实请求进行替换。 /// private HttpRequestMessage BuildBillRequest() { var isEnterpriseBillApi = _options.BillApiUrl.Contains("simpleTradeOrderQuery", StringComparison.OrdinalIgnoreCase) || _options.BillApiUrl.Contains("mbillexprod.alipay.com", StringComparison.OrdinalIgnoreCase); var isConsumeHtmlPage = _options.BillApiUrl.Contains("consumeprod.alipay.com/record/advanced.htm", StringComparison.OrdinalIgnoreCase); HttpRequestMessage request; if (isConsumeHtmlPage) { request = BuildConsumeHtmlGetRequest(); } else if (isEnterpriseBillApi) { request = BuildEnterpriseBillPostRequest(); } else { request = BuildLegacyGetRequest(); } ApplyDefaultRequestHeaders(request, isHtmlPage: isConsumeHtmlPage); return request; } private HttpRequestMessage BuildConsumeHtmlGetRequest() { var url = _options.BillApiUrl; if (!string.IsNullOrWhiteSpace(_options.CToken)) { url += (_options.BillApiUrl.Contains('?') ? "&" : "?") + "ctoken=" + Uri.EscapeDataString(_options.CToken); } return new HttpRequestMessage(HttpMethod.Get, url); } private HttpRequestMessage BuildConsumeHtmlPostRequest() { var now = DateTime.Now; var begin = now.Date.AddDays(-Math.Max(1, _options.QueryDays - 1)); var form = new Dictionary { ["beginDate"] = begin.ToString("yyyy.MM.dd"), ["beginTime"] = "00:00", ["endDate"] = now.ToString("yyyy.MM.dd"), ["endTime"] = "24:00", ["dateRange"] = _options.QueryDays <= 1 ? "today" : _options.QueryDays <= 7 ? "sevenDays" : "oneMonth", ["status"] = "all", ["keyword"] = "bizNo", ["keyValue"] = string.Empty, ["dateType"] = "createDate", ["minAmount"] = string.Empty, ["maxAmount"] = string.Empty, ["fundFlow"] = "all", ["tradeType"] = "ALL", ["pageNum"] = "1", ["_input_charset"] = "utf-8" }; if (!string.IsNullOrWhiteSpace(_options.CToken)) { form["ctoken"] = _options.CToken; } var request = new HttpRequestMessage(HttpMethod.Post, _options.BillApiUrl) { Content = new FormUrlEncodedContent(form) }; ApplyDefaultRequestHeaders(request, isHtmlPage: true); return request; } private HttpRequestMessage BuildLegacyGetRequest() { var builder = new StringBuilder(); builder.Append(_options.BillApiUrl); var separator = _options.BillApiUrl.Contains('?') ? "&" : "?"; builder.Append(separator); builder.Append("pageSize=").Append(_options.PageSize); if (!string.IsNullOrWhiteSpace(_options.CToken)) { builder.Append("&ctoken=").Append(Uri.EscapeDataString(_options.CToken)); } if (!string.IsNullOrWhiteSpace(_options.JsonpCallback)) { builder.Append("&_callback=").Append(Uri.EscapeDataString(_options.JsonpCallback)); } builder.Append("&_input_charset=utf-8"); builder.Append("&_output_charset=utf-8"); builder.Append("&_=").Append(DateTimeOffset.Now.ToUnixTimeMilliseconds()); return new HttpRequestMessage(HttpMethod.Get, builder.ToString()); } private void ApplyDefaultRequestHeaders(HttpRequestMessage request, bool isHtmlPage) { request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"); request.Headers.TryAddWithoutValidation("Accept", isHtmlPage ? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" : "application/json, text/plain, */*"); request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9"); var referer = _options.BillApiUrl.Contains("consumeprod.alipay.com", StringComparison.OrdinalIgnoreCase) ? "https://consumeprod.alipay.com/record/advanced.htm" : "https://mbillexprod.alipay.com/"; var origin = _options.BillApiUrl.Contains("consumeprod.alipay.com", StringComparison.OrdinalIgnoreCase) ? "https://consumeprod.alipay.com" : "https://mbillexprod.alipay.com"; request.Headers.TryAddWithoutValidation("Referer", referer); request.Headers.TryAddWithoutValidation("Origin", origin); if (!isHtmlPage) { request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); } } private async Task EnsureResponseAndReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var rawResponseText = await ReadResponseTextSafeAsync(response, cancellationToken); if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.Found || response.StatusCode == HttpStatusCode.Moved || response.StatusCode == HttpStatusCode.Redirect) { var location = response.Headers.Location?.ToString() ?? string.Empty; RaiseCookieExpiredAndStop(string.IsNullOrWhiteSpace(location) ? $"支付宝返回 {(int)response.StatusCode},疑似 Cookie 失效或触发验证。" : $"支付宝返回 {(int)response.StatusCode},疑似 Cookie 失效或触发验证。Location={location}"); return rawResponseText; } if (!response.IsSuccessStatusCode) { RaiseStatus("Error", $"支付宝账单接口请求失败:HTTP {(int)response.StatusCode},响应:{TrimForLog(rawResponseText)}"); } return rawResponseText; } private static async Task ReadResponseTextSafeAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); if (bytes.Length == 0) { return string.Empty; } var charset = response.Content.Headers.ContentType?.CharSet?.Trim().Trim('"'); var encoding = TryGetEncoding(charset); if (encoding == null && !string.IsNullOrWhiteSpace(charset) && charset.Contains("gb", StringComparison.OrdinalIgnoreCase)) { encoding = TryGetEncoding("gb18030"); } encoding ??= DetectEncodingFromContent(bytes); // 对支付宝中文 HTML,最后兜底优先使用 GB18030,而不是 UTF-8。 encoding ??= TryGetEncoding("gb18030"); encoding ??= new UTF8Encoding(false); try { return encoding.GetString(bytes); } catch { return Encoding.UTF8.GetString(bytes); } } private static Encoding? TryGetEncoding(string? charset) { if (string.IsNullOrWhiteSpace(charset)) { return null; } try { return Encoding.GetEncoding(charset); } catch { return null; } } private static Encoding? DetectEncodingFromContent(byte[] bytes) { try { if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { return Encoding.UTF8; } var head = Encoding.ASCII.GetString(bytes, 0, Math.Min(bytes.Length, 4096)); var metaCharset = Regex.Match( head, "charset=([a-zA-Z0-9_\\-]+)", RegexOptions.IgnoreCase); if (metaCharset.Success) { var charsetName = metaCharset.Groups[1].Value.Trim(); var detected = TryGetEncoding(charsetName); if (detected != null) { return detected; } if (charsetName.Contains("gb", StringComparison.OrdinalIgnoreCase)) { detected = TryGetEncoding("gb18030"); if (detected != null) { return detected; } } } // 对中文站点优先尝试 GB18030,而不是直接回退 UTF-8。 return TryGetEncoding("gb18030") ?? Encoding.UTF8; } catch { return null; } } private static bool LooksLikeBillHtml(string text) { if (string.IsNullOrWhiteSpace(text)) { return false; } return text.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase) || text.Contains("table-index-bill", StringComparison.OrdinalIgnoreCase) || text.Contains("J-item-", StringComparison.OrdinalIgnoreCase); } private HttpRequestMessage BuildEnterpriseBillPostRequest() { var now = DateTime.Now; var begin = now.Date.AddDays(-Math.Max(1, _options.QueryDays - 1)); var end = now; var form = new Dictionary { ["pageSize"] = _options.PageSize.ToString(), ["pageNum"] = "1", ["channelType"] = "ALL", ["_input_charset"] = "utf-8", ["_output_charset"] = "utf-8", ["beginTime"] = begin.ToString("yyyy-MM-dd 00:00:00"), ["endTime"] = end.ToString("yyyy-MM-dd HH:mm:ss") }; if (!string.IsNullOrWhiteSpace(_options.CToken)) { form["ctoken"] = _options.CToken; } var request = new HttpRequestMessage(HttpMethod.Post, _options.BillApiUrl) { Content = new FormUrlEncodedContent(form) }; return request; } /// /// 根据最新 Cookie 重建 HttpClient。 /// 必须使用 AllowAutoRedirect = false, /// 这样当服务器返回 302 时,我们才能第一时间识别出会话异常,而不是被自动跳转“吃掉”。 /// private void RecreateHttpClient() { _httpClient?.Dispose(); _httpHandler?.Dispose(); if (_cookieContainer == null) { throw new InvalidOperationException("CookieContainer 不能为空。"); } _httpHandler = new HttpClientHandler { UseCookies = true, CookieContainer = _cookieContainer, AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli }; _httpClient = new HttpClient(_httpHandler) { Timeout = TimeSpan.FromSeconds(20) }; } /// /// 当检测到 Cookie 失效时: /// 1. 通知主界面 /// 2. 自动停止轮询 /// private void RaiseCookieExpiredAndStop(string message) { RaiseStatus("CookieExpired", message); Stop(); } private void RaiseStatus(string statusCode, string message, Exception? exception = null) { StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs { StatusCode = statusCode, Message = message, Exception = exception }); } private static string TrimForLog(string value, int maxLength = 300) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } return value.Length <= maxLength ? value : value[..maxLength] + "..."; } private static DateTimeOffset TryParseAlipayTime(string text) { if (DateTimeOffset.TryParse(text, out var parsed)) { return parsed; } return DateTimeOffset.Now; } private int SeedInitialSnapshot(IEnumerable records) { var count = 0; foreach (var record in records) { var orderNo = !string.IsNullOrWhiteSpace(record.TradeNo) ? record.TradeNo.Trim() : !string.IsNullOrWhiteSpace(record.OrderNo) ? record.OrderNo.Trim() : record.GetFirstNonEmpty("bizInNo", "trade_no", "tradeNo", "order_no", "orderNo", "bizNo", "id", "messageId"); if (string.IsNullOrWhiteSpace(orderNo)) { continue; } lock (_syncRoot) { if (_processedOrderNos.Add(orderNo)) { count++; } } } return count; } /// /// 兼容支付宝返回 JSONP 的情况。 /// 例如:callback({...}) 需要先剥离外层回调壳,才能继续走 JSON 反序列化。 /// private void ProcessRecord(AlipayBillRecord record, string rawResponseText) { var orderNo = !string.IsNullOrWhiteSpace(record.TradeNo) ? record.TradeNo.Trim() : !string.IsNullOrWhiteSpace(record.OrderNo) ? record.OrderNo.Trim() : record.GetFirstNonEmpty("bizInNo", "trade_no", "tradeNo", "order_no", "orderNo", "bizNo", "id", "messageId"); if (string.IsNullOrWhiteSpace(orderNo)) { RaiseStatus("Trace", $"支付宝记录缺少订单号,已跳过。字段预览:{TrimForLog(record.BuildDebugPreview(), 500)}"); return; } var statusText = !string.IsNullOrWhiteSpace(record.Status) ? record.Status : record.GetFirstNonEmpty("status", "tradeStatus", "state"); if (!string.IsNullOrWhiteSpace(statusText) && !statusText.Contains("SUCCESS", StringComparison.OrdinalIgnoreCase) && !statusText.Contains("交易成功", StringComparison.OrdinalIgnoreCase) && !statusText.Contains("收款成功", StringComparison.OrdinalIgnoreCase) && !statusText.Contains("已收款", StringComparison.OrdinalIgnoreCase) && !statusText.Contains("ok", StringComparison.OrdinalIgnoreCase)) { RaiseStatus("Trace", $"支付宝记录状态非成功,已跳过。orderNo={orderNo},status={statusText}"); return; } bool isNew; lock (_syncRoot) { isNew = _processedOrderNos.Add(orderNo); } if (!isNew) { RaiseStatus("Trace", $"支付宝记录重复,已跳过。orderNo={orderNo}"); return; } var amount = record.Amount != 0 ? record.Amount : record.TryGetAmountFromExtra(); if (amount <= 0) { RaiseStatus("Trace", $"支付宝记录金额非收入,已跳过。orderNo={orderNo},amount={amount:0.00}"); return; } var remark = !string.IsNullOrWhiteSpace(record.Remark) ? record.Remark : record.GetFirstNonEmpty("remark", "content", "title", "mainDesc", "desc", "memo"); var payer = !string.IsNullOrWhiteSpace(record.PayerName) ? record.PayerName : record.GetFirstNonEmpty("payerName", "payer", "userName", "nickName", "fromUserName", "oppositeName"); var timeText = !string.IsNullOrWhiteSpace(record.GmtCreate) ? record.GmtCreate : record.GetFirstNonEmpty("gmtCreate", "createTime", "messageTime", "time", "payTime"); var paidAt = TryParseAlipayTime(timeText); RaiseStatus("Trace", $"支付宝发现新收款:orderNo={orderNo},amount={amount:0.00},remark={TrimForLog(remark, 80)}"); PaymentDetected?.Invoke(this, new AlipayPaymentDetectedEventArgs { OrderNo = orderNo, Amount = amount, Remark = remark, PaidAt = paidAt, Payer = payer, RawJson = rawResponseText }); } private bool TryReadRecordsFromHtml(string html, out List records) { records = new List(); if (string.IsNullOrWhiteSpace(html)) { return false; } if (!html.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase) && !html.Contains("table-index-bill", StringComparison.OrdinalIgnoreCase) && !html.Contains("ui-record-table", StringComparison.OrdinalIgnoreCase)) { return false; } var normalized = Regex.Replace(html, "", string.Empty, RegexOptions.IgnoreCase); normalized = Regex.Replace(normalized, "", string.Empty, RegexOptions.IgnoreCase); normalized = WebUtility.HtmlDecode(normalized); var rowMatches = Regex.Matches( normalized, "]*>([\\s\\S]*?)", RegexOptions.IgnoreCase); foreach (Match rowMatch in rowMatches) { var rowHtml = rowMatch.Value; if (!rowHtml.Contains("tradeNo", StringComparison.OrdinalIgnoreCase) && !rowHtml.Contains("amount", StringComparison.OrdinalIgnoreCase) && !rowHtml.Contains("status", StringComparison.OrdinalIgnoreCase) && !rowHtml.Contains("J-item-", StringComparison.OrdinalIgnoreCase)) { continue; } var timeCell = ExtractCellByClass(rowHtml, "time"); var nameCell = ExtractCellByClass(rowHtml, "name"); var tradeNoCell = ExtractCellByClass(rowHtml, "tradeNo"); var otherCell = ExtractCellByClass(rowHtml, "other"); var amountCell = ExtractCellByClass(rowHtml, "amount"); var statusCell = ExtractCellByClass(rowHtml, "status"); var timeText = NormalizePlainText(timeCell); var nameText = NormalizePlainText(nameCell); var tradeNoText = NormalizePlainText(tradeNoCell); var otherText = NormalizePlainText(otherCell); var amountText = NormalizePlainText(amountCell); var statusText = NormalizePlainText(statusCell); var orderNo = TryMatchGroup(tradeNoText, "订单号[::]\\s*([0-9A-Za-z]{10,64})"); var tradeNo = TryMatchGroup(tradeNoText, "交易号[::]\\s*([0-9A-Za-z]{10,64})"); var bizNo = TryMatchGroup(tradeNoText, "流水号[::]\\s*([0-9A-Za-z]{10,64})"); var fallbackNo = ExtractPossibleTradeNo(tradeNoText, rowHtml); var primaryNo = !string.IsNullOrWhiteSpace(tradeNo) ? tradeNo : !string.IsNullOrWhiteSpace(orderNo) ? orderNo : !string.IsNullOrWhiteSpace(bizNo) ? bizNo : fallbackNo; if (string.IsNullOrWhiteSpace(primaryNo)) { continue; } var amount = ParseAlipayAmount(amountText, rowHtml); if (amount == 0m) { continue; } var paidAtText = NormalizeAlipayHtmlTime(timeText); var remark = string.IsNullOrWhiteSpace(nameText) ? (string.IsNullOrWhiteSpace(tradeNoText) ? NormalizePlainText(rowHtml) : tradeNoText) : nameText; var payer = otherText; var record = new AlipayBillRecord { TradeNo = !string.IsNullOrWhiteSpace(tradeNo) ? tradeNo : primaryNo, OrderNo = !string.IsNullOrWhiteSpace(orderNo) ? orderNo : (!string.IsNullOrWhiteSpace(bizNo) ? bizNo : primaryNo), Amount = amount, Remark = remark, PayerName = payer, Status = statusText, GmtCreate = paidAtText }; records.Add(record); } return records.Count > 0; } private static string ExtractCellByClass(string rowHtml, string className) { if (string.IsNullOrWhiteSpace(rowHtml) || string.IsNullOrWhiteSpace(className)) { return string.Empty; } var pattern = $"]*class=\"[^\"]*\\b{Regex.Escape(className)}\\b[^\"]*\"[^>]*>([\\s\\S]*?)"; var match = Regex.Match(rowHtml, pattern, RegexOptions.IgnoreCase); if (match.Success) { return match.Groups[1].Value; } pattern = $"]*data-role=\"[^\"]*\\b{Regex.Escape(className)}\\b[^\"]*\"[^>]*>([\\s\\S]*?)"; match = Regex.Match(rowHtml, pattern, RegexOptions.IgnoreCase); return match.Success ? match.Groups[1].Value : string.Empty; } private static string NormalizePlainText(string htmlFragment) { if (string.IsNullOrWhiteSpace(htmlFragment)) { return string.Empty; } var text = Regex.Replace(htmlFragment, "", " ", RegexOptions.IgnoreCase); text = Regex.Replace(text, "<[^>]+>", " "); text = WebUtility.HtmlDecode(text); text = Regex.Replace(text, "\\s+", " ").Trim(); return text; } private static string TryMatchGroup(string text, string pattern) { if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } var match = Regex.Match(text, pattern, RegexOptions.IgnoreCase); return match.Success ? match.Groups[1].Value.Trim() : string.Empty; } private static string NormalizeAlipayHtmlTime(string text) { if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } var match = Regex.Match(text, "(20\\d{2})\\.(\\d{2})\\.(\\d{2})\\s+(\\d{2}:\\d{2})(?::(\\d{2}))?", RegexOptions.IgnoreCase); if (match.Success) { var second = match.Groups[5].Success ? match.Groups[5].Value : "00"; return $"{match.Groups[1].Value}-{match.Groups[2].Value}-{match.Groups[3].Value} {match.Groups[4].Value}:{second}"; } match = Regex.Match(text, "(20\\d{2})-(\\d{2})-(\\d{2})\\s+(\\d{2}:\\d{2})(?::(\\d{2}))?", RegexOptions.IgnoreCase); if (match.Success) { var second = match.Groups[5].Success ? match.Groups[5].Value : "00"; return $"{match.Groups[1].Value}-{match.Groups[2].Value}-{match.Groups[3].Value} {match.Groups[4].Value}:{second}"; } return text; } private static string ExtractPossibleTradeNo(string tradeNoText, string rowHtml) { var directMatch = Regex.Match( tradeNoText, "(?]+([0-9A-Za-z]{16,64})", RegexOptions.IgnoreCase); if (htmlMatch.Success) { return htmlMatch.Groups[1].Value.Trim(); } return string.Empty; } private static decimal ParseAlipayAmount(string amountText, string rowHtml) { if (string.IsNullOrWhiteSpace(amountText)) { amountText = NormalizePlainText(rowHtml); } var normalized = amountText .Replace("¥", string.Empty) .Replace("¥", string.Empty) .Replace(",", string.Empty) .Trim(); var amountMatch = Regex.Match(normalized, "([+-]?)\\s*([0-9]+(?:\\.[0-9]{1,2})?)", RegexOptions.IgnoreCase); if (!amountMatch.Success) { return 0m; } if (!decimal.TryParse(amountMatch.Groups[2].Value, out var amount)) { return 0m; } var sign = amountMatch.Groups[1].Value; var text = $"{amountText} {rowHtml}"; if (sign == "-" || text.Contains("支出", StringComparison.OrdinalIgnoreCase)) { return -amount; } return amount; } private bool TryReadRecordsFromRoot(string json, out List? records) { records = null; try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; foreach (var key in new[] { "records", "list", "tradeList", "result", "items" }) { if (!root.TryGetProperty(key, out var node)) { continue; } if (node.ValueKind == JsonValueKind.Array) { records = JsonSerializer.Deserialize>(node.GetRawText(), _jsonOptions) ?? new List(); return true; } if (node.ValueKind == JsonValueKind.Object) { foreach (var nestedKey in new[] { "records", "list", "tradeList", "items" }) { if (!node.TryGetProperty(nestedKey, out var nestedNode) || nestedNode.ValueKind != JsonValueKind.Array) { continue; } records = JsonSerializer.Deserialize>(nestedNode.GetRawText(), _jsonOptions) ?? new List(); return true; } } } } catch { } return false; } private static string TryExtractJsonFromJsonp(string text) { if (string.IsNullOrWhiteSpace(text)) { return text; } var trimmed = text.Trim(); if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) || (trimmed.StartsWith("[") && trimmed.EndsWith("]"))) { return trimmed; } // 支付宝这类接口常见返回格式: // /**/callback({...}) // 或 callback({...}) // 因此前面先容忍注释前缀,再提取括号内 JSON。 var match = Regex.Match( trimmed, @"^(?:/\*\*/\s*)?[a-zA-Z0-9_\$]+\((.*)\)\s*;?\s*$", RegexOptions.Singleline); if (match.Success) { return match.Groups[1].Value.Trim(); } return trimmed; } public void Dispose() { if (_disposed) { return; } Stop(); _httpClient?.Dispose(); _httpHandler?.Dispose(); _disposed = true; } } /// /// 支付宝 WebView2 登录窗口。 /// 用于让用户扫码登录支付宝网页版,并在登录成功后自动提取 Cookie。 /// public sealed class AlipayLoginForm : Form { private readonly AlipayMonitorOptions _options; private readonly WebView2 _webView; private readonly Label _statusLabel; private string _detectedCtoken = string.Empty; private bool _loginEventRaised; private bool _navigatedToBillPage; private bool _waitingSecurityVerify; /// /// 当检测到登录成功并提取到 Cookie 后触发。 /// public event EventHandler? LoginSucceeded; /// /// 当登录页发生异常时触发。 /// public event EventHandler? StatusChanged; public AlipayLoginForm(AlipayMonitorOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); Text = "支付宝扫码登录"; StartPosition = FormStartPosition.CenterParent; Width = 1100; Height = 760; _statusLabel = new Label { Dock = DockStyle.Top, Height = 36, Text = "正在初始化支付宝登录页,请稍候……", TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(12, 0, 0, 0) }; _webView = new WebView2 { Dock = DockStyle.Fill }; Controls.Add(_webView); Controls.Add(_statusLabel); Load += AlipayLoginForm_Load; FormClosed += AlipayLoginForm_FormClosed; } private async void AlipayLoginForm_Load(object? sender, EventArgs e) { try { await _webView.EnsureCoreWebView2Async(); _webView.CoreWebView2.Settings.IsStatusBarEnabled = false; _webView.CoreWebView2.Settings.AreDevToolsEnabled = true; _webView.CoreWebView2.Settings.IsZoomControlEnabled = true; _webView.CoreWebView2.NavigationCompleted += CoreWebView2_NavigationCompleted; _webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded; _webView.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested; _statusLabel.Text = "请使用支付宝扫码登录。"; _webView.CoreWebView2.Navigate(_options.LoginUrl); } catch (Exception ex) { StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs { StatusCode = "Error", Message = $"初始化支付宝登录窗口失败:{ex.Message}", Exception = ex }); } } private void AlipayLoginForm_FormClosed(object? sender, FormClosedEventArgs e) { if (_webView.CoreWebView2 != null) { _webView.CoreWebView2.NavigationCompleted -= CoreWebView2_NavigationCompleted; _webView.CoreWebView2.DOMContentLoaded -= CoreWebView2_DOMContentLoaded; _webView.CoreWebView2.WebResourceRequested -= CoreWebView2_WebResourceRequested; } _webView.Dispose(); } private void CoreWebView2_WebResourceRequested(object? sender, CoreWebView2WebResourceRequestedEventArgs e) { try { var url = e.Request.Uri ?? string.Empty; if (string.IsNullOrWhiteSpace(url)) { return; } if (url.Contains("ctoken=", StringComparison.OrdinalIgnoreCase) || url.Contains("advanced.htm", StringComparison.OrdinalIgnoreCase) || url.Contains("getMsgInfosNew.json", StringComparison.OrdinalIgnoreCase) || url.Contains("/web/bi.do", StringComparison.OrdinalIgnoreCase) || url.Contains("/record/", StringComparison.OrdinalIgnoreCase)) { if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) { var ctoken = TryGetQueryParameter(uri.Query, "ctoken"); if (!string.IsNullOrWhiteSpace(ctoken)) { _detectedCtoken = ctoken.Trim(); _statusLabel.Text = $"已捕获支付宝请求信号,ctoken={_detectedCtoken}"; } } } } catch { } } /// /// 通过 URL 跳转初步判断“是否已经登录成功”。 /// 注意:不同版本支付宝网页版,登录成功后的 URL 可能不同, /// 这里建议你在实测时打印 URL,并按真实情况补充判定规则。 /// private async void CoreWebView2_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e) { if (!e.IsSuccess || _webView.CoreWebView2 == null) { return; } var currentUrl = _webView.Source?.AbsoluteUri ?? string.Empty; _statusLabel.Text = $"当前页面:{currentUrl}"; if (IsSecurityVerifyUrl(currentUrl)) { _waitingSecurityVerify = true; _statusLabel.Text = "支付宝触发安全校验,请在当前页面完成验证,完成后程序会自动继续。"; StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs { StatusCode = "SecurityVerify", Message = "支付宝触发安全校验,请在登录窗口内完成验证。" }); return; } if (IsBillReadyUrl(currentUrl)) { _waitingSecurityVerify = false; await TryExtractCookiesAndRaiseAsync(currentUrl); return; } if (LooksLikeLoginSuccessUrl(currentUrl)) { if (await TryNavigateToBillPageBeforeExtractAsync(currentUrl)) { return; } } } /// /// 通过 DOM 元素辅助判断登录成功。 /// 某些站点不会明显跳转 URL,但会在登录后渲染“账单/交易记录/退出”等元素。 /// 这里给出一个执行 JS 检测页面文本的示例。 /// private async void CoreWebView2_DOMContentLoaded(object? sender, CoreWebView2DOMContentLoadedEventArgs e) { if (_loginEventRaised || _webView.CoreWebView2 == null || IsDisposed) { return; } try { var core = _webView.CoreWebView2; if (core == null) { return; } var script = """ (() => { const text = document.body ? document.body.innerText : ""; const title = document.title || ""; return JSON.stringify({ title, hasBillKeyword: text.includes("账单") || text.includes("交易记录") || text.includes("收支明细"), hasLogoutKeyword: text.includes("退出") || text.includes("安全设置"), location: location.href }); })(); """; var result = await core.ExecuteScriptAsync(script); if (string.IsNullOrWhiteSpace(result)) { return; } var json = JsonSerializer.Deserialize(result); if (string.IsNullOrWhiteSpace(json)) { return; } using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var hasBillKeyword = root.TryGetProperty("hasBillKeyword", out var billProp) && billProp.GetBoolean(); var hasLogoutKeyword = root.TryGetProperty("hasLogoutKeyword", out var logoutProp) && logoutProp.GetBoolean(); var currentUrl = root.TryGetProperty("location", out var urlProp) ? urlProp.GetString() ?? string.Empty : string.Empty; if (IsSecurityVerifyUrl(currentUrl)) { _waitingSecurityVerify = true; _statusLabel.Text = "支付宝触发安全校验,请在当前页面完成验证,完成后程序会自动继续。"; return; } if (IsBillReadyUrl(currentUrl)) { _waitingSecurityVerify = false; await TryExtractCookiesAndRaiseAsync(currentUrl); return; } if (hasBillKeyword || hasLogoutKeyword) { if (await TryNavigateToBillPageBeforeExtractAsync(currentUrl)) { return; } } } catch (Exception ex) { StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs { StatusCode = "Warn", Message = $"DOM 登录检测异常:{ex.Message}", Exception = ex }); } } private async Task TryNavigateToBillPageBeforeExtractAsync(string currentUrl) { if (_webView.CoreWebView2 == null || _navigatedToBillPage) { return false; } if (IsBillReadyUrl(currentUrl) || IsSecurityVerifyUrl(currentUrl)) { return false; } var billUrl = BuildBillEntryUrl(); _navigatedToBillPage = true; _statusLabel.Text = "登录成功,正在自动跳转账单页以补齐会话 Cookie……"; await Task.Delay(800); if (_webView.CoreWebView2 == null || IsDisposed) { return false; } _webView.CoreWebView2.Navigate(billUrl); return true; } /// /// 登录成功后提取全部 Cookie,并转换为 HttpClient 可用的 CookieContainer。 /// private async Task TryExtractCookiesAndRaiseAsync(string currentUrl) { if (_loginEventRaised || _webView.CoreWebView2 == null || IsDisposed) { return; } if (!IsBillReadyUrl(currentUrl)) { if (await TryNavigateToBillPageBeforeExtractAsync(currentUrl)) { return; } return; } _loginEventRaised = true; try { _statusLabel.Text = "检测到已进入支付宝账单页,正在提取 Cookie……"; // null 表示取当前 WebView 所有站点 Cookie。 var cookies = await _webView.CoreWebView2.CookieManager.GetCookiesAsync(null); var cookieContainer = WebView2CookieHelper.ConvertToCookieContainer(cookies); var ctoken = ResolveCtoken(currentUrl, cookies); LoginSucceeded?.Invoke(this, new AlipayLoginSucceededEventArgs { Cookies = cookies, CookieContainer = cookieContainer, CToken = ctoken, CurrentUrl = currentUrl }); _statusLabel.Text = string.IsNullOrWhiteSpace(ctoken) ? "Cookie 提取成功,可关闭窗口。" : $"Cookie 与 ctoken 提取成功,可关闭窗口。ctoken={ctoken}"; } catch (Exception ex) { _loginEventRaised = false; StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs { StatusCode = "Error", Message = $"提取 Cookie 失败:{ex.Message}", Exception = ex }); } } private string BuildBillEntryUrl() { var url = _options.BillApiUrl; if (!string.IsNullOrWhiteSpace(_detectedCtoken) && !url.Contains("ctoken=", StringComparison.OrdinalIgnoreCase)) { url += (url.Contains('?') ? "&" : "?") + "ctoken=" + Uri.EscapeDataString(_detectedCtoken); } return url; } /// /// 判断当前 URL 是否疑似登录成功。 /// 真实项目里请把你抓到的“登录后页面 URL 特征”补充到这里。 /// private string ResolveCtoken(string currentUrl, IReadOnlyList cookies) { if (Uri.TryCreate(currentUrl, UriKind.Absolute, out var uri)) { var byUrl = TryGetQueryParameter(uri.Query, "ctoken"); if (!string.IsNullOrWhiteSpace(byUrl)) { return byUrl.Trim(); } } if (!string.IsNullOrWhiteSpace(_detectedCtoken)) { return _detectedCtoken.Trim(); } foreach (var cookie in cookies) { if (string.Equals(cookie.Name, "ctoken", StringComparison.OrdinalIgnoreCase) || string.Equals(cookie.Name, "_CHIPS-ctoken", StringComparison.OrdinalIgnoreCase)) { if (!string.IsNullOrWhiteSpace(cookie.Value)) { return cookie.Value.Trim(); } } } return string.Empty; } private static string? TryGetQueryParameter(string query, string key) { if (string.IsNullOrWhiteSpace(query)) { return null; } var trimmed = query.TrimStart('?'); var parts = trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries); foreach (var part in parts) { var kv = part.Split('=', 2); if (kv.Length == 0) { continue; } if (!string.Equals(Uri.UnescapeDataString(kv[0]), key, StringComparison.OrdinalIgnoreCase)) { continue; } return kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; } return null; } private static bool LooksLikeLoginSuccessUrl(string url) { if (string.IsNullOrWhiteSpace(url)) { return false; } return url.Contains("my.alipay.com", StringComparison.OrdinalIgnoreCase) || url.Contains("lab.alipay.com", StringComparison.OrdinalIgnoreCase) || url.Contains("mbillexprod.alipay.com", StringComparison.OrdinalIgnoreCase) || url.Contains("consumeprod.alipay.com", StringComparison.OrdinalIgnoreCase) || url.Contains("getMsgInfosNew.json", StringComparison.OrdinalIgnoreCase) || url.Contains("bill", StringComparison.OrdinalIgnoreCase) || url.Contains("trade", StringComparison.OrdinalIgnoreCase); } private static bool IsBillReadyUrl(string url) { if (string.IsNullOrWhiteSpace(url)) { return false; } return url.Contains("consumeprod.alipay.com/record/advanced.htm", StringComparison.OrdinalIgnoreCase); } private static bool IsSecurityVerifyUrl(string url) { if (string.IsNullOrWhiteSpace(url)) { return false; } return url.Contains("consumeprod.alipay.com/record/checkSecurity.htm", StringComparison.OrdinalIgnoreCase) || url.Contains("checkSecurity", StringComparison.OrdinalIgnoreCase) || url.Contains("securityId=", StringComparison.OrdinalIgnoreCase); } } /// /// WebView2 Cookie 帮助类。 /// 负责把 WebView2 Cookie 列表转换为 HttpClient 可直接使用的 CookieContainer。 /// public static class WebView2CookieHelper { /// /// 将 WebView2 返回的 Cookie 列表转换为 CookieContainer。 /// 这是“浏览器登录态 -> HttpClient 轮询态”最关键的一步。 /// public static CookieContainer ConvertToCookieContainer(IEnumerable cookies) { var container = new CookieContainer(); foreach (var item in cookies) { try { if (string.IsNullOrWhiteSpace(item.Name) || string.IsNullOrWhiteSpace(item.Domain)) { continue; } var domain = NormalizeCookieDomain(item.Domain); var path = string.IsNullOrWhiteSpace(item.Path) ? "/" : item.Path; var cookie = new Cookie(item.Name, item.Value, path, domain) { HttpOnly = item.IsHttpOnly, Secure = item.IsSecure }; // WebView2 Cookie 的 Expires 若未设置,通常会给出 MinValue 或异常值。 // 此处做保护处理,避免 .NET Cookie 因非法时间报错。 if (item.Expires > DateTime.MinValue.AddYears(1)) { cookie.Expires = item.Expires; } container.Add(cookie); } catch { // 某些特殊 Cookie 域名格式可能不被 .NET Cookie 接受,直接跳过即可。 } } return container; } /// /// 规范化 Cookie 域名。 /// 例如把 ".alipay.com" 转成 "alipay.com",以兼容 CookieContainer.Add。 /// private static string NormalizeCookieDomain(string domain) { var value = domain.Trim(); while (value.StartsWith('.')) { value = value[1..]; } return value; } }