From d977c9da83e9efe979696c0b21ff288b3f911edd Mon Sep 17 00:00:00 2001 From: backuppc Date: Wed, 12 Nov 2025 13:57:13 +0900 Subject: [PATCH 1/2] add chatserver & client --- EETGW.sln | 23 +- Project/ChatClientService.cs | 283 ++++++++++++ Project/ChatMessage.cs | 76 ++++ Project/Dialog/fChat.Designer.cs | 184 ++++++++ Project/Dialog/fChat.cs | 412 +++++++++++++++++ Project/Dialog/fChat.resx | 64 +++ Project/EETGW.csproj | 9 + Project/EETGW.csproj.user | 2 +- Project/Properties/Settings.Designer.cs | 2 +- Project/Properties/Settings.settings | 4 +- Project/app.config | 30 +- Project/fMain.Designer.cs | 14 +- Project/fMain.cs | 224 +++++++++- Project/fMain.resx | 178 ++++---- SubProject/AmkorRestfulService | 1 - SubProject/ChatServer/App.config | 27 ++ SubProject/ChatServer/ChatClient.cs | 174 ++++++++ SubProject/ChatServer/ChatMessage.cs | 76 ++++ SubProject/ChatServer/NoticeServer.csproj | 73 +++ SubProject/ChatServer/NoticeService.cs | 116 +++++ SubProject/ChatServer/Program.cs | 288 ++++++++++++ .../ChatServer/Properties/AssemblyInfo.cs | 33 ++ .../Properties/Settings.Designer.cs | 50 +++ .../ChatServer/Properties/Settings.settings | 12 + SubProject/ChatServer/TcpChatServer.cs | 421 ++++++++++++++++++ SubProject/ChatServer/packages.config | 4 + 26 files changed, 2662 insertions(+), 118 deletions(-) create mode 100644 Project/ChatClientService.cs create mode 100644 Project/ChatMessage.cs create mode 100644 Project/Dialog/fChat.Designer.cs create mode 100644 Project/Dialog/fChat.cs create mode 100644 Project/Dialog/fChat.resx delete mode 160000 SubProject/AmkorRestfulService create mode 100644 SubProject/ChatServer/App.config create mode 100644 SubProject/ChatServer/ChatClient.cs create mode 100644 SubProject/ChatServer/ChatMessage.cs create mode 100644 SubProject/ChatServer/NoticeServer.csproj create mode 100644 SubProject/ChatServer/NoticeService.cs create mode 100644 SubProject/ChatServer/Program.cs create mode 100644 SubProject/ChatServer/Properties/AssemblyInfo.cs create mode 100644 SubProject/ChatServer/Properties/Settings.Designer.cs create mode 100644 SubProject/ChatServer/Properties/Settings.settings create mode 100644 SubProject/ChatServer/TcpChatServer.cs create mode 100644 SubProject/ChatServer/packages.config diff --git a/EETGW.sln b/EETGW.sln index 07357ec..ec4773d 100644 --- a/EETGW.sln +++ b/EETGW.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36310.24 d17.14 +# Visual Studio Express 15 for Windows Desktop +VisualStudioVersion = 15.0.36324.19 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EETGW", "Project\EETGW.csproj", "{65F3E762-800C-499E-862F-A535642EC59F}" EndProject @@ -44,7 +44,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YARTE", "Sub\YARTE\YARTE.cs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console_SendMail", "Sub\Console_SendMail\Console_SendMail.csproj", "{8C94D335-7468-4964-AA24-1E3313CF7ABA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AmkorRestfulService", "SubProject\AmkorRestfulService\AmkorRestfulService.csproj", "{58CFC90C-5068-46A2-A8DE-0E92EE9E0990}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoticeServer", "SubProject\ChatServer\NoticeServer.csproj", "{8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -168,14 +168,14 @@ Global {8C94D335-7468-4964-AA24-1E3313CF7ABA}.Release|Any CPU.Build.0 = Release|Any CPU {8C94D335-7468-4964-AA24-1E3313CF7ABA}.Release|x86.ActiveCfg = Release|Any CPU {8C94D335-7468-4964-AA24-1E3313CF7ABA}.Release|x86.Build.0 = Release|Any CPU - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Debug|x86.ActiveCfg = Debug|x86 - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Debug|x86.Build.0 = Debug|x86 - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Release|Any CPU.ActiveCfg = Release|Any CPU - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Release|Any CPU.Build.0 = Release|Any CPU - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Release|x86.ActiveCfg = Release|x86 - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990}.Release|x86.Build.0 = Release|x86 + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Debug|x86.Build.0 = Debug|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Release|Any CPU.Build.0 = Release|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Release|x86.ActiveCfg = Release|Any CPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -195,7 +195,6 @@ Global {CAFE5CD0-C055-4C77-9253-8D5EE9558D43} = {6C7EC99E-7367-4255-A039-EF5E8D75A2F6} {3869B8C1-1290-4864-B72D-D771475F914D} = {6C7EC99E-7367-4255-A039-EF5E8D75A2F6} {DB5EE9C8-EACF-4231-877E-B9DFD7A714DE} = {28105E67-9D33-4627-8E26-FCE67700622F} - {58CFC90C-5068-46A2-A8DE-0E92EE9E0990} = {6C7EC99E-7367-4255-A039-EF5E8D75A2F6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B5B1FD72-356F-4840-83E8-B070AC21C8D9} diff --git a/Project/ChatClientService.cs b/Project/ChatClientService.cs new file mode 100644 index 0000000..7849fe6 --- /dev/null +++ b/Project/ChatClientService.cs @@ -0,0 +1,283 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using Newtonsoft.Json; + +namespace Project +{ + /// + /// Background chat client service with auto-reconnect + /// + public class ChatClientService : IDisposable + { + private TcpClient tcpClient; + private NetworkStream stream; + private Thread receiveThread; + private Thread reconnectThread; + private bool isRunning; + private bool isConnected; + + private string serverIp; + private int serverPort; + private string nickName; + private string employeeId; + private string hostName; + private string userGroup; + + /// Connection status changed event + public event EventHandler ConnectionStatusChanged; + + /// Message received event + public event EventHandler MessageReceived; + + /// Connection status + public bool IsConnected => isConnected; + + /// User nickname + public string NickName => nickName; + + /// User employee ID + public string EmployeeId => employeeId; + + /// User group + public string UserGroup => userGroup; + + public ChatClientService(string serverIp, int serverPort) + { + this.serverIp = serverIp; + this.serverPort = serverPort; + this.hostName = System.Net.Dns.GetHostName(); + } + + /// + /// Start background service + /// + public void Start(string nickName, string employeeId, string userGroup) + { + if (isRunning) + return; + + this.nickName = nickName; + this.employeeId = employeeId; + this.userGroup = userGroup; + isRunning = true; + + // Start reconnect thread + reconnectThread = new Thread(ReconnectLoop) + { + IsBackground = true, + Name = "ChatReconnectThread" + }; + reconnectThread.Start(); + } + + /// + /// Stop background service + /// + public void Stop() + { + isRunning = false; + Disconnect(); + + try + { + reconnectThread?.Join(2000); + receiveThread?.Join(2000); + } + catch { } + } + + /// + /// Auto-reconnect loop + /// + private void ReconnectLoop() + { + while (isRunning) + { + if (!isConnected) + { + try + { + Connect(); + } + catch + { + // Retry after 5 seconds + Thread.Sleep(5000); + } + } + else + { + Thread.Sleep(1000); + } + } + } + + /// + /// Connect to server + /// + private void Connect() + { + try + { + tcpClient = new TcpClient(); + tcpClient.Connect(serverIp, serverPort); + stream = tcpClient.GetStream(); + isConnected = true; + + // Notify connection status + ConnectionStatusChanged?.Invoke(this, true); + + // Send connect message + var connectMsg = new ChatMessage + { + Type = MessageType.Connect, + NickName = nickName, + EmployeeId = employeeId, + HostName = hostName, + UserGroup = userGroup, + Timestamp = DateTime.Now + }; + SendMessage(connectMsg); + + // Start receive thread + receiveThread = new Thread(ReceiveLoop) + { + IsBackground = true, + Name = "ChatReceiveThread" + }; + receiveThread.Start(); + } + catch + { + Disconnect(); + throw; + } + } + + /// + /// Disconnect from server + /// + private void Disconnect() + { + if (!isConnected) + return; + + isConnected = false; + + try + { + stream?.Close(); + tcpClient?.Close(); + } + catch { } + + // Notify connection status + ConnectionStatusChanged?.Invoke(this, false); + } + + /// + /// Message receive loop + /// + private void ReceiveLoop() + { + byte[] buffer = new byte[8192]; + + try + { + while (isRunning && isConnected && tcpClient.Connected) + { + int bytesRead = stream.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + { + // Connection closed + break; + } + + string jsonData = Encoding.UTF8.GetString(buffer, 0, bytesRead); + var message = JsonConvert.DeserializeObject(jsonData); + + if (message != null) + { + // Filter messages by group (dev can see all) + // UserListResponse and system messages should always be received + if (userGroup == "dev" || + message.UserGroup == userGroup || + message.Type == MessageType.Notice || + message.Type == MessageType.UserListResponse || + message.Type == MessageType.Pong) + { + MessageReceived?.Invoke(this, message); + } + } + } + } + catch + { + // Connection error + } + finally + { + Disconnect(); + } + } + + /// + /// Send message + /// + public bool SendMessage(ChatMessage message) + { + if (!isConnected || tcpClient == null || !tcpClient.Connected) + return false; + + try + { + message.NickName = nickName; + message.UserGroup = userGroup; + message.Timestamp = DateTime.Now; + + string jsonData = JsonConvert.SerializeObject(message); + byte[] data = Encoding.UTF8.GetBytes(jsonData); + stream.Write(data, 0, data.Length); + stream.Flush(); + return true; + } + catch + { + Disconnect(); + return false; + } + } + + /// + /// Send chat message + /// + public bool SendChatMessage(string content) + { + var message = new ChatMessage + { + Type = MessageType.Chat, + Content = content + }; + return SendMessage(message); + } + + /// + /// Request user list + /// + public bool RequestUserList() + { + var message = new ChatMessage + { + Type = MessageType.UserListRequest + }; + return SendMessage(message); + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/Project/ChatMessage.cs b/Project/ChatMessage.cs new file mode 100644 index 0000000..20134f7 --- /dev/null +++ b/Project/ChatMessage.cs @@ -0,0 +1,76 @@ +using System; + +namespace Project +{ + /// + /// Chat message type + /// + public enum MessageType + { + /// Client connect + Connect = 0, + /// Client disconnect + Disconnect = 1, + /// Normal chat message + Chat = 2, + /// Server notice + Notice = 3, + /// Whisper message + Whisper = 4, + /// User list request + UserListRequest = 5, + /// User list response + UserListResponse = 6, + /// Ping (keep alive) + Ping = 7, + /// Pong (ping response) + Pong = 8 + } + + /// + /// Chat message protocol + /// + [Serializable] + public class ChatMessage + { + /// Message type + public MessageType Type { get; set; } + + /// Sender nickname + public string NickName { get; set; } + + /// Sender employee ID (사번) + public string EmployeeId { get; set; } + + /// Sender IP + public string IpAddress { get; set; } + + /// Sender hostname + public string HostName { get; set; } + + /// Message content + public string Content { get; set; } + + /// Target employee ID (for 1:1 chat) + public string TargetEmployeeId { get; set; } + + /// Target nickname (for whisper, null for broadcast) + public string TargetNickName { get; set; } + + /// Send time + public DateTime Timestamp { get; set; } + + /// User group (for filtering) + public string UserGroup { get; set; } + + public ChatMessage() + { + Timestamp = DateTime.Now; + } + + public override string ToString() + { + return $"[{Timestamp:HH:mm:ss}] {NickName}: {Content}"; + } + } +} diff --git a/Project/Dialog/fChat.Designer.cs b/Project/Dialog/fChat.Designer.cs new file mode 100644 index 0000000..af5c4b8 --- /dev/null +++ b/Project/Dialog/fChat.Designer.cs @@ -0,0 +1,184 @@ +namespace Project.Dialog +{ + partial class fChat + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.txtChatDisplay = new System.Windows.Forms.TextBox(); + this.txtInput = new System.Windows.Forms.TextBox(); + this.btnSend = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.timerStatus = new System.Windows.Forms.Timer(this.components); + this.panelTop = new System.Windows.Forms.Panel(); + this.panelBottom = new System.Windows.Forms.Panel(); + this.lblRecipient = new System.Windows.Forms.Label(); + this.cboRecipient = new System.Windows.Forms.ComboBox(); + this.panelTop.SuspendLayout(); + this.panelBottom.SuspendLayout(); + this.SuspendLayout(); + // + // txtChatDisplay + // + this.txtChatDisplay.BackColor = System.Drawing.Color.White; + this.txtChatDisplay.Dock = System.Windows.Forms.DockStyle.Fill; + this.txtChatDisplay.Font = new System.Drawing.Font("Consolas", 9F); + this.txtChatDisplay.Location = new System.Drawing.Point(0, 30); + this.txtChatDisplay.Multiline = true; + this.txtChatDisplay.Name = "txtChatDisplay"; + this.txtChatDisplay.ReadOnly = true; + this.txtChatDisplay.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtChatDisplay.Size = new System.Drawing.Size(500, 340); + this.txtChatDisplay.TabIndex = 0; + // + // txtInput + // + this.txtInput.Dock = System.Windows.Forms.DockStyle.Fill; + this.txtInput.Font = new System.Drawing.Font("Malgun Gothic", 9F); + this.txtInput.Location = new System.Drawing.Point(0, 0); + this.txtInput.Name = "txtInput"; + this.txtInput.Size = new System.Drawing.Size(420, 23); + this.txtInput.TabIndex = 1; + this.txtInput.KeyDown += new System.Windows.Forms.KeyEventHandler(this.txtInput_KeyDown); + // + // btnSend + // + this.btnSend.Dock = System.Windows.Forms.DockStyle.Right; + this.btnSend.Location = new System.Drawing.Point(420, 0); + this.btnSend.Name = "btnSend"; + this.btnSend.Size = new System.Drawing.Size(80, 30); + this.btnSend.TabIndex = 2; + this.btnSend.Text = "Send"; + this.btnSend.UseVisualStyleBackColor = true; + this.btnSend.Click += new System.EventHandler(this.btnSend_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Dock = System.Windows.Forms.DockStyle.Fill; + this.lblStatus.Font = new System.Drawing.Font("Malgun Gothic", 9F, System.Drawing.FontStyle.Bold); + this.lblStatus.ForeColor = System.Drawing.Color.Green; + this.lblStatus.Location = new System.Drawing.Point(5, 5); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Padding = new System.Windows.Forms.Padding(5); + this.lblStatus.Size = new System.Drawing.Size(83, 25); + this.lblStatus.TabIndex = 3; + this.lblStatus.Text = "Connected"; + this.lblStatus.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // timerStatus + // + this.timerStatus.Enabled = true; + this.timerStatus.Interval = 1000; + this.timerStatus.Tick += new System.EventHandler(this.timerStatus_Tick); + // + // lblRecipient + // + this.lblRecipient.AutoSize = true; + this.lblRecipient.Dock = System.Windows.Forms.DockStyle.Left; + this.lblRecipient.Font = new System.Drawing.Font("Malgun Gothic", 9F); + this.lblRecipient.Location = new System.Drawing.Point(5, 5); + this.lblRecipient.Name = "lblRecipient"; + this.lblRecipient.Padding = new System.Windows.Forms.Padding(0, 5, 5, 0); + this.lblRecipient.Size = new System.Drawing.Size(45, 20); + this.lblRecipient.TabIndex = 5; + this.lblRecipient.Text = "To:"; + this.lblRecipient.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // cboRecipient + // + this.cboRecipient.Dock = System.Windows.Forms.DockStyle.Left; + this.cboRecipient.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cboRecipient.Font = new System.Drawing.Font("Malgun Gothic", 9F); + this.cboRecipient.FormattingEnabled = true; + this.cboRecipient.Location = new System.Drawing.Point(50, 5); + this.cboRecipient.Name = "cboRecipient"; + this.cboRecipient.Size = new System.Drawing.Size(200, 23); + this.cboRecipient.TabIndex = 6; + // + // panelTop + // + this.panelTop.Controls.Add(this.cboRecipient); + this.panelTop.Controls.Add(this.lblRecipient); + this.panelTop.Controls.Add(this.lblStatus); + this.panelTop.Dock = System.Windows.Forms.DockStyle.Top; + this.panelTop.Location = new System.Drawing.Point(0, 0); + this.panelTop.Name = "panelTop"; + this.panelTop.Padding = new System.Windows.Forms.Padding(5); + this.panelTop.Size = new System.Drawing.Size(500, 30); + this.panelTop.TabIndex = 4; + // + // panelBottom + // + this.panelBottom.Controls.Add(this.txtInput); + this.panelBottom.Controls.Add(this.btnSend); + this.panelBottom.Dock = System.Windows.Forms.DockStyle.Bottom; + this.panelBottom.Location = new System.Drawing.Point(0, 370); + this.panelBottom.Name = "panelBottom"; + this.panelBottom.Size = new System.Drawing.Size(500, 30); + this.panelBottom.TabIndex = 5; + // + // fChat + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(500, 400); + this.Controls.Add(this.txtChatDisplay); + this.Controls.Add(this.panelBottom); + this.Controls.Add(this.panelTop); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; + this.KeyPreview = true; + this.MaximizeBox = true; + this.MinimizeBox = true; + this.MinimumSize = new System.Drawing.Size(400, 300); + this.Name = "fChat"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Chat"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.fChat_FormClosing); + this.Load += new System.EventHandler(this.fChat_Load); + this.panelTop.ResumeLayout(false); + this.panelTop.PerformLayout(); + this.panelBottom.ResumeLayout(false); + this.panelBottom.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.TextBox txtChatDisplay; + private System.Windows.Forms.TextBox txtInput; + private System.Windows.Forms.Button btnSend; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.Timer timerStatus; + private System.Windows.Forms.Panel panelTop; + private System.Windows.Forms.Panel panelBottom; + private System.Windows.Forms.Label lblRecipient; + private System.Windows.Forms.ComboBox cboRecipient; + } +} diff --git a/Project/Dialog/fChat.cs b/Project/Dialog/fChat.cs new file mode 100644 index 0000000..fc72a5f --- /dev/null +++ b/Project/Dialog/fChat.cs @@ -0,0 +1,412 @@ +using FCOMMON; +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace Project.Dialog +{ + /// + /// User info for recipient selection + /// + public class ChatUserInfo + { + public string EmployeeId { get; set; } + public string NickName { get; set; } + public string UserGroup { get; set; } + + public override string ToString() + { + return $"{NickName} ({EmployeeId})"; + } + } + + public partial class fChat : fBase + { + private ChatClientService chatService; + private string targetEmployeeId; + private string targetNickName; + + /// + /// Constructor for new chat or specific recipient + /// + public fChat(ChatClientService service, string targetEmployeeId = null, string targetNickName = null) + { + InitializeComponent(); + this.chatService = service; + this.targetEmployeeId = targetEmployeeId; + this.targetNickName = targetNickName; + this.KeyDown += (s, e) => { if (e.KeyCode == Keys.Escape) this.Close(); }; + } + + private void fChat_Load(object sender, EventArgs e) + { + // Subscribe to message received event + if (chatService != null) + { + chatService.MessageReceived += OnMessageReceived; + } + + // Load user list for recipient selection + LoadUserList(); + + // If target is specified, set it + if (!string.IsNullOrEmpty(targetEmployeeId)) + { + SetRecipient(targetEmployeeId, targetNickName); + } + + // Load recent messages (if any) + RefreshConnectionStatus(); + + // Request user list from server + if (chatService != null && chatService.IsConnected) + { + System.Diagnostics.Debug.WriteLine("[DEBUG] Requesting user list from server..."); + chatService.RequestUserList(); + } + else + { + System.Diagnostics.Debug.WriteLine($"[DEBUG] Cannot request user list - Service: {chatService != null}, Connected: {chatService?.IsConnected}"); + } + } + + /// + /// Load user list into combobox + /// + private void LoadUserList() + { + cboRecipient.Items.Clear(); + + // Add placeholder + cboRecipient.Items.Add(new ChatUserInfo + { + EmployeeId = "", + NickName = "Select recipient...", + UserGroup = "" + }); + + // In a real implementation, this would load from server + // For now, user will need to type employee ID manually + // The server's UserListResponse should populate this + + cboRecipient.SelectedIndex = 0; + } + + /// + /// Set specific recipient + /// + private void SetRecipient(string employeeId, string nickName) + { + targetEmployeeId = employeeId; + targetNickName = nickName; + + // Try to find in combobox + bool found = false; + for (int i = 0; i < cboRecipient.Items.Count; i++) + { + var user = cboRecipient.Items[i] as ChatUserInfo; + if (user != null && user.EmployeeId == employeeId) + { + cboRecipient.SelectedIndex = i; + found = true; + break; + } + } + + // If not found, add it + if (!found && !string.IsNullOrEmpty(employeeId)) + { + var user = new ChatUserInfo + { + EmployeeId = employeeId, + NickName = nickName ?? employeeId, + UserGroup = "" + }; + cboRecipient.Items.Add(user); + cboRecipient.SelectedItem = user; + } + + // Update form title + this.Text = $"Chat - {nickName ?? employeeId}"; + } + + private void fChat_FormClosing(object sender, FormClosingEventArgs e) + { + // Unsubscribe events + if (chatService != null) + { + chatService.MessageReceived -= OnMessageReceived; + } + } + + /// + /// Message received event handler + /// + private void OnMessageReceived(object sender, ChatMessage message) + { + if (InvokeRequired) + { + BeginInvoke(new Action(() => OnMessageReceived(sender, message))); + return; + } + + // Get current recipient from combobox + var selectedUser = cboRecipient.SelectedItem as ChatUserInfo; + string currentTargetId = selectedUser?.EmployeeId ?? targetEmployeeId; + + // Filter messages - only show messages between current user and target + string myEmployeeId = chatService.EmployeeId; + + // Show message if: + // 1. It's from target to me + // 2. It's from me to target (echo from server) + // 3. It's a notice (broadcast) + bool shouldDisplay = false; + + if (message.Type == MessageType.Notice) + { + shouldDisplay = true; + + // If it's a join/leave notice, refresh user list + if (message.Content.Contains("has joined") || message.Content.Contains("has left")) + { + System.Diagnostics.Debug.WriteLine("[DEBUG] User joined/left, refreshing user list..."); + if (chatService != null && chatService.IsConnected) + { + chatService.RequestUserList(); + } + } + } + else if (message.Type == MessageType.UserListResponse) + { + // Handle user list response + HandleUserListResponse(message); + return; + } + else if (!string.IsNullOrEmpty(currentTargetId)) + { + // From target to me + if (message.EmployeeId == currentTargetId && message.TargetEmployeeId == myEmployeeId) + { + shouldDisplay = true; + } + // From me to target (echo) + else if (message.EmployeeId == myEmployeeId && message.TargetEmployeeId == currentTargetId) + { + shouldDisplay = true; + } + } + + if (shouldDisplay) + { + AppendMessage(message); + } + } + + /// + /// Handle user list response from server + /// + private void HandleUserListResponse(ChatMessage message) + { + try + { + System.Diagnostics.Debug.WriteLine($"[DEBUG] UserListResponse received: {message.Content}"); + + // Parse user list from Content (format: "employeeId1:nickName1:userGroup1,employeeId2:nickName2:userGroup2,...") + if (string.IsNullOrEmpty(message.Content)) + { + System.Diagnostics.Debug.WriteLine("[DEBUG] UserListResponse content is empty"); + return; + } + + cboRecipient.Items.Clear(); + + // Add placeholder + cboRecipient.Items.Add(new ChatUserInfo + { + EmployeeId = "", + NickName = "Select recipient...", + UserGroup = "" + }); + + string[] users = message.Content.Split(','); + System.Diagnostics.Debug.WriteLine($"[DEBUG] Parsing {users.Length} users"); + + foreach (var userStr in users) + { + if (string.IsNullOrWhiteSpace(userStr)) + continue; + + string[] parts = userStr.Split(':'); + System.Diagnostics.Debug.WriteLine($"[DEBUG] User parts: {string.Join(", ", parts)}"); + + if (parts.Length >= 2) + { + string empId = parts[0].Trim(); + string nick = parts[1].Trim(); + + System.Diagnostics.Debug.WriteLine($"[DEBUG] Processing user: {empId} - {nick}, My ID: {chatService.EmployeeId}"); + + // Don't add myself + if (empId != chatService.EmployeeId) + { + var user = new ChatUserInfo + { + EmployeeId = empId, + NickName = nick, + UserGroup = parts.Length > 2 ? parts[2].Trim() : "" + }; + cboRecipient.Items.Add(user); + System.Diagnostics.Debug.WriteLine($"[DEBUG] Added user: {user}"); + } + else + { + System.Diagnostics.Debug.WriteLine($"[DEBUG] Skipped myself: {empId}"); + } + } + } + + System.Diagnostics.Debug.WriteLine($"[DEBUG] Total items in combo: {cboRecipient.Items.Count}"); + + // Restore previous selection if exists + if (!string.IsNullOrEmpty(targetEmployeeId)) + { + SetRecipient(targetEmployeeId, targetNickName); + } + else + { + cboRecipient.SelectedIndex = 0; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ERROR] HandleUserListResponse: {ex.Message}"); + MessageBox.Show($"Error loading user list: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// + /// Append message to chat display + /// + private void AppendMessage(ChatMessage message) + { + string timeStr = message.Timestamp.ToString("HH:mm:ss"); + string displayMsg = ""; + + switch (message.Type) + { + case MessageType.Chat: + displayMsg = $"[{timeStr}] {message.NickName}: {message.Content}"; + break; + + case MessageType.Notice: + displayMsg = $"[{timeStr}] [NOTICE] {message.Content}"; + break; + + case MessageType.Whisper: + displayMsg = $"[{timeStr}] [WHISPER from {message.NickName}] {message.Content}"; + break; + + default: + return; + } + + txtChatDisplay.AppendText(displayMsg + Environment.NewLine); + txtChatDisplay.ScrollToCaret(); + } + + /// + /// Send button click + /// + private void btnSend_Click(object sender, EventArgs e) + { + SendMessage(); + } + + /// + /// Input text key down (Enter to send) + /// + private void txtInput_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter) + { + e.SuppressKeyPress = true; + SendMessage(); + } + } + + /// + /// Send chat message + /// + private void SendMessage() + { + string content = txtInput.Text.Trim(); + if (string.IsNullOrEmpty(content)) + return; + + if (chatService == null || !chatService.IsConnected) + { + MessageBox.Show("Not connected to chat server.", "Chat", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Get target from combobox + var selectedUser = cboRecipient.SelectedItem as ChatUserInfo; + string currentTargetId = selectedUser?.EmployeeId ?? targetEmployeeId; + + if (string.IsNullOrEmpty(currentTargetId)) + { + MessageBox.Show("Please select a recipient.", "Chat", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Create message with target + var message = new ChatMessage + { + Type = MessageType.Chat, + Content = content, + TargetEmployeeId = currentTargetId + }; + + bool sent = chatService.SendMessage(message); + if (sent) + { + // Note: Server will echo the message back, so we don't display it here + // This prevents duplicate messages + + // Clear input + txtInput.Text = ""; + txtInput.Focus(); + } + else + { + MessageBox.Show("Failed to send message.", "Chat", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// + /// Refresh connection status display + /// + private void RefreshConnectionStatus() + { + if (chatService != null && chatService.IsConnected) + { + lblStatus.Text = $"Connected - {chatService.NickName} ({chatService.UserGroup})"; + lblStatus.ForeColor = Color.Green; + } + else + { + lblStatus.Text = "Disconnected"; + lblStatus.ForeColor = Color.Red; + } + } + + /// + /// Timer to update status periodically + /// + private void timerStatus_Tick(object sender, EventArgs e) + { + RefreshConnectionStatus(); + } + } +} diff --git a/Project/Dialog/fChat.resx b/Project/Dialog/fChat.resx new file mode 100644 index 0000000..a9e5900 --- /dev/null +++ b/Project/Dialog/fChat.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + 17, 17 + + diff --git a/Project/EETGW.csproj b/Project/EETGW.csproj index 1ec57f3..14ae08e 100644 --- a/Project/EETGW.csproj +++ b/Project/EETGW.csproj @@ -314,6 +314,10 @@ fMsgWindow.cs + + + fChat.cs + Form @@ -478,6 +482,8 @@ + + Form @@ -507,6 +513,9 @@ fMsgWindow.cs + + fChat.cs + fLogin.cs diff --git a/Project/EETGW.csproj.user b/Project/EETGW.csproj.user index f6caabb..0789c32 100644 --- a/Project/EETGW.csproj.user +++ b/Project/EETGW.csproj.user @@ -1,4 +1,4 @@ - + ftp://10.131.36.205:2121/Install/GroupWare/|ftp://10.131.36.205:2121/Install/|ftp://10.131.36.205/Install/|게시\ diff --git a/Project/Properties/Settings.Designer.cs b/Project/Properties/Settings.Designer.cs index d1d2bf2..72968ee 100644 --- a/Project/Properties/Settings.Designer.cs +++ b/Project/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace Project.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.9.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.9.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); diff --git a/Project/Properties/Settings.settings b/Project/Properties/Settings.settings index 7f4e81e..4d038f7 100644 --- a/Project/Properties/Settings.settings +++ b/Project/Properties/Settings.settings @@ -4,7 +4,7 @@ <?xml version="1.0" encoding="utf-16"?> -<SerializableConnectionString xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> +<SerializableConnectionString xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <ConnectionString>Data Source=K4FASQL.kr.ds.amkor.com,50150;Initial Catalog=EE;Persist Security Info=True;User ID=eeadm;Password=uJnU8a8q&amp;DJ+ug-D;Encrypt=False;TrustServerCertificate=True</ConnectionString> <ProviderName>System.Data.SqlClient</ProviderName> </SerializableConnectionString> @@ -12,7 +12,7 @@ <?xml version="1.0" encoding="utf-16"?> -<SerializableConnectionString xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> +<SerializableConnectionString xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <ConnectionString>Data Source=K4FASQL.kr.ds.amkor.com,50150;Initial Catalog=EE;Persist Security Info=True;User ID=eeadm;Password=uJnU8a8q&amp;DJ+ug-D;Encrypt=False;TrustServerCertificate=True</ConnectionString> <ProviderName>System.Data.SqlClient</ProviderName> </SerializableConnectionString> diff --git a/Project/app.config b/Project/app.config index 7f92479..8cbec29 100644 --- a/Project/app.config +++ b/Project/app.config @@ -3,16 +3,26 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/Project/fMain.Designer.cs b/Project/fMain.Designer.cs index 0158d91..4303cfb 100644 --- a/Project/fMain.Designer.cs +++ b/Project/fMain.Designer.cs @@ -41,6 +41,7 @@ this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); this.sbLoginUseTime = new System.Windows.Forms.ToolStripStatusLabel(); this.sbWeb = new System.Windows.Forms.ToolStripStatusLabel(); + this.sbChat = new System.Windows.Forms.ToolStripStatusLabel(); this.menuStrip1 = new System.Windows.Forms.MenuStrip(); this.btSetting = new System.Windows.Forms.ToolStripMenuItem(); this.로그인ToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -191,7 +192,8 @@ this.sbLogin, this.toolStripStatusLabel1, this.sbLoginUseTime, - this.sbWeb}); + this.sbWeb, + this.sbChat}); this.statusStrip1.Location = new System.Drawing.Point(1, 622); this.statusStrip1.Name = "statusStrip1"; this.statusStrip1.Size = new System.Drawing.Size(1094, 22); @@ -228,6 +230,15 @@ this.sbWeb.Size = new System.Drawing.Size(31, 17); this.sbWeb.Text = "WEB"; // + // sbChat + // + this.sbChat.ForeColor = System.Drawing.Color.Gray; + this.sbChat.IsLink = true; + this.sbChat.Name = "sbChat"; + this.sbChat.Size = new System.Drawing.Size(42, 17); + this.sbChat.Text = "Notice"; + this.sbChat.Click += new System.EventHandler(this.sbChat_Click); + // // menuStrip1 // this.menuStrip1.Font = new System.Drawing.Font("맑은 고딕", 10F); @@ -1343,6 +1354,7 @@ private System.Windows.Forms.ToolStripSeparator toolStripMenuItem17; private System.Windows.Forms.ToolStripMenuItem webview2TestToolStripMenuItem; private System.Windows.Forms.ToolStripStatusLabel sbWeb; + private System.Windows.Forms.ToolStripStatusLabel sbChat; private System.Windows.Forms.ToolStripSeparator toolStripMenuItem18; } } diff --git a/Project/fMain.cs b/Project/fMain.cs index 33b7e43..f7b6890 100644 --- a/Project/fMain.cs +++ b/Project/fMain.cs @@ -20,11 +20,20 @@ namespace Project string SearchKey = string.Empty; private IDisposable webApp; bool webok = false; + private ChatClientService chatService; + private System.Windows.Forms.Timer chatBlinkTimer; + private bool chatHasNewMessage = false; + private Dictionary chatMessageSenders = new Dictionary(); // employeeId -> nickName public fMain() { InitializeComponent(); + + // Initialize chat blink timer + chatBlinkTimer = new System.Windows.Forms.Timer(); + chatBlinkTimer.Interval = 500; // 500ms blink + chatBlinkTimer.Tick += ChatBlinkTimer_Tick; this.KeyDown += (s1, e1) => { if (e1.KeyCode == Keys.F12) btSetting.PerformClick(); @@ -93,6 +102,10 @@ namespace Project } } + // Stop chat service + chatService?.Stop(); + chatBlinkTimer?.Stop(); + FCOMMON.Pub.log.Add("Program Close"); FCOMMON.Pub.log.Flush(); } @@ -193,6 +206,9 @@ namespace Project Func_Login(); + // Start chat service after login + StartChatService(); + ///즐겨찾기 목록 갱신 Update_FavoriteSite(); @@ -1558,8 +1574,214 @@ namespace Project fdashboard.RefreshView(); Console.WriteLine( "view update"); } - + } } + + #region Chat Functions + + /// + /// Start chat service after login + /// + private void StartChatService() + { + try + { + // Get user info + string nickName = FCOMMON.info.Login.nameK ?? "Unknown"; + string employeeId = FCOMMON.info.Login.no ?? "Unknown"; + string userGroup = FCOMMON.info.Login.gcode ?? "default"; + + // Initialize chat service + //var chatServerIP = "127.0.0.1";// : "10.131.11.45"; + var chatServerIP = "10.131.11.45"; + chatService = new ChatClientService(chatServerIP, 5000); + chatService.ConnectionStatusChanged += ChatService_ConnectionStatusChanged; + chatService.MessageReceived += ChatService_MessageReceived; + + // Start background service + chatService.Start(nickName, employeeId, userGroup); + + FCOMMON.Pub.log.AddI($"Chat service started: {nickName} ({employeeId}, {userGroup})"); + } + catch (Exception ex) + { + FCOMMON.Pub.log.AddE($"Failed to start chat service: {ex.Message}"); + } + } + + /// + /// Chat connection status changed + /// + private void ChatService_ConnectionStatusChanged(object sender, bool isConnected) + { + if (InvokeRequired) + { + BeginInvoke(new Action(() => ChatService_ConnectionStatusChanged(sender, isConnected))); + return; + } + + if (isConnected) + { + sbChat.ForeColor = Color.Green; + sbChat.Text = "Notice ●"; + } + else + { + sbChat.ForeColor = Color.Gray; + sbChat.Text = "Notice ○"; + } + } + + /// + /// Chat message received + /// + private void ChatService_MessageReceived(object sender, ChatMessage message) + { + if (InvokeRequired) + { + BeginInvoke(new Action(() => ChatService_MessageReceived(sender, message))); + return; + } + + // Track message senders (only messages directed to me) + string myEmployeeId = chatService.EmployeeId; + if ((message.Type == MessageType.Chat || message.Type == MessageType.Whisper) && + !string.IsNullOrEmpty(message.EmployeeId) && + message.EmployeeId != myEmployeeId && + message.TargetEmployeeId == myEmployeeId) + { + // Add or update sender + if (!chatMessageSenders.ContainsKey(message.EmployeeId)) + { + chatMessageSenders[message.EmployeeId] = message.NickName ?? message.EmployeeId; + } + + // Start blinking + chatHasNewMessage = true; + chatBlinkTimer.Start(); + } + } + + /// + /// Chat blink timer tick + /// + private void ChatBlinkTimer_Tick(object sender, EventArgs e) + { + if (!chatHasNewMessage) + { + chatBlinkTimer.Stop(); + sbChat.ForeColor = chatService?.IsConnected == true ? Color.Green : Color.Gray; + return; + } + + // Toggle color + sbChat.ForeColor = sbChat.ForeColor == Color.Red ? Color.Green : Color.Red; + } + + /// + /// sbChat status label click event + /// + private void sbChat_Click(object sender, EventArgs e) + { + // Stop blinking + chatHasNewMessage = false; + chatBlinkTimer.Stop(); + sbChat.ForeColor = chatService?.IsConnected == true ? Color.Green : Color.Gray; + + // Show chat dialog + if (chatService == null) + { + MessageBox.Show("Chat service is not running.", "Chat", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // If there are senders who sent messages, show selection dialog + if (chatMessageSenders.Count > 0) + { + // Create simple selection form + using (var selectForm = new Form()) + { + selectForm.Text = "Select Sender"; + selectForm.Size = new Size(400, 300); + selectForm.StartPosition = FormStartPosition.CenterParent; + selectForm.FormBorderStyle = FormBorderStyle.FixedDialog; + selectForm.MaximizeBox = false; + selectForm.MinimizeBox = false; + + var listBox = new ListBox + { + Dock = DockStyle.Fill, + Font = new Font("Malgun Gothic", 10) + }; + + var buttonPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 50 + }; + + var btnOk = new Button + { + Text = "Open Chat", + DialogResult = DialogResult.OK, + Size = new Size(100, 30), + Location = new Point(140, 10) + }; + + var btnNew = new Button + { + Text = "New Chat", + DialogResult = DialogResult.Retry, + Size = new Size(100, 30), + Location = new Point(250, 10) + }; + + buttonPanel.Controls.Add(btnOk); + buttonPanel.Controls.Add(btnNew); + + selectForm.Controls.Add(listBox); + selectForm.Controls.Add(buttonPanel); + selectForm.AcceptButton = btnOk; + + // Populate list with senders + foreach (var sender2 in chatMessageSenders) + { + listBox.Items.Add(new { EmployeeId = sender2.Key, NickName = sender2.Value, Display = $"{sender2.Value} ({sender2.Key})" }); + } + listBox.DisplayMember = "Display"; + + if (listBox.Items.Count > 0) + listBox.SelectedIndex = 0; + + var result = selectForm.ShowDialog(this); + + if (result == DialogResult.OK && listBox.SelectedItem != null) + { + // Open chat with selected sender + dynamic selected = listBox.SelectedItem; + var chatForm = new Dialog.fChat(chatService, selected.EmployeeId, selected.NickName); + chatForm.Show(); + + // Remove from senders list (chat opened) + chatMessageSenders.Remove(selected.EmployeeId); + } + else if (result == DialogResult.Retry) + { + // Open new chat (user will select recipient in chat window) + var chatForm = new Dialog.fChat(chatService); + chatForm.Show(); + } + } + } + else + { + // No senders, open new chat + var chatForm = new Dialog.fChat(chatService); + chatForm.Show(); + } + } + + #endregion } } diff --git a/Project/fMain.resx b/Project/fMain.resx index 1964ad6..764f88c 100644 --- a/Project/fMain.resx +++ b/Project/fMain.resx @@ -184,16 +184,16 @@ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m - dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpVLbSqJRGPVqruY9Zp7gf4F5jbkSEhFE - 00pFS4IyFSVTxHN4yAIPlCc8lGCpReJFqKAoaKJzNVeSMIgya/begw5SNAxzsfbP/vjWWt+3/s0D8F94 - s/geQqEQTk9P4Xa7Ybfbv/Kq1SooyuUySqUSisUiCoUCstksUqkULi8vEY1GcXFxgWAwyHrn8znG4zEs - FkuLV6lUsFgsMJvN3sXLywt8Pt+3yWSCTqeD4+PjgdFoFPHu7u5YYTQa4eHhAcPhELQ2GAxwfX2NXq+H - dDrNemKxGFqtFpvIYDB8oSuxvW5ubnB/f49MJsPd3t4iHo9zlHx2dsbRNTweD0fJVquVC4fDbP+Dg4PP - K4FcLodGo8Ey6Pf7LAPqTMntdnvlTATx9PRE3bG3t/dxJUBxdXWF6XSKfD4PkjSXTCapE0fHJWFxlHx0 - dMR5vV5KJpTfvJUAadTUajV0u11QMnWORCJoNptUkDmTVVCv16FUKn++EqAIBAI0aTidTthsNpo0SNI4 - PDzE/v4+tFotEokEtra23hb4GxQKxQe5XA6JRPJ9WWOH3+//RMZzORwOmjTMZjP0ej1Nmrmq1Wrs7OxA - JpORdvBEItF0TYCEpaHfk5MTqclkeiZk6bJhic3NTalYLH4mZKlAIPixrLODvGkNSZr9rsfHR5yfn7Ok - VSoVtre3QchwuVzsGZNe8Pn83prAEjqdjoW1u7v7agKhUCjd2NigZHL9U19r+neA9wvhROqXtIFlogAA + dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpZLPahpRGMVdddX3aJ9gXqCv0ZVgkAGJ + ozEqMUpAExUlE2UwziQhZtRANMRoxP+g+SeKi6BCgkJM0K66EoUiSk+5FyJYSwPt4jBwued3vu/cUQBQ + /I+WDt6TLMs4OjqCKIoQBOGr4u7uDkQ3Nzcol8solUrI5/PIZDJIpVK4uLhALBbD6ekpTk5O6N3pdIrB + YACe59uK29tbzGYzTCaTv2o0GuHw8PDbcDjE09MTdnd3e263m1VcX1/Tg36/j2q1itfXV5CzXq+HQqGA + breLq6sreicej6PdbtOJXC7Xl3kHxWIR9/f3SKfTTKVSwfn5OUPM4XCYIWtIksQQs8/nYyKRCN3fbrd/ + ngOy2SyazSbt4Pn5mXZAkon58fFxnhwOh/Hw8EDSYbVaP84BRIlEAuPxGLlcDrIsM8lkkiQxZFye5xli + 3tnZYQ4ODogZb745IBaLWer1OjqdDoiZJJ+dnaHVahEgTZYkCY1GAyaT6ecSgCgUCpGmsb+/D7/fT5qG + 2+2Gw+HA1tYWbDYbLi8vsba29mfAezIajR/0ej1WV1e/LwCOj48/SZIUDAQCpGl4vV44nU7SNE3d2NjA + +vo6dDod3Z1l2fECQBRFC/nu7e1pPR7Pi9Pp1P6eznGcVqPRvLAsq11ZWfmxABAEwcLzPH2uWq2GaDRK + mzabzTAYDOA4DsFgkP7GgiBAqVR2FwBv2t7epmVtbm4uTaBWq7UqlYqY50+4BPgX/QLhROqXAnK3iwAA AABJRU5ErkJggg== @@ -254,34 +254,34 @@ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m - dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAALrSURBVDhPbZLZU9NXGIb5V3rn2FuttiJCUVtxuanI - AGFJ2EQWl1GMIERASkQnhCQQQogRDSEhLHEERRmlUBKWH0sSI+DODxEdtcNF9danBzrOqOHiPVfnfd7z - fe+JAqIOFl/98UCRbW5/kc23v9BGQmEr+wqEjln5Xei3/Bb2HrWwJ6+Z+GwjMWkXX6z51rR+CHODMH9W - mwdRVXWhrOwi84KHDI2HdE0naRUuFOddiHvE55qJVmiF7SuAMM+qzfe461tg/sVb5p6/Ye7ZCuGnyzx8 - 8orgwhKBeZnXb96zurrK5pik7wCFrYvtI4850dBPhkhOr3CvS1HuIlUkp5R1kHzOuT5WXHZTJCChoFV2 - jz+n8X6Yu+EVbgdX6A8sc2vmFV5pie6JRdxjL7k29ISEgpZIgFiW7PQ9paTDj3XoMcY7QXReibqeSWo9 - 41R7JGq9QayDC2KplkiA2LTsECOccgxjGgix/M8nlj58RBZafP+Rl+/+ReOWaBqYE41sABA1yfaheY7b - 76PvD6Ptlqh0jVHe7qe0bZSSNh9lHRL6vrCoszkSIDqWrYOPyG8ZoO5WmNreEFXdAco7Z1E7pzntkDjj - mKLuZkj8BXMkYE+eRW66EyLH0Ed1TxCNZ5ZS1zQl7RInr09SdHWcIvs4f/YE2J2zQQu7c5tlQ1+AzMu9 - nHfPcNY5xekbEsXXJjhmGyO3xc9Rq58qAf01uzESEJ9jlnXeGVJrOsVT/08tEKlrpiyLj3TTCErTqNjJ - FHFZpkiA+ByytksiqcJJsX2C/LVUix+l+W8UhhGS64dJ1Q2jbpskVrUBIDarSa5xT3JYfYM88VxV8+h6 - aop+mCNX/uKPSw9I1A5xxjbBLpWRTdGJ8jeAXapGucYzxaXeIFpviJruIJWdATQds5Q5ZkSV05y1i9Es - Y8Qojfyw5VDbN4AYpal1Z6aJ6EwjOzIM/JJu4Oe0Bran6dmu0LMttZ6fUurZmqJja7Lu5hczEPUfnqi7 - iiOVMjYAAAAASUVORK5CYII= + dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAL2SURBVDhPbdHJU9t1HMbx/ivenHq1tirSImVHL7V2 + gLAkrAkErVNoypZCRVJ0QkhCQjbS2BASwpJOoaUyLQVJCPxYkhhZWtvyo0gdW4eDePXtEMcDhsPzuT2v + Z77zPQGc+KR+4J18pWM9T+kI5tU5yK2zk1NrJ0dhI1thI0tuJbPGQkZ1P+kVBlKLv35+2DtM4uQrHb15 + SsffKvM0so4RpO0jlF33U6r2U6IeprjNi6TFS77SQXqVmRSJhiNAntKxpjL/wIPgJhvPf2P92SvWf9kj + /nSXn568JLq5Q2RD5NdXr9nf3+dk6qX/AXX27cG5Lb7snaT0up+SNl8iklYvRS1eCpuHKLjmSTwrrcKU + DOTW2kVf+Bl9D+M8iO9xL7rHZGSXu6svCQg7jC5u41t4wa2ZJ+TWWpOBHIVN9ASf0jgUwjazheF+FG1A + oHtsiS5/mBt+ga5AFNv0JtkKSzKQrbCJ7rktvnLPYpyKsfvHX+y8OUB8c8D26wNe/P4nap+AaWqdLPkx + QJbcKjpnNvjC+RDdZBzNqEC7d4HWwRBNrnkaXUGahwR0E3Eya/qTgcwai2ib/hm5dYruu3G6xmN0jEZo + HV5D5Vnhilugwb1M950YGdXmZCCj2iKa7seo1E9wYyyK2r9Gk3eFxkGBy98voRwIo3SG+WYswvnKY37h + fFW/qJ+IUPbtOC2+Va56lrlyW6D+1iIKxwJV1hA1thAd3hU+ruhLBtIrzaI2sEpR5zAN7n9XawfCiVK5 + JUiJcQ6pcZ7WwWXSyo3JQFqFSdSMCFxq81DvXER+uGoJITX/iEQ/R0HPLEXaWVSuJc7JjgHOlZvETt8S + n6luU20NIeufT6wW6mb5/LvHXLj5iIuaGRoci5yVGXg75aJ4BDgr6xM7/cvcHI+iCcToHI3SPhxBPbRG + s3uVJtcKV50Cly0LpEoNvPXup64jQKrUaP+ozEhKmYEPS/V8UKLn/eJezhTrOCPRcbqoh/cKezhVqOVU + gfbOf+XD/AOeqLuKeIA3fgAAAABJRU5ErkJggg== iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m - dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHuSURBVDhPfZJbbxJRFIXPn+irpv+CZOIv88XamD7W - Ng1t41trNHINhHsgAYFwC4SrzAyIIwEBMWkTdKZyyfLsQxw7HezDeph99vrW3mcO63Q6aLfbaDabaDQa - qNfrqNVqqFarqFQqewDYY2KtVgubzcam0WgkIMVi8VEIo2QyDIdDkaxpmgmhGp8CuVzuvxBGI1MzpVGh - XC5XF4sFVquVqBOwUCggnU7vhDBK/dvIzb/5yEPDMPD92y/k4zNxNhgMkM1mkUqlbBBGydREWq/XIPOP - mYHzQxUnzxXkYltIv9+nKRCPxy0QxnfU5/O5aFoulyLZ+VLF1fEA7840XBz2TIiqqjQFwuGwCWGlUukZ - 31GfzbZNcuNGJF+ffMGHi682iKIoNAUCgYCACEo+n5f4jvp0OhVNvfYtnAcK3p7+gzgPVHyMbM+73S5N - AZ/Pt2fukslkJL6jPplMbJD3Tg1vjvq4PPrE/85aTBGNRuHxeJ6aABLfT0omk/p4PDYhZy9kXL7q4fq0 - h58L4755nzwWACmRSEixWEynl0gQtXWDq9efcWesTLPb7RZmkg1AikQiUigU0uklEoREZl63mEkW430F - g0EHv2mdHpgsyzvNJMvHQ/n9fofX69XJ7HK5bGaSrfBQPNXBzU92nQFgfwCJli/+LKS33wAAAABJRU5E + dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHuSURBVDhPhZLdb9JgGMX7T+xW439BQvzLvHEuZpdz + y4JbvNuMxpaPlED5CE2KhbS0gYwvgYKIBATEZCZoqxRyzPMS6hCGF+fm6XN+57xvX65er6NWq6FSqaBc + LuPm5galUgnFYhGmaR4A4PaJq1arWC6XWxoMBgyi6/peCEfJZOj3+yy51+t5EJqZpolcLncvhKPKtExp + NDAMozibzeC6LpsTUNM0KIqyE8JR6nrRMIzfuq73HcfB1y8/kU9N2LdutwtVVSHL8haEo+R15cViATJ/ + mzh4eWTh9EkLueQK0ul0qAVSqdQGhDNN055Op2xpPp+z5MAzC1cnXbw57+HiqO1BLMuiFojH4x6EKxQK + jzVNsyeT1VKzfMuSr08/4d3F5y1Iq9WiFhBFkUEYJZ/P+1VVtcfjMVtq174jcNjC67O/kMChhffS6nuj + 0aAWCIfDB95ZstmsX1EUezQabUHeBnp4ddzB5fEHuO6CtUgkEggGgw83blSWZX8mk7GHw6EHOX/axOXz + Nq7P2vgxc+6aH3lHuKt0Ou1PJpM2vUR2cdVbXL34iF+O65kFQWDmnQCSJEn+WCxm00tc/2IyS5K0Yb4X + QIpGoz5RFG16YM1mc6d5L4AUiUR8oVDIJjPP81vm/wJIgiD4eJ5/8O98rT+Jli/+ECJFiAAAAABJRU5E rkJggg== @@ -369,32 +369,32 @@ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m - dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpVLbSqJRGPVqruY9Zp7gf4F5jbkSEhFE - 00pFS4IyFSVTxHN4yAIPlCc8lGCpReJFqKAoaKJzNVeSMIgya/begw5SNAxzsfbP/vjWWt+3/s0D8F94 - s/geQqEQTk9P4Xa7Ybfbv/Kq1SooyuUySqUSisUiCoUCstksUqkULi8vEY1GcXFxgWAwyHrn8znG4zEs - FkuLV6lUsFgsMJvN3sXLywt8Pt+3yWSCTqeD4+PjgdFoFPHu7u5YYTQa4eHhAcPhELQ2GAxwfX2NXq+H - dDrNemKxGFqtFpvIYDB8oSuxvW5ubnB/f49MJsPd3t4iHo9zlHx2dsbRNTweD0fJVquVC4fDbP+Dg4PP - K4FcLodGo8Ey6Pf7LAPqTMntdnvlTATx9PRE3bG3t/dxJUBxdXWF6XSKfD4PkjSXTCapE0fHJWFxlHx0 - dMR5vV5KJpTfvJUAadTUajV0u11QMnWORCJoNptUkDmTVVCv16FUKn++EqAIBAI0aTidTthsNpo0SNI4 - PDzE/v4+tFotEokEtra23hb4GxQKxQe5XA6JRPJ9WWOH3+//RMZzORwOmjTMZjP0ej1Nmrmq1Wrs7OxA - JpORdvBEItF0TYCEpaHfk5MTqclkeiZk6bJhic3NTalYLH4mZKlAIPixrLODvGkNSZr9rsfHR5yfn7Ok - VSoVtre3QchwuVzsGZNe8Pn83prAEjqdjoW1u7v7agKhUCjd2NigZHL9U19r+neA9wvhROqXtIFlogAA + dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpZLPahpRGMVdddX3aJ9gXqCv0ZVgkAGJ + ozEqMUpAExUlE2UwziQhZtRANMRoxP+g+SeKi6BCgkJM0K66EoUiSk+5FyJYSwPt4jBwued3vu/cUQBQ + /I+WDt6TLMs4OjqCKIoQBOGr4u7uDkQ3Nzcol8solUrI5/PIZDJIpVK4uLhALBbD6ekpTk5O6N3pdIrB + YACe59uK29tbzGYzTCaTv2o0GuHw8PDbcDjE09MTdnd3e263m1VcX1/Tg36/j2q1itfXV5CzXq+HQqGA + breLq6sreicej6PdbtOJXC7Xl3kHxWIR9/f3SKfTTKVSwfn5OUPM4XCYIWtIksQQs8/nYyKRCN3fbrd/ + ngOy2SyazSbt4Pn5mXZAkon58fFxnhwOh/Hw8EDSYbVaP84BRIlEAuPxGLlcDrIsM8lkkiQxZFye5xli + 3tnZYQ4ODogZb745IBaLWer1OjqdDoiZJJ+dnaHVahEgTZYkCY1GAyaT6ecSgCgUCpGmsb+/D7/fT5qG + 2+2Gw+HA1tYWbDYbLi8vsba29mfAezIajR/0ej1WV1e/LwCOj48/SZIUDAQCpGl4vV44nU7SNE3d2NjA + +vo6dDod3Z1l2fECQBRFC/nu7e1pPR7Pi9Pp1P6eznGcVqPRvLAsq11ZWfmxABAEwcLzPH2uWq2GaDRK + mzabzTAYDOA4DsFgkP7GgiBAqVR2FwBv2t7epmVtbm4uTaBWq7UqlYqY50+4BPgX/QLhROqXAnK3iwAA AABJRU5ErkJggg== iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29m - dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpVLbSqJRGPVqruY9Zp7gf4F5jbkSEhFE - 00pFS4IyFSVTxHN4yAIPlCc8lGCpReJFqKAoaKJzNVeSMIgya/begw5SNAxzsfbP/vjWWt+3/s0D8F94 - s/geQqEQTk9P4Xa7Ybfbv/Kq1SooyuUySqUSisUiCoUCstksUqkULi8vEY1GcXFxgWAwyHrn8znG4zEs - FkuLV6lUsFgsMJvN3sXLywt8Pt+3yWSCTqeD4+PjgdFoFPHu7u5YYTQa4eHhAcPhELQ2GAxwfX2NXq+H - dDrNemKxGFqtFpvIYDB8oSuxvW5ubnB/f49MJsPd3t4iHo9zlHx2dsbRNTweD0fJVquVC4fDbP+Dg4PP - K4FcLodGo8Ey6Pf7LAPqTMntdnvlTATx9PRE3bG3t/dxJUBxdXWF6XSKfD4PkjSXTCapE0fHJWFxlHx0 - dMR5vV5KJpTfvJUAadTUajV0u11QMnWORCJoNptUkDmTVVCv16FUKn++EqAIBAI0aTidTthsNpo0SNI4 - PDzE/v4+tFotEokEtra23hb4GxQKxQe5XA6JRPJ9WWOH3+//RMZzORwOmjTMZjP0ej1Nmrmq1Wrs7OxA - JpORdvBEItF0TYCEpaHfk5MTqclkeiZk6bJhic3NTalYLH4mZKlAIPixrLODvGkNSZr9rsfHR5yfn7Ok - VSoVtre3QchwuVzsGZNe8Pn83prAEjqdjoW1u7v7agKhUCjd2NigZHL9U19r+neA9wvhROqXtIFlogAA + dHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAIwSURBVDhPpZLPahpRGMVdddX3aJ9gXqCv0ZVgkAGJ + ozEqMUpAExUlE2UwziQhZtRANMRoxP+g+SeKi6BCgkJM0K66EoUiSk+5FyJYSwPt4jBwued3vu/cUQBQ + /I+WDt6TLMs4OjqCKIoQBOGr4u7uDkQ3Nzcol8solUrI5/PIZDJIpVK4uLhALBbD6ekpTk5O6N3pdIrB + YACe59uK29tbzGYzTCaTv2o0GuHw8PDbcDjE09MTdnd3e263m1VcX1/Tg36/j2q1itfXV5CzXq+HQqGA + breLq6sreicej6PdbtOJXC7Xl3kHxWIR9/f3SKfTTKVSwfn5OUPM4XCYIWtIksQQs8/nYyKRCN3fbrd/ + ngOy2SyazSbt4Pn5mXZAkon58fFxnhwOh/Hw8EDSYbVaP84BRIlEAuPxGLlcDrIsM8lkkiQxZFye5xli + 3tnZYQ4ODogZb745IBaLWer1OjqdDoiZJJ+dnaHVahEgTZYkCY1GAyaT6ecSgCgUCpGmsb+/D7/fT5qG + 2+2Gw+HA1tYWbDYbLi8vsba29mfAezIajR/0ej1WV1e/LwCOj48/SZIUDAQCpGl4vV44nU7SNE3d2NjA + +vo6dDod3Z1l2fECQBRFC/nu7e1pPR7Pi9Pp1P6eznGcVqPRvLAsq11ZWfmxABAEwcLzPH2uWq2GaDRK + mzabzTAYDOA4DsFgkP7GgiBAqVR2FwBv2t7epmVtbm4uTaBWq7UqlYqY50+4BPgX/QLhROqXAnK3iwAA AABJRU5ErkJggg== @@ -416,52 +416,52 @@ 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 - AT99qmQqX2rFAAAAAElFTkSuQmCC + YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIHSURBVDhPY2CAgZnGrAxLtTMZluosYFim08KwWFcdLL5K + i4dhmVYhwzLteQzLdFoZlqlLwfWggKU6qxmW6fyH48U6PxiWaHoyLNO8jiK+VOsJwyotCTTNWjYgSYG1 + lv/9Tub8T7lc/z/3ZMX/eXNiP9S05/9LvdzwP/Jc2X/VbV5QQ7T7UA1YpjOBf435seQr9d+Kb/X8L7nZ + 8//QzJz/jzrT/l+dlPkTJAbDJrtDTzAs0bmGasBSXcHiW911MEUdh+r/Xy+N+L+/1f7v9UKvzz3bCuEG + FN/qugBSj2oAAwND8c3uuTBFlZc6lp0vDDl4Odnt28Ugtf/bKpz2Fl/s+AiSK7jZ8w5dLxjAXFB0s6cE + xH/RGiH+uMzz/5UMrT9Psm3UCm/26hff6vpcdKvnPLpeMCi+02NcfKPrOIz/uNjT81Gpx38wLvHyAKu5 + 2V1afKu7BUUjMii+0y0GYz+p8MqCGfCk0jsTJFZ/v54j99ZEPhRNuMCjCs++J+Ve/0H4cblXL7o8QfC4 + zHMj3AulHhvQ5fGC//vrOV50RZ1+0xP3H4RfdkaeAomhq0MB/w70a/490Nv3/0DvmfsHen//P9D7HxlD + xc6A1ezv0UBo3DaR/e+B3vmne+r/oWvChUFq/x7omQfSy/B3f28vugJiMUgvw78DfbZ/D/Qs/3+gdxUp + GKQHpBcAOYOqX5UHGvcAAAAASUVORK5CYII= diff --git a/SubProject/AmkorRestfulService b/SubProject/AmkorRestfulService deleted file mode 160000 index cd4e137..0000000 --- a/SubProject/AmkorRestfulService +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cd4e1379bc7b1fd493d1e70ca073fb47eb07d9a5 diff --git a/SubProject/ChatServer/App.config b/SubProject/ChatServer/App.config new file mode 100644 index 0000000..8645d93 --- /dev/null +++ b/SubProject/ChatServer/App.config @@ -0,0 +1,27 @@ + + + + +
+ + + + + + + + + + + + + + + 5000 + + + 1000 + + + + diff --git a/SubProject/ChatServer/ChatClient.cs b/SubProject/ChatServer/ChatClient.cs new file mode 100644 index 0000000..213e9cb --- /dev/null +++ b/SubProject/ChatServer/ChatClient.cs @@ -0,0 +1,174 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using Newtonsoft.Json; + +namespace NoticeServer +{ + /// + /// 연결된 채팅 클라이언트 정보 및 통신 관리 + /// + public class ChatClient + { + private TcpClient tcpClient; + private NetworkStream stream; + private Thread receiveThread; + private bool isConnected; + + /// 클라이언트 고유 ID + public string ClientId { get; private set; } + + /// 닉네임 + public string NickName { get; set; } + + /// 사원번호 (Employee ID) + public string EmployeeId { get; set; } + + /// 사용자 그룹 + public string UserGroup { get; set; } + + /// IP 주소 + public string IpAddress { get; private set; } + + /// 호스트명 + public string HostName { get; set; } + + /// 연결 시간 + public DateTime ConnectedTime { get; private set; } + + /// 마지막 활동 시간 + public DateTime LastActivity { get; set; } + + /// 메시지 수신 이벤트 + public event EventHandler MessageReceived; + + /// 연결 해제 이벤트 + public event EventHandler Disconnected; + + public ChatClient(TcpClient client) + { + tcpClient = client; + stream = client.GetStream(); + isConnected = true; + ClientId = Guid.NewGuid().ToString(); + ConnectedTime = DateTime.Now; + LastActivity = DateTime.Now; + + var endpoint = client.Client.RemoteEndPoint.ToString(); + IpAddress = endpoint.Split(':')[0]; + + StartReceiving(); + } + + /// + /// 메시지 수신 스레드 시작 + /// + private void StartReceiving() + { + receiveThread = new Thread(ReceiveLoop) + { + IsBackground = true, + Name = $"ChatClient-{ClientId}" + }; + receiveThread.Start(); + } + + /// + /// 메시지 수신 루프 + /// + private void ReceiveLoop() + { + byte[] buffer = new byte[4096]; + + try + { + while (isConnected && tcpClient.Connected) + { + int bytesRead = stream.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + { + // Connection closed + break; + } + + string jsonData = Encoding.UTF8.GetString(buffer, 0, bytesRead); + var message = JsonConvert.DeserializeObject(jsonData); + + if (message != null) + { + LastActivity = DateTime.Now; + + // Set nickname, employeeId, userGroup from Connect message + if (message.Type == MessageType.Connect && !string.IsNullOrEmpty(message.NickName)) + { + NickName = message.NickName; + EmployeeId = message.EmployeeId; + UserGroup = message.UserGroup; + HostName = message.HostName; + } + + MessageReceived?.Invoke(this, message); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Client {NickName ?? ClientId} receive error: {ex.Message}"); + } + finally + { + Disconnect(); + } + } + + /// + /// Send message + /// + public bool SendMessage(ChatMessage message) + { + if (!isConnected || !tcpClient.Connected) + return false; + + try + { + string jsonData = JsonConvert.SerializeObject(message); + byte[] data = Encoding.UTF8.GetBytes(jsonData); + stream.Write(data, 0, data.Length); + stream.Flush(); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Client {NickName ?? ClientId} send error: {ex.Message}"); + Disconnect(); + return false; + } + } + + /// + /// 연결 해제 + /// + public void Disconnect() + { + if (!isConnected) + return; + + isConnected = false; + + try + { + stream?.Close(); + tcpClient?.Close(); + } + catch { } + + Disconnected?.Invoke(this, ClientId); + } + + public override string ToString() + { + return $"{NickName ?? "Unknown"} ({IpAddress})"; + } + } +} diff --git a/SubProject/ChatServer/ChatMessage.cs b/SubProject/ChatServer/ChatMessage.cs new file mode 100644 index 0000000..bb9227d --- /dev/null +++ b/SubProject/ChatServer/ChatMessage.cs @@ -0,0 +1,76 @@ +using System; + +namespace NoticeServer +{ + /// + /// 채팅 메시지 타입 + /// + public enum MessageType + { + /// 클라이언트 연결 + Connect, + /// 클라이언트 연결 해제 + Disconnect, + /// 일반 채팅 메시지 + Chat, + /// 서버 공지 + Notice, + /// 귓속말 + Whisper, + /// 사용자 목록 요청 + UserListRequest, + /// 사용자 목록 응답 + UserListResponse, + /// 핑 (연결 유지) + Ping, + /// 퐁 (핑 응답) + Pong + } + + /// + /// 채팅 메시지 프로토콜 + /// + [Serializable] + public class ChatMessage + { + /// 메시지 타입 + public MessageType Type { get; set; } + + /// 발신자 닉네임 + public string NickName { get; set; } + + /// 발신자 사원번호 (Employee ID) + public string EmployeeId { get; set; } + + /// 발신자 IP + public string IpAddress { get; set; } + + /// 발신자 호스트명 + public string HostName { get; set; } + + /// 메시지 내용 + public string Content { get; set; } + + /// 수신자 사원번호 (1:1 채팅용) + public string TargetEmployeeId { get; set; } + + /// 수신자 닉네임 (귓속말용, null이면 전체) + public string TargetNickName { get; set; } + + /// 전송 시간 + public DateTime Timestamp { get; set; } + + /// 사용자 그룹 + public string UserGroup { get; set; } + + public ChatMessage() + { + Timestamp = DateTime.Now; + } + + public override string ToString() + { + return $"[{Timestamp:HH:mm:ss}] {NickName}: {Content}"; + } + } +} diff --git a/SubProject/ChatServer/NoticeServer.csproj b/SubProject/ChatServer/NoticeServer.csproj new file mode 100644 index 0000000..fda8916 --- /dev/null +++ b/SubProject/ChatServer/NoticeServer.csproj @@ -0,0 +1,73 @@ + + + + + Debug + AnyCPU + {8E9A4B1C-6D5F-4E2A-9F3B-1C8D7E6A5B4F} + Exe + NoticeServer + NoticeServer + v4.8 + 512 + true + true + + + x86 + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + ..\..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + Component + + + True + True + Settings.settings + + + + + + + + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + + \ No newline at end of file diff --git a/SubProject/ChatServer/NoticeService.cs b/SubProject/ChatServer/NoticeService.cs new file mode 100644 index 0000000..113dd6e --- /dev/null +++ b/SubProject/ChatServer/NoticeService.cs @@ -0,0 +1,116 @@ +using System; +using System.ServiceProcess; +using System.Threading; +using System.Threading.Tasks; +using System.Diagnostics; + +namespace NoticeServer +{ + public partial class NoticeService : ServiceBase + { + private CancellationTokenSource _cancellationTokenSource; + private Task _serviceTask; + private static TcpChatServer _tcpServer; + + public NoticeService() + { + InitializeComponent(); + ServiceName = "EETGWNoticeService"; + CanStop = true; + CanPauseAndContinue = false; + AutoLog = true; + } + + protected override void OnStart(string[] args) + { + try + { + EventLog.WriteEntry(ServiceName, "Notice 서비스가 시작됩니다.", EventLogEntryType.Information); + + _cancellationTokenSource = new CancellationTokenSource(); + _serviceTask = Task.Run(() => DoWork(_cancellationTokenSource.Token)); + + EventLog.WriteEntry(ServiceName, "Notice 서비스가 성공적으로 시작되었습니다.", EventLogEntryType.Information); + } + catch (Exception ex) + { + EventLog.WriteEntry(ServiceName, $"서비스 시작 중 오류 발생: {ex.Message}", EventLogEntryType.Error); + throw; + } + } + + protected override void OnStop() + { + try + { + EventLog.WriteEntry(ServiceName, "Notice 서비스를 중지합니다.", EventLogEntryType.Information); + + _cancellationTokenSource?.Cancel(); + + // Stop server + _tcpServer?.Stop(); + + if (_serviceTask != null) + { + // 최대 30초 대기 + if (!_serviceTask.Wait(TimeSpan.FromSeconds(30))) + { + EventLog.WriteEntry(ServiceName, "서비스 중지 시간 초과", EventLogEntryType.Warning); + } + } + + EventLog.WriteEntry(ServiceName, "Notice 서비스가 중지되었습니다.", EventLogEntryType.Information); + } + catch (Exception ex) + { + EventLog.WriteEntry(ServiceName, $"서비스 중지 중 오류 발생: {ex.Message}", EventLogEntryType.Error); + } + finally + { + _cancellationTokenSource?.Dispose(); + _serviceTask?.Dispose(); + } + } + + private void DoWork(CancellationToken cancellationToken) + { + EventLog.WriteEntry(ServiceName, "Notice 서비스 작업을 시작합니다.", EventLogEntryType.Information); + + try + { + // Read settings + int tcpPort = Properties.Settings.Default.TcpPort; + int maxClients = Properties.Settings.Default.MaxClients; + + // Start server + _tcpServer = new TcpChatServer(tcpPort, maxClients); + _tcpServer.Start(); + + EventLog.WriteEntry(ServiceName, $"Notice 서버가 포트 {tcpPort}에서 시작되었습니다.", EventLogEntryType.Information); + + // Wait for cancellation + while (!cancellationToken.IsCancellationRequested) + { + if (cancellationToken.WaitHandle.WaitOne(1000)) + { + break; // 취소 요청 시 종료 + } + } + } + catch (Exception ex) + { + EventLog.WriteEntry(ServiceName, $"서버 실행 중 오류 발생: {ex.Message}\n{ex.StackTrace}", EventLogEntryType.Error); + } + + EventLog.WriteEntry(ServiceName, "Notice 서비스 작업이 종료되었습니다.", EventLogEntryType.Information); + } + + private void InitializeComponent() + { + // + // NoticeService + // + this.ServiceName = "EETGWNoticeService"; + } + } +} diff --git a/SubProject/ChatServer/Program.cs b/SubProject/ChatServer/Program.cs new file mode 100644 index 0000000..b8f8e6c --- /dev/null +++ b/SubProject/ChatServer/Program.cs @@ -0,0 +1,288 @@ +using System; +using System.Configuration; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.ServiceProcess; +using System.Threading; + +namespace NoticeServer +{ + partial class Program + { + static TcpChatServer tcpServer; + static bool isRunning = true; + + static void Main(string[] args) + { + // 명령행 인수 처리 + if (args.Length > 0) + { + string command = args[0].ToLower(); + switch (command) + { + case "-install": + case "/install": + InstallService(); + return; + case "-uninstall": + case "/uninstall": + UninstallService(); + return; + case "-console": + case "/console": + RunAsConsole(); + return; + case "-help": + case "/help": + case "/?": + ShowHelp(); + return; + } + } + + // 서비스로 실행 + if (Environment.UserInteractive) + { + // 대화형 모드에서는 콘솔로 실행 + RunAsConsole(); + } + else + { + // 서비스로 실행 + ServiceBase[] ServicesToRun; + ServicesToRun = new ServiceBase[] + { + new NoticeService() + }; + ServiceBase.Run(ServicesToRun); + } + } + + private static void ShowHelp() + { + Console.WriteLine("EETGW Notice Server (Chat Server)"); + Console.WriteLine("사용법:"); + Console.WriteLine(" NoticeServer.exe - 서비스로 실행 (또는 대화형 모드에서 콘솔 실행)"); + Console.WriteLine(" NoticeServer.exe -console - 콘솔 모드로 실행"); + Console.WriteLine(" NoticeServer.exe -install - 서비스 설치"); + Console.WriteLine(" NoticeServer.exe -uninstall - 서비스 제거"); + Console.WriteLine(" NoticeServer.exe -help - 도움말 표시"); + } + + private static void InstallService() + { + try + { + string servicePath = $"\"{System.Reflection.Assembly.GetExecutingAssembly().Location}\""; + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = "sc.exe", + Arguments = $"create EETGWNoticeService binPath= \"{servicePath}\" DisplayName= \"EETGW Notice Service\" start= auto", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (Process process = Process.Start(startInfo)) + { + process.WaitForExit(); + if (process.ExitCode == 0) + { + Console.WriteLine("서비스가 성공적으로 설치되었습니다."); + Console.WriteLine("서비스를 시작하려면 다음 명령을 실행하세요:"); + Console.WriteLine("net start EETGWNoticeService"); + } + else + { + string error = process.StandardError.ReadToEnd(); + Console.WriteLine($"서비스 설치 실패: {error}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"서비스 설치 중 오류 발생: {ex.Message}"); + } + } + + private static void UninstallService() + { + try + { + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = "sc.exe", + Arguments = "delete EETGWNoticeService", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (Process process = Process.Start(startInfo)) + { + process.WaitForExit(); + if (process.ExitCode == 0) + { + Console.WriteLine("서비스가 성공적으로 제거되었습니다."); + } + else + { + string error = process.StandardError.ReadToEnd(); + Console.WriteLine($"서비스 제거 실패: {error}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"서비스 제거 중 오류 발생: {ex.Message}"); + } + } + + private static void RunAsConsole() + { + // 중복실행 방지 체크 + if (!CheckSingleInstance()) + { + return; // 프로그램 종료 + } + + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.Title = "EETGW Notice Server (Chat Server)"; + Console.WriteLine("========================================"); + Console.WriteLine(" EETGW Notice Server v1.0 (콘솔 모드)"); + Console.WriteLine("========================================\n"); + + // Read settings + int tcpPort = Properties.Settings.Default.TcpPort; + int maxClients = Properties.Settings.Default.MaxClients; + + // Get local IP address + string localIP = GetLocalIPAddress(); + string hostName = Dns.GetHostName(); + + Console.WriteLine($"Server IP: {localIP}"); + Console.WriteLine($"Host Name: {hostName}"); + Console.WriteLine($"TCP Port: {tcpPort}"); + Console.WriteLine($"Max Clients: {maxClients}\n"); + + // Start server + tcpServer = new TcpChatServer(tcpPort, maxClients); + tcpServer.Start(); + + Console.WriteLine("\nServer started successfully."); + Console.WriteLine("Commands: status, clear, quit, help"); + Console.WriteLine("종료하려면 Ctrl+C를 누르거나 'quit'를 입력하세요.\n"); + + // Ctrl+C handler + Console.CancelKeyPress += (sender, e) => + { + Console.WriteLine("\n서버를 종료합니다..."); + e.Cancel = true; + isRunning = false; + }; + + // Command processing loop + while (isRunning) + { + string command = Console.ReadLine()?.Trim().ToLower(); + + switch (command) + { + case "status": + case "s": + tcpServer.PrintStatus(); + break; + + case "clear": + case "cls": + Console.Clear(); + Console.WriteLine("EETGW Notice Server - Commands: status, clear, quit\n"); + break; + + case "quit": + case "exit": + case "q": + isRunning = false; + break; + + case "help": + case "h": + case "?": + PrintHelp(); + break; + + case "": + break; + + default: + Console.WriteLine($"Unknown command: {command}"); + Console.WriteLine("Available commands: status, clear, quit, help"); + break; + } + + Thread.Sleep(100); + } + + // Server shutdown + Console.WriteLine("\nShutting down server..."); + tcpServer.Stop(); + + Console.WriteLine("Server stopped."); + Thread.Sleep(1000); + } + + /// + /// 중복실행 방지 체크 + /// + /// 단일 인스턴스인 경우 true, 중복실행인 경우 false + static bool CheckSingleInstance() + { + string processName = Process.GetCurrentProcess().ProcessName; + Process[] processes = Process.GetProcessesByName(processName); + + if (processes.Length > 1) + { + // 중복실행 감지 + string message = $"⚠️ 프로그램이 이미 실행 중입니다!\n\n" + + "동시에 여러 개의 프로그램을 실행할 수 없습니다.\n"; + + Console.WriteLine(message); + return false; + } + + return true; // 단일 인스턴스 + } + + static string GetLocalIPAddress() + { + try + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + var ipAddress = host.AddressList + .FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork); + + if (ipAddress != null) + return ipAddress.ToString(); + } + catch { } + + return "127.0.0.1"; + } + + static void PrintHelp() + { + Console.WriteLine("\n========================================"); + Console.WriteLine("Available Commands:"); + Console.WriteLine("========================================"); + Console.WriteLine("status (s) - Show server status and client list"); + Console.WriteLine("clear (cls) - Clear screen"); + Console.WriteLine("quit (q) - Stop server"); + Console.WriteLine("help (h) - Show this help"); + Console.WriteLine("========================================\n"); + } + } +} diff --git a/SubProject/ChatServer/Properties/AssemblyInfo.cs b/SubProject/ChatServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..339928c --- /dev/null +++ b/SubProject/ChatServer/Properties/AssemblyInfo.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해 +// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면 +// 이러한 특성 값을 변경하세요. +[assembly: AssemblyTitle("ChatServer")] +[assembly: AssemblyDescription("GroupWare Chat Server - TCP/UDP 기반 다중 사용자 채팅 서버")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ChatServer")] +[assembly: AssemblyCopyright("Copyright © 2025")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에 +// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면 +// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요. +[assembly: ComVisible(false)] + +// 이 프로젝트가 COM에 노출되는 경우 다음 GUID는 typelib의 ID를 나타냅니다. +[assembly: Guid("8e9a4b1c-6d5f-4e2a-9f3b-1c8d7e6a5b4f")] + +// 어셈블리의 버전 정보는 다음 네 가지 값으로 구성됩니다. +// +// 주 버전 +// 부 버전 +// 빌드 번호 +// 수정 버전 +// +[assembly: AssemblyVersion("25.11.12.1300")] +[assembly: AssemblyFileVersion("25.11.12.1300")] diff --git a/SubProject/ChatServer/Properties/Settings.Designer.cs b/SubProject/ChatServer/Properties/Settings.Designer.cs new file mode 100644 index 0000000..567a4b9 --- /dev/null +++ b/SubProject/ChatServer/Properties/Settings.Designer.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// +// 이 코드는 도구를 사용하여 생성되었습니다. +// 런타임 버전:4.0.30319.42000 +// +// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면 +// 이러한 변경 내용이 손실됩니다. +// +//------------------------------------------------------------------------------ + +namespace ChatServer.Properties { + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.9.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default { + get { + return defaultInstance; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("5000")] + public int TcpPort { + get { + return ((int)(this["TcpPort"])); + } + set { + this["TcpPort"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("1000")] + public int MaxClients { + get { + return ((int)(this["MaxClients"])); + } + set { + this["MaxClients"] = value; + } + } + } +} diff --git a/SubProject/ChatServer/Properties/Settings.settings b/SubProject/ChatServer/Properties/Settings.settings new file mode 100644 index 0000000..c93109f --- /dev/null +++ b/SubProject/ChatServer/Properties/Settings.settings @@ -0,0 +1,12 @@ + + + + + + 5000 + + + 1000 + + + \ No newline at end of file diff --git a/SubProject/ChatServer/TcpChatServer.cs b/SubProject/ChatServer/TcpChatServer.cs new file mode 100644 index 0000000..b0f27d3 --- /dev/null +++ b/SubProject/ChatServer/TcpChatServer.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +namespace NoticeServer +{ + /// + /// TCP 채팅 서버 + /// + public class TcpChatServer + { + private TcpListener tcpListener; + private Thread acceptThread; + private bool isRunning; + private int tcpPort; + private int maxClients; + + private readonly object clientsLock = new object(); + private List clients = new List(); + + public TcpChatServer(int tcpPort, int maxClients = 100) + { + this.tcpPort = tcpPort; + this.maxClients = maxClients; + } + + /// + /// TCP 채팅 서버 시작 + /// + public void Start() + { + if (isRunning) + return; + + try + { + tcpListener = new TcpListener(IPAddress.Any, tcpPort); + tcpListener.Start(); + isRunning = true; + + acceptThread = new Thread(AcceptLoop) + { + IsBackground = true, + Name = "TcpAcceptThread" + }; + acceptThread.Start(); + + Console.WriteLine($"[TCP] Chat server started (Port: {tcpPort})"); + Console.WriteLine($"[TCP] Max clients: {maxClients}"); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Failed to start TCP server: {ex.Message}"); + isRunning = false; + } + } + + /// + /// 클라이언트 연결 수락 루프 + /// + private void AcceptLoop() + { + while (isRunning) + { + try + { + TcpClient tcpClient = tcpListener.AcceptTcpClient(); + + lock (clientsLock) + { + if (clients.Count >= maxClients) + { + Console.WriteLine("[REJECTED] Maximum clients exceeded"); + tcpClient.Close(); + continue; + } + } + + var client = new ChatClient(tcpClient); + client.MessageReceived += OnClientMessageReceived; + client.Disconnected += OnClientDisconnected; + + lock (clientsLock) + { + clients.Add(client); + } + + Console.WriteLine($"[CONNECTED] New client: {client.IpAddress} (ID: {client.ClientId})"); + } + catch (SocketException) + { + // Server shutdown + break; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Client accept error: {ex.Message}"); + } + } + } + + /// + /// 클라이언트 메시지 수신 이벤트 핸들러 + /// + private void OnClientMessageReceived(object sender, ChatMessage message) + { + var client = sender as ChatClient; + if (client == null) + return; + + Console.WriteLine($"[RECEIVED] {client.NickName ?? client.IpAddress}: {message.Type} - {message.Content}"); + + switch (message.Type) + { + case MessageType.Connect: + HandleConnect(client, message); + break; + + case MessageType.Chat: + HandleChat(client, message); + break; + + case MessageType.Whisper: + HandleWhisper(client, message); + break; + + case MessageType.UserListRequest: + HandleUserListRequest(client); + break; + + case MessageType.Ping: + HandlePing(client); + break; + + case MessageType.Disconnect: + client.Disconnect(); + break; + } + } + + /// + /// 연결 처리 + /// + private void HandleConnect(ChatClient client, ChatMessage message) + { + // Check employee ID duplication + lock (clientsLock) + { + if (!string.IsNullOrEmpty(message.EmployeeId) && + clients.Any(c => c.ClientId != client.ClientId && c.EmployeeId == message.EmployeeId)) + { + var errorMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = "Employee ID already connected.", + Timestamp = DateTime.Now + }; + client.SendMessage(errorMsg); + client.Disconnect(); + return; + } + } + + Console.WriteLine($"[LOGIN] {message.NickName} ({message.EmployeeId}, {message.UserGroup}) from {client.IpAddress}, {message.HostName}"); + + // Welcome message + var welcomeMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = $"Welcome to the chat server, {message.NickName}!", + Timestamp = DateTime.Now + }; + client.SendMessage(welcomeMsg); + + // Notify other users + var noticeMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = $"{message.NickName} ({message.EmployeeId}) has joined the chat.", + Timestamp = DateTime.Now + }; + BroadcastMessage(noticeMsg, client.ClientId); + } + + /// + /// 채팅 메시지 처리 (1:1 messaging) + /// + private void HandleChat(ChatClient client, ChatMessage message) + { + // Set sender information + message.NickName = client.NickName; + message.EmployeeId = client.EmployeeId; + message.UserGroup = client.UserGroup; + message.IpAddress = client.IpAddress; + message.HostName = client.HostName; + message.Timestamp = DateTime.Now; + + // Send to target recipient (1:1 messaging) + if (!string.IsNullOrEmpty(message.TargetEmployeeId)) + { + lock (clientsLock) + { + // Find target by employee ID + var targetClient = clients.FirstOrDefault(c => c.EmployeeId == message.TargetEmployeeId); + if (targetClient != null) + { + // Send to target + targetClient.SendMessage(message); + // Echo back to sender + client.SendMessage(message); + Console.WriteLine($"[CHAT] {client.EmployeeId} -> {message.TargetEmployeeId}: {message.Content}"); + } + else + { + var errorMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = $"User '{message.TargetEmployeeId}' not found or offline.", + Timestamp = DateTime.Now + }; + client.SendMessage(errorMsg); + } + } + } + else + { + // No target specified, send error + var errorMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = "Please select a recipient.", + Timestamp = DateTime.Now + }; + client.SendMessage(errorMsg); + } + } + + /// + /// Whisper message handler + /// + private void HandleWhisper(ChatClient client, ChatMessage message) + { + message.NickName = client.NickName; + message.IpAddress = client.IpAddress; + message.Timestamp = DateTime.Now; + + lock (clientsLock) + { + var targetClient = clients.FirstOrDefault(c => c.NickName == message.TargetNickName); + if (targetClient != null) + { + targetClient.SendMessage(message); + Console.WriteLine($"[WHISPER] {client.NickName} -> {message.TargetNickName}: {message.Content}"); + } + else + { + var errorMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = $"User '{message.TargetNickName}' not found.", + Timestamp = DateTime.Now + }; + client.SendMessage(errorMsg); + } + } + } + + /// + /// 사용자 목록 요청 처리 + /// + private void HandleUserListRequest(ChatClient client) + { + lock (clientsLock) + { + // Format: "employeeId1:nickName1:userGroup1,employeeId2:nickName2:userGroup2,..." + // Filter: dev can see all, others can only see same group + var filteredClients = clients + .Where(c => !string.IsNullOrEmpty(c.NickName) && !string.IsNullOrEmpty(c.EmployeeId)) + .Where(c => client.UserGroup == "dev" || c.UserGroup == client.UserGroup); + + var userListStr = string.Join(",", filteredClients + .Select(c => $"{c.EmployeeId}:{c.NickName}:{c.UserGroup ?? "default"}")); + + var response = new ChatMessage + { + Type = MessageType.UserListResponse, + Content = userListStr, + Timestamp = DateTime.Now + }; + + client.SendMessage(response); + + Console.WriteLine($"[USER_LIST] Sent to {client.EmployeeId} ({client.UserGroup}): {userListStr}"); + } + } + + /// + /// Ping 처리 + /// + private void HandlePing(ChatClient client) + { + var pongMsg = new ChatMessage + { + Type = MessageType.Pong, + Timestamp = DateTime.Now + }; + client.SendMessage(pongMsg); + } + + /// + /// 클라이언트 연결 해제 이벤트 핸들러 + /// + private void OnClientDisconnected(object sender, string clientId) + { + ChatClient disconnectedClient = null; + + lock (clientsLock) + { + disconnectedClient = clients.FirstOrDefault(c => c.ClientId == clientId); + if (disconnectedClient != null) + { + clients.Remove(disconnectedClient); + } + } + + if (disconnectedClient != null) + { + Console.WriteLine($"[DISCONNECTED] {disconnectedClient.NickName ?? disconnectedClient.IpAddress} (ID: {clientId})"); + + if (!string.IsNullOrEmpty(disconnectedClient.NickName)) + { + var noticeMsg = new ChatMessage + { + Type = MessageType.Notice, + Content = $"{disconnectedClient.NickName} has left the chat.", + Timestamp = DateTime.Now + }; + BroadcastMessage(noticeMsg); + } + } + } + + /// + /// 모든 클라이언트에게 메시지 전송 + /// + private void BroadcastMessage(ChatMessage message, string excludeClientId = null) + { + lock (clientsLock) + { + foreach (var client in clients.ToList()) + { + if (excludeClientId != null && client.ClientId == excludeClientId) + continue; + + if (!string.IsNullOrEmpty(client.NickName)) + { + client.SendMessage(message); + } + } + } + } + + /// + /// 서버 상태 출력 + /// + public void PrintStatus() + { + Console.WriteLine("\n========================================"); + Console.WriteLine($"Current Clients: {clients.Count}/{maxClients}"); + Console.WriteLine("========================================"); + + lock (clientsLock) + { + foreach (var client in clients) + { + if (!string.IsNullOrEmpty(client.NickName)) + { + Console.WriteLine($"- {client.NickName} ({client.EmployeeId}, {client.UserGroup})"); + Console.WriteLine($" IP: {client.IpAddress}, Host: {client.HostName}"); + Console.WriteLine($" Connected: {client.ConnectedTime:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($" Last Activity: {client.LastActivity:HH:mm:ss}"); + } + } + } + + Console.WriteLine("========================================\n"); + } + + /// + /// TCP 서버 중지 + /// + public void Stop() + { + if (!isRunning) + return; + + isRunning = false; + + // Disconnect all clients + lock (clientsLock) + { + foreach (var client in clients.ToList()) + { + client.Disconnect(); + } + clients.Clear(); + } + + try + { + tcpListener?.Stop(); + } + catch { } + + Console.WriteLine("[TCP] Chat server stopped"); + } + } +} diff --git a/SubProject/ChatServer/packages.config b/SubProject/ChatServer/packages.config new file mode 100644 index 0000000..efd7b64 --- /dev/null +++ b/SubProject/ChatServer/packages.config @@ -0,0 +1,4 @@ + + + + From cbbef7a016b7ebda8ec90937f4d5152aa162d3de Mon Sep 17 00:00:00 2001 From: backuppc Date: Thu, 13 Nov 2025 13:08:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=97=90=EC=84=9C=20=EC=82=AC=EB=B2=88|=EB=B6=80?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=20AddPrgmUser5=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project/Pub.cs | 12 +++++++++--- Project/fMain.cs | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Project/Pub.cs b/Project/Pub.cs index 2e7e962..39a9494 100644 --- a/Project/Pub.cs +++ b/Project/Pub.cs @@ -299,7 +299,7 @@ namespace Project } } - public static void CheckNRegister3(string prgmName, string develop, string prgmVersion) + public static void CheckNRegister5(string prgmName, string develop, string prgmVersion,string mcid, string remark) { if (prgmName.Length > 50) prgmName = prgmName.Substring(0, 50); //길이제한 var task = Task.Factory.StartNew(() => @@ -345,7 +345,7 @@ namespace Project SqlConnection conn = new SqlConnection(Properties.Settings.Default.CS);// "Data Source=K4FASQL.kr.ds.amkor.com,50150;Initial Catalog=EE;Persist Security Info=True;User ID=eeadm;Password=uJnU8a8q&DJ+ug-D!" ; conn.Open(); - string ProcName = "AddPrgmUser3"; + string ProcName = "AddPrgmUser5"; SqlCommand cmd = new SqlCommand(ProcName, conn); cmd.CommandType = CommandType.StoredProcedure; @@ -373,7 +373,13 @@ namespace Project param = cmd.Parameters.Add("@hostname", SqlDbType.NVarChar, 100); param.Value = fullname; - cmd.ExecuteNonQuery(); + param = cmd.Parameters.Add("@mcid", SqlDbType.NVarChar, 10); + param.Value = mcid; + + param = cmd.Parameters.Add("@remark", SqlDbType.NVarChar, 255); + param.Value = remark; + + var cnt = cmd.ExecuteNonQuery(); conn.Close(); } catch (Exception ex) diff --git a/Project/fMain.cs b/Project/fMain.cs index f7b6890..95633ea 100644 --- a/Project/fMain.cs +++ b/Project/fMain.cs @@ -215,7 +215,8 @@ namespace Project UpdateControls(); //사용기록추적 - Pub.CheckNRegister3(Application.ProductName, "chi", Application.ProductVersion); + var remark = $"{FCOMMON.info.Login.gcode}|{FCOMMON.info.Login.dept}"; + Pub.CheckNRegister5(Application.ProductName, "chi", Application.ProductVersion, FCOMMON.info.Login.no, remark); } void Update_FavoriteSite()