Compare commits
2 Commits
08ba6a6046
...
f17dedda20
| Author | SHA1 | Date | |
|---|---|---|---|
| f17dedda20 | |||
| f357d426d0 |
35
docs/20260413_에이전트크리에이터소속시간UTC변환.md
Normal file
35
docs/20260413_에이전트크리에이터소속시간UTC변환.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
## 작업 개요
|
||||||
|
|
||||||
|
- [x] `AdminAgentCreatorService`의 소속 등록 시 `assignedAt` 입력값을 한국 시간에서 UTC로 변환해 저장한다.
|
||||||
|
- [x] `AdminAgentCreatorService`의 소속 해제 시 `unassignedAt` 입력값을 한국 시간에서 UTC로 변환해 검증 후 저장한다.
|
||||||
|
- [x] `AdminAgentCreatorServiceTest`에 한국 시간 입력이 UTC로 저장되는 회귀 테스트를 추가한다.
|
||||||
|
- [x] 변경 파일 진단과 Gradle 검증(`test`, `ktlintCheck`)을 수행하고 결과를 기록한다.
|
||||||
|
- [x] `LocalDateTimeExtensions`에 타임존 입력 기반 UTC 변환 확장함수를 추가하고 기본 타임존을 KST로 둔다.
|
||||||
|
- [x] `AdminAgentCreatorService`의 시간 변환을 확장함수 호출로 리팩터링한다.
|
||||||
|
- [x] 확장함수 테스트와 관련 서비스 테스트를 다시 검증하고 기록한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 구현
|
||||||
|
|
||||||
|
- 무엇을: 관리자 에이전트-크리에이터 소속 등록/해제 시각의 UTC 저장 정합성 보정.
|
||||||
|
- 왜: 요청 시각은 한국 시간 기준인데 현재 DB에는 변환 없이 그대로 저장되어 실제 저장 기준과 맞지 않음.
|
||||||
|
- 어떻게:
|
||||||
|
- `AdminAgentCreatorServiceTest`에 등록 저장값 UTC 변환, 해제 저장값 UTC 변환, 해제 시각 비교 UTC 기준 검증 테스트를 추가했다.
|
||||||
|
- `AdminAgentCreatorService`에서 `assignedAt`/`unassignedAt`를 저장 및 비교 전에 `Asia/Seoul -> UTC`로 변환하도록 수정했다.
|
||||||
|
- `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가.
|
||||||
|
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest"` 실행: 성공(BUILD SUCCESSFUL).
|
||||||
|
- `./gradlew ktlintCheck` 실행: 성공(BUILD SUCCESSFUL).
|
||||||
|
|
||||||
|
### 2차 수정
|
||||||
|
|
||||||
|
- 무엇을: `Asia/Seoul -> UTC` 변환 책임을 `LocalDateTime` 확장함수로 이동하고 서비스는 그 확장함수를 사용하도록 리팩터링.
|
||||||
|
- 왜: 서비스 레이어의 타임존 변환 중복을 줄이고, 동일한 변환 규칙을 재사용 가능한 확장함수로 통일하기 위함.
|
||||||
|
- 어떻게:
|
||||||
|
- `LocalDateTimeExtensionsTest`에 기본 KST 변환과 사용자 지정 타임존 변환 테스트를 먼저 추가했고, 확장함수 부재 상태에서 실패함을 확인했다.
|
||||||
|
- `LocalDateTimeExtensions`에 `convertToUtc(timeZone: ZoneId = Asia/Seoul)` 확장함수를 추가했다.
|
||||||
|
- `AdminAgentCreatorService`는 내부 변환 함수를 제거하고 `request.assignedAt.convertToUtc()` / `request.unassignedAt.convertToUtc()`를 사용하도록 수정했다.
|
||||||
|
- `./gradlew test --tests "kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensionsTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest"` 실행: 성공(BUILD SUCCESSFUL).
|
||||||
|
- `./gradlew ktlintCheck` 실행: 성공(BUILD SUCCESSFUL).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
|
package kr.co.vividnext.sodalive.admin.partner.agent.assignment
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertToUtc
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
|
import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation
|
||||||
@@ -37,14 +38,15 @@ class AdminAgentCreatorService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val existingRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(request.creatorId)
|
val existingRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(request.creatorId)
|
||||||
if (hasAssignmentOverlap(existingRelations, request.assignedAt)) {
|
val assignedAt = request.assignedAt.convertToUtc()
|
||||||
|
if (hasAssignmentOverlap(existingRelations, assignedAt)) {
|
||||||
throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap")
|
throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap")
|
||||||
}
|
}
|
||||||
|
|
||||||
val relation = AgentCreatorRelation()
|
val relation = AgentCreatorRelation()
|
||||||
relation.agent = agent
|
relation.agent = agent
|
||||||
relation.creator = creator
|
relation.creator = creator
|
||||||
relation.assignedAt = request.assignedAt
|
relation.assignedAt = assignedAt
|
||||||
try {
|
try {
|
||||||
relationRepository.saveAndFlush(relation)
|
relationRepository.saveAndFlush(relation)
|
||||||
} catch (e: DataIntegrityViolationException) {
|
} catch (e: DataIntegrityViolationException) {
|
||||||
@@ -67,11 +69,12 @@ class AdminAgentCreatorService(
|
|||||||
|
|
||||||
val assignedAt = relation.assignedAt
|
val assignedAt = relation.assignedAt
|
||||||
?: throw SodaException(messageKey = "partner.agent.assignment.not_found")
|
?: throw SodaException(messageKey = "partner.agent.assignment.not_found")
|
||||||
if (!request.unassignedAt.isAfter(assignedAt)) {
|
val unassignedAt = request.unassignedAt.convertToUtc()
|
||||||
|
if (!unassignedAt.isAfter(assignedAt)) {
|
||||||
throw SodaException(messageKey = "partner.agent.assignment.invalid_unassigned_at")
|
throw SodaException(messageKey = "partner.agent.assignment.invalid_unassigned_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
relation.unassignedAt = request.unassignedAt
|
relation.unassignedAt = unassignedAt
|
||||||
relationRepository.save(relation)
|
relationRepository.save(relation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.extensions
|
|||||||
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
|
||||||
|
|
||||||
fun LocalDateTime.getTimeAgoString(): String {
|
fun LocalDateTime.getTimeAgoString(): String {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
@@ -16,3 +20,9 @@ fun LocalDateTime.getTimeAgoString(): String {
|
|||||||
else -> "${duration.toDays() / 365}년전"
|
else -> "${duration.toDays() / 365}년전"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDateTime {
|
||||||
|
return atZone(timeZone)
|
||||||
|
.withZoneSameInstant(UTC_ZONE_ID)
|
||||||
|
.toLocalDateTime()
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class AdminAgentCreatorServiceTest {
|
|||||||
@DisplayName("관리자는 에이전트와 크리에이터를 소속으로 연결할 수 있다")
|
@DisplayName("관리자는 에이전트와 크리에이터를 소속으로 연결할 수 있다")
|
||||||
fun shouldAssignCreatorToAgent() {
|
fun shouldAssignCreatorToAgent() {
|
||||||
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
||||||
|
val expectedUtcAssignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
|
||||||
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
|
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
|
||||||
agent.id = 11L
|
agent.id = 11L
|
||||||
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
|
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
|
||||||
@@ -55,7 +56,7 @@ class AdminAgentCreatorServiceTest {
|
|||||||
Mockito.verify(relationRepository).saveAndFlush(relationCaptor.capture())
|
Mockito.verify(relationRepository).saveAndFlush(relationCaptor.capture())
|
||||||
assertEquals(agent, relationCaptor.value.agent)
|
assertEquals(agent, relationCaptor.value.agent)
|
||||||
assertEquals(creator, relationCaptor.value.creator)
|
assertEquals(creator, relationCaptor.value.creator)
|
||||||
assertEquals(assignedAt, relationCaptor.value.assignedAt)
|
assertEquals(expectedUtcAssignedAt, relationCaptor.value.assignedAt)
|
||||||
assertEquals(null, relationCaptor.value.unassignedAt)
|
assertEquals(null, relationCaptor.value.unassignedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +254,7 @@ class AdminAgentCreatorServiceTest {
|
|||||||
@DisplayName("종료된 이력이 있으면 이후 시각에 다시 소속 지정할 수 있다")
|
@DisplayName("종료된 이력이 있으면 이후 시각에 다시 소속 지정할 수 있다")
|
||||||
fun shouldAssignCreatorWhenPreviousAssignmentAlreadyEnded() {
|
fun shouldAssignCreatorWhenPreviousAssignmentAlreadyEnded() {
|
||||||
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
||||||
|
val previousUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
|
||||||
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
|
val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT)
|
||||||
agent.id = 11L
|
agent.id = 11L
|
||||||
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
|
val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR)
|
||||||
@@ -261,7 +263,7 @@ class AdminAgentCreatorServiceTest {
|
|||||||
existingRelation.agent = agent
|
existingRelation.agent = agent
|
||||||
existingRelation.creator = creator
|
existingRelation.creator = creator
|
||||||
existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
|
existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
|
||||||
existingRelation.unassignedAt = assignedAt
|
existingRelation.unassignedAt = previousUnassignedAt
|
||||||
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
|
val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt)
|
||||||
|
|
||||||
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
|
Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent))
|
||||||
@@ -278,6 +280,7 @@ class AdminAgentCreatorServiceTest {
|
|||||||
fun shouldCloseCreatorAssignmentWindow() {
|
fun shouldCloseCreatorAssignmentWindow() {
|
||||||
val relation = AgentCreatorRelation()
|
val relation = AgentCreatorRelation()
|
||||||
relation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
|
relation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0)
|
||||||
|
val expectedUtcUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
|
||||||
val request = RemoveAgentCreatorRequest(
|
val request = RemoveAgentCreatorRequest(
|
||||||
creatorId = 22L,
|
creatorId = 22L,
|
||||||
unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0)
|
||||||
@@ -294,10 +297,37 @@ class AdminAgentCreatorServiceTest {
|
|||||||
|
|
||||||
service.removeCreator(request)
|
service.removeCreator(request)
|
||||||
|
|
||||||
assertEquals(request.unassignedAt, relation.unassignedAt)
|
assertEquals(expectedUtcUnassignedAt, relation.unassignedAt)
|
||||||
Mockito.verify(relationRepository).save(relation)
|
Mockito.verify(relationRepository).save(relation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("소속 종료 시각 비교는 한국 시간 입력을 UTC로 변환한 뒤 수행한다")
|
||||||
|
fun shouldValidateUnassignedAtAfterConvertingKstToUtc() {
|
||||||
|
val relation = AgentCreatorRelation()
|
||||||
|
relation.assignedAt = LocalDateTime.of(2026, 4, 9, 1, 0)
|
||||||
|
val request = RemoveAgentCreatorRequest(
|
||||||
|
creatorId = 22L,
|
||||||
|
unassignedAt = LocalDateTime.of(2026, 4, 9, 9, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(
|
||||||
|
Member(
|
||||||
|
password = "password",
|
||||||
|
nickname = "creator",
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
).also { it.id = 22L }
|
||||||
|
)
|
||||||
|
Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.removeCreator(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("partner.agent.assignment.invalid_unassigned_at", exception.messageKey)
|
||||||
|
Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("소속 정보가 없으면 해제할 수 없다")
|
@DisplayName("소속 정보가 없으면 해제할 수 없다")
|
||||||
fun shouldThrowWhenAssignmentDoesNotExist() {
|
fun shouldThrowWhenAssignmentDoesNotExist() {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.vividnext.sodalive.extensions
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.fail
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class LocalDateTimeExtensionsTest {
|
||||||
|
@Test
|
||||||
|
fun shouldConvertToUtcUsingKstByDefault() {
|
||||||
|
val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0)
|
||||||
|
|
||||||
|
val utcDateTime = invokeConvertToUtc(localDateTime)
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 4, 9, 1, 0), utcDateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldConvertToUtcUsingProvidedTimezone() {
|
||||||
|
val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0)
|
||||||
|
|
||||||
|
val utcDateTime = invokeConvertToUtc(localDateTime, ZoneId.of("Asia/Bangkok"))
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 4, 9, 3, 0), utcDateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeConvertToUtc(localDateTime: LocalDateTime, timeZone: ZoneId = ZoneId.of("Asia/Seoul")): LocalDateTime {
|
||||||
|
return try {
|
||||||
|
val method = Class.forName("kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensionsKt")
|
||||||
|
.getMethod("convertToUtc", LocalDateTime::class.java, ZoneId::class.java)
|
||||||
|
method.invoke(null, localDateTime, timeZone) as LocalDateTime
|
||||||
|
} catch (e: ReflectiveOperationException) {
|
||||||
|
fail("LocalDateTime.convertToUtc 확장함수를 찾을 수 없습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user