diff --git a/AlipayMonitor.cs b/AlipayMonitor.cs
new file mode 100644
index 0000000..855d524
--- /dev/null
+++ b/AlipayMonitor.cs
@@ -0,0 +1,1955 @@
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Web.WebView2.WinForms;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+
+namespace Vmianqian;
+
+///
+/// 支付宝监控状态变更事件参数。
+/// 用于通知主界面:登录成功、Cookie 失效、轮询启动、轮询停止、发生异常等。
+///
+public sealed class AlipayStatusChangedEventArgs : EventArgs
+{
+ ///
+ /// 当前状态代码,例如:Ready / Running / Stopped / CookieExpired / Error
+ ///
+ public string StatusCode { get; init; } = string.Empty;
+
+ ///
+ /// 给 UI 展示的中文描述信息。
+ ///
+ public string Message { get; init; } = string.Empty;
+
+ ///
+ /// 可选异常对象,便于调用方记录详细日志。
+ ///
+ public Exception? Exception { get; init; }
+}
+
+///
+/// 支付宝登录成功事件参数。
+/// 当用户在 WebView2 中完成扫码登录后,提取 Cookie 并通知主界面。
+///
+public sealed class AlipayLoginSucceededEventArgs : EventArgs
+{
+ ///
+ /// 提取到的原始 Cookie 列表。
+ ///
+ public IReadOnlyList Cookies { get; init; } = Array.Empty();
+
+ ///
+ /// 已转换为 HttpClient 可用格式的 CookieContainer。
+ ///
+ public CookieContainer CookieContainer { get; init; } = new();
+
+ ///
+ /// 自动提取到的 ctoken。
+ /// 可来自请求 URL 参数,也可来自 Cookie。
+ ///
+ public string CToken { get; init; } = string.Empty;
+
+ ///
+ /// 当前登录完成后所在地址。
+ ///
+ public string CurrentUrl { get; init; } = string.Empty;
+}
+
+///
+/// 支付宝账单事件。
+/// 当轮询到一笔新的收款记录时,向外抛出此事件,便于主界面更新表格、调用服务端回调。
+///
+public sealed class AlipayPaymentDetectedEventArgs : EventArgs
+{
+ ///
+ /// 支付宝订单号 / 流水号。
+ /// 用于本地去重。
+ ///
+ public string OrderNo { get; init; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 付款说明 / 备注。
+ ///
+ public string Remark { get; init; } = string.Empty;
+
+ ///
+ /// 收款时间。
+ ///
+ public DateTimeOffset PaidAt { get; init; }
+
+ ///
+ /// 付款人信息。
+ ///
+ public string Payer { get; init; } = string.Empty;
+
+ ///
+ /// 原始响应 JSON,便于调试。
+ ///
+ public string RawJson { get; init; } = string.Empty;
+}
+
+///
+/// 支付宝监控配置。
+/// 该类只保存“支付宝监控”所需的核心参数。
+///
+public sealed class AlipayMonitorOptions
+{
+ ///
+ /// 支付宝登录页地址。
+ /// 实战中可根据抓包结果改成更稳定的登录入口。
+ ///
+ public string LoginUrl { get; set; } = "https://auth.alipay.com/login/index.htm";
+
+ ///
+ /// 个人账单接口地址。
+ /// 注意:这里先给出占位地址,真实项目中必须通过 F12 抓包确认。
+ ///
+ public string BillApiUrl { get; set; } = "https://consumeprod.alipay.com/record/advanced.htm";
+
+ ///
+ /// 轮询最小间隔秒数。
+ /// 为了降低风控,不建议写死固定频率。
+ ///
+ public int MinPollSeconds { get; set; } = 15;
+
+ ///
+ /// 轮询最大间隔秒数。
+ ///
+ public int MaxPollSeconds { get; set; } = 35;
+
+ ///
+ /// 可选:支付宝 AppId。
+ /// 如果某些接口请求头 / 参数里需要带上,可从 UI 配置传入。
+ ///
+ public string AppId { get; set; } = string.Empty;
+
+ ///
+ /// 可选:支付宝用户 PID / UserId。
+ /// 如果后续业务需要绑定到账户信息,可以通过配置保存。
+ ///
+ public string UserId { get; set; } = string.Empty;
+
+ ///
+ /// 可选:从真实请求 URL 中提取出来的 ctoken。
+ /// 许多支付宝接口会把它作为防 CSRF / 会话校验参数放在 QueryString 中。
+ ///
+ public string CToken { get; set; } = string.Empty;
+
+ ///
+ /// 可选:JSONP 回调名。
+ /// 某些支付宝接口返回 callback({...}) 这种 JSONP,而不是纯 JSON。
+ ///
+ public string JsonpCallback { get; set; } = "callback";
+
+ ///
+ /// 账单查询条数。
+ ///
+ public int PageSize { get; set; } = 10;
+
+ ///
+ /// 轮询时默认回查最近多少天的账单。
+ ///
+ public int QueryDays { get; set; } = 1;
+}
+
+///
+/// 支付宝账单接口响应模型示例。
+/// 注意:真实字段名要以 F12 抓到的 JSON 为准,这里只是演示“如何解析 JSON”。
+///
+public sealed class AlipayBillApiResponse
+{
+ [JsonPropertyName("success")]
+ public bool Success { get; set; }
+
+ [JsonPropertyName("message")]
+ public string Message { get; set; } = string.Empty;
+
+ [JsonPropertyName("data")]
+ public AlipayBillApiData? Data { get; set; }
+
+ ///
+ /// 支付宝消息中心接口常见字段:ok / fail。
+ /// 你的当前接口 getMsgInfosNew.json 返回的就是这一套结构。
+ ///
+ [JsonPropertyName("stat")]
+ public string Stat { get; set; } = string.Empty;
+
+ ///
+ /// 支付宝消息中心返回的消息数组。
+ ///
+ [JsonPropertyName("infos")]
+ public List Infos { get; set; } = new();
+}
+
+///
+/// 支付宝账单接口 data 节点。
+///
+public sealed class AlipayBillApiData
+{
+ [JsonPropertyName("records")]
+ public List Records { get; set; } = new();
+}
+
+///
+/// 单笔账单记录示例。
+/// 真实字段名称、结构、时间格式请以抓包结果为准后再微调。
+///
+public sealed class AlipayBillRecord
+{
+ ///
+ /// 支付宝交易流水号 / 订单号。
+ /// 去重时优先使用这个字段。
+ ///
+ [JsonPropertyName("tradeNo")]
+ public string TradeNo { get; set; } = string.Empty;
+
+ ///
+ /// 订单号备用字段。
+ /// 有些接口可能叫 bizInNo / trade_no / orderNo。
+ ///
+ [JsonPropertyName("orderNo")]
+ public string OrderNo { get; set; } = string.Empty;
+
+ ///
+ /// 金额。
+ ///
+ [JsonPropertyName("amount")]
+ public decimal Amount { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ [JsonPropertyName("remark")]
+ public string Remark { get; set; } = string.Empty;
+
+ ///
+ /// 付款方昵称。
+ ///
+ [JsonPropertyName("payerName")]
+ public string PayerName { get; set; } = string.Empty;
+
+ ///
+ /// 状态,例如 SUCCESS。
+ ///
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// 支付时间文本。
+ /// 真实接口可能是 yyyy-MM-dd HH:mm:ss,也可能是时间戳。
+ ///
+ [JsonPropertyName("gmtCreate")]
+ public string GmtCreate { get; set; } = string.Empty;
+
+ ///
+ /// 兜底接收真实支付宝接口中的其它字段。
+ /// 当当前模型字段名与真实返回不一致时,可从这里继续提取。
+ ///
+ [JsonExtensionData]
+ public Dictionary Extra { get; set; } = new();
+
+ public string GetFirstNonEmpty(params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (!Extra.TryGetValue(name, out var element))
+ {
+ continue;
+ }
+
+ var value = element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString(),
+ JsonValueKind.Number => element.ToString(),
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ _ => element.ToString()
+ };
+
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value.Trim();
+ }
+ }
+
+ return string.Empty;
+ }
+
+ public decimal TryGetAmountFromExtra()
+ {
+ foreach (var key in new[] { "amount", "totalAmount", "transAmount", "price", "money", "paidAmount" })
+ {
+ if (!Extra.TryGetValue(key, out var element))
+ {
+ continue;
+ }
+
+ if (element.ValueKind == JsonValueKind.Number && element.TryGetDecimal(out var decimalValue))
+ {
+ return decimalValue;
+ }
+
+ var text = element.ToString();
+ if (decimal.TryParse(text, out decimalValue))
+ {
+ return decimalValue;
+ }
+ }
+
+ var mergedText = string.Join(" ", new[]
+ {
+ Remark,
+ GetFirstNonEmpty("content", "title", "mainDesc", "desc", "memo")
+ }.Where(x => !string.IsNullOrWhiteSpace(x)));
+
+ var match = Regex.Match(mergedText, @"(?:¥|¥)?\s*(\d+(?:\.\d{1,2})?)");
+ if (match.Success && decimal.TryParse(match.Groups[1].Value, out var parsed))
+ {
+ return parsed;
+ }
+
+ return 0m;
+ }
+
+ public string BuildDebugPreview()
+ {
+ if (Extra.Count == 0)
+ {
+ return $"tradeNo={TradeNo}, orderNo={OrderNo}, amount={Amount}, remark={Remark}, payerName={PayerName}, gmtCreate={GmtCreate}";
+ }
+
+ var parts = Extra
+ .Take(12)
+ .Select(x => $"{x.Key}={x.Value}")
+ .ToList();
+
+ return string.Join(", ", parts);
+ }
+}
+
+///
+/// 支付宝 Web 轮询监听核心类。
+/// 职责:
+/// 1. 接收登录后 Cookie
+/// 2. 用 HttpClient + CookieContainer 调用账单接口
+/// 3. 随机延迟轮询,降低风控
+/// 4. 基于订单号进行本地内存去重
+/// 5. 当 Cookie 失效时通过 Event 通知 UI 停止轮询
+///
+public sealed class AlipayMonitor : IDisposable
+{
+ static AlipayMonitor()
+ {
+ // .NET Core / .NET 5+ 默认不加载 GBK/GB2312/GB18030 等代码页。
+ // 支付宝部分 HTML 页面仍可能使用这些中文编码,因此这里必须显式注册。
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
+
+ private readonly AlipayMonitorOptions _options;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly Random _random = new();
+ private readonly HashSet _processedOrderNos = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _syncRoot = new();
+
+ private HttpClient? _httpClient;
+ private HttpClientHandler? _httpHandler;
+ private CancellationTokenSource? _pollingCts;
+ private CookieContainer? _cookieContainer;
+ private bool _hasCompletedInitialSnapshot;
+ private bool _disposed;
+
+ ///
+ /// 状态变更事件。
+ ///
+ public event EventHandler? StatusChanged;
+
+ ///
+ /// 检测到新收款事件。
+ ///
+ public event EventHandler? PaymentDetected;
+
+ ///
+ /// 当前是否正在轮询。
+ ///
+ public bool IsRunning => _pollingCts is { IsCancellationRequested: false };
+
+ public AlipayMonitor(AlipayMonitorOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+
+ if (_options.MinPollSeconds <= 0)
+ {
+ _options.MinPollSeconds = 15;
+ }
+
+ if (_options.MaxPollSeconds < _options.MinPollSeconds)
+ {
+ _options.MaxPollSeconds = _options.MinPollSeconds;
+ }
+ }
+
+ ///
+ /// 设置登录后的 Cookie。
+ /// 每次重新扫码登录后,都应该调用这个方法刷新 HttpClient 会话。
+ ///
+ /// 由 WebView2 Cookie 转换而来的 CookieContainer。
+ public void SetCookies(CookieContainer cookieContainer)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ _cookieContainer = cookieContainer ?? throw new ArgumentNullException(nameof(cookieContainer));
+ _hasCompletedInitialSnapshot = false;
+ RecreateHttpClient();
+ RaiseStatus("Ready", "支付宝 Cookie 已更新,可以开始轮询。");
+ }
+
+ ///
+ /// 更新当前会话使用的 ctoken。
+ /// 一般在 WebView2 登录完成后,和 Cookie 一起刷新。
+ ///
+ public void SetCtoken(string? ctoken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ _options.CToken = ctoken?.Trim() ?? string.Empty;
+ RaiseStatus("Ready", string.IsNullOrWhiteSpace(_options.CToken)
+ ? "支付宝 ctoken 未提取到,当前仅使用 Cookie 轮询。"
+ : $"支付宝 ctoken 已更新:{_options.CToken}");
+ }
+
+ ///
+ /// 手动导入已保存的订单号,用于重启程序后避免重复推送最近账单。
+ /// 如果不需要,可不调用。
+ ///
+ public void SeedProcessedOrders(IEnumerable orderNos)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (orderNos == null)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ foreach (var orderNo in orderNos)
+ {
+ if (!string.IsNullOrWhiteSpace(orderNo))
+ {
+ _processedOrderNos.Add(orderNo.Trim());
+ }
+ }
+ }
+ }
+
+ ///
+ /// 启动后台轮询任务。
+ /// 采用 Task + Task.Delay 方式实现,便于取消与控制随机间隔。
+ ///
+ public void Start()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_cookieContainer == null || _httpClient == null)
+ {
+ throw new InvalidOperationException("尚未设置支付宝 Cookie,请先登录并提取 Cookie。");
+ }
+
+ if (IsRunning)
+ {
+ return;
+ }
+
+ _pollingCts = new CancellationTokenSource();
+ var token = _pollingCts.Token;
+
+ _ = Task.Run(async () =>
+ {
+ RaiseStatus("Running", "支付宝轮询已启动。");
+
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ await PollOnceAsync(token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ RaiseStatus("Error", $"支付宝轮询发生异常:{ex.Message}", ex);
+ }
+
+ var delaySeconds = _random.Next(_options.MinPollSeconds, _options.MaxPollSeconds + 1);
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+
+ RaiseStatus("Stopped", "支付宝轮询已停止。");
+ }, token);
+ }
+
+ ///
+ /// 停止后台轮询。
+ ///
+ public void Stop()
+ {
+ if (_pollingCts == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _pollingCts.Cancel();
+ }
+ catch
+ {
+ }
+ finally
+ {
+ _pollingCts.Dispose();
+ _pollingCts = null;
+ }
+ }
+
+ ///
+ /// 单次轮询账单接口。
+ /// 这里演示了:
+ /// 1. 如何带 Cookie 发请求
+ /// 2. 如何判断 403 / 302
+ /// 3. 如何解析 JSON
+ /// 4. 如何进行本地去重
+ ///
+ private async Task PollOnceAsync(CancellationToken cancellationToken)
+ {
+ if (_httpClient == null)
+ {
+ throw new InvalidOperationException("HttpClient 未初始化。");
+ }
+
+ var rawResponseText = await FetchBillPageContentAsync(cancellationToken);
+
+ if (TryReadRecordsFromHtml(rawResponseText, out var htmlRecords))
+ {
+ RaiseStatus("Trace", $"支付宝 HTML 页面解析成功,本次返回记录数:{htmlRecords.Count}");
+ foreach (var record in htmlRecords)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ProcessRecord(record, rawResponseText);
+ }
+ return;
+ }
+
+ RaiseStatus("Trace",
+ $"支付宝响应未命中账单表格。tradeRecordsIndex={rawResponseText.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase)},J-item={rawResponseText.Contains("J-item-", StringComparison.OrdinalIgnoreCase)},tbody={rawResponseText.Contains("(json, _jsonOptions);
+ }
+ catch (Exception ex)
+ {
+ // advanced.htm 很可能返回 HTML,因此这里不要立刻判定 Cookie 失效。
+ RaiseStatus("Trace", $"支付宝响应不是可解析 JSON:{ex.Message},原始响应预览:{TrimForLog(rawResponseText)}");
+ RaiseCookieExpiredAndStop($"支付宝账单页面解析失败,疑似 Cookie 失效或触发验证。原始响应预览:{TrimForLog(rawResponseText)}");
+ return;
+ }
+
+ // 兼容多种常见结构:
+ // 1. data.records
+ // 2. stat + infos
+ // 3. 根节点 records/list/tradeList
+ var records = payload?.Data?.Records;
+ if ((records == null || records.Count == 0) && payload?.Infos != null)
+ {
+ records = payload.Infos;
+ }
+
+ if ((records == null || records.Count == 0) && TryReadRecordsFromRoot(json, out var rootRecords))
+ {
+ records = rootRecords;
+ }
+
+ // 如果 stat=ok,即使 infos 为空,也说明 Cookie 实际可用,只是当前没有账单消息。
+ if (payload != null &&
+ string.Equals(payload.Stat, "ok", StringComparison.OrdinalIgnoreCase) &&
+ records == null)
+ {
+ records = new List();
+ }
+
+ if (records == null)
+ {
+ RaiseCookieExpiredAndStop($"支付宝账单响应结构异常,疑似会话失效。原始响应预览:{TrimForLog(rawResponseText)}");
+ return;
+ }
+
+ RaiseStatus("Trace", $"支付宝轮询成功,本次返回记录数:{records.Count}");
+
+ if (!_hasCompletedInitialSnapshot)
+ {
+ var seededCount = SeedInitialSnapshot(records);
+ _hasCompletedInitialSnapshot = true;
+ RaiseStatus("Ready", $"支付宝首次账单基线预热完成,已载入 {seededCount} 条历史记录,后续仅处理新收款。");
+ return;
+ }
+
+ foreach (var record in records)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ProcessRecord(record, rawResponseText);
+ }
+ }
+
+ ///
+ /// 拉取支付宝账单页内容。
+ /// 对 advanced.htm 优先使用 GET 获取真实 HTML 页面源码;
+ /// 若未命中表格,再退回 POST 表单方式。
+ /// 这样更贴近你在浏览器地址栏直接访问 advanced.htm 时看到的页面结果。
+ ///
+ private async Task FetchBillPageContentAsync(CancellationToken cancellationToken)
+ {
+ var isConsumeHtmlPage = _options.BillApiUrl.Contains("consumeprod.alipay.com/record/advanced.htm", StringComparison.OrdinalIgnoreCase);
+ if (!isConsumeHtmlPage)
+ {
+ using var request = BuildBillRequest();
+ using var response = await _httpClient!.SendAsync(request, cancellationToken);
+ return await EnsureResponseAndReadAsync(response, cancellationToken);
+ }
+
+ using var getRequest = BuildConsumeHtmlGetRequest();
+ using var getResponse = await _httpClient!.SendAsync(getRequest, cancellationToken);
+ var getHtml = await EnsureResponseAndReadAsync(getResponse, cancellationToken);
+
+ if (LooksLikeBillHtml(getHtml))
+ {
+ return getHtml;
+ }
+
+ RaiseStatus("Trace", $"支付宝 GET advanced.htm 未命中表格,尝试回退 POST。响应预览:{TrimForLog(getHtml, 400)}");
+
+ using var postRequest = BuildConsumeHtmlPostRequest();
+ using var postResponse = await _httpClient!.SendAsync(postRequest, cancellationToken);
+ return await EnsureResponseAndReadAsync(postResponse, cancellationToken);
+ }
+
+ ///
+ /// 构建账单接口请求。
+ /// 注意:这里的 QueryString / Header / Referer 都只是“示例模板”,
+ /// 必须根据你 F12 抓到的真实请求进行替换。
+ ///
+ private HttpRequestMessage BuildBillRequest()
+ {
+ var isEnterpriseBillApi = _options.BillApiUrl.Contains("simpleTradeOrderQuery", StringComparison.OrdinalIgnoreCase) ||
+ _options.BillApiUrl.Contains("mbillexprod.alipay.com", StringComparison.OrdinalIgnoreCase);
+ var isConsumeHtmlPage = _options.BillApiUrl.Contains("consumeprod.alipay.com/record/advanced.htm", StringComparison.OrdinalIgnoreCase);
+
+ HttpRequestMessage request;
+ if (isConsumeHtmlPage)
+ {
+ request = BuildConsumeHtmlGetRequest();
+ }
+ else if (isEnterpriseBillApi)
+ {
+ request = BuildEnterpriseBillPostRequest();
+ }
+ else
+ {
+ request = BuildLegacyGetRequest();
+ }
+
+ ApplyDefaultRequestHeaders(request, isHtmlPage: isConsumeHtmlPage);
+ return request;
+ }
+
+ private HttpRequestMessage BuildConsumeHtmlGetRequest()
+ {
+ var url = _options.BillApiUrl;
+ if (!string.IsNullOrWhiteSpace(_options.CToken))
+ {
+ url += (_options.BillApiUrl.Contains('?') ? "&" : "?") + "ctoken=" + Uri.EscapeDataString(_options.CToken);
+ }
+
+ return new HttpRequestMessage(HttpMethod.Get, url);
+ }
+
+ private HttpRequestMessage BuildConsumeHtmlPostRequest()
+ {
+ var now = DateTime.Now;
+ var begin = now.Date.AddDays(-Math.Max(1, _options.QueryDays - 1));
+
+ var form = new Dictionary
+ {
+ ["beginDate"] = begin.ToString("yyyy.MM.dd"),
+ ["beginTime"] = "00:00",
+ ["endDate"] = now.ToString("yyyy.MM.dd"),
+ ["endTime"] = "24:00",
+ ["dateRange"] = _options.QueryDays <= 1 ? "today" : _options.QueryDays <= 7 ? "sevenDays" : "oneMonth",
+ ["status"] = "all",
+ ["keyword"] = "bizNo",
+ ["keyValue"] = string.Empty,
+ ["dateType"] = "createDate",
+ ["minAmount"] = string.Empty,
+ ["maxAmount"] = string.Empty,
+ ["fundFlow"] = "all",
+ ["tradeType"] = "ALL",
+ ["pageNum"] = "1",
+ ["_input_charset"] = "utf-8"
+ };
+
+ if (!string.IsNullOrWhiteSpace(_options.CToken))
+ {
+ form["ctoken"] = _options.CToken;
+ }
+
+ var request = new HttpRequestMessage(HttpMethod.Post, _options.BillApiUrl)
+ {
+ Content = new FormUrlEncodedContent(form)
+ };
+
+ ApplyDefaultRequestHeaders(request, isHtmlPage: true);
+ return request;
+ }
+
+ private HttpRequestMessage BuildLegacyGetRequest()
+ {
+ var builder = new StringBuilder();
+ builder.Append(_options.BillApiUrl);
+
+ var separator = _options.BillApiUrl.Contains('?') ? "&" : "?";
+ builder.Append(separator);
+ builder.Append("pageSize=").Append(_options.PageSize);
+
+ if (!string.IsNullOrWhiteSpace(_options.CToken))
+ {
+ builder.Append("&ctoken=").Append(Uri.EscapeDataString(_options.CToken));
+ }
+
+ if (!string.IsNullOrWhiteSpace(_options.JsonpCallback))
+ {
+ builder.Append("&_callback=").Append(Uri.EscapeDataString(_options.JsonpCallback));
+ }
+
+ builder.Append("&_input_charset=utf-8");
+ builder.Append("&_output_charset=utf-8");
+ builder.Append("&_=").Append(DateTimeOffset.Now.ToUnixTimeMilliseconds());
+
+ return new HttpRequestMessage(HttpMethod.Get, builder.ToString());
+ }
+
+ private void ApplyDefaultRequestHeaders(HttpRequestMessage request, bool isHtmlPage)
+ {
+ request.Headers.TryAddWithoutValidation("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36");
+ request.Headers.TryAddWithoutValidation("Accept", isHtmlPage
+ ? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
+ : "application/json, text/plain, */*");
+ request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9");
+
+ var referer = _options.BillApiUrl.Contains("consumeprod.alipay.com", StringComparison.OrdinalIgnoreCase)
+ ? "https://consumeprod.alipay.com/record/advanced.htm"
+ : "https://mbillexprod.alipay.com/";
+ var origin = _options.BillApiUrl.Contains("consumeprod.alipay.com", StringComparison.OrdinalIgnoreCase)
+ ? "https://consumeprod.alipay.com"
+ : "https://mbillexprod.alipay.com";
+
+ request.Headers.TryAddWithoutValidation("Referer", referer);
+ request.Headers.TryAddWithoutValidation("Origin", origin);
+
+ if (!isHtmlPage)
+ {
+ request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
+ }
+ }
+
+ private async Task EnsureResponseAndReadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ var rawResponseText = await ReadResponseTextSafeAsync(response, cancellationToken);
+
+ if (response.StatusCode == HttpStatusCode.Forbidden ||
+ response.StatusCode == HttpStatusCode.Found ||
+ response.StatusCode == HttpStatusCode.Moved ||
+ response.StatusCode == HttpStatusCode.Redirect)
+ {
+ var location = response.Headers.Location?.ToString() ?? string.Empty;
+ RaiseCookieExpiredAndStop(string.IsNullOrWhiteSpace(location)
+ ? $"支付宝返回 {(int)response.StatusCode},疑似 Cookie 失效或触发验证。"
+ : $"支付宝返回 {(int)response.StatusCode},疑似 Cookie 失效或触发验证。Location={location}");
+ return rawResponseText;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ RaiseStatus("Error", $"支付宝账单接口请求失败:HTTP {(int)response.StatusCode},响应:{TrimForLog(rawResponseText)}");
+ }
+
+ return rawResponseText;
+ }
+
+ private static async Task ReadResponseTextSafeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ if (bytes.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ var charset = response.Content.Headers.ContentType?.CharSet?.Trim().Trim('"');
+ var encoding = TryGetEncoding(charset);
+
+ if (encoding == null &&
+ !string.IsNullOrWhiteSpace(charset) &&
+ charset.Contains("gb", StringComparison.OrdinalIgnoreCase))
+ {
+ encoding = TryGetEncoding("gb18030");
+ }
+
+ encoding ??= DetectEncodingFromContent(bytes);
+
+ // 对支付宝中文 HTML,最后兜底优先使用 GB18030,而不是 UTF-8。
+ encoding ??= TryGetEncoding("gb18030");
+ encoding ??= new UTF8Encoding(false);
+
+ try
+ {
+ return encoding.GetString(bytes);
+ }
+ catch
+ {
+ return Encoding.UTF8.GetString(bytes);
+ }
+ }
+
+ private static Encoding? TryGetEncoding(string? charset)
+ {
+ if (string.IsNullOrWhiteSpace(charset))
+ {
+ return null;
+ }
+
+ try
+ {
+ return Encoding.GetEncoding(charset);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static Encoding? DetectEncodingFromContent(byte[] bytes)
+ {
+ try
+ {
+ if (bytes.Length >= 3 &&
+ bytes[0] == 0xEF &&
+ bytes[1] == 0xBB &&
+ bytes[2] == 0xBF)
+ {
+ return Encoding.UTF8;
+ }
+
+ var head = Encoding.ASCII.GetString(bytes, 0, Math.Min(bytes.Length, 4096));
+ var metaCharset = Regex.Match(
+ head,
+ "charset=([a-zA-Z0-9_\\-]+)",
+ RegexOptions.IgnoreCase);
+
+ if (metaCharset.Success)
+ {
+ var charsetName = metaCharset.Groups[1].Value.Trim();
+ var detected = TryGetEncoding(charsetName);
+ if (detected != null)
+ {
+ return detected;
+ }
+
+ if (charsetName.Contains("gb", StringComparison.OrdinalIgnoreCase))
+ {
+ detected = TryGetEncoding("gb18030");
+ if (detected != null)
+ {
+ return detected;
+ }
+ }
+ }
+
+ // 对中文站点优先尝试 GB18030,而不是直接回退 UTF-8。
+ return TryGetEncoding("gb18030") ?? Encoding.UTF8;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool LooksLikeBillHtml(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return false;
+ }
+
+ return text.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase) ||
+ text.Contains("table-index-bill", StringComparison.OrdinalIgnoreCase) ||
+ text.Contains("J-item-", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private HttpRequestMessage BuildEnterpriseBillPostRequest()
+ {
+ var now = DateTime.Now;
+ var begin = now.Date.AddDays(-Math.Max(1, _options.QueryDays - 1));
+ var end = now;
+
+ var form = new Dictionary
+ {
+ ["pageSize"] = _options.PageSize.ToString(),
+ ["pageNum"] = "1",
+ ["channelType"] = "ALL",
+ ["_input_charset"] = "utf-8",
+ ["_output_charset"] = "utf-8",
+ ["beginTime"] = begin.ToString("yyyy-MM-dd 00:00:00"),
+ ["endTime"] = end.ToString("yyyy-MM-dd HH:mm:ss")
+ };
+
+ if (!string.IsNullOrWhiteSpace(_options.CToken))
+ {
+ form["ctoken"] = _options.CToken;
+ }
+
+ var request = new HttpRequestMessage(HttpMethod.Post, _options.BillApiUrl)
+ {
+ Content = new FormUrlEncodedContent(form)
+ };
+
+ return request;
+ }
+
+ ///
+ /// 根据最新 Cookie 重建 HttpClient。
+ /// 必须使用 AllowAutoRedirect = false,
+ /// 这样当服务器返回 302 时,我们才能第一时间识别出会话异常,而不是被自动跳转“吃掉”。
+ ///
+ private void RecreateHttpClient()
+ {
+ _httpClient?.Dispose();
+ _httpHandler?.Dispose();
+
+ if (_cookieContainer == null)
+ {
+ throw new InvalidOperationException("CookieContainer 不能为空。");
+ }
+
+ _httpHandler = new HttpClientHandler
+ {
+ UseCookies = true,
+ CookieContainer = _cookieContainer,
+ AllowAutoRedirect = false,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
+ };
+
+ _httpClient = new HttpClient(_httpHandler)
+ {
+ Timeout = TimeSpan.FromSeconds(20)
+ };
+ }
+
+ ///
+ /// 当检测到 Cookie 失效时:
+ /// 1. 通知主界面
+ /// 2. 自动停止轮询
+ ///
+ private void RaiseCookieExpiredAndStop(string message)
+ {
+ RaiseStatus("CookieExpired", message);
+ Stop();
+ }
+
+ private void RaiseStatus(string statusCode, string message, Exception? exception = null)
+ {
+ StatusChanged?.Invoke(this, new AlipayStatusChangedEventArgs
+ {
+ StatusCode = statusCode,
+ Message = message,
+ Exception = exception
+ });
+ }
+
+ private static string TrimForLog(string value, int maxLength = 300)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ return value.Length <= maxLength ? value : value[..maxLength] + "...";
+ }
+
+ private static DateTimeOffset TryParseAlipayTime(string text)
+ {
+ if (DateTimeOffset.TryParse(text, out var parsed))
+ {
+ return parsed;
+ }
+
+ return DateTimeOffset.Now;
+ }
+
+ private int SeedInitialSnapshot(IEnumerable records)
+ {
+ var count = 0;
+
+ foreach (var record in records)
+ {
+ var orderNo = !string.IsNullOrWhiteSpace(record.TradeNo)
+ ? record.TradeNo.Trim()
+ : !string.IsNullOrWhiteSpace(record.OrderNo)
+ ? record.OrderNo.Trim()
+ : record.GetFirstNonEmpty("bizInNo", "trade_no", "tradeNo", "order_no", "orderNo", "bizNo", "id", "messageId");
+
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ continue;
+ }
+
+ lock (_syncRoot)
+ {
+ if (_processedOrderNos.Add(orderNo))
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// 兼容支付宝返回 JSONP 的情况。
+ /// 例如:callback({...}) 需要先剥离外层回调壳,才能继续走 JSON 反序列化。
+ ///
+ private void ProcessRecord(AlipayBillRecord record, string rawResponseText)
+ {
+ var orderNo = !string.IsNullOrWhiteSpace(record.TradeNo)
+ ? record.TradeNo.Trim()
+ : !string.IsNullOrWhiteSpace(record.OrderNo)
+ ? record.OrderNo.Trim()
+ : record.GetFirstNonEmpty("bizInNo", "trade_no", "tradeNo", "order_no", "orderNo", "bizNo", "id", "messageId");
+
+ if (string.IsNullOrWhiteSpace(orderNo))
+ {
+ RaiseStatus("Trace", $"支付宝记录缺少订单号,已跳过。字段预览:{TrimForLog(record.BuildDebugPreview(), 500)}");
+ return;
+ }
+
+ var statusText = !string.IsNullOrWhiteSpace(record.Status)
+ ? record.Status
+ : record.GetFirstNonEmpty("status", "tradeStatus", "state");
+
+ if (!string.IsNullOrWhiteSpace(statusText) &&
+ !statusText.Contains("SUCCESS", StringComparison.OrdinalIgnoreCase) &&
+ !statusText.Contains("交易成功", StringComparison.OrdinalIgnoreCase) &&
+ !statusText.Contains("收款成功", StringComparison.OrdinalIgnoreCase) &&
+ !statusText.Contains("已收款", StringComparison.OrdinalIgnoreCase) &&
+ !statusText.Contains("ok", StringComparison.OrdinalIgnoreCase))
+ {
+ RaiseStatus("Trace", $"支付宝记录状态非成功,已跳过。orderNo={orderNo},status={statusText}");
+ return;
+ }
+
+ bool isNew;
+ lock (_syncRoot)
+ {
+ isNew = _processedOrderNos.Add(orderNo);
+ }
+
+ if (!isNew)
+ {
+ RaiseStatus("Trace", $"支付宝记录重复,已跳过。orderNo={orderNo}");
+ return;
+ }
+
+ var amount = record.Amount != 0 ? record.Amount : record.TryGetAmountFromExtra();
+ if (amount <= 0)
+ {
+ RaiseStatus("Trace", $"支付宝记录金额非收入,已跳过。orderNo={orderNo},amount={amount:0.00}");
+ return;
+ }
+
+ var remark = !string.IsNullOrWhiteSpace(record.Remark)
+ ? record.Remark
+ : record.GetFirstNonEmpty("remark", "content", "title", "mainDesc", "desc", "memo");
+ var payer = !string.IsNullOrWhiteSpace(record.PayerName)
+ ? record.PayerName
+ : record.GetFirstNonEmpty("payerName", "payer", "userName", "nickName", "fromUserName", "oppositeName");
+ var timeText = !string.IsNullOrWhiteSpace(record.GmtCreate)
+ ? record.GmtCreate
+ : record.GetFirstNonEmpty("gmtCreate", "createTime", "messageTime", "time", "payTime");
+ var paidAt = TryParseAlipayTime(timeText);
+
+ RaiseStatus("Trace", $"支付宝发现新收款:orderNo={orderNo},amount={amount:0.00},remark={TrimForLog(remark, 80)}");
+
+ PaymentDetected?.Invoke(this, new AlipayPaymentDetectedEventArgs
+ {
+ OrderNo = orderNo,
+ Amount = amount,
+ Remark = remark,
+ PaidAt = paidAt,
+ Payer = payer,
+ RawJson = rawResponseText
+ });
+ }
+
+ private bool TryReadRecordsFromHtml(string html, out List records)
+ {
+ records = new List();
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return false;
+ }
+
+ if (!html.Contains("tradeRecordsIndex", StringComparison.OrdinalIgnoreCase) &&
+ !html.Contains("table-index-bill", StringComparison.OrdinalIgnoreCase) &&
+ !html.Contains("ui-record-table", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var normalized = Regex.Replace(html, "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+