ChatSystem init...1

This commit is contained in:
최준흠 2026-01-16 15:40:38 +09:00
commit bad40540bd
7 changed files with 1210 additions and 0 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
DB_HOST=localhost
DB_USER=root
DB_PASS=
DB_NAME=chat_db
PORT=3000
SESSION_SECRET=your_secret_key

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
#codeigniter4
.env
package-lock.json
node_modules/
public/vendor/
uploads/

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "chatsystem",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "http://gitlab.idcjp.jp:3000/idcjp/ChatSystem.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"fs-extra": "^11.3.3",
"multer": "^2.0.2",
"mysql2": "^3.16.0",
"socket.io": "^4.8.3"
}
}

382
public/chat.js Normal file
View File

@ -0,0 +1,382 @@
const socket = io();
// UI Elements
const chatMessages = document.getElementById('chat-messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const authToggleBtn = document.getElementById('auth-toggle-btn');
const loginArea = document.getElementById('login-area');
const chatInputArea = document.getElementById('chat-input-area');
const loginSubmitBtn = document.getElementById('login-submit-btn');
const loginCancelBtn = document.getElementById('login-cancel-btn');
const imageBtn = document.getElementById('image-btn');
const imageInput = document.getElementById('image-input');
const replyInfo = document.getElementById('reply-info');
const sidebar = document.getElementById('sidebar');
const roomListContainer = document.getElementById('room-list');
const roomSearchInput = document.getElementById('room-search');
const headerAvatar = document.getElementById('header-avatar');
const targetNameLabel = document.getElementById('target-name');
const targetStatusLabel = document.getElementById('target-status');
const exitReplyBtn = document.getElementById('exit-reply-btn');
const signupShowBtn = document.getElementById('signup-show-btn');
// Unique Anonymous ID generation/recovery
function getAnonymousId() {
let id = localStorage.getItem('chat_anon_id');
if (!id) {
id = 'anon_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('chat_anon_id', id);
}
return id;
}
const isAdminPath = window.location.pathname.includes('/admin');
// Load User from LocalStorage or create Anonymous
let savedUser = localStorage.getItem('chat_user');
let currentUser;
// CI4 Session Bridge: CI4 뷰에서 넘겨준 세션 정보가 있는지 확인
// CI4 PHP 예시: window.chatConfig = { isLog-in: <?= session()->get('ISLGIN') ?>, auth: <?= json_encode(session()->get('AUTH')) ?> };
if (window.chatConfig && window.chatConfig.ISLGIN) {
const auth = window.chatConfig.AUTH;
currentUser = {
id: auth.id || auth.uid,
username: auth.name || auth.id,
role: auth.role || 'user'
};
// 세션 정보가 있으면 로컬 스토리지도 동기화 (선택 사항)
localStorage.setItem('chat_user', JSON.stringify(currentUser));
} else if (savedUser) {
currentUser = JSON.parse(savedUser);
} else {
currentUser = { id: getAnonymousId(), username: '익명', role: 'guest' };
}
if (currentUser && currentUser.role !== 'guest') {
if (authToggleBtn) authToggleBtn.textContent = '로그아웃 (' + currentUser.username + ')';
}
let currentRoom = (isAdminPath && isConsultantRole(currentUser.role))
? 'consultants_group'
: 'room_' + currentUser.id;
let currentConsultant = null;
// Initialize
if (isAdminPath) {
const headerTitle = document.querySelector('#chat-header span');
if (headerTitle) headerTitle.innerText = 'IDC 상담원 관리 시스템';
// 상담원 채널 접속 시 로그인 영역 자동 표시 및 사이드바 노출 준비
if (currentUser.role === 'guest') {
loginArea.style.display = 'flex';
} else if (isConsultantRole(currentUser.role)) {
if (sidebar) sidebar.style.display = 'flex';
}
} else {
// 일반 채팅 경로(/chat)
authToggleBtn.innerText = '상담원 로그인';
if (sidebar) sidebar.style.display = 'none';
if (replyInfo) replyInfo.style.display = 'none';
// 일반 사용자용 화면은 너비를 컴팩트하게 조정 (기존 1000px의 70% 수준인 700px)
const chatWidget = document.getElementById('chat-widget');
if (chatWidget) chatWidget.style.width = '700px';
}
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
// 룸 목록 업데이트 수신 (상담원 전용)
socket.on('update_room_list', (roomList) => {
if (!roomListContainer) return;
allRoomsData = roomList; // 검색 필터링을 위해 저장
renderRoomList(allRoomsData);
});
let allRoomsData = [];
function getAvatarColor(name) {
const colors = ['#0078d4', '#107c10', '#d13438', '#008575', '#8764b8', '#004e8c'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function renderRoomList(rooms) {
// 기본 모니터링 룸 추가
let listHtml = `
<div class="room-item ${currentRoom === 'consultants_group' ? 'active' : ''}" onclick="exitReplyMode()">
<div class="avatar" style="background: #605e5c;">📢</div>
<div class="room-info">
<span class="name">전체 모니터링</span>
<span class="preview">모든 실시간 대화 감시</span>
</div>
</div>
`;
rooms.forEach(roomInfo => {
const isActive = currentRoom === roomInfo.room;
const initial = roomInfo.username.charAt(0);
const avatarColor = getAvatarColor(roomInfo.username);
listHtml += `
<div class="room-item ${isActive ? 'active' : ''}" onclick="joinUserRoom('${roomInfo.room}', '${roomInfo.username}')">
<div class="avatar" style="background: ${avatarColor};">${initial}</div>
<div class="room-info">
<span class="name">${roomInfo.username}</span>
<span class="preview">최근 메시지 확인 ...</span>
</div>
</div>
`;
});
roomListContainer.innerHTML = listHtml;
}
// 룸 검색 필터링 제거 (사용자 요청)
// if (roomSearchInput) { ... }
// Send Message
async function sendMessage(text = null, imageUrl = null) {
const message = text || messageInput.value.trim();
if (message || imageUrl) {
const data = {
room: currentRoom,
sender: currentUser.username,
user_code: currentUser.id,
consultant_code: currentConsultant,
message: imageUrl || message,
msg_type: imageUrl ? 'image' : 'text',
role: currentUser.role, // Add role to metadata
timestamp: new Date().toLocaleTimeString()
};
socket.emit('send_message', data);
if (!imageUrl) messageInput.value = '';
}
}
sendBtn.addEventListener('click', () => sendMessage());
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// Image Upload Handling
imageBtn.addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) await uploadAndSendImage(file);
imageInput.value = '';
});
// Clipboard Paste Handling (Screen Capture)
messageInput.addEventListener('paste', async (e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (!clipboardData) return;
const items = clipboardData.items;
let imageFound = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
imageFound = true;
console.log('이미지 붙여넣기 감지됨:', file.name);
await uploadAndSendImage(file);
}
}
}
// 이미지를 전송했다면 텍스트박스에 텍스트가 중복으로 들어가는 것을 방지하고 싶을 때 사용 (선택 사항)
// if (imageFound) e.preventDefault();
});
async function uploadAndSendImage(file) {
const formData = new FormData();
formData.append('image', file);
console.log('이미지 업로드 시도 중:', file.name);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`서버 응답 오류: ${response.status}`);
}
const result = await response.json();
if (result.success) {
console.log('이미지 업로드 성공:', result.imageUrl);
await sendMessage(null, result.imageUrl);
} else {
alert('이미지 업로드 실패: ' + result.message);
}
} catch (err) {
console.error('이미지 업로드 프로세스 오류:', err);
alert('이미지 전송 중 오류가 발생했습니다. 서버 로그를 확인하거나 다시 시도해 주세요.');
}
}
// Helper: 상담원 역할 확인
function isConsultantRole(role) {
if (!role) return false;
const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant'];
const roles = String(role).toLowerCase();
return consultantKeywords.some(keyword => roles.includes(keyword));
}
// Receive Message
socket.on('receive_message', (data) => {
if (!chatMessages) return;
const msgDiv = document.createElement('div');
msgDiv.classList.add('message');
// Align messages: Consultant right, Guest left (Skype style is often left-dominant, but we follow chat standards)
const isConsultant = isConsultantRole(data.role);
const isMe = (data.user_code === currentUser.id);
msgDiv.classList.add('message');
msgDiv.classList.add(isMe ? 'right' : 'left');
let contentHtml = '';
if (data.msg_type === 'image') {
contentHtml += `<img src="${data.message}" style="max-width:100%; border-radius:8px; cursor:pointer;" onclick="window.open(this.src)">`;
} else {
contentHtml += `<span>${data.message}</span>`;
}
msgDiv.innerHTML = contentHtml;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// 만약 상담원이고 전체 모니터링 모드라면 룸 목록의 미리보기 텍스트 업데이트
if (isAdminPath && isConsultantRole(currentUser.role) && currentRoom === 'consultants_group') {
const roomItem = document.querySelector(`.room-item[onclick*="${data.room}"] .preview`);
if (roomItem) {
roomItem.innerText = data.msg_type === 'image' ? '(이미지 메시지)' : data.message;
}
}
});
// 상담원이 특정 유저의 대화방에 참여하는 함수 (답장 기능)
function joinUserRoom(roomName, userName) {
if (currentRoom === roomName) return; // 이미 해당 방이면 무시
// 기존 대화 내용 비우기
chatMessages.innerHTML = '';
currentRoom = roomName;
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
// UI 업데이트
updateSidebarActive();
if (targetNameLabel) targetNameLabel.innerText = userName;
if (targetStatusLabel) targetStatusLabel.innerText = '상담 중';
if (headerAvatar) {
headerAvatar.innerText = userName.charAt(0);
headerAvatar.style.background = getAvatarColor(userName);
}
if (exitReplyBtn) exitReplyBtn.style.display = 'block';
const sysMsg = document.createElement('div');
sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;";
sysMsg.innerHTML = `--- <b>${userName}</b> 님과의 대화가 시작되었습니다 ---`;
chatMessages.appendChild(sysMsg);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function updateSidebarActive() {
const items = document.querySelectorAll('.room-item');
items.forEach(item => {
// onclick 속성에서 룸 분석 (간결한 구현을 위해 속성 매칭)
if (item.getAttribute('onclick').includes(currentRoom)) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
}
// 답장 모드 종료 (전체 모니터링으로 복귀)
function exitReplyMode() {
if (currentRoom === 'consultants_group') return;
chatMessages.innerHTML = '';
currentRoom = 'consultants_group';
socket.emit('join_room', { room: currentRoom, user_id: currentUser.id, role: currentUser.role });
updateSidebarActive();
if (targetNameLabel) targetNameLabel.innerText = '전체 모니터링';
if (targetStatusLabel) targetStatusLabel.innerText = '실시간 대기 중';
if (headerAvatar) {
headerAvatar.innerText = '📢';
headerAvatar.style.background = '#605e5c';
}
if (exitReplyBtn) exitReplyBtn.style.display = 'none';
const sysMsg = document.createElement('div');
sysMsg.style.cssText = "text-align:center; margin:20px 0; font-size:12px; color:var(--text-sub); width:100%;";
sysMsg.innerHTML = `--- 실시간 전체 대화 모니터링 중입니다 ---`;
chatMessages.appendChild(sysMsg);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (exitReplyBtn) {
exitReplyBtn.addEventListener('click', exitReplyMode);
}
// Auth Toggle
authToggleBtn.addEventListener('click', () => {
if (currentUser.role === 'guest') {
loginArea.style.display = loginArea.style.display === 'flex' ? 'none' : 'flex';
} else {
// 로그아웃 처리: 세션 삭제 후 새로고침
localStorage.removeItem('chat_user');
location.reload();
}
});
// Handle Login Cancellation
loginCancelBtn.addEventListener('click', () => {
if (isAdminPath) {
location.href = '/chat';
} else {
loginArea.style.display = 'none';
}
});
// Handle Login Submit
loginSubmitBtn.addEventListener('click', async () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (result.success) {
// 로그인 정보 저장 및 새로고침
localStorage.setItem('chat_user', JSON.stringify(result.user));
alert(`${result.user.username}님 환영합니다!`);
location.reload();
} else {
alert('로그인 실패: ' + result.message);
}
} catch (err) {
console.error('Login error:', err);
alert('로그인 처리 중 오류 발생');
}
});
// Signup Show(Page Redirect)는 HTML의 onclick="location.href='/register'"으로 처리됨

360
public/index.html Normal file
View File

@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IDC 상담 채팅</title>
<style>
:root {
--skype-blue: #0078d4;
--skype-bg: #f3f2f1;
--sidebar-bg: #ffffff;
--text-main: #201f1e;
--text-sub: #605e5c;
--msg-sent: #0078d4;
--msg-received: #f3f2f1;
--border-color: #edebe9;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: #faf9f8;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#chat-widget {
width: 1000px;
height: 85vh;
background: white;
display: flex;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
}
/* Sidebar Styles */
#sidebar {
width: 320px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
#sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
#sidebar-header h2 {
font-size: 18px;
color: var(--text-main);
margin-bottom: 15px;
}
.search-container {
position: relative;
background: #f3f2f1;
border-radius: 4px;
padding: 5px 10px;
display: flex;
align-items: center;
}
.search-container input {
background: none;
border: none;
width: 100%;
padding: 5px;
outline: none;
font-size: 14px;
}
#room-list {
flex: 1;
overflow-y: auto;
padding: 10px 0;
}
.room-item {
padding: 12px 20px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: background 0.2s;
}
.room-item:hover {
background: #f3f2f1;
}
.room-item.active {
background: #edebe9;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--skype-blue);
color: white;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: 16px;
flex-shrink: 0;
}
.room-info {
flex: 1;
overflow: hidden;
}
.room-info .name {
font-weight: 600;
display: block;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-info .preview {
font-size: 12px;
color: var(--text-sub);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Main Chat Styles */
#main-chat {
flex: 1;
display: flex;
flex-direction: column;
}
#chat-header {
padding: 15px 25px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: white;
height: 70px;
}
#chat-header .user-meta {
display: flex;
align-items: center;
gap: 12px;
}
#chat-header .user-meta .name {
font-weight: 600;
font-size: 16px;
}
#chat-messages {
flex: 1;
padding: 20px 30px;
overflow-y: auto;
background: white;
display: flex;
flex-direction: column;
gap: 8px;
}
.message {
max-width: 70%;
padding: 10px 16px;
border-radius: 18px;
font-size: 14px;
line-height: 1.5;
position: relative;
margin-bottom: 4px;
}
.message.left {
align-self: flex-start;
background: var(--msg-received);
color: var(--text-main);
border-bottom-left-radius: 4px;
}
.message.right {
align-self: flex-end;
background: var(--msg-sent);
color: white;
border-bottom-right-radius: 4px;
}
.message .sender-id {
display: none;
/* Skype 스타일은 버블 내 아이디 표시 지양 */
}
/* Input Area */
#chat-input-area {
padding: 15px 25px;
display: flex;
align-items: center;
gap: 15px;
border-top: 1px solid var(--border-color);
}
#message-input {
flex: 1;
border: none;
padding: 10px;
font-size: 14px;
outline: none;
}
.icon-btn {
background: none;
border: none;
color: var(--skype-blue);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
}
#send-btn {
background: var(--skype-blue);
color: white;
border: none;
padding: 8px 18px;
border-radius: 20px;
font-weight: 600;
cursor: pointer;
}
/* Modals & Others */
.login-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.login-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 320px;
text-align: center;
}
.login-content input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
}
#auth-toggle-btn {
background: none;
border: 1px solid var(--skype-blue);
color: var(--skype-blue);
padding: 5px 12px;
border-radius: 15px;
cursor: pointer;
font-size: 12px;
}
</style>
</head>
<body>
<div id="chat-widget">
<!-- Sidebar -->
<div id="sidebar">
<div id="sidebar-header">
<h2>상담 채널</h2>
<div style="font-size: 12px; color: var(--text-sub); margin-top: 5px;">실시간 상담 대기 및 목록</div>
</div>
<div id="room-list">
<!-- Room items will be injected here -->
</div>
</div>
<!-- Main Chat Area -->
<div id="main-chat">
<div id="chat-header">
<div class="user-meta" id="active-user-meta">
<div class="avatar" id="header-avatar">?</div>
<div class="info">
<div class="name" id="target-name">전체 모니터링</div>
<div style="font-size: 12px; color: var(--text-sub);" id="target-status">실시간 대기 중</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<button id="exit-reply-btn"
style="display:none; background:none; border:none; color:var(--skype-blue); cursor:pointer; font-weight:600; font-size:13px;">전체보기</button>
<button id="auth-toggle-btn">로그인</button>
<button id="signup-show-btn" onclick="location.href='/register'"
style="background:none; border:1px solid #ddd; color:#666; padding:5px 12px; border-radius:15px; cursor:pointer; font-size:12px;">회원가입</button>
</div>
</div>
<div id="chat-messages">
<!-- Messages will be injected here -->
</div>
<!-- Reply Info (Skype 스타일에서는 헤더나 플로팅 바 대신 헤더 정보 활용 가능하지만 기존 로직 유지를 위해 숨김 처리 또는 통합) -->
<div id="reply-info" style="display:none;"></div>
<div id="chat-input-area">
<input type="file" id="image-input" accept="image/*" style="display: none;">
<button id="image-btn" class="icon-btn" title="이미지 첨부">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</button>
<input type="text" id="message-input" placeholder="메시지를 입력하세요...">
<button id="send-btn">전송</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="login-area" class="login-modal">
<div class="login-content">
<h3 style="margin-bottom: 20px;">사용자 로그인</h3>
<input type="text" id="username" placeholder="아이디">
<input type="password" id="password" placeholder="비밀번호">
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button id="login-submit-btn" style="flex: 1;">로그인</button>
<button id="login-cancel-btn" style="flex: 1; background: #eee; color: #333;">취소</button>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/chat.js"></script>
</html>

