1956 lines
65 KiB
C#
1956 lines
65 KiB
C#
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;
|
||
}
|
||
}
|