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 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 _pendingOrders = new(); private bool _isSynchronizingNavigation; private string _wechatMonitorMode = string.Empty; private CancellationTokenSource? _wechatHookCts; private CancellationTokenSource? _wechatProtocolCts; private CancellationTokenSource? _wechatSidCaptureCts; private readonly HashSet _wechatHookSeen = new(StringComparer.Ordinal); private readonly HashSet _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 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 lblWechatPollingTitle = 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 AntdUI.Input txtAliPath = null!; private AntdUI.Input txtAliAppId = null!; private AntdUI.Input txtAliPid = null!; private NumericUpDown numAlipayInterval = null!; private AntdUI.Input txtServicePort = null!; private AntdUI.Input txtListenPath = 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(); 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.Resize += (_, _) => LayoutHomePage(); pageWechat.Resize += (_, _) => LayoutWechatPage(); pageAlipay.Resize += (_, _) => LayoutAlipayPage(); pageSettings.Resize += (_, _) => LayoutSettingsPage(); 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; LayoutHomePage(); LayoutWechatPage(); LayoutAlipayPage(); LayoutSettingsPage(); if (IsInDesigner()) return; EnableWindowDrag(titlebar); EnableWindowDrag(lblTopNotice); EnableWindowDrag(lblWechatStatusValue); EnableWindowDrag(lblAlipayStatusValue); } 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 ); } logCard.ResumeLayout(false); } } finally { pageHome.ResumeLayout(false); } } private void LayoutWechatPage() { const int margin = 20; const int gap = 16; var hookCard = FindCard(pageWechat, "wechat-hook"); var protocolCard = FindCard(pageWechat, "wechat-protocol"); var gridCard = FindCard(pageWechat, "wechat-log"); if (hookCard != null && protocolCard != null) { var totalWidth = Math.Max(0, pageWechat.ClientSize.Width - margin * 2 - gap); var hookWidth = totalWidth / 2; var protocolWidth = totalWidth - hookWidth; hookCard.Bounds = new Rectangle(margin, 20, hookWidth, 280); protocolCard.Bounds = new Rectangle(hookCard.Right + gap, 20, protocolWidth, 280); 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(92, 36); var sidWidth = Math.Max(180, protocolCard.ClientSize.Width - 48 - btnWechatSidAuto.Width - 12); txtWechatId.Width = sidWidth; btnWechatSidAuto.Location = new Point(txtWechatId.Left + txtWechatId.Width + 12, txtWechatId.Top); var halfWidth = Math.Max(160, (sidWidth - 12) / 2); var rightColX = 24 + halfWidth + 12; lblWechatFrequencyTitle.Location = new Point(24, 146); numWechatInterval.Location = new Point(24, 182); numWechatInterval.Size = new Size(96, 30); lblWechatPollingTitle.Location = new Point(rightColX, 146); chkWheel.Location = new Point(rightColX, 198); btnWechatProtocolStart.Size = new Size(120, 42); btnWechatProtocolStart.Location = new Point(24, btnWechatHookStart.Bottom - btnWechatProtocolStart.Height); } if (gridCard != null && hookCard != null) { gridCard.Left = margin; gridCard.Width = Math.Max(0, pageWechat.ClientSize.Width - margin * 2); gridCard.Top = hookCard.Bottom + margin; var bottom = pageWechat.ClientSize.Height - 20; gridCard.Height = Math.Max(260, bottom - gridCard.Top); 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() { LayoutPageCards(pageAlipay, 20); var gridCard = FindCard(pageAlipay, "alipay-log"); if (gridCard != null) { var bottom = pageAlipay.ClientSize.Height - 20; gridCard.Height = Math.Max(260, bottom - gridCard.Top); numAlipayInterval.Size = new Size(96, 30); 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 listenInputWidth = Math.Max(220, listenCard.ClientSize.Width - 48); txtServicePort.Width = Math.Min(220, listenInputWidth); txtListenPath.Width = Math.Min(360, listenInputWidth); 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; } } 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("Callback", "回调状态"); return grid; } private DataGridView CreateAlipayGrid() { var grid = new DataGridView { AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, BackgroundColor = Color.White, BorderStyle = BorderStyle.FixedSingle }; grid.Columns.Add("Index", "序号"); grid.Columns.Add("OrderNo", "订单号"); grid.Columns.Add("Amount", "金额"); grid.Columns.Add("Time", "时间"); grid.Columns.Add("Remark", "备注"); grid.Columns.Add("Callback", "回调状态"); return grid; } private void Form1_Load(object? sender, EventArgs e) { LoadConfig(); BindConfigToUi(); InitializeRuntimeTimer(); InitializeHeartbeatTimer(); SelectPage("home"); UpdateServiceStatus(false); ApplyHeartbeatSetting(); Log("程序已启动。"); } private void Form1_FormClosing(object? sender, FormClosingEventArgs e) { StopListener(); StopHeartbeat(); StopWechatHook(); StopWechatProtocol(); StopWechatSidCapture(); _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.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(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(); return; } try { var json = File.ReadAllText(_pendingOrdersFilePath, Encoding.UTF8); _pendingOrders = JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); } catch { _pendingOrders = new List(); } } 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; txtAliPath.Text = _config.AlipayPath; txtAliAppId.Text = _config.AlipayAppId; txtAliPid.Text = _config.AlipayUserId; txtServicePort.Text = _config.ListenPort.ToString(); txtListenPath.Text = _config.ListenPath; numWechatInterval.Value = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 3600); numAlipayInterval.Value = Math.Min(Math.Max(_config.AlipayIntervalSeconds, 1), 3600); 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.AlipayAppId = txtAliAppId.Text.Trim(); _config.AlipayUserId = txtAliPid.Text.Trim(); _config.ListenPort = ParsePort(txtServicePort.Text); _config.ListenPath = string.IsNullOrWhiteSpace(txtListenPath.Text) ? "/notify/" : txtListenPath.Text.Trim(); _config.WechatIntervalSeconds = (int)numWechatInterval.Value; _config.AlipayIntervalSeconds = (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 { 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(); var token = _wechatSidCaptureCts.Token; UpdateWechatMonitorButtons(); btnWechatSidAuto.Text = "停止捕获"; btnWechatSidAuto.Type = TTypeMini.Error; btnWechatSidAuto.Loading = true; _ = Task.Run(async () => { while (!token.IsCancellationRequested) { try { var result = TryExtractSidFromWechatLocalFiles(); if (!string.IsNullOrWhiteSpace(result.Sid)) { BeginInvoke(() => ApplyCapturedSid(result.Sid, result.Version, result.Source)); return; } await Task.Delay(TimeSpan.FromSeconds(2), token); } catch (OperationCanceledException) { break; } catch (Exception ex) { BeginInvoke(() => Log($"SID 捕获轮询异常:{ex.Message}")); await Task.Delay(TimeSpan.FromSeconds(2), token); } } }, token); } private void StopWechatSidCapture() { try { _wechatSidCaptureCts?.Cancel(); } catch { } try { _wechatSidCaptureCts?.Dispose(); } catch { } _wechatSidCaptureCts = null; UpdateWechatMonitorButtons(); if (btnWechatSidAuto != null) { btnWechatSidAuto.Text = "自动获取"; btnWechatSidAuto.Type = TTypeMini.Primary; btnWechatSidAuto.Ghost = true; btnWechatSidAuto.Loading = false; btnWechatSidAuto.Enabled = true; } } 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) { if (_wechatSidCaptureCts != null) { StopWechatSidCapture(); UpdateWechatMonitorButtons(); Log("SID 获取已停止。"); return; } try { SaveUiToConfig(); SaveConfig(); LaunchWechatForSidCapture(); Log("SID 捕获已启动:请等待微信打开,并进入微信收款助手/收款小账本页面。"); StartWechatSidCapture(); UpdateWechatMonitorButtons(); } catch (Exception ex) { Log($"启动微信并获取 SID 失败:{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 sidCaptureActive = _wechatSidCaptureCts != null; var protocolActive = string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal); btnWechatHookStart.Text = sidCaptureActive ? "停止获取SID" : "1. 启动微信"; btnWechatHookStart.Type = sidCaptureActive ? TTypeMini.Error : TTypeMini.Primary; btnWechatProtocolStart.Text = protocolActive ? "停止监听" : "2. 开始监听"; 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(); 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(); 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 ExtractAutomationText(AutomationElement root, int max) { var results = new List(); try { var walker = TreeWalker.RawViewWalker; var stack = new Stack(); 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 ExtractElementTexts(AutomationElement element) { var values = new List(); 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; _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; } 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); // 先调用 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(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(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)); } } else { 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(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) { 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, type: 1); var response = await _httpClient.GetAsync(pendingUrl, token); var body = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { Log($"待支付订单同步失败:HTTP {(int)response.StatusCode} {response.StatusCode}"); return; } PendingOrdersApiResponse? payload = null; try { payload = JsonSerializer.Deserialize(body, _jsonOptions); } catch (Exception ex) { var preview = body.Length > 200 ? body[..200] + "..." : body; Log($"待支付订单同步失败:JSON 解析异常 {ex.Message},响应预览={preview}"); return; } if (payload?.Code != 1 || payload.Data == null) { if (payload != null) { Log($"待支付订单同步失败:code={payload.Code} msg={payload.Msg}"); } return; } _pendingOrders = 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(); SavePendingOrders(); if (_wechatProtocolLastPendingCount != _pendingOrders.Count) { Log($"待支付订单同步成功:共 {_pendingOrders.Count} 条微信待支付订单"); _wechatProtocolLastPendingCount = _pendingOrders.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 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 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(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 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(); 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 TrySendTestEmailAsync(string senderEmail, string notifyEmail, string smtpHost, int smtpPort, SecureSocketOptions socketOptions, string authCode, List 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(); 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(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(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 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 { $"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 { $"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; } var callbackText = callbackResult.StatusCode == HttpStatusCode.OK ? "成功" : $"失败({(int)callbackResult.StatusCode})"; if (string.Equals(paymentEvent.Channel, "alipay", StringComparison.OrdinalIgnoreCase)) { gridAlipayLogs.Rows.Insert( 0, gridAlipayLogs.Rows.Count + 1, paymentEvent.OrderNo, paymentEvent.Amount.ToString("0.00"), paymentEvent.ReceivedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"), paymentEvent.Payer, 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, 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() { const int intervalSeconds = 30; _heartbeatTimer.Interval = intervalSeconds * 1000; if (!_heartbeatTimer.Enabled) { _heartbeatTimer.Start(); } else { _heartbeatTimer.Stop(); _heartbeatTimer.Start(); } } private void StopHeartbeat() { if (_heartbeatTimer.Enabled) { _heartbeatTimer.Stop(); } } private async Task SendHeartbeatAsync(bool writeSuccessLog) { try { var serverUrl = NormalizeServerUrl(txtServerUrl.Text); var apiKey = txtApiKey.Text.Trim(); if (string.IsNullOrWhiteSpace(serverUrl)) { Log("心跳检测异常:未配置服务端地址。"); return; } if (string.IsNullOrWhiteSpace(apiKey)) { Log("心跳检测异常:未配置通信密钥。"); return; } var heartbeatCheckResult = await RequestHeartbeatStateAsync(serverUrl, apiKey); if (!heartbeatCheckResult.IsSuccessStatusCode) { Log($"心跳检测异常:HTTP {(int)heartbeatCheckResult.StatusCode} {heartbeatCheckResult.StatusCode},内容:{heartbeatCheckResult.ResponseBody}"); return; } var heartbeatResponse = heartbeatCheckResult.Response; if (heartbeatResponse == null) { Log($"心跳检测异常:服务端返回无法解析,内容:{heartbeatCheckResult.ResponseBody}"); return; } if (heartbeatResponse.Code != 1) { 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"})" }; Log($"心跳检测异常:状态={stateText},最后支付={lastPayText},最后心跳={lastHeartText},返回JSON={heartbeatCheckResult.ResponseBody}"); } catch (Exception ex) { Log($"心跳检测异常:{ex.Message}"); } } private async Task 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 response = await _httpClient.GetAsync(url); var responseBody = await response.Content.ReadAsStringAsync(); HeartbeatStateResponse? parsed = null; try { parsed = JsonSerializer.Deserialize(responseBody, _jsonOptions); } catch { } var result = new HeartbeatRequestResult { StatusCode = response.StatusCode, ResponseBody = responseBody, Response = parsed }; lastResult = result; if (!response.IsSuccessStatusCode) { continue; } if (parsed?.Code == 1) { return result; } if (parsed?.Msg?.Contains("签名校验不通过", StringComparison.OrdinalIgnoreCase) == true) { continue; } return result; } return lastResult ?? new HeartbeatRequestResult { StatusCode = HttpStatusCode.ServiceUnavailable, ResponseBody = "未获得有效心跳响应。" }; } 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 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 EnumerateWechatCandidateFiles(string root) { var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { ".log", ".txt", ".json", ".js", ".html", ".cache", ".db", ".dat" }; IEnumerator? 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 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 { $"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 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(); } } } 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 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; } = 5; public bool EnableWheelPolling { get; set; } = true; public bool EnableHeartbeat { get; set; } = false; 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? 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 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 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; } } }