diff --git a/.golangci.yml b/.golangci.yml index 53c04e29..098e5248 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,9 @@ output: # This file contains only configs which differ from defaults. # All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml linters-settings: + gosec: + excludes: + - G115 cyclop: # The maximal code complexity to report. # Default: 10 diff --git a/api/web.go b/api/web.go index d6edd982..ad3e3447 100644 --- a/api/web.go +++ b/api/web.go @@ -90,3 +90,43 @@ func ParsePaginationQueries(c echo.Context, defaultPage, defaultLimit int) (page return page, size, nil } + +func ParseCursorPaginationQueries(c echo.Context, defaultLimit int) (prev, next, limit int, err *AppError) { + prevQuery := c.QueryParam("prev") + nextQuery := c.QueryParam("next") + sizeQuery := c.QueryParam("size") + + if prevQuery == "" && nextQuery == "" { + return 0, 0, 0, ErrInvalidPagination(errors.New("expected either prev or next query")) + } + + if prevQuery != "" { + var atoiError error + prev, atoiError = strconv.Atoi(prevQuery) + if atoiError != nil || prev <= 0 { + return 0, 0, 0, ErrInvalidPagination(errors.New("expected integer value bigger than 0 for query: prev")) + } + } + + if nextQuery != "" { + var atoiError error + next, atoiError = strconv.Atoi(nextQuery) + if atoiError != nil || next <= 0 { + return 0, 0, 0, ErrInvalidPagination(errors.New("expected integer value bigger than 0 for query: next")) + } + } + + if sizeQuery != "" { + var atoiError error + limit, atoiError = strconv.Atoi(sizeQuery) + if atoiError != nil || limit <= 0 { + return 0, 0, 0, ErrInvalidPagination(errors.New("expected integer value bigger than 0 for query: size")) + } + } + + if limit == 0 { + limit = defaultLimit + } + + return prev, next, limit, nil +} diff --git a/cmd/server/handler/chat_handler.go b/cmd/server/handler/chat_handler.go index cf9b9cdf..65fb3421 100644 --- a/cmd/server/handler/chat_handler.go +++ b/cmd/server/handler/chat_handler.go @@ -3,81 +3,200 @@ package handler import ( "net/http" - "github.com/gorilla/websocket" "github.com/labstack/echo/v4" - - "github.com/pet-sitter/pets-next-door-api/internal/chat" - "github.com/pet-sitter/pets-next-door-api/internal/domain/user" + pnd "github.com/pet-sitter/pets-next-door-api/api" + domain "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" "github.com/pet-sitter/pets-next-door-api/internal/service" ) type ChatHandler struct { - wsServer *chat.WebSocketServer - upgrader websocket.Upgrader - stateManager *chat.StateManager - authService service.AuthService - chatService service.ChatService -} - -var upgrader = websocket.Upgrader{ - // 사이즈 단위: byte - // 영어 기준: 2048 글자 - // 한국어(글자당 3바이트) 기준: 약 682 글자 - // 영어 + 한국어 기준(글자당 2바이트로 가정): 약 1024 글자 - ReadBufferSize: 2048, - WriteBufferSize: 2048, + authService service.AuthService + chatService service.ChatService } -func NewChatController( - wsServer *chat.WebSocketServer, - stateManager chat.StateManager, +func NewChatHandler( authService service.AuthService, chatService service.ChatService, ) *ChatHandler { return &ChatHandler{ - wsServer: wsServer, - upgrader: upgrader, - stateManager: &stateManager, - authService: authService, - chatService: chatService, + authService: authService, + chatService: chatService, } } -func (h *ChatHandler) ServerWebsocket( - c echo.Context, w http.ResponseWriter, r *http.Request, -) error { +// FindRoomByID godoc +// @Summary 채팅방을 조회합니다. +// @Description +// @Tags chat +// @Accept json +// @Produce json +// @Param roomID path int true "채팅방 ID" +// @Security FirebaseAuth +// @Success 200 {object} domain.RoomSimpleInfo +// @Router /chat/rooms/{roomID} [get] +func (h ChatHandler) FindRoomByID(c echo.Context) error { foundUser, err := h.authService.VerifyAuthAndGetUser(c.Request().Context(), c.Request().Header.Get("Authorization")) if err != nil { return c.JSON(err.StatusCode, err) } - conn, err2 := upgrader.Upgrade(w, r, nil) - if err2 != nil { - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": err2.Error(), - }) + roomID, err := pnd.ParseIDFromPath(c, "roomID") + if err != nil { + return c.JSON(err.StatusCode, err) } - client := h.initializeOrUpdateClient(conn, foundUser) + res, err := h.chatService.FindChatRoomByUIDAndRoomID(c.Request().Context(), foundUser.FirebaseUID, int64(*roomID)) + if err != nil { + return c.JSON(err.StatusCode, err) + } - // 클라이언트의 메시지를 읽고 쓰는 데 사용되는 고루틴을 시작 (비동기) - go client.HandleWrite() - go client.HandleRead(*h.stateManager, &h.chatService) + return c.JSON(http.StatusOK, res) +} - return nil +// CreateRoom godoc +// @Summary 채팅방을 생성합니다. +// @Description +// @Tags chat +// @Accept json +// @Produce json +// @Param request body domain.CreateRoomRequest true "채팅방 생성 요청" +// @Security FirebaseAuth +// @Success 201 {object} domain.RoomSimpleInfo +// @Router /chat/rooms [post] +func (h ChatHandler) CreateRoom(c echo.Context) error { + user, err := h.authService.VerifyAuthAndGetUser(c.Request().Context(), c.Request().Header.Get("Authorization")) + if err != nil { + return c.JSON(err.StatusCode, err) + } + var createRoomRequest domain.CreateRoomRequest + + if bodyError := pnd.ParseBody(c, &createRoomRequest); bodyError != nil { + return c.JSON(bodyError.StatusCode, err) + } + + res, err := h.chatService.CreateRoom( + c.Request().Context(), + createRoomRequest.RoomName, + createRoomRequest.RoomType, + user.FirebaseUID, + ) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + return c.JSON(http.StatusCreated, res) } -// 클라이언트를 초기화하거나 기존 클라이언트를 업데이트하는 함수 -func (h *ChatHandler) initializeOrUpdateClient( - conn *websocket.Conn, userData *user.InternalView, -) *chat.Client { - client := h.wsServer.StateManager.FindClientByUID(userData.FirebaseUID) - if client == nil { - client = chat.NewClient(conn, userData.Nickname, userData.FirebaseUID) - h.wsServer.StateManager.RegisterClient(client) - } else { - // 기존 클라이언트가 있는 경우 연결을 업데이트 - client.UpdateConn(conn) - } - return client +// JoinChatRoom godoc +// @Summary 채팅방에 참가합니다. +// @Description 채팅방에 참가합니다. +// @Tags chat +// @Accept json +// @Produce json +// @Param roomID path int true "채팅방 ID" +// @Security FirebaseAuth +// @Success 200 {object} domain.JoinRoomsView +// @Router /chat/rooms/{roomID}/join [post] +func (h ChatHandler) JoinChatRoom(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser(c.Request().Context(), c.Request().Header.Get("Authorization")) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + roomID, err := pnd.ParseIDFromPath(c, "roomID") + if err != nil { + return c.JSON(err.StatusCode, err) + } + + res, err := h.chatService.JoinRoom(c.Request().Context(), int64(*roomID), foundUser.FirebaseUID) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + return c.JSON(http.StatusOK, res) +} + +// LeaveChatRoom godoc +// @Summary 채팅방을 나갑니다. +// @Description 채팅방을 나갑니다. +// @Tags chat +// @Accept json +// @Produce json +// @Param roomID path int true "채팅방 ID" +// @Security FirebaseAuth +// @Success 200 +// @Router /chat/rooms/{roomID}/leave [post] +func (h ChatHandler) LeaveChatRoom(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser(c.Request().Context(), c.Request().Header.Get("Authorization")) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + roomID, err := pnd.ParseIDFromPath(c, "roomID") + if err != nil { + return c.JSON(err.StatusCode, err) + } + + res := h.chatService.LeaveRoom(c.Request().Context(), int64(*roomID), foundUser.FirebaseUID) + return c.JSON(http.StatusOK, res) +} + +// FindAllRooms godoc +// @Summary 사용자의 채팅방 목록을 조회합니다. +// @Description 사용자의 채팅방 목록을 조회합니다. +// @Tags chat +// @Accept json +// @Produce json +// @Security FirebaseAuth +// @Success 200 {object} domain.JoinRoomsView +// @Router /chat/rooms [get] +func (h ChatHandler) FindAllRooms(c echo.Context) error { + foundUser, err := h.authService.VerifyAuthAndGetUser(c.Request().Context(), c.Request().Header.Get("Authorization")) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + rooms, err := h.chatService.FindAllByUserUID(c.Request().Context(), foundUser.FirebaseUID) + if err != nil { + return c.JSON(err.StatusCode, err) + } + return c.JSON(http.StatusOK, rooms) +} + +// FindMessagesByRoomID godoc +// @Summary 채팅방의 메시지 목록을 조회합니다. +// @Description 채팅방의 메시지 목록을 조회합니다. +// @Tags chat +// @Accept json +// @Produce json +// @Param roomID path int true "채팅방 ID" +// @Param prev query int false "이전 페이지" +// @Param next query int false "다음 페이지" +// @Param size query int false "페이지 사이즈" default(30) +// @Security FirebaseAuth +// @Success 200 {object} domain.MessageCursorView +// @Router /chat/rooms/{roomID}/messages [get] +func (h ChatHandler) FindMessagesByRoomID(c echo.Context) error { + roomID, err := pnd.ParseIDFromPath(c, "roomID") + if err != nil { + return c.JSON(err.StatusCode, err) + } + + prev, next, limit, appError := pnd.ParseCursorPaginationQueries(c, 30) + if appError != nil { + return c.JSON(appError.StatusCode, appError) + } + + res, err := h.chatService.FindChatRoomMessagesByRoomID( + c.Request().Context(), + int64(*roomID), + int64(prev), + int64(next), + int64(limit), + ) + if err != nil { + return c.JSON(err.StatusCode, err) + } + + return c.JSON(http.StatusOK, res) } diff --git a/cmd/server/router.go b/cmd/server/router.go index da3ba526..6476801e 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -55,7 +55,7 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { breedService := service.NewBreedService(db) sosPostService := service.NewSOSPostService(db) conditionService := service.NewSOSConditionService(db) - // chatService := service.NewChatService(db) + chatService := service.NewChatService(db) // Initialize handlers authHandler := handler.NewAuthHandler(authService, kakaoinfra.NewKakaoDefaultClient()) @@ -64,6 +64,7 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { breedHandler := handler.NewBreedHandler(*breedService) sosPostHandler := handler.NewSOSPostHandler(*sosPostService, authService) conditionHandler := handler.NewConditionHandler(*conditionService) + chatHandler := handler.NewChatHandler(authService, *chatService) // // InMemoryStateManager는 클라이언트와 채팅방의 상태를 메모리에 저장하고 관리합니다. // // 이 메서드는 단순하고 빠르며 테스트 목적으로 적합합니다. @@ -72,7 +73,7 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { // wsServer := chat.NewWebSocketServer(stateManager) // go wsServer.Run() // chat.InitializeWebSocketServer(ctx, wsServer, chatService) - // chatHandler := handler.NewChatController(wsServer, stateManager, authService, *chatService) + // chatHandler := handler.NewChatHandler(wsServer, stateManager, authService, *chatService) // RegisterChan middlewares logger := zerolog.New(os.Stdout) @@ -142,13 +143,6 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { postAPIGroup.GET("/sos/conditions", conditionHandler.FindConditions) } - // chatAPIGroup := apiRouteGroup.Group("/chat") - // { - // chatAPIGroup.GET("/ws", func(c echo.Context) error { - // return chatHandler.ServerWebsocket(c, c.Response().Writer, c.Request()) - // }) - // } - upgrader := wschat.NewDefaultUpgrader() wsServerV2 := wschat.NewWSServer(upgrader, authService, *mediaService) @@ -157,6 +151,12 @@ func NewRouter(app *firebaseinfra.FirebaseApp) (*echo.Echo, error) { chatAPIGroup := apiRouteGroup.Group("/chat") { chatAPIGroup.GET("/ws", wsServerV2.HandleConnections) + chatAPIGroup.POST("/rooms", chatHandler.CreateRoom) + chatAPIGroup.PUT("/rooms/:roomID/join", chatHandler.JoinChatRoom) + chatAPIGroup.PUT("/rooms/:roomID/leave", chatHandler.LeaveChatRoom) + chatAPIGroup.GET("/rooms/:roomID", chatHandler.FindRoomByID) + chatAPIGroup.GET("/rooms", chatHandler.FindAllRooms) + chatAPIGroup.GET("/rooms/:roomID/messages", chatHandler.FindMessagesByRoomID) } return e, nil diff --git a/internal/chat/client.go b/internal/chat/client.go deleted file mode 100644 index 8675d532..00000000 --- a/internal/chat/client.go +++ /dev/null @@ -1,287 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/gorilla/websocket" - "github.com/rs/zerolog/log" - - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/service" -) - -const ( - // WebSocket 연결에서 메시지를 쓰는 데 최대 10초를 기다림 - writeWait = 10 * time.Second - // WebSocket 연결에서 마지막으로 받은 메시지 이후로 60초를 기다림 - pongWait = 60 * time.Second - // 서버가 클라이언트에게 ping 메시지를 보내는 주기 - pingPeriod = (pongWait * 9) / 10 - maxMessageSize = 2048 -) - -var newline = []byte{'\n'} - -type Client struct { - Conn *websocket.Conn `json:"-"` - MessageSender chan []byte `json:"-"` - FbUID string `json:"id"` - Name string `json:"name"` -} - -func NewClient(conn *websocket.Conn, name, fbUID string) *Client { - return &Client{ - FbUID: fbUID, - Name: name, - Conn: conn, - MessageSender: make(chan []byte, 256), - } -} - -func (client *Client) HandleRead(stateManager StateManager, chatService *service.ChatService) *pnd.AppError { - defer client.disconnect(stateManager) - client.setupConnection() - - for { - _, jsonMessage, err := client.Conn.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "예상치 못한 연결 종료 오류가 발생했습니다. userID="+client.FbUID) - } - break - } - if len(jsonMessage) > maxMessageSize { - errMsg := fmt.Sprintf("메시지 크기가 최대 크기(%d 바이트)를 초과합니다.", maxMessageSize) - err := client.Conn.WriteMessage(websocket.TextMessage, []byte(errMsg)) - if err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "메시지 크기 초과 오류 메시지를 전송하는 데 실패했습니다. userID="+client.FbUID) - } - continue - } - client.handleNewMessage(jsonMessage, stateManager, chatService) - } - return nil -} - -func (client *Client) HandleWrite() { - ticker := time.NewTicker(pingPeriod) - defer func() { - ticker.Stop() - client.Conn.Close() - }() - - for { - select { - case message, ok := <-client.MessageSender: - if !ok { - client.writeCloseMessage() - return - } - client.writeMessage(message) - - case <-ticker.C: - client.sendPing() - } - } -} - -func (client *Client) setupConnection() *pnd.AppError { - err := client.Conn.SetReadDeadline(time.Now().Add(pongWait)) - if err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "읽기 제한 시간 설정에 실패했습니다. userID="+client.FbUID) - } - - client.Conn.SetPongHandler(func(string) error { - err := client.Conn.SetReadDeadline(time.Now().Add(pongWait)) - if err != nil { - return err - } - return nil - }) - return nil -} - -func (client *Client) writeMessage(message []byte) *pnd.AppError { - if err := client.Conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "쓰기 제한 시간 설정에 실패했습니다. userID="+client.FbUID) - } - w, err := client.Conn.NextWriter(websocket.TextMessage) - if err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "다음 작성자 설정에 실패했습니다. userID="+client.FbUID) - } - defer w.Close() - - if _, err := w.Write(message); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "메시지 쓰기에 실패했습니다. userID=%s"+client.FbUID) - } - - n := len(client.MessageSender) - for i := 0; i < n; i++ { - if _, err := w.Write(newline); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "개행 문자 쓰기에 실패했습니다. userID=%s"+client.FbUID) - } - - if _, err := w.Write(<-client.MessageSender); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "채널에서 메시지 읽기에 실패했습니다. userID=%s"+client.FbUID) - } - } - return nil -} - -func (client *Client) writeCloseMessage() *pnd.AppError { - err := client.Conn.SetWriteDeadline(time.Now().Add(writeWait)) - if err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "종료 메시지의 쓰기 제한 시간 설정에 실패했습니다. userID=%s"+client.FbUID) - } - err = client.Conn.WriteMessage(websocket.CloseMessage, []byte{}) - if err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "종료 메시지 쓰기에 실패했습니다. userID=%s"+client.FbUID) - } - return nil -} - -func (client *Client) sendPing() *pnd.AppError { - if err := client.Conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "핑 메시지의 쓰기 제한 시간 설정에 실패했습니다. userID=%s"+client.FbUID) - } - if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "핑 메시지 쓰기에 실패했습니다. userID=%s"+client.FbUID) - } - return nil -} - -func (client *Client) disconnect(stateManager StateManager) *pnd.AppError { - stateManager.UnregisterClient(client) - for roomID := range stateManager.GetClientRooms(client.FbUID) { - stateManager.LeaveRoom(roomID, client.FbUID) - } - close(client.MessageSender) - if err := client.Conn.Close(); err != nil { - return pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "연결 종료에 실패했습니다. userID=%s"+client.FbUID) - } - return nil -} - -func (client *Client) handleNewMessage( - jsonMessage []byte, stateManager StateManager, chatService *service.ChatService, -) *pnd.AppError { - var message Message - if err := json.Unmarshal(jsonMessage, &message); err != nil { - return pnd.NewAppError(err, http.StatusBadRequest, pnd.ErrCodeInvalidBody, - "JSON 메시지 해독에 실패했습니다. userID=%s"+client.FbUID) - } - message.Sender = client - switch message.Action { - case SendMessageAction: - roomID := message.Room.GetID() - if room := stateManager.FindRoomByID(roomID); room != nil { - room.BroadcastChan <- &message - } - case JoinRoomAction: - client.handleJoinRoomMessage(message, stateManager, chatService) - case LeaveRoomAction: - client.handleLeaveRoomMessage(message.Room.GetID(), stateManager) - } - return nil -} - -func (client *Client) handleJoinRoomMessage( - message Message, stateManager StateManager, chatService *service.ChatService, -) *pnd.AppError { - if message.Room == nil { - return pnd.NewAppError(nil, http.StatusBadRequest, pnd.ErrCodeInvalidBody, - "채팅방 정보가 nil입니다. userID=%s"+client.FbUID) - } - - room, err := client.CreateRoomIfNotExists(message, stateManager, chatService) - if err != nil { - return err - } - - if message.Sender == nil { - return pnd.NewAppError(nil, http.StatusBadRequest, pnd.ErrCodeInvalidBody, - "보낸 사람이 nil입니다. userID=%s"+client.FbUID) - } - - if !stateManager.IsClientInRoom(client.FbUID, message.Room.GetID()) { - if room.RegisterChan == nil { - return pnd.NewAppError(nil, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "방 등록 채널이 nil입니다. userID=%s"+client.FbUID) - } - - stateManager.JoinRoom(room.ID, client.FbUID) - room.RegisterChan <- client - err := client.notifyRoomJoined(room, message.Sender) - if err != nil { - return err - } - } - - return nil -} - -func (client *Client) CreateRoomIfNotExists( - message Message, stateManager StateManager, chatService *service.ChatService, -) (*Room, *pnd.AppError) { - if stateManager == nil { - return nil, pnd.NewAppError(nil, http.StatusInternalServerError, pnd.ErrCodeUnknown, - "StateManager가 nil입니다. userID=%s"+client.FbUID) - } - room := stateManager.FindRoomByID(message.Room.GetID()) - if room == nil { - log.Info().Msgf("ID %d의 방을 찾을 수 없어 새 방을 생성합니다. userID=%s", message.Room.GetID(), client.FbUID) - var err *pnd.AppError - room, err = stateManager.CreateRoom(message.Room.Name, message.Room.RoomType, chatService) - if err != nil { - log.Error().Err(err.Err).Msgf("방 생성에 실패했습니다. userID=%s", client.FbUID) - return nil, err - } - return room, nil - } - return room, nil -} - -func (client *Client) handleLeaveRoomMessage(roomID int64, stateManager StateManager) { - stateManager.LeaveRoom(roomID, client.FbUID) - room := stateManager.FindRoomByID(roomID) - room.UnregisterChan <- client -} - -func (client *Client) notifyRoomJoined(room *Room, sender *Client) *pnd.AppError { - message := Message{ - Action: RoomJoinedAction, - Room: room, - Sender: sender, - } - - encodedMessage, err := message.encode() - if err != nil { - return err - } - client.MessageSender <- encodedMessage - return nil -} - -func (client *Client) UpdateConn(conn *websocket.Conn) { - client.Conn = conn -} - -func (client *Client) GetName() string { - return client.Name -} diff --git a/internal/chat/in_memory_state_manager.go b/internal/chat/in_memory_state_manager.go deleted file mode 100644 index 26327d79..00000000 --- a/internal/chat/in_memory_state_manager.go +++ /dev/null @@ -1,142 +0,0 @@ -package chat - -import ( - "context" - "sync" - - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" - "github.com/pet-sitter/pets-next-door-api/internal/service" -) - -type InMemoryStateManager struct { - clients map[string]*Client - rooms map[int64]*Room - // 클라이언트의 고유 식별자(FbUID)를 키로 사용하고, 클라이언트가 참여한 방의 ID를 값으로 갖는 또 다른 맵을 값으로 가집니다. - // 값이 struct{}인 이유는 이중 맵에서 값의 실제 데이터가 필요 없기 때문입니다. struct{}는 메모리를 거의 차지하지 않으므로 효율적입니다. - clientRooms map[string]map[int64]struct{} - roomClientUIDs map[int64][]string - mutex sync.RWMutex -} - -func NewInMemoryStateManager() *InMemoryStateManager { - return &InMemoryStateManager{ - clients: make(map[string]*Client), - rooms: make(map[int64]*Room), - clientRooms: make(map[string]map[int64]struct{}), - roomClientUIDs: make(map[int64][]string), - } -} - -func (m *InMemoryStateManager) RegisterClient(client *Client) *pnd.AppError { - m.mutex.Lock() - defer m.mutex.Unlock() - m.clients[client.FbUID] = client - return nil -} - -func (m *InMemoryStateManager) UnregisterClient(client *Client) *pnd.AppError { - m.mutex.Lock() - defer m.mutex.Unlock() - delete(m.clients, client.FbUID) - return nil -} - -func (m *InMemoryStateManager) FindClientByUID(uid string) *Client { - m.mutex.RLock() - defer m.mutex.RUnlock() - client, ok := m.clients[uid] - if !ok { - return nil - } - return client -} - -func (m *InMemoryStateManager) FindRoomByID(roomID int64) *Room { - m.mutex.RLock() - defer m.mutex.RUnlock() - room, ok := m.rooms[roomID] - if !ok { - return nil - } - return room -} - -func (m *InMemoryStateManager) CreateRoom( - name string, roomType chat.RoomType, chatService *service.ChatService, -) (*Room, *pnd.AppError) { - ctx := context.Background() - - row, err := chatService.CreateRoom(ctx, name, roomType) - if err != nil { - return nil, err - } - room := NewRoom(row.ID, row.Name, row.RoomType, m) - go room.RunRoom(chatService) - m.rooms[room.GetID()] = room - return room, nil -} - -func (m *InMemoryStateManager) BroadcastToClients(message []byte) *pnd.AppError { - m.mutex.RLock() - defer m.mutex.RUnlock() - - for _, client := range m.clients { - client.MessageSender <- message - } - return nil -} - -func (m *InMemoryStateManager) JoinRoom(roomID int64, clientID string) *pnd.AppError { - m.mutex.Lock() - defer m.mutex.Unlock() - if _, ok := m.clientRooms[clientID]; !ok { - m.clientRooms[clientID] = make(map[int64]struct{}) - } - m.clientRooms[clientID][roomID] = struct{}{} - m.roomClientUIDs[roomID] = append(m.roomClientUIDs[roomID], clientID) - return nil -} - -func (m *InMemoryStateManager) LeaveRoom(roomID int64, clientID string) *pnd.AppError { - m.mutex.Lock() - defer m.mutex.Unlock() - delete(m.clientRooms[clientID], roomID) - for i, id := range m.roomClientUIDs[roomID] { - if id == clientID { - m.roomClientUIDs[roomID] = append(m.roomClientUIDs[roomID][:i], m.roomClientUIDs[roomID][i+1:]...) - break - } - } - return nil -} - -func (m *InMemoryStateManager) IsClientInRoom(clientID string, roomID int64) bool { - m.mutex.RLock() - defer m.mutex.RUnlock() - _, ok := m.clientRooms[clientID][roomID] - return ok -} - -func (m *InMemoryStateManager) GetClientRooms(clientID string) map[int64]struct{} { - m.mutex.RLock() - defer m.mutex.RUnlock() - return m.clientRooms[clientID] -} - -func (m *InMemoryStateManager) GetRoomClients(roomID int64) map[string]*Client { - m.mutex.RLock() - defer m.mutex.RUnlock() - clientMap := make(map[string]*Client) - for _, clientID := range m.roomClientUIDs[roomID] { - clientMap[clientID] = m.clients[clientID] - } - return clientMap -} - -func (m *InMemoryStateManager) SetRoom(room *Room) *pnd.AppError { - m.mutex.Lock() - defer m.mutex.Unlock() - m.rooms[room.ID] = room - return nil -} diff --git a/internal/chat/init.go b/internal/chat/init.go deleted file mode 100644 index e4d37677..00000000 --- a/internal/chat/init.go +++ /dev/null @@ -1,44 +0,0 @@ -package chat - -import ( - "context" - - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/service" -) - -// 서버가 시작되거나 재시작될 때, 채널 상태 롤백 -func InitializeWebSocketServer( - ctx context.Context, wsServer *WebSocketServer, chatService *service.ChatService, -) *pnd.AppError { - rows, err := chatService.FindUserChatRoom(ctx) - if err != nil { - return err - } - - // 클라이언트를 중복 생성하지 않도록 관리하는 맵 - clientMap := make(map[string]*Client) - for _, row := range rows { - // 클라이언트를 생성하거나 기존 클라이언트를 재사용 - client, exists := clientMap[row.UserInfo.FirebaseUID] - if !exists { - client = NewClient(nil, row.UserInfo.Nickname, row.UserInfo.FirebaseUID) - wsServer.StateManager.RegisterClient(client) - clientMap[row.UserInfo.FirebaseUID] = client - } - - // 방을 생성하거나 기존 방을 불러옴 - room := wsServer.StateManager.FindRoomByID(row.RoomInfo.ID) - if room == nil { - room = NewRoom(row.RoomInfo.ID, row.RoomInfo.Name, row.RoomInfo.RoomType, wsServer.StateManager) - wsServer.StateManager.SetRoom(room) - go room.RunRoom(chatService) - } - - // 클라이언트를 방에 등록 - if !wsServer.StateManager.IsClientInRoom(client.FbUID, room.ID) { - wsServer.StateManager.JoinRoom(room.ID, client.FbUID) - } - } - return nil -} diff --git a/internal/chat/message.go b/internal/chat/message.go deleted file mode 100644 index ed9ac3fc..00000000 --- a/internal/chat/message.go +++ /dev/null @@ -1,32 +0,0 @@ -package chat - -import ( - "encoding/json" - "net/http" - - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" -) - -const ( - SendMessageAction = "SEND_MESSAGE" // 클라이언트가 메시지를 보낼 때 - JoinRoomAction = "JOIN_ROOM" // 클라이언트가 방에 참여할 때 - LeaveRoomAction = "LEAVE_ROOM" // 클라이언트가 방을 떠날 때 - RoomJoinedAction = "ROOM_JOINED" // 클라이언트가 방에 성공적으로 참여했음을 알릴 때 -) - -type Message struct { - Action string `json:"action"` - Message string `json:"message"` - MessageType chat.MessageType `json:"messageType"` - Room *Room `json:"room"` - Sender *Client `json:"sender"` -} - -func (message *Message) encode() ([]byte, *pnd.AppError) { - bytes, err := json.Marshal(message) - if err != nil { - return nil, pnd.NewAppError(err, http.StatusInternalServerError, pnd.ErrCodeMessageEncodingFailed, "메시지 인코딩에 실패했습니다.") - } - return bytes, nil -} diff --git a/internal/chat/room.go b/internal/chat/room.go deleted file mode 100644 index c774412c..00000000 --- a/internal/chat/room.go +++ /dev/null @@ -1,111 +0,0 @@ -package chat - -import ( - "context" - "fmt" - - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" - "github.com/pet-sitter/pets-next-door-api/internal/service" -) - -type Room struct { - ID int64 `json:"id"` - Name string `json:"name"` - RoomType chat.RoomType `json:"roomType"` - StateManager StateManager `json:"-"` - RegisterChan chan *Client `json:"-"` - UnregisterChan chan *Client `json:"-"` - BroadcastChan chan *Message `json:"-"` -} - -const welcomeMessage = "%s 이 참여하셨습니다." - -func NewRoom(id int64, name string, roomType chat.RoomType, stateManager StateManager) *Room { - return &Room{ - ID: id, - Name: name, - RoomType: roomType, - StateManager: stateManager, - RegisterChan: make(chan *Client), - UnregisterChan: make(chan *Client), - BroadcastChan: make(chan *Message), - } -} - -func (room *Room) RunRoom(chatService *service.ChatService) { - for { - select { - case client := <-room.RegisterChan: - room.registerClientInRoom(client, room.ID, chatService) - case client := <-room.UnregisterChan: - room.unregisterClientInRoom(client, room.ID, chatService) - case message := <-room.BroadcastChan: - room.broadcastToClientsInRoom(message, chatService) - } - } -} - -func (room *Room) registerClientInRoom(client *Client, roomID int64, chatService *service.ChatService) *pnd.AppError { - ctx := context.Background() - exists, err := chatService.ExistsUserInRoom(ctx, roomID, client.FbUID) - if exists { - return nil - } - if err != nil { - return err - } - _, err = chatService.JoinRoom(ctx, roomID, client.FbUID) - if err != nil { - return err - } - room.notifyClientJoined(client, chatService) - return nil -} - -func (room *Room) unregisterClientInRoom(client *Client, roomID int64, chatService *service.ChatService) *pnd.AppError { - ctx := context.Background() - err := chatService.LeaveRoom(ctx, roomID, client.FbUID) - if err != nil { - return err - } - return nil -} - -func (room *Room) broadcastToClientsInRoom(message *Message, chatService *service.ChatService) *pnd.AppError { - ctx := context.Background() - row, err := chatService.SaveMessage(ctx, room.ID, message.Sender.FbUID, message.Message, message.MessageType) - if err != nil { - return err - } - clients := room.StateManager.GetRoomClients(room.ID) - - for _, client := range clients { - client.MessageSender <- []byte(row.Content) - } - return nil -} - -func (room *Room) notifyClientJoined(client *Client, chatService *service.ChatService) *pnd.AppError { - message := &Message{ - Action: SendMessageAction, - Room: room, - Message: fmt.Sprintf(welcomeMessage, client.GetName()), - MessageType: chat.MessageTypeNormal, - Sender: client, - } - - err := room.broadcastToClientsInRoom(message, chatService) - if err != nil { - return err - } - return nil -} - -func (room *Room) GetID() int64 { - return room.ID -} - -func (room *Room) GetName() string { - return room.Name -} diff --git a/internal/chat/state_manager.go b/internal/chat/state_manager.go deleted file mode 100644 index ebb17699..00000000 --- a/internal/chat/state_manager.go +++ /dev/null @@ -1,24 +0,0 @@ -package chat - -import ( - pnd "github.com/pet-sitter/pets-next-door-api/api" - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" - "github.com/pet-sitter/pets-next-door-api/internal/service" -) - -type StateManager interface { - RegisterClient(client *Client) *pnd.AppError - UnregisterClient(client *Client) *pnd.AppError - FindClientByUID(uid string) *Client - FindRoomByID(roomID int64) *Room - CreateRoom( - name string, roomType chat.RoomType, roomService *service.ChatService, - ) (*Room, *pnd.AppError) - BroadcastToClients(message []byte) *pnd.AppError - JoinRoom(roomID int64, clientID string) *pnd.AppError - LeaveRoom(roomID int64, clientID string) *pnd.AppError - IsClientInRoom(clientID string, roomID int64) bool - GetClientRooms(clientID string) map[int64]struct{} - GetRoomClients(roomID int64) map[string]*Client - SetRoom(room *Room) *pnd.AppError -} diff --git a/internal/chat/web_socket_server.go b/internal/chat/web_socket_server.go deleted file mode 100644 index cb152951..00000000 --- a/internal/chat/web_socket_server.go +++ /dev/null @@ -1,30 +0,0 @@ -package chat - -type WebSocketServer struct { - StateManager StateManager - RegisterChan chan *Client - UnregisterChan chan *Client - BroadcastChan chan []byte -} - -func NewWebSocketServer(stateManager StateManager) *WebSocketServer { - return &WebSocketServer{ - StateManager: stateManager, - RegisterChan: make(chan *Client), - UnregisterChan: make(chan *Client), - BroadcastChan: make(chan []byte), - } -} - -func (server *WebSocketServer) Run() { - for { - select { - case client := <-server.RegisterChan: - server.StateManager.RegisterClient(client) - case client := <-server.UnregisterChan: - server.StateManager.UnregisterClient(client) - case message := <-server.BroadcastChan: - server.StateManager.BroadcastToClients(message) - } - } -} diff --git a/internal/domain/chat/model.go b/internal/domain/chat/model.go index e7490a8e..504d16c4 100644 --- a/internal/domain/chat/model.go +++ b/internal/domain/chat/model.go @@ -1,48 +1,73 @@ package chat -import "time" +import ( + "time" +) type ( RoomType string MessageType string ) +func (t RoomType) IsValid() bool { + switch t { + case EventRoomType: + return true + default: + return false + } +} + const ( - RoomTypePersonal = "personal" - RoomTypeGathering = "gathering" + EventRoomType = "event" ) const ( - MessageTypeNormal = "normal" - MessageTypePromise = "promise" + EventMessage = "event" ) -type Room struct { - ID int64 `field:"id" json:"id"` - Name string `field:"name" json:"name"` - RoomType RoomType `field:"RoomType" json:"RoomType"` - CreatedAt time.Time `field:"createdAt" json:"createdAt"` - UpdatedAt time.Time `field:"updatedAt" json:"updatedAt"` - DeletedAt time.Time `field:"deletedAt" json:"deletedAt"` +type RoomSimpleInfo struct { + ID string `field:"id" json:"id"` + RoomName string `field:"roomName" json:"roomName"` + RoomType string `field:"roomType" json:"roomType"` + JoinUser *JoinUsersSimpleInfo `field:"joinUser" json:"joinUser"` + CreatedAt time.Time `field:"createdAt" json:"createdAt"` + UpdatedAt time.Time `field:"updatedAt" json:"updatedAt"` } -type Message struct { - ID int64 `field:"id" json:"id"` - UserID int64 `field:"userID" json:"userID"` - RoomID int64 `field:"roomID" json:"roomID"` - MessageType MessageType `field:"messageType" json:"messageType"` - Content string `field:"content" json:"content"` - CreatedAt time.Time `field:"createdAt" json:"createdAt"` - UpdatedAt time.Time `field:"updatedAt" json:"updatedAt"` - DeletedAt time.Time `field:"deletedAt" json:"deletedAt"` +type JoinUsersSimpleInfo struct { + ID string `field:"id" json:"userId"` + UserNickname string `field:"nickname" json:"userNickname"` + UserProfileImage string `field:"profileImage" json:"profileImageUrl"` +} + +type JoinRoom struct { + UserID string + RoomID string + JoinedAt time.Time } -type UserChatRoom struct { - ID int64 `field:"id" json:"id"` - UserID int64 `field:"userID" json:"userID"` - RoomID int64 `field:"roomID" json:"roomID"` - JoinedAt time.Time `field:"joinedAt" json:"joinedAt"` - LeftAt time.Time `field:"leftAt" json:"leftAt"` +// 조회 시 Room 정보를 반환하는 View +type JoinRoomsView struct { + Items []RoomSimpleInfo `field:"items" json:"items"` } -type UserChatRoomList []*UserChatRoom +type UserChatRoomMessageView struct { + ID string `field:"id" json:"id"` + MessageType string `field:"messageType" json:"messageType"` +} + +type Message struct { + ID int64 `field:"id" json:"id"` + UserID int64 `field:"userID" json:"userID"` + RoomID int64 `field:"roomID" json:"roomID"` + MessageType string `field:"messageType" json:"messageType"` + Content string `field:"content" json:"content"` + CreatedAt time.Time `field:"createdAt" json:"createdAt"` +} + +type MessageCursorView struct { + HasNext *bool `field:"hasNext" json:"hasNext"` + HasPrev *bool `field:"hasPrev" json:"hasPrev"` + Items []Message `field:"items" json:"items,omitempty"` +} diff --git a/internal/domain/chat/request.go b/internal/domain/chat/request.go new file mode 100644 index 00000000..3823f0c8 --- /dev/null +++ b/internal/domain/chat/request.go @@ -0,0 +1,6 @@ +package chat + +type CreateRoomRequest struct { + RoomName string `json:"roomName" validate:"required"` + RoomType string `json:"roomType" validate:"required"` +} diff --git a/internal/domain/chat/validation.go b/internal/domain/chat/validation.go new file mode 100644 index 00000000..f36b80a0 --- /dev/null +++ b/internal/domain/chat/validation.go @@ -0,0 +1,14 @@ +package chat + +import "errors" + +// Validate to validate CreateRoomRequest +func (r CreateRoomRequest) RoomTypeValidate() error { + // RoomType이 Model에 정의된 값인지 확인 + switch r.RoomType { + case EventRoomType: + return nil + default: + return errors.New("invalid room type. please check room type") + } +} diff --git a/internal/domain/chat/view.go b/internal/domain/chat/view.go index 7f2b8db6..86102754 100644 --- a/internal/domain/chat/view.go +++ b/internal/domain/chat/view.go @@ -1,100 +1,90 @@ package chat import ( - "time" + "strconv" - utils "github.com/pet-sitter/pets-next-door-api/internal/common" - "github.com/pet-sitter/pets-next-door-api/internal/domain/user" databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" ) -type JoinRoomView struct { - UserID int64 - RoomID int64 - JoinedAt time.Time +func ToCreateRoom(row databasegen.CreateRoomRow, users *JoinUsersSimpleInfo) *RoomSimpleInfo { + return &RoomSimpleInfo{ + ID: string(row.ID), + RoomName: row.Name, + RoomType: row.RoomType, + JoinUser: users, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } } -type UserChatRoomView struct { - ID int64 - UserID int64 - RoomID int64 - JoinedAt time.Time - UserInfo user.WithProfileImage - RoomInfo *Room +func ToJoinUsers(row databasegen.FindUserRow) *JoinUsersSimpleInfo { + return &JoinUsersSimpleInfo{ + ID: strconv.FormatInt(int64(row.ID), 10), + UserNickname: row.Nickname, + UserProfileImage: row.ProfileImageUrl.String, + } } -type UserChatRoomViewList []*UserChatRoomView - -func ToJoinRoomView(row databasegen.JoinRoomRow) *JoinRoomView { - return &JoinRoomView{ - UserID: row.UserID, - RoomID: row.RoomID, +func ToJoinRoom(row databasegen.JoinRoomRow) *JoinRoom { + return &JoinRoom{ + UserID: strconv.FormatInt(row.UserID, 10), + RoomID: strconv.FormatInt(row.RoomID, 10), JoinedAt: row.JoinedAt, } } -func ToCreateRoom(row databasegen.CreateRoomRow) *Room { - return &Room{ - ID: int64(row.ID), - Name: row.Name, - RoomType: RoomType(row.RoomType), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, +func ToUserChatRoomsView(rows []databasegen.FindAllUserChatRoomsRow) *JoinRoomsView { + if len(rows) == 0 { + return nil } -} -func ToMessage(row databasegen.WriteMessageRow) *Message { - return &Message{ - ID: int64(row.ID), - RoomID: row.RoomID, - UserID: row.UserID, - Content: row.Content, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + // rows를 반복하며 JoinRoomsView로 변환 + roomSimpleInfos := make([]RoomSimpleInfo, len(rows)) + for i, r := range rows { + roomSimpleInfos[i] = RoomSimpleInfo{ + ID: strconv.FormatInt(r.UserID, 10), + RoomName: r.ChatRoomName, + RoomType: r.ChatRoomType, + CreatedAt: r.ChatRoomCreatedAt, + UpdatedAt: r.ChatRoomUpdatedAt, + } + } + + return &JoinRoomsView{ + Items: roomSimpleInfos, } } -func ToRoom(row databasegen.FindRoomByIDRow) *Room { - return &Room{ - ID: int64(row.ID), - Name: row.Name, - RoomType: RoomType(row.RoomType), +func ToUserChatRoomView(row databasegen.FindRoomByIDRow) *RoomSimpleInfo { + return &RoomSimpleInfo{ + ID: strconv.FormatInt(int64(row.ID), 10), + RoomName: row.Name, + RoomType: row.RoomType, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } -func ToUserChatRoom(row databasegen.FindUserChatRoomsRow) *UserChatRoomView { - return &UserChatRoomView{ - ID: int64(row.ID), - UserID: row.UserID, - RoomID: row.RoomID, - JoinedAt: row.JoinedAt, - UserInfo: user.WithProfileImage{ - ID: int64(row.ID), - Email: row.Email, - Nickname: row.Nickname, - Fullname: row.Fullname, - ProfileImageURL: utils.NullStrToStrPtr(row.ProfileImageUrl), - FirebaseProviderType: user.FirebaseProviderType(row.FbProviderType.String), - FirebaseUID: row.FbUid.String, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }, - RoomInfo: &Room{ - ID: row.RoomID, - Name: row.ChatRoomName, - RoomType: RoomType(row.ChatRoomType), - CreatedAt: row.ChatRoomCreatedAt, - UpdatedAt: row.ChatRoomUpdatedAt, - }, +func ToUserChatRoomMessageView(row []databasegen.FindMessageByRoomIDRow, hasNext, hasPrev *bool) *MessageCursorView { + if len(row) == 0 { + return nil + } + + messages := make([]Message, len(row)) + for i, r := range row { + messages[i] = Message{ + ID: int64(r.ID), + UserID: r.UserID, + RoomID: r.RoomID, + MessageType: r.MessageType, + Content: r.Content, + CreatedAt: r.CreatedAt, + } } -} -func ToUserChatRoomFromRows(rows []databasegen.FindUserChatRoomsRow) UserChatRoomViewList { - userChatRooms := make([]*UserChatRoomView, len(rows)) - for i, row := range rows { - userChatRooms[i] = ToUserChatRoom(row) + return &MessageCursorView{ + Items: messages, + HasNext: hasNext, + HasPrev: hasPrev, } - return userChatRooms } diff --git a/internal/infra/database/gen/chats.sql.go b/internal/infra/database/gen/chats.sql.go index 1b9c20ca..2a7171d0 100644 --- a/internal/infra/database/gen/chats.sql.go +++ b/internal/infra/database/gen/chats.sql.go @@ -85,19 +85,30 @@ SELECT user_id, room_id, message_type, - content + content, + created_at FROM chat_messages WHERE - room_id = $3 -ORDER BY created_at DESC -LIMIT $1 OFFSET $2 + chat_messages.deleted_at IS NULL +AND + room_id = $4 +AND + (($1 != 0 AND $2 != 0 AND id > $1 AND id < $2) +OR + ($1 != 0 AND $2 = 0 AND id < $1) +OR + ($1 = 0 AND $2 != 0 AND id > $2)) +ORDER BY + chat_messages.created_at ASC +LIMIT $3 ` type FindMessageByRoomIDParams struct { - Limit int32 - Offset int32 - RoomID sql.NullInt64 + Prev int64 + Next int64 + Limit int64 + RoomID int64 } type FindMessageByRoomIDRow struct { @@ -106,15 +117,23 @@ type FindMessageByRoomIDRow struct { RoomID int64 MessageType string Content string + CreatedAt time.Time } -func (q *Queries) FindMessageByRoomID(ctx context.Context, arg FindMessageByRoomIDParams) ([]FindMessageByRoomIDRow, error) { - rows, err := q.db.QueryContext(ctx, findMessageByRoomID, arg.Limit, arg.Offset, arg.RoomID) +func (q *Queries) FindMessageByRoomID(ctx context.Context, arg FindMessageByRoomIDParams) (*bool, *bool, []FindMessageByRoomIDRow, error) { + // Limit + 1로 메시지 조회 + rows, err := q.db.QueryContext(ctx, findMessageByRoomID, arg.Prev, arg.Next, arg.Limit+1, arg.RoomID) if err != nil { - return nil, err + return nil, nil, nil, err } defer rows.Close() + var items []FindMessageByRoomIDRow + hasPrev := false + hasNext := false + var firstID int64 + var lastID int64 + for rows.Next() { var i FindMessageByRoomIDRow if err := rows.Scan( @@ -123,21 +142,53 @@ func (q *Queries) FindMessageByRoomID(ctx context.Context, arg FindMessageByRoom &i.RoomID, &i.MessageType, &i.Content, + &i.CreatedAt, ); err != nil { - return nil, err + return nil, nil, nil, err + } + + // 첫 번째 ID 추출 + if firstID == 0 { + firstID = int64(i.ID) } + // 마지막 ID 추출 + lastID = int64(i.ID) + items = append(items, i) } - if err := rows.Close(); err != nil { - return nil, err + + // Next 이후 메시지가 있는지 확인 (Limit + 1 로 가져와서 확인) + if int64(len(items)) > arg.Limit { + hasNext = true + items = items[:len(items)-1] // 추가된 마지막 항목을 삭제하여 반환된 메시지 수를 Limit에 맞춤 } - if err := rows.Err(); err != nil { - return nil, err + + // 첫 번째 메시지의 ID를 기준으로 이전 메시지가 있는지 확인 + if firstID != 0 { + checkPrevQuery := `SELECT COUNT(1) FROM chat_messages WHERE room_id = $1 AND id < $2 AND deleted_at IS NULL` + checkPrevRow := q.db.QueryRowContext(ctx, checkPrevQuery, arg.RoomID, firstID) + var count int + if err := checkPrevRow.Scan(&count); err == nil && count > 0 { + hasPrev = true + } } - return items, nil + + // 마지막 메시지의 ID를 기준으로 이후 메시지가 있는지 확인 + if lastID != 0 { + checkNextQuery := `SELECT COUNT(1) FROM chat_messages WHERE room_id = $1 AND id > $2 AND deleted_at IS NULL` + checkNextRow := q.db.QueryRowContext(ctx, checkNextQuery, arg.RoomID, lastID) + var count int + if err := checkNextRow.Scan(&count); err == nil && count > 0 { + hasNext = true + } + } + + // 결과 반환 + return &hasNext, &hasPrev, items, nil } -const findRoomByID = `-- name: FindRoomByID :one + +const findRoomByIDAndUserID = `-- name: FindRoomByIDAndUserID :one SELECT id, name, @@ -147,8 +198,15 @@ SELECT FROM chat_rooms WHERE - (chat_rooms.id = $1) - AND (chat_rooms.deleted_at IS NULL) + chat_rooms.deleted_at IS NULL + AND (chat_rooms.id = $1) + AND EXISTS ( + SELECT 1 + FROM user_chat_rooms + WHERE user_id = $2 + AND room_id = chat_rooms.id + AND left_at IS NULL + ) ` type FindRoomByIDRow struct { @@ -157,10 +215,11 @@ type FindRoomByIDRow struct { RoomType string CreatedAt time.Time UpdatedAt time.Time + DeletedAt time.Time } -func (q *Queries) FindRoomByID(ctx context.Context, id sql.NullInt32) (FindRoomByIDRow, error) { - row := q.db.QueryRowContext(ctx, findRoomByID, id) +func (q *Queries) FindRoomByIDAndUserID(ctx context.Context, roomId int64, userId int64) (FindRoomByIDRow, error) { + row := q.db.QueryRowContext(ctx, findRoomByIDAndUserID, roomId, userId) var i FindRoomByIDRow err := row.Scan( &i.ID, @@ -172,7 +231,7 @@ func (q *Queries) FindRoomByID(ctx context.Context, id sql.NullInt32) (FindRoomB return i, err } -const findUserChatRooms = `-- name: FindUserChatRooms :many +const findAllUserChatRooms = `-- name: FindAllUserChatRoomsByUserUID :many SELECT user_chat_rooms.id, user_chat_rooms.user_id, @@ -201,9 +260,13 @@ FROM ON users.profile_image_id = media.id WHERE user_chat_rooms.left_at IS NULL +AND + chat_rooms.deleted_at IS NULL +AND + user_chat_rooms.user_id = $1 ` -type FindUserChatRoomsRow struct { +type FindAllUserChatRoomsRow struct { ID int32 UserID int64 RoomID int64 @@ -223,15 +286,15 @@ type FindUserChatRoomsRow struct { ChatRoomUpdatedAt time.Time } -func (q *Queries) FindUserChatRooms(ctx context.Context) ([]FindUserChatRoomsRow, error) { - rows, err := q.db.QueryContext(ctx, findUserChatRooms) +func (q *Queries) FindAllUserChatRoomsByUserUID(ctx context.Context, userId int64) ([]FindAllUserChatRoomsRow, error) { + rows, err := q.db.QueryContext(ctx, findAllUserChatRooms, userId) if err != nil { return nil, err } defer rows.Close() - var items []FindUserChatRoomsRow + var items []FindAllUserChatRoomsRow for rows.Next() { - var i FindUserChatRoomsRow + var i FindAllUserChatRoomsRow if err := rows.Scan( &i.ID, &i.UserID, @@ -272,6 +335,14 @@ joined_at) VALUES ($1, $2, NOW()) RETURNING id, user_id, room_id, joined_at ` +const joinRooms = `-- name: JoinRooms :exec +INSERT INTO user_chat_rooms +(user_id, +room_id, +joined_at) +VALUES ($1, $2, NOW()) +RETURNING id, user_id, room_id, joined_at +` type JoinRoomParams struct { UserID int64 @@ -329,52 +400,3 @@ func (q *Queries) UserExistsInRoom(ctx context.Context, roomID int64) (bool, err err := row.Scan(&exists) return exists, err } - -const writeMessage = `-- name: WriteMessage :one -INSERT INTO chat_messages -(user_id, -room_id, -message_type, -content, -created_at, -updated_at) -VALUES ($1, $2, $3, $4, NOW(), NOW()) -RETURNING id, user_id, room_id, message_type, content, created_at, updated_at -` - -type WriteMessageParams struct { - UserID int64 - RoomID int64 - MessageType string - Content string -} - -type WriteMessageRow struct { - ID int32 - UserID int64 - RoomID int64 - MessageType string - Content string - CreatedAt time.Time - UpdatedAt time.Time -} - -func (q *Queries) WriteMessage(ctx context.Context, arg WriteMessageParams) (WriteMessageRow, error) { - row := q.db.QueryRowContext(ctx, writeMessage, - arg.UserID, - arg.RoomID, - arg.MessageType, - arg.Content, - ) - var i WriteMessageRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.RoomID, - &i.MessageType, - &i.Content, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/internal/infra/database/gen/users.sql.go b/internal/infra/database/gen/users.sql.go index 1a099e65..6e7d740b 100644 --- a/internal/infra/database/gen/users.sql.go +++ b/internal/infra/database/gen/users.sql.go @@ -292,3 +292,41 @@ func (q *Queries) UpdateUserByFbUID(ctx context.Context, arg UpdateUserByFbUIDPa ) return i, err } + +func (q *Queries) FindUsersByIds(ctx context.Context, id int64) ([]FindUsersRow, error) { + rows, err := q.db.QueryContext(ctx, FindUsersByID, id) + if err != nil || rows == nil { + return nil, err + } + + defer rows.Close() + var items []FindUsersRow + for rows.Next() { + var i FindUsersRow + if err := rows.Scan(&i.ID, &i.Nickname, &i.ProfileImageUrl); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FindUsersByID = `-- name: FindUsersByID :one +SELECT users.id, + users.nickname, + media.url AS profile_image_url +FROM users + LEFT OUTER JOIN + media + ON + users.profile_image_id = media.id + +WHERE users.id = $1 +AND users.deleted_at IS NULL +` diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index ccda85f4..b7f7c2f1 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -2,13 +2,13 @@ package service import ( "context" + "errors" pnd "github.com/pet-sitter/pets-next-door-api/api" utils "github.com/pet-sitter/pets-next-door-api/internal/common" - databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" "github.com/pet-sitter/pets-next-door-api/internal/infra/database" + databasegen "github.com/pet-sitter/pets-next-door-api/internal/infra/database/gen" ) type ChatService struct { @@ -22,47 +22,85 @@ func NewChatService(conn *database.DB) *ChatService { } func (s *ChatService) CreateRoom( - ctx context.Context, name string, roomType chat.RoomType, -) (*chat.Room, *pnd.AppError) { - row, err := databasegen.New(s.conn).CreateRoom(ctx, databasegen.CreateRoomParams{ - Name: name, - RoomType: string(roomType), + ctx context.Context, name, roomType, userFirebaseUID string, +) ( + *chat.RoomSimpleInfo, *pnd.AppError, +) { + userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ + FbUid: utils.StrToNullStr(userFirebaseUID), }) if err != nil { return nil, pnd.FromPostgresError(err) } - return chat.ToCreateRoom(row), nil + + // 채팅방 생성 + tx, transactionError := s.conn.BeginTx(ctx) + defer tx.Rollback() + + if transactionError != nil { + return nil, transactionError + } + + q := databasegen.New(tx) + row, databaseGenError := q.CreateRoom(ctx, databasegen.CreateRoomParams{ + Name: name, + RoomType: roomType, + }) + + if databaseGenError != nil { + return nil, pnd.FromPostgresError(databaseGenError) + } + + _, err3 := q.JoinRoom(ctx, databasegen.JoinRoomParams{ + UserID: int64(userData.ID), + RoomID: int64(row.ID), + }) + + if err3 != nil { + return nil, pnd.FromPostgresError(err3) + } + + tx.Commit() + + return chat.ToCreateRoom(row, chat.ToJoinUsers(userData)), nil } -func (s *ChatService) JoinRoom( - ctx context.Context, roomID int64, fbUID string, -) (*chat.JoinRoomView, *pnd.AppError) { +func (s *ChatService) JoinRoom(ctx context.Context, roomID int64, fbUID string) (*chat.JoinRoom, *pnd.AppError) { userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ FbUid: utils.StrToNullStr(fbUID), }) if err != nil { return nil, pnd.FromPostgresError(err) } - row, err := databasegen.New(s.conn).JoinRoom(ctx, databasegen.JoinRoomParams{ - RoomID: roomID, - UserID: int64(userData.ID), - }) + // 채팅방에 이미 참여중인지 확인 + exists, err := databasegen.New(s.conn).UserExistsInRoom(ctx, roomID) if err != nil { return nil, pnd.FromPostgresError(err) } - return chat.ToJoinRoomView(row), nil + if !exists { + row, err := databasegen.New(s.conn).JoinRoom(ctx, databasegen.JoinRoomParams{ + RoomID: roomID, + UserID: int64(userData.ID), + }) + if err != nil { + return nil, pnd.FromPostgresError(err) + } + + return chat.ToJoinRoom(row), nil + } + + return nil, pnd.ErrBadRequest(errors.New("이미 참여중인 채팅방입니다")) } -func (s *ChatService) LeaveRoom( - ctx context.Context, roomID int64, fbUID string, -) *pnd.AppError { +func (s *ChatService) LeaveRoom(ctx context.Context, roomID int64, fbUID string) *pnd.AppError { userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ FbUid: utils.StrToNullStr(fbUID), }) if err != nil { return pnd.FromPostgresError(err) } + err = databasegen.New(s.conn).LeaveRoom(ctx, databasegen.LeaveRoomParams{ RoomID: roomID, UserID: int64(userData.ID), @@ -84,56 +122,52 @@ func (s *ChatService) LeaveRoom( return nil } -func (s *ChatService) SaveMessage( - ctx context.Context, roomID int64, fbUID, message string, messageType chat.MessageType, -) (*chat.Message, *pnd.AppError) { +func (s *ChatService) FindAllByUserUID(ctx context.Context, fbUID string) (*chat.JoinRoomsView, *pnd.AppError) { userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ FbUid: utils.StrToNullStr(fbUID), }) if err != nil { return nil, pnd.FromPostgresError(err) } - row, err := databasegen.New(s.conn).WriteMessage(ctx, databasegen.WriteMessageParams{ - RoomID: roomID, - UserID: int64(userData.ID), - MessageType: string(messageType), - Content: message, - }) + rows, err := databasegen.New(s.conn).FindAllUserChatRoomsByUserUID(ctx, int64(userData.ID)) if err != nil { return nil, pnd.FromPostgresError(err) } - return chat.ToMessage(row), nil + + // rows를 반복하며 각 row에 대해 ToJoinRoom을 호출하여 JoinRoom으로 변환 + return chat.ToUserChatRoomsView(rows), nil } -func (s *ChatService) FindRoomByID(ctx context.Context, roomID *int64) (*chat.Room, *pnd.AppError) { - row, err := databasegen.New(s.conn).FindRoomByID(ctx, utils.Int64PtrToNullInt32(roomID)) +func (s *ChatService) FindChatRoomByUIDAndRoomID(ctx context.Context, fbUID string, roomID int64) ( + *chat.RoomSimpleInfo, *pnd.AppError, +) { + userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ + FbUid: utils.StrToNullStr(fbUID), + }) if err != nil { return nil, pnd.FromPostgresError(err) } - return chat.ToRoom(row), nil -} -func (s *ChatService) FindUserChatRoom(ctx context.Context) (chat.UserChatRoomViewList, *pnd.AppError) { - rows, err := databasegen.New(s.conn).FindUserChatRooms(ctx) + row, err := databasegen.New(s.conn).FindRoomByIDAndUserID(ctx, roomID, int64(userData.ID)) if err != nil { return nil, pnd.FromPostgresError(err) } - return chat.ToUserChatRoomFromRows(rows), nil + + return chat.ToUserChatRoomView(row), nil } -func (s *ChatService) ExistsUserInRoom(ctx context.Context, roomID int64, fbUID string) (bool, *pnd.AppError) { - userData, err := databasegen.New(s.conn).FindUser(ctx, databasegen.FindUserParams{ - FbUid: utils.StrToNullStr(fbUID), - }) - if err != nil { - return false, pnd.FromPostgresError(err) - } - exists, err := databasegen.New(s.conn).ExistsUserInRoom(ctx, databasegen.ExistsUserInRoomParams{ +func (s *ChatService) FindChatRoomMessagesByRoomID(ctx context.Context, roomID, prev, next, limit int64) ( + *chat.MessageCursorView, *pnd.AppError, +) { + hasNext, hasPrev, rows, err := databasegen.New(s.conn).FindMessageByRoomID(ctx, databasegen.FindMessageByRoomIDParams{ + Prev: prev, + Next: next, + Limit: limit, RoomID: roomID, - UserID: int64(userData.ID), }) if err != nil { - return false, pnd.FromPostgresError(err) + return nil, pnd.FromPostgresError(err) } - return exists, nil + + return chat.ToUserChatRoomMessageView(rows, hasNext, hasPrev), nil } diff --git a/internal/service/tests/chat_service_test.go b/internal/service/tests/chat_service_test.go deleted file mode 100644 index b068b65d..00000000 --- a/internal/service/tests/chat_service_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package service - -import ( - "context" - "testing" - - "github.com/pet-sitter/pets-next-door-api/internal/domain/chat" - "github.com/pet-sitter/pets-next-door-api/internal/domain/media" - "github.com/pet-sitter/pets-next-door-api/internal/tests" - "github.com/stretchr/testify/assert" -) - -func TestCreateRoom(t *testing.T) { - t.Run("채팅방을 생성한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - - // Given - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - - // When - createdRoom, err := chatService.CreateRoom(ctx, roomName, roomType) - - // Then - assert.Nil(t, err) - assert.Equal(t, roomName, createdRoom.Name) - assert.Equal(t, roomType, createdRoom.RoomType) - }) -} - -func TestJoinRoom(t *testing.T) { - t.Run("채팅방에 입장한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - mediaService := tests.NewMockMediaService(db) - userService := tests.NewMockUserService(db) - - // Given - profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") - - userRequest := tests.NewDummyRegisterUserRequest(&profileImage.ID) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - - // When - joinRoomView, err := chatService.JoinRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // Then - assert.Nil(t, err) - assert.Equal(t, createdRoom.ID, joinRoomView.RoomID) - assert.Equal(t, createdUser.ID, joinRoomView.UserID) - }) -} - -func TestLeaveRoom(t *testing.T) { - t.Run("채팅방을 떠난다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - mediaService := tests.NewMockMediaService(db) - userService := tests.NewMockUserService(db) - - // Given - profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") - - userRequest := tests.NewDummyRegisterUserRequest(&profileImage.ID) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - _, _ = chatService.JoinRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // When - leaveRoomErr := chatService.LeaveRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // Then - assert.Nil(t, leaveRoomErr) - }) -} - -func TestSaveMessage(t *testing.T) { - t.Run("메시지를 저장한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - mediaService := tests.NewMockMediaService(db) - userService := tests.NewMockUserService(db) - - // Given - profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") - - userRequest := tests.NewDummyRegisterUserRequest(&profileImage.ID) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - _, _ = chatService.JoinRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // When - message := "Hello, World!" - savedMessage, _ := chatService.SaveMessage( - ctx, createdRoom.ID, createdUser.FirebaseUID, message, chat.MessageTypeNormal, - ) - - // Then - assert.Equal(t, createdRoom.ID, savedMessage.RoomID) - assert.Equal(t, createdUser.ID, savedMessage.UserID) - assert.Equal(t, message, savedMessage.Content) - }) -} - -func TestFindRoomByID(t *testing.T) { - t.Run("채팅방을 ID로 조회한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - - // Given - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - - // When - foundRoom, err := chatService.FindRoomByID(ctx, &createdRoom.ID) - - // Then - assert.Nil(t, err) - assert.Equal(t, createdRoom.ID, foundRoom.ID) - assert.Equal(t, createdRoom.Name, foundRoom.Name) - }) -} - -func TestFindUserChatRoom(t *testing.T) { - t.Run("사용자의 채팅방 참여 목록을 조회한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - mediaService := tests.NewMockMediaService(db) - userService := tests.NewMockUserService(db) - - // Given - profileImage, _ := mediaService.UploadMedia(ctx, nil, media.TypeImage, "profile_image.jpg") - - userRequest := tests.NewDummyRegisterUserRequest(&profileImage.ID) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - - _, _ = chatService.JoinRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // When - userChatRooms, err := chatService.FindUserChatRoom(ctx) - - // Then - assert.Nil(t, err) - assert.NotEmpty(t, userChatRooms) - - for _, userChatRoom := range userChatRooms { - if userChatRoom.RoomID == createdRoom.ID && userChatRoom.UserID == createdUser.ID { - assert.Equal(t, createdRoom.ID, userChatRoom.RoomInfo.ID) - assert.Equal(t, roomName, userChatRoom.RoomInfo.Name) - assert.Equal(t, roomType, userChatRoom.RoomInfo.RoomType) - assert.Equal(t, createdUser.Email, userChatRoom.UserInfo.Email) - assert.Equal(t, createdUser.Nickname, userChatRoom.UserInfo.Nickname) - assert.Equal(t, profileImage.URL, *userChatRoom.UserInfo.ProfileImageURL) - } - } - }) -} - -func TestExistsUserInRoom(t *testing.T) { - t.Run("사용자가 지정된 채팅방에 참여하고 있을 때 true를 반환한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - userService := tests.NewMockUserService(db) - - // Given - userRequest := tests.NewDummyRegisterUserRequest(nil) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - _, _ = chatService.JoinRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // When - exists, err := chatService.ExistsUserInRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // Then - assert.Nil(t, err) - assert.True(t, exists) - }) - - t.Run("사용자가 지정된 채팅방에 참여하지 않을 때 false를 반환한다", func(t *testing.T) { - db, tearDown := tests.SetUp(t) - defer tearDown(t) - ctx := context.Background() - chatService := tests.NewMockChatService(db) - userService := tests.NewMockUserService(db) - - // Given - userRequest := tests.NewDummyRegisterUserRequest(nil) - createdUser, _ := userService.RegisterUser(ctx, userRequest) - - roomName := "Test Room" - roomType := chat.RoomType(chat.RoomTypePersonal) - createdRoom, _ := chatService.CreateRoom(ctx, roomName, roomType) - - // When - exists, err := chatService.ExistsUserInRoom(ctx, createdRoom.ID, createdUser.FirebaseUID) - - // Then - assert.Nil(t, err) - assert.False(t, exists) - }) -}