Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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
import io.github.wntopia.gikipedia.server.domain.article.dto.response.RecentChangeResDto
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
Expand Down Expand Up @@ -60,6 +62,7 @@ class ArticleController(
private val queryRecentChangesService: QueryRecentChangesService,
private val queryPopularArticlesService: QueryPopularArticlesService,
private val trackArticleViewService: TrackArticleViewService,
private val queryArticleEditorsService: QueryArticleEditorsService,
) {
@Operation(
summary = "문서 생성",
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
) {
companion object {
val EMPTY = ArticleEditorsResDto(false, 0, emptyList())
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class CollaborationRoom(
private val objectMapper: ObjectMapper,
) {
private val sessions = LinkedHashMap<String, WebSocketSession>()
private val editorsBySessionId = LinkedHashMap<String, EditorIdentity>()

/** 가장 최근 `sync` 또는 `save` 메시지가 실어온 plain text — 안전망 저장의 원천. */
var lastKnownPlainText: String? = null
Expand All @@ -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<EditorIdentity> = editorsBySessionId.values.distinctBy { it.userId ?: it.label }

/** @return 이 호출로 인해 room이 비게 됐는지 여부 */
@Synchronized
fun leave(sessionId: String): Boolean {
sessions.remove(sessionId)
editorsBySessionId.remove(sessionId)
return sessions.isEmpty()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}!!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<WebSocketSession>().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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,18 @@ class ArticleCollaborationWebSocketHandlerTest {
assertThat(messagesSentTo(session)).anyMatch { it.type == CollaborationMessage.TYPE_ERROR }
verify(session, org.mockito.kotlin.never()).close(any<CloseStatus>())
}

@Test
@DisplayName("접속하면 room의 현재 편집자에 나오고, 나가면 비어 있다")
fun exposesCurrentEditorsWhileConnected() {
whenever(taskScheduler.schedule(any<Runnable>(), any<Instant>())).thenReturn(mock<ScheduledFuture<*>>())
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()
}
}
Loading