Skip to content

Commit ac6fd1e

Browse files
authored
Merge pull request #60 from pirogramming/feat-jh
feat: connected latest msg with ws
2 parents 854b519 + 017f4c8 commit ac6fd1e

8 files changed

Lines changed: 154 additions & 9 deletions

File tree

backend-chat/app/api/chat.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
22
import json
3+
import os
4+
import httpx
35
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
46
from datetime import datetime
57
from app.services.chat_service import chat_manager
68
from app.models.chat import ChatMessage
7-
from app.core.auth import verify_token # ✅ 추가
9+
from app.core.auth import verify_token
10+
from app.core.config import redis_client
811

912
router = APIRouter()
1013

@@ -98,6 +101,32 @@ async def websocket_endpoint(websocket: WebSocket, room_id: str):
98101
await chat_manager.save_message(msg_obj)
99102
await chat_manager.set_room_last_message(room_id, msg_obj)
100103

104+
# 상대방에게 채팅 목록 갱신 알림 (Redis Publish)
105+
try:
106+
django_url = os.getenv("DJANGO_API_URL", "http://backend-core:8000")
107+
token = auth_msg.get("token", "")
108+
async with httpx.AsyncClient(timeout=5.0) as client:
109+
r = await client.get(
110+
f"{django_url}/api/missions/api/chat/room/{room_id}/participants/",
111+
headers={"Authorization": f"Bearer {token}"} if token else {},
112+
)
113+
if r.status_code == 200:
114+
resp = r.json()
115+
user_ids = resp.get("user_ids", [])
116+
for uid in user_ids:
117+
if int(uid) != user_id:
118+
payload = {
119+
"type": "chat_update",
120+
"target_id": int(uid),
121+
"room_id": int(room_id),
122+
"last_message": (data or "")[:80],
123+
"created_at": msg_obj.created_at.strftime("%Y-%m-%dT%H:%M:%S"),
124+
}
125+
await redis_client.publish("global_notifications", json.dumps(payload, ensure_ascii=False))
126+
break
127+
except Exception as e:
128+
print(f"[DEBUG] chat_update publish 실패: {e}")
129+
101130
await chat_manager.publish_message({
102131
"type": "TALK",
103132
"sender_id": user_id,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
2+
from app.services.notification_service import notification_manager
3+
4+
router = APIRouter()
5+
6+
7+
@router.websocket("/notify/{user_id}")
8+
async def notify_websocket(websocket: WebSocket, user_id: int):
9+
await notification_manager.connect(websocket, user_id)
10+
try:
11+
while True:
12+
await websocket.receive_text()
13+
except WebSocketDisconnect:
14+
notification_manager.disconnect(user_id)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import json
2+
import asyncio
3+
from fastapi import WebSocket
4+
from app.core.config import redis_client
5+
6+
CHANNEL = "global_notifications"
7+
8+
9+
class NotificationManager:
10+
def __init__(self):
11+
self.active_connections: dict[int, WebSocket] = {}
12+
13+
async def connect(self, websocket: WebSocket, user_id: int):
14+
await websocket.accept()
15+
self.active_connections[int(user_id)] = websocket
16+
17+
def disconnect(self, user_id: int):
18+
uid = int(user_id)
19+
if uid in self.active_connections:
20+
del self.active_connections[uid]
21+
22+
async def send_to_user(self, user_id: int, data: dict):
23+
uid = int(user_id)
24+
if uid in self.active_connections:
25+
try:
26+
await self.active_connections[uid].send_text(json.dumps(data, ensure_ascii=False))
27+
except Exception:
28+
pass
29+
30+
async def start_listener(self):
31+
pubsub = redis_client.pubsub()
32+
await pubsub.subscribe(CHANNEL)
33+
try:
34+
async for message in pubsub.listen():
35+
if message.get("type") == "message":
36+
try:
37+
data = json.loads(message["data"])
38+
target_id = data.get("target_id")
39+
if target_id is not None:
40+
await self.send_to_user(target_id, data)
41+
except (json.JSONDecodeError, KeyError):
42+
pass
43+
except asyncio.CancelledError:
44+
await pubsub.unsubscribe(CHANNEL)
45+
46+
47+
notification_manager = NotificationManager()

backend-chat/main.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import asyncio
12
from fastapi import FastAPI
23
from fastapi.middleware.cors import CORSMiddleware
34
from app.api.chat import router as chat_router
4-
from app.api.missions import router as mission_router # ✨ 추가
5+
from app.api.missions import router as mission_router
6+
from app.api.notification import router as notify_router
57
from app.services.mission_stream_service import mission_stream_manager
8+
from app.services.notification_service import notification_manager
69

7-
app = FastAPI()
10+
app = FastAPI()
811

9-
# CORS 설정 (403 방지)
1012
app.add_middleware(
1113
CORSMiddleware,
1214
allow_origins=["http://localhost", "http://127.0.0.1"],
@@ -16,12 +18,15 @@
1618
)
1719

1820
app.include_router(chat_router, prefix="/ws")
19-
app.include_router(mission_router, prefix="/stream") # ✨ 추가
21+
app.include_router(notify_router, prefix="/ws")
22+
app.include_router(mission_router, prefix="/stream")
23+
2024

2125
@app.on_event("startup")
2226
async def startup_event():
23-
"""서버 시작 시 Redis 리스너 시작"""
2427
await mission_stream_manager.start()
28+
asyncio.create_task(notification_manager.start_listener())
29+
2530

2631
@app.get("/health")
2732
async def health_check():

backend-chat/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ motor
44
pydantic>=2.0
55
pydantic-settings
66
redis[asyncio]>=4.2.0 # ✨ 이렇게 수정
7-
PyJWT>=2.8.0
7+
PyJWT>=2.8.0
8+
httpx

backend-core/missions/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
path("<int:mission_id>/edit/", views.mission_update_view, name="mission_update_view"),
1515
path("location/pick/", views.location_picker, name="location_picker"),
1616
path("chat/", views.chat_list, name="chat_list"),
17+
path("api/chat/room/<int:room_id>/participants/", views.chat_room_participants, name="chat_room_participants"),
1718
path("api/<int:mission_id>/confirm/", views.mission_confirm_helper, name="mission_confirm_helper_api"),
1819
path("api/<int:mission_id>/reject/", views.mission_reject_helper, name="mission_reject_helper_api"),
1920
path("api/<int:mission_id>/complete/", views.mission_complete, name="mission_complete_api"),

backend-core/missions/views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
from datetime import datetime
56
from typing import Any
67

78
from django.contrib.auth.decorators import login_required
@@ -526,17 +527,25 @@ def chat_list(request: HttpRequest) -> HttpResponse:
526527
continue
527528
# Redis에서 이 방의 마지막 메시지 미리보기 조회
528529
last_message = None
530+
last_message_created_at = None
529531
try:
530532
raw = redis_client.get(f"chat:room:{room.id}:last")
531533
if raw:
532534
data = json.loads(raw)
533535
last_message = data.get("content") or None
536+
created_at_str = data.get("created_at")
537+
if created_at_str:
538+
try:
539+
last_message_created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
540+
except (ValueError, TypeError):
541+
pass
534542
except (json.JSONDecodeError, TypeError):
535543
pass
536544
mission_groups[room.mission].append({
537545
"room": room,
538546
"other_user": other,
539547
"last_message": last_message,
548+
"last_message_created_at": last_message_created_at,
540549
})
541550

542551
grouped_list = [
@@ -551,6 +560,15 @@ def chat_list(request: HttpRequest) -> HttpResponse:
551560
)
552561

553562

563+
@api_view(['GET'])
564+
@permission_classes([IsAuthenticated])
565+
def chat_room_participants(request: HttpRequest, room_id: int) -> Response:
566+
"""채팅방 참여자 user_id 목록 (FastAPI 목록 갱신 알림용)"""
567+
room = get_object_or_404(ChatRoom, id=room_id)
568+
if request.user not in (room.user1, room.user2):
569+
return Response({"error": "권한 없음"}, status=status.HTTP_403_FORBIDDEN)
570+
return Response({"user_ids": [room.user1_id, room.user2_id]})
571+
554572
@api_view(['GET'])
555573
@permission_classes([IsAuthenticated])
556574
def my_missions(request):

backend-core/templates/chat/list.html

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ <h1 class="page-title">채팅 목록</h1>
3030

3131
<div class="chat-room-content">
3232
{% for item in group.rooms %}
33-
<a href="{% url 'missions:chat_room' group.mission.id item.room.id %}" class="individual-chat-link">
33+
<a href="{% url 'missions:chat_room' group.mission.id item.room.id %}" class="individual-chat-link"
34+
data-room-id="{{ item.room.id }}">
3435
<div class="chat-user-avatar">
3536
{{ item.other_user.get_display_name|slice:":1" }}
3637
</div>
3738
<div class="chat-user-info">
3839
<div class="user-info-top">
3940
<span class="user-name">{{ item.other_user.get_display_name }}</span>
40-
<span class="chat-time">{{ item.room.created_at|date:"A g:i"|default:"최근" }}</span>
41+
<span class="chat-time">{% if item.last_message_created_at %}{{item.last_message_created_at|date:"A g:i" }}{% else %}{{ item.room.created_at|date:"A g:i"|default:"최근" }}{% endif %}</span>
4142
</div>
4243
<p class="last-preview">{{ item.last_message|default:"대화 내용이 없습니다." }}</p>
4344
</div>
@@ -65,5 +66,34 @@ <h1 class="page-title">채팅 목록</h1>
6566
const group = element.parentElement;
6667
group.classList.toggle('active');
6768
}
69+
70+
(function () {
71+
const userId = {{ request.user.id }};
72+
const host = "{{ request.get_host }}";
73+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
74+
const ws = new WebSocket(protocol + "//" + host + "/ws/notify/" + userId);
75+
76+
ws.onmessage = function (event) {
77+
try {
78+
const data = JSON.parse(event.data);
79+
if (data.type === "chat_update" && data.room_id != null) {
80+
const row = document.querySelector('[data-room-id="' + data.room_id + '"]');
81+
if (row) {
82+
const preview = row.querySelector('.last-preview');
83+
const timeEl = row.querySelector('.chat-time');
84+
if (preview) preview.textContent = data.last_message || "대화 내용이 없습니다.";
85+
if (timeEl && data.created_at) {
86+
const d = new Date(data.created_at);
87+
timeEl.textContent = d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" });
88+
}
89+
}
90+
}
91+
} catch (e) { }
92+
};
93+
94+
ws.onclose = function () {
95+
console.log("알림 소켓 끊김");
96+
};
97+
})();
6898
</script>
6999
{% endblock %}

0 commit comments

Comments
 (0)