VmianqianC/Form1.cs

3649 lines
138 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using AntdUI;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Windows.Automation;
using Titanium.Web.Proxy;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Models;
using WinLabel = System.Windows.Forms.Label;
using WinPanel = System.Windows.Forms.Panel;
using WinTextBox = System.Windows.Forms.TextBox;
namespace Vmianqian
{
public partial class Form1 : BorderlessForm
{
private const int WmNclButtonDown = 0x00A1;
private const int HtCaption = 0x0002;
[DllImport("user32.dll")]
private static extern bool ReleaseCapture();
[DllImport("user32.dll")]
private static extern nint SendMessage(nint hWnd, int msg, nint wParam, nint lParam);
private readonly string _configFilePath = Path.Combine(AppContext.BaseDirectory, "appsettings.client.json");
private readonly string _pendingOrdersFilePath = Path.Combine(AppContext.BaseDirectory, "pending-orders.client.json");
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true };
private readonly HttpClient _httpClient = new();
private readonly System.Windows.Forms.Timer _runtimeTimer = new();
private readonly System.Windows.Forms.Timer _heartbeatTimer = new();
private DateTime _appStartTime = DateTime.Now;
private HttpListener? _httpListener;
private CancellationTokenSource? _listenerCancellationTokenSource;
private ClientConfig _config = new();
private List<PendingOrderRecord> _pendingOrders = new();
private bool _isSynchronizingNavigation;
private string _wechatMonitorMode = string.Empty;
private CancellationTokenSource? _wechatHookCts;
private CancellationTokenSource? _wechatProtocolCts;
private CancellationTokenSource? _wechatSidCaptureCts;
private ProxyServer? _proxyServer;
private readonly HashSet<string> _wechatHookSeen = new(StringComparer.Ordinal);
private readonly HashSet<string> _wechatProtocolSeen = new(StringComparer.Ordinal);
private string _wechatProtocolLastTransId = string.Empty;
private long _wechatProtocolLastCreateTime;
private int? _wechatProtocolLastPendingCount;
private int? _wechatProtocolLastHomeType;
private DateTime _wechatHookLastDiagAt = DateTime.MinValue;
private bool _wechatHookFoundLedgerAnchor;
private bool _heartbeatRequestInProgress;
private int _wechatProtocolAuthFailCount;
private Vmianqian.AlipayMonitor? _alipayMonitor;
private AntdUI.Label? _lblWechatSidHint;
private AntdUI.PageHeader titlebar = null!;
private AntdUI.PageHeader bottomBar = null!;
private AntdUI.Menu menu = null!;
private WinPanel contentHost = null!;
private AntdUI.Button buttonCollapse = null!;
private WinLabel lblRuntimeBottom = null!;
private string _currentPageKey = "home";
private WinPanel pageHome = null!;
private WinPanel pageWechat = null!;
private WinPanel pageAlipay = null!;
private WinPanel pageSettings = null!;
private AntdUI.Label lblSummaryTitle = null!;
private AntdUI.Label lblSummaryDesc = null!;
private AntdUI.Label lblTopNotice = null!;
private AntdUI.Label lblWechatStatusValue = null!;
private AntdUI.Label lblAlipayStatusValue = null!;
private AntdUI.Input txtServerUrl = null!;
private AntdUI.Input txtApiKey = null!;
private AntdUI.Input txtSenderEmail = null!;
private AntdUI.Input txtSmtpHost = null!;
private AntdUI.Input txtSmtpPort = null!;
private AntdUI.Input txtNotifyEmail = null!;
private AntdUI.Input txtEmailAuthCode = null!;
private AntdUI.Label lblServerUrlTitle = null!;
private AntdUI.Label lblApiKeyTitle = null!;
private AntdUI.Label lblSenderEmailTitle = null!;
private AntdUI.Label lblSmtpHostTitle = null!;
private AntdUI.Label lblSmtpPortTitle = null!;
private AntdUI.Label lblNotifyEmailTitle = null!;
private AntdUI.Label lblEmailAuthCodeTitle = null!;
private AntdUI.Label lblWechatSidTitle = null!;
private AntdUI.Label lblWechatFrequencyTitle = null!;
private AntdUI.Label lblHeartbeatDesc = null!;
private AntdUI.Label lblMemberPlaceholder = null!;
private AntdUI.Switch chkHeartbeatEnabled = null!;
private AntdUI.Button btnHeartbeatCheck = null!;
private AntdUI.Button btnClearLog = null!;
private WinTextBox txtLog = null!;
private AntdUI.Input txtWechatPath = null!;
private AntdUI.Input txtWechatId = null!;
private AntdUI.Button btnSelectWechatPath = null!;
private AntdUI.Button btnWechatHookStart = null!;
private AntdUI.Button btnWechatProtocolStart = null!;
private AntdUI.Button btnWechatSidAuto = null!;
private AntdUI.Button btnClearWechatLog = null!;
private NumericUpDown numWechatInterval = null!;
private AntdUI.Checkbox chkWheel = null!;
private DataGridView gridWechatLogs = null!;
private DataGridView gridAlipayLogs = null!;
private AntdUI.Button btnSaveConfig = null!;
private AntdUI.Button btnToggleService = null!;
private AntdUI.Button btnEmailSave = null!;
private AntdUI.Button btnEmailTest = null!;
public Form1()
{
InitializeComponent();
InitializeDesignerLayout();
InitializeWechatUi();
// InitializeAlipayUi();
ReloadMenuItems();
SelectPage("home");
if (IsInDesigner()) return;
Load += Form1_Load;
FormClosing += Form1_FormClosing;
}
private static bool IsInDesigner()
{
return LicenseManager.UsageMode == LicenseUsageMode.Designtime;
}
private void InitializeDesignerLayout()
{
pageHome.HorizontalScroll.Enabled = false;
pageHome.HorizontalScroll.Visible = false;
pageWechat.HorizontalScroll.Enabled = false;
pageWechat.HorizontalScroll.Visible = false;
pageAlipay.HorizontalScroll.Enabled = false;
pageAlipay.HorizontalScroll.Visible = false;
pageSettings.HorizontalScroll.Enabled = false;
pageSettings.HorizontalScroll.Visible = false;
if (IsInDesigner()) return;
EnableWindowDrag(titlebar);
EnableWindowDrag(lblTopNotice);
EnableWindowDrag(lblWechatStatusValue);
EnableWindowDrag(lblAlipayStatusValue);
}
private void InitializeWechatUi()
{
_lblWechatSidHint = new AntdUI.Label
{
Name = "lblWechatSidHint",
Text = "请微信搜索“微信收款助手”服务号点击小账本菜单点击进入小账本点击后才可自动获取SID",
ForeColor = Color.DimGray,
Visible = false,
Size = new Size(380, 52),
TextAlign = ContentAlignment.TopLeft
};
wechatProtocolCard.Controls.Add(_lblWechatSidHint);
_lblWechatSidHint.BringToFront();
}
// private void InitializeAlipayUi()
// {
// txtAliPath.PlaceholderText = "支付宝账单页面 URL默认 advanced.htm";
// txtAliAppId.PlaceholderText = "支付宝 AppId当前版本未使用";
// txtAliPid.PlaceholderText = "支付宝 UserId/Pid当前版本未使用";
// lblAlipayDesc.Text = "流程:点击扫码登录后,在内嵌浏览器完成支付宝登录;登录成功后系统自动提取 Cookie/ctoken 并立即开始后台监听;按钮会切换为“停止监听”。";
// numAlipayInterval.Minimum = 15;
// numAlipayInterval.Maximum = 35;
// numAlipayInterval.Value = 15;
// // 按你的要求,这些参数不再前端展示,统一改为后台默认运行。
// txtAliPath.Visible = false;
// txtAliPath.Enabled = false;
// numAlipayInterval.Visible = false;
// numAlipayInterval.Enabled = false;
// txtAliAppId.Visible = false;
// txtAliPid.Visible = false;
// txtAliAppId.Enabled = false;
// txtAliPid.Enabled = false;
// txtAliAppId.Text = string.Empty;
// txtAliPid.Text = string.Empty;
// if (string.IsNullOrWhiteSpace(txtAliPath.Text) || IsLegacyAlipayApiUrl(txtAliPath.Text))
// {
// txtAliPath.Text = GetDefaultAlipayBillApiUrl();
// }
// // 支付宝页已改为设计器控件,这里只做初始化,不再运行时动态创建按钮。
// btnAlipayLogin.Text = "扫码登录";
// btnAlipayLogin.Type = TTypeMini.Primary;
// btnAlipayStart.Text = "开始监听";
// btnAlipayStart.Type = TTypeMini.Primary;
// btnAlipayStart.Visible = false;
// btnAlipayStart.Enabled = false;
// btnAlipayStop.Text = "停止监听";
// btnAlipayStop.Type = TTypeMini.Error;
// btnAlipayStop.Visible = false;
// btnAlipayStop.Enabled = false;
// }
private void ReloadMenuItems()
{
menu.Items.Clear();
menu.Items.Add(new AntdUI.MenuItem { Text = "首页", IconSvg = "HomeOutlined", Tag = "home" });
menu.Items.Add(new AntdUI.MenuItem { Text = "微信监控", IconSvg = "WechatOutlined", Tag = "wechat" });
menu.Items.Add(new AntdUI.MenuItem { Text = "支付宝监控", IconSvg = "AlipayCircleOutlined", Tag = "alipay" });
menu.Items.Add(new AntdUI.MenuItem { Text = "软件设置", IconSvg = "SettingOutlined", Tag = "settings" });
}
private void LayoutHomePage()
{
const int margin = 20;
const int gap = 16;
var pageAvailableWidth = Math.Max(0, pageHome.ClientSize.Width - margin * 2);
pageHome.SuspendLayout();
var summaryCard = FindCard(pageHome, "home-summary");
var top = 160;
try
{
if (summaryCard != null)
{
summaryCard.SuspendLayout();
lblSummaryTitle.Location = new Point(28, 20);
var summaryDescTop = lblSummaryTitle.Bottom + 8;
var summaryDescWidth = Math.Max(220, pageAvailableWidth - 56);
var summaryDescPreferred = TextRenderer.MeasureText(
lblSummaryDesc.Text,
lblSummaryDesc.Font,
new Size(summaryDescWidth, 0),
TextFormatFlags.WordBreak);
var summaryDescHeight = Math.Max(24, summaryDescPreferred.Height);
lblSummaryDesc.Location = new Point(28, summaryDescTop);
lblSummaryDesc.Size = new Size(summaryDescWidth, summaryDescHeight);
var summaryHeight = Math.Max(120, summaryDescTop + summaryDescHeight + 20);
summaryCard.Bounds = new Rectangle(margin, 20, pageAvailableWidth, summaryHeight);
top = summaryCard.Bottom + margin;
summaryCard.ResumeLayout(false);
}
var configCard = FindCard(pageHome, "home-config");
var memberCard = FindCard(pageHome, "home-member");
if (configCard != null && memberCard != null)
{
configCard.SuspendLayout();
memberCard.SuspendLayout();
var availableWidth = Math.Max(0, pageAvailableWidth);
var rowWidth = Math.Max(0, availableWidth - gap);
var leftWidth = rowWidth / 2;
var rightWidth = rowWidth - leftWidth;
var configHeight = 390;
var memberHeight = 128;
configCard.Bounds = new Rectangle(margin, top, leftWidth, configHeight);
memberCard.Bounds = new Rectangle(margin + leftWidth + gap, top, rightWidth, memberHeight);
const int contentLeft = 24;
const int contentRight = 24;
var contentWidth = Math.Max(220, configCard.ClientSize.Width - contentLeft - contentRight);
var inputWidth = contentWidth;
lblServerUrlTitle.Location = new Point(contentLeft, 20);
txtServerUrl.Location = new Point(contentLeft, 48);
txtServerUrl.Width = inputWidth;
lblApiKeyTitle.Location = new Point(contentLeft, 118);
txtApiKey.Location = new Point(contentLeft, 146);
txtApiKey.Width = inputWidth;
const int actionTop = 236;
const int buttonGap = 12;
var actionButtonWidth = Math.Max(96, (contentWidth - buttonGap) / 2);
btnSaveConfig.Location = new Point(24, actionTop);
btnSaveConfig.Size = new Size(actionButtonWidth, 55);
btnHeartbeatCheck.Location = new Point(btnSaveConfig.Right + buttonGap, actionTop);
btnHeartbeatCheck.Size = new Size(actionButtonWidth, 55);
const int heartbeatTop = 310;
chkHeartbeatEnabled.Location = new Point(contentLeft, heartbeatTop - 4);
lblHeartbeatDesc.Location = new Point(chkHeartbeatEnabled.Right + 4, heartbeatTop + 1);
lblMemberPlaceholder.Size = new Size(Math.Max(220, memberCard.ClientSize.Width - 48), 52);
var rowBottom = Math.Max(configCard.Bottom, memberCard.Bottom);
var logCardTop = rowBottom + margin;
var homeLogCard = FindCard(pageHome, "home-log");
if (homeLogCard != null)
{
homeLogCard.Top = logCardTop;
}
configCard.ResumeLayout(false);
memberCard.ResumeLayout(false);
}
var logCard = FindCard(pageHome, "home-log");
if (logCard != null)
{
logCard.SuspendLayout();
logCard.Left = margin;
logCard.Width = Math.Max(0, pageHome.ClientSize.Width - margin * 2);
var bottom = pageHome.ClientSize.Height - 20;
logCard.Height = Math.Max(220, bottom - logCard.Top);
if (txtLog != null)
{
txtLog.Size = new Size(Math.Max(240, logCard.ClientSize.Width - 50), Math.Max(120, logCard.ClientSize.Height - 76));
}
if (btnClearLog != null)
{
btnClearLog.Size = new Size(92, 36);
btnClearLog.Location = new Point(
Math.Max(24, logCard.ClientSize.Width - btnClearLog.Width - 24),
14
);
btnClearLog.Visible = true;
btnClearLog.BringToFront();
}
logCard.ResumeLayout(false);
}
}
finally
{
pageHome.ResumeLayout(false);
}
}
private void LayoutWechatPage()
{
var hookCard = FindCard(pageWechat, "wechat-hook");
var protocolCard = FindCard(pageWechat, "wechat-protocol");
var gridCard = FindCard(pageWechat, "wechat-log");
if (hookCard != null && protocolCard != null)
{
// 为 SID 提示文字和输入框预留足够空间
protocolCard.Height = 250;
var hookPathWidth = Math.Max(140, hookCard.ClientSize.Width - 24 - 24 - btnSelectWechatPath.Width - 12);
txtWechatPath.Width = hookPathWidth;
btnSelectWechatPath.Size = new Size(92, 36);
btnSelectWechatPath.Location = new Point(txtWechatPath.Left + txtWechatPath.Width + 12, txtWechatPath.Top);
btnWechatHookStart.Size = new Size(168, 42);
btnWechatHookStart.Location = new Point(24, 182);
lblWechatSidTitle.Location = new Point(24, 54);
btnWechatSidAuto.Size = new Size(110, 36);
var sidWidth = Math.Max(180, protocolCard.ClientSize.Width - 48 - btnWechatSidAuto.Width - 12);
btnWechatSidAuto.Location = new Point(24, 76);
btnWechatProtocolStart.Size = new Size(120, 42);
btnWechatProtocolStart.Location = new Point(150, 72);
if (_lblWechatSidHint != null)
{
// 将提示文字显示到三个按钮下方的空白区域(你标红框的位置)
_lblWechatSidHint.Location = new Point(24, 132);
_lblWechatSidHint.Size = new Size(Math.Max(260, protocolCard.ClientSize.Width - 48), 44);
_lblWechatSidHint.BringToFront();
}
txtWechatId.Width = sidWidth;
txtWechatId.Location = new Point(24, 178);
lblWechatFrequencyTitle.Location = new Point(24, 18);
numWechatInterval.Location = new Point(126, 15);
numWechatInterval.Size = new Size(72, 30);
chkWheel.Location = new Point(220, 12);
}
if (gridCard != null)
{
if (protocolCard != null)
{
gridCard.Top = protocolCard.Bottom + 18;
gridCard.Height = Math.Max(220, pageWechat.ClientSize.Height - gridCard.Top - 23);
}
if (gridWechatLogs != null)
{
gridWechatLogs.Size = new Size(Math.Max(240, gridCard.ClientSize.Width - 50), Math.Max(120, gridCard.ClientSize.Height - 76));
}
if (btnClearWechatLog != null)
{
btnClearWechatLog.Size = new Size(92, 36);
btnClearWechatLog.Location = new Point(Math.Max(24, gridCard.ClientSize.Width - btnClearWechatLog.Width - 24), 14);
}
}
}
private void LayoutAlipayPage()
{
var configCard = FindCard(pageAlipay, "alipay-config");
if (configCard != null)
{
var left = 24;
var top = 24;
txtAliPath.Location = new Point(-5000, -5000);
txtAliAppId.Location = new Point(-5000, -5000);
txtAliPid.Location = new Point(-5000, -5000);
numAlipayInterval.Location = new Point(-5000, -5000);
btnAlipayLogin.Location = new Point(left, top);
btnAlipayStart.Location = new Point(-5000, -5000);
btnAlipayStop.Location = new Point(-5000, -5000);
lblAlipayDesc.Location = new Point(left, 76);
lblAlipayDesc.Size = new Size(Math.Max(360, configCard.ClientSize.Width - 48), 40);
}
var gridCard = FindCard(pageAlipay, "alipay-log");
if (gridCard != null)
{
if (gridAlipayLogs != null)
{
gridAlipayLogs.Size = new Size(Math.Max(240, gridCard.ClientSize.Width - 50), Math.Max(120, gridCard.ClientSize.Height - 76));
}
}
}
private void LayoutSettingsPage()
{
LayoutPageCards(pageSettings, 20);
var listenCard = FindCard(pageSettings, "settings-listen");
var emailCard = FindCard(pageSettings, "settings-email");
if (listenCard == null || emailCard == null)
{
return;
}
listenCard.Top = 20;
emailCard.Top = listenCard.Bottom + 16;
var emailInputWidth = Math.Max(220, Math.Min(520, emailCard.ClientSize.Width - 48));
txtSenderEmail.Width = emailInputWidth;
txtSmtpHost.Width = emailInputWidth;
txtNotifyEmail.Width = emailInputWidth;
txtSmtpPort.Width = Math.Min(220, emailInputWidth);
var authCodeWidth = Math.Max(260, Math.Min(360, emailCard.ClientSize.Width - 584 - 24));
var authCodeLeft = Math.Max(24, emailCard.ClientSize.Width - authCodeWidth - 24);
lblEmailAuthCodeTitle.Location = new Point(authCodeLeft, 278);
txtEmailAuthCode.Location = new Point(authCodeLeft, 306);
txtEmailAuthCode.Width = authCodeWidth;
btnEmailSave.Size = new Size(110, 42);
btnEmailTest.Size = new Size(110, 42);
btnEmailSave.Location = new Point(24, 350);
btnEmailTest.Location = new Point(btnEmailSave.Right + 12, 350);
}
private static void LayoutPageCards(WinPanel page, int margin)
{
var targetWidth = Math.Max(0, page.ClientSize.Width - margin * 2);
foreach (Control control in page.Controls)
{
control.Left = margin;
control.Width = targetWidth;
}
}
private static Control? FindCard(Control parent, string tag)
{
foreach (Control control in parent.Controls)
{
if (string.Equals(control.Tag?.ToString(), tag, StringComparison.Ordinal))
{
return control;
}
var nested = FindCard(control, tag);
if (nested != null)
{
return nested;
}
}
return null;
}
private AntdUI.Label CreateTitleLabel(string text, int x, int y)
{
return new AntdUI.Label
{
Text = text,
AutoSize = true,
Location = new Point(x, y)
};
}
private AntdUI.Input CreateInput(int x, int y, int width, string placeholder)
{
return new AntdUI.Input
{
Location = new Point(x, y),
Size = new Size(width, 55),
PlaceholderText = placeholder
};
}
private DataGridView CreateWechatGrid()
{
var grid = new DataGridView
{
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
ReadOnly = true,
RowHeadersVisible = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Color.White,
BorderStyle = BorderStyle.FixedSingle,
};
grid.Columns.Add("Shop", "收款方");
grid.Columns.Add("Amount", "金额");
grid.Columns.Add("Time", "时间");
grid.Columns.Add("Remark", "备注/订单号");
grid.Columns.Add("OrderStatus", "订单状态");
grid.Columns.Add("Callback", "回调状态");
return grid;
}
private DataGridView CreateAlipayGrid()
{
var grid = new DataGridView
{
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
ReadOnly = true,
RowHeadersVisible = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None,
BackgroundColor = Color.White,
BorderStyle = BorderStyle.FixedSingle
};
// 列顺序按你的要求统一为:
// 序号、金额、时间、订单号、备注、订单状态、回调状态
grid.Columns.Add("Index", "序号");
grid.Columns.Add("Amount", "金额");
grid.Columns.Add("Time", "时间");
grid.Columns.Add("OrderNo", "订单号");
grid.Columns.Add("Remark", "备注");
grid.Columns.Add("OrderStatus", "订单状态");
grid.Columns.Add("Callback", "回调状态");
// 固定宽度列:
// - 序号:约 4 个中文字符宽度
// - 金额:约 8 个字符宽度
// - 时间:固定展示 yyyy-MM-dd HH:mm:ss
// - 订单状态 / 回调状态:约 4 个中文字符宽度
grid.Columns["Index"]!.Width = 72;
grid.Columns["Amount"]!.Width = 90;
grid.Columns["Time"]!.Width = 160;
grid.Columns["OrderStatus"]!.Width = 72;
grid.Columns["Callback"]!.Width = 72;
// 剩余宽度留给订单号和备注
grid.Columns["OrderNo"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
grid.Columns["Remark"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
grid.Columns["OrderNo"]!.FillWeight = 58;
grid.Columns["Remark"]!.FillWeight = 42;
return grid;
}
private void Form1_Load(object? sender, EventArgs e)
{
LoadConfig();
BindConfigToUi();
InitializeRuntimeTimer();
InitializeHeartbeatTimer();
SelectPage("home");
UpdateServiceStatus(false);
// 心跳续签默认强制开启,避免因未勾选开关导致监控端掉线。
_config.EnableHeartbeat = true;
chkHeartbeatEnabled.Checked = true;
ApplyHeartbeatSetting();
Log("程序已启动。");
}
private void Form1_FormClosing(object? sender, FormClosingEventArgs e)
{
StopListener();
StopHeartbeat();
StopWechatHook();
StopWechatProtocol();
StopWechatSidCapture();
_alipayMonitor?.Stop();
_alipayMonitor?.Dispose();
_runtimeTimer.Stop();
_runtimeTimer.Dispose();
_heartbeatTimer.Dispose();
_httpClient.Dispose();
}
private void InitializeRuntimeTimer()
{
_runtimeTimer.Interval = 1000;
_runtimeTimer.Tick += (_, _) =>
{
var span = DateTime.Now - _appStartTime;
lblRuntimeBottom.Text = $"{span.Days:00}天:{span.Hours:00}时:{span.Minutes:00}分:{span.Seconds:00}秒";
};
_runtimeTimer.Start();
}
private void InitializeHeartbeatTimer()
{
// 心跳在后台静默执行,不向付款日志区域刷屏。
_heartbeatTimer.Interval = 10 * 1000;
_heartbeatTimer.Tick += async (_, _) => await SendHeartbeatAsync(false);
}
private void ButtonCollapse_Click(object? sender, EventArgs e)
{
var nextCollapsed = !menu.Collapsed;
menu.SuspendLayout();
menu.Collapsed = nextCollapsed;
menu.Width = nextCollapsed ? 56 : 220;
ReloadMenuItems();
menu.ResumeLayout();
menu.Refresh();
buttonCollapse.Toggle = !nextCollapsed;
SyncMenuSelection();
}
private void Menu_SelectChanged(object? sender, MenuSelectEventArgs e)
{
if (_isSynchronizingNavigation)
{
return;
}
var tag = e.Value.Tag?.ToString();
if (!string.IsNullOrWhiteSpace(tag))
{
SelectPage(tag);
}
}
private void SyncMenuSelection()
{
_isSynchronizingNavigation = true;
try
{
switch (_currentPageKey)
{
case "home":
menu.SelectIndex(0, true);
break;
case "wechat":
menu.SelectIndex(1, true);
break;
case "alipay":
menu.SelectIndex(2, true);
break;
case "settings":
menu.SelectIndex(3, true);
break;
}
}
finally
{
_isSynchronizingNavigation = false;
}
}
private void SelectPage(string pageKey)
{
_currentPageKey = pageKey;
pageHome.Visible = pageKey == "home";
pageWechat.Visible = pageKey == "wechat";
pageAlipay.Visible = pageKey == "alipay";
pageSettings.Visible = pageKey == "settings";
if (pageHome.Visible) pageHome.BringToFront();
else if (pageWechat.Visible) pageWechat.BringToFront();
else if (pageAlipay.Visible) pageAlipay.BringToFront();
else if (pageSettings.Visible) pageSettings.BringToFront();
SyncMenuSelection();
}
private void LoadConfig()
{
if (!File.Exists(_configFilePath))
{
_config = new ClientConfig();
}
else
{
var json = File.ReadAllText(_configFilePath, Encoding.UTF8);
_config = JsonSerializer.Deserialize<ClientConfig>(json, _jsonOptions) ?? new ClientConfig();
}
LoadPendingOrders();
}
private void SaveConfig()
{
var json = JsonSerializer.Serialize(_config, _jsonOptions);
File.WriteAllText(_configFilePath, json, Encoding.UTF8);
}
private void LoadPendingOrders()
{
if (!File.Exists(_pendingOrdersFilePath))
{
_pendingOrders = new List<PendingOrderRecord>();
return;
}
try
{
var json = File.ReadAllText(_pendingOrdersFilePath, Encoding.UTF8);
_pendingOrders = JsonSerializer.Deserialize<List<PendingOrderRecord>>(json, _jsonOptions) ?? new List<PendingOrderRecord>();
}
catch
{
_pendingOrders = new List<PendingOrderRecord>();
}
}
private void SavePendingOrders()
{
var json = JsonSerializer.Serialize(_pendingOrders, _jsonOptions);
File.WriteAllText(_pendingOrdersFilePath, json, Encoding.UTF8);
}
private void BindConfigToUi()
{
txtServerUrl.Text = _config.ServerUrl;
txtApiKey.Text = _config.ApiKey;
txtSenderEmail.Text = _config.SenderEmail;
txtSmtpHost.Text = _config.SmtpHost;
txtSmtpPort.Text = _config.SmtpPort.ToString();
txtNotifyEmail.Text = _config.NotifyEmail;
txtEmailAuthCode.Text = _config.EmailAuthCode;
txtWechatPath.Text = _config.WechatPath;
txtWechatId.Text = _config.WechatSid;
var savedAlipayUrl = string.IsNullOrWhiteSpace(_config.AlipayBillApiUrl)
? _config.AlipayPath
: _config.AlipayBillApiUrl;
txtAliPath.Text = string.IsNullOrWhiteSpace(savedAlipayUrl) || IsLegacyAlipayApiUrl(savedAlipayUrl)
? GetDefaultAlipayBillApiUrl()
: savedAlipayUrl;
txtAliAppId.Text = string.Empty;
txtAliPid.Text = string.Empty;
numWechatInterval.Value = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 3600);
numAlipayInterval.Value = Math.Min(Math.Max(_config.AlipayIntervalSeconds, 15), 35);
chkWheel.Checked = _config.EnableWheelPolling;
chkHeartbeatEnabled.Checked = _config.EnableHeartbeat;
_config.WechatApiVersion = string.IsNullOrWhiteSpace(_config.WechatApiVersion) ? "7.10.1" : _config.WechatApiVersion.Trim();
}
private void SaveUiToConfig()
{
_config.ServerUrl = NormalizeServerUrl(txtServerUrl.Text);
_config.ApiKey = txtApiKey.Text.Trim();
_config.SenderEmail = txtSenderEmail.Text.Trim();
_config.SmtpHost = txtSmtpHost.Text.Trim();
_config.SmtpPort = ParseSmtpPort(txtSmtpPort.Text);
_config.NotifyEmail = txtNotifyEmail.Text.Trim();
_config.EmailAuthCode = txtEmailAuthCode.Text.Trim();
_config.WechatPath = txtWechatPath.Text.Trim();
_config.WechatSid = txtWechatId.Text.Trim();
_config.AlipayPath = txtAliPath.Text.Trim();
_config.AlipayBillApiUrl = txtAliPath.Text.Trim();
_config.AlipayAppId = string.Empty;
_config.AlipayUserId = string.Empty;
_config.WechatIntervalSeconds = (int)numWechatInterval.Value;
_config.AlipayIntervalSeconds = Math.Max(15, (int)numAlipayInterval.Value);
_config.EnableWheelPolling = chkWheel.Checked;
_config.EnableHeartbeat = chkHeartbeatEnabled.Checked;
}
private static int ParsePort(string text)
{
if (!int.TryParse(text, out var port) || port < 1 || port > 65535)
{
return 8989;
}
return port;
}
private static int ParseSmtpPort(string text)
{
if (!int.TryParse(text, out var port) || port < 1 || port > 65535)
{
return 465;
}
return port;
}
private void btnSaveConfig_Click(object? sender, EventArgs e)
{
try
{
SaveUiToConfig();
SaveConfig();
ApplyHeartbeatSetting();
Log("配置已保存。");
}
catch (Exception ex)
{
Log($"保存配置失败:{ex.Message}");
MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnSelectWechatPath_Click(object? sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Title = "选择微信程序路径",
Filter = "微信程序|WeChat.exe;Weixin.exe;WeChatAppEx.exe|可执行文件|*.exe|所有文件|*.*",
CheckFileExists = true,
Multiselect = false
};
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
txtWechatPath.Text = dialog.FileName;
SaveUiToConfig();
SaveConfig();
Log($"微信安装路径已保存:{dialog.FileName}");
}
private void btnClearLog_Click(object? sender, EventArgs e)
{
txtLog.Clear();
}
private void btnClearWechatLog_Click(object? sender, EventArgs e)
{
gridWechatLogs.Rows.Clear();
}
private async void btnWechatSidAuto_Click(object? sender, EventArgs e)
{
if (_wechatSidCaptureCts != null)
{
StopWechatSidCapture();
Log("SID 捕获已停止。");
return;
}
try
{
if (!string.IsNullOrWhiteSpace(txtWechatId.Text))
{
MessageBox.Show(
"检测到当前微信 SID 输入框已有内容,点击确定后将自动清空,再重新获取最新 SID。",
"微信 SID 提示",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
txtWechatId.Text = string.Empty;
}
Log("SID 捕获已启动:请微信搜索“微信收款助手”服务号,点击小账本菜单,进入小账本页面后再等待自动获取。");
StartWechatSidCapture();
}
catch (Exception ex)
{
Log($"自动获取 SID 异常:{ex.Message}");
StopWechatSidCapture();
}
}
private void LaunchWechatForSidCapture()
{
var path = txtWechatPath?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
throw new InvalidOperationException("请先选择正确的微信程序路径WeChat.exe/Weixin.exe/WeChatAppEx.exe。");
}
var processName = Path.GetFileNameWithoutExtension(path);
var alreadyRunning = !string.IsNullOrWhiteSpace(processName) && Process.GetProcessesByName(processName).Length > 0;
if (alreadyRunning)
{
Log($"检测到微信已运行:{processName},开始直接捕获 SID。");
return;
}
var startInfo = new ProcessStartInfo
{
FileName = path,
WorkingDirectory = Path.GetDirectoryName(path) ?? AppContext.BaseDirectory,
UseShellExecute = true
};
Process.Start(startInfo);
Log($"已启动微信:{path}");
}
private void StartWechatSidCapture()
{
StopWechatSidCapture();
_wechatSidCaptureCts = new CancellationTokenSource();
UpdateWechatMonitorButtons();
btnWechatSidAuto.Text = "停止获取";
btnWechatSidAuto.Type = TTypeMini.Error;
btnWechatSidAuto.Ghost = false;
btnWechatSidAuto.Loading = false;
if (_lblWechatSidHint != null)
{
_lblWechatSidHint.Visible = true;
_lblWechatSidHint.BringToFront();
}
try
{
_proxyServer = new ProxyServer();
_proxyServer.CertificateManager.EnsureRootCertificate();
_proxyServer.BeforeRequest += OnWechatSidProxyBeforeRequest;
var explicitEndPoint = new ExplicitProxyEndPoint(IPAddress.Any, 8000, true);
_proxyServer.AddEndPoint(explicitEndPoint);
_proxyServer.Start();
_proxyServer.SetAsSystemHttpProxy(explicitEndPoint);
_proxyServer.SetAsSystemHttpsProxy(explicitEndPoint);
Log("已启动本地代理捕获,请在微信中进入【收款小账本】。若出现证书提示,请点击“是”。");
}
catch
{
StopWechatSidCapture();
throw;
}
}
private void StopWechatSidCapture()
{
try { _wechatSidCaptureCts?.Cancel(); } catch { }
try { _wechatSidCaptureCts?.Dispose(); } catch { }
_wechatSidCaptureCts = null;
if (_proxyServer != null)
{
try
{
_proxyServer.BeforeRequest -= OnWechatSidProxyBeforeRequest;
_proxyServer.RestoreOriginalProxySettings();
_proxyServer.Stop();
}
catch (Exception ex)
{
Log($"停止代理时发生异常:{ex.Message}");
}
finally
{
_proxyServer.Dispose();
_proxyServer = null;
}
}
UpdateWechatMonitorButtons();
if (btnWechatSidAuto != null)
{
btnWechatSidAuto.Text = "2. 获取SID";
btnWechatSidAuto.Type = TTypeMini.Primary;
btnWechatSidAuto.Ghost = true;
btnWechatSidAuto.Loading = false;
btnWechatSidAuto.Enabled = true;
}
if (_lblWechatSidHint != null)
{
_lblWechatSidHint.Visible = false;
}
}
private async Task OnWechatSidProxyBeforeRequest(object sender, SessionEventArgs e)
{
if (_wechatSidCaptureCts?.IsCancellationRequested == true)
{
return;
}
var requestUrl = e.HttpClient.Request.Url;
if (!requestUrl.Contains("smallbook.wxpapp.weixin.qq.com/qrappzd", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var uri))
{
return;
}
var sid = TryGetQueryParameter(uri.Query, "sid");
var version = TryGetQueryParameter(uri.Query, "v");
if (!string.IsNullOrWhiteSpace(sid))
{
BeginInvoke(() => ApplyCapturedSid(sid, string.IsNullOrWhiteSpace(version) ? "7.10.1" : version, "NetworkProxy"));
}
await Task.CompletedTask;
}
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 void ApplyCapturedSid(string sid, string version, string source)
{
txtWechatId.Text = sid;
SaveUiToConfig();
_config.WechatApiVersion = string.IsNullOrWhiteSpace(version) ? _config.WechatApiVersion : version;
SaveConfig();
Log($"已捕获 SID{sid}v={_config.WechatApiVersion},来源={source}");
StopWechatSidCapture();
}
private void btnWechatHookStart_Click(object? sender, EventArgs e)
{
try
{
SaveUiToConfig();
SaveConfig();
LaunchWechatForSidCapture();
Log("已执行启动微信操作。请继续点击“2. 获取SID”然后进入微信收款助手的小账本页面。");
}
catch (Exception ex)
{
Log($"启动微信失败:{ex.Message}");
MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnWechatProtocolStart_Click(object? sender, EventArgs e)
{
if (string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal))
{
StopWechatProtocol();
_wechatMonitorMode = string.Empty;
UpdateWechatMonitorButtons();
UpdateWechatStatusUi();
Log("协议监听已停止。");
return;
}
try
{
SaveUiToConfig();
SaveConfig();
if (!string.IsNullOrWhiteSpace(_wechatMonitorMode))
{
StopWechatMonitoring();
}
if (string.IsNullOrWhiteSpace(_config.WechatSid))
{
throw new InvalidOperationException("请先填写或自动获取微信 SID。");
}
StartWechatProtocol();
_wechatMonitorMode = "protocol";
UpdateWechatMonitorButtons();
UpdateWechatStatusUi();
Log($"协议监听已启动。SID={_config.WechatSid}v={_config.WechatApiVersion}");
}
catch (Exception ex)
{
Log($"协议监听启动失败:{ex.Message}");
MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void UpdateWechatMonitorButtons()
{
if (btnWechatHookStart == null || btnWechatProtocolStart == null)
{
return;
}
var protocolActive = string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal);
// 按你的要求,左侧“启动微信”按钮不跟随 SID 获取状态变化。
btnWechatHookStart.Text = "1. 启动微信";
btnWechatHookStart.Type = TTypeMini.Primary;
btnWechatProtocolStart.Text = protocolActive ? "停止监听" : "3. 开始监听";
btnWechatProtocolStart.Type = protocolActive ? TTypeMini.Error : TTypeMini.Primary;
}
private void StopWechatMonitoring()
{
if (string.Equals(_wechatMonitorMode, "hook", StringComparison.Ordinal))
{
StopWechatHook();
}
else if (string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal))
{
StopWechatProtocol();
}
UpdateWechatStatusUi();
}
private void StartWechatHook()
{
StopWechatHook();
_wechatHookSeen.Clear();
_wechatHookFoundLedgerAnchor = false;
_wechatHookLastDiagAt = DateTime.MinValue;
_wechatHookCts = new CancellationTokenSource();
var token = _wechatHookCts.Token;
_ = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
await PollWechatLedgerOnceAsync(token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Log($"Hook 轮询异常:{ex.Message}");
}
var delaySeconds = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 60);
try
{
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
private void StopWechatHook()
{
try { _wechatHookCts?.Cancel(); } catch { }
try { _wechatHookCts?.Dispose(); } catch { }
_wechatHookCts = null;
}
private async Task PollWechatLedgerOnceAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();
var processes = Process.GetProcessesByName("Weixin")
.Concat(Process.GetProcessesByName("WeChat"))
.Concat(Process.GetProcessesByName("WeChatAppEx"))
.ToArray();
if (processes.Length == 0)
{
return;
}
foreach (var process in processes)
{
if (process.MainWindowHandle == IntPtr.Zero)
{
continue;
}
AutomationElement? window = null;
try
{
window = AutomationElement.FromHandle(process.MainWindowHandle);
}
catch
{
continue;
}
if (window == null)
{
continue;
}
// 优先收敛到“收款小账本”区域附近,降低噪声与误报
var scanRoot = FindWechatLedgerScanRoot(window) ?? window;
if (!ReferenceEquals(scanRoot, window))
{
_wechatHookFoundLedgerAnchor = true;
}
// 双通道:小账本区域 + 整窗聊天文本(微信收款助手/微信支付)
var texts = ExtractAutomationText(scanRoot, max: 2500);
var windowTexts = ExtractAutomationText(window, max: 3000);
if (windowTexts.Count > 0)
{
texts.AddRange(windowTexts);
}
texts = texts
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.Ordinal)
.ToList();
var moneyCandidates = 0;
var keywordSamples = new List<string>();
foreach (var t in texts)
{
token.ThrowIfCancellationRequested();
if (t.Contains('¥') || t.Contains('¥'))
{
moneyCandidates++;
}
if (ContainsLedgerKeyword(t) && keywordSamples.Count < 8)
{
keywordSamples.Add(t.Length > 40 ? t[..40] : t);
}
if (TryParseWechatLedgerLine(t, out var amount, out var remark))
{
var key = $"{amount:0.00}|{remark}|{t}";
var hash = CreateMd5(key);
if (_wechatHookSeen.Add(hash))
{
await OnWechatPaymentDetectedAsync(amount, remark, raw: t);
}
}
}
// 低频诊断:告诉你扫描到没有包含金额的文本/是否定位到小账本锚点
if ((DateTime.Now - _wechatHookLastDiagAt).TotalSeconds >= 5)
{
_wechatHookLastDiagAt = DateTime.Now;
if (!_wechatHookFoundLedgerAnchor)
{
Log("Hook 诊断:未定位到“收款小账本”区域,建议打开并停留在收款小账本页面再试。");
}
else if (keywordSamples.Count > 0)
{
Log($"Hook 诊断:已命中关键词样本 {string.Join(" | ", keywordSamples.Distinct())}");
}
else if (moneyCandidates == 0)
{
Log("Hook 诊断:已扫描微信窗口,但未命中“微信支付收款/收款到账/¥/¥/元”等文本。");
}
}
}
}
private static AutomationElement? FindWechatLedgerScanRoot(AutomationElement window)
{
// UIA 不支持 contains 条件,只能遍历找关键词命中
var walker = TreeWalker.RawViewWalker;
var stack = new Stack<AutomationElement>();
stack.Push(window);
while (stack.Count > 0)
{
var current = stack.Pop();
if (IsLedgerAnchor(current))
{
return current;
}
AutomationElement? child = null;
try { child = walker.GetFirstChild(current); } catch { child = null; }
while (child != null)
{
stack.Push(child);
try { child = walker.GetNextSibling(child); } catch { break; }
}
}
return null;
}
private static bool IsLedgerAnchor(AutomationElement element)
{
foreach (var text in ExtractElementTexts(element))
{
if (ContainsLedgerKeyword(text))
{
return true;
}
}
return false;
}
private static bool ContainsLedgerKeyword(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
return text.Contains("收款小账本", StringComparison.OrdinalIgnoreCase) ||
text.Contains("微信收款助手", StringComparison.OrdinalIgnoreCase) ||
text.Contains("微信支付", StringComparison.OrdinalIgnoreCase) ||
text.Contains("收款到账", StringComparison.OrdinalIgnoreCase) ||
text.Contains("收款通知", StringComparison.OrdinalIgnoreCase) ||
text.Contains("收款记录", StringComparison.OrdinalIgnoreCase) ||
text.Contains("收款设置", StringComparison.OrdinalIgnoreCase) ||
text.Contains("今日收款", StringComparison.OrdinalIgnoreCase) ||
text.Contains("经营收款服务", StringComparison.OrdinalIgnoreCase) ||
text.Contains("补充经营信息", StringComparison.OrdinalIgnoreCase);
}
private static List<string> ExtractAutomationText(AutomationElement root, int max)
{
var results = new List<string>();
try
{
var walker = TreeWalker.RawViewWalker;
var stack = new Stack<AutomationElement>();
stack.Push(root);
while (stack.Count > 0 && results.Count < max)
{
var current = stack.Pop();
foreach (var text in ExtractElementTexts(current))
{
if (results.Count >= max)
{
break;
}
results.Add(text);
}
AutomationElement? child = null;
try
{
child = walker.GetFirstChild(current);
}
catch
{
child = null;
}
while (child != null)
{
stack.Push(child);
try
{
child = walker.GetNextSibling(child);
}
catch
{
break;
}
}
}
}
catch
{
}
return results;
}
private static IEnumerable<string> ExtractElementTexts(AutomationElement element)
{
var values = new List<string>();
try
{
if (!string.IsNullOrWhiteSpace(element.Current.Name))
{
values.Add(element.Current.Name.Trim());
}
}
catch { }
try
{
if (!string.IsNullOrWhiteSpace(element.Current.HelpText))
{
values.Add(element.Current.HelpText.Trim());
}
}
catch { }
try
{
if (!string.IsNullOrWhiteSpace(element.Current.AutomationId))
{
values.Add(element.Current.AutomationId.Trim());
}
}
catch { }
try
{
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valuePatternObj))
{
var value = ((ValuePattern)valuePatternObj).Current.Value;
if (!string.IsNullOrWhiteSpace(value))
{
values.Add(value.Trim());
}
}
}
catch { }
try
{
if (element.TryGetCurrentPattern(TextPattern.Pattern, out var textPatternObj))
{
var text = ((TextPattern)textPatternObj).DocumentRange.GetText(256);
if (!string.IsNullOrWhiteSpace(text))
{
values.Add(text.Trim());
}
}
}
catch { }
return values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct();
}
private static bool TryParseWechatLedgerLine(string text, out decimal amount, out string remark)
{
amount = 0;
remark = string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var t = text.Replace(" ", string.Empty).Replace("\u00A0", string.Empty);
if (!t.Contains("收款") &&
!t.Contains("微信支付") &&
!t.Contains("入账") &&
!t.Contains("到账") &&
!t.Contains("¥") &&
!t.Contains("¥") &&
!t.Contains("元"))
{
return false;
}
// 兼容样式:
// - 微信支付收款0.10元
// - 收款到账0.10元
// - ¥0.10 / ¥0.10
// - 收款0.10
var match = Regex.Match(
t,
@"(?:微信支付)?(?:收款到账|收款通知|收款)\s*(\d+(?:\.\d{1,2})?)\s*元|(?:¥|¥)\s*(\d+(?:\.\d{1,2})?)|收款\s*(\d+(?:\.\d{1,2})?)",
RegexOptions.IgnoreCase);
if (!match.Success)
{
return false;
}
var amountText =
match.Groups[1].Success ? match.Groups[1].Value :
match.Groups[2].Success ? match.Groups[2].Value :
match.Groups[3].Value;
if (!decimal.TryParse(amountText, out amount) || amount <= 0)
{
return false;
}
remark = t;
if (remark.Length > 120)
{
remark = remark[..120];
}
return true;
}
private async Task OnWechatPaymentDetectedAsync(decimal amount, string remark, string raw)
{
var evt = new PaymentEvent
{
Channel = "wechat",
Amount = amount,
OrderNo = remark,
TradeNo = string.Empty,
Payer = "wechat-ledger",
Status = "success",
ReceivedAt = DateTimeOffset.Now,
Raw = raw
};
var callbackResult = await ForwardEventToServerAsync(evt);
AddPaymentLog(evt, callbackResult);
Log($"Hook 收到收款:{amount:0.00}{remark}");
}
private void UpdateWechatStatusUi()
{
if (lblWechatStatusValue is null)
{
return;
}
var active = string.Equals(_wechatMonitorMode, "hook", StringComparison.Ordinal) ||
string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal);
lblWechatStatusValue.Text = active ? "微信: 监听中" : "微信: 离线";
lblWechatStatusValue.ForeColor = active ? Color.Green : Color.Red;
}
private void StartWechatProtocol()
{
StopWechatProtocol();
_wechatProtocolSeen.Clear();
_wechatProtocolLastPendingCount = null;
_wechatProtocolLastHomeType = null;
_wechatProtocolAuthFailCount = 0;
_wechatProtocolCts = new CancellationTokenSource();
var token = _wechatProtocolCts.Token;
_ = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
await PollWechatProtocolOnceAsync(token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Log($"协议轮询异常:{ex.Message}");
}
var delaySeconds = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 60);
try
{
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token);
}
catch (OperationCanceledException)
{
break;
}
}
}, token);
}
private void StopWechatProtocol()
{
try { _wechatProtocolCts?.Cancel(); } catch { }
try { _wechatProtocolCts?.Dispose(); } catch { }
_wechatProtocolCts = null;
_wechatProtocolLastPendingCount = null;
_wechatProtocolLastHomeType = null;
_wechatProtocolAuthFailCount = 0;
}
private async Task PollWechatProtocolOnceAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();
var sid = _config.WechatSid?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(sid))
{
Log("协议诊断:未填写微信 SID。");
return;
}
var version = string.IsNullOrWhiteSpace(_config.WechatApiVersion) ? "7.10.1" : _config.WechatApiVersion.Trim();
if (!_config.EnableWheelPolling)
{
Log("协议诊断:接口轮询未启用。");
return;
}
await SyncPendingOrdersFromServerAsync(token, 1);
// 先调用 gethomedata拿到可用时间区间与最新游标
var homeUrl = BuildWechatSmallbookUrl(sid, version);
var startTime = DateTimeOffset.Now.AddDays(-7).ToUnixTimeSeconds();
var endTime = DateTimeOffset.Now.ToUnixTimeSeconds();
string homeBody = string.Empty;
int? homeType = null;
foreach (var typeCandidate in new[] { 0, 1, 2, 39 })
{
var homeRequest = new
{
start_time = startTime,
end_time = endTime,
type = typeCandidate
};
var candidateBody = await SendWechatProtocolRequestAsync(homeUrl, homeRequest, token);
if (string.IsNullOrWhiteSpace(candidateBody))
{
continue;
}
WechatSmallbookResponse? candidatePayload = null;
try
{
candidatePayload = JsonSerializer.Deserialize<WechatSmallbookResponse>(candidateBody, _jsonOptions);
}
catch
{
candidatePayload = null;
}
if (candidatePayload?.Retcode == 0)
{
homeBody = candidateBody;
homeType = typeCandidate;
if (_wechatProtocolLastHomeType != typeCandidate)
{
Log($"协议诊断(gethomedata):已命中可用 type={typeCandidate}");
_wechatProtocolLastHomeType = typeCandidate;
}
break;
}
if (candidatePayload?.Msg?.Contains("type不合法", StringComparison.OrdinalIgnoreCase) == true)
{
continue;
}
homeBody = candidateBody;
homeType = typeCandidate;
break;
}
if (!string.IsNullOrWhiteSpace(homeBody))
{
try
{
var payload = JsonSerializer.Deserialize<WechatSmallbookResponse>(homeBody, _jsonOptions);
if (payload?.Retcode != 0)
{
if (payload != null)
{
Log($"协议诊断(gethomedata)retcode={payload.Retcode} errcode={payload.Errcode} msg={payload.Msg}" + (homeType != null ? $" type={homeType}" : string.Empty));
if (payload.Msg.Contains("效验登录态失败", StringComparison.OrdinalIgnoreCase))
{
_wechatProtocolAuthFailCount++;
if (_wechatProtocolAuthFailCount >= 2)
{
BeginInvoke(() =>
{
StopWechatProtocol();
_wechatMonitorMode = string.Empty;
UpdateWechatMonitorButtons();
UpdateWechatStatusUi();
MessageBox.Show("微信 SID 已失效请重新点击“2. 获取SID”后再开始监听。", "微信监听提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
});
}
}
}
}
else
{
_wechatProtocolAuthFailCount = 0;
var incomes = payload.Data?.IncomeList;
if (incomes != null)
{
foreach (var income in incomes)
{
token.ThrowIfCancellationRequested();
UpdateWechatProtocolCursor(income.TransId, income.Timestamp);
var detailRaw = await FetchWechatTransactionDetailAsync(sid, version, income.TransId, income.Timestamp, token);
var id = !string.IsNullOrWhiteSpace(income.TransId)
? income.TransId!
: (!string.IsNullOrWhiteSpace(income.RollId) ? income.RollId! : $"{income.Timestamp}|{income.Fee}");
await ProcessWechatProtocolIncomeAsync(
id: id,
transId: income.TransId,
timestamp: income.Timestamp,
feeCent: income.Fee,
raw: string.IsNullOrWhiteSpace(detailRaw) ? homeBody : detailRaw);
}
}
}
}
catch (Exception ex)
{
Log($"协议诊断(gethomedata)JSON 解析失败:{ex.Message}");
}
}
// 再用最新游标请求 classifyread
if (!string.IsNullOrWhiteSpace(_wechatProtocolLastTransId) && _wechatProtocolLastCreateTime > 0)
{
var classifyUrl = BuildWechatClassifyReadUrl(sid, version);
var classifyRequest = new
{
v = version,
trans_id = _wechatProtocolLastTransId,
last_bill_id = (string?)null,
count = 10,
page_num = 1,
create_time = _wechatProtocolLastCreateTime,
last_id = "",
last_create_time = 0,
sid = sid
};
var classifyBody = await SendWechatProtocolRequestAsync(classifyUrl, classifyRequest, token);
if (!string.IsNullOrWhiteSpace(classifyBody))
{
try
{
var classifyPayload = JsonSerializer.Deserialize<WechatClassifyReadResponse>(classifyBody, _jsonOptions);
if (classifyPayload?.Retcode == 0 && classifyPayload.Data?.PersonBillList != null)
{
foreach (var bill in classifyPayload.Data.PersonBillList)
{
token.ThrowIfCancellationRequested();
UpdateWechatProtocolCursor(bill.TransId, bill.CreateTime);
await ProcessWechatProtocolIncomeAsync(
id: bill.TransId ?? $"{bill.CreateTime}|{bill.TotalFee}",
transId: bill.TransId,
timestamp: bill.CreateTime,
feeCent: bill.TotalFee,
raw: classifyBody);
}
}
else if (classifyPayload != null && classifyPayload.Retcode != 0)
{
Log($"协议诊断(classifyread)retcode={classifyPayload.Retcode} errcode={classifyPayload.Errcode} msg={classifyPayload.Msg}");
}
}
catch (Exception ex)
{
Log($"协议诊断(classifyread)JSON 解析失败:{ex.Message}");
}
}
}
}
private async Task SyncPendingOrdersFromServerAsync(CancellationToken token, int payType, bool writeLog = true)
{
var serverUrl = NormalizeServerUrl(_config.ServerUrl);
var apiKey = _config.ApiKey?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiKey))
{
return;
}
var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
var sign = CreateMd5(timestamp + apiKey);
var pendingUrl = BuildPendingOrdersUrl(serverUrl, timestamp, sign, payType);
var response = await _httpClient.GetAsync(pendingUrl, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
{
if (writeLog)
{
Log($"待支付订单同步失败(type={payType})HTTP {(int)response.StatusCode} {response.StatusCode}");
}
return;
}
PendingOrdersApiResponse? payload = null;
try
{
payload = JsonSerializer.Deserialize<PendingOrdersApiResponse>(body, _jsonOptions);
}
catch (Exception ex)
{
var preview = body.Length > 200 ? body[..200] + "..." : body;
if (writeLog)
{
Log($"待支付订单同步失败(type={payType})JSON 解析异常 {ex.Message},响应预览={preview}");
}
return;
}
if (payload?.Code != 1 || payload.Data == null)
{
if (payload != null && writeLog)
{
Log($"待支付订单同步失败(type={payType})code={payload.Code} msg={payload.Msg}");
}
return;
}
var syncedOrders = payload.Data
.Select(x => new PendingOrderRecord
{
OrderId = x.OrderId ?? string.Empty,
PayId = x.PayId ?? string.Empty,
Param = x.Param ?? string.Empty,
PayType = x.PayType,
Price = x.Price,
ReallyPrice = x.ReallyPrice,
TimeOut = x.TimeOut,
State = x.State,
Date = x.Date,
RegisteredAt = DateTimeOffset.Now
})
.Where(x => !string.IsNullOrWhiteSpace(x.OrderId) && !string.IsNullOrWhiteSpace(x.PayId))
.ToList();
_pendingOrders.RemoveAll(x => x.PayType == payType);
_pendingOrders.AddRange(syncedOrders);
SavePendingOrders();
if (payType == 1)
{
if (_wechatProtocolLastPendingCount != syncedOrders.Count)
{
if (writeLog)
{
Log($"待支付订单同步成功:共 {syncedOrders.Count} 条微信待支付订单");
}
_wechatProtocolLastPendingCount = syncedOrders.Count;
}
}
else if (writeLog)
{
Log($"待支付订单同步成功:共 {syncedOrders.Count} 条支付宝待支付订单");
}
}
private static string BuildWechatSmallbookUrl(string sid, string version)
{
var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim();
return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/gethomedata?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}";
}
private static string BuildWechatClassifyReadUrl(string sid, string version)
{
var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim();
return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/classifyread?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}";
}
private static string BuildWechatTransactionDetailUrl(string sid, string version)
{
var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim();
return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/transactiondetail?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}";
}
private async Task<string> SendWechatProtocolRequestAsync(string url, object payload, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
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 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf254186b) XWEB/19481");
request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*");
request.Headers.TryAddWithoutValidation("X-Module-Name", "mmpaysmbpdreceiptassistmp");
request.Headers.TryAddWithoutValidation("X-Page", "pages/detail/detail");
request.Headers.TryAddWithoutValidation("X-Track-Id", $"TB{Guid.NewGuid():N}".ToUpperInvariant());
request.Headers.TryAddWithoutValidation("xweb_xhr", "1");
request.Headers.TryAddWithoutValidation("X-Appid", "unknown");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Mode", "cors");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Dest", "empty");
request.Headers.TryAddWithoutValidation("Referer", "https://servicewechat.com/wx28be8489b7a36aaa/1167/page-frame.html");
request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9");
var json = JsonSerializer.Serialize(payload, _jsonOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
{
Log($"协议诊断HTTP {(int)response.StatusCode}url={url},响应={body}");
return string.Empty;
}
return body;
}
private async Task<string> FetchWechatTransactionDetailAsync(string sid, string version, string? transId, long createTime, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(transId) || createTime <= 0)
{
return string.Empty;
}
var url = BuildWechatTransactionDetailUrl(sid, version);
var payload = new
{
v = version,
id = transId,
create_time = createTime,
sid = sid
};
var body = await SendWechatProtocolRequestAsync(url, payload, token);
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
try
{
var parsed = JsonSerializer.Deserialize<WechatApiBaseResponse>(body, _jsonOptions);
if (parsed?.Retcode != 0)
{
Log($"协议诊断(transactiondetail)retcode={parsed?.Retcode} errcode={parsed?.Errcode} msg={parsed?.Msg}");
return string.Empty;
}
}
catch (Exception ex)
{
Log($"协议诊断(transactiondetail)JSON 解析失败:{ex.Message}");
return string.Empty;
}
return body;
}
private void UpdateWechatProtocolCursor(string? transId, long createTime)
{
if (!string.IsNullOrWhiteSpace(transId))
{
_wechatProtocolLastTransId = transId.Trim();
}
if (createTime > _wechatProtocolLastCreateTime)
{
_wechatProtocolLastCreateTime = createTime;
}
}
private async Task ProcessWechatProtocolIncomeAsync(string id, string? transId, long timestamp, decimal feeCent, string raw)
{
if (string.IsNullOrWhiteSpace(id) || !_wechatProtocolSeen.Add(id))
{
return;
}
var amount = feeCent > 0 ? feeCent / 100m : 0m;
if (amount <= 0)
{
return;
}
var receivedAt = timestamp > 0
? DateTimeOffset.FromUnixTimeSeconds(timestamp)
: DateTimeOffset.Now;
var remark = string.IsNullOrWhiteSpace(transId) ? id : transId!;
var evt = new PaymentEvent
{
Channel = "wechat",
Amount = amount,
OrderNo = remark,
TradeNo = transId ?? string.Empty,
Payer = "wechat-protocol",
Status = "success",
ReceivedAt = receivedAt,
Raw = raw
};
var callbackResult = await ForwardEventToServerAsync(evt);
AddPaymentLog(evt, callbackResult);
Log($"协议收到收款:{amount:0.00},订单={remark}");
}
private async void btnToggleService_Click(object? sender, EventArgs e)
{
if (IsListenerRunning())
{
StopListener();
UpdateServiceStatus(false);
Log("监听已停止。");
return;
}
try
{
SaveUiToConfig();
ValidateConfig(_config);
SaveConfig();
await StartListenerAsync();
UpdateServiceStatus(true);
Log($"本地监听已启动:{BuildLocalListenUrl(_config)}");
}
catch (Exception ex)
{
UpdateServiceStatus(false);
Log($"启动监听失败:{ex.Message}");
MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async void btnEmailTest_Click(object? sender, EventArgs e)
{
try
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_config.NotifyEmail))
{
throw new InvalidOperationException("请先填写通知邮箱。");
}
if (string.IsNullOrWhiteSpace(_config.SenderEmail))
{
throw new InvalidOperationException("请先填写发送邮箱。");
}
if (string.IsNullOrWhiteSpace(_config.SmtpHost))
{
throw new InvalidOperationException("请先填写 SMTP 主机。");
}
if (_config.SmtpPort <= 0)
{
throw new InvalidOperationException("请先填写有效的 SMTP 端口。");
}
if (string.IsNullOrWhiteSpace(_config.EmailAuthCode))
{
throw new InvalidOperationException("请先填写邮箱授权码。");
}
await SendTestEmailAsync(
_config.SenderEmail.Trim(),
_config.NotifyEmail.Trim(),
_config.SmtpHost.Trim(),
_config.SmtpPort,
_config.EmailAuthCode.Trim());
Log($"测试邮件已发送:{_config.NotifyEmail}");
MessageBox.Show("测试邮件发送成功。", "邮箱测试", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log($"测试邮件发送失败:{ex.Message}");
MessageBox.Show(ex.Message, "邮箱测试失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnEmailSave_Click(object? sender, EventArgs e)
{
try
{
SaveUiToConfig();
SaveConfig();
Log("邮箱配置已保存到本地缓存文件。");
MessageBox.Show("邮箱配置已保存。", "保存成功", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log($"保存邮箱配置失败:{ex.Message}");
MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnAlipayLogin_Click(object? sender, EventArgs e)
{
try
{
if (_alipayMonitor?.IsRunning == true)
{
_alipayMonitor.Stop();
lblAlipayStatusValue.Text = "支付宝: 离线";
lblAlipayStatusValue.ForeColor = Color.Red;
btnAlipayLogin.Text = "扫码登录";
btnAlipayLogin.Type = TTypeMini.Primary;
Log("支付宝轮询监听已停止。");
return;
}
SaveUiToConfig();
SaveConfig();
using var loginForm = new Vmianqian.AlipayLoginForm(BuildAlipayOptions());
loginForm.LoginSucceeded += (_, args) =>
{
EnsureAlipayMonitorCreated();
_alipayMonitor!.SetCookies(args.CookieContainer);
_alipayMonitor.SetCtoken(args.CToken);
if (!string.IsNullOrWhiteSpace(args.CToken))
{
var pureApiUrl = txtAliPath.Text.Trim();
if (Uri.TryCreate(pureApiUrl, UriKind.Absolute, out var apiUri))
{
pureApiUrl = $"{apiUri.Scheme}://{apiUri.Host}{apiUri.AbsolutePath}";
}
txtAliPath.Text = pureApiUrl;
}
Log($"支付宝登录成功Cookie 已提取ctoken={args.CToken},当前地址:{args.CurrentUrl}");
lblAlipayStatusValue.Text = "支付宝: 已登录";
lblAlipayStatusValue.ForeColor = Color.DarkOrange;
// 登录成功后自动开始监听,不再需要用户手动再点一次。
_alipayMonitor.Start();
btnAlipayLogin.Text = "停止监听";
btnAlipayLogin.Type = TTypeMini.Error;
loginForm.BeginInvoke(() => loginForm.Close());
};
loginForm.StatusChanged += (_, args) =>
{
Log($"支付宝登录窗口:{args.Message}");
};
loginForm.ShowDialog(this);
}
catch (Exception ex)
{
Log($"打开支付宝登录窗口失败:{ex.Message}");
MessageBox.Show(ex.Message, "支付宝登录失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnAlipayStart_Click(object? sender, EventArgs e)
{
try
{
SaveUiToConfig();
SaveConfig();
EnsureAlipayMonitorCreated();
_alipayMonitor!.Start();
lblAlipayStatusValue.Text = "支付宝: 监听中";
lblAlipayStatusValue.ForeColor = Color.LimeGreen;
Log("支付宝轮询监听已启动。");
}
catch (Exception ex)
{
Log($"启动支付宝监听失败:{ex.Message}");
MessageBox.Show(ex.Message, "支付宝监听启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void btnAlipayStop_Click(object? sender, EventArgs e)
{
_alipayMonitor?.Stop();
lblAlipayStatusValue.Text = "支付宝: 离线";
lblAlipayStatusValue.ForeColor = Color.Red;
Log("支付宝轮询监听已停止。");
}
private Vmianqian.AlipayMonitorOptions BuildAlipayOptions()
{
var apiUrl = txtAliPath.Text.Trim();
var ctoken = string.Empty;
if (Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri))
{
ctoken = TryGetQueryParameter(uri.Query, "ctoken") ?? string.Empty;
apiUrl = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}";
}
return new Vmianqian.AlipayMonitorOptions
{
LoginUrl = "https://auth.alipay.com/login/index.htm",
BillApiUrl = string.IsNullOrWhiteSpace(apiUrl) || IsLegacyAlipayApiUrl(apiUrl)
? GetDefaultAlipayBillApiUrl()
: apiUrl,
AppId = string.Empty,
UserId = string.Empty,
CToken = ctoken,
JsonpCallback = "callback",
MinPollSeconds = 15,
MaxPollSeconds = Math.Max(15, Math.Min(35, (int)numAlipayInterval.Value)),
PageSize = 10
};
}
private void EnsureAlipayMonitorCreated()
{
if (_alipayMonitor != null)
{
return;
}
_alipayMonitor = new Vmianqian.AlipayMonitor(BuildAlipayOptions());
_alipayMonitor.SeedProcessedOrders(_pendingOrders
.Where(x => x.PayType == 2 && !string.IsNullOrWhiteSpace(x.TradeNo))
.Select(x => x.TradeNo));
_alipayMonitor.StatusChanged += (_, args) =>
{
if (InvokeRequired)
{
BeginInvoke(() => HandleAlipayStatusChanged(args));
return;
}
HandleAlipayStatusChanged(args);
};
_alipayMonitor.PaymentDetected += async (_, args) =>
{
var evt = new PaymentEvent
{
Channel = "alipay",
Amount = args.Amount,
OrderNo = args.OrderNo,
TradeNo = args.OrderNo,
Payer = string.IsNullOrWhiteSpace(args.Remark) ? args.Payer : args.Remark,
Status = "success",
ReceivedAt = args.PaidAt == default ? DateTimeOffset.Now : args.PaidAt,
Raw = args.RawJson
};
try
{
// 支付宝轮询不像微信协议监听那样会在每轮自动同步待支付订单,
// 因此这里在回调前主动同步一次,避免本地订单缓存过旧导致“未匹配到待支付订单”。
await SyncPendingOrdersFromServerAsync(CancellationToken.None, 2, false);
}
catch (Exception ex)
{
Log($"支付宝回调前同步待支付订单失败:{ex.Message}");
}
var callbackResult = await ForwardEventToServerAsync(evt);
AddPaymentLog(evt, callbackResult);
Log($"支付宝收到收款:{args.Amount:0.00},订单号={args.OrderNo},付款方={args.Payer}");
};
}
private void HandleAlipayStatusChanged(Vmianqian.AlipayStatusChangedEventArgs args)
{
// Trace 级别属于轮询诊断日志,容易造成“解析成功 / 重复订单 / 已跳过”刷屏。
// 这里只显示关键日志登录、运行、停止、Cookie 失效等。
if (!string.Equals(args.StatusCode, "Trace", StringComparison.OrdinalIgnoreCase))
{
Log($"支付宝状态:{args.Message}");
}
switch (args.StatusCode)
{
case "Running":
lblAlipayStatusValue.Text = "支付宝: 监听中";
lblAlipayStatusValue.ForeColor = Color.LimeGreen;
btnAlipayLogin.Text = "停止监听";
btnAlipayLogin.Type = TTypeMini.Error;
break;
case "Ready":
lblAlipayStatusValue.Text = "支付宝: 已登录";
lblAlipayStatusValue.ForeColor = Color.DarkOrange;
break;
case "CookieExpired":
lblAlipayStatusValue.Text = "支付宝: Cookie失效";
lblAlipayStatusValue.ForeColor = Color.Red;
btnAlipayLogin.Text = "扫码登录";
btnAlipayLogin.Type = TTypeMini.Primary;
MessageBox.Show("支付宝 Cookie 失效,请重新扫码登录。", "支付宝监听提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
break;
case "Stopped":
lblAlipayStatusValue.Text = "支付宝: 离线";
lblAlipayStatusValue.ForeColor = Color.Red;
btnAlipayLogin.Text = "扫码登录";
btnAlipayLogin.Type = TTypeMini.Primary;
break;
}
}
private async Task StartListenerAsync()
{
StopListener();
_listenerCancellationTokenSource = new CancellationTokenSource();
_httpListener = new HttpListener();
var prefix = BuildHttpPrefix(_config);
_httpListener.Prefixes.Add(prefix);
_httpListener.Start();
_ = Task.Run(() => ListenLoopAsync(_listenerCancellationTokenSource.Token));
await Task.CompletedTask;
}
private bool IsListenerRunning()
{
return _httpListener is { IsListening: true };
}
private void StopListener()
{
try
{
_listenerCancellationTokenSource?.Cancel();
}
catch
{
}
try
{
if (_httpListener is { IsListening: true })
{
_httpListener.Stop();
}
}
catch
{
}
try
{
_httpListener?.Close();
}
catch
{
}
_httpListener = null;
_listenerCancellationTokenSource?.Dispose();
_listenerCancellationTokenSource = null;
}
private async Task SendTestEmailAsync(string senderEmail, string notifyEmail, string smtpHost, int smtpPort, string authCode)
{
var errors = new List<string>();
var attempts = new List<(int Port, SecureSocketOptions SocketOptions)>
{
(smtpPort, smtpPort == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls),
(smtpPort, SecureSocketOptions.StartTlsWhenAvailable),
(smtpPort, SecureSocketOptions.Auto)
};
if (smtpPort == 465)
{
attempts.Add((587, SecureSocketOptions.StartTls));
attempts.Add((587, SecureSocketOptions.StartTlsWhenAvailable));
}
else if (smtpPort == 587)
{
attempts.Add((465, SecureSocketOptions.SslOnConnect));
attempts.Add((465, SecureSocketOptions.Auto));
}
foreach (var (port, socketOptions) in attempts.Distinct())
{
if (await TrySendTestEmailAsync(senderEmail, notifyEmail, smtpHost, port, socketOptions, authCode, errors))
{
return;
}
}
throw new InvalidOperationException("测试邮件发送失败:" + Environment.NewLine + string.Join(Environment.NewLine, errors));
}
private static async Task<bool> TrySendTestEmailAsync(string senderEmail, string notifyEmail, string smtpHost, int smtpPort, SecureSocketOptions socketOptions, string authCode, List<string> errors)
{
try
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(senderEmail));
message.To.Add(MailboxAddress.Parse(notifyEmail));
message.Subject = "V免签客户端测试邮件";
message.Body = new TextPart("plain")
{
Text = $"这是一封测试邮件,发送时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"
};
using var smtp = new SmtpClient
{
Timeout = 15000
};
await smtp.ConnectAsync(smtpHost, smtpPort, socketOptions);
await smtp.AuthenticateAsync(senderEmail, authCode);
await smtp.SendAsync(message);
await smtp.DisconnectAsync(true);
return true;
}
catch (Exception ex)
{
errors.Add($"SMTP {smtpHost}:{smtpPort} Socket={socketOptions} -> {GetDetailedExceptionMessage(ex)}");
return false;
}
}
private static string GetDetailedExceptionMessage(Exception ex)
{
var parts = new List<string>();
Exception? current = ex;
while (current != null)
{
if (!string.IsNullOrWhiteSpace(current.Message))
{
parts.Add(current.Message.Trim());
}
current = current.InnerException;
}
return parts.Count == 0 ? ex.GetType().Name : string.Join(" | ", parts.Distinct());
}
private async Task ListenLoopAsync(CancellationToken cancellationToken)
{
if (_httpListener == null)
{
return;
}
while (!cancellationToken.IsCancellationRequested && _httpListener.IsListening)
{
try
{
var context = await _httpListener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
Log($"监听循环异常:{ex.Message}");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
try
{
if (!string.Equals(context.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonResponseAsync(context.Response, 405, new { ok = false, message = "Only POST is supported." });
return;
}
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8);
var body = await reader.ReadToEndAsync();
Log($"收到本地事件:{body}");
var registration = TryParsePendingOrderRegistration(body);
if (registration != null)
{
RegisterPendingOrder(registration);
await WriteJsonResponseAsync(context.Response, 200, new
{
ok = true,
registered = true,
orderId = registration.OrderId,
payId = registration.PayId
});
return;
}
var paymentEvent = JsonSerializer.Deserialize<PaymentEvent>(body, _jsonOptions);
if (paymentEvent == null)
{
await WriteJsonResponseAsync(context.Response, 400, new { ok = false, message = "请求体不能为空。" });
return;
}
paymentEvent.ReceivedAt = paymentEvent.ReceivedAt == default ? DateTimeOffset.Now : paymentEvent.ReceivedAt;
paymentEvent.Raw ??= body;
var callbackResult = await ForwardEventToServerAsync(paymentEvent);
AddPaymentLog(paymentEvent, callbackResult);
await WriteJsonResponseAsync(context.Response, 200, new
{
ok = true,
forwarded = true,
statusCode = (int)callbackResult.StatusCode,
responseBody = callbackResult.ResponseBody
});
}
catch (Exception ex)
{
Log($"处理请求失败:{ex.Message}");
await WriteJsonResponseAsync(context.Response, 500, new { ok = false, message = ex.Message });
}
}
private PendingOrderRegistration? TryParsePendingOrderRegistration(string body)
{
try
{
var registration = JsonSerializer.Deserialize<PendingOrderRegistration>(body, _jsonOptions);
if (registration == null ||
string.IsNullOrWhiteSpace(registration.OrderId) ||
string.IsNullOrWhiteSpace(registration.PayId))
{
return null;
}
return registration;
}
catch
{
return null;
}
}
private void RegisterPendingOrder(PendingOrderRegistration registration)
{
var existing = _pendingOrders.FirstOrDefault(x => string.Equals(x.OrderId, registration.OrderId, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.PayId = registration.PayId.Trim();
existing.Param = registration.Param?.Trim() ?? string.Empty;
existing.Price = registration.Price;
existing.ReallyPrice = registration.ReallyPrice <= 0 ? registration.Price : registration.ReallyPrice;
existing.PayType = registration.PayType;
existing.Date = registration.Date;
existing.TimeOut = registration.TimeOut;
existing.State = registration.State;
existing.RegisteredAt = DateTimeOffset.Now;
}
else
{
_pendingOrders.Add(new PendingOrderRecord
{
OrderId = registration.OrderId.Trim(),
PayId = registration.PayId.Trim(),
Param = registration.Param?.Trim() ?? string.Empty,
Price = registration.Price,
ReallyPrice = registration.ReallyPrice <= 0 ? registration.Price : registration.ReallyPrice,
PayType = registration.PayType,
Date = registration.Date,
TimeOut = registration.TimeOut,
State = registration.State,
RegisteredAt = DateTimeOffset.Now
});
}
SavePendingOrders();
Log($"订单登记成功orderId={registration.OrderId} payId={registration.PayId} price={registration.Price:0.##}");
}
private async Task<ServerCallbackResult> ForwardEventToServerAsync(PaymentEvent paymentEvent)
{
var serverUrl = NormalizeServerUrl(_config.ServerUrl);
if (string.IsNullOrWhiteSpace(serverUrl))
{
return new ServerCallbackResult
{
StatusCode = HttpStatusCode.OK,
ResponseBody = "未配置服务端 URL已跳过回调。"
};
}
var matchedOrder = MatchPendingOrder(paymentEvent);
if (matchedOrder == null)
{
return new ServerCallbackResult
{
StatusCode = HttpStatusCode.Conflict,
ResponseBody = "未匹配到待支付订单,已跳过回调。"
};
}
var matched = matchedOrder;
var type = matched.PayType > 0
? matched.PayType.ToString(System.Globalization.CultureInfo.InvariantCulture)
: (string.Equals(paymentEvent.Channel, "wechat", StringComparison.OrdinalIgnoreCase) ? "1" : "2");
var payId = matched.PayId;
var param = matched.Param ?? string.Empty;
var priceValue = matched.Price > 0 ? matched.Price : paymentEvent.Amount;
var reallyPriceValue = matched.ReallyPrice > 0 ? matched.ReallyPrice : paymentEvent.Amount;
var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
var sign = CreateMd5(matched.OrderId + paymentEvent.TradeNo + timestamp + (_config.ApiKey ?? string.Empty));
var callbackUrl = BuildAppPushOrderUrl(
serverUrl,
matched.OrderId,
paymentEvent.TradeNo,
timestamp,
sign);
Log($"转发到服务端:{callbackUrl}");
var response = await _httpClient.GetAsync(callbackUrl);
var responseBody = await response.Content.ReadAsStringAsync();
Log($"服务端响应HTTP {(int)response.StatusCode} {response.StatusCode},内容:{responseBody}");
if (response.IsSuccessStatusCode && (responseBody.Contains("\"code\":1", StringComparison.OrdinalIgnoreCase) || responseBody.Contains("\"msg\":\"成功\"", StringComparison.OrdinalIgnoreCase) || responseBody.Contains("success", StringComparison.OrdinalIgnoreCase)))
{
MarkPendingOrderCompleted(matched.OrderId, paymentEvent.TradeNo);
}
return new ServerCallbackResult
{
StatusCode = response.StatusCode,
ResponseBody = responseBody
};
}
private PendingOrderRecord? MatchPendingOrder(PaymentEvent paymentEvent)
{
var expectedType = string.Equals(paymentEvent.Channel, "wechat", StringComparison.OrdinalIgnoreCase) ? 1 : 2;
var now = paymentEvent.ReceivedAt == default ? DateTimeOffset.Now : paymentEvent.ReceivedAt;
var candidate = _pendingOrders
.Where(x => x.State == 0 && x.PayType == expectedType)
.Where(x => Math.Abs(x.Price - paymentEvent.Amount) < 0.0001m || Math.Abs(x.ReallyPrice - paymentEvent.Amount) < 0.0001m)
.OrderByDescending(x => x.Date)
.ThenByDescending(x => x.RegisteredAt)
.FirstOrDefault(x =>
{
if (x.Date <= 0)
{
return true;
}
var createdAt = DateTimeOffset.FromUnixTimeSeconds(x.Date);
var timeoutMinutes = x.TimeOut > 0 ? x.TimeOut : 30;
return now >= createdAt.AddMinutes(-1) && now <= createdAt.AddMinutes(timeoutMinutes + 2);
});
if (candidate != null)
{
Log($"匹配待支付订单成功orderId={candidate.OrderId} payId={candidate.PayId} param={candidate.Param}");
}
else
{
Log($"未匹配到待支付订单channel={paymentEvent.Channel} amount={paymentEvent.Amount:0.##} tradeNo={paymentEvent.TradeNo}");
}
return candidate;
}
private void MarkPendingOrderCompleted(string orderId, string? tradeNo)
{
var order = _pendingOrders.FirstOrDefault(x => string.Equals(x.OrderId, orderId, StringComparison.OrdinalIgnoreCase));
if (order == null)
{
return;
}
order.State = 1;
order.TradeNo = tradeNo ?? string.Empty;
order.CompletedAt = DateTimeOffset.Now;
SavePendingOrders();
Log($"待支付订单已标记完成orderId={orderId}");
}
private static string BuildLegacyNotifyUrl(string serverUrl, string payId, string param, string type, string price, string reallyPrice, string sign)
{
var query = new List<string>
{
$"payId={Uri.EscapeDataString(payId)}",
$"param={Uri.EscapeDataString(param)}",
$"type={Uri.EscapeDataString(type)}",
$"price={Uri.EscapeDataString(price)}",
$"reallyPrice={Uri.EscapeDataString(reallyPrice)}",
$"sign={Uri.EscapeDataString(sign)}"
};
var separator = serverUrl.Contains('?') ? "&" : "?";
return serverUrl + separator + string.Join("&", query);
}
private static string BuildAppPushOrderUrl(string serverUrl, string orderId, string tradeNo, string timestamp, string sign)
{
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("服务端地址格式无效。");
}
var pushUri = new Uri(baseUri, "/appPushOrder");
var query = new List<string>
{
$"orderId={Uri.EscapeDataString(orderId)}",
$"tradeNo={Uri.EscapeDataString(tradeNo ?? string.Empty)}",
$"t={Uri.EscapeDataString(timestamp)}",
$"sign={Uri.EscapeDataString(sign)}"
};
return pushUri + "?" + string.Join("&", query);
}
private static string BuildPendingOrdersUrl(string serverUrl, string timestamp, string sign, int type)
{
return $"{serverUrl.TrimEnd('/')}/getPendingOrders?t={Uri.EscapeDataString(timestamp)}&sign={Uri.EscapeDataString(sign)}&type={type}";
}
private void AddPaymentLog(PaymentEvent paymentEvent, ServerCallbackResult callbackResult)
{
if (InvokeRequired)
{
BeginInvoke(() => AddPaymentLog(paymentEvent, callbackResult));
return;
}
// 将单一状态拆分为两个独立的文本,向 V免签官方术语对齐
string orderStatusText;
string callbackText;
if (callbackResult.StatusCode == HttpStatusCode.OK)
{
orderStatusText = "完成"; // 匹配成功并推送到云端
callbackText = "成功";
}
else if (callbackResult.StatusCode == HttpStatusCode.Conflict)
{
orderStatusText = "过期"; // 本地待支付列表中未找到对应的订单
callbackText = "本地跳过";
}
else
{
orderStatusText = "异常"; // 网络错误或云端返回报错
callbackText = $"失败({(int)callbackResult.StatusCode})";
}
if (string.Equals(paymentEvent.Channel, "alipay", StringComparison.OrdinalIgnoreCase))
{
// 支付宝列对应关系:序号, 订单号, 金额, 时间, 备注, 订单状态, 回调状态
gridAlipayLogs.Rows.Insert(
0,
gridAlipayLogs.Rows.Count + 1,
paymentEvent.Amount.ToString("0.00"),
paymentEvent.ReceivedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"),
paymentEvent.OrderNo,
paymentEvent.Payer,
orderStatusText, // 第六列:订单状态
callbackText
); // 第七列:回调状态
lblAlipayStatusValue.Text = "支付宝: 监听中";
lblAlipayStatusValue.ForeColor = Color.LimeGreen;
}
else
{
// 微信列对应关系:收款方, 金额, 时间, 备注/订单号, 订单状态, 回调状态
gridWechatLogs.Rows.Insert(
0,
paymentEvent.Payer,
paymentEvent.Amount.ToString("0.00"),
paymentEvent.ReceivedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"),
paymentEvent.OrderNo,
orderStatusText, // 塞入第五列:订单状态
callbackText
); // 塞入第六列:回调状态
lblWechatStatusValue.Text = "微信: 监听中";
lblWechatStatusValue.ForeColor = Color.LimeGreen;
}
}
private void chkHeartbeatEnabled_CheckedChanged(object? sender, EventArgs e)
{
ApplyHeartbeatSetting();
}
private async void btnHeartbeatCheck_Click(object? sender, EventArgs e)
{
await SendHeartbeatAsync(true);
}
private void ApplyHeartbeatSetting()
{
SaveUiToConfig();
if (_config.EnableHeartbeat)
{
StartHeartbeat();
}
else
{
StopHeartbeat();
}
}
private void StartHeartbeat()
{
// 按当前要求:每 10 秒自动续签一次心跳。
var intervalSeconds = 10;
_config.HeartbeatIntervalSeconds = 10;
_heartbeatTimer.Interval = intervalSeconds * 1000;
if (!_heartbeatTimer.Enabled)
{
_heartbeatTimer.Start();
}
else
{
_heartbeatTimer.Stop();
_heartbeatTimer.Start();
}
_ = SendHeartbeatAsync(false);
}
private void StopHeartbeat()
{
if (_heartbeatTimer.Enabled)
{
_heartbeatTimer.Stop();
}
}
private async Task SendHeartbeatAsync(bool writeSuccessLog)
{
if (_heartbeatRequestInProgress)
{
return;
}
_heartbeatRequestInProgress = true;
try
{
var serverUrl = NormalizeServerUrl(txtServerUrl.Text);
var apiKey = txtApiKey.Text.Trim();
if (string.IsNullOrWhiteSpace(serverUrl))
{
if (writeSuccessLog)
{
Log("心跳检测异常:未配置服务端地址。");
}
return;
}
if (string.IsNullOrWhiteSpace(apiKey))
{
if (writeSuccessLog)
{
Log("心跳检测异常:未配置通信密钥。");
}
return;
}
var heartbeatPushResult = await RequestAppHeartbeatAsync(serverUrl, apiKey);
if (!heartbeatPushResult.IsSuccessStatusCode)
{
if (writeSuccessLog)
{
Log($"心跳上报失败HTTP {(int)heartbeatPushResult.StatusCode} {heartbeatPushResult.StatusCode},内容:{heartbeatPushResult.ResponseBody}");
}
return;
}
if (heartbeatPushResult.Response?.Code != 1)
{
if (writeSuccessLog)
{
Log($"心跳上报失败code={heartbeatPushResult.Response?.Code ?? 0}msg={heartbeatPushResult.Response?.Msg ?? ""},内容:{heartbeatPushResult.ResponseBody}");
}
return;
}
var heartbeatCheckResult = await RequestHeartbeatStateAsync(serverUrl, apiKey);
if (!heartbeatCheckResult.IsSuccessStatusCode)
{
if (writeSuccessLog)
{
Log($"心跳检测异常HTTP {(int)heartbeatCheckResult.StatusCode} {heartbeatCheckResult.StatusCode},内容:{heartbeatCheckResult.ResponseBody}");
}
return;
}
var heartbeatResponse = heartbeatCheckResult.Response;
if (heartbeatResponse == null)
{
if (writeSuccessLog)
{
Log($"心跳检测异常:服务端返回无法解析,内容:{heartbeatCheckResult.ResponseBody}");
}
return;
}
if (heartbeatResponse.Code != 1)
{
if (writeSuccessLog)
{
Log($"心跳检测异常code={heartbeatResponse.Code}msg={heartbeatResponse.Msg}");
}
return;
}
if (heartbeatResponse.Data == null)
{
if (writeSuccessLog)
{
Log("心跳检测:心跳=正常,最后支付=-,最后心跳=-");
}
return;
}
var lastPayText = FormatUnixTimestamp(heartbeatResponse.Data.LastPay);
var lastHeartText = FormatUnixTimestamp(heartbeatResponse.Data.LastHeart);
var monitorState = heartbeatResponse.Data.State ?? heartbeatResponse.Data.JkState;
if (monitorState == 1)
{
if (writeSuccessLog)
{
Log($"心跳检测:心跳=正常,最后支付={lastPayText},最后心跳={lastHeartText}");
}
return;
}
var stateText = monitorState switch
{
0 => "掉线",
-1 => "未绑定监控端",
_ => $"未知({monitorState?.ToString() ?? "null"})"
};
if (writeSuccessLog)
{
Log($"心跳检测异常:状态={stateText},最后支付={lastPayText},最后心跳={lastHeartText}返回JSON={heartbeatCheckResult.ResponseBody}");
}
}
catch (Exception ex)
{
if (writeSuccessLog)
{
Log($"心跳检测异常:{ex.Message}");
}
}
finally
{
_heartbeatRequestInProgress = false;
}
}
private async Task<HeartbeatRequestResult> RequestAppHeartbeatAsync(string serverUrl, string apiKey)
{
var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
var url = BuildAppHeartbeatUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey));
return await SendHeartbeatRequestAsync(url);
}
private async Task<HeartbeatRequestResult> RequestHeartbeatStateAsync(string serverUrl, string apiKey)
{
var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
var candidates = new[]
{
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey)),
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp)),
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey), apiKey, null),
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp), apiKey, null),
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey), null, apiKey),
BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp), null, apiKey)
};
HeartbeatRequestResult? lastResult = null;
foreach (var url in candidates.Distinct())
{
var result = await SendHeartbeatRequestAsync(url);
lastResult = result;
if (!result.IsSuccessStatusCode)
{
continue;
}
if (result.Response?.Code == 1)
{
return result;
}
if (result.Response?.Msg?.Contains("签名校验不通过", StringComparison.OrdinalIgnoreCase) == true)
{
continue;
}
return result;
}
return lastResult ?? new HeartbeatRequestResult
{
StatusCode = HttpStatusCode.ServiceUnavailable,
ResponseBody = "未获得有效心跳响应。"
};
}
private async Task<HeartbeatRequestResult> SendHeartbeatRequestAsync(string url)
{
var response = await _httpClient.GetAsync(url);
var responseBody = await response.Content.ReadAsStringAsync();
HeartbeatStateResponse? parsed = null;
try
{
parsed = JsonSerializer.Deserialize<HeartbeatStateResponse>(responseBody, _jsonOptions);
}
catch
{
}
return new HeartbeatRequestResult
{
StatusCode = response.StatusCode,
ResponseBody = responseBody,
Response = parsed
};
}
private async Task WriteJsonResponseAsync(HttpListenerResponse response, int statusCode, object payload)
{
response.StatusCode = statusCode;
response.ContentType = "application/json; charset=utf-8";
var json = JsonSerializer.Serialize(payload, _jsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes);
response.OutputStream.Close();
}
private void UpdateServiceStatus(bool isRunning)
{
if (lblTopNotice is null)
{
return;
}
lblTopNotice.Text = isRunning
? $"监听中:{BuildLocalListenUrl(_config)}"
: string.Empty;
if (btnToggleService != null)
{
btnToggleService.Text = isRunning ? "停止监听" : "启动监听";
btnToggleService.Type = isRunning ? TTypeMini.Error : TTypeMini.Primary;
}
}
private void EnableWindowDrag(Control control)
{
control.MouseDown += (_, e) =>
{
if (e.Button != MouseButtons.Left)
{
return;
}
ReleaseCapture();
SendMessage(Handle, WmNclButtonDown, HtCaption, 0);
};
}
private static (string Sid, string Version, string Source) TryExtractSidFromWechatLocalFiles()
{
foreach (var root in GetWechatDataRoots())
{
if (!Directory.Exists(root))
{
continue;
}
var scanned = 0;
foreach (var file in EnumerateWechatCandidateFiles(root))
{
if (scanned >= 2500)
{
break;
}
scanned++;
try
{
var info = new FileInfo(file);
if (!info.Exists || info.Length <= 0 || info.Length > 16 * 1024 * 1024)
{
continue;
}
string? text = null;
try
{
text = File.ReadAllText(file);
}
catch
{
text = null;
}
if (!string.IsNullOrWhiteSpace(text) && TryExtractSidFromText(text, out var sid, out var version))
{
return (sid, version, file);
}
if (TryExtractSidFromBinary(file, out sid, out version))
{
return (sid, version, file);
}
}
catch
{
}
}
}
return (string.Empty, string.Empty, string.Empty);
}
private static IEnumerable<string> GetWechatDataRoots()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(appData, "Tencent", "WeChat");
yield return Path.Combine(appData, "Tencent", "Weixin");
yield return Path.Combine(appData, "Tencent", "WeChatAppEx");
yield return Path.Combine(localAppData, "Tencent", "WeChat");
yield return Path.Combine(localAppData, "Tencent", "Weixin");
yield return Path.Combine(localAppData, "Tencent", "WeChatAppEx");
}
private static IEnumerable<string> EnumerateWechatCandidateFiles(string root)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".log", ".txt", ".json", ".js", ".html", ".cache", ".db", ".dat"
};
IEnumerator<string>? it = null;
try
{
it = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).GetEnumerator();
}
catch
{
yield break;
}
using (it)
{
while (true)
{
string? file = null;
try
{
if (!it.MoveNext())
{
yield break;
}
file = it.Current;
}
catch
{
continue;
}
if (string.IsNullOrWhiteSpace(file))
{
continue;
}
var ext = Path.GetExtension(file);
if (!allowed.Contains(ext))
{
continue;
}
yield return file;
}
}
}
private static bool TryExtractSidFromText(string? text, out string sid, out string version)
{
sid = string.Empty;
version = string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var match = Regex.Match(
text,
@"(?:\?|&|""|\s)sid=([A-Za-z0-9\-_]{16,})",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!match.Success)
{
return false;
}
sid = match.Groups[1].Value.Trim();
var vMatch = Regex.Match(
text,
@"(?:\?|&|""|\s)v=([0-9]+(?:\.[0-9]+){1,3})",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (vMatch.Success)
{
version = vMatch.Groups[1].Value.Trim();
}
return !string.IsNullOrWhiteSpace(sid);
}
private static bool TryExtractSidFromBinary(string file, out string sid, out string version)
{
sid = string.Empty;
version = string.Empty;
try
{
var bytes = File.ReadAllBytes(file);
if (bytes.Length == 0)
{
return false;
}
// 以拉丁编码保留字节映射,便于从二进制中检索 ASCII URL/sid 片段
var text = Encoding.Latin1.GetString(bytes);
return TryExtractSidFromText(text, out sid, out version);
}
catch
{
return false;
}
}
private static void ValidateConfig(ClientConfig config)
{
if (config.ListenPort < 1 || config.ListenPort > 65535)
{
throw new InvalidOperationException("监听端口必须在 1-65535 之间。");
}
var serverUrl = NormalizeServerUrl(config.ServerUrl);
if (!string.IsNullOrWhiteSpace(serverUrl) &&
(!Uri.TryCreate(serverUrl, UriKind.Absolute, out var serverUri) ||
(serverUri.Scheme != Uri.UriSchemeHttp && serverUri.Scheme != Uri.UriSchemeHttps)))
{
throw new InvalidOperationException("服务端 URL 必须是有效的 HTTP/HTTPS 地址。");
}
}
private static string GetDefaultAlipayBillApiUrl()
{
return "https://consumeprod.alipay.com/record/advanced.htm";
}
private static bool IsLegacyAlipayApiUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
return false;
}
return url.Contains("mbillexprod.alipay.com/enterprise/simpleTradeOrderQuery.json", StringComparison.OrdinalIgnoreCase) ||
url.Contains("getMsgInfosNew.json", StringComparison.OrdinalIgnoreCase) ||
url.Contains("messageBox", StringComparison.OrdinalIgnoreCase) ||
url.Contains("msginfos", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeServerUrl(string? url)
{
var value = url?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
value = "https://" + value;
}
return value;
}
private void Log(string message)
{
if (InvokeRequired)
{
BeginInvoke(() => Log(message));
return;
}
txtLog.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}");
}
private static string NormalizeListenPath(string? path)
{
var normalized = string.IsNullOrWhiteSpace(path) ? "/notify/" : path.Trim();
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}
if (!normalized.EndsWith('/'))
{
normalized += "/";
}
return normalized;
}
private static string BuildHttpPrefix(ClientConfig config)
{
return $"http://+:{config.ListenPort}{NormalizeListenPath(config.ListenPath)}";
}
private static string BuildLocalListenUrl(ClientConfig config)
{
return $"http://127.0.0.1:{config.ListenPort}{NormalizeListenPath(config.ListenPath)}";
}
private static string BuildHeartbeatStateUrl(string serverUrl, string timestamp, string sign, string? key = null, string? userKey = null)
{
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("服务端地址格式无效。");
}
var stateUri = new Uri(baseUri, "/getState");
var query = new List<string>
{
$"t={Uri.EscapeDataString(timestamp)}",
$"sign={Uri.EscapeDataString(sign)}"
};
if (!string.IsNullOrWhiteSpace(key))
{
query.Add($"key={Uri.EscapeDataString(key)}");
}
if (!string.IsNullOrWhiteSpace(userKey))
{
query.Add($"userkey={Uri.EscapeDataString(userKey)}");
}
return $"{stateUri}?{string.Join("&", query)}";
}
private static string BuildAppHeartbeatUrl(string serverUrl, string timestamp, string sign)
{
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("服务端地址格式无效。");
}
var heartUri = new Uri(baseUri, "/appHeart");
return $"{heartUri}?t={Uri.EscapeDataString(timestamp)}&sign={Uri.EscapeDataString(sign)}";
}
private static string CreateMd5(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = MD5.HashData(bytes);
var builder = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
{
builder.Append(b.ToString("x2"));
}
return builder.ToString();
}
private static string FormatUnixTimestamp(long? timestamp)
{
if (timestamp == null || timestamp <= 0)
{
return "-";
}
try
{
return DateTimeOffset.FromUnixTimeSeconds(timestamp.Value).LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
}
catch
{
return timestamp.Value.ToString();
}
}
private void numWechatInterval_ValueChanged(object sender, EventArgs e)
{
}
private void gridPanel1_Click(object sender, EventArgs e)
{
}
private void label1_Click(object sender, EventArgs e)
{
}
}
public sealed class ClientConfig
{
public string ServerUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public string SenderEmail { get; set; } = string.Empty;
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; } = 465;
public string NotifyEmail { get; set; } = string.Empty;
public string EmailAuthCode { get; set; } = string.Empty;
public string WechatPath { get; set; } = string.Empty;
public string WechatSid { get; set; } = string.Empty;
public string WechatApiVersion { get; set; } = "7.10.1";
public string AlipayPath { get; set; } = string.Empty;
public string AlipayBillApiUrl { get; set; } = "https://consumeprod.alipay.com/record/advanced.htm";
public string AlipayAppId { get; set; } = string.Empty;
public string AlipayUserId { get; set; } = string.Empty;
public int ListenPort { get; set; } = 8989;
public int WechatIntervalSeconds { get; set; } = 5;
public int AlipayIntervalSeconds { get; set; } = 15;
public bool EnableWheelPolling { get; set; } = true;
public bool EnableHeartbeat { get; set; } = true;
public int HeartbeatIntervalSeconds { get; set; } = 30;
public string ListenPath { get; set; } = "/notify/";
}
public sealed class PaymentEvent
{
public string Channel { get; set; } = "wechat";
public decimal Amount { get; set; }
public string OrderNo { get; set; } = string.Empty;
public string TradeNo { get; set; } = string.Empty;
public string Payer { get; set; } = string.Empty;
public string Status { get; set; } = "success";
public DateTimeOffset ReceivedAt { get; set; }
public string? Raw { get; set; }
}
public sealed class PendingOrderRegistration
{
public string OrderId { get; set; } = string.Empty;
public string PayId { get; set; } = string.Empty;
public string? Param { get; set; }
public int PayType { get; set; }
public decimal Price { get; set; }
public decimal ReallyPrice { get; set; }
public int TimeOut { get; set; }
public int State { get; set; }
public long Date { get; set; }
}
public sealed class PendingOrderRecord
{
public string OrderId { get; set; } = string.Empty;
public string PayId { get; set; } = string.Empty;
public string Param { get; set; } = string.Empty;
public int PayType { get; set; }
public decimal Price { get; set; }
public decimal ReallyPrice { get; set; }
public int TimeOut { get; set; }
public int State { get; set; }
public long Date { get; set; }
public DateTimeOffset RegisteredAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string TradeNo { get; set; } = string.Empty;
}
public sealed class PendingOrdersApiResponse
{
public int Code { get; set; }
public string Msg { get; set; } = string.Empty;
public List<PendingOrderRegistration>? Data { get; set; }
}
public sealed class ServerCallbackPayload
{
public string ApiKey { get; set; } = string.Empty;
public string Channel { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string OrderNo { get; set; } = string.Empty;
public string TradeNo { get; set; } = string.Empty;
public string Payer { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTimeOffset ReceivedAt { get; set; }
public string? Raw { get; set; }
}
public sealed class ServerCallbackResult
{
public HttpStatusCode StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
}
public sealed class HeartbeatRequestResult
{
public HttpStatusCode StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
public HeartbeatStateResponse? Response { get; set; }
public bool IsSuccessStatusCode => (int)StatusCode >= 200 && (int)StatusCode <= 299;
}
public sealed class HeartbeatStateResponse
{
public int Code { get; set; }
public string Msg { get; set; } = string.Empty;
public HeartbeatStateData? Data { get; set; }
}
public sealed class HeartbeatStateData
{
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
[JsonPropertyName("lastpay")]
public long? LastPay { get; set; }
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
[JsonPropertyName("lastheart")]
public long? LastHeart { get; set; }
[JsonPropertyName("state")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public int? State { get; set; }
[JsonPropertyName("jkstate")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public int? JkState { get; set; }
}
public sealed class WechatSmallbookResponse
{
[JsonPropertyName("retcode")]
public int Retcode { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public WechatSmallbookData? Data { get; set; }
}
public sealed class WechatApiBaseResponse
{
[JsonPropertyName("retcode")]
public int Retcode { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
}
public sealed class WechatSmallbookData
{
[JsonPropertyName("income_list")]
public List<WechatSmallbookIncome> IncomeList { get; set; } = new();
}
public sealed class WechatSmallbookIncome
{
[JsonPropertyName("fee")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public decimal Fee { get; set; }
[JsonPropertyName("timestamp")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public long Timestamp { get; set; }
[JsonPropertyName("trans_id")]
public string? TransId { get; set; }
[JsonPropertyName("roll_id")]
public string? RollId { get; set; }
}
public sealed class WechatClassifyReadResponse
{
[JsonPropertyName("retcode")]
public int Retcode { get; set; }
[JsonPropertyName("errcode")]
public int Errcode { get; set; }
[JsonPropertyName("msg")]
public string Msg { get; set; } = string.Empty;
[JsonPropertyName("data")]
public WechatClassifyReadData? Data { get; set; }
}
public sealed class WechatClassifyReadData
{
[JsonPropertyName("person_bill_list")]
public List<WechatClassifyReadBill> PersonBillList { get; set; } = new();
}
public sealed class WechatClassifyReadBill
{
[JsonPropertyName("trans_id")]
public string? TransId { get; set; }
[JsonPropertyName("create_time")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public long CreateTime { get; set; }
[JsonPropertyName("total_fee")]
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public decimal TotalFee { get; set; }
}
}