diff --git a/29WxOrderWpf/App.xaml.cs b/29WxOrderWpf/App.xaml.cs
index 490f111..56d7175 100644
--- a/29WxOrderWpf/App.xaml.cs
+++ b/29WxOrderWpf/App.xaml.cs
@@ -143,8 +143,8 @@ namespace _29WxOrderWpf
}
#endregion
- //new MainWindow().Show();
- new OrderSupportTicketWindow().Show();
+ new MainWindow().Show();
+ //new OrderSupportTicketWindow().Show();
}
//static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
diff --git a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListCtrl.Command.cs b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListCtrl.Command.cs
index 59acae4..7fec599 100644
--- a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListCtrl.Command.cs
+++ b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListCtrl.Command.cs
@@ -30,7 +30,7 @@ public partial class OrderListCtrl
{
if (_statisticsDialog == null)
{
- _statisticsDialog =new ShopRevenueStatisticsView();
+ _statisticsDialog = new ShopRevenueStatisticsView();
_statisticsDialog.Closed += (s, e) => _statisticsDialog = null;
}
_statisticsDialog.Show();
@@ -310,7 +310,7 @@ public partial class OrderListCtrl
private void ShowPDDInvoiceDialog()
{
var view = new PDDInvoiceView();
- DialogService.Show(view);
+ DialogService.ShowOrActivateWindow(view);
}
#endregion
}
diff --git a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml
index 3eeede0..57a7997 100644
--- a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml
+++ b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml
@@ -1,86 +1,86 @@
+ x:Class="_29WxOrderWpf.Ctrls.OrderCtrls.OrderListRowHeaderCtrl"
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:local="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:uc="clr-namespace:_29WxOrderWpf.UserControls"
+ d:DesignHeight="50"
+ d:DesignWidth="800"
+ mc:Ignorable="d">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
+
-
+
-
-
-
-
+
+
+
+
+
-
-
+ Width="15"
+ Height="15"
+ Margin="2,0,0,0"
+ d:Fill="SkyBlue"
+ Data="M 0,0 L 1,0 L 1,1 L 10,1 L 7,4 L 10,7 L 1,7 L 1,12 L 0,12 Z"
+ Fill="{Binding ColourFlagBrush}"
+ Stretch="Uniform" />
+
+
-
+
-
-
-
+
+
diff --git a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml.cs b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml.cs
index 4b810ae..29f555f 100644
--- a/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml.cs
+++ b/29WxOrderWpf/Ctrls/OrderCtrls/OrderListRowHeaderCtrl.xaml.cs
@@ -3,6 +3,7 @@ using AllBase;
using AllBase.Helper;
using AllBase.Model.DBModel;
using NaiBa.Util.Extension;
+using OrderSupportTicket.Models;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -25,43 +26,43 @@ using Work._29Tools.Forms.ShopForms;
namespace _29WxOrderWpf.Ctrls.OrderCtrls
{
- ///
- /// OrderListRowHeaderCtrl.xaml 的交互逻辑
- ///
- public partial class OrderListRowHeaderCtrl : UserControl
- {
- public OrderListRowHeaderCtrl()
- {
- InitializeComponent();
- this.DataContextChanged += OrderListRowHeaderCtrl_DataContextChanged;
- txtOrderId.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtOrderId_MouseLeftButtonUp), true);
- txtShopName.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtShopName_MouseLeftButtonUp), true);
- }
+ ///
+ /// OrderListRowHeaderCtrl.xaml 的交互逻辑
+ ///
+ public partial class OrderListRowHeaderCtrl : UserControl
+ {
+ public OrderListRowHeaderCtrl()
+ {
+ InitializeComponent();
+ this.DataContextChanged += OrderListRowHeaderCtrl_DataContextChanged;
+ txtOrderId.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtOrderId_MouseLeftButtonUp), true);
+ txtShopName.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtShopName_MouseLeftButtonUp), true);
+ }
- private void txtShopName_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (this.DataContext is oms_wei_order_info order)
- {
- var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
- if (shop != null)
- {
- WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
- ShopBLL.CheckShopLogin(shop);
- if (shop.LoginStatus == "登录成功")
- {
- // 在线直接进店
- shopLoginForm.openType = EnumOpenType.manage;
- shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
- shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/home";
- shopLoginForm.Show();
- }
- else
- {
- // 掉线登录
- shopLoginForm.openType = EnumOpenType.login;
- shopLoginForm.ShowDialog();
+ private void txtShopName_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (this.DataContext is oms_wei_order_info order)
+ {
+ var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
+ if (shop != null)
+ {
+ WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
+ ShopBLL.CheckShopLogin(shop);
+ if (shop.LoginStatus == "登录成功")
+ {
+ // 在线直接进店
+ shopLoginForm.openType = EnumOpenType.manage;
+ shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
+ shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/home";
+ shopLoginForm.Show();
+ }
+ else
+ {
+ // 掉线登录
+ shopLoginForm.openType = EnumOpenType.login;
+ shopLoginForm.ShowDialog();
- if (shop.LoginStatus == "登录成功")
+ if (shop.LoginStatus == "登录成功")
{
shop.Is29Supplier = false;
for (int i = 0; i < 100; i++)
@@ -82,87 +83,87 @@ namespace _29WxOrderWpf.Ctrls.OrderCtrls
if (i * 20 >= totalCount)
break;
}
-
- foreach (var ord in GlobalConfig.lstFilterOrder.Where(o => o.shop_id.ParseToString() == shop.ErpShopId).ToList())
- {
- // 更新代发管理情况
- foreach (var orderItem in ord.orderItems)
- Dispatcher.Invoke(new Action(() => { orderItem.UpdateOrderItemBuyButton(); }));
- }
- }
- }
- }
- }
- }
- private void txtOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (this.DataContext is oms_wei_order_info order)
- {
- var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
- if (shop != null)
- {
- WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
- shopLoginForm.openType = EnumOpenType.manage;
- shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
-
- {
- shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/order/detail?orderid=" + order.order_id;
- }
- shopLoginForm.Show();
- }
- }
- }
+ foreach (var ord in GlobalConfig.lstFilterOrder.Where(o => o.shop_id.ParseToString() == shop.ErpShopId).ToList())
+ {
+ // 更新代发管理情况
+ foreach (var orderItem in ord.orderItems)
+ Dispatcher.Invoke(new Action(() => { orderItem.UpdateOrderItemBuyButton(); }));
+ }
+ }
+ }
+ }
+ }
+ }
- private void OrderListRowHeaderCtrl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
- {
- if (this.DataContext is oms_wei_order_info order)
- {
- if (order.orderItems.Count > 5)
- {
- txtCount.Text = "该订单共计 " + order.orderItems.Count + " 件商品";
- }
- }
- }
+ private void txtOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (this.DataContext is oms_wei_order_info order)
+ {
+ var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
+ if (shop != null)
+ {
+ WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
+ shopLoginForm.openType = EnumOpenType.manage;
+ shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
- private void Path_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- //var orderId = ((sender as Border)?.Tag).ParseToString();
- if (this.DataContext is oms_wei_order_info order)
- {
+ {
+ shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/order/detail?orderid=" + order.order_id;
+ }
+ shopLoginForm.Show();
+ }
+ }
+ }
- try
- {
- //Clipboard.SetText(order.order_id); // 复制成功,但会报错,改为SetDataObject
- Clipboard.SetDataObject(order.order_id);
- BaseMsgWindow.ShowAnimateMessage("复制成功");
- }
- catch (Exception ex)
- {
- BaseMsgWindow.ShowError("复制失败 " + ex.Message);
- }
- }
- }
+ private void OrderListRowHeaderCtrl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (this.DataContext is oms_wei_order_info order)
+ {
+ if (order.orderItems.Count > 5)
+ {
+ txtCount.Text = "该订单共计 " + order.orderItems.Count + " 件商品";
+ }
+ }
+ }
- private void pathShop_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- if (this.DataContext is oms_wei_order_info order)
- {
- var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
- if (shop != null)
- {
- try
- {
- Clipboard.SetDataObject(shop.ShopAccount);
- BaseMsgWindow.ShowAnimateMessage("复制成功");
- }
- catch (Exception ex)
- {
- BaseMsgWindow.ShowError("复制失败 " + ex.Message);
- }
- }
- }
- }
+ private void Path_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ //var orderId = ((sender as Border)?.Tag).ParseToString();
+ if (this.DataContext is oms_wei_order_info order)
+ {
+
+ try
+ {
+ //Clipboard.SetText(order.order_id); // 复制成功,但会报错,改为SetDataObject
+ Clipboard.SetDataObject(order.order_id);
+ BaseMsgWindow.ShowAnimateMessage("复制成功");
+ }
+ catch (Exception ex)
+ {
+ BaseMsgWindow.ShowError("复制失败 " + ex.Message);
+ }
+ }
+ }
+
+ private void pathShop_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (this.DataContext is oms_wei_order_info order)
+ {
+ var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
+ if (shop != null)
+ {
+ try
+ {
+ Clipboard.SetDataObject(shop.ShopAccount);
+ BaseMsgWindow.ShowAnimateMessage("复制成功");
+ }
+ catch (Exception ex)
+ {
+ BaseMsgWindow.ShowError("复制失败 " + ex.Message);
+ }
+ }
+ }
+ }
private void borderFlag_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
@@ -195,9 +196,44 @@ namespace _29WxOrderWpf.Ctrls.OrderCtrls
order.colour_flag = flag;
Thread.Sleep(1000);
Dispatcher.Invoke(new Action(() => { MainWindow.Instance.orderListCtrl.FilterOneOrder(order); }));
-
+
});
}
}
+ ///
+ /// 创建工单
+ ///
+ ///
+ ///
+ private async void btn_createOrderSupportTicket(object sender, RoutedEventArgs e)
+ {
+ if (!(this.DataContext is oms_wei_order_info order))
+ return;
+
+ var shop = order.Shop
+ ?? GlobalConfig.ShopLoginList.FirstOrDefault(
+ s => s.ErpShopId == order.shop_id.ParseToString());
+ var appId = shop?.ShopId?.Trim();
+ if (string.IsNullOrWhiteSpace(appId)
+ || string.IsNullOrWhiteSpace(order.order_id))
+ {
+ BaseMsgWindow.ShowAnimateMessage(
+ "订单缺少店铺 APPID 或订单号,无法创建工单",
+ "Error",
+ 5);
+ return;
+ }
+
+ var ticketOrder = TicketOrderOption.FromWechatOrder(order, appId);
+
+ try
+ {
+ await MainWindow.Instance.CreateOrderSupportTicketAsync(ticketOrder);
+ }
+ catch (Exception ex)
+ {
+ BaseMsgWindow.ShowError("打开工单窗口失败:" + ex.Message);
+ }
+ }
}
}
diff --git a/29WxOrderWpf/MainWindow.xaml b/29WxOrderWpf/MainWindow.xaml
index 1a61354..ce03a0a 100644
--- a/29WxOrderWpf/MainWindow.xaml
+++ b/29WxOrderWpf/MainWindow.xaml
@@ -1,233 +1,273 @@
+ x:Class="_29WxOrderWpf.MainWindow"
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows"
+ xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:local="clr-namespace:_29WxOrderWpf"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
+ xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls"
+ xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls"
+ x:Name="mainWindow"
+ Title="MainWindow"
+ Width="1600"
+ Height="900"
+ Closing="BaseWindow_Closing"
+ Loaded="Window_Loaded"
+ Style="{StaticResource BaseNoneBorderWindowStyle}"
+ mc:Ignorable="d">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Width="1"
+ Height="20"
+ Margin="10,0"
+ Background="#b893b2" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ x:Name="pddBuyerOrderReadCtrl"
+ Grid.Column="1"
+ Width="1200"
+ HorizontalAlignment="Right"
+ Panel.ZIndex="-1"
+ d:Visibility="Visible"
+ Visibility="Collapsed" />
+ x:Name="tbBuyerOrderReadCtrl"
+ Grid.Column="1"
+ Width="1200"
+ HorizontalAlignment="Right"
+ Panel.ZIndex="-1"
+ d:Visibility="Collapsed"
+ Visibility="Collapsed" />
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs
index 07f25bd..b15ec5a 100644
--- a/29WxOrderWpf/MainWindow.xaml.cs
+++ b/29WxOrderWpf/MainWindow.xaml.cs
@@ -14,6 +14,9 @@ using CefSharp;
using NaiBa.Util;
using NaiBa.Util.Extension;
using Newtonsoft.Json.Linq;
+using OrderSupportTicket.Models;
+using OrderSupportTicket.Services;
+using OrderSupportTicket.Views;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -41,249 +44,261 @@ using System.Xml.Linq;
using Work;
using Work._29Tools.BLL;
using Work._29Tools.Forms.UserForms;
+using WxOrder.Framework.Commons;
+using WxOrder.Framework.Commons.Dialogs;
using static System.Windows.Forms.VisualStyles.VisualStyleElement;
+using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;
namespace _29WxOrderWpf
{
- ///
- /// MainWindow.xaml 的交互逻辑
- ///
- public partial class MainWindow : BaseWindow, INotifyPropertyChanged
- {
- public static MainWindow Instance { get; private set; }
+ ///
+ /// MainWindow.xaml 的交互逻辑
+ ///
+ public partial class MainWindow : BaseWindow, INotifyPropertyChanged
+ {
+ public static MainWindow Instance { get; private set; }
- public event PropertyChangedEventHandler PropertyChanged;
- public string Balance { get { return "快递余额:" + BaseClass.Balance.ToString("0.00"); } set { OnPropertyChanged("Balance"); } }
+ private readonly TicketRealtimeMonitor _ticketMonitor = TicketRealtimeMonitor.Current;
+ private TicketClientOptions _ticketOptions;
+ private OrderSupportTicketView _orderSupportView;
- public MainWindow()
- {
- InitializeComponent();
- this.DataContext = this;
- Instance = this;
+ public event PropertyChangedEventHandler PropertyChanged;
+ public string Balance { get { return "快递余额:" + BaseClass.Balance.ToString("0.00"); } set { OnPropertyChanged("Balance"); } }
- gridUser.Visibility = Visibility.Collapsed;
- }
+ public MainWindow()
+ {
+ InitializeComponent();
+ this.DataContext = this;
+ Instance = this;
- private void Window_Loaded(object sender, RoutedEventArgs e)
- {
- GlobalConfig.Dispatcher = this.Dispatcher;
- lblDebug.Visibility = Visibility.Collapsed;
+ gridUser.Visibility = Visibility.Collapsed;
- ucWindowBox.SetWindowMax();
- this.Title = BaseClass.ToolName;
+ _ticketMonitor.NewMessageReceived += OnTicketNewMessageReceived;
+ _ticketMonitor.UnreadCountChanged += OnTicketUnreadCountChanged;
+ Closed += MainWindow_Closed;
+ }
- new Thread(AutoAntiGrabbingMechanism).Start(); // 反抓包
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ GlobalConfig.Dispatcher = this.Dispatcher;
+ lblDebug.Visibility = Visibility.Collapsed;
- #region 弹出登录窗口
- LoginWindow loginWindow = new LoginWindow(App.args);
- loginWindow.Owner = this;
- loginWindow.ShowDialog();
- #endregion
+ ucWindowBox.SetWindowMax();
+ this.Title = BaseClass.ToolName;
- if (loginWindow.IsLogin)
- {
- lblVersion.Content = BaseClass.ToolName + " V" + BaseClass.Varsion + " - " + BaseClass.NickName;
+ new Thread(AutoAntiGrabbingMechanism).Start(); // 反抓包
- //shopListCtrl.Visibility = Visibility.Visible;
- orderListCtrl.Visibility = Visibility.Visible;
+ #region 弹出登录窗口
+ LoginWindow loginWindow = new LoginWindow(App.args);
+ loginWindow.Owner = this;
+ loginWindow.ShowDialog();
+ #endregion
+
+ if (loginWindow.IsLogin)
+ {
+ lblVersion.Content = BaseClass.ToolName + " V" + BaseClass.Varsion + " - " + BaseClass.NickName;
+
+ //shopListCtrl.Visibility = Visibility.Visible;
+ orderListCtrl.Visibility = Visibility.Visible;
pddBuyerOrderReadCtrl.Visibility = Visibility.Visible;
tbBuyerOrderReadCtrl.Visibility = Visibility.Visible;
pddBuyerSelectCtrl.Visibility = Visibility.Visible;
- Task.Run(() =>
- {
- GetServerShop();
+ Task.Run(() =>
+ {
+ GetServerShop();
+ StartTicketRealtimeMonitor();//启动工单监控
GetShopSupplier(GlobalConfig.ShopLoginList.ToList());
});
- Task.Run(() =>
- {
- GetPddBuyer();
+ Task.Run(() =>
+ {
+ GetPddBuyer();
GetTaobaoBuyer();
});
- }
- }
- protected virtual void OnPropertyChanged(string propertyName)
- {
- if (this.PropertyChanged != null)//有改变
- this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
- }
+ }
+ }
+ protected virtual void OnPropertyChanged(string propertyName)
+ {
+ if (this.PropertyChanged != null)//有改变
+ this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
+ }
- #region 反抓包功能
- private void AutoAntiGrabbingMechanism()
- {
- while (true)
- {
+ #region 反抓包功能
+ private void AutoAntiGrabbingMechanism()
+ {
+ while (true)
+ {
#if DEBUG
#else
AntiGrabbingMechanism();
#endif
- try { Thread.Sleep(3000); } catch { }
- }
- }
+ try { Thread.Sleep(3000); } catch { }
+ }
+ }
- [DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
- [return: MarshalAs(UnmanagedType.Bool)]
- internal static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);
- private List _AntiProcesses = "WINRAR,WIRESHARK,FIDDLER,HTTPDEBUGGERPRO,IEHTTPANALYZER,CHARLES,HTTPWATCH,SMARTSNIFF,MINISNIFFER,PROXIFIER,HTTP ANALYZER,SNIFFER,WSOCKEXPERT,IPANALYSE,ANALYZER,HTTP ANALYZER STAND-ALONE EDITION".Split(',').ToList();
- private void AntiGrabbingMechanism()
- {
- //Gate 1 - Anti Proxy
- if (IsUseSystemProxy())
- {
- LockUserAndKillProcess();
- }
+ [DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);
+ private List _AntiProcesses = "WINRAR,WIRESHARK,FIDDLER,HTTPDEBUGGERPRO,IEHTTPANALYZER,CHARLES,HTTPWATCH,SMARTSNIFF,MINISNIFFER,PROXIFIER,HTTP ANALYZER,SNIFFER,WSOCKEXPERT,IPANALYSE,ANALYZER,HTTP ANALYZER STAND-ALONE EDITION".Split(',').ToList();
+ private void AntiGrabbingMechanism()
+ {
+ //Gate 1 - Anti Proxy
+ if (IsUseSystemProxy())
+ {
+ LockUserAndKillProcess();
+ }
- //Gate 2 - Anti Special Processes
- System.Diagnostics.Process[] processList = System.Diagnostics.Process.GetProcesses();
- foreach (System.Diagnostics.Process process in processList)
- {
- try
- {
- if (_AntiProcesses.Contains(process.ProcessName.ToUpper()))
- {
- process.Kill();
- LockUserAndKillProcess();
- }
+ //Gate 2 - Anti Special Processes
+ System.Diagnostics.Process[] processList = System.Diagnostics.Process.GetProcesses();
+ foreach (System.Diagnostics.Process process in processList)
+ {
+ try
+ {
+ if (_AntiProcesses.Contains(process.ProcessName.ToUpper()))
+ {
+ process.Kill();
+ LockUserAndKillProcess();
+ }
- if (process.MainWindowTitle.Contains("正在捕获"))
- {
- process.Kill();
- LockUserAndKillProcess();
- }
+ if (process.MainWindowTitle.Contains("正在捕获"))
+ {
+ process.Kill();
+ LockUserAndKillProcess();
+ }
- if (!IsWin23App(process))
- {
- continue;
- }
- if (process.MainModule?.FileVersionInfo.FileDescription != null)
- {
- foreach (string antiProcess in _AntiProcesses)
- {
- if (process.MainModule.FileVersionInfo.FileDescription.ToUpper().Contains(antiProcess))
- {
- process.Kill();
- LockUserAndKillProcess();
- }
- }
- }
- }
- catch (Exception e)
- {
- }
- }
- }
+ if (!IsWin23App(process))
+ {
+ continue;
+ }
+ if (process.MainModule?.FileVersionInfo.FileDescription != null)
+ {
+ foreach (string antiProcess in _AntiProcesses)
+ {
+ if (process.MainModule.FileVersionInfo.FileDescription.ToUpper().Contains(antiProcess))
+ {
+ process.Kill();
+ LockUserAndKillProcess();
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ }
+ }
+ }
- private void LockUserAndKillProcess()
- {
- string url = BaseClass.BuildObjString("user&act=add_user_lock");
- string data = UserLogin.GetSignaturex("", "");
- string html = GetHtml.PostUtf8(url, data);
- Process.GetCurrentProcess().Kill();
- }
- public static bool IsUseSystemProxy()
- {
- BindingFlags bindingAttr = BindingFlags.Instance | BindingFlags.NonPublic;
- IWebProxy systemWebProxy = WebRequest.GetSystemWebProxy();
- FieldInfo field1 = systemWebProxy.GetType().GetField("webProxy", bindingAttr);
- if (field1 != (FieldInfo)null)
- {
- WebProxy webProxy = field1.GetValue((object)systemWebProxy) as WebProxy;
- FieldInfo field2 = webProxy.GetType().GetField("_ProxyHostAddresses", bindingAttr);
- if (field2 != (FieldInfo)null)
- {
- Hashtable hashtable = field2.GetValue((object)webProxy) as Hashtable;
- if (hashtable != null && hashtable.Count > 0)
- return true;
- }
- }
- return false;
- }
+ private void LockUserAndKillProcess()
+ {
+ string url = BaseClass.BuildObjString("user&act=add_user_lock");
+ string data = UserLogin.GetSignaturex("", "");
+ string html = GetHtml.PostUtf8(url, data);
+ Process.GetCurrentProcess().Kill();
+ }
+ public static bool IsUseSystemProxy()
+ {
+ BindingFlags bindingAttr = BindingFlags.Instance | BindingFlags.NonPublic;
+ IWebProxy systemWebProxy = WebRequest.GetSystemWebProxy();
+ FieldInfo field1 = systemWebProxy.GetType().GetField("webProxy", bindingAttr);
+ if (field1 != (FieldInfo)null)
+ {
+ WebProxy webProxy = field1.GetValue((object)systemWebProxy) as WebProxy;
+ FieldInfo field2 = webProxy.GetType().GetField("_ProxyHostAddresses", bindingAttr);
+ if (field2 != (FieldInfo)null)
+ {
+ Hashtable hashtable = field2.GetValue((object)webProxy) as Hashtable;
+ if (hashtable != null && hashtable.Count > 0)
+ return true;
+ }
+ }
+ return false;
+ }
- public bool IsWin23App(Process process)
- {
- if (Environment.Is64BitOperatingSystem)
- {
- IntPtr processHandle;
- bool retVal;
+ public bool IsWin23App(Process process)
+ {
+ if (Environment.Is64BitOperatingSystem)
+ {
+ IntPtr processHandle;
+ bool retVal;
- try
- {
- processHandle = Process.GetProcessById(process.Id).Handle;
- }
- catch
- {
- return false;
- }
- return IsWow64Process(processHandle, out retVal) && retVal;
- }
- return true;
- }
- #endregion
+ try
+ {
+ processHandle = Process.GetProcessById(process.Id).Handle;
+ }
+ catch
+ {
+ return false;
+ }
+ return IsWow64Process(processHandle, out retVal) && retVal;
+ }
+ return true;
+ }
+ #endregion
- private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
- {
- if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
- Process.GetCurrentProcess().Kill();
- else
- e.Cancel = true;
- }
+ private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
+ Process.GetCurrentProcess().Kill();
+ else
+ e.Cancel = true;
+ }
- #region 读取微信店铺
- public bool isReadShop { get; set; } = false;
- public bool hasReadShop { get; set; } = false;
- public void GetServerShop()
- {
- if (isReadShop)
- return;
+ #region 读取微信店铺
+ public bool isReadShop { get; set; } = false;
+ public bool hasReadShop { get; set; } = false;
+ public void GetServerShop()
+ {
+ if (isReadShop)
+ return;
- var tList = new ConcurrentList();
- List list = new List();
- Dispatcher.Invoke(new Action(() =>
- {
- }));
- isReadShop = true;
- int page = 0;
- while (true)
- {
- page++;
- var url = BaseClass.Build29WxObjString("shop/get_shop_list");
- var data = UserLogin.GetSignaturex() + "&page=" + page;
- data += "&is_get_erp_shop_id=true";
- var html = GetHtml.PostUtf8(url, data);
- if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
- html = GetHtml.PostUtf8(url, data);
- if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
- html = GetHtml.PostUtf8(url, data);
- if (string.IsNullOrEmpty(html)
- || !html.Contains("shop_id") || !html.Contains("shop_account") || !html.Contains("login_pass"))
- {
- break;
- }
+ var tList = new ConcurrentList();
+ List list = new List();
+ Dispatcher.Invoke(new Action(() =>
+ {
+ }));
+ isReadShop = true;
+ int page = 0;
+ while (true)
+ {
+ page++;
+ var url = BaseClass.Build29WxObjString("shop/get_shop_list");
+ var data = UserLogin.GetSignaturex() + "&page=" + page;
+ data += "&is_get_erp_shop_id=true";
+ var html = GetHtml.PostUtf8(url, data);
+ if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
+ html = GetHtml.PostUtf8(url, data);
+ if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
+ html = GetHtml.PostUtf8(url, data);
+ if (string.IsNullOrEmpty(html)
+ || !html.Contains("shop_id") || !html.Contains("shop_account") || !html.Contains("login_pass"))
+ {
+ break;
+ }
- var jArr = JObject.Parse(html)["data"];
- if (jArr.Count() == 0)
- break;
+ var jArr = JObject.Parse(html)["data"];
+ if (jArr.Count() == 0)
+ break;
- foreach (JObject jObj in jArr)
- {
- var shop = ShopBLL.DeserializationShop(jObj);
+ foreach (JObject jObj in jArr)
+ {
+ var shop = ShopBLL.DeserializationShop(jObj);
- var shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
- while (shopExist != null)
- {
- shop.Is29Supplier = shopExist.Is29Supplier;
- shop.IsSelect = shopExist.IsSelect;
- shop.IsFilter = shopExist.IsFilter;
- list.Remove(shopExist);
+ var shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
+ while (shopExist != null)
+ {
+ shop.Is29Supplier = shopExist.Is29Supplier;
+ shop.IsSelect = shopExist.IsSelect;
+ shop.IsFilter = shopExist.IsFilter;
+ list.Remove(shopExist);
shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
}
- list.Add(shop);
+ list.Add(shop);
- if (!GlobalConfig.ShopLoginList.Any(s => s.ShopId == shop.ShopId && !shop.is_point_shop_expire))
- {
- Dispatcher.Invoke(new Action(() =>
- {
- GlobalConfig.ShopLoginList.Add(shop);
- }));
+ if (!GlobalConfig.ShopLoginList.Any(s => s.ShopId == shop.ShopId && !shop.is_point_shop_expire))
+ {
+ Dispatcher.Invoke(new Action(() =>
+ {
+ GlobalConfig.ShopLoginList.Add(shop);
+ }));
Task.Run(() =>
{
@@ -332,27 +347,27 @@ namespace _29WxOrderWpf
}));
});
}
- }
- }
- Dispatcher.Invoke(new Action(() =>
- {
- foreach (var shop in GlobalConfig.ShopLoginList.Where(s => !list.Any(ss => ss.ShopId == s.ShopId)).ToList())
- {
- GlobalConfig.ShopLoginList.Remove(shop);
- }
- foreach (var shop in GlobalConfig.ShopLoginList.Where(s => s.is_point_shop_expire).ToList())
- {
- GlobalConfig.ShopLoginList.Remove(shop);
- }
- }));
- isReadShop = false;
- hasReadShop = true;
+ }
+ }
+ Dispatcher.Invoke(new Action(() =>
+ {
+ foreach (var shop in GlobalConfig.ShopLoginList.Where(s => !list.Any(ss => ss.ShopId == s.ShopId)).ToList())
+ {
+ GlobalConfig.ShopLoginList.Remove(shop);
+ }
+ foreach (var shop in GlobalConfig.ShopLoginList.Where(s => s.is_point_shop_expire).ToList())
+ {
+ GlobalConfig.ShopLoginList.Remove(shop);
+ }
+ }));
+ isReadShop = false;
+ hasReadShop = true;
while (tList.Where(ta => !ta.IsFaulted && !ta.IsCompleted).Count() >= 1)
{
tList.RemoveAll(ta => ta.IsFaulted || ta.IsCompleted || ta.IsCanceled); Thread.Sleep(100);
}
- }
+ }
public void GetShopSupplier(List shops)
{
@@ -369,7 +384,7 @@ namespace _29WxOrderWpf
var jArr = JsonHelper.JObjectParse(rst)["list"] as JArray;
// appid "wx7809625286c28f2f" name "百川日用";
if (jArr.Any(aa => aa["appid"].ParseToString() == "wx7809625286c28f2f" && aa["status"].ParseToInt() == 1))
- //if (rst.Contains("wx7809625286c28f2f") || rst.Contains("百川日用"))
+ //if (rst.Contains("wx7809625286c28f2f") || rst.Contains("百川日用"))
continue;
rst = new WxApiBLL().SetShopSupplier(shop, "wx7809625286c28f2f", "4431685489938137094");
@@ -380,7 +395,7 @@ namespace _29WxOrderWpf
}
catch
{ }
- }
+ }
}
#endregion
@@ -392,83 +407,83 @@ namespace _29WxOrderWpf
/// 读取拼多多买手
///
public void GetPddBuyer()
- {
- if (isGetPddBuyer)
- return;
+ {
+ if (isGetPddBuyer)
+ return;
- isGetPddBuyer = true;
- //var ids = dgvBuyer.Rows.Cast().Where(r => r.Cells["colSelect"].Value.ParseToBool() == true).Select(s => s.Cells["colBuyerId"].Value.ParseToString()).ToList();
- //Invoke(new Action(() => { pbLoading.Visible = true; }));
+ isGetPddBuyer = true;
+ //var ids = dgvBuyer.Rows.Cast().Where(r => r.Cells["colSelect"].Value.ParseToBool() == true).Select(s => s.Cells["colBuyerId"].Value.ParseToString()).ToList();
+ //Invoke(new Action(() => { pbLoading.Visible = true; }));
- try
- {
- var url = BaseClass.Build29PDDObjString("user/get_buyer");
- var data = UserLogin.GetSignaturex();
- data += "&page_size=200";
+ try
+ {
+ var url = BaseClass.Build29PDDObjString("user/get_buyer");
+ var data = UserLogin.GetSignaturex();
+ data += "&page_size=200";
- var result = GetHtml.PostUtf8(url, data);
- if (string.IsNullOrEmpty(result) || !result.Contains("\"code\":200"))
- result = GetHtml.PostUtf8(url, data);
+ var result = GetHtml.PostUtf8(url, data);
+ if (string.IsNullOrEmpty(result) || !result.Contains("\"code\":200"))
+ result = GetHtml.PostUtf8(url, data);
- if (result.Contains("\"code\":200"))
- {
- List loginInfoLst = JsonHelper.DeserializeJsonToList(JsonHelper.JObjectParse(result)["data"].ParseToString());
-
- foreach (var buyer in GlobalConfig.PDDBuyerLoginList.Where(bu => !loginInfoLst.Any(b => b.buyer_id == bu.buyer_id)).ToList())
- {
- Dispatcher.Invoke(new Action(() => { GlobalConfig.PDDBuyerLoginList.Remove(buyer); }));
- }
+ if (result.Contains("\"code\":200"))
+ {
+ List loginInfoLst = JsonHelper.DeserializeJsonToList(JsonHelper.JObjectParse(result)["data"].ParseToString());
- foreach (var b in loginInfoLst)
- {
- Dispatcher.Invoke(new Action(() =>
- {
- var buyer = GlobalConfig.PDDBuyerLoginList.FirstOrDefault(bu => bu.buyer_id == b.buyer_id);
- if (buyer == null)
- GlobalConfig.PDDBuyerLoginList.Add(b);
- else
- {
- buyer.add_time = b.add_time;
- buyer.buyer_id = b.buyer_id;
- buyer.buyer_name = b.buyer_name;
- buyer.buyer_nick = b.buyer_nick;
- buyer.cookie = b.cookie;
- buyer.mdy_time = b.mdy_time;
- buyer.notes = b.notes;
- buyer.status = b.status;
- buyer.user_name = b.user_name;
- }
- }));
- }
+ foreach (var buyer in GlobalConfig.PDDBuyerLoginList.Where(bu => !loginInfoLst.Any(b => b.buyer_id == bu.buyer_id)).ToList())
+ {
+ Dispatcher.Invoke(new Action(() => { GlobalConfig.PDDBuyerLoginList.Remove(buyer); }));
+ }
- //Invoke(new Action(() =>
- //{
- //dgvBuyer.Rows.Clear();
- //cmbBuyer.Items.Clear();
- //}));
+ foreach (var b in loginInfoLst)
+ {
+ Dispatcher.Invoke(new Action(() =>
+ {
+ var buyer = GlobalConfig.PDDBuyerLoginList.FirstOrDefault(bu => bu.buyer_id == b.buyer_id);
+ if (buyer == null)
+ GlobalConfig.PDDBuyerLoginList.Add(b);
+ else
+ {
+ buyer.add_time = b.add_time;
+ buyer.buyer_id = b.buyer_id;
+ buyer.buyer_name = b.buyer_name;
+ buyer.buyer_nick = b.buyer_nick;
+ buyer.cookie = b.cookie;
+ buyer.mdy_time = b.mdy_time;
+ buyer.notes = b.notes;
+ buyer.status = b.status;
+ buyer.user_name = b.user_name;
+ }
+ }));
+ }
- foreach (var buyer in GlobalConfig.PDDBuyerLoginList)
- {
- //Invoke(new Action(() =>
- //{
- //BindBuyer(buyer, "", ids);
- //cmbBuyer.Items.Add(buyer.buyer_nick);
- //}));
- }
- }
- }
- catch
- {
- }
+ //Invoke(new Action(() =>
+ //{
+ //dgvBuyer.Rows.Clear();
+ //cmbBuyer.Items.Clear();
+ //}));
- //Invoke(new Action(() =>
- //{
- // pbLoading.Visible = false;
- //}));
+ foreach (var buyer in GlobalConfig.PDDBuyerLoginList)
+ {
+ //Invoke(new Action(() =>
+ //{
+ //BindBuyer(buyer, "", ids);
+ //cmbBuyer.Items.Add(buyer.buyer_nick);
+ //}));
+ }
+ }
+ }
+ catch
+ {
+ }
- isGetPddBuyer = false;
- hasGetPddBuyer = true;
- }
+ //Invoke(new Action(() =>
+ //{
+ // pbLoading.Visible = false;
+ //}));
+
+ isGetPddBuyer = false;
+ hasGetPddBuyer = true;
+ }
#endregion
#region 读取拼多多买手号
@@ -537,18 +552,18 @@ namespace _29WxOrderWpf
#endregion
private void lblBalance_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- WXGetBalanceQrCodeForm balanceQrCodeForm = new WXGetBalanceQrCodeForm(StartUp.UpdateBalance);
- balanceQrCodeForm.ShowDialog();
- }
+ {
+ WXGetBalanceQrCodeForm balanceQrCodeForm = new WXGetBalanceQrCodeForm(StartUp.UpdateBalance);
+ balanceQrCodeForm.ShowDialog();
+ }
- private void gridPddBuyerOrderReadCtrl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- //if (Panel.GetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl) < 1)
- // Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, 1);
- //else
- // Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, -1);
- }
+ private void gridPddBuyerOrderReadCtrl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ //if (Panel.GetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl) < 1)
+ // Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, 1);
+ //else
+ // Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, -1);
+ }
private void gridTitle_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
@@ -571,5 +586,158 @@ namespace _29WxOrderWpf
view.ShowDialog();
}
- }
+
+ private void bt_showOrderSupport(object sender, RoutedEventArgs e)
+ {
+ OpenOrderSupportTicket(_ticketMonitor.LatestNotification);
+ _ticketMonitor.MarkAllRead();
+ }
+
+ private void StartTicketRealtimeMonitor()
+ {
+ try
+ {
+ _ticketMonitor.Start(GetTicketOptions().Clone());
+ UpdateOrderSupportBadge();
+ }
+ catch (Exception ex)
+ {
+ LogSave.Add("TicketRealtimeStartError:" + ex);
+ }
+ }
+
+ private TicketClientOptions GetTicketOptions()
+ {
+ if (_ticketOptions != null)
+ return _ticketOptions;
+
+ _ticketOptions = new TicketClientOptions
+ {
+ UserName = BaseClass.UserName,
+ AppIds = GlobalConfig.ShopLoginList.Select(a=>a.ShopId).ToList(),
+ Orders = []
+ };
+
+
+
+ //OrderSupportTicketView
+ // .CreateDefaultOptions(BaseClass.UserName)
+ // .Clone();
+ return _ticketOptions;
+ }
+
+ private void OpenOrderSupportTicket(TicketRealtimeNotification notification)
+ {
+ var view = EnsureOrderSupportView();
+ view.ViewModel.ShowHistory();
+
+ if (notification != null)
+ {
+ view.OpenTicket(
+ notification.AppId,
+ notification.WechatOrderId);
+ }
+
+ // DialogService 会复用同类型的已打开窗口,避免重复创建。
+ DialogService.ShowOrActivateWindow(view);
+ }
+
+ ///
+ /// 从订单行打开工单页面。若左侧列表已有相同订单号的工单,直接定位到该工单;
+ /// 否则在同一个工单页面中进入新建工单状态。
+ ///
+ public async Task CreateOrderSupportTicketAsync(TicketOrderOption order)
+ {
+ if (order == null)
+ return;
+
+ var view = EnsureOrderSupportView();
+ await view.ViewModel.InitializeAsync();
+
+ var existing = view.ViewModel.FindTicketByOrderId(order.OrderId);
+ if (existing != null)
+ {
+ view.ViewModel.ApplyOrderInfo(order);
+ view.OpenTicket(existing.AppId, existing.WechatOrderId);
+ }
+ else
+ {
+ view.OpenNewTicket(order);
+ }
+
+ DialogService.ShowOrActivateWindow(view);
+ }
+
+ private OrderSupportTicketView EnsureOrderSupportView()
+ {
+ if (_orderSupportView == null)
+ {
+ _orderSupportView = new OrderSupportTicketView(
+ new TicketApiService(),
+ _ticketMonitor.RealtimeService,
+ GetTicketOptions().Clone(),
+ ownsRealtime: false);
+ }
+
+ return _orderSupportView;
+ }
+
+ private void OnTicketNewMessageReceived(
+ object sender,
+ TicketInboxMessageEventArgs e)
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(
+ () => OnTicketNewMessageReceived(sender, e)));
+ return;
+ }
+
+ var notification = e?.Notification;
+ if (notification == null)
+ return;
+
+ UpdateOrderSupportBadge();
+ }
+
+ private void OnTicketUnreadCountChanged(
+ object sender,
+ TicketUnreadCountChangedEventArgs e)
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(
+ () => OnTicketUnreadCountChanged(sender, e)));
+ return;
+ }
+ UpdateOrderSupportBadge(e?.UnreadCount ?? 0);
+ }
+
+ private void UpdateOrderSupportBadge()
+ {
+ UpdateOrderSupportBadge(_ticketMonitor.UnreadCount);
+ }
+
+ private void UpdateOrderSupportBadge(int unreadCount)
+ {
+ if (borderOrderSupportUnread == null)
+ return;
+
+ borderOrderSupportUnread.Visibility = unreadCount > 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ tbOrderSupportUnread.Text = unreadCount > 99
+ ? "99+"
+ : unreadCount.ToString();
+ btOrderSupport.ToolTip = unreadCount > 0
+ ? $"客户工单({unreadCount} 条未读消息)"
+ : "客户工单";
+ }
+
+ private void MainWindow_Closed(object sender, EventArgs e)
+ {
+ _ticketMonitor.NewMessageReceived -= OnTicketNewMessageReceived;
+ _ticketMonitor.UnreadCountChanged -= OnTicketUnreadCountChanged;
+ }
+ }
}
diff --git a/29WxOrderWpf/OrderSupportTicketWindow.xaml b/29WxOrderWpf/OrderSupportTicketWindow.xaml
deleted file mode 100644
index c909806..0000000
--- a/29WxOrderWpf/OrderSupportTicketWindow.xaml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
diff --git a/29WxOrderWpf/OrderSupportTicketWindow.xaml.cs b/29WxOrderWpf/OrderSupportTicketWindow.xaml.cs
deleted file mode 100644
index 4da3cd5..0000000
--- a/29WxOrderWpf/OrderSupportTicketWindow.xaml.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Shapes;
-
-namespace _29WxOrderWpf;
-
-///
-/// OrderSupportTicketWindow.xaml 的交互逻辑
-///
-public partial class OrderSupportTicketWindow : Window
-{
- public OrderSupportTicketWindow()
- {
- InitializeComponent();
- }
-}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/AsyncHelpers.cs b/OrderSupportTicket/LaunchDarkly.EventSource/AsyncHelpers.cs
new file mode 100644
index 0000000..340b295
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/AsyncHelpers.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LaunchDarkly.EventSource
+{
+ internal static class AsyncHelpers
+ {
+ // General-purpose timeout logic that uses a timed CancellationToken instead of
+ // Task.Delay. The cancellationToken that is passed in is for explicitly cancelling
+ // the operation from elsewhere; DoWithTimeout wraps this to create a token that can
+ // also be cancelled by a timeout, and passes that token to taskFn.
+ //
+ // This method will only work if taskFn actually responds to the cancellation token
+ // by throwing an OperationCanceledException. If taskFn ignores the token, timeouts
+ // will not happen.
+ internal static async Task DoWithTimeout(
+ TimeSpan timeout,
+ CancellationToken cancellationToken,
+ Func> taskFn
+ )
+ {
+ using (var timeoutCancellation = new CancellationTokenSource(timeout))
+ {
+ using (var combinedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, timeoutCancellation.Token))
+ {
+ Task task = null;
+ try
+ {
+ task = taskFn(combinedCancellation.Token);
+ var result = await task;
+ return result;
+ }
+ catch (AggregateException e) when (e.InnerException is OperationCanceledException)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ throw e.InnerException;
+ }
+ throw new ReadTimeoutException();
+ }
+ catch (OperationCanceledException)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ throw new ReadTimeoutException();
+ }
+ }
+ }
+ // We never need to call SuppressExceptions(task) in this method, because the act
+ // of awaiting the task - whether it completes normally or throws an exception -
+ // makes it "observed". The timeout isn't happening in a different task, it's
+ // causing this task to throw an OperationCanceledException, after which point it
+ // can't generate any other exceptions.
+ }
+
+ // Take a task that can't be terminated early with a CancellationToken, and wrap it in a task
+ // that can. If you cancel the wrapped task, the inner task will still continue, but we're
+ // assuming that in these situations we'll eventually be closing the underlying socket which
+ // will terminate any pending I/O.
+ internal static Task AllowCancellation(Task originalTask, CancellationToken cancellationToken)
+ {
+ var cancellableTask = originalTask.ContinueWith(
+ completedTask => completedTask.GetAwaiter().GetResult(),
+ cancellationToken,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default
+ );
+
+ // Now, if originalTask either completes successfully or fails with an exception, anyone who
+ // was waiting on cancellableTask will get the result or the exception.
+ //
+ // But if cancellationToken gets cancelled, cancellableTask will stop and throw an
+ // OperationCancelledException, just as cancellable I/O operation normally would. The
+ // caller is no longer blocked - even though originalTask may still be executing.
+ //
+ // Normally it would be a problem that originalTask could still be hanging around, but
+ // in our case if things are getting cancelled it means the connection is going to be
+ // closed soon, causing any lingering I/O tasks that use that connection to fail. We
+ // just need to use SuppressExceptions to make sure those failures are discarded rather
+ // than causing an "unobserved exception" situation.
+
+ cancellableTask.ContinueWith(
+ completedTask =>
+ {
+ SuppressExceptions(originalTask);
+ },
+ TaskContinuationOptions.OnlyOnCanceled
+ );
+ return cancellableTask;
+ }
+
+ ///
+ /// Adds a continuation to a task so that if the task throws an uncaught exception, the exception
+ /// is not "unobserved" (which can be a fatal error in some versions of .NET). We must do this
+ /// whenever we're going to discard a task without awaiting it, if there's any possibility that
+ /// it could throw an exception.
+ ///
+ /// a task we are not going to await
+ internal static void SuppressExceptions(Task task)
+ {
+ task.ContinueWith(
+ t =>
+ {
+ // Simply accessing the Exception property makes this exception observed.
+ var e = t.Exception;
+ },
+ TaskContinuationOptions.OnlyOnFaulted
+ );
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ByteArrayLineScanner.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ByteArrayLineScanner.cs
new file mode 100644
index 0000000..7906f29
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ByteArrayLineScanner.cs
@@ -0,0 +1,179 @@
+using System;
+using System.IO;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Internal implementation of a buffered text line parser for UTF-8 data.
+ ///
+ ///
+ ///
+ /// This is used as follows. 1. The caller puts some data into the byte buffer. 2. The caller
+ /// calls ScanToEndOfLine; if it returns true, the lineOut parameter is set to point
+ /// to the content of the line (not including the line ending character(s)). If it returns
+ /// false, put more data into the buffer and try again.
+ ///
+ ///
+ /// Since in UTF-8 all multi-byte characters use values greater than 127 for all of their
+ /// bytes, this logic doesn't need to do any UTF-8 decoding or even know how many bytes are
+ /// in a character; it just looks for the line-ending sequences CR, LF, or CR+LF.
+ ///
+ ///
+ internal struct ByteArrayLineScanner
+ {
+ private readonly byte[] _buffer;
+ private readonly int _capacity;
+ private int _count;
+ private int _startPos;
+
+ public byte[] Buffer => _buffer;
+ public int Capacity => _capacity;
+ public int Count => _count;
+ public int Available => _capacity - _count;
+
+ private MemoryStream _partialLine;
+
+ public ByteArrayLineScanner(int capacity)
+ {
+ _buffer = new byte[capacity];
+ _capacity = capacity;
+ _count = 0;
+ _partialLine = null;
+ _startPos = 0;
+ }
+
+ ///
+ /// The caller calls this method after having already added count more bytes at the
+ /// end of the buffer. We do it this way instead of having an AddBytes(byte[], int)
+ /// method because we don't want to allocate a second buffer just to be the destination for
+ /// a read operation.
+ ///
+ /// number of bytes added
+ public void AddedBytes(int count)
+ {
+ _count += count;
+ }
+
+ ///
+ /// Searches for the next line ending and, if successful, provides the line data.
+ ///
+ /// if successful, this is set to point to the bytes for the line
+ /// not including any CR/LF; whenever possible this is a reference to the underlying
+ /// buffer, not a copy, so the caller should read/copy it before doing anything else to the
+ /// buffer
+ /// true if a full line was read, false if we need more data first
+ public bool ScanToEndOfLine(out Utf8ByteSpan lineOut)
+ {
+ if (_startPos == _count)
+ {
+ _startPos = _count = 0;
+ lineOut = new Utf8ByteSpan();
+ return false;
+ }
+
+ if (_startPos == 0 && _partialLine != null && _partialLine.Position > 0
+ && _partialLine.GetBuffer()[_partialLine.Position - 1] == '\r')
+ {
+ // This is an edge case where the very last byte we previously saw was a CR, and we didn't know
+ // whether the next byte would be LF or not, but we had to dump the buffer into _partialLine
+ // because it was completely full. So, now we can return the line that's already in _partialLine,
+ // but if the first byte in the buffer is LF we should skip past it.
+ if (_buffer[_startPos] == '\n')
+ {
+ _startPos++;
+ }
+ lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0,
+ (int)_partialLine.Position - 1); // don't include the CR
+ _partialLine = null;
+ return true;
+ }
+
+ int startedAt = _startPos, pos = _startPos;
+
+ while (pos < _count)
+ {
+ var b = _buffer[pos];
+ if (b == '\n') // LF by itself terminates a line
+ {
+ _startPos = pos + 1; // next line will start after the LF
+ break;
+ }
+ if (b == '\r')
+ {
+ if (pos < (_count - 1))
+ {
+ _startPos = pos + 1; // next line will start after the CR--
+ if (_buffer[pos + 1] == '\n') // --unless there was an LF right after that
+ {
+ _startPos++;
+ }
+ break;
+ }
+ else
+ {
+ // CR by itself and CR+LF are both valid line endings in SSE, so if the very
+ // last character we saw was a CR, we can't know when the line is fully read
+ // until we've gotten more data. So we'll need to treat this as an incomplete
+ // line.
+ pos++;
+ break;
+ }
+ }
+ pos++;
+ }
+
+ if (pos == _count) // we didn't find a line terminator
+ {
+ lineOut = new Utf8ByteSpan();
+ if (_count < _capacity)
+ {
+ // There's still room in the buffer, so we'll re-scan the line once they add more bytes
+ return false;
+ }
+ // We need to dump the incomplete line into _partialLine so we can make room in the buffer
+ var partialCount = pos - _startPos;
+ if (_partialLine is null)
+ {
+ _partialLine = new MemoryStream(partialCount);
+ }
+ _partialLine.Write(_buffer, _startPos, partialCount);
+
+ // Clear the main buffer
+ _startPos = _count = 0;
+
+ return false;
+ }
+
+ if (_partialLine != null && _partialLine.Position > 0)
+ {
+ _partialLine.Write(_buffer, startedAt, pos - startedAt);
+ lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0, (int)_partialLine.Position);
+ _partialLine = null;
+
+ // If there are still bytes in the main buffer, move them over to make more room. It's
+ // safe for us to do this before the caller has looked at lineOut, because lineOut is now
+ // a reference to the separate _partialLine buffer, not to the main buffer.
+ if (_startPos < _count)
+ {
+ System.Buffer.BlockCopy(_buffer, _startPos, _buffer, 0, _count - _startPos);
+ }
+ _count -= _startPos;
+ _startPos = 0;
+ }
+ else
+ {
+ lineOut = new Utf8ByteSpan(_buffer, startedAt, pos - startedAt);
+ if (_startPos == _count)
+ {
+ // If we've scanned all the data in the buffer, reset _startPos and _count to indicate
+ // that the entire buffer is available for the next read. It's safe for us to do this
+ // before the caller has looked at lineOut, because we're not actually modifying any
+ // bytes in the buffer. It's the caller's responsibility not to modify the buffer
+ // until it has already done whatever needs to be done with the lineOut data.
+ _startPos = _count = 0;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs b/OrderSupportTicket/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs
new file mode 100644
index 0000000..733e7eb
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Provides data recieved in the EventSource event.
+ ///
+ ///
+ public class CommentReceivedEventArgs : EventArgs
+ {
+ ///
+ /// Gets the comment received in the Server Sent Event.
+ ///
+ ///
+ /// The comment.
+ ///
+ public string Comment { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The comment received in the Server Sent Event.
+ public CommentReceivedEventArgs(string comment)
+ {
+ Comment = comment;
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs b/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs
new file mode 100644
index 0000000..5bc1aaa
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// An immutable class containing configuration properties for .
+ ///
+ ///
+ ///
+ public sealed class Configuration
+ {
+ #region Constants
+
+ ///
+ /// The default value for :
+ /// one second.
+ ///
+ public static readonly TimeSpan DefaultInitialRetryDelay = TimeSpan.FromSeconds(1);
+
+ ///
+ /// The default value for :
+ /// 30 seconds.
+ ///
+ public static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30);
+
+ ///
+ /// The default value for :
+ /// 10 seconds.
+ ///
+ public static readonly TimeSpan DefaultResponseStartTimeout = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Obsolete name for .
+ ///
+ [Obsolete("Use DefaultResponseStartTimeout")]
+ public static readonly TimeSpan DefaultConnectionTimeout = DefaultResponseStartTimeout;
+
+ ///
+ /// The default value for :
+ /// 5 minutes.
+ ///
+ public static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromMinutes(5);
+
+ ///
+ /// The default value for :
+ /// one minute.
+ ///
+ public static readonly TimeSpan DefaultBackoffResetThreshold = TimeSpan.FromMinutes(1);
+
+ ///
+ /// The logger name that will be used if you specified a logging implementation but did not
+ /// provide a specific logger instance.
+ ///
+ ///
+ public const string DefaultLoggerName = "EventSource";
+
+ #endregion
+
+ #region Public Properties
+
+ ///
+ /// The amount of time a connection must stay open before the EventSource resets its backoff delay.
+ ///
+ ///
+ /// A connection that stays open for at least this long also discards any temporary bounds set
+ /// by .
+ ///
+ ///
+ public TimeSpan BackoffResetThreshold { get; }
+
+ ///
+ /// Obsolete name for .
+ ///
+ [Obsolete("Use ResponseStartTimeout")]
+ public TimeSpan ConnectionTimeout => ResponseStartTimeout;
+
+ ///
+ /// The HttpClient that will be used as the HTTP client, or null for a new HttpClient.
+ ///
+ ///
+ public HttpClient HttpClient { get; }
+
+ ///
+ /// The HttpMessageHandler that will be used for the HTTP client, or null for the default handler.
+ ///
+ ///
+ public HttpMessageHandler HttpMessageHandler { get; }
+
+ ///
+ /// Delegate hook invoked before an HTTP request has been performed.
+ ///
+ ///
+ public Action HttpRequestModifier { get; }
+
+ ///
+ /// The initial amount of time to wait before attempting to reconnect to the EventSource API.
+ ///
+ ///
+ public TimeSpan InitialRetryDelay { get; }
+
+ ///
+ /// Gets the last event identifier.
+ ///
+ ///
+ public string LastEventId { get; }
+
+ ///
+ /// The logger to be used for all EventSource log output.
+ ///
+ ///
+ /// This is never null; if logging is not configured, it will be LaunchDarkly.Logging.Logs.None.
+ ///
+ ///
+
+ ///
+ /// The maximum amount of time to wait before attempting to reconnect.
+ ///
+ ///
+ public TimeSpan MaxRetryDelay { get; }
+
+ ///
+ /// The HTTP method that will be used when connecting to the EventSource API.
+ ///
+ ///
+ public HttpMethod Method { get; }
+
+ ///
+ /// Whether to use UTF-8 byte arrays internally if possible when reading the stream.
+ ///
+ ///
+ public bool PreferDataAsUtf8Bytes { get; }
+
+ ///
+ /// The timeout when reading from the EventSource API.
+ ///
+ ///
+ public TimeSpan ReadTimeout { get; }
+
+ ///
+ /// A factory for HTTP request body content, if the HTTP method is one that allows a request body.
+ /// is one that allows a request body. This is in the form of a factory function because the request
+ /// may need to be sent more than once.
+ ///
+ ///
+ public Func RequestBodyFactory { get; }
+
+ ///
+ /// The request headers to be sent with each EventSource HTTP request.
+ ///
+ ///
+ ///
+ public IDictionary RequestHeaders { get; }
+
+ ///
+ /// The maximum amount of time to wait between starting an HTTP request and receiving the response
+ /// headers.
+ ///
+ ///
+ public TimeSpan ResponseStartTimeout { get; }
+
+ ///
+ /// Gets the used when connecting to an EventSource API.
+ ///
+ public Uri Uri { get; }
+
+ #endregion
+
+ #region Internal Constructor
+
+ internal Configuration(ConfigurationBuilder builder)
+ {
+ Uri = builder._uri;
+
+
+
+ BackoffResetThreshold = builder._backoffResetThreshold;
+ HttpClient = builder._httpClient;
+ HttpMessageHandler = (builder._httpClient != null) ? null : builder._httpMessageHandler;
+ InitialRetryDelay = builder._initialRetryDelay;
+ LastEventId = builder._lastEventId;
+ MaxRetryDelay = builder._maxRetryDelay;
+ Method = builder._method;
+ PreferDataAsUtf8Bytes = builder._preferDataAsUtf8Bytes;
+ ReadTimeout = builder._readTimeout;
+ RequestHeaders = new Dictionary(builder._requestHeaders);
+ ResponseStartTimeout = builder._responseStartTimeout;
+ RequestBodyFactory = builder._requestBodyFactory;
+ HttpRequestModifier = builder._httpRequestModifier;
+
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Provides a new for constructing a configuration.
+ ///
+ /// the EventSource URI
+ /// a new builder instance
+ /// if the URI is null
+ public static ConfigurationBuilder Builder(Uri uri) =>
+ new ConfigurationBuilder(uri);
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs
new file mode 100644
index 0000000..6915a7a
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs
@@ -0,0 +1,448 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// A standard Builder pattern for constructing a instance.
+ ///
+ ///
+ ///
+ /// Initialize a builder by calling new ConfigurationBuilder(uri) or
+ /// Configuration.Builder(uri). The URI is always required; all other properties
+ /// are set to defaults. Use the builder's setter methods to modify any desired properties;
+ /// setter methods can be chained. Then call Build() to construct the final immutable
+ /// Configuration.
+ ///
+ ///
+ /// All setter methods will throw ArgumentException if called with an invalid value,
+ /// so it is never possible for Build() to fail.
+ ///
+ ///
+ public class ConfigurationBuilder
+ {
+ #region Private Fields
+
+ internal readonly Uri _uri;
+ internal TimeSpan _initialRetryDelay = Configuration.DefaultInitialRetryDelay;
+ internal TimeSpan _backoffResetThreshold = Configuration.DefaultBackoffResetThreshold;
+ internal string _lastEventId;
+ internal IDictionary _requestHeaders = new Dictionary();
+ internal HttpMessageHandler _httpMessageHandler;
+ internal HttpClient _httpClient;
+ internal TimeSpan _maxRetryDelay = Configuration.DefaultMaxRetryDelay;
+ internal HttpMethod _method = HttpMethod.Get;
+ internal bool _preferDataAsUtf8Bytes = false;
+ internal TimeSpan _readTimeout = Configuration.DefaultReadTimeout;
+ internal Func _requestBodyFactory;
+ internal TimeSpan _responseStartTimeout = Configuration.DefaultResponseStartTimeout;
+ internal Action _httpRequestModifier;
+ #endregion
+
+ #region Constructor
+
+ internal ConfigurationBuilder(Uri uri)
+ {
+ if (uri == null)
+ {
+ throw new ArgumentNullException(nameof(uri));
+ }
+ this._uri = uri;
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Constructs a instance based on the current builder properies.
+ ///
+ /// the configuration
+ public Configuration Build() =>
+ new Configuration(this);
+
+ ///
+ /// Obsolete name for .
+ ///
+ /// the timeout
+ /// the builder
+ [Obsolete("Use ResponseStartTimeout")]
+ public ConfigurationBuilder ConnectionTimeout(TimeSpan responseStartTimeout) =>
+ ResponseStartTimeout(responseStartTimeout);
+
+ ///
+ /// Sets a delegate hook invoked before an HTTP request is performed. This may be useful if you
+ /// want to modify some properties of the request that EventSource doesn't already have an option for.
+ ///
+ /// code that will be called with the request before it is sent
+ /// the builder
+ public ConfigurationBuilder HttpRequestModifier(Action httpRequestModifier)
+ {
+ this._httpRequestModifier = httpRequestModifier;
+ return this;
+ }
+
+ ///
+ /// Sets the initial amount of time to wait before attempting to reconnect to the EventSource API.
+ ///
+ ///
+ ///
+ /// If the connection fails more than once, the retry delay will increase from this value using
+ /// a backoff algorithm.
+ ///
+ ///
+ /// A server-directed reconnection time received via the SSE retry: field supersedes
+ /// this value, as do bounds installed by
+ /// .
+ ///
+ ///
+ /// The default value is . Negative values
+ /// are changed to zero.
+ ///
+ ///
+ /// The actual duration of each delay will vary slightly because there is a random jitter
+ /// factor to avoid clients all reconnecting at once.
+ ///
+ ///
+ /// the initial retry delay
+ /// the builder
+ ///
+ public ConfigurationBuilder InitialRetryDelay(TimeSpan initialRetryDelay)
+ {
+ _initialRetryDelay = FiniteTimeSpan(initialRetryDelay);
+ return this;
+ }
+
+ ///
+ /// Sets the maximum amount of time to wait before attempting to reconnect.
+ ///
+ ///
+ ///
+ /// EventSource uses an exponential backoff algorithm (with random jitter) so that
+ /// the delay between reconnections starts at but
+ /// increases with each subsequent attempt. MaxRetryDelay sets a limit on how long
+ /// the delay can be.
+ ///
+ ///
+ /// The default value is . Negative values
+ /// are changed to zero.
+ ///
+ ///
+ /// the maximum retry delay
+ /// the builder
+ ///
+ public ConfigurationBuilder MaxRetryDelay(TimeSpan maxRetryDelay)
+ {
+ _maxRetryDelay = FiniteTimeSpan(maxRetryDelay);
+ return this;
+ }
+
+ ///
+ /// Sets the amount of time a connection must stay open before the EventSource resets its backoff delay.
+ ///
+ ///
+ ///
+ /// If a connection fails before the threshold has elapsed, the delay before reconnecting will be greater
+ /// than the last delay; if it fails after the threshold, the delay will start over at the initial minimum
+ /// value. This prevents long delays from occurring on connections that are only rarely restarted.
+ ///
+ ///
+ /// A connection that stays open for at least this long also discards any bounds set by
+ /// , restoring
+ /// and . A
+ /// server-directed reconnection time received via the SSE retry: field is not
+ /// discarded.
+ ///
+ ///
+ /// The default value is . Negative
+ /// values are changed to zero.
+ ///
+ ///
+ /// the threshold time
+ /// the builder
+ public ConfigurationBuilder BackoffResetThreshold(TimeSpan backoffResetThreshold)
+ {
+ _backoffResetThreshold = FiniteTimeSpan(backoffResetThreshold);
+ return this;
+ }
+
+ ///
+ /// Sets the maximum amount of time EventSource will wait between starting an HTTP request and
+ /// receiving the response headers.
+ ///
+ ///
+ ///
+ /// This is the same as the Timeout property in .NET's HttpClient. The default value is
+ /// .
+ ///
+ ///
+ /// It is not the same as a TCP connection timeout. A connection timeout would include only the
+ /// time of establishing the connection, not the time it takes for the server to prepare the beginning
+ /// of the response. .NET does not consistently support a connection timeout, but if you are using .NET
+ /// Core or .NET 5+ you can implement it by using SocketsHttpHandler as your
+ /// and setting the
+ /// ConnectTimeout property there.
+ ///
+ ///
+ /// the timeout
+ ///
+ public ConfigurationBuilder ResponseStartTimeout(TimeSpan responseStartTimeout)
+ {
+ _responseStartTimeout = TimeSpanCanBeInfinite(responseStartTimeout);
+ return this;
+ }
+
+ ///
+ /// Sets the timeout when reading from the EventSource API.
+ ///
+ ///
+ ///
+ /// The connection will be automatically dropped and restarted if the server sends no data within
+ /// this interval. This prevents keeping a stale connection that may no longer be working. It is common
+ /// for SSE servers to send a simple comment line (":") as a heartbeat to prevent timeouts.
+ ///
+ ///
+ /// The default value is .
+ ///
+ ///
+ public ConfigurationBuilder ReadTimeout(TimeSpan readTimeout)
+ {
+ _readTimeout = TimeSpanCanBeInfinite(readTimeout);
+ return this;
+ }
+
+ ///
+ /// Sets the last event identifier.
+ ///
+ ///
+ /// Setting this value will cause EventSource to add a "Last-Event-ID" header in its HTTP request.
+ /// This normally corresponds to the field of a previously
+ /// received event.
+ ///
+ /// the event identifier
+ /// the builder
+ public ConfigurationBuilder LastEventId(string lastEventId)
+ {
+ _lastEventId = lastEventId;
+ return this;
+ }
+
+ ///
+ /// Sets the logging implementation to be used for all EventSource log output.
+ ///
+ ///
+ ///
+ /// This uses the ILogAdapter abstraction from the LaunchDarkly.Logging library,
+ /// which provides several basic implementations such as Logs.ToConsole and an integration
+ /// with the .NET Core logging framework. For more about this and about adapters to other logging
+ /// frameworks, see LaunchDarkly.Logging.
+ ///
+ ///
+ /// LaunchDarkly.Logging defines logging levels of Debug, Info, Warn, and Error. If you do not
+ /// want detailed Debug-level logging, use the Level() modifier to set a minimum level of Info
+ /// or above, as shown in the code example (unless you are using an adapter to another logging
+ /// framework that has its own way of doing log filtering).
+ ///
+ ///
+ /// Log messages will use as the logger name. If you
+ /// want to specify a different logger name, use .
+ ///
+ ///
+ /// If you don't specify or ,
+ /// EventSource will not do any logging.
+ ///
+ ///
+ ///
+ /// using LaunchDarkly.Logging;
+ ///
+ /// // Send log output to the console (standard error), suppressing Debug messages
+ /// var config = new ConfigurationBuilder(uri).
+ /// LogAdapter(Logs.ToConsole.Level(LogLevel.Info)).
+ /// Build();
+ ///
+ /// a LaunchDarkly.Logging.ILogAdapter
+ /// the builder
+ public ConfigurationBuilder LogAdapter()
+ {
+ return this;
+ }
+
+ ///
+ /// Sets a custom logger to be used for all EventSource log output.
+ ///
+ ///
+ ///
+ /// This uses the Logger type from the LaunchDarkly.Logging library,
+ /// which provides several basic implementations such as Logs.ToConsole and an integration
+ /// with the .NET Core logging framework. For more about this and about adapters to other logging
+ /// frameworks, see LaunchDarkly.Logging.
+ ///
+ ///
+ /// If you don't specify or ,
+ /// EventSource will not do any logging.
+ ///
+ ///
+ ///
+ /// using LaunchDarkly.Logging;
+ ///
+ /// // Define a logger that sends output to the console (standard output), suppressing
+ /// // Debug messages, and using a logger name of "EventStream"
+ /// var logger = Logs.ToConsole.Level(LogLevel.Info).Logger("EventStream");
+ /// var config = new ConfigurationBuilder(uri).
+ /// Logger(logger).
+ /// Build();
+ ///
+ /// a LaunchDarkly.Logging.Logger instance
+ /// the builder
+ public ConfigurationBuilder Logger()
+ {
+ return this;
+ }
+
+ ///
+ /// Specifies whether to use UTF-8 byte arrays internally if possible when
+ /// reading the stream.
+ ///
+ ///
+ /// As described in , in some applications it may be
+ /// preferable to store and process event data as UTF-8 byte arrays rather than
+ /// strings. By default, EventSource will use the string type when
+ /// processing the event stream; if you then use
+ /// to get the data, it will be converted to a byte array as needed. However, if
+ /// you set PreferDataAsUtf8Bytes to , the event data
+ /// will be stored internally as a UTF-8 byte array so that if you read
+ /// , you will get the same array with no
+ /// extra copying or conversion. Therefore, for greatest efficiency you should set
+ /// this to if you intend to process the data as UTF-8. Note
+ /// that Server-Sent Event streams always use UTF-8 encoding, as required by the
+ /// SSE specification.
+ ///
+ /// true if you intend to request the event
+ /// data as UTF-8 bytes
+ /// the builder
+ public ConfigurationBuilder PreferDataAsUtf8Bytes(bool preferDataAsUtf8Bytes)
+ {
+ _preferDataAsUtf8Bytes = preferDataAsUtf8Bytes;
+ return this;
+ }
+
+ ///
+ /// Sets the request headers to be sent with each EventSource HTTP request.
+ ///
+ /// the headers (null is equivalent to an empty dictionary)
+ /// the builder
+ public ConfigurationBuilder RequestHeaders(IDictionary headers)
+ {
+ _requestHeaders = headers is null ? new Dictionary() :
+ new Dictionary(headers);
+ return this;
+ }
+
+ ///
+ /// Adds a request header to be sent with each EventSource HTTP request.
+ ///
+ /// the header name
+ /// the header value
+ /// the builder
+ public ConfigurationBuilder RequestHeader(string name, string value)
+ {
+ if (name != null)
+ {
+ _requestHeaders[name] = value;
+ }
+ return this;
+ }
+
+ ///
+ /// Sets the HttpMessageHandler that will be used for the HTTP client, or null for the default handler.
+ ///
+ ///
+ /// If you have specified a custom HTTP client instance with , then
+ /// is ignored.
+ ///
+ /// the message handler implementation
+ /// the builder
+ public ConfigurationBuilder HttpMessageHandler(HttpMessageHandler handler)
+ {
+ this._httpMessageHandler = handler;
+ return this;
+ }
+
+ ///
+ /// Specifies that EventSource should use a specific HttpClient instance for HTTP requests.
+ ///
+ ///
+ ///
+ /// Normally, EventSource creates its own HttpClient and disposes of it when you dispose of the
+ /// EventSource. If you provide your own HttpClient using this method, you are responsible for
+ /// managing the HttpClient's lifecycle-- EventSource will not dispose of it.
+ ///
+ ///
+ /// EventSource will not modify this client's properties, so if you call
+ /// or , those methods will be ignored.
+ ///
+ ///
+ /// an HttpClient instance, or null to use the default behavior
+ /// the builder
+ public ConfigurationBuilder HttpClient(HttpClient client)
+ {
+ this._httpClient = client;
+ return this;
+ }
+
+ ///
+ /// Sets the HTTP method that will be used when connecting to the EventSource API.
+ ///
+ ///
+ /// By default, this is .
+ ///
+ public ConfigurationBuilder Method(HttpMethod method)
+ {
+ this._method = method ?? throw new ArgumentNullException(nameof(method));
+ return this;
+ }
+
+ ///
+ /// Sets a factory for HTTP request body content, if the HTTP method is one that allows a request body.
+ ///
+ ///
+ /// This is in the form of a factory function because the request may need to be sent more than once.
+ ///
+ /// the factory function, or null for none
+ /// the builder
+ public ConfigurationBuilder RequestBodyFactory(Func factory)
+ {
+ this._requestBodyFactory = factory;
+ return this;
+ }
+
+ ///
+ /// Equivalent , but for content
+ /// that is a simple string.
+ ///
+ /// the content
+ /// the Content-Type header
+ /// the builder
+ public ConfigurationBuilder RequestBody(string bodyString, string contentType)
+ {
+ return RequestBodyFactory(() => new StringContent(bodyString, Encoding.UTF8, contentType));
+ }
+
+ #endregion
+
+ #region Private methods
+
+ // Canonicalizes the value so all negative numbers become InfiniteTimeSpan
+ private static TimeSpan TimeSpanCanBeInfinite(TimeSpan t) =>
+ t < TimeSpan.Zero ? Timeout.InfiniteTimeSpan : t;
+
+ // Replaces all negative times with zero
+ private static TimeSpan FiniteTimeSpan(TimeSpan t) =>
+ t < TimeSpan.Zero ? TimeSpan.Zero : t;
+
+ #endregion
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Constants.cs b/OrderSupportTicket/LaunchDarkly.EventSource/Constants.cs
new file mode 100644
index 0000000..da18b4d
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/Constants.cs
@@ -0,0 +1,53 @@
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// An internal class used to hold static values used when processing Server Sent Events.
+ ///
+ internal static class Constants
+ {
+ ///
+ /// The HTTP header name for Accept.
+ ///
+ internal static string AcceptHttpHeader = "Accept";
+
+ ///
+ /// The HTTP header name for the last event identifier.
+ ///
+ internal static string LastEventIdHttpHeader = "Last-Event-ID";
+
+ ///
+ /// The HTTP header value for the Content Type.
+ ///
+ internal static string EventStreamContentType = "text/event-stream";
+
+ ///
+ /// The event type name for a Retry in a Server Sent Event.
+ ///
+ internal static string RetryField = "retry";
+
+ ///
+ /// The largest reconnection time honored from a Server Sent Event retry field.
+ ///
+ internal const long MaxServerDirectedRetryDelayMillis = 3_600_000;
+
+ ///
+ /// The identifier field name in a Server Sent Event.
+ ///
+ internal static string IdField = "id";
+
+ ///
+ /// The event type field name in a Server Sent Event.
+ ///
+ internal static string EventField = "event";
+
+ ///
+ /// The data field name in a Server Sent Event.
+ ///
+ internal static string DataField = "data";
+
+ ///
+ /// The message field name in a Server Sent Event.
+ ///
+ internal static string MessageField = "message";
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventParser.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventParser.cs
new file mode 100644
index 0000000..1a7082b
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventParser.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// An internal class containing helper methods to parse Server Sent Event data.
+ ///
+ internal static class EventParser
+ {
+ ///
+ /// The result returned by or .
+ ///
+ internal struct Result
+ {
+ internal string FieldName { get; set; } // null means it's a comment
+ internal string ValueString { get; set; }
+ internal Utf8ByteSpan ValueBytes { get; set; }
+
+ internal string GetValueAsString() => ValueString is null ? ValueBytes.GetString() : ValueString;
+
+ internal bool IsComment => FieldName is null;
+ internal bool IsDataField => FieldName == "data";
+ internal bool IsEventField => FieldName == "event";
+ internal bool IsIdField => FieldName == "id";
+ internal bool IsRetryField => FieldName == "retry";
+ }
+
+ ///
+ /// Attempts to parse a single non-empty line of SSE content that was read as a string. Empty lines
+ /// should not be not passed to this method.
+ ///
+ /// a line that was read from the stream, not including any trailing CR/LF
+ /// a containing the parsed field or comment; ValueString will
+ /// be set rather than ValueBytes
+ internal static Result ParseLineString(string line)
+ {
+ var colonPos = line.IndexOf(':');
+ if (colonPos == 0) // comment
+ {
+ return new Result { ValueString = line };
+ }
+ if (colonPos < 0) // field name without a value - assume empty value
+ {
+ return new Result { FieldName = line, ValueString = "" };
+ }
+ int valuePos = colonPos + 1;
+ if (valuePos < line.Length && line[valuePos] == ' ')
+ {
+ valuePos++; // trim a single leading space from the value, if present
+ }
+ return new Result
+ {
+ FieldName = line.Substring(0, colonPos),
+ ValueString = line.Substring(valuePos)
+ };
+ }
+
+ ///
+ /// Attempts to parse a single non-empty line of SSE content that was read as UTF-8 bytes. Empty lines
+ /// should not be not passed to this method.
+ ///
+ /// a line that was read from the stream, not including any trailing CR/LF
+ /// a containing the parsed field or comment; ValueBytes
+ /// will be set rather than ValueString
+ public static Result ParseLineUtf8Bytes(Utf8ByteSpan line)
+ {
+ if (line.Length > 0 && line.Data[line.Offset] == ':') // comment
+ {
+ return new Result { ValueBytes = line };
+ }
+ int colonPos = 0;
+ for (; colonPos < line.Length && line.Data[line.Offset + colonPos] != ':'; colonPos++) { }
+ string fieldName = Encoding.UTF8.GetString(line.Data, line.Offset, colonPos);
+ if (colonPos == line.Length) // field name without a value - assume empty value
+ {
+ return new Result {
+ FieldName = fieldName,
+ ValueBytes = new Utf8ByteSpan()
+ };
+ }
+ int valuePos = colonPos + 1;
+ if (valuePos < line.Length && line.Data[line.Offset + valuePos] == ' ')
+ {
+ valuePos++; // trim a single leading space from the value, if present
+ }
+ return new Result
+ {
+ FieldName = fieldName,
+ ValueBytes = new Utf8ByteSpan(line.Data, line.Offset + valuePos, line.Length - valuePos)
+ };
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs
new file mode 100644
index 0000000..5d1dd7d
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs
@@ -0,0 +1,508 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Provides an EventSource client for consuming Server-Sent Events. Additional details on the Server-Sent Events spec
+ /// can be found at https://html.spec.whatwg.org/multipage/server-sent-events.html
+ ///
+ public class EventSource : IEventSource, IDisposable
+ {
+ #region Private Fields
+
+ private readonly Configuration _configuration;
+ private readonly HttpClient _httpClient;
+
+ private List _eventDataStringBuffer;
+ private MemoryStream _eventDataUtf8ByteBuffer;
+ private string _eventName;
+ private string _lastEventId;
+ private readonly ExponentialBackoffWithDecorrelation _backOff;
+ private readonly TimeSpan _configuredInitialRetryDelay;
+ private readonly TimeSpan _configuredMaxRetryDelay;
+ private CancellationTokenSource _currentRequestToken;
+ private readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource();
+ private static readonly TimeSpan MaxSleepTime = TimeSpan.FromMilliseconds(int.MaxValue);
+ private readonly Stopwatch _connectionTimer = new Stopwatch();
+ private ReadyState _readyState;
+
+ #endregion
+
+ #region Public Events
+
+ ///
+ public event EventHandler Opened;
+
+ ///
+ public event EventHandler Closed;
+
+ ///
+ public event EventHandler MessageReceived;
+
+ ///
+ public event EventHandler CommentReceived;
+
+ ///
+ public event EventHandler Error;
+
+ #endregion Public Events
+
+ #region Public Properties
+
+ ///
+ public ReadyState ReadyState
+ {
+ get
+ {
+ lock (this)
+ {
+ return _readyState;
+ }
+ }
+ private set
+ {
+ lock (this)
+ {
+ _readyState = value;
+ }
+ }
+ }
+
+ internal TimeSpan BackOffDelay
+ {
+ get;
+ private set;
+ }
+
+ // Exposed for tests, so that retry-bounds behavior can be asserted directly
+ internal ExponentialBackoffWithDecorrelation BackOff => _backOff;
+
+ #endregion
+
+ #region Public Constructors
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// the configuration
+ public EventSource(Configuration configuration)
+ {
+ _readyState = ReadyState.Raw;
+
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+
+
+ _configuredInitialRetryDelay = _configuration.InitialRetryDelay;
+ _configuredMaxRetryDelay = _configuration.MaxRetryDelay;
+
+ _backOff = new ExponentialBackoffWithDecorrelation(_configuredInitialRetryDelay,
+ _configuredMaxRetryDelay);
+
+ _httpClient = _configuration.HttpClient ?? CreateHttpClient();
+ }
+
+ ///
+ /// Shortcut for initializing an with only a stream URI
+ /// and no custom properties.
+ ///
+ /// the stream URI
+ /// if the URI is null
+ public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {}
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ public async Task StartAsync()
+ {
+ bool firstTime = true;
+ while (ReadyState != ReadyState.Shutdown)
+ {
+ if (!firstTime)
+ {
+ if (_connectionTimer.IsRunning)
+ {
+ if (_connectionTimer.Elapsed >= _configuration.BackoffResetThreshold)
+ {
+ // Sustained healthy operation reverts any temporary bounds and resets
+ // n. Reverting takes precedence over bounds that were
+ // set during the preceding fault window.
+ ClearTemporaryRetryDelayBounds();
+ _backOff.ResetBackoffN();
+ }
+ _connectionTimer.Reset();
+ }
+ await MaybeWaitWithBackOff();
+ }
+ firstTime = false;
+
+ CancellationTokenSource newRequestTokenSource = null;
+ CancellationToken cancellationToken;
+ lock (this)
+ {
+ if (_readyState == ReadyState.Shutdown)
+ {
+ // in case Close() was called in between the previous ReadyState check and the creation of the new token
+ return;
+ }
+ newRequestTokenSource = new CancellationTokenSource();
+ _currentRequestToken?.Dispose();
+ _currentRequestToken = newRequestTokenSource;
+ }
+
+ if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open)
+ {
+ throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState));
+ }
+
+ SetReadyState(ReadyState.Connecting);
+ cancellationToken = newRequestTokenSource.Token;
+
+ try
+ {
+ await ConnectToEventSourceAsync(cancellationToken);
+
+ // ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled.
+ Close(ReadyState.Closed);
+ }
+ catch (Exception e)
+ {
+ CancelCurrentRequest();
+
+ // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant.
+ if (ReadyState != ReadyState.Shutdown)
+ {
+ Exception realException = e;
+ if (realException is AggregateException ae && ae.InnerExceptions.Count == 1)
+ {
+ realException = ae.InnerException;
+ }
+ if (realException is OperationCanceledException oe)
+ {
+ // This exception could either be the result of us explicitly cancelling a request, in which case we don't
+ // need to do anything else, or it could be that the request timed out.
+ if (oe.CancellationToken.IsCancellationRequested)
+ {
+ realException = null;
+ }
+ else
+ {
+ realException = new TimeoutException();
+ }
+ }
+
+ if (realException != null)
+ {
+ OnError(new ExceptionEventArgs(realException));
+ }
+ Close(ReadyState.Closed);
+ }
+ }
+ }
+ }
+
+ private async Task MaybeWaitWithBackOff() {
+ TimeSpan sleepTime = _backOff.GetNextBackOff();
+ if (sleepTime > MaxSleepTime)
+ {
+ // Task.Delay throws for anything above the platform timer ceiling, and this method
+ // runs outside the reconnect loop's exception handling, so an unclamped value
+ // would fault StartAsync and stop the stream permanently.
+ sleepTime = MaxSleepTime;
+ }
+ if (sleepTime > TimeSpan.Zero)
+ {
+ BackOffDelay = sleepTime;
+ try
+ {
+ await Task.Delay(sleepTime, _shutdownTokenSource.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Cancellation happened during the wait, likely intentional close
+ }
+ }
+ }
+
+ ///
+ public void Restart(bool resetBackoffDelay)
+ {
+ lock (this)
+ {
+ if (_readyState != ReadyState.Open)
+ {
+ return;
+ }
+ if (resetBackoffDelay)
+ {
+ _backOff.ResetBackoffN();
+ }
+ }
+ CancelCurrentRequest();
+ }
+
+ ///
+ public void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay)
+ {
+ _backOff.SetBounds(initialDelay, maxDelay);
+ }
+
+ ///
+ public void ClearTemporaryRetryDelayBounds()
+ {
+ _backOff.SetBounds(_configuredInitialRetryDelay, _configuredMaxRetryDelay);
+ }
+
+ ///
+ /// Closes the connection to the SSE server. The EventSource cannot be reopened after this.
+ ///
+ public void Close()
+ {
+ if (ReadyState != ReadyState.Shutdown)
+ {
+ Close(ReadyState.Shutdown);
+ }
+ CancelCurrentRequest();
+ _shutdownTokenSource.Cancel();
+
+ // do not dispose httpClient if it is user provided
+ if (_configuration.HttpClient == null)
+ {
+ _httpClient.Dispose();
+ }
+ }
+
+ ///
+ /// Equivalent to calling .
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Close();
+ }
+ }
+
+ #endregion
+
+ #region Private Methods
+
+ private HttpClient CreateHttpClient()
+ {
+ var client =_configuration.HttpMessageHandler is null ?
+ new HttpClient() :
+ new HttpClient(_configuration.HttpMessageHandler, false);
+ client.Timeout = _configuration.ResponseStartTimeout;
+ return client;
+ }
+
+ private void CancelCurrentRequest()
+ {
+ CancellationTokenSource requestTokenSource = null;
+ lock (this)
+ {
+ requestTokenSource = _currentRequestToken;
+ _currentRequestToken = null;
+ }
+ if (requestTokenSource != null)
+ {
+ requestTokenSource.Cancel();
+ requestTokenSource.Dispose();
+ }
+ }
+
+ internal virtual EventSourceService GetEventSourceService(Configuration configuration)
+ {
+ return new EventSourceService(configuration, _httpClient);
+ }
+
+ private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken)
+ {
+ _eventDataStringBuffer = null;
+ _eventDataUtf8ByteBuffer = null;
+
+ var svc = GetEventSourceService(_configuration);
+
+ svc.ConnectionOpened += (o, e) => {
+ _connectionTimer.Restart();
+ SetReadyState(ReadyState.Open, OnOpened, e.Headers);
+ };
+ svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); };
+
+ await svc.GetDataAsync(
+ ProcessResponseLineString,
+ ProcessResponseLineUtf8,
+ _lastEventId,
+ cancellationToken
+ );
+ }
+
+ private void Close(ReadyState state)
+ {
+ SetReadyState(state, OnClosed);
+ }
+
+ private void ProcessResponseLineString(string content)
+ {
+ if (content == null)
+ {
+ // StreamReader may emit a null if the stream has been closed; there's nothing to
+ // be done at this level in that case
+ return;
+ }
+ if (content.Length == 0)
+ {
+ DispatchEvent();
+ }
+ else
+ {
+ HandleParsedLine(EventParser.ParseLineString(content));
+ }
+ }
+
+ private void ProcessResponseLineUtf8(Utf8ByteSpan content)
+ {
+ if (content.Length == 0)
+ {
+ DispatchEvent();
+ }
+ else
+ {
+ HandleParsedLine(EventParser.ParseLineUtf8Bytes(content));
+ }
+ }
+
+
+ private void SetReadyState(ReadyState state, Action action = null,
+ IEnumerable>> headers = null)
+ {
+ lock (this)
+ {
+ if (_readyState == state || _readyState == ReadyState.Shutdown)
+ {
+ return;
+ }
+
+ _readyState = state;
+ }
+
+ if (action != null)
+ {
+ action(new StateChangedEventArgs(state, headers));
+ }
+ }
+
+ private void HandleParsedLine(EventParser.Result result)
+ {
+ if (result.IsComment)
+ {
+ OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString()));
+ }
+ else if (result.IsDataField)
+ {
+ if (result.ValueString != null)
+ {
+ if (_eventDataStringBuffer is null)
+ {
+ _eventDataStringBuffer = new List(2);
+ }
+ _eventDataStringBuffer.Add(result.ValueString);
+ _eventDataStringBuffer.Add("\n");
+ }
+ else
+ {
+ if (_eventDataUtf8ByteBuffer is null)
+ {
+ _eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1);
+ }
+ _eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length);
+ _eventDataUtf8ByteBuffer.WriteByte((byte)'\n');
+ }
+ }
+ else if (result.IsEventField)
+ {
+ _eventName = result.GetValueAsString();
+ }
+ else if (result.IsIdField)
+ {
+ _lastEventId = result.GetValueAsString();
+ }
+ else if (result.IsRetryField)
+ {
+ if (long.TryParse(result.GetValueAsString(), out var retry))
+ {
+ // Clamp while the value is still an integer.
+ var millis = Math.Max(0,
+ Math.Min(retry, Constants.MaxServerDirectedRetryDelayMillis));
+ _backOff.SetServerDirectedMinDelay(TimeSpan.FromMilliseconds(millis));
+ }
+ }
+ }
+
+ private void DispatchEvent()
+ {
+ var name = _eventName ?? Constants.MessageField;
+ _eventName = null;
+ MessageEvent message;
+
+ if (_eventDataStringBuffer != null)
+ {
+ if (_eventDataStringBuffer.Count == 0)
+ {
+ return;
+ }
+ // remove last item which is always a trailing newline
+ _eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1);
+ var dataString = string.Concat(_eventDataStringBuffer);
+ message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri);
+
+ _eventDataStringBuffer.Clear();
+ }
+ else
+ {
+ if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0)
+ {
+ return;
+ }
+ var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0,
+ (int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline
+ message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri);
+
+ // We've now taken ownership of the original buffer; null out the previous
+ // reference to it so a new one will be created next time
+ _eventDataUtf8ByteBuffer = null;
+ }
+
+ OnMessageReceived(new MessageReceivedEventArgs(message));
+ }
+
+ private void OnOpened(StateChangedEventArgs e) =>
+ Opened?.Invoke(this, e);
+
+ private void OnClosed(StateChangedEventArgs e) =>
+ Closed?.Invoke(this, e);
+
+ private void OnMessageReceived(MessageReceivedEventArgs e) =>
+ MessageReceived?.Invoke(this, e);
+
+ private void OnCommentReceived(CommentReceivedEventArgs e) =>
+ CommentReceived?.Invoke(this, e);
+
+ private void OnError(ExceptionEventArgs e) =>
+ Error?.Invoke(this, e);
+
+ #endregion
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceOpenedEventArgs.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceOpenedEventArgs.cs
new file mode 100644
index 0000000..04d7c06
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceOpenedEventArgs.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Event arguments for the EventSourceService opened event.
+ ///
+ internal class EventSourceOpenedEventArgs: EventArgs
+ {
+ ///
+ /// Construct new event arguments.
+ ///
+ /// headers from the HTTP response
+ public EventSourceOpenedEventArgs(IEnumerable>> headers)
+ {
+ Headers = headers;
+ }
+
+ ///
+ /// Response headers from the underlying HTTP request.
+ ///
+ public IEnumerable>> Headers { get; }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs
new file mode 100644
index 0000000..4b21710
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs
@@ -0,0 +1,281 @@
+using System;
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+
+using static LaunchDarkly.EventSource.AsyncHelpers;
+
+namespace LaunchDarkly.EventSource
+{
+ internal class EventSourceService
+ {
+ #region Private Fields
+
+ // Must be >= 4096 to bypass Xamarin.Android.Net.AndroidMessageHandler's
+ // BufferedStream-4096 small-count code path, which stalls SSE reads for
+ // ~60 s waiting for the next server keepalive. See SDK-2755.
+ private const int ReadBufferSize = 8192;
+
+ private readonly Configuration _configuration;
+ private readonly HttpClient _httpClient;
+
+ private const string UserAgentProduct = "DotNetClient";
+ internal static readonly string UserAgentVersion = ((AssemblyInformationalVersionAttribute)typeof(EventSource)
+ .GetTypeInfo()
+ .Assembly
+ .GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute)))
+ .InformationalVersion;
+
+ #endregion
+
+ #region Public Events
+
+ ///
+ /// Occurs when the connection to the EventSource API has been opened.
+ ///
+ public event EventHandler ConnectionOpened;
+ ///
+ /// Occurs when the connection to the EventSource API has been closed.
+ ///
+ public event EventHandler ConnectionClosed;
+
+ #endregion
+
+ #region Constructors
+
+ internal EventSourceService(Configuration configuration, HttpClient httpClient)
+ {
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Initiates the request to the EventSource API and parses Server Sent Events received by the API.
+ ///
+ /// A A task that represents the work queued to execute in the ThreadPool.
+ public async Task GetDataAsync(
+ Action processResponseLineString,
+ Action processResponseLineUTF8,
+ string lastEventId,
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await ConnectToEventSourceApi(processResponseLineString, processResponseLineUTF8, lastEventId, cancellationToken);
+ }
+
+ #endregion
+
+ #region Private Methods
+
+ private async Task ConnectToEventSourceApi(
+ Action processResponseLineString,
+ Action processResponseLineUTF8,
+ string lastEventId,
+ CancellationToken cancellationToken
+ )
+ {
+
+ var request = CreateHttpRequestMessage(_configuration.Uri, lastEventId);
+
+ this._configuration.HttpRequestModifier?.Invoke(request);
+
+ using (var response = await _httpClient.SendAsync(request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken).ConfigureAwait(false))
+ {
+ ValidateResponse(response);
+
+ using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
+ {
+ var encoding = DetectEncoding(response);
+ if (encoding != Encoding.UTF8)
+ {
+ throw new EventSourceServiceCancelledException(
+ string.Format(Resources.ErrorWrongEncoding, encoding.HeaderName));
+ }
+
+ OnConnectionOpened(response.Headers);
+
+ if (_configuration.PreferDataAsUtf8Bytes)
+ {
+ await ProcessResponseFromUtf8StreamAsync(processResponseLineUTF8, stream, cancellationToken);
+ }
+ else
+ {
+ using (var reader = new StreamReader(stream, Encoding.UTF8,
+ detectEncodingFromByteOrderMarks: true, bufferSize: ReadBufferSize))
+ {
+ await ProcessResponseFromReaderAsync(processResponseLineString, reader, cancellationToken);
+ }
+ }
+ }
+
+ OnConnectionClosed();
+ }
+ }
+
+ private Encoding DetectEncoding(HttpResponseMessage response)
+ {
+ var charset = response.Content.Headers.ContentType?.CharSet;
+ if (charset != null)
+ {
+ try
+ {
+ return Encoding.GetEncoding(charset);
+ }
+ catch (ArgumentException) { }
+ }
+ return Encoding.UTF8;
+ }
+
+ protected virtual async Task ProcessResponseFromReaderAsync(
+ Action processResponse,
+ StreamReader reader,
+ CancellationToken cancellationToken
+ )
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var line = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken,
+ token => AllowCancellation(reader.ReadLineAsync(), token));
+ if (line == null)
+ {
+ // this means the stream is done, i.e. the connection was closed
+ return;
+ }
+ processResponse(line);
+ }
+ }
+
+ protected async Task ProcessResponseFromUtf8StreamAsync(
+ Action processResponseLine,
+ Stream stream,
+ CancellationToken cancellationToken
+ )
+ {
+ var lineScanner = new ByteArrayLineScanner(ReadBufferSize);
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ // Note that even though Stream.ReadAsync has an overload that takes a CancellationToken, that
+ // does not actually work for network sockets (https://stackoverflow.com/questions/12421989/networkstream-readasync-with-a-cancellation-token-never-cancels).
+ // So we must use AsyncHelpers.AllowCancellation to wrap it in a cancellable task.
+ int bytesRead = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken,
+ token => AllowCancellation(stream.ReadAsync(lineScanner.Buffer, lineScanner.Count, lineScanner.Available), token));
+ if (bytesRead == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return;
+ }
+ lineScanner.AddedBytes(bytesRead);
+ while (lineScanner.ScanToEndOfLine(out var lineSpan))
+ {
+ processResponseLine(lineSpan);
+ }
+ }
+ }
+
+ private HttpRequestMessage CreateHttpRequestMessage(Uri uri, string lastEventId)
+ {
+ var request = new HttpRequestMessage(_configuration.Method ?? HttpMethod.Get, uri);
+
+ // Add all headers provided in the Configuration Headers. This allows a consumer to provide any request headers to the EventSource API
+ if (_configuration.RequestHeaders != null)
+ {
+ foreach (var item in _configuration.RequestHeaders)
+ {
+ try
+ {
+ request.Headers.Add(item.Key, item.Value);
+ }
+ catch (FormatException)
+ {
+ // Avoid showing the Authorization header value in the exception message
+ if (item.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new FormatException("The Authorization header is invalid.");
+ }
+
+ throw;
+ }
+ }
+ }
+
+ // Add the request body, if any.
+ if (_configuration.RequestBodyFactory != null)
+ {
+ HttpContent requestBody = _configuration.RequestBodyFactory();
+ if (requestBody != null)
+ {
+ request.Content = requestBody;
+ }
+ }
+
+ // If the lastEventId was provided, include it as a header to the API request.
+ if (!string.IsNullOrWhiteSpace(lastEventId))
+ {
+ request.Headers.Remove(Constants.LastEventIdHttpHeader);
+ request.Headers.Add(Constants.LastEventIdHttpHeader, lastEventId);
+ }
+
+ // If we haven't set the LastEventId header and if the EventSource Configuration was provided with a LastEventId,
+ // include it as a header to the API request.
+ if (!string.IsNullOrWhiteSpace(_configuration.LastEventId) && !request.Headers.Contains(Constants.LastEventIdHttpHeader))
+ request.Headers.Add(Constants.LastEventIdHttpHeader, _configuration.LastEventId);
+
+ if (request.Headers.UserAgent.Count == 0)
+ request.Headers.UserAgent.ParseAdd(UserAgentProduct + "/" + UserAgentVersion);
+
+ // Add the Accept Header if it wasn't provided in the Configuration
+ if (!request.Headers.Contains(Constants.AcceptHttpHeader))
+ request.Headers.Add(Constants.AcceptHttpHeader, Constants.EventStreamContentType);
+
+ request.Headers.ExpectContinue = false;
+ request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
+
+ return request;
+ }
+
+ private void ValidateResponse(HttpResponseMessage response)
+ {
+ // Any non-2xx response status is an error. A 204 (no content) is also an error.
+ if (!response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent)
+ {
+ throw new EventSourceServiceUnsuccessfulResponseException((int)response.StatusCode, response.Headers);
+ }
+
+ if (response.Content == null)
+ {
+ throw new EventSourceServiceCancelledException(Resources.ErrorEmptyResponse);
+ }
+
+ if (response.Content.Headers.ContentType.MediaType != Constants.EventStreamContentType)
+ {
+ throw new EventSourceServiceCancelledException(
+ string.Format(Resources.ErrorWrongContentType, response.Content.Headers.ContentType));
+ }
+ }
+
+ private void OnConnectionOpened(HttpHeaders headers)
+ {
+ ConnectionOpened?.Invoke(this, new EventSourceOpenedEventArgs(headers));
+ }
+
+ private void OnConnectionClosed()
+ {
+ ConnectionClosed?.Invoke(this, EventArgs.Empty);
+ }
+
+ #endregion
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs
new file mode 100644
index 0000000..dd8fdbd
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// General superclass for exceptions that caused the EventSource to disconnect or fail to establish
+ /// a connection.
+ ///
+ public class EventSourceServiceCancelledException : Exception
+ {
+
+ #region Public Constructors
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// the exception message
+ public EventSourceServiceCancelledException(string message) : base(message) { }
+
+ ///
+ /// Creates a new instance with an inner exception.
+ ///
+ /// the exception message
+ /// the inner exception
+ public EventSourceServiceCancelledException(string message, Exception innerException) :
+ base(message, innerException)
+ { }
+
+ #endregion
+
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs
new file mode 100644
index 0000000..ebd2ecf
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Indicates that the EventSource was able to establish an HTTP connection, but received a
+ /// non-successful status code.
+ ///
+ public class EventSourceServiceUnsuccessfulResponseException : EventSourceServiceCancelledException
+ {
+ #region Public Properties
+
+ ///
+ /// The HTTP status code of the response.
+ ///
+ public int StatusCode { get; }
+
+ ///
+ /// The HTTP headers from the response.
+ ///
+ public IEnumerable>> Headers { get; }
+
+ #endregion
+
+ #region Public Constructors
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// the HTTP status code of the response
+ public EventSourceServiceUnsuccessfulResponseException(int statusCode) :
+ this(statusCode, new Dictionary>())
+ {
+ }
+
+ ///
+ /// Creates a new instance with headers.
+ ///
+ /// the HTTP status code of the response
+ /// the HTTP headers from the response
+ public EventSourceServiceUnsuccessfulResponseException(int statusCode, IEnumerable>> headers) :
+ base(string.Format(Resources.ErrorHttpStatus, statusCode))
+ {
+ StatusCode = statusCode;
+ Headers = headers ?? new Dictionary>();
+ }
+
+ #endregion
+
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ExceptionEventArgs.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ExceptionEventArgs.cs
new file mode 100644
index 0000000..301a85d
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ExceptionEventArgs.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Provides exception data raised in the EventSource event.
+ ///
+ ///
+ public class ExceptionEventArgs : EventArgs
+ {
+ ///
+ /// Gets the that represents the error that occurred.
+ ///
+ ///
+ /// The exception.
+ ///
+ public Exception Exception { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A that represents the error that occurred.
+ public ExceptionEventArgs(Exception ex)
+ {
+ Exception = ex;
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs
new file mode 100644
index 0000000..c4f4908
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs
@@ -0,0 +1,206 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ internal sealed class ExponentialBackoffWithDecorrelation
+ {
+ private readonly object _lock = new object();
+ private readonly Random _randomSource;
+
+ private TimeSpan _minimumDelay;
+ private TimeSpan _maximumDelay;
+ private int _backoffN;
+ private TimeSpan? _serverDirectedMinDelay;
+
+ public ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay)
+ : this(minimumDelay, maximumDelay, new Random())
+ {
+ }
+
+ ///
+ /// Constructs an instance with a caller-supplied , so that tests can
+ /// make jitter deterministic.
+ ///
+ internal ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay,
+ Random randomSource)
+ {
+ _minimumDelay = NonNegative(minimumDelay);
+ _maximumDelay = NonNegative(maximumDelay);
+ _randomSource = randomSource ?? new Random();
+ }
+
+ ///
+ /// Gets the next backoff duration, advancing n.
+ ///
+ public TimeSpan GetNextBackOff()
+ {
+ lock (_lock)
+ {
+ long unjittered = GetUnjitteredMillisecondsForN(_backoffN);
+
+ // Random.Next takes an int bound. 2^31 milliseconds is far longer than any
+ // reconnect delay we would use, so saturating here cannot affect a realistic
+ // delay.
+ int jitterBound = (int)Math.Min(unjittered, int.MaxValue);
+ long delayMillis = unjittered / 2
+ + (jitterBound > 0 ? _randomSource.Next(jitterBound) / 2 : 0);
+
+ _backoffN++;
+ return TimeSpan.FromMilliseconds(delayMillis);
+ }
+ }
+
+ ///
+ /// Returns the delay in whole milliseconds for a given backoff n, before jitter is
+ /// applied: minimumDelay * 2^n, limited to the maximum.
+ ///
+ ///
+ ///
+ /// The caller must hold the lock. This reads the bounds without taking it because it is the
+ /// inner core of , which already holds it; taking it here would
+ /// only be a re-entrant acquisition on the hot path. Anything needing a synchronized read of
+ /// the bounds on its own should use the accessors below, which take the lock themselves.
+ ///
+ ///
+ /// n is compared against the ceiling rather than used to compute a value that is
+ /// then clamped, so no intermediate ever exceeds the maximum. That matters because
+ /// n is unbounded: a long outage keeps incrementing it, and computing
+ /// minimumDelay * 2^n directly would overflow.
+ ///
+ ///
+ internal long GetUnjitteredMillisecondsForN(int n)
+ {
+ long min = (_serverDirectedMinDelay ?? _minimumDelay).Ticks
+ / TimeSpan.TicksPerMillisecond;
+ long max = _maximumDelay.Ticks / TimeSpan.TicksPerMillisecond;
+
+ if (min <= 0 || max <= 0)
+ {
+ return 0;
+ }
+ if (n <= 0)
+ {
+ return min > max ? max : min;
+ }
+
+ // Shifting the ceiling down cannot overflow, whereas shifting the minimum up can.
+ // The n >= 63 test is required rather than defensive: C# masks a 64-bit shift count
+ // to its low 6 bits, so `max >> 64` would silently mean `max >> 0`.
+ if (n >= 63 || min > (max >> n))
+ {
+ return max;
+ }
+ return min << n;
+ }
+
+ ///
+ /// Applies a server-directed reconnection time received via the SSE retry: field,
+ /// replacing the minimum delay used to compute subsequent backoffs.
+ ///
+ ///
+ ///
+ /// The override takes precedence over both the configured minimum and any minimum
+ /// installed by . It is sticky: it survives bounds changes and
+ /// , and is replaced only by a later server-directed value.
+ ///
+ ///
+ /// The maximum is never affected, so the ceiling that bounds reconnection keeps applying.
+ ///
+ ///
+ /// n resets, so the next attempt uses the directed value itself rather than continuing to
+ /// double from wherever the progression had reached.
+ ///
+ ///
+ /// Negative values become zero. No upper bound is applied here; a caller taking a value
+ /// from an untrusted source is responsible for capping it first.
+ ///
+ ///
+ /// the server-directed reconnection time
+ public void SetServerDirectedMinDelay(TimeSpan minDelay)
+ {
+ var value = NonNegative(minDelay);
+ lock (_lock)
+ {
+ _serverDirectedMinDelay = value;
+ _backoffN = 0;
+ }
+ }
+
+ ///
+ /// Replaces the delay bounds.
+ ///
+ ///
+ /// If the new bounds differ from the current ones, n resets so that the
+ /// next delay starts from the new minimum. If they are the same, this does nothing, which
+ /// makes repeated calls with identical values idempotent -- a caller that reapplies the
+ /// same bounds on every failure will not pin the delay at the minimum.
+ ///
+ /// the new minimum delay; negative values become zero
+ /// the new maximum delay; negative values become zero
+ public void SetBounds(TimeSpan minimumDelay, TimeSpan maximumDelay)
+ {
+ var min = NonNegative(minimumDelay);
+ var max = NonNegative(maximumDelay);
+ lock (_lock)
+ {
+ if (min == _minimumDelay && max == _maximumDelay)
+ {
+ return;
+ }
+ _minimumDelay = min;
+ _maximumDelay = max;
+ _backoffN = 0;
+ }
+ }
+
+ ///
+ /// Resets n so the next delay starts from the current minimum, without
+ /// changing the bounds.
+ ///
+ public void ResetBackoffN()
+ {
+ lock (_lock)
+ {
+ _backoffN = 0;
+ }
+ }
+
+ // Accessors for tests.
+
+ internal int GetBackoffN()
+ {
+ lock (_lock)
+ {
+ return _backoffN;
+ }
+ }
+
+ internal TimeSpan? GetServerDirectedMinDelay()
+ {
+ lock (_lock)
+ {
+ return _serverDirectedMinDelay;
+ }
+ }
+
+ internal TimeSpan GetMinimumDelay()
+ {
+ lock (_lock)
+ {
+ return _minimumDelay;
+ }
+ }
+
+ internal TimeSpan GetMaximumDelay()
+ {
+ lock (_lock)
+ {
+ return _maximumDelay;
+ }
+ }
+
+ // Matches the clamping behavior of ConfigurationBuilder's retry-delay setters: a negative
+ // duration becomes zero rather than throwing.
+ private static TimeSpan NonNegative(TimeSpan t) => t < TimeSpan.Zero ? TimeSpan.Zero : t;
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/IEventSource.cs b/OrderSupportTicket/LaunchDarkly.EventSource/IEventSource.cs
new file mode 100644
index 0000000..4ec4dba
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/IEventSource.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Threading.Tasks;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// This interface defines the public members of .
+ ///
+ public interface IEventSource
+ {
+ #region Public Events
+
+ ///
+ /// Occurs when the connection to the EventSource API has been opened.
+ ///
+ event EventHandler Opened;
+ ///
+ /// Occurs when the connection to the EventSource API has been closed.
+ ///
+ event EventHandler Closed;
+ ///
+ /// Occurs when a Server Sent Event from the EventSource API has been received.
+ ///
+ event EventHandler MessageReceived;
+ ///
+ /// Occurs when a comment has been received from the EventSource API.
+ ///
+ event EventHandler CommentReceived;
+ ///
+ /// Occurs when an error has happened when the EventSource is open and processing Server Sent Events.
+ ///
+ event EventHandler Error;
+
+ #endregion Public Events
+
+ #region Public Properties
+
+ ///
+ /// Gets the state of the EventSource connection.
+ ///
+ ReadyState ReadyState { get; }
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Initiates a connection to the SSE server and begins parsing events.
+ ///
+ /// a that will be completed only when the
+ /// EventSource is closed
+ /// if the method was called again after the
+ /// stream connection was already active
+ Task StartAsync();
+
+ ///
+ /// Triggers the same "close and retry" behavior as if an error had been encountered on the stream.
+ ///
+ ///
+ ///
+ /// If the stream is currently active, this closes the connection, waits for some amount of time
+ /// as determined by the usual backoff behavior (and ), and
+ /// then attempts to reconnect. If the stream is not yet connected, is already waiting to
+ /// reconnect, or has been permanently shut down, this has no effect.
+ ///
+ ///
+ /// The method returns immediately without waiting for the reconnection to happen. You will
+ /// receive and events when it does happen (or an
+ /// event if the new connection attempt fails).
+ ///
+ ///
+ /// true if the delay before reconnection should be reset to
+ /// the lowest level of the currently active bounds, which could be the temporary bounds if
+ /// is in effect; false if it
+ /// should increase according to the usual exponential backoff logic
+ void Restart(bool resetBackoffDelay);
+
+ ///
+ /// Temporarily replaces the bounds used to compute reconnection delays.
+ ///
+ ///
+ ///
+ /// The configured and
+ /// normally govern the
+ /// exponential backoff between reconnection attempts. This method installs different
+ /// bounds at runtime, so a caller can slow reconnection down without reconfiguring or
+ /// recreating the EventSource.
+ ///
+ ///
+ /// The bounds are temporary: they remain in effect until either a connection stays
+ /// open for at least
+ /// , at which point the
+ /// configured bounds are restored automatically, or
+ /// is called.
+ ///
+ ///
+ /// Changing the bounds resets the backoff level, so the next delay is computed from the new
+ /// rather than continuing to grow from where it left off.
+ /// Calling this with bounds that are already in effect does nothing, so it is safe to call
+ /// repeatedly.
+ ///
+ ///
+ /// Negative values are changed to zero. If is greater than
+ /// , delays are limited to . No
+ /// argument causes this method to throw, and no argument can cause a later reconnect to
+ /// fail.
+ ///
+ ///
+ /// A server-directed reconnection time received via the SSE retry: field supersedes
+ /// , and the configured
+ /// along with it. Such a
+ /// value does not affect , so the ceiling set here continues to
+ /// bound reconnection.
+ ///
+ ///
+ /// It is safe to call this from any thread, including from an
+ /// handler.
+ ///
+ ///
+ /// the lowest delay to use while these bounds are in effect,
+ /// unless a server-directed retry: value supersedes it
+ /// the highest delay to use while these bounds are in effect
+ ///
+ void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay);
+
+ ///
+ /// Discards any bounds installed by
+ /// , restoring the configured
+ /// and, unless a server-directed
+ /// retry: value is in effect, the configured
+ /// .
+ ///
+ ///
+ ///
+ /// Restoring the configured bounds resets the backoff level. If no temporary bounds
+ /// are in effect this does nothing. The count of reconnection attempts is not reset.
+ /// Calling this is not required as the configured bounds are also restored automatically
+ /// after a connection stays open for
+ /// .
+ ///
+ ///
+ /// A server-directed reconnection time received via the SSE retry: field is not
+ /// discarded and continues to supersede
+ /// , so the configured
+ /// minimum may not be the one actually in use afterwards. Only a later retry: value
+ /// replaces it. is unaffected
+ /// and is always restored.
+ ///
+ ///
+ ///
+ void ClearTemporaryRetryDelayBounds();
+
+ ///
+ /// Closes the connection to the SSE server. The EventSource cannot be reopened after this.
+ ///
+ void Close();
+
+ #endregion
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/MessageEvent.cs b/OrderSupportTicket/LaunchDarkly.EventSource/MessageEvent.cs
new file mode 100644
index 0000000..a1c7332
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/MessageEvent.cs
@@ -0,0 +1,224 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Represents the Server-Sent Event message received from a stream.
+ ///
+ ///
+ ///
+ /// An SSE event consists of an event name (defaulting to "message" if not specified),
+ /// a data string, and an optional "ID" string that the server may provide.
+ ///
+ ///
+ /// The event name and ID properties are always stored as strings. By default, the
+ /// data property is also stored as a string. However, in some applications, it may
+ /// be desirable to represent the data as a UTF-8 byte array (for instance, if you are
+ /// using the System.Text.Json API to parse JSON data).
+ ///
+ ///
+ /// Since strings in .NET use two-byte UTF-16 characters, if you have a large block of
+ /// UTF-8 data it is considerably more efficient to process it in its original form
+ /// rather than converting it to or from a string. stores
+ /// data as strings by default, but you set
+ /// it can store the raw UTF-8 data instead. In either case, MessageEvent will
+ /// convert types transparently so that you can read either
+ /// or .
+ ///
+ ///
+ public struct MessageEvent
+ {
+ ///
+ /// The default value of if the SSE stream did not specify an
+ /// event: field.
+ ///
+ public const string DefaultName = "message";
+
+ #region Private Fields
+
+ private readonly string _name;
+ private readonly string _dataString;
+ private readonly Utf8ByteSpan _dataUtf8Bytes;
+ private readonly string _lastEventId;
+ private readonly Uri _origin;
+
+ #endregion
+
+ #region Public Constructors
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// the event name
+ /// the data received in the server-sent event
+ /// the last event identifier, or null
+ /// the origin URI of the stream
+ public MessageEvent(string name, string data, string lastEventId, Uri origin)
+ {
+ _name = name;
+ _dataString = data;
+ _dataUtf8Bytes = new Utf8ByteSpan();
+ _lastEventId = lastEventId;
+ _origin = origin;
+ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// providing the data as a UTF-8 byte span.
+ ///
+ /// the event name
+ /// the data received in the server-sent event;
+ /// the MessageEvent will store a reference to the byte array, rather than
+ /// copying it, so it should not be modified afterward by the caller
+ ///
+ /// the last event identifier, or null
+ /// the origin URI of the stream
+ public MessageEvent(string name, Utf8ByteSpan dataUtf8Bytes, string lastEventId, Uri origin)
+ {
+ _name = name;
+ _dataString = null;
+ _dataUtf8Bytes = dataUtf8Bytes;
+ _lastEventId = lastEventId;
+ _origin = origin;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// the event name
+ /// the data received in the server-sent event
+ /// the origin URI of the stream
+ ///
+ /// The will be initialized to null.
+ ///
+ public MessageEvent(string name, string data, Uri origin) : this(name, data, null, origin) { }
+
+ #endregion
+
+ #region Public Properties
+
+ ///
+ /// The event name.
+ ///
+ ///
+ /// This can be specified by the server in the event: field in the SSE data, as in
+ /// event: my-event-name. If there is no event: field, the default name
+ /// is .
+ ///
+ public string Name => _name;
+
+ ///
+ /// Gets the data received in the event as a string.
+ ///
+ ///
+ ///
+ /// This is the value of the data: field in the SSE data; if there are multiple
+ /// data: lines for a single event, they are concatenated with "\n".
+ ///
+ ///
+ /// If the data was originally stored as a string, the same string is returned.
+ /// If it was stored as a UTF-8 byte array, the bytes are copied to a new string.
+ ///
+ ///
+ public string Data => _dataString is null ? _dataUtf8Bytes.GetString() : _dataString;
+
+ ///
+ /// Gets the data received in the event as a UTF-8 byte span.
+ ///
+ ///
+ ///
+ /// This is the value of the data: field in the SSE data; if there are multiple
+ /// data: lines for a single event, they are concatenated with "\n".
+ ///
+ ///
+ /// If the data was originally stored as UTF-8 bytes, the returned value refers to
+ /// the same array, offset, and length (it is the caller's responsibility not to
+ /// modify the byte array). If it was originally stored as a string, the string
+ /// is copied to a new byte array.
+ ///
+ ///
+ public Utf8ByteSpan DataUtf8Bytes => _dataString is null ? _dataUtf8Bytes :
+ new Utf8ByteSpan(_dataString);
+
+ ///
+ /// Gets the last event identifier received in the server-sent event.
+ ///
+ ///
+ /// This is the value of the id: field in the SSE data. If there is no such
+ /// field, it is null. You can use a previously received
+ /// value with when starting
+ /// a new to tell the server what the last event you
+ /// received was, although not all servers support this.
+ ///
+ public string LastEventId => _lastEventId;
+
+ ///
+ /// Gets the origin URI of the stream that generated the server-sent event.
+ ///
+ public Uri Origin => _origin;
+
+ ///
+ /// True if the event data is stored internally as UTF-8 bytes.
+ ///
+ ///
+ /// The data can be accessed with either or
+ /// regardless of the value of this property. The property only indicates the original
+ /// format of the data, so, for instance, if it is then
+ /// reading will have more overhead (due to copying) than
+ /// reading .
+ ///
+ public bool IsDataUtf8Bytes => _dataString is null;
+
+ #endregion
+
+ #region Public Methods
+
+ ///
+ /// Determines whether the specified is equal to this instance.
+ ///
+ ///
+ /// This method is potentially inefficient and should be used only in testing.
+ ///
+ /// the to compare with this instance
+ /// if the instances are equal
+ public override bool Equals(object obj)
+ {
+ if (!(obj is MessageEvent that) ||
+ !object.Equals(Name, that.Name) ||
+ !object.Equals(LastEventId, that.LastEventId) ||
+ !object.Equals(Origin, that.Origin))
+ {
+ return false;
+ }
+ if (this._dataString != null)
+ {
+ return that._dataString != null ?
+ this._dataString == that._dataString :
+ that._dataUtf8Bytes.Equals(this._dataString);
+
+ }
+ return that._dataString != null ?
+ this._dataUtf8Bytes.Equals(that._dataString) :
+ this._dataUtf8Bytes.Equals(that._dataUtf8Bytes);
+ }
+
+ ///
+ /// Returns a hash code for this instance.
+ ///
+ ///
+ /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.
+ /// This method is potentially inefficient and should be used only in testing.
+ ///
+ public override int GetHashCode()
+ {
+ int hash = 17;
+
+ hash = hash * 31 + (_dataString != null ? _dataString.GetHashCode() : _dataUtf8Bytes.GetString().GetHashCode());
+ hash = hash * 31 + (_lastEventId != null ? _lastEventId.GetHashCode() : 0);
+ hash = hash * 31 + (_origin != null ? _origin.GetHashCode() : 0);
+ return hash;
+ }
+
+ #endregion
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs b/OrderSupportTicket/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs
new file mode 100644
index 0000000..43662e8
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// The parameter type for a event handler.
+ ///
+ ///
+ public class MessageReceivedEventArgs : EventArgs
+ {
+ ///
+ /// A representing the event that was received from the
+ /// SSE stream.
+ ///
+ public MessageEvent Message { get; }
+
+ ///
+ /// Shortcut for getting the property of the event.
+ ///
+ public string EventName => Message.Name;
+
+ ///
+ /// Initializes a new instance.
+ ///
+ /// the received from the stream
+ public MessageReceivedEventArgs(MessageEvent message)
+ {
+ Message = message;
+ }
+ }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ReadTimeoutException.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ReadTimeoutException.cs
new file mode 100644
index 0000000..ef27b57
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ReadTimeoutException.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// An exception that indicates that the configured read timeout elapsed without receiving
+ /// any new data from the server.
+ ///
+ ///
+ /// Socket connections can fail silently, in which case an EventSource client without a read
+ /// timeout would hang forever waiting for new data. A read timeout allows you to make a new
+ /// stream connection in this case. The server can send periodic comment lines (":\n") to
+ /// keep the client from timing out if the connection is still working.
+ ///
+ public class ReadTimeoutException : Exception
+ {
+ ///
+ public override string Message => Resources.ErrorReadTimeout;
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ReadyState.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ReadyState.cs
new file mode 100644
index 0000000..def4357
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/ReadyState.cs
@@ -0,0 +1,29 @@
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Represents the state of the connection in the class.
+ ///
+ public enum ReadyState
+ {
+ ///
+ /// The initial state of the connection.
+ ///
+ Raw,
+ ///
+ /// The connection has not yet been established, or it was closed and is reconnecting.
+ ///
+ Connecting,
+ ///
+ /// The connection is open and is processing events as it receives them.
+ ///
+ Open,
+ ///
+ /// The connection is closed. This could also occur when an error is received.
+ ///
+ Closed,
+ ///
+ /// The connection has been shutdown explicitly by the consumer using the method.
+ ///
+ Shutdown
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Resources.Designer.cs b/OrderSupportTicket/LaunchDarkly.EventSource/Resources.Designer.cs
new file mode 100644
index 0000000..8c722f6
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/Resources.Designer.cs
@@ -0,0 +1,114 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace LaunchDarkly.EventSource {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ /// This class was generated by MSBuild using the GenerateResource task.
+ /// To add or remove a member, edit your .resx file then rerun MSBuild.
+ ///
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Build.Tasks.StronglyTypedResourceBuilder", "15.1.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LaunchDarkly.EventSource.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Invalid attempt to call Start() while the connection state is {0}.
+ ///
+ internal static string ErrorAlreadyStarted {
+ get {
+ return ResourceManager.GetString("ErrorAlreadyStarted", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to HTTP response had no body.
+ ///
+ internal static string ErrorEmptyResponse {
+ get {
+ return ResourceManager.GetString("ErrorEmptyResponse", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unexpected HTTP status code {0} from server.
+ ///
+ internal static string ErrorHttpStatus {
+ get {
+ return ResourceManager.GetString("ErrorHttpStatus", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Read timeout elapsed with no new data from server; connection may have been silently dropped.
+ ///
+ internal static string ErrorReadTimeout {
+ get {
+ return ResourceManager.GetString("ErrorReadTimeout", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unexpected HTTP content type "{0}"; should be "text/event-stream".
+ ///
+ internal static string ErrorWrongContentType {
+ get {
+ return ResourceManager.GetString("ErrorWrongContentType", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unexpected character encoding {0}; should be UTF-8.
+ ///
+ internal static string ErrorWrongEncoding {
+ get {
+ return ResourceManager.GetString("ErrorWrongEncoding", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Resources.resx b/OrderSupportTicket/LaunchDarkly.EventSource/Resources.resx
new file mode 100644
index 0000000..82bf32d
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/Resources.resx
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Read timeout elapsed with no new data from server; connection may have been silently dropped
+
+
+ Invalid attempt to call Start() while the connection state is {0}
+
+
+ Unexpected HTTP status code {0} from server
+
+
+ Unexpected HTTP content type "{0}"; should be "text/event-stream"
+
+
+ Unexpected character encoding {0}; should be UTF-8
+
+
+ HTTP response had no body
+
+
\ No newline at end of file
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/StateChangedEventArgs.cs b/OrderSupportTicket/LaunchDarkly.EventSource/StateChangedEventArgs.cs
new file mode 100644
index 0000000..4d2074e
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/StateChangedEventArgs.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Provides data for the state of the connection.
+ ///
+ ///
+ public class StateChangedEventArgs : EventArgs
+ {
+ ///
+ /// Gets the state of the EventSource connection.
+ ///
+ ///
+ /// One of the values, which represents the state of the EventSource connection.
+ ///
+ public ReadyState ReadyState { get; }
+
+ ///
+ /// Get the response headers. Only populated for .
+ ///
+ /// A collection of header values when the ReadyState is Open, or null.
+ ///
+ ///
+ public IEnumerable>> Headers { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// One of the values, which represents the state of the EventSource connection.
+ /// Response headers when the is . Otherwise null.
+ public StateChangedEventArgs(ReadyState readyState, IEnumerable>> headers = null)
+ {
+ ReadyState = readyState;
+ Headers = headers;
+ }
+ }
+}
diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Utf8ByteSpan.cs b/OrderSupportTicket/LaunchDarkly.EventSource/Utf8ByteSpan.cs
new file mode 100644
index 0000000..52f0aa3
--- /dev/null
+++ b/OrderSupportTicket/LaunchDarkly.EventSource/Utf8ByteSpan.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Text;
+
+namespace LaunchDarkly.EventSource
+{
+ ///
+ /// Points to a span of UTF-8-encoded text in a buffer.
+ ///
+ ///
+ /// This is similar to the Span type in .NET 5. It is used internally by
+ /// EventSource to store event data if the stream is using UTF-8 encoding.
+ /// If so, reading will return the same
+ /// byte array, to avoid unnecessary copying.
+ ///
+ public struct Utf8ByteSpan
+ {
+ ///
+ /// The byte array containing the data. May be null if is zero.
+ ///
+ ///
+ /// It is the caller's responsibility not to modify the array.
+ ///
+ public byte[] Data { get; }
+
+ ///
+ /// The offset of the first relevant byte of data within the array. This may be
+ /// greater than zero if the span represents a subset of a larger buffer.
+ ///
+ public int Offset { get; }
+
+ ///
+ /// The number of bytes of relevant data within the array. This may be less than
+ /// Data.Length if the span represents a subset of a larger buffer.
+ ///
+ public int Length { get; }
+
+ ///
+ /// Constructs a new instance.
+ ///
+ /// The byte array containing the data.
+ /// The offset of the first relevant byte of data within the array.
+ /// The number of bytes of relevant data within the array.
+ public Utf8ByteSpan(byte[] data, int offset, int length)
+ {
+ Data = length == 0 ? null : data;
+ Offset = offset;
+ Length = length;
+ }
+
+ ///
+ /// Constructs a new instance by copying a string.
+ ///
+ /// A string to convert to UTF-8 bytes.
+ public Utf8ByteSpan(string s)
+ {
+ Data = s.Length == 0 ? null : Encoding.UTF8.GetBytes(s);
+ Offset = 0;
+ Length = Data is null ? 0 : Data.Length;
+ }
+
+ ///
+ /// Converts the UTF-8 byte data to a string.
+ ///
+ /// A new string.
+ public string GetString() => Data is null ? "" : Encoding.UTF8.GetString(Data, Offset, Length);
+
+ ///
+ /// Tests whether the bytes in this span are the same as another span.
+ ///
+ /// Another Utf8ByteSpan.
+ /// True if the two spans have the same length and the same
+ /// data, starting from each one's Offset.
+ public bool Equals(Utf8ByteSpan other)
+ {
+ var len = Length;
+ if (len != other.Length)
+ {
+ return false;
+ }
+ int offset = Offset, otherOffset = other.Offset;
+ byte[] data = Data, otherData = other.Data;
+ for (int i = 0; i < len; i++)
+ {
+ if (data[i + offset] != otherData[i + otherOffset])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// Tests whether the bytes in this span are the same as the UTF-8 encoding
+ /// of the specified string.
+ ///
+ ///
+ /// This method is potentially inefficient and should be used only in testing.
+ ///
+ /// A string.
+ /// True if the bytes are equivalent.
+ public bool Equals(string s)
+ {
+ byte[] data = Data;
+ int offset = Offset, len = Length;
+ for (int i = 0; i < len; i++)
+ {
+ if (data[offset + i] > 127)
+ {
+ // There are multi-byte characters in the span, so we can't do a 1:1 comparison of bytes
+ return GetString().Equals(s);
+ }
+ }
+ // There are no multi-byte characters, so we don't need to convert to or from a UTF-16 string
+ if (len != s.Length)
+ {
+ return false;
+ }
+ for (int i = 0; i < len; i++)
+ {
+ if ((char)data[offset + i] != s[i])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/OrderSupportTicket/Models/TicketClientOptions.cs b/OrderSupportTicket/Models/TicketClientOptions.cs
index 19e3b8b..229b6d3 100644
--- a/OrderSupportTicket/Models/TicketClientOptions.cs
+++ b/OrderSupportTicket/Models/TicketClientOptions.cs
@@ -1,3 +1,5 @@
+using AllBase;
+using AllBase.Model.DBModel;
using System;
using System.Collections.Generic;
using System.Configuration;
@@ -32,6 +34,25 @@ public sealed class TicketClientOptions
///
public IList Orders { get; set; }
+ ///
+ /// 创建参数副本,避免宿主与页面共享可变集合。
+ ///
+ public TicketClientOptions Clone()
+ {
+ return new TicketClientOptions
+ {
+ UserName = UserName,
+ AppIds = (AppIds ?? Enumerable.Empty())
+ .Where(appId => !string.IsNullOrWhiteSpace(appId))
+ .Select(appId => appId.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToList(),
+ Orders = (Orders ?? Enumerable.Empty())
+ .Where(order => order != null)
+ .ToList()
+ };
+ }
+
public static TicketClientOptions FromApplicationSettings()
{
var purchaseAccount =
@@ -72,6 +93,11 @@ public sealed class TicketOrderOption
///
public string GoodsName { get; set; }
+ ///
+ /// 商品缩略图地址,用于新建工单页展示关联商品。
+ ///
+ public string ImageUrl { get; set; }
+
///
/// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
///
@@ -97,23 +123,54 @@ public sealed class TicketOrderOption
}
///
- /// 订单详情展示文本,包含商品名、金额和下单时间。
+ /// 订单详情展示文本。商品名已包含在 中,这里只展示金额和下单时间。
///
public string ProductDescription
{
get
{
- var product = string.IsNullOrWhiteSpace(GoodsName)
- ? "未提供商品名称"
- : GoodsName;
var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN"));
var orderedAt = OrderedAt == default
? "下单时间未知"
: OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);
- return $"商品:{product}\n金额:{amount} 下单时间:{orderedAt}";
+ return $"金额 {amount} · {orderedAt}";
}
}
+ ///
+ /// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。
+ ///
+ public static TicketOrderOption FromWechatOrder(
+ oms_wei_order_info order,
+ string appId)
+ {
+ if (order == null)
+ return null;
+
+ var goodsName = string.Join(
+ "、",
+ (order.orderItems ?? Enumerable.Empty())
+ .Select(item => item.title)
+ .Where(title => !string.IsNullOrWhiteSpace(title))
+ .Distinct());
+ if (goodsName.Length > 200)
+ goodsName = goodsName.Substring(0, 200) + "…";
+
+ return new TicketOrderOption
+ {
+ AppId = appId?.Trim(),
+ OrderId = order.order_id,
+ GoodsName = goodsName,
+ ImageUrl = (order.orderItems ?? Enumerable.Empty())
+ .Select(item => item.thumb_img)
+ .FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
+ Amount = (order.order_price ?? 0) / 100m,
+ OrderedAt = order.create_time > 0
+ ? BaseClass.StampToDateTime(order.create_time)
+ : default
+ };
+ }
+
///
/// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
///
diff --git a/OrderSupportTicket/Models/WorkOrderListCache.cs b/OrderSupportTicket/Models/WorkOrderListCache.cs
index b615b64..50cd081 100644
--- a/OrderSupportTicket/Models/WorkOrderListCache.cs
+++ b/OrderSupportTicket/Models/WorkOrderListCache.cs
@@ -4,7 +4,7 @@ using System.Collections.Generic;
namespace OrderSupportTicket.Models;
///
-/// 工单列表本地缓存,仅用于比较服务器列表是否有新数据。
+/// 工单列表本地缓存,用于比较服务器列表是否有新数据,并保存商品展示信息。
///
public sealed class WorkOrderListCache
{
@@ -34,4 +34,19 @@ public sealed class WorkOrderListCacheItem
/// 最后一条公开消息时间,用于判断是否有任意新消息。
///
public DateTimeOffset LastMessageAt { get; set; }
+
+ ///
+ /// 商品名称。订单列表异步加载或直接打开工单时,用缓存补充商品信息。
+ ///
+ public string ProductName { get; set; }
+
+ ///
+ /// 商品缩略图地址。
+ ///
+ public string ProductImageUrl { get; set; }
+
+ ///
+ /// 订单金额,单位元。旧缓存没有该字段时为 null。
+ ///
+ public decimal? Amount { get; set; }
}
diff --git a/OrderSupportTicket/OrderSupportTicket.csproj b/OrderSupportTicket/OrderSupportTicket.csproj
index ce49198..d15a26e 100644
--- a/OrderSupportTicket/OrderSupportTicket.csproj
+++ b/OrderSupportTicket/OrderSupportTicket.csproj
@@ -11,7 +11,7 @@
-
+
@@ -22,6 +22,7 @@
+ ..\DllReference\NaiBa.Util.dll
diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
index 62eccf1..ca3e573 100644
--- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
+++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
@@ -77,7 +77,7 @@ public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
{
try
{
- await source.StartAsync().ConfigureAwait(false);
+ await source.StartAsync();
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, source))
diff --git a/OrderSupportTicket/Services/ProductImageLoader.cs b/OrderSupportTicket/Services/ProductImageLoader.cs
new file mode 100644
index 0000000..7b17931
--- /dev/null
+++ b/OrderSupportTicket/Services/ProductImageLoader.cs
@@ -0,0 +1,35 @@
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Media;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 商品缩略图加载器,限制并发下载数量,避免工单列表同时发起大量请求。
+///
+internal static class ProductImageLoader
+{
+ private static readonly SemaphoreSlim Gate = new SemaphoreSlim(4, 4);
+
+ public static async Task LoadAsync(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ return null;
+
+ await Gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ return await Task.Run(
+ () => AllBase.ImageBuild.LoadBitmipImageFromUrl(url.Trim()))
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ return null;
+ }
+ finally
+ {
+ Gate.Release();
+ }
+ }
+}
diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs
index b04533a..14d6fb3 100644
--- a/OrderSupportTicket/Services/TicketApiService.cs
+++ b/OrderSupportTicket/Services/TicketApiService.cs
@@ -10,6 +10,7 @@ using System.Runtime.Serialization.Json;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using WxOrder.Framework.Commons;
namespace OrderSupportTicket.Services;
@@ -18,7 +19,7 @@ namespace OrderSupportTicket.Services;
///
public sealed class TicketApiService : ITicketApiService
{
- internal const string ServerUrl = "https://ai.e57.cn";
+ internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
private static readonly Regex AppIdRegex =
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
@@ -28,7 +29,13 @@ public sealed class TicketApiService : ITicketApiService
new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
private static readonly Regex OperationIdRegex =
new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
-
+ static TicketApiService()
+ {
+ if (EnvironmentHelper.IsTest)
+ {
+ ServerUrl = "http://localhost:5188";
+ }
+ }
///
/// 获取工单列表
///
@@ -64,7 +71,7 @@ public sealed class TicketApiService : ITicketApiService
return await PostAsync(
ApiRoot + "/list",
- request).ConfigureAwait(false);
+ request);
}
///
/// 获取工单消息
@@ -100,8 +107,7 @@ public sealed class TicketApiService : ITicketApiService
var path = ApiRoot
+ "/messages";
- return await PostAsync(path, request)
- .ConfigureAwait(false);
+ return await PostAsync(path, request);
}
///
/// 创建工单
@@ -170,8 +176,7 @@ public sealed class TicketApiService : ITicketApiService
+ "/orders/"
+ Uri.EscapeDataString(workOrder.WechatOrderId)
+ "/messages";
- return await PostAsync(path, request)
- .ConfigureAwait(false);
+ return await PostAsync(path, request);
}
public async Task MarkReadAsync(
@@ -196,7 +201,7 @@ public sealed class TicketApiService : ITicketApiService
+ "/orders/"
+ Uri.EscapeDataString(wechatOrderId)
+ "/read";
- var html = await PostBodyAsync(path, request).ConfigureAwait(false);
+ var html = await PostBodyAsync(path, request);
EnsureSuccessBody(html);
}
@@ -245,7 +250,7 @@ public sealed class TicketApiService : ITicketApiService
string path,
object request)
{
- var html = await PostBodyAsync(path, request).ConfigureAwait(false);
+ var html = await PostBodyAsync(path, request);
return DeserializeResponse(html);
}
@@ -256,7 +261,7 @@ public sealed class TicketApiService : ITicketApiService
var url = BuildUri(path);
// 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。
var json = Serialize(request);
- var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false);
+ var html = await GetHtml.PostJsonAsync(url, json);
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
return html;
diff --git a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs
new file mode 100644
index 0000000..dee49e5
--- /dev/null
+++ b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs
@@ -0,0 +1,291 @@
+using OrderSupportTicket.Models;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows.Threading;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 应用级工单实时监视器。SSE 连接的生命周期与应用一致,不随工单页关闭而停止。
+///
+public sealed class TicketRealtimeMonitor : IDisposable
+{
+ private readonly object _sync = new object();
+ private readonly HashSet _seenEventIds =
+ new HashSet(StringComparer.Ordinal);
+ private readonly Dictionary _unreadMessages =
+ new Dictionary(StringComparer.Ordinal);
+ private readonly Dispatcher _dispatcher;
+ private TicketClientOptions _options;
+ private string _optionsFingerprint;
+ private TicketRealtimeNotification _latestNotification;
+ private bool _subscribed;
+ private bool _disposed;
+
+ private TicketRealtimeMonitor()
+ {
+ RealtimeService = new EventSourceTicketRealtimeService();
+ RealtimeService.NotificationReceived += OnNotificationReceived;
+ _subscribed = true;
+ _dispatcher = System.Windows.Application.Current?.Dispatcher
+ ?? Dispatcher.CurrentDispatcher;
+ }
+
+ ///
+ /// 全局唯一的工单实时监视器。
+ ///
+ public static TicketRealtimeMonitor Current { get; } = new TicketRealtimeMonitor();
+
+ ///
+ /// 供工单页复用的实时服务,避免重复建立 SSE 连接。
+ ///
+ public ITicketRealtimeService RealtimeService { get; }
+
+ ///
+ /// 收到新的工单留言通知时触发。
+ ///
+ public event EventHandler NewMessageReceived;
+
+ ///
+ /// 未读工单数量变化时触发。
+ ///
+ public event EventHandler UnreadCountChanged;
+
+ ///
+ /// 当前未读工单数量。
+ ///
+ public int UnreadCount
+ {
+ get
+ {
+ lock (_sync)
+ return _unreadMessages.Values.Sum();
+ }
+ }
+
+ ///
+ /// 是否已经启动过实时连接。
+ ///
+ public bool IsStarted
+ {
+ get
+ {
+ lock (_sync)
+ return _options != null;
+ }
+ }
+
+ ///
+ /// 最近一条新消息通知,供主页面点击角标时定位工单。
+ ///
+ public TicketRealtimeNotification LatestNotification
+ {
+ get
+ {
+ lock (_sync)
+ return _latestNotification;
+ }
+ }
+
+ ///
+ /// 启动或复用实时连接。相同账号和 APPID 重复调用不会重建连接。
+ ///
+ public void Start(TicketClientOptions options)
+ {
+ if (_disposed)
+ throw new ObjectDisposedException(nameof(TicketRealtimeMonitor));
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ TicketApiService.ValidateOptions(options);
+ var fingerprint = BuildFingerprint(options);
+
+ lock (_sync)
+ {
+ if (string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal)
+ && RealtimeService.State != TicketRealtimeState.Disconnected)
+ {
+ return;
+ }
+
+ _options = options;
+ _optionsFingerprint = fingerprint;
+ }
+
+ RealtimeService.Start(options);
+ }
+
+ ///
+ /// 将指定工单标记为已读。
+ ///
+ public void MarkRead(string appId, string wechatOrderId)
+ {
+ if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId))
+ return;
+
+ var key = BuildTicketKey(appId, wechatOrderId);
+ var changed = false;
+ lock (_sync)
+ changed = _unreadMessages.Remove(key);
+ if (changed)
+ RaiseUnreadCountChanged();
+ }
+
+ ///
+ /// 清空全部未读提示。
+ ///
+ public void MarkAllRead()
+ {
+ var changed = false;
+ lock (_sync)
+ {
+ if (_unreadMessages.Count > 0)
+ {
+ _unreadMessages.Clear();
+ changed = true;
+ }
+ }
+ if (changed)
+ RaiseUnreadCountChanged();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ if (_subscribed)
+ {
+ RealtimeService.NotificationReceived -= OnNotificationReceived;
+ _subscribed = false;
+ }
+ RealtimeService.Dispose();
+ }
+
+ private void OnNotificationReceived(
+ object sender,
+ TicketRealtimeNotificationEventArgs args)
+ {
+ var notification = args?.Notification;
+ if (notification == null || notification.IsResyncRequired)
+ return;
+ if (string.IsNullOrWhiteSpace(notification.AppId)
+ || string.IsNullOrWhiteSpace(notification.WechatOrderId))
+ {
+ return;
+ }
+ if (!IsMessageAdded(notification))
+ return;
+
+ if (!string.IsNullOrWhiteSpace(notification.EventId))
+ {
+ lock (_sync)
+ {
+ if (!_seenEventIds.Add(notification.EventId))
+ return;
+ if (_seenEventIds.Count > 5000)
+ _seenEventIds.Clear();
+ }
+ }
+
+ var key = BuildTicketKey(notification.AppId, notification.WechatOrderId);
+ int unreadCount;
+ lock (_sync)
+ {
+ _unreadMessages.TryGetValue(key, out var ticketUnreadCount);
+ _unreadMessages[key] = ticketUnreadCount + 1;
+ _latestNotification = notification;
+ unreadCount = _unreadMessages.Values.Sum();
+ }
+
+ _dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_disposed)
+ return;
+ NewMessageReceived?.Invoke(
+ this,
+ new TicketInboxMessageEventArgs(notification, unreadCount));
+ UnreadCountChanged?.Invoke(
+ this,
+ new TicketUnreadCountChangedEventArgs(unreadCount));
+ }));
+ }
+
+ private void RaiseUnreadCountChanged()
+ {
+ var unreadCount = UnreadCount;
+ _dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (!_disposed)
+ UnreadCountChanged?.Invoke(
+ this,
+ new TicketUnreadCountChangedEventArgs(unreadCount));
+ }));
+ }
+
+ private static bool IsMessageAdded(TicketRealtimeNotification notification)
+ {
+ if (!string.IsNullOrWhiteSpace(notification.MessageId))
+ return true;
+ return !string.IsNullOrWhiteSpace(notification.Type)
+ && notification.Type.IndexOf(
+ "message_added",
+ StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ private static string BuildTicketKey(string appId, string wechatOrderId)
+ {
+ return appId + "|" + wechatOrderId;
+ }
+
+ private static string BuildFingerprint(TicketClientOptions options)
+ {
+ var appIds = (options.AppIds ?? Enumerable.Empty())
+ .Where(appId => !string.IsNullOrWhiteSpace(appId))
+ .Select(appId => appId.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(appId => appId, StringComparer.Ordinal);
+ return options.UserName + "|" + string.Join(",", appIds);
+ }
+}
+
+///
+/// 主页面收到的工单新消息事件参数。
+///
+public sealed class TicketInboxMessageEventArgs : EventArgs
+{
+ public TicketInboxMessageEventArgs(
+ TicketRealtimeNotification notification,
+ int unreadCount)
+ {
+ Notification = notification;
+ UnreadCount = unreadCount;
+ }
+
+ ///
+ /// 服务端推送的原始通知。
+ ///
+ public TicketRealtimeNotification Notification { get; }
+
+ ///
+ /// 收到该通知后的未读工单数量。
+ ///
+ public int UnreadCount { get; }
+}
+
+///
+/// 未读工单数量变化事件参数。
+///
+public sealed class TicketUnreadCountChangedEventArgs : EventArgs
+{
+ public TicketUnreadCountChangedEventArgs(int unreadCount)
+ {
+ UnreadCount = unreadCount;
+ }
+
+ ///
+ /// 当前未读工单数量。
+ ///
+ public int UnreadCount { get; }
+}
diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
index 303bd77..1560a0a 100644
--- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
+++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
@@ -1,3 +1,5 @@
+using AllBase.Helper;
+using AllBase.Model.DBModel;
using NaiBa.Util.Helper;
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
@@ -5,28 +7,35 @@ using OrderSupportTicket.Services;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
+using System.Windows.Data;
+using System.Windows.Media;
using System.Windows.Threading;
using WPFCommon.Models;
using WxOrder.Framework.Commons;
+using WxOrder.Framework.Commons.Dialogs;
namespace OrderSupportTicket.ViewModels;
///
-/// 工单页总 ViewModel,负责工单查询、留言操作、订单筛选和 SSE 刷新。
+/// 工单页总 ViewModel,负责工单查询、留言操作、新建工单和 SSE 刷新。
///
-public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
+public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IDisposable
{
- private const string SavedListConfigName = "OrderSupportTicket";
+ ///
+ /// 新建工单弹窗中传入 的参数名。
+ ///
+ public const string OrderParameterKey = "order";
+
+ private const string SavedListConfigName = "ordersupportticket";
private readonly ITicketApiService _api;
private readonly ITicketRealtimeService _realtime;
private readonly Dispatcher _dispatcher;
- private readonly DelegateCommand _newTicketCommand;
- private readonly DelegateCommand _clearOrderFilterCommand;
private readonly AsyncDelegateCommand _selectTicketCommand;
private readonly AsyncDelegateCommand _createTicketCommand;
private readonly AsyncDelegateCommand _sendMessageCommand;
@@ -43,11 +52,15 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
private bool _configured;
private bool _initialized;
private bool _disposed;
+ private readonly bool _ownsRealtime;
+ private string _pendingOpenKey;
private WorkOrderSummaryViewModel _selectedTicket;
private TicketOrderOption _selectedOrder;
+ private ImageSource _selectedOrderImage;
+ private int _selectedOrderImageVersion;
private string _chatText;
private string _draftText;
- private string _orderFilterText = string.Empty;
+ private string _ticketSearchText = string.Empty;
private bool _isDraft;
private bool _isBusy;
private bool _isLoading;
@@ -58,6 +71,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
private bool _isNoticeError;
private bool _hasNewData;
private string _newDataText;
+ private bool _isWindowActive;
private TicketRealtimeState _sseState;
private string _sseStatusText;
private int _messageScrollVersion;
@@ -70,22 +84,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
public OrderSupportTicketViewModel(
ITicketApiService api,
ITicketRealtimeService realtime,
- TicketClientOptions options = null)
+ TicketClientOptions options = null,
+ bool ownsRealtime = true)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_realtime = realtime ?? throw new ArgumentNullException(nameof(realtime));
+ _ownsRealtime = ownsRealtime;
_dispatcher = System.Windows.Application.Current?.Dispatcher
?? Dispatcher.CurrentDispatcher;
Tickets = new ObservableCollection();
Messages = new ObservableCollection();
- AvailableOrders = new ObservableCollection();
- FilteredOrders = new ObservableCollection();
-
- _newTicketCommand = new DelegateCommand(OpenNewDraft, () => !IsBusy);
- _clearOrderFilterCommand = new DelegateCommand(
- () => OrderFilterText = string.Empty,
- () => !string.IsNullOrWhiteSpace(OrderFilterText));
+ TicketView = CollectionViewSource.GetDefaultView(Tickets);
+ TicketView.Filter = FilterTicket;
_selectTicketCommand = new AsyncDelegateCommand(
SelectTicketAsync,
ticket => ticket != null && !IsBusy);
@@ -101,7 +112,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
_retryRealtimeCommand = new AsyncDelegateCommand(
RestartRealtimeAsync,
() => !IsBusy && _configured);
-
_realtime.NotificationReceived += OnNotificationReceived;
_realtime.StateChanged += OnRealtimeStateChanged;
SseState = TicketRealtimeState.Disconnected;
@@ -116,31 +126,16 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
///
public ObservableCollection Tickets { get; }
+ ///
+ /// 经过搜索条件过滤后的历史工单。
+ ///
+ public ICollectionView TicketView { get; }
+
///
/// 当前选中工单的公开留言。
///
public ObservableCollection Messages { get; }
- ///
- /// 宿主注入的全部可关联订单。
- ///
- public ObservableCollection AvailableOrders { get; }
-
- ///
- /// 经过订单号或商品名筛选后的关联订单。
- ///
- public ObservableCollection FilteredOrders { get; }
-
- ///
- /// 打开新建工单草稿的命令。
- ///
- public ICommand NewTicketCommand => _newTicketCommand;
-
- ///
- /// 清除关联订单筛选条件的命令。
- ///
- public ICommand ClearOrderFilterCommand => _clearOrderFilterCommand;
-
///
/// 选择并加载历史工单的命令。
///
@@ -196,7 +191,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
if (SetValue(ref _selectedOrder, value))
{
RaisePropertyChanged(nameof(SelectedOrderDescription));
+ RaisePropertyChanged(nameof(SelectedOrderText));
RaiseCreateCanExecuteChanged();
+ _ = LoadSelectedOrderImageAsync(value);
}
}
}
@@ -234,17 +231,18 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
}
///
- /// 关联订单筛选关键词,可匹配订单号或商品名。
+ /// 历史工单搜索关键词,可匹配订单号、店铺、状态或最近动态。
///
- public string OrderFilterText
+ public string TicketSearchText
{
- get => _orderFilterText;
+ get => _ticketSearchText;
set
{
- if (SetValue(ref _orderFilterText, value ?? string.Empty))
+ if (SetValue(ref _ticketSearchText, value ?? string.Empty))
{
- RefreshFilteredOrders();
- _clearOrderFilterCommand.RaiseCanExecuteChanged();
+ if (IsDraft)
+ CancelDraft();
+ TicketView?.Refresh();
}
}
}
@@ -281,9 +279,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
RaiseSendCanExecuteChanged();
RaiseCreateCanExecuteChanged();
RaiseLoadEarlierCanExecuteChanged();
- _newTicketCommand.RaiseCanExecuteChanged();
_retryRealtimeCommand.RaiseCanExecuteChanged();
_selectTicketCommand.RaiseCanExecuteChanged();
+ if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey))
+ _ = SelectPendingTicketAsync();
}
}
}
@@ -355,6 +354,22 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
private set => SetValue(ref _newDataText, value);
}
+ ///
+ /// 工单页所在窗口当前是否处于激活且可见状态。
+ /// 未激活时只刷新数据,不把当前工单标记为已读。
+ ///
+ public bool IsWindowActive
+ {
+ get => _isWindowActive;
+ set
+ {
+ if (!SetValue(ref _isWindowActive, value))
+ return;
+ if (value && _initialized && !IsDraft && SelectedTicket != null)
+ _ = MarkSelectedTicketReadAsync();
+ }
+ }
+
///
/// SSE 当前连接状态。
///
@@ -402,26 +417,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
///
public bool IsNotBusy => !IsBusy;
- ///
- /// 是否存在任何可关联订单。
- ///
- public bool HasAvailableOrders => AvailableOrders.Count > 0;
-
- ///
- /// 当前筛选结果中是否存在订单。
- ///
- public bool HasFilteredOrders => FilteredOrders.Count > 0;
-
- ///
- /// 关联订单总数或筛选结果数量文本。
- ///
- public string OrderFilterSummaryText =>
- HasAvailableOrders
- ? (string.IsNullOrWhiteSpace(OrderFilterText)
- ? $"共 {FilteredOrders.Count} 个订单"
- : $"匹配 {FilteredOrders.Count} 个订单")
- : "暂无可关联订单";
-
///
/// 留言输入框字数统计文本。
///
@@ -444,51 +439,249 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
/// 当前关联订单的商品、金额和下单时间说明。
///
public string SelectedOrderDescription =>
- SelectedOrder?.ProductDescription ?? "请选择关联订单";
+ SelectedOrder?.ProductDescription ?? "未提供关联订单";
+
+ ///
+ /// 当前关联订单的订单号和商品名摘要。
+ ///
+ public string SelectedOrderText =>
+ SelectedOrder?.DisplayText ?? "未提供关联订单";
+
+ ///
+ /// 当前关联订单的商品缩略图,加载完成前为空。
+ ///
+ public ImageSource SelectedOrderImage
+ {
+ get => _selectedOrderImage;
+ private set => SetValue(ref _selectedOrderImage, value);
+ }
+
+ ///
+ /// 弹窗标题。
+ ///
+ public string Title => "客户工单";
+
+ ///
+ /// 由 DialogService 绑定,页面本身不主动请求关闭。
+ ///
+#pragma warning disable CS0067
+ public event Action RequestClose;
+#pragma warning restore CS0067
+
+ public bool CanCloseDialog()
+ {
+ return true;
+ }
+
+ public void OnDialogOpened(IDialogParameters parameters)
+ {
+ if (parameters == null)
+ return;
+
+ if (parameters.TryGetValue("options", out var options)
+ && options != null)
+ {
+ Configure(options);
+ }
+
+ var order = GetOrderFromParameters(parameters);
+ if (order != null)
+ OpenNewTicket(order);
+ }
+
+ ///
+ /// 从弹窗参数中取出需要新建工单的订单。优先使用订单对象,
+ /// 同时兼容按字段传入订单号、APPID 等信息的调用方式。
+ ///
+ private static TicketOrderOption GetOrderFromParameters(
+ IDialogParameters parameters)
+ {
+ foreach (var key in new[]
+ {
+ OrderParameterKey,
+ "ticketOrder",
+ "TicketOrderOption"
+ })
+ {
+ if (parameters.TryGetValue(key, out var order)
+ && order != null)
+ {
+ return order;
+ }
+ }
+
+ if (parameters.TryGetValue("options", out var options)
+ && options?.Orders != null)
+ {
+ var optionOrder = options.Orders.FirstOrDefault(o => o != null);
+ if (optionOrder != null)
+ return optionOrder;
+ }
+
+ foreach (var key in parameters.Keys ?? Enumerable.Empty())
+ {
+ if (!parameters.TryGetValue