From bce58e031a7d648dd8a3946f39c9e82fb93c207d Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Fri, 4 Sep 2026 12:32:25 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC,=20=EC=95=88=EC=9D=BD?= =?UTF-8?q?=EC=9D=80=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98=20count=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/chat/models.py | 6 +++++- app/modules/chat/repository.py | 36 ++++++++++++++++++++++++++++++++++ app/modules/chat/router.py | 23 ++++++++++++++++++++-- app/modules/chat/schemas.py | 3 ++- app/modules/chat/service.py | 31 +++++++++++++++++++++-------- 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/app/modules/chat/models.py b/app/modules/chat/models.py index 54b97b6..4f030b8 100644 --- a/app/modules/chat/models.py +++ b/app/modules/chat/models.py @@ -2,7 +2,8 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import Enum, Column, DateTime +from sqlalchemy import Enum, Column, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column from sqlmodel import Field, Relationship, SQLModel from app.shared.models.base import BaseModel @@ -22,6 +23,9 @@ class ChatRoomMember(SQLModel, table=True): default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(DateTime(timezone=True), nullable=False), ) + last_read_message_id: Mapped[UUID | None] = mapped_column( + ForeignKey("chat_messages.id"), nullable=True, default=None + ) class ChatRoom(BaseModel, table=True): __tablename__ = "chat_rooms" diff --git a/app/modules/chat/repository.py b/app/modules/chat/repository.py index 666569d..a9242d6 100644 --- a/app/modules/chat/repository.py +++ b/app/modules/chat/repository.py @@ -102,6 +102,11 @@ 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 is_room_member(self, room_id: UUID, member_id: UUID) -> bool: stmt = select(ChatRoomMember).where( ChatRoomMember.chat_room_id == room_id, @@ -116,3 +121,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..15827a9 100644 --- a/app/modules/chat/router.py +++ b/app/modules/chat/router.py @@ -10,6 +10,7 @@ from app.modules.member.models import Member 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"]) @@ -115,6 +116,24 @@ async def chat_websocket( except WebSocketDisconnect: manager.disconnect(room_id, websocket) except Exception as e: - # 기타 에러 발생 시 연결 종료 manager.disconnect(room_id, websocket) - await websocket.close(code=1011) # Internal Error + await websocket.close(code=1011) + +@router.post( + path="/rooms/{room_id}/read/{message_id}", + response_model=ApiResponse[None], + summary="채팅방 메시지 읽음 처리", + description="채팅방에서 특정 메시지를 가장 최근에 읽은 메시지로 처리합니다." +) +async def mark_as_read( + room_id: UUID, + message_id: UUID, + current_member: CurrentMemberDep, + service: ChatService = Depends(get_chat_service) +): + await service.mark_ad_read(room_id, current_member.id, message_id) + return ApiResponse.success( + code="MESSAGE_MARKED_AS_READ", + message="메시지 읽음 처리 완료", + data=None, + ) \ No newline at end of file diff --git a/app/modules/chat/schemas.py b/app/modules/chat/schemas.py index 5d48f9d..9715a24 100644 --- a/app/modules/chat/schemas.py +++ b/app/modules/chat/schemas.py @@ -36,7 +36,8 @@ class ChatRoomRead(ChatRoomBase): id: UUID created_at: datetime members: list[MemberOut] = [] - + unread_count: int = 0 + class Config: from_attributes = True diff --git a/app/modules/chat/service.py b/app/modules/chat/service.py index 9778d7c..0f62956 100644 --- a/app/modules/chat/service.py +++ b/app/modules/chat/service.py @@ -8,7 +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.chat.schemas import ChatMessageRead, ChatRoomRead from app.shared.enums import ChatRoomType from app.core.exceptions import AppError from app.core.redis import redis_client @@ -146,18 +146,23 @@ async def get_history(self, room_id: UUID, member_id: UUID, limit: int = 50, off 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[ChatRoomRead]: """참여 중인 채팅방 목록을 조회합니다.""" - # 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) + + result = [] + for room in rooms: + count = await self.repository.get_unread_count(room.id, member_id) + room_data = ChatRoomRead.model_validate(room) + room_data.unread_count = count + result.append(room_data) + return result async def delete_room(self, room_id: UUID, member_id: UUID): """채팅방을 삭제합니다.""" @@ -173,3 +178,13 @@ async def delete_room(self, room_id: UUID, member_id: UUID): await self.repository.delete_room(room) await self.session.commit() + + async def mark_ad_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) \ No newline at end of file From 2cbe07e2d62b02e4e0c9b5adf1c74eda70eed531 Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Fri, 4 Sep 2026 22:18:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(chat):=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅방 목록에 마지막 메시지 미리보기/시각 표시 - last_read_message_id 마이그레이션 추가 - 응답 ApiResponse 래핑, In/Out 스키마 네이밍, ConfigDict 통일 - ChatServiceDep 의존성 패턴 적용 - 프론트: 안읽은 뱃지, 읽음 처리 연동, 마지막 메시지 미리보기, 응답 언랩 대응 --- ..._add_last_read_message_id_to_chat_room_.py | 34 ++++ app/modules/chat/dependencies.py | 16 +- app/modules/chat/models.py | 10 +- app/modules/chat/repository.py | 17 +- app/modules/chat/router.py | 190 +++++++++++------- app/modules/chat/schemas.py | 61 +++--- app/modules/chat/service.py | 14 +- app/static/member-detail.html | 9 +- app/static/project-detail.html | 76 ++++++- app/static/style.css | 22 ++ 10 files changed, 311 insertions(+), 138 deletions(-) create mode 100644 alembic/versions/a930eb13180a_add_last_read_message_id_to_chat_room_.py 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..f9d441b --- /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: d8b2114a1c79 +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] = 'd8b2114a1c79' +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 7644d8f..bc60692 100644 --- a/app/modules/chat/dependencies.py +++ b/app/modules/chat/dependencies.py @@ -1,14 +1,14 @@ +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 -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: +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 4f030b8..9cdabaf 100644 --- a/app/modules/chat/models.py +++ b/app/modules/chat/models.py @@ -2,8 +2,7 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from sqlalchemy import Enum, Column, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Enum, Column, DateTime from sqlmodel import Field, Relationship, SQLModel from app.shared.models.base import BaseModel @@ -23,8 +22,11 @@ class ChatRoomMember(SQLModel, table=True): default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(DateTime(timezone=True), nullable=False), ) - last_read_message_id: Mapped[UUID | None] = mapped_column( - ForeignKey("chat_messages.id"), nullable=True, default=None + last_read_message_id: UUID | None = Field( + default=None, + foreign_key="chat_messages.id", + nullable=True, + description="마지막으로 읽은 메시지 고유키", ) class ChatRoom(BaseModel, table=True): diff --git a/app/modules/chat/repository.py b/app/modules/chat/repository.py index a9242d6..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 @@ -107,6 +107,21 @@ async def get_message_by_id(self, message_id: UUID) -> ChatMessage | None: 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, diff --git a/app/modules/chat/router.py b/app/modules/chat/router.py index 15827a9..3a21db9 100644 --- a/app/modules/chat/router.py +++ b/app/modules/chat/router.py @@ -1,64 +1,125 @@ -from uuid import UUID 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) + 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": "채팅방이 삭제되었습니다."} + 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: @@ -73,13 +134,14 @@ 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), + websocket: WebSocket, + room_id: UUID, + token: Annotated[str, Query()], + member_service: MemberServiceDep, + chat_service: ChatServiceDep, ): # 핸드쉐이크 먼저 수락 await websocket.accept() @@ -88,52 +150,34 @@ async def chat_websocket( 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) # Policy Violation return # 연결 await manager.connect(room_id, websocket) - + 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 + content=data, ) - + # 브로드캐스트용 데이터 구성 - msg_data = ChatMessageRead.model_validate(message).model_dump(mode="json") - + msg_data = ChatMessageOut.model_validate(message).model_dump(mode="json") + # Redis를 통해 전체 서버 인스턴스로 발행 await manager.publish(room_id, msg_data) - + except WebSocketDisconnect: manager.disconnect(room_id, websocket) - except Exception as e: + except Exception: + # 기타 에러 발생 시 연결 종료 manager.disconnect(room_id, websocket) - await websocket.close(code=1011) - -@router.post( - path="/rooms/{room_id}/read/{message_id}", - response_model=ApiResponse[None], - summary="채팅방 메시지 읽음 처리", - description="채팅방에서 특정 메시지를 가장 최근에 읽은 메시지로 처리합니다." -) -async def mark_as_read( - room_id: UUID, - message_id: UUID, - current_member: CurrentMemberDep, - service: ChatService = Depends(get_chat_service) -): - await service.mark_ad_read(room_id, current_member.id, message_id) - return ApiResponse.success( - code="MESSAGE_MARKED_AS_READ", - message="메시지 읽음 처리 완료", - data=None, - ) \ No newline at end of file + await websocket.close(code=1011) # Internal Error diff --git a/app/modules/chat/schemas.py b/app/modules/chat/schemas.py index 9715a24..c96b4cb 100644 --- a/app/modules/chat/schemas.py +++ b/app/modules/chat/schemas.py @@ -1,46 +1,39 @@ +#dto from datetime import datetime from uuid import UUID -from pydantic import BaseModel -from app.shared.enums import ChatRoomType -from app.modules.member.schemas import MemberOut -class ChatMessageBase(BaseModel): - content: str +from pydantic import ConfigDict +from sqlmodel import SQLModel, Field -class ChatMessageCreate(ChatMessageBase): - chat_room_id: UUID +from app.shared.enums import ChatRoomType +from app.modules.member.schemas import MemberOut -class ChatMessageRead(ChatMessageBase): - id: UUID - chat_room_id: UUID - sender_id: UUID - sender: MemberOut - created_at: datetime - class Config: - from_attributes = True +#요청 +class DirectChatRoomCreateIn(SQLModel): + target_member_id: UUID = Field(description="대화 상대 회원 고유키") -class ChatRoomBase(BaseModel): - project_id: UUID - type: ChatRoomType - name: str | None = None -class ChatRoomCreate(ChatRoomBase): - pass +#응답 +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 DirectChatRoomCreate(BaseModel): - project_id: UUID - target_member_id: UUID + model_config = ConfigDict(from_attributes=True) -class ChatRoomRead(ChatRoomBase): - id: UUID - created_at: datetime - members: list[MemberOut] = [] - unread_count: int = 0 - 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 0f62956..090eec2 100644 --- a/app/modules/chat/service.py +++ b/app/modules/chat/service.py @@ -8,7 +8,7 @@ from app.modules.chat.repository import ChatRepository from app.modules.chat.models import ChatRoom, ChatMessage -from app.modules.chat.schemas import ChatMessageRead, ChatRoomRead +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 @@ -146,7 +146,7 @@ async def get_history(self, room_id: UUID, member_id: UUID, limit: int = 50, off return await self.repository.get_messages(room_id, limit, offset) - async def list_rooms(self, project_id: UUID, member_id: UUID) -> list[ChatRoomRead]: + async def list_rooms(self, project_id: UUID, member_id: UUID) -> list[ChatRoomOut]: """참여 중인 채팅방 목록을 조회합니다.""" group_room = await self.get_or_create_group_room(project_id) @@ -155,12 +155,15 @@ async def list_rooms(self, project_id: UUID, member_id: UUID) -> list[ChatRoomRe await self.session.commit() 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 = ChatRoomRead.model_validate(room) + 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 @@ -179,7 +182,7 @@ async def delete_room(self, room_id: UUID, member_id: UUID): await self.repository.delete_room(room) await self.session.commit() - async def mark_ad_read(self, room_id: UUID, member_id: UUID, message_id: UUID) -> None: + 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("채팅방 멤버가 아닙니다.") @@ -187,4 +190,5 @@ async def mark_ad_read(self, room_id: UUID, member_id: UUID, message_id: UUID) - 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) \ No newline at end of file + 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/static/member-detail.html b/app/static/member-detail.html index 1cca35d..9ac255a 100644 --- a/app/static/member-detail.html +++ b/app/static/member-detail.html @@ -230,17 +230,16 @@

사용자

'Content-Type': 'application/json' }, body: JSON.stringify({ - project_id: projectId, target_member_id: targetMemberId }) }); - - const room = await response.json(); + + const result = await response.json(); if (response.ok) { // 성공 시 해당 프로젝트 페이지의 채팅방으로 이동 - location.href = `/static/project-detail.html?id=${projectId}&chat_room_id=${room.id}`; + location.href = `/static/project-detail.html?id=${projectId}&chat_room_id=${result.data.id}`; } else { - alert('채팅방 생성 실패: ' + (room.message || '오류가 발생했습니다.')); + alert('채팅방 생성 실패: ' + (result.message || '오류가 발생했습니다.')); } } catch (e) { alert('통신 오류 발생'); diff --git a/app/static/project-detail.html b/app/static/project-detail.html index 00ea109..82099a5 100644 --- a/app/static/project-detail.html +++ b/app/static/project-detail.html @@ -633,7 +633,8 @@

${t.title}

const response = await fetch(`/chat/projects/${currentProjectId}/rooms`, { headers: { 'Authorization': `Bearer ${token}` } }); - chatRooms = await response.json(); + const result = await response.json(); + chatRooms = result.data; renderChatRooms(); } catch (e) { console.error('채팅방 로드 실패', e); @@ -647,11 +648,13 @@

${t.title}

return; } - // 1. 단체 채팅방을 상단으로 정렬 (group 타입이 앞에 오도록) + // 1. 단체 채팅방은 상단 고정, 그 외에는 마지막 메시지 시각 기준 최근순 정렬 const sortedRooms = [...chatRooms].sort((a, b) => { if (a.type === 'group' && b.type !== 'group') return -1; if (a.type !== 'group' && b.type === 'group') return 1; - return 0; + const aTime = a.last_message ? new Date(a.last_message.created_at).getTime() : 0; + const bTime = b.last_message ? new Date(b.last_message.created_at).getTime() : 0; + return bTime - aTime; }); const myId = localStorage.getItem('user_id'); @@ -671,15 +674,23 @@

${t.title}

} } + // 마지막 메시지 미리보기 (없으면 기본 라벨) + const preview = room.last_message + ? escapeHtml(room.last_message.content) + : (room.type === 'group' ? '단체 채팅방' : '1:1 채팅'); + const previewTime = room.last_message ? formatPreviewTime(room.last_message.created_at) : ''; + return `
${displayIcon ? displayIcon : ``}
-
+

${displayName}

-

${room.type === 'group' ? '단체 채팅방' : '1:1 채팅'}

+

${preview}

+ ${previewTime ? `${previewTime}` : ''} + ${room.unread_count > 0 ? `${room.unread_count > 99 ? '99+' : room.unread_count}` : ''} ${room.type === 'direct' ? `
+