Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.weeth.domain.attendance.application.mapper.AttendanceMapper
import com.weeth.domain.attendance.domain.port.QrAttendancePort
import com.weeth.domain.attendance.domain.port.SseBroadcastPort
import com.weeth.domain.club.domain.service.ClubPermissionPolicy
import com.weeth.domain.session.application.exception.SessionNotFoundException
import com.weeth.domain.session.domain.repository.SessionReader
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
Expand All @@ -33,11 +34,14 @@ class GenerateQrTokenUseCase(
requireNotNull(
txTemplate.execute {
clubPermissionPolicy.requireAdmin(clubId, userId)
sessionReader.getById(sessionId)
sessionReader
.getById(sessionId)
.takeIf { it.club.id == clubId }
?: throw SessionNotFoundException()
},
)

qrAttendancePort.store(sessionId, session.code)
qrAttendancePort.store(clubId, sessionId, session.code)
val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS)
ssePort.broadcast(clubId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt))
return attendanceMapper.toQrTokenResponse(session, expiredAt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.weeth.domain.attendance.domain.port.QrAttendancePort
import com.weeth.domain.attendance.domain.port.SseBroadcastPort
import com.weeth.domain.attendance.domain.port.SseSubscribePort
import com.weeth.domain.club.domain.service.ClubMemberPolicy
import com.weeth.domain.session.domain.repository.SessionReader
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
Expand All @@ -16,7 +15,6 @@ class SubscribeAttendanceSseUseCase(
private val sseSubscribePort: SseSubscribePort,
private val sseBroadcastPort: SseBroadcastPort,
private val clubMemberPolicy: ClubMemberPolicy,
private val sessionReader: SessionReader,
private val qrAttendancePort: QrAttendancePort,
) {
@Transactional(readOnly = true)
Expand All @@ -28,8 +26,8 @@ class SubscribeAttendanceSseUseCase(

val emitter = sseSubscribePort.subscribe(clubId, userId)

val openSession = sessionReader.findOpenByClubId(clubId)
val expiredAt = openSession?.let { qrAttendancePort.getExpiredAt(it.id) }
val activeSessionId = qrAttendancePort.getActiveSessionId(clubId)
val expiredAt = activeSessionId?.let { qrAttendancePort.getExpiredAt(it) }

if (expiredAt != null) {
sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import java.time.LocalDateTime
interface QrAttendancePort {
companion object {
const val TTL_SECONDS = 600L
const val ACTIVE_TTL_SECONDS = TTL_SECONDS + 30L
const val KEY_PREFIX = "qr:"
const val ACTIVE_KEY_PREFIX = "active-qr:"
}

/**
* QR 출석 코드를 Redis에 저장합니다.
* key: sessionId, value: code (TTL 10분)
* QR 출석 코드와 클럽의 현재 활성 QR 세션을 Redis에 저장합니다.
* code key: sessionId, value: code (TTL 10분)
* active key: clubId, value: sessionId (TTL 10분 30초)
*/
fun store(
clubId: Long,
sessionId: Long,
code: Int,
)
Expand All @@ -23,6 +27,21 @@ interface QrAttendancePort {
*/
fun getCode(sessionId: Long): Int?

/**
* clubId에 해당하는 현재 활성 QR 세션 ID를 반환합니다.
* 활성 QR이 없거나 active key가 만료된 경우 null을 반환합니다.
*/
fun getActiveSessionId(clubId: Long): Long?

/**
* 현재 활성 QR 세션이 sessionId와 일치하면 active key를 삭제하고 true를 반환합니다.
* 일치하지 않거나 활성 QR이 없으면 false를 반환합니다.
*/
fun clearActiveSessionIfMatches(
clubId: Long,
sessionId: Long,
): Boolean

/**
* sessionId에 해당하는 QR 코드의 만료 시각을 반환합니다.
* QR이 없거나 TTL이 만료된 경우 null을 반환합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component
@Component
class QrExpiredEventListener(
private val sessionReader: SessionReader,
private val qrAttendancePort: QrAttendancePort,
private val sseBroadcastPort: SseBroadcastPort,
) : MessageListener {
private val log = LoggerFactory.getLogger(javaClass)
Expand All @@ -27,6 +28,7 @@ class QrExpiredEventListener(

val clubId = sessionReader.findClubIdById(sessionId) ?: return
runCatching {
if (!qrAttendancePort.clearActiveSessionIfMatches(clubId, sessionId)) return
sseBroadcastPort.broadcast(clubId, AttendanceSseEvent.QR_CLOSE, null)
}.onFailure { e ->
log.error("QR 만료 이벤트 처리 실패: sessionId={}", sessionId, e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.weeth.domain.attendance.infrastructure

import com.weeth.domain.attendance.domain.port.QrAttendancePort
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.script.DefaultRedisScript
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
Expand All @@ -11,18 +12,62 @@ class RedisQrAttendanceAdapter(
private val redisTemplate: RedisTemplate<String, String>,
) : QrAttendancePort {
override fun store(
clubId: Long,
sessionId: Long,
code: Int,
) {
redisTemplate.opsForValue().set(key(sessionId), code.toString(), QrAttendancePort.TTL_SECONDS, TimeUnit.SECONDS)
redisTemplate.execute(
storeScript,
listOf(key(sessionId), activeKey(clubId)),
code.toString(),
sessionId.toString(),
QrAttendancePort.TTL_SECONDS.toString(),
QrAttendancePort.ACTIVE_TTL_SECONDS.toString(),
)
}

override fun getCode(sessionId: Long): Int? = redisTemplate.opsForValue().get(key(sessionId))?.toIntOrNull()

override fun getActiveSessionId(clubId: Long): Long? =
redisTemplate.opsForValue().get(activeKey(clubId))?.toLongOrNull()

override fun clearActiveSessionIfMatches(
clubId: Long,
sessionId: Long,
): Boolean =
redisTemplate.execute(
clearIfMatchesScript,
listOf(activeKey(clubId)),
sessionId.toString(),
) == 1L

override fun getExpiredAt(sessionId: Long): LocalDateTime? {
val ttl = redisTemplate.getExpire(key(sessionId), TimeUnit.SECONDS)
return if (ttl > 0) LocalDateTime.now().plusSeconds(ttl) else null
}

private val storeScript =
DefaultRedisScript(
"""
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[3])
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[4])
return 1
""".trimIndent(),
Long::class.java,
)

private val clearIfMatchesScript =
DefaultRedisScript(
"""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""".trimIndent(),
Long::class.java,
)

private fun key(sessionId: Long) = "${QrAttendancePort.KEY_PREFIX}$sessionId"

private fun activeKey(clubId: Long) = "${QrAttendancePort.ACTIVE_KEY_PREFIX}$clubId"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.weeth.domain.attendance.application.mapper.AttendanceMapper
import com.weeth.domain.attendance.domain.port.QrAttendancePort
import com.weeth.domain.attendance.domain.port.SseBroadcastPort
import com.weeth.domain.club.domain.service.ClubPermissionPolicy
import com.weeth.domain.club.fixture.ClubTestFixture
import com.weeth.domain.session.application.exception.SessionNotFoundException
import com.weeth.domain.session.domain.repository.SessionReader
import com.weeth.domain.session.fixture.SessionTestFixture
Expand Down Expand Up @@ -48,10 +49,16 @@ class GenerateQrTokenUseCaseTest :
describe("execute") {
val sessionId = 1L
val code = 123456
val clubId = 10L

context("유효한 sessionId") {
it("Redis에 코드를 저장하고 QrTokenResponse를 반환한다") {
val session = SessionTestFixture.createSession(id = sessionId, code = code)
val session =
SessionTestFixture.createSession(
id = sessionId,
code = code,
club = ClubTestFixture.createClub(id = clubId),
)
val expectedResponse =
QrTokenResponse(
sessionId = sessionId,
Expand All @@ -60,16 +67,16 @@ class GenerateQrTokenUseCaseTest :
)

every { sessionReader.getById(sessionId) } returns session
every { qrAttendancePort.store(sessionId, code) } just Runs
every { qrAttendancePort.store(clubId, sessionId, code) } just Runs
every { attendanceMapper.toQrTokenResponse(eq(session), any()) } returns expectedResponse

val result = useCase.execute(sessionId, 10L, 20L)
val result = useCase.execute(sessionId, clubId, 20L)

result shouldBe expectedResponse
verify(exactly = 1) { clubPermissionPolicy.requireAdmin(10L, 20L) }
verify(exactly = 1) { qrAttendancePort.store(sessionId, code) }
verify(exactly = 1) { clubPermissionPolicy.requireAdmin(clubId, 20L) }
verify(exactly = 1) { qrAttendancePort.store(clubId, sessionId, code) }
verify(exactly = 1) {
ssePort.broadcast(10L, AttendanceSseEvent.QR_OPEN, any<AttendanceOpenEvent>())
ssePort.broadcast(clubId, AttendanceSseEvent.QR_OPEN, any<AttendanceOpenEvent>())
}
}
}
Expand All @@ -78,9 +85,27 @@ class GenerateQrTokenUseCaseTest :
it("SessionNotFoundException을 던진다") {
every { sessionReader.getById(sessionId) } throws SessionNotFoundException()

shouldThrow<SessionNotFoundException> { useCase.execute(sessionId, 10L, 20L) }
shouldThrow<SessionNotFoundException> { useCase.execute(sessionId, clubId, 20L) }

verify(exactly = 0) { qrAttendancePort.store(any(), any(), any()) }
verify(exactly = 0) { ssePort.broadcast(any(), any(), any()) }
}
}

context("다른 클럽의 sessionId") {
it("SessionNotFoundException을 던지고 QR을 저장하지 않는다") {
val session =
SessionTestFixture.createSession(
id = sessionId,
code = code,
club = ClubTestFixture.createClub(id = 999L),
)
every { sessionReader.getById(sessionId) } returns session

shouldThrow<SessionNotFoundException> { useCase.execute(sessionId, clubId, 20L) }

verify(exactly = 0) { qrAttendancePort.store(any(), any()) }
verify(exactly = 1) { clubPermissionPolicy.requireAdmin(clubId, 20L) }
verify(exactly = 0) { qrAttendancePort.store(any(), any(), any()) }
verify(exactly = 0) { ssePort.broadcast(any(), any(), any()) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import com.weeth.domain.attendance.domain.port.SseBroadcastPort
import com.weeth.domain.attendance.domain.port.SseSubscribePort
import com.weeth.domain.club.application.exception.MemberNotActiveException
import com.weeth.domain.club.domain.service.ClubMemberPolicy
import com.weeth.domain.session.domain.repository.SessionReader
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
Expand All @@ -23,18 +22,16 @@ class SubscribeAttendanceSseUseCaseTest :
val sseSubscribePort = mockk<SseSubscribePort>()
val sseBroadcastPort = mockk<SseBroadcastPort>(relaxed = true)
val clubMemberPolicy = mockk<ClubMemberPolicy>()
val sessionReader = mockk<SessionReader>()
val qrAttendancePort = mockk<QrAttendancePort>()
val useCase =
SubscribeAttendanceSseUseCase(
sseSubscribePort,
sseBroadcastPort,
clubMemberPolicy,
sessionReader,
qrAttendancePort,
)

beforeTest { clearMocks(sseSubscribePort, sseBroadcastPort, clubMemberPolicy, sessionReader, qrAttendancePort) }
beforeTest { clearMocks(sseSubscribePort, sseBroadcastPort, clubMemberPolicy, qrAttendancePort) }

describe("execute") {
val clubId = 1L
Expand All @@ -48,7 +45,7 @@ class SubscribeAttendanceSseUseCaseTest :

context("활성 QR이 없는 경우") {
it("qr-none 이벤트를 전송하고 emitter를 반환한다") {
every { sessionReader.findOpenByClubId(clubId) } returns null
every { qrAttendancePort.getActiveSessionId(clubId) } returns null

val result = useCase.execute(clubId, userId)

Expand All @@ -62,10 +59,9 @@ class SubscribeAttendanceSseUseCaseTest :
}
}

context("열린 세션이 있지만 QR이 만료된 경우") {
context("활성 QR 세션은 있지만 QR이 만료된 경우") {
it("qr-none 이벤트를 전송한다") {
val session = mockk<com.weeth.domain.session.domain.entity.Session> { every { id } returns 10L }
every { sessionReader.findOpenByClubId(clubId) } returns session
every { qrAttendancePort.getActiveSessionId(clubId) } returns 10L
every { qrAttendancePort.getExpiredAt(10L) } returns null

useCase.execute(clubId, userId)
Expand All @@ -78,9 +74,8 @@ class SubscribeAttendanceSseUseCaseTest :

context("활성 QR이 있는 경우") {
it("qr-open 이벤트를 전송하고 emitter를 반환한다") {
val session = mockk<com.weeth.domain.session.domain.entity.Session> { every { id } returns 10L }
val expiredAt = LocalDateTime.now().plusMinutes(5)
every { sessionReader.findOpenByClubId(clubId) } returns session
every { qrAttendancePort.getActiveSessionId(clubId) } returns 10L
every { qrAttendancePort.getExpiredAt(10L) } returns expiredAt

val result = useCase.execute(clubId, userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.weeth.domain.attendance.infrastructure

import com.weeth.domain.attendance.application.event.AttendanceSseEvent
import com.weeth.domain.attendance.domain.port.QrAttendancePort
import com.weeth.domain.attendance.domain.port.SseBroadcastPort
import com.weeth.domain.session.domain.repository.SessionReader
import io.kotest.assertions.throwables.shouldNotThrow
Expand All @@ -14,22 +15,33 @@ import org.springframework.data.redis.connection.Message
class QrExpiredEventListenerTest :
DescribeSpec({
val sessionReader = mockk<SessionReader>()
val qrAttendancePort = mockk<QrAttendancePort>()
val sseBroadcastPort = mockk<SseBroadcastPort>(relaxed = true)
val listener = QrExpiredEventListener(sessionReader, sseBroadcastPort)
val listener = QrExpiredEventListener(sessionReader, qrAttendancePort, sseBroadcastPort)

beforeTest { clearMocks(sessionReader, sseBroadcastPort) }
beforeTest { clearMocks(sessionReader, qrAttendancePort, sseBroadcastPort) }

fun message(key: String): Message = mockk { every { body } returns key.toByteArray() }

describe("onMessage") {
context("qr:{sessionId} 키가 만료된 경우") {
it("해당 클럽에 qr-close를 broadcast한다") {
it("현재 활성 QR과 일치하면 해당 클럽에 qr-close를 broadcast한다") {
every { sessionReader.findClubIdById(42L) } returns 7L
every { qrAttendancePort.clearActiveSessionIfMatches(7L, 42L) } returns true

listener.onMessage(message("qr:42"), null)

verify { sseBroadcastPort.broadcast(7L, AttendanceSseEvent.QR_CLOSE, null) }
}

it("현재 활성 QR과 일치하지 않으면 broadcast하지 않는다") {
every { sessionReader.findClubIdById(42L) } returns 7L
every { qrAttendancePort.clearActiveSessionIfMatches(7L, 42L) } returns false

listener.onMessage(message("qr:42"), null)

verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) }
}
}

context("qr: 접두사가 아닌 키가 만료된 경우") {
Expand Down Expand Up @@ -61,6 +73,7 @@ class QrExpiredEventListenerTest :
context("broadcast 중 예외가 발생하는 경우") {
it("예외가 전파되지 않는다") {
every { sessionReader.findClubIdById(42L) } returns 7L
every { qrAttendancePort.clearActiveSessionIfMatches(7L, 42L) } returns true
every { sseBroadcastPort.broadcast(any(), any(), any()) } throws RuntimeException("network error")

shouldNotThrow<Exception> { listener.onMessage(message("qr:42"), null) }
Expand Down
Loading