VmianqianC/AlipayMonitor.cs

1956 lines
65 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 支付宝监控状态变更事件参数。
/// 用于通知主界面登录成功、Cookie 失效、轮询启动、轮询停止、发生异常等。
/// </summary>
public sealed class AlipayStatusChangedEventArgs : EventArgs
{
/// <summary>
/// 当前状态代码例如Ready / Running / Stopped / CookieExpired / Error
/// </summary>
public string StatusCode { get; init; } = string.Empty;
/// <summary>
/// 给 UI 展示的中文描述信息。
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// 可选异常对象,便于调用方记录详细日志。
/// </summary>
public Exception? Exception { get; init; }
}
/// <summary>
/// 支付宝登录成功事件参数。
/// 当用户在 WebView2 中完成扫码登录后,提取 Cookie 并通知主界面。
/// </summary>
public sealed class AlipayLoginSucceededEventArgs : EventArgs
{
/// <summary>
/// 提取到的原始 Cookie 列表。
/// </summary>
public IReadOnlyList<CoreWebView2Cookie> Cookies { get; init; } = Array.Empty<CoreWebView2Cookie>();
/// <summary>
/// 已转换为 HttpClient 可用格式的 CookieContainer。
/// </summary>
public CookieContainer CookieContainer { get; init; } = new();
/// <summary>
/// 自动提取到的 ctoken。
/// 可来自请求 URL 参数,也可来自 Cookie。
/// </summary>
public string CToken { get; init; } = string.Empty;
/// <summary>
/// 当前登录完成后所在地址。
/// </summary>
public string CurrentUrl { get; init; } = string.Empty;
}
/// <summary>
/// 支付宝账单事件。
/// 当轮询到一笔新的收款记录时,向外抛出此事件,便于主界面更新表格、调用服务端回调。
/// </summary>
public sealed class AlipayPaymentDetectedEventArgs : EventArgs
{
/// <summary>
/// 支付宝订单号 / 流水号。
/// 用于本地去重。
/// </summary>
public string OrderNo { get; init; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 付款说明 / 备注。
/// </summary>
public string Remark { get; init; } = string.Empty;
/// <summary>
/// 收款时间。
/// </summary>
public DateTimeOffset PaidAt { get; init; }
/// <summary>
/// 付款人信息。
/// </summary>
public string Payer { get; init; } = string.Empty;
/// <summary>
/// 原始响应 JSON便于调试。
/// </summary>
public string RawJson { get; init; } = string.Empty;
}
/// <summary>
/// 支付宝监控配置。
/// 该类只保存“支付宝监控”所需的核心参数。
/// </summary>
public sealed class AlipayMonitorOptions
{
/// <summary>
/// 支付宝登录页地址。
/// 实战中可根据抓包结果改成更稳定的登录入口。
/// </summary>
public string LoginUrl { get; set; } = "https://auth.alipay.com/login/index.htm";
/// <summary>
/// 个人账单接口地址。
/// 注意:这里先给出占位地址,真实项目中必须通过 F12 抓包确认。
/// </summary>
public string BillApiUrl { get; set; } = "https://consumeprod.alipay.com/record/advanced.htm";
/// <summary>
/// 轮询最小间隔秒数。
/// 为了降低风控,不建议写死固定频率。
/// </summary>
public int MinPollSeconds { get; set; } = 15;
/// <summary>
/// 轮询最大间隔秒数。
/// </summary>
public int MaxPollSeconds { get; set; } = 35;
/// <summary>
/// 可选:支付宝 AppId。
/// 如果某些接口请求头 / 参数里需要带上,可从 UI 配置传入。
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 可选:支付宝用户 PID / UserId。
/// 如果后续业务需要绑定到账户信息,可以通过配置保存。
/// </summary>
public string UserId { get; set; } = string.Empty;
/// <summary>
/// 可选:从真实请求 URL 中提取出来的 ctoken。
/// 许多支付宝接口会把它作为防 CSRF / 会话校验参数放在 QueryString 中。
/// </summary>
public string CToken { get; set; } = string.Empty;
/// <summary>
/// 可选JSONP 回调名。
/// 某些支付宝接口返回 callback({...}) 这种 JSONP而不是纯 JSON。
/// </summary>
public string JsonpCallback { get; set; } = "callback";
/// <summary>
/// 账单查询条数。
/// </summary>
public int PageSize { get; set; } = 10;
/// <summary>
/// 轮询时默认回查最近多少天的账单。
/// </summary>
public int QueryDays { get; set; } = 1;
}
/// <summary>
/// 支付宝账单接口响应模型示例。
/// 注意:真实字段名要以 F12 抓到的 JSON 为准,这里只是演示“如何解析 JSON”。
/// </summary>
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; }
/// <summary>
/// 支付宝消息中心接口常见字段ok / fail。
/// 你的当前接口 getMsgInfosNew.json 返回的就是这一套结构。
/// </summary>
[JsonPropertyName("stat")]
public string Stat { get; set; } = string.Empty;
/// <summary>
/// 支付宝消息中心返回的消息数组。
/// </summary>
[JsonPropertyName("infos")]
public List<AlipayBillRecord> Infos { get; set; } = new();
}
/// <summary>
/// 支付宝账单接口 data 节点。
/// </summary>
public sealed class AlipayBillApiData
{
[JsonPropertyName("records")]
public List<AlipayBillRecord> Records { get; set; } = new();
}
/// <summary>
/// 单笔账单记录示例。
/// 真实字段名称、结构、时间格式请以抓包结果为准后再微调。
/// </summary>
public sealed class AlipayBillRecord
{
/// <summary>
/// 支付宝交易流水号 / 订单号。
/// 去重时优先使用这个字段。
/// </summary>
[JsonPropertyName("tradeNo")]
public string TradeNo { get; set; } = string.Empty;
/// <summary>
/// 订单号备用字段。
/// 有些接口可能叫 bizInNo / trade_no / orderNo。
/// </summary>
[JsonPropertyName("orderNo")]
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
[JsonPropertyName("amount")]
public decimal Amount { get; set; }
/// <summary>
/// 备注。
/// </summary>
[JsonPropertyName("remark")]
public string Remark { get; set; } = string.Empty;
/// <summary>
/// 付款方昵称。
/// </summary>
[JsonPropertyName("payerName")]
public string PayerName { get; set; } = string.Empty;
/// <summary>
/// 状态,例如 SUCCESS。
/// </summary>
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
/// <summary>
/// 支付时间文本。
/// 真实接口可能是 yyyy-MM-dd HH:mm:ss也可能是时间戳。
/// </summary>
[JsonPropertyName("gmtCreate")]
public string GmtCreate { get; set; } = string.Empty;
/// <summary>
/// 兜底接收真实支付宝接口中的其它字段。
/// 当当前模型字段名与真实返回不一致时,可从这里继续提取。
/// </summary>
[JsonExtensionData]
public Dictionary<string, JsonElement> 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);
}
}
/// <summary>
/// 支付宝 Web 轮询监听核心类。
/// 职责:
/// 1. 接收登录后 Cookie
/// 2. 用 HttpClient + CookieContainer 调用账单接口
/// 3. 随机延迟轮询,降低风控
/// 4. 基于订单号进行本地内存去重
/// 5. 当 Cookie 失效时通过 Event 通知 UI 停止轮询
/// </summary>
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<string> _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;
/// <summary>
/// 状态变更事件。
/// </summary>
public event EventHandler<AlipayStatusChangedEventArgs>? StatusChanged;
/// <summary>
/// 检测到新收款事件。
/// </summary>
public event EventHandler<AlipayPaymentDetectedEventArgs>? PaymentDetected;
/// <summary>
/// 当前是否正在轮询。
/// </summary>
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;
}
}
/// <summary>
/// 设置登录后的 Cookie。
/// 每次重新扫码登录后,都应该调用这个方法刷新 HttpClient 会话。
/// </summary>
/// <param name="cookieContainer">由 WebView2 Cookie 转换而来的 CookieContainer。</param>
public void SetCookies(CookieContainer cookieContainer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_cookieContainer = cookieContainer ?? throw new ArgumentNullException(nameof(cookieContainer));
_hasCompletedInitialSnapshot = false;
RecreateHttpClient();
RaiseStatus("Ready", "支付宝 Cookie 已更新,可以开始轮询。");
}
/// <summary>
/// 更新当前会话使用的 ctoken。
/// 一般在 WebView2 登录完成后,和 Cookie 一起刷新。
/// </summary>
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}");
}
/// <summary>
/// 手动导入已保存的订单号,用于重启程序后避免重复推送最近账单。
/// 如果不需要,可不调用。
/// </summary>
public void SeedProcessedOrders(IEnumerable<string> orderNos)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (orderNos == null)
{
return;
}
lock (_syncRoot)
{
foreach (var orderNo in orderNos)
{
if (!string.IsNullOrWhiteSpace(orderNo))
{
_processedOrderNos.Add(orderNo.Trim());
}
}
}
}
/// <summary>
/// 启动后台轮询任务。
/// 采用 Task + Task.Delay 方式实现,便于取消与控制随机间隔。
/// </summary>
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);
}
/// <summary>
/// 停止后台轮询。
/// </summary>
public void Stop()
{
if (_pollingCts == null)
{
return;
}
try
{
_pollingCts.Cancel();
}
catch
{
}
finally
{
_pollingCts.Dispose();
_pollingCts = null;
}
}
/// <summary>
/// 单次轮询账单接口。
/// 这里演示了:
/// 1. 如何带 Cookie 发请求
/// 2. 如何判断 403 / 302
/// 3. 如何解析 JSON
/// 4. 如何进行本地去重
/// </summary>
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("<tbody", StringComparison.OrdinalIgnoreCase)},响应预览:{TrimForLog(rawResponseText, 500)}");
var json = TryExtractJsonFromJsonp(rawResponseText);
AlipayBillApiResponse? payload;
try
{
payload = JsonSerializer.Deserialize<AlipayBillApiResponse>(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<AlipayBillRecord>();
}
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);
}
}
/// <summary>
/// 拉取支付宝账单页内容。
/// 对 advanced.htm 优先使用 GET 获取真实 HTML 页面源码;
/// 若未命中表格,再退回 POST 表单方式。
/// 这样更贴近你在浏览器地址栏直接访问 advanced.htm 时看到的页面结果。
/// </summary>
private async Task<string> 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);
}
/// <summary>
/// 构建账单接口请求。
/// 注意:这里的 QueryString / Header / Referer 都只是“示例模板”,
/// 必须根据你 F12 抓到的真实请求进行替换。
/// </summary>
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<string, string>
{
["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<string> 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<string> 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<string, string>
{
["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;
}
/// <summary>
/// 根据最新 Cookie 重建 HttpClient。
/// 必须使用 AllowAutoRedirect = false
/// 这样当服务器返回 302 时,我们才能第一时间识别出会话异常,而不是被自动跳转“吃掉”。
/// </summary>
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)
};
}
/// <summary>
/// 当检测到 Cookie 失效时:
/// 1. 通知主界面
/// 2. 自动停止轮询
/// </summary>
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<AlipayBillRecord> 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;
}
/// <summary>
/// 兼容支付宝返回 JSONP 的情况。
/// 例如callback({...}) 需要先剥离外层回调壳,才能继续走 JSON 反序列化。
/// </summary>
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<AlipayBillRecord> records)
{
records = new List<AlipayBillRecord>();
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, "<script[\\s\\S]*?</script>", string.Empty, RegexOptions.IgnoreCase);
normalized = Regex.Replace(normalized, "<style[\\s\\S]*?</style>", string.Empty, RegexOptions.IgnoreCase);
normalized = WebUtility.HtmlDecode(normalized);
var rowMatches = Regex.Matches(
normalized,
"<tr[^>]*>([\\s\\S]*?)</tr>",
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 = $"<td[^>]*class=\"[^\"]*\\b{Regex.Escape(className)}\\b[^\"]*\"[^>]*>([\\s\\S]*?)</td>";
var match = Regex.Match(rowHtml, pattern, RegexOptions.IgnoreCase);
if (match.Success)
{
return match.Groups[1].Value;
}
pattern = $"<td[^>]*data-role=\"[^\"]*\\b{Regex.Escape(className)}\\b[^\"]*\"[^>]*>([\\s\\S]*?)</td>";
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, "<br\\s*/?>", " ", 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,
"(?<!\\d)([0-9A-Za-z]{16,64})(?!\\d)",
RegexOptions.IgnoreCase);
if (directMatch.Success)
{
return directMatch.Groups[1].Value.Trim();
}
var htmlMatch = Regex.Match(
rowHtml,
"(?:tradeNo|orderNo|bizInNo|bizNo|id|messageId)[\"'=:\\s>]+([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<AlipayBillRecord>? 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<List<AlipayBillRecord>>(node.GetRawText(), _jsonOptions) ?? new List<AlipayBillRecord>();
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<List<AlipayBillRecord>>(nestedNode.GetRawText(), _jsonOptions) ?? new List<AlipayBillRecord>();
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;
}
}
/// <summary>
/// 支付宝 WebView2 登录窗口。
/// 用于让用户扫码登录支付宝网页版,并在登录成功后自动提取 Cookie。
/// </summary>
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;
/// <summary>
/// 当检测到登录成功并提取到 Cookie 后触发。
/// </summary>
public event EventHandler<AlipayLoginSucceededEventArgs>? LoginSucceeded;
/// <summary>
/// 当登录页发生异常时触发。
/// </summary>
public event EventHandler<AlipayStatusChangedEventArgs>? 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
{
}
}
/// <summary>
/// 通过 URL 跳转初步判断“是否已经登录成功”。
/// 注意:不同版本支付宝网页版,登录成功后的 URL 可能不同,
/// 这里建议你在实测时打印 URL并按真实情况补充判定规则。
/// </summary>
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;
}
}
}
/// <summary>
/// 通过 DOM 元素辅助判断登录成功。
/// 某些站点不会明显跳转 URL但会在登录后渲染“账单/交易记录/退出”等元素。
/// 这里给出一个执行 JS 检测页面文本的示例。
/// </summary>
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<string>(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<bool> 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;
}
/// <summary>
/// 登录成功后提取全部 Cookie并转换为 HttpClient 可用的 CookieContainer。
/// </summary>
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;
}
/// <summary>
/// 判断当前 URL 是否疑似登录成功。
/// 真实项目里请把你抓到的“登录后页面 URL 特征”补充到这里。
/// </summary>
private string ResolveCtoken(string currentUrl, IReadOnlyList<CoreWebView2Cookie> 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);
}
}
/// <summary>
/// WebView2 Cookie 帮助类。
/// 负责把 WebView2 Cookie 列表转换为 HttpClient 可直接使用的 CookieContainer。
/// </summary>
public static class WebView2CookieHelper
{
/// <summary>
/// 将 WebView2 返回的 Cookie 列表转换为 CookieContainer。
/// 这是“浏览器登录态 -> HttpClient 轮询态”最关键的一步。
/// </summary>
public static CookieContainer ConvertToCookieContainer(IEnumerable<CoreWebView2Cookie> 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;
}
/// <summary>
/// 规范化 Cookie 域名。
/// 例如把 ".alipay.com" 转成 "alipay.com",以兼容 CookieContainer.Add。
/// </summary>
private static string NormalizeCookieDomain(string domain)
{
var value = domain.Trim();
while (value.StartsWith('.'))
{
value = value[1..];
}
return value;
}
}