ChatSystem/server.js
2026-01-16 15:40:38 +09:00

279 lines
9.8 KiB
JavaScript

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}`);
});