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 _pendingOrders = new(); private bool _isSynchronizingNavigation; private string _wechatMonitorMode = string.Empty; private CancellationTokenSource? _wechatHookCts; private CancellationTokenSource? _wechatProtocolCts; private CancellationTokenSource? _wechatSidCaptureCts; private readonly HashSet _wechatHookSeen = new(StringComparer.Ordinal); private readonly HashSet _wechatProtocolSeen = new(StringComparer.Ordinal); private string _wechatProtocolLastTransId = string.Empty; private long _wechatProtocolLastCreateTime; private int? _wechatProtocolLastPendingCount; private int? _wechatProtocolLastHomeType; private DateTime _wechatHookLastDiagAt = DateTime.MinValue; private bool _wechatHookFoundLedgerAnchor; private AntdUI.PageHeader titlebar = null!; private AntdUI.PageHeader bottomBar = null!; private AntdUI.Menu menu = null!; private WinPanel contentHost = null!; private AntdUI.Button buttonCollapse = null!; private WinLabel lblRuntimeBottom = null!; private string _currentPageKey = "home"; private WinPanel pageHome = null!; private WinPanel pageWechat = null!; private WinPanel pageAlipay = null!; private WinPanel pageSettings = null!; private AntdUI.Label lblSummaryTitle = null!; private AntdUI.Label lblSummaryDesc = null!; private AntdUI.Label lblTopNotice = null!; private AntdUI.Label lblWechatStatusValue = null!; private AntdUI.Label lblAlipayStatusValue = null!; private AntdUI.Input txtServerUrl = null!; private AntdUI.Input txtApiKey = null!; private AntdUI.Input txtSenderEmail = null!; private AntdUI.Input txtSmtpHost = null!; private AntdUI.Input txtSmtpPort = null!; private AntdUI.Input txtNotifyEmail = null!; private AntdUI.Input txtEmailAuthCode = null!; private AntdUI.Label lblServerUrlTitle = null!; private AntdUI.Label lblApiKeyTitle = null!; private AntdUI.Label lblSenderEmailTitle = null!; private AntdUI.Label lblSmtpHostTitle = null!; private AntdUI.Label lblSmtpPortTitle = null!; private AntdUI.Label lblNotifyEmailTitle = null!; private AntdUI.Label lblEmailAuthCodeTitle = null!; private AntdUI.Label lblWechatSidTitle = null!; private AntdUI.Label lblWechatFrequencyTitle = null!; private AntdUI.Label lblWechatPollingTitle = null!; private AntdUI.Label lblHeartbeatDesc = null!; private AntdUI.Label lblMemberPlaceholder = null!; private AntdUI.Switch chkHeartbeatEnabled = null!; private AntdUI.Button btnHeartbeatCheck = null!; private AntdUI.Button btnClearLog = null!; private WinTextBox txtLog = null!; private AntdUI.Input txtWechatPath = null!; private AntdUI.Input txtWechatId = null!; private AntdUI.Button btnSelectWechatPath = null!; private AntdUI.Button btnWechatHookStart = null!; private AntdUI.Button btnWechatProtocolStart = null!; private AntdUI.Button btnWechatSidAuto = null!; private AntdUI.Button btnClearWechatLog = null!; private NumericUpDown numWechatInterval = null!; private AntdUI.Checkbox chkWheel = null!; private AntdUI.Input txtAliPath = null!; private AntdUI.Input txtAliAppId = null!; private AntdUI.Input txtAliPid = null!; private NumericUpDown numAlipayInterval = null!; private AntdUI.Input txtServicePort = null!; private AntdUI.Input txtListenPath = null!; private DataGridView gridWechatLogs = null!; private DataGridView gridAlipayLogs = null!; private AntdUI.Button btnSaveConfig = null!; private AntdUI.Button btnToggleService = null!; private AntdUI.Button btnEmailSave = null!; private AntdUI.Button btnEmailTest = null!; public Form1() { InitializeComponent(); 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(json, _jsonOptions) ?? new ClientConfig(); } LoadPendingOrders(); } private void SaveConfig() { var json = JsonSerializer.Serialize(_config, _jsonOptions); File.WriteAllText(_configFilePath, json, Encoding.UTF8); } private void LoadPendingOrders() { if (!File.Exists(_pendingOrdersFilePath)) { _pendingOrders = new List(); return; } try { var json = File.ReadAllText(_pendingOrdersFilePath, Encoding.UTF8); _pendingOrders = JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); } catch { _pendingOrders = new List(); } } private void SavePendingOrders() { var json = JsonSerializer.Serialize(_pendingOrders, _jsonOptions); File.WriteAllText(_pendingOrdersFilePath, json, Encoding.UTF8); } private void BindConfigToUi() { txtServerUrl.Text = _config.ServerUrl; txtApiKey.Text = _config.ApiKey; txtSenderEmail.Text = _config.SenderEmail; txtSmtpHost.Text = _config.SmtpHost; txtSmtpPort.Text = _config.SmtpPort.ToString(); txtNotifyEmail.Text = _config.NotifyEmail; txtEmailAuthCode.Text = _config.EmailAuthCode; txtWechatPath.Text = _config.WechatPath; txtWechatId.Text = _config.WechatSid; txtAliPath.Text = _config.AlipayPath; txtAliAppId.Text = _config.AlipayAppId; txtAliPid.Text = _config.AlipayUserId; txtServicePort.Text = _config.ListenPort.ToString(); txtListenPath.Text = _config.ListenPath; numWechatInterval.Value = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 3600); numAlipayInterval.Value = Math.Min(Math.Max(_config.AlipayIntervalSeconds, 1), 3600); chkWheel.Checked = _config.EnableWheelPolling; chkHeartbeatEnabled.Checked = _config.EnableHeartbeat; _config.WechatApiVersion = string.IsNullOrWhiteSpace(_config.WechatApiVersion) ? "7.10.1" : _config.WechatApiVersion.Trim(); } private void SaveUiToConfig() { _config.ServerUrl = NormalizeServerUrl(txtServerUrl.Text); _config.ApiKey = txtApiKey.Text.Trim(); _config.SenderEmail = txtSenderEmail.Text.Trim(); _config.SmtpHost = txtSmtpHost.Text.Trim(); _config.SmtpPort = ParseSmtpPort(txtSmtpPort.Text); _config.NotifyEmail = txtNotifyEmail.Text.Trim(); _config.EmailAuthCode = txtEmailAuthCode.Text.Trim(); _config.WechatPath = txtWechatPath.Text.Trim(); _config.WechatSid = txtWechatId.Text.Trim(); _config.AlipayPath = txtAliPath.Text.Trim(); _config.AlipayAppId = txtAliAppId.Text.Trim(); _config.AlipayUserId = txtAliPid.Text.Trim(); _config.ListenPort = ParsePort(txtServicePort.Text); _config.ListenPath = string.IsNullOrWhiteSpace(txtListenPath.Text) ? "/notify/" : txtListenPath.Text.Trim(); _config.WechatIntervalSeconds = (int)numWechatInterval.Value; _config.AlipayIntervalSeconds = (int)numAlipayInterval.Value; _config.EnableWheelPolling = chkWheel.Checked; _config.EnableHeartbeat = chkHeartbeatEnabled.Checked; } private static int ParsePort(string text) { if (!int.TryParse(text, out var port) || port < 1 || port > 65535) { return 8989; } return port; } private static int ParseSmtpPort(string text) { if (!int.TryParse(text, out var port) || port < 1 || port > 65535) { return 465; } return port; } private void btnSaveConfig_Click(object? sender, EventArgs e) { try { SaveUiToConfig(); SaveConfig(); ApplyHeartbeatSetting(); Log("配置已保存。"); } catch (Exception ex) { Log($"保存配置失败:{ex.Message}"); MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void btnSelectWechatPath_Click(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Title = "选择微信程序路径", Filter = "微信程序|WeChat.exe;Weixin.exe;WeChatAppEx.exe|可执行文件|*.exe|所有文件|*.*", CheckFileExists = true, Multiselect = false }; if (dialog.ShowDialog(this) != DialogResult.OK) { return; } txtWechatPath.Text = dialog.FileName; SaveUiToConfig(); SaveConfig(); Log($"微信安装路径已保存:{dialog.FileName}"); } private 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(); foreach (var t in texts) { token.ThrowIfCancellationRequested(); if (t.Contains('¥') || t.Contains('¥')) { moneyCandidates++; } if (ContainsLedgerKeyword(t) && keywordSamples.Count < 8) { keywordSamples.Add(t.Length > 40 ? t[..40] : t); } if (TryParseWechatLedgerLine(t, out var amount, out var remark)) { var key = $"{amount:0.00}|{remark}|{t}"; var hash = CreateMd5(key); if (_wechatHookSeen.Add(hash)) { await OnWechatPaymentDetectedAsync(amount, remark, raw: t); } } } // 低频诊断:告诉你扫描到没有包含金额的文本/是否定位到小账本锚点 if ((DateTime.Now - _wechatHookLastDiagAt).TotalSeconds >= 5) { _wechatHookLastDiagAt = DateTime.Now; if (!_wechatHookFoundLedgerAnchor) { Log("Hook 诊断:未定位到“收款小账本”区域,建议打开并停留在收款小账本页面再试。"); } else if (keywordSamples.Count > 0) { Log($"Hook 诊断:已命中关键词样本 {string.Join(" | ", keywordSamples.Distinct())}"); } else if (moneyCandidates == 0) { Log("Hook 诊断:已扫描微信窗口,但未命中“微信支付收款/收款到账/¥/¥/元”等文本。"); } } } } private static AutomationElement? FindWechatLedgerScanRoot(AutomationElement window) { // UIA 不支持 contains 条件,只能遍历找关键词命中 var walker = TreeWalker.RawViewWalker; var stack = new Stack(); stack.Push(window); while (stack.Count > 0) { var current = stack.Pop(); if (IsLedgerAnchor(current)) { return current; } AutomationElement? child = null; try { child = walker.GetFirstChild(current); } catch { child = null; } while (child != null) { stack.Push(child); try { child = walker.GetNextSibling(child); } catch { break; } } } return null; } private static bool IsLedgerAnchor(AutomationElement element) { foreach (var text in ExtractElementTexts(element)) { if (ContainsLedgerKeyword(text)) { return true; } } return false; } private static bool ContainsLedgerKeyword(string? text) { if (string.IsNullOrWhiteSpace(text)) { return false; } return text.Contains("收款小账本", StringComparison.OrdinalIgnoreCase) || text.Contains("微信收款助手", StringComparison.OrdinalIgnoreCase) || text.Contains("微信支付", StringComparison.OrdinalIgnoreCase) || text.Contains("收款到账", StringComparison.OrdinalIgnoreCase) || text.Contains("收款通知", StringComparison.OrdinalIgnoreCase) || text.Contains("收款记录", StringComparison.OrdinalIgnoreCase) || text.Contains("收款设置", StringComparison.OrdinalIgnoreCase) || text.Contains("今日收款", StringComparison.OrdinalIgnoreCase) || text.Contains("经营收款服务", StringComparison.OrdinalIgnoreCase) || text.Contains("补充经营信息", StringComparison.OrdinalIgnoreCase); } private static List ExtractAutomationText(AutomationElement root, int max) { var results = new List(); try { var walker = TreeWalker.RawViewWalker; var stack = new Stack(); stack.Push(root); while (stack.Count > 0 && results.Count < max) { var current = stack.Pop(); foreach (var text in ExtractElementTexts(current)) { if (results.Count >= max) { break; } results.Add(text); } AutomationElement? child = null; try { child = walker.GetFirstChild(current); } catch { child = null; } while (child != null) { stack.Push(child); try { child = walker.GetNextSibling(child); } catch { break; } } } } catch { } return results; } private static IEnumerable ExtractElementTexts(AutomationElement element) { var values = new List(); try { if (!string.IsNullOrWhiteSpace(element.Current.Name)) { values.Add(element.Current.Name.Trim()); } } catch { } try { if (!string.IsNullOrWhiteSpace(element.Current.HelpText)) { values.Add(element.Current.HelpText.Trim()); } } catch { } try { if (!string.IsNullOrWhiteSpace(element.Current.AutomationId)) { values.Add(element.Current.AutomationId.Trim()); } } catch { } try { if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valuePatternObj)) { var value = ((ValuePattern)valuePatternObj).Current.Value; if (!string.IsNullOrWhiteSpace(value)) { values.Add(value.Trim()); } } } catch { } try { if (element.TryGetCurrentPattern(TextPattern.Pattern, out var textPatternObj)) { var text = ((TextPattern)textPatternObj).DocumentRange.GetText(256); if (!string.IsNullOrWhiteSpace(text)) { values.Add(text.Trim()); } } } catch { } return values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(); } private static bool TryParseWechatLedgerLine(string text, out decimal amount, out string remark) { amount = 0; remark = string.Empty; if (string.IsNullOrWhiteSpace(text)) { return false; } var t = text.Replace(" ", string.Empty).Replace("\u00A0", string.Empty); if (!t.Contains("收款") && !t.Contains("微信支付") && !t.Contains("入账") && !t.Contains("到账") && !t.Contains("¥") && !t.Contains("¥") && !t.Contains("元")) { return false; } // 兼容样式: // - 微信支付收款0.10元 // - 收款到账0.10元 // - ¥0.10 / ¥0.10 // - 收款0.10 var match = Regex.Match( t, @"(?:微信支付)?(?:收款到账|收款通知|收款)\s*(\d+(?:\.\d{1,2})?)\s*元|(?:¥|¥)\s*(\d+(?:\.\d{1,2})?)|收款\s*(\d+(?:\.\d{1,2})?)", RegexOptions.IgnoreCase); if (!match.Success) { return false; } var amountText = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value; if (!decimal.TryParse(amountText, out amount) || amount <= 0) { return false; } remark = t; if (remark.Length > 120) { remark = remark[..120]; } return true; } private async Task OnWechatPaymentDetectedAsync(decimal amount, string remark, string raw) { var evt = new PaymentEvent { Channel = "wechat", Amount = amount, OrderNo = remark, TradeNo = string.Empty, Payer = "wechat-ledger", Status = "success", ReceivedAt = DateTimeOffset.Now, Raw = raw }; var callbackResult = await ForwardEventToServerAsync(evt); AddPaymentLog(evt, callbackResult); Log($"Hook 收到收款:{amount:0.00}({remark})"); } private void UpdateWechatStatusUi() { if (lblWechatStatusValue is null) { return; } var active = string.Equals(_wechatMonitorMode, "hook", StringComparison.Ordinal) || string.Equals(_wechatMonitorMode, "protocol", StringComparison.Ordinal); lblWechatStatusValue.Text = active ? "微信: 在线" : "微信: 离线"; lblWechatStatusValue.ForeColor = active ? Color.Green : Color.Red; } private void StartWechatProtocol() { StopWechatProtocol(); _wechatProtocolSeen.Clear(); _wechatProtocolLastPendingCount = null; _wechatProtocolLastHomeType = null; _wechatProtocolCts = new CancellationTokenSource(); var token = _wechatProtocolCts.Token; _ = Task.Run(async () => { while (!token.IsCancellationRequested) { try { await PollWechatProtocolOnceAsync(token); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log($"协议轮询异常:{ex.Message}"); } var delaySeconds = Math.Min(Math.Max(_config.WechatIntervalSeconds, 1), 60); try { await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token); } catch (OperationCanceledException) { break; } } }, token); } private void StopWechatProtocol() { try { _wechatProtocolCts?.Cancel(); } catch { } try { _wechatProtocolCts?.Dispose(); } catch { } _wechatProtocolCts = null; _wechatProtocolLastPendingCount = null; _wechatProtocolLastHomeType = null; } private async Task PollWechatProtocolOnceAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); var sid = _config.WechatSid?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(sid)) { Log("协议诊断:未填写微信 SID。"); return; } var version = string.IsNullOrWhiteSpace(_config.WechatApiVersion) ? "7.10.1" : _config.WechatApiVersion.Trim(); if (!_config.EnableWheelPolling) { Log("协议诊断:接口轮询未启用。"); return; } await SyncPendingOrdersFromServerAsync(token); // 先调用 gethomedata,拿到可用时间区间与最新游标 var homeUrl = BuildWechatSmallbookUrl(sid, version); var startTime = DateTimeOffset.Now.AddDays(-7).ToUnixTimeSeconds(); var endTime = DateTimeOffset.Now.ToUnixTimeSeconds(); string homeBody = string.Empty; int? homeType = null; foreach (var typeCandidate in new[] { 0, 1, 2, 39 }) { var homeRequest = new { start_time = startTime, end_time = endTime, type = typeCandidate }; var candidateBody = await SendWechatProtocolRequestAsync(homeUrl, homeRequest, token); if (string.IsNullOrWhiteSpace(candidateBody)) { continue; } WechatSmallbookResponse? candidatePayload = null; try { candidatePayload = JsonSerializer.Deserialize(candidateBody, _jsonOptions); } catch { candidatePayload = null; } if (candidatePayload?.Retcode == 0) { homeBody = candidateBody; homeType = typeCandidate; if (_wechatProtocolLastHomeType != typeCandidate) { Log($"协议诊断(gethomedata):已命中可用 type={typeCandidate}"); _wechatProtocolLastHomeType = typeCandidate; } break; } if (candidatePayload?.Msg?.Contains("type不合法", StringComparison.OrdinalIgnoreCase) == true) { continue; } homeBody = candidateBody; homeType = typeCandidate; break; } if (!string.IsNullOrWhiteSpace(homeBody)) { try { var payload = JsonSerializer.Deserialize(homeBody, _jsonOptions); if (payload?.Retcode != 0) { if (payload != null) { Log($"协议诊断(gethomedata):retcode={payload.Retcode} errcode={payload.Errcode} msg={payload.Msg}" + (homeType != null ? $" type={homeType}" : string.Empty)); } } else { var incomes = payload.Data?.IncomeList; if (incomes != null) { foreach (var income in incomes) { token.ThrowIfCancellationRequested(); UpdateWechatProtocolCursor(income.TransId, income.Timestamp); var detailRaw = await FetchWechatTransactionDetailAsync(sid, version, income.TransId, income.Timestamp, token); var id = !string.IsNullOrWhiteSpace(income.TransId) ? income.TransId! : (!string.IsNullOrWhiteSpace(income.RollId) ? income.RollId! : $"{income.Timestamp}|{income.Fee}"); await ProcessWechatProtocolIncomeAsync( id: id, transId: income.TransId, timestamp: income.Timestamp, feeCent: income.Fee, raw: string.IsNullOrWhiteSpace(detailRaw) ? homeBody : detailRaw); } } } } catch (Exception ex) { Log($"协议诊断(gethomedata):JSON 解析失败:{ex.Message}"); } } // 再用最新游标请求 classifyread if (!string.IsNullOrWhiteSpace(_wechatProtocolLastTransId) && _wechatProtocolLastCreateTime > 0) { var classifyUrl = BuildWechatClassifyReadUrl(sid, version); var classifyRequest = new { v = version, trans_id = _wechatProtocolLastTransId, last_bill_id = (string?)null, count = 10, page_num = 1, create_time = _wechatProtocolLastCreateTime, last_id = "", last_create_time = 0, sid = sid }; var classifyBody = await SendWechatProtocolRequestAsync(classifyUrl, classifyRequest, token); if (!string.IsNullOrWhiteSpace(classifyBody)) { try { var classifyPayload = JsonSerializer.Deserialize(classifyBody, _jsonOptions); if (classifyPayload?.Retcode == 0 && classifyPayload.Data?.PersonBillList != null) { foreach (var bill in classifyPayload.Data.PersonBillList) { token.ThrowIfCancellationRequested(); UpdateWechatProtocolCursor(bill.TransId, bill.CreateTime); await ProcessWechatProtocolIncomeAsync( id: bill.TransId ?? $"{bill.CreateTime}|{bill.TotalFee}", transId: bill.TransId, timestamp: bill.CreateTime, feeCent: bill.TotalFee, raw: classifyBody); } } else if (classifyPayload != null && classifyPayload.Retcode != 0) { Log($"协议诊断(classifyread):retcode={classifyPayload.Retcode} errcode={classifyPayload.Errcode} msg={classifyPayload.Msg}"); } } catch (Exception ex) { Log($"协议诊断(classifyread):JSON 解析失败:{ex.Message}"); } } } } private async Task SyncPendingOrdersFromServerAsync(CancellationToken token) { var serverUrl = NormalizeServerUrl(_config.ServerUrl); var apiKey = _config.ApiKey?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiKey)) { return; } var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var sign = CreateMd5(timestamp + apiKey); var pendingUrl = BuildPendingOrdersUrl(serverUrl, timestamp, sign, type: 1); var response = await _httpClient.GetAsync(pendingUrl, token); var body = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { Log($"待支付订单同步失败:HTTP {(int)response.StatusCode} {response.StatusCode}"); return; } PendingOrdersApiResponse? payload = null; try { payload = JsonSerializer.Deserialize(body, _jsonOptions); } catch (Exception ex) { var preview = body.Length > 200 ? body[..200] + "..." : body; Log($"待支付订单同步失败:JSON 解析异常 {ex.Message},响应预览={preview}"); return; } if (payload?.Code != 1 || payload.Data == null) { if (payload != null) { Log($"待支付订单同步失败:code={payload.Code} msg={payload.Msg}"); } return; } _pendingOrders = payload.Data .Select(x => new PendingOrderRecord { OrderId = x.OrderId ?? string.Empty, PayId = x.PayId ?? string.Empty, Param = x.Param ?? string.Empty, PayType = x.PayType, Price = x.Price, ReallyPrice = x.ReallyPrice, TimeOut = x.TimeOut, State = x.State, Date = x.Date, RegisteredAt = DateTimeOffset.Now }) .Where(x => !string.IsNullOrWhiteSpace(x.OrderId) && !string.IsNullOrWhiteSpace(x.PayId)) .ToList(); SavePendingOrders(); if (_wechatProtocolLastPendingCount != _pendingOrders.Count) { Log($"待支付订单同步成功:共 {_pendingOrders.Count} 条微信待支付订单"); _wechatProtocolLastPendingCount = _pendingOrders.Count; } } private static string BuildWechatSmallbookUrl(string sid, string version) { var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim(); return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/gethomedata?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}"; } private static string BuildWechatClassifyReadUrl(string sid, string version) { var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim(); return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/classifyread?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}"; } private static string BuildWechatTransactionDetailUrl(string sid, string version) { var v = string.IsNullOrWhiteSpace(version) ? "7.10.1" : version.Trim(); return $"https://smallbook.wxpapp.weixin.qq.com/qrappzd/user/transactiondetail?sid={Uri.EscapeDataString(sid)}&v={Uri.EscapeDataString(v)}"; } private async Task SendWechatProtocolRequestAsync(string url, object payload, CancellationToken token) { using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf254186b) XWEB/19481"); request.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); request.Headers.TryAddWithoutValidation("X-Module-Name", "mmpaysmbpdreceiptassistmp"); request.Headers.TryAddWithoutValidation("X-Page", "pages/detail/detail"); request.Headers.TryAddWithoutValidation("X-Track-Id", $"TB{Guid.NewGuid():N}".ToUpperInvariant()); request.Headers.TryAddWithoutValidation("xweb_xhr", "1"); request.Headers.TryAddWithoutValidation("X-Appid", "unknown"); request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site"); request.Headers.TryAddWithoutValidation("Sec-Fetch-Mode", "cors"); request.Headers.TryAddWithoutValidation("Sec-Fetch-Dest", "empty"); request.Headers.TryAddWithoutValidation("Referer", "https://servicewechat.com/wx28be8489b7a36aaa/1167/page-frame.html"); request.Headers.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9"); var json = JsonSerializer.Serialize(payload, _jsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await _httpClient.SendAsync(request, token); var body = await response.Content.ReadAsStringAsync(token); if (!response.IsSuccessStatusCode) { Log($"协议诊断:HTTP {(int)response.StatusCode},url={url},响应={body}"); return string.Empty; } return body; } private async Task FetchWechatTransactionDetailAsync(string sid, string version, string? transId, long createTime, CancellationToken token) { if (string.IsNullOrWhiteSpace(transId) || createTime <= 0) { return string.Empty; } var url = BuildWechatTransactionDetailUrl(sid, version); var payload = new { v = version, id = transId, create_time = createTime, sid = sid }; var body = await SendWechatProtocolRequestAsync(url, payload, token); if (string.IsNullOrWhiteSpace(body)) { return string.Empty; } try { var parsed = JsonSerializer.Deserialize(body, _jsonOptions); if (parsed?.Retcode != 0) { Log($"协议诊断(transactiondetail):retcode={parsed?.Retcode} errcode={parsed?.Errcode} msg={parsed?.Msg}"); return string.Empty; } } catch (Exception ex) { Log($"协议诊断(transactiondetail):JSON 解析失败:{ex.Message}"); return string.Empty; } return body; } private void UpdateWechatProtocolCursor(string? transId, long createTime) { if (!string.IsNullOrWhiteSpace(transId)) { _wechatProtocolLastTransId = transId.Trim(); } if (createTime > _wechatProtocolLastCreateTime) { _wechatProtocolLastCreateTime = createTime; } } private async Task ProcessWechatProtocolIncomeAsync(string id, string? transId, long timestamp, decimal feeCent, string raw) { if (string.IsNullOrWhiteSpace(id) || !_wechatProtocolSeen.Add(id)) { return; } var amount = feeCent > 0 ? feeCent / 100m : 0m; if (amount <= 0) { return; } var receivedAt = timestamp > 0 ? DateTimeOffset.FromUnixTimeSeconds(timestamp) : DateTimeOffset.Now; var remark = string.IsNullOrWhiteSpace(transId) ? id : transId!; var evt = new PaymentEvent { Channel = "wechat", Amount = amount, OrderNo = remark, TradeNo = transId ?? string.Empty, Payer = "wechat-protocol", Status = "success", ReceivedAt = receivedAt, Raw = raw }; var callbackResult = await ForwardEventToServerAsync(evt); AddPaymentLog(evt, callbackResult); Log($"协议收到收款:{amount:0.00},订单={remark}"); } private async void btnToggleService_Click(object? sender, EventArgs e) { if (IsListenerRunning()) { StopListener(); UpdateServiceStatus(false); Log("监听已停止。"); return; } try { SaveUiToConfig(); ValidateConfig(_config); SaveConfig(); await StartListenerAsync(); UpdateServiceStatus(true); Log($"本地监听已启动:{BuildLocalListenUrl(_config)}"); } catch (Exception ex) { UpdateServiceStatus(false); Log($"启动监听失败:{ex.Message}"); MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private async void btnEmailTest_Click(object? sender, EventArgs e) { try { SaveUiToConfig(); if (string.IsNullOrWhiteSpace(_config.NotifyEmail)) { throw new InvalidOperationException("请先填写通知邮箱。"); } if (string.IsNullOrWhiteSpace(_config.SenderEmail)) { throw new InvalidOperationException("请先填写发送邮箱。"); } if (string.IsNullOrWhiteSpace(_config.SmtpHost)) { throw new InvalidOperationException("请先填写 SMTP 主机。"); } if (_config.SmtpPort <= 0) { throw new InvalidOperationException("请先填写有效的 SMTP 端口。"); } if (string.IsNullOrWhiteSpace(_config.EmailAuthCode)) { throw new InvalidOperationException("请先填写邮箱授权码。"); } await SendTestEmailAsync( _config.SenderEmail.Trim(), _config.NotifyEmail.Trim(), _config.SmtpHost.Trim(), _config.SmtpPort, _config.EmailAuthCode.Trim()); Log($"测试邮件已发送:{_config.NotifyEmail}"); MessageBox.Show("测试邮件发送成功。", "邮箱测试", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { Log($"测试邮件发送失败:{ex.Message}"); MessageBox.Show(ex.Message, "邮箱测试失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void btnEmailSave_Click(object? sender, EventArgs e) { try { SaveUiToConfig(); SaveConfig(); Log("邮箱配置已保存到本地缓存文件。"); MessageBox.Show("邮箱配置已保存。", "保存成功", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { Log($"保存邮箱配置失败:{ex.Message}"); MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private async Task StartListenerAsync() { StopListener(); _listenerCancellationTokenSource = new CancellationTokenSource(); _httpListener = new HttpListener(); var prefix = BuildHttpPrefix(_config); _httpListener.Prefixes.Add(prefix); _httpListener.Start(); _ = Task.Run(() => ListenLoopAsync(_listenerCancellationTokenSource.Token)); await Task.CompletedTask; } private bool IsListenerRunning() { return _httpListener is { IsListening: true }; } private void StopListener() { try { _listenerCancellationTokenSource?.Cancel(); } catch { } try { if (_httpListener is { IsListening: true }) { _httpListener.Stop(); } } catch { } try { _httpListener?.Close(); } catch { } _httpListener = null; _listenerCancellationTokenSource?.Dispose(); _listenerCancellationTokenSource = null; } private async Task SendTestEmailAsync(string senderEmail, string notifyEmail, string smtpHost, int smtpPort, string authCode) { var errors = new List(); var attempts = new List<(int Port, SecureSocketOptions SocketOptions)> { (smtpPort, smtpPort == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls), (smtpPort, SecureSocketOptions.StartTlsWhenAvailable), (smtpPort, SecureSocketOptions.Auto) }; if (smtpPort == 465) { attempts.Add((587, SecureSocketOptions.StartTls)); attempts.Add((587, SecureSocketOptions.StartTlsWhenAvailable)); } else if (smtpPort == 587) { attempts.Add((465, SecureSocketOptions.SslOnConnect)); attempts.Add((465, SecureSocketOptions.Auto)); } foreach (var (port, socketOptions) in attempts.Distinct()) { if (await TrySendTestEmailAsync(senderEmail, notifyEmail, smtpHost, port, socketOptions, authCode, errors)) { return; } } throw new InvalidOperationException("测试邮件发送失败:" + Environment.NewLine + string.Join(Environment.NewLine, errors)); } private static async Task TrySendTestEmailAsync(string senderEmail, string notifyEmail, string smtpHost, int smtpPort, SecureSocketOptions socketOptions, string authCode, List errors) { try { var message = new MimeMessage(); message.From.Add(MailboxAddress.Parse(senderEmail)); message.To.Add(MailboxAddress.Parse(notifyEmail)); message.Subject = "V免签客户端测试邮件"; message.Body = new TextPart("plain") { Text = $"这是一封测试邮件,发送时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}" }; using var smtp = new SmtpClient { Timeout = 15000 }; await smtp.ConnectAsync(smtpHost, smtpPort, socketOptions); await smtp.AuthenticateAsync(senderEmail, authCode); await smtp.SendAsync(message); await smtp.DisconnectAsync(true); return true; } catch (Exception ex) { errors.Add($"SMTP {smtpHost}:{smtpPort} Socket={socketOptions} -> {GetDetailedExceptionMessage(ex)}"); return false; } } private static string GetDetailedExceptionMessage(Exception ex) { var parts = new List(); Exception? current = ex; while (current != null) { if (!string.IsNullOrWhiteSpace(current.Message)) { parts.Add(current.Message.Trim()); } current = current.InnerException; } return parts.Count == 0 ? ex.GetType().Name : string.Join(" | ", parts.Distinct()); } private async Task ListenLoopAsync(CancellationToken cancellationToken) { if (_httpListener == null) { return; } while (!cancellationToken.IsCancellationRequested && _httpListener.IsListening) { try { var context = await _httpListener.GetContextAsync(); _ = Task.Run(() => HandleRequestAsync(context), cancellationToken); } catch (HttpListenerException) { break; } catch (ObjectDisposedException) { break; } catch (Exception ex) { Log($"监听循环异常:{ex.Message}"); } } } private async Task HandleRequestAsync(HttpListenerContext context) { try { if (!string.Equals(context.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { await WriteJsonResponseAsync(context.Response, 405, new { ok = false, message = "Only POST is supported." }); return; } using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8); var body = await reader.ReadToEndAsync(); Log($"收到本地事件:{body}"); var registration = TryParsePendingOrderRegistration(body); if (registration != null) { RegisterPendingOrder(registration); await WriteJsonResponseAsync(context.Response, 200, new { ok = true, registered = true, orderId = registration.OrderId, payId = registration.PayId }); return; } var paymentEvent = JsonSerializer.Deserialize(body, _jsonOptions); if (paymentEvent == null) { await WriteJsonResponseAsync(context.Response, 400, new { ok = false, message = "请求体不能为空。" }); return; } paymentEvent.ReceivedAt = paymentEvent.ReceivedAt == default ? DateTimeOffset.Now : paymentEvent.ReceivedAt; paymentEvent.Raw ??= body; var callbackResult = await ForwardEventToServerAsync(paymentEvent); AddPaymentLog(paymentEvent, callbackResult); await WriteJsonResponseAsync(context.Response, 200, new { ok = true, forwarded = true, statusCode = (int)callbackResult.StatusCode, responseBody = callbackResult.ResponseBody }); } catch (Exception ex) { Log($"处理请求失败:{ex.Message}"); await WriteJsonResponseAsync(context.Response, 500, new { ok = false, message = ex.Message }); } } private PendingOrderRegistration? TryParsePendingOrderRegistration(string body) { try { var registration = JsonSerializer.Deserialize(body, _jsonOptions); if (registration == null || string.IsNullOrWhiteSpace(registration.OrderId) || string.IsNullOrWhiteSpace(registration.PayId)) { return null; } return registration; } catch { return null; } } private void RegisterPendingOrder(PendingOrderRegistration registration) { var existing = _pendingOrders.FirstOrDefault(x => string.Equals(x.OrderId, registration.OrderId, StringComparison.OrdinalIgnoreCase)); if (existing != null) { existing.PayId = registration.PayId.Trim(); existing.Param = registration.Param?.Trim() ?? string.Empty; existing.Price = registration.Price; existing.ReallyPrice = registration.ReallyPrice <= 0 ? registration.Price : registration.ReallyPrice; existing.PayType = registration.PayType; existing.Date = registration.Date; existing.TimeOut = registration.TimeOut; existing.State = registration.State; existing.RegisteredAt = DateTimeOffset.Now; } else { _pendingOrders.Add(new PendingOrderRecord { OrderId = registration.OrderId.Trim(), PayId = registration.PayId.Trim(), Param = registration.Param?.Trim() ?? string.Empty, Price = registration.Price, ReallyPrice = registration.ReallyPrice <= 0 ? registration.Price : registration.ReallyPrice, PayType = registration.PayType, Date = registration.Date, TimeOut = registration.TimeOut, State = registration.State, RegisteredAt = DateTimeOffset.Now }); } SavePendingOrders(); Log($"订单登记成功:orderId={registration.OrderId} payId={registration.PayId} price={registration.Price:0.##}"); } private async Task ForwardEventToServerAsync(PaymentEvent paymentEvent) { var serverUrl = NormalizeServerUrl(_config.ServerUrl); if (string.IsNullOrWhiteSpace(serverUrl)) { return new ServerCallbackResult { StatusCode = HttpStatusCode.OK, ResponseBody = "未配置服务端 URL,已跳过回调。" }; } var matchedOrder = MatchPendingOrder(paymentEvent); if (matchedOrder == null) { return new ServerCallbackResult { StatusCode = HttpStatusCode.Conflict, ResponseBody = "未匹配到待支付订单,已跳过回调。" }; } var matched = matchedOrder; var type = matched.PayType > 0 ? matched.PayType.ToString(System.Globalization.CultureInfo.InvariantCulture) : (string.Equals(paymentEvent.Channel, "wechat", StringComparison.OrdinalIgnoreCase) ? "1" : "2"); var payId = matched.PayId; var param = matched.Param ?? string.Empty; var priceValue = matched.Price > 0 ? matched.Price : paymentEvent.Amount; var reallyPriceValue = matched.ReallyPrice > 0 ? matched.ReallyPrice : paymentEvent.Amount; var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var sign = CreateMd5(matched.OrderId + paymentEvent.TradeNo + timestamp + (_config.ApiKey ?? string.Empty)); var callbackUrl = BuildAppPushOrderUrl( serverUrl, matched.OrderId, paymentEvent.TradeNo, timestamp, sign); Log($"转发到服务端:{callbackUrl}"); var response = await _httpClient.GetAsync(callbackUrl); var responseBody = await response.Content.ReadAsStringAsync(); Log($"服务端响应:HTTP {(int)response.StatusCode} {response.StatusCode},内容:{responseBody}"); if (response.IsSuccessStatusCode && (responseBody.Contains("\"code\":1", StringComparison.OrdinalIgnoreCase) || responseBody.Contains("\"msg\":\"成功\"", StringComparison.OrdinalIgnoreCase) || responseBody.Contains("success", StringComparison.OrdinalIgnoreCase))) { MarkPendingOrderCompleted(matched.OrderId, paymentEvent.TradeNo); } return new ServerCallbackResult { StatusCode = response.StatusCode, ResponseBody = responseBody }; } private PendingOrderRecord? MatchPendingOrder(PaymentEvent paymentEvent) { var expectedType = string.Equals(paymentEvent.Channel, "wechat", StringComparison.OrdinalIgnoreCase) ? 1 : 2; var now = paymentEvent.ReceivedAt == default ? DateTimeOffset.Now : paymentEvent.ReceivedAt; var candidate = _pendingOrders .Where(x => x.State == 0 && x.PayType == expectedType) .Where(x => Math.Abs(x.Price - paymentEvent.Amount) < 0.0001m || Math.Abs(x.ReallyPrice - paymentEvent.Amount) < 0.0001m) .OrderByDescending(x => x.Date) .ThenByDescending(x => x.RegisteredAt) .FirstOrDefault(x => { if (x.Date <= 0) { return true; } var createdAt = DateTimeOffset.FromUnixTimeSeconds(x.Date); var timeoutMinutes = x.TimeOut > 0 ? x.TimeOut : 30; return now >= createdAt.AddMinutes(-1) && now <= createdAt.AddMinutes(timeoutMinutes + 2); }); if (candidate != null) { Log($"匹配待支付订单成功:orderId={candidate.OrderId} payId={candidate.PayId} param={candidate.Param}"); } else { Log($"未匹配到待支付订单:channel={paymentEvent.Channel} amount={paymentEvent.Amount:0.##} tradeNo={paymentEvent.TradeNo}"); } return candidate; } private void MarkPendingOrderCompleted(string orderId, string? tradeNo) { var order = _pendingOrders.FirstOrDefault(x => string.Equals(x.OrderId, orderId, StringComparison.OrdinalIgnoreCase)); if (order == null) { return; } order.State = 1; order.TradeNo = tradeNo ?? string.Empty; order.CompletedAt = DateTimeOffset.Now; SavePendingOrders(); Log($"待支付订单已标记完成:orderId={orderId}"); } private static string BuildLegacyNotifyUrl(string serverUrl, string payId, string param, string type, string price, string reallyPrice, string sign) { var query = new List { $"payId={Uri.EscapeDataString(payId)}", $"param={Uri.EscapeDataString(param)}", $"type={Uri.EscapeDataString(type)}", $"price={Uri.EscapeDataString(price)}", $"reallyPrice={Uri.EscapeDataString(reallyPrice)}", $"sign={Uri.EscapeDataString(sign)}" }; var separator = serverUrl.Contains('?') ? "&" : "?"; return serverUrl + separator + string.Join("&", query); } private static string BuildAppPushOrderUrl(string serverUrl, string orderId, string tradeNo, string timestamp, string sign) { if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var baseUri)) { throw new InvalidOperationException("服务端地址格式无效。"); } var pushUri = new Uri(baseUri, "/appPushOrder"); var query = new List { $"orderId={Uri.EscapeDataString(orderId)}", $"tradeNo={Uri.EscapeDataString(tradeNo ?? string.Empty)}", $"t={Uri.EscapeDataString(timestamp)}", $"sign={Uri.EscapeDataString(sign)}" }; return pushUri + "?" + string.Join("&", query); } private static string BuildPendingOrdersUrl(string serverUrl, string timestamp, string sign, int type) { return $"{serverUrl.TrimEnd('/')}/getPendingOrders?t={Uri.EscapeDataString(timestamp)}&sign={Uri.EscapeDataString(sign)}&type={type}"; } private void AddPaymentLog(PaymentEvent paymentEvent, ServerCallbackResult callbackResult) { if (InvokeRequired) { BeginInvoke(() => AddPaymentLog(paymentEvent, callbackResult)); return; } var callbackText = callbackResult.StatusCode == HttpStatusCode.OK ? "成功" : $"失败({(int)callbackResult.StatusCode})"; if (string.Equals(paymentEvent.Channel, "alipay", StringComparison.OrdinalIgnoreCase)) { gridAlipayLogs.Rows.Insert( 0, gridAlipayLogs.Rows.Count + 1, paymentEvent.OrderNo, paymentEvent.Amount.ToString("0.00"), paymentEvent.ReceivedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"), paymentEvent.Payer, callbackText); lblAlipayStatusValue.Text = "支付宝: 在线"; lblAlipayStatusValue.ForeColor = Color.LimeGreen; } else { gridWechatLogs.Rows.Insert( 0, paymentEvent.Payer, paymentEvent.Amount.ToString("0.00"), paymentEvent.ReceivedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"), paymentEvent.OrderNo, callbackText); lblWechatStatusValue.Text = "微信: 在线"; lblWechatStatusValue.ForeColor = Color.LimeGreen; } } private void chkHeartbeatEnabled_CheckedChanged(object? sender, EventArgs e) { ApplyHeartbeatSetting(); } private async void btnHeartbeatCheck_Click(object? sender, EventArgs e) { await SendHeartbeatAsync(true); } private void ApplyHeartbeatSetting() { SaveUiToConfig(); if (_config.EnableHeartbeat) { StartHeartbeat(); } else { StopHeartbeat(); } } private void StartHeartbeat() { const int intervalSeconds = 30; _heartbeatTimer.Interval = intervalSeconds * 1000; if (!_heartbeatTimer.Enabled) { _heartbeatTimer.Start(); } else { _heartbeatTimer.Stop(); _heartbeatTimer.Start(); } } private void StopHeartbeat() { if (_heartbeatTimer.Enabled) { _heartbeatTimer.Stop(); } } private async Task SendHeartbeatAsync(bool writeSuccessLog) { try { var serverUrl = NormalizeServerUrl(txtServerUrl.Text); var apiKey = txtApiKey.Text.Trim(); if (string.IsNullOrWhiteSpace(serverUrl)) { Log("心跳检测异常:未配置服务端地址。"); return; } if (string.IsNullOrWhiteSpace(apiKey)) { Log("心跳检测异常:未配置通信密钥。"); return; } var heartbeatCheckResult = await RequestHeartbeatStateAsync(serverUrl, apiKey); if (!heartbeatCheckResult.IsSuccessStatusCode) { Log($"心跳检测异常:HTTP {(int)heartbeatCheckResult.StatusCode} {heartbeatCheckResult.StatusCode},内容:{heartbeatCheckResult.ResponseBody}"); return; } var heartbeatResponse = heartbeatCheckResult.Response; if (heartbeatResponse == null) { Log($"心跳检测异常:服务端返回无法解析,内容:{heartbeatCheckResult.ResponseBody}"); return; } if (heartbeatResponse.Code != 1) { Log($"心跳检测异常:code={heartbeatResponse.Code},msg={heartbeatResponse.Msg}"); return; } if (heartbeatResponse.Data == null) { if (writeSuccessLog) { Log("心跳检测:心跳=正常,最后支付=-,最后心跳=-"); } return; } var lastPayText = FormatUnixTimestamp(heartbeatResponse.Data.LastPay); var lastHeartText = FormatUnixTimestamp(heartbeatResponse.Data.LastHeart); var monitorState = heartbeatResponse.Data.State ?? heartbeatResponse.Data.JkState; if (monitorState == 1) { if (writeSuccessLog) { Log($"心跳检测:心跳=正常,最后支付={lastPayText},最后心跳={lastHeartText}"); } return; } var stateText = monitorState switch { 0 => "掉线", -1 => "未绑定监控端", _ => $"未知({monitorState?.ToString() ?? "null"})" }; Log($"心跳检测异常:状态={stateText},最后支付={lastPayText},最后心跳={lastHeartText},返回JSON={heartbeatCheckResult.ResponseBody}"); } catch (Exception ex) { Log($"心跳检测异常:{ex.Message}"); } } private async Task RequestHeartbeatStateAsync(string serverUrl, string apiKey) { var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); var candidates = new[] { BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey)), BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp)), BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey), apiKey, null), BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp), apiKey, null), BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(timestamp + apiKey), null, apiKey), BuildHeartbeatStateUrl(serverUrl, timestamp, CreateMd5(apiKey + timestamp), null, apiKey) }; HeartbeatRequestResult? lastResult = null; foreach (var url in candidates.Distinct()) { var response = await _httpClient.GetAsync(url); var responseBody = await response.Content.ReadAsStringAsync(); HeartbeatStateResponse? parsed = null; try { parsed = JsonSerializer.Deserialize(responseBody, _jsonOptions); } catch { } var result = new HeartbeatRequestResult { StatusCode = response.StatusCode, ResponseBody = responseBody, Response = parsed }; lastResult = result; if (!response.IsSuccessStatusCode) { continue; } if (parsed?.Code == 1) { return result; } if (parsed?.Msg?.Contains("签名校验不通过", StringComparison.OrdinalIgnoreCase) == true) { continue; } return result; } return lastResult ?? new HeartbeatRequestResult { StatusCode = HttpStatusCode.ServiceUnavailable, ResponseBody = "未获得有效心跳响应。" }; } private async Task WriteJsonResponseAsync(HttpListenerResponse response, int statusCode, object payload) { response.StatusCode = statusCode; response.ContentType = "application/json; charset=utf-8"; var json = JsonSerializer.Serialize(payload, _jsonOptions); var bytes = Encoding.UTF8.GetBytes(json); response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes); response.OutputStream.Close(); } private void UpdateServiceStatus(bool isRunning) { if (lblTopNotice is null) { return; } lblTopNotice.Text = isRunning ? $"监听中:{BuildLocalListenUrl(_config)}" : string.Empty; if (btnToggleService != null) { btnToggleService.Text = isRunning ? "停止监听" : "启动监听"; btnToggleService.Type = isRunning ? TTypeMini.Error : TTypeMini.Primary; } } private void EnableWindowDrag(Control control) { control.MouseDown += (_, e) => { if (e.Button != MouseButtons.Left) { return; } ReleaseCapture(); SendMessage(Handle, WmNclButtonDown, HtCaption, 0); }; } private static 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 GetWechatDataRoots() { var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); yield return Path.Combine(appData, "Tencent", "WeChat"); yield return Path.Combine(appData, "Tencent", "Weixin"); yield return Path.Combine(appData, "Tencent", "WeChatAppEx"); yield return Path.Combine(localAppData, "Tencent", "WeChat"); yield return Path.Combine(localAppData, "Tencent", "Weixin"); yield return Path.Combine(localAppData, "Tencent", "WeChatAppEx"); } private static IEnumerable EnumerateWechatCandidateFiles(string root) { var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { ".log", ".txt", ".json", ".js", ".html", ".cache", ".db", ".dat" }; IEnumerator? it = null; try { it = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).GetEnumerator(); } catch { yield break; } using (it) { while (true) { string? file = null; try { if (!it.MoveNext()) { yield break; } file = it.Current; } catch { continue; } if (string.IsNullOrWhiteSpace(file)) { continue; } var ext = Path.GetExtension(file); if (!allowed.Contains(ext)) { continue; } yield return file; } } } private static bool TryExtractSidFromText(string? text, out string sid, out string version) { sid = string.Empty; version = string.Empty; if (string.IsNullOrWhiteSpace(text)) { return false; } var match = Regex.Match( text, @"(?:\?|&|""|\s)sid=([A-Za-z0-9\-_]{16,})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); if (!match.Success) { return false; } sid = match.Groups[1].Value.Trim(); var vMatch = Regex.Match( text, @"(?:\?|&|""|\s)v=([0-9]+(?:\.[0-9]+){1,3})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); if (vMatch.Success) { version = vMatch.Groups[1].Value.Trim(); } return !string.IsNullOrWhiteSpace(sid); } private static bool TryExtractSidFromBinary(string file, out string sid, out string version) { sid = string.Empty; version = string.Empty; try { var bytes = File.ReadAllBytes(file); if (bytes.Length == 0) { return false; } // 以拉丁编码保留字节映射,便于从二进制中检索 ASCII URL/sid 片段 var text = Encoding.Latin1.GetString(bytes); return TryExtractSidFromText(text, out sid, out version); } catch { return false; } } private static void ValidateConfig(ClientConfig config) { if (config.ListenPort < 1 || config.ListenPort > 65535) { throw new InvalidOperationException("监听端口必须在 1-65535 之间。"); } var serverUrl = NormalizeServerUrl(config.ServerUrl); if (!string.IsNullOrWhiteSpace(serverUrl) && (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var serverUri) || (serverUri.Scheme != Uri.UriSchemeHttp && serverUri.Scheme != Uri.UriSchemeHttps))) { throw new InvalidOperationException("服务端 URL 必须是有效的 HTTP/HTTPS 地址。"); } } private static string NormalizeServerUrl(string? url) { var value = url?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { value = "https://" + value; } return value; } private void Log(string message) { if (InvokeRequired) { BeginInvoke(() => Log(message)); return; } txtLog.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}"); } private static string NormalizeListenPath(string? path) { var normalized = string.IsNullOrWhiteSpace(path) ? "/notify/" : path.Trim(); if (!normalized.StartsWith('/')) { normalized = "/" + normalized; } if (!normalized.EndsWith('/')) { normalized += "/"; } return normalized; } private static string BuildHttpPrefix(ClientConfig config) { return $"http://+:{config.ListenPort}{NormalizeListenPath(config.ListenPath)}"; } private static string BuildLocalListenUrl(ClientConfig config) { return $"http://127.0.0.1:{config.ListenPort}{NormalizeListenPath(config.ListenPath)}"; } private static string BuildHeartbeatStateUrl(string serverUrl, string timestamp, string sign, string? key = null, string? userKey = null) { if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var baseUri)) { throw new InvalidOperationException("服务端地址格式无效。"); } var stateUri = new Uri(baseUri, "/getState"); var query = new List { $"t={Uri.EscapeDataString(timestamp)}", $"sign={Uri.EscapeDataString(sign)}" }; if (!string.IsNullOrWhiteSpace(key)) { query.Add($"key={Uri.EscapeDataString(key)}"); } if (!string.IsNullOrWhiteSpace(userKey)) { query.Add($"userkey={Uri.EscapeDataString(userKey)}"); } return $"{stateUri}?{string.Join("&", query)}"; } private static string CreateMd5(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hash = MD5.HashData(bytes); var builder = new StringBuilder(hash.Length * 2); foreach (var b in hash) { builder.Append(b.ToString("x2")); } return builder.ToString(); } private static string FormatUnixTimestamp(long? timestamp) { if (timestamp == null || timestamp <= 0) { return "-"; } try { return DateTimeOffset.FromUnixTimeSeconds(timestamp.Value).LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"); } catch { return timestamp.Value.ToString(); } } } public sealed class ClientConfig { public string ServerUrl { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty; public string SenderEmail { get; set; } = string.Empty; public string SmtpHost { get; set; } = string.Empty; public int SmtpPort { get; set; } = 465; public string NotifyEmail { get; set; } = string.Empty; public string EmailAuthCode { get; set; } = string.Empty; public string WechatPath { get; set; } = string.Empty; public string WechatSid { get; set; } = string.Empty; public string WechatApiVersion { get; set; } = "7.10.1"; public string AlipayPath { get; set; } = string.Empty; public string AlipayAppId { get; set; } = string.Empty; public string AlipayUserId { get; set; } = string.Empty; public int ListenPort { get; set; } = 8989; public int WechatIntervalSeconds { get; set; } = 5; public int AlipayIntervalSeconds { get; set; } = 5; public bool EnableWheelPolling { get; set; } = true; public bool EnableHeartbeat { get; set; } = false; public int HeartbeatIntervalSeconds { get; set; } = 30; public string ListenPath { get; set; } = "/notify/"; } public sealed class PaymentEvent { public string Channel { get; set; } = "wechat"; public decimal Amount { get; set; } public string OrderNo { get; set; } = string.Empty; public string TradeNo { get; set; } = string.Empty; public string Payer { get; set; } = string.Empty; public string Status { get; set; } = "success"; public DateTimeOffset ReceivedAt { get; set; } public string? Raw { get; set; } } public sealed class PendingOrderRegistration { public string OrderId { get; set; } = string.Empty; public string PayId { get; set; } = string.Empty; public string? Param { get; set; } public int PayType { get; set; } public decimal Price { get; set; } public decimal ReallyPrice { get; set; } public int TimeOut { get; set; } public int State { get; set; } public long Date { get; set; } } public sealed class PendingOrderRecord { public string OrderId { get; set; } = string.Empty; public string PayId { get; set; } = string.Empty; public string Param { get; set; } = string.Empty; public int PayType { get; set; } public decimal Price { get; set; } public decimal ReallyPrice { get; set; } public int TimeOut { get; set; } public int State { get; set; } public long Date { get; set; } public DateTimeOffset RegisteredAt { get; set; } public DateTimeOffset? CompletedAt { get; set; } public string TradeNo { get; set; } = string.Empty; } public sealed class PendingOrdersApiResponse { public int Code { get; set; } public string Msg { get; set; } = string.Empty; public List? Data { get; set; } } public sealed class ServerCallbackPayload { public string ApiKey { get; set; } = string.Empty; public string Channel { get; set; } = string.Empty; public decimal Amount { get; set; } public string OrderNo { get; set; } = string.Empty; public string TradeNo { get; set; } = string.Empty; public string Payer { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public DateTimeOffset ReceivedAt { get; set; } public string? Raw { get; set; } } public sealed class ServerCallbackResult { public HttpStatusCode StatusCode { get; set; } public string ResponseBody { get; set; } = string.Empty; } public sealed class HeartbeatRequestResult { public HttpStatusCode StatusCode { get; set; } public string ResponseBody { get; set; } = string.Empty; public HeartbeatStateResponse? Response { get; set; } public bool IsSuccessStatusCode => (int)StatusCode >= 200 && (int)StatusCode <= 299; } public sealed class HeartbeatStateResponse { public int Code { get; set; } public string Msg { get; set; } = string.Empty; public HeartbeatStateData? Data { get; set; } } public sealed class HeartbeatStateData { [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] [JsonPropertyName("lastpay")] public long? LastPay { get; set; } [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] [JsonPropertyName("lastheart")] public long? LastHeart { get; set; } [JsonPropertyName("state")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public int? State { get; set; } [JsonPropertyName("jkstate")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public int? JkState { get; set; } } public sealed class WechatSmallbookResponse { [JsonPropertyName("retcode")] public int Retcode { get; set; } [JsonPropertyName("errcode")] public int Errcode { get; set; } [JsonPropertyName("msg")] public string Msg { get; set; } = string.Empty; [JsonPropertyName("data")] public WechatSmallbookData? Data { get; set; } } public sealed class WechatApiBaseResponse { [JsonPropertyName("retcode")] public int Retcode { get; set; } [JsonPropertyName("errcode")] public int Errcode { get; set; } [JsonPropertyName("msg")] public string Msg { get; set; } = string.Empty; } public sealed class WechatSmallbookData { [JsonPropertyName("income_list")] public List IncomeList { get; set; } = new(); } public sealed class WechatSmallbookIncome { [JsonPropertyName("fee")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public decimal Fee { get; set; } [JsonPropertyName("timestamp")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public long Timestamp { get; set; } [JsonPropertyName("trans_id")] public string? TransId { get; set; } [JsonPropertyName("roll_id")] public string? RollId { get; set; } } public sealed class WechatClassifyReadResponse { [JsonPropertyName("retcode")] public int Retcode { get; set; } [JsonPropertyName("errcode")] public int Errcode { get; set; } [JsonPropertyName("msg")] public string Msg { get; set; } = string.Empty; [JsonPropertyName("data")] public WechatClassifyReadData? Data { get; set; } } public sealed class WechatClassifyReadData { [JsonPropertyName("person_bill_list")] public List PersonBillList { get; set; } = new(); } public sealed class WechatClassifyReadBill { [JsonPropertyName("trans_id")] public string? TransId { get; set; } [JsonPropertyName("create_time")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public long CreateTime { get; set; } [JsonPropertyName("total_fee")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public decimal TotalFee { get; set; } } }