diff --git a/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt b/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt index 770a971a..e1d5f408 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt @@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/v1") class ChatController( private val chatService: ChatService, - val authClient: AuthClient, - val serverClient: ServerClient, + private val authClient: AuthClient, + private val serverClient: ServerClient, ) { @PostMapping("/chat") fun createChat( diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt b/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt index 85829ec3..6e6f1097 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt @@ -3,6 +3,7 @@ package kpring.chat.chatroom.api.v1 import kpring.chat.chatroom.service.ChatRoomService import kpring.core.auth.client.AuthClient import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest +import kpring.core.global.dto.response.ApiResponse import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* @@ -34,4 +35,15 @@ class ChatRoomController( val result = chatRoomService.exitChatRoom(chatRoomId, userId) return ResponseEntity.ok().body(result) } + + @PatchMapping("/chatroom/{chatRoomId}/invite/{userId}") + fun inviteToChatRoomByUserId( + @PathVariable("userId") userId: String, + @PathVariable("chatRoomId") chatRoomId: String, + @RequestHeader("Authorization") token: String, + ): ResponseEntity<*> { + val inviterId = authClient.getTokenInfo(token).data!!.userId + chatRoomService.inviteToChatRoomByUserIdWithLock(userId, inviterId, chatRoomId) + return ResponseEntity.ok().body(ApiResponse(status = 201)) + } } diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/dto/Lock.kt b/chat/src/main/kotlin/kpring/chat/chatroom/dto/Lock.kt new file mode 100644 index 00000000..2fdf1198 --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/dto/Lock.kt @@ -0,0 +1,7 @@ +package kpring.chat.chatroom.dto + +data class Lock( + val lockId: String, + val owner: String, + val acquired: Boolean, +) diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt b/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt index c309123f..a1b36d8c 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt @@ -9,16 +9,20 @@ class ChatRoom : BaseTime() { @Id var id: String? = null - var members: MutableList = mutableListOf() + var members: MutableSet = mutableSetOf() fun getUsers(): List { - return members + return members.stream().toList() } fun addUsers(list: List) { members.addAll(list) } + fun addUser(userId: String) { + members.add(userId) + } + fun removeUser(userId: String) { members.remove(userId) } diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/model/DistributedLock.kt b/chat/src/main/kotlin/kpring/chat/chatroom/model/DistributedLock.kt new file mode 100644 index 00000000..ff193cb0 --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/model/DistributedLock.kt @@ -0,0 +1,12 @@ +package kpring.chat.chatroom.model + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document + +@Document(collection = "distributedLocks") +data class DistributedLock( + @Id + val id: String, + val owner: String, + val expiresAt: Long, +) diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/repository/DistributedLockRepository.kt b/chat/src/main/kotlin/kpring/chat/chatroom/repository/DistributedLockRepository.kt new file mode 100644 index 00000000..5d5e7165 --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/repository/DistributedLockRepository.kt @@ -0,0 +1,8 @@ +package kpring.chat.chatroom.repository + +import kpring.chat.chatroom.model.DistributedLock +import org.springframework.data.mongodb.repository.MongoRepository +import org.springframework.stereotype.Repository + +@Repository +interface DistributedLockRepository : MongoRepository diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt b/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt index f277d55f..090b4e33 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt @@ -6,10 +6,12 @@ import kpring.chat.global.exception.ErrorCode import kpring.chat.global.exception.GlobalException import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service class ChatRoomService( private val chatRoomRepository: ChatRoomRepository, + private val lockService: DistributedLockService, ) { fun createChatRoom( request: CreateChatRoomRequest, @@ -30,6 +32,31 @@ class ChatRoomService( chatRoomRepository.save(chatRoom) } + @Transactional + fun inviteToChatRoomByUserIdWithLock( + userId: String, + inviterId: String, + chatRoomId: String, + ): Boolean { + val lock = lockService.getLock(chatRoomId) + + inviteToChatRoomByUserId(userId, inviterId, chatRoomId) + + lockService.releaseLock(lock.lockId, lock.owner) + return true + } + + fun inviteToChatRoomByUserId( + userId: String, + inviterId: String, + chatRoomId: String, + ) { + verifyChatRoomAccess(chatRoomId, inviterId) + val chatRoom: ChatRoom = getChatRoom(chatRoomId) + chatRoom.addUser(userId) + chatRoomRepository.save(chatRoom) + } + fun verifyChatRoomAccess( chatRoomId: String, userId: String, diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/service/DistributedLockService.kt b/chat/src/main/kotlin/kpring/chat/chatroom/service/DistributedLockService.kt new file mode 100644 index 00000000..606b94ba --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/service/DistributedLockService.kt @@ -0,0 +1,58 @@ +package kpring.chat.chatroom.service + +import kpring.chat.chatroom.dto.Lock +import kpring.chat.chatroom.model.DistributedLock +import kpring.chat.chatroom.repository.DistributedLockRepository +import kpring.chat.global.exception.ErrorCode +import kpring.chat.global.exception.GlobalException +import org.springframework.stereotype.Service +import java.util.* + +@Service +class DistributedLockService( + private val lockRepository: DistributedLockRepository, +) { + fun acquireLock( + lockId: String, + owner: String, + expireInMillis: Long, + ): Boolean { + val now = System.currentTimeMillis() + val expiresAt = now + expireInMillis + + val optionalLock = lockRepository.findById(lockId) + return if (optionalLock.isPresent) { + val lock = optionalLock.get() + if (lock.expiresAt < now) { + lockRepository.save(DistributedLock(lockId, owner, expiresAt)) + true + } else { + false + } + } else { + lockRepository.save(DistributedLock(lockId, owner, expiresAt)) + true + } + } + + fun releaseLock( + lockId: String, + owner: String, + ) { + val optionalLock = lockRepository.findById(lockId) + if (optionalLock.isPresent && optionalLock.get().owner == owner) { + lockRepository.deleteById(lockId) + } + } + + fun getLock(chatRoomId: String): Lock { + val lockId = "chatRoom:$chatRoomId:lock" + val owner = UUID.randomUUID().toString() + + val lockAcquired = acquireLock(lockId, owner, 10000) // 10초 동안 잠금 유지 + if (!lockAcquired) { + throw GlobalException(ErrorCode.CONCURRENCY_CONFLICTION) + } + return Lock(lockId = lockId, owner = owner, acquired = lockAcquired) + } +} diff --git a/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt b/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt index 09798d7a..042df163 100644 --- a/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt +++ b/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt @@ -12,4 +12,7 @@ enum class ErrorCode(val httpStatus: Int, val message: String) { // 404 CHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 id로 chatroom을 찾을 수 없습니다"), + + // 500 + CONCURRENCY_CONFLICTION(HttpStatus.CONFLICT.value(), "동시성 충돌이 발생했습니다."), } diff --git a/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt b/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt index 49d0ba99..d118e384 100644 --- a/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt +++ b/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt @@ -1,22 +1,30 @@ package kpring.chat.chatroom +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kpring.chat.chatroom.dto.Lock import kpring.chat.chatroom.model.ChatRoom import kpring.chat.chatroom.repository.ChatRoomRepository import kpring.chat.chatroom.service.ChatRoomService +import kpring.chat.chatroom.service.DistributedLockService import kpring.chat.global.ChatRoomTest import kpring.chat.global.CommonTest +import kpring.chat.global.exception.ErrorCode +import kpring.chat.global.exception.GlobalException import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest import java.util.* class ChatRoomServiceTest : FunSpec({ val chatRoomRepository = mockk() - val chatRoomService = ChatRoomService(chatRoomRepository) + val lockService = mockk() + val chatRoomService = ChatRoomService(chatRoomRepository, lockService) test("createChatRoom 는 새 ChatRoom을 저장해야 한다") { // Given @@ -49,4 +57,66 @@ class ChatRoomServiceTest : FunSpec({ verify { chatRoomRepository.save(chatRoom) } chatRoom.members.shouldNotContain(CommonTest.TEST_USER_ID) } + + test("inviteToChatRoomByUserIdWithLock 는 동시성 잠금을 획득한 후 사용자를 초대해야 한다") { + // Given + val chatRoom = + ChatRoom().apply { + id = ChatRoomTest.TEST_ROOM_ID + addUsers(ChatRoomTest.TEST_MEMBERS) + } + val lock = Lock("chatRoom:${chatRoom.id}:lock", UUID.randomUUID().toString(), true) + + every { lockService.getLock(any()) } returns lock + every { lockService.releaseLock(any(), any()) } returns Unit + every { chatRoomRepository.findById(chatRoom.id!!) } returns Optional.of(chatRoom) + every { chatRoomRepository.save(any()) } returns chatRoom + every { chatRoomRepository.existsByIdAndMembersContaining(any(), CommonTest.TEST_USER_ID) } returns true + + // When + chatRoomService.inviteToChatRoomByUserIdWithLock(CommonTest.TEST_USER_ID, CommonTest.TEST_USER_ID, chatRoom.id!!) + + // Then + verify { lockService.getLock(any()) } + verify { chatRoomRepository.save(chatRoom) } + verify { lockService.releaseLock(any(), any()) } + chatRoom.members.shouldContain(CommonTest.TEST_USER_ID) + } + + test("inviteToChatRoomByUserId 는 사용자를 초대해야 한다") { + // Given + val chatRoom = + ChatRoom().apply { + id = ChatRoomTest.TEST_ROOM_ID + addUsers(ChatRoomTest.TEST_MEMBERS) + } + + every { chatRoomRepository.findById(chatRoom.id!!) } returns Optional.of(chatRoom) + every { chatRoomRepository.save(any()) } returns chatRoom + every { chatRoomRepository.existsByIdAndMembersContaining(any(), CommonTest.TEST_USER_ID) } returns true + + // When + chatRoomService.inviteToChatRoomByUserId(CommonTest.TEST_USER_ID, CommonTest.TEST_USER_ID, chatRoom.id!!) + + // Then + verify { chatRoomRepository.save(chatRoom) } + chatRoom.members.shouldContain(CommonTest.TEST_USER_ID) + } + + test("inviteToChatRoomByUserIdWithLock 는 잠금 획득 실패 시 예외를 발생해야 한다") { + // Given + val chatRoomId = ChatRoomTest.TEST_ROOM_ID + val userId = CommonTest.TEST_USER_ID + + every { lockService.getLock(any()) } throws GlobalException(ErrorCode.CONCURRENCY_CONFLICTION) + + // When + val exception = + shouldThrow { + chatRoomService.inviteToChatRoomByUserIdWithLock(userId, userId, chatRoomId) + } + + // Then + exception.getErrorCode() shouldBe ErrorCode.CONCURRENCY_CONFLICTION + } }) diff --git a/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt b/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt new file mode 100644 index 00000000..28cc701f --- /dev/null +++ b/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt @@ -0,0 +1,120 @@ +package kpring.chat.chat.api.v1 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.junit5.MockKExtension +import kpring.chat.chatroom.api.v1.ChatRoomController +import kpring.chat.chatroom.service.ChatRoomService +import kpring.chat.global.ChatRoomTest +import kpring.chat.global.CommonTest +import kpring.core.auth.client.AuthClient +import kpring.core.auth.dto.response.TokenInfo +import kpring.core.auth.enums.TokenType +import kpring.core.global.dto.response.ApiResponse +import kpring.test.restdoc.dsl.restDoc +import kpring.test.restdoc.json.JsonDataType +import kpring.test.web.URLBuilder +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.restdocs.ManualRestDocumentation +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.web.context.WebApplicationContext + +@WebMvcTest(controllers = [ChatRoomController::class]) +@ExtendWith(RestDocumentationExtension::class) +@ExtendWith(SpringExtension::class) +@ExtendWith(MockKExtension::class) +class ChatRoomControllerTest( + private val om: ObjectMapper, + webContext: WebApplicationContext, + @MockkBean val chatRoomService: ChatRoomService, + @MockkBean val authClient: AuthClient, +) : DescribeSpec({ + + val restDocument = ManualRestDocumentation() + val webTestClient: WebTestClient = + MockMvcWebTestClient.bindToApplicationContext(webContext).configureClient() + .baseUrl("http://localhost:8081") + .filter( + WebTestClientRestDocumentation.documentationConfiguration(restDocument).operationPreprocessors() + .withRequestDefaults(Preprocessors.prettyPrint()).withResponseDefaults(Preprocessors.prettyPrint()), + ) + .build() + + beforeSpec { restDocument.beforeTest(this.javaClass, "chatroom controller") } + + afterSpec { restDocument.afterTest() } + + describe("PATCH /api/v1/chatroom : inviteToChatRoomByUserId api test") { + + val url = "/api/v1/chatroom/{chatRoomId}/invite/{userId}" + it("inviteToChatRoomByUserId api test") { + + // Given + val userId = CommonTest.TEST_ANOTHER_USER_ID + val inviterId = CommonTest.TEST_USER_ID + val chatRoomId = ChatRoomTest.TEST_ROOM_ID + val data = true + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = CommonTest.TEST_USER_ID, + ), + ) + + every { + chatRoomService.inviteToChatRoomByUserIdWithLock( + userId, + inviterId, + chatRoomId, + ) + } returns data + + // When + val result = + webTestClient.patch().uri( + URLBuilder(url) + .build(), + chatRoomId, + userId, + ) + .header("Authorization", "Bearer mock_token") + .exchange() + + val docs = + result + .expectStatus() + .isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(status = 201))) + + // Then + docs.restDoc( + identifier = "invite_to_chatroom_by_user_id_201", + description = "채팅방에 user id로 초대하기 api", + ) { + request { + path { + "chatRoomId" mean "채팅방 id" + "userId" mean "초대받는 user id" + } + } + + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + } + })