feat(event): v2 일반 이벤트 상세 조회를 추가한다

This commit is contained in:
2026-10-29 11:02:45 +09:00
parent 8345cc25ad
commit b758a81834
4 changed files with 426 additions and 0 deletions
@@ -188,6 +188,7 @@ class SecurityConfig(
.antMatchers("/live/recommend/channel").permitAll()
.antMatchers(HttpMethod.GET, "/live/room").permitAll()
.antMatchers(HttpMethod.GET, "/event").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/events/*").permitAll()
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
.antMatchers("/ad-tracking/app-launch").permitAll()
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.api.event.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.event.application.EventDetailService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/events")
class EventDetailController(private val service: EventDetailService) {
@GetMapping("/{id}")
fun getEvent(
@PathVariable id: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.getEvent(id, member))
}
@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.api.event.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class EventDetailService(
private val repository: EventRepository,
private val authRepository: AuthRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional(readOnly = true)
fun getEvent(id: Long, member: Member?): EventItem {
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
val event = repository.findByIdOrNull(id)
?: throw SodaException(messageKey = "common.error.invalid_request")
val now = LocalDateTime.now()
val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
if (!event.isActive || event.startDate > now || event.endDate < now || event.lang != lang) {
throw SodaException(messageKey = "common.error.invalid_request")
}
if (member?.role != MemberRole.ADMIN) {
val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
if (event.isAdult != null && event.isAdult != isAdult) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
return EventItem(
id = id,
thumbnailImageUrl = imageUrl(event.thumbnailImage),
detailImageUrl = event.detailImage?.let { imageUrl(it) },
popupImageUrl = event.popupImage?.let { imageUrl(it) },
link = event.link
)
}
private fun imageUrl(image: String): String = if (image.startsWith("https://")) image else "$cloudFrontHost/$image"
}
@@ -0,0 +1,350 @@
package kr.co.vividnext.sodalive.v2.api.event
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import java.time.LocalDateTime
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:event-detail-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class EventDetailControllerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val mockMvc: MockMvc,
private val eventRepository: EventRepository,
private val memberRepository: MemberRepository,
private val authRepository: AuthRepository,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
@AfterEach
fun cleanUp() {
eventRepository.deleteAllInBatch()
authRepository.deleteAllInBatch()
memberRepository.deleteAllInBatch()
}
@Test
@DisplayName("익명 상세는 기존 목록과 같은 5개 필드와 외부 링크를 반환하고 엔티티를 변경하지 않는다")
fun shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity() {
val event = saveEvent(
activeEvent().apply {
detailImage = null
popupImage = null
link = "https://external.test/promotion?from=soda&target=event#detail"
}
)
val detail = httpGet("/api/v2/events/${event.id}")
assertEquals(
objectMapper.readTree(
"""{"success":true,"message":null,"data":{
"id":${event.id},"thumbnailImageUrl":"https://cdn.test/thumbnail.png",
"detailImageUrl":null,"popupImageUrl":null,
"link":"https://external.test/promotion?from=soda&target=event#detail"
},"errorProperty":null}"""
),
detail
)
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail["data"])
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
assertEquals("thumbnail.png", persisted.thumbnailImage)
assertEquals(null, persisted.detailImage)
assertEquals(null, persisted.popupImage)
assertEquals(event.link, persisted.link)
}
@ParameterizedTest
@CsvSource(
"https://images.test/image.png, https://images.test/image.png",
"relative/image.png, https://cdn.test/relative/image.png",
"http://images.test/image.png, https://cdn.test/http://images.test/image.png"
)
@DisplayName("세 이미지 필드는 HTTPS만 보존하고 상대 경로와 HTTP는 기존 CDN 규칙을 따른다")
fun shouldMapAllImageFieldsLikeTheLegacyList(image: String, expectedImage: String) {
val event = saveEvent(
activeEvent().apply {
thumbnailImage = image
detailImage = image
popupImage = image
link = null
}
)
val detail = httpGet("/api/v2/events/${event.id}")["data"]
assertEquals(5, detail.size())
assertEquals(event.id, detail["id"].asLong())
assertEquals(expectedImage, detail["thumbnailImageUrl"].asText())
assertEquals(expectedImage, detail["detailImageUrl"].asText())
assertEquals(expectedImage, detail["popupImageUrl"].asText())
assertEquals(true, detail["link"].isNull)
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail)
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
assertEquals(image, persisted.thumbnailImage)
assertEquals(image, persisted.detailImage)
assertEquals(image, persisted.popupImage)
}
@ParameterizedTest
@CsvSource(
"DEFAULT, KO",
"KR, KO",
"JP, JA",
"US, KO",
nullValues = ["DEFAULT"]
)
@DisplayName("익명 상세의 국가별 언어와 비노출 응답은 기존 목록과 일치한다")
fun shouldApplyTheSameCountryLanguageAsTheLegacyList(country: String?, expectedLang: Lang) {
val events = listOf(Lang.KO, Lang.JA, Lang.EN).map { lang ->
saveEvent(activeEvent().apply { this.lang = lang })
}
val expected = events.single { it.lang == expectedLang }
val legacyItems = httpGet("/event", country)["data"]["eventList"]
assertEquals(listOf(expected.id), legacyItems.map { it["id"].asLong() })
events.forEach { event ->
val detail = httpGet("/api/v2/events/${event.id}", country)
if (event.id == expected.id) {
assertEquals(true, detail["success"].asBoolean())
assertEquals(legacyItems.single(), detail["data"])
} else {
assertInvalidRequest(detail)
}
}
}
@Test
@DisplayName("성인 null false true의 익명 미인증 인증 관리자 노출은 KO와 JA 모두 기존 목록과 같다")
fun shouldMatchTheLegacyAdultMatrixForAnonymousUnverifiedVerifiedAndAdminViewers() {
val unverified = saveMember("unverified")
val verified = saveMember("verified", verified = true)
val admin = saveMember("admin", role = MemberRole.ADMIN)
assertEquals(null, authRepository.getAuthIdByMemberId(requireNotNull(unverified.id)))
assertEquals(1L, authRepository.count())
listOf("KR" to Lang.KO, "JP" to Lang.JA).forEach { (country, lang) ->
val general = saveEvent(activeEvent().apply { this.lang = lang })
val nonAdult = saveEvent(
activeEvent().apply {
this.lang = lang
isAdult = false
}
)
val adult = saveEvent(
activeEvent().apply {
this.lang = lang
isAdult = true
}
)
val events = listOf(general, nonAdult, adult)
val viewers = listOf(
null to setOf(general.id, nonAdult.id),
unverified to setOf(general.id, nonAdult.id),
verified to setOf(general.id, adult.id),
admin to setOf(general.id, nonAdult.id, adult.id)
)
viewers.forEach { (member, expectedIds) ->
val legacyItems = viewerGet("/event", member, country)["data"]["eventList"]
assertEquals(expectedIds, legacyItems.map { it["id"].asLong() }.toSet())
events.forEach { event ->
val detail = viewerGet("/api/v2/events/${event.id}", member, country)
if (event.id in expectedIds) {
assertEquals(true, detail["success"].asBoolean())
assertEquals(legacyItems.single { it["id"].asLong() == event.id }, detail["data"])
} else {
assertInvalidRequest(detail)
}
}
}
}
}
@Test
@DisplayName("관리자도 활성 기간 언어 조건을 우회하지 못하고 비노출 상세는 미존재와 같다")
fun shouldRejectAllHiddenStatesEvenForAdminWhileMatchingTheLegacyList() {
val visible = saveEvent(activeEvent())
val hidden = listOf(
activeEvent().apply { isActive = false },
activeEvent().apply {
startDate = LocalDateTime.now().plusDays(1)
endDate = startDate.plusDays(1)
},
activeEvent().apply {
endDate = LocalDateTime.now().minusDays(1)
startDate = endDate.minusDays(1)
},
activeEvent().apply { lang = Lang.JA },
activeEvent().apply { lang = Lang.EN }
).map { saveEvent(it) }
val unverified = saveMember("hidden-unverified")
val verified = saveMember("hidden-verified", verified = true)
val admin = saveMember("hidden-admin", role = MemberRole.ADMIN)
val missing = httpGet("/api/v2/events/${Long.MAX_VALUE}")
assertInvalidRequest(missing)
listOf(null, unverified, verified, admin).forEach { member ->
val legacyItems = viewerGet("/event", member)["data"]["eventList"]
assertEquals(listOf(visible.id), legacyItems.map { it["id"].asLong() })
hidden.forEach { event ->
assertEquals(missing, viewerGet("/api/v2/events/${event.id}", member))
}
}
}
@ParameterizedTest
@CsvSource("0", "-1", "9223372036854775807")
@DisplayName("양수가 아니거나 미존재인 ID는 HTTP 200 invalid_request 실패 응답을 반환한다")
fun shouldRejectNonpositiveAndMissingIdsWithTheCommonInvalidRequest(id: String) {
assertInvalidRequest(httpGet("/api/v2/events/$id"))
val result = mockGet("/api/v2/events/$id")
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
}
@ParameterizedTest
@CsvSource("not-a-number", "9223372036854775808")
@DisplayName("숫자가 아니거나 Long 범위를 넘는 ID는 기존 바인딩과 공통 예외 응답을 유지한다")
fun shouldKeepTheGlobalBindingErrorResponseForMalformedIds(id: String) {
val response = httpGet("/api/v2/events/$id")
assertEquals(
objectMapper.readTree(
"""{"success":false,"message":"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"data":null,"errorProperty":null}"""
),
response
)
assertEquals(
MethodArgumentTypeMismatchException::class.java,
mockGet("/api/v2/events/$id").resolvedException?.javaClass
)
}
@ParameterizedTest
@CsvSource(
"POST, /api/v2/events/1", "PUT, /api/v2/events/1", "DELETE, /api/v2/events/1",
"GET, /api/v2/events", "POST, /api/v2/events", "GET, /api/v2/events/1/extra",
"GET, /api/v2/events/1/extra/deeper", "GET, /event/popup", "GET, /admin/event/banner",
"POST, /event", "PUT, /event", "DELETE, /event/1", "GET, /admin/event/banner/1"
)
@DisplayName("익명 쓰기 collection 하위 경로 기존 팝업 관리자 API는 계속 인증이 필요하다")
fun shouldKeepAnonymousRequestsOutsideTheDetailGetProtected(method: HttpMethod, path: String) {
val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java)
println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
}
private fun httpGet(path: String, country: String? = null): JsonNode {
val headers = HttpHeaders().apply {
country?.let { set("CloudFront-Viewer-Country", it) }
}
val response = restTemplate.exchange(path, HttpMethod.GET, HttpEntity<Void>(headers), String::class.java)
println("HTTP GET $path country=$country -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.OK, response.statusCode)
return objectMapper.readTree(response.body)
}
private fun viewerGet(path: String, member: Member?, country: String? = null): JsonNode {
if (member == null) return httpGet(path, country)
return objectMapper.readTree(mockGet(path, member, country).response.contentAsByteArray)
}
private fun mockGet(path: String, member: Member? = null, country: String? = null): MvcResult {
val request = get(path).apply {
country?.let { header("CloudFront-Viewer-Country", it) }
member?.let {
val adapter = MemberAdapter(it)
with(authentication(UsernamePasswordAuthenticationToken(adapter, null, adapter.authorities)))
}
}
val result = mockMvc.perform(request).andReturn()
println(
"MockMvc GET $path role=${member?.role} country=$country -> ${result.response.status} " +
result.response.getContentAsString(Charsets.UTF_8)
)
assertEquals(200, result.response.status)
return result
}
private fun assertInvalidRequest(response: JsonNode) {
assertEquals(
objectMapper.readTree(
"""{"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}"""
),
response
)
}
private fun saveMember(seed: String, role: MemberRole = MemberRole.USER, verified: Boolean = false): Member {
val member = memberRepository.saveAndFlush(Member(password = "test-password", nickname = seed, role = role))
if (verified) {
val auth = authRepository.saveAndFlush(
Auth("test-member", "19900101", "test-ci-$seed", "test-di-$seed", 1).apply { this.member = member }
)
assertEquals(auth.id, authRepository.getAuthIdByMemberId(requireNotNull(member.id)))
}
return member
}
private fun saveEvent(event: Event): Event = eventRepository.saveAndFlush(event)
private fun activeEvent(): Event = Event(
thumbnailImage = "thumbnail.png",
detailImage = "detail.png",
popupImage = "popup.png",
link = "https://external.test/event",
title = "not-returned-title",
isAdult = null,
isPopup = false,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1)
)
}