feat(subbanner): 관리자 CRUD와 이미지·기간 검증을 추가한다

This commit is contained in:
2026-10-29 23:22:04 +09:00
parent aa6863f4f9
commit a30196e0c9
5 changed files with 894 additions and 0 deletions
@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.AdminSubBannerResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
@RestController
@RequestMapping("/admin/sub-banners")
@PreAuthorize("hasRole('ADMIN')")
class AdminSubBannerController(
private val service: SubBannerService,
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
) {
@GetMapping
fun list(@RequestParam(required = false) placement: String?): ApiResponse<List<AdminSubBannerResponse>> {
val selected = SubBannerPlacement.values().firstOrNull { it.name == placement }
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner placement")
return ApiResponse.ok(service.findAllByPlacement(selected).map { AdminSubBannerResponse.from(it, cloudFrontHost) })
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
AdminSubBannerResponse.from(service.create(image, request), cloudFrontHost)
)
@PutMapping("/{id}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable id: Long,
@RequestPart("image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
AdminSubBannerResponse.from(service.update(id, image, request), cloudFrontHost)
)
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long): ApiResponse<Any> {
service.delete(id)
return ApiResponse.ok()
}
}
@@ -0,0 +1,191 @@
package kr.co.vividnext.sodalive.v2.subbanner.application
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
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.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerRequest
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.net.URI
import java.net.URISyntaxException
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
import java.time.temporal.ChronoUnit
@Service
class SubBannerService(
private val repository: SubBannerRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}") private val bucket: String,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) {
@Transactional(readOnly = true)
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = repository.findAllByPlacement(placement)
@Transactional(readOnly = true)
fun findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner> {
val now = nowProvider().truncatedTo(ChronoUnit.SECONDS)
return repository.findVisibleByPlacement(placement, now)
}
@Transactional
fun create(image: MultipartFile, request: String): SubBanner {
val fields = readFields(request)
val input = readRequest(fields)
val startDate = parseDate(input.startDate, 0)
val endDate = parseDate(input.endDate, 59)
validateRange(startDate, endDate)
val imagePath = uploadImage(image)
return repository.save(
SubBanner(
placement = input.placement,
imagePath = imagePath,
webUrl = input.webUrl,
androidDeepLink = input.androidDeepLink,
iosDeepLink = input.iosDeepLink,
startDate = startDate,
endDate = endDate
)
)
}
@Transactional
fun update(id: Long, image: MultipartFile?, request: String): SubBanner {
val existing = findExisting(id)
val fields = readFields(request)
val input = readRequest(fields)
val startDate = if (fields.has("startDate")) parseDate(input.startDate, 0) else existing.startDate
val endDate = if (fields.has("endDate")) parseDate(input.endDate, 59) else existing.endDate
validateRange(startDate, endDate)
val imagePath = image?.let { uploadImage(it) } ?: existing.imagePath
return repository.save(
existing.copy(
placement = input.placement,
imagePath = imagePath,
webUrl = input.webUrl,
androidDeepLink = input.androidDeepLink,
iosDeepLink = input.iosDeepLink,
startDate = startDate,
endDate = endDate
)
)
}
@Transactional
fun delete(id: Long) {
findExisting(id)
repository.deleteById(id)
}
private fun findExisting(id: Long): SubBanner = repository.findById(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Sub-banner not found")
private fun readFields(request: String): JsonNode {
val fields = try {
objectMapper.readerFor(JsonNode::class.java)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readValue<JsonNode>(request)
} catch (_: JsonProcessingException) {
invalidRequest()
}
if (!fields.isObject || !fields.path("placement").isTextual ||
SubBannerPlacement.values().none { it.name == fields["placement"].textValue() }
) {
invalidRequest()
}
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
val value = fields[field]
if (value == null || (!value.isNull && !value.isTextual)) invalidRequest()
}
listOf("startDate", "endDate").forEach { field ->
val value = fields[field]
if (value != null && !value.isNull && !value.isTextual) invalidRequest()
}
return fields
}
private fun readRequest(fields: JsonNode): SubBannerRequest {
val input = objectMapper.treeToValue(fields, SubBannerRequest::class.java)
val normalized = input.copy(
webUrl = input.webUrl?.trim()?.takeIf { it.isNotEmpty() },
androidDeepLink = input.androidDeepLink?.trim()?.takeIf { it.isNotEmpty() },
iosDeepLink = input.iosDeepLink?.trim()?.takeIf { it.isNotEmpty() }
)
if (listOf(normalized.webUrl, normalized.androidDeepLink, normalized.iosDeepLink).all { it == null }) invalidRequest()
validateUri(normalized.webUrl, web = true)
validateUri(normalized.androidDeepLink, web = false)
validateUri(normalized.iosDeepLink, web = false)
return normalized
}
private fun validateUri(value: String?, web: Boolean) {
if (value == null) return
val uri = try {
URI(value)
} catch (_: URISyntaxException) {
invalidRequest()
}
if (!uri.isAbsolute || listOf("javascript", "data", "file", "content").any { it.equals(uri.scheme, ignoreCase = true) }) {
invalidRequest()
}
if (web && ((!uri.scheme.equals("http", true) && !uri.scheme.equals("https", true)) || uri.host.isNullOrBlank())) {
invalidRequest()
}
}
private fun parseDate(value: String?, second: Int): LocalDateTime? {
if (value == null) return null
if (value.length != 16) invalidRequest()
val utc = try {
val local = LocalDateTime.parse(value, DATE_FORMAT).withSecond(second)
val zone = ZoneId.of("Asia/Seoul")
if (zone.rules.getValidOffsets(local).isEmpty()) invalidRequest()
local.atZone(zone)
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime()
} catch (_: DateTimeParseException) {
invalidRequest()
}
if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP) invalidRequest()
return utc
}
private fun validateRange(startDate: LocalDateTime?, endDate: LocalDateTime?) {
if (startDate != null && endDate != null && startDate > endDate) invalidRequest()
}
private fun uploadImage(image: MultipartFile): String {
if (image.isEmpty) invalidRequest()
try {
validateImage(image, gifAllowed = true)
} catch (_: SodaException) {
invalidRequest()
}
return image.inputStream.use { s3Uploader.upload(it, bucket, "sub_banner/${generateFileName()}") }
}
private fun invalidRequest(): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner request")
companion object {
private val DATE_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT)
private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1)
private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7)
}
}
@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.subbanner.dto
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
data class AdminSubBannerResponse(
val id: Long,
val placement: SubBannerPlacement,
val imageUrl: String,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?,
val startDate: String?,
val endDate: String?
) {
companion object {
fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse(
id = requireNotNull(banner.id),
placement = banner.placement,
imageUrl = "$cloudFrontHost/${banner.imagePath}",
webUrl = banner.webUrl,
androidDeepLink = banner.androidDeepLink,
iosDeepLink = banner.iosDeepLink,
startDate = formatDate(banner.startDate),
endDate = formatDate(banner.endDate)
)
private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC)
?.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}
}
@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.subbanner.dto
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
data class SubBannerRequest(
val placement: SubBannerPlacement,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?,
val startDate: String? = null,
val endDate: String? = null
)