VmianqianC/Form1.cs

3594 lines
135 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using 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; }
}
}