using System.Net; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using AntdUI; 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 readonly string _configFilePath = Path.Combine(AppContext.BaseDirectory, "appsettings.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 bool _isSynchronizingNavigation; 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 txtNotifyEmail = null!; private AntdUI.Label lblServerUrlTitle = null!; private AntdUI.Label lblApiKeyTitle = null!; private AntdUI.Label lblNotifyEmailTitle = null!; private AntdUI.Label lblHeartbeatTitle = 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 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 btnStartService = null!; private AntdUI.Button btnStopService = 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"; MinimumSize = new Size(1180, 860); if (Width < 1280 || Height < 900) { Size = new Size(1280, 900); } 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 = "AntdUI", SubText = "Demo" }; lblAlipayStatusValue = new AntdUI.Label { Dock = DockStyle.Right, Width = 88, Text = "支付宝: 离线", TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.Red }; lblWechatStatusValue = new AntdUI.Label { Dock = DockStyle.Right, Width = 76, Text = "微信: 离线", TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.Red }; titlebar.Controls.Add(lblTopNotice); titlebar.Controls.Add(lblAlipayStatusValue); titlebar.Controls.Add(lblWechatStatusValue); } 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 = 360; 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(); summaryCard.Bounds = new Rectangle(margin, 20, pageAvailableWidth, 120); lblSummaryTitle.Location = new Point(28, 24); lblSummaryDesc.Location = new Point(30, 64); lblSummaryDesc.Size = new Size(Math.Max(220, pageAvailableWidth - 60), 28); 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; lblNotifyEmailTitle.Location = new Point(contentLeft, 168); txtNotifyEmail.Location = new Point(contentLeft, 196); txtNotifyEmail.Width = inputWidth; const int actionTop = 252; const int buttonGap = 12; var actionButtonWidth = Math.Max(72, (contentWidth - buttonGap * 3) / 4); btnSaveConfig.Location = new Point(24, actionTop); btnSaveConfig.Size = new Size(actionButtonWidth, 36); btnStartService.Location = new Point(btnSaveConfig.Right + buttonGap, actionTop); btnStartService.Size = new Size(actionButtonWidth, 36); btnStopService.Location = new Point(btnStartService.Right + buttonGap, actionTop); btnStopService.Size = new Size(actionButtonWidth, 36); btnHeartbeatCheck.Location = new Point(btnStopService.Right + buttonGap, actionTop); btnHeartbeatCheck.Size = new Size(actionButtonWidth, 34); const int heartbeatTop = 300; lblHeartbeatTitle.Location = new Point(contentLeft, heartbeatTop); chkHeartbeatEnabled.Location = new Point(98, 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() { LayoutPageCards(pageWechat, 20); var gridCard = FindCard(pageWechat, "wechat-log"); if (gridCard != null) { 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)); } } } 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); } 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(30, 64), Size = new Size(980, 28), 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); lblNotifyEmailTitle = CreateTitleLabel("通知邮箱", 24, 168); configCard.Controls.Add(lblNotifyEmailTitle); txtNotifyEmail = CreateInput(24, 196, 500, "可选"); configCard.Controls.Add(txtNotifyEmail); btnSaveConfig = new AntdUI.Button { Text = "保存配置", Type = TTypeMini.Primary, Location = new Point(24, 252), Size = new Size(110, 36), }; btnSaveConfig.Click += btnSaveConfig_Click; btnStartService = new AntdUI.Button { Text = "启动监听", Type = TTypeMini.Primary, Location = new Point(146, 252), Size = new Size(110, 36), }; btnStartService.Click += btnStart_Click; btnStopService = new AntdUI.Button { Text = "停止监听", Type = TTypeMini.Primary, Location = new Point(268, 252), Size = new Size(110, 36), }; btnStopService.Click += btnStop_Click; configCard.Controls.Add(btnSaveConfig); configCard.Controls.Add(btnStartService); configCard.Controls.Add(btnStopService); lblHeartbeatTitle = CreateTitleLabel("心跳检测", 24, 300); configCard.Controls.Add(lblHeartbeatTitle); chkHeartbeatEnabled = new AntdUI.Switch { Location = new Point(98, 296), Size = new Size(62, 28), AutoSize = false }; chkHeartbeatEnabled.CheckedChanged += chkHeartbeatEnabled_CheckedChanged; lblHeartbeatDesc = new AntdUI.Label { Text = "自动心跳", AutoSize = true, Location = new Point(164, 301), ForeColor = Color.DimGray }; btnHeartbeatCheck = new AntdUI.Button { Text = "立即检测", Type = TTypeMini.Primary, Location = new Point(390, 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 card = CreateCardPanel(new Rectangle(20, 20, 1080, 150)); card.Tag = "wechat-config"; card.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Controls.Add(CreateTitleLabel("微信安装路径", 24, 20)); txtWechatPath = CreateInput(24, 48, 390, "微信.exe 路径"); card.Controls.Add(txtWechatPath); card.Controls.Add(CreateTitleLabel("微信 SID", 440, 20)); txtWechatId = CreateInput(440, 48, 220, "可选"); card.Controls.Add(txtWechatId); card.Controls.Add(CreateTitleLabel("轮询频率(秒)", 690, 20)); numWechatInterval = new NumericUpDown { Location = new Point(690, 51), Size = new Size(96, 23), Minimum = 1, Maximum = 3600, Value = 5 }; card.Controls.Add(numWechatInterval); chkWheel = new AntdUI.Checkbox { Text = "启用轮询", Location = new Point(820, 50), AutoSize = true }; card.Controls.Add(chkWheel); 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 = "wechat-log"; gridCard.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; gridCard.Controls.Add(CreateTitleLabel("微信监听记录", 24, 18)); 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(card); pageWechat.Controls.Add(gridCard); } 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 card = CreateCardPanel(new Rectangle(20, 20, 1080, 260)); card.Tag = "settings-main"; card.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Controls.Add(CreateTitleLabel("本地监听端口", 24, 20)); txtServicePort = CreateInput(24, 48, 180, "8989"); card.Controls.Add(txtServicePort); card.Controls.Add(CreateTitleLabel("本地监听路径", 240, 20)); txtListenPath = CreateInput(240, 48, 240, "/notify/"); card.Controls.Add(txtListenPath); var info = new AntdUI.Label { Text = "这个页面用于维护本地监听设置与项目说明。\r\n\r\n目标顺序:\r\n1. 先让本地监听和服务端回调稳定运行\r\n2. 再接入微信 PC 端监听\r\n3. 再接入支付宝 PC 端监听", AutoSize = false, Location = new Point(24, 105), Size = new Size(920, 120), ForeColor = Color.DimGray }; card.Controls.Add(info); pageSettings.Controls.Add(card); } 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(); _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(); return; } var json = File.ReadAllText(_configFilePath, Encoding.UTF8); _config = JsonSerializer.Deserialize(json, _jsonOptions) ?? new ClientConfig(); } private void SaveConfig() { var json = JsonSerializer.Serialize(_config, _jsonOptions); File.WriteAllText(_configFilePath, json, Encoding.UTF8); } private void BindConfigToUi() { txtServerUrl.Text = _config.ServerUrl; txtApiKey.Text = _config.ApiKey; txtNotifyEmail.Text = _config.NotifyEmail; 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; } private void SaveUiToConfig() { _config.ServerUrl = NormalizeServerUrl(txtServerUrl.Text); _config.ApiKey = txtApiKey.Text.Trim(); _config.NotifyEmail = txtNotifyEmail.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 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 async void btnStart_Click(object? sender, EventArgs e) { 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 void btnStop_Click(object? sender, EventArgs e) { StopListener(); UpdateServiceStatus(false); Log("监听已停止。"); } 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 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 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 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 async Task ForwardEventToServerAsync(PaymentEvent paymentEvent) { var serverUrl = NormalizeServerUrl(_config.ServerUrl); if (string.IsNullOrWhiteSpace(serverUrl)) { return new ServerCallbackResult { StatusCode = HttpStatusCode.OK, ResponseBody = "未配置服务端 URL,已跳过回调。" }; } var payload = new ServerCallbackPayload { ApiKey = _config.ApiKey, Channel = paymentEvent.Channel, Amount = paymentEvent.Amount, OrderNo = paymentEvent.OrderNo, TradeNo = paymentEvent.TradeNo, Payer = paymentEvent.Payer, Status = paymentEvent.Status, ReceivedAt = paymentEvent.ReceivedAt, Raw = paymentEvent.Raw }; Log($"转发到服务端:{serverUrl}"); var response = await _httpClient.PostAsJsonAsync(serverUrl, payload, _jsonOptions); var responseBody = await response.Content.ReadAsStringAsync(); Log($"服务端响应:HTTP {(int)response.StatusCode} {response.StatusCode},内容:{responseBody}"); return new ServerCallbackResult { StatusCode = response.StatusCode, ResponseBody = responseBody }; } 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) { lblTopNotice.Text = isRunning ? $"监听中:{BuildLocalListenUrl(_config)}" : "监听未启动"; } 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 NotifyEmail { get; set; } = string.Empty; public string WechatPath { get; set; } = string.Empty; public string WechatSid { get; set; } = string.Empty; 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 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; } } }