151
public/register.html Normal file
View File

@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 - IDC 상담 채널</title>
<link href="https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--skype-blue: #0078d4;
--bg-light: #f3f2f1;
--text-main: #201f1e;
}
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-light);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: var(--text-main);
}
.register-container {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
text-align: center;
}
h2 {
margin-bottom: 30px;
color: var(--skype-blue);
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
}
input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--skype-blue);
}
button {
width: 100%;
padding: 14px;
background: var(--skype-blue);
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-top: 10px;
}
button:hover {
background: #005a9e;
}
.footer-links {
margin-top: 25px;
font-size: 14px;
color: #666;
}
.footer-links a {
color: var(--skype-blue);
text-decoration: none;
}
</style>
</head>
<body>
<div class="register-container">
<h2>상담 서비스 회원가입</h2>
<div class="form-group">
<label for="reg-id">아이디</label>
<input type="text" id="reg-id" placeholder="사용할 아이디를 입력하세요">
</div>
<div class="form-group">
<label for="reg-pw">비밀번호</label>
<input type="password" id="reg-pw" placeholder="비밀번호를 입력하세요">
</div>
<button id="register-btn">회원가입 하기</button>
<div class="footer-links">
이미 계정이 있으신가요? <a href="/chat">로그인으로 돌아가기</a>
</div>
</div>
<script>
document.getElementById('register-btn').addEventListener('click', async () => {
const username = document.getElementById('reg-id').value.trim();
const password = document.getElementById('reg-pw').value.trim();
if (!username || !password) {
alert('모든 필드를 입력해주세요.');
return;
}
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (result.success) {
alert('회원가입이 완료되었습니다! 로그인 페이지로 이동합니다.');
window.location.href = '/chat';
} else {
alert('실패: ' + result.message);
}
} catch (err) {
console.error('API Error:', err);
alert('서버와 통신 중 오류가 발생했습니다.');
}
});
</script>
</body>
</html>

