From 10bb0803d9f71f188f8353929f0421da3f6a0bf7 Mon Sep 17 00:00:00 2001 From: Chfei Date: Sun, 18 Oct 2026 20:35:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=B7=A5=E5=8D=95=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E9=A1=B5=E9=9D=A2=EF=BC=8C=E8=AE=A2=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=8A=A0=E5=B7=A5=E5=8D=95=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 29WxOrderWpf/App.xaml.cs | 4 +- .../Ctrls/OrderCtrls/OrderListCtrl.Command.cs | 4 +- .../OrderCtrls/OrderListRowHeaderCtrl.xaml | 456 ++++++----- .../OrderCtrls/OrderListRowHeaderCtrl.xaml.cs | 260 +++--- 29WxOrderWpf/MainWindow.xaml | 482 ++++++----- 29WxOrderWpf/MainWindow.xaml.cs | 768 +++++++++++------- 29WxOrderWpf/OrderSupportTicketWindow.xaml | 14 - 29WxOrderWpf/OrderSupportTicketWindow.xaml.cs | 26 - .../LaunchDarkly.EventSource/AsyncHelpers.cs | 115 +++ .../ByteArrayLineScanner.cs | 179 ++++ .../CommentReceivedEventArgs.cs | 28 + .../LaunchDarkly.EventSource/Configuration.cs | 210 +++++ .../ConfigurationBuilder.cs | 448 ++++++++++ .../LaunchDarkly.EventSource/Constants.cs | 53 ++ .../LaunchDarkly.EventSource/EventParser.cs | 96 +++ .../LaunchDarkly.EventSource/EventSource.cs | 508 ++++++++++++ .../EventSourceOpenedEventArgs.cs | 25 + .../EventSourceService.cs | 281 +++++++ .../EventSourceServiceCancelledException.cs | 34 + ...rceServiceUnsuccessfulResponseException.cs | 51 ++ .../ExceptionEventArgs.cs | 28 + .../ExponentialBackoffWithDecorrelation.cs | 206 +++++ .../LaunchDarkly.EventSource/IEventSource.cs | 160 ++++ .../LaunchDarkly.EventSource/MessageEvent.cs | 224 +++++ .../MessageReceivedEventArgs.cs | 31 + .../ReadTimeoutException.cs | 20 + .../LaunchDarkly.EventSource/ReadyState.cs | 29 + .../Resources.Designer.cs | 114 +++ .../LaunchDarkly.EventSource/Resources.resx | 138 ++++ .../StateChangedEventArgs.cs | 39 + .../LaunchDarkly.EventSource/Utf8ByteSpan.cs | 128 +++ .../Models/TicketClientOptions.cs | 67 +- .../Models/WorkOrderListCache.cs | 17 +- OrderSupportTicket/OrderSupportTicket.csproj | 3 +- .../EventSourceTicketRealtimeService.cs | 2 +- .../Services/ProductImageLoader.cs | 35 + .../Services/TicketApiService.cs | 25 +- .../Services/TicketRealtimeMonitor.cs | 291 +++++++ .../ViewModels/OrderSupportTicketViewModel.cs | 734 +++++++++++++---- .../ViewModels/WorkOrderSummaryViewModel.cs | 150 +++- .../Views/OrderSupportTicketView.xaml | 487 ++++++----- .../Views/OrderSupportTicketView.xaml.cs | 207 ++++- .../Commons/Dialogs/DialogService.cs | 34 + .../Commons}/ToastHelper.cs | 0 44 files changed, 5920 insertions(+), 1291 deletions(-) delete mode 100644 29WxOrderWpf/OrderSupportTicketWindow.xaml delete mode 100644 29WxOrderWpf/OrderSupportTicketWindow.xaml.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/AsyncHelpers.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ByteArrayLineScanner.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/Constants.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventParser.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventSourceOpenedEventArgs.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ExceptionEventArgs.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/IEventSource.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/MessageEvent.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ReadTimeoutException.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/ReadyState.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/Resources.Designer.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/Resources.resx create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/StateChangedEventArgs.cs create mode 100644 OrderSupportTicket/LaunchDarkly.EventSource/Utf8ByteSpan.cs create mode 100644 OrderSupportTicket/Services/ProductImageLoader.cs create mode 100644 OrderSupportTicket/Services/TicketRealtimeMonitor.cs rename {29WxOrderWpf/ToolCtrls => WxOrder.Framework/Commons}/ToastHelper.cs (100%) 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="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(key, out var value)) + continue; + + if (value is TicketOrderOption directOrder) + return directOrder; + + if (value is TicketClientOptions option) + { + var optionOrder = option.Orders?.FirstOrDefault(o => o != null); + if (optionOrder != null) + return optionOrder; + } + } + + var appId = GetParameterText(parameters, "appId", "AppId"); + var orderId = GetParameterText( + parameters, + "wechatOrderId", + "orderId", + "OrderId"); + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(orderId)) + return null; + + decimal.TryParse( + GetParameterText(parameters, "amount", "Amount"), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var amount); + DateTime.TryParse( + GetParameterText(parameters, "orderedAt", "OrderedAt"), + CultureInfo.CurrentCulture, + DateTimeStyles.None, + out var orderedAt); + + return new TicketOrderOption + { + AppId = appId.Trim(), + OrderId = orderId.Trim(), + GoodsName = GetParameterText(parameters, "goodsName", "GoodsName"), + ImageUrl = GetParameterText(parameters, "imageUrl", "ImageUrl"), + Amount = amount, + OrderedAt = orderedAt + }; + } + + private static string GetParameterText( + IDialogParameters parameters, + params string[] keys) + { + foreach (var key in keys) + { + if (parameters.TryGetValue(key, out var value) + && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + return null; + } + + /// + /// 后台下载关联订单的商品缩略图,避免阻塞界面;切换订单后丢弃过期结果。 + /// + private async Task LoadSelectedOrderImageAsync(TicketOrderOption order) + { + var version = ++_selectedOrderImageVersion; + SelectedOrderImage = null; + + var imageUrl = order?.ImageUrl?.Trim(); + if (string.IsNullOrEmpty(imageUrl)) + return; + + var image = await ProductImageLoader.LoadAsync(imageUrl); + if (image == null || version != _selectedOrderImageVersion || _disposed) + return; + + try + { + await _dispatcher.InvokeAsync(() => + { + if (version == _selectedOrderImageVersion && !_disposed) + SelectedOrderImage = image; + }); + } + catch + { + // 页面关闭过程中忽略图片回填失败。 + } + } + + public void OnDialogClosed() + { + // 工单页面由主窗口持有并复用,关闭弹窗时保留页面状态。 + } + + /// + /// 切换回历史工单页面。 + /// + public void ShowHistory() + { + CancelDraft(); + } + + /// + /// 取消尚未提交的新工单草稿。 + /// + public void CancelNewTicketDraft() + { + CancelDraft(); + } public void Configure(TicketClientOptions options) { if (_disposed) throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel)); - _ = _realtime.StopAsync(); - SseState = TicketRealtimeState.Disconnected; - SseStatusText = "实时推送:未连接"; + if (_ownsRealtime) + { + _ = _realtime.StopAsync(); + SseState = TicketRealtimeState.Disconnected; + SseStatusText = GetRealtimeStatusText(SseState); + } + else + { + SseState = _realtime.State; + SseStatusText = GetRealtimeStatusText(SseState); + } _configured = false; _initialized = false; + _pendingOpenKey = null; _options = CloneOptions(options); HasNewData = false; NewDataText = string.Empty; IsDraft = false; SelectedTicket = null; + SelectedOrder = null; + DraftText = string.Empty; Tickets.Clear(); Messages.Clear(); - AvailableOrders.Clear(); - foreach (var order in _options.Orders ?? Enumerable.Empty()) - { - if (order != null) - AvailableOrders.Add(order); - } - OrderFilterText = string.Empty; - RefreshFilteredOrders(); - RaisePropertyChanged(nameof(HasAvailableOrders)); if (_options.AppIds == null || _options.AppIds.Count == 0) { - _options.AppIds = AvailableOrders + _options.AppIds = (_options.Orders ?? Enumerable.Empty()) + .Where(o => o != null) .Select(o => o.AppId) .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); } LoadSavedOrderTimes(); + ApplyOptionsValidation(); + } + private void ApplyOptionsValidation() + { try { TicketApiService.ValidateOptions(_options); - if (_options.AppIds.Count == 0) + if (_options.AppIds == null || _options.AppIds.Count == 0) throw new ArgumentException("appIds 至少需要一个 APPID。"); _configured = true; NoticeText = string.Empty; @@ -496,6 +689,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable } catch (Exception ex) { + _configured = false; SetNotice( "工单页配置不完整:" + ex.Message + " 请通过 TicketClientOptions 注入拍单账号和 APPID。", @@ -522,7 +716,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable try { var started = false; - if (!_initialized || _realtime.State == TicketRealtimeState.Disconnected) + // 共享连接由宿主统一维护,页面首次打开时不能因为 _initialized=false 重启连接。 + var shouldStartRealtime = + _realtime.State == TicketRealtimeState.Disconnected + || (_ownsRealtime && !_initialized); + if (shouldStartRealtime) { _realtime.Start(_options); started = true; @@ -533,6 +731,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable await RefreshAllWorkOrdersAsync(loadSelectedMessages: true); _initialized = true; } + + await SelectPendingTicketAsync(); } catch (Exception ex) { @@ -543,26 +743,91 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable public async Task DeactivateAsync() { SaveWorkOrderList(); - await _realtime.StopAsync(); + if (_ownsRealtime) + await _realtime.StopAsync(); } public void OpenNewTicket(TicketOrderOption order) { if (order == null) return; - if (!AvailableOrders.Any(o => - string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal))) - { - AvailableOrders.Add(order); - RaisePropertyChanged(nameof(HasAvailableOrders)); - } - OrderFilterText = string.Empty; - RefreshFilteredOrders(); - SelectedOrder = AvailableOrders.First(o => - string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal)); + + RememberOrderInfo(order); + CancelDraft(); + TicketSearchText = string.Empty; + SelectedOrder = order; + EnsureOrderAppId(order.AppId); OpenNewDraft(); } + /// + /// 订单列表异步加载完成后,将商品名称、图片和价格补充到对应工单。 + /// + public void ApplyOrderInfo(TicketOrderOption order) + { + if (order == null || string.IsNullOrWhiteSpace(order.OrderId)) + return; + + RememberOrderInfo(order); + + var ticket = Tickets.FirstOrDefault(t => + string.Equals(t.WechatOrderId, order.OrderId, StringComparison.Ordinal) + && (string.IsNullOrWhiteSpace(order.AppId) + || string.Equals(t.AppId, order.AppId, StringComparison.Ordinal))); + if (ticket == null) + return; + + ticket.ApplyProductInfo(order.GoodsName, order.ImageUrl, order.Amount); + TicketView.Refresh(); + SaveWorkOrderList(); + } + + private void EnsureOrderAppId(string appId) + { + if (_options == null || string.IsNullOrWhiteSpace(appId)) + return; + + if (_options.AppIds == null) + _options.AppIds = new List(); + + if (!_options.AppIds.Any(id => + string.Equals(id, appId, StringComparison.Ordinal))) + { + _options.AppIds.Add(appId); + } + + if (!_configured) + ApplyOptionsValidation(); + } + + /// + /// 打开指定工单;页面尚未初始化时会在首次加载完成后自动定位。 + /// + public void OpenTicket(string appId, string wechatOrderId) + { + if (_disposed || !_configured) + return; + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId)) + return; + + _pendingOpenKey = appId + "|" + wechatOrderId; + if (_initialized) + _ = SelectPendingTicketAsync(); + } + + /// + /// 按订单号查找历史工单。订单号在工单中作为唯一业务键使用。 + /// + public WorkOrderSummaryViewModel FindTicketByOrderId(string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return null; + + var target = orderId.Trim(); + return Tickets.FirstOrDefault(ticket => + string.Equals(ticket.WechatOrderId, target, StringComparison.Ordinal)); + } + public void Dispose() { if (_disposed) @@ -571,33 +836,73 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable _disposed = true; _realtime.NotificationReceived -= OnNotificationReceived; _realtime.StateChanged -= OnRealtimeStateChanged; - _realtime.Dispose(); + if (_ownsRealtime) + _realtime.Dispose(); } private void OpenNewDraft() { + if (SelectedOrder == null) + { + SetNotice("未提供关联订单,无法新建工单。", true); + return; + } + IsDraft = true; SelectedTicket = null; Messages.Clear(); HasMoreMessages = false; _nextBefore = null; - if (SelectedOrder == null) - SelectedOrder = FilteredOrders.FirstOrDefault(); - if (AvailableOrders.Count == 0) + DraftText = string.Empty; + _pendingCreateFingerprint = null; + _pendingCreateRequestId = null; + ClearNotice(); + } + + /// + /// 取消未提交的新工单。点击其他工单或开始搜索时会调用。 + /// + private void CancelDraft() + { + if (!IsDraft) + return; + + IsDraft = false; + SelectedOrder = null; + DraftText = string.Empty; + _pendingCreateFingerprint = null; + _pendingCreateRequestId = null; + ClearNotice(); + } + + private async Task SelectPendingTicketAsync() + { + var key = _pendingOpenKey; + if (string.IsNullOrWhiteSpace(key) || IsBusy) + return; + + try { - SetNotice( - "没有可关联的本地订单。请通过 TicketClientOptions.Orders 注入订单后再新建工单。", - true); + _pendingOpenKey = null; + var ticket = FindTicket(key); + if (ticket == null) + { + var parts = key.Split(new[] { '|' }, 2); + if (parts.Length != 2) + return; + await RefreshSingleWorkOrderAsync(parts[0], parts[1]); + ticket = FindTicket(key); + } + + if (ticket != null) + { + IsDraft = false; + await SelectTicketAsync(ticket); + } } - else if (FilteredOrders.Count == 0) + catch (Exception ex) { - SetNotice( - "当前筛选条件下没有匹配订单,请调整订单号或商品名关键词。", - true); - } - else - { - ClearNotice(); + SetNotice("打开指定工单失败:" + DescribeException(ex), true); } } @@ -606,7 +911,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable if (ticket == null || IsBusy) return; - IsDraft = false; + TicketSearchText = string.Empty; + CancelDraft(); + ApplyKnownProductInfo(ticket); SelectedTicket = ticket; await LoadMessagesAsync(ticket, replace: true); } @@ -637,9 +944,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable description, null); - var targetKey = result.AppId + "|" + result.WechatOrderId; var operation = (result.Operation ?? string.Empty).ToLowerInvariant(); var isExisting = operation == "existing"; + + var targetKey = result.AppId + "|" + result.WechatOrderId; if (isExisting) { ChatText = description; @@ -895,6 +1203,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable } if (hasNewMessage) item.MarkHasNewMessage(); + ApplyKnownProductInfo(item); if (merged.All(t => !string.Equals(t.Key, item.Key, StringComparison.Ordinal))) merged.Add(item); } @@ -954,6 +1263,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable if (hasNewMessage) existing.MarkHasNewMessage(); + ApplyKnownProductInfo(existing); SaveWorkOrderList(); ReorderTickets(); if (SelectedTicket != null @@ -1011,6 +1321,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable private async Task MarkReadBestEffortAsync(WorkOrderSummaryViewModel ticket) { + if (!_isWindowActive || ticket == null) + return; + var maxSequence = Messages.Count == 0 ? 0 : Messages.Max(m => m.SequenceNo); @@ -1019,7 +1332,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable if (_lastReadSequences.TryGetValue(ticket.Key, out var lastRead) && maxSequence <= lastRead) { - ticket.MarkRead(); + MarkTicketRead(ticket); return; } @@ -1030,8 +1343,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable ticket.AppId, ticket.WechatOrderId, maxSequence); + if (!_isWindowActive) + return; _lastReadSequences[ticket.Key] = maxSequence; - ticket.MarkRead(); + MarkTicketRead(ticket); SaveWorkOrderList(); } catch @@ -1040,6 +1355,22 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable } } + private async Task MarkSelectedTicketReadAsync() + { + var ticket = SelectedTicket; + if (ticket == null || IsDraft) + return; + await MarkReadBestEffortAsync(ticket); + } + + private static void MarkTicketRead(WorkOrderSummaryViewModel ticket) + { + ticket.MarkRead(); + TicketRealtimeMonitor.Current.MarkRead( + ticket.AppId, + ticket.WechatOrderId); + } + private void OnNotificationReceived( object sender, TicketRealtimeNotificationEventArgs args) @@ -1100,16 +1431,21 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable if (_disposed || args == null) return; SseState = args.State; - SseStatusText = args.State switch - { - TicketRealtimeState.Connected => "实时推送:已连接", - TicketRealtimeState.Connecting => "实时推送:连接中…", - TicketRealtimeState.Reconnecting => "实时推送:重连中…", - _ => "实时推送:已断开" - }; + SseStatusText = GetRealtimeStatusText(args.State); })); } + private static string GetRealtimeStatusText(TicketRealtimeState state) + { + return state switch + { + TicketRealtimeState.Connected => "实时推送:已连接", + TicketRealtimeState.Connecting => "实时推送:连接中…", + TicketRealtimeState.Reconnecting => "实时推送:重连中…", + _ => "实时推送:已断开" + }; + } + private bool CanCreateTicket() { return !IsBusy @@ -1119,29 +1455,20 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable && DraftText.Trim().Length <= 2000; } - private void RefreshFilteredOrders() + private bool FilterTicket(object item) { - var selectedKey = SelectedOrder?.OrderKey; - var keyword = (OrderFilterText ?? string.Empty).Trim(); - var matches = string.IsNullOrWhiteSpace(keyword) - ? AvailableOrders - : AvailableOrders.Where(order => - ContainsIgnoreCase(order.OrderId, keyword) - || ContainsIgnoreCase(order.GoodsName, keyword)); + if (!(item is WorkOrderSummaryViewModel ticket)) + return false; - FilteredOrders.Clear(); - foreach (var order in matches) - FilteredOrders.Add(order); + var keyword = (TicketSearchText ?? string.Empty).Trim(); + if (keyword.Length == 0) + return true; - SelectedOrder = FilteredOrders.FirstOrDefault(order => - string.Equals( - order.OrderKey, - selectedKey, - StringComparison.Ordinal)) - ?? FilteredOrders.FirstOrDefault(); - - RaisePropertyChanged(nameof(HasFilteredOrders)); - RaisePropertyChanged(nameof(OrderFilterSummaryText)); + return ContainsIgnoreCase(ticket.WechatOrderId, keyword) + || ContainsIgnoreCase(ticket.ShopName, keyword) + || ContainsIgnoreCase(ticket.ProductName, keyword) + || ContainsIgnoreCase(ticket.StatusText, keyword) + || ContainsIgnoreCase(ticket.PreviewText, keyword); } private static bool ContainsIgnoreCase(string source, string keyword) @@ -1185,6 +1512,121 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable IsNoticeError = false; } + /// + /// 记录订单商品信息,后续刷新工单列表时用于补齐列表和对话中的商品展示。 + /// + private void RememberOrderInfo(TicketOrderOption order) + { + if (order == null + || string.IsNullOrWhiteSpace(order.OrderId) + || _options == null) + { + return; + } + + if (_options.Orders == null) + _options.Orders = new List(); + + var existing = _options.Orders.FirstOrDefault(o => + o != null + && string.Equals(o.OrderId, order.OrderId, StringComparison.Ordinal) + && (string.IsNullOrWhiteSpace(o.AppId) + || string.IsNullOrWhiteSpace(order.AppId) + || string.Equals(o.AppId, order.AppId, StringComparison.Ordinal))); + if (existing == null) + { + _options.Orders.Add(order); + return; + } + + if (!string.IsNullOrWhiteSpace(order.GoodsName)) + existing.GoodsName = order.GoodsName; + if (!string.IsNullOrWhiteSpace(order.ImageUrl)) + existing.ImageUrl = order.ImageUrl; + if (order.Amount > 0) + existing.Amount = order.Amount; + if (order.OrderedAt != default) + existing.OrderedAt = order.OrderedAt; + } + + /// + /// 依次使用本地缓存、已注入订单和订单列表快照补齐工单商品信息。 + /// + private void ApplyKnownProductInfo(WorkOrderSummaryViewModel ticket) + { + if (ticket == null) + return; + + if (_savedWorkOrders.TryGetValue(ticket.WechatOrderId, out var saved) + && saved != null) + { + ticket.ApplyProductInfo( + saved.ProductName, + saved.ProductImageUrl, + saved.Amount); + } + + var option = _options?.Orders?.FirstOrDefault(o => + o != null + && string.Equals(o.OrderId, ticket.WechatOrderId, StringComparison.Ordinal) + && (string.IsNullOrWhiteSpace(o.AppId) + || string.Equals(o.AppId, ticket.AppId, StringComparison.Ordinal))); + if (option != null) + { + ticket.ApplyProductInfo( + option.GoodsName, + option.ImageUrl, + option.Amount); + } + + if (!string.IsNullOrWhiteSpace(ticket.ProductName) + && !string.IsNullOrWhiteSpace(ticket.ProductImageUrl) + && ticket.ProductAmount.HasValue) + { + return; + } + + var localOrder = FindLocalOrder(ticket.WechatOrderId); + if (localOrder == null) + return; + + var localOption = TicketOrderOption.FromWechatOrder(localOrder, ticket.AppId); + if (localOption == null) + return; + + ticket.ApplyProductInfo( + localOption.GoodsName, + localOption.ImageUrl, + localOption.Amount); + RememberOrderInfo(localOption); + } + + /// + /// 从已加载的订单列表快照中查找商品信息,订单列表仍在加载时返回 null。 + /// + private static oms_wei_order_info FindLocalOrder(string orderId) + { + if (string.IsNullOrWhiteSpace(orderId)) + return null; + + try + { + var orders = GlobalConfig.lstOrder; + if (orders == null || orders.Count == 0) + return null; + + var snapshot = new oms_wei_order_info[orders.Count]; + orders.CopyTo(snapshot, 0); + return snapshot.FirstOrDefault(o => + o != null + && string.Equals(o.order_id, orderId, StringComparison.Ordinal)); + } + catch + { + return null; + } + } + private WorkOrderSummaryViewModel FindTicket(string key) { if (string.IsNullOrWhiteSpace(key)) @@ -1279,6 +1721,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable { if (string.IsNullOrWhiteSpace(_options?.UserName)) return; + if (!_initialized && Tickets.Count == 0) + return; UpdateSavedWorkOrders(); var cache = new WorkOrderListCache @@ -1316,7 +1760,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable return new WorkOrderListCacheItem { OrderId = ticket.WechatOrderId, - LastMessageAt = ticket.LastMessageAt + LastMessageAt = ticket.LastMessageAt, + ProductName = ticket.ProductName, + ProductImageUrl = ticket.ProductImageUrl, + Amount = ticket.ProductAmount }; } @@ -1344,20 +1791,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable private static TicketClientOptions CloneOptions(TicketClientOptions options) { - if (options == null) - return new TicketClientOptions(); - return new TicketClientOptions - { - UserName = options.UserName, - AppIds = (options.AppIds ?? Enumerable.Empty()) - .Where(a => !string.IsNullOrWhiteSpace(a)) - .Select(a => a.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(), - Orders = (options.Orders ?? Enumerable.Empty()) - .Where(o => o != null) - .ToList() - }; + return options?.Clone() ?? new TicketClientOptions(); } private static string DescribeException(Exception ex) diff --git a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs index 663bf19..b199720 100644 --- a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs +++ b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs @@ -1,6 +1,12 @@ +using AllBase.Helper; using OrderSupportTicket.Models.Api; +using OrderSupportTicket.Services; using System; using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Media; +using System.Windows.Threading; using WPFCommon.Models; namespace OrderSupportTicket.ViewModels; @@ -21,6 +27,13 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx private int _unreadCount; private bool _hasNewMessage; private string _version; + private string _productName; + private string _productImageUrl; + private decimal? _productAmount; + private ImageSource _productImage; + private int _productImageVersion; + private readonly Dispatcher _dispatcher = + System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; public WorkOrderSummaryViewModel(WorkOrderSummaryDto dto) { @@ -35,7 +48,29 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx public string AppId { get => _appId; - private set => SetValue(ref _appId, value); + private set + { + if (SetValue(ref _appId, value)) + RaisePropertyChanged(nameof(ShopName)); + } + } + + /// + /// 工单所属店铺的显示名称。列表中不直接展示 APPID。 + /// + public string ShopName + { + get + { + var shop = GlobalConfig.ShopLoginList?.FirstOrDefault(s => + string.Equals(s.ShopId, AppId, StringComparison.Ordinal) + || string.Equals(s.AppKey, AppId, StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(shop?.ShopAccount)) + return shop.ShopAccount; + if (!string.IsNullOrWhiteSpace(shop?.CompanyName)) + return shop.CompanyName; + return "未知店铺"; + } } /// @@ -144,6 +179,66 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx private set => SetValue(ref _version, value); } + /// + /// 商品名称,优先来自当前订单列表或本地工单缓存。 + /// + public string ProductName + { + get => _productName; + private set + { + if (SetValue(ref _productName, value)) + RaisePropertyChanged(nameof(ProductNameText)); + } + } + + /// + /// 商品名称的展示文本,尚未同步到商品信息时给出提示。 + /// + public string ProductNameText => + string.IsNullOrWhiteSpace(ProductName) ? "商品信息待同步" : ProductName; + + /// + /// 商品金额,单位元;旧缓存没有金额时为 null。 + /// + public decimal? ProductAmount + { + get => _productAmount; + private set + { + if (SetValue(ref _productAmount, value)) + RaisePropertyChanged(nameof(ProductAmountText)); + } + } + + /// + /// 商品金额展示文本。 + /// + public string ProductAmountText => + ProductAmount.HasValue + ? "¥ " + ProductAmount.Value.ToString( + "N2", + CultureInfo.GetCultureInfo("zh-CN")) + : string.Empty; + + /// + /// 商品缩略图地址。 + /// + public string ProductImageUrl + { + get => _productImageUrl; + private set => SetValue(ref _productImageUrl, value); + } + + /// + /// 商品缩略图,加载完成前为空。 + /// + public ImageSource ProductImage + { + get => _productImage; + private set => SetValue(ref _productImage, value); + } + /// /// 用于合并工单的 APPID 与订单号组合键。 /// @@ -230,6 +325,30 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx UnreadCount = 1; } + /// + /// 合并商品信息。空值不会覆盖已有数据,便于先应用缓存、再应用最新的订单信息。 + /// + public void ApplyProductInfo( + string productName, + string imageUrl, + decimal? amount) + { + if (!string.IsNullOrWhiteSpace(productName)) + ProductName = productName.Trim(); + if (amount.HasValue) + ProductAmount = amount; + + if (string.IsNullOrWhiteSpace(imageUrl)) + return; + + var normalizedUrl = imageUrl.Trim(); + if (string.Equals(ProductImageUrl, normalizedUrl, StringComparison.Ordinal)) + return; + + ProductImageUrl = normalizedUrl; + _ = LoadProductImageAsync(normalizedUrl); + } + private void Apply(WorkOrderSummaryDto dto) { AppId = dto.AppId; @@ -246,6 +365,35 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx Version = dto.Version; } + private async Task LoadProductImageAsync(string url) + { + try + { + var version = ++_productImageVersion; + ProductImage = null; + + var image = await ProductImageLoader.LoadAsync(url); + if (image == null || version != _productImageVersion) + return; + + if (_dispatcher.CheckAccess()) + { + ProductImage = image; + return; + } + + await _dispatcher.InvokeAsync(() => + { + if (version == _productImageVersion) + ProductImage = image; + }); + } + catch + { + // 商品图加载失败不影响工单列表展示。 + } + } + private bool IsAtLeastCurrentVersion(string incomingVersion) { if (string.IsNullOrWhiteSpace(incomingVersion)) diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml b/OrderSupportTicket/Views/OrderSupportTicketView.xaml index 8a1b1d6..369c08e 100644 --- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml @@ -4,12 +4,14 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:behaviors="clr-namespace:OrderSupportTicket.Behaviors" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:fk="WxOrder.Framework" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:models="clr-namespace:OrderSupportTicket.Models" xmlns:vm="clr-namespace:OrderSupportTicket.ViewModels" - MinWidth="760" - MinHeight="480" d:DataContext="{d:DesignInstance Type=vm:OrderSupportTicketViewModel}" + fk:DialogWindowProperty.Height="750" + fk:DialogWindowProperty.ResizeMode="CanResizeWithGrip" + fk:DialogWindowProperty.Width="1000" + fk:DialogWindowProperty.WindowStartupLocation="CenterScreen" Background="#EEF1F4" FontFamily="Microsoft YaHei UI" FontSize="14" @@ -21,6 +23,7 @@ + @@ -32,6 +35,20 @@ + + + + + + - + - - - + + + + + + + + + + - - + + - - - + - + - - + + + + + + + + + + + + Margin="9,0,0,0" + VerticalAlignment="Center"> + + + + + + + + + + + + + + + + + + @@ -458,26 +575,93 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + Text="{Binding SelectedTicket.ProductNameText}" + TextTrimming="CharacterEllipsis" /> + + + + + + + + - - + + public OrderSupportTicketView( + ITicketApiService api, + ITicketRealtimeService realtime, + TicketClientOptions options, + bool ownsRealtime) { InitializeComponent(); - options = new TicketClientOptions - { - UserName="18990991234", - AppIds = ["wx316b1291351558aa", "wx7999743c3d64c05c"], - Orders = [ - new TicketOrderOption - { - Amount=109m, - AppId="wx316b1291351558aa", - GoodsName="梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤", - OrderId="9000000000000000001", - OrderedAt=new DateTime(2020, 1, 1), - }, - new TicketOrderOption - { - Amount=109m, - AppId="wx7999743c3d64c05c", - GoodsName="洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸", - OrderId="9000000000000000002", - OrderedAt=new DateTime(2020, 1, 1), - } - ] - }; - ViewModel = new OrderSupportTicketViewModel(api, realtime, options); + ViewModel = new OrderSupportTicketViewModel( + api, + realtime, + options ?? CreateDefaultOptions(), + ownsRealtime); DataContext = ViewModel; Loaded += OnLoaded; Unloaded += OnUnloaded; } + /// + /// 当前联调使用的默认参数。正式接入时可由宿主替换为登录账号和店铺 APPID。 + /// + public static TicketClientOptions CreateDefaultOptions(string fallbackUserName = null) + { + var configured = TicketClientOptions.FromApplicationSettings(); + var userName = !string.IsNullOrWhiteSpace(configured.UserName) + ? configured.UserName + : (!string.IsNullOrWhiteSpace(fallbackUserName) + ? fallbackUserName.Trim() + : "18990991234"); + var appIds = configured.AppIds != null && configured.AppIds.Count > 0 + ? configured.AppIds + : new List + { + "wx316b1291351558aa", + "wx7999743c3d64c05c" + }; + + return new TicketClientOptions + { + UserName = userName, + AppIds = appIds, + Orders = + [ + new TicketOrderOption + { + Amount = 109m, + AppId = "wx316b1291351558aa", + GoodsName = "梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤", + OrderId = "9000000000000000001", + OrderedAt = new DateTime(2020, 1, 1), + }, + new TicketOrderOption + { + Amount = 109m, + AppId = "wx7999743c3d64c05c", + GoodsName = "洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸", + OrderId = "9000000000000000002", + OrderedAt = new DateTime(2020, 1, 1), + } + ] + }; + } + /// /// 当前页面对应的工单 ViewModel。 /// @@ -76,11 +120,58 @@ public partial class OrderSupportTicketView : UserControl ViewModel.OpenNewTicket(order); } + /// + /// 打开并定位到指定工单。 + /// + public void OpenTicket(string appId, string wechatOrderId) + { + ViewModel.OpenTicket(appId, wechatOrderId); + } + + private void TicketSearchBox_GotFocus(object sender, RoutedEventArgs e) + { + ViewModel.CancelNewTicketDraft(); + } + + /// + /// 点击订单号复制到剪贴板,并使用宿主窗口的 Toast 提示结果。 + /// + private void CopyOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is not FrameworkElement element) + return; + + var orderId = element.Tag as string; + if (string.IsNullOrWhiteSpace(orderId) + && element.DataContext is WorkOrderSummaryViewModel ticket) + { + orderId = ticket.WechatOrderId; + } + if (string.IsNullOrWhiteSpace(orderId)) + return; + + var window = Window.GetWindow(this) ?? _hostWindow; + if (window == null) + return; + + try + { + Clipboard.SetText(orderId); + ToastHelper.Show(window, "订单号已复制"); + } + catch (Exception ex) + { + ToastHelper.Show(window, "复制失败:" + ex.Message, ToastType.Error); + } + } + private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) { if (_active) return; _active = true; + AttachHostWindow(Window.GetWindow(this)); + UpdateWindowActiveState(); await ViewModel.InitializeAsync(); } @@ -89,6 +180,72 @@ public partial class OrderSupportTicketView : UserControl if (!_active) return; _active = false; + ViewModel.IsWindowActive = false; + DetachHostWindow(); await ViewModel.DeactivateAsync(); } + + private void AttachHostWindow(Window window) + { + DetachHostWindow(); + _hostWindow = window; + if (_hostWindow == null) + return; + + _hostWindow.Activated += OnHostWindowActivated; + _hostWindow.Deactivated += OnHostWindowDeactivated; + _hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged; + _hostWindow.StateChanged += OnHostWindowStateChanged; + _hostWindow.Closed += OnHostWindowClosed; + } + + private void DetachHostWindow() + { + if (_hostWindow == null) + return; + + _hostWindow.Activated -= OnHostWindowActivated; + _hostWindow.Deactivated -= OnHostWindowDeactivated; + _hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged; + _hostWindow.StateChanged -= OnHostWindowStateChanged; + _hostWindow.Closed -= OnHostWindowClosed; + _hostWindow = null; + } + + private void UpdateWindowActiveState() + { + var window = _hostWindow; + ViewModel.IsWindowActive = window != null + && window.IsVisible + && window.IsActive + && window.WindowState != WindowState.Minimized; + } + + private void OnHostWindowActivated(object sender, EventArgs e) + { + UpdateWindowActiveState(); + } + + private void OnHostWindowDeactivated(object sender, EventArgs e) + { + UpdateWindowActiveState(); + } + + private void OnHostWindowIsVisibleChanged( + object sender, + DependencyPropertyChangedEventArgs e) + { + UpdateWindowActiveState(); + } + + private void OnHostWindowStateChanged(object sender, EventArgs e) + { + UpdateWindowActiveState(); + } + + private void OnHostWindowClosed(object sender, EventArgs e) + { + ViewModel.IsWindowActive = false; + DetachHostWindow(); + } } diff --git a/WxOrder.Framework/Commons/Dialogs/DialogService.cs b/WxOrder.Framework/Commons/Dialogs/DialogService.cs index f9c4675..ce51529 100644 --- a/WxOrder.Framework/Commons/Dialogs/DialogService.cs +++ b/WxOrder.Framework/Commons/Dialogs/DialogService.cs @@ -12,7 +12,41 @@ namespace WxOrder.Framework.Commons.Dialogs public static class DialogService { // ========== 对外接口 ========== + public static bool ExistsWindow(FrameworkElement view) + { + var window = Application.Current.Windows.OfType().FirstOrDefault(w => w.Content != null && w.Content.GetType() == view.GetType()); + return window != null; + } + public static void ActivateWindow(FrameworkElement view) + { + var window = Application.Current.Windows.OfType().FirstOrDefault(w => w.Content != null && w.Content.GetType() == view.GetType()); + if (window == null) + return; + if (!window.IsVisible) + window.Show(); + if (window.WindowState == WindowState.Minimized) + window.WindowState = WindowState.Normal; + + if (!window.Activate()) + { + // 窗口被其他进程遮挡时,短暂置顶以切换到用户面前。 + window.Topmost = true; + window.Topmost = false; + window.Activate(); + } + } + public static void ShowOrActivateWindow(FrameworkElement view, IDialogParameters parameters = null, Action callback = null) + { + if (ExistsWindow(view)) + { + ActivateWindow(view); + } + else + { + Show(view, parameters, callback); + } + } /// 非模态弹出 public static void Show(FrameworkElement view, IDialogParameters parameters = null, diff --git a/29WxOrderWpf/ToolCtrls/ToastHelper.cs b/WxOrder.Framework/Commons/ToastHelper.cs similarity index 100% rename from 29WxOrderWpf/ToolCtrls/ToastHelper.cs rename to WxOrder.Framework/Commons/ToastHelper.cs