1634 lines
59 KiB
C#
1634 lines
59 KiB
C#
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<ClientConfig>(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<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 async Task<ServerCallbackResult> 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<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)
|
||
{
|
||
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<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 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; }
|
||
}
|
||
}
|