278
server.js Normal file
View File

@ -0,0 +1,278 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const mysql = require('mysql2/promise');
const bcrypt = require('bcryptjs');
const cors = require('cors');
const path = require('path');
const multer = require('multer');
const fs = require('fs-extra');
require('dotenv').config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1e8 // 100MB
});
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Multer Storage Configuration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, 'uploads');
fs.ensureDirSync(uploadDir);
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({ storage: storage });
// DB Pool Connection
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Create Table if not exists
const initDB = async () => {
try {
// Create table
await pool.execute(`
CREATE TABLE IF NOT EXISTS chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_code VARCHAR(50),
consultant_code VARCHAR(50),
room_id VARCHAR(100),
content TEXT,
msg_type VARCHAR(10) DEFAULT 'text',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Ensure msg_type column exists (for existing tables)
const [columns] = await pool.execute("SHOW COLUMNS FROM chat_messages LIKE 'msg_type'");
if (columns.length === 0) {
await pool.execute("ALTER TABLE chat_messages ADD COLUMN msg_type VARCHAR(10) DEFAULT 'text' AFTER content");
console.log('DB schema updated: added msg_type column');
}
console.log('Database initialized');
} catch (err) {
console.error('DB Init Error:', err);
}
};
initDB();
// Image Upload API
app.post('/api/upload', upload.single('image'), (req, res) => {
try {
if (!req.file) {
console.log('Upload failed: No file uploaded');
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
const imageUrl = `/uploads/${req.file.filename}`;
console.log('File uploaded successfully:', imageUrl);
res.json({ success: true, imageUrl });
} catch (err) {
console.error('Upload API Error:', err);
res.status(500).json({ success: false, message: 'Upload internal error' });
}
});
// Basic Routes
app.get('/chat', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/admin/chat', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/register', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'register.html'));
});
// 루트로 접속 시 기본적으로 /chat으로 리다이렉트
app.get('/', (req, res) => {
res.redirect('/chat');
});
// Login API (Supports both 'user' and 'clientinfo' tables)
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
try {
// 1. 상담원(user) 테이블 먼저 조회
let [rows] = await pool.execute('SELECT id, passwd, role FROM user WHERE id = ?', [username]);
let isConsultant = true;
if (rows.length === 0) {
// 2. 상담원이 없으면 일반 사용자(clientinfo) 테이블 조회
[rows] = await pool.execute('SELECT id, passwd, role FROM clientinfo WHERE id = ?', [username]);
isConsultant = false;
}
if (rows.length === 0) {
return res.status(401).json({ success: false, message: '사용자 계정을 찾을 수 없습니다.' });
}
const user = rows[0];
const isValid = await bcrypt.compare(password, user.passwd);
if (!isValid) {
return res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
}
res.json({
success: true,
user: {
id: user.id,
username: user.id,
role: user.role || (isConsultant ? 'master' : 'user')
}
});
} catch (error) {
console.error('Login API error:', error);
res.status(500).json({ success: false, message: '서버 내부 오류' });
}
});
// User Registration API (clientinfo table)
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
try {
// 중복 체크
const [existing] = await pool.execute('SELECT id FROM clientinfo WHERE id = ?', [username]);
if (existing.length > 0) {
return res.status(400).json({ success: false, message: '이미 존재하는 아이디입니다.' });
}
const hashedPassword = await bcrypt.hash(password, 10);
await pool.execute(
'INSERT INTO clientinfo (id, passwd, role, created_at) VALUES (?, ?, ?, NOW())',
[username, hashedPassword, 'user']
);
res.json({ success: true, message: '회원가입이 완료되었습니다.' });
} catch (error) {
console.error('Registration API error:', error);
res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다.' });
}
});
const isConsultantRole = (role) => {
if (!role) return false;
// 상담원 관련 키워드 목록 (manager/manage 오타 및 콤마 구분 대응)
const consultantKeywords = ['manage', 'manager', 'cloudflare', 'security', 'director', 'master', 'admin', 'consultant'];
// role이 "manager,cloudflare..." 처럼 여러 개일 경우를 대비해 체크
const roles = String(role).toLowerCase();
return consultantKeywords.some(keyword => roles.includes(keyword));
};
// 룸 목록 관리를 위한 객체 (room_id -> { user_id, last_message, ... })
const activeRooms = new Map();
const emitRoomList = () => {
const roomList = Array.from(activeRooms.entries()).map(([room, data]) => ({
room,
user_id: data.user_id,
username: data.user_id.startsWith('anon_') ? `익명(${data.user_id.substr(-5)})` : data.user_id
}));
io.to('consultants_group').emit('update_room_list', roomList);
};
// Socket.io logic
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
socket.on('join_room', (data) => {
const { room, role, user_id } = data;
console.log('[DEBUG] Join Room Request:', data);
// 상담원의 경우 룸 전환 시 정리가 필요함
if (isConsultantRole(role)) {
// 다른 모든 룸에서 퇴장 순회 (본인 소켓 ID 방 제외)
socket.rooms.forEach(r => {
if (r !== socket.id) socket.leave(r);
});
if (room === 'consultants_group') {
socket.join('consultants_group');
console.log(`[DEBUG] Consultant ${user_id} joined consultants_group`);
// 입장 즉시 최신 룸 목록 전송
emitRoomList();
} else {
// 특정 유저 룸 조인
socket.join(room);
console.log(`[DEBUG] Consultant ${user_id} moved to 1:1 room: ${room}`);
}
} else {
// 일반 유저나 익명은 본인 방 가입
socket.join(room);
console.log(`[DEBUG] User ${user_id} joined room: ${room}`);
// 일반 유저의 룸을 활성 목록에 추가
if (room.startsWith('room_')) {
activeRooms.set(room, { user_id: user_id, socketId: socket.id });
emitRoomList();
}
}
});
socket.on('send_message', async (data) => {
const { room, sender, message, user_code, consultant_code, msg_type, role } = data;
console.log(`[DEBUG] Message from ${sender} (role: ${role}) targeting room: ${room}`);
try {
await pool.execute(
'INSERT INTO chat_messages (user_code, consultant_code, room_id, content, msg_type, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[user_code || 'anonymous', consultant_code || null, room, message, msg_type || 'text']
);
} catch (err) {
console.error('Error saving message:', err);
}
// 해당 룸 사용자에게 메시지 전송
io.to(room).emit('receive_message', data);
// 상담원 그룹(전체 모니터링)에도 메시지 복사 전송
// 유저의 메시지뿐만 아니라 상담원의 모든 답장도 다른 상담원 및 발신자 본인이 볼 수 있게 함
if (room !== 'consultants_group') {
io.to('consultants_group').emit('receive_message', data);
}
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
// 해당 소켓이 소유했던 룸 정보 제거
for (let [room, data] of activeRooms.entries()) {
if (data.socketId === socket.id) {
activeRooms.delete(room);
emitRoomList();
break;
}
}
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});