feat(notice): 예약 관리와 공개 시점 기반 조회를 구현한다
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.notice
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CreateNoticeRequest(
|
||||
val title: String,
|
||||
val content: String
|
||||
val content: String,
|
||||
val publishAt: String? = null
|
||||
) {
|
||||
fun toEntity(): ServiceNotice {
|
||||
return ServiceNotice(title, content)
|
||||
fun toEntity(publishAt: LocalDateTime? = null): ServiceNotice {
|
||||
return ServiceNotice(title, content, publishAt = publishAt, isScheduled = publishAt != null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.notice
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
@@ -14,20 +17,31 @@ import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/notice")
|
||||
class ServiceNoticeController(private val service: ServiceNoticeService) {
|
||||
class ServiceNoticeController(
|
||||
private val service: ServiceNoticeService,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun createNotice(@RequestBody request: CreateNoticeRequest) = ApiResponse.ok(
|
||||
service.save(request),
|
||||
"등록되었습니다."
|
||||
)
|
||||
fun createNotice(@RequestBody fields: JsonNode): ApiResponse<Long> {
|
||||
val publishAt = fields["publishAt"]
|
||||
if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
val request = objectMapper.treeToValue(fields, CreateNoticeRequest::class.java)
|
||||
return ApiResponse.ok(service.save(request), "등록되었습니다.")
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun updateNotice(@RequestBody request: UpdateNoticeRequest) = ApiResponse.ok(
|
||||
service.update(request),
|
||||
"수정되었습니다."
|
||||
)
|
||||
fun updateNotice(@RequestBody fields: JsonNode): ApiResponse<Unit> {
|
||||
val publishAt = fields["publishAt"]
|
||||
if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
val request = objectMapper.treeToValue(fields, UpdateNoticeRequest::class.java)
|
||||
return ApiResponse.ok(service.update(request, publishAtPresent = fields.has("publishAt")), "수정되었습니다.")
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
|
||||
@@ -2,32 +2,52 @@ package kr.co.vividnext.sodalive.notice
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
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
|
||||
@Transactional(readOnly = true)
|
||||
class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
||||
class ServiceNoticeService(
|
||||
private val repository: ServiceServiceNoticeRepository,
|
||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||
) {
|
||||
@Transactional
|
||||
fun save(request: CreateNoticeRequest): Long {
|
||||
if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_required")
|
||||
if (request.content.isBlank()) throw SodaException(messageKey = "notice.error.content_required")
|
||||
|
||||
val notice = request.toEntity()
|
||||
val publishAt = request.publishAt?.let { parsePublishAt(it, nowProvider()) }
|
||||
val notice = request.toEntity(publishAt)
|
||||
return repository.save(notice).id!!
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: UpdateNoticeRequest) {
|
||||
fun update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false) {
|
||||
if (request.id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
if (request.title.isNullOrBlank() && request.content.isNullOrBlank()) {
|
||||
if (request.title.isNullOrBlank() && request.content.isNullOrBlank() && !publishAtPresent) {
|
||||
throw SodaException(messageKey = "notice.error.update_required")
|
||||
}
|
||||
|
||||
val notice = repository.findByIdOrNull(request.id)
|
||||
val notice = repository.findByIdForUpdate(request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
if (publishAtPresent) {
|
||||
val now = nowProvider()
|
||||
val currentPublishAt = notice.publishAt
|
||||
if (!notice.isActive || !notice.isScheduled || currentPublishAt == null || currentPublishAt <= now) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
notice.publishAt = request.publishAt?.let { parsePublishAt(it, now) } ?: now.truncatedTo(ChronoUnit.SECONDS)
|
||||
notice.isScheduled = request.publishAt != null
|
||||
}
|
||||
|
||||
if (!request.title.isNullOrBlank()) notice.title = request.title
|
||||
if (!request.content.isNullOrBlank()) notice.content = request.content
|
||||
}
|
||||
@@ -35,20 +55,45 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor
|
||||
@Transactional
|
||||
fun delete(id: Long) {
|
||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val notice = repository.findByIdOrNull(id)
|
||||
val notice = repository.findByIdForUpdate(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
notice.isActive = false
|
||||
}
|
||||
|
||||
fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse {
|
||||
val totalCount = repository.getNoticeTotalCount()
|
||||
val noticeList = repository.getNoticeList(pageable)
|
||||
val now = nowProvider()
|
||||
val totalCount = repository.getNoticeTotalCount(now)
|
||||
val noticeList = repository.getNoticeList(pageable, now)
|
||||
|
||||
return GetNoticeResponse(totalCount, noticeList)
|
||||
}
|
||||
|
||||
fun getLatestNotice(): NoticeItem? {
|
||||
return repository.getLatestNotice()
|
||||
val now = nowProvider()
|
||||
return repository.getLatestNotice(now)
|
||||
}
|
||||
|
||||
private fun parsePublishAt(value: String, now: LocalDateTime): LocalDateTime {
|
||||
if (value.length != 19) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val utc = try {
|
||||
LocalDateTime.parse(value, PUBLISH_AT_FORMAT)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneOffset.UTC)
|
||||
.toLocalDateTime()
|
||||
} catch (_: DateTimeParseException) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP || utc <= now) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
return utc
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PUBLISH_AT_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,121 @@
|
||||
package kr.co.vividnext.sodalive.notice
|
||||
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.Expression
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.notice.QServiceNotice.serviceNotice
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeItem
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.QAdminNoticeItem
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Repository
|
||||
interface ServiceServiceNoticeRepository : JpaRepository<ServiceNotice, Long>, ServiceNoticeQueryRepository
|
||||
interface ServiceServiceNoticeRepository : JpaRepository<ServiceNotice, Long>, ServiceNoticeQueryRepository {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select notice from ServiceNotice notice where notice.id = :id")
|
||||
fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice?
|
||||
}
|
||||
|
||||
interface ServiceNoticeQueryRepository {
|
||||
fun getNoticeTotalCount(): Int
|
||||
fun getNoticeList(pageable: Pageable): List<NoticeItem>
|
||||
fun getLatestNotice(): NoticeItem?
|
||||
fun getNoticeTotalCount(now: LocalDateTime): Int
|
||||
fun getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>
|
||||
fun getLatestNotice(now: LocalDateTime): NoticeItem?
|
||||
fun getAdminNoticeTotalCount(): Int
|
||||
fun getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository {
|
||||
override fun getNoticeTotalCount(): Int {
|
||||
return queryFactory
|
||||
.select(serviceNotice.id)
|
||||
.from(serviceNotice)
|
||||
.where(serviceNotice.isActive.isTrue)
|
||||
.fetch()
|
||||
.size
|
||||
override fun getNoticeTotalCount(now: LocalDateTime): Int {
|
||||
return requireNotNull(
|
||||
queryFactory.select(serviceNotice.id.count())
|
||||
.from(serviceNotice)
|
||||
.where(isPublic(now))
|
||||
.fetchOne()
|
||||
).toInt()
|
||||
}
|
||||
|
||||
override fun getNoticeList(pageable: Pageable): List<NoticeItem> {
|
||||
override fun getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QNoticeItem(
|
||||
serviceNotice.id,
|
||||
serviceNotice.title,
|
||||
serviceNotice.content,
|
||||
getFormattedDate(serviceNotice.createdAt)
|
||||
getFormattedDate(
|
||||
CaseBuilder().`when`(serviceNotice.isScheduled.isTrue)
|
||||
.then(serviceNotice.publishAt).otherwise(serviceNotice.createdAt),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
)
|
||||
)
|
||||
.from(serviceNotice)
|
||||
.where(isPublic(now))
|
||||
.offset(pageable.offset)
|
||||
.limit(pageable.pageSize.toLong())
|
||||
.orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getLatestNotice(now: LocalDateTime): NoticeItem? {
|
||||
return getNoticeList(PageRequest.of(0, 1), now).firstOrNull()
|
||||
}
|
||||
|
||||
override fun getAdminNoticeTotalCount(): Int {
|
||||
return requireNotNull(
|
||||
queryFactory.select(serviceNotice.id.count())
|
||||
.from(serviceNotice)
|
||||
.where(serviceNotice.isActive.isTrue)
|
||||
.fetchOne()
|
||||
).toInt()
|
||||
}
|
||||
|
||||
override fun getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QAdminNoticeItem(
|
||||
serviceNotice.id,
|
||||
serviceNotice.title,
|
||||
serviceNotice.content,
|
||||
serviceNotice.isActive,
|
||||
serviceNotice.isScheduled,
|
||||
getFormattedDate(serviceNotice.publishAt, "%Y-%m-%d %H:%i:%s"),
|
||||
getFormattedDate(serviceNotice.createdAt, "%Y-%m-%d %H:%i:%s")
|
||||
)
|
||||
)
|
||||
.from(serviceNotice)
|
||||
.where(serviceNotice.isActive.isTrue)
|
||||
.offset(pageable.offset)
|
||||
.limit(pageable.pageSize.toLong())
|
||||
.orderBy(serviceNotice.id.desc())
|
||||
.orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getLatestNotice(): NoticeItem? {
|
||||
return queryFactory
|
||||
.select(
|
||||
QNoticeItem(
|
||||
serviceNotice.id,
|
||||
serviceNotice.title,
|
||||
serviceNotice.content,
|
||||
getFormattedDate(serviceNotice.createdAt)
|
||||
)
|
||||
)
|
||||
.from(serviceNotice)
|
||||
.where(serviceNotice.isActive.isTrue)
|
||||
.orderBy(serviceNotice.id.desc())
|
||||
.fetchFirst()
|
||||
private fun isPublic(now: LocalDateTime): BooleanExpression {
|
||||
return serviceNotice.isActive.isTrue.and(serviceNotice.publishAt.isNull.or(serviceNotice.publishAt.loe(now)))
|
||||
}
|
||||
|
||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||
private fun getFormattedDate(dateTime: Expression<LocalDateTime>, format: String): StringTemplate {
|
||||
return Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
LocalDateTime::class.java,
|
||||
"CONVERT_TZ({0},{1},{2})",
|
||||
dateTimePath,
|
||||
dateTime,
|
||||
"UTC",
|
||||
"Asia/Seoul"
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
format
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.notice
|
||||
data class UpdateNoticeRequest(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val content: String? = null
|
||||
val content: String? = null,
|
||||
val publishAt: String? = null
|
||||
)
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.notice.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.notice.application.AdminNoticeService
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/admin/notices")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminNoticeController(private val service: AdminNoticeService) {
|
||||
@GetMapping
|
||||
fun getNotices(pageable: Pageable): ApiResponse<AdminNoticeResponse> = ApiResponse.ok(service.getNoticeList(pageable))
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.notice.application
|
||||
|
||||
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class AdminNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
||||
fun getNoticeList(pageable: Pageable): AdminNoticeResponse {
|
||||
val totalCount = repository.getAdminNoticeTotalCount()
|
||||
val noticeList = repository.getAdminNoticeList(pageable)
|
||||
return AdminNoticeResponse(totalCount, noticeList)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class AdminNoticeItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isActive: Boolean,
|
||||
val isScheduled: Boolean,
|
||||
val publishAt: String?,
|
||||
val createdAt: String
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
|
||||
|
||||
data class AdminNoticeResponse(
|
||||
val totalCount: Int,
|
||||
val noticeList: List<AdminNoticeItem>
|
||||
)
|
||||
+10
-2
@@ -5,9 +5,17 @@ import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) {
|
||||
fun getLatestNotices(): List<NoticeItem> = repository.getNoticeList(PageRequest.of(0, 3))
|
||||
class LatestNoticeService(
|
||||
private val repository: ServiceServiceNoticeRepository,
|
||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||
) {
|
||||
fun getLatestNotices(): List<NoticeItem> {
|
||||
val now = nowProvider()
|
||||
return repository.getNoticeList(PageRequest.of(0, 3), now)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user