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, "