diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt index 3aae2e0..4dba217 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt @@ -4,6 +4,7 @@ import io.github.wntopia.gikipedia.server.domain.article.dto.request.CreateArtic import io.github.wntopia.gikipedia.server.domain.article.dto.request.LookupArticleTitlesReqDto import io.github.wntopia.gikipedia.server.domain.article.dto.request.UpdateArticleImageReqDto import io.github.wntopia.gikipedia.server.domain.article.dto.request.UpdateArticleReqDto +import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleEditorsResDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleResDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.LookupArticleTitlesResDto import io.github.wntopia.gikipedia.server.domain.article.dto.response.PopularArticleResDto @@ -11,6 +12,7 @@ import io.github.wntopia.gikipedia.server.domain.article.dto.response.RecentChan import io.github.wntopia.gikipedia.server.domain.article.service.CreateArticleService import io.github.wntopia.gikipedia.server.domain.article.service.LookupArticleTitlesService import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleByTitleService +import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleEditorsService import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleService import io.github.wntopia.gikipedia.server.domain.article.service.QueryPopularArticlesService import io.github.wntopia.gikipedia.server.domain.article.service.QueryRecentChangesService @@ -60,6 +62,7 @@ class ArticleController( private val queryRecentChangesService: QueryRecentChangesService, private val queryPopularArticlesService: QueryPopularArticlesService, private val trackArticleViewService: TrackArticleViewService, + private val queryArticleEditorsService: QueryArticleEditorsService, ) { @Operation( summary = "문서 생성", @@ -208,6 +211,20 @@ class ArticleController( @Parameter(hidden = true) request: HttpServletRequest, ): ArticleResDto = withViewCount(queryArticleService.execute(articleId), request) + @Operation( + summary = "문서 현재 편집자 조회", + description = + "지금 실시간 공동편집 room에 접속해 있는 편집자를 반환한다. 읽기 화면의 \"○○ 편집 중\" 표시에 쓴다. " + + "편집 중인 사람이 없거나 공동편집 room이 없는 문서(존재하지 않는 문서 포함)는 오류 없이 " + + "`editing=false`의 빈 결과를 반환한다. 로그인 없이 호출할 수 있다. 읽기 전용 구독은 제공하지 않으므로 " + + "표시가 필요한 동안 10~30초 간격으로 폴링하면 된다.", + ) + @ApiResponses(value = [ApiResponse(responseCode = "200", description = "조회 성공")]) + @GetMapping("/{articleId}/editors") + fun editors( + @Parameter(description = "문서 ID", example = "1") @PathVariable articleId: Long, + ): ArticleEditorsResDto = queryArticleEditorsService.execute(articleId) + @Operation( summary = "문서 수정", description = diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleEditorsResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleEditorsResDto.kt new file mode 100644 index 0000000..80f864c --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleEditorsResDto.kt @@ -0,0 +1,19 @@ +package io.github.wntopia.gikipedia.server.domain.article.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ArticleEditorsResDto( + @field:Schema(description = "지금 이 문서를 편집 중인 사람이 있는지 여부") + val editing: Boolean, + @field:Schema(description = "편집 중인 사용자 수 (같은 사용자의 여러 탭은 한 명으로 센다)", example = "2") + val editorCount: Int, + @field:Schema( + description = "편집 중인 사용자 이름 (학번 제외). \"○○ 외 N명\"은 첫 이름과 editorCount - 1로 만든다.", + example = "[\"이서연\", \"홍길동\"]", + ) + val editorNames: List, +) { + companion object { + val EMPTY = ArticleEditorsResDto(false, 0, emptyList()) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryArticleEditorsService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryArticleEditorsService.kt new file mode 100644 index 0000000..96ad1df --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/QueryArticleEditorsService.kt @@ -0,0 +1,7 @@ +package io.github.wntopia.gikipedia.server.domain.article.service + +import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleEditorsResDto + +interface QueryArticleEditorsService { + fun execute(articleId: Long): ArticleEditorsResDto +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImpl.kt new file mode 100644 index 0000000..75682ea --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImpl.kt @@ -0,0 +1,23 @@ +package io.github.wntopia.gikipedia.server.domain.article.service.impl + +import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleEditorsResDto +import io.github.wntopia.gikipedia.server.domain.article.service.QueryArticleEditorsService +import io.github.wntopia.gikipedia.server.domain.collaboration.service.CollaborationRoomRegistry +import org.springframework.stereotype.Service + +/** 활성 공동편집 room의 현재 참여자 기준. room이 없거나 비어 있으면(마지막 편집자가 나갔으면) 빈 결과다. */ +@Service +class QueryArticleEditorsServiceImpl( + private val collaborationRoomRegistry: CollaborationRoomRegistry, +) : QueryArticleEditorsService { + override fun execute(articleId: Long): ArticleEditorsResDto { + val editors = collaborationRoomRegistry.findIfActive(articleId)?.currentEditors().orEmpty() + if (editors.isEmpty()) return ArticleEditorsResDto.EMPTY + + return ArticleEditorsResDto( + editing = true, + editorCount = editors.size, + editorNames = editors.map { it.name ?: it.label }, + ) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoom.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoom.kt index ae64a00..ddd767c 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoom.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoom.kt @@ -19,6 +19,7 @@ class CollaborationRoom( private val objectMapper: ObjectMapper, ) { private val sessions = LinkedHashMap() + private val editorsBySessionId = LinkedHashMap() /** 가장 최근 `sync` 또는 `save` 메시지가 실어온 plain text — 안전망 저장의 원천. */ var lastKnownPlainText: String? = null @@ -31,14 +32,23 @@ class CollaborationRoom( private var pendingLeaveSafetyNet: ScheduledFuture<*>? = null @Synchronized - fun join(session: WebSocketSession) { + fun join( + session: WebSocketSession, + editor: EditorIdentity? = null, + ) { sessions[session.id] = session + editor?.let { editorsBySessionId[session.id] = it } } + /** 지금 room에 접속해 있는 편집자들. 같은 사용자가 여러 탭으로 들어와 있어도 한 번만 나온다. */ + @Synchronized + fun currentEditors(): List = editorsBySessionId.values.distinctBy { it.userId ?: it.label } + /** @return 이 호출로 인해 room이 비게 됐는지 여부 */ @Synchronized fun leave(sessionId: String): Boolean { sessions.remove(sessionId) + editorsBySessionId.remove(sessionId) return sessions.isEmpty() } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/service/CollaborationRoomRegistry.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/service/CollaborationRoomRegistry.kt index b91ce54..825081a 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/service/CollaborationRoomRegistry.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/service/CollaborationRoomRegistry.kt @@ -1,6 +1,7 @@ package io.github.wntopia.gikipedia.server.domain.collaboration.service import io.github.wntopia.gikipedia.server.domain.collaboration.model.CollaborationRoom +import io.github.wntopia.gikipedia.server.global.security.session.EditorIdentity import org.springframework.stereotype.Component import org.springframework.web.socket.WebSocketSession import tools.jackson.databind.ObjectMapper @@ -27,11 +28,12 @@ class CollaborationRoomRegistry( fun joinOrCreate( articleId: Long, session: WebSocketSession, + editor: EditorIdentity? = null, ): CollaborationRoom = rooms.compute(articleId) { _, existing -> val room = existing ?: CollaborationRoom(articleId, objectMapper) room.cancelPendingSafetyNet() - room.join(session) + room.join(session, editor) room }!! diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandler.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandler.kt index 14f73f2..4d8469c 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandler.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandler.kt @@ -63,9 +63,10 @@ class ArticleCollaborationWebSocketHandler( return } - editorsBySessionId[session.id] = authenticationReader.getEditor(session.attributes) + val editor = authenticationReader.getEditor(session.attributes) + editorsBySessionId[session.id] = editor val decoratedSession = ConcurrentWebSocketSessionDecorator(session, SEND_TIME_LIMIT_MS, BUFFER_SIZE_LIMIT_BYTES) - val room = collaborationRoomRegistry.joinOrCreate(articleId, decoratedSession) + val room = collaborationRoomRegistry.joinOrCreate(articleId, decoratedSession, editor) room.sendTo(session.id, bootstrap) } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/AuthenticationReader.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/AuthenticationReader.kt index 59ce198..bc693ce 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/AuthenticationReader.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/AuthenticationReader.kt @@ -28,7 +28,7 @@ class AuthenticationReader { private fun editorOf(userInfo: DataGsmUserInfoResDto?): EditorIdentity { val student = userInfo?.student ?: throw ExpectedException("인증이 필요합니다.", HttpStatus.UNAUTHORIZED) - return EditorIdentity(userInfo.id, "${student.studentNumber ?: "?"} ${student.name ?: "?"}") + return EditorIdentity(userInfo.id, "${student.studentNumber ?: "?"} ${student.name ?: "?"}", student.name) } /** 로그인한 사용자의 DataGSM 계정 id. 로그인하지 않았거나 계정 id가 없으면 401. */ diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/EditorIdentity.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/EditorIdentity.kt index 20511ca..9432613 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/EditorIdentity.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/global/security/session/EditorIdentity.kt @@ -7,4 +7,6 @@ package io.github.wntopia.gikipedia.server.global.security.session data class EditorIdentity( val userId: Long?, val label: String, + /** 화면에 "○○ 편집 중"으로 보여줄 이름(학번 제외). 알 수 없으면 null. */ + val name: String? = null, ) diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImplTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImplTest.kt new file mode 100644 index 0000000..3795a32 --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleEditorsServiceImplTest.kt @@ -0,0 +1,59 @@ +package io.github.wntopia.gikipedia.server.domain.article.service.impl + +import io.github.wntopia.gikipedia.server.domain.collaboration.service.CollaborationRoomRegistry +import io.github.wntopia.gikipedia.server.global.security.session.EditorIdentity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.web.socket.WebSocketSession +import tools.jackson.module.kotlin.jacksonObjectMapper + +class QueryArticleEditorsServiceImplTest { + private val registry = CollaborationRoomRegistry(jacksonObjectMapper()) + private val service = QueryArticleEditorsServiceImpl(registry) + + private fun fakeSession(id: String) = mock().also { whenever(it.id).thenReturn(id) } + + @Test + @DisplayName("누군가 편집 중이면 편집자 이름과 인원 수를 반환한다") + fun returnsEditorsWhileEditing() { + registry.joinOrCreate(1L, fakeSession("a"), EditorIdentity(1L, "2412 홍길동", "홍길동")) + registry.joinOrCreate(1L, fakeSession("b"), EditorIdentity(2L, "2413 김철수", "김철수")) + + val result = service.execute(1L) + + assertThat(result.editing).isTrue() + assertThat(result.editorCount).isEqualTo(2) + assertThat(result.editorNames).containsExactly("홍길동", "김철수") + } + + @Test + @DisplayName("이름을 모르면 라벨로 대신한다") + fun fallsBackToLabel() { + registry.joinOrCreate(1L, fakeSession("a"), EditorIdentity(1L, "2412 홍길동")) + + assertThat(service.execute(1L).editorNames).containsExactly("2412 홍길동") + } + + @Test + @DisplayName("마지막 편집자가 나가면 결과가 비어 있다") + fun emptyAfterLastEditorLeaves() { + val room = registry.joinOrCreate(1L, fakeSession("a"), EditorIdentity(1L, "2412 홍길동", "홍길동")) + room.leave("a") + + val result = service.execute(1L) + + assertThat(result.editing).isFalse() + assertThat(result.editorCount).isEqualTo(0) + assertThat(result.editorNames).isEmpty() + } + + @Test + @DisplayName("활성 room이 없는 문서도 오류 없이 빈 결과를 반환한다") + fun emptyWhenNoRoom() { + assertThat(service.execute(404L).editing).isFalse() + assertThat(service.execute(404L).editorNames).isEmpty() + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoomTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoomTest.kt index cf84f0e..245cfc2 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoomTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/model/CollaborationRoomTest.kt @@ -1,5 +1,7 @@ package io.github.wntopia.gikipedia.server.domain.collaboration.model +import io.github.wntopia.gikipedia.server.global.security.session.EditorIdentity +import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatCode import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -75,4 +77,31 @@ class CollaborationRoomTest { verify(closed, never()).sendMessage(any()) } + + @Test + @DisplayName("접속한 편집자가 현재 편집자로 나오고, 나가면 빠진다") + fun tracksCurrentEditors() { + room.join(fakeSession("a"), EditorIdentity(1L, "2412 홍길동", "홍길동")) + room.join(fakeSession("b"), EditorIdentity(2L, "2413 김철수", "김철수")) + + assertThat(room.currentEditors().map { it.name }).containsExactly("홍길동", "김철수") + + room.leave("a") + assertThat(room.currentEditors().map { it.name }).containsExactly("김철수") + + room.leave("b") + assertThat(room.currentEditors()).isEmpty() + } + + @Test + @DisplayName("같은 사용자가 여러 탭으로 접속해도 한 명으로 센다") + fun countsSameUserOnce() { + val editor = EditorIdentity(1L, "2412 홍길동", "홍길동") + room.join(fakeSession("a"), editor) + room.join(fakeSession("b"), editor) + + assertThat(room.currentEditors()).hasSize(1) + assertThat(room.leave("a")).isFalse() + assertThat(room.currentEditors()).hasSize(1) + } } diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandlerTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandlerTest.kt index 3761034..c66e052 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandlerTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/collaboration/websocket/ArticleCollaborationWebSocketHandlerTest.kt @@ -261,4 +261,18 @@ class ArticleCollaborationWebSocketHandlerTest { assertThat(messagesSentTo(session)).anyMatch { it.type == CollaborationMessage.TYPE_ERROR } verify(session, org.mockito.kotlin.never()).close(any()) } + + @Test + @DisplayName("접속하면 room의 현재 편집자에 나오고, 나가면 비어 있다") + fun exposesCurrentEditorsWhileConnected() { + whenever(taskScheduler.schedule(any(), any())).thenReturn(mock>()) + val session = fakeSession("a", 1L, "2412 홍길동") + + handler.afterConnectionEstablished(session) + val room = requireNotNull(collaborationRoomRegistry.findIfActive(1L)) + assertThat(room.currentEditors().map { it.label }).containsExactly("2412 홍길동") + + handler.afterConnectionClosed(session, CloseStatus.NORMAL) + assertThat(room.currentEditors()).isEmpty() + } }