Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,4 @@ jobs:
echo "❌ Health check failed. Rolling back..."
docker rm -f node-app-$TARGET_COLOR || true
exit 1
EOF
EOF
111 changes: 61 additions & 50 deletions public/client-test.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,97 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Socket.IO 채팅</title>
<title>Socket.IO 채팅 (JWT 토큰 입력)</title>
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
</head>
<body>
<h2>실시간 채팅</h2>
<h2>실시간 채팅 (JWT 인증)</h2>

<div>
<label for="senderIdInput">사용자 ID 입력:</label>
<input type="text" id="senderIdInput" placeholder="사용자 ID를 입력하세요" />
<button id="joinBtn">채팅방 입장</button>
<label for="tokenInput">JWT 토큰 입력:</label>
<input type="text" id="tokenInput" placeholder="JWT 토큰을 입력하세요" style="width: 400px;" />
<button id="connectBtn">서버 연결 및 채팅방 입장</button>
</div>
<div id="chatroom" style="display:none;">

<div id="chatroom" style="display:none; margin-top: 20px;">
<div id="messages" style="border:1px solid #ccc; height:300px; overflow-y:scroll; margin-bottom:10px; padding:5px;"></div>

<input type="text" id="messageInput" placeholder="메시지 입력 (최대 255자)" maxlength="255" style="width:60%;" />
<input type="file" id="imageInput" accept="image/*" />
<button id="sendBtn">전송</button>
</div>

<script>
// 서버 소켓 연결 (URL 바꾸기)
const socket = io('http://localhost:3000');
let socket = null;
const chatroomId = '1'; // 실제 채팅방 아이디 동적으로 받아야 함

let senderId = null;
const tokenInput = document.getElementById('tokenInput');
const connectBtn = document.getElementById('connectBtn');

const joinBtn = document.getElementById('joinBtn');
const senderIdInput = document.getElementById('senderIdInput');
const chatroomDiv = document.getElementById('chatroom');

const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const imageInput = document.getElementById('imageInput');
const sendBtn = document.getElementById('sendBtn');

joinBtn.addEventListener('click', () => {
const inputVal = senderIdInput.value.trim();
if (!inputVal) {
alert('사용자 ID를 입력해주세요.');
connectBtn.addEventListener('click', () => {
const token = tokenInput.value.trim();
if (!token) {
alert('JWT 토큰을 입력해주세요.');
return;
}
senderId = inputVal;

// 채팅방 입장
socket.emit('join', chatroomId);

// UI 변경
senderIdInput.disabled = true;
joinBtn.disabled = true;
chatroomDiv.style.display = 'block';
});

// 메시지 받기
socket.on('chat message', (msg) => {
const div = document.createElement('div');
div.style.borderBottom = '1px solid #eee';
div.style.padding = '5px';

let content = `<b>사용자 ${msg.senderId}:</b> ${msg.content || ''}`;
if (msg.imageUrl) {
content += `<br><img src="${msg.imageUrl}" alt="이미지" style="max-width:200px; max-height:200px;" />`;
// 기존 연결 있으면 끊기
if (socket) {
socket.disconnect();
}
div.innerHTML = content;

messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
});

// 에러 받기
socket.on('error', (err) => {
alert('에러: ' + err.message);
// 소켓 연결 (토큰을 auth에 담아 보냄)
socket = io('http://localhost:3000', {
auth: {
token: token,
}
});

socket.on('connect', () => {
console.log('서버 연결됨, 채팅방 입장');
socket.emit('join', chatroomId);

chatroomDiv.style.display = 'block';
tokenInput.disabled = true;
connectBtn.disabled = true;
});

socket.on('chat message', (msg) => {
const div = document.createElement('div');
div.style.borderBottom = '1px solid #eee';
div.style.padding = '5px';

let content = `<b>사용자 ${msg.senderId}:</b> ${msg.content || ''}`;
if (msg.imageUrl) {
content += `<br><img src="${msg.imageUrl}" alt="이미지" style="max-width:200px; max-height:200px;" />`;
}
div.innerHTML = content;

messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
});

socket.on('error', (err) => {
alert('에러: ' + err.message);
});

socket.on('disconnect', () => {
alert('서버 연결이 끊겼습니다.');
chatroomDiv.style.display = 'none';
tokenInput.disabled = false;
connectBtn.disabled = false;
});
});

// 전송 버튼 클릭
sendBtn.addEventListener('click', () => {
if (!senderId) {
alert('먼저 사용자 ID를 입력하고 채팅방에 입장하세요.');
if (!socket || !socket.connected) {
alert('서버에 연결되어 있지 않습니다.');
return;
}

Expand All @@ -102,7 +115,6 @@ <h2>실시간 채팅</h2>

socket.emit('chat message', {
chatroomId,
senderId,
content,
imageBase64: base64Image,
});
Expand All @@ -111,7 +123,6 @@ <h2>실시간 채팅</h2>
} else {
socket.emit('chat message', {
chatroomId,
senderId,
content,
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/chat/dto/chatroom.dto.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export class ChatroomListResponseDto {
this.artist_profile_image = room.artist.profileImage;
this.request_id = room.request.id;
this.request_title = room.request.commission.title;
this.last_message = room.chatMessages[0]?.content || null;
// 이미지가 있으면 이미지 URL, 없으면 텍스트 content
const lastMsg = room.chatMessages[0];
this.last_message = lastMsg?.imageUrl || lastMsg?.content || null;
this.last_message_time = room.chatMessages[0]?.createdAt || null;
this.has_unread = unreadCount;
}
Expand Down
25 changes: 16 additions & 9 deletions src/chat/repository/chat.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,27 @@ export const ChatRepository = {
},

async markAsRead(accountId, messageId) {
return await prisma.chatMessageRead.upsert({
const existing = await prisma.chatMessageRead.findFirst({
where: {
messageId_accountId: {
messageId: BigInt(messageId),
accountId: BigInt(accountId),
},
},
update: { read: true },
create: {
messageId: BigInt(messageId),
accountId: BigInt(accountId),
read: true,
},
});

if (existing) {
return await prisma.chatMessageRead.update({
where: { id: existing.id },
data: { read: true },
});
} else {
return await prisma.chatMessageRead.create({
data: {
messageId: BigInt(messageId),
accountId: BigInt(accountId),
read: true,
},
});
}
},

async isMessageRead(accountId, messageId) {
Expand Down
41 changes: 38 additions & 3 deletions src/chat/repository/chatroom.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export const ChatroomRepository = {
},

async findChatroomsByUser(consumerId) {
return prisma.chatroom.findMany({
// 1. 채팅방 기본 정보 + 마지막 메시지(내용, 생성시간, id) 조회
const chatrooms = await prisma.chatroom.findMany({
where: { consumerId },
include: {
artist: {
Expand All @@ -38,7 +39,7 @@ export const ChatroomRepository = {
id: true,
commission: {
select: {
title: true
title: true,
}
}
}
Expand All @@ -47,12 +48,46 @@ export const ChatroomRepository = {
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
content: true,
createdAt: true
createdAt: true,
}
}
}
});

// 2. 마지막 메시지 ID 목록 수집
const messageIds = chatrooms
.map(room => room.chatMessages[0]?.id)
.filter(Boolean); // null 제외

if (messageIds.length === 0) {
return chatrooms;
}

// 3. 메시지 ID로 이미지 URL 조회
const images = await prisma.image.findMany({
where: {
target: "chat_messages",
targetId: { in: messageIds },
},
});

// 4. 이미지 URL 매핑 (messageId -> imageUrl)
const imageMap = {};
images.forEach(img => {
imageMap[img.targetId.toString()] = img.imageUrl;
});

// 5. 채팅방 객체에 이미지 URL 병합
chatrooms.forEach(room => {
const msg = room.chatMessages[0];
if (msg) {
msg.imageUrl = imageMap[msg.id.toString()] || null;
}
});

return chatrooms;
},

async softDeleteChatrooms(chatroomIds, userType, userId) {
Expand Down
21 changes: 11 additions & 10 deletions src/chat/socket/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export default function setupSocket(server) {

// 채팅방 join
socket.on("join", async (chatroomId) => {
socket.join(chatroomId);
const room = String(chatroomId);
socket.join(room);
console.log(`User ${socket.user.userId} joined chatroom ${chatroomId}`);

try {
Expand Down Expand Up @@ -70,7 +71,8 @@ export default function setupSocket(server) {
});

// 메시지 수신
socket.on("chat message", async ({ chatroomId, senderId, content, imageBase64 }) => {
socket.on("chat message", async ({ chatroomId, content, imageBase64 }) => {
const room = String(chatroomId);
try {
if (content && content.length > 255) {
socket.emit("error", { message: "메시지 길이는 255자를 초과할 수 없습니다." });
Expand All @@ -81,19 +83,18 @@ export default function setupSocket(server) {

// 이미지 처리
if (imageBase64) {
const buffer = Buffer.from(imageBase64, 'base64');
imageUrl = await uploadToS3({
buffer,
folderName: "messages",
extension: "png"
});
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
console.log("Buffer length:", buffer.length);
console.log("Is Buffer:", Buffer.isBuffer(buffer));
imageUrl = await uploadToS3(buffer, "messages", "png");
}

// 메시지 저장
const savedMessage = await prisma.chatMessage.create({
data: {
chatroomId: BigInt(chatroomId),
senderId: BigInt(senderId),
senderId: BigInt(socket.user.accountId),
content,
},
});
Expand All @@ -119,7 +120,7 @@ export default function setupSocket(server) {
createdAt: savedMessage.createdAt,
}));

io.to(chatroomId).emit("chat message", safeMessage);
io.to(room).emit("chat message", safeMessage);

} catch (err) {
console.error("Socket message error:", err);
Expand Down
13 changes: 8 additions & 5 deletions src/common/swagger/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@
"type": "object",
"properties": {
"resultType": { "type": "string", "example": "SUCCESS" },
"error": { "type": "object", "nullable": true },
"error": { "type": "object", "nullable": true, "example": null },
"success": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 10 },
"consumerId": { "type": "integer", "example": 1 },
"artistId": { "type": "integer", "example": 2 },
"requestId": { "type": "integer", "example": 3 }
"id": { "type": "string", "example": "1" },
"consumerId": { "type": "string", "example": "1" },
"artistId": { "type": "string", "example": "1" },
"requestId": { "type": "string", "example": "1" },
"hiddenArtist": { "type": "boolean", "example": false },
"hiddenConsumer": { "type": "boolean", "example": false },
"createdAt": { "type": "string", "format": "date-time", "example": "2025-08-07T15:35:42.375Z" }
}
}
}
Expand Down