VmianqianC/Form1.cs
2026-04-28 01:24:44 +08:00

1634 lines
59 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.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; }
}
}