3594 lines
135 KiB
C#
3594 lines
135 KiB
C#
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using System.Diagnostics;
|
||
using System.Text.RegularExpressions;
|
||
using System.Runtime.InteropServices;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using AntdUI;
|
||
using MailKit.Net.Smtp;
|
||
using MailKit.Security;
|
||
using MimeKit;
|
||
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<PendingOrderRecord> _pendingOrders = new();
|
||
private bool _isSynchronizingNavigation;
|
||
private string _wechatMonitorMode = string.Empty;
|
||
private CancellationTokenSource? _wechatHookCts;
|
||
private CancellationTokenSource? _wechatProtocolCts;
|
||
private CancellationTokenSource? _wechatSidCaptureCts;
|
||
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 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();
|
||
BuildAntdDemoStyleUi();
|
||
Load += Form1_Load;
|
||
FormClosing += Form1_FormClosing;
|
||
}
|
||
|
||
private void BuildAntdDemoStyleUi()
|
||
{
|
||
BackColor = Color.White;
|
||
Font = new Font("Microsoft YaHei UI", 9F);
|
||
Text = "V免签 Demo";
|
||
var fixedWindowSize = new Size(1200, 1000);
|
||
MinimumSize = fixedWindowSize;
|
||
MaximumSize = fixedWindowSize;
|
||
Size = fixedWindowSize;
|
||
MaximizeBox = false;
|
||
|
||
BuildTitlebar();
|
||
BuildBottomBar();
|
||
BuildMenu();
|
||
BuildContentHost();
|
||
|
||
Controls.Add(contentHost);
|
||
Controls.Add(menu);
|
||
Controls.Add(bottomBar);
|
||
Controls.Add(titlebar);
|
||
}
|
||
|
||
private void BuildTitlebar()
|
||
{
|
||
titlebar = new AntdUI.PageHeader
|
||
{
|
||
Dock = DockStyle.Top,
|
||
Height = 44,
|
||
DividerShow = true,
|
||
ShowButton = true,
|
||
ShowIcon = true,
|
||
Text = "V免签PC客户端",
|
||
SubText = "V0.0.1"
|
||
};
|
||
|
||
lblTopNotice = new AntdUI.Label
|
||
{
|
||
Dock = DockStyle.Fill,
|
||
Text = "监听未启动",
|
||
TextAlign = ContentAlignment.MiddleRight,
|
||
ForeColor = Color.DimGray
|
||
};
|
||
|
||
lblAlipayStatusValue = new AntdUI.Label
|
||
{
|
||
Dock = DockStyle.Right,
|
||
Width = 120,
|
||
Text = "支付宝: 离线",
|
||
TextAlign = ContentAlignment.MiddleRight,
|
||
ForeColor = Color.Red
|
||
};
|
||
|
||
lblWechatStatusValue = new AntdUI.Label
|
||
{
|
||
Dock = DockStyle.Right,
|
||
Width = 120,
|
||
Text = "微信: 离线",
|
||
TextAlign = ContentAlignment.MiddleRight,
|
||
ForeColor = Color.Red
|
||
};
|
||
|
||
titlebar.Controls.Add(lblTopNotice);
|
||
titlebar.Controls.Add(lblAlipayStatusValue);
|
||
titlebar.Controls.Add(lblWechatStatusValue);
|
||
|
||
EnableWindowDrag(titlebar);
|
||
EnableWindowDrag(lblTopNotice);
|
||
EnableWindowDrag(lblWechatStatusValue);
|
||
EnableWindowDrag(lblAlipayStatusValue);
|
||
}
|
||
|
||
private void BuildBottomBar()
|
||
{
|
||
bottomBar = new AntdUI.PageHeader
|
||
{
|
||
Dock = DockStyle.Bottom,
|
||
Height = 40,
|
||
DividerShow = true,
|
||
UseLeftMargin = false
|
||
};
|
||
|
||
buttonCollapse = new AntdUI.Button
|
||
{
|
||
Dock = DockStyle.Left,
|
||
Width = 50,
|
||
Ghost = true,
|
||
Radius = 0,
|
||
WaveSize = 0,
|
||
IconRatio = 0.6F,
|
||
IconSvg = "MenuUnfoldOutlined",
|
||
ToggleIconSvg = "MenuFoldOutlined",
|
||
Toggle = true
|
||
};
|
||
buttonCollapse.Click += ButtonCollapse_Click;
|
||
|
||
lblRuntimeBottom = new WinLabel
|
||
{
|
||
Dock = DockStyle.Right,
|
||
Width = 220,
|
||
TextAlign = ContentAlignment.MiddleRight,
|
||
ForeColor = Color.Gray,
|
||
Text = "00天:00时:00分:00秒"
|
||
};
|
||
|
||
bottomBar.Controls.Add(lblRuntimeBottom);
|
||
bottomBar.Controls.Add(buttonCollapse);
|
||
}
|
||
|
||
private void BuildMenu()
|
||
{
|
||
menu = new AntdUI.Menu
|
||
{
|
||
Dock = DockStyle.Left,
|
||
Width = 220,
|
||
Collapsed = false,
|
||
Unique = true,
|
||
IconRatio = 1F,
|
||
Indent = true,
|
||
Round = false,
|
||
Radius = 0,
|
||
Font = new Font("Microsoft YaHei UI", 9F)
|
||
};
|
||
menu.SelectChanged += Menu_SelectChanged;
|
||
ReloadMenuItems();
|
||
}
|
||
|
||
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 BuildContentHost()
|
||
{
|
||
contentHost = new WinPanel
|
||
{
|
||
Dock = DockStyle.Fill,
|
||
BackColor = Color.White
|
||
};
|
||
|
||
pageHome = CreatePagePanel();
|
||
pageWechat = CreatePagePanel();
|
||
pageAlipay = CreatePagePanel();
|
||
pageSettings = CreatePagePanel();
|
||
|
||
contentHost.Controls.Add(pageSettings);
|
||
contentHost.Controls.Add(pageAlipay);
|
||
contentHost.Controls.Add(pageWechat);
|
||
contentHost.Controls.Add(pageHome);
|
||
|
||
BuildHomePage();
|
||
BuildWechatPage();
|
||
BuildAlipayPage();
|
||
BuildSettingsPage();
|
||
|
||
pageHome.Resize += (_, _) => LayoutHomePage();
|
||
pageWechat.Resize += (_, _) => LayoutWechatPage();
|
||
pageAlipay.Resize += (_, _) => LayoutAlipayPage();
|
||
pageSettings.Resize += (_, _) => LayoutSettingsPage();
|
||
|
||
LayoutHomePage();
|
||
LayoutWechatPage();
|
||
LayoutAlipayPage();
|
||
LayoutSettingsPage();
|
||
}
|
||
|
||
private WinPanel CreatePagePanel()
|
||
{
|
||
var panel = new WinPanel
|
||
{
|
||
Dock = DockStyle.Fill,
|
||
BackColor = Color.FromArgb(245, 247, 250),
|
||
AutoScroll = true
|
||
};
|
||
panel.HorizontalScroll.Enabled = false;
|
||
panel.HorizontalScroll.Visible = false;
|
||
return panel;
|
||
}
|
||
|
||
private void LayoutHomePage()
|
||
{
|
||
const int margin = 20;
|
||
const int gap = 16;
|
||
const int cardHeight = 420;
|
||
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 rowBottom = top + cardHeight;
|
||
var rowWidth = Math.Max(0, availableWidth - gap);
|
||
var leftWidth = rowWidth / 2;
|
||
var rightWidth = rowWidth - leftWidth;
|
||
|
||
configCard.Bounds = new Rectangle(margin, top, leftWidth, cardHeight);
|
||
memberCard.Bounds = new Rectangle(margin + leftWidth + gap, top, rightWidth, cardHeight);
|
||
|
||
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, 94);
|
||
txtApiKey.Location = new Point(contentLeft, 122);
|
||
txtApiKey.Width = inputWidth;
|
||
|
||
const int actionTop = 252;
|
||
const int buttonGap = 12;
|
||
var actionButtonWidth = Math.Max(72, (contentWidth - buttonGap * 2) / 3);
|
||
btnSaveConfig.Location = new Point(24, actionTop);
|
||
btnSaveConfig.Size = new Size(actionButtonWidth, 36);
|
||
btnToggleService.Location = new Point(btnSaveConfig.Right + buttonGap, actionTop);
|
||
btnToggleService.Size = new Size(actionButtonWidth, 36);
|
||
btnHeartbeatCheck.Location = new Point(btnToggleService.Right + buttonGap, actionTop);
|
||
btnHeartbeatCheck.Size = new Size(actionButtonWidth, 34);
|
||
|
||
const int heartbeatTop = 300;
|
||
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 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.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, 240);
|
||
protocolCard.Bounds = new Rectangle(hookCard.Right + gap, 20, protocolWidth, 240);
|
||
|
||
var hookPathWidth = Math.Max(140, hookCard.ClientSize.Width - 24 - 24 - btnSelectWechatPath.Width - 12);
|
||
txtWechatPath.Width = hookPathWidth;
|
||
btnSelectWechatPath.Location = new Point(txtWechatPath.Left + txtWechatPath.Width + 12, txtWechatPath.Top);
|
||
btnWechatHookStart.Size = new Size(120, 36);
|
||
btnWechatHookStart.Location = new Point(24, hookCard.ClientSize.Height - btnWechatHookStart.Height - 16);
|
||
|
||
lblWechatSidTitle.Location = new Point(24, 54);
|
||
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, 122);
|
||
numWechatInterval.Location = new Point(24, 150);
|
||
lblWechatPollingTitle.Location = new Point(rightColX, 122);
|
||
chkWheel.Location = new Point(rightColX, 150);
|
||
btnWechatProtocolStart.Size = new Size(120, 36);
|
||
btnWechatProtocolStart.Location = new Point(24, protocolCard.ClientSize.Height - btnWechatProtocolStart.Height - 16);
|
||
}
|
||
|
||
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.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);
|
||
|
||
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.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 void BuildHomePage()
|
||
{
|
||
var summaryCard = CreateCardPanel(new Rectangle(20, 20, 1080, 120));
|
||
summaryCard.Tag = "home-summary";
|
||
|
||
lblSummaryTitle = new AntdUI.Label
|
||
{
|
||
Text = "欢迎使用 V免签 PC 监听客户端",
|
||
Font = new Font("Microsoft YaHei UI", 22F, FontStyle.Regular),
|
||
AutoSize = true,
|
||
Location = new Point(28, 24)
|
||
};
|
||
|
||
lblSummaryDesc = new AntdUI.Label
|
||
{
|
||
Text = "当前阶段先打通“PC端本地监听 -> V免签服务端回调 -> 心跳检测”链路,整体布局按 AntdUI Demo 风格复刻。",
|
||
AutoSize = false,
|
||
Location = new Point(70, 64),
|
||
ForeColor = Color.DimGray
|
||
};
|
||
|
||
summaryCard.Controls.Add(lblSummaryTitle);
|
||
summaryCard.Controls.Add(lblSummaryDesc);
|
||
|
||
var configCard = CreateCardPanel(new Rectangle(20, 160, 1080, 360));
|
||
configCard.Tag = "home-config";
|
||
|
||
lblServerUrlTitle = CreateTitleLabel("V免签地址", 24, 20);
|
||
configCard.Controls.Add(lblServerUrlTitle);
|
||
txtServerUrl = CreateInput(24, 48, 500, "例如:https://你的域名/");
|
||
configCard.Controls.Add(txtServerUrl);
|
||
|
||
lblApiKeyTitle = CreateTitleLabel("通信密钥 / Token", 24, 94);
|
||
configCard.Controls.Add(lblApiKeyTitle);
|
||
txtApiKey = CreateInput(24, 122, 500, "请输入与服务端密钥");
|
||
configCard.Controls.Add(txtApiKey);
|
||
|
||
btnSaveConfig = new AntdUI.Button
|
||
{
|
||
Text = "保存配置",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(24, 252),
|
||
Size = new Size(110, 36),
|
||
};
|
||
btnSaveConfig.Click += btnSaveConfig_Click;
|
||
|
||
btnToggleService = new AntdUI.Button
|
||
{
|
||
Text = "启动监听",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(146, 292),
|
||
Size = new Size(110, 36),
|
||
};
|
||
btnToggleService.Click += btnToggleService_Click;
|
||
|
||
configCard.Controls.Add(btnSaveConfig);
|
||
configCard.Controls.Add(btnToggleService);
|
||
|
||
chkHeartbeatEnabled = new AntdUI.Switch
|
||
{
|
||
Location = new Point(24, 296),
|
||
Size = new Size(62, 28),
|
||
AutoSize = false
|
||
};
|
||
chkHeartbeatEnabled.CheckedChanged += chkHeartbeatEnabled_CheckedChanged;
|
||
|
||
lblHeartbeatDesc = new AntdUI.Label
|
||
{
|
||
Text = "自动心跳",
|
||
AutoSize = true,
|
||
Location = new Point(90, 301),
|
||
ForeColor = Color.DimGray
|
||
};
|
||
|
||
btnHeartbeatCheck = new AntdUI.Button
|
||
{
|
||
Text = "心跳检测",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(268, 292),
|
||
Size = new Size(110, 34)
|
||
};
|
||
btnHeartbeatCheck.Click += btnHeartbeatCheck_Click;
|
||
|
||
configCard.Controls.Add(chkHeartbeatEnabled);
|
||
configCard.Controls.Add(lblHeartbeatDesc);
|
||
configCard.Controls.Add(btnHeartbeatCheck);
|
||
|
||
var memberCard = CreateCardPanel(new Rectangle(660, 160, 440, 360));
|
||
memberCard.Tag = "home-member";
|
||
memberCard.Controls.Add(CreateTitleLabel("会员登录绑定", 24, 20));
|
||
lblMemberPlaceholder = new AntdUI.Label
|
||
{
|
||
Text = "预留区域:后续用于会员登录、设备绑定、解绑与状态展示。",
|
||
AutoSize = false,
|
||
Location = new Point(24, 56),
|
||
Size = new Size(392, 52),
|
||
ForeColor = Color.DimGray
|
||
};
|
||
memberCard.Controls.Add(lblMemberPlaceholder);
|
||
|
||
var logCard = CreateCardPanel(new Rectangle(20, 500, 1080, 300));
|
||
logCard.Tag = "home-log";
|
||
|
||
logCard.Controls.Add(CreateTitleLabel("运行日志", 24, 18));
|
||
btnClearLog = new AntdUI.Button
|
||
{
|
||
Text = "清空",
|
||
Type = TTypeMini.Primary,
|
||
Ghost = true,
|
||
Location = new Point(980, 14),
|
||
Size = new Size(72, 30),
|
||
Anchor = AnchorStyles.Top | AnchorStyles.Right
|
||
};
|
||
btnClearLog.Click += (_, _) => txtLog.Clear();
|
||
logCard.Controls.Add(btnClearLog);
|
||
|
||
txtLog = new WinTextBox
|
||
{
|
||
Location = new Point(24, 50),
|
||
Size = new Size(1030, 224),
|
||
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
|
||
Multiline = true,
|
||
ScrollBars = ScrollBars.Vertical,
|
||
ReadOnly = true,
|
||
BackColor = Color.Black,
|
||
ForeColor = Color.Lime,
|
||
BorderStyle = BorderStyle.FixedSingle
|
||
};
|
||
logCard.Controls.Add(txtLog);
|
||
|
||
pageHome.Controls.Add(summaryCard);
|
||
pageHome.Controls.Add(configCard);
|
||
pageHome.Controls.Add(memberCard);
|
||
pageHome.Controls.Add(logCard);
|
||
}
|
||
|
||
private void BuildWechatPage()
|
||
{
|
||
var hookCard = CreateCardPanel(new Rectangle(20, 20, 532, 240));
|
||
hookCard.Tag = "wechat-hook";
|
||
hookCard.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
hookCard.Controls.Add(CreateTitleLabel("Hook 功能", 24, 18));
|
||
hookCard.Controls.Add(CreateTitleLabel("微信安装路径", 24, 54));
|
||
txtWechatPath = CreateInput(24, 48, 390, "微信.exe 路径");
|
||
txtWechatPath.Location = new Point(24, 82);
|
||
txtWechatPath.Width = 280;
|
||
hookCard.Controls.Add(txtWechatPath);
|
||
btnSelectWechatPath = new AntdUI.Button
|
||
{
|
||
Text = "选择路径",
|
||
Type = TTypeMini.Primary,
|
||
Ghost = true,
|
||
Location = new Point(316, 82),
|
||
Size = new Size(92, 36)
|
||
};
|
||
btnSelectWechatPath.Click += btnSelectWechatPath_Click;
|
||
hookCard.Controls.Add(btnSelectWechatPath);
|
||
btnWechatHookStart = new AntdUI.Button
|
||
{
|
||
Text = "开始监听",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(24, 128),
|
||
Size = new Size(120, 36)
|
||
};
|
||
btnWechatHookStart.Click += btnWechatHookStart_Click;
|
||
hookCard.Controls.Add(btnWechatHookStart);
|
||
|
||
var protocolCard = CreateCardPanel(new Rectangle(568, 20, 532, 240));
|
||
protocolCard.Tag = "wechat-protocol";
|
||
protocolCard.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
protocolCard.Controls.Add(CreateTitleLabel("协议功能", 24, 18));
|
||
|
||
lblWechatSidTitle = CreateTitleLabel("微信 SID", 24, 54);
|
||
protocolCard.Controls.Add(lblWechatSidTitle);
|
||
txtWechatId = CreateInput(24, 82, 356, "可选");
|
||
protocolCard.Controls.Add(txtWechatId);
|
||
btnWechatSidAuto = new AntdUI.Button
|
||
{
|
||
Text = "自动获取",
|
||
Type = TTypeMini.Primary,
|
||
Ghost = true,
|
||
Location = new Point(392, 82),
|
||
Size = new Size(92, 36)
|
||
};
|
||
btnWechatSidAuto.Click += btnWechatSidAuto_Click;
|
||
protocolCard.Controls.Add(btnWechatSidAuto);
|
||
|
||
lblWechatFrequencyTitle = CreateTitleLabel("监控频率(秒)", 24, 122);
|
||
protocolCard.Controls.Add(lblWechatFrequencyTitle);
|
||
numWechatInterval = new NumericUpDown
|
||
{
|
||
Location = new Point(24, 150),
|
||
Size = new Size(96, 23),
|
||
Minimum = 1,
|
||
Maximum = 3600,
|
||
Value = 5
|
||
};
|
||
protocolCard.Controls.Add(numWechatInterval);
|
||
|
||
lblWechatPollingTitle = CreateTitleLabel("接口轮询", 274, 122);
|
||
protocolCard.Controls.Add(lblWechatPollingTitle);
|
||
chkWheel = new AntdUI.Checkbox
|
||
{
|
||
Text = "接口轮询",
|
||
Location = new Point(274, 150),
|
||
AutoSize = true
|
||
};
|
||
protocolCard.Controls.Add(chkWheel);
|
||
btnWechatProtocolStart = new AntdUI.Button
|
||
{
|
||
Text = "开始监听",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(24, 196),
|
||
Size = new Size(120, 36)
|
||
};
|
||
btnWechatProtocolStart.Click += btnWechatProtocolStart_Click;
|
||
protocolCard.Controls.Add(btnWechatProtocolStart);
|
||
|
||
var gridCard = CreateCardPanel(new Rectangle(20, 280, 1080, 450));
|
||
gridCard.Tag = "wechat-log";
|
||
gridCard.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||
gridCard.Controls.Add(CreateTitleLabel("微信监听记录", 24, 18));
|
||
btnClearWechatLog = new AntdUI.Button
|
||
{
|
||
Text = "清空",
|
||
Type = TTypeMini.Primary,
|
||
Ghost = true,
|
||
Location = new Point(980, 14),
|
||
Size = new Size(72, 30),
|
||
Anchor = AnchorStyles.Top | AnchorStyles.Right
|
||
};
|
||
btnClearWechatLog.Click += (_, _) => gridWechatLogs.Rows.Clear();
|
||
gridCard.Controls.Add(btnClearWechatLog);
|
||
|
||
gridWechatLogs = CreateWechatGrid();
|
||
gridWechatLogs.Location = new Point(24, 50);
|
||
gridWechatLogs.Size = new Size(1030, 464);
|
||
gridWechatLogs.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||
gridCard.Controls.Add(gridWechatLogs);
|
||
|
||
pageWechat.Controls.Add(hookCard);
|
||
pageWechat.Controls.Add(protocolCard);
|
||
pageWechat.Controls.Add(gridCard);
|
||
UpdateWechatMonitorButtons();
|
||
}
|
||
|
||
private void BuildAlipayPage()
|
||
{
|
||
var card = CreateCardPanel(new Rectangle(20, 20, 1080, 150));
|
||
card.Tag = "alipay-config";
|
||
card.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
|
||
card.Controls.Add(CreateTitleLabel("旺旺/支付宝路径", 24, 20));
|
||
txtAliPath = CreateInput(24, 48, 300, "程序路径");
|
||
card.Controls.Add(txtAliPath);
|
||
|
||
card.Controls.Add(CreateTitleLabel("应用 ID", 350, 20));
|
||
txtAliAppId = CreateInput(350, 48, 170, "AppId");
|
||
card.Controls.Add(txtAliAppId);
|
||
|
||
card.Controls.Add(CreateTitleLabel("用户 ID", 550, 20));
|
||
txtAliPid = CreateInput(550, 48, 170, "Pid/UserId");
|
||
card.Controls.Add(txtAliPid);
|
||
|
||
card.Controls.Add(CreateTitleLabel("轮询频率(秒)", 750, 20));
|
||
numAlipayInterval = new NumericUpDown
|
||
{
|
||
Location = new Point(750, 51),
|
||
Size = new Size(96, 23),
|
||
Minimum = 1,
|
||
Maximum = 3600,
|
||
Value = 5
|
||
};
|
||
card.Controls.Add(numAlipayInterval);
|
||
|
||
var desc = new AntdUI.Label
|
||
{
|
||
Text = "后续这里接入支付宝真实到账监听逻辑,目前保留参数配置与回调结果展示。",
|
||
AutoSize = false,
|
||
Location = new Point(24, 102),
|
||
Size = new Size(960, 24),
|
||
ForeColor = Color.DimGray
|
||
};
|
||
card.Controls.Add(desc);
|
||
|
||
var gridCard = CreateCardPanel(new Rectangle(20, 190, 1080, 540));
|
||
gridCard.Tag = "alipay-log";
|
||
gridCard.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||
gridCard.Controls.Add(CreateTitleLabel("支付宝监听记录", 24, 18));
|
||
|
||
gridAlipayLogs = CreateAlipayGrid();
|
||
gridAlipayLogs.Location = new Point(24, 50);
|
||
gridAlipayLogs.Size = new Size(1030, 464);
|
||
gridAlipayLogs.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||
gridCard.Controls.Add(gridAlipayLogs);
|
||
|
||
pageAlipay.Controls.Add(card);
|
||
pageAlipay.Controls.Add(gridCard);
|
||
}
|
||
|
||
private void BuildSettingsPage()
|
||
{
|
||
var listenCard = CreateCardPanel(new Rectangle(20, 20, 1080, 200));
|
||
listenCard.Tag = "settings-listen";
|
||
listenCard.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
|
||
listenCard.Controls.Add(CreateTitleLabel("本地监听端口", 24, 20));
|
||
txtServicePort = CreateInput(24, 48, 180, "8989");
|
||
listenCard.Controls.Add(txtServicePort);
|
||
|
||
listenCard.Controls.Add(CreateTitleLabel("本地监听路径", 240, 20));
|
||
txtListenPath = CreateInput(240, 48, 240, "/notify/");
|
||
listenCard.Controls.Add(txtListenPath);
|
||
|
||
var info = new AntdUI.Label
|
||
{
|
||
Text = "这个页面用于维护本地监听、邮箱通知与项目说明。",
|
||
AutoSize = false,
|
||
Location = new Point(24, 108),
|
||
Size = new Size(920, 60),
|
||
ForeColor = Color.DimGray
|
||
};
|
||
listenCard.Controls.Add(info);
|
||
|
||
var emailCard = CreateCardPanel(new Rectangle(20, 236, 1080, 420));
|
||
emailCard.Tag = "settings-email";
|
||
emailCard.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||
|
||
emailCard.Controls.Add(CreateTitleLabel("邮箱通知配置", 24, 20));
|
||
lblSenderEmailTitle = CreateTitleLabel("发送邮箱", 24, 56);
|
||
emailCard.Controls.Add(lblSenderEmailTitle);
|
||
txtSenderEmail = CreateInput(24, 84, 500, "例如:yunzer_cn@163.com");
|
||
emailCard.Controls.Add(txtSenderEmail);
|
||
|
||
lblSmtpHostTitle = CreateTitleLabel("SMTP 主机", 24, 130);
|
||
emailCard.Controls.Add(lblSmtpHostTitle);
|
||
txtSmtpHost = CreateInput(24, 158, 500, "例如:smtp.163.com");
|
||
emailCard.Controls.Add(txtSmtpHost);
|
||
|
||
lblSmtpPortTitle = CreateTitleLabel("SMTP 端口", 24, 204);
|
||
emailCard.Controls.Add(lblSmtpPortTitle);
|
||
txtSmtpPort = CreateInput(24, 232, 220, "例如:465");
|
||
emailCard.Controls.Add(txtSmtpPort);
|
||
|
||
lblNotifyEmailTitle = CreateTitleLabel("通知邮箱", 24, 278);
|
||
emailCard.Controls.Add(lblNotifyEmailTitle);
|
||
txtNotifyEmail = CreateInput(24, 306, 500, "用于接收测试邮件");
|
||
emailCard.Controls.Add(txtNotifyEmail);
|
||
|
||
lblEmailAuthCodeTitle = CreateTitleLabel("授权码", 560, 278);
|
||
emailCard.Controls.Add(lblEmailAuthCodeTitle);
|
||
txtEmailAuthCode = CreateInput(560, 306, 320, "用于发送测试邮件");
|
||
emailCard.Controls.Add(txtEmailAuthCode);
|
||
|
||
btnEmailSave = new AntdUI.Button
|
||
{
|
||
Text = "保存配置",
|
||
Type = TTypeMini.Primary,
|
||
Location = new Point(24, 350),
|
||
Size = new Size(110, 36)
|
||
};
|
||
btnEmailSave.Click += btnEmailSave_Click;
|
||
emailCard.Controls.Add(btnEmailSave);
|
||
|
||
btnEmailTest = new AntdUI.Button
|
||
{
|
||
Text = "邮箱测试",
|
||
Type = TTypeMini.Primary,
|
||
Ghost = true,
|
||
Location = new Point(146, 350),
|
||
Size = new Size(110, 36)
|
||
};
|
||
btnEmailTest.Click += btnEmailTest_Click;
|
||
emailCard.Controls.Add(btnEmailTest);
|
||
|
||
pageSettings.Controls.Add(listenCard);
|
||
pageSettings.Controls.Add(emailCard);
|
||
}
|
||
|
||
private WinPanel CreateCardPanel(Rectangle bounds)
|
||
{
|
||
return new WinPanel
|
||
{
|
||
Bounds = bounds,
|
||
BackColor = Color.White,
|
||
BorderStyle = BorderStyle.None
|
||
};
|
||
}
|
||
|
||
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, 36),
|
||
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<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;
|
||
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 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 StartWechatSidCapture()
|
||
{
|
||
StopWechatSidCapture();
|
||
_wechatSidCaptureCts = new CancellationTokenSource();
|
||
var token = _wechatSidCaptureCts.Token;
|
||
btnWechatSidAuto.Text = "停止捕获";
|
||
btnWechatSidAuto.Type = TTypeMini.Error;
|
||
btnWechatSidAuto.Loading = true;
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
if (TryExtractSidFromClipboard(out var clipSid, out var clipVersion))
|
||
{
|
||
BeginInvoke(() => ApplyCapturedSid(clipSid, clipVersion, "剪贴板"));
|
||
return;
|
||
}
|
||
|
||
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;
|
||
|
||
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 (string.Equals(_wechatMonitorMode, "hook", StringComparison.Ordinal))
|
||
{
|
||
StopWechatHook();
|
||
_wechatMonitorMode = string.Empty;
|
||
UpdateWechatMonitorButtons();
|
||
UpdateWechatStatusUi();
|
||
Log("Hook 监听已停止。");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
SaveUiToConfig();
|
||
SaveConfig();
|
||
if (!string.IsNullOrWhiteSpace(_wechatMonitorMode))
|
||
{
|
||
StopWechatMonitoring();
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(txtWechatPath?.Text) || !File.Exists(txtWechatPath.Text.Trim()))
|
||
{
|
||
throw new InvalidOperationException("请先选择正确的微信程序路径(WeChat.exe/Weixin.exe/WeChatAppEx.exe)。");
|
||
}
|
||
|
||
StartWechatHook();
|
||
_wechatMonitorMode = "hook";
|
||
UpdateWechatMonitorButtons();
|
||
UpdateWechatStatusUi();
|
||
Log("Hook 监听已启动(将用于监听微信通知/收款小账本)。");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log($"Hook 监听启动失败:{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 hookActive = string.Equals(_wechatMonitorMode, "hook", StringComparison.Ordinal);
|
||
var protocolActive = string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal);
|
||
|
||
btnWechatHookStart.Text = hookActive ? "停止监听" : "开始监听";
|
||
btnWechatHookStart.Type = hookActive ? TTypeMini.Error : TTypeMini.Primary;
|
||
btnWechatProtocolStart.Text = protocolActive ? "停止监听" : "开始监听";
|
||
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;
|
||
_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<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));
|
||
}
|
||
}
|
||
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<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)
|
||
{
|
||
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<PendingOrdersApiResponse>(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<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 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;
|
||
}
|
||
|
||
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<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 response = await _httpClient.GetAsync(url);
|
||
var responseBody = await response.Content.ReadAsStringAsync();
|
||
|
||
HeartbeatStateResponse? parsed = null;
|
||
try
|
||
{
|
||
parsed = JsonSerializer.Deserialize<HeartbeatStateResponse>(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 bool TryExtractSidFromClipboard(out string sid, out string version)
|
||
{
|
||
sid = string.Empty;
|
||
version = string.Empty;
|
||
try
|
||
{
|
||
if (!Clipboard.ContainsText())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var text = Clipboard.GetText();
|
||
return TryExtractSidFromText(text, out sid, out version);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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 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 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<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; }
|
||
}
|
||
}
|