feat(subbanner): 관리자 CRUD와 이미지·기간 검증을 추가한다
This commit is contained in:
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
+191
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user