279 lines
9.8 KiB
JavaScript
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}`);
|
|
});
|