diff --git a/alembic/versions/a930eb13180a_add_last_read_message_id_to_chat_room_.py b/alembic/versions/a930eb13180a_add_last_read_message_id_to_chat_room_.py new file mode 100644 index 0000000..6200984 --- /dev/null +++ b/alembic/versions/a930eb13180a_add_last_read_message_id_to_chat_room_.py @@ -0,0 +1,34 @@ +"""add_last_read_message_id_to_chat_room_member + +Revision ID: a930eb13180a +Revises: 7e51810ce786 +Create Date: 2026-09-04 22:11:01.816890 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a930eb13180a' +down_revision: Union[str, Sequence[str], None] = '7e51810ce786' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_room_members', sa.Column('last_read_message_id', sa.Uuid(), nullable=True)) + op.create_foreign_key('chat_room_members_last_read_message_id_fkey', 'chat_room_members', 'chat_messages', ['last_read_message_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('chat_room_members_last_read_message_id_fkey', 'chat_room_members', type_='foreignkey') + op.drop_column('chat_room_members', 'last_read_message_id') + # ### end Alembic commands ### diff --git a/app/modules/chat/dependencies.py b/app/modules/chat/dependencies.py index 58057d7..d0893d5 100644 --- a/app/modules/chat/dependencies.py +++ b/app/modules/chat/dependencies.py @@ -1,15 +1,15 @@ +from typing import Annotated + from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_session + +from app.core.database import DbSessionDep from app.modules.chat.repository import ChatRepository from app.modules.chat.service import ChatService from app.modules.project.repository import ProjectRepository -async def get_chat_repository(session: AsyncSession = Depends(get_session)) -> ChatRepository: - return ChatRepository(session) -async def get_chat_service( - session: AsyncSession = Depends(get_session), - repository: ChatRepository = Depends(get_chat_repository) -) -> ChatService: - return ChatService(session, repository, ProjectRepository(session)) +def get_chat_service(session: DbSessionDep) -> ChatService: + repository = ChatRepository(session) + return ChatService(session, repository) + +ChatServiceDep = Annotated[ChatService, Depends(get_chat_service)] diff --git a/app/modules/chat/models.py b/app/modules/chat/models.py index 54b97b6..9cdabaf 100644 --- a/app/modules/chat/models.py +++ b/app/modules/chat/models.py @@ -22,6 +22,12 @@ class ChatRoomMember(SQLModel, table=True): default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(DateTime(timezone=True), nullable=False), ) + last_read_message_id: UUID | None = Field( + default=None, + foreign_key="chat_messages.id", + nullable=True, + description="마지막으로 읽은 메시지 고유키", + ) class ChatRoom(BaseModel, table=True): __tablename__ = "chat_rooms" diff --git a/app/modules/chat/repository.py b/app/modules/chat/repository.py index 666569d..776e6cd 100644 --- a/app/modules/chat/repository.py +++ b/app/modules/chat/repository.py @@ -2,7 +2,7 @@ from uuid import UUID from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import select, and_, func +from sqlmodel import select, func, update from app.modules.chat.models import ChatRoom, ChatRoomMember, ChatMessage from app.shared.enums import ChatRoomType @@ -102,6 +102,26 @@ async def get_messages(self, room_id: UUID, limit: int = 50, offset: int = 0) -> result = await self.session.execute(stmt) return result.scalars().all() + async def get_message_by_id(self, message_id: UUID) -> ChatMessage | None: + stmt = select(ChatMessage).where(ChatMessage.id == message_id) + result = await self.session.execute(stmt) + return result.scalar() + + async def get_last_messages(self, room_ids: list[UUID]) -> dict[UUID, ChatMessage]: + """여러 방의 마지막 메시지를 DISTINCT ON으로 한 번에 조회합니다.""" + if not room_ids: + return {} + # DISTINCT ON (chat_room_id) + created_at DESC → 방별 가장 최근 메시지 1건 + stmt = ( + select(ChatMessage) + .where(ChatMessage.chat_room_id.in_(room_ids), ChatMessage.is_deleted == False) + .options(selectinload(ChatMessage.sender)) + .distinct(ChatMessage.chat_room_id) + .order_by(ChatMessage.chat_room_id, ChatMessage.created_at.desc()) + ) + result = await self.session.execute(stmt) + return {message.chat_room_id: message for message in result.scalars().all()} + async def is_room_member(self, room_id: UUID, member_id: UUID) -> bool: stmt = select(ChatRoomMember).where( ChatRoomMember.chat_room_id == room_id, @@ -116,3 +136,34 @@ async def delete_room(self, room: ChatRoom) -> None: room.deleted_at = datetime.now(timezone.utc) self.session.add(room) await self.session.flush() + + async def mark_as_read(self, room_id: UUID, member_id: UUID, message_id: UUID) -> None: + await self.session.execute( + update(ChatRoomMember) + .where( + ChatRoomMember.chat_room_id == room_id, + ChatRoomMember.member_id == member_id, + ) + .values(last_read_message_id = message_id) + ) + + async def get_unread_count(self, room_id: UUID, member_id: UUID) -> int: + result = await self.session.execute( + select(ChatRoomMember.last_read_message_id) + .where( + ChatRoomMember.chat_room_id == room_id, + ChatRoomMember.member_id == member_id, + ) + ) + last_read_message_id = result.scalar_one_or_none() + + count_query = select(func.count()).where( + ChatMessage.chat_room_id == room_id, + ChatMessage.is_deleted == False, + ) + if last_read_message_id is not None: + sub_query = select(ChatMessage.created_at).where(ChatMessage.id == last_read_message_id).scalar_subquery() + count_query = count_query.where(ChatMessage.created_at > sub_query) + + result = await self.session.execute(count_query) + return result.scalar_one() \ No newline at end of file diff --git a/app/modules/chat/router.py b/app/modules/chat/router.py index 162ba32..8f6c0d8 100644 --- a/app/modules/chat/router.py +++ b/app/modules/chat/router.py @@ -1,63 +1,140 @@ -from uuid import UUID +import json +import asyncio from typing import Annotated -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Query +from uuid import UUID -from app.modules.chat.service import ChatService, manager -from app.modules.chat.dependencies import get_chat_service -from app.modules.chat.schemas import ChatRoomRead, ChatMessageRead, DirectChatRoomCreate, ChatRoomDetailRead -from app.modules.member.dependencies import CurrentMemberDep, get_member_service, MemberServiceDep -from app.modules.project.dependencies import ProjectParticipantDep +from fastapi import APIRouter, Path, Query, WebSocket, WebSocketDisconnect + +from app.modules.chat.dependencies import ChatServiceDep +from app.modules.chat.service import manager +from app.modules.chat.schemas import ChatRoomOut, ChatMessageOut, DirectChatRoomCreateIn +from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep from app.modules.member.models import Member +from app.modules.project.dependencies import ProjectParticipantDep from app.core.exceptions import AppError from app.core.security import decode_token +from app.shared.schemas import ApiResponse + +router = APIRouter(prefix="/chat", tags=["Chat"]) -router = APIRouter(prefix="/chat", tags=["chat"]) -@router.get("/projects/{project_id}/rooms", response_model=list[ChatRoomRead]) +@router.get( + path="/projects/{project_id}/rooms", + response_model=ApiResponse[list[ChatRoomOut]], + summary="채팅방 목록 조회", + description="프로젝트 내에서 현재 회원이 참여 중인 채팅방 목록을 조회합니다.", +) async def list_rooms( - project_id: UUID, - current_member: CurrentMemberDep, - _project: ProjectParticipantDep, - service: ChatService = Depends(get_chat_service) + service: ChatServiceDep, + current_member: CurrentMemberDep, + _project: ProjectParticipantDep, + project_id: Annotated[UUID, Path(description="조회할 프로젝트 ID")], ): - """프로젝트 내 채팅방 목록을 조회합니다.""" - return await service.list_rooms(project_id, current_member.id) + rooms = await service.list_rooms(project_id, current_member.id) + return ApiResponse.success( + code="CHAT_ROOM_LIST_FETCHED", + message="채팅방 목록 조회 성공", + data=rooms, + ) -@router.post("/projects/{project_id}/rooms/direct", response_model=ChatRoomRead) + +@router.post( + path="/projects/{project_id}/rooms/direct", + response_model=ApiResponse[ChatRoomOut], + summary="1:1 채팅방 생성", + description="1:1 채팅방을 생성하거나 기존 방을 반환합니다.", +) async def create_direct_room( - project_id: UUID, - data: DirectChatRoomCreate, - current_member: CurrentMemberDep, - _project: ProjectParticipantDep, - service: ChatService = Depends(get_chat_service) + service: ChatServiceDep, + current_member: CurrentMemberDep, + _project: ProjectParticipantDep, + project_id: Annotated[UUID, Path(description="프로젝트 ID")], + data: DirectChatRoomCreateIn, ): - """1:1 채팅방을 생성하거나 기존 방을 반환합니다.""" - return await service.get_or_create_direct_room(project_id, current_member.id, data.target_member_id) + room = await service.get_or_create_direct_room(project_id, current_member.id, data.target_member_id) + + await manager.publish_project(project_id, { + "type": "room_created", + "data": { + "room": ChatRoomOut.model_validate(room).model_dump(mode="json"), + "member_ids": [str(member.id) for member in room.members], + }, + }) + return ApiResponse.success( + code="CHAT_DIRECT_ROOM_CREATED", + message="1:1 채팅방 생성 성공", + data=ChatRoomOut.model_validate(room), + ) -@router.get("/rooms/{room_id}/messages", response_model=list[ChatMessageRead]) + +@router.get( + path="/rooms/{room_id}/messages", + response_model=ApiResponse[list[ChatMessageOut]], + summary="채팅 이력 조회", + description="채팅방의 메시지 이력을 페이지네이션으로 조회합니다.", +) async def get_messages( - room_id: UUID, - current_member: CurrentMemberDep, - limit: int = 50, - offset: int = 0, - service: ChatService = Depends(get_chat_service) + service: ChatServiceDep, + current_member: CurrentMemberDep, + room_id: Annotated[UUID, Path(description="채팅방 ID")], + limit: Annotated[int, Query(ge=1, le=100, description="조회 개수")] = 50, + offset: Annotated[int, Query(ge=0, description="조회 시작 위치")] = 0, ): - """채팅 이력을 조회합니다.""" - return await service.get_history(room_id, current_member.id, limit, offset) + messages = await service.get_history(room_id, current_member.id, limit, offset) + return ApiResponse.success( + code="CHAT_MESSAGE_LIST_FETCHED", + message="채팅 이력 조회 성공", + data=[ChatMessageOut.model_validate(message) for message in messages], + ) + -@router.delete("/rooms/{room_id}") +@router.delete( + path="/rooms/{room_id}", + response_model=ApiResponse[None], + summary="채팅방 삭제", + description="1:1 채팅방을 삭제합니다. 단체 채팅방은 삭제할 수 없습니다.", +) async def delete_room( - room_id: UUID, - current_member: CurrentMemberDep, - service: ChatService = Depends(get_chat_service) + service: ChatServiceDep, + current_member: CurrentMemberDep, + room_id: Annotated[UUID, Path(description="삭제할 채팅방 ID")], ): - """채팅방을 삭제합니다.""" await service.delete_room(room_id, current_member.id) - return {"message": "채팅방이 삭제되었습니다."} + # 방 참여자들에게 실시간으로 삭제 알림 + await manager.publish(room_id, { + "type": "room_deleted", + "data": {"room_id": str(room_id)}, + }) + return ApiResponse.success( + code="CHAT_ROOM_DELETED", + message="채팅방 삭제 성공", + data=None, + ) + + +@router.post( + path="/rooms/{room_id}/read/{message_id}", + response_model=ApiResponse[None], + summary="채팅방 메시지 읽음 처리", + description="채팅방에서 특정 메시지를 가장 최근에 읽은 메시지로 처리합니다.", +) +async def mark_as_read( + service: ChatServiceDep, + current_member: CurrentMemberDep, + room_id: Annotated[UUID, Path(description="채팅방 ID")], + message_id: Annotated[UUID, Path(description="읽음 처리할 메시지 ID")], +): + await service.mark_as_read(room_id, current_member.id, message_id) + return ApiResponse.success( + code="CHAT_MESSAGE_MARKED_AS_READ", + message="메시지 읽음 처리 성공", + data=None, + ) + async def get_ws_current_member( - token: str, - member_service: MemberServiceDep + token: str, + member_service: MemberServiceDep, ) -> Member: """WebSocket용 토큰 인증 (쿼리 파라미터 기반)""" try: @@ -72,49 +149,197 @@ async def get_ws_current_member( except Exception: raise AppError.unauthorized("WebSocket 인증 실패") -@router.websocket("/ws/{room_id}") -async def chat_websocket( - member_service: MemberServiceDep, - websocket: WebSocket, - room_id: UUID, - token: Annotated[str, Query()], - chat_service: ChatService = Depends(get_chat_service), + +@router.websocket("/ws/projects/{project_id}") +async def project_websocket( + websocket: WebSocket, + project_id: UUID, + token: Annotated[str, Query()], + member_service: MemberServiceDep, + chat_service: ChatServiceDep, ): - # 핸드쉐이크 먼저 수락 await websocket.accept() - # 인증 try: current_member = await get_ws_current_member(token, member_service) except Exception: - await websocket.close(code=1008) # Policy Violation + await websocket.close(code=1008) return - # 연결 - await manager.connect(room_id, websocket) - + rooms = await chat_service.list_rooms(project_id, current_member.id) + room_ids = {room.id for room in rooms} + for rid in room_ids: + await manager.connect(rid, websocket) + + # 제어 채널 구독 태스크 시작 (접속 후 생긴 방을 실시간으로 구독) + project_task = asyncio.create_task( + manager.subscribe_project_events(project_id, current_member.id, websocket, room_ids) + ) + + focused_room: UUID | None = None + try: while True: - # 클라이언트로부터 메시지 대기 data = await websocket.receive_text() - - # 메시지 저장 - # TODO: 메시지 타입(TEXT, IMAGE 등) 처리 추가 가능 - message = await chat_service.send_message( - room_id=room_id, - sender_id=current_member.id, - content=data - ) - - # 브로드캐스트용 데이터 구성 - msg_data = ChatMessageRead.model_validate(message).model_dump(mode="json") - - # Redis를 통해 전체 서버 인스턴스로 발행 - await manager.publish(room_id, msg_data) - + payload = json.loads(data) + event_type = payload.get("type") + + if event_type == "message": + rid = UUID(payload["room_id"]) + if rid not in room_ids: + continue + + content = (payload.get("content") or "").strip() + + if not content: + continue + + message = await chat_service.send_message(rid, current_member.id, content) + msg_data = ChatMessageOut.model_validate(message).model_dump(mode="json") + await manager.publish( + rid, + { + "type": "message", + "data": msg_data + } + ) + + elif event_type in ("typing_start", "typing_stop"): + rid = UUID(payload["room_id"]) + if rid not in room_ids: + continue + + await manager.publish( + rid, + { + "type": "typing", + "data": { + "room_id": str(rid), + "member_id": str(current_member.id), + "username": current_member.username, + "is_typing": event_type == "typing_start" + } + } + ) + + elif event_type == "focus": + rid = UUID(payload["room_id"]) + if rid not in room_ids: + continue + + await manager.switch_focus(current_member.id, current_member.username, focused_room, rid) + focused_room = rid + + elif event_type == "blur": + await manager.switch_focus(current_member.id, current_member.username, focused_room, None) + focused_room = None + except WebSocketDisconnect: - manager.disconnect(room_id, websocket) - except Exception as e: - # 기타 에러 발생 시 연결 종료 - manager.disconnect(room_id, websocket) - await websocket.close(code=1011) # Internal Error + pass + except Exception: + await websocket.close(code=1011) # Internal Error + finally: + project_task.cancel() + for rid in list(room_ids): + manager.disconnect(rid, websocket) + + if focused_room is not None: + await manager.leave_presence(focused_room, current_member.id, current_member.username) + +# @router.websocket("/ws/{room_id}") +# async def chat_websocket( +# websocket: WebSocket, +# room_id: UUID, +# token: Annotated[str, Query()], +# member_service: MemberServiceDep, +# chat_service: ChatServiceDep, +# ): +# # 핸드쉐이크 먼저 수락 +# await websocket.accept() +# +# # 인증 +# try: +# current_member = await get_ws_current_member(token, member_service) +# except Exception: +# await websocket.close(code=1008) # Policy Violation +# return +# +# # 연결 +# await manager.connect(room_id, websocket) +# first = await manager.add_presence(room_id, current_member.id) +# if first == 1: +# await manager.publish(room_id, { +# "type": "presence", +# "data": { +# "member_id": str(current_member.id), +# "username": current_member.username, +# "online": True, +# }, +# }) +# +# try: +# while True: +# # 클라이언트로부터 메시지 대기 +# data = await websocket.receive_text() +# +# try: +# payload = json.loads(data) +# if not isinstance(payload, dict): +# raise ValueError +# event_type = payload.get("type", "message") +# +# except (json.JSONDecodeError, ValueError): +# event_type = "message" +# payload = {"content": data} +# +# if event_type == "message": +# content = (payload.get("content") or "").strip() +# +# if not content: +# continue +# message = await chat_service.send_message( +# room_id=room_id, +# sender_id=current_member.id, +# content=content, +# ) +# msg_data = ChatMessageOut.model_validate(message).model_dump(mode="json") +# await manager.publish( +# room_id, +# { +# "type": "message", +# "data": msg_data +# } +# ) +# +# elif event_type in ("typing_start", "typing_stop"): +# await manager.publish( +# room_id, +# { +# "type": "typing", +# "data": { +# "member_id": str(current_member.id), +# "username": current_member.username, +# "is_typing": event_type == "typing_start" +# } +# } +# ) +# +# except WebSocketDisconnect: +# pass +# except Exception: +# await websocket.close(code=1011) # Internal Error +# finally: +# manager.disconnect(room_id, websocket) +# last = await manager.remove_presence(room_id, current_member.id) +# if last == 0: +# await manager.publish( +# room_id, +# { +# "type": "presence", +# "data": { +# "member_id": str(current_member.id), +# "username": current_member.username, +# "online": False, +# } +# } +# ) diff --git a/app/modules/chat/schemas.py b/app/modules/chat/schemas.py index a462644..c96b4cb 100644 --- a/app/modules/chat/schemas.py +++ b/app/modules/chat/schemas.py @@ -1,44 +1,39 @@ +#dto from datetime import datetime from uuid import UUID -from pydantic import BaseModel + +from pydantic import ConfigDict +from sqlmodel import SQLModel, Field + from app.shared.enums import ChatRoomType from app.modules.member.schemas import MemberOut -class ChatMessageBase(BaseModel): - content: str - -class ChatMessageCreate(ChatMessageBase): - chat_room_id: UUID -class ChatMessageRead(ChatMessageBase): - id: UUID - chat_room_id: UUID - sender_id: UUID - sender: MemberOut - created_at: datetime +#요청 +class DirectChatRoomCreateIn(SQLModel): + target_member_id: UUID = Field(description="대화 상대 회원 고유키") - class Config: - from_attributes = True -class ChatRoomBase(BaseModel): - project_id: UUID - type: ChatRoomType - name: str | None = None +#응답 +class ChatMessageOut(SQLModel): + id: UUID = Field(description="메시지 고유키") + chat_room_id: UUID = Field(description="채팅방 고유키") + sender_id: UUID = Field(description="발신자 회원 고유키") + sender: MemberOut = Field(description="발신자 정보") + content: str = Field(description="메시지 내용") + created_at: datetime = Field(description="생성 일시") -class ChatRoomCreate(ChatRoomBase): - pass + model_config = ConfigDict(from_attributes=True) -class DirectChatRoomCreate(BaseModel): - target_member_id: UUID -class ChatRoomRead(ChatRoomBase): - id: UUID - created_at: datetime - members: list[MemberOut] = [] - - class Config: - from_attributes = True +class ChatRoomOut(SQLModel): + id: UUID = Field(description="채팅방 고유키") + project_id: UUID = Field(description="프로젝트 고유키") + type: ChatRoomType = Field(description="채팅방 유형(group/direct)") + name: str | None = Field(default=None, description="채팅방 이름 (단체 채팅방용)") + created_at: datetime = Field(description="생성 일시") + members: list[MemberOut] = Field(default=[], description="채팅방 참여자 목록") + unread_count: int = Field(default=0, description="읽지 않은 메시지 수") + last_message: ChatMessageOut | None = Field(default=None, description="마지막 메시지") -class ChatRoomDetailRead(ChatRoomRead): - members: list[MemberOut] - # messages: list[ChatMessageRead] # 메시지는 별도 페이징 API로 가져오는 것이 효율적 + model_config = ConfigDict(from_attributes=True) diff --git a/app/modules/chat/service.py b/app/modules/chat/service.py index 07fbc13..08854c7 100644 --- a/app/modules/chat/service.py +++ b/app/modules/chat/service.py @@ -8,8 +8,7 @@ from app.modules.chat.repository import ChatRepository from app.modules.chat.models import ChatRoom, ChatMessage -from app.modules.chat.schemas import ChatMessageRead -from app.modules.project.repository import ProjectRepository +from app.modules.chat.schemas import ChatRoomOut, ChatMessageOut from app.shared.enums import ChatRoomType from app.core.exceptions import AppError from app.core.redis import redis_client @@ -28,12 +27,12 @@ async def connect(self, room_id: UUID, websocket: WebSocket): self.active_connections[room_id] = set() # 해당 방에 대한 첫 연결이면 Redis 구독 시작 self.sub_tasks[room_id] = asyncio.create_task(self._subscribe_room(room_id)) - + self.active_connections[room_id].add(websocket) def disconnect(self, room_id: UUID, websocket: WebSocket): if room_id in self.active_connections: - self.active_connections[room_id].remove(websocket) + self.active_connections[room_id].discard(websocket) if not self.active_connections[room_id]: # 더 이상 연결된 세션이 없으면 구독 중단 if room_id in self.sub_tasks: @@ -46,7 +45,7 @@ async def _subscribe_room(self, room_id: UUID): pubsub = redis_client.pubsub() channel_name = f"chat:{room_id}" await pubsub.subscribe(channel_name) - + try: async for message in pubsub.listen(): if message["type"] == "message": @@ -66,19 +65,107 @@ async def _local_broadcast(self, room_id: UUID, message: dict): try: await connection.send_json(message) except Exception: - self.active_connections[room_id].remove(connection) + self.active_connections[room_id].discard(connection) async def publish(self, room_id: UUID, message: dict): """Redis 채널에 메시지를 발행합니다 (전체 서버 인스턴스로 확산).""" await redis_client.publish(f"chat:{room_id}", json.dumps(message)) + async def publish_project(self, project_id: UUID, message: dict): + """프로젝트 제어 채널에 발행 (방 목록 변경 알림용)""" + await redis_client.publish(f"project:{project_id}", json.dumps(message)) + + async def subscribe_project_events(self, project_id: UUID, member_id: UUID, websocket: WebSocket, + room_ids: set[UUID]): + pubsub = redis_client.pubsub() + channel = f"project:{project_id}" + await pubsub.subscribe(channel) + try: + async for message in pubsub.listen(): + if message["type"] != "message": + continue + evt = json.loads(message["data"]) + if evt.get("type") != "room_created": + continue + data = evt["data"] + if str(member_id) not in data["member_ids"]: + continue + new_room_id = UUID(data["room"]["id"]) + if new_room_id in room_ids: + continue + await self.connect(new_room_id, websocket) + room_ids.add(new_room_id) + await websocket.send_json({"type": "room_created", "data": data["room"]}) + except asyncio.CancelledError: + await pubsub.unsubscribe(channel) + except Exception as e: + logger.error(f"해당 프로젝트 이벤트 구독 중 오류 발생: {e}") + await pubsub.unsubscribe(channel) + + def _presence_key(self, room_id: UUID) -> str: + return f"presence:room:{room_id}" + + async def add_presence(self, room_id: UUID, member_id: UUID) -> int: + """연결 수 +1, 갱신된 연결 수 반환. 1이면 '방금 온라인 됨'""" + count = await redis_client.hincrby(self._presence_key(room_id), str(member_id), 1) + return count + + async def remove_presence(self, room_id: UUID, member_id: UUID) -> int: + """연결 수 -1, 0 이하면 필드 삭제. 0이면 '방금 오프라인 됨'""" + key = self._presence_key(room_id) + count = await redis_client.hincrby(key, str(member_id), -1) + if count <= 0: + await redis_client.hdel(key, str(member_id)) + return 0 + return count + + async def is_present(self, room_id: UUID, member_id: UUID) -> bool: + return await redis_client.hexists(self._presence_key(room_id), str(member_id)) + + async def enter_presence(self, room_id: UUID, member_id: UUID, username: str) -> None: + """focus 시 호출: presence 등록, 첫 진입이면 online 브로드캐스트""" + first = await self.add_presence(room_id, member_id) + if first == 1: + await self.publish(room_id, { + "type": "presence", + "data": { + "room_id": str(room_id), + "member_id": str(member_id), + "username": username, + "online": True, + }, + }) + + async def leave_presence(self, room_id: UUID, member_id: UUID, username: str) -> None: + """blur/disconnect 시 호출: presence 해제, 마지막이면 offline 브로드캐스트""" + last = await self.remove_presence(room_id, member_id) + if last == 0: + await self.publish(room_id, { + "type": "presence", + "data": { + "room_id": str(room_id), + "member_id": str(member_id), + "username": username, + "online": False, + }, + }) + + async def switch_focus(self, member_id: UUID, username: str, old_room: UUID | None, new_room: UUID | None) -> None: + """보던 방(old)에서 나가고 새 방(new)으로 focus 이동""" + if old_room == new_room: + return + if old_room is not None: + await self.leave_presence(old_room, member_id, username) + if new_room is not None: + await self.enter_presence(new_room, member_id, username) + + manager = ConnectionManager() class ChatService: - def __init__(self, session: AsyncSession, repository: ChatRepository, project_repository: ProjectRepository): + def __init__(self, session: AsyncSession, repository: ChatRepository): self.session = session self.repository = repository - self.project_repository = project_repository async def get_or_create_group_room(self, project_id: UUID) -> ChatRoom: """프로젝트의 단체 채팅방을 조회하거나 없으면 생성합니다.""" @@ -100,9 +187,6 @@ async def get_or_create_direct_room(self, project_id: UUID, member_a: UUID, memb if member_a == member_b: raise AppError.bad_request("자기 자신과는 대화할 수 없습니다.") - if not await self.project_repository.is_member(project_id, member_b): - raise AppError.bad_request("상대방이 해당 프로젝트의 멤버가 아닙니다.") - room = await self.repository.get_direct_room(project_id, member_a, member_b) if not room: room = ChatRoom( @@ -113,7 +197,7 @@ async def get_or_create_direct_room(self, project_id: UUID, member_a: UUID, memb # 멤버 추가 await self.repository.add_member_to_room(room.id, member_a) await self.repository.add_member_to_room(room.id, member_b) - + await self.session.commit() # members가 포함된 상태로 다시 조회 room = await self.repository.get_room_by_id(room.id) @@ -121,14 +205,13 @@ async def get_or_create_direct_room(self, project_id: UUID, member_a: UUID, memb async def send_message(self, room_id: UUID, sender_id: UUID, content: str) -> ChatMessage: """메시지를 DB에 저장하고 반환합니다.""" - # 삭제된(또는 존재하지 않는) 방인지 먼저 확인 — 멤버십 행이 남아있어도 삭제된 방엔 메시지를 못 쓰게 막는다. - room = await self.repository.get_room_by_id(room_id) - if not room: - raise AppError.not_found("채팅방") - # 멤버십 확인 if not await self.repository.is_room_member(room_id, sender_id): # 단체 채팅방인 경우 자동 참여 처리 고려 가능하나, 여기서는 에러 처리 + room = await self.repository.get_room_by_id(room_id) + if not room: + raise AppError.not_found("채팅방을 찾을 수 없습니다.") + if room.type == ChatRoomType.GROUP: # 단체방은 프로젝트 멤버면 자동 참여 await self.repository.add_member_to_room(room_id, sender_id) @@ -147,28 +230,31 @@ async def send_message(self, room_id: UUID, sender_id: UUID, content: str) -> Ch async def get_history(self, room_id: UUID, member_id: UUID, limit: int = 50, offset: int = 0): """채팅 이력을 조회합니다.""" - # 삭제된(또는 존재하지 않는) 방이면, 멤버십 행이 남아있어도 이력을 볼 수 없게 막는다. - room = await self.repository.get_room_by_id(room_id) - if not room: - raise AppError.not_found("채팅방") - if not await self.repository.is_room_member(room_id, member_id): raise AppError.forbidden("채팅방 멤버가 아닙니다.") return await self.repository.get_messages(room_id, limit, offset) - async def list_rooms(self, project_id: UUID, member_id: UUID): + async def list_rooms(self, project_id: UUID, member_id: UUID) -> list[ChatRoomOut]: """참여 중인 채팅방 목록을 조회합니다.""" - # 1. 단체 채팅방 보장 및 자동 참여 group_room = await self.get_or_create_group_room(project_id) - - # 사용자가 단체 채팅방 멤버가 아니면 추가 (프로젝트 멤버임은 상위 로직에서 검증 권장) + if not await self.repository.is_room_member(group_room.id, member_id): await self.repository.add_member_to_room(group_room.id, member_id) await self.session.commit() - - # 2. 참여 중인 모든 채팅방(단체+DM) 목록 반환 - return await self.repository.get_rooms_by_project(project_id, member_id) + + rooms = await self.repository.get_rooms_by_project(project_id, member_id) + last_messages = await self.repository.get_last_messages([room.id for room in rooms]) + + result = [] + for room in rooms: + count = await self.repository.get_unread_count(room.id, member_id) + room_data = ChatRoomOut.model_validate(room) + room_data.unread_count = count + last = last_messages.get(room.id) + room_data.last_message = ChatMessageOut.model_validate(last) if last else None + result.append(room_data) + return result async def delete_room(self, room_id: UUID, member_id: UUID): """채팅방을 삭제합니다.""" @@ -178,9 +264,20 @@ async def delete_room(self, room_id: UUID, member_id: UUID): if room.type == ChatRoomType.GROUP: raise AppError.bad_request("단체 채팅방은 삭제할 수 없습니다.") - + if not await self.repository.is_room_member(room_id, member_id): raise AppError.forbidden("채팅방 삭제 권한이 없습니다.") - + await self.repository.delete_room(room) await self.session.commit() + + async def mark_as_read(self, room_id: UUID, member_id: UUID, message_id: UUID) -> None: + if not await self.repository.is_room_member(room_id, member_id): + raise AppError.forbidden("채팅방 멤버가 아닙니다.") + + message = await self.repository.get_message_by_id(message_id) + if not message or message.chat_room_id != room_id: + raise AppError.not_found("메시지") + + await self.repository.mark_as_read(room_id, member_id, message_id) + await self.session.commit() \ No newline at end of file diff --git a/app/shared/enums.py b/app/shared/enums.py index ae03134..16115d3 100644 --- a/app/shared/enums.py +++ b/app/shared/enums.py @@ -25,4 +25,9 @@ class InvitationStatus(str, Enum): class ChatRoomType(str, Enum): GROUP = "group" - DIRECT = "direct" \ No newline at end of file + DIRECT = "direct" + +class ChatEventType(str, Enum): + MESSAGE = "message" + TYPING = "typing" + PRESENCE = "presence" \ No newline at end of file diff --git a/app/static/member-detail.html b/app/static/member-detail.html index 1310bbf..0d3452c 100644 --- a/app/static/member-detail.html +++ b/app/static/member-detail.html @@ -127,6 +127,59 @@
대화를 시작하려면 왼쪽 목록에서 방을 선택하세요.
+