feat(notice): 예약 관리와 공개 시점 기반 조회를 구현한다

This commit is contained in:
2026-10-30 22:50:58 +09:00
parent 4f4c842ccf
commit 394949ab6e
16 changed files with 1544 additions and 69 deletions
@@ -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
)
@@ -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))
}
@@ -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
)
@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.admin.notice.dto
data class AdminNoticeResponse(
val totalCount: Int,
val noticeList: List<AdminNoticeItem>
)
@@ -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)
}